Skip to content

Move WTFTimer out of the shared timer heap to fix a cross-thread race - #33131

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/09f07a17/timer-heap-lock
Jul 16, 2026
Merged

Move WTFTimer out of the shared timer heap to fix a cross-thread race#33131
Jarred-Sumner merged 2 commits into
mainfrom
farm/09f07a17/timer-heap-lock

Conversation

@robobun

@robobun robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

WTF::RunLoop::TimerBase (JSC's GC scheduler timers, and the timer behind an Atomics.waitAsync timeout) is started and stopped from threads other than the one that owns its run loop: Atomics.notify on thread B re-arms thread A's JSRunLoopTimer and cancels thread A's waitAsync timeout, and ~ArrayBufferContents does the same from whichever thread drops the last SharedArrayBuffer reference.

Those timers shared one pairing heap (All.timers) with setTimeout and every other same-thread timer. All.lock existed only because of them, and get_timeout's pre-poll pass peeked and popped that shared heap without taking it, racing the locked Intrusive::remove from the other threads. In a debug build that trips

assertion failed: self.root == v    (src/io/heap.rs:165, Intrusive::remove)

and in a release build the check compiles out, so remove walks the corrupted links silently.

Fix

Rather than locking the shared heap, take the one cross-thread client out of it:

  • All.wtf_timers (a Guarded<TimerHeap>) holds only WTFTimer nodes, reached through All::wtf_arm and All::wtf_disarm from any thread.
  • All.lock is deleted. insert, remove, update, the regular heap, the fake timers, and the epoch are single threaded again, asserted with a debug_assert! on the owning thread id, so setTimeout no longer takes a mutex per call. The fake-timer lock helper and its assert_locked machinery existed only because of that shared lock, so they are deleted too.
  • get_timeout no longer pops and fires inline. It drains the due WTFTimers through their own mutex (dropping the guard across each fire, which can synchronously re-enter it), then returns min(wtf, regular, quic).
  • drain_timers drains the WTFTimer heap first, preserving both existing firing paths: the pre-poll one only runs when the uws loop is active, and on Windows drain_timers runs from on_uv_timer.
  • WTFTimer.lock (the per-instance mutex) is deleted; wtf_timers owns everything it guarded, and update never took it anyway.
  • ensure_uv_timer folds both heaps into the libuv deadline and is now only reachable from the owning thread, which also removes the cross-thread TLS hazard it had.

WTFTimer never enters the fake-timer heap. Its tag already returned false from allow_fake_timers(), but it can no longer reach that branch at all.

Reproducer

test/js/web/timers/timer-heap-race.test.ts with timer-heap-atomics-fixture.ts: four threads each arm batches of short Atomics.waitAsync timeouts while the others Atomics.notify them, alongside setTimeout churn. Under a debug build of main this aborts within a few seconds with the assertion above, or with an ASan heap-use-after-free in Intrusive::remove at src/io/heap.rs:178. With this change it runs to completion.

A second fixture drives Bun.gc(true) in a setTimeout loop so the per-VM JSRunLoopTimer is re-armed and popped repeatedly on the owning thread.

Rebased onto #33359, #33623, #33896, #34009

Squashed to one commit and rebased; the earlier commits in the branch history were the two rejected approaches (locking get_timeout, then Guarded<Heaps>).

One conflict had semantic content: #33896 switched the regular heap's clock reads in get_timeout and next from AllowMockedTime to ForceRealTime because every node that lands there is armed in real-time units. WTFTimer is one of those tags, and its new separate heap is the same case, so the ForceRealTime change is also applied in drain_due_wtf_timers. The #33359 hunk (skip uv_timer.start when already due sooner) and the #33623 hunks (set_wall_ms, clear_wall, Bun__FakeTimers__setSystemTime) applied without semantic interaction.

#34009 added a now_out: &mut Option<Timespec> out-parameter to get_timeout so its caller can reuse the monotonic clock read; the lazy maybe_now in the rewritten body becomes a reborrow of that out-parameter, and drain_due_wtf_timers fills it the same way the old loop did.

Verification

bun bd test test/js/web/timers/timer-heap-race.test.ts    # 2 pass
bun bd test test/js/bun/test/test-timers.test.ts          # #33896's suite, all pass
bun bd test test/js/bun/test/fake-timers/fake-timers.test.ts   # #33623's suite, all pass

Three setTimeout doesn't leak when X is called inside its own callback tests in test/js/web/timers/ fail under debug + ASan on this machine, and did so identically with an unmodified src/ at the previous merge base: their fixtures widen the RSS threshold only when process.execPath.includes("bun-asan"), which is never true for the bun-debug binary name.


[review] gate passed · iteration 11 · 10 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/timers/timer-heap-race.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f0fa43421)

test/js/web/timers/timer-heap-race.test.ts:
19 | 
20 |   return { stdout, stderr, signal: proc.signalCode, exitCode };
21 | }
22 | 
23 | it("timer heap survives cross-thread Atomics.waitAsync timeout cancellation", async () => {
24 |   expect(await runFixture("timer-heap-atomics-fixture.ts")).toEqual({
                                                                 ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "signal": null,
-   "stderr": Any<String>,
+   "exitCode": 134,
+   "signal": "SIGABRT",
+   "stderr": 
+ "============================================================
+ Bun Debug v1.4.0 (f0fa43421) Linux x64
+ Linux Kernel v6.17.0 | glibc v2.41
+ CPU: sse42 popcnt avx avx2 avx51
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3036.31ms]
(skip) timer heap stays consistent while GC re-arms the RunLoop timer

 1 pass
 1 skip
 0 fail
 1 expect() calls
Ran 2 tests across 1 file. [3.21s]
__F:0:S:1
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/timers/timer-heap-race.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f0fa43421)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3787.70ms]
(pass) timer heap stays consistent while GC re-arms the RunLoop timer [2478.37ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [8.23s]
__F:0:S:0

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
error: could not download file from 'https://static.rust-lang.org/dist/2026-05-06/channel-rust-nightly.toml' to '/root/.rustup/tmp/pg_7uy9hcvyywudd_file.toml': error downloading file: error sending request for url (https://static.rust-lang.org/dist/2026-05-06/channel-rust-nightly.toml): client error (Connect): tls handshake eof
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f0fa43421e
  features     (none)

22 deps, 106 codegen, 1168 objects in 667ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [10.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1231] gen bindgenv2
[4/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [
... (truncated)
diff hotspot
src/runtime/hw_exports.rs                        |   4 +-
 src/runtime/jsc_hooks.rs                         |  12 +-
 src/runtime/test_runner/timers/FakeTimers.rs     | 183 ++--------
 src/runtime/timer/Timer.rs                       |   6 +-
 src/runtime/timer/WTFTimer.rs                    |  58 +---
 src/runtime/timer/mod.rs                         | 414 ++++++++++++-----------
 src/runtime/timer/timer_object_internals.rs      |   8 +-
 test/js/web/timers/timer-heap-atomics-fixture.ts |  68 ++++
 test/js/web/timers/timer-heap-gc-fixture.ts      |  17 +
 test/js/web/timers/timer-heap-race.test.ts       |  43 +++
 10 files changed, 411 insertions(+), 402 deletions(-)

gate history · 3 passed · 0 rejected · iteration 11

evidence per changed file
file                                              reads  edits  tests
src/runtime/hw_exports.rs                             2      3      0
src/runtime/jsc_hooks.rs                              2      2      0
src/runtime/test_runner/timers/FakeTimers.rs          9     16      0
src/runtime/timer/Timer.rs                            1      1      0
src/runtime/timer/WTFTimer.rs                         3     13      0
src/runtime/timer/mod.rs                             24     59      0
src/runtime/timer/timer_object_internals.rs           4      3      0
test/js/web/timers/timer-heap-atomics-fixture.ts      2      4      0
test/js/web/timers/timer-heap-gc-fixture.ts           2      3      0
test/js/web/timers/timer-heap-race.test.ts            3      6      0

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The timer runtime moves heap and epoch state behind a guarded Heaps container, updates timer pop, timeout, cancellation, and fake-timer paths to use that lock, and adds Atomics and GC timer stress fixtures with regression tests.

Changes

Timer heap locking refactor and race coverage

Layer / File(s) Summary
Heap state and timer bookkeeping
src/runtime/timer/mod.rs, src/runtime/timer/timer_object_internals.rs
TimerHeap::delete_min now marks popped timers as removed and fired. Heaps stores the timer heaps and epoch behind Guarded, All uses that container for initialization and insertion, ensure_uv_timer snapshots deadlines under the heap lock, and TimerObjectInternals::init reads the epoch through the new accessor.
Timeout selection and deferred fire
src/runtime/timer/mod.rs, src/runtime/timer/WTFTimer.rs
All::get_timeout and All::next use heaps.lock() for peek and delete-min operations while keeping the heap lock out of WTFTimer::fire. WTFTimer::fire no longer rewrites the fired state after the pop site already updated it.
Fake timer access through guarded heaps
src/runtime/test_runner/timers/FakeTimers.rs
FakeTimers now reaches per-thread timer state through heaps().lock(), removes the lock-assertion flow, stops resetting in_heap during clear, and updates execute, activation, deactivation, counting, and state checks to use the guarded heaps API.
Timer race fixtures and regression tests
test/js/web/timers/timer-heap-atomics-fixture.ts, test/js/web/timers/timer-heap-gc-fixture.ts, test/js/web/timers/timer-heap-race.test.ts
The Atomics fixture adds worker-thread wait/notify activity and a main-thread hammer loop. The GC fixture repeatedly allocates, forces GC, and reschedules a timer. The regression test spawns both fixtures as subprocesses and checks their output and exit status.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: moving WTFTimer out of the shared heap to address a cross-thread race.
Description check ✅ Passed It covers what the PR changes and how it was verified, which satisfies the template requirements despite using custom section headings.

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

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:27 PM PT - Jul 14th, 2026

@robobun, your commit f0fa434 has 1 failures in Build #73001 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33131

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

bun-33131 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Crash in Timer.fire #18181 - Long-running process crashes in Timer.fire during GC IncrementalSweeper activity; stack trace shows the timer fire path racing with GC, matching the exact data race scenario fixed here
  2. Segfault at 0x8 in tickImmediateTasks during long Claude Code session (macOS arm64, v1.3.14) #30418 - Segfault at null+offset in tickImmediateTasksTimerObjectInternals.run during a long idle session; consistent with a corrupted/stale timer node from the unlocked heap pop
  3. Crashed during Claude Code session #24033 - Wild pointer segfault at Bun__JSTimeout__callTimerObjectInternals.fire; crash directly in timer callback dispatch, consistent with firing a corrupted timer heap entry

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #18181
Fixes #30418
Fixes #24033

🤖 Generated with Claude Code

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

I looked at all three before adding any Fixes lines, since those auto-close the issues on merge.

#18181 is plausibly this bug, but I cannot prove it, so I am not closing it. Its stack is the exact code path this PR serializes: the event loop's pre-poll timer pass (autoTickActive) popping and firing the per-VM RunLoop timer (EventLoopTimer.fire -> JSRunLoopTimer::Manager::timerDidFireCallback -> IncrementalSweeper::doWork). That pop is the unlocked heap access, and the node it pops is the one the collector thread's locked All::update contends with (JSRunLoopTimer::Manager routes every GC timer, including the sweeper's, through that one node). A corrupted pairing heap hands out wild or duplicate nodes, and the resulting memory corruption is the kind of thing a libpas consistency abort (the Illegal instruction inside bmalloc_heap_config_specialized_try_deallocate_... in that report) only catches much later. The faulting frame is several layers past the timer heap, though, so the report is consistent with this race rather than proven by it, and an unexplained allocator abort in a long-running process has other candidate causes.

#30418 is not this bug. That crash is tickImmediateTasks -> ImmediateObject.runImmediateTask. Immediates are drained from their own queue, not from the timer heap, so the unlocked pop this PR fixes is not on that path.

#24033 is not this bug either. The fault is inside the user's JS callback (JSC::constructSet, many JS frames past the dispatch). TimerObjectInternals.fire in that stack only says the code ran from a setTimeout; the timer object itself was dispatched successfully.

Leaving the description as is; if a maintainer wants #18181 linked, the cross-reference from this comment is there.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can the code be restructured to avoid unsafe instead of adding more unsafe?

@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 didn't find issues, but this restructures lock acquisition and node-state bookkeeping in the core event-loop timer heap with subtle cross-thread and re-entrancy reasoning — worth a human pass on the lock/unlock placement and the broadened InHeap::None no-op in remove_lock_held.

Extended reasoning...

Overview

This PR modifies src/runtime/timer/mod.rs and src/runtime/timer/WTFTimer.rs to close a data race on the intrusive pairing heap that backs every runtime timer (setTimeout/setInterval, the JSC GC RunLoop timer, the date-header timer, the event-loop-delay monitor, etc.). All::get_timeout now takes All.lock around its peek/pop loop and releases it only around EventLoopTimer::fire; both pop sites (get_timeout and All::next) now write in_heap = None / state = FIRED under the lock; WTFTimer::fire no longer writes state itself; remove_lock_held now treats InHeap::None as a silent no-op instead of debug_assert!(false); and the Windows on_uv_timer callback now locks around ensure_uv_timer. A new debug-only test fixture drives GC against a timer chain to exercise the path.

Security risks

None in the auth/crypto/permission sense. The risk class here is memory safety and liveness: this is unsafe Rust with raw-pointer heap manipulation and manual (non-RAII) lock/unlock. A misplaced unlock would deadlock the event loop (the Mutex is non-recursive and fire re-enters All); a missed lock would leave the original race; and the InHeap::None early-return now silently swallows a state that previously asserted, which could mask future bookkeeping bugs in callers other than the documented WTFTimer::cancel race.

Level of scrutiny

High. This is the scheduling core of the runtime — a regression here affects every timer in every Bun process. The control flow in get_timeout was non-trivially restructured (the inner match became a break Some((min_next, now)) loop with post-loop branching), and All::next now writes state = FIRED for all popped nodes, not just WTFTimer, which depends on the claim that every fire handler already treats a popped node as FIRED. The reasoning in the PR description is thorough and the changes look correct to me, but the unlock-around-fire pattern, the cross-thread re-arm window between pop and fire, and the interaction with the Windows insert_lock_held → ensure_uv_timer path (already called under lock) deserve a reviewer who knows this subsystem.

Other factors

The author explicitly notes the race cannot be deterministically reproduced from JS, so the new test is a smoke test for the code path rather than a fail-before regression. CI is still building. No prior human review on this PR. Given the concurrency subtlety, the unsafe-Rust surface, and the breadth of timers routed through these two functions, I'm deferring rather than approving.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Done in d748590. The lock no longer goes through a raw pointer anywhere.

get_timeout's locked heap pass moved into a private &mut self helper that does a plain self.lock.lock() / unlock() in safe code and pops via the safe TimerHeap::delete_min wrapper, the same shape All::next already has. get_timeout loops it through the short-lived &mut *this and fires outside the lock, mirroring how drain_timers loops All::next. The Windows path got the same treatment (rearm_uv_timer(&mut self)).

The detach marking moved into TimerHeap::delete_min itself, which is the function that nulls the node's heap links and so already owns the "no longer linked" invariant. That covers get_timeout, All::next, and the fake-timer heap's pop sites with one block instead of one per site.

Net, the diff now removes one more unsafe block than it adds: get_timeout plus the new helper have one fewer unsafe site than the unmodified function did, WTFTimer::fire loses one, and TimerHeap::delete_min gains the only genuinely new one (two field writes on the just-popped node).

One thing from the automated review while I was at it: instead of dropping the debug_assert!(false) in remove_lock_held's InHeap::None arm outright, it is now narrowed to the WTFTimer tag, since that is the only tag whose state == ACTIVE gate runs off the JS thread and can lose the race to the pop. A node of any other tag reaching that arm is still a caller bug and still asserts.

@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 didn't find any bugs, but this reworks lock ordering and node-state invariants on the cross-thread timer heap that every setTimeout and the GC scheduler share, so it warrants a careful human pass.

Extended reasoning...

Overview

This PR changes src/runtime/timer/mod.rs and src/runtime/timer/WTFTimer.rs to close a data race on the intrusive pairing-heap that backs all runtime timers. All::get_timeout now takes All.lock around its peek/pop (via a new pop_due_wtf_timer helper and HeapHead enum) and releases it around fire(); TimerHeap::delete_min now writes in_heap = None and state = FIRED on the popped node; WTFTimer::fire no longer rewrites state; remove_lock_held now no-ops on an already-detached WTFTimer node instead of asserting; and Windows' on_uv_timer re-arm now locks via a new rearm_uv_timer. A debug-only regression test and fixture are added.

Security risks

None in the conventional sense — no parsing, auth, or external input handling. The risk class here is memory safety / heap corruption: a wrong lock placement or a missed state transition in this code can corrupt the intrusive heap that schedules every timer in the process, which is exactly the failure mode the PR is fixing.

Level of scrutiny

High. This is core event-loop scheduling code on the hot path of every setTimeout/setInterval and the JSC GC scheduler, with cross-thread mutation, a non-recursive mutex that must be released around re-entrant fire(), and TOCTOU windows between unlocked state == ACTIVE checks and locked heap operations. The reasoning in the description is detailed and looks sound, but the correctness depends on subtle invariants (e.g., that delete_min's new unconditional state = FIRED write is compatible with every caller, including the three FakeTimers call sites that also go through TimerHeap::delete_min), and the author notes the underlying race cannot be deterministically reproduced from JS.

Other factors

The bug-hunting pass found nothing, the existing timer test suite reportedly passes, and the new test exercises the lock placement (a misplaced lock deadlocks the debug mutex). Still, given the blast radius — silent heap corruption in release builds if any of the new state-machine edges are wrong — and the cross-cutting change to delete_min semantics, a maintainer who knows this subsystem should sign off.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

On the one concrete question in the automated review, the TimerHeap::delete_min state write and the FakeTimers callers:

  • FakeTimers::execute_next and execute_until pop under All.lock and fire the node outside it, the same shape as All::next and get_timeout, so they want the same mark. FakeTimers::fire itself only reads the node's deadline, and the per-tag fire handlers it dispatches into either set state = FIRED themselves or only compare it against CANCELLED, which a node that was just the heap minimum cannot be.
  • FakeTimers::clear pops to cancel rather than fire and overwrites state = CANCELLED on the next statement; delete_min's doc comment names that caller.

bun bd test test/js/bun/test/test-timers.test.ts test/js/bun/test/fake-timers/ passes (62 tests), alongside the test/js/web/timers and test/js/web/abort suites from the description.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/timers/setTimeout.test.js`:
- Around line 559-568: The test in setTimeout.test.js is too strict about
stderr, since filtering only the ASAN warning still leaves other benign
debug/ASAN output that can make the empty-string assertion flaky. Update the
assertion in the relevant test case to keep validating the important success
signals via stdout, exitCode, and signal, and relax stderr handling so it only
checks for absence of fatal/error markers instead of requiring stderr to be
exactly empty. Use the existing stderrLines handling in the test as the place to
adjust this expectation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 14471e5d-38d5-411b-a1d8-c69fec61d1fe

📥 Commits

Reviewing files that changed from the base of the PR and between a5d122d and 238c713.

📒 Files selected for processing (4)
  • src/runtime/timer/WTFTimer.rs
  • src/runtime/timer/mod.rs
  • test/js/web/timers/setTimeout.test.js
  • test/js/web/timers/timer-heap-gc-fixture.ts

Comment thread test/js/web/timers/setTimeout.test.js Outdated
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

When I opened this PR I wrote that I could not build a standalone JS reproducer for this race and doubted one exists. That was wrong. I had only traced the collector-thread writer, whose re-arms take the zero-delay imminent path and skip the heap. The writer that makes this reachable from ordinary code is Atomics.notify.

Atomics.waitAsync(..., timeout) arms a RunLoop::DispatchTimer on the waiting thread, which Bun backs with a WTFTimer node in that thread's timer heap. Notifying that waiter from another thread stops the timer on the notifying thread (Waiter::clearTimer -> RunLoop::TimerBase::stop -> WTFTimer__cancel -> All::remove): a locked removal in the waiter VM's heap, racing the waiter's own unlocked pre-poll pop in get_timeout.

This push adds test/js/web/timers/timer-heap-atomics-fixture.ts (three workers keep batches of short-timeout Atomics.waitAsync waiters armed while the main thread and siblings hammer Atomics.notify) and moves both heap regression tests into test/js/web/timers/timer-heap-race.test.ts. On an unfixed debug build the fixture crashed in every run I measured (8/8 with a 2 s churn window on 16 cores, 6/6 pinned to 2 cores), either on the intrusive heap's root assertion (src/io/heap.rs:165) or as an ASAN heap-use-after-free on the node get_timeout peeks; with the fix it is clean across 8 consecutive test runs and three 15 s soaks. The PR description now carries the full analysis.

Both tests live in timer-heap-race.test.ts rather than setTimeout.test.js because that file has three pre-existing debug-build failures (RSS thresholds in its leak fixtures that only widen for binaries named bun-asan), which would bury this pair's signal. The stderr relaxation from 664034a is preserved in the shared helper there.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/timers/timer-heap-atomics-fixture.ts`:
- Around line 1-18: Trim the header comment in timer-heap-atomics-fixture.ts
down to at most 3 lines while preserving only the durable invariant. Keep the
essential purpose of the fixture around Atomics.waitAsync, Atomics.notify, and
the cross-thread timer-heap interaction, but remove the detailed call-chain,
crash-mode, and debugging explanation from the inline comment. Leave the deeper
rationale for the PR description and keep the comment style aligned with the
repo guideline.

In `@test/js/web/timers/timer-heap-race.test.ts`:
- Around line 25-30: The runFixture() helper is discarding child stderr, which
hides useful crash diagnostics in failure diffs. Update runFixture() so it
returns stderr alongside stdout, signal, and exitCode, and keep it in the
asserted result object with a non-strict matcher to preserve ASAN/assert output
without making the test brittle. Use the existing runFixture() flow in
timer-heap-race.test.ts so all crash-oriented fixture checks include stderr in
their assertions.
- Around line 1-6: Trim the oversized header comments in timer-heap-race.test.ts
to fit the repo’s 3-line inline-comment limit while preserving only the durable
invariant note. Shorten the top-of-file comment near the timer heap regression
tests and the other header block referenced by the review, keeping the essential
references to src/runtime/timer, All.lock, and WTFTimer behavior. Move any
longer rationale or debugging context out of the comment and into the PR
description instead.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: be20181b-443f-409c-bfa8-8dd296e59856

📥 Commits

Reviewing files that changed from the base of the PR and between 664034a and e41a919.

📒 Files selected for processing (2)
  • test/js/web/timers/timer-heap-atomics-fixture.ts
  • test/js/web/timers/timer-heap-race.test.ts

Comment thread test/js/web/timers/timer-heap-atomics-fixture.ts Outdated
Comment thread test/js/web/timers/timer-heap-race.test.ts Outdated
Comment thread test/js/web/timers/timer-heap-race.test.ts Outdated

@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 didn't find any issues, but this reworks lock acquisition and detach-state invariants on the cross-thread intrusive timer heap that schedules every runtime timer — the lock-ordering (acquire in pop_due_wtf_timer, release before re-entrant fire) and the new InHeap::None early-return in remove_lock_held are subtle enough to warrant a human pass.

Extended reasoning...

Overview

This PR fixes a data race in src/runtime/timer/mod.rs where All::get_timeout peeked and popped the intrusive pairing-heap without holding All.lock, while WTFTimer__update/WTFTimer__cancel mutate the same heap from JSC's collector thread under that lock. The fix factors the locked peek/pop into pop_due_wtf_timer (returning a new HeapHead enum), moves the in_heap = None / state = FIRED detach marking into TimerHeap::delete_min itself, removes a now-redundant unlocked state write in WTFTimer::fire, makes remove_lock_held tolerate an already-popped WTFTimer node, and adds a locked rearm_uv_timer wrapper for the Windows path. Three new test files (two fixtures + one test) exercise the GC and Atomics.waitAsync cross-thread paths.

Security risks

None identified. This is internal scheduler synchronization with no user-controlled input, auth, crypto, or external I/O surface.

Level of scrutiny

High. This is core runtime infrastructure: the heap that schedules every setTimeout/setInterval/setImmediate and every JSC RunLoop timer. The change involves cross-thread lock discipline on an intrusive data structure with raw-pointer node manipulation in unsafe Rust, and the correctness argument hinges on subtle ordering (lock released before fire() because the non-recursive Mutex would deadlock on re-entry; state/in_heap written under lock so a racing update sees a consistent view). The PR description is exceptionally thorough and the refactor mirrors the existing All::next pattern, but a wrong move here corrupts the heap silently in release builds. The author also notes the race cannot be deterministically reproduced from JS, so the new tests are regression guards on the code path rather than proof of the fix.

Other factors

  • The remove_lock_held change replaces a hard debug_assert!(false) with a tag-narrowed assert plus early return — this is a behavioural relaxation that a human should confirm cannot mask a real double-remove on non-WTFTimer paths.
  • TimerHeap::delete_min now writes state = FIRED for all callers including FakeTimers; the author verified the fake-timer suite passes, but it broadens the function's contract.
  • CI shows musl build failures on the latest commit (likely infra given the ci: retrigger commit, but unconfirmed).
  • No prior review from me on this PR; the only resolved thread (CodeRabbit's stderr-assertion nit) was addressed in 664034a.

Comment thread test/js/web/timers/timer-heap-gc-fixture.ts Outdated
Comment thread src/runtime/timer/mod.rs Outdated
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Independent corroboration: a fuzz run against current main (ffea69a) hit this same race as an AddressSanitizer heap-use-after-free, a READ of size 8 of timer-heap memory on a Worker thread, under an Atomics.waitAsync plus cross-thread Atomics.notify load. The same workload has also produced the assertion failed: self.root == v panic, the (*a).heap().next.is_null() assertion in Intrusive::meld, and a null pointer dereference inside Intrusive::meld reached from Intrusive::remove: the spread you get from racing writers over shared heap links.

None of it changes the fix. It does settle the question of whether the debug assertion was just conservative: the unlocked pop reads freed memory in release builds too.

@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.

Both earlier nits are addressed and the automated pass found nothing further, but this restructures locking on the event loop's per-tick timer heap (cross-thread WTFTimer races, fire-outside-the-lock, the delete_min state contract, and the relaxed remove_lock_held arm), so it needs a maintainer's eyes.

Extended reasoning...

Overview

This PR fixes a data race in All::get_timeout (src/runtime/timer/mod.rs), the event loop's pre-poll pass that computes the epoll/kqueue timeout and fires due WTFTimers. Before, it peeked/popped the intrusive pairing heap without All.lock, while WTFTimer__cancel / WTFTimer__update mutate the same heap from other threads (Atomics.notify, JSC's collector). The fix:

  • moves the locked peek/pop into a new pop_due_wtf_timer(&mut self) -> HeapHead helper and fires outside the lock (the Mutex is non-recursive and firing re-enters All::update/remove);
  • has TimerHeap::delete_min mark the popped node in_heap = None / state = FIRED under the lock, and removes the now-redundant (and racy) state = FIRED write from WTFTimer::fire;
  • makes remove_lock_held's InHeap::None arm a no-op for the WTFTimer tag (the only tag whose state == ACTIVE gate runs off-thread and can lose the race to the pop), keeping the debug assertion for every other tag;
  • wraps the Windows on_uv_timer re-arm peek in the same lock via rearm_uv_timer;
  • drops a now-dead in_heap = None write in FakeTimers::clear (and its InHeap import);
  • adds two stress fixtures and a regression test (timer-heap-race.test.ts).

Security risks

None in the conventional sense (no auth/crypto/input parsing). The change is memory-safety relevant: the bug it fixes is a real heap-use-after-free in release builds. The fix narrows unsafe surface (one fewer net unsafe block per the author's accounting) and the new raw-pointer writes in delete_min are on a node the function just unlinked under the lock.

Level of scrutiny

High. get_timeout runs on every event-loop iteration of every Bun process; the change touches lock ordering, cross-thread state-machine semantics (ACTIVE/FIRED/CANCELLED), and intrusive-heap invariants shared with All::next, drain_timers, and the fake-timer pop sites. The argument for removing WTFTimer::fire's state = FIRED write (it would clobber a concurrent re-arm's ACTIVE between the pop releasing the lock and fire() running) is sound on inspection but is exactly the kind of reasoning a maintainer who owns this subsystem should confirm. Likewise the relaxed InHeap::None arm in remove_lock_held: returning early without writing state = CANCELLED is justified in the comment, but it changes a long-standing invariant.

Other factors

  • The PR is bot-authored with no human review yet on the timeline.
  • CI shows musl build failures on the latest commit (likely infra, but unverified).
  • The author ran the timer/abort/fake-timer suites and the new fixture both with and without the fix; the Atomics fixture reproduces the crash reliably on an unfixed debug build.
  • All prior review feedback (CodeRabbit's stderr/comment-trim nits and my two inline nits) has been addressed; nothing outstanding.
  • The behavior of get_timeout is preserved for the non-WTFTimer-due / not-yet-due / empty-heap cases via the HeapHead enum, including the spec = 0 branch when a non-WTFTimer is already due.

Given the subsystem and the subtlety of the lock/state reasoning, this should not be auto-approved.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Final CI status, since the remaining red is not actionable from this diff.

Everything that exercises this change is green: every build-rust / build-cpp / build-bun lane on every platform, cargo clippy, Format, Lint, and the new test/js/web/timers/timer-heap-race.test.ts, which fails on the unfixed tree (assertion failed: self.root == v at src/io/heap.rs:165, or an ASAN heap-use-after-free in Intrusive::remove) and passes with the fix.

The three failures on the final build (67198) are unrelated to the diff:

  • darwin 26 aarch64 - test-bun (both attempts) dies with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' before running a single test. It has failed identically on every build of this PR (67143, 67151, 67173, 67198).
  • test/js/node/test/parallel/test-net-connect-memleak.js fails on the two alpine 3.23 x64 lanes. The identical failure annotation appears on at least seven other concurrent PRs' builds (67199, 67197, 67195, 67193, 67192, 67190, 67183, whose branches touch fetch blobs, redis, the npm registry, http server timeouts, markdown, a WebKit upgrade, and websockets), and it never shows on main only because main builds run no alpine test jobs. It is a pre-existing, repo-wide alpine-x64 failure.
  • terminal.test.ts > creates subprocess with terminal attached timed out (90 s) on one darwin 14 x64 shard. That test uses no timers at all: it awaits PTY data and the child's exit, both of which wake the event loop through fd readiness independent of any poll timeout, so it cannot be starved by the timer changes here. The same process ran the file's other 89 tests normally before and after it, and the test passed on every other shard and platform in the same build.

Nothing further to push from my side.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is terrible code. Think about how to make the data structure better and try again.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Done in f52d292. You were right; naming the actual defect made the fix smaller.

The defect was the data structure: All.lock and the heaps it guarded were sibling fields related only by a naming convention (remove vs remove_lock_held), which is exactly how get_timeout could ship without taking it, and my previous revision added another layer of that same manual discipline.

Now the mutex owns the heaps. All.heaps is a Guarded<Heaps> (the existing bun_threading equivalent of parking_lot::Mutex<T>), where Heaps is the real heap, the fake-timer heap, and the scheduling epoch, i.e. exactly what lock was documented to guard. That makes the bug unrepresentable instead of a convention, and it deleted the things worth deleting:

  • get_timeout pops inside a guard scope and fires after it drops. No unlock to pair up, no helper, no enum carrying state out of the locked region; its original control flow is back.
  • the insert_lock_held / remove_lock_held twins are methods on the guarded data, unreachable without the lock.
  • FakeTimers (which shares the lock) loses timers_lock_guard(), which locked All.lock while its callers separately raw-dereferenced the heap it was guarding, and assert_locked(), a container_of walk up to All that debug-asserted the mutex state at runtime. The type proves both now.

Nothing takes or releases the timer lock by hand anywhere, and the diff against main is net negative (431 deletions, 330 insertions). Re-verified both ways: with src reverted to main the regression test dies on the intrusive heap's root assertion (or ASAN's use-after-free in Intrusive::remove), and with this it passes, along with the fake-timer, timer, and abort suites.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/test_runner/timers/FakeTimers.rs`:
- Around line 122-126: The invariant comment in FakeTimers should be shortened
to fit the repo’s 3-line limit without losing the key ownership and locking
note. Compress the documentation on the drain path around the heap guard and
`TimerObjectInternals::release_heap_pin`, keeping only the essential point that
the caller releases the heap ref and JS pin after the guard drops because
`timer::All::remove` re-locks the heaps. Apply the same trimming to the other
long comment block referenced in the review so both stay within the
comment-length guideline.

In `@src/runtime/timer/mod.rs`:
- Around line 796-803: WTFTimer::update currently re-arms the timer through
ensure_uv_timer(), which can lazily use TLS even when called off the owning JS
thread; replace that path so insert/update do not touch the libuv handle from a
foreign thread. Route the rearm through the owning JS thread or a thread-safe
wakeup/dispatch path, and keep the loop/handle access constrained to the timer’s
registered loop rather than relying on debug_assert! affinity checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cd98da5a-5dd6-40d2-9360-430a5d5ccf87

📥 Commits

Reviewing files that changed from the base of the PR and between 5172408 and f52d292.

📒 Files selected for processing (3)
  • src/runtime/test_runner/timers/FakeTimers.rs
  • src/runtime/timer/mod.rs
  • src/runtime/timer/timer_object_internals.rs

Comment thread src/runtime/test_runner/timers/FakeTimers.rs Outdated
Comment thread src/runtime/timer/mod.rs Outdated
Comment thread src/runtime/timer/WTFTimer.rs Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Can we simplify this by not relying on the timer heap for the WTF Timer? It can be special cased. All other timers are same thread. This timer shouldn't involve the fake timers list either.

Comment thread src/md/output.rs
@robobun robobun changed the title timers: take the heap lock in get_timeout and mark popped nodes detached Move WTFTimer out of the shared timer heap to fix a cross-thread race Jun 30, 2026
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 823e4ff.

WTFTimer nodes now live in their own Guarded<TimerHeap> (All.wtf_timers), reached through wtf_arm/wtf_disarm from any thread. All.lock is deleted: the regular heap, the fake timers, and the epoch are single threaded again (with a debug_assert! on the owning thread id), so setTimeout no longer takes a mutex per call. The fake-timer lock helper and its asserts existed only because of that shared lock, so they are gone too.

get_timeout also stops being a pop-and-fire loop as a result: it drains the due WTF timers through their own mutex, then just returns min(wtf, regular, quic).

I rewrote the title and description, since they described the previous approach.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delete all the code comments. Simplify further.

Comment thread test/js/web/timers/timer-heap-gc-fixture.ts Outdated
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 3bab0e3.

Every comment the branch added is gone. The one-line SAFETY notes on unsafe blocks stay because the workspace sets undocumented_unsafe_blocks = "deny", so clippy fails without them.

Two more deletions fell out:

  • WTFTimer.lock, the per-instance mutex. Everything it guarded is owned by the wtf_timers mutex now, and update never took it, so it never actually synchronized with cancel or secondsUntilFire.
  • My TimerHeap::delete_min change. It reverts to main's body; the one pop site that needs the FIRED mark does it itself, under the wtf_timers mutex.

Net against the previous revision: 37 insertions, 249 deletions.

Comment thread test/js/web/timers/timer-heap-race.test.ts
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for f0fa434 (Buildkite build 73001, finished): clippy, Format, and Lint are green. The only failing test is test/js/node/test/parallel/test-net-connect-memleak.js on both alpine-3.23 lanes, and it is red on other concurrent PRs too (for example build 73011, an unrelated FFI branch, shows the same failure four times). Every other lane passed; nothing in any timer, fake-timer, or Atomics suite failed on any platform, including the new timer-heap-race.test.ts.

Older builds

Build 71634 (f8fe07b, before the second rebase) had one failure: autobahn.test.ts on darwin-14-aarch64, exec format error from a linux/amd64 docker image on an arm64 host. Build 71626 (b406f31) was canceled when f8fe07b superseded it. Build 67379 (3bab0e3) had only infrastructure failures: both darwin 26 aarch64 - test-bun jobs on buildkite-agent artifact download timed out, both alpine jobs exiting 2 with zero failing tests, and a terminal.test.ts agent-specific timeout.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Independent confirmation that this race is also the cause of an open high-volume timer crash in Sentry: BUN-3K4E, 205 events since 2026-06-18, 27 in the last 24 hours, macOS aarch64 dominant, still occurring on the newest 1.4.0 canaries.

Its decoded stack is:

bun_runtime::timer::All::drain_timers            src/runtime/timer/mod.rs:1042
EventLoopTimer::fire                             src/event_loop/EventLoopTimer.rs:177
TimerObjectInternals::fire                       src/runtime/timer/timer_object_internals.rs:572
TimerObjectInternals::run                        src/runtime/timer/timer_object_internals.rs:251
Bun__JSTimeout__call                             src/jsc/bindings/NodeTimerObject.cpp:84
Bun::call                                        src/jsc/bindings/NodeTimerObject.cpp:57
JSC::Interpreter::executeCallImpl
llint_call_javascript                            <segv reading 0x0>

i.e. a setTimeout callback whose JSFunction was already collected. I could not find a single-threaded path to that state. I audited every this_value transition in timer_object_internals.rs (init, do_refresh, convert_to_interval, every re-arm inside fire(); they all set_strong before the heap insert), instrumented fire() and reschedule() with debug_assert!(this_value.is_strong()), and ran the timer, net and http suites plus nine stress shapes over refresh(), _repeat, _idleTimeout and numeric-id clearTimeout without one hit. The invariant "reachable from the timer heap implies Strong" holds everywhere single threaded.

It does not survive this PR's heap corruption. Against an otherwise unmodified main I added one assertion at TimerObjectInternals::fire entry:

assert!(
    s.this_value.get().is_strong() || !s.this_value.get().is_not_empty(),
    "BUG(fire-entry): JS timer fired while this_value was an unprotected weak JSValue (id={}, kind={}, state={})",
    ...
);

and commented out the two heap debug_assert!s (Intrusive::remove's self.root == v at heap.rs:165 and meld's a.next.is_null() at heap.rs:207) so the corruption propagates the way a release build lets it, instead of aborting there. Six runs of this branch's timer-heap-atomics-fixture.ts against that build:

  • 4 runs: ASan heap-use-after-free. Three are a READ in All::get_timeout at mod.rs:910 on a WTFTimer box freed by ~RunLoop::DispatchTimer on another thread (the unlocked pre-poll walk this PR serializes away). One is a READ in Intrusive::combine_siblings at heap.rs:258 inside delete_min, the heap traversal itself walking a freed node.
  • 1 run: timed out.
  • 1 run hit the new assertion, on the exact Sentry call path:
panic: BUG(fire-entry): JS timer fired while this_value was an unprotected weak JSValue (id=335, kind=0, state=3)
  TimerObjectInternals::fire   src/runtime/timer/timer_object_internals.rs
  __bun_fire_timer             src/runtime/dispatch.rs:952
  EventLoopTimer::fire         src/event_loop/EventLoopTimer.rs:177
  All::drain_timers            src/runtime/timer/mod.rs:1042
  jsc_hooks::auto_tick_active  src/runtime/jsc_hooks.rs:1096

kind=0 is SetTimeout and state=3 is FIRED: a one-shot that had already fired was popped out of the corrupted pairing heap a second time. Its first fire() had already downgraded this_value from Strong to a raw JsRef::Weak (timer_object_internals.rs:547) and released the heap ref, and JsRef::try_get() has no liveness check on the Weak variant, so in a release build the second fire hands Bun__JSTimeout__call a collected JSTimeout whose callback slot points at a swept JSFunction. getCallData still reads a JSFunction-typed cell from the stale structure, so it falls through to profiledCall and dies one frame into the LLInt call prologue reading 0x0. That is the Sentry stack.

So this change also fixes BUN-3K4E. A line in the description would let that Sentry issue be resolved on merge. One nit on the Sentry side: it marks this as "not present on 1.3.x", but the Zig getTimeout was equally lockless, so the crash is not new in 1.4. Sentry regrouped it under the new Rust symbol names.

Two smaller things this surfaced, neither of which should hold this up:

  1. Intrusive::remove(v) treats v.prev == null as "v is the root" and calls delete_min(). Since delete_min nulls all three links of the node it pops, calling remove on a node that was already popped silently evicts whatever the current root is in a release build; the debug_assert!(self.root == v) is the only guard. Nothing on this branch can reach that, but it is a sharp edge for a future caller.
  2. fire() drops a one-shot's only GC root (the downgrade() at timer_object_internals.rs:547) before invoking the callback, so the wrapper's survival through the callback depends entirely on the conservative stack scan. Moving that downgrade into the is_timer_done branch after the callback would make "in the heap implies strong" unconditional, and would also fix a real leak: a setInterval that stops rescheduling itself (callback sets t._repeat = null or t._idleTimeout = -1) never downgrades, so its Strong pins the Timeout forever.

@robobun
robobun force-pushed the farm/09f07a17/timer-heap-lock branch from 3bab0e3 to b406f31 Compare July 10, 2026 21:20
Comment thread src/runtime/timer/mod.rs
Comment thread src/runtime/timer/WTFTimer.rs
Comment thread src/runtime/timer/mod.rs Outdated
Comment thread src/runtime/timer/mod.rs
robobun added 2 commits July 14, 2026 20:26
WTF::RunLoop::TimerBase is the only timer WebKit starts and stops from
threads other than the owning run loop's: Atomics.notify on thread B
re-arms thread A's GC JSRunLoopTimer and cancels thread A's
Atomics.waitAsync timeout, and ~ArrayBufferContents does the same from
whichever thread drops the last SharedArrayBuffer reference. Keeping
those nodes in the same pairing heap as setTimeout was the only reason
All.lock existed, and get_timeout's pre-poll pass peeked and popped that
shared heap without taking it, racing the locked Intrusive::remove from
those other threads and corrupting the heap links.

Evict the one cross-thread client instead of locking everything:

- All.wtf_timers (Guarded<TimerHeap>) holds only WTFTimer nodes,
  reached through All::wtf_arm / All::wtf_disarm from any thread.
- All.lock is deleted. insert/remove/update, the regular heap, the
  fake timers, and the epoch are single threaded again (asserted), so
  setTimeout no longer takes a mutex per call. The fake-timer lock
  helper and its assert machinery existed only because of that shared
  lock, so they are deleted too.
- get_timeout no longer pops and fires inline: it drains the due WTF
  timers through their own mutex, then returns min(wtf, regular, quic).
- drain_timers drains the WTF heap first, keeping the firing paths on
  an inactive loop and on Windows.
- WTFTimer.lock (the per-instance mutex) is deleted; wtf_timers owns
  everything it guarded, and update never took it anyway.
- ensure_uv_timer folds both heaps into the libuv deadline and is now
  only reachable from the owning thread.
The braces were there to scope a guard that no longer exists; nothing
inside has a drop, and the three vectors it fills are declared outside.
@robobun
robobun force-pushed the farm/09f07a17/timer-heap-lock branch from f8fe07b to f0fa434 Compare July 14, 2026 20:30

@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.

No issues found in this pass, but this restructures cross-thread synchronization for the event-loop timer heap (deletes All.lock, adds wtf_timers: Guarded<TimerHeap>, rewrites get_timeout/drain_timers/next) — worth a human look at the final shape.

Reviewed: the wtf_arm/wtf_disarm guard scope vs. the unlocked fire() — the residual deinit-during-fire window is pre-existing and currently unreachable per the dispatchAfter self-cycle analysis in the thread.
Checked ensure_uv_timer now folds both heaps and is only reached on the owning thread; the pre-existing cross-thread TLS hazard is not widened.
Checked in_heap is never set for WTF nodes (wtf_arm doesn't write it) and remove() debug-asserts on WTFTimer tag, so the two heaps cannot cross.
Verified the FakeTimers lock-helper deletion is safe: every remaining access is JS-thread-only, asserted via assert_js_thread().

Extended reasoning...

Overview

This PR fixes a cross-thread data race on the event-loop timer heap by moving WTFTimer (JSC's RunLoop::TimerBase backing — GC scheduler timers and Atomics.waitAsync timeouts) out of the shared All.timers pairing heap into its own Guarded<TimerHeap>. The shared All.lock mutex and per-instance WTFTimer.lock are deleted; the regular heap, fake-timer heap, and epoch become single-threaded again with a debug_assert! on the owning thread id. get_timeout is rewritten from a pop-and-fire loop to a drain-WTF-then-min(wtf, regular, quic) computation. FakeTimers loses its timers_lock_guard()/assert_locked() machinery. ~486 lines changed in mod.rs, ~180 net-removed in FakeTimers.rs, plus two new stress-test fixtures.

Security risks

None in the traditional sense — no user-input parsing, auth, or crypto. The risk class here is memory safety: this is unsafe Rust manipulating an intrusive pairing heap across threads via raw pointers, and the bug it fixes is a heap-use-after-free reachable from ordinary JS (Atomics.waitAsync + Atomics.notify). The fix's own correctness depends on the invariant that state == ACTIVE ⇔ linked in wtf_timers, enforced under the guard, and that the guard is dropped before every re-entrant fire().

Level of scrutiny

High. This is core event-loop concurrency infrastructure with pervasive unsafe, and it went through three architectural revisions (lock get_timeoutGuarded<Heaps> → separate heap). The final design was chosen at a maintainer's explicit direction, but the maintainer has not yet reviewed the implementation of that direction. Several subtle concerns were raised and analyzed in the thread (the residual deinit-during-fire window, the newly-unlocked seconds_until_timer read, the wtf_now reuse in drain_timers) — each has a reasoned response, but they are the kind of tradeoff a maintainer should sign off on rather than a bot.

Other factors

The PR has a working regression test that fails on main (SIGABRT / ASan UAF) and passes with the fix, plus a Sentry crash (BUN-3K4E, 205 events) traced to the same corruption. CI is green modulo unrelated infrastructure failures. All prior inline review comments (mine and CodeRabbit's) are resolved. The bug-hunting system found nothing this run. Net diff is negative (-437/+448 with substantial deletions of lock scaffolding). Still, the combination of scope, unsafe density, and cross-thread lifetime reasoning puts this well outside the auto-approve bar.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: the timer-heap-atomics-fixture.ts test this PR added goes red on the x64-asan lane (build 73570) because terminating a worker with pending Atomics.waitAsync tickets leaks the ConcurrentTask nodes that ~VM() -> WaiterListManager::unregister -> scheduleWorkSoon enqueues after the worker's last drain. That leak is pre-existing, the fixture is just the first thing to exercise it; fix in #34293.

hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (57 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (70 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (52 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (52 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...

# Conflicts:
#	test/js/bun/websocket/websocket-server.test.ts
Jarred-Sumner pushed a commit that referenced this pull request Jul 17, 2026
…loop's last tick (#34293)

`test/js/web/timers/timer-heap-race.test.ts` went red on the x64-asan
lane after #33131 landed (build 73570):

```
SUMMARY: AddressSanitizer: 1280 byte(s) leaked in 40 allocation(s).
  ConcurrentTask::create src/event_loop/ConcurrentTask.rs:319
  Bun__queueJSCDeferredWorkTaskConcurrently src/jsc/JSCScheduler.rs:64
  Bun::JSCTaskScheduler::onScheduleWorkSoon JSCTaskScheduler.cpp:54
  JSC::DeferredWorkTimer::scheduleWorkSoon DeferredWorkTimer.cpp:235
  JSC::Waiter::cancelAndClear WaiterListManager.cpp:298
  JSC::WaiterListManager::unregister(JSC::VM*) WaiterListManager.cpp:310
  JSC::VM::~VM() VM.cpp:591
  WebWorker__teardownJSCVM Worker.cpp:676
```

The leak is pre-existing; #33131's new cross-thread `Atomics.waitAsync`
fixture is the first test that terminates a worker with pending async
waiters on a SharedArrayBuffer the parent keeps alive.

### Cause

Worker `shutdown()` drains the concurrent task queue once
(`release_queued_tasks_for_shutdown`), then calls
`WebWorker__teardownJSCVM`, which ends in `~VM()`. `~VM()` runs
`WaiterListManager::unregister(this)`, and for every pending
`Atomics.waitAsync` ticket that reaches `Waiter::cancelAndClear` →
`DeferredWorkTimer::scheduleWorkSoon` → our `onScheduleWorkSoon` hook.
The hook allocates a `JSCDeferredWorkTask` and a `ConcurrentTask` and
enqueues them into the worker's concurrent queue, which was just drained
for the last time. When the worker's `VirtualMachine` box is
raw-`dealloc`'d, both become unreachable. The same path is reachable
from the final `collectNow` via
`JSFinalizationRegistry::finalizeUnconditionally`.

A second, narrower leak: a cross-thread `Atomics.notify` that lands
between the worker's last tick and `teardownJSCVM` enqueues a
`JSCDeferredWorkTask` that `release_queued_tasks_for_shutdown` forwards
into `self.tasks`. `__bun_release_task_at_shutdown` had no arm for that
tag, so it was re-queued, and `EventLoop::deinit` re-queued it once more
into a freshly allocated `LinearFifo` buffer that leaked on worker
dealloc.

### Fix

- `JSCTaskScheduler` gets an `std::atomic<bool> m_isShuttingDown`, set
at the start of `WebWorker__teardownJSCVM` and
`Zig__GlobalObject__destructOnExit` (mirroring the existing
`ctx->markTerminating()`). `onScheduleWorkSoon` and `onAddPendingWork`
drop the work once it's set; `onScheduleWorkSoon` also balances the
`onAddPendingWork` ref via `onCancelPendingWork`.
- `__bun_release_task_at_shutdown` gains a `JSCDeferredWorkTask` arm
that deletes the job via a new `Bun__deleteDeferredWorkTask` FFI. This
runs before JSC teardown, so `~Ref<TicketData>` and the captured `Task`
lambda release against a live VM.

### Test

`timer-heap-atomics-teardown-fixture.ts` terminates a worker with 32
pending `Atomics.waitAsync` tickets on a parent-owned SAB, a few of them
notified cross-thread first, under `detect_leaks=1`. Without the fix
LSan reports ~29 `ConcurrentTask` allocations from
`WaiterListManager::unregister` and SIGABRTs; with it the fixture exits
clean. The original race fixture is also 0/10 failures under the CI env
(was ~1/5 on a debug build and 1/1 on release-asan).

### Overlap with #34270

\#34270 adds the same `m_isShuttingDown`/`onScheduleWorkSoon` gate
(there named `m_isTerminating`) while fixing a separate
`FinalizationRegistry` assert, but without the
`__bun_release_task_at_shutdown` arm the race fixture still fails ~1/10
on that branch. Whichever lands first, the other is a small rebase over
`JSCTaskScheduler.{h,cpp}`.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 3 · 9 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/timers/timer-heap-race.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (4d9b926)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3806.16ms]
(pass) timer heap stays consistent while GC re-arms the RunLoop timer [2408.11ms]
51 |       ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1",
52 |       LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:suppressions=${path.join(import.meta.dir, "..", "..", "..", "leaksan.supp")}`,
53 |     });
54 |     // LSan writes its leak report to stderr and SIGABRTs; stdout holds the
55 |     // fixture's own OK line either way, so assert exitCode/signal explicitly.
56 |     expect({ stdout, stderr, signal, exitCode }).toEqual({
        
... (truncated)

release without fix: 2 skipped
bun test v1.4.0-canary.1 (1498d7b)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3042.35ms]
(skip) timer heap stays consistent while GC re-arms the RunLoop timer
(skip) terminating a worker with pending Atomics.waitAsync tickets does not leak deferred-work tasks

 1 pass
 2 skip
 0 fail
 1 expect() calls
Ran 3 tests across 1 file. [3.20s]
__F:0:S:2
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/timers/timer-heap-race.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (4d9b926)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3805.27ms]
(pass) timer heap stays consistent while GC re-arms the RunLoop timer [2481.63ms]
(pass) terminating a worker with pending Atomics.waitAsync tickets does not leak deferred-work tasks [7403.00ms]

 3 pass
 0 fail
 3 expect() calls
Ran 3 tests across 1 file. [15.73s]
__F:0:S:0

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     4d9b926
  features     (none)

22 deps, 106 codegen, 1168 objects in 891ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b)

Checked 124 installs across 170 packages (no changes) [13.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b)

Checked 1 install across 2 packages (no changes) [3.00ms]
[3/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b)

Checked 129 installs across 147 packages (no changes) [11.00ms]
[4/1231] gen ErrorCode+*.h
[5/1231] fetch zlib
[zlib] up to date
[6/1231] gen bindgenv2
[7/1231] fetch picohttpparser
[picohttpparser] up to date
[8/1231] gen .bind
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/jsc/VirtualMachine.rs                          |  8 +++
 src/jsc/bindings/JSCTaskScheduler.cpp              | 77 ++++++++++++++++++----
 src/jsc/bindings/JSCTaskScheduler.h                | 13 ++++
 src/jsc/bindings/ZigGlobalObject.cpp               |  2 +
 src/jsc/bindings/webcore/Worker.cpp                |  5 ++
 src/jsc/web_worker.rs                              |  8 +++
 src/runtime/dispatch.rs                            | 15 +++++
 .../timers/timer-heap-atomics-teardown-fixture.ts  | 32 +++++++++
 test/js/web/timers/timer-heap-race.test.ts         | 25 ++++++-
 9 files changed, 171 insertions(+), 14 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 3

<details><summary>evidence per changed file</summary>

```
file                                                      reads  edits  tests
src/jsc/VirtualMachine.rs                                     5      2      0
src/jsc/bindings/JSCTaskScheduler.cpp                         7     10      0
src/jsc/bindings/JSCTaskScheduler.h                           5      4      0
src/jsc/bindings/ZigGlobalObject.cpp                          1      1      0
src/jsc/bindings/webcore/Worker.cpp                           1      1      0
src/jsc/web_worker.rs                                         5      4      0
src/runtime/dispatch.rs                                       2      1      0
…st/js/web/timers/timer-heap-atomics-teardown-fixture.ts      0      2      0
test/js/web/timers/timer-heap-race.test.ts                    2      3      0
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

The first test in timer-heap-race.test.ts has been flakily red on the asan lane since this landed: one TimeoutObject box (480 B) survives the exit-time collectNow because process.exit is reached from worker.onmessage, where JSEventListener::handleEvent's JSLockHolder keeps an extra RefPtr<VM> on the stack and ~VM() never runs. Pre-existing VM-teardown gap; the fixture is just the first test to hit it. Fix in #34448.

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