Skip to content

RunLoopBun: break the DispatchTimer self-ref when stop() disarms a still-active timer#305

Open
robobun wants to merge 1 commit into
mainfrom
farm/dispatchtimer-stop-break-cycle
Open

RunLoopBun: break the DispatchTimer self-ref when stop() disarms a still-active timer#305
robobun wants to merge 1 commit into
mainfrom
farm/dispatchtimer-stop-break-cycle

Conversation

@robobun

@robobun robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Every Atomics.waitAsync(buf, i, v, timeout) woken by Atomics.notify (or by WaiterListManager::unregister) before its timeout fires leaks a WTF::RunLoop::DispatchTimer and the Bun-side WTFTimer box it owns.

RunLoop::dispatchAfter stores its fire lambda in DispatchTimer::m_function, and the lambda captures timer.copyRef() to the timer itself. That self-ref is only moved out when fired() runs. Waiter::clearTimer just does m_timer->stop(); m_timer = nullptr;, which drops the waiter's ref but leaves the self-ref intact.

Change

  • TimerBase gets a virtual didStopWhileActive() hook (under USE(BUN_EVENT_LOOP), default no-op).
  • DispatchTimer overrides it to clear m_function, releasing the self-ref (and the captured Ref<Waiter>).
  • WTFTimer__cancel returns bool: whether it removed a still-ACTIVE node from Bun's wtf_timers heap.
  • TimerBase::stop() calls didStopWhileActive() when WTFTimer__cancel returns true.

The return-true path proves fired() is not executing: Bun's drain_due_wtf_timers pops a node and sets its state to FIRED under the same lock that wtf_disarm checks for ACTIVE. So m_function is not on any stack when it is cleared. When WTFTimer__cancel returns false (already popped, or reached from inside the fire lambda's own protectedTimer->stop()), we leave m_function alone; fired() breaks the cycle itself.

Other TimerBase subclasses inherit the empty default; they have no self-ref to release. ~DispatchTimer is not reached here because the caller (Waiter::clearTimer) still holds m_timer across stop().

Coordination

Requires oven-sh/bun#34456, which changes the Rust-side WTFTimer__cancel to return bool. The bun PR also carries a temporary bun-side cycle break so it is safe to land before WEBKIT_VERSION is bumped to include this.

…ill-active timer

RunLoop::dispatchAfter stores a lambda in DispatchTimer::m_function that
captures a Ref to the timer itself. That self-ref is only moved out when
fired() runs; a caller that stops the timer before it fires
(WaiterListManager::clearTimer on Atomics.notify/unregister) orphans the
cycle and leaks both the DispatchTimer and the Bun-side WTFTimer box it
owns.

Add a virtual TimerBase::didStopWhileActive() hook (USE(BUN_EVENT_LOOP)
only). DispatchTimer overrides it to clear m_function. TimerBase::stop()
calls it when WTFTimer__cancel reports the timer was still in Bun's
wtf_timers heap, which means Bun's drain_due_wtf_timers has not popped it
(the ACTIVE to FIRED transition is serialised by the heap lock) and so
fired() is not executing on any thread.

WTFTimer__cancel's signature changes from void to bool; oven-sh/bun#34456
provides the matching Rust side.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 17, 2026
…en-sh/WebKit#305)

The current prebuilt WTF declares WTFTimer__cancel as void and ignores
the return; once WEBKIT_VERSION includes oven-sh/WebKit#305,
RunLoopBun's TimerBase::stop() reads it to drive the new virtual
didStopWhileActive() hook and Bun__breakDispatchTimerCycle becomes a
no-op second clear (to be deleted with the bump).
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3559e899-3e35-4554-9e41-ce5c83606950

📥 Commits

Reviewing files that changed from the base of the PR and between 722f2a8 and 7d8b6f5.

📒 Files selected for processing (2)
  • Source/WTF/wtf/RunLoop.h
  • Source/WTF/wtf/bun/RunLoopBun.cpp

Walkthrough

Bun-backed RunLoop timers now report when stopping removes an armed timer. DispatchTimer uses this lifecycle hook to clear its stored dispatch function and release the self-reference captured by dispatchAfter().

Changes

Bun timer cleanup

Layer / File(s) Summary
Timer stop lifecycle hook
Source/WTF/wtf/RunLoop.h
TimerBase adds didStopWhileActive(), and Bun DispatchTimer overrides it to clear m_function.
Bun cancellation integration
Source/WTF/wtf/bun/RunLoopBun.cpp
TimerBase::stop() checks WTFTimer__cancel and invokes the hook when cancellation removes an armed timer; the return semantics are documented.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning It describes the change, but it does not follow the required WebKit template or include the Bugzilla link, Reviewed by line, or commit-message structure. Rewrite the description in the repository template format with a bug title, Bugzilla URL, Reviewed by line, explanation, and changed-file entries.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: fixing DispatchTimer self-reference cleanup on stop().
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.

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

Comment on lines 93 to 96
if (auto* ref = std::get_if<std::reference_wrapper<Bun__WTFTimer>>(&m_impl)) {
WTFTimer__cancel(&ref->get());
if (WTFTimer__cancel(&ref->get()))
didStopWhileActive();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 This change makes ~TimerBase / WTFTimer__deinit newly reachable on a foreign thread: in the Atomics.notify-before-timeout path, clearTimer on thread B now drops the self-ref inside stop() and then the last ref via m_timer = nullptr, so WTFTimer__deinit runs on thread B against a Bun__WTFTimer created for thread A's event loop. Pre-PR this destructor was unreachable here (leak), so this is a new cross-thread lifecycle path — the PR's safety argument covers WTFTimer__cancel under the heap lock but doesn't mention WTFTimer__deinit. Worth confirming bun#34456 makes wtf_deinit safe off the owning thread, or deferring the self-ref release back to the timer's RunLoop.

Extended reasoning...

What changed

didStopWhileActive() now clears DispatchTimer::m_function inside stop() when WTFTimer__cancel returns true. m_function holds the timer.copyRef() self-reference that RunLoop::dispatchAfter captures (RunLoop.cpp:176), so clearing it drops one ref on the DispatchTimer. That is exactly the leak fix — but it also moves where the last ref can fall to zero.

Step-by-step trace (the exact scenario the PR targets)

  1. Thread A calls Atomics.waitAsync(buf, i, v, timeout). WaiterListManager::waitAsyncImpl calls RunLoop::currentSingleton().dispatchAfter(...) on thread A. dispatchAfter does adoptRef(*new DispatchTimer) (refcount = 1), then setFunction([timer = timer.copyRef(), ...]) (refcount = 2), and returns the outer Ref, which is moved into Waiter::m_timer. Net: refcount = 2Waiter::m_timer + the self-ref inside m_function. The Bun__WTFTimer was created via WTFTimer__create against thread A's event loop.
  2. Thread B calls Atomics.notify on the same address before the timeout. WaiterListManager::notifyWaiternotifyWaiterImplWaiter::scheduleWorkAndClearclearTimer(listLocker) all run synchronously on thread B while holding list->lock.
  3. clearTimer (WaiterListManager.h:98) does m_timer->stop(). In RunLoopBun.cpp, WTFTimer__cancel returns true (node was still ACTIVE in the heap), so didStopWhileActive() runs and does m_function = { }. Destroying the lambda destroys its captured Ref<DispatchTimer>: refcount 2 → 1.
  4. clearTimer then does m_timer = nullptr on the very next line: refcount 1 → 0. ~DispatchTimer~TimerBase runs on thread B and calls WTFTimer__deinit(&ref->get()) on a Bun__WTFTimer that was created for thread A's event loop.

There are no other refs that would keep the timer alive past step 4: RunLoop::m_registeredTimers is a HashSet<TimerBase*> of raw pointers, and the Zig-side Bun__WTFTimer holds a raw TimerBase* back-pointer, not a Ref.

Why this is new behavior

  • Pre-PR, stop() only called WTFTimer__cancel; the self-ref in m_function survived, so after m_timer = nullptr the refcount was still 1 and the timer leaked. ~TimerBase / WTFTimer__deinit were unreachable on this path.
  • In the fires-first path, fired() runs on thread A, protectedTimer on thread A's stack is the last ref, and ~TimerBase runs on thread A.
  • So cross-thread WTFTimer__deinit did not exist before this PR and is introduced here.

Why the PR's own argument doesn't cover it

The description says:

~DispatchTimer is not reached here because the caller (Waiter::clearTimer) still holds m_timer across stop().

That's true for the duration of stop() itself (so didStopWhileActive() can't destroy *this mid-call), but m_timer = nullptr is the immediately following statement in clearTimer, still on thread B. The description and bun#34456 discuss making WTFTimer__cancel return bool and be lock-safe, but say nothing about WTFTimer__deinit being called from a thread other than the one that called WTFTimer__create.

Impact and fix

If the Zig-side wtf_deinit touches thread-A-local state (the owning VirtualMachine / event-loop's wtf_timers bookkeeping, a per-thread allocator, etc.) without the same lock wtf_disarm takes, this trades a leak for cross-thread corruption. It may well already be safe — WTFTimer__cancel was already called cross-thread pre-PR, so the Zig side already locks the heap, and deinit after a successful cancel may just be a locked unlink + free — but that's worth confirming explicitly since it isn't stated. If it isn't safe, one option is to post the self-ref release back to m_runLoop (e.g. m_runLoop->dispatch([f = WTF::move(m_function)] { })) so the last deref and WTFTimer__deinit happen on the owning thread.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
7d8b6f52 autobuild-preview-pr-305-7d8b6f52 2026-07-17 09:34:38 UTC

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant