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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions