diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 443e7d9182..862b2014bc 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -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', () => { diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 609c0e28d5..c5eb6e8cf9 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -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; }