fix(process): await Swift frontend lifetime pipe (#7948)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 08:43:04 +08:00
1 parent 0448f318e6
commit c92322b473
4 files changed
+40 -15

No files matched your search

+4 -3
View File
@@ -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) {
+8
View File
@@ -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();
+23 -6
View File
@@ -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<typeof child.stdout>,
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;
});
@@ -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];