fix: improve windows background process handling and cleanup

This commit is contained in:
xuewenjie
2025-12-04 15:20:45 +08:00
parent 6729980b47
commit d9928eab66
3 changed files with 125 additions and 7 deletions

3
.gitignore vendored
View File

@@ -57,3 +57,6 @@ gha-creds-*.json
# Log files # Log files
patch_output.log patch_output.log
# test files
demo-app

View File

@@ -7,7 +7,7 @@
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import type { PtyImplementation } from '../utils/getPty.js'; import type { PtyImplementation } from '../utils/getPty.js';
import { getPty } from '../utils/getPty.js'; import { getPty } from '../utils/getPty.js';
import { spawn as cpSpawn } from 'node:child_process'; import { spawn as cpSpawn, spawnSync } from 'node:child_process';
import { TextDecoder } from 'node:util'; import { TextDecoder } from 'node:util';
import os from 'node:os'; import os from 'node:os';
import type { IPty } from '@lydell/node-pty'; import type { IPty } from '@lydell/node-pty';
@@ -106,6 +106,51 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
export class ShellExecutionService { export class ShellExecutionService {
private static activePtys = new Map<number, ActivePty>(); private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Set<number>();
static {
const cleanup = () => {
// Cleanup PTYs
for (const [pid, pty] of this.activePtys) {
try {
if (os.platform() === 'win32') {
pty.ptyProcess.kill();
} else {
process.kill(-pid, 'SIGKILL');
}
} catch {
// ignore
}
}
// Cleanup child processes
for (const pid of this.activeChildProcesses) {
try {
if (os.platform() === 'win32') {
spawnSync('taskkill', ['/pid', pid.toString(), '/f', '/t']);
} else {
process.kill(-pid, 'SIGKILL');
}
} catch {
// ignore
}
}
};
process.on('exit', cleanup);
// Ensure cleanup happens on SIGINT/SIGTERM
const signalHandler = () => {
process.exit();
};
// We only attach these if we are in a node environment where we can control the process
if (typeof process !== 'undefined' && process.on) {
process.on('SIGINT', signalHandler);
process.on('SIGTERM', signalHandler);
}
}
/** /**
* Executes a shell command using `node-pty`, capturing all output and lifecycle events. * Executes a shell command using `node-pty`, capturing all output and lifecycle events.
* *
@@ -281,9 +326,13 @@ export class ShellExecutionService {
abortSignal.addEventListener('abort', abortHandler, { once: true }); abortSignal.addEventListener('abort', abortHandler, { once: true });
if (child.pid) {
this.activeChildProcesses.add(child.pid);
}
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
if (child.pid) { if (child.pid) {
this.activePtys.delete(child.pid); this.activeChildProcesses.delete(child.pid);
} }
handleExit(code, signal); handleExit(code, signal);
}); });
@@ -310,7 +359,7 @@ export class ShellExecutionService {
} }
}); });
return { pid: undefined, result }; return { pid: child.pid, result };
} catch (e) { } catch (e) {
const error = e as Error; const error = e as Error;
return { return {

View File

@@ -29,6 +29,7 @@ import { summarizeToolOutput } from '../utils/summarizer.js';
import type { import type {
ShellExecutionConfig, ShellExecutionConfig,
ShellOutputEvent, ShellOutputEvent,
ShellExecutionResult,
} from '../services/shellExecutionService.js'; } from '../services/shellExecutionService.js';
import { ShellExecutionService } from '../services/shellExecutionService.js'; import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
@@ -47,6 +48,7 @@ export interface ShellToolParams {
is_background: boolean; is_background: boolean;
description?: string; description?: string;
directory?: string; directory?: string;
timeout?: number;
} }
export class ShellToolInvocation extends BaseToolInvocation< export class ShellToolInvocation extends BaseToolInvocation<
@@ -132,6 +134,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
.toString('hex')}.tmp`; .toString('hex')}.tmp`;
const tempFilePath = path.join(os.tmpdir(), tempFileName); const tempFilePath = path.join(os.tmpdir(), tempFileName);
const timeoutMs = this.params.timeout ?? 3600000;
const abortController = new AbortController();
const onAbort = () => abortController.abort();
signal.addEventListener('abort', onAbort);
const timeoutId = setTimeout(() => {
abortController.abort();
}, timeoutMs);
try { try {
// Add co-author to git commit commands // Add co-author to git commit commands
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand); const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
@@ -139,11 +149,30 @@ export class ShellToolInvocation extends BaseToolInvocation<
const shouldRunInBackground = this.params.is_background; const shouldRunInBackground = this.params.is_background;
let finalCommand = processedCommand; let finalCommand = processedCommand;
// If explicitly marked as background and doesn't already end with &, add it // On non-Windows, use & to run in background.
if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) { // On Windows, we don't use start /B because it creates a detached process that
// doesn't die when the parent dies. Instead, we rely on the race logic below
// to return early while keeping the process attached (detached: false).
if (
!isWindows &&
shouldRunInBackground &&
!finalCommand.trim().endsWith('&')
) {
finalCommand = finalCommand.trim() + ' &'; finalCommand = finalCommand.trim() + ' &';
} }
// On Windows, append a keep-alive command to ensure the shell process
// stays alive even if the main command exits (e.g. spawns a detached child).
// This ensures we always have a valid PID for cleanup.
if (isWindows && shouldRunInBackground) {
// Remove trailing & if present to avoid syntax errors (e.g. "cmd & & ping")
let cmd = finalCommand.trim();
while (cmd.endsWith('&')) {
cmd = cmd.slice(0, -1).trim();
}
finalCommand = cmd + ' & ping -n 86400 127.0.0.1 >nul';
}
// pgrep is not available on Windows, so we can't get background PIDs // pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows const commandToExecute = isWindows
? finalCommand ? finalCommand
@@ -206,7 +235,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
lastUpdateTime = Date.now(); lastUpdateTime = Date.now();
} }
}, },
signal, abortController.signal,
this.config.getShouldUseNodePtyShell(), this.config.getShouldUseNodePtyShell(),
shellExecutionConfig ?? {}, shellExecutionConfig ?? {},
); );
@@ -215,7 +244,34 @@ export class ShellToolInvocation extends BaseToolInvocation<
setPidCallback(pid); setPidCallback(pid);
} }
const result = await resultPromise; let result: ShellExecutionResult;
if (shouldRunInBackground && isWindows) {
// For Windows background tasks, we wait a short time to catch immediate errors.
// If it's still running, we return early.
const startupDelay = 1000;
const raceResult = await Promise.race([
resultPromise,
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), startupDelay),
),
]);
if (raceResult === null) {
// Timeout reached, process is still running.
const pidMsg = pid ? ` PID: ${pid}` : '';
const winHint = isWindows
? ' (Note: Use taskkill /F /T /PID <pid> to stop)'
: '';
return {
llmContent: `Background command started.${pidMsg}${winHint}`,
returnDisplay: `Background command started.${pidMsg}${winHint}`,
};
} else {
result = raceResult;
}
} else {
result = await resultPromise;
}
const backgroundPIDs: number[] = []; const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') { if (os.platform() !== 'win32') {
@@ -321,6 +377,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
...executionError, ...executionError,
}; };
} finally { } finally {
clearTimeout(timeoutId);
signal.removeEventListener('abort', onAbort);
if (fs.existsSync(tempFilePath)) { if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
} }
@@ -454,6 +512,11 @@ export class ShellTool extends BaseDeclarativeTool<
description: description:
'(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.', '(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
}, },
timeout: {
type: 'number',
description:
'(OPTIONAL) The timeout in milliseconds for the command. If not provided, a default timeout (1 hour) is applied.',
},
}, },
required: ['command', 'is_background'], required: ['command', 'is_background'],
}, },
@@ -478,6 +541,9 @@ export class ShellTool extends BaseDeclarativeTool<
if (!params.command.trim()) { if (!params.command.trim()) {
return 'Command cannot be empty.'; return 'Command cannot be empty.';
} }
if (params.timeout !== undefined && params.timeout <= 0) {
return 'Timeout must be a positive number.';
}
if (getCommandRoots(params.command).length === 0) { if (getCommandRoots(params.command).length === 0) {
return 'Could not identify command root to obtain permission from user.'; return 'Could not identify command root to obtain permission from user.';
} }