diff --git a/project.cpp b/project.cpp index fdc399c..bd0c0a6 100644 --- a/project.cpp +++ b/project.cpp @@ -266,6 +266,7 @@ extern "C" Configuration CrafterBuildProject(std::span 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() }); diff --git a/tests/WasiBrowserRuntimeExit/fixture/harness.mjs b/tests/WasiBrowserRuntimeExit/fixture/harness.mjs new file mode 100644 index 0000000..4e1cde9 --- /dev/null +++ b/tests/WasiBrowserRuntimeExit/fixture/harness.mjs @@ -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); diff --git a/tests/WasiBrowserRuntimeExit/main.cpp b/tests/WasiBrowserRuntimeExit/main.cpp new file mode 100644 index 0000000..4af52c8 --- /dev/null +++ b/tests/WasiBrowserRuntimeExit/main.cpp @@ -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; +}