|
| 1 | +import childProcess from "node:child_process"; |
| 2 | +import { syncBuiltinESMExports } from "node:module"; |
| 3 | +import { EventEmitter } from "node:events"; |
| 4 | +import { PassThrough } from "node:stream"; |
| 5 | +import { killProcessTree } from "../common/process-tree"; |
1 | 6 | import { afterEach, test } from "node:test"; |
2 | 7 | import assert from "node:assert/strict"; |
3 | 8 | import * as fs from "fs"; |
4 | 9 | import * as os from "os"; |
5 | 10 | import * as path from "path"; |
6 | | -import { setTimeout as delay } from "node:timers/promises"; |
| 11 | +import { setTimeout as delay, setImmediate as nextTurn } from "node:timers/promises"; |
7 | 12 | import type { BackgroundProcessCompletion, ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor"; |
8 | 13 | import { handleBashTool } from "../tools/bash-handler"; |
9 | 14 | import { handleEditTool } from "../tools/edit-handler"; |
@@ -105,6 +110,186 @@ test("Bash timeout control can extend the active command deadline", async () => |
105 | 110 | assert.equal(result.metadata?.timeoutMs, 1000); |
106 | 111 | }); |
107 | 112 |
|
| 113 | +for (const stream of ["stdout", "stderr"]) { |
| 114 | + test(`Bash bounds draining when a descendant holds ${stream}`, { timeout: 8_000 }, async () => { |
| 115 | + const workspace = createTempWorkspace(); |
| 116 | + const exits: Array<string | number> = []; |
| 117 | + const chunks: string[] = []; |
| 118 | + let pid: number | undefined; |
| 119 | + let control: ProcessTimeoutControl | undefined; |
| 120 | + let revoked = 0; |
| 121 | + const startedAt = Date.now(); |
| 122 | + try { |
| 123 | + const result = await handleBashTool( |
| 124 | + { command: `sleep 30 ${stream === "stdout" ? "2>/dev/null" : ">/dev/null"} & printf 'hi\\n'` }, |
| 125 | + createContext(`bash-held-${stream}`, workspace, { |
| 126 | + bashTimeoutMs: 1_000, |
| 127 | + bashMinTimeoutMs: 1, |
| 128 | + onProcessStart: (value) => { |
| 129 | + pid = value as number; |
| 130 | + }, |
| 131 | + onProcessStdout: (_pid, chunk) => chunks.push(chunk), |
| 132 | + onProcessExit: (value) => exits.push(value), |
| 133 | + onProcessTimeoutControl: (_pid, value) => { |
| 134 | + if (value) control = value; |
| 135 | + else revoked++; |
| 136 | + }, |
| 137 | + }) |
| 138 | + ); |
| 139 | + assert.ok(Date.now() - startedAt < 6_000); |
| 140 | + assert.equal(result.ok, true); |
| 141 | + assert.equal(result.metadata?.timedOut, false); |
| 142 | + assert.equal(result.metadata?.exitCode, 0); |
| 143 | + assert.match(result.output ?? "", /hi/); |
| 144 | + assert.match(result.output ?? "", /Output streams did not close/); |
| 145 | + assert.equal(exits.length, 1); |
| 146 | + assert.equal(revoked, 1); |
| 147 | + const info = control!.getInfo(); |
| 148 | + assert.deepEqual(control!.setTimeoutMs(1), info); |
| 149 | + const count = chunks.length; |
| 150 | + await delay(50); |
| 151 | + assert.equal(chunks.length, count); |
| 152 | + assert.equal(exits.length, 1); |
| 153 | + } finally { |
| 154 | + if (pid) killProcessTree(pid, "SIGKILL"); |
| 155 | + } |
| 156 | + }); |
| 157 | +} |
| 158 | + |
| 159 | +test("Bash drains delayed output and preserves a failing shell exit", { timeout: 5_000 }, async () => { |
| 160 | + const result = await handleBashTool( |
| 161 | + { command: "(sleep 0.2; printf 'late-out'; printf 'late-err' >&2) & exit 7" }, |
| 162 | + createContext("bash-drain-failure", createTempWorkspace()) |
| 163 | + ); |
| 164 | + assert.equal(result.ok, false); |
| 165 | + assert.equal(result.metadata?.exitCode, 7); |
| 166 | + assert.match(result.output ?? "", /late-out/); |
| 167 | + assert.match(result.output ?? "", /late-err/); |
| 168 | + assert.doesNotMatch(result.output ?? "", /Output streams did not close/); |
| 169 | +}); |
| 170 | + |
| 171 | +for (const lateEvent of ["close", "exit", "none"]) { |
| 172 | + test(`Bash timeout stays failed with late event: ${lateEvent}`, async (t) => { |
| 173 | + const child = Object.assign(new EventEmitter(), { |
| 174 | + pid: 12345, |
| 175 | + stdout: new PassThrough(), |
| 176 | + stderr: new PassThrough(), |
| 177 | + }); |
| 178 | + const spawnMock = t.mock.method(childProcess, "spawn", () => child); |
| 179 | + // Simulate an unsuccessful kill without touching any real process. |
| 180 | + const killMock = t.mock.method(process, "kill", () => { |
| 181 | + throw new Error("ESRCH"); |
| 182 | + }); |
| 183 | + const taskkillMock = t.mock.method(childProcess, "spawnSync", () => ({ status: 128 })); |
| 184 | + syncBuiltinESMExports(); |
| 185 | + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); |
| 186 | + let exits = 0; |
| 187 | + let revocations = 0; |
| 188 | + const chunks: string[] = []; |
| 189 | + try { |
| 190 | + let completed = false; |
| 191 | + const promise = handleBashTool( |
| 192 | + { command: "ignored" }, |
| 193 | + createContext("bash-timeout-race", createTempWorkspace(), { |
| 194 | + bashTimeoutMs: 100, |
| 195 | + bashMinTimeoutMs: 1, |
| 196 | + onProcessExit: () => { |
| 197 | + exits++; |
| 198 | + }, |
| 199 | + onProcessTimeoutControl: (_pid, control) => { |
| 200 | + if (!control) revocations++; |
| 201 | + }, |
| 202 | + onProcessStdout: (_pid, chunk) => { |
| 203 | + chunks.push(chunk); |
| 204 | + }, |
| 205 | + }) |
| 206 | + ).then((value) => { |
| 207 | + completed = true; |
| 208 | + return value; |
| 209 | + }); |
| 210 | + const captured = "before" + "x".repeat(35_000); |
| 211 | + child.stdout.write(captured); |
| 212 | + t.mock.timers.tick(100); |
| 213 | + assert.ok(killMock.mock.callCount() > 0); |
| 214 | + t.mock.timers.tick(1_500); |
| 215 | + if (lateEvent !== "none") child.emit("exit", 0, null); |
| 216 | + if (lateEvent === "close") child.emit("close", 0, null); |
| 217 | + t.mock.timers.tick(500); |
| 218 | + await nextTurn(); |
| 219 | + assert.equal(completed, true, "late exit must not extend timeout grace"); |
| 220 | + const result = await promise; |
| 221 | + assert.equal(result.ok, false); |
| 222 | + assert.equal(result.error, "Command timed out."); |
| 223 | + assert.equal(result.metadata?.timedOut, true); |
| 224 | + assert.equal(result.metadata?.exitCode, null); |
| 225 | + assert.equal(result.metadata?.signal, null); |
| 226 | + assert.match(result.output ?? "", /before/); |
| 227 | + assert.equal(result.metadata?.truncated, true); |
| 228 | + if (lateEvent !== "close") assert.match(result.output ?? "", /Output streams did not close/); |
| 229 | + child.emit("exit", 0, null); |
| 230 | + child.emit("close", 0, null); |
| 231 | + child.stdout.emit("data", "after"); |
| 232 | + t.mock.timers.tick(10_000); |
| 233 | + assert.equal(exits, 1); |
| 234 | + assert.equal(revocations, 1); |
| 235 | + assert.deepEqual(chunks, [captured]); |
| 236 | + } finally { |
| 237 | + spawnMock.mock.restore(); |
| 238 | + killMock.mock.restore(); |
| 239 | + taskkillMock.mock.restore(); |
| 240 | + t.mock.timers.reset(); |
| 241 | + syncBuiltinESMExports(); |
| 242 | + } |
| 243 | + }); |
| 244 | +} |
| 245 | + |
| 246 | +for (const replacement of ["deleted", "file"]) { |
| 247 | + test(`Bash falls back when cached cwd is ${replacement}`, async () => { |
| 248 | + const workspace = createTempWorkspace(); |
| 249 | + const subdir = path.join(workspace, "child"); |
| 250 | + fs.mkdirSync(subdir); |
| 251 | + const context = createContext(`bash-cwd-${replacement}`, workspace); |
| 252 | + // Native realpath also expands Windows 8.3 aliases (RUNNER~1 vs runneradmin). |
| 253 | + const changed = await handleBashTool({ command: "cd child" }, context); |
| 254 | + assert.equal(changed.ok, true); |
| 255 | + assert.equal(fs.realpathSync.native(String(changed.metadata?.cwd)), fs.realpathSync.native(subdir)); |
| 256 | + const retained = await handleBashTool({ command: "pwd" }, context); |
| 257 | + assert.equal(fs.realpathSync.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(subdir)); |
| 258 | + fs.rmdirSync(subdir); |
| 259 | + if (replacement === "file") fs.writeFileSync(subdir, "not a directory"); |
| 260 | + const result = await handleBashTool({ command: "pwd" }, context); |
| 261 | + assert.equal(result.ok, true); |
| 262 | + assert.equal(fs.realpathSync.native(String(result.metadata?.startCwd)), fs.realpathSync.native(workspace)); |
| 263 | + }); |
| 264 | +} |
| 265 | + |
| 266 | +test( |
| 267 | + "Bash preserves Git Bash virtual mount cwd as a native Windows directory", |
| 268 | + { skip: process.platform !== "win32" }, |
| 269 | + async () => { |
| 270 | + const context = createContext("bash-virtual-cwd", createTempWorkspace()); |
| 271 | + const changed = await handleBashTool({ command: "cd /tmp && pwd -W" }, context); |
| 272 | + assert.equal(changed.ok, true); |
| 273 | + const nativeCwd = String(changed.metadata?.cwd); |
| 274 | + assert.equal(path.isAbsolute(nativeCwd), true); |
| 275 | + assert.equal(fs.statSync(nativeCwd).isDirectory(), true); |
| 276 | + assert.equal(fs.realpathSync.native(nativeCwd), fs.realpathSync.native((changed.output ?? "").trim())); |
| 277 | + |
| 278 | + const retained = await handleBashTool({ command: "pwd -W" }, context); |
| 279 | + assert.equal(retained.ok, true); |
| 280 | + assert.equal(fs.realpathSync.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(nativeCwd)); |
| 281 | + assert.equal(fs.realpathSync.native((retained.output ?? "").trim()), fs.realpathSync.native(nativeCwd)); |
| 282 | + } |
| 283 | +); |
| 284 | + |
| 285 | +test("Bash reports an invalid project root as a spawn failure", { timeout: 3_000 }, async () => { |
| 286 | + const root = path.join(createTempWorkspace(), "missing"); |
| 287 | + const result = await handleBashTool({ command: "pwd" }, createContext("bash-invalid-root", root)); |
| 288 | + assert.equal(result.ok, false); |
| 289 | + assert.match(result.error ?? "", /ENOENT/); |
| 290 | + assert.equal(result.metadata?.timedOut, false); |
| 291 | +}); |
| 292 | + |
108 | 293 | test("Bash can run commands in the background and report completion output", async () => { |
109 | 294 | const workspace = createTempWorkspace(); |
110 | 295 | let completion: BackgroundProcessCompletion | null = null; |
|
0 commit comments