Repository navigation
Conversation
…-safe The fixture's put() bumped the shared count with Atomics.add and then stored the tag. worker.terminate() can throw the TerminationException inside the host call between those two steps (JSC handles VM traps in RETURN_IF_EXCEPTION), leaving a count that points at an unwritten slot, which the terminate() test then read as [ready, 0]. Store the tag first and publish the count last, so a put() that is cut off records nothing. The worker is the log's only writer, so a plain load/store pair is enough.
|
Status: test-only fix, ready for review. The branch now includes main (449877f). Reproduced how: a loop of
The PR description has the CI rate, the probe and its results. |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Write event tags before publishing counts test/js/node/worker_threads/worker_threads.test.ts |
The put helper stores the event tag before updating the shared count. |
Possibly related PRs
- oven-sh/bun#40132: Changes worker shutdown behavior during exit-listener execution, which is also relevant to worker termination and event logging.
Suggested reviewers: dylan-conway
Priority:
Merge Risk: 
Minimal · up to 021f6
-
The change improves test reliability without altering production behavior.
Pre-merge checks: 
4
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
LGTM — targeted test-fixture fix for a documented CI flake.
Reviewed: the reordered put() (write tag, then publish count) is safe because the worker is the log's sole writer and the parent reads only after 'exit'; Atomics.store is seq-cst so the tag is visible before the count. Confirmed the count store stays unconditional (overflow clamp at line 2568 still works) and the second test (process.exit path, no async termination trap) is unaffected. The invariant the test protects — a handler that completes after the stop request is recorded — is preserved; only a put() already in flight when termination landed changes from a spurious 0 to nothing.
Extended reasoning...
Overview
One-line change (plus a four-line explanatory comment) inside the inline worker source string in test/js/node/worker_threads/worker_threads.test.ts, describe block "worker stop ordering as seen by the worker's own handlers". The old put bumped the shared count with Atomics.add and then stored the tag; the new put reads the count, stores the tag into its slot, and stores the incremented count last. The parent's read logic (Math.min(Atomics.load(log, 0), log.length - 1) slots, interval ticks filtered) and both tests' assertions are untouched.
Security risks
None. Test-only; no production code, no user-facing behavior, no external inputs.
Level of scrutiny
Low-to-medium. It is a de-flake, so per REVIEW.md the key question is whether the change silently weakens the assertion. It does not: a handler that runs to completion after terminate() still writes its tag and publishes its count, so the expect(tags).toEqual([TAG.ready]) assertion still fails on it. Only a put() that termination interrupts mid-flight — i.e., one that started before the request was serviced, not after — now records nothing instead of a bogus 0. That is exactly the flake shape reported ([12, 0]) and is not something the test intended to catch.
Correctness details checked:
- Single-writer: all
put()callers (interval, event handlers, process 'exit' handler) run on the worker's thread, so replacingAtomics.addwith load/store cannot lose an increment. - Ordering: JS
Atomics.storeis sequentially consistent, so the tag store happens-before the count store; the parent'sAtomics.load(log, 0)after the 'exit' event sees the count only after the tag is visible. - Overflow path: the count store is outside the
if (i < log.length)guard, matching the old unconditionalAtomics.add; the parent'sMath.minclamp at line 2568 still bounds the slice. - The
process.exit()test (second case in the describe) does not involve async termination traps between host calls, so its recorded sequence is unchanged.
Other factors
The PR description is unusually thorough: it names the mechanism (JSC VM traps serviced inside RETURN_IF_EXCEPTION, so TerminationException can be thrown between two consecutive host calls), rules out the alternative hypothesis (parent seeing 'exit' before the worker VM is gone) with source references, and includes a probe that widens the window and reproduces the 0-entry only with the old ordering. Verified to pass under bun bd test and 40/40 release loops. No prior reviews or comments on the PR.
|
Cross-reference: #39067 (making worker_threads.test.ts concurrent) touches the same describe. It hit the same |
|
Seen again on main at b993710. A full-file The fixture on main still has the two-step |
|
Updated 1:38 AM PT - Oct 26th, 2026
✅ @robobun, your commit 021f6b2869b7c866b24b11461165a4aca2892159 passed in 🧪 To try this PR locally: bunx bun-pr 38194That installs a local version of the PR into your bun-38194 --bun |
Behaviour change: none
Test-only fix for the flaky test "terminate(): nothing of the worker's runs after the request" in
test/js/node/worker_threads/worker_threads.test.ts.Problem
expect(tags).toEqual([TAG.ready])receives[12, 0]. No tag is 0. CI: 54 of 875 sampled builds since 2026-09-02, all on the x64-asan lane.put()bumps the count of a shared log withAtomics.add, then writes the tag withAtomics.store.worker.terminate()can stop the worker between the two. The parent then reads a slot that nothing wrote.Fix
put()stores the tag first and publishes the count last. Aput()that the stop cuts short records nothing.bun bd test(debug+ASAN) the describe block fails 7 of 260 runs before, 0 of 200 after. Release+ASAN: 2 of 600 before, 0 of 600 after. The whole file passes.Background
TerminationExceptionat its next check.RETURN_IF_EXCEPTIONin a host function is such a check, so the stop can land between twoAtomicscalls.put()in flight. In 35 of 35 runs with a 0 entry it is an interval tick. The 0 stays after the thread is joined.Notes
The failure as CI prints it
CI rate
Source: the Buildkite annotations of 1016 builds, sampled in windows between build 100000 and build 132961.
[12, 0]on debian 13 x64-asan. 47 pass on the first retry and 7 on the second.What did not change, and what is not known
SHARE_ENVprocess.envprototype #45283 and Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 change other parts of the file.Why the stop lands in a put() so often
terminate()when it gets "ready". The first interval tick of the worker runs at almost the same moment.The mechanism in the code
RETURN_IF_EXCEPTIONhandles pending traps:Source/JavaScriptCore/runtime/ExceptionScope.h:131at the pinned WebKit commit 680ef47cec (vm.traps().maybeNeedHandling(), thenvm.hasExceptionsAfterHandlingTraps()).Atomics.storepasses at least four of these checks before its write:runtime/AtomicsObject.cpp:360,:363,:341,:343. The write is at:349.Atomics.addreturns directly after its atomic step (:119).VmHandle::request_termination(src/jsc/VmHandle.rs:391) sets the handle toStoppingbefore it fires the trap.TimerObjectInternals::fire(src/runtime/timer/timer_object_internals.rs:493) does not call the callback whenscript_execution_status()is notRunning.shutdown()(src/jsc/web_worker.rs:1013) callsWebWorker__workerGlobalScopeDestroyedafter it destroys the VM.Probe results
The probe runs the fixture's worker source and the fixture's parent steps. It adds a slot that holds the tag of the
put()in flight. It also reads the log a second time, 100 ms afterterminate()resolves. Both builds are main at 449877f. Six probe processes run at a time, three for each logger.TAG.intervalTick. No run has a tag of a handler that the stop starts (exit, close, error, cancel).put()calls, and the log shows nothing of them.Loops of the test command
Each run is one bun process. Runs of the old fixture and of the new fixture alternate.
-t "worker stop ordering", one at a time-t "worker stop ordering", four at a time-t "worker stop ordering", four at a time[12, 0]in the terminate test.bun bd. release+ASAN isbun run build:asan. The CI lane builds--profile=ci-build --asan=on.bun bd test test/js/node/worker_threads/worker_threads.test.tson this branch: 161 pass, 0 fail.Earlier probe (2026-08-13)
That probe put N
Atomics.load(log, 0);statements between the two steps ofput(). It terminated 500 workers for each logger on a release build.put()calls before the count store.Probe source