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:
parent
0dabce0b74
commit
fec206839e
3 changed files with 243 additions and 0 deletions
|
|
@ -266,6 +266,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.AddTest("SysrootCompileFlags").Dependencies({ CrafterBuildLib.get() });
|
cfg.AddTest("SysrootCompileFlags").Dependencies({ CrafterBuildLib.get() });
|
||||||
cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() });
|
cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() });
|
||||||
cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() });
|
cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() });
|
||||||
|
cfg.AddTest("WasiBrowserRuntimeExit").Dependencies({ CrafterBuildLib.get() });
|
||||||
cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
|
cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
|
||||||
cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() });
|
cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() });
|
||||||
cfg.AddTest("WasmExceptions").Dependencies({ CrafterBuildLib.get() });
|
cfg.AddTest("WasmExceptions").Dependencies({ CrafterBuildLib.get() });
|
||||||
|
|
|
||||||
167
tests/WasiBrowserRuntimeExit/fixture/harness.mjs
Normal file
167
tests/WasiBrowserRuntimeExit/fixture/harness.mjs
Normal 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);
|
||||||
75
tests/WasiBrowserRuntimeExit/main.cpp
Normal file
75
tests/WasiBrowserRuntimeExit/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue