diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index f01d4fc2ae..fd2a50407f 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -523,6 +523,7 @@ export const createDarwinProcessRunTokenReader = ( maxBuffer: 1024 * 1024, signal, stopSignal: SWIFTC_INTERRUPT, + lifetimePipe: true, env: compilerEnvironment, }); } catch (cause: unknown) { @@ -535,9 +536,9 @@ export const createDarwinProcessRunTokenReader = ( } catch (cause: unknown) { operation.primaryFailure = { cause }; if (operationRoot !== undefined) { - // execFileOutput has settled, so inherited compiler pipes are closed. - // That is not proof for a child that did not inherit them; this owner - // does not signal processes it did not launch. + // The extra pipe keeps settlement pending for Swift frontend writers + // that inherit it after the driver and its standard streams close. + // It cannot establish completion of a child that did not inherit it. try { await cleanupRoot(operationRoot); } catch (cleanup: unknown) { diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 14cc0f59b4..60066cb19a 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -68,6 +68,14 @@ it.each([undefined, "SIGTERM"] as const)( }, ); +it("rejects a lifetime pipe without a stoppable command", async () => { + await expect( + execFileOutput(process.execPath, ["-e", "process.exit(0)"], { + lifetimePipe: true, + }), + ).rejects.toThrow("lifetimePipe requires stopSignal"); +}); + describe("execFileOutput", () => { it("ignores non-Error and incomplete failure metadata", () => { expect(execFileOutputFailure("failure")).toBeUndefined(); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 86f3920391..19522a6901 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -20,6 +20,8 @@ type ExecFileOutputOptions = Omit< * execFile sends SIGTERM on abort and rejects before the process exits. */ readonly stopSignal?: NodeJS.Signals; + /** Wait for inherited copies of an extra drained descriptor to close. Requires stopSignal. */ + readonly lifetimePipe?: boolean; }; /** How long a stopped process may take to exit before it is killed. */ @@ -149,7 +151,12 @@ export const execFileBytes = ( options: ExecFileOutputOptions = {}, ): Promise<{ readonly stdout: Buffer; readonly stderr: Buffer }> => new Promise((resolve, reject) => { - const { stopSignal, signal, timeout, ...execOptions } = options; + const { stopSignal, signal, timeout, lifetimePipe, ...execOptions } = + options; + if (lifetimePipe === true && stopSignal === undefined) { + reject(new TypeError("lifetimePipe requires stopSignal")); + return; + } const invalid = timeout === undefined ? undefined : invalidTimeout(timeout); if (invalid !== undefined) { reject(invalid); @@ -213,7 +220,7 @@ const startStoppableCommand = ( options: ExecFileOutputOptions & { readonly stopSignal: NodeJS.Signals }, complete: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, ): void => { - const { stopSignal, signal, timeout, ...execOptions } = options; + const { stopSignal, signal, timeout, lifetimePipe, ...execOptions } = options; const maxBuffer = options.maxBuffer ?? Number.POSITIVE_INFINITY; if (typeof maxBuffer !== "number" || !(maxBuffer >= 0)) { const error = new RangeError("maxBuffer must be a nonnegative number"); @@ -231,9 +238,16 @@ const startStoppableCommand = ( // pipe lifetime of descendants still using caller-owned temporary files. const child = spawn(command, [...arguments_], { ...execOptions, - stdio: ["ignore", "pipe", "pipe"], + stdio: + lifetimePipe === true + ? ["ignore", "pipe", "pipe", "pipe"] + : ["ignore", "pipe", "pipe"], killSignal: stopSignal, }); + if (lifetimePipe === true) { + const pipe = child.stdio[3]; + if (pipe != null && "resume" in pipe) pipe.resume(); + } // The first stop request names the failure; a later one is not resent. const stop = (reason: "abort" | "timeout" | "output-limit"): void => { if (stopped !== undefined || rootFailed || processError !== null) return; @@ -244,7 +258,10 @@ const startStoppableCommand = ( const onAbort = (): void => { stop("abort"); }; - const capture = (stream: typeof child.stdout, name: "stdout" | "stderr") => { + const capture = ( + stream: NonNullable, + name: "stdout" | "stderr", + ) => { const chunks: Buffer[] = []; let retained = 0; stream.on("data", (chunk: Buffer) => { @@ -266,8 +283,8 @@ const startStoppableCommand = ( }); return () => Buffer.concat(chunks, retained); }; - const stdout = capture(child.stdout, "stdout"); - const stderr = capture(child.stderr, "stderr"); + const stdout = capture(child.stdout!, "stdout"); + const stderr = capture(child.stderr!, "stderr"); child.on("error", (error: Error) => { processError ??= error; }); diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index f3c5ccbd97..64d224a3b0 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -813,9 +813,8 @@ const temporaryStateSchema = z.object({ }); /** - * A compiler that behaves like swift-driver: a job in its own process group - * writes an object under TMPDIR, SIGINT stops the job and deletes the object, - * and any other signal leaves the job writing after the driver exits. + * A compiler whose detached job closes standard streams but retains fd 3, + * writes an object after the SIGINT-stopped driver exits, then closes fd 3. */ const writeTemporaryObjectCompiler = async (directory: string) => { const executable = join(directory, "fake-temporary-xcrun"); @@ -828,14 +827,14 @@ const path = process.getBuiltinModule("node:path"); const { spawn } = process.getBuiltinModule("node:child_process"); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "TemporaryDirectory.")); const object = path.join(temporary, "ProcessRunTokenReader-1.o"); +let lifetime = "ignore"; +try { if (fs.fstatSync(3).isSocket()) lifetime = 3; } catch {} const job = spawn(process.execPath, ["-e", "setTimeout(() => { const fs = process.getBuiltinModule('node:fs'); fs.mkdirSync(" + JSON.stringify(temporary) + ", { recursive: true }); fs.writeFileSync(" + JSON.stringify(object) + ", 'object'); }, 300)"], - { detached: true, stdio: "ignore" }); + { detached: true, stdio: ["ignore", "ignore", "ignore", lifetime] }); job.unref(); process.on("SIGINT", () => { - job.kill("SIGKILL"); - fs.rmSync(object, { force: true }); process.exit(130); }); const output = process.argv[process.argv.indexOf("-o") + 1];