Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/run/ralph-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = [];
Expand Down
53 changes: 53 additions & 0 deletions tests/integration/ralph-stdout-drain.test.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>((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();
}
});
});
26 changes: 26 additions & 0 deletions tests/run/ralph-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChildProcess>);
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);
Expand Down