import { spawn } from 'child_process' type ExecFileOptions = { input?: string timeout?: number useCwd?: boolean env?: NodeJS.ProcessEnv /** Resolve as soon as the child *exits*, instead of waiting for its * stdio streams to close. Use this for tools that fork a daemon and * let the daemon inherit the parent's stdio (e.g. `wl-copy`): the * child exits immediately, but `'close'` never fires because the * daemon holds the pipes open. * * When true, stdout and stderr are set to 'ignore' to prevent the * daemon from inheriting those pipe FDs — the caller must not * depend on collecting stdout/stderr content. Both will always be * empty strings in this mode. */ resolveOnExit?: boolean } export function execFileNoThrow( file: string, args: string[], options: ExecFileOptions = {} ): Promise<{ stdout: string stderr: string code: number error?: string }> { return new Promise(resolve => { // When resolveOnExit is true, ignore stdout/stderr so the daemon // doesn't inherit those pipe FDs — prevents handle leaks that can // keep the parent process alive. No output data is collected in // this mode; both stdout and stderr will be empty strings. const stdioConfig = options.resolveOnExit ? ['pipe', 'ignore', 'ignore'] as const : 'pipe' as const const child = spawn(file, args, { cwd: options.useCwd ? process.cwd() : undefined, env: options.env, stdio: stdioConfig }) let stdout = '' let stderr = '' let timedOut = false let settled = false const settle = (code: number, error?: string) => { if (settled) { return } settled = true if (timer) { clearTimeout(timer) } // Destroy any remaining streams to release FDs promptly. // After settle(), nobody reads from these anymore. child.stdout?.destroy() child.stderr?.destroy() resolve({ stdout, stderr, code, ...(error ? { error } : {}) }) } const timer = options.timeout ? setTimeout(() => { timedOut = true child.kill('SIGTERM') // When resolving on exit, SIGTERM-ing a child that has already // exited is a no-op and `'exit'` won't fire again — settle here // so the promise doesn't leak. Safe under settled-guard. if (options.resolveOnExit) { settle(124) } }, options.timeout) : null child.stdout?.on('data', chunk => { stdout += String(chunk) }) child.stderr?.on('data', chunk => { stderr += String(chunk) }) child.on('error', error => { settle(1, String(error)) }) if (options.resolveOnExit) { // 'exit' fires when the child process itself exits — even if the // daemon it forked still holds the inherited stdio pipes open. // When a signal kills the child, code is null — map that to 1 // so callers don't mistake a signal-terminated run for success. child.on('exit', (code, signal) => { const exitCode = timedOut ? 124 : (code ?? (signal ? 1 : 0)) settle(exitCode) }) } else { child.on('close', (code, signal) => { const exitCode = timedOut ? 124 : (code ?? (signal ? 1 : 0)) settle(exitCode) }) } if (options.input) { child.stdin?.write(options.input) } child.stdin?.end() }) }