fix(coding-agent): fix windows hanging when descendants inherit stdout/stderr handles (#2389)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { waitForChildProcess } from "../utils/child-process.js";
|
||||
|
||||
/**
|
||||
* Options for executing shell commands.
|
||||
@@ -85,20 +86,22 @@ export async function execCommand(
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: code ?? 0, killed });
|
||||
});
|
||||
|
||||
proc.on("error", (_err) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: 1, killed });
|
||||
});
|
||||
// Wait for process termination without hanging on inherited stdio handles
|
||||
// held open by detached descendants.
|
||||
waitForChildProcess(proc)
|
||||
.then((code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: code ?? 0, killed });
|
||||
})
|
||||
.catch((_err) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: 1, killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { waitForChildProcess } from "../../utils/child-process.js";
|
||||
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||
|
||||
@@ -98,13 +99,6 @@ export function createLocalBashOperations(): BashOperations {
|
||||
child.stderr.on("data", onData);
|
||||
}
|
||||
|
||||
// Handle shell spawn errors
|
||||
child.on("error", (err) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Handle abort signal - kill entire process tree
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
@@ -120,23 +114,30 @@ export function createLocalBashOperations(): BashOperations {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
// Handle shell spawn errors and wait for the process to terminate without hanging
|
||||
// on inherited stdio handles held by detached descendants.
|
||||
waitForChildProcess(child)
|
||||
.then((code) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ exitCode: code });
|
||||
});
|
||||
resolve({ exitCode: code });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
86
packages/coding-agent/src/utils/child-process.ts
Normal file
86
packages/coding-agent/src/utils/child-process.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const EXIT_STDIO_GRACE_MS = 100;
|
||||
|
||||
/**
|
||||
* Wait for a child process to terminate without hanging on inherited stdio handles.
|
||||
*
|
||||
* On Windows, daemonized descendants can inherit the child's stdout/stderr pipe
|
||||
* handles. In that case the child emits `exit`, but `close` can hang forever even
|
||||
* though the original process is already gone. We wait briefly for stdio to end,
|
||||
* then forcibly stop tracking the inherited handles.
|
||||
*/
|
||||
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let exited = false;
|
||||
let exitCode: number | null = null;
|
||||
let postExitTimer: NodeJS.Timeout | undefined;
|
||||
let stdoutEnded = child.stdout === null;
|
||||
let stderrEnded = child.stderr === null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (postExitTimer) {
|
||||
clearTimeout(postExitTimer);
|
||||
postExitTimer = undefined;
|
||||
}
|
||||
child.removeListener("error", onError);
|
||||
child.removeListener("exit", onExit);
|
||||
child.removeListener("close", onClose);
|
||||
child.stdout?.removeListener("end", onStdoutEnd);
|
||||
child.stderr?.removeListener("end", onStderrEnd);
|
||||
};
|
||||
|
||||
const finalize = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const maybeFinalizeAfterExit = () => {
|
||||
if (!exited || settled) return;
|
||||
if (stdoutEnded && stderrEnded) {
|
||||
finalize(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdoutEnd = () => {
|
||||
stdoutEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onStderrEnd = () => {
|
||||
stderrEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onExit = (code: number | null) => {
|
||||
exited = true;
|
||||
exitCode = code;
|
||||
maybeFinalizeAfterExit();
|
||||
if (!settled) {
|
||||
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = (code: number | null) => {
|
||||
finalize(code);
|
||||
};
|
||||
|
||||
child.stdout?.once("end", onStdoutEnd);
|
||||
child.stderr?.once("end", onStderrEnd);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
child.once("close", onClose);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user