Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/core/src/tests/tool-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ test("Bash timeout control can extend the active command deadline", async () =>
assert.equal(result.metadata?.timeoutMs, 1000);
});

test("Bash settles when a background descendant keeps the output pipe open", async () => {
const workspace = createTempWorkspace();
const exitedPids: Array<string | number> = [];
const startedAt = Date.now();

const result = await handleBashTool(
{
// `sleep 5 &` inherits the tool's stdout/stderr pipes and outlives the shell,
// so once the shell is gone the kill is a no-op and 'close' never arrives.
// The call must still settle instead of wedging the session forever.
command: "sleep 5 & printf 'hi\\n'",
},
createContext("bash-held-pipe", workspace, {
bashTimeoutMs: 60_000,
bashMinTimeoutMs: 1,
onProcessExit: (pid) => exitedPids.push(pid),
})
);

assert.ok(Date.now() - startedAt < 10_000, "must not wait for the 60s command timeout");
assert.equal(result.ok, true);
assert.match(result.output ?? "", /hi/);
assert.match(result.output ?? "", /background process still holds/);
assert.equal(exitedPids.length, 1);
});

test("Bash can run commands in the background and report completion output", async () => {
const workspace = createTempWorkspace();
let completion: BackgroundProcessCompletion | null = null;
Expand Down
91 changes: 89 additions & 2 deletions packages/core/src/tools/bash-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const MAX_CAPTURE_CHARS = 10 * 1024 * 1024;
const BACKGROUND_OUTPUT_DIR = path.join(os.tmpdir(), "deepcode-background");
const TRAILING_BACKGROUND_OPERATOR_PATTERN = /(^|[^\\&])\s*&\s*$/;
const sessionWorkingDirs = new Map<string, string>();
// A backgrounded descendant (`foo &`, `nohup ... &`) inherits this tool call's
// stdout/stderr pipes. Once the shell itself is gone, killing its pid is a no-op
// and 'close' may never fire, which used to hang the tool call — and the whole
// session — forever. After these graces the promise is settled unconditionally.
const TIMEOUT_SETTLE_GRACE_MS = 2_000;
const EXIT_SETTLE_GRACE_MS = 2_000;
const HELD_PIPE_NOTE =
"[deepcode] The command shell exited, but a background process still holds this call's output pipe; the call was settled anyway. Use run_in_background: true for detached work.";

export function clearSessionWorkingDir(sessionId: string): void {
if (!sessionId) {
Expand Down Expand Up @@ -96,12 +104,37 @@ function stripTrailingBackgroundOperator(command: string): string {
}

function getSessionCwd(sessionId: string, fallback: string): string {
return sessionWorkingDirs.get(sessionId) ?? fallback;
const stored = sessionWorkingDirs.get(sessionId);
if (stored && isUsableCwd(stored)) {
return stored;
}
// 存储的 cwd 已失效(如 Git Bash 虚拟路径 /tmp 转成 Windows 后不存在),
// 回退到 projectRoot 并清掉坏记录,避免 spawn ENOENT 导致整个 bash 工具坏死。
if (stored) {
sessionWorkingDirs.delete(sessionId);
}
return fallback;
}

function updateSessionCwd(sessionId: string, fallback: string, cwd: string | null): void {
const nextCwd = cwd ?? fallback;
sessionWorkingDirs.set(sessionId, nextCwd);
// 只记录有效目录;无效 cwd(如 Git Bash 的 /tmp 被转成 \tmp)会导致下次 spawn 失败。
if (isUsableCwd(nextCwd)) {
sessionWorkingDirs.set(sessionId, nextCwd);
} else {
sessionWorkingDirs.delete(sessionId);
}
}

function isUsableCwd(cwd: string): boolean {
if (!cwd) {
return false;
}
try {
return fs.statSync(cwd).isDirectory();
} catch {
return false;
}
}

function buildShellCommand(command: string): {
Expand Down Expand Up @@ -179,14 +212,64 @@ async function executeShellCommand(
timeoutTimer = null;
}
};
let forceSettleTimer: ReturnType<typeof setTimeout> | null = null;
const cancelForceSettleTimer = () => {
if (forceSettleTimer) {
clearTimeout(forceSettleTimer);
forceSettleTimer = null;
}
};
// Settle even when 'close' never fires: a detached descendant can keep the
// stdout/stderr pipes open forever after the shell pid itself is gone.
const forceSettle = (childExit: { code: number | null; signal: string | null } | null) => {
if (settled) {
return;
}
settled = true;
cancelForceSettleTimer();
stopTimeoutTimer();
child.stdout?.destroy();
child.stderr?.destroy();
if (typeof pid === "number") {
context.onProcessTimeoutControl?.(pid, null);
context.onProcessExit?.(pid);
}
if (childExit && !timedOut) {
stdout = `${stdout}${stdout && !stdout.endsWith("\n") ? "\n" : ""}${HELD_PIPE_NOTE}\n`;
}
resolve({
stdout,
stderr,
// Once the timeout has fired the exit status is meaningless: the shell was
// killed, so report the timeout rather than a bogus success.
exitCode: timedOut ? null : (childExit?.code ?? null),
signal: timedOut ? null : (childExit?.signal ?? null),
error,
timedOut,
timeoutMs,
deadlineAtMs,
});
};
const triggerTimeout = () => {
if (settled || timedOut || typeof pid !== "number") {
return;
}
timedOut = true;
stopTimeoutTimer();
killProcessTree(pid, "SIGKILL");
// The kill above is a no-op once the shell pid is gone, so do not rely on
// 'close' to ever arrive.
forceSettleTimer = setTimeout(() => forceSettle(null), TIMEOUT_SETTLE_GRACE_MS);
};
child.on("exit", (code, signal) => {
// The shell is gone; if 'close' has not followed right away a descendant is
// holding our pipes, so settle instead of hanging until the timeout.
cancelForceSettleTimer();
forceSettleTimer = setTimeout(
() => forceSettle({ code: typeof code === "number" ? code : null, signal: signal ?? null }),
EXIT_SETTLE_GRACE_MS
);
});
const scheduleTimeout = () => {
stopTimeoutTimer();
if (settled) {
Expand Down Expand Up @@ -235,7 +318,11 @@ async function executeShellCommand(
});

child.on("close", (code, signal) => {
if (settled) {
return;
}
settled = true;
cancelForceSettleTimer();
stopTimeoutTimer();
if (typeof pid === "number") {
context.onProcessTimeoutControl?.(pid, null);
Expand Down