diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index b2e10429..0e8461d0 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,6 +110,186 @@ test("Bash timeout control can extend the active command deadline", async () => assert.equal(result.metadata?.timeoutMs, 1000); }); +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( + { 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/); +}); + +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); + // 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.native(String(changed.metadata?.cwd)), fs.realpathSync.native(subdir)); + const retained = await handleBashTool({ command: "pwd" }, context); + 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.native(String(result.metadata?.startCwd)), fs.realpathSync.native(workspace)); + }); +} + +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.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.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(nativeCwd)); + assert.equal(fs.realpathSync.native((retained.output ?? "").trim()), fs.realpathSync.native(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)); + 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 () => { 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..482f6319 100644 --- a/packages/core/src/tools/bash-handler.ts +++ b/packages/core/src/tools/bash-handler.ts @@ -20,6 +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(); +// 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] 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) { @@ -77,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); } @@ -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): { @@ -114,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); @@ -124,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`; @@ -146,6 +182,7 @@ async function executeShellCommand( timedOut: boolean; timeoutMs: number; deadlineAtMs: number; + outputDrainTimedOut: boolean; }> { return new Promise((resolve) => { const detached = process.platform !== "win32"; @@ -157,6 +194,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, @@ -179,17 +221,80 @@ async function executeShellCommand( timeoutTimer = null; } }; + let forceSettleTimer: ReturnType | null = null; + const cancelForceSettleTimer = () => { + if (forceSettleTimer) { + clearTimeout(forceSettleTimer); + forceSettleTimer = null; + } + }; + const unregisterTimeoutControl = () => { + if (timeoutControlRegistered && typeof pid === "number") { + timeoutControlRegistered = false; + context.onProcessTimeoutControl?.(pid, null); + } + }; + const finish = (outputDrainTimedOut = false) => { + if (settled) { + return; + } + settled = true; + cancelForceSettleTimer(); + stopTimeoutTimer(); + child.stdout?.destroy(); + child.stderr?.destroy(); + unregisterTimeoutControl(); + if (typeof pid === "number") { + context.onProcessExit?.(pid); + } + resolve({ + stdout, + stderr, + 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"); }; + child.on("exit", (code, signal) => { + 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()); @@ -198,6 +303,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()) { @@ -209,49 +317,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) => { - settled = true; - 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(); + } }); } @@ -422,7 +518,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, @@ -478,12 +574,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}.`; }