🐛 Contain the browser bundler's process tree within its build scope - #422
🐛 Contain the browser bundler's process tree within its build scope#422taras wants to merge 1 commit into
Conversation
An interrupted build could leave its hash-named output in the scratch directory under the concurrent verifier. Two cancellation windows caused it: a halt during `until(Deno.makeTempFile(...))` abandons a promise that still creates the file after teardown, before any removal is registered; and `exec` from @effectionx/process suspends between spawning the child and registering the teardown that kills it, so a halt landing there orphans the bundler, which later writes the output after the removal already ran. The output path is now chosen without touching the filesystem, its force-removal is registered first, and the bundler runs through scripts/lib/contained-run.ts, which arms a terminate-and-join teardown before the process exists and creates the process in the same synchronous continuation. Teardown signals the detached process group and joins the child's close event — held open by the esbuild service through its inherited stderr — so the scratch cleanup runs strictly after the bundler's tree is gone. No sleeps, polling, or retries. Reproduced with 60 interrupted builds under CPU saturation sweeping halt delays 0-29ms: the previous code leaks a makeTempFile-named file within a few iterations; the contained build stays clean across all of them, with no immediate or late leftovers.
|
Full concurrent battery on this branch (commit b8f35a8, macOS arm64, Deno 2.9.5): Differential soak (60 interrupted builds under full CPU saturation, halt delays swept 0–29ms, alternating |
PR #422: 🐛 Contain the browser bundler's process tree within its build scope4 files, +203 / -6 Scope✅ PR scope looks good. Structural✅ No structural bloat detected. SlopOxlint slop signals:
Static AnalysisOxlint: 1 diagnostic across 1 file (1 rule) no-console (1): scripts/build-web-client.ts CorrectnessFILE, PATTERN, CONCERN, QUESTION for author |
Why
The concurrent
deno task verifybattery intermittently left the browser bundler's hash-named output in a test-owned scratch directory after scope teardown, failingbuild-web-client … leaves nothing behind when a build fails(#417). The same interruption paths run in production teardown, so any interruptedbuild:webcould leave the same litter in the system temp directory.Closes #417.
What changes
Before:
An interrupted
bundleClienthad two cancellation windows, each only wide enough to hit under heavy scheduler load:until(Deno.makeTempFile(...))abandons the promise — Effection'suntilcancel is a no-op — so the OS creates the scratch file after teardown, and theensure(rm)on the next line was never registered. The reported leftover (2361ba29aac95f1f.js) matchesmakeTempFile's 16-hex naming exactly.execfrom@effectionx/processsuspends several times between spawning the child and registering the teardown that kills it. A halt landing there orphansdeno bundleand its esbuild service, which later write the real output after the removal already ran.After:
The output path is chosen without touching the filesystem, its force-removal is registered before anything exists, and the bundler runs through a contained spawn whose terminate-and-join teardown is armed before the process exists, in the same synchronous continuation. Teardown signals the bundler's detached process group and joins the child's
closeevent before the scratch cleanup runs. No sleeps, polling, retries, deadlines, serialization, or exclusions.How it works
The join is the child's
closeevent, which settles only when every holder of the child's piped stderr has exited — the esbuild service inherits the bundler's fd 2 (verified withlsof), so grandchildren are covered. A halt can land before the spawn (nothing acquired, force-rmtolerates the missing file) or after the cleanup is armed — never between, because there is no suspension point between the two.Review guide
Start with:
scripts/lib/contained-run.tsThen review:
containedRunand thepid/exitedguards (skip the signal after a natural exit so a recycled pid is never signalled).bundleClientinscripts/build-web-client.ts: sync name choice,rm(..., { force: true })registered first, so LIFO teardown reaches it only afterrun's join.scripts/tests/contained-run.test.ts, especially "terminates and joins the whole tree before earlier-registered cleanup runs".Deno.execPath()fixtures; the existing build-web-client reason no longer mentionsmakeTempFile).Look carefully at:
catcharoundprocess.kill(-pid, …): on macOS,killpgon an all-zombie group throwsEPERMrather thanESRCH; either way the join onclosestill runs, unlike upstream's catch which skips its wait.What must stay true
leaves nothing behind …tests and the new join test.How to verify it
deno test --allow-all --frozen scripts/tests/contained-run.test.ts— the join test's grandchild inherits the child's piped stderr and would run forever, so the scope releasing at all proves group signal delivery, and the marker it writes while shutting down proves the join finished before earlier-registered cleanup ran. Mutation-checked: child-only kill makes it hang; skipping the join fails its assertion.halt()and failing-scope paths, with immediate and late leftover sweeps): the previous code leaks amakeTempFile-named file within ~3 iterations; this branch stays clean across all 60.deno task verify— full concurrent battery, green (report below in comments/CI).Scope
Included
Intentionally unchanged
@effectionx/processacquisition gap itself — escalated as a separate issue; every otherexeccall site keeps its current behavior.denoland/deno#36417wedge note and the battery's per-command deadline handling of it.test:nodefailure from the same battery run, recorded on Intermittent phantom single-failure in Node/Bun runtime suites #275.New abstractions
containedRun(scripts/lib/contained-run.ts) exists becauseexeccannot guarantee the child is owned across a halt during acquisition; the bundler needs that guarantee since cleanup registered before it deletes the file its process tree writes. Single production consumer today, contract pinned by its own test file.Scope confirmation