Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 151 additions & 116 deletions packages/core/src/utils/shell-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export async function resolveExecutable(
let bashLanguage: Language | null = null;
let treeSitterInitialization: Promise<void> | null = null;
let treeSitterInitializationError: Error | null = null;
let parserState: 'uninitialized' | 'initializing' | 'initialized' | 'error' =
'uninitialized';

class ShellParserInitializationError extends Error {
constructor(cause: Error) {
Expand Down Expand Up @@ -163,16 +165,37 @@ async function loadBashLanguage(): Promise<void> {
}

export async function initializeShellParsers(): Promise<void> {
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<void>((_, 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 {
Expand Down Expand Up @@ -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?.();
}
};

/**
Expand Down Expand Up @@ -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,
});
try {
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;

const rl = readline.createInterface({
input: child.stdout,
terminal: false,
});
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
// ensure we don't open a window on windows if possible/relevant
windowsHide: true,
});

const errorChunks: Buffer[] = [];
let stderrTotalBytes = 0;
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
const rl = readline.createInterface({
input: child.stdout,
terminal: false,
});

child.stderr.on('data', (chunk) => {
if (stderrTotalBytes < MAX_STDERR_BYTES) {
errorChunks.push(chunk);
stderrTotalBytes += chunk.length;
}
});
const errorChunks: Buffer[] = [];
let stderrTotalBytes = 0;
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit

let error: Error | null = null;
child.on('error', (err) => {
error = err;
});
child.stderr.on('data', (chunk) => {
if (stderrTotalBytes < MAX_STDERR_BYTES) {
errorChunks.push(chunk);
stderrTotalBytes += chunk.length;
}
});

const onAbort = () => {
// If manually aborted by signal, we kill immediately.
if (!child.killed) child.kill();
};
let error: Error | null = null;
child.on('error', (err) => {
error = err;
});

if (options?.signal?.aborted) {
onAbort();
} else {
options?.signal?.addEventListener('abort', onAbort);
}
const onAbort = () => {
// If manually aborted by signal, we kill immediately.
if (!child.killed) child.kill();
};

let finished = false;
try {
for await (const line of rl) {
if (options?.signal?.aborted) break;
yield line;
if (options?.signal?.aborted) {
onAbort();
} else {
options?.signal?.addEventListener('abort', onAbort);
}
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
let finished = false;
try {
for await (const line of rl) {
if (options?.signal?.aborted) break;
yield line;
}
killedByGenerator = true;
}

// Ensure we wait for the process to exit to check codes
await new Promise<void>((resolve, reject) => {
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
if (error) {
reject(error);
return;
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<void>((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?.();
}
}
Loading