fix(browse): capture browser-skill subprocess output via temp files, not pipes (core of #2559)

Under a loaded parent, the FIRST piped Bun.spawn in a process
intermittently yields an empty stderr even though the child wrote it and
exited 0 — measured identically with readers-attached-before-exit and with
a manual getReader() drain, so it's loss inside the async pipe plumbing,
not read ordering. It flaked `$B skill test` (bun test writes its banner to
stdout and the pass/fail summary to stderr, so a dropped stderr silently
degraded the result to just the banner) and would blank a skill's JSON
result on `$B skill run` while still reporting success.

New runToFiles() points the child's stdout/stderr at temp files via
Bun.file() (never raw fds — closing self-opened fds around a spawn tripped
Bun's fd bookkeeping into a stray epoll_ctl EBADF), awaits exit, then reads
the files: the kernel has flushed everything by child exit, so the
post-exit read is complete, and chatty children can't stall on a full pipe
buffer. Both handleTest and spawnSkill route through it (timeout + capped
read preserved via timeoutMs/maxStdoutBytes). Bun.spawnSync would also
capture reliably but would deadlock: a spawned skill calls back into this
same daemon on GSTACK_PORT.

The `tests passed for "<name>"` fallback is gone — a passing bun test
always prints a summary, so exit 0 with no output means the run was NOT
captured, and handleTest now throws instead of fabricating success. The
E2E assertion checks both stream halves (banner + summary + "Ran N tests")
instead of the loose alternation whose `tests passed` branch matched the
synthetic fallback vacuously. A static tripwire pins the structure:
runToFiles owns the module's ONLY Bun.spawn, and no site reads child
output via stdout:'pipe' / new Response(proc.stdout) / getReader().

Scope: the PR's repo-wide test-file sweep is deliberately not absorbed —
this is the core only, per the wave plan.

Tests: browser-skill-commands + browser-skills-e2e + browser-skill-write
74 pass, 0 fail.

Re-derived from PR #2559 by @frederik-kaster-noygear.

Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:59:08 -07:00
co-authored by Frederik Kaster Claude Fable 5
parent f4e84b4dc9
commit be66d4f06f
3 changed files with 168 additions and 67 deletions
@@ -382,3 +382,42 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
}, 10_000);
});
describe('subprocess capture goes through temp files, not pipes', () => {
// Tripwire. Capturing a child's output through `stdout: 'pipe'` is lossy
// here: under a loaded parent, the first piped spawn in the process
// intermittently yields an empty stderr even though the child wrote it and
// exited 0. Neither draining before awaiting exit nor a manual getReader()
// loop avoids it — both were measured losing the same bytes. It flaked
// `$B skill test` (a dropped stderr left only bun's banner) and would blank
// a skill's JSON result on `$B skill run` while still reporting success.
//
// runToFiles() points the child's fds at temp files instead, so the kernel
// has flushed everything by the time the child exits. This test fails if a
// refactor reintroduces pipe capture in this module.
//
// Comments are stripped first, so the module's own prose — which names the
// banned pattern in order to explain it — doesn't trip checks meant for code.
const src = fs.readFileSync(
path.join(import.meta.dir, '..', 'src', 'browser-skill-commands.ts'), 'utf-8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
it("does not spawn with stdout/stderr: 'pipe'", () => {
expect(src).not.toMatch(/std(out|err):\s*'pipe'/);
});
it('does not read child output via Response(proc.stdout/stderr) or getReader', () => {
expect(src).not.toMatch(/new Response\(\s*proc\.(stdout|stderr)/);
expect(src).not.toMatch(/proc\.(stdout|stderr)[\s\S]{0,40}getReader\(/);
});
it('every spawn site routes through runToFiles', () => {
// The structural invariant: runToFiles owns the module's only Bun.spawn,
// so any present or future spawn site inherits the file-based capture.
// Counted rather than name-checked so adding a spawn site that bypasses
// the helper fails here instead of silently reintroducing the bug.
expect(src.match(/Bun\.spawn\(/g) ?? []).toHaveLength(1);
expect((src.match(/await runToFiles\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
});
});
+12 -2
View File
@@ -85,7 +85,17 @@ describe('browser-skills E2E — bundled hackernews-frontpage', () => {
// It takes ~1s. Run it last so other assertions are quick.
test('$B skill test hackernews-frontpage runs script.test.ts and reports pass', async () => {
const result = await handleSkillCommand(['test', 'hackernews-frontpage'], { port: 0 });
// bun test prints summary to stderr; handleSkillCommand returns stderr || stdout
expect(result).toMatch(/13 pass|0 fail|tests passed/);
// `bun test` splits its report across streams: the version banner goes to
// stdout, the pass/fail summary to stderr. handleSkillCommand must return
// both, so assert on each stream's half.
//
// This used to flake under full-suite load: capturing the child through
// pipes dropped stderr on the first piped spawn in the process, so the
// result was just the banner. The old `13 pass|0 fail|tests passed` regex
// also had a `tests passed` alternative that matched a synthetic fallback
// string, which would have passed vacuously on an empty capture.
expect(result).toMatch(/bun test v/); // stdout half
expect(result).toMatch(/\b0 fail\b/); // stderr half
expect(result).toMatch(/Ran \d+ tests/);
}, 30_000);
});