diff --git a/CHANGELOG.md b/CHANGELOG.md index 30877b6..b1c2f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to WASM-OJ are recorded here. Releases follow ## 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. - Interactive contestants and interactors now use the same in-module instruction meter as standalone runs, and the runtime reads each program's counter when it exits. They are charged exactly what a standalone run charges for the same code; the previous host meter left out the 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 c8f0f35..2799b24 100644 --- a/src/server/server-runner.ts +++ b/src/server/server-runner.ts @@ -724,7 +724,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); @@ -897,7 +897,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); @@ -1015,7 +1015,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);