test(wasi-runtime): exit and park, under node

runtime.js had no test that runs it. WasiBrowserRuntimeExit runs the
real file under node, once per scenario, against a module assembled in
the harness. Between _start and the call that ends it the module has
the frames that caught the old exit: a destructor (catch_all_ref, then
throw_ref), a catch (...) and a noexcept function, each reporting when
it runs. An exit has to run none of them and record its status; a park
has to run none of them, nor the code after it, and leave the program
callable with its state intact; an exit from a later call has to end
the program too, a return from main has to give status 0, and a crash
or an exception nothing caught has to stay an error.

With the JS throw put back into proc_exit, the exit from main and the
exit from a later call both end in the noexcept frame's terminate.

It skips without node, or with a node whose engine lacks the
standardized exception handling. The Flatpak has no node, so CI does
not run it; the native loop does.

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

View file

@ -266,6 +266,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("SysrootCompileFlags").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntimeExit").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmExceptions").Dependencies({ CrafterBuildLib.get() });

View file

@ -0,0 +1,167 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Runs wasi-runtime/runtime.js (argv[2], copied to a .mjs so that node loads
// it as a module) once per scenario against the module assembled below, and
// prints one line per scenario for the test to check. Between _start and the
// call that ends it, the module has the frames C++ built with
// -fwasm-exceptions puts there: a destructor (catch_all_ref, then
// throw_ref), a catch (...) (catch_all) and a noexcept function (catch_all,
// then std::terminate). Each reports through env.trace when it runs, and on
// the way out of an exit or a park none of them may.
import { pathToFileURL } from "node:url";
const uleb = (n) => {
const out = [];
do {
const byte = n & 0x7f;
n >>>= 7;
out.push(n ? byte | 0x80 : byte);
} while (n);
return out;
};
const sleb = (n) => {
const out = [];
for (;;) {
const byte = n & 0x7f;
n >>= 7;
const done = (n === 0 && !(byte & 0x40)) || (n === -1 && (byte & 0x40));
out.push(done ? byte : byte | 0x80);
if (done) return out;
}
};
const vec = (items) => [...uleb(items.length), ...items.flat()];
const name = (text) => vec([...text].map((c) => [c.charCodeAt(0)]));
const section = (id, items) => {
const bytes = vec(items);
return [id, ...uleb(bytes.length), ...bytes];
};
const UNREACHABLE = 0x00, BLOCK = 0x02, IF = 0x04, ELSE = 0x05, THROW = 0x08, THROW_REF = 0x0a, END = 0x0b;
const RETURN = 0x0f, CALL = 0x10, TRY_TABLE = 0x1f, LOCAL_GET = 0x20, LOCAL_SET = 0x21, GLOBAL_GET = 0x23;
const GLOBAL_SET = 0x24, I32_CONST = 0x41, I32_EQ = 0x46;
const I32 = 0x7f, EXNREF = 0x69, NO_RESULT = 0x40, FUNC_TYPE = 0x60, CATCH_ALL = 0x02, CATCH_ALL_REF = 0x03;
const code = (locals, instructions) => {
const bytes = [...vec(locals), ...instructions, END];
return [...uleb(bytes.length), ...bytes];
};
// Types, imported functions, then the module's own.
const TAKES_I32 = 0, NOTHING = 1, GIVES_I32 = 2;
const PROC_EXIT = 0, PARK = 1, SCENARIO = 2, TRACE = 3;
const END_WITH = 4, NOEXCEPT = 5, CATCHES_ALL = 6, CLEANS_UP = 7, START = 8, FRAME = 9, QUIT = 10;
// What env.trace reports.
const DESTRUCTOR = 1, CATCH = 2, TERMINATE = 3, AFTER_PARK = 4;
// What env.scenario selects in _start.
const EXIT = 1, PARK_MAIN = 2, THROW_UNCAUGHT = 4, CRASH = 5;
const PARK_CODE = -1;
const ifScenario = (scenario, instructions) => [LOCAL_GET, 0, I32_CONST, ...sleb(scenario), I32_EQ, IF, NO_RESULT, ...instructions, END];
const module = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
...section(1, [
[FUNC_TYPE, ...vec([[I32]]), ...vec([])],
[FUNC_TYPE, ...vec([]), ...vec([])],
[FUNC_TYPE, ...vec([]), ...vec([[I32]])],
]),
...section(2, [
[...name("wasi_snapshot_preview1"), ...name("proc_exit"), 0x00, TAKES_I32],
[...name("crafter_runtime"), ...name("park"), 0x00, NOTHING],
[...name("env"), ...name("scenario"), 0x00, GIVES_I32],
[...name("env"), ...name("trace"), 0x00, TAKES_I32],
]),
...section(3, [[TAKES_I32], [TAKES_I32], [TAKES_I32], [TAKES_I32], [NOTHING], [GIVES_I32], [TAKES_I32]]),
...section(5, [[0x00, 0x01]]),
// One tag, for the exception nothing catches.
...section(13, [[0x00, TAKES_I32]]),
...section(6, [[I32, 0x01, I32_CONST, 0x00, END]]),
...section(7, [
[...name("memory"), 0x02, 0],
[...name("_start"), 0x00, START],
[...name("frame"), 0x00, FRAME],
[...name("quit"), 0x00, QUIT],
]),
...section(10, [
// END_WITH(code): park for PARK_CODE, else proc_exit(code).
code([], [LOCAL_GET, 0, I32_CONST, ...sleb(PARK_CODE), I32_EQ, IF, NO_RESULT, CALL, PARK, ELSE, LOCAL_GET, 0, CALL, PROC_EXIT, END]),
// NOEXCEPT(code): anything thrown through it is std::terminate.
code([], [
BLOCK, NO_RESULT, TRY_TABLE, NO_RESULT, ...vec([[CATCH_ALL, 0]]), LOCAL_GET, 0, CALL, END_WITH, END, RETURN, END,
I32_CONST, TERMINATE, CALL, TRACE, UNREACHABLE,
]),
// CATCHES_ALL(code): try { NOEXCEPT(code); } catch (...) {}
code([], [
BLOCK, NO_RESULT, TRY_TABLE, NO_RESULT, ...vec([[CATCH_ALL, 0]]), LOCAL_GET, 0, CALL, NOEXCEPT, END, RETURN, END,
I32_CONST, CATCH, CALL, TRACE,
]),
// CLEANS_UP(code): a frame with a destructor around CATCHES_ALL(code).
code([[...uleb(1), EXNREF]], [
BLOCK, EXNREF, TRY_TABLE, NO_RESULT, ...vec([[CATCH_ALL_REF, 0]]), LOCAL_GET, 0, CALL, CATCHES_ALL, END, RETURN, END,
LOCAL_SET, 1, I32_CONST, DESTRUCTOR, CALL, TRACE, LOCAL_GET, 1, THROW_REF,
]),
// _start: what env.scenario says; anything else returns from main.
code([[...uleb(1), I32]], [
CALL, SCENARIO, LOCAL_SET, 0,
...ifScenario(EXIT, [I32_CONST, 3, CALL, CLEANS_UP]),
...ifScenario(PARK_MAIN, [I32_CONST, 42, GLOBAL_SET, 0, I32_CONST, ...sleb(PARK_CODE), CALL, CLEANS_UP, I32_CONST, AFTER_PARK, CALL, TRACE]),
...ifScenario(THROW_UNCAUGHT, [I32_CONST, 9, THROW, 0]),
...ifScenario(CRASH, [UNREACHABLE]),
]),
// frame(): the state _start left, as a later frame callback reads it.
code([], [GLOBAL_GET, 0]),
// quit(code): an exit from a later call, through the same frames.
code([], [LOCAL_GET, 0, CALL, CLEANS_UP]),
]),
]);
// The standardized encoding (try_table) is what the module and runtime.js
// both depend on; an older node has only the legacy one.
const exnrefProbe = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 10, 8, 1, 6, 0, 31, 64, 0, 11, 11]);
if (!WebAssembly.validate(exnrefProbe)) {
console.log("SKIP: this node has no WebAssembly exception handling");
process.exit(0);
}
if (!WebAssembly.validate(module)) {
console.log("FAIL: the fixture module does not validate");
process.exit(1);
}
// What runtime.js expects of a page: no variants.json or files.json, so it
// loads CRAFTER_WASM_URL.
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
window.CRAFTER_WASM_URL = "fixture.wasm";
globalThis.fetch = async (url) => {
if (String(url).split("?")[0] === "fixture.wasm") return new Response(module, { headers: { "Content-Type": "application/wasm" } });
return new Response(null, { status: 404 });
};
const runtime = pathToFileURL(process.argv[2]).href;
const run = async (label, scenario, after = () => "") => {
const trace = [];
window.crafter_webbuild_env = { scenario: () => scenario, trace: (what) => { trace.push(what); } };
delete window.crafter_wasi;
let outcome = "ended";
try {
// A query makes it a module of its own, run afresh.
await import(`${runtime}?scenario=${scenario}`);
} catch (e) {
outcome = `rejected with ${e?.constructor?.name}`;
}
const wasi = window.crafter_wasi;
console.log(`${label}: ${outcome}, exitCode=${wasi?.exitCode} parked=${wasi?.parked} trace=[${trace}]${after(wasi, trace)}`);
};
await run("exit", EXIT);
await run("park", PARK_MAIN, (wasi, trace) => {
const frame = wasi.instance.exports.frame();
let quit = "returned";
try {
wasi.instance.exports.quit(7);
} catch (e) {
quit = `threw ${e.constructor.name}`;
}
return `; frame()=${frame}; quit(7) ${quit}, exitCode=${wasi.exitCode} trace=[${trace}]`;
});
await run("return", 3);
await run("uncaught exception", THROW_UNCAUGHT);
await run("crash", CRASH);

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// The browser runtime has to end a call into the program without running any
// more of it: when the program exits (exit, _Exit, a nonzero return from
// main), and when it parks to hand the thread to the browser for good, which
// Crafter::Window::StartSync does on DOM. runtime.js used to throw a
// JavaScript error out of proc_exit, and a program built with
// -fwasm-exceptions catches that. An exit then ran the destructors of every
// frame it left, a catch (...) swallowed it and carried on, and a noexcept
// function turned it into std::terminate, while the page went on calling
// into a program whose state those destructors had torn down.
//
// fixture/harness.mjs runs the real runtime.js under node against a module
// with the frames that catch between _start and the call that ends it: a
// destructor, a catch (...) and a noexcept function, each reporting when it
// runs. It checks that an exit runs none of them and records its status;
// that a park runs none of them either, nor the code after it, and leaves the
// program callable with its state intact; that an exit from a later call
// ends the program too; that returning from main ends it with status 0; and
// that a crash or an exception nothing caught still surfaces as an error.
// Skips without node, or with a node whose engine lacks exception handling.
// The Flatpak has no node, so this one runs in the native loop.
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
}
int main() {
if (std::system("which node > /dev/null 2>&1") != 0) {
std::println(std::cerr, "SKIP: node not on PATH");
return 77;
}
const fs::path scratch = fs::temp_directory_path() / "crafter-build-wasi-browser-runtime-exit";
fs::remove_all(scratch);
fs::create_directories(scratch);
// node runs a .js file as a CommonJS script, which cannot await at its
// top level the way runtime.js does.
const fs::path runtime = scratch / "runtime.mjs";
fs::copy_file(fs::current_path() / "wasi-runtime" / "runtime.js", runtime);
const fs::path harness = fs::current_path() / "tests" / "WasiBrowserRuntimeExit" / "fixture" / "harness.mjs";
const auto run = RunCommandWithTimeout(std::format("node {} {}", ShellQuote(harness.string()), ShellQuote(runtime.string())), std::chrono::seconds(60));
fs::remove_all(scratch);
if (run.output.contains("SKIP:")) {
std::println(std::cerr, "{}", run.output);
return 77;
}
Check(run.exitCode == 0, std::format("the harness ran to the end (exit {})", run.exitCode));
Check(run.output.contains("exit: ended, exitCode=3 parked=false trace=[]"), "an exit runs no destructor, catch (...) or terminate, and its status is recorded");
Check(run.output.contains("park: ended, exitCode=null parked=true trace=[]; frame()=42"), "a park runs none of them nor the code after it, and the program stays callable with its state");
Check(run.output.contains("quit(7) threw RuntimeError, exitCode=7 trace=[]"), "an exit from a later call ends the program the same way");
Check(run.output.contains("return: ended, exitCode=0 parked=false trace=[]"), "returning from main ends the program with status 0");
Check(run.output.contains("uncaught exception: rejected with Exception, exitCode=null"), "an exception nothing caught is not taken for an exit");
Check(run.output.contains("crash: rejected with RuntimeError, exitCode=null"), "a crash is not taken for an exit");
if (failures > 0) {
std::println(std::cerr, "{} assertions failed; harness output:\n{}", failures, run.output);
return 1;
}
return 0;
}