ADR 013. Restoring exception unwinding across vendor-C frames (the try/except-over-MPZ wall)¶
- Status: Proposed (drafted 2026-06-13)
- Decider: project maintainers
- Supersedes: nothing
- Branches from: ADR 012 §3 D2 (per-feature flags as the only durable gate; this ADR introduces
build_options.exception_unwindfollowing that discipline). Phase 3.5 step 4 + 6 retrospectives innotes/12-is-full-audit.mddocumented the limitation that this ADR resolves. - Consequences: introduces a second exception-propagation discipline (setjmp/longjmp trampolines around enumerated vendor-C entry points) alongside the existing polled-sentinel; gated on a new
build_options.exception_unwindflag (default true onfull, false onnano+ wasm32 carveout per spike result); commits to maintaining a small, explicit wrapped-set insrc/runtime/nlr_trampoline.zig; re-enablesnpy_mirror_layout_check()to defend the dual-discipline ABI; commits to porting wrapped vendor-C subsystems to Zig over time (Alternative E) as the eventual destination, with each port retiring its trampoline.
1. Context¶
The strangler retired NLR (vendor MicroPython's setjmp / longjmp over mp_nlr_buf_t chains) in favor of a polled-sentinel protocol: pyerr.pending_exc is set at the raise site, every caller polls after a potentially-raising call, and the VM dispatch loop polls between every opcode (src/runtime/vm.zig:415-435). try/except dispatch (src/runtime/vm.zig:199-271) just works under this discipline as long as every frame between the raise and the handler is Zig-with-try or C-that-polls. NLR removal was the prerequisite for wasm32-wasi (no clean setjmp/longjmp there) and is documented in TR-001 §3.2.
Phase 3.5 of ADR 012 brought the full arm to 524/552 (94.9%) by re-adding vendor C subsystems that the strangler had removed: py/objint_mpz.c, py/mpz.c, py/objint.c, py/modio.c, py/objstringio.c, extmod/modre.c, etc. These files contain mp_raise_msg / mp_raise_ValueError / mp_raise_TypeError / mp_raise_OSError call sites that assume NLR will unwind back to a registered handler. Without NLR, the strangler ported these helpers as @panic stubs ("unreachable under nano"). Phase 3.5 steps 4 + 6 liberated them to a graceful exit path: pyerr.pending_exc = exc; print traceback; exit(1). The exit(1) bypasses Python try/except entirely.
Affected tests today (residual after ADR 012 Phase 3.5):
- 4 intbig (
builtin_divmod_intbig,builtin_pow3_intbig,int_big_error,int_bytes_intbig). Example:try: divmod(1<<65, 0); except ZeroDivisionError: - 2 io (
io_bytesio_ext2,io_stringio1). Example:try: bio.read(); except: ... - parts of
builtin_pow3(try: pow("x", 5, 6); except TypeError:) - probably more once additional vendor C lands in the link
Affected vendor C raise sites enumerated by exploration:
py/objint_mpz.c:240,279,295,358,360,444,462: divide-by-zero, negative shift, negative power, pow3 type-check, OverflowErrorextmod/modre.c:454,468: "regex too complex", "error in regex"py/modio.c:173:mp_raise_OSError(err)in I/O paths
The strangler removed both NLR AND the ability to add C frames that raise back to Python frames without reintroducing the mechanism somehow. Adding more vendor C to the link (which Phase 3.5 will continue) widens this gap.
2. Decision space¶
How do we let Python try/except catch exceptions raised inside vendor C code that's still in the link, without losing the strangler's gains (no defer-across-longjmp hazards in nano, wasm32 stays clean, native 541/541 sacrosanct, vendor C surface trending down not up)?
The decision splits along three axes:
- Where the unwind discipline lives. Vendor-C-side via NLR (re-add setjmp/longjmp), or polled-sentinel extended into vendor C via patches, or accept the exit.
- Which arms/platforms get the fix. Full-only or both arms; native-only or wasm too.
- How much vendor C surface we re-add or patch. The strangler's "net-shrink C" discipline (
CLAUDE.mdstrangler-discipline note) constrains this.
3. Alternatives¶
A. Restore vendor NLR under full only (setjmp + nlr_buf_t shim)¶
Re-add vendor/micropython_embed/py/nlrsetjmp.c (+ nlr.c) to build.zig gated on a new build_options.exception_unwind flag (default on for full, off for nano). Rewrite longlongRaiseDie under full to call nlr_jump(exc) instead of exit(1). Re-author the lost npy_nlr_run trampolines (~3-4 entry pads at the VM frame boundaries; note these were entirely removed, not just disabled, per vm.zig:6,276 and raise.zig:13 removal-of comments).
| Dimension | Value |
|---|---|
| LOC change | +180-220 net Zig/build; re-adds 124 LOC vendor C; corrected from the original +150 estimate (audit found npy_nlr_run trampolines are gone, not paused; must be reauthored) |
| Binary size | Native full: +5-15 KB. Nano + wasm: unchanged. |
| Runtime overhead | ~10ns setjmp per protected VM-frame entry on amd64; ~2-5% perf hit on bytecode benchmarks |
| Nano risk | Zero by construction if strictly gated on build_options.exception_unwind=false for nano |
| Wasm portability | Conditional on the setjmp spike (open question) |
| Tests unblocked | ~10 |
Pros: highest fidelity to vendor MP; centralized push pad covers all vendor-C raises that follow.
Cons: reintroduces the defer-across-longjmp footgun the strangler removed (scoped to full only, but the bug class returns); dual-discipline runtime under full (polled-sentinel + NLR), where mixing them races (Zig sets pending_exc, vendor C runs and longjumps, pending_exc is now stale; mitigation requires every NLR unwind to clear pending_exc); re-adds 124 LOC vendor C, violating the strangler-discipline "net-shrink" rule.
B. Hybrid trampoline: setjmp per high-value vendor-C call site (RECOMMENDED)¶
No global NLR chain. Write a small Zig/C trampoline npy_call_vendor_with_setjmp(fn, args) that does setjmp locally, registers an nlr_buf_t for the duration of the call, and on longjmp converts to polled-sentinel (pyerr.pending_exc = val; return error sentinel). Wrap an enumerable set of vendor-C entry points: the binary-op dispatcher (covers all 7 MPZ raises), the stream write/flush path (covers modio.c:173), and 3-4 more high-value sites identified by triage.
| Dimension | Value |
|---|---|
| LOC change | +110-150 net Zig/C; corrected from the original +70-110 estimate (audit found 6 raise helpers in raise.zig need dual-mode rewrite, and wrap-site coverage was undercounted) |
| Binary size | +1-3 KB (one trampoline + jmpbuf per active wrapped call). Smaller than A. |
| Runtime overhead | setjmp paid per wrapped vendor-C call (not per VM entry). 10-30% overhead on MPZ-heavy microbenchmarks; invisible on real programs. |
| Nano risk | Zero if exception_unwind=false on nano. Trampolines compile to no-ops there. |
| Wasm portability | Conditional on the setjmp spike (open question). If spike fails, B becomes B' = native-only B (Alternative C shape). |
| Tests unblocked | ~7 (after the modio.c wrap-target correction; modre raises stay blocked pending E port) |
Pros: best tests-unblocked-per-LOC ratio; no vendor C re-added (preserves strangler-discipline "net-shrink C" rule); scopes the defer-across-longjmp footgun to a small enumerable set of wrapped sites; aligns with the natural boundaries where polled-sentinel polls already live.
Cons: inconsistent UX (try/except catches ZeroDivisionError from wrapped MPZ but not re.error from unwrapped modre); the wrapped-set tends to grow as new tests expose unwrapped raises, paying scaling interest over time until E completes the ports. The verifier flagged that the original modio.c:173 wrap target was wrong (the raise fires from mp_stream_write_exactly flush path, not mp_stream_read); fixed in this draft.
C. POSIX-only NLR (A + explicit wasm exit(1) carveout)¶
Alternative A applied with a clean target-gate: nlrsetjmp.c + the longlongRaiseDie rewrite compile only when target.os.tag != .wasi. Wasm full arm keeps exit(1). Documents the platform divergence in the feature matrix (ADR 012 update). Same is_wasm gate pattern already used at build.zig:189 (LongintImpl) + build.zig:385.
| Dimension | Value |
|---|---|
| LOC change | +180-220 (same as A); 124 LOC vendor C (native-target only) |
| Binary size | Native full: +5-15 KB. Wasm: unchanged. |
| Runtime overhead | Native: same as A. Wasm: zero (still exits). |
| Nano risk | Zero (same gating as A) |
| Wasm portability | Wasm semantics unchanged by construction |
| Tests unblocked | ~10 on native; 0 incremental on wasm |
Pros: closes the visible native test gaps with vendor-MP fidelity; preserves the strangler's original wasm-clean invariant.
Cons: permanent native-vs-wasm semantic divergence (try: 10**100 except OverflowError: works on the dev binary, dies on the wasm build): ages badly; cuts against ADR 012's "default is the powerful CPython experience" framing.
D. Wasm-exceptions proposal (true exception unwinding via WasmEH)¶
Use the WebAssembly exception-handling proposal (-fwasm-exceptions, __builtin_wasm_throw / __builtin_wasm_catch). Write a new nlrwasm.c (~80 LOC, parallel to nlrsetjmp.c). Toolchain transforms the unwind into wasm throw / catch with out-of-line unwind tables.
| Dimension | Value |
|---|---|
| LOC change | +50 build glue + ~100 Zig shim + ~80 LOC new nlrwasm.c; combined: +200-250 total |
| Binary size | Wasm: +1-3 KB shim. WasmEH unwind tables are out-of-line: can be size-NEUTRAL or smaller than setjmp. |
| Runtime overhead | Wasm: zero per-frame entry cost (only pays on raise). Best perf profile of all alternatives on the unwind path. |
| Nano risk | Low if cleanly gated on build_options.wasm_exceptions |
| Wasm portability | The whole point; toolchain readiness is UNVERIFIED |
| Tests unblocked | ~10 |
Pros: best long-term wasm story; closes the platform divergence Alternative C documents; best perf profile (zero per-frame cost; only pay on raise).
Cons: toolchain readiness unverified (needs spike: 10-line C with __builtin_wasm_throw, build with current zig 0.x + wasi-sdk, run under wasmtime); WasmEH spec has had two iterations (legacy + new), and zig / wasi-libc may pin one and not the other.
E. Stay course + document + replace vendor C with Zig over time¶
Keep exit(1). Document the ~10 affected tests as "vendor-C raise across non-NLR boundary" in CLAUDE.md known-issues + a new corpus/full-known-fails.txt (analogous to wasm's). Track each affected vendor file (objint_mpz.c, mpz.c, objint.c, modre.c, modio.c, objstringio.c) as a port target. Each port lands a Zig replacement that natively uses polled-sentinel; the corresponding tests close as ports complete.
| Dimension | Value |
|---|---|
| LOC change | 0 today; ~3000-5000 LOC of vendor C to port over multiple cycles |
| Binary size | Zero short-term. Long-term net SHRINK as ports land. |
| Runtime overhead | Zero new (status quo) |
| Nano risk | Zero (no code change) |
| Wasm portability | N/A (no code change) |
| Tests unblocked | 0 short-term; ~10 across multiple cycles as ports land |
Pros: aligns perfectly with the project's stated direction; respects the strangler-discipline rule.
Cons: zero tests closed this session: the ~10 affected tests stay broken for cycles; exit(1) on try/except is a permanent papercut every demo writer and corpus author hits.
F. Vendor-source patches converting mp_raise_* callers to polled-sentinel¶
Extend the polled-sentinel discipline INTO vendor C via small patches maintained at make port time. Convert ~15 vendor mp_raise_* call sites to set pyerr.pending_exc + return MP_OBJ_NULL (or equivalent sentinel for the callee's return type). Cascade fixes: every caller of every patched function must also poll.
| Dimension | Value |
|---|---|
| LOC change | +30 patch lines + cascade fixes (tens of vendor functions deep for MPZ); ~150 LOC patches + ongoing maintenance |
| Binary size | Probably -2 KB (mp_raise_* trampolines simpler; no setjmp infra) |
| Runtime overhead | Zero (one branch per polled check, matches existing nano discipline) |
| Nano risk | Low if patches generate identical C for both arms |
| Wasm portability | Wasm-clean by construction |
| Tests unblocked | ~8 |
Pros: extends the project's existing discipline rather than grafting a competing one; best long-term ideological fit with the strangler philosophy.
Cons: patch rot vs upstream MicroPython: every make port cycle requires re-merging; quarterly upstream pulls become significantly more expensive; cascade depth: sentinel propagation must thread through tens of vendor MPZ functions, each patched function's callers also need to poll.
4. Recommendation¶
B (Hybrid trampoline) for v0.7, with E (port-over-time) as the long-term destination and a documented intent to evaluate D (WasmEH) once toolchain readiness is verified.
Why B: best tests-unblocked-per-LOC ratio (~7 tests / ~120 LOC); does not re-add vendor C (preserves the strangler-discipline rule); scopes the defer-across-longjmp footgun to a small enumerable set of wrapped sites; aligns with the natural boundaries where polled-sentinel polls already live. It closes the headline UX papercuts (ZeroDivisionError, OverflowError, OSError, StringIO close) this cycle without committing to a global NLR restoration. A is mechanically cheaper but reintroduces 124 LOC vendor C and a global dual-discipline runtime: too much surface for too little marginal benefit over B. C splits the contract on platform lines (bad for portability + CPython-experience framing). D is the best long-term wasm answer but toolchain readiness is unverified. F is ideologically clean but maintenance-toxic at make port cadence.
Execution outline:
- Spike (30 min): write a 10-line setjmp/longjmp roundtrip in C, build with
zig cc -target wasm32-wasi, run under wasmtime. Settles the wasi-libc setjmp question that gates B (and A and C). If setjmp works on wasm32-wasi, B is single-flag-gated; if it doesn't, B becomes B' (native-only wrap; wasm full retainsexit(1)). - Add
build_options.exception_unwindflag in build.zig (default true on full, false on nano). Do NOT useis_fullas the gate (ADR 012 §2.4 deprecates it as a feature predicate). - Write the trampoline shim in
src/runtime/nlr_trampoline.zig(~30 LOC):npy_call_vendor_with_setjmp(fn, args)sets up a localnlr_buf_t, registers it inmp_state_ctx.thread.nlr_top, calls fn, on longjmp convertsnlr_buf.ret_valtopyerr.pending_excand returns an error sentinel. - Re-enable
npy_mirror_layout_check()atsrc/main.zig:202as a prerequisite: the disabledmp_state_vm._pad0asserts atmp_mirrors.zig:1199-1203are the exact ABI-mirror hazard NLR restoration reactivates. Notes/06 follow-up item #1 / #2 covers the layout debt; this work merges with that. - Wrap 3 high-value vendor entry points:
mp_obj_int_binary_opat runtime.zig binary-op slot dispatch (covers all 7 MPZ raises)mp_stream_write_exactlyat the io slot dispatch (coversmodio.c:173; corrected target per verifier flag)mp_obj_int_pow3at modbuiltins powImpl (covers pow3 type-check + divide-by-zero)- For modre.c: defer to E (port modre wrapper to Zig in a follow-up cycle). Document
re_error/re_limittests in CLAUDE.md known-issues with a tracking reference. - Land golden refreshes for the now-passing tests (4 intbig + 2 io + pow3 portions). Verify the wasm gate stays at its baseline.
- Update ADR 012's feature matrix to add "exception unwind across vendor C" as a named feature, on by default for full, with the wrapped-set listed explicitly. Cross-link from CLAUDE.md.
Success criteria:
- Native gate stays at 541/541 (the strangler invariant; non-negotiable).
- Full arm corpus moves from 524/552 to ≥530/552 (target: +6 tests from the wrapped-set; +10 if modre is wrapped too).
- Wasm gate stays green (
wasm-known-fails.txtunchanged or shrinks; never grows). - Integration suite stays at 30/30 on both arms.
npy_mirror_layout_check()runs at startup and passes on amd64 + wasm32 (validates the ABI-mirror hazard is closed).- The 4 intbig tests (
builtin_pow3_intbig,builtin_divmod_intbig,int_bytes_intbig,op_error_intbig) and the 2 io tests (io_stringio1,io_bytesio_ext2) pass via realtry/except, verified by reading the test source for thetry/exceptshape and confirming byte-exact match against vendor MP. - An ADR-013-follow-up issue is filed tracking modre + future high-value sites as port targets (Alternative E), with explicit milestones.
5. Consequences¶
Positive. The headline UX papercut closes: try/except ZeroDivisionError, OverflowError, OSError, and ValueError over StringIO operations behave like CPython on full. ~7 corpus tests move from failing to passing. The strangler-discipline rule (net-shrink C) is preserved (no vendor NLR re-added). The polled-sentinel discipline remains the project's primary error-propagation model; the trampolines are a scoped exception to it. Re-enabling npy_mirror_layout_check() pays down deferred v0.6.0 layout debt. The wrapped-set is explicit and enumerable in the codebase, so future maintainers can audit it without surprise.
Negative. Two error-propagation disciplines coexist under full (polled-sentinel + scoped NLR), each with distinct hazards. Defer-across-longjmp returns as a bug class for any Zig code that uses defer inside a wrapped vendor-C call (mitigation: wrapped-set is small and audit-able; CI-style invariant possible). Wasm full's behavior depends on the setjmp spike: if wasi-libc's setjmp doesn't work cleanly, wasm full retains exit(1) and Alternative C's platform divergence reappears (mitigation: WasmEH (D) becomes the explicit follow-up).
6. Open questions¶
- Does setjmp/longjmp work in the current zig + wasi-sdk bundle for wasm32-wasi? Investigated: no.
wasm-wasi-musl/setjmp.h#errors out unconditionally ("Setjmp/longjmp support requires Exception handling support, which is not yet standardized"). Even-mllvm -wasm-enable-sjljdoesn't bypass the header guard. Alternative B → B' (native-only trampoline; wasm full retainsexit(1)). Wasm divergence gets documented as a known-issue; the projected closures land on native only. WasmEH (D) becomes the explicit follow-up if/when the wasi-libc header guard relaxes. - Is WasmEH support in zig 0.16+ stable enough to commit to as the wasm-side answer? (30-minute spike:
__builtin_wasm_throw+ wasi-sdk + wasmtime roundtrip.) - What is the cascade depth of polled-sentinel propagation in vendor MPZ if we ever pursue F? (1-2h spike: manually convert
objint_mpz.c:240divide-by-zero topending_exc+ return null, count how many cascading patches result.) - Should the trampoline shim live in Zig (cleaner, but Zig's setjmp/longjmp interop is limited) or in a thin C file (matches vendor pattern, easier setjmp ABI)?
- Re-enabling
npy_mirror_layout_check()may expose latentmp_state_vmlayout drift on wasm32 (the deferred v0.6.0 hazard). Estimated effort to refit the partial mirror? - Once modre is wrapped (or ported), does the same trampoline pattern naturally extend to future high-value vendor C subsystems (e.g. when stdlib additions reach for vendor extmod files)?
- Does ADR 012 need an explicit feature-matrix row for "exception unwind across vendor C" with the wrapped-set enumerated, or is a CLAUDE.md known-issues entry sufficient?
- When E lands its first port (
modio.c→ Zig is smallest at ~150 LOC), does the corresponding trampoline get removed in the same commit, or kept as defense-in-depth?