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"); +});