From cdc0fc77f4da2dea5b9888ab8ab876ac28bd9e2d Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 6 Oct 2026 18:21:31 +0000 Subject: [PATCH] fix(browse): chain reads piped stdin synchronously, so Windows runs the flow (#3039) On Windows, an awaited Bun.stdin.text() inside the CLI's un-awaited main() let the process exit 0 before reading stdin: a replayed flow reported success with nothing sent to the daemon. Read fd 0 synchronously (skipped for a terminal); empty input now gets the server's usage error and a non-zero exit. Co-authored-by: VaibDeveloper --- browse/src/cli.ts | 7 ++- browse/test/cli-chain-stdin.test.ts | 74 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 browse/test/cli-chain-stdin.test.ts diff --git a/browse/src/cli.ts b/browse/src/cli.ts index e7b5b1f91..e9e209296 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -2003,9 +2003,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: await handleTunnel(commandArgs); // always exits } - // Special case: chain reads from stdin + // Special case: chain reads from stdin. Synchronously: on Windows an awaited + // Bun.stdin.text() inside this un-awaited main() did not keep the event loop + // alive, so a piped flow exited 0 with nothing sent to the daemon (#3039). + // A terminal has no flow to read; the empty argument gets the server's usage. if (command === 'chain' && commandArgs.length === 0) { - const stdin = await Bun.stdin.text(); + const stdin = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8'); commandArgs.push(stdin.trim()); } diff --git a/browse/test/cli-chain-stdin.test.ts b/browse/test/cli-chain-stdin.test.ts new file mode 100644 index 000000000..e9305ae48 --- /dev/null +++ b/browse/test/cli-chain-stdin.test.ts @@ -0,0 +1,74 @@ +/** + * #3039: `chain` with no arguments reads its JSON flow from stdin. On Windows + * an awaited Bun.stdin.text() inside the CLI's un-awaited main() let the + * process exit 0 before reading, so a replayed flow "passed" with nothing + * sent to the daemon. The CLI reads stdin synchronously; this drives the real + * CLI against a stub daemon (the Windows free lane runs it natively). + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const CLI = path.resolve(import.meta.dir, '../src/cli.ts'); + +describe('#3039: chain reads its flow from piped stdin', () => { + let scratch: string; + let daemon: ReturnType; + let received: Array<{ command: string; args: string[] }>; + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-chain-stdin-')); + received = []; + daemon = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: async (req) => { + const url = new URL(req.url); + if (url.pathname === '/health') return Response.json({ status: 'healthy' }); + if (url.pathname === '/command') { + const body = await req.json(); + received.push(body); + return body.args?.[0] ? new Response('chain ran') : new Response('Usage: echo \'[["goto","url"]]\' | browse chain', { status: 400 }); + } + return new Response('not found', { status: 404 }); + }, + }); + }); + + afterEach(() => { + try { daemon.stop(true); } catch {} + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + async function runChain(stdin: string) { + const stateFile = path.join(scratch, 'browse.json'); + fs.writeFileSync(stateFile, JSON.stringify({ pid: process.pid, port: daemon.port, token: 'chain-stdin-test', mode: 'launched' })); + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !/^(BROWSE_|GSTACK_)/.test(key)) env[key] = value; + } + Object.assign(env, { HOME: scratch, GSTACK_HOME: path.join(scratch, '.gstack'), BROWSE_STATE_FILE: stateFile }); + const cli = Bun.spawn([process.execPath, CLI, 'chain'], { + cwd: scratch, env, stdin: new TextEncoder().encode(stdin), stdout: 'pipe', stderr: 'pipe', + }); + const timer = setTimeout(() => cli.kill('SIGKILL'), 30_000); + const [code, stdout, stderr] = await Promise.all([cli.exited, new Response(cli.stdout).text(), new Response(cli.stderr).text()]); + clearTimeout(timer); + return { code, out: stdout + stderr }; + } + + test('a piped flow reaches the daemon as the chain argument', async () => { + const flow = '[["js","1+1"]]'; + const r = await runChain(`${flow}\n`); + expect(received).toEqual([{ command: 'chain', args: [flow] }]); + expect(r.out).toContain('chain ran'); + expect(r.code).toBe(0); + }, 45_000); + + test('empty stdin is a usage error, never a silent exit 0', async () => { + const r = await runChain(''); + expect(r.code).not.toBe(0); + expect(r.out).toContain('Usage'); + }, 45_000); +});