import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { executeBash } from "../src/core/bash-executor.js"; import { createBashTool } from "../src/core/tools/bash.js"; function toBashSingleQuotedArg(value: string): string { return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`; } function createInheritedStdioCommand(pidFile: string): string { const pidFileArg = toBashSingleQuotedArg(pidFile); return ( 'node -e "' + "const fs=require('fs');" + "const {spawn}=require('child_process');" + "const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" + "fs.writeFileSync(process.argv[1], String(child.pid));" + "child.unref();" + "console.log('child-exiting');" + '" ' + pidFileArg ); } function cleanupDetachedChild(pidFile: string): void { if (!existsSync(pidFile)) { return; } const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10); if (Number.isFinite(pid) && pid > 0) { try { execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" }); } catch { // Process may have already exited. } } } async function withTimeout(promise: Promise, ms: number, onTimeout: () => void): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { onTimeout(); reject(new Error(`Timed out after ${ms}ms`)); }, ms); promise.then( (value) => { clearTimeout(timeoutId); resolve(value); }, (error: unknown) => { clearTimeout(timeoutId); reject(error); }, ); }); } function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string { return ( result.content ?.filter((block) => block.type === "text") .map((block) => block.text ?? "") .join("\n") ?? "" ); } describe.skipIf(process.platform !== "win32")("Windows child-process close handling", () => { let testDir: string; beforeEach(() => { testDir = join(tmpdir(), `coding-agent-bash-close-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(testDir, { recursive: true }); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); it("executeBash resolves after the shell exits even if inherited stdio handles stay open", async () => { const pidFile = join(testDir, "executor-grandchild.pid"); const command = createInheritedStdioCommand(pidFile); const controller = new AbortController(); try { const result = await withTimeout(executeBash(command, { signal: controller.signal }), 3000, () => { controller.abort(); }); expect(result.output).toContain("child-exiting"); expect(result.exitCode).toBe(0); expect(result.cancelled).toBe(false); } finally { controller.abort(); cleanupDetachedChild(pidFile); } }); it("bash tool resolves after the shell exits even if inherited stdio handles stay open", async () => { const pidFile = join(testDir, "tool-grandchild.pid"); const command = createInheritedStdioCommand(pidFile); const controller = new AbortController(); const bashTool = createBashTool(testDir); try { const result = await withTimeout(bashTool.execute("test-call", { command }, controller.signal), 3000, () => { controller.abort(); }); expect(getTextOutput(result)).toContain("child-exiting"); } finally { controller.abort(); cleanupDetachedChild(pidFile); } }); });