Skip to content

Strangler journey

How ~53,000 lines of vendor C became one 86-line file. Short version. Full methodology in TR-001; reusable lessons in notes/lessons-learned/.

The setup

MicroPython is a self-contained Python implementation in C. Its unix/standard port compiles to ~652 KB and runs a corpus of ~550 small Python programs whose outputs are known. Deviations are real bugs.

The strangler starts by freezing that corpus's output. From then on, every nanopython build is checked against those frozen bytes. Any drift fails the build. That single byte-exact oracle is what let a full-runtime rewrite in a different language stay honest for the whole arc.

The arc

The rewrite replaced the C runtime one subsystem at a time (lexer, parser, compiler, emitter, .mpy loader, VM dispatch, object model, allocator, GC). Each subsystem got a Zig module matching the vendor C file's link surface, then the vendor .c file was dropped from the compile graph and the Zig module linked in its place. If the binary still linked and the corpus stayed green, the port shipped.

Three later arcs finished the extraction:

  • The .mpy loader (~940 LOC). Ported field-for-field against the vendor loader's binary format, then the C file was removed and the compile graph produced zero vendor C.
  • The qstr / module registration tooling. MicroPython's build tools scan .c files for MP_QSTR_* markers to build the qstr table. A small tooling extension (tools/zig_qstr_shadow.py) lifts markers out of .zig files into a generated shim that the upstream scanner can see. Nine tombstone C files disappeared; one remains, src/objects/objtype/objmodule_shim.c (86 LOC), which holds MICROPY_REGISTERED_MODULES (a preprocessor macro expanding to a C struct initializer).
  • Non-local return. MicroPython uses setjmp/longjmp for exception propagation. Zig has no setjmp; it has error{PyRaise}!T. Every raise site converted to return error.PyRaise; every catch site converted to try or catch. When the last vendor NLR file was deleted, the runtime was exception-propagating end-to-end without setjmp. That change is what made the wasm target possible: wasm lacks native setjmp and the NLR-elimination sidestepped a wall the project would have hit later.

The result

Before After
Compiled C ~53,000 LOC 86 LOC
Vendor C in the compile graph thousands of LOC 0
Corpus byte-identical (nano) 534/541 at the start 541/541

The remaining 86 LOC is the qstr-gen tombstone. Removing it needs either upstream tooling changes or a Zig-native module-table generator; it's not in current scope.

The methodology, in three primitives

  • A byte-exact differential oracle. The corpus lives frozen; every build checks against it; drift fails. This is what lets a large mechanical rewrite proceed without a design phase.
  • The type-mirror pattern (src/mp_mirrors.zig). Zig extern struct declarations mirror vendor MicroPython C structs field-for-field, with @sizeOf / @offsetOf asserts so a layout drift fails the build at the right place. Zig code reads and writes vendor structs directly, no shim layer.
  • Polled-sentinel error propagation. Every function that can raise returns a PyError!T union; every caller polls or propagates via try. Across the C↔Zig boundary the sentinel is a status word, which lets both sides participate in the same discipline without one having to speak the other's exception convention.

TR-001 develops all three as a self-contained methodology, with the audit numbers and the design decisions that made them work.

What the strangler taught

A handful of rules held across the whole arc:

  • Corpus goldens are binary fixtures. Never run formatters over them. A trailing-newline tool silently broke the invariant once.
  • _ = &func; forces Zig to analyze a function's body; _ = func; does not. Using the wrong form gave "fake green" builds that shipped uncompiled code.
  • When fanning out N mechanical conversions in parallel, do one first as a pattern-locker. The remaining N-1 copy the pattern exactly. Independent adversarial verification on each conversion caught issues the porting agents missed.
  • Bail early rather than ship a broken tree. Two attempts at the allocator-NLR removal bailed on architectural couplings the audit missed. The third succeeded because the earlier bails localized the coupling exactly.

The full lessons list, along with the FFI conventions and the validation harness, lives in notes/lessons-learned/.