Web Audio internals: nodes, handlers, and threading

The bug sits in Blink's Web Audio module, which runs inside the sandboxed renderer process. Web Audio's JavaScript-facing AudioNode objects delegate to AudioHandler objects, which hold the actual processing logic. Each AudioNode keeps its AudioHandler alive via a scoped_refptr, and each node also holds a strong Member reference to the BaseAudioContext that created it. That context reference means a BaseAudioContext can't be garbage-collected until all of its nodes are gone.

Rendering walks the audio graph from a destination node. During a render quantum, if an AudioNode is garbage-collected on the main thread, its AudioHandler can be deleted while the audio thread is still using it. To avoid that, the node transfers ownership of its handler to a DeferredTaskHandler when it's destroyed during rendering. The handler is then kept in rendering_orphan_handlers_ until the quantum finishes, at which point ClearHandlersToBeDeleted purges the list. Destruction of the execution context also triggers ClearHandlersToBeDeleted.

Access to AudioHandler objects from the audio thread is normally guarded by GraphAutoLocker or the newer tear_down_mutex_ on BaseAudioContext. Some node types also have fine-grained locks — for example, PannerNode has process_lock_. The bug here is a spot where that protection is missing, and the interesting part is how to reliably land in that missing-lock window.

The race in OfflineAudioDestinationNode

In Chrome 80.0.3987.132, the rendering path in OfflineAudioDestinationNode looks like this:

  {
    MutexTryLocker try_locker(Context()->GetTearDownMutex());
    if (try_locker.Locked()) {
      DCHECK_GE(NumberOfInputs(), 1u);

      // This will cause the node(s) connected to us to process, which in turn
      // will pull on their input(s), all the way backwards through the
      // rendering graph.
      AudioBus* rendered_bus = Input(0).Pull(destination_bus, number_of_frames);

      if (!rendered_bus) {
        destination_bus->Zero();
      } else if (rendered_bus != destination_bus) {
        // in-place processing was not possible - so copy
        destination_bus->CopyFrom(*rendered_bus);
      }
    } else {
      destination_bus->Zero();
    }

    // Process nodes which need a little extra help because they are not
    // connected to anything, but still need to process.
    Context()->GetDeferredTaskHandler().ProcessAutomaticPullNodes(            //<--- Only protected if try_locker succeeded
        number_of_frames);
  }

The code attempts to take the teardown lock before pulling input via Input(0).Pull(...). However, ProcessAutomaticPullNodes runs unconditionally, regardless of whether the lock was acquired:

void DeferredTaskHandler::ProcessAutomaticPullNodes(
    uint32_t frames_to_process) {
  DCHECK(IsAudioThread());

  for (unsigned i = 0; i < rendering_automatic_pull_handlers_.size(); ++i) {
    rendering_automatic_pull_handlers_[i]->ProcessIfNecessary(
        frames_to_process);
  }
}

If the audio thread fails to take the lock, then ProcessAutomaticPullNodes reads rendering_automatic_pull_handlers_ with no synchronization. That alone isn't enough to produce a use-after-free — the handlers in that list have to be freed first, and the list itself has to still contain stale pointers.

How the stale list gets populated

rendering_automatic_pull_handlers_ is refreshed each time HandlePreRenderTasks runs, just before the audio thread tries to grab the teardown lock:

  if (Context()->HandlePreRenderTasks(nullptr, nullptr)) {      //<--- Updates `rendering_automatic_pull_handlers_`
    SuspendOfflineRendering();
    return true;
  }

  {
    MutexTryLocker try_locker(Context()->GetTearDownMutex());
    if (try_locker.Locked()) {
      DCHECK_GE(NumberOfInputs(), 1u);

That refresh is protected by the graph lock. The consequence: for a use-after-free, any graph change — specifically destruction of an AudioNode — must happen after rendering_automatic_pull_handlers_ is updated. Otherwise the handler gets removed from the list and won't be touched again.

So to hit ProcessAutomaticPullNodes with freed memory, you need:

  1. An AudioNode whose handler is in rendering_automatic_pull_handlers_.
  2. A garbage collection that destroys that node after HandlePreRenderTasks updates the list, but before the audio thread reaches ProcessAutomaticPullNodes.
  3. The node's handler to be moved into rendering_orphan_handlers_ on destruction (since it happens mid-render).
  4. Both lists — rendering_orphan_handlers_ and rendering_automatic_pull_handlers_ — to be cleared before ProcessAutomaticPullNodes runs.

Clearing happens in ClearHandlersToBeDeleted:

void DeferredTaskHandler::ClearHandlersToBeDeleted() {
  DCHECK(IsMainThread());
  GraphAutoLocker locker(*this);
  tail_processing_handlers_.clear();
  rendering_orphan_handlers_.clear();
  deletable_orphan_handlers_.clear();
  automatic_pull_handlers_.clear();
  rendering_automatic_pull_handlers_.clear();
  active_source_handlers_.clear();
}

Since rendering_automatic_pull_handlers_ is cleared last, if ProcessAutomaticPullNodes executes afterward, it dereferences handlers that no longer exist.

On the audio thread, reaching ProcessAutomaticPullNodes with no lock requires the teardown lock acquisition to fail. That means BaseAudioContext::Uninitialize must have been called and completed first — which also runs ClearHandlersToBeDeleted to empty out the handler lists.

The timing problem

Putting it all together, the required sequence on the main thread is:

race window

HandlePreRenderTasks completes on the audio thread; then, in the window before the audio thread tries the teardown lock, the main thread must perform a full GC cycle (which disposes the target node and moves its handler to rendering_orphan_handlers_), then destroy the execution context so that BaseAudioContext::Uninitialize runs ClearHandlersToBeDeleted.

  if (Context()->HandlePreRenderTasks(nullptr, nullptr)) {
  ...
  //Destruction window, where GC needs to complete followed by a BaseAudioContext::Uninitialize called.
  {
    MutexTryLocker try_locker(Context()->GetTearDownMutex());
    if (try_locker.Locked()) {
      DCHECK_GE(NumberOfInputs(), 1u);

That's a remarkably tight window. Without some way to deliberately skew the relative speed of the two threads, fitting a GC cycle and a context teardown between two adjacent audio-thread operations isn't practical — which is precisely why this bug is interesting to trigger, and hard to exploit. The same pattern, though, recurs in the Web Audio module's locking code and in variants found via CodeQL analysis, and understanding the race is the first step to reasoning about when the protective lists fail to keep handlers alive.

GC Trigger Path Through Promise Rejection

An alternative approach targets garbage collection inside BaseAudioContext::Uninitialize itself, before ClearHandlersToBeDeleted runs. The method calls RejectPendingResolvers early in its execution, which for an OfflineAudioContext allocates a DOMException for each unresolved promise:

void OfflineAudioContext::RejectPendingResolvers() {
  ...
  for (auto& pending_suspend_resolver : scheduled_suspends_) {
    pending_suspend_resolver.value->Reject(MakeGarbageCollected<DOMException>(
        DOMExceptionCode::kInvalidStateError, "Audio context is going away"));   //<--- Allocates GCed objects
  }
  ...
}

Each MakeGarbageCollected allocation adds memory pressure, but DOMException objects are small — you would need an impractically large number of promises to force a collection on their own. The practical route is to accumulate memory pressure beforehand so that a small final allocation tips the threshold.

When the Blink heap allocates a GarbageCollected object, the allocation path checks available free space first. If none exists, OutOfLineAllocate claims new space, then calls AllocatedObjectSizeSafepoint, which propagates through EmbedderHeapTracer::IncreaseAllocatedSize into LocalEmbedderHeapTracer::StartIncrementalMarkingIfNeeded:

void LocalEmbedderHeapTracer::StartIncrementalMarkingIfNeeded() {
  if (!FLAG_global_gc_scheduling || !FLAG_incremental_marking) return;

  Heap* heap = isolate_->heap();
  heap->StartIncrementalMarkingIfAllocationLimitIsReached(
      heap->GCFlagsForIncrementalMarking(),
      kGCCallbackScheduleIdleGarbageCollection);
  if (heap->AllocationLimitOvershotByLargeMargin()) {
    heap->FinalizeIncrementalMarkingAtomically(           //<--- Triggers a full GC
        i::GarbageCollectionReason::kExternalFinalize);
  }
}

At this checkpoint, the heap->AllocationLimitOvershotByLargeMargin() test determines whether a full GC fires across both old and young space. The overshoot value is cumulative: it includes not just the current allocation but also previously allocated memory not yet freed. During the current cycle, executing the GarbageCollected object in parallel while threads access the JS wrapper GarbageCollected object has the same effect as directly accessing the GarbageCollected object.

Since the overshoot calculation considers all live memory, a specifically sized prior allocation can make even the tiny RejectPendingResolvers allocations trip the full GC trigger. With adequate pre-allocation, GC fires during that callback at a precisely chosen moment.

Timing the Concurrent Access

Experimentally, GC fires during RejectPendingResolvers only when a large allocation happens immediately before context destruction. The trigger sequence is:

  //Prepare memory pressure
  for (let i = 0; i < 180; i++) {
    arr[i] = new Array(1024 * 1024);
    arr[i].fill(1);
  }
  let frame = document.getElementById("ifrm");
  //Trigger BaseAudioContext::Uninitialize and then GC within it.
  frame.parentNode.removeChild(frame);

These allocations occur inside an iframe holding the BaseAudioContext and its AudioHandler. Cross-thread behavior then unfolds as:

threads

The difficulty is that a large allocation creates a wide timing window. Minor fluctuations in allocation duration shift when BaseAudioContext::Uninitialize and ClearHandlersToBeDeleted land relative to each other, making the race unreliable.

An AudioWorkletNode provides better control. Its user-supplied processing function lets you delay audio-thread work by an amount roughly matching the gap between BaseAudioContext::Uninitialize and ClearHandlersToBeDeleted. If the uninitialize call lands within the right window, the teardown method runs concurrently with ProcessAutomaticPullNodes:

  if (!IsInitialized()) {
    destination_bus->Zero();
    return false;
  }
  //Window start to trigger `BaseAudioContext::Uninitialize`
  if (Context()->HandlePreRenderTasks(nullptr, nullptr)) {
    SuspendOfflineRendering();
    return true;
  }
  //Window end to trigger `BaseAudioContext::Uninitialize`
  {
    MutexTryLocker try_locker(Context()->GetTearDownMutex());

In a component_build, the thread tear-down lock synchronizes both threads naturally. BaseAudioContext::Uninitialize must start while the AudioWorkletNode is mid-processing, then blocks on the lock until the audio thread releases it. Because this release point aligns consistently with the rendering quantum, the uninitialize call lands in the correct window of the next quantum as well, making the race straightforward to hit.

On a non-component build the lock still synchronizes the threads, but BaseAudioContext::Uninitialize fires too early and misses the window. Synchronizing there would likely require comparing currentFrame values between the audio and main threads inside the AudioWorkletProcessor — workable, but tedious, and not attempted in this research.

Retry Strategy Without Resetting Heap State

Since the race depends on precise timing, multiple attempts are normally required. Reloading the page does not work, though, because a reload preserves the allocation threshold essential to triggering GC at the right point. Instead, two hosts can serve identical pages and redirect to each other. Each redirect loads the page in a fresh renderer, resetting allocation counters and thresholds. After enough hops, the bug fires from a single user click without any state pollution between attempts.