From 6ed56c8f0938e1fdaf8ae5631640e419f60fc347 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 21:11:05 +0100 Subject: [PATCH 1/2] fix(wasi): the setjmp macro selection omitted __wasi__ 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 --- docs/build-system.md | 35 +++++++- runtime/actors/aether_panic.h | 36 +++++++- tests/ae_sweep_prune.txt | 1 + tests/integration/wasi_panic_link/paniclib.ae | 22 +++++ .../wasi_panic_link/test_wasi_panic_link.sh | 88 +++++++++++++++++++ 5 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 tests/integration/wasi_panic_link/paniclib.ae create mode 100755 tests/integration/wasi_panic_link/test_wasi_panic_link.sh diff --git a/docs/build-system.md b/docs/build-system.md index 9951b6519..8d86b1b6a 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -633,7 +633,7 @@ $ wasmtime hello.wasm hello from wasi ``` -Three things had to change for that, and they are worth knowing because each +Four things had to change for that, and they are worth knowing because each was a place where WASI had been forgotten beside Emscripten: - **The scheduler.** WASI has no usable threads, but zig's wasi-libc ships @@ -651,6 +651,39 @@ was a place where WASI had been forgotten beside Emscripten: which wasm rejects ("relocations for function or section offsets are only supported in metadata sections"). The guard excluded `__EMSCRIPTEN__` but not `__wasi__`. +- **`setjmp`/`longjmp` selection.** `aether_panic.c` guarded its crash handler + with `!defined(__wasi__)`, but `aether_panic.h`'s macro selection did not. + WASI is hosted and does not define `__EMSCRIPTEN__`, so it fell into the + POSIX arm and got `_setjmp`/`_longjmp` — which wasi-libc declares but never + implements. A **link** error, so it surfaced only at the end of a cross + build (`undefined symbol: _longjmp`). See the caveat below. + +### `panic` / `try` / `catch` are fail-stop on WASI + +There is no working `setjmp` on `wasm32-wasi` in either spelling. `_setjmp` is +declared but unimplemented; plain `setjmp` is a hard `#error` in wasi-libc +directing you to `-mllvm -wasm-enable-sjlj` and an engine implementing the +exception-handling proposal (measured on zig 0.16.0, that flag does not help — +the `#error` fires first). Real support needs the WebAssembly +exception-handling proposal in both toolchain and engine. + +So on WASI the runtime **does not unwind**: `AETHER_SIGSETJMP` always takes the +first-return arm and `AETHER_SIGLONGJMP` calls `abort()`. A `panic()` traps the +instance instead of unwinding to the nearest `catch`, and a `catch` block +therefore never runs: + +``` +$ node --experimental-wasi call.mjs module.wasm +aether: panic outside any try/catch or actor: negative +safe(-1) -> trapped: RuntimeError +``` + +That message says "outside any try/catch" even when there *is* one, because the +frame never registers. This is a real semantic reduction, and the alternative +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. Code +targeting WASI should treat `panic` as fatal and use `(value, err)` returns for +anything it expects to recover from. `--target=wasm` remains the route to a runnable **browser** bundle with JS glue; `wasm32-wasi` produces a self-contained module for a WASI runtime. diff --git a/runtime/actors/aether_panic.h b/runtime/actors/aether_panic.h index 52965a275..a75e939c2 100644 --- a/runtime/actors/aether_panic.h +++ b/runtime/actors/aether_panic.h @@ -27,6 +27,7 @@ #include #include +#include /* abort(), for the __wasi__ no-unwind arm below */ #include "../utils/aether_compiler.h" #ifdef __cplusplus @@ -61,7 +62,40 @@ extern "C" { // The aether_sigjmp_buf typedef stays so call sites read uniformly; // on every target it's now jmp_buf under the hood. typedef jmp_buf aether_sigjmp_buf; -#if defined(_WIN32) || defined(__EMSCRIPTEN__) || (defined(__STDC_HOSTED__) && __STDC_HOSTED__ == 0) +#if defined(__wasi__) + /* wasm32-wasi has NO usable setjmp/longjmp, in either spelling. + * + * WASI is hosted (__STDC_HOSTED__ == 1) and does not define + * __EMSCRIPTEN__, so without this arm it falls into the POSIX branch below + * and gets _setjmp/_longjmp -- which wasi-libc declares but never + * implements. That is a LINK error, not a compile error, so it surfaces + * only at the very end of a cross build: + * + * wasm-ld: error: libaether.a(aether_panic.o): undefined symbol: _longjmp + * + * The plain setjmp/longjmp arm is no better: wasi-libc's is a + * hard `#error` telling you to compile with `-mllvm -wasm-enable-sjlj` and + * use an engine implementing the exception-handling proposal. Measured on + * zig 0.16.0, passing that flag does not help either -- the #error fires + * before the pass ever runs. Real support needs the WebAssembly + * exception-handling proposal in both toolchain and engine. + * + * So: do not unwind. SETJMP always takes the first-return arm, and LONGJMP + * traps. + * + * 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. That is a real semantic reduction, and + * it is documented in docs/build-system.md alongside the target's other + * caveats. + * + * 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. */ + #define AETHER_SIGSETJMP(buf, savemask) ((void)(buf), 0) + #define AETHER_SIGLONGJMP(buf, val) (((void)(buf)), ((void)(val)), abort()) +#elif defined(_WIN32) || defined(__EMSCRIPTEN__) || (defined(__STDC_HOSTED__) && __STDC_HOSTED__ == 0) #define AETHER_SIGSETJMP(buf, savemask) setjmp(buf) #define AETHER_SIGLONGJMP(buf, val) longjmp((buf), (val)) #else diff --git a/tests/ae_sweep_prune.txt b/tests/ae_sweep_prune.txt index 0c6cdfa21..490b7b93d 100644 --- a/tests/ae_sweep_prune.txt +++ b/tests/ae_sweep_prune.txt @@ -177,6 +177,7 @@ tests/integration/tcp_poll_fullduplex/ tests/integration/transitive_module_import/ tests/integration/ufcs_cross_module/ tests/integration/varargs_forward/ +tests/integration/wasi_panic_link/ tests/integration/wasm_emit_lib_exports/ tests/integration/where_clause/ tests/integration/wycheproof/ diff --git a/tests/integration/wasi_panic_link/paniclib.ae b/tests/integration/wasi_panic_link/paniclib.ae new file mode 100644 index 000000000..70a2b14ad --- /dev/null +++ b/tests/integration/wasi_panic_link/paniclib.ae @@ -0,0 +1,22 @@ +// Library-shaped source that DRAGS THE PANIC MACHINERY INTO THE LINK. +// +// A library with no panic/try/catch never references aether_panic.o, so it +// links on wasi even when the setjmp selection is wrong -- which is exactly +// why the bug went unnoticed. `try` makes codegen emit AETHER_SIGSETJMP and +// `panic` pulls in AETHER_SIGLONGJMP, so both macros must resolve to +// something wasm-ld can find. + +risky(x: int) -> int { + if x < 0 { + panic("negative") + } + return x * 2 +} + +safe(x: int) -> int { + try { + return risky(x) + } catch reason { + return 0 + } +} diff --git a/tests/integration/wasi_panic_link/test_wasi_panic_link.sh b/tests/integration/wasi_panic_link/test_wasi_panic_link.sh new file mode 100755 index 000000000..de0ce0756 --- /dev/null +++ b/tests/integration/wasi_panic_link/test_wasi_panic_link.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# `--target=wasm32-wasi` links code that uses panic / try / catch. +# +# aether_panic.c guarded its crash handler with !defined(__wasi__), but +# aether_panic.h's setjmp MACRO SELECTION did not. WASI is hosted +# (__STDC_HOSTED__ == 1) and does not define __EMSCRIPTEN__, so it fell into +# 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 +# +# The fixture deliberately USES try/catch/panic: a wasi library without them +# links fine even with the selection wrong, because nothing references +# aether_panic.o. That is why this needs its own fixture rather than reusing +# cross_emit_lib's. +# +# Asserts: +# - the module links at all (the regression) +# - it is a real wasm binary +# - the exported functions are present +# - no _setjmp/_longjmp import survives into the module +# +# Skips without zig. Cost: ONE cross link (~90 TUs; there is no per-target +# archive cache), so this does exactly one and asserts everything on it. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" + +if [ ! -x "$AE" ]; then + echo " [SKIP] wasi_panic_link: ae not built" + exit 0 +fi +if ! command -v zig >/dev/null 2>&1; then + echo " [SKIP] wasi_panic_link: zig not on PATH" + exit 0 +fi + +TMPDIR_T="$(mktemp -d)" +cleanup() { rm -rf "$TMPDIR_T"; } +trap cleanup EXIT + +OUT="$TMPDIR_T/paniclib.wasm" +BUILD_LOG="$TMPDIR_T/build.log" + +if ! "$AE" build --target=wasm32-wasi --emit=lib \ + "$SCRIPT_DIR/paniclib.ae" -o "$OUT" >"$BUILD_LOG" 2>&1; then + echo " [FAIL] wasi_panic_link: cross build failed" + # The regression's signature, surfaced directly when it recurs. + if grep -q '_longjmp\|_setjmp' "$BUILD_LOG"; then + echo " setjmp selection regressed: aether_panic.h's macro" + echo " arms must special-case __wasi__ (see the header)." + fi + sed -n '1,20p' "$BUILD_LOG" + exit 1 +fi + +[ -f "$OUT" ] || { echo " [FAIL] wasi_panic_link: no output at $OUT"; exit 1; } + +# A real wasm module, not an empty file or a host artifact. +case "$(file -b "$OUT" 2>/dev/null)" in + *WebAssembly*) ;; + *) + echo " [FAIL] wasi_panic_link: not a wasm module:" + echo " $(file -b "$OUT" 2>/dev/null)" + exit 1 + ;; +esac + +# The functions must actually be exported -- a module that links but exports +# nothing would satisfy every check above and be useless. +for sym in aether_risky aether_safe; do + if ! strings "$OUT" | grep -q "$sym"; then + echo " [FAIL] wasi_panic_link: $sym missing from the module" + exit 1 + fi +done + +# Nothing should still be reaching for the unimplemented pair. +if strings "$OUT" | grep -qE '^_setjmp$|^_longjmp$'; then + echo " [FAIL] wasi_panic_link: module still references _setjmp/_longjmp" + exit 1 +fi + +echo " [PASS] wasi_panic_link: wasm32-wasi links panic/try/catch, exports present" From 1d188c6173f91d945d8d673c24810b686d7e8316 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 21:11:18 +0100 Subject: [PATCH 2/2] docs(changelog): record the wasi setjmp fix Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c69af064f..bf1613437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. +## [current] + +### Fixed + +- **`--target=wasm32-wasi` links code that uses `panic` / `try` / `catch`.** + `aether_panic.c` guarded its crash handler with `!defined(__wasi__)`, but + `aether_panic.h`'s setjmp macro selection did not: WASI is hosted and does + not define `__EMSCRIPTEN__`, so it fell into the POSIX arm and got + `_setjmp`/`_longjmp`, which wasi-libc declares but never implements. A link + error rather than a compile error, so it surfaced only at the end of a cross + build (`wasm-ld: undefined symbol: _longjmp`), and only for code that + actually reached the panic machinery — a library without `try`/`catch` linked + fine, which is why it went unnoticed. There is no working `setjmp` on wasi in + either spelling (plain `setjmp` is a hard `#error` in wasi-libc, and + `-mllvm -wasm-enable-sjlj` does not help on zig 0.16.0), so the wasi arm does + not unwind: `panic` traps the instance instead of unwinding to the nearest + `catch`. That semantic reduction is documented in `docs/build-system.md`. + Native targets are unaffected. + ## [0.576.0] ### Added