diff --git a/apps/browser-demos/playwright.config.ts b/apps/browser-demos/playwright.config.ts
index 1c0e52765..ae9d2aff8 100644
--- a/apps/browser-demos/playwright.config.ts
+++ b/apps/browser-demos/playwright.config.ts
@@ -26,12 +26,16 @@ export default defineConfig({
},
{
name: "firefox",
- testMatch: "coi.spec.ts",
+ testMatch: ["coi.spec.ts", "wasm-trap-signal.spec.ts"],
use: { browserName: "firefox" },
},
{
name: "webkit",
- testMatch: ["coi.spec.ts", "kandelo-webkit-smoke.spec.ts"],
+ testMatch: [
+ "coi.spec.ts",
+ "kandelo-webkit-smoke.spec.ts",
+ "wasm-trap-signal.spec.ts",
+ ],
use: { browserName: "webkit" },
},
],
diff --git a/apps/browser-demos/test/wasm-trap-signal.spec.ts b/apps/browser-demos/test/wasm-trap-signal.spec.ts
new file mode 100644
index 000000000..ad29da6ac
--- /dev/null
+++ b/apps/browser-demos/test/wasm-trap-signal.spec.ts
@@ -0,0 +1,94 @@
+import { expect, test } from "@playwright/test";
+import {
+ classifyWasmCrashSignal,
+ SIGFPE,
+ SIGILL,
+ SIGSEGV,
+} from "../../../host/src/trap-signals";
+
+const TRAP_MODULES = [
+ {
+ name: "memory-oob",
+ expectedSignum: SIGSEGV,
+ bytes: [
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 5, 3,
+ 1, 0, 1, 7, 7, 1, 3, 114, 117, 110, 0, 0, 10, 12, 1, 10, 0, 65,
+ 128, 128, 4, 40, 2, 0, 26, 11,
+ ],
+ },
+ {
+ name: "divide-by-zero",
+ expectedSignum: SIGFPE,
+ bytes: [
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 7,
+ 1, 3, 114, 117, 110, 0, 0, 10, 10, 1, 8, 0, 65, 1, 65, 0, 109, 26,
+ 11,
+ ],
+ },
+ {
+ name: "unreachable",
+ expectedSignum: SIGILL,
+ bytes: [
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 7,
+ 1, 3, 114, 117, 110, 0, 0, 10, 5, 1, 3, 0, 0, 11,
+ ],
+ },
+ {
+ name: "indirect-null-table-entry",
+ expectedSignum: SIGILL,
+ bytes: [
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 4, 4,
+ 1, 112, 0, 1, 7, 7, 1, 3, 114, 117, 110, 0, 0, 10, 9, 1, 7, 0, 65,
+ 0, 17, 0, 0, 11,
+ ],
+ },
+ {
+ name: "indirect-table-oob",
+ expectedSignum: SIGSEGV,
+ bytes: [
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2,
+ 1, 0, 4, 4, 1, 112, 0, 1, 7, 7, 1, 3, 114, 117, 110, 0,
+ 0, 10, 9, 1, 7, 0, 65, 1, 17, 0, 0, 11,
+ ],
+ },
+] as const;
+
+test("Wasm trap messages classify to POSIX signals in this browser engine", async ({
+ page,
+ browserName,
+}) => {
+ await page.setContent("
wasm trap signal test");
+
+ const messages = await page.evaluate(async (fixtures) => {
+ const results: Record = {};
+ for (const fixture of fixtures) {
+ const { instance } = await WebAssembly.instantiate(new Uint8Array(fixture.bytes));
+ const run = instance.exports.run;
+ if (typeof run !== "function") {
+ throw new Error(`fixture ${fixture.name} did not export run()`);
+ }
+ try {
+ run();
+ results[fixture.name] = "NO_TRAP";
+ } catch (err) {
+ if (err instanceof Error) {
+ results[fixture.name] = `${err.name}: ${err.message}`;
+ } else {
+ results[fixture.name] = String(err);
+ }
+ }
+ }
+ return results;
+ }, TRAP_MODULES);
+
+ for (const fixture of TRAP_MODULES) {
+ const message = messages[fixture.name];
+ expect(message, `${browserName} ${fixture.name}`).toBeDefined();
+ expect(message, `${browserName} ${fixture.name}`).not.toBe("NO_TRAP");
+ const classification = classifyWasmCrashSignal(message);
+ expect(
+ classification?.signum,
+ `${browserName} ${fixture.name}: ${message}`,
+ ).toBe(fixture.expectedSignum);
+ }
+});
diff --git a/examples/divzero_trap_test.c b/examples/divzero_trap_test.c
new file mode 100644
index 000000000..36f85c967
--- /dev/null
+++ b/examples/divzero_trap_test.c
@@ -0,0 +1,15 @@
+/*
+ * Deliberately trigger a Wasm integer divide-by-zero trap.
+ */
+#include
+
+int main(void)
+{
+ fprintf(stderr, "before-divzero\n");
+ fflush(stderr);
+ volatile int numerator = 1;
+ volatile int denominator = 0;
+ volatile int value = numerator / denominator;
+ fprintf(stderr, "after-divzero-SHOULD-NEVER-REACH:%d\n", value);
+ return 1;
+}
diff --git a/examples/oob_trap_test.c b/examples/oob_trap_test.c
new file mode 100644
index 000000000..253917d6b
--- /dev/null
+++ b/examples/oob_trap_test.c
@@ -0,0 +1,16 @@
+/*
+ * Deliberately dereference an address outside the configured wasm32
+ * linear-memory maximum so the engine raises a memory OOB trap.
+ */
+#include
+#include
+
+int main(void)
+{
+ fprintf(stderr, "before-oob\n");
+ fflush(stderr);
+ volatile uint32_t *p = (volatile uint32_t *)(uintptr_t)0x7fffffffU;
+ volatile uint32_t value = *p;
+ fprintf(stderr, "after-oob-SHOULD-NEVER-REACH:%u\n", value);
+ return 1;
+}
diff --git a/examples/wasm_trap_test.c b/examples/wasm_trap_test.c
index 0ae0f9b33..bfc5966cc 100644
--- a/examples/wasm_trap_test.c
+++ b/examples/wasm_trap_test.c
@@ -13,8 +13,8 @@
* promise never resolves and tests time out.
*
* With the fix, this program terminates promptly and `spawn()`
- * resolves. Expected exit code is 0 because worker-main treats
- * `unreachable` as the normal `_Exit` exit pattern.
+ * resolves. An arbitrary user-space `unreachable` is classified as
+ * SIGILL; only the known kernel_exit path is treated as normal exit.
*/
#include
diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts
index 41d81dc73..04ee996cb 100644
--- a/host/src/browser-kernel-worker-entry.ts
+++ b/host/src/browser-kernel-worker-entry.ts
@@ -80,6 +80,12 @@ import { TlsNetworkBackend } from "./networking/tls-network-backend";
import { patchWasmForThread } from "./worker-main";
import { detectPtrWidth, extractHeapBase } from "./constants";
import { ThreadExitCoordinator } from "./thread-exit-coordinator";
+import {
+ classifiedSignalOrFallback,
+ classifiedTrapExitStatus,
+ signalExitStatus,
+ SIGSEGV,
+} from "./trap-signals";
import type {
CentralizedWorkerInitMessage,
CentralizedThreadInitMessage,
@@ -737,7 +743,7 @@ function installProcessWorkerListeners(
pid: number,
): void {
let exited = false;
- const finalize = (status: number, source: string) => {
+ const finalize = (status: number, source: string, crashSignum?: number) => {
if (exited) return;
exited = true;
if (processes.get(pid)?.worker !== worker) return; // already replaced (e.g. by exec)
@@ -747,19 +753,20 @@ function installProcessWorkerListeners(
} else {
console.warn(message);
}
- handleExit(pid, status);
+ handleExit(pid, status, crashSignum);
};
- // Status conventions match the Node host (PR #410):
- // 139 = 128 + SIGSEGV (11) — uncaught wasm trap, worker died without
- // a SYS_exit_group. Matches a Linux child killed by SIGSEGV.
- // -1 — worker-main caught an instantiation/init error and posted
- // {type:"error"}. Same convention as Node-side handleSpawn.
+ // Status conventions match the Node host:
+ // 128+signal — classified Wasm trap, or generic SIGSEGV when a worker
+ // dies without a trap reason. Matches Linux signal-death status.
+ // -1 — worker-main caught an unclassified instantiation/init error
+ // and posted {type:"error"}.
// m.status — worker-main posted {type:"exit"}, normal exit path.
worker.on("error", (err: Error) => {
if (intentionallyTerminated.has(worker as object)) return;
console.error(`[kernel-worker] Worker error pid=${pid}:`, err.message);
- finalize(128 + 11, "worker.onerror");
+ const signum = classifiedSignalOrFallback(err);
+ finalize(signalExitStatus(signum), "worker.onerror", signum);
});
worker.on("exit", (code: number) => {
// BrowserWorkerHandle synthesizes an "exit" event when the underlying
@@ -773,7 +780,7 @@ function installProcessWorkerListeners(
// finalized via the "error" handler above, so this is a no-op there.
// If "exit" fires without a prior "error" (worker died via some path
// we don't yet model), still treat it as a crash.
- finalize(128 + 11, "worker exit event");
+ finalize(signalExitStatus(SIGSEGV), "worker exit event", SIGSEGV);
});
worker.on("message", (msg: unknown) => {
const m = msg as { type?: string; message?: string; pid?: number; status?: number };
@@ -789,7 +796,8 @@ function installProcessWorkerListeners(
`[process-worker] ${m.message ?? "unknown error"}\n`,
);
post({ type: "stderr", pid, data: errBytes });
- finalize(-1, "worker-main error message");
+ const signum = classifiedSignalOrFallback(m.message);
+ finalize(classifiedTrapExitStatus(m.message) ?? -1, "worker-main error message", signum);
} else if (m.type === "exit") {
finalize(m.status ?? 0, "worker-main exit message");
}
@@ -1189,11 +1197,19 @@ function handleThreadExit(pid: number, channelOffset: number): boolean {
return threadExits.requestExit(pid, channelOffset);
}
-function handleExit(pid: number, exitStatus: number): void {
- void finishProcessExit(pid, exitStatus);
+function signalFromExitStatus(exitStatus: number): number | null {
+ return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null;
+}
+
+function handleExit(pid: number, exitStatus: number, crashSignum?: number): void {
+ void finishProcessExit(pid, exitStatus, crashSignum);
}
-async function finishProcessExit(pid: number, exitStatus: number): Promise {
+async function finishProcessExit(
+ pid: number,
+ exitStatus: number,
+ crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV,
+): Promise {
if (processTeardowns.has(pid)) return;
const info = processes.get(pid);
@@ -1207,7 +1223,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise
threadedProcessPids.delete(pid);
const teardown = (async () => {
- // Synthesize a SIGSEGV-style reap *before* `deactivateProcess` in
+ // Synthesize a signal-style reap *before* `deactivateProcess` in
// case the worker died without sending SYS_EXIT_GROUP (uncaught
// wasm trap -> onerror, worker-main `{type:"error"}` -> finalize(-1),
// externally terminated Worker -> "exit" event). Without this, a
@@ -1216,7 +1232,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise
// `hostReaped`: when the kernel already processed a clean
// SYS_EXIT_GROUP for this pid, this is a no-op. Mirrors
// `finalizeProcessWorker` in host/src/node-kernel-worker-entry.ts.
- try { kernelWorker.notifyHostProcessCrashed(pid); } catch { /* best-effort */ }
+ try { kernelWorker.notifyHostProcessCrashed(pid, crashSignum); } catch { /* best-effort */ }
// Check if this is a "top-level" process or a fork child
// For now, always deactivate — the main thread tracks exit promises
kernelWorker.deactivateProcess(pid);
diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts
index aafd081f5..6d1bbe949 100644
--- a/host/src/node-kernel-worker-entry.ts
+++ b/host/src/node-kernel-worker-entry.ts
@@ -39,6 +39,12 @@ import { patchWasmForThread } from "./worker-main";
import { ThreadExitCoordinator } from "./thread-exit-coordinator";
import { detectPtrWidth, extractHeapBase } from "./constants";
import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD, WASM_PAGE_SIZE } from "./constants";
+import {
+ classifiedSignalOrFallback,
+ classifiedTrapExitStatus,
+ signalExitStatus,
+ SIGSEGV,
+} from "./trap-signals";
import {
computeProcessMemoryLayout,
createProcessMemory,
@@ -107,9 +113,9 @@ const intentionallyTerminated = new WeakSet