diff --git a/src/process/spawn-utils.ts b/src/process/spawn-utils.ts index 2d4604432..200a549eb 100644 --- a/src/process/spawn-utils.ts +++ b/src/process/spawn-utils.ts @@ -1,5 +1,10 @@ import type { ChildProcess, SpawnOptions } from "node:child_process"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; export type SpawnFallback = { label: string; @@ -49,12 +54,122 @@ function shouldRetry(err: unknown, codes: string[]): boolean { return code.length > 0 && codes.includes(code); } +// Fake child process interface for EBADF workaround +interface FakeChildProcess extends EventEmitter { + stdout: PassThrough; + stderr: PassThrough; + stdin: PassThrough; + pid: number; + killed: boolean; + kill: () => boolean; +} + +// EBADF workaround: capture output via temp files, use stdio: ignore for spawn +function createFakeChildFromSync(argv: string[], options: SpawnOptions): ChildProcess { + const fakeChild: FakeChildProcess = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new PassThrough(), + pid: process.pid, + killed: false, + kill: () => { + fakeChild.killed = true; + return true; + }, + }); + + const child = fakeChild as unknown as ChildProcess; + + // Extract command from argv (typically [shell, "-c", command]) + const command = argv.length >= 3 ? argv[2] : argv.join(" "); + + // Create temp files for output capture + const tmpDir = os.tmpdir(); + const id = `clawdbot-ebadf-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const stdoutFile = path.join(tmpDir, `${id}.stdout`); + const stderrFile = path.join(tmpDir, `${id}.stderr`); + + // Wrap command to redirect output to files + const wrappedCommand = `( ${command} ) > "${stdoutFile}" 2> "${stderrFile}"`; + + setImmediate(() => { + try { + // spawnSync with stdio: ignore - no pipes needed + const result = spawnSync("/bin/sh", ["-c", wrappedCommand], { + cwd: options.cwd || process.cwd(), + timeout: 300000, + stdio: "ignore", + }); + + if (result.error) { + // Clean up temp files + try { + fs.unlinkSync(stdoutFile); + } catch {} + try { + fs.unlinkSync(stderrFile); + } catch {} + fakeChild.emit("error", result.error); + return; + } + + fakeChild.pid = result.pid || process.pid; + + // Read output from temp files + try { + const stdoutData = fs.readFileSync(stdoutFile, "utf8"); + if (stdoutData) fakeChild.stdout.write(stdoutData); + } catch {} + + try { + const stderrData = fs.readFileSync(stderrFile, "utf8"); + if (stderrData) fakeChild.stderr.write(stderrData); + } catch {} + + // Clean up temp files + try { + fs.unlinkSync(stdoutFile); + } catch {} + try { + fs.unlinkSync(stderrFile); + } catch {} + + fakeChild.stdout.end(); + fakeChild.stderr.end(); + fakeChild.emit("close", result.status, result.signal); + } catch (err) { + // Clean up temp files + try { + fs.unlinkSync(stdoutFile); + } catch {} + try { + fs.unlinkSync(stderrFile); + } catch {} + fakeChild.emit("error", err); + } + }); + + // Emit spawn immediately + process.nextTick(() => { + fakeChild.emit("spawn"); + }); + + return child; +} + async function spawnAndWaitForSpawn( spawnImpl: typeof spawn, argv: string[], options: SpawnOptions, + useSyncFallback = false, ): Promise { - const child = spawnImpl(argv[0], argv.slice(1), options); + let child: ChildProcess; + + if (useSyncFallback) { + child = createFakeChildFromSync(argv, options); + } else { + child = spawnImpl(argv[0], argv.slice(1), options); + } return await new Promise((resolve, reject) => { let settled = false; @@ -95,19 +210,27 @@ export async function spawnWithFallback( const retryCodes = params.retryCodes ?? DEFAULT_RETRY_CODES; const baseOptions = { ...params.options }; const fallbacks = params.fallbacks ?? []; - const attempts: Array<{ label?: string; options: SpawnOptions }> = [ - { options: baseOptions }, + const attempts: Array<{ label?: string; options: SpawnOptions; useSync?: boolean }> = [ + { options: baseOptions, useSync: false }, ...fallbacks.map((fallback) => ({ label: fallback.label, options: { ...baseOptions, ...fallback.options }, + useSync: false, })), + // Final EBADF fallback: spawnSync with stdio:ignore + file capture + { label: "file-capture", options: baseOptions, useSync: true }, ]; let lastError: unknown; for (let index = 0; index < attempts.length; index += 1) { const attempt = attempts[index]; try { - const child = await spawnAndWaitForSpawn(spawnImpl, params.argv, attempt.options); + const child = await spawnAndWaitForSpawn( + spawnImpl, + params.argv, + attempt.options, + attempt.useSync, + ); return { child, usedFallback: index > 0, @@ -115,11 +238,16 @@ export async function spawnWithFallback( }; } catch (err) { lastError = err; - const nextFallback = fallbacks[index]; - if (!nextFallback || !shouldRetry(err, retryCodes)) { + const nextAttempt = attempts[index + 1]; + if (!nextAttempt || !shouldRetry(err, retryCodes)) { throw err; } - params.onFallback?.(err, nextFallback); + if (nextAttempt.label) { + params.onFallback?.(err, { + label: nextAttempt.label, + options: nextAttempt.options, + }); + } } }