Skip to content

fix(wasi): the setjmp macro selection omitted __wasi__ - #1728

Merged
paul-hammant merged 2 commits into
mainfrom
fix/wasi-setjmp-macro-selection
Aug 23, 2026
Merged

fix(wasi): the setjmp macro selection omitted __wasi__#1728
paul-hammant merged 2 commits into
mainfrom
fix/wasi-setjmp-macro-selection

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

From an ask filed by the html-sanitizer downstream. Validated first — the report is accurate in every particular I could check, including the two claims that would have made a simpler fix possible.

The bug

aether_panic.c guards its sigaction crash handler with !defined(__wasi__). aether_panic.h, which selects the setjmp/longjmp macro pair, does not. WASI is hosted (__STDC_HOSTED__ == 1) and does not define __EMSCRIPTEN__, so it lands in the POSIX arm and gets _setjmp/_longjmp — which wasi-libc declares but never implements.

Reproduced on main @ ad413dbb:

wasm-ld: error: libaether.a(aether_panic.o): undefined symbol: _longjmp
wasm-ld: error: paniclib.wasm.o: undefined symbol: _setjmp
Error: cross-linking for wasm32-wasi failed.

Why it went unnoticed: it is a link error, and only for code that reaches the panic machinery. A wasi library with no try/catch/panic never references aether_panic.o and links fine — I confirmed that first, which is what sent me looking for a fixture that actually drags it in.

Why the obvious fix does not work — checked, not assumed

The report claims the plain setjmp/longjmp arm is no better. Verified independently with a five-line C file and zig cc -target wasm32-wasi:

wasm-wasi-musl/setjmp.h:13:2: error: Setjmp/longjmp support requires Exception
handling support, which is not yet standardized. To enable it, compile with
`-mllvm -wasm-enable-sjlj` and use an engine that implements the Exception
handling proposal.

It is a hard #error, not a link failure. And passing -mllvm -wasm-enable-sjlj as the message suggests does not help on zig 0.16.0 — the #error fires before the pass ever runs. So there is no working setjmp on wasm32-wasi in either spelling, and a non-unwinding arm is the only option short of the WebAssembly exception-handling proposal landing in both toolchain and engine.

The fix

A dedicated __wasi__ arm that does not unwind: AETHER_SIGSETJMP always takes the first-return path, AETHER_SIGLONGJMP calls abort(). Plus <stdlib.h> for abort().

The consequence, plainly: on wasi, panic / try / catch are fail-stop rather than recoverable. A panic traps the instance instead of unwinding to the nearest catch, and the catch never runs. That is a real semantic reduction, now documented in docs/build-system.md as a fourth entry in its existing "places where WASI had been forgotten beside Emscripten" list, with its own subsection spelling out the consequence for downstream code.

It is still strictly better than the alternative, which is that wasi cannot link at all. Nothing silently mis-executes: abort() is a trap the host observes, not a fallthrough into a half-unwound stack. A catch block that never runs is visible; one that runs on a corrupt stack is not.

Verified behaviourally, not just by linking

The module instantiates under node's WASI and executes correctly:

risky(21) = 42 (want 42)
safe(21)  = 42 (want 42)
aether: panic outside any try/catch or actor: negative
safe(-1)  -> trapped: RuntimeError (fail-stop, as designed)

Note the message says "outside any try/catch" even though there is one — because the frame never registers. That is the documented reduction being visible rather than silent.

Native builds are unaffected: try/catch still recovers (caught: negative, then risky(21) = 42), and make test is 394 passed / 0 failed.

The regression test

tests/integration/wasi_panic_link/ — and its fixture deliberately uses try/catch/panic, because one without them passes even with the selection wrong. I verified the test earns its place by reverting the fix: it fails with the original link error and prints an actionable diagnosis pointing at the header. Added to tests/ae_sweep_prune.txt (the fixture has no main), and I confirmed the prune actually excludes it rather than assuming.

Cost-capped at one cross link, matching the convention in the neighbouring cross_emit_lib test.

Not in scope

The ask also raises artifact size — 2.0 MB native vs 85 KB from a hand-rolled zig cc script, of which 82% is DWARF — and proposes either a --release/--size mode or --cflags=/--ldflags= passthrough. That is genuinely independent (the setjmp fix alone makes the target usable; the size work makes it preferable), it is a design decision about the CLI surface rather than a bug, and it deserves its own issue and its own discussion. Deliberately not bundled here.

🤖 Generated with Claude Code

paul-hammant and others added 2 commits August 23, 2026 21:11
aether_panic.c guards its sigaction crash handler with
!defined(__wasi__). aether_panic.h, which selects the setjmp/longjmp
macro pair, did not. WASI is hosted (__STDC_HOSTED__ == 1) and does not
define __EMSCRIPTEN__, so it landed in the POSIX arm and got
_setjmp/_longjmp -- which wasi-libc declares but never implements.

That is a LINK error, not a compile error, so it surfaced only at the
very end of a cross build:

    wasm-ld: error: libaether.a(aether_panic.o): undefined symbol: _longjmp

and only for code that actually reaches the panic machinery. A wasi
library with no try/catch/panic never references aether_panic.o and
links fine, which is why this went unnoticed until a downstream tried
--emit=lib on real code.

Neither obvious fix works. Plain setjmp/longjmp is a hard #error in
wasi-libc directing you to `-mllvm -wasm-enable-sjlj` plus an engine
implementing the exception-handling proposal; measured on zig 0.16.0
that flag does not help, because the #error fires before the pass runs.
Real support needs the WebAssembly exception-handling proposal in both
toolchain and engine.

So the wasi arm does not unwind: SIGSETJMP always takes the
first-return arm and SIGLONGJMP calls abort().

THE CONSEQUENCE, PLAINLY: on wasi, panic/try/catch are fail-stop rather
than recoverable. A panic traps the instance instead of unwinding to the
nearest catch, and the catch never runs. That is a real semantic
reduction, documented in docs/build-system.md beside the target's other
caveats. It is still strictly better than the alternative, which is that
wasi cannot link at all -- abort() is a trap the host observes, not a
fallthrough into a half-unwound stack.

Verified end to end rather than by linking alone: the module instantiates
under node's WASI, aether_risky(21) and aether_safe(21) both return 42,
and aether_safe(-1) traps with a RuntimeError after printing the panic
reason. Native try/catch still recovers normally; make test 394/0.

The regression test uses try/catch/panic deliberately, since a fixture
without them passes even with the selection wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit a80b598 into main Aug 23, 2026
26 checks passed
@paul-hammant
paul-hammant deleted the fix/wasi-setjmp-macro-selection branch August 23, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant