From ef1d783a5b402023f80264f817a391dddd125316 Mon Sep 17 00:00:00 2001 From: Selim Ataballyev Date: Fri, 19 Jun 2026 20:02:00 +0500 Subject: [PATCH 1/2] Drain piped child stdout/stderr to prevent Ralph loop deadlock --- src/run/ralph-process.ts | 11 +++++++++++ tests/run/ralph-process.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/run/ralph-process.ts b/src/run/ralph-process.ts index e3ac703..13bd1f5 100644 --- a/src/run/ralph-process.ts +++ b/src/run/ralph-process.ts @@ -218,6 +218,17 @@ export function spawnRalphLoop( windowsHide: true, }); + // When stdio is piped (dashboard / swarm mode), nothing consumes the child's + // stdout/stderr — the dashboards read progress from .ralph/ state files, not + // from the pipe. An unconsumed pipe fills its OS buffer (~64KB) and the child + // blocks on write() forever, so the loop hangs and its exit is never observed. + // Drain both streams (flowing mode, data discarded) so the child can always + // make progress regardless of how much it writes. + if (!options.inheritStdio) { + child.stdout?.resume(); + child.stderr?.resume(); + } + let state: RalphProcessState = "running"; let exitCode: number | null = null; let exitCallbacks: Array<(code: number | null) => void> = []; diff --git a/tests/run/ralph-process.test.ts b/tests/run/ralph-process.test.ts index 5c0317c..40ec2dc 100644 --- a/tests/run/ralph-process.test.ts +++ b/tests/run/ralph-process.test.ts @@ -409,6 +409,32 @@ describe("spawnRalphLoop", () => { ); }); + it("drains piped stdout/stderr so the child cannot deadlock on a full pipe buffer", async () => { + const stdout = Object.assign(new EventEmitter(), { resume: vi.fn() }); + const stderr = Object.assign(new EventEmitter(), { resume: vi.fn() }); + const mockChild = createMockChild({ + stdout, + stderr, + } as unknown as Partial); + mockSpawn.mockReturnValue(mockChild); + + const { spawnRalphLoop } = await import("../../src/run/ralph-process.js"); + spawnRalphLoop("/project", "claude-code", { inheritStdio: false }); + + expect(stdout.resume).toHaveBeenCalledTimes(1); + expect(stderr.resume).toHaveBeenCalledTimes(1); + }); + + it("does not touch streams when stdio is inherited", async () => { + // With inherited stdio the child exposes no stdout/stderr handles; the + // drain step must be skipped without throwing. + const mockChild = createMockChild(); + mockSpawn.mockReturnValue(mockChild); + + const { spawnRalphLoop } = await import("../../src/run/ralph-process.js"); + expect(() => spawnRalphLoop("/project", "claude-code", { inheritStdio: true })).not.toThrow(); + }); + it("tracks exit code and updates state on child exit", async () => { const mockChild = createMockChild(); mockSpawn.mockReturnValue(mockChild); From f278be95e2c5655755240c5888ca7bcebab7eee2 Mon Sep 17 00:00:00 2001 From: Selim Ataballyev Date: Fri, 19 Jun 2026 23:04:07 +0500 Subject: [PATCH 2/2] Add integration test proving stdout drain prevents loop deadlock --- tests/integration/ralph-stdout-drain.test.ts | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/integration/ralph-stdout-drain.test.ts diff --git a/tests/integration/ralph-stdout-drain.test.ts b/tests/integration/ralph-stdout-drain.test.ts new file mode 100644 index 0000000..a992d2b --- /dev/null +++ b/tests/integration/ralph-stdout-drain.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnRalphLoop, validateBashAvailable } from "../../src/run/ralph-process.js"; + +/** + * Regression test for the pipe-buffer deadlock: in non-inherit (dashboard / + * swarm) mode the loop's stdout/stderr are piped but nothing consumes them. + * A child that writes more than the OS pipe buffer (~64KB) blocks on write() + * forever unless the streams are drained. This spawns a REAL bash process that + * emits ~600KB and asserts it actually exits. + */ +describe("ralph loop stdout drain (integration)", { timeout: 30000 }, () => { + let projectDir: string; + + beforeEach(async () => { + projectDir = join(tmpdir(), `bmalph-drain-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(join(projectDir, ".ralph"), { recursive: true }); + // Emit far more than the ~64KB pipe buffer, then exit cleanly. + await writeFile( + join(projectDir, ".ralph", "ralph_loop.sh"), + '#!/bin/bash\nfor i in $(seq 1 20000); do echo "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; done\nexit 0\n' + ); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + }); + + it("does not deadlock when the piped child floods stdout", async () => { + await validateBashAvailable(); + + const rp = spawnRalphLoop(projectDir, "claude-code", { inheritStdio: false }); + + try { + const exitCode = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("loop deadlocked: child never exited (pipe buffer not drained)")), + 15000 + ); + rp.onExit((code) => { + clearTimeout(timer); + resolve(code); + }); + }); + + expect(exitCode).toBe(0); + } finally { + if (rp.state === "running") rp.kill(); + } + }); +});