/** * #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 { 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 | 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 | 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); });