Repository navigation
fix(browse): capture skill subprocess output via temp files, not pipes - #2559
Closed
frederik-kaster-noygear wants to merge 19 commits into
Closed
frederik-kaster-noygear wants to merge 19 commits into
frederik-kaster-noygear wants to merge 19 commits into
Conversation
Machine-local, comma-separated supplement to the committed .gstack/redact-prepush-ignore file. Empty by default (no behavior change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Large generated data files (multi-MB CSV/Parquet exports) oversize the 1 MiB scan cap and trip a false-positive HIGH engine.input_too_large, forcing a full GSTACK_REDACT_PREPUSH=skip that also skips real code in the same push. Add an opt-in, auditable path-ignore. Ignore globs merge two sources: the committed .gstack/redact-prepush-ignore file (one glob per line, # comments — reviewable in git history, shared across contributors) and the machine-local redact_prepush_ignore_globs config key. Matched repo-root-relative paths (Bun.Glob, so ** spans directories) are dropped from the scanned diff via a git :(exclude) pathspec, and a one-line stderr notice always reports how many paths / bytes were skipped — an exemption is never silent. Preserves the garrytan#1946 fail-closed invariant: an ignore glob is an explicit opt-in; anything NOT matched still goes through scan(), so a bare oversize non-ignored file still blocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ignored large file passes with a skip notice; non-ignored large file still fail-closed blocks; a real secret in a non-ignored file still blocks even with ignore rules present; an explicit ignore glob exempts its file (auditable opt-in); the machine-local config key is an additional source; no ignore rules leaves default behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review found a reproduced fail-open: a plain `:(exclude)<name>` git pathspec is glob-ENABLED, so a legal on-disk filename holding a `?`/`[`/`]` metacharacter was interpreted as a PATTERN and could drop OTHER, non-ignored files from the scan — a real credential in a sibling file (verified with a live AKIA/PEM key) slipped past with exit 0. Use `:(top,exclude,literal)` for excludes and `:(top,literal)` for the skip-accounting diff. `literal` disables glob interpretation so only the exact ignored path is exempted; `top` anchors at the repo root so excludes line up with the root-relative names from `--name-only` regardless of cwd (the positive pathspec is `:/`, also root-anchored). Also pass `core.quotePath=false` on the name-only diff so non-ASCII filenames arrive as raw UTF-8 and the glob matches (otherwise git C-quotes them and the user's ignore rule silently no-ops). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(g) a non-ASCII filename is exempted by its glob (core.quotePath=false).
(h) a `?`-bearing ignored filename does NOT drag a sibling holding a real
secret out of the scan — the sibling credential still blocks (exit 1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gstack-config reads values whitespace-delimited, so a space after a comma silently drops the trailing globs. Document it and steer multi-glob users to the committed .gstack/redact-prepush-ignore file (one glob per line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Step 3.5 to the upgrade skill: when the install uses a fork-with-carried- patch layout (an `upstream` remote distinct from `origin`), rebase the local commits onto the latest upstream and push to the fork BEFORE the Step 4 `reset --hard origin/main`, so one /gstack-upgrade both keeps the local patch and advances to upstream. Gated so vanilla installs (no upstream remote, or upstream==origin) are a strict no-op. Conflict/push-failure abort cleanly and stop the upgrade with manual remediation instructions; offline falls through to the current fork state. NOT FOR UPSTREAM PR — this is personal fork-maintenance automation. Keep it on fork main only; do not push to the redact PR branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`browse goto <url>` followed by `browse text` could return the full text of a completely different page, wrapped in an UNTRUSTED envelope whose `source:` banner named that other page. Content looked plausible and was attributed to a URL that was never read. In one research session, 113 of 535 reads (21%) came back from a different host than the goto that preceded them, with no error. Cause: one daemon per git root with one global `activeTabId`. `goto` and `text` are separate CLI processes making separate /command requests, and handleCommandInternalImpl is async — it yields at every await, so requests interleave. A concurrent agent's goto lands between this caller's goto and its read. The comment claiming this was "safe because Bun's event loop is single-threaded" was false and is corrected in place. The fix is detection, not prevention: - nav-guard.ts records, per tab, the URL the last goto committed to and how many navigations have landed unread. A page-content read is refused with 409 when the tab drifted cross-origin, when the navigation didn't load a page, or when two gotos landed with no read between them (the clobber signature). Provenance is per-tab, never daemon-global: a global "last goto" is updated by the clobbering client too, so it would match and still serve the wrong page. - goto now reports the URL it actually landed on, flagging redirects, instead of echoing back the URL it was asked for while the tab sat elsewhere. - The read path resolves the session and every URL from this request's own tab rather than through browserManager.getCurrentUrl(), which reads the global activeTabId. That was the mislabelling half: content from one tab stamped with another tab's address in the banner the model reads as provenance. - sanitizePageUrl() is shared between the envelope and the guard's errors. Guard errors are returned outside the envelope, and a page picks its own URL via pushState, so an untruncated URL was a free text channel to the model. back/forward/reload move the expectation without counting as a fresh request, so `goto URL; reload; text` does not refuse itself. This detects the collision; it does not prevent it. Agents sharing a repo still share a tab. Per-agent tab isolation was prototyped as caller-supplied "lanes" and pulled back out: review found a double-resolve race, lane tabs created outside the tabOwnership model, and a lane-hijack path from unscoped low-entropy ids. That needs to be built on the existing clientId/tabOwnership model rather than beside it, as its own change. Verified: `goto A; goto B; text` exits 1 naming both URLs instead of returning B's text; `goto; reload; text` passes; full browse suite has the same two pre-existing failures as a clean tree, no new ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven browse test files ended afterAll with `setTimeout(() => process.exit(0), 500)`, a workaround for bm.close() hanging. Whichever ran first tore down the bun test runner ~500ms later, so `bun test browse/test/` stopped after ~3 of 125 files, printed no summary, and exited 0 — including when tests had already failed. Replace with the pattern nav-guard-e2e.test.ts already documents: fire the close and don't await it, don't exit. These files never called bm.close() at all, so this also stops leaking a Chromium per file. Before: 3 files, no summary, exit 0 (with failures present). After: 125 files, 2082 tests, real summary, exit 1 on failure / 0 on pass. The failures this exposes are pre-existing and were merely unreachable before; each was verified against the pre-change code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bin/gstack-config resolves GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR, but gstack-config.test.ts and gstack-update-check.test.ts isolated themselves with GSTACK_STATE_DIR alone. telemetry.test.ts, domain-skills-e2e.test.ts and domain-skills-storage.test.ts each set GSTACK_HOME at module top level with no restore, and bun shares one process across test files — so the leaked value outranked the tests' temp dir and the spawned script read somewhere else entirely. Pin all three precedence levels to one dir, placed after the extraEnv spread so the two tests that deliberately override GSTACK_STATE_DIR to a nested path still decide which dir is used. gstack-update-check reads GSTACK_STATE_DIR directly but shells out to gstack-config, so it inherited the same bug one level down — and failed by asserting a wrong value rather than erroring. Both files passed standalone and only failed in the directory run, which is why this stayed hidden until the teardown fix let the suite run to the end. Verified against all three leak sources pairwise; full-suite failures drop from 107 to 93, removing exactly these 10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 4bed1c0, which pinned the state vars at the gstack-config and gstack-update-check spawn sites. This covers the rest of the class. Bun runs test files sequentially in one process (module-load -> tests -> afterAll, then the next file), ordered by directory entry rather than by name or CLI argument order. A file that writes process.env.GSTACK_HOME at module top level without restoring it therefore poisons every file that runs after it, and which files those are varies by checkout. That is why this surfaces intermittently. Exposed spawn site (1 remaining in browse/test): learnings-injection.test.ts isolated itself with HOME alone, but bin/gstack-learnings-search resolves GSTACK_HOME:-$HOME/.gstack, so a leaked GSTACK_HOME outranks the override. Verified directly: with the leak the script reads the leaked directory and runs its full bun -e pipeline instead of exiting early. The existing assertion (status != 99) passes either way, so this silently exercised the wrong code path. Leak sources hardened (4, not the 3 first identified -- cdp-e2e.test.ts is a fourth). Each now saves the prior value and restores it in afterAll. Also restored alongside GSTACK_HOME: GSTACK_TELEMETRY_OFF (telemetry sets '0' and cdp-e2e sets '1', so they were overwriting each other) and GSTACK_PROJECT_SLUG. afterAll rather than afterEach is deliberate. test-setup.ts is already the central guard for this class, but it restores on afterEach, which would wipe the module-top-level assignment these files need for the duration of their own tests -- its own comment says as much. Per-file afterAll restore is the complement that covers the file boundary the central guard cannot. The spawn-site pin remains the load-order-proof guard; the restores stop the next such test from reintroducing the hazard for new spawn sites. Verified: 125 files / 2082 tests, 1975 pass / 92 fail / 7 errors both before and after. Two runs of identical code produce different failure sets (sidebar-ux flakes run to run), so the failure set was compared against the baseline's own steady state rather than a single run. Each touched file passes standalone, in all 12 victim x leak-source pairings, and all seven together. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes a regression introduced by b096fff. Measured against the real ~/.gstack/analytics/browse-telemetry.jsonl over a full-suite run: before b096fff: delta 0 lines after b096fff: delta 1 line <- regression with this fix: delta 0 lines (confirmed over 3 runs) Root cause is in src/telemetry.ts, which resolves the target path twice across an await: ensureDir() // resolves GSTACK_HOME .then(() => fs.appendFile(telemetryFile(), // resolves it AGAIN telemetryFile() is evaluated inside the .then(), after ensureDir() has already resolved. logTelemetry is fire-and-forget, so restoring GSTACK_HOME in afterAll landed inside that window: ensureDir() created the temp analytics dir, then the append re-resolved against os.homedir()/.gstack and wrote a stray noop_test row into the developer's real analytics file. No error, because the directory it checked did exist. Before b096fff this was masked -- the unrestored leak meant the late write went to some other test's temp dir instead. Closing the leak is still correct; it just needs the in-flight writes to settle into TMP_HOME first, which is what the 50ms drain does (the file already uses a 30ms settle in readEvents for the same reason). The underlying double-resolution in telemetry.ts is left alone here: it is a real latent bug but it is production code, and any fix belongs in a change that can be reviewed on its own terms rather than folded into a test-isolation pass. Suite unchanged at 125 files / 2082 tests, 1975 pass / 92 fail / 7 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s tests test/relink.test.ts passed standalone (24/24) but failed in the directory run on a wrong-value assertion: it expected flat skill names and got gstack-* prefixed ones. Root cause is in test/gstack-memory-helpers.test.ts, not in either file named in the original report. Both describe blocks there save the "original" GSTACK_HOME inside beforeEach but restore it in afterAll. beforeEach re-reads the variable after the previous test already overwrote it, so by the last iteration the saved value is a temp dir from an earlier test, and afterAll "restores" that instead of the real value. bun shares one process across test files, so the stale dir then outranked the pin in every later file. relink pins GSTACK_STATE_DIR, but gstack-relink shells out to gstack-config, which ranks GSTACK_STATE_ROOT > GSTACK_HOME above it. The leaked GSTACK_HOME won, so one test's `gstack-config set skill_prefix true` landed in the leaked dir — which, unlike the per-test tmpDir, survived into the next test and was read back there. Fixes, each verified to close the bug on its own: - memory-helpers: capture the saved values at collection time, before any beforeEach has run. - relink, telemetry, team-mode: pin all three precedence levels to one dir, placed after the env spread so a deliberate per-call override still decides it. telemetry and team-mode target scripts that read GSTACK_STATE_DIR but shell out to gstack-config, the same one-level-down bug fixed in gstack-update-check. - budget-override: it sets GSTACK_HOME at module load with no restore; add an afterAll (not afterEach — its tests need the value for their whole run). - redact-audit-log: restore GSTACK_HOME instead of unconditionally deleting it. test-setup.ts is left alone: adding GSTACK_HOME to RESTORE_KEYS would break the files that legitimately set it at module load, as the comment there explains. uninstall.test.ts needs no change — gstack-uninstall reads only GSTACK_STATE_DIR and does not shell out, so a leaked GSTACK_HOME cannot reach it. Root-suite failures drop from 7 to 6 (4036 -> 4037 pass), removing exactly the relink test and introducing no regressions. The 6 remaining also fail standalone and are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
handoff.test.ts launched Chromium against the developer's actual
~/.gstack/chromium-profile — the profile holding real cookies, sessions
and history. Confirmed causally by sampling --user-data-dir from the live
process table during the run: 42 samples showed ephemeral, test-spawned
PIDs using the real profile, all dead once the file finished. With this
change that count is 0 for the file and 0 across the full suite.
Two coupled changes, both required — reverting either puts the count back
(38 with the src fix alone reverted):
1. browse/src/browser-manager.ts — the headless→headed handoff path
hardcoded $HOME/.gstack/chromium-profile instead of calling
resolveChromiumProfile() like launch() and launchHeaded() do. That
bypassed the documented override chain, so CHROMIUM_PROFILE could not
redirect it. This is also a real non-test bug: gbrowser's
per-workspace CHROMIUM_PROFILE was silently dropped the moment a
session handed off to headed mode.
2. browse/test/handoff.test.ts — pin CHROMIUM_PROFILE to a temp dir,
restored in afterAll. Basename stays 'chromium-profile' so
cleanSingletonLocks()' own guard still recognises it.
Beyond the profile being driven directly, cleanSingletonLocks() deletes
Singleton{Lock,Socket,Cookie} from whatever it resolves — the exact thing
its own docstring says callers must not do without external coordination
that a test run cannot provide.
Note on method: ~/.gstack/chromium-profile mtime is NOT a usable signal
here. A long-running local browse daemon writes to it on its own; an idle
control with no tests running showed the mtime advancing regardless. The
earlier mtime-based readings in this investigation were confounded, and
the process-table sampling above replaced them.
Scope: an earlier attempt also pinned CHROMIUM_PROFILE at five server/CLI
spawn sites (pair-agent-e2e, pair-agent-tunnel-eval, sidebar-integration,
watchdog, commands). Measured with the same method, those sites never
launch Chromium against the real profile — 0 samples with the pins
reverted — and the pins cost 5 tests and added 1 failure plus 2 errors
(2077/93/9 against a 2082/92/7 steady state). Dropped as unjustified.
Suite unchanged at 125 files / 2082 tests, 1975 pass / 92 fail / 7
errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fix exposed 652042a made `bun test browse/test/` run all 125 files instead of ~3, which surfaced 98 failures. Triaged each as a stale assertion, an obsolete pin, or a real regression. One real regression — closeTab left the browser with zero tabs. browser-manager.ts: the page's own 'close' listener (wirePageEvents) fires during `await page.close()` and already repoints activeTabId, setting it to 0 when no tabs remain. closeTab then re-read activeTabId to decide whether to auto-create, so `tabId === this.activeTabId` never matched and the newTab() branch was dead code. `$B closetab` on the last tab left the session with no page until the user ran goto/newtab. Capture wasActive before the close. The test asserting this (snapshot.test.ts) was correct as written. Stale assertions — behavior intact, the test drifted. Each verified against source before touching: - dual-listener: canDispatchOverTunnel gained an args param so it can also reject --out disk writes over the tunnel. - sidebar-tabs: ws.close() gained (4001, 'intentional-restart'); the 4001 is load-bearing, the close handler uses it to skip the re-attach loop. - sidebar-tabs: the terminal-agent spawn moved out of cli.ts into the shared spawnTerminalAgent helper, so the Bun.spawn now lives in terminal-agent-control.ts. - terminal-agent: lazy spawn was refactored into the idempotent maybeSpawnPty helper, which is what calls spawnClaude now. - xvfb: the test was self-defeating rather than drifted. It used process.pid as its wrong-cmdline fixture, but running the file by name puts "xvfb.test.ts" in the runner's argv, so the substring check matched itself. isOurXvfb was always correct. Retargeted at pid 1. Note this one only fails on per-file invocation, never in a full-suite run. - sidebar-ux: the cleanup button survives but was rewired from a /sidebar-command POST to PTY injection, and its prompt was condensed. Retargeted at the real contract, including that gstackScanForPTYInject runs *before* gstackInjectToTerminal, asserted by index so the order cannot silently invert. Obsolete — deleted. ed1e4be replaced the HTTP-polling chat sidebar with the PTY terminal REPL; browse/src/sidebar-agent.ts is gone and server.ts carries an explicit rip marker for the /sidebar-* routes. Removed four test files that drive those dead endpoints and 25 describes in sidebar-ux.test.ts (1670 -> 790 lines). Two of the deleted files are security-named, so to be explicit: no protection was dropped. The old banner already used textContent/createElement throughout, so there was no escaping helper to lose, and the untrusted-content ingress scan is still live at POST /pty-inject-scan with its own coverage. What went away is the attack surface, not the guard. Also dropped the sidebar CSS describe: .stop-btn, .agent-tool and .experimental-banner have zero references in sidepanel.js and sidepanel.html, so those tests were passing while pinning dead styling. Suite: 1956-1957 pass, 15 skip, 0-1 fail across 121 files. The one intermittent failure is terminal-agent-ring-buffer-runtime.test.ts asserting two back-to-back mintLease() calls share an expiresAt — it straddles a millisecond boundary under load. Pre-existing, untouched here, passes standalone. browser-skills-e2e.test.ts flakes the same way; it spawns a nested `bun test`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nfed /health status Carried on the fork only until garrytan#2557 merges. This is NOT a "LOCAL (fork-only)" patch: it is upstream-bound, and once garrytan#2557 lands the /gstack-upgrade rebase should drop it as already-applied. If that rebase reports a conflict here, check whether upstream took the change first. ## Problem The sidebar's SEC shield has been dead UI since the PTY terminal rewrite (ed1e4be, garrytan#1216). That commit deleted `updateSecurityShield()` and both of its call sites along with the chat poll loop, but left the markup and CSS in place. Nothing has set the element's `data-status` since, and nothing unhides it — it ships `style="display:none"` and the only code that ever set `display: inline-flex` was the deleted function. Re-wiring it to /health.security is not viable, because that field is not live data either. `getStatus()` reads `classifierStatus` out of ~/.gstack/security/session-state.json, and the only writer of that file was sidebar-agent.ts, which the same rewrite deleted. `writeSessionState()` now has no production caller anywhere in the tree — its sole remaining caller is a unit test. So /health reports either a permanent 'inactive' (no file), or a stale 'protected' wherever an old state file survived on disk. A test run is enough to leave one behind. That makes the shield worse than absent if it is ever unhidden: a green shield sourced from leftover state reads as "no threats detected" when the real state is "not measured". This is the same fail-open class as garrytan#2026 (security dashboard reporting "0 attacks" on backend error) and the project's own 9cc41b7 guards-failing-open wave. ## Fix Remove the indicator and the status feed behind it, rather than wiring a needle to a gauge that measures nothing: - shield markup in sidepanel.html, its CSS, and the stale comment in sidepanel.js that documented the indicator as "not driven yet" - the `security` field from /health and the getStatus import - getStatus, SecurityStatus, StatusDetail, and the now-unreachable SessionState / readSessionState / writeSessionState - the tests covering them, including a getStatus shape assertion that passed on leftover fixture data and so never caught the false green The live defenses are untouched: the canary, content-security L1-L3, and the L4 sidecar on /pty-inject-scan all report through their own call sites and never through /health. Per-tab decision files are unaffected. ## browse/test/security-sidepanel-dom.test.ts Deleted outright, because every test in it is already dead. Two assert the shield's data-status flips off /health.security. The other four drive the security banner, whose markup ed1e4be removed — and the whole suite stubs /sidebar-chat, an endpoint that no longer exists. The file looks healthy only because every case is test.skipIf(!CHROMIUM_AVAILABLE) and silently skips wherever Playwright is absent. garrytan#2230 independently deletes this same file. If that lands first this hunk drops out as a delete/delete; the change is otherwise standalone. ## Note No regression test asserting the absence of the `security` key: it would fail on a correct future change that re-feeds the status from live signals (isSidecarAvailable, the content filters), punishing the fix rather than the regression. The reasoning is recorded in a comment where the field used to be. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'lease registry is independent of ring buffer state' ended by asserting that two back-to-back mintLease() calls produced identical expiresAt values. That only holds if both calls land in the same millisecond; under full-suite load they sometimes straddle a tick and the test failed with a 1 ms difference. It passed standalone and in most full runs, so it read as noise rather than a real regression. Assert closeness (<= 5 ms) instead of equality. The property the test exists to check is that both leases got the same TTL policy and that neither is coupled to ring-buffer state; wall-clock coincidence was never part of that, so nothing is lost. Injecting a clock into pty-session-lease would have bought exact equality at the cost of a test seam in production code for an assertion that does not need one. Swept the rest of browse/test/ for the same shape: no other assertion compares two separately-sampled timestamps for exact equality. The timestamp equalities in cdp-inspector-history-cap use synthetic counters, and the elapsed-duration budgets in sanitize and security-audit-r2 have order-of-magnitude headroom. Verified over 7 consecutive full `bun test browse/test/` runs: the lease assertion never failed. One run showed 2 unrelated load flakes (browser-skills-e2e nested `bun test` returning only its banner, and the pair-agent tunnel eval timing out on connect); both passed in the others. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`$B skill test` flaked under full-suite load, returning only bun's version
banner. Root cause is not read ordering: under a loaded parent, the FIRST
piped spawn in a process intermittently yields an empty stderr even though
the child wrote it and exited 0. Instrumenting a full `bun test browse/test/`
run showed it in 4 of 4 runs:
pipe#0 exit=0 outLen=28 errLen=0 <- lost
pipe#1 exit=0 outLen=28 errLen=76
pipe#2 exit=0 outLen=28 errLen=76
Both candidate workarounds still lose the same bytes: attaching readers
before awaiting `proc.exited`, and a manual `getReader()` drain loop. It bit
`skill test` because `bun test` splits its report across streams (banner ->
stdout, pass/fail summary -> stderr) and `handleTest` returned
`stderr || stdout`, so a dropped stderr degraded silently to the banner.
runToFiles() points the child's stdout/stderr at temp files, so the kernel
has flushed everything by the time the child exits. spawnSkill had the same
defect and now shares the helper rather than carrying a second copy of the
fix; there the loss was worse, blanking a skill's JSON result on
`$B skill run` while still reporting success.
Uses `Bun.file(path)` rather than raw fds from `fs.openSync`: opening and
closing descriptors around a spawn puts us in Bun's fd bookkeeping and
produced an intermittent `EBADF: bad file descriptor, epoll_ctl` on a later
spawn.
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; it now throws instead of returning an unverifiable success string
that the old assertion matched vacuously.
Verified with 5 consecutive full runs of `bun test browse/test/`. The
`terminal-agent` PTY WebSocket failures in that suite are pre-existing,
confirmed by reproducing them on a clean tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pipe-capture data loss it guards against is load-dependent and silent, so a behavioural test can't catch it reliably. Assert the structure instead: runToFiles owns the module's only Bun.spawn, and no site reads child output through `stdout: 'pipe'`, `new Response(proc.stdout)`, or `getReader()`. Comments are stripped before matching so the module's own prose, which names the banned pattern in order to explain it, doesn't trip the checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
frederik-kaster-noygear
added a commit
to frederik-kaster-noygear/gstack
that referenced
this pull request
Aug 13, 2026
… temp files, not pipes
`$B skill test` flaked under full-suite load, returning only bun's version
banner. Root cause is not read ordering: under a loaded parent, the FIRST
piped spawn in a process intermittently yields an empty stderr even though
the child wrote it and exited 0. Instrumenting a full `bun test browse/test/`
run showed it in 4 of 4 runs:
pipe#0 exit=0 outLen=28 errLen=0 <- lost
pipe#1 exit=0 outLen=28 errLen=76
pipe#2 exit=0 outLen=28 errLen=76
Both candidate workarounds still lose the same bytes: attaching readers
before awaiting `proc.exited`, and a manual `getReader()` drain loop. It bit
`skill test` because `bun test` splits its report across streams (banner ->
stdout, pass/fail summary -> stderr) and `handleTest` returned
`stderr || stdout`, so a dropped stderr degraded silently to the banner.
runToFiles() points the child's stdout/stderr at temp files, so the kernel
has flushed everything by the time the child exits. spawnSkill had the same
defect and now shares the helper rather than carrying a second copy of the
fix; there the loss was worse, blanking a skill's JSON result on
`$B skill run` while still reporting success.
Uses `Bun.file(path)` rather than raw fds from `fs.openSync`: opening and
closing descriptors around a spawn puts us in Bun's fd bookkeeping and
produced an intermittent `EBADF: bad file descriptor, epoll_ctl` on a later
spawn.
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; it now throws instead of returning an unverifiable success string
that the old assertion matched vacuously.
Verified with 5 consecutive full runs of `bun test browse/test/`. The
`terminal-agent` PTY WebSocket failures in that suite are pre-existing,
confirmed by reproducing them on a clean tree.
Upstream PR: garrytan#2559
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frederik-kaster-noygear
added a commit
to frederik-kaster-noygear/gstack
that referenced
this pull request
Aug 13, 2026
…ith a static tripwire The pipe-capture data loss it guards against is load-dependent and silent, so a behavioural test can't catch it reliably. Assert the structure instead: runToFiles owns the module's only Bun.spawn, and no site reads child output through `stdout: 'pipe'`, `new Response(proc.stdout)`, or `getReader()`. Comments are stripped before matching so the module's own prose, which names the banned pattern in order to explain it, doesn't trip the checks. Upstream PR: garrytan#2559 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frederik-kaster-noygear
added a commit
to frederik-kaster-noygear/gstack
that referenced
this pull request
Aug 14, 2026
… temp files, not pipes
`$B skill test` flaked under full-suite load, returning only bun's version
banner. Root cause is not read ordering: under a loaded parent, the FIRST
piped spawn in a process intermittently yields an empty stderr even though
the child wrote it and exited 0. Instrumenting a full `bun test browse/test/`
run showed it in 4 of 4 runs:
pipe#0 exit=0 outLen=28 errLen=0 <- lost
pipe#1 exit=0 outLen=28 errLen=76
pipe#2 exit=0 outLen=28 errLen=76
Both candidate workarounds still lose the same bytes: attaching readers
before awaiting `proc.exited`, and a manual `getReader()` drain loop. It bit
`skill test` because `bun test` splits its report across streams (banner ->
stdout, pass/fail summary -> stderr) and `handleTest` returned
`stderr || stdout`, so a dropped stderr degraded silently to the banner.
runToFiles() points the child's stdout/stderr at temp files, so the kernel
has flushed everything by the time the child exits. spawnSkill had the same
defect and now shares the helper rather than carrying a second copy of the
fix; there the loss was worse, blanking a skill's JSON result on
`$B skill run` while still reporting success.
Uses `Bun.file(path)` rather than raw fds from `fs.openSync`: opening and
closing descriptors around a spawn puts us in Bun's fd bookkeeping and
produced an intermittent `EBADF: bad file descriptor, epoll_ctl` on a later
spawn.
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; it now throws instead of returning an unverifiable success string
that the old assertion matched vacuously.
Verified with 5 consecutive full runs of `bun test browse/test/`. The
`terminal-agent` PTY WebSocket failures in that suite are pre-existing,
confirmed by reproducing them on a clean tree.
Upstream PR: garrytan#2559
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frederik-kaster-noygear
added a commit
to frederik-kaster-noygear/gstack
that referenced
this pull request
Aug 14, 2026
…ith a static tripwire The pipe-capture data loss it guards against is load-dependent and silent, so a behavioural test can't catch it reliably. Assert the structure instead: runToFiles owns the module's only Bun.spawn, and no site reads child output through `stdout: 'pipe'`, `new Response(proc.stdout)`, or `getReader()`. Comments are stripped before matching so the module's own prose, which names the banned pattern in order to explain it, doesn't trip the checks. Upstream PR: garrytan#2559 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 of 9 tasks
Owner
|
Thank you — this landed in v1.67.0.0 (credited in the CHANGELOG). Closing since the fix is on main. 🙏 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
$B skill testis flaky under load. In a fullbun test browse/test/run it intermittently returns only bun's version banner:The obvious reading is a read-ordering bug:
handleTestawaitedproc.exitedbefore attaching readers, so it looked like it was reading a stream snapshot. That is not the cause. Fixing the ordering did not fix the flake.Root cause
Under a loaded parent, the first piped spawn in a process intermittently yields an empty stderr even though the child wrote it and exited 0. Instrumenting a full suite run, spawning the same child three times in a row:
Reproduced in 4 of 4 instrumented runs. Two candidate workarounds were measured losing the same bytes in the same position, so neither is a fix:
proc.exitedand settling withPromise.allstream.getReader()drain loop (whatreadCappeddid)Bun.spawnSynccaptures reliably, but blocking is not an option in this module: a spawned skill calls back into the same daemon onGSTACK_PORT, so a synchronous wait would deadlock it.It surfaced in
$B skill testbecausebun testsplits its report across streams (banner to stdout, pass/fail summary to stderr) andhandleTestreturnedstderr || stdout, so a dropped stderr degraded silently to the banner. Worth noting the capture was lossy on every run, not just failing ones: the banner was missing from successful captures too.Fix
runToFiles()points the child's stdout/stderr at temp files viaBun.file(path), awaits exit, then reads the files. The kernel has flushed everything by the time the child exits, so the post-exit read is always complete. It also removes the pipe-buffer stall risk on chatty children.spawnSkillhad the same defect and now shares the helper rather than carrying a second copy of the fix. There the loss is worse than a test flake: a dropped read blanks a skill's JSON result on$B skill runwhile still reporting success.Uses
Bun.file(path)rather than raw fds fromfs.openSync. Opening descriptors and closing them around a spawn puts the caller into Bun's fd bookkeeping and produced an intermittentEBADF: bad file descriptor, epoll_ctlon a later spawn, observed once in a full-suite run.The
tests passed for "<name>"fallback is removed. A passingbun testalways prints a summary, so exit 0 with no output means the run was not captured; it now throws rather than returning an unverifiable success string. The old assertion'stests passedalternative matched that string, so it would have passed vacuously on an empty capture.Tests
runToFilesowns the module's onlyBun.spawn, and that no site reads child output viastdout: 'pipe',new Response(proc.stdout), orgetReader(). The data loss is load-dependent and silent, so a behavioural test cannot catch it reliably; the structure is what is pinned.Verification
5 consecutive full runs of
bun test browse/test/(1970 tests each) with the target test passing in all five, and noEBADF.The
terminal-agent: PTY round-trip via real WebSocketfailures in that suite are pre-existing and unrelated, confirmed by reproducing them on a clean tree (2 of 3 baseline runs).Not addressed here
14 other
stdout: 'pipe'capture sites share this pattern acrossbrowse/src/. The highest-value one iscookie-import-browser.ts, wheregetMacKeychainPasswordreturnsstdout.trim()as a keychain password and the DPAPI path base64-decodes stdout into a decryption key, so a lost read yields a silent empty credential rather than a loud error. Left out to keep this reviewable.Caveat
This establishes that the first piped spawn can drop a stream and that file-based capture is immune. It does not explain why Bun drops it. The fix is verified empirically against the reproduction, not derived from Bun internals.
🤖 Generated with Claude Code