From b0d7c2f75d55efc85f1bc7e878919f83855bca80 Mon Sep 17 00:00:00 2001 From: Raymo Date: Mon, 14 Sep 2026 19:59:27 +0800 Subject: [PATCH 1/4] fix(core): stop the bash tool from hanging forever when a child holds the pipe executeShellCommand only resolved the tool call inside the child's 'close' event. A backgrounded descendant (`cmd &`, `nohup ... &`) inherits the tool call's stdout/stderr pipes and keeps them open after the shell itself exits, so 'close' never fires; the timeout path called killProcessTree(pid) only, which is a no-op once that pid is gone (taskkill /PID /T /F exits 128) and did not settle the promise either. The promise never settled, so the tool call never returned and the whole CLI session hung permanently (observed 2026-09-13 23:44; the machine had to be hard-powered-off). - settle unconditionally 2s after the timeout kill, and 2s after the child's 'exit' event, destroying the pipes on the way out - ignore a late 'close' once settled, and keep the timeout status instead of letting 'exit' report exitCode 0 for a killed command - append a note telling the model to use run_in_background for detached work - also validate the stored session cwd (fs.statSync().isDirectory()): a poisoned cwd (git-bash /tmp/x stored as \tmp\x) made every later spawn fail with ENOENT, killing the bash tool for the rest of the session Regression test: "Bash settles when a background descendant keeps the output pipe open". --- packages/core/src/tests/tool-handlers.test.ts | 26 ++++++ packages/core/src/tools/bash-handler.ts | 91 ++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index b2e10429..d120d141 100644 --- a/packages/core/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -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 = []; + 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; diff --git a/packages/core/src/tools/bash-handler.ts b/packages/core/src/tools/bash-handler.ts index 5da07944..59409a26 100644 --- a/packages/core/src/tools/bash-handler.ts +++ b/packages/core/src/tools/bash-handler.ts @@ -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(); +// 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) { @@ -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): { @@ -179,6 +212,44 @@ async function executeShellCommand( timeoutTimer = null; } }; + let forceSettleTimer: ReturnType | 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; @@ -186,7 +257,19 @@ async function executeShellCommand( 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) { @@ -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); From 80988616a1912f10684a9c5420b76fad25da5f72 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 15 Sep 2026 16:07:01 +0800 Subject: [PATCH 2/4] fix(core): separate bash execution timeout from output draining --- packages/core/src/tests/tool-handlers.test.ts | 179 ++++++++++++++++-- packages/core/src/tools/bash-handler.ts | 132 +++++++------ 2 files changed, 227 insertions(+), 84 deletions(-) diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index d120d141..c1ebdcf0 100644 --- a/packages/core/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -1,9 +1,14 @@ +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { killProcessTree } from "../common/process-tree"; import { afterEach, test } from "node:test"; import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { setTimeout as delay } from "node:timers/promises"; +import { setTimeout as delay, setImmediate as nextTurn } from "node:timers/promises"; import type { BackgroundProcessCompletion, ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor"; import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; @@ -105,30 +110,162 @@ 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 = []; - const startedAt = Date.now(); +for (const stream of ["stdout", "stderr"]) { + test(`Bash bounds draining when a descendant holds ${stream}`, { timeout: 8_000 }, async () => { + const workspace = createTempWorkspace(); + const exits: Array = []; + const chunks: string[] = []; + let pid: number | undefined; + let control: ProcessTimeoutControl | undefined; + let revoked = 0; + const startedAt = Date.now(); + try { + const result = await handleBashTool( + { command: `sleep 30 ${stream === "stdout" ? "2>/dev/null" : ">/dev/null"} & printf 'hi\\n'` }, + createContext(`bash-held-${stream}`, workspace, { + bashTimeoutMs: 1_000, + bashMinTimeoutMs: 1, + onProcessStart: (value) => { + pid = value as number; + }, + onProcessStdout: (_pid, chunk) => chunks.push(chunk), + onProcessExit: (value) => exits.push(value), + onProcessTimeoutControl: (_pid, value) => { + if (value) control = value; + else revoked++; + }, + }) + ); + assert.ok(Date.now() - startedAt < 6_000); + assert.equal(result.ok, true); + assert.equal(result.metadata?.timedOut, false); + assert.equal(result.metadata?.exitCode, 0); + assert.match(result.output ?? "", /hi/); + assert.match(result.output ?? "", /Output streams did not close/); + assert.equal(exits.length, 1); + assert.equal(revoked, 1); + const info = control!.getInfo(); + assert.deepEqual(control!.setTimeoutMs(1), info); + const count = chunks.length; + await delay(50); + assert.equal(chunks.length, count); + assert.equal(exits.length, 1); + } finally { + if (pid) killProcessTree(pid, "SIGKILL"); + } + }); +} +test("Bash drains delayed output and preserves a failing shell exit", { timeout: 5_000 }, async () => { 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), - }) + { command: "(sleep 0.2; printf 'late-out'; printf 'late-err' >&2) & exit 7" }, + createContext("bash-drain-failure", createTempWorkspace()) ); + assert.equal(result.ok, false); + assert.equal(result.metadata?.exitCode, 7); + assert.match(result.output ?? "", /late-out/); + assert.match(result.output ?? "", /late-err/); + assert.doesNotMatch(result.output ?? "", /Output streams did not close/); +}); - 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); +for (const lateEvent of ["close", "exit", "none"]) { + test(`Bash timeout stays failed with late event: ${lateEvent}`, async (t) => { + const child = Object.assign(new EventEmitter(), { + pid: 12345, + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + const spawnMock = t.mock.method(childProcess, "spawn", () => child); + // Simulate an unsuccessful kill without touching any real process. + const killMock = t.mock.method(process, "kill", () => { + throw new Error("ESRCH"); + }); + const taskkillMock = t.mock.method(childProcess, "spawnSync", () => ({ status: 128 })); + syncBuiltinESMExports(); + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + let exits = 0; + let revocations = 0; + const chunks: string[] = []; + try { + let completed = false; + const promise = handleBashTool( + { command: "ignored" }, + createContext("bash-timeout-race", createTempWorkspace(), { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessExit: () => { + exits++; + }, + onProcessTimeoutControl: (_pid, control) => { + if (!control) revocations++; + }, + onProcessStdout: (_pid, chunk) => { + chunks.push(chunk); + }, + }) + ).then((value) => { + completed = true; + return value; + }); + const captured = "before" + "x".repeat(35_000); + child.stdout.write(captured); + t.mock.timers.tick(100); + assert.ok(killMock.mock.callCount() > 0); + t.mock.timers.tick(1_500); + if (lateEvent !== "none") child.emit("exit", 0, null); + if (lateEvent === "close") child.emit("close", 0, null); + t.mock.timers.tick(500); + await nextTurn(); + assert.equal(completed, true, "late exit must not extend timeout grace"); + const result = await promise; + assert.equal(result.ok, false); + assert.equal(result.error, "Command timed out."); + assert.equal(result.metadata?.timedOut, true); + assert.equal(result.metadata?.exitCode, null); + assert.equal(result.metadata?.signal, null); + assert.match(result.output ?? "", /before/); + assert.equal(result.metadata?.truncated, true); + if (lateEvent !== "close") assert.match(result.output ?? "", /Output streams did not close/); + child.emit("exit", 0, null); + child.emit("close", 0, null); + child.stdout.emit("data", "after"); + t.mock.timers.tick(10_000); + assert.equal(exits, 1); + assert.equal(revocations, 1); + assert.deepEqual(chunks, [captured]); + } finally { + spawnMock.mock.restore(); + killMock.mock.restore(); + taskkillMock.mock.restore(); + t.mock.timers.reset(); + syncBuiltinESMExports(); + } + }); +} + +for (const replacement of ["deleted", "file"]) { + test(`Bash falls back when cached cwd is ${replacement}`, async () => { + const workspace = createTempWorkspace(); + const subdir = path.join(workspace, "child"); + fs.mkdirSync(subdir); + const context = createContext(`bash-cwd-${replacement}`, workspace); + assert.equal((await handleBashTool({ command: "cd child" }, context)).ok, true); + const retained = await handleBashTool({ command: "pwd" }, context); + assert.equal(fs.realpathSync(String(retained.metadata?.startCwd)), fs.realpathSync(subdir)); + fs.rmdirSync(subdir); + if (replacement === "file") fs.writeFileSync(subdir, "not a directory"); + const result = await handleBashTool({ command: "pwd" }, context); + assert.equal(result.ok, true); + assert.equal(fs.realpathSync(String(result.metadata?.startCwd)), fs.realpathSync(workspace)); + }); +} + +test("Bash reports an invalid project root as a spawn failure", { timeout: 3_000 }, async () => { + const root = path.join(createTempWorkspace(), "missing"); + const result = await handleBashTool({ command: "pwd" }, createContext("bash-invalid-root", root)); + assert.equal(result.ok, false); + assert.match(result.error ?? "", /ENOENT/); + assert.equal(result.metadata?.timedOut, false); }); test("Bash can run commands in the background and report completion output", async () => { diff --git a/packages/core/src/tools/bash-handler.ts b/packages/core/src/tools/bash-handler.ts index 59409a26..3aac6991 100644 --- a/packages/core/src/tools/bash-handler.ts +++ b/packages/core/src/tools/bash-handler.ts @@ -20,14 +20,11 @@ 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(); -// 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; +// Process completion and output EOF are separate: descendants may retain pipes. +// Bound output draining after exit, and completion after a timeout kill attempt. +const IO_DRAIN_TIMEOUT_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."; + "[deepcode] Output streams did not close within the drain deadline; later output may not have been collected. Use run_in_background: true for detached work."; export function clearSessionWorkingDir(sessionId: string): void { if (!sessionId) { @@ -85,9 +82,12 @@ export async function handleBashTool( execution.timeoutMs, execution.deadlineAtMs ); + if (execution.outputDrainTimedOut) { + result.output = `${result.output}${result.output && !result.output.endsWith("\n") ? "\n" : ""}${HELD_PIPE_NOTE}`; + } updateSessionCwd(context.sessionId, startCwd, result.cwd); - if (execution.error || result.exitCode !== 0 || result.signal !== null) { + if (execution.timedOut || execution.error || result.exitCode !== 0 || result.signal !== null) { const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error, execution.timedOut); return formatResult({ ...result, ok: false }, "bash", errorMessage); } @@ -179,6 +179,7 @@ async function executeShellCommand( timedOut: boolean; timeoutMs: number; deadlineAtMs: number; + outputDrainTimedOut: boolean; }> { return new Promise((resolve) => { const detached = process.platform !== "win32"; @@ -190,6 +191,11 @@ async function executeShellCommand( let deadlineAtMs = startedAtMs + timeoutMs; let timedOut = false; let settled = false; + let childExit: { code: number | null; signal: string | null } | null = null; + let stdout = ""; + let stderr = ""; + let error: string | undefined; + let timeoutControlRegistered = false; let timeoutTimer: ReturnType | null = null; const child = spawn(shellPath, shellArgs, { cwd, @@ -219,9 +225,13 @@ async function executeShellCommand( 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) => { + const unregisterTimeoutControl = () => { + if (timeoutControlRegistered && typeof pid === "number") { + timeoutControlRegistered = false; + context.onProcessTimeoutControl?.(pid, null); + } + }; + const finish = (outputDrainTimedOut = false) => { if (settled) { return; } @@ -230,49 +240,58 @@ async function executeShellCommand( stopTimeoutTimer(); child.stdout?.destroy(); child.stderr?.destroy(); + unregisterTimeoutControl(); 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, + outputDrainTimedOut, }); }; + const startDrainTimer = () => { + // An exit after timeout must not extend the original completion deadline. + if (!forceSettleTimer) { + forceSettleTimer = setTimeout( + () => + finish( + Boolean((child.stdout && !child.stdout.readableEnded) || (child.stderr && !child.stderr.readableEnded)) + ), + IO_DRAIN_TIMEOUT_MS + ); + } + }; const triggerTimeout = () => { - if (settled || timedOut || typeof pid !== "number") { + if (settled || timedOut || childExit || typeof pid !== "number") { return; } timedOut = true; stopTimeoutTimer(); + startDrainTimer(); + unregisterTimeoutControl(); + // Unix process groups can outlive their leader. Regardless of kill success, + // completion must not depend on either exit or pipe EOF arriving. 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 - ); + if (settled || childExit) { + return; + } + childExit = { code, signal }; + stopTimeoutTimer(); + startDrainTimer(); + unregisterTimeoutControl(); }); const scheduleTimeout = () => { stopTimeoutTimer(); - if (settled) { + if (settled || timedOut || childExit) { return; } const remainingMs = Math.max(0, deadlineAtMs - Date.now()); @@ -281,6 +300,9 @@ async function executeShellCommand( const timeoutControl: ProcessTimeoutControl = { getInfo: getTimeoutInfo, setTimeoutMs: (nextTimeoutMs) => { + if (settled || timedOut || childExit) { + return getTimeoutInfo(); + } timeoutMs = clampBashTimeoutMs(nextTimeoutMs, minTimeoutMs); deadlineAtMs = startedAtMs + timeoutMs; if (deadlineAtMs <= Date.now()) { @@ -292,53 +314,37 @@ async function executeShellCommand( }, }; - if (typeof pid === "number") { - context.onProcessStart?.(pid, command); - context.onProcessTimeoutControl?.(pid, timeoutControl); - scheduleTimeout(); - } - - let stdout = ""; - let stderr = ""; - let error: string | undefined; - child.stdout?.on("data", (chunk: string | Buffer) => { + if (settled) return; stdout = appendChunk(stdout, chunk); const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); context.onProcessStdout?.(pid as number, text); }); child.stderr?.on("data", (chunk: string | Buffer) => { + if (settled) return; stderr = appendChunk(stderr, chunk); const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); context.onProcessStdout?.(pid as number, text); }); child.on("error", (spawnError) => { + if (settled) return; error = spawnError.message; + finish(); }); child.on("close", (code, signal) => { - if (settled) { - return; - } - settled = true; - cancelForceSettleTimer(); - stopTimeoutTimer(); - if (typeof pid === "number") { - context.onProcessTimeoutControl?.(pid, null); - context.onProcessExit?.(pid); - } - resolve({ - stdout, - stderr, - exitCode: typeof code === "number" ? code : null, - signal: signal ?? null, - error, - timedOut, - timeoutMs, - deadlineAtMs, - }); + if (settled) return; + childExit ??= { code, signal }; + finish(); }); + + if (typeof pid === "number") { + context.onProcessStart?.(pid, command); + timeoutControlRegistered = true; + context.onProcessTimeoutControl?.(pid, timeoutControl); + scheduleTimeout(); + } }); } @@ -509,7 +515,7 @@ function buildToolCommandResult( const combined = joinOutput(cleanedStdout, stderr); const { text, truncated } = truncateOutput(combined); return { - ok: exitCode === 0 && signal === null, + ok: !timedOut && exitCode === 0 && signal === null, output: text, cwd, exitCode, @@ -565,12 +571,12 @@ function truncateOutput(output: string): { text: string; truncated: boolean } { } function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string, timedOut = false): string { - if (error) { - return error; - } if (timedOut) { return "Command timed out."; } + if (error) { + return error; + } if (signal) { return `Command terminated by signal ${signal}.`; } From 257c677af89dc97471e6beca1aded6d68c2a76d2 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 15 Sep 2026 16:19:27 +0800 Subject: [PATCH 3/4] fix(core): capture native cwd from Git Bash on Windows --- packages/core/src/tests/tool-handlers.test.ts | 23 ++++++++++++++++++- packages/core/src/tools/bash-handler.ts | 5 +++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index c1ebdcf0..b8147fd1 100644 --- a/packages/core/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -249,7 +249,9 @@ for (const replacement of ["deleted", "file"]) { const subdir = path.join(workspace, "child"); fs.mkdirSync(subdir); const context = createContext(`bash-cwd-${replacement}`, workspace); - assert.equal((await handleBashTool({ command: "cd child" }, context)).ok, true); + const changed = await handleBashTool({ command: "cd child" }, context); + assert.equal(changed.ok, true); + assert.equal(fs.realpathSync(String(changed.metadata?.cwd)), fs.realpathSync(subdir)); const retained = await handleBashTool({ command: "pwd" }, context); assert.equal(fs.realpathSync(String(retained.metadata?.startCwd)), fs.realpathSync(subdir)); fs.rmdirSync(subdir); @@ -260,6 +262,25 @@ for (const replacement of ["deleted", "file"]) { }); } +test( + "Bash preserves Git Bash virtual mount cwd as a native Windows directory", + { skip: process.platform !== "win32" }, + async () => { + const context = createContext("bash-virtual-cwd", createTempWorkspace()); + const changed = await handleBashTool({ command: "cd /tmp && pwd -W" }, context); + assert.equal(changed.ok, true); + const nativeCwd = String(changed.metadata?.cwd); + assert.equal(path.isAbsolute(nativeCwd), true); + assert.equal(fs.statSync(nativeCwd).isDirectory(), true); + assert.equal(fs.realpathSync(nativeCwd), fs.realpathSync((changed.output ?? "").trim())); + + const retained = await handleBashTool({ command: "pwd -W" }, context); + assert.equal(retained.ok, true); + assert.equal(fs.realpathSync(String(retained.metadata?.startCwd)), fs.realpathSync(nativeCwd)); + assert.equal(fs.realpathSync((retained.output ?? "").trim()), fs.realpathSync(nativeCwd)); + } +); + test("Bash reports an invalid project root as a spawn failure", { timeout: 3_000 }, async () => { const root = path.join(createTempWorkspace(), "missing"); const result = await handleBashTool({ command: "pwd" }, createContext("bash-invalid-root", root)); diff --git a/packages/core/src/tools/bash-handler.ts b/packages/core/src/tools/bash-handler.ts index 3aac6991..482f6319 100644 --- a/packages/core/src/tools/bash-handler.ts +++ b/packages/core/src/tools/bash-handler.ts @@ -147,6 +147,9 @@ function buildShellCommand(command: string): { const initCommand = buildShellInitCommand(shellPath); const disableExtglobCommand = buildDisableExtglobCommand(shellPath); const normalizedCommand = rewriteWindowsNullRedirect(command); + // Git Bash mounts such as /tmp and /usr cannot be mapped by replacing + // separators or drive prefixes. Ask Bash for the native path while it is alive. + const cwdExpression = process.platform === "win32" ? '"$(builtin pwd -W)"' : '"$PWD"'; const wrappedParts = []; if (initCommand) { wrappedParts.push(initCommand); @@ -157,7 +160,7 @@ function buildShellCommand(command: string): { wrappedParts.push( normalizedCommand, "__DEEPCODE_STATUS__=$?", - `printf '%s%s\\n' "${marker}" "$PWD"`, + `printf '%s%s\\n' "${marker}" ${cwdExpression}`, "exit $__DEEPCODE_STATUS__" ); const wrappedCommand = `{ ${wrappedParts.join("; ")}; } < /dev/null`; From 698d9e0f9efc34372f0ac7b7a620ad0a2ebc5327 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 15 Sep 2026 16:31:28 +0800 Subject: [PATCH 4/4] test(core): normalize Windows short paths with native realpath --- packages/core/src/tests/tool-handlers.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index b8147fd1..0e8461d0 100644 --- a/packages/core/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -249,16 +249,17 @@ for (const replacement of ["deleted", "file"]) { const subdir = path.join(workspace, "child"); fs.mkdirSync(subdir); const context = createContext(`bash-cwd-${replacement}`, workspace); + // Native realpath also expands Windows 8.3 aliases (RUNNER~1 vs runneradmin). const changed = await handleBashTool({ command: "cd child" }, context); assert.equal(changed.ok, true); - assert.equal(fs.realpathSync(String(changed.metadata?.cwd)), fs.realpathSync(subdir)); + assert.equal(fs.realpathSync.native(String(changed.metadata?.cwd)), fs.realpathSync.native(subdir)); const retained = await handleBashTool({ command: "pwd" }, context); - assert.equal(fs.realpathSync(String(retained.metadata?.startCwd)), fs.realpathSync(subdir)); + assert.equal(fs.realpathSync.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(subdir)); fs.rmdirSync(subdir); if (replacement === "file") fs.writeFileSync(subdir, "not a directory"); const result = await handleBashTool({ command: "pwd" }, context); assert.equal(result.ok, true); - assert.equal(fs.realpathSync(String(result.metadata?.startCwd)), fs.realpathSync(workspace)); + assert.equal(fs.realpathSync.native(String(result.metadata?.startCwd)), fs.realpathSync.native(workspace)); }); } @@ -272,12 +273,12 @@ test( const nativeCwd = String(changed.metadata?.cwd); assert.equal(path.isAbsolute(nativeCwd), true); assert.equal(fs.statSync(nativeCwd).isDirectory(), true); - assert.equal(fs.realpathSync(nativeCwd), fs.realpathSync((changed.output ?? "").trim())); + assert.equal(fs.realpathSync.native(nativeCwd), fs.realpathSync.native((changed.output ?? "").trim())); const retained = await handleBashTool({ command: "pwd -W" }, context); assert.equal(retained.ok, true); - assert.equal(fs.realpathSync(String(retained.metadata?.startCwd)), fs.realpathSync(nativeCwd)); - assert.equal(fs.realpathSync((retained.output ?? "").trim()), fs.realpathSync(nativeCwd)); + assert.equal(fs.realpathSync.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(nativeCwd)); + assert.equal(fs.realpathSync.native((retained.output ?? "").trim()), fs.realpathSync.native(nativeCwd)); } );