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(); * the host's spawn promise would hang waiting for an exit notification * that never comes. This listener detects that case — when the worker we * registered here is *still* the one bound to `pid` in `processes` and we - * didn't terminate it ourselves — and synthesizes a crash exit so the - * host learns the process is gone. Encoded as 128+SIGSEGV (139) by - * convention for "killed by signal 11". + * didn't terminate it ourselves — and synthesizes a SIGSEGV crash exit + * so the host learns the process is gone. There is no reliable trap + * reason on this path, so it keeps the generic 128+SIGSEGV convention. */ function installCrashSafetyNet( worker: ReturnType, @@ -123,7 +129,7 @@ function installCrashSafetyNet( `[process-worker] pid=${pid} crashed (worker exit code=${code}, no SYS_exit_group from wasm)\n`, ); post({ type: "stderr", pid, data: errBytes }); - void finalizeProcessWorker(pid, worker, 128 + 11 /* SIGSEGV */); + void finalizeProcessWorker(pid, worker, signalExitStatus(SIGSEGV), SIGSEGV); }); } @@ -170,6 +176,10 @@ function reportProcessExit(pid: number, status: number): void { post({ type: "exit", pid, status }); } +function signalFromExitStatus(exitStatus: number): number | null { + return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null; +} + // PTY index per-PID const ptyByPid = new Map(); @@ -198,17 +208,18 @@ async function finalizeProcessWorker( pid: number, worker: ReturnType, exitStatus: number, + crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV, ): Promise { const cur = processes.get(pid); if (cur && cur.worker === worker) { - // 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, instantiation failure → `{type:"error"}` path). // Without this, a concurrent waitpid in the parent blocks until // destroy because the kernel never marked the child as a zombie. // Idempotent via `hostReaped`: when the kernel already processed // a clean SYS_EXIT_GROUP for this pid, this is a no-op. - try { kernelWorker.notifyHostProcessCrashed(pid); } catch { /* best-effort */ } + try { kernelWorker.notifyHostProcessCrashed(pid, crashSignum); } catch { /* best-effort */ } try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } processes.delete(pid); threadModuleCache.delete(pid); @@ -219,6 +230,48 @@ async function finalizeProcessWorker( reportProcessExit(pid, exitStatus); } +function processWorkerErrorDisposition(reason: string | undefined): { + exitStatus: number; + signum: number; +} { + return { + exitStatus: classifiedTrapExitStatus(reason) ?? -1, + signum: classifiedSignalOrFallback(reason), + }; +} + +function unexpectedWorkerCrashDisposition(reason: unknown): { + exitStatus: number; + signum: number; +} { + const signum = classifiedSignalOrFallback(reason); + return { exitStatus: signalExitStatus(signum), signum }; +} + +function finalizeProcessWorkerError( + pid: number, + worker: ReturnType, + message: string | undefined, +): void { + const errBytes = new TextEncoder().encode(`[process-worker] ${message ?? "unknown error"}\n`); + post({ type: "stderr", pid, data: errBytes }); + const { exitStatus, signum } = processWorkerErrorDisposition(message); + void finalizeProcessWorker(pid, worker, exitStatus, signum); +} + +function finalizeUnexpectedWorkerError( + pid: number, + worker: ReturnType, + label: string, + err: unknown, +): void { + const message = err instanceof Error ? (err.message ?? String(err)) : String(err); + const errBytes = new TextEncoder().encode(`[kernel-worker] pid=${pid}: ${label}: ${message}\n`); + post({ type: "stderr", pid, data: errBytes }); + const { exitStatus, signum } = unexpectedWorkerCrashDisposition(err); + void finalizeProcessWorker(pid, worker, exitStatus, signum); +} + function post(msg: KernelToMainMessage) { port.postMessage(msg); } @@ -540,20 +593,6 @@ async function handleInit(msg: InitMessage) { post({ type: "ready" }); } -// Init-time failure path. centralizedWorkerMain catches its own throws and -// posts {type:"error"} — without surfacing those, spawn() hangs on exitResolvers. -function failProcess(pid: number, reason: string) { - const text = `[kernel-worker] pid=${pid}: ${reason}\n`; - post({ type: "stderr", pid, data: new TextEncoder().encode(text) }); - try { kernelWorker.deactivateProcess(pid); } catch {} - const info = processes.get(pid); - info?.worker.terminate().catch(() => {}); - processes.delete(pid); - threadModuleCache.delete(pid); - ptyByPid.delete(pid); - reportProcessExit(pid, -1); -} - // --- Spawn --- function handleSpawn(msg: SpawnMessage) { @@ -633,11 +672,7 @@ function handleSpawn(msg: SpawnMessage) { threadAllocator, }); - worker.on("error", (err: Error) => failProcess(pid, `worker error: ${err.message ?? err}`)); - worker.on("message", (m: unknown) => { - const wmsg = m as WorkerToHostMessage; - if (wmsg.type === "error") failProcess(pid, wmsg.message); - }); + worker.on("error", (err: Error) => finalizeUnexpectedWorkerError(pid, worker, "worker error", err)); // Process-worker top-level catch in worker-main posts {type:"error"} // for instantiation failures (ABI mismatch, link errors). Without @@ -647,9 +682,7 @@ function handleSpawn(msg: SpawnMessage) { worker.on("message", (raw: unknown) => { const m = raw as { type: string; pid?: number; message?: string; status?: number }; if (m.type === "error" && m.pid === pid) { - const errBytes = new TextEncoder().encode(`[process-worker] ${m.message ?? "unknown error"}\n`); - post({ type: "stderr", pid, data: errBytes }); - void finalizeProcessWorker(pid, worker, -1); + finalizeProcessWorkerError(pid, worker, m.message); } else if (m.type === "exit" && m.pid === pid) { // worker-main posts {type:"exit"} when _start returns or hits an // "unreachable" trap (the latter is treated as normal _Exit). If @@ -737,18 +770,12 @@ async function handleFork( threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), }); - childWorker.on("error", (err: Error) => failProcess(childPid, `worker error: ${err.message ?? err}`)); - childWorker.on("message", (m: unknown) => { - const wmsg = m as WorkerToHostMessage; - if (wmsg.type === "error") failProcess(childPid, wmsg.message); - }); + childWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(childPid, childWorker, "worker error", err)); childWorker.on("message", (raw: unknown) => { const m = raw as { type: string; pid?: number; message?: string; status?: number }; if (m.type === "error" && m.pid === childPid) { - const errBytes = new TextEncoder().encode(`[process-worker] ${m.message ?? "unknown error"}\n`); - post({ type: "stderr", pid: childPid, data: errBytes }); - void finalizeProcessWorker(childPid, childWorker, -1); + finalizeProcessWorkerError(childPid, childWorker, m.message); } else if (m.type === "exit" && m.pid === childPid) { void finalizeProcessWorker(childPid, childWorker, m.status ?? 0); } @@ -827,11 +854,7 @@ async function handleExec( threadAllocator: newThreadAllocator, }); - newWorker.on("error", (err: Error) => failProcess(pid, `exec worker error: ${err.message ?? err}`)); - newWorker.on("message", (m: unknown) => { - const wmsg = m as WorkerToHostMessage; - if (wmsg.type === "error") failProcess(pid, wmsg.message); - }); + newWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(pid, newWorker, "exec worker error", err)); // Forward worker-main top-level errors (instantiation failures, // uncaught wasm traps) so the host learns the process died — same @@ -839,9 +862,7 @@ async function handleExec( newWorker.on("message", (raw: unknown) => { const m = raw as { type: string; pid?: number; message?: string; status?: number }; if (m.type === "error" && m.pid === pid) { - const errBytes = new TextEncoder().encode(`[process-worker] ${m.message ?? "unknown error"}\n`); - post({ type: "stderr", pid, data: errBytes }); - void finalizeProcessWorker(pid, newWorker, -1); + finalizeProcessWorkerError(pid, newWorker, m.message); } else if (m.type === "exit" && m.pid === pid) { void finalizeProcessWorker(pid, newWorker, m.status ?? 0); } @@ -942,18 +963,12 @@ async function handlePosixSpawn( threadAllocator, }); - newWorker.on("error", (err: Error) => failProcess(childPid, `spawn worker error: ${err.message ?? err}`)); - newWorker.on("message", (m: unknown) => { - const wmsg = m as WorkerToHostMessage; - if (wmsg.type === "error") failProcess(childPid, wmsg.message); - }); + newWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(childPid, newWorker, "spawn worker error", err)); newWorker.on("message", (raw: unknown) => { const m = raw as { type: string; pid?: number; message?: string; status?: number }; if (m.type === "error" && m.pid === childPid) { - const errBytes = new TextEncoder().encode(`[process-worker] ${m.message ?? "unknown error"}\n`); - post({ type: "stderr", pid: childPid, data: errBytes }); - void finalizeProcessWorker(childPid, newWorker, -1); + finalizeProcessWorkerError(childPid, newWorker, m.message); } else if (m.type === "exit" && m.pid === childPid) { void finalizeProcessWorker(childPid, newWorker, m.status ?? 0); } diff --git a/host/src/trap-signals.ts b/host/src/trap-signals.ts new file mode 100644 index 000000000..eb5a204c3 --- /dev/null +++ b/host/src/trap-signals.ts @@ -0,0 +1,138 @@ +export const SIGILL = 4; +export const SIGFPE = 8; +export const SIGSEGV = 11; + +export type WasmCrashCategory = + | "memory" + | "bounds" + | "stack" + | "arithmetic" + | "illegal-instruction"; + +export interface WasmCrashSignalClassification { + category: WasmCrashCategory; + signum: number; + signalName: "SIGILL" | "SIGFPE" | "SIGSEGV"; + matched: string; +} + +interface TrapPattern { + category: WasmCrashCategory; + signum: number; + signalName: WasmCrashSignalClassification["signalName"]; + patterns: RegExp[]; +} + +const TRAP_PATTERNS: TrapPattern[] = [ + { + category: "arithmetic", + signum: SIGFPE, + signalName: "SIGFPE", + patterns: [ + /divide by zero/i, + /division by zero/i, + /remainder by zero/i, + /integer overflow/i, + /integer divide by zero/i, + ], + }, + { + category: "memory", + signum: SIGSEGV, + signalName: "SIGSEGV", + patterns: [ + /memory access out of bounds/i, + /out of bounds memory access/i, + /out-of-bounds memory/i, + /index out of bounds.*memory/i, + /memory out of bounds/i, + ], + }, + { + category: "bounds", + signum: SIGSEGV, + signalName: "SIGSEGV", + patterns: [ + /RuntimeError:[^\n]*\bindex out of bounds\b/i, + /table index (?:is )?out of bounds/i, + /table index (?:is )?outside/i, + /out of bounds call_indirect/i, + /indirect call.*out of bounds/i, + ], + }, + { + category: "illegal-instruction", + signum: SIGILL, + signalName: "SIGILL", + patterns: [ + /\bunreachable\b/i, + /call_indirect.*null/i, + /call_indirect.*type mismatch/i, + /call_indirect.*signature.*does not match/i, + /indirect call.*null/i, + /indirect call.*type mismatch/i, + /function signature mismatch/i, + /signature mismatch/i, + /signature.*does not match/i, + /type mismatch/i, + /null function/i, + /undefined element/i, + /uninitialized element/i, + ], + }, + { + category: "stack", + signum: SIGSEGV, + signalName: "SIGSEGV", + patterns: [ + /maximum call stack/i, + /call stack size exceeded/i, + /call stack exhausted/i, + /stack overflow/i, + /stack exhausted/i, + ], + }, +]; + +function crashText(reason: unknown): string { + if (reason instanceof Error) { + return reason.stack ? `${reason.message}\n${reason.stack}` : reason.message; + } + return String(reason ?? ""); +} + +export function classifyWasmCrashSignal(reason: unknown): WasmCrashSignalClassification | null { + const text = crashText(reason); + if (!text) return null; + + for (const group of TRAP_PATTERNS) { + for (const pattern of group.patterns) { + const match = pattern.exec(text); + if (!match) continue; + return { + category: group.category, + signum: group.signum, + signalName: group.signalName, + matched: match[0], + }; + } + } + + return null; +} + +export function signalExitStatus(signum: number): number { + return 128 + signum; +} + +export function classifiedSignalOrFallback( + reason: unknown, + fallback: number = SIGSEGV, +): number { + return classifyWasmCrashSignal(reason)?.signum ?? fallback; +} + +export function classifiedTrapExitStatus(reason: unknown): number | null { + const classification = classifyWasmCrashSignal(reason); + return classification ? signalExitStatus(classification.signum) : null; +} diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 47cf7936d..ac371ef9f 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -136,6 +136,8 @@ export interface RunProgramOptions { * only (NodeKernelHost.getForkCount); main-thread mode falls back to * reading from the kernel instance directly. */ captureForkCount?: boolean; + /** Use the canonical rootfs image in worker-thread mode. Defaults to true. */ + useDefaultRootfs?: boolean; } export interface RunProgramResult { @@ -202,7 +204,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { stdout += new TextDecoder().decode(data); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index 617576f82..192b41f48 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -25,6 +25,8 @@ const TEST_PROGRAMS = [ "getaddrinfo_test.c", "sysv_ipc_test.c", "wasm_trap_test.c", + "oob_trap_test.c", + "divzero_trap_test.c", "abort_test.c", "mount_probe_test.c", "getpwent_smoke.c", diff --git a/host/test/trap-signals.test.ts b/host/test/trap-signals.test.ts new file mode 100644 index 000000000..c78a1a3ba --- /dev/null +++ b/host/test/trap-signals.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + classifiedSignalOrFallback, + classifiedTrapExitStatus, + classifyWasmCrashSignal, + signalExitStatus, + SIGFPE, + SIGILL, + SIGSEGV, +} from "../src/trap-signals"; + +describe("Wasm trap signal classification", () => { + it("maps memory and stack traps to SIGSEGV", () => { + expect(classifyWasmCrashSignal("RuntimeError: memory access out of bounds")).toMatchObject({ + category: "memory", + signum: SIGSEGV, + signalName: "SIGSEGV", + }); + expect(classifyWasmCrashSignal("RuntimeError: Out of bounds memory access")).toMatchObject({ + category: "memory", + signum: SIGSEGV, + }); + expect(classifyWasmCrashSignal("RangeError: Maximum call stack size exceeded")).toMatchObject({ + category: "stack", + signum: SIGSEGV, + }); + expect(classifyWasmCrashSignal("RuntimeError: call stack exhausted")).toMatchObject({ + category: "stack", + signum: SIGSEGV, + }); + }); + + it("maps generic Wasm bounds traps to SIGSEGV", () => { + for (const message of [ + "RuntimeError: index out of bounds", + "RuntimeError: table index is out of bounds", + "RuntimeError: Out of bounds call_indirect", + ]) { + expect(classifyWasmCrashSignal(message), message).toMatchObject({ + category: "bounds", + signum: SIGSEGV, + signalName: "SIGSEGV", + }); + } + }); + + it("maps arithmetic traps to SIGFPE", () => { + for (const message of [ + "RuntimeError: divide by zero", + "RuntimeError: integer divide by zero", + "RuntimeError: integer overflow", + "RuntimeError: remainder by zero", + ]) { + expect(classifyWasmCrashSignal(message), message).toMatchObject({ + category: "arithmetic", + signum: SIGFPE, + signalName: "SIGFPE", + }); + } + }); + + it("maps illegal control-flow traps to SIGILL", () => { + for (const message of [ + "RuntimeError: unreachable", + "RuntimeError: unreachable executed", + "RuntimeError: indirect call type mismatch", + "RuntimeError: null function or function signature mismatch", + "RuntimeError: call_indirect to a signature that does not match", + "RuntimeError: call_indirect to a null table entry", + ]) { + expect(classifyWasmCrashSignal(message), message).toMatchObject({ + category: "illegal-instruction", + signum: SIGILL, + signalName: "SIGILL", + }); + } + }); + + it("does not classify loader and ABI errors as Wasm trap causes", () => { + for (const message of [ + "CompileError: WebAssembly.compile(): expected magic word", + "LinkError: WebAssembly.instantiate(): Import #0 module=\"env\" error", + "ABI version mismatch: program=1 kernel=2", + ]) { + expect(classifyWasmCrashSignal(message), message).toBeNull(); + expect(classifiedTrapExitStatus(message), message).toBeNull(); + } + }); + + it("keeps call_indirect bounds traps separate from null indirect calls", () => { + const classification = classifyWasmCrashSignal("RuntimeError: Out of bounds call_indirect"); + expect(classification).toMatchObject({ + category: "bounds", + signum: SIGSEGV, + }); + expect(classifyWasmCrashSignal("RuntimeError: call_indirect to a null table entry")).toMatchObject({ + category: "illegal-instruction", + signum: SIGILL, + }); + }); + + it("returns POSIX-style signal exit statuses for classified traps", () => { + expect(signalExitStatus(SIGILL)).toBe(132); + expect(signalExitStatus(SIGFPE)).toBe(136); + expect(signalExitStatus(SIGSEGV)).toBe(139); + expect(classifiedTrapExitStatus("RuntimeError: divide by zero")).toBe(136); + expect(classifiedSignalOrFallback("not a wasm trap")).toBe(SIGSEGV); + }); +}); diff --git a/host/test/wasm-trap.test.ts b/host/test/wasm-trap.test.ts index 0ceb748a1..f48a0a346 100644 --- a/host/test/wasm-trap.test.ts +++ b/host/test/wasm-trap.test.ts @@ -9,7 +9,12 @@ * - `wasm_trap_test`: `__builtin_trap()` from main(). Worker-main * reports the unhandled `unreachable` * RuntimeError as a process failure; the host - * must still resolve spawn() promptly. + * maps it to SIGILL and resolves promptly. + * + * - `oob_trap_test`: Out-of-bounds linear-memory access maps to + * SIGSEGV. + * + * - `divzero_trap_test`: Integer divide-by-zero maps to SIGFPE. * * - `abort_test`: abort(). musl's abort() raises SIGABRT and * then loops on `for(;;) a_crash()` as a @@ -24,18 +29,25 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; import { runCentralizedProgram } from "./centralized-test-helper"; +import { signalExitStatus, SIGFPE, SIGILL, SIGSEGV } from "../src/trap-signals"; const __dirname = dirname(fileURLToPath(import.meta.url)); const wasmTrapBin = join(__dirname, "../../examples/wasm_trap_test.wasm"); +const oobTrapBin = join(__dirname, "../../examples/oob_trap_test.wasm"); +const divzeroTrapBin = join(__dirname, "../../examples/divzero_trap_test.wasm"); const abortBin = join(__dirname, "../../examples/abort_test.wasm"); -const programsBuilt = existsSync(wasmTrapBin) && existsSync(abortBin); +const programsBuilt = existsSync(wasmTrapBin) && + existsSync(oobTrapBin) && + existsSync(divzeroTrapBin) && + existsSync(abortBin); describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { it("__builtin_trap() in user code: spawn() resolves promptly, no hang", async () => { const t0 = Date.now(); const { exitCode, stderr } = await runCentralizedProgram({ programPath: wasmTrapBin, + useDefaultRootfs: false, timeout: 5000, }); const elapsed = Date.now() - t0; @@ -44,7 +56,41 @@ describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { expect(stderr).not.toContain("SHOULD-NEVER-REACH"); // Arbitrary `unreachable` traps are no longer masked as successful exits; // only the known kernel_exit path is interpreted as normal termination. - expect(exitCode).toBe(-1); + expect(exitCode).toBe(signalExitStatus(SIGILL)); + expect(stderr).toContain("RuntimeError"); + expect(stderr).toContain("unreachable"); + expect(elapsed).toBeLessThan(3000); + }, 8_000); + + it("out-of-bounds memory trap resolves as SIGSEGV", async () => { + const t0 = Date.now(); + const { exitCode, stderr } = await runCentralizedProgram({ + programPath: oobTrapBin, + useDefaultRootfs: false, + timeout: 5000, + }); + const elapsed = Date.now() - t0; + + expect(stderr).toContain("before-oob"); + expect(stderr).not.toContain("SHOULD-NEVER-REACH"); + expect(exitCode).toBe(signalExitStatus(SIGSEGV)); + expect(stderr).toContain("RuntimeError"); + expect(elapsed).toBeLessThan(3000); + }, 8_000); + + it("integer divide-by-zero trap resolves as SIGFPE", async () => { + const t0 = Date.now(); + const { exitCode, stderr } = await runCentralizedProgram({ + programPath: divzeroTrapBin, + useDefaultRootfs: false, + timeout: 5000, + }); + const elapsed = Date.now() - t0; + + expect(stderr).toContain("before-divzero"); + expect(stderr).not.toContain("SHOULD-NEVER-REACH"); + expect(exitCode).toBe(signalExitStatus(SIGFPE)); + expect(stderr).toContain("RuntimeError"); expect(elapsed).toBeLessThan(3000); }, 8_000); @@ -52,18 +98,19 @@ describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { const t0 = Date.now(); const { exitCode, stderr } = await runCentralizedProgram({ programPath: abortBin, + useDefaultRootfs: false, timeout: 5000, }); const elapsed = Date.now() - t0; expect(stderr).toContain("before-abort"); expect(stderr).not.toContain("SHOULD-NEVER-REACH"); - // exitCode could legitimately be 0 (a_crash unreachable trap → - // worker-main's `_Exit` interpretation) or 134 (128+SIGABRT, when - // raise(SIGABRT) terminates via the kernel's default action). + // exitCode could legitimately be 132 (abort backstop's unreachable + // trap) or 134 (128+SIGABRT, when raise(SIGABRT) terminates via the + // kernel's default action). // Both are valid "process terminated" outcomes; the regression // we're guarding against is HANGING. - expect([0, 134]).toContain(exitCode); + expect([signalExitStatus(SIGILL), 134]).toContain(exitCode); expect(elapsed).toBeLessThan(3000); }, 8_000); });