Three Bugs in the Shadow

The decompressor compiled through the interpreter and ran clean, then failed to compile through the C backend three different ways. A parameter named after a C keyword was declared one way and referenced another. A plain local variable vanished from its captures because a different function had an inner helper of the same name. And a match arm returning a Vec emitted a compound literal for a struct that does not exist. Three undeclared-identifier errors, none of which any prior test had reached — the shadow a real program casts on the machinery under it.

merecodegenclosuresbugslanguage-design

The decompressor ran correctly on the interpreter the first time. Then it was handed to the C backend, and refused to compile — three separate ways, each an undeclared-identifier error in the generated C, each in a corner of the closure-lifting machinery that no test had ever pushed on. The interpreter never saw any of them, which is exactly why they had survived: the interpreter evaluates the source directly, while these bugs live in the translation to C. A three-hundred-line program dense with nested, recursive, capture-heavy functions was the first thing that cast a shadow big enough to see them in.

A name declared one way, used another

The first was a parameter named index. That is a perfectly good name in the source language, but index is a function in C’s string library, so the backend has always sanitized such names to index_ at every reference site. It had not done so at the declaration site — so the generated function declared a parameter index and its body referred to index_, which was undeclared. The fix is to sanitize both, and it had to be applied in two places, not one: the parameter of a lifted function, and the parameter of an anonymous-closure adapter — the form a deeply curried inner function takes. The decompressor’s Huffman decoder was curried enough to become the latter, which is why it found the second, subtler path.

A variable stolen by a namesake

The second was the strangest. A plain local variable p — bound by a let and read by an inner loop — was silently dropped from that loop’s captured variables, so the generated call referenced a p that wasn’t in scope. The cause was a name collision across functions. When the compiler lifts an inner function to the top level, it must not treat the inner function’s name as a captured value. It checked that with a global map of inner-function names — and a different function elsewhere in the program happened to have an inner helper also named p. So the global check said “p is an inner function, exclude it from captures,” and the local variable p was thrown out along with the namesake it had nothing to do with. The fix scopes the check per-function: a name is excluded only if it names an inner function in the same host. The free-variable analysis had been right all along; a downstream filter was stealing its answer.

A zero value for a type that isn’t there

The third was in pattern matching. A match whose result type is a Vec needs a value to fall through to if no arm matches (an abort path that never runs, but must type-check). For scalars that is 0; for a struct it is a {0} compound literal. The code built the struct name with the variant-name mangler and produced (Vec___heap_int){0} — but a Vec is not a by-value struct in the C backend, it is a pointer, and there is no Vec___heap_int type to make a literal of. Pointer-typed containers must fall through to a null pointer, not a compound literal. The fix routes containers and other pointer result types to 0. Each of these is small; together they are the episode’s lesson, the one dogfooding keeps re-teaching from a new angle: a feature is bright and a test is narrow, and the bugs live in the shadow that only a real, demanding program is heavy enough to cast. All three fixed, the decompressor compiles with no workarounds — and promptly reveals a problem that dwarfs them. That is the next episode: 484 megabytes to inflate one.

← Back to Mere: Building a Language