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 <VaibDeveloper@users.noreply.github.com>
This commit is contained in:
garrytanandVaibDeveloper committed 2026-10-06 18:21:31 +00:00
1 parent 2328cd3bbc
commit cdc0fc77f4
2 files changed
+79 -2

No files matched your search

+5 -2
View File
@@ -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());
}
+74
View File
@@ -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<typeof Bun.serve>;
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<string, string> = {};
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);
});