From f1e1b3995190efdab1ef637ba4f29173e7043bc6 Mon Sep 17 00:00:00 2001 From: TakalaWang Date: Tue, 6 Oct 2026 20:00:52 +0800 Subject: [PATCH] fix(server): keep a stdin error handler after cancelling a run ServerRunner's cleanup for the runtime preparation stage, native runs and native interactive sessions removed the child stdin `error` listener. When a run is cancelled or hits its wall deadline while the parent is still writing a large request (for example the ~15 MB CPython runtime request), the child is SIGKILLed, the pending write fails with EPIPE, and with no listener left the error is emitted unhandled and crashes the host process with "Uncaught Error: write EPIPE". Swap the listener for a no-op instead of dropping it, mirroring the existing late child `error` handling. ServerCompiler and the Go stage already keep their stdin error listeners for the child's lifetime. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ src/server/server-runner-preparation.test.ts | 34 +++++++++++++++++++- src/server/server-runner.ts | 6 ++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d204d..c8e6110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to WASM-OJ are recorded here. Releases follow [Semantic Versioning](https://semver.org/) and the contract/package versioning policy in [the versioning policy](docs/versioning.md). +## Unreleased + +- Fixed a host process crash (`Uncaught Error: write EPIPE`) when `ServerRunner` cancelled or + timed out a runtime preparation stage, run or interactive session while its request was + still being written to the child's stdin. Late stdin errors after cleanup are now ignored. + ## 0.2.3 - 2026-10-05 - Download pinned browser toolchain assets before the compiler timeout starts, so slow diff --git a/src/server/server-runner-preparation.test.ts b/src/server/server-runner-preparation.test.ts index 600753e..f77986c 100644 --- a/src/server/server-runner-preparation.test.ts +++ b/src/server/server-runner-preparation.test.ts @@ -9,7 +9,7 @@ import { costProfileId } from "../core/cost-profile"; import { DEFAULT_DETERMINISM } from "../core/determinism"; import { DEFAULT_RESOURCE_POLICY } from "../core/resources"; import { PYTHON_PACKAGE } from "../core/toolchains"; -import type { BuildArtifact, RunConfig } from "../core/types"; +import type { BuildArtifact, InteractiveRunConfig, RunConfig } from "../core/types"; import { RuntimeDriverRegistry } from "@wasm-oj/core"; const spawnState = vi.hoisted(() => ({ spawn: vi.fn() })); @@ -118,6 +118,30 @@ describe("ServerRunner isolated preparation lifecycle", () => { } }, ); + + it.each(["preparation", "run", "interaction"] as const)( + "absorbs a late stdin EPIPE after a cancelled %s child is killed", + async (stage) => { + const child = stalledChild(); + spawnState.spawn.mockReturnValue(child); + const runner = stage === "preparation" ? await createRunner() : await createNativeRunner(); + try { + const running = stage === "interaction" + ? runner.interact(wasmArtifact(), wasmArtifact(), interactiveConfig()) + : runner.run(wasmArtifact(), runConfig()); + const rejection = expect(running).rejects.toThrow(/cancel|superseded/i); + await vi.waitFor(() => expect(spawnState.spawn).toHaveBeenCalledOnce()); + + runner.cancel(); + await rejection; + + const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + expect(() => child.stdin.emit("error", epipe)).not.toThrow(); + } finally { + runner.dispose(); + } + }, + ); }); async function createRunner(): Promise { @@ -200,3 +224,11 @@ function runConfig(): RunConfig { resources: { ...DEFAULT_RESOURCE_POLICY }, }; } + +function interactiveConfig(): InteractiveRunConfig { + return { + contestant: { args: [], env: {}, resources: { ...DEFAULT_RESOURCE_POLICY } }, + interactor: { args: [], env: {}, resources: { ...DEFAULT_RESOURCE_POLICY } }, + determinism: { ...DEFAULT_DETERMINISM }, + }; +} diff --git a/src/server/server-runner.ts b/src/server/server-runner.ts index 6eaa268..d0fc5fd 100644 --- a/src/server/server-runner.ts +++ b/src/server/server-runner.ts @@ -727,7 +727,7 @@ export class ServerRunner implements Runner { const cleanup = (ignoreLateChildError: boolean) => { child.off("close", onClose); child.off("error", onChildError); - child.stdin.off("error", onStdinError); + child.stdin.off("error", onStdinError).on("error", () => undefined); child.stdout.off("data", onStdout); child.stderr.off("data", onStderr); this.activePreparationStages.delete(active); @@ -900,7 +900,7 @@ export class ServerRunner implements Runner { clearTimeout(timer); child.off("close", onClose); child.off("error", onChildError); - child.stdin.off("error", onStdinError); + child.stdin.off("error", onStdinError).on("error", () => undefined); child.stdout.off("data", onStdout); child.stderr.off("data", onStderr); this.activeNativeRuns.delete(active); @@ -1018,7 +1018,7 @@ export class ServerRunner implements Runner { clearTimeout(timer); child.off("close", onClose); child.off("error", onChildError); - child.stdin.off("error", onStdinError); + child.stdin.off("error", onStdinError).on("error", () => undefined); child.stdout.off("data", onStdout); child.stderr.off("data", onStderr); this.activeNativeRuns.delete(active);