| |
| |
| |
|
|
| import { spawn, type ChildProcess } from 'child_process'; |
| import { StringDecoder } from 'string_decoder'; |
|
|
| export interface SubprocessOptions { |
| command: string; |
| args: string[]; |
| cwd: string; |
| env?: Record<string, string>; |
| abortController?: AbortController; |
| timeout?: number; |
| |
| |
| |
| |
| |
| stdinData?: string; |
| } |
|
|
| export interface SubprocessResult { |
| stdout: string; |
| stderr: string; |
| exitCode: number | null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown> { |
| const { command, args, cwd, env, abortController, timeout = 30000, stdinData } = options; |
|
|
| const processEnv = { |
| ...process.env, |
| ...env, |
| }; |
|
|
| |
| console.log(`[SubprocessManager] Spawning: ${command} ${args.join(' ')}`); |
| console.log(`[SubprocessManager] Working directory: ${cwd}`); |
| if (stdinData) { |
| console.log(`[SubprocessManager] Passing ${stdinData.length} bytes via stdin`); |
| } |
|
|
| |
| const needsShell = |
| process.platform === 'win32' && |
| (command.toLowerCase().endsWith('.cmd') || command === 'npx' || command === 'npm'); |
|
|
| const childProcess: ChildProcess = spawn(command, args, { |
| cwd, |
| env: processEnv, |
| |
| stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'], |
| shell: needsShell, |
| }); |
|
|
| |
| if (stdinData && childProcess.stdin) { |
| childProcess.stdin.write(stdinData); |
| childProcess.stdin.end(); |
| } |
|
|
| let stderrOutput = ''; |
| let lastOutputTime = Date.now(); |
| let timeoutHandle: NodeJS.Timeout | null = null; |
| let processExited = false; |
|
|
| |
| |
| |
| |
| let streamEnded = false; |
| let notifyConsumer: (() => void) | null = null; |
|
|
| |
| childProcess.on('exit', () => { |
| processExited = true; |
| }); |
|
|
| |
| if (childProcess.stderr) { |
| childProcess.stderr.on('data', (data: Buffer) => { |
| const text = data.toString(); |
| stderrOutput += text; |
| console.warn(`[SubprocessManager] stderr: ${text}`); |
| }); |
| } |
|
|
| |
| const resetTimeout = () => { |
| lastOutputTime = Date.now(); |
| if (timeoutHandle) { |
| clearTimeout(timeoutHandle); |
| } |
| timeoutHandle = setTimeout(() => { |
| const elapsed = Date.now() - lastOutputTime; |
| if (elapsed >= timeout) { |
| console.error(`[SubprocessManager] Process timeout: no output for ${timeout}ms`); |
| childProcess.kill('SIGTERM'); |
| } |
| }, timeout); |
| }; |
|
|
| resetTimeout(); |
|
|
| |
| let abortHandler: (() => void) | null = null; |
| if (abortController) { |
| abortHandler = () => { |
| console.log('[SubprocessManager] Abort signal received, killing process'); |
| if (timeoutHandle) { |
| clearTimeout(timeoutHandle); |
| } |
| childProcess.kill('SIGTERM'); |
|
|
| |
| |
| |
| streamEnded = true; |
| if (notifyConsumer) { |
| notifyConsumer(); |
| notifyConsumer = null; |
| } |
|
|
| |
| |
| const killTimer = setTimeout(() => { |
| if (!processExited) { |
| console.log('[SubprocessManager] Escalated to SIGKILL after SIGTERM timeout'); |
| try { |
| childProcess.kill('SIGKILL'); |
| } catch { |
| |
| } |
| } |
| }, 3000); |
|
|
| |
| childProcess.once('exit', () => { |
| clearTimeout(killTimer); |
| }); |
| }; |
| |
| if (abortController.signal.aborted) { |
| abortHandler(); |
| } else { |
| abortController.signal.addEventListener('abort', abortHandler); |
| } |
| } |
|
|
| |
| const cleanupAbortListener = () => { |
| if (abortController && abortHandler) { |
| abortController.signal.removeEventListener('abort', abortHandler); |
| abortHandler = null; |
| } |
| }; |
|
|
| |
| |
| |
| if (childProcess.stdout) { |
| |
| const eventQueue: unknown[] = []; |
| |
| let lineBuffer = ''; |
| |
| const decoder = new StringDecoder('utf8'); |
|
|
| childProcess.stdout.on('data', (chunk: Buffer) => { |
| resetTimeout(); |
|
|
| lineBuffer += decoder.write(chunk); |
| const lines = lineBuffer.split('\n'); |
| |
| lineBuffer = lines.pop() || ''; |
|
|
| for (const line of lines) { |
| const trimmed = line.trim(); |
| if (!trimmed) continue; |
|
|
| try { |
| eventQueue.push(JSON.parse(trimmed)); |
| } catch (parseError) { |
| console.error(`[SubprocessManager] Failed to parse JSONL line: ${trimmed}`, parseError); |
| eventQueue.push({ |
| type: 'error', |
| error: `Failed to parse output: ${trimmed}`, |
| }); |
| } |
| } |
|
|
| |
| if (notifyConsumer && eventQueue.length > 0) { |
| notifyConsumer(); |
| notifyConsumer = null; |
| } |
| }); |
|
|
| childProcess.stdout.on('end', () => { |
| |
| lineBuffer += decoder.end(); |
|
|
| |
| if (lineBuffer.trim()) { |
| try { |
| eventQueue.push(JSON.parse(lineBuffer.trim())); |
| } catch (parseError) { |
| console.error( |
| `[SubprocessManager] Failed to parse final JSONL line: ${lineBuffer}`, |
| parseError |
| ); |
| eventQueue.push({ |
| type: 'error', |
| error: `Failed to parse output: ${lineBuffer}`, |
| }); |
| } |
| lineBuffer = ''; |
| } |
|
|
| streamEnded = true; |
| |
| if (notifyConsumer) { |
| notifyConsumer(); |
| notifyConsumer = null; |
| } |
| }); |
|
|
| childProcess.stdout.on('error', (error) => { |
| console.error('[SubprocessManager] stdout error:', error); |
| streamEnded = true; |
| if (notifyConsumer) { |
| notifyConsumer(); |
| notifyConsumer = null; |
| } |
| }); |
|
|
| try { |
| |
| while (!streamEnded || eventQueue.length > 0) { |
| if (eventQueue.length > 0) { |
| yield eventQueue.shift()!; |
| } else { |
| |
| await new Promise<void>((resolve) => { |
| notifyConsumer = resolve; |
| }); |
| } |
| } |
| } finally { |
| if (timeoutHandle) { |
| clearTimeout(timeoutHandle); |
| } |
| cleanupAbortListener(); |
| } |
| } else { |
| |
| cleanupAbortListener(); |
| } |
|
|
| |
| |
| |
| const exitCode = await new Promise<number | null>((resolve) => { |
| if (processExited) { |
| resolve(childProcess.exitCode ?? null); |
| return; |
| } |
|
|
| childProcess.on('exit', (code) => { |
| console.log(`[SubprocessManager] Process exited with code: ${code}`); |
| resolve(code); |
| }); |
|
|
| childProcess.on('error', (error) => { |
| console.error('[SubprocessManager] Process error:', error); |
| resolve(null); |
| }); |
| }); |
|
|
| |
| if (exitCode !== 0 && exitCode !== null) { |
| const errorMessage = stderrOutput || `Process exited with code ${exitCode}`; |
| console.error(`[SubprocessManager] Process failed: ${errorMessage}`); |
| yield { |
| type: 'error', |
| error: errorMessage, |
| }; |
| } |
|
|
| |
| if (exitCode === 0 && !stderrOutput) { |
| console.log('[SubprocessManager] Process completed successfully'); |
| } |
| } |
|
|
| |
| |
| |
| export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> { |
| const { command, args, cwd, env, abortController, stdinData } = options; |
|
|
| const processEnv = { |
| ...process.env, |
| ...env, |
| }; |
|
|
| return new Promise((resolve, reject) => { |
| |
| const needsShell = |
| process.platform === 'win32' && |
| (command.toLowerCase().endsWith('.cmd') || command === 'npx' || command === 'npm'); |
|
|
| const childProcess = spawn(command, args, { |
| cwd, |
| env: processEnv, |
| stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'], |
| shell: needsShell, |
| }); |
|
|
| if (stdinData && childProcess.stdin) { |
| childProcess.stdin.write(stdinData); |
| childProcess.stdin.end(); |
| } |
|
|
| let stdout = ''; |
| let stderr = ''; |
|
|
| if (childProcess.stdout) { |
| childProcess.stdout.on('data', (data: Buffer) => { |
| stdout += data.toString(); |
| }); |
| } |
|
|
| if (childProcess.stderr) { |
| childProcess.stderr.on('data', (data: Buffer) => { |
| stderr += data.toString(); |
| }); |
| } |
|
|
| |
| let abortHandler: (() => void) | null = null; |
| const cleanupAbortListener = () => { |
| if (abortController && abortHandler) { |
| abortController.signal.removeEventListener('abort', abortHandler); |
| abortHandler = null; |
| } |
| }; |
|
|
| if (abortController) { |
| abortHandler = () => { |
| cleanupAbortListener(); |
| childProcess.kill('SIGTERM'); |
|
|
| |
| const killTimer = setTimeout(() => { |
| try { |
| childProcess.kill('SIGKILL'); |
| } catch { |
| |
| } |
| }, 3000); |
| childProcess.once('exit', () => clearTimeout(killTimer)); |
|
|
| reject(new Error('Process aborted')); |
| }; |
| abortController.signal.addEventListener('abort', abortHandler); |
| } |
|
|
| childProcess.on('exit', (code) => { |
| cleanupAbortListener(); |
| resolve({ stdout, stderr, exitCode: code }); |
| }); |
|
|
| childProcess.on('error', (error) => { |
| cleanupAbortListener(); |
| reject(error); |
| }); |
| }); |
| } |
|
|