From 0cc9ed8b6537953d45d6cb6c7b114a5fd1b60e3c Mon Sep 17 00:00:00 2001 From: Sean McLellan Date: Wed, 28 Jan 2026 15:32:49 -0500 Subject: [PATCH 1/2] fix(spawn): add EBADF fallback using temp files (fixes #3556) --- src/process/spawn-utils.ts | 134 ++++++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 8 deletions(-) diff --git a/src/process/spawn-utils.ts b/src/process/spawn-utils.ts index 2d4604432..a4b534871 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,112 @@ function shouldRetry(err: unknown, codes: string[]): boolean { return code.length > 0 && codes.includes(code); } +// EBADF workaround: capture output via temp files, use stdio: ignore for spawn +function createFakeChildFromSync(argv: string[], options: SpawnOptions): ChildProcess { + const child = new EventEmitter() as unknown as ChildProcess; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + + child.stdout = stdout; + child.stderr = stderr; + child.stdin = new PassThrough(); + child.pid = process.pid; + child.killed = false; + child.kill = () => { + child.killed = true; + return true; + }; + + // 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 {} + child.emit("error", result.error); + return; + } + + child.pid = result.pid || process.pid; + + // Read output from temp files + try { + const stdoutData = fs.readFileSync(stdoutFile, "utf8"); + if (stdoutData) stdout.write(stdoutData); + } catch {} + + try { + const stderrData = fs.readFileSync(stderrFile, "utf8"); + if (stderrData) stderr.write(stderrData); + } catch {} + + // Clean up temp files + try { + fs.unlinkSync(stdoutFile); + } catch {} + try { + fs.unlinkSync(stderrFile); + } catch {} + + stdout.end(); + stderr.end(); + child.emit("close", result.status, result.signal); + } catch (err) { + // Clean up temp files + try { + fs.unlinkSync(stdoutFile); + } catch {} + try { + fs.unlinkSync(stderrFile); + } catch {} + child.emit("error", err); + } + }); + + // Emit spawn immediately + process.nextTick(() => { + child.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 +200,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 +228,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, + }); + } } } From 52ffd744f7980d1152a617be8b6375952f8a26c5 Mon Sep 17 00:00:00 2001 From: Sean McLellan Date: Wed, 28 Jan 2026 15:48:11 -0500 Subject: [PATCH 2/2] fix: TypeScript readonly property errors in EBADF workaround --- src/process/spawn-utils.ts | 52 +++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/src/process/spawn-utils.ts b/src/process/spawn-utils.ts index a4b534871..200a549eb 100644 --- a/src/process/spawn-utils.ts +++ b/src/process/spawn-utils.ts @@ -54,21 +54,31 @@ 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 child = new EventEmitter() as unknown as ChildProcess; - const stdout = new PassThrough(); - const stderr = new PassThrough(); + 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; + }, + }); - child.stdout = stdout; - child.stderr = stderr; - child.stdin = new PassThrough(); - child.pid = process.pid; - child.killed = false; - child.kill = () => { - child.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(" "); @@ -99,21 +109,21 @@ function createFakeChildFromSync(argv: string[], options: SpawnOptions): ChildPr try { fs.unlinkSync(stderrFile); } catch {} - child.emit("error", result.error); + fakeChild.emit("error", result.error); return; } - child.pid = result.pid || process.pid; + fakeChild.pid = result.pid || process.pid; // Read output from temp files try { const stdoutData = fs.readFileSync(stdoutFile, "utf8"); - if (stdoutData) stdout.write(stdoutData); + if (stdoutData) fakeChild.stdout.write(stdoutData); } catch {} try { const stderrData = fs.readFileSync(stderrFile, "utf8"); - if (stderrData) stderr.write(stderrData); + if (stderrData) fakeChild.stderr.write(stderrData); } catch {} // Clean up temp files @@ -124,9 +134,9 @@ function createFakeChildFromSync(argv: string[], options: SpawnOptions): ChildPr fs.unlinkSync(stderrFile); } catch {} - stdout.end(); - stderr.end(); - child.emit("close", result.status, result.signal); + fakeChild.stdout.end(); + fakeChild.stderr.end(); + fakeChild.emit("close", result.status, result.signal); } catch (err) { // Clean up temp files try { @@ -135,13 +145,13 @@ function createFakeChildFromSync(argv: string[], options: SpawnOptions): ChildPr try { fs.unlinkSync(stderrFile); } catch {} - child.emit("error", err); + fakeChild.emit("error", err); } }); // Emit spawn immediately process.nextTick(() => { - child.emit("spawn"); + fakeChild.emit("spawn"); }); return child;