feat(core): Add configurable inactivity timeout for shell commands (#13531)

This commit is contained in:
Gal Zahavi
2025-11-26 13:43:33 -08:00
committed by GitHub
parent 87edeb4e32
commit 0d29385e1b
9 changed files with 124 additions and 7 deletions

View File

@@ -92,6 +92,7 @@ describe('ShellTool', () => {
getGeminiClient: vi.fn(),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
} as unknown as Config;
shellTool = new ShellTool(mockConfig);
@@ -219,7 +220,7 @@ describe('ShellTool', () => {
wrappedCommand,
tempRootDir,
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -244,7 +245,7 @@ describe('ShellTool', () => {
wrappedCommand,
subdir,
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -265,7 +266,7 @@ describe('ShellTool', () => {
wrappedCommand,
path.join(tempRootDir, 'subdir'),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -292,7 +293,7 @@ describe('ShellTool', () => {
'dir',
tempRootDir,
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -376,6 +377,30 @@ describe('ShellTool', () => {
expect(result.returnDisplay).toBe('long output');
});
it('should NOT start a timeout if timeoutMs is <= 0', async () => {
// Mock the timeout config to be 0
(mockConfig.getShellToolInactivityTimeout as Mock).mockReturnValue(0);
vi.useFakeTimers();
const invocation = shellTool.build({ command: 'sleep 10' });
const promise = invocation.execute(mockAbortSignal);
// Verify no timeout logic is triggered even after a long time
resolveShellExecution({
output: 'finished',
exitCode: 0,
});
await promise;
// If we got here without aborting/timing out logic interfering, we're good.
// We can also verify that setTimeout was NOT called for the inactivity timeout.
// However, since we don't have direct access to the internal `resetTimeout`,
// we can infer success by the fact it didn't abort.
vi.useRealTimers();
});
it('should clean up the temp file on synchronous execution error', async () => {
const error = new Error('sync spawn error');
mockShellExecutionService.mockImplementation(() => {

View File

@@ -150,6 +150,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
.toString('hex')}.tmp`;
const tempFilePath = path.join(os.tmpdir(), tempFileName);
const timeoutMs = this.config.getShellToolInactivityTimeout();
const timeoutController = new AbortController();
let timeoutTimer: NodeJS.Timeout | undefined;
// Handle signal combination manually to avoid TS issues or runtime missing features
const combinedController = new AbortController();
const onAbort = () => combinedController.abort();
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
@@ -169,11 +178,30 @@ export class ShellToolInvocation extends BaseToolInvocation<
let lastUpdateTime = Date.now();
let isBinaryStream = false;
const resetTimeout = () => {
if (timeoutMs <= 0) {
return;
}
if (timeoutTimer) clearTimeout(timeoutTimer);
timeoutTimer = setTimeout(() => {
timeoutController.abort();
}, timeoutMs);
};
signal.addEventListener('abort', onAbort, { once: true });
timeoutController.signal.addEventListener('abort', onAbort, {
once: true,
});
// Start timeout
resetTimeout();
const { result: resultPromise, pid } =
await ShellExecutionService.execute(
commandToExecute,
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
@@ -211,7 +239,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
lastUpdateTime = Date.now();
}
},
signal,
combinedController.signal,
this.config.getEnableInteractiveShell(),
shellExecutionConfig ?? {},
);
@@ -246,8 +274,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
let llmContent = '';
let timeoutMessage = '';
if (result.aborted) {
llmContent = 'Command was cancelled by user before it could complete.';
if (timeoutController.signal.aborted) {
timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${(
timeoutMs / 60000
).toFixed(1)} minutes without output.`;
llmContent = timeoutMessage;
} else {
llmContent =
'Command was cancelled by user before it could complete.';
}
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
} else {
@@ -282,7 +319,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
returnDisplayMessage = 'Command cancelled by user.';
if (timeoutMessage) {
returnDisplayMessage = timeoutMessage;
} else {
returnDisplayMessage = 'Command cancelled by user.';
}
} else if (result.signal) {
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
@@ -327,6 +368,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
...executionError,
};
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}