Engineering
The boundary where both sides compile and one of them is lying
Across a C ABI, a misspelled function is not a compile error and a mistyped one is not even visible to nm. A 32-bit declaration over a 64-bit value turned 23347 MB into 2.8 GB — plausible, and wrong by eight times.
Updated 2026-09-12Measured 2026-09-11
Two languages meeting over a C ABI have no shared compiler, so nothing checks that they agree. Each side declares what it believes the other provides, both compile, and the disagreement — if there is one — becomes a runtime property.
Everybody knows this in the abstract. What is less obvious is how the two failure modes differ, because one is merely annoying and the other is genuinely dangerous.
A wrong name fails late, but it fails
Declare runlix_generat where the library exports runlix_generate, and nothing complains. On
most platforms symbol resolution is lazy: the library loads, the program runs, and the call fails
the first time it is reached. Which might be in a code path you exercise on the second day.
Annoying, but honest. When it happens, the error names the symbol, and you fix a typo.
The fix that scales is not care — it is a test. Read the symbol table of the built library and compare it against the declarations, in both directions:
- every symbol the caller declares it will call must be exported by the library;
- every external symbol the library declares it needs must exist in what you link.
Both halves matter. We had the first for a while and not the second, and the missing half was the one that would have caught a real defect.
A wrong type does not fail at all
This is the interesting one.
Declare the right name with the wrong type and the symbol table check passes, because a symbol
table knows names and not signatures. nm will tell you runlix_memory exists. It will not
tell you that one side thinks it writes 64 bits and the other thinks it reads 32.
Here is the measured case. The engine reports the memory ceiling MLX recommends, as a u64
written through an out parameter. The C# side declared the parameter as out uint — 32 bits.
Both sides compile. Nothing crashes. The value comes back truncated to its low 32 bits.
The real ceiling on the machine was 23347 MB. What arrived was 2.8 GB.
Look at what makes that bad. It is not zero, which would be obviously wrong. It is not enormous, which would be obviously wrong. It is a number that looks exactly like the memory ceiling of a smaller Mac — so the code downstream believed it, sized its budget for it, and started evicting models it had plenty of room for. Wrong by a factor of eight, and entirely plausible.
Why the usual defences do not catch this
The compiler cannot. Each side compiled against its own declaration and both were internally consistent.
The linker cannot. The name matched.
nm cannot. Signatures are not in the symbol table.
Tests often cannot, and this is the part worth dwelling on. A test that asserts the value is "reasonable" passes: 2.8 GB is reasonable. A test that asserts it is greater than zero passes. A test that asserts a specific value would have caught it — but you cannot assert a specific memory ceiling, because it differs per machine.
What did catch it was comparing the two declarations side by side, deliberately, as a task. Which is to say: a human read both and noticed. That is not a satisfying answer, so the question becomes how to make it repeatable.
What repeatable looks like
Three things, in increasing order of effort and effectiveness.
Generate one side from the other. If the declarations come from a single source, they cannot disagree. Tools exist for this in most language pairs, and if the boundary is small enough, generating by hand from a shared description is fine too. This is the real fix.
Assert the round trip where you can. Some values can be checked against an independent source — a memory ceiling can be compared against what the system reports, loosely, and a factor-of-eight discrepancy fails a loose check comfortably. Not everything has such a source, but more things do than you would guess.
Keep the boundary small and boring. Every function is a place for the two declarations to drift. A boundary of fifteen functions can be read in one sitting; a boundary of two hundred cannot, and the ones nobody reads are where this lives.
We do the third, we now have the first half of the symbol check in both directions, and the memory ceiling has an independent sanity check. The type mismatch itself was found by reading.
What the symbol check looks like in practice
It is less work than it sounds, and it is worth writing out because the obvious implementation only does half the job.
Read the exported symbols of the built library — nm -gU on macOS, nm -D on Linux, or the
platform equivalent — and read the undefined ones from the same output. Then compare against two
lists you already have: the external functions the caller declares, and the external functions the
library declares it needs.
The first comparison catches a typo on the caller's side. The second catches a typo on the library's side, and also catches something more interesting: a dependency you thought you were linking and are not. That second half is the one people skip, and it is the one that found a real problem here.
Two details make the difference between a check that works and one that passes forever. Take the symbol list from the built artefact, not from the source — parsing the source reproduces the same declarations you are trying to verify, so it agrees with itself by construction. And run it in the same job that builds, not as a separate manual step, because a check somebody has to remember is a check that stops running the week it matters.
What still is not covered
Being clear about the limits, since the point of this note is that the usual defences miss things.
The symbol check does not cover struct layout. If both sides declare a struct and one adds a field, the names still match and the sizes do not. That is the same failure with a larger blast radius, and the only real defence is generating the struct from one definition.
It does not cover calling convention or lifetime. Who allocates, who frees, whether a pointer outlives the call — none of that is in a symbol table, and none of it is in a type signature either. It lives in comments and in discipline, which is another argument for a boundary small enough to hold in your head.
The other reason to keep the boundary small
Worth mentioning because it pushed the same decision from a different direction: our C boundary is a polling interface rather than a callback one. The caller asks for the next token; nothing calls back into the managed side.
That choice was made to avoid a foreign thread entering a garbage-collected runtime at a moment nobody chose. But it has a second effect that turned out to matter more: a polling boundary has fewer function shapes, no function pointers crossing, and no lifetime questions about who owns a callback. Fewer declarations to keep in agreement is the same property as fewer places to be wrong.
The short version
- A misspelled external function is not a build error. Link resolution is lazy.
- A mistyped one is not visible to
nmeither, because symbol tables hold names, not signatures. - Measured:
out uintover au64turned a 23347 MB ceiling into 2.8 GB — truncated, plausible, wrong by eight times. - Check symbols in both directions, generate declarations from one source where you can, and keep the boundary small enough that somebody can read all of it.
Measured on 11 September 2026, on a C ABI between Rust and .NET. Nothing about it is specific to that pair.