diff --git a/.github/workflows/browser-demos-ci.yml b/.github/workflows/browser-demos-ci.yml
index 87b54cccc..432401f78 100644
--- a/.github/workflows/browser-demos-ci.yml
+++ b/.github/workflows/browser-demos-ci.yml
@@ -57,10 +57,10 @@ jobs:
working-directory: apps/browser-demos
run: npx playwright install --with-deps chromium firefox webkit
- - name: Run cross-origin isolation smoke tests
+ - name: Run browser smoke tests
working-directory: apps/browser-demos
run: |
- npx playwright test test/coi.spec.ts \
+ npx playwright test test/coi.spec.ts test/wasm-trap-signal.spec.ts \
--project=chromium \
--project=firefox \
--project=webkit
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/public/trap-signal-test.html b/apps/browser-demos/public/trap-signal-test.html
new file mode 100644
index 000000000..32586fdc1
--- /dev/null
+++ b/apps/browser-demos/public/trap-signal-test.html
@@ -0,0 +1,2 @@
+
+
Wasm trap signal test
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..cd9043583
--- /dev/null
+++ b/apps/browser-demos/test/wasm-trap-signal.spec.ts
@@ -0,0 +1,157 @@
+import { expect, test } from "@playwright/test";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { SIGFPE, SIGILL, SIGSEGV } from "../../../host/src/trap-signals";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const trapSignalsModulePath = resolve(
+ __dirname,
+ "../../../host/src/trap-signals.ts",
+);
+
+// Browser engines do not all isolate synthetic Wasm traps well enough for a
+// stable E2E test. The host tests exercise real traps; this suite verifies that
+// the classifier Kandelo ships to browsers maps representative engine messages.
+const TRAP_MESSAGE_CASES = [
+ {
+ name: "v8 memory-oob",
+ message: "RuntimeError: memory access out of bounds",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "jsc memory-oob",
+ message: "RuntimeError: Out of bounds memory access",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "spidermonkey generic bounds",
+ message: "RuntimeError: index out of bounds",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "table index bounds",
+ message: "RuntimeError: table index is out of bounds",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "call_indirect bounds",
+ message: "RuntimeError: Out of bounds call_indirect",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "divide by zero",
+ message: "RuntimeError: divide by zero",
+ expectedSignum: SIGFPE,
+ expectedSignalName: "SIGFPE",
+ },
+ {
+ name: "integer divide by zero",
+ message: "RuntimeError: integer divide by zero",
+ expectedSignum: SIGFPE,
+ expectedSignalName: "SIGFPE",
+ },
+ {
+ name: "integer overflow",
+ message: "RuntimeError: integer overflow",
+ expectedSignum: SIGFPE,
+ expectedSignalName: "SIGFPE",
+ },
+ {
+ name: "unreachable",
+ message: "RuntimeError: unreachable",
+ expectedSignum: SIGILL,
+ expectedSignalName: "SIGILL",
+ },
+ {
+ name: "unreachable executed",
+ message: "RuntimeError: unreachable executed",
+ expectedSignum: SIGILL,
+ expectedSignalName: "SIGILL",
+ },
+ {
+ name: "indirect type mismatch",
+ message: "RuntimeError: indirect call type mismatch",
+ expectedSignum: SIGILL,
+ expectedSignalName: "SIGILL",
+ },
+ {
+ name: "null indirect function",
+ message: "RuntimeError: null function or function signature mismatch",
+ expectedSignum: SIGILL,
+ expectedSignalName: "SIGILL",
+ },
+ {
+ name: "call_indirect signature mismatch",
+ message: "RuntimeError: call_indirect to a signature that does not match",
+ expectedSignum: SIGILL,
+ expectedSignalName: "SIGILL",
+ },
+ {
+ name: "call_indirect null table entry",
+ message: "RuntimeError: call_indirect to a null table entry",
+ expectedSignum: SIGILL,
+ expectedSignalName: "SIGILL",
+ },
+ {
+ name: "stack overflow",
+ message: "RangeError: Maximum call stack size exceeded",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "stack exhausted",
+ message: "RuntimeError: call stack exhausted",
+ expectedSignum: SIGSEGV,
+ expectedSignalName: "SIGSEGV",
+ },
+ {
+ name: "loader compile error",
+ message: "CompileError: WebAssembly.compile(): expected magic word",
+ expectedSignum: null,
+ expectedSignalName: null,
+ },
+ {
+ name: "ABI mismatch",
+ message: "ABI version mismatch: program=1 kernel=2",
+ expectedSignum: null,
+ expectedSignalName: null,
+ },
+] as const;
+
+for (const trapCase of TRAP_MESSAGE_CASES) {
+ test(`@trap-signal classifies ${trapCase.name} in the browser`, async ({
+ page,
+ baseURL,
+ browserName,
+ }) => {
+ expect(baseURL).toBeTruthy();
+ const moduleUrl = new URL(`/@fs/${trapSignalsModulePath}`, baseURL).href;
+
+ await page.goto(new URL("/trap-signal-test.html", baseURL).href);
+ const classification = await page.evaluate(
+ async ({ moduleUrl, message }) => {
+ const trapSignals = await import(/* @vite-ignore */ moduleUrl);
+ return trapSignals.classifyWasmCrashSignal(message) as {
+ signum: number;
+ signalName: string;
+ } | null;
+ },
+ { moduleUrl, message: trapCase.message },
+ );
+
+ if (trapCase.expectedSignum === null) {
+ expect(classification, `${browserName} ${trapCase.name}`).toBeNull();
+ return;
+ }
+
+ expect(classification, `${browserName} ${trapCase.name}`).toMatchObject({
+ signum: trapCase.expectedSignum,
+ signalName: trapCase.expectedSignalName,
+ });
+ });
+}
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/run-example.ts b/examples/run-example.ts
index 1fec9a85d..5f44badec 100644
--- a/examples/run-example.ts
+++ b/examples/run-example.ts
@@ -59,6 +59,7 @@ const nanoWasm = tryResolveBinary("programs/nano.wasm");
const tclshWasm = tryResolveBinary("programs/tcl.wasm");
const testfixtureWasm = tryResolveBinary("programs/sqlite/testfixture.wasm");
const mysqltestWasm = tryResolveBinary("programs/mariadb/mysqltest.wasm");
+const echoWasm = tryResolveBinary("programs/echo.wasm") ?? resolve(repoRoot, "examples/echo.wasm");
// GNU coreutils multi-call binary supports all of these as argv[0]
const coreutilsNames = [
@@ -73,9 +74,9 @@ const coreutilsNames = [
// Values may be null when a program isn't fetched/built locally.
// Consumers filter out null entries before use.
const builtinPrograms: Record = {
- "echo": resolve(repoRoot, "examples/echo.wasm"),
- "/bin/echo": resolve(repoRoot, "examples/echo.wasm"),
- "/usr/bin/echo": resolve(repoRoot, "examples/echo.wasm"),
+ "echo": echoWasm,
+ "/bin/echo": echoWasm,
+ "/usr/bin/echo": echoWasm,
"sh": dashWasm,
"/bin/sh": dashWasm,
"dash": dashWasm,
@@ -257,12 +258,6 @@ function resolveProgram(path: string): ArrayBuffer | null {
if (mapped) {
return loadBytes(mapped);
}
- // execlp() searches the inherited host/dev-shell PATH. In CI that can
- // resolve tools like gencat to /nix/store/.../bin/gencat; never load that
- // host ELF as a guest program.
- if (path.endsWith("/gencat")) {
- return loadBytes(resolve(repoRoot, "examples/gencat.wasm"));
- }
const kernelCwd = process.env.KERNEL_CWD || process.cwd();
const candidates = [
path,
@@ -280,6 +275,14 @@ function resolveProgram(path: string): ArrayBuffer | null {
return null;
}
+function guestEnv(): string[] {
+ const kernelPath = process.env.KERNEL_PATH ?? "/usr/local/bin:/usr/bin:/bin";
+ const inherited = Object.entries(process.env)
+ .filter(([k, v]) => v !== undefined && k !== "PATH")
+ .map(([k, v]) => `${k}=${v}`);
+ return [...inherited, `PATH=${kernelPath}`];
+}
+
async function main() {
const name = process.argv[2];
if (!name) {
@@ -340,9 +343,7 @@ async function main() {
const timeoutMs = parseInt(process.env.TIMEOUT || "30000", 10);
const exitPromise = host.spawn(loadBytes(programPath), processArgv, {
env: [
- ...Object.entries(process.env)
- .filter(([, v]) => v !== undefined)
- .map(([k, v]) => `${k}=${v}`),
+ ...guestEnv(),
...gitEnv,
],
cwd: process.env.KERNEL_CWD || process.cwd(),
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..6ba172d35 100644
--- a/host/src/browser-kernel-worker-entry.ts
+++ b/host/src/browser-kernel-worker-entry.ts
@@ -64,6 +64,7 @@ import { CentralizedKernelWorker } from "./kernel-worker";
import type {
ForkFromThreadContext,
ResolvedSpawnProgram,
+ SpawnProgramResolution,
} from "./kernel-worker";
import type { KernelPointer } from "./kernel";
import { BrowserWorkerAdapter } from "./worker-adapter-browser";
@@ -78,8 +79,14 @@ import {
import type { MountConfig } from "./vfs/types";
import { TlsNetworkBackend } from "./networking/tls-network-backend";
import { patchWasmForThread } from "./worker-main";
-import { detectPtrWidth, extractHeapBase } from "./constants";
+import { detectPtrWidth, extractHeapBase, isWasmModuleBytes } from "./constants";
import { ThreadExitCoordinator } from "./thread-exit-coordinator";
+import {
+ classifiedSignalOrFallback,
+ classifiedTrapExitStatus,
+ signalExitStatus,
+ SIGSEGV,
+} from "./trap-signals";
import type {
CentralizedWorkerInitMessage,
CentralizedThreadInitMessage,
@@ -110,6 +117,7 @@ let io: VirtualPlatformIO;
let maxPages: number = DEFAULT_MAX_PAGES;
let defaultThreadSlots: number = DEFAULT_PROCESS_THREAD_SLOTS;
let defaultEnv: string[] = [];
+const ENOEXEC = 8;
// Process tracking
interface ProcessInfo {
@@ -159,14 +167,17 @@ async function resolveExecutableForLaunch(
path: string,
argv: string[],
depth = 0,
-): Promise {
+): Promise {
if (depth > MAX_SHEBANG_DEPTH) return null;
await memfs.ensureMaterialized(path);
const bytes = readFileFromFs(path);
if (!bytes) return null;
const shebang = parseShebang(bytes);
- if (!shebang) return { programBytes: bytes, argv };
+ if (!shebang) {
+ if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC };
+ return { programBytes: bytes, argv };
+ }
const scriptArgv = [
shebang.interpreter,
@@ -639,6 +650,11 @@ async function handleSpawn(msg: Extract)
return;
}
+ if (!isWasmModuleBytes(programBytes)) {
+ respondError(msg.requestId, "ENOEXEC: program is not a WebAssembly module");
+ return;
+ }
+
// Pid: if the caller pre-picked one, honor it (legacy spawn() callers
// still do); otherwise the worker is the source of truth and allocates.
const pid = msg.pid ?? kernelWorker.allocatePid();
@@ -737,7 +753,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 +763,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 +790,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 +806,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");
}
@@ -881,6 +899,7 @@ async function handleExec(
): Promise {
const resolved = await resolveExecutableForLaunch(path, argv);
if (!resolved) return -2; // ENOENT
+ if ("errno" in resolved) return -resolved.errno;
const { programBytes: bytes, argv: launchArgv } = resolved;
// Program found — run kernel exec setup
@@ -996,7 +1015,7 @@ async function handleExec(
async function handlePosixSpawnResolve(
path: string,
argv: string[],
-): Promise {
+): Promise {
return resolveExecutableForLaunch(path, argv);
}
@@ -1189,11 +1208,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;
}
-async function finishProcessExit(pid: number, exitStatus: number): Promise {
+function handleExit(pid: number, exitStatus: number, crashSignum?: number): void {
+ void finishProcessExit(pid, exitStatus, crashSignum);
+}
+
+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 +1234,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 +1243,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/constants.ts b/host/src/constants.ts
index 8359c18de..b0188ca70 100644
--- a/host/src/constants.ts
+++ b/host/src/constants.ts
@@ -20,6 +20,20 @@ export const DEFAULT_MAX_PAGES = PROCESS_MEMORY_DEFAULT_MAX_PAGES;
export const PAGES_PER_THREAD = PROCESS_MEMORY_PAGES_PER_THREAD_SLOT;
export const PAGES_PER_THREAD_SLOT = PROCESS_MEMORY_PAGES_PER_THREAD_SLOT;
+/** Return true when bytes start with a WebAssembly module header. */
+export function isWasmModuleBytes(programBytes: ArrayBuffer): boolean {
+ const src = new Uint8Array(programBytes);
+ return src.length >= 8 &&
+ src[0] === 0x00 &&
+ src[1] === 0x61 &&
+ src[2] === 0x73 &&
+ src[3] === 0x6d &&
+ src[4] === 0x01 &&
+ src[5] === 0x00 &&
+ src[6] === 0x00 &&
+ src[7] === 0x00;
+}
+
/**
* Read an unsigned LEB128 starting at `off`.
* Returns [value, bytesConsumed].
diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts
index 29e883fff..a8dfcbcb7 100644
--- a/host/src/kernel-worker.ts
+++ b/host/src/kernel-worker.ts
@@ -505,7 +505,19 @@ export interface ResolvedSpawnProgram {
argv: string[];
}
-export type SpawnProgramResolution = ArrayBuffer | ResolvedSpawnProgram;
+export interface SpawnResolveError {
+ errno: number;
+}
+
+export type SpawnProgramResolution = ArrayBuffer | ResolvedSpawnProgram | SpawnResolveError;
+
+function isSpawnResolveError(
+ resolution: SpawnProgramResolution,
+): resolution is SpawnResolveError {
+ return !(resolution instanceof ArrayBuffer) &&
+ "errno" in resolution &&
+ typeof resolution.errno === "number";
+}
/** Callbacks for fork/exec/exit handling. */
export interface CentralizedKernelCallbacks {
@@ -548,7 +560,8 @@ export interface CentralizedKernelCallbacks {
/**
* Pre-flight resolution step for SYS_SPAWN. Returns the program bytes
* for `path` (or `{ programBytes, argv }` when resolution rewrites argv,
- * e.g. a shebang script), or `null` for ENOENT. **Must NOT have side effects** —
+ * e.g. a shebang script), `{ errno }` for a located but unlaunchable
+ * program, or `null` for ENOENT. **Must NOT have side effects** —
* `handleSpawn` calls this BEFORE `kernel_spawn_process` so that file
* actions never run on a doomed PATH-iteration. POSIX requires
* file_actions to run "exactly once," and `posix_spawnp`'s PATH-walk
@@ -5761,6 +5774,10 @@ export class CentralizedKernelWorker {
this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 2); // ENOENT
return;
}
+ if (isSpawnResolveError(resolved)) {
+ this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, resolved.errno >>> 0);
+ return;
+ }
const programBytes = resolved instanceof ArrayBuffer ? resolved : resolved.programBytes;
const launchArgv = resolved instanceof ArrayBuffer ? argv : resolved.argv;
this.handleSpawnAfterResolve(
diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts
index 00b7ad66f..d79d7c58d 100644
--- a/host/src/node-kernel-host.ts
+++ b/host/src/node-kernel-host.ts
@@ -12,7 +12,7 @@
* const exitCode = await host.spawn(programBytes, ["hello"], { env: [...] });
* await host.destroy();
*/
-import { readFileSync, existsSync } from "node:fs";
+import { readFileSync, existsSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { pathToFileURL } from "node:url";
@@ -553,10 +553,10 @@ function spawnKernelWorkerThread(): NodeThreadWorker {
const distJs = entryTs.replace(/\/src\/([^/]+)\.ts$/, "/dist/$1.js");
// Check for compiled .js version first (much faster startup)
- if (existsSync(distJs)) {
+ if (compiledEntryIsCurrent(entryTs, distJs)) {
return new NodeThreadWorker(distJs);
}
- if (existsSync(entryJs)) {
+ if (compiledEntryIsCurrent(entryTs, entryJs)) {
return new NodeThreadWorker(entryJs);
}
@@ -572,3 +572,9 @@ function spawnKernelWorkerThread(): NodeThreadWorker {
].join("\n");
return new NodeThreadWorker(bootstrap, { eval: true });
}
+
+function compiledEntryIsCurrent(sourcePath: string, compiledPath: string): boolean {
+ if (!existsSync(compiledPath)) return false;
+ if (!existsSync(sourcePath)) return true;
+ return statSync(compiledPath).mtimeMs >= statSync(sourcePath).mtimeMs;
+}
diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts
index aafd081f5..431e833e6 100644
--- a/host/src/node-kernel-worker-entry.ts
+++ b/host/src/node-kernel-worker-entry.ts
@@ -19,7 +19,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { CentralizedKernelWorker } from "./kernel-worker";
-import type { ForkFromThreadContext, ResolvedSpawnProgram } from "./kernel-worker";
+import type {
+ ForkFromThreadContext,
+ ResolvedSpawnProgram,
+ SpawnProgramResolution,
+} from "./kernel-worker";
import { NodePlatformIO } from "./platform/node";
import {
VirtualPlatformIO,
@@ -37,8 +41,14 @@ import { NodeWorkerAdapter } from "./worker-adapter";
import { ThreadPageAllocator } from "./thread-allocator";
import { patchWasmForThread } from "./worker-main";
import { ThreadExitCoordinator } from "./thread-exit-coordinator";
-import { detectPtrWidth, extractHeapBase } from "./constants";
+import { detectPtrWidth, extractHeapBase, isWasmModuleBytes } 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,
@@ -79,6 +89,7 @@ let rootfsMemfs: MemoryFileSystem | null = null;
/** Per-boot scratch directory; cleaned up on `destroy`. Only set when the
* worker constructs a `VirtualPlatformIO` from the default mount spec. */
let sessionDir: string | null = null;
+const ENOEXEC = 8;
// Process tracking
interface ProcessInfo {
@@ -107,9 +118,9 @@ const intentionallyTerminated = new WeakSet