fix(wasi-runtime): end exit and park without running the program

runtime.js implemented proc_exit by throwing a JavaScript error through
the wasm stack, and the switch to -fwasm-exceptions made that error
catchable: every cleanup pad, catch (...) and noexcept terminate pad
catches a foreign exception. An exit therefore ran the destructors of
every frame it left, a catch (...) swallowed it and carried on, and
under a noexcept frame it ended in std::terminate. Crafter.Graphics
calls std::_Exit(0) in Window::StartSync to hand the thread to
requestAnimationFrame; once the exit's destructors had torn down
main's locals, every later frame read destroyed state.

proc_exit is now a function of a small helper module, bound directly
as the program's import, that reports to the runtime and then executes
unreachable. A trap is the one thing wasm code cannot catch, and raised
in wasm rather than from JS it does not depend on each engine keeping
a trap uncatchable when it passes back through a JS frame. Nothing of
the program runs on the way out and its shadow stack stays as it was,
which is what exit and _Exit require. window.crafter_wasi.exitCode
holds the status, also after a return from main, so that bridges which
call into the program on their own can stop.

Handing the thread to the browser for good is a separate import,
crafter_runtime.park. It ends the call the same way but leaves the
program alive, and main's locals valid for the calls that follow, so
StartSync keeps its contract that no caller code after it runs. Until
Crafter.Graphics moves to park, its _Exit behaves as before minus the
destructors: the runtime records an exit but cannot stop a bridge that
calls exports itself.

A WASI reactor build, where main returns and state lives in statics,
was the alternative. It breaks StartSync's promise never to return,
which the native targets rely on. JSPI would keep the whole stack
alive, but Safari does not have it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 05:55:21 +01:00
commit 0dabce0b74
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 76 additions and 24 deletions

View file

@ -737,7 +737,11 @@ export namespace Crafter {
// open it). Opt-in: WASI builds destined for wasmtime/wasmer don't need
// this and shouldn't carry the extra files. Call from project.cpp after
// outputName is set; index.html is generated against the current
// outputName so renaming the binary later requires another call.
// outputName so renaming the binary later requires another call. In that
// runtime exit and _Exit end the program without running any more of it;
// a program that hands the thread to the browser and keeps serving calls
// parks instead, through the crafter_runtime.park import (runtime.js,
// PROGRAM_END).
void EnableWasiBrowserRuntime(Configuration& cfg);
// Register the relaxed-SIMD codegen variant on cfg. Builds an additional

View file

@ -176,11 +176,53 @@ function markDirty(path) {
scheduleFlush();
}
// ─── leaving the program ───────────────────────────────────────────────
// exit and _Exit (proc_exit) end the program. park hands the thread to the
// browser for good while the program goes on serving the calls bridges make
// into it (Crafter::Window::StartSync on DOM): the import
// crafter_runtime.park, `void park(void)`, which never returns. Either way
// the current call into the program has to end without running any more of
// its code: not the destructors of the frames it leaves, not a catch (...),
// not the std::terminate of a noexcept function. Built with
// -fwasm-exceptions, every one of those catches a JavaScript exception
// thrown through it, so the way out cannot be a throw from JS. A trap is
// the one thing wasm code cannot catch, and this module raises it in wasm
// itself, bound directly as the program's import; a trap raised from JS
// would depend on each engine keeping it uncatchable when it passes back
// into wasm. It reports first, through host.ended, which has returned by
// the time it traps:
// (import "host" "ended" (func $ended (param $how i32) (param $code i32)))
// (func (export "exit") (param $code i32)
// i32.const 1 local.get $code call $ended unreachable)
// (func (export "park")
// i32.const 2 i32.const 0 call $ended unreachable)
// The program's own state is left as it is, its shadow stack included, so
// the locals of a parked main stay valid for the calls that follow.
const PROGRAM_END = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x0d, 0x03, 0x60, 0x02, 0x7f, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x00, 0x00,
0x02, 0x0e, 0x01, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x05, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x00, 0x00,
0x03, 0x03, 0x02, 0x01, 0x02,
0x07, 0x0f, 0x02, 0x04, 0x65, 0x78, 0x69, 0x74, 0x00, 0x01, 0x04, 0x70, 0x61, 0x72, 0x6b, 0x00, 0x02,
0x0a, 0x15, 0x02,
0x09, 0x00, 0x41, 0x01, 0x20, 0x00, 0x10, 0x00, 0x00, 0x0b,
0x09, 0x00, 0x41, 0x02, 0x41, 0x00, 0x10, 0x00, 0x00, 0x0b,
]);
const ENDED_BY_EXIT = 1;
const ENDED_BY_PARK = 2;
class Wasi {
#encodedStdin;
#envEncodedStrings;
#argEncodedStrings;
instance;
// The program's exit status once it has ended, through exit or _Exit or
// by returning from main; null while it runs or is parked. A bridge that
// calls into the program on its own, such as a frame loop, stops once
// this is set: nothing of an ended program may run.
exitCode = null;
// True once the program has parked (see PROGRAM_END).
parked = false;
#fdTable;
#nextFd;
#decoder;
@ -194,6 +236,27 @@ class Wasi {
this.#nextFd = 100; // stay clear of stdio + preopens
this.#decoder = new TextDecoder();
this.bind();
const end = new WebAssembly.Instance(new WebAssembly.Module(PROGRAM_END), {
host: { ended: (how, code) => this.ended(how, code) },
}).exports;
// Own properties, so instantiation takes these wasm functions as the
// program's imports rather than a JS method.
this.proc_exit = end.exit;
this.park = end.park;
}
// Called by PROGRAM_END just before its trap ends the call into the
// program. An exit after a park ends the program all the same.
ended(how, code) {
if (how === ENDED_BY_PARK) {
this.parked = true;
} else if (how === ENDED_BY_EXIT) {
console.log(`[wasi] proc_exit(${code})`);
this.exitCode = code;
// The last chance for anything written this session to reach
// OPFS before the page is torn down.
opfsFlushAll();
}
}
bind() {
@ -805,22 +868,6 @@ class Wasi {
sizePointerBuffer[0] = envByteLength;
return 0;
}
proc_exit(code) {
// Throw a sentinel so the wasm stack unwinds back to runtime.js
// WITHOUT executing the `unreachable` instruction wasi-libc emits
// after __wasi_proc_exit (which is declared noreturn). Trapping
// there would mark the wasm call as crashed and may interrupt any
// browser-side render loop that called _Exit on purpose (e.g.
// Crafter::Window::StartSync on DOM, which hands the loop to rAF
// and then exits main without running static destructors).
console.log(`[wasi] proc_exit(${code})`);
// Trigger a final flush so anything written this session has the
// best chance of reaching OPFS before the page is torn down.
opfsFlushAll();
const e = new Error(`wasi proc_exit(${code})`);
e.crafterWasiExit = code;
throw e;
}
}
// ─── wasm feature-detect probes ────────────────────────────────────────
@ -980,6 +1027,7 @@ if (window.crafter_webbuild_env_ready) {
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {
wasi_snapshot_preview1: wasi,
env: window.crafter_webbuild_env,
crafter_runtime: { park: wasi.park },
});
wasi.instance = instance;
// Alias under both names — `crafter_wasi` is the canonical, but the older
@ -990,12 +1038,12 @@ window.crafter_webbuild_wasi = wasi;
try {
instance.exports._start();
// main returned 0; any other status reaches proc_exit.
wasi.exitCode = 0;
opfsFlushAll();
} catch (e) {
if (e && typeof e.crafterWasiExit === "number") {
// Clean exit from std::_Exit / __wasi_proc_exit. The wasm instance
// remains valid; any rAF callback registered before exit keeps
// running. (See Crafter::Window::StartSync DOM impl.)
} else {
throw e;
}
// proc_exit and park end _start with PROGRAM_END's trap after saying
// which they were. Any other error, a crash or a C++ exception that
// nothing caught, is the program failing.
if (wasi.exitCode === null && !wasi.parked) throw e;
}