mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* fix(cli): stop parseOpArgs hanging on a non-TTY stdin with no input (#3513) parseOpArgs read stdin for stdin-capable ops via readFileSync(0), assuming non-TTY implies piped content. In a non-TTY with no piped input — a CI step, a cron job, an agent harness that inherits a non-TTY stdin without ever writing to it — that call never returns. The stdin fill moves out of parseOpArgs into an async applyStdinParam with a bounded read (readStdinBounded), called by the op dispatch right after arg parsing: - TTY: skipped, as before. - Regular file / /dev/null (fstat says not a pipe/socket): readFileSync returns without blocking — `gbrain put x < file` and `< /dev/null` behave exactly as before (empty-but-readable still yields ''). - FIFO/socket: stream-read with a deadline on the FIRST byte only (default 5000ms, GBRAIN_STDIN_TIMEOUT_MS overrides). Real pipes (`echo foo | gbrain put x`, heredocs) deliver their first byte in milliseconds; once any data arrives the deadline lifts and the read drains to EOF, so slow producers keep working. An empty pipe that closes yields ''. A pipe that never delivers a byte times out, the param stays unset, and the existing required-param usage error fails fast with exit 1. Regression tests spawn the real CLI with a held-open, never-written pipe (hangs 20s+ on pre-fix code; exits ~1s fixed) plus parity cases for data pipes, /dev/null, and empty closed pipes, and a subprocess driver pinning content preservation through applyStdinParam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(build): restore the executable bit on src/cli.ts check:cli-exec requires mode 100755; the edit in this branch landed it as 100644, failing `bun run verify` (1/32) on an otherwise-green PR. Mode only, no content change. * fix(cli): keep the R4-pinned stdin branch shape in applyStdinParam (#3513) Shard 10's R4 regression pin (test/cycle/regression-pr-wave-r1-r2-r4.test.ts, protecting PR #1325's Windows /dev/stdin → fd 0 fix) asserts three source literals in src/cli.ts: `readFileSync(0, ...)`, the `op.cliHints?.stdin` + `MAX_STDIN = 5_000_000` branch, and the `!process.stdin.isTTY` gate. The bounded-read refactor kept the first two but inverted the TTY gate into a positive early-return, dropping the pinned `!process.stdin.isTTY` spelling. Restore the original branch shape inside applyStdinParam (guard + read + cap + assign), unchanged semantics. The #1325 protection itself was never at risk: no '/dev/stdin' anywhere, readFileSync(0) remains the read for non-pipe stdin, and pipes drain through process.stdin (fd 0, cross-platform). The pin now passes unmodified. A comment marks the shape as R4-pinned so the next refactor doesn't trip it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.1 KiB
TypeScript
174 lines
7.1 KiB
TypeScript
/**
|
|
* #3513: parseOpArgs' stdin read must never block forever.
|
|
*
|
|
* In a non-TTY with no piped input — a CI step, a cron job, an agent
|
|
* harness that inherits a non-TTY stdin without writing to it — the old
|
|
* inline `readFileSync(0)` never returned. The fix bounds the read with a
|
|
* first-byte deadline (pipes/sockets only) and falls through to the
|
|
* existing required-param usage error on timeout.
|
|
*
|
|
* The load-bearing regression test spawns the REAL CLI with a held-open,
|
|
* never-written pipe: on pre-fix code it hangs until our observation window
|
|
* kills it; on fixed code it exits 1 with the usage error well inside the
|
|
* window. The stdin read + required-param check both run BEFORE engine
|
|
* connect, so no brain/DB is touched.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { dirname, join } from 'path';
|
|
|
|
const REPO = dirname(import.meta.dir);
|
|
const CLI = join(REPO, 'src', 'cli.ts');
|
|
|
|
interface CliRun {
|
|
exited: boolean;
|
|
exitCode: number | null;
|
|
stderr: string;
|
|
}
|
|
|
|
/** Narrow Bun's `number | FileSink` stdin union to the pipe sink. */
|
|
function pipeSink(proc: { stdin: unknown }): { write(d: string): unknown; end(): unknown } {
|
|
const s = proc.stdin;
|
|
if (!s || typeof s === 'number') throw new Error('expected a piped stdin sink');
|
|
return s as { write(d: string): unknown; end(): unknown };
|
|
}
|
|
|
|
/**
|
|
* Spawn the CLI with the given stdin wiring. `holdPipeOpen` keeps the write
|
|
* end of the stdin pipe alive without ever writing — the #3513 repro. The
|
|
* observation window kills the child if it hasn't exited (pre-fix hang).
|
|
*/
|
|
async function runCliWithStdin(
|
|
args: string[],
|
|
stdin: 'hold-open' | 'closed-empty' | { data: string } | { file: string },
|
|
windowMs: number,
|
|
): Promise<CliRun> {
|
|
const proc = Bun.spawn(['bun', 'run', CLI, ...args], {
|
|
cwd: REPO,
|
|
env: { ...process.env, GBRAIN_STDIN_TIMEOUT_MS: '500' },
|
|
stdin: typeof stdin === 'object' && 'file' in stdin ? Bun.file(stdin.file) : 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
if (typeof stdin === 'object' && 'data' in stdin) {
|
|
pipeSink(proc).write(stdin.data);
|
|
await pipeSink(proc).end();
|
|
} else if (stdin === 'closed-empty') {
|
|
await pipeSink(proc).end();
|
|
}
|
|
// 'hold-open': never write, never close — the CI/cron/agent-harness shape.
|
|
|
|
let exited = true;
|
|
const killer = setTimeout(() => {
|
|
exited = false;
|
|
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
}, windowMs);
|
|
const [exitCode, stderr] = await Promise.all([
|
|
proc.exited,
|
|
new Response(proc.stderr).text(),
|
|
]);
|
|
clearTimeout(killer);
|
|
try { pipeSink(proc).end(); } catch { /* hold-open cleanup */ }
|
|
return { exited, exitCode: exited ? exitCode : null, stderr };
|
|
}
|
|
|
|
describe('#3513 — stdin-capable op with a non-TTY, never-written stdin', () => {
|
|
test('exits fast with the usage error instead of blocking forever', async () => {
|
|
// `put` declares stdin:'content' (required). No inline content, no piped
|
|
// input → the bounded read times out at 500ms, content stays unset, and
|
|
// the required-param check prints usage and exits 1. Pre-fix: readFileSync(0)
|
|
// blocks until the 20s window kills the child.
|
|
const run = await runCliWithStdin(['put', 'stdin-hang-test-slug'], 'hold-open', 20_000);
|
|
expect(run.exited).toBe(true); // pre-#3513 this is false: the read never returns
|
|
expect(run.exitCode).toBe(1);
|
|
expect(run.stderr).toContain('Usage: gbrain put');
|
|
}, 30_000);
|
|
|
|
test('a genuine pipe with data is still consumed (no hang, no crash)', async () => {
|
|
// Piped content fills `content`; the missing positional slug then fails
|
|
// the required check — proving the stream path read stdin and moved on.
|
|
const run = await runCliWithStdin(['put'], { data: '# hello\n' }, 20_000);
|
|
expect(run.exited).toBe(true);
|
|
expect(run.exitCode).toBe(1);
|
|
expect(run.stderr).toContain('Usage: gbrain put');
|
|
}, 30_000);
|
|
|
|
test('empty-but-real input (`< /dev/null`) does not hang', async () => {
|
|
const run = await runCliWithStdin(['put'], { file: '/dev/null' }, 20_000);
|
|
expect(run.exited).toBe(true);
|
|
expect(run.exitCode).toBe(1);
|
|
}, 30_000);
|
|
|
|
test('an empty pipe that closes immediately does not hang', async () => {
|
|
const run = await runCliWithStdin(['put'], 'closed-empty', 20_000);
|
|
expect(run.exited).toBe(true);
|
|
expect(run.exitCode).toBe(1);
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('#3513 — applyStdinParam content preservation (subprocess driver)', () => {
|
|
// Drive the exported helper in a child process so we control the child's
|
|
// real fd 0 — bun test's own stdin is not a reliable fixture.
|
|
const DRIVER = `
|
|
const { applyStdinParam } = await import(${JSON.stringify(CLI)});
|
|
const op = { name: 'put', params: { content: { type: 'string', required: true } }, cliHints: { stdin: 'content' } };
|
|
const params = {};
|
|
await applyStdinParam(op, params);
|
|
console.log(JSON.stringify(params));
|
|
process.exit(0);
|
|
`;
|
|
|
|
async function runDriver(
|
|
stdin: 'hold-open' | 'closed-empty' | { data: string } | { file: string },
|
|
): Promise<{ exited: boolean; params: Record<string, unknown> | null }> {
|
|
const proc = Bun.spawn(['bun', '-e', DRIVER], {
|
|
cwd: REPO,
|
|
env: { ...process.env, GBRAIN_STDIN_TIMEOUT_MS: '500' },
|
|
stdin: typeof stdin === 'object' && 'file' in stdin ? Bun.file(stdin.file) : 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
if (typeof stdin === 'object' && 'data' in stdin) {
|
|
pipeSink(proc).write(stdin.data);
|
|
await pipeSink(proc).end();
|
|
} else if (stdin === 'closed-empty') {
|
|
await pipeSink(proc).end();
|
|
}
|
|
let exited = true;
|
|
const killer = setTimeout(() => {
|
|
exited = false;
|
|
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
}, 20_000);
|
|
const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
clearTimeout(killer);
|
|
try { pipeSink(proc).end(); } catch { /* hold-open cleanup */ }
|
|
const line = stdout.trim().split('\n').pop() ?? '';
|
|
let params: Record<string, unknown> | null = null;
|
|
try { params = JSON.parse(line); } catch { /* child killed before printing */ }
|
|
return { exited, params };
|
|
}
|
|
|
|
test('piped data lands in the stdin param verbatim', async () => {
|
|
const { exited, params } = await runDriver({ data: '---\ntitle: x\n---\nbody' });
|
|
expect(exited).toBe(true);
|
|
expect(params?.content).toBe('---\ntitle: x\n---\nbody');
|
|
}, 30_000);
|
|
|
|
test('/dev/null yields empty-string content (readable, empty — pre-fix parity)', async () => {
|
|
const { exited, params } = await runDriver({ file: '/dev/null' });
|
|
expect(exited).toBe(true);
|
|
expect(params?.content).toBe('');
|
|
}, 30_000);
|
|
|
|
test('empty closed pipe yields empty-string content', async () => {
|
|
const { exited, params } = await runDriver('closed-empty');
|
|
expect(exited).toBe(true);
|
|
expect(params?.content).toBe('');
|
|
}, 30_000);
|
|
|
|
test('held-open pipe times out and leaves the param unset', async () => {
|
|
const { exited, params } = await runDriver('hold-open');
|
|
expect(exited).toBe(true); // completes inside the window instead of hanging
|
|
expect(params).toEqual({});
|
|
}, 30_000);
|
|
});
|