Last updated: 2026-10-02

U
Undergraduate level
APL
Applied / Methodological — Knowledge with a 5–10 year half-life — stable practice

Chapter 11: The Switch Exposed Everything

For new readers

This is one instalment in an ongoing, chronological diary of building PatLang, written up in numbered "Acts" as the project actually happened, warts included. You don't need to have read the earlier instalments to follow this one, but it helps to know what just changed: the native x64 backend had just been switched on as the default compilation path, after years of living behind a flag defaulting to off. This instalment is what happened in the first eight days of programs actually being compiled that way by default, rather than opted into on request.

A direct continuation of the previous instalment (Acts LXVI-LXVII: the self-hosted assembler and linker finally trusted as the real toolchain, and the two bugs — one silent, one merely slow — that trusting them turned up). That instalment ended with the switch flipped and a clean bill of health: every stress test passing, every known divergence between the self-hosted assembler and the real one gone. This one starts the very next day, and the clean bill of health did not survive contact with the rest of the codebase. Flipping a default doesn't just change which code path a new feature exercises — it changes which code path everything already written exercises, including machinery nobody had pushed hard in years.

Act LXVIII: a fixpoint that had never actually been tested

Chapter 1 celebrated a fixpoint: patc1.exe compiling its own source into patc2.exe, with the two binaries producing byte-identical Rust for the same input. That test proved self-hosting through the Rust-codegen path. Nobody had ever run the equivalent test through the native x64 path — patc1.exe compiling patc2.exe via --x64, and patc2.exe going on to compile a patc3.exe of its own — because until the switch flipped, there was no reason to. The first attempt crashed immediately, on a bug with an oddly specific threshold: calling argv() and then declaring roughly twenty or more further local variables in the same function corrupted the last element of the returned list with trailing garbage. Nineteen locals: clean. Twenty: corrupted. patc1_main.patlang's own top-level driver code declares far more than twenty locals before it ever checks whether args[2] says --x64 — so under self-hosted compilation, that check silently evaluated false, and the compiler walked into the wrong branch before it had even started.cf. Chapter 1's Gen C fixpoint — same idea, different path

Chasing that one threshold led somewhere much bigger. The heap backing the x64 runtime was a fixed-size static reservation, and it had already been resized four times in one session — 16MB, then 512MB, then 1.9GB, then 2.1GB, then settling at 1.98GB — each resize a stopgap against a number that kept not being enough. The real ceiling, once found, wasn't a PatLang bug at all: a byte-level bisection against NtCreateSection and LoadLibraryExW, run against a hand-resized executable, pinned a hard Windows loader limit — no PE image without high-entropy ASLR and a fixed base address can have a SizeOfImage past roughly 0x77000000, about 1.875 gigabytes, full stop. However generous, no static reservation could safely approach that number — and patc2.exe's own no-garbage-collection self-compile kept landing within single-digit-to-tens of bytes of whatever ceiling happened to be current. Before the real cause was found, one comment on the open issue is worth keeping for its own sake: it reports the overage between requested and reserved memory growing far faster than the resize steps between builds, "which is not what plain 'not quite enough memory' would look like" — and rather than guess further, the session stopped and flagged exactly that mismatch as the next thing to explain, instead of trying one more heap size and hoping. In the end, the fix replaced the static reservation entirely with a real VirtualAlloc reserve-large/commit-incrementally scheme: 64 gigabytes of address space reserved lazily on first touch, committed in 256-megabyte chunks as the bump pointer advances — a heap that grows with what a compile actually needs instead of a number chosen in advance and hoped to be enough.

Fixing the heap uncovered two more bugs hiding behind it, each invisible until a compile actually ran at the new, much larger scale. A pointer-versus-plain-integer range check had quietly relied on heap addresses always sitting numerically above string-literal addresses — true by construction under the old fixed layout, where the heap was always the last and highest static item, false the moment the heap's base became an address VirtualAlloc chose on its own. And a per-function codegen walk indexed into a produces_float list unconditionally on every print() call, even though that list is only ever populated for functions classified as float-heavy — everywhere else it's empty, and the index read past its end. Because native codegen's own list access is bounds-checked and returns a safe default out of range, this had never caused a visible problem there; the x64 runtime's own list-read primitive has no such guard, though, so it read whatever bytes of heap happened to sit past an empty list's header. Confirmed non-zero and truthy often enough to matter: because patc2.exe is itself compiled via --x64, every single compile it performed hit this path, silently routing ordinary print("some string") calls into the float formatter and printing garbage instead of the string. That one bug is the actual reason patc1 compiling patc2 had always worked while patc2 compiling a working patc3 never once had — not the heap size, which only made the symptom easier to trigger. Lesson: a fixpoint test proves what it actually exercises, not what it resembles. A Rust-codegen self-hosting fixpoint and a native-codegen one sound like the same claim; they're two different tests of two different code paths, and passing one says nothing about the other until someone actually runs it.

Act LXIX: then it got worse before it got better

With the fixpoint closing for real, the next instinct — same as the instalment before this one — was to push harder rather than stop. It found a great deal. Bitwise operations (band, bor, bxor, bnot, shl, shr) rejected any Float value outright, which sounds narrow until you notice that the function doing the rejecting runs on every single --x64 compile regardless of whether the program being compiled uses bitwise operators at all — a single untyped value reaching that one check anywhere in the pipeline failed the whole build, reproduced even on the trivial print(1+1). The first theory blamed a recent, unrelated fix to list_len's return type; reverting it appeared to help on one test run, then didn't on the next, which turned out to be this project's own already-documented build flakiness coincidentally lining up with the theory rather than confirming it. Found by rebuilding with the failure sites instrumented directly rather than guessing from the symptom, the real cause was a type the rejection check had simply never been taught about — Value::Float added as a separate fast-path variant after the original check was written, and never added to its list of acceptable inputs.

The print() return-value story closed in two stages a day apart, and the gap between them is the more interesting part. print() under the native backend had always returned a placeholder true — not because anyone intended Unit-typed values to behave like booleans, but because the runtime's own comment on the line said plainly that a real Unit literal "needs actual language syntax, parked." Investigating the resulting bug (string concatenation silently dropping its string operand and printing raw payload bits as an integer instead) reached that same parked note and, for a moment, concluded it was correctly diagnosed but not quickly fixable — logged as such, left open, retitled in spirit to say what the problem actually was. It turned out to be fixable the same day anyway: the IR support for a genuine Unit constant already existed, reachable from ordinary PatLang source today via an unremarkable if false then end, which had simply never occurred to anyone as a way to produce one. print() was restructured to use exactly that, closing the issue for real rather than merely diagnosing it. The deeper fix came the next day, once a genuine unit literal landed in the language for an unrelated reason: the lower-level runtime helpers print() had been quietly working around — rt_print_str, rt_print_bool, rt_print_float — still returned the old placeholder themselves, invisible as long as nothing called them directly. With a real literal finally available, fixing them was a one-line change each. Lesson: a documented, deliberate simplification is not the same thing as a problem with no cheap fix available right now — it's worth checking, the moment anything nearby changes, whether the actual blocker you parked it behind is still there.a known gap, closed only once the real fix existed

A heap-bounds bug found the same week shows what "unconditionally" costs when a fix is scoped slightly too broadly. A coercion added days earlier to handle values that might be either a tagged float or a raw integer ran on every bitwise operation this backend emits — including hash_string, a function whose arithmetic is deliberately raw 64-bit accumulator math, never meant to be interpreted as a tagged value at all, and explicitly excluded from the numeric-tower typing the coercion existed to support. The coercion's own safety check only verified that a value's tag bits matched a float's and that its payload was non-zero — not enough, because over enough loop iterations, hash_string's own wraparound accumulator can coincidentally land on a bit pattern satisfying both conditions by pure chance. On a real, roughly 950-kilobyte input, that coincidence happened, and the coercion dereferenced the hash accumulator as though it were a genuine heap pointer. The fix narrowed "is this plausibly a real pointer" from "non-zero" to "falls inside an address range this program could actually have allocated" — the same technique a different piece of this backend already used to answer the identical question for a different caller, reused rather than reinvented. Three more bugs from the same stretch are smaller but worth naming: a stale compiled runtime object silently reused instead of detected as out of date; floor() quietly resolving to dead code left over from an earlier fix, never actually exercised under a real --x64 run before now; and two separate functions — list accumulation through a function call, and the JSON parser — doing quadratic-time work that should have been linear, the same value-semantics-cloning shape Chapter 1 named back in Act IV, found for a third time in a third place.

Act LXX: a debugger, pattern matching for real, and the checker that had drifted too

Two genuinely new capabilities landed in the same window as all of the above, neither one a bugfix. A real debugger — breakpoints, step-into, step-out, call-stack introspection — was added to the self-hosted meta-circular interpreter and wired into the browser IDE, built entirely in PatLang with no new Rust primitives: source-line information threaded through the parser and lowerer first, then pause/resume built on the fiber machinery that already existed for an unrelated reason. Because the IDE's WASM calling convention is one call in, one result out, with no way to hold a session open across separate calls, interactivity in the browser works by capturing one complete execution as a scrubbable trace rather than pausing a live process — a real constraint turned into a real design, not quietly dropped. And pattern matching (match/case) landed as pure lowering-only sugar — zero new IR nodes — closing an issue that had sat open long enough to be worth naming on its own. Both were verified the way this project has learned to verify things that touch more than one layer: the debugger against a real compiled binary stepping through real breakpoints, not just source inspection; match/case against an eleven-scenario suite plus a live demo page, which caught a guard-evaluation-order bug and a jump-patching mistake neither one would have been obvious from reading the lowering code alone.

The same week supplied a small, sharp joke at the project's own expense. The whole repository had moved drives, from F: to D:, and several files still had the old drive hardcoded into absolute paths — an ordinary tidying job, done and verified. Then the tool that exists specifically to catch two hand-maintained implementations drifting apart — the script that regenerates a PatLang mirror of the Rust runtime's own prelude constants, the fix Chapter 1's Act V built after finding exactly this kind of drift once already — turned out to have the identical bug: its own input and output paths were hardcoded to F:\PatLang, so after the move it had been silently running, finding nothing to change, and exiting successfully, for however long the drive mismatch had gone unnoticed. The drift-detector itself had drifted, and said nothing was wrong the whole time it was wrong. Fixing its paths to be relative to its own location let it actually do its job again, and running it for real found and closed a genuine six-chunk parity gap that had been sitting there, unreported, the entire time. Lesson: a tool that exists to catch drift is not exempt from drifting itself — if anything, it needs checking more often than the thing it watches, precisely because its whole job is to report "nothing to see here," and a broken instance of exactly that tool looks identical to a healthy one until someone thinks to ask it the question directly.cf. Chapter 1, Act V: two things that should agree, drifting

Lessons from this arc, the short version

  • A fixpoint test proves what it actually exercises, not what it resembles. Two different code paths producing the "same" self-hosting claim are two different tests; passing one says nothing about the other until it's actually run.
  • When a theory appears confirmed on one run and refuted on the next, suspect something nondeterministic (or already-documented-flaky) lining up with the theory by chance — not the theory being right half the time.
  • A hard platform ceiling is worth bisecting for directly, byte by byte against ground truth, rather than repeatedly guessing a bigger number and hoping it converges. An overage growing faster than the step size between attempts is itself a signal worth stopping and naming, not pushing through.
  • A documented, deliberate simplification is not the same claim as "no cheap fix exists right now." Worth rechecking, the moment anything nearby changes, whether the actual blocker is still there.
  • A safety check scoped to "non-zero" instead of "plausible" will eventually meet a value that satisfies it by coincidence. Reuse a stricter, already-proven range check rather than a looser one that happens to work most of the time.
  • A tool built to catch drift needs to be checked for drift itself — its job is reporting "nothing wrong," and a silently broken instance of it reports exactly that, indistinguishable from a healthy one, until someone asks it the question directly rather than trusting its silence.

References: What the Literature Already Knew

As with the rest of this series, none of the three references below were consulted while any of this arc's bugs was being tracked down — each was found the way the rest of this page describes, by pushing a real build past where it had ever been pushed before. They're listed afterward because a named technique or a named metaphor already had the shape of the problem.

  • Bisecting a hard Windows loader ceiling byte by byte against real system calls (Act LXVIII) — isolating the exact boundary of a failure by systematically narrowing the input, rather than guessing at a cause and testing that guess directly, is the general method Zeller and Hildebrandt formalised as delta debugging: an automatable search for the smallest input that still reproduces a failure, which here took the shape of searching for the smallest change in image size that still triggered the loader's refusal1.
  • print()'s placeholder return value, explicitly documented as parked rather than silently left broken (Act LXIX) — a known gap, deliberately incurred and clearly recorded rather than hidden, paid down once its real blocker lifted, is close to the original sense Cunningham gave technical debt: a conscious shortcut, named as such, that still has to be settled later rather than forgotten2.
  • A drift-checking tool silently failing to detect the exact drift it exists for (Act LXX) — a monitor that can itself fail silently, and whose silence is indistinguishable from a clean result, is the specific failure mode Fowler's account of self-testing code argues against: the whole value of an automated check is lost the moment the check itself isn't also kept honest3.

  1. Zeller, A., & Hildebrandt, R. (2002). Simplifying and isolating failure-inducing input. IEEE Transactions on Software Engineering, 28(2), 183–200. https://doi.org/10.1109/32.988498 ↩

  2. Cunningham, W. (1992). The WyCash portfolio management system. OOPSLA '92 Experience Report. Source of the technical debt metaphor. https://doi.org/10.1145/157709.157715 ↩

  3. Fowler, M. (1999). Refactoring: Improving the Design of Existing Code. Addison-Wesley. Source of the self-testing-code argument. ↩

See also

The Journey of Building PatLang (Acts I-VI), the second instalment (Acts VII-XIV), the third (Acts XV-XXIII), the fourth (Acts XXIV-XXVIII), the fifth (Acts XXIX-XXXV), the sixth (Acts XXXVI-XLV), the seventh (Acts XLVI-L), the eighth (Acts LI-LVII), the ninth (Acts LVIII-LXV), and the tenth (Acts LXVI-LXVII) for where this page picks up from. The browser IDE is where the new debugger actually lives; the match/case demo shows the pattern-matching sugar this arc closed out. The heap, bitwise-op, and runtime-prelude fixes described above live in self_hosting/lib/x64_runtime.patlang and codegen_x64.patlang; the mirror-check tooling that needed checking is tools/regen_runtime_rs.py.