fix(test): bound CLI subprocess fan-out so unit (linux) passes on GitHub-hosted runners (LAC-2715) - #37
Conversation
…ives small CI runners (LAC-2715) The 'opencode run (non-interactive subprocess)' suite spawned one cold 'bun run src/index.ts' per cliIt.concurrent test with unbounded fan-out. Each child re-transpiles the whole dependency graph, and per-test HOME/XDG_CACHE_HOME isolation left every child's Bun transpiler cache cold. On 4-vCPU GitHub-hosted runners the ~10 simultaneous cold starts starve each other until every child blows its 30s timeout with empty stdout (reproduced locally: cold caches + CPU stress -> 8 fail; with this fix under the same stress -> 13 pass). - Gate short-lived spawns behind a semaphore sized to the host cores; the per-spawn 30s timer starts once the permit is held, so a genuine hang still fails fast with the same tight budget. - Share one content-addressed Bun transpiler cache across children via BUN_RUNTIME_TRANSPILER_CACHE_PATH so only the first spawn pays the transpile cost. - Annotate synthesized timeout stderr with elapsed/budget timing since AppProcess drops collected stderr on timeout. - Raise per-test bun timeouts to cover serialized suite throughput; hang detection now lives in the per-spawn timeout, not the test one. Co-Authored-By: Paperclip <noreply@paperclip.ing>
fc21187 to
d9a6562
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces performance optimizations for the test suite by implementing a concurrency gate (spawnGate) and a shared Bun transpiler cache to prevent resource starvation on CI runners. The changes include adding a semaphore to limit concurrent subprocess spawns and configuring a shared cache directory for transpilation. I have kept the reviewer's suggestion to make the shared cache directory user-specific to avoid potential permission conflicts in multi-tenant environments.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const sharedTranspilerCache = path.join(os.tmpdir(), "opencode-test-bun-transpiler-cache") | ||
| fs.mkdirSync(sharedTranspilerCache, { recursive: true }) |
There was a problem hiding this comment.
Using a hardcoded directory name like "opencode-test-bun-transpiler-cache" in the shared system temp directory (os.tmpdir()) can lead to permission conflicts (EACCES) in multi-tenant CI environments or on shared local development machines when different users run the tests.
To prevent this, we should make the directory name user-specific by appending the current username.
| const sharedTranspilerCache = path.join(os.tmpdir(), "opencode-test-bun-transpiler-cache") | |
| fs.mkdirSync(sharedTranspilerCache, { recursive: true }) | |
| const username = os.userInfo?.().username || process.env.USER || "default" | |
| const sharedTranspilerCache = path.join(os.tmpdir(), "opencode-test-bun-transpiler-cache-" + username) | |
| fs.mkdirSync(sharedTranspilerCache, { recursive: true }) |
There was a problem hiding this comment.
Applied in 9174c6b — the cache dir is now suffixed with the current username. Used a try/catch around os.userInfo() (it can throw ENOENT in containers without a passwd entry) with USER/USERNAME env fallbacks for POSIX/Windows.
…CCES on shared machines
cc7f362
into
LAC-2385/blacksmith-to-github-runners
Paperclip issue
LAC-2715 — Fix unit (linux) CI failures:
opencode run (non-interactive subprocess)suite times out at ~30s on GitHub-hosted runners.Root cause
The suite spawns one cold
bun run src/index.tssubprocess percliIt.concurrenttest with unbounded fan-out (~10 at once, some tests spawn 3 sequentially). Each child re-transpiles the entire dependency graph, and the harness's per-testHOME/XDG_CACHE_HOMEisolation leaves every child's Bun transpiler cache cold. On a 4-vCPU GitHub-hosted runner the simultaneous cold starts starve each other until every full-session child hits its 30s subprocess timeout with empty stdout (exit -1,Error: Timed out) — exactly the 7 failures on run 29067555482. The tests that survived are the fail-fast ones (unknown model, bad attach) that exit before the heavy boot.Reproduced locally: fresh install/cold caches → 8 fail; warm second run → 13 pass. Deterministic A/B under 12× CPU-hog stress: unfixed → 5 pass / 8 fail (same signature as CI), fixed → 13 pass.
Fix (no blind timeout raises — root cause + fail-fast diagnostics)
spawnGatesemaphore intest/lib/cli-process.tscaps concurrent short-lived CLI spawns atmax(2, min(4, cores/2)). The per-spawn 30s timer starts only once the permit is held, so queue wait is not charged againsttimeoutMs/durationMsand a genuinely hung subprocess still dies at 30s.BUN_RUNTIME_TRANSPILER_CACHE_PATH(content-addressed, so per-test isolation is preserved). Only the first spawn pays the transpile cost; local fulltest/cli/runtime dropped 305s → 219s.AppProcessdrops collected stderr on timeout, so the synthesized stderr now includes elapsed time and the budget (Timed out after Xms (timeout 30000ms)) instead of a bareError: Timed out.run-process.test.ts: with gated spawns a test's wall clock includes queueing behind other tests, so the test-level budget must cover serialized suite throughput. Hang detection lives in the per-spawn timeout, not the test timeout. Duration regression assertions (durationMs < 30_000) are unchanged and now measure pure subprocess time.Acceptance criteria
unit (linux)reachessuccesson GitHub-hosted runners → verified by this PR's own CI (base:LAC-2385/blacksmith-to-github-runners).Testing
bun test test/cli/run/run-process.test.ts— 13 pass, cold shared cache.bun test test/cli/(all 53 files using or adjacent to the fixture) — 414 pass / 0 fail.bun run typecheck(packages/opencode) — clean.