← All reports

RunLoop::Timer thread-affinity assertions and eight teardown race fixes

Component: WTF RunLoop | f53d1f2

RunLoop::Timer wraps the platform run loop timer — CFRunLoopTimer on Cocoa, GSource on GLib, and so on. Its operations do not all have the same thread-safety story, which is the crux here: starting or re-arming a timer is safe from any thread, because it only (re)schedules work and never frees the TimerBase. That is exactly what dispatch() / dispatchAfter() and JSRunLoopTimer already rely on. Stopping or destroying an active timer is different — it frees platform state that an in-flight callback on the owning thread may still be executing against.

This commit encodes that asymmetry as ASSERT_WITH_SECURITY_IMPLICATION checks requiring stop() and the destructor to run on the timer's own run loop thread when the timer is active, then fixes the eight real cross-thread teardown races those assertions caught — including a JSC Atomics.waitAsync timer that was being stopped from another agent's thread and would leak its self-reference.

  Any thread            Owning run loop thread
  ----------            ----------------------
  startOneShot()  --+
  startRepeating()  +--> (re)schedule only        safe: never frees TimerBase
  dispatchAfter() --+

  stop()          --+
  ~Timer()          +--> invalidate platform      must run here: an in-flight
                         timer state              callback may still be running

These were genuine use-after-free-shaped races between an in-flight timer callback and cross-thread teardown, closed across JSC, WebCore graphics and scrolling, and WebKit's compositor and event-dispatch code. The assertions are the durable half of the change: they turn a silent race — one that reproduces only when CFRunLoopTimerInvalidate() and an already-fired callback interleave badly — into an immediate, attributable crash at the offending call site.

The forward-facing pattern is teardown of run-loop-owned state from a thread that does not own the run loop. Narrowly: the assertions only cover RunLoop::Timer, so the equivalent WTF and WebCore wrappers around platform run-loop resources — observers, sources, and dispatch-suspended objects — have no such guard; audit them for stop()/invalidate()/destructor paths reachable from a WorkQueue lambda. The match tell is a member timer or source whose owner is ThreadSafeRefCounted, because thread-safe refcounting on the owner is frequently mistaken for thread-safe teardown of what it owns. Wider: the JSC case here — a timer stopped from another agent's thread, leaking its self-reference — generalizes to any self-referencing callback object whose only release path runs on stop; enumerate the JSRunLoopTimer subclasses and the WebCore animation and scrolling timers for the same self-Ref-released-on-stop shape. Widest: the asymmetry itself (arm from anywhere, disarm only on the owner) is a common platform-wrapper contract that is almost never documented at the call site, so any wrapper offering both an arming and a disarming API without stating their differing affinity is worth a pass.