From 014afa38929d46d58067b617c707759cd753cf8d Mon Sep 17 00:00:00 2001 From: DeryFerd Date: Sat, 17 Oct 2026 20:44:04 +0700 Subject: [PATCH] fix(process): gate PTY launch on cancellation after executable sampling Executable sampling ends with awaited descriptor stat and close calls, so a caller cancellation can arrive after the sampler's last internal check. The sampler correctly retained the completed digest and closed its descriptor, and the caller then launched the selected program anyway. Recheck the caller's signal at the consuming launch boundary. A completed sample establishes file facts; it does not authorize execution for an operation that has already been cancelled. Refs #4512 --- src/process/capture/ProcessHarness.ts | 5 + .../processPrelaunchCancellation.test.ts | 112 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/process-global/processPrelaunchCancellation.test.ts diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index 5a1697d387..d881ec78c9 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -272,6 +272,11 @@ const startCaptureRuntime = async ( scenario.executable, options.signal, ); + // Sampling ends with awaited descriptor stat and close calls, so a caller + // cancellation can arrive after the sampler's last internal check. A + // completed sample establishes file facts; it must not authorize a launch + // for an operation that has already been cancelled. + assertNotCancelled(options.signal); const startedAt = new Date(); const started = monotonicProcessCaptureClock(); let lastOutput = started; diff --git a/tests/process-global/processPrelaunchCancellation.test.ts b/tests/process-global/processPrelaunchCancellation.test.ts new file mode 100644 index 0000000000..587e21257c --- /dev/null +++ b/tests/process-global/processPrelaunchCancellation.test.ts @@ -0,0 +1,112 @@ +import { expect, it, vi } from "vitest"; + +import { captureProcessScenario } from "../../src/process/capture/ProcessHarness.js"; +import { parseProcessScenario } from "../../src/domain/process/processScenario.js"; +import { emptyProcessCapture } from "../../src/domain/process/processCapture.fixture.js"; + +const control = vi.hoisted(() => ({ + abortPath: undefined as string | undefined, + controller: undefined as AbortController | undefined, +})); + +const launch = vi.hoisted(() => ({ calls: 0 })); + +/** + * Intercept the native PTY launch. The launch decision is what this regression + * covers; no target process may be created, so the sentinel throws before any + * child exists. + */ +vi.mock("@lydell/node-pty", () => ({ + spawn: () => { + launch.calls += 1; + throw new Error("PTY launch sentinel: a target would have been started"); + }, +})); + +/** + * Abort during the descriptor close that ends executable sampling. Real + * `open`/`stat`/`close` still run, so the sampler's completed digest stays + * genuine; only the cancellation schedule is injected. + */ +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + open: async (...args: Parameters) => { + const handle = await original.open(...args); + if (control.abortPath === undefined || args[0] !== control.abortPath) + return handle; + const close = handle.close.bind(handle); + handle.close = async () => { + await close(); + control.controller?.abort(); + }; + return handle; + }, + }; +}); + +it("does not launch the target when cancellation lands during executable sampling", async () => { + const controller = new AbortController(); + control.abortPath = process.execPath; + control.controller = controller; + launch.calls = 0; + try { + const result = await captureProcessScenario( + parseProcessScenario({ + executable: process.execPath, + arguments: ["--eval", "process.exit(0)"], + working_directory: process.cwd(), + timeout_ms: 5_000, + idle_timeout_ms: 5_000, + settle_ms: 0, + }), + controller.signal, + // The portable startup logic is platform-independent; the controlled + // Linux platform keeps the harness's own admission gate out of the way. + "linux", + {}, + async () => ({ + files: [], + truncated: false, + completeRoots: [], + coverage: emptyProcessCapture().truncation_details.filesystem_before, + }), + ); + expect(launch.calls).toBe(0); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toMatchObject({ reason: "cancelled" }); + } finally { + control.abortPath = undefined; + control.controller = undefined; + } +}); + +it("still launches when executable sampling completes without cancellation", async () => { + control.abortPath = undefined; + launch.calls = 0; + const result = await captureProcessScenario( + parseProcessScenario({ + executable: process.execPath, + arguments: ["--eval", "process.exit(0)"], + working_directory: process.cwd(), + timeout_ms: 5_000, + idle_timeout_ms: 5_000, + settle_ms: 0, + }), + undefined, + "linux", + {}, + async () => ({ + files: [], + truncated: false, + completeRoots: [], + coverage: emptyProcessCapture().truncation_details.filesystem_before, + }), + ); + expect(launch.calls).toBe(1); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected the launch sentinel failure"); + expect(String(result.error.cause)).toContain("PTY launch sentinel"); +});