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
Problem
RenderService._registerIntersectionObservercreates anIntersectionObserverwhose callback closes overthis:https://github.com/xtermjs/xterm.js/blob/master/src/browser/services/RenderService.ts#L129
On dispose,
_observerDisposablecallsobserver.disconnect(). In theory that releases the callback. In practice — verified against a production Chrome tab — the callback's closure lives on, keepingthis(RenderService) reachable, and with it the whole service graph:Evidence
Heap-snapshot diff of a multi-tile terminal app (kolu) across 30 mount/unmount cycles of 7
Terminalinstances:native:system / JSArrayBufferDataobject:Uint32Arrayobject:ArrayBufferobject:BufferLine(minifiedZ1)All 175,594 retained
Uint32Arrayinstances traced through the same retainer signature:175,594 retained BufferLines = 30 toggles × 7 terminals × ~830 lines per terminal — basically the full scrollback buffer of every disposed
Terminalwas being held pastterminal.dispose().Proposal
Hold
thisin the observer callback viaWeakRefinstead of a direct closure capture. Then even if the callback is retained somewhere pastobserver.disconnect(), the RenderService and its service graph (includingUint32Arraycell 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
fix/dispose-leaks) addresses two adjacent leaks (CursorBlinkStateManager+_pausedResizeTask) — independent from this one.🤖 Diagnosis assisted by Claude Code