Skip to content

Worker teardown test gate: add a closed mode and pin each weak post's outcome - #38458

Open
robobun wants to merge 1 commit into
mainfrom
farm/5fe08baf/teardown-gate-closed-mode
Open

Worker teardown test gate: add a closed mode and pin each weak post's outcome#38458
robobun wants to merge 1 commit into
mainfrom
farm/5fe08baf/teardown-gate-closed-mode

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #38299, from the worker-late-completion.test.ts thread in #38299 (review).

Problem

  • The weak rows of test/js/web/workers/worker-late-completion.test.ts matched [vm] late post: <tag> ( only, so they passed whether the post was released by the wait or refused.
  • The gate only parks posts until Draining, so since worker-refused-completion.test.ts was deleted nothing reaches the Closed -> Refused paths on purpose: VmHandle::post_cpp_task deleting the EventLoopTask on the posting thread (src/jsc/VmHandle.rs), and the waiter thread's refused branch (src/spawn/process.rs, release_ref_from_waiter_thread).
  • The released path was not deterministic either. With main's gate, the parked poster and the tearing-down worker race between Draining and Closed: close_and_wait publishes Draining, runs service(), sees no tickets and stores Closed before the woken poster gets to post(). Building this branch's test against main's src, the three weak rows (BroadcastChannel, MessagePort, waiter thread) came out refused in 23 of 30 runs here, so on main those rows mostly exercise refusal, and which one they exercise is luck.

Fix

  • BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE takes a mode (src/bun_core/env_var.rs, string instead of a feature flag; TestGate::from_env in VmHandle.rs; web_worker.rs / VirtualMachine.rs pass the mode through).
  • draining: as before, plus a parked count in the gate's debug state. A weak post the gate holds for Draining is counted from arrival until it has been made and reported, and close_and_wait does not store Closed while the count is non-zero (test_gate::nothing_parked), so such a post is always released by the wait. Ticketed posts need nothing extra: the wait is for their ticket.
  • closed: a weak post is parked until Closed and so refused. The parked thread holds one unit of active for the duration (the existing "weak access in progress" count), so close_and_wait returns, and the VM is destroyed, only after the post has been refused and reported; close_and_wait now notifies the condvar after publishing Closed as well as Draining.
  • Bypass, as requested: in closed mode a weak post that arrives while a ticket is outstanding is parked only until Draining. The poster may be the thread the wait is waiting for (ConcurrentCppTask::run_owned makes WebCrypto's result post while still holding the ticket), so parking it until Closed would hang the wait.
  • Test: each weak row now runs under both modes and asserts the exact line: released by the wait under draining, refused under closed, and released by the wait under both for crypto.subtle.digest (underTicket), which is the row that exercises the bypass (without it that run never ends). Ticketed rows are unchanged and run once. The waiter thread row now leaves the worker after the child's stdout hits EOF instead of right away, so the exit post reaches the gate before the worker tears down; with the old shape the child (a debug bun -e 0) usually exits long after the worker has closed, and the post is never parked at all.
  • Verified: bun bd test test/js/web/workers/worker-late-completion.test.ts 37/37 (debug, Linux). The 8 weak variants repeated 15 times serially and 10 times in 3 parallel loops: 360/360. Against main's src (test only): the 3 closed rows that matter fail as intended, and the draining rows flip between outcomes as described above. test/internal/source-lints/vm-thread-door.test.ts 48/48; cargo check -p bun_jsc --release (the not(debug_assertions) stub module) and cargo clippy -p bun_jsc -p bun_core clean.

Background

  • A worker VM's teardown (VmHandle::close_and_wait) goes Draining (wait for every Ticket, releasing whatever arrives on the worker's own thread) and then Closed (nothing off-thread may reach the VM any more). A weak poster, something holding a VmHandle but no ticket (another thread's MessagePort or BroadcastChannel post, the child-process waiter thread), gets Posted::Queued before Closed and Posted::Refused after, and on refusal must free its own payload; those refusal paths are what the closed mode covers.
  • active is the count of threads currently inside a weak access on the handle; close_and_wait publishes Closed and then waits for it to reach zero, so a weak access either finished before the VM is destroyed or saw Closed. The closed mode reuses it to keep the VM alive until the parked post has been reported.
  • The test gate (test_gate module in VmHandle.rs) exists only in builds with debug assertions and only for first-level workers; it parks a cross-thread post on its own thread until the worker's handle reaches the requested state, and names the post and its outcome on stderr, which is what the test reads.
Outcome of the weak rows under the draining gate, main's src vs this branch

Built with git stash push -- src/ (this branch's test file, main's gate), then bun test ... -t "draining gate" 10 times; the three rows expecting released by the wait:

iter 1:  pass=1 fail=2 (refused)
iter 2:  pass=0 fail=3
iter 3:  pass=2 fail=1
iter 4:  pass=1 fail=2
iter 5:  pass=1 fail=2
iter 6:  pass=1 fail=2
iter 7:  pass=0 fail=3
iter 8:  pass=0 fail=3
iter 9:  pass=0 fail=3
iter 10: pass=1 fail=2

Failure detail is always the same line, e.g. [vm] late post: CppTask (refused).

With this branch's src: 15 serial iterations of all 8 weak variants, then 10 iterations in each of 3 parallel loops, no failures (360/360).


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-late-completion.test.ts

… outcome

BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE now takes a mode. "draining" holds a
cross-thread post until the worker's teardown is waiting, as before, and
the wait no longer closes while a post it released has not been made, so
the post is always delivered and released by the wait. "closed" holds a
weak post until the wait has ended, so it is refused and the poster frees
its own payload; close_and_wait does not return until that post has been
made. A weak post made while a ticket is outstanding is never held past
the start of the wait in either mode, since the wait may be for the
thread making it (WebCrypto posts its result from the pool task that
carries the ticket).

worker-late-completion.test.ts runs each weak row under both modes and
asserts the outcome word: released by the wait under draining, refused
under closed, released under both for the WebCrypto row. The waiter
thread row now leaves the worker once the child's stdout has hit EOF, so
the exit post is in the gate's hands before the worker tears down.
Without the src change, the three weak rows came out refused in 23 of 30
runs under the draining gate (the worker closes before the woken poster
posts), which is what the previous prefix match was hiding.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

@robobun, your commit 20df49a has some failures in Build #95884 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38458

That installs a local version of the PR into your bun-38458 executable, so you can run:

bun-38458 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; the diff is green on every CI job that ran.

  • Requested in the worker-late-completion thread of One door out of a VM's thread: tickets + a teardown that waits #38299 (review 4935920206): a gate mode that parks until Closed, with the outstanding-ticket bypass, and weak rows that assert the outcome word.
  • Local: worker-late-completion.test.ts 37/37 on the debug build; the 8 weak variants 360/360 across serial and 3x parallel loops. With main's src and this test, the closed rows fail and the draining rows flip between outcomes (23 of 30 runs refused), see the PR body.
  • CI build 95884 finished with 178 of 179 jobs passed, including the darwin 14 aarch64 - test-bun shard that eventually ran (16:05 UTC) and the other darwin lanes, all of which run this test file. The one red job is the second darwin 14 aarch64 - test-bun shard, which never started: it expired in the queue four times (that lane had roughly 230 jobs waiting on 6 agents) and the automatic retries stopped after the third. The three test blips that showed up along the way (fetch-leak, sqlite, inspect-error-leak) all passed on retry and are unrelated to this change.
  • Retrying that single expired job is enough to make the build green; I have not re-pushed to avoid queuing a fresh build behind the same lane.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 22439bc8-637b-4eac-9f47-6e960b017375

📥 Commits

Reviewing files that changed from the base of the PR and between 385aee6 and 20df49a.

📒 Files selected for processing (5)
  • src/bun_core/env_var.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/VmHandle.rs
  • src/jsc/web_worker.rs
  • test/js/web/workers/worker-late-completion.test.ts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. It extends debug-only test infrastructure with a second gate mode and reworks the condvar/atomic interplay in close_and_wait / test_gate::weak_post; the concurrency reasoning looks sound and release builds compile it all away, but given it was requested in a specific review thread and touches the VM teardown wait, the reviewer who asked for it should confirm the shape matches what they had in mind.

What was reviewed:

  • Traced the closed-mode path: manual active.fetch_add + Access guard is balanced against the inner enter() in the post closure, and close_and_wait's post-Closed active wait cannot return before the parked poster has reported.
  • Traced the draining-mode path: parked is incremented before park_until and decremented after report + notify, so nothing_parked cannot let the wait store Closed under a poster that has been let through.
  • Checked the ticket-outstanding bypass: tickets == 0 at check time means the poster itself holds no ticket, so parking it until Closed cannot deadlock the wait-for-tickets.
  • Verified the release stub module still compiles (all changed hooks stubbed; first_report_secs is only called under cfg(debug_assertions)), and no other reader of the old boolean feature flag remains.
Extended reasoning...

Overview

Follow-up to #38299: turns the debug-only BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE from a boolean into a two-mode string (draining / closed), so the worker-teardown test can deterministically exercise both the "weak post released by the wait" and "weak post refused after Closed" paths, and assert the exact outcome word instead of just that a line appeared. Touches env_var.rs (flag kind change), VmHandle.rs (the test_gate module and two hook calls in close_and_wait), web_worker.rs / VirtualMachine.rs (pass the mode through), and the test file.

Security risks

None. Everything new is behind #[cfg(debug_assertions)] and a BUN_DEBUG_* env var read only for first-level workers; the release-build stub module makes every hook a no-op (including the new nothing_parkedtrue), so close_and_wait's production behaviour is byte-identical.

Level of scrutiny

Higher than the diff size suggests. The test_gate module is concurrent code that parks foreign threads on the same condvar the JS thread's teardown wait sleeps on, and the new closed mode reuses the production active count to keep close_and_wait from returning until the parked poster has reported. I walked both modes' wakeup orderings (see the summary above) and found them correct — in particular the double-Access in the closed path (one manual, one from the inner enter() inside the refused post) is balanced, and the parked counter is bumped before park_until so the JS thread cannot race past it to Closed. The waiter-thread test row is still timing-biased (stdout EOF vs. reap → post), which the PR body acknowledges and validates with 360/360 runs.

Other factors

This was requested in a specific review thread by a reviewer who presumably has the surrounding context (the deleted worker-refused-completion.test.ts, the exact bypass semantics they wanted). The PR body is thorough — cross-checked against main's src, clippy/release-check clean, source-lint test still passes — and the change is internally consistent. Deferring so the requesting reviewer can confirm this is the shape they asked for; nothing here needs changing from what I can see.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants