diff --git a/interfaces/Crafter.Build-Clang.cppm b/interfaces/Crafter.Build-Clang.cppm index 05fec40..ba60a0d 100644 --- a/interfaces/Crafter.Build-Clang.cppm +++ b/interfaces/Crafter.Build-Clang.cppm @@ -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 diff --git a/wasi-runtime/runtime.js b/wasi-runtime/runtime.js index b94b33f..d8dbef1 100644 --- a/wasi-runtime/runtime.js +++ b/wasi-runtime/runtime.js @@ -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; }