diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 8486be0de9..b0329c6e05 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -108,6 +108,8 @@ export async function resolveExecutable( let bashLanguage: Language | null = null; let treeSitterInitialization: Promise | null = null; let treeSitterInitializationError: Error | null = null; +let parserState: 'uninitialized' | 'initializing' | 'initialized' | 'error' = + 'uninitialized'; class ShellParserInitializationError extends Error { constructor(cause: Error) { @@ -163,16 +165,37 @@ async function loadBashLanguage(): Promise { } export async function initializeShellParsers(): Promise { - if (!treeSitterInitialization) { - treeSitterInitialization = loadBashLanguage().catch((error) => { - treeSitterInitialization = null; - // Log the error but don't throw, allowing the application to fall back to safe defaults (ASK_USER) - // or regex checks where appropriate. - debugLogger.debug('Failed to initialize shell parsers:', error); + if (parserState === 'uninitialized') { + parserState = 'initializing'; + let timerId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timerId = setTimeout( + () => reject(new Error('Tree-sitter initialization timed out')), + 30000, + ); }); + treeSitterInitialization = Promise.race([ + loadBashLanguage(), + timeoutPromise, + ]) + .finally(() => { + if (timerId) clearTimeout(timerId); + }) + .then(() => { + parserState = 'initialized'; + }) + .catch((error) => { + parserState = 'error'; + treeSitterInitialization = null; + // Log the error but don't throw, allowing the application to fall back to safe defaults (ASK_USER) + // or regex checks where appropriate. + debugLogger.debug('Failed to initialize shell parsers:', error); + }); } - await treeSitterInitialization; + if (treeSitterInitialization) { + await treeSitterInitialization; + } } export interface ParsedCommandDetail { @@ -847,34 +870,40 @@ export const spawnAsync = async ( const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared; - return new Promise((resolve, reject) => { - const child = spawn(finalCommand, finalArgs, { - ...options, - env: finalEnv, - }); - let stdout = ''; - let stderr = ''; + try { + return await new Promise((resolve, reject) => { + const child = spawn(finalCommand, finalArgs, { + ...options, + env: finalEnv, + }); + let stdout = ''; + let stderr = ''; - child.stdout.on('data', (data) => { - stdout += data.toString(); - }); + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); - child.on('close', (code) => { - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(`Command failed with exit code ${code}:\n${stderr}`)); - } - }); + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject( + new Error(`Command failed with exit code ${code}:\n${stderr}`), + ); + } + }); - child.on('error', (err) => { - reject(err); + child.on('error', (err) => { + reject(err); + }); }); - }); + } finally { + prepared.cleanup?.(); + } }; /** @@ -902,109 +931,115 @@ export async function* execStreaming( env: options?.env ?? process.env, }); - const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared; - - const child = spawn(finalCommand, finalArgs, { - ...options, - env: finalEnv, - // ensure we don't open a window on windows if possible/relevant - windowsHide: true, - }); - - const rl = readline.createInterface({ - input: child.stdout, - terminal: false, - }); - - const errorChunks: Buffer[] = []; - let stderrTotalBytes = 0; - const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit - - child.stderr.on('data', (chunk) => { - if (stderrTotalBytes < MAX_STDERR_BYTES) { - errorChunks.push(chunk); - stderrTotalBytes += chunk.length; - } - }); - - let error: Error | null = null; - child.on('error', (err) => { - error = err; - }); - - const onAbort = () => { - // If manually aborted by signal, we kill immediately. - if (!child.killed) child.kill(); - }; - - if (options?.signal?.aborted) { - onAbort(); - } else { - options?.signal?.addEventListener('abort', onAbort); - } - - let finished = false; try { - for await (const line of rl) { - if (options?.signal?.aborted) break; - yield line; - } - finished = true; - } finally { - rl.close(); - options?.signal?.removeEventListener('abort', onAbort); + const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared; - // Ensure process is killed when the generator is closed (consumer breaks loop) - let killedByGenerator = false; - if (!finished && child.exitCode === null && !child.killed) { - try { - child.kill(); - } catch { - // ignore error if process is already dead + const child = spawn(finalCommand, finalArgs, { + ...options, + env: finalEnv, + // ensure we don't open a window on windows if possible/relevant + windowsHide: true, + }); + + const rl = readline.createInterface({ + input: child.stdout, + terminal: false, + }); + + const errorChunks: Buffer[] = []; + let stderrTotalBytes = 0; + const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit + + child.stderr.on('data', (chunk) => { + if (stderrTotalBytes < MAX_STDERR_BYTES) { + errorChunks.push(chunk); + stderrTotalBytes += chunk.length; } - killedByGenerator = true; + }); + + let error: Error | null = null; + child.on('error', (err) => { + error = err; + }); + + const onAbort = () => { + // If manually aborted by signal, we kill immediately. + if (!child.killed) child.kill(); + }; + + if (options?.signal?.aborted) { + onAbort(); + } else { + options?.signal?.addEventListener('abort', onAbort); } - // Ensure we wait for the process to exit to check codes - await new Promise((resolve, reject) => { - // If an error occurred before we got here (e.g. spawn failure), reject immediately. - if (error) { - reject(error); - return; + let finished = false; + try { + for await (const line of rl) { + if (options?.signal?.aborted) break; + yield line; + } + finished = true; + } finally { + rl.close(); + options?.signal?.removeEventListener('abort', onAbort); + + // Ensure process is killed when the generator is closed (consumer breaks loop) + let killedByGenerator = false; + if (!finished && child.exitCode === null && !child.killed) { + try { + child.kill(); + } catch { + // ignore error if process is already dead + } + killedByGenerator = true; } - function checkExit(code: number | null) { - // If we aborted or killed it manually, we treat it as success (stop waiting) - if (options?.signal?.aborted || killedByGenerator) { - resolve(); + // Ensure we wait for the process to exit to check codes + await new Promise((resolve, reject) => { + // If an error occurred before we got here (e.g. spawn failure), reject immediately. + if (error) { + reject(error); return; } - const allowed = options?.allowedExitCodes ?? [0]; - if (code !== null && allowed.includes(code)) { - resolve(); - } else { - // If we have an accumulated error or explicit error event - if (error) reject(error); - else { - const stderr = Buffer.concat(errorChunks).toString('utf8'); - const truncatedMsg = - stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : ''; - reject( - new Error( - `Process exited with code ${code}: ${stderr}${truncatedMsg}`, - ), - ); + function checkExit(code: number | null) { + // If we aborted or killed it manually, we treat it as success (stop waiting) + if (options?.signal?.aborted || killedByGenerator) { + resolve(); + return; + } + + const allowed = options?.allowedExitCodes ?? [0]; + if (code !== null && allowed.includes(code)) { + resolve(); + } else { + // If we have an accumulated error or explicit error event + if (error) reject(error); + else { + const stderr = Buffer.concat(errorChunks).toString('utf8'); + const truncatedMsg = + stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : ''; + reject( + new Error( + `Process exited with code ${code}: ${stderr}${truncatedMsg}`, + ), + ); + } } } - } - if (child.exitCode !== null) { - checkExit(child.exitCode); - } else { - child.on('close', (code) => checkExit(code)); - child.on('error', (err) => reject(err)); - } - }); + if (child.exitCode !== null) { + checkExit(child.exitCode); + } else { + child.on('close', (code) => checkExit(code)); + child.on('error', (err) => { + reject(err); + }); + } + }); + } + } finally { + prepared.cleanup?.(); } }