Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion docs/build-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
36 changes: 35 additions & 1 deletion runtime/actors/aether_panic.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

#include <setjmp.h>
#include <stdatomic.h>
#include <stdlib.h> /* abort(), for the __wasi__ no-unwind arm below */
#include "../utils/aether_compiler.h"

#ifdef __cplusplus
Expand Down Expand Up @@ -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 <setjmp.h> 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
Expand Down
1 change: 1 addition & 0 deletions tests/ae_sweep_prune.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
22 changes: 22 additions & 0 deletions tests/integration/wasi_panic_link/paniclib.ae
Original file line number Diff line number Diff line change
@@ -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
}
}
88 changes: 88 additions & 0 deletions tests/integration/wasi_panic_link/test_wasi_panic_link.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading