Skip to content

IntersectionObserver callback retains RenderService → BufferLines after dispose #5820

@srid

Description

@srid

Problem

RenderService._registerIntersectionObserver creates an IntersectionObserver whose callback closes over this:

https://github.com/xtermjs/xterm.js/blob/master/src/browser/services/RenderService.ts#L129

const observer = new w.IntersectionObserver(
  e => this._handleIntersectionChange(e[e.length - 1]),
  { threshold: 0 }
);

On dispose, _observerDisposable calls observer.disconnect(). In theory that releases the callback. In practice — verified against a production Chrome tab — the callback's closure lives on, keeping this (RenderService) reachable, and with it the whole service graph:

RenderService._coreService
  → CoreService._bufferService
  → BufferService.buffers._normal
  → Buffer.lines (CircularList)
  → BufferLine[]
  → Uint32Array (cell data, ~1.3 KB each + native ArrayBuffer backing)

Evidence

Heap-snapshot diff of a multi-tile terminal app (kolu) across 30 mount/unmount cycles of 7 Terminal instances:

Class Δ count Δ bytes
native:system / JSArrayBufferData +175,594 +220 MB
object:Uint32Array +175,594 +10 MB
object:ArrayBuffer +175,594 +9 MB
object:BufferLine (minified Z1) +175,594 +5 MB

All 175,594 retained Uint32Array instances traced through the same retainer signature:

Window.IntersectionObserver (registry)
  → callback closure
  → Context
  → RenderService (minified ep)
  → _coreService (sp) → _bufferService (ap)
  → buffers._normal (CS) → .lines (SS)
  → ._array → BufferLine (Z1)
  → ._data → Uint32Array

175,594 retained BufferLines = 30 toggles × 7 terminals × ~830 lines per terminal — basically the full scrollback buffer of every disposed Terminal was being held past terminal.dispose().

Proposal

Hold this in the observer callback via WeakRef instead of a direct closure capture. Then even if the callback is retained somewhere past observer.disconnect(), the RenderService and its service graph (including Uint32Array cell buffers) become GC-eligible on dispose.

   private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void {
     if ('IntersectionObserver' in w) {
-      const observer = new w.IntersectionObserver(
-        e => this._handleIntersectionChange(e[e.length - 1]),
-        { threshold: 0 }
-      );
+      const weakSelf = new WeakRef(this);
+      const observer = new w.IntersectionObserver(
+        e => weakSelf.deref()?._handleIntersectionChange(e[e.length - 1]),
+        { threshold: 0 }
+      );
       observer.observe(screenElement);
       this._observerDisposable.value = toDisposable(() => observer.disconnect());
     }
   }

Functional behavior is preserved: while RenderService is alive, weakSelf.deref() returns it. Once no strong refs remain, the intersection callback becomes a no-op and the buffer graph is free to GC.

Happy to send a PR. I have not chased down why disconnect() isn't fully releasing the callback in practice (devtools extension? browser native registry? different for Chrome vs Firefox?) — but the defensive WeakRef wrap breaks the retention regardless of the underlying cause.

Related

🤖 Diagnosis assisted by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions