Skip to content

fix(plugins): compile bundles in a child process so a wedged Bun.build cannot hang the server or CI - #1144

Merged
cuongtranba merged 1 commit into
mainfrom
fix/plugin-build-subprocess
Sep 23, 2026
Merged

cuongtranba merged 1 commit into
mainfrom
fix/plugin-build-subprocess

Conversation

@cuongtranba

Copy link
Copy Markdown
Owner

The failure this fixes

The v1.58.1 publish (run 35817749252) died with exit 137: the CI hang watchdog SIGKILLed bun test after 170 s of silence. The diagnostics it captured tell the story precisely:

  • plugin_validate > accepts the hello fixture without installing it failed at its 60 s test timeout — the compile never finished
  • the next compile-running test never printed at all
  • bun sat idle in ep_poll with zero child processes — nothing external was stuck; the bundler wedged in-process

Root cause

buildPluginBundles ran Bun.build in-process with JS plugin callbacks (onResolve/onLoad for the host-module rewrite). On bun ≤ 1.3.x all Bun.build() calls serialize behind a single bundle thread, which can park inside the in-flight build's MiniEventLoop waiting on plugin work — fixed upstream in oven-sh/bun#35060 and oven-sh/bun#42680, neither in the pinned bun 1.3.11. Once one build parks, every later compile queues behind it forever. In production the identical wedge would hang a real install / validate / reload RPC with no way out.

Fix — process isolation, the pattern the plugin system already uses

buildPluginBundles now spawns plugin-build-child.adapter.ts (same process.execPath + script-path shape as spawnPluginChild; the package ships src/server/ so the child runs from source):

  • 20 s deadline → SIGKILL, then one retry in a fresh process; final failure is a clean {ok:false} with the child's stderr tail, never a hang
  • verdict travels as a marker-line JSON on stdout, read on the close event so the pipe is fully drained before settling
  • buildPluginBundlesInProcess stays exported only for the child; public callers (plugin-service install, plugin_validate) are unchanged
  • isolation is faster, not a tax: a child compile of the hello fixture measures ~100–200 ms where the tests used to budget 60 s
  • the deadline path is pinned by a deterministic stalling-build-child fixture test

CLAUDE.md's Plugin System section records the incident, the upstream bug, and why the compile must not move back in-process.

Also: one wall-clock flake defused

agent.test.ts dispose() resolves after gracefulTimeoutMs even if closed never resolves asserted elapsed < 500 around a 100 ms timer — observed at 731 ms under ordinary load, failing the suite twice while validating this change. The ceiling is now 5 s: the promise (dispose proceeds instead of waiting forever) is proven by the ≥ 90 ms bound and by resolving at all; a sub-second ceiling was measuring the scheduler.

Validation

  • bun run test — 8379 pass / 0 fail (623 files)
  • bun run typecheck, lint, lint:comments, check:arch, check:commits — clean
  • deadline test kills a stalling child twice and reports in ~1 s

CI noise scoreboard for today

  1. mock.module registry poison → fixed in fix(plugins): inject the contributions loader so a module mock cannot poison CI #1141 (shipped in v1.58.1), gate in test(guard): ban new module mocks and record the mock.module lesson #1143
  2. in-process Bun.build wedge → this PR (v1.58.1 publish re-dispatched meanwhile)
  3. dispose timing ceiling → this PR

…d cannot hang the server or CI

The v1.58.1 publish died at exit 137: plugin_validate sat at its 60s test
timeout with bun idle in ep_poll and zero child processes, every later
compile queued up behind it, and the CI hang watchdog killed the run at
170s. In-process Bun.build with JS plugin callbacks can park the single
bundle thread inside the in-flight build's MiniEventLoop — fixed upstream
in oven-sh/bun 35060 and 42680, neither in the pinned bun 1.3.11 — and in
production the same wedge would hang a real install, validate, or reload
RPC forever.

buildPluginBundles now spawns plugin-build-child.adapter.ts, the same
execPath-plus-script-path pattern as the plugin server child, with a 20s
deadline, one fresh-process retry, and a marker-line JSON verdict on
stdout read on the close event so the pipe is drained before settling.
A wedged bundler becomes a clean ok:false after the deadline instead of a
hung process, and the isolation costs nothing: a child compile of the
hello fixture measures 100-200ms where the tests used to budget 60s. The
deadline path is pinned by a stalling-child fixture test.

Also widens the dispose gracefulTimeoutMs upper bound in agent.test.ts
from 500ms to 5s: a 100ms timer measured against a 500ms wall-clock
ceiling fails under ordinary machine load (observed 731ms), which is
scheduler noise, not the promise under test — dispose proceeding instead
of waiting forever is proven by the lower bound and by resolving at all.
@cuongtranba
cuongtranba merged commit 0433a1c into main Sep 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant