PyError Convention¶
pyerr.PyError is the Zig error type that replaced MicroPython's setjmp/longjmp-based NLR (non-local return). It's the convention every Zig fn that can raise a Python exception must use.
The full type¶
pub const PyError = error{PyRaise};
pub threadlocal var pending_exc: usize = 0;
pub inline fn bridge(comptime T: type, r: PyError!T) T {
return r catch {
return std.mem.zeroes(T);
};
}
That's it. Three declarations in src/pyerr.zig. The simplicity is intentional: the error union is Zig-idiomatic, the threadlocal carries the exception object, the bridge converts between the two at C-ABI boundaries.
The propagation pattern¶
Inside Zig code:
fn computeImpl(arg: Obj) PyError!Obj {
if (bad(arg)) try raiseTypeErrorBadArg();
return doWork(arg);
}
fn raiseTypeErrorBadArg() PyError!void {
pyerr.pending_exc = mp_obj_new_exception_msg(&mp_type_TypeError, "bad arg");
return error.PyRaise;
}
At C-ABI export boundaries:
pub export fn npy_compute(arg: Obj) callconv(.c) Obj {
return pyerr.bridge(Obj, computeImpl(arg));
}
pyerr.bridge catches error.PyRaise and returns std.mem.zeroes(Obj) (which is MP_OBJ_NULL for the Obj = usize representation). The exception object is in pyerr.pending_exc for any C caller to poll.
Why this replaces NLR¶
MicroPython uses a longjmp-based NLR. Every mp_raise_* macro is noreturn and calls nlr_jump(exc) which longjmps to the nearest nlr_push buffer. The protected regions are wrapped:
nlr_buf_t nlr;
if (nlr_push(&nlr) == 0) {
// protected body
nlr_pop();
} else {
// unwind path: nlr.ret_val is the exception
}
This is C-idiomatic but has costs:
- Setjmp/longjmp is a stack-unwinding primitive that's hard to wrap in Zig. Zig has no
setjmp.h; bringing it in requires linking to libc and giving up on platforms like wasm32 (which lacks native setjmp). - No destructors fire on unwind. Allocations between
nlr_pushand the raise are leaked. - The control flow is invisible to the type system. Any C fn might longjmp; you can't tell from the signature.
Zig's error union gives all three:
- It's syntactically explicit (
return error.PyRaise,try,catch). deferanderrdeferrun on unwind, so cleanup is automatic.- Functions that can raise are typed as
PyError!T; the type system enforces correct handling.
The transition (NX-3) took ~20 commits across 4 days of intensive work; see Strangler Journey § NX-3 (strangler-journey.md).
The bridge mechanism in detail¶
pyerr.bridge(T, r) does two things:
- If
risT(success), return it. - If
riserror.PyRaise, returnstd.mem.zeroes(T).
Critically, pyerr.bridge does NOT clear pyerr.pending_exc. The pending exception remains set after the bridge returns. This is intentional: C callers that don't yet know about the PyError convention can poll pyerr.pending_exc to detect the raise.
// Inside Zig
const result = pyerr.bridge(Obj, computeImpl(arg));
if (pyerr.pending_exc != 0) {
// The bridge returned MP_OBJ_NULL because computeImpl raised.
// pending_exc holds the exception object.
pyerr.pending_exc = 0; // claim it
// ... handle the exception
}
Inside Zig (where the caller is also PyError-aware), the bridge isn't needed:
The mixed model (Zig callers using try, C-ABI exports using bridge + polled-sentinel) made the NX-3 transition incremental. Old C callers never had to change; new Zig callers got native error propagation.
When bridge isn't enough¶
The bridge handles the simple case: function returns T; on raise, return zero. Two cases where it doesn't:
noreturn exports¶
Some pub export fns have noreturn signature (typically the mp_raise_* family that vendor C calls expecting longjmp). After NX-3, the surviving noreturn exports use @panic instead of nlr_jump:
pub export fn mp_raise_TypeError(msg: [*:0]const u8) callconv(.c) noreturn {
pyerr.pending_exc = mp_obj_new_exception_msg(&mp_type_TypeError, msg);
@panic("TypeError raised — callers must convert to PyError before strangle completes");
}
This is acceptable for nano because the remaining callers are in dead-under-nano code paths (file_io.zig, stream.zig). Under full those modules would be live and would need PyError-style conversion; that's the full-arm rehab arc (notes/40-full-arm-rehab.md).
mp_obj_str_get_str returning a pointer¶
mp_obj_str_get_str returns ?[*]const u8, a nullable raw pointer. The bridge mechanism is built for zero-able value types; a nullable pointer needs a custom shape.
The convention (locked in NX-3 N4a) is:
pub export fn mp_obj_str_get_str(self_in: Obj) callconv(.c) ?[*]const u8 {
// ... checks if self_in is str-or-bytes ...
// On bad input: sets pyerr.pending_exc to TypeError, returns null.
// On good input: returns pointer to byte data.
}
Callers must orelse return error.PyRaise (if PyError-aware) or check the null + pyerr.pending_exc themselves (if at a C-ABI boundary).
This is the closest equivalent to "PyError for fns that return pointers" the codebase has. It works because ?[*]const u8 is one machine word, so the C ABI is preserved.
Polled sentinel: the bridge pattern's other half¶
When a Zig fn returns via pyerr.bridge and the C caller doesn't speak PyError, the caller polls:
// In a hypothetical remaining C caller (vendor-style):
mp_obj_t result = npy_some_zig_fn(arg);
if (npy_pyerr_get_pending() != 0) {
// A Zig raise happened; result is MP_OBJ_NULL.
mp_obj_t exc = npy_pyerr_take_pending();
// ... handle the exception (typically nlr_raise back, in old code)
}
The polled-sentinel accessors (npy_pyerr_get_pending, npy_pyerr_take_pending, npy_pyerr_set_pending) are exported from pyerr.zig. After NX-3, there are no C callers using them; the only remaining poll site is zig_compile_bridge.zig (Zig polling its own Zig exports after a bridge wrap).
These accessors are documented as "EXPERIMENTAL (deleted after B2 finalization)"; they'll go when no consumer remains.
errdefer for allocator cleanup¶
Zig's errdefer is the strangler's answer to vendor's nlr_push_jump_callback. In MicroPython:
nlr_jump_callback_node_globals_locals_t ctx;
ctx.globals = mp_globals_get();
ctx.locals = mp_locals_get();
mp_globals_set(new_globals);
nlr_push_jump_callback(&ctx.callback, mp_globals_locals_set_from_nlr_jump_callback);
// ... protected body ...
nlr_pop_jump_callback(true); // runs the callback on clean exit too
The callback fires on either clean exit OR longjmp unwind, restoring globals/locals before propagating.
In Zig (post-NX-3a):
const saved_g = mp_globals_get();
defer mp_globals_set(saved_g);
const saved_l = mp_locals_get();
defer mp_locals_set(saved_l);
mp_globals_set(new_globals);
// ... protected body using try/catch ...
// defers run on scope exit, including PyError-return
defer runs on BOTH paths (success and error.PyRaise), making the cleanup invisible to the type system but reliable. This is the exact pattern that replaced zig_compile_bridge.c's nlr_push_jump_callback in NX-3a (commit 46e4647).
Common failure modes¶
Forgot try¶
Without try, inner has type PyError!Obj; doMore expects Obj, so the compiler catches the mismatch. The fix is try computeImpl(arg).
Forgot to set pending_exc before returning error.PyRaise¶
pyerr.pending_exc is still whatever it was before. If anything in the caller chain reads it as "an exception is pending," they'll see stale data. The fix is always pyerr.pending_exc = ...; return error.PyRaise; as a pair.
Bridge wrap with the wrong type¶
pub export fn npy_compute() callconv(.c) Obj {
return pyerr.bridge(void, computeImpl()); // BUG: T should be Obj
}
std.mem.zeroes(void) produces {}; std.mem.zeroes(Obj) produces 0. Wrong T → wrong sentinel → callers get garbage. The fix is pyerr.bridge(Obj, ...).
Calling pyerr.bridge from a noreturn fn¶
bridge returns a value; it can't be the last expression in a noreturn fn. If the fn is noreturn, use @panic or call a polled helper that sets pending_exc without trying to return.
Lifecycle and threading¶
pyerr.pending_exc is declared threadlocal. Each OS thread has its own value. This matches MicroPython's threading model (which is, in nanopython, "no threads"; the threadlocal is single-instance).
When threading lands (future work, under full config), the threadlocal ensures multiple threads don't trample each other's pending exceptions.
See also¶
src/pyerr.zig(in tree): the canonical sourcenotes/16-b2-residual.md: original B2 plannotes/41-nx3-nlr-boundary.md: NX-3 plan- Strangler Journey § NX-3 (
strangler-journey.md): the narrative - Contributing § The validator pattern (
contributing.md): how to apply the convention to new code