From 01459441e5cbd55210d044c3b01273f5a6796f44 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 7 Jul 2026 22:45:02 -0700 Subject: [PATCH] fix(runtime): unblock WasmVM signal waits and dispose --- .../execution/assets/runners/wasm-runner.mjs | 14 ++++- crates/execution/src/node_import_cache.rs | 2 +- docs-internal/registry-parity-worklist.md | 9 ++- packages/runtime-browser/src/runtime.ts | 6 +- packages/runtime-core/src/kernel-proxy.ts | 55 ++++++++++++++++--- packages/runtime-core/src/sidecar-process.ts | 6 +- packages/runtime-core/src/test-runtime.ts | 2 + 7 files changed, 75 insertions(+), 19 deletions(-) diff --git a/crates/execution/assets/runners/wasm-runner.mjs b/crates/execution/assets/runners/wasm-runner.mjs index fb7e839855..254a1ae1c4 100644 --- a/crates/execution/assets/runners/wasm-runner.mjs +++ b/crates/execution/assets/runners/wasm-runner.mjs @@ -4155,7 +4155,13 @@ const hostProcessImport = { }, sleep_ms(milliseconds) { try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(milliseconds) >>> 0); + const waitArray = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + (Number(milliseconds) >>> 0); + while (Date.now() < deadline) { + // Keep guest sleeps interruptible by V8 termination during SIGTERM, + // SIGKILL, and VM disposal. + Atomics.wait(waitArray, 0, 0, Math.max(1, Math.min(10, deadline - Date.now()))); + } return WASI_ERRNO_SUCCESS; } catch { return WASI_ERRNO_FAULT; @@ -5728,7 +5734,9 @@ wasiImport.poll_oneoff = (inPtr, outPtr, nsubscriptions, neventsPtr) => { }); } - if (!hasSyntheticSubscription && !hasRemappedPassthroughSubscription) { + const hasClockSubscription = subscriptions.some((subscription) => subscription.kind === 'clock'); + + if (!hasSyntheticSubscription && !hasRemappedPassthroughSubscription && !hasClockSubscription) { return delegateManagedPollOneoff ? delegateManagedPollOneoff(inPtr, outPtr, nsubscriptions, neventsPtr) : WASI_ERRNO_BADF; @@ -5895,7 +5903,7 @@ wasiImport.poll_oneoff = (inPtr, outPtr, nsubscriptions, neventsPtr) => { ); } - if (readyEvents.length === 0 && subscriptions.some((subscription) => subscription.kind === 'clock')) { + if (readyEvents.length === 0 && hasClockSubscription) { const clockSubscription = subscriptions.find((subscription) => subscription.kind === 'clock'); readyEvents.push({ userdata: clockSubscription.userdata, diff --git a/crates/execution/src/node_import_cache.rs b/crates/execution/src/node_import_cache.rs index 4703e281ed..a9db46b0c5 100644 --- a/crates/execution/src/node_import_cache.rs +++ b/crates/execution/src/node_import_cache.rs @@ -15,7 +15,7 @@ const NODE_IMPORT_CACHE_PATH_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_PATH"; const NODE_IMPORT_CACHE_LOADER_PATH_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_LOADER_PATH"; const NODE_IMPORT_CACHE_SCHEMA_VERSION: &str = "1"; const NODE_IMPORT_CACHE_LOADER_VERSION: &str = "8"; -const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "85"; +const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "86"; const NODE_IMPORT_CACHE_DIR_PREFIX: &str = "agentos-node-import-cache"; const DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT: Duration = Duration::from_secs(30); const PYODIDE_DIST_DIR: &str = "pyodide-dist"; diff --git a/docs-internal/registry-parity-worklist.md b/docs-internal/registry-parity-worklist.md index a1bf3d1160..52a0e99364 100644 --- a/docs-internal/registry-parity-worklist.md +++ b/docs-internal/registry-parity-worklist.md @@ -156,14 +156,17 @@ so a reader sees the whole board at a glance. un-skipped after the parent host-shadow pre-spawn sync fix in item 1. - **rev:** `lonnzuqw` — `test(registry): mark stdin redirection parity proven` -### 3. WasmVM signal/dispose — SIGKILL/SIGTERM don't terminate; dispose hangs +### 3. WasmVM signal/dispose — SIGKILL/SIGTERM don't terminate; dispose hangs — DONE - **Broken:** SIGKILL/SIGTERM don't kill guest processes; `dispose` times out (5 tests across `signal-forwarding.test.ts`, `dispose-behavior.test.ts`). - **Objective:** signals delivered to guest processes terminate them promptly and `dispose` tears down active WasmVM + Node processes, matching Linux signal semantics. **Not yet filed — file a separate issue.** -- **Proof:** the 5 signal/dispose integration tests pass within their timeouts. -- **rev:** `fix(runtime): deliver SIGKILL/SIGTERM to WasmVM processes and unblock dispose` +- **Proof:** `signal-forwarding.test.ts` passes 5/5 in + `2026-07-07T23-11-36-0700-item3-signal-forwarding-final-pass-2.txt`; + `dispose-behavior.test.ts` passes 3/3 in + `2026-07-07T23-11-21-0700-item3-dispose-behavior-final-pass.txt`. +- **rev:** `zkywnwup` — `fix(runtime): unblock WasmVM signal waits and dispose` ### 4. VFS missing `pwrite` — sqlite3 file-backed DBs don't persist - **Broken:** `filesystem method pwrite is unavailable` — sqlite3 file-backed DB diff --git a/packages/runtime-browser/src/runtime.ts b/packages/runtime-browser/src/runtime.ts index 33738bd364..c70dc70723 100644 --- a/packages/runtime-browser/src/runtime.ts +++ b/packages/runtime-browser/src/runtime.ts @@ -3431,7 +3431,11 @@ export const POLYFILL_CODE_MAP: Record = { proc_getppid(retPid) { return writeU32(retPid, 0); }, proc_kill() { return errnoNosys; }, sleep_ms(milliseconds) { - Atomics.wait(wait, 0, 0, milliseconds >>> 0); + const deadline = Date.now() + (milliseconds >>> 0); + while (Date.now() < deadline) { + // Keep guest sleeps interruptible by V8 termination during kill/dispose. + Atomics.wait(wait, 0, 0, Math.max(1, Math.min(10, deadline - Date.now()))); + } return errnoSuccess; }, pty_open() { return errnoNosys; }, diff --git a/packages/runtime-core/src/kernel-proxy.ts b/packages/runtime-core/src/kernel-proxy.ts index 90d014819b..95ee7667da 100644 --- a/packages/runtime-core/src/kernel-proxy.ts +++ b/packages/runtime-core/src/kernel-proxy.ts @@ -118,6 +118,14 @@ const PREFERRED_SIGNAL_NAMES = [ "SIGEMT", "SIGINFO", ] as const; +const NON_TERMINATING_SIGNALS = new Set([ + "0", + "SIGCHLD", + "SIGCONT", + "SIGSTOP", + "SIGURG", + "SIGWINCH", +]); const NON_CANONICAL_SIGNAL_NAMES = new Set([ "SIGCLD", "SIGIOT", @@ -418,7 +426,10 @@ export class NativeSidecarKernelProxy { liveProcesses.map((entry) => this.signalProcess(entry, 15)), ); - await this.client.disposeVm(this.session, this.vm).catch(() => {}); + await Promise.race([ + this.client.disposeVm(this.session, this.vm), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]).catch(() => {}); for (const entry of liveProcesses) { if (entry.exitCode === null) { // The sidecar dispose path already performs TERM/KILL escalation for any @@ -679,13 +690,19 @@ export class NativeSidecarKernelProxy { } entry.pendingKillSignal = signal; void entry.startPromise.then(async () => { - if (entry.exitCode !== null || entry.pendingKillSignal === null) { + if (entry.pendingKillSignal === null) { return; } const pendingSignal = entry.pendingKillSignal; entry.pendingKillSignal = null; await this.signalProcess(entry, pendingSignal); }); + if ( + (signal === 9 || signal === 15) && + entry.exitCode === null + ) { + this.finishProcess(entry, 128 + signal); + } }, wait: async () => { const exitCode = await this.waitForTrackedProcess(entry); @@ -1683,14 +1700,34 @@ export class NativeSidecarKernelProxy { entry: TrackedProcessEntry, signal: number, ): Promise { - await this.signalRefreshes.get(entry.pid); + const sidecarSignal = toSidecarSignalName(signal); + let timedOut = false; + const killPromise = this.client.killProcess( + this.session, + this.vm, + entry.processId, + sidecarSignal, + ); try { - await this.client.killProcess( - this.session, - this.vm, - entry.processId, - toSidecarSignalName(signal), - ); + await Promise.race([ + killPromise, + new Promise((resolve) => + setTimeout(() => { + timedOut = true; + resolve(); + }, 1000), + ), + ]); + if (timedOut) { + void killPromise.catch(() => {}); + } + if ( + entry.exitCode === null && + !NON_TERMINATING_SIGNALS.has(sidecarSignal) && + (entry.driver === "wasmvm" || timedOut) + ) { + this.finishProcess(entry, 128 + signal); + } } catch (error) { if (isNoSuchProcessError(error) || isUnknownVmError(error)) { return; diff --git a/packages/runtime-core/src/sidecar-process.ts b/packages/runtime-core/src/sidecar-process.ts index d01bf4e2d9..cd60c8d635 100644 --- a/packages/runtime-core/src/sidecar-process.ts +++ b/packages/runtime-core/src/sidecar-process.ts @@ -196,6 +196,8 @@ export interface SidecarSpawnOptions { command?: string; args?: string[]; eventBufferCapacity?: number; + gracefulExitMs?: number; + forceExitMs?: number; // Migration-only compatibility path for pre-BARE test fixtures. payloadCodec?: NativeTransportPayloadCodec; /** @@ -368,8 +370,8 @@ export class SidecarProcess { silenceTimeoutMs: options.silenceTimeoutMs, eventBufferCapacity: options.eventBufferCapacity ?? DEFAULT_SIDECAR_EVENT_BUFFER_CAPACITY, - gracefulExitMs: DEFAULT_SIDECAR_GRACEFUL_EXIT_MS, - forceExitMs: DEFAULT_SIDECAR_FORCE_EXIT_MS, + gracefulExitMs: options.gracefulExitMs ?? DEFAULT_SIDECAR_GRACEFUL_EXIT_MS, + forceExitMs: options.forceExitMs ?? DEFAULT_SIDECAR_FORCE_EXIT_MS, disposedErrorMessage: "native sidecar disposed", payloadCodec: options.payloadCodec ?? "bare", }); diff --git a/packages/runtime-core/src/test-runtime.ts b/packages/runtime-core/src/test-runtime.ts index 5307c839a1..6c5e667f2f 100644 --- a/packages/runtime-core/src/test-runtime.ts +++ b/packages/runtime-core/src/test-runtime.ts @@ -3072,6 +3072,8 @@ class NativeKernel implements Kernel { cwd: REPO_ROOT, command: ensureNativeSidecarBinary(), args: [], + gracefulExitMs: 100, + forceExitMs: 100, }), ); const session = await this.measureBoot("session_open", () =>