Add timeout for shell-utils to prevent hangs. (#16667)

This commit is contained in:
Jacob Richman
2026-01-14 16:48:02 -08:00
committed by GitHub
parent 4b2e9f7954
commit ae198029bc
2 changed files with 57 additions and 3 deletions

View File

@@ -44,6 +44,16 @@ vi.mock('shell-quote', () => ({
quote: mockQuote,
}));
const mockDebugLogger = vi.hoisted(() => ({
error: vi.fn(),
debug: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
}));
vi.mock('./debugLogger.js', () => ({
debugLogger: mockDebugLogger,
}));
const isWindowsRuntime = process.platform === 'win32';
const describeWindowsOnly = isWindowsRuntime ? describe : describe.skip;
@@ -161,6 +171,28 @@ describe('getCommandRoots', () => {
const roots = shellUtils.getCommandRoots('ls -la');
expect(roots).toEqual([]);
});
it('should handle bash parser timeouts', () => {
const nowSpy = vi.spyOn(performance, 'now');
// Mock performance.now() to trigger timeout:
// 1st call: start time = 0. deadline = 0 + 1000ms.
// 2nd call (and onwards): inside progressCallback, return 2000ms.
nowSpy.mockReturnValueOnce(0).mockReturnValue(2000);
// Use a very complex command to ensure progressCallback is triggered at least once
const complexCommand =
'ls -la && ' + Array(100).fill('echo "hello"').join(' && ');
const roots = getCommandRoots(complexCommand);
expect(roots).toEqual([]);
expect(nowSpy).toHaveBeenCalled();
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Bash command parsing timed out for command:',
complexCommand,
);
nowSpy.mockRestore();
});
});
describe('hasRedirection', () => {

View File

@@ -11,7 +11,7 @@ import {
spawnSync,
type SpawnOptionsWithoutStdio,
} from 'node:child_process';
import type { Node } from 'web-tree-sitter';
import type { Node, Tree } from 'web-tree-sitter';
import { Language, Parser, Query } from 'web-tree-sitter';
import { loadWasmBinary } from './fileUtils.js';
import { debugLogger } from './debugLogger.js';
@@ -119,6 +119,7 @@ interface CommandParseResult {
}
const POWERSHELL_COMMAND_ENV = '__GCLI_POWERSHELL_COMMAND__';
const PARSE_TIMEOUT_MICROS = 1000 * 1000; // 1 second
// Encode the parser script as UTF-16LE base64 so we can pass it via PowerShell's -EncodedCommand flag;
// this avoids brittle quoting/escaping when spawning PowerShell and ensures the script is received byte-for-byte.
@@ -179,14 +180,35 @@ function createParser(): Parser | null {
}
}
function parseCommandTree(command: string) {
function parseCommandTree(
command: string,
timeoutMicros: number = PARSE_TIMEOUT_MICROS,
): Tree | null {
const parser = createParser();
if (!parser || !command.trim()) {
return null;
}
const deadline = performance.now() + timeoutMicros / 1000;
let timedOut = false;
try {
return parser.parse(command);
const tree = parser.parse(command, null, {
progressCallback: () => {
if (performance.now() > deadline) {
timedOut = true;
return true as unknown as void; // Returning true cancels parsing, but type says void
}
},
});
if (timedOut) {
debugLogger.error('Bash command parsing timed out for command:', command);
// Returning a partial tree could be risky so we return null to be safe.
return null;
}
return tree;
} catch {
return null;
}