mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 17:05:28 +02:00
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:
co-authored by
Frederik Kaster
Claude Fable 5
parent
f4e84b4dc9
commit
be66d4f06f
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user