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
.mpyloader (~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
.cfiles forMP_QSTR_*markers to build the qstr table. A small tooling extension (tools/zig_qstr_shadow.py) lifts markers out of.zigfiles 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 holdsMICROPY_REGISTERED_MODULES(a preprocessor macro expanding to a C struct initializer). - Non-local return. MicroPython uses
setjmp/longjmpfor exception propagation. Zig has nosetjmp; it haserror{PyRaise}!T. Every raise site converted toreturn error.PyRaise; every catch site converted totryorcatch. When the last vendor NLR file was deleted, the runtime was exception-propagating end-to-end withoutsetjmp. That change is what made the wasm target possible: wasm lacks nativesetjmpand 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). Zigextern structdeclarations mirror vendor MicroPython C structs field-for-field, with@sizeOf/@offsetOfasserts 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!Tunion; every caller polls or propagates viatry. 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/.