Summary
Three related MutableDisposable / DebouncedIdleTask fields in the xterm graph are created but never registered for disposal, leaving timers/intervals running past Terminal.dispose(). Each one transitively keeps the whole Terminal instance in memory, defeating host-app cleanup.
Discovered via heap-snapshot retainer walk in a downstream app (kolu) that mounts/unmounts <Terminal> across layout-mode toggles. Full methodology + heap-analysis scripts: juspay/kolu#606, docs/perf-investigations/mode-toggle-leak.md.
The three gaps
1. addon-webgl/src/WebglRenderer.ts:33
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = new MutableDisposable();
Every sibling MutableDisposable field in WebglRenderer is wrapped in this._register(...) — this one isn't. CursorBlinkStateManager runs a setInterval for the cursor blink and its dispose() correctly clears it, but dispose() is never invoked because the MutableDisposable is not in the renderer's disposable store. The setInterval survives WebglRenderer.dispose() and keeps the renderer reachable via its _renderCallback closure.
2. src/common/TaskQueue.ts — DebouncedIdleTask
Class has no dispose() method. Any pending idle callback it scheduled via this._queue.enqueue(...) survives its "owner's" disposal.
3. src/browser/services/RenderService.ts:71
this._pausedResizeTask = new DebouncedIdleTask(this._logService);
Declared without this._register(...). Same class of bug as (1).
Retention chain (observed via V8 heap snapshot BFS from root)
Window → DOMTimer → ScheduledAction → V8Function
→ CursorBlinkStateManager._renderCallback → WebglRenderer → Terminal
Impact in the downstream app
Repro: 6 Focus↔Canvas layout toggles with 4 <Terminal> components.
| Stage |
Retained disposed Terminals |
| Before any fix |
24 / 28 |
| After host-app addon-reference cleanup |
6 / 28 |
| After fixing these three upstream gaps |
0 / 28 |
After forced GC, yn._store._isDisposed === true for all 24 retained instances — i.e. Terminal.dispose() ran, but memory was not reclaimable because the live interval/idle task held the graph.
Proposed fix
Three small edits following the pattern of surrounding code. See PR (to follow) for the exact diff — 6 lines changed across 3 files. Zero behavior change; the setup just gains disposal propagation where it was already intended.
I'll open the PR shortly and link back to this issue.
Summary
Three related
MutableDisposable/DebouncedIdleTaskfields in the xterm graph are created but never registered for disposal, leaving timers/intervals running pastTerminal.dispose(). Each one transitively keeps the wholeTerminalinstance in memory, defeating host-app cleanup.Discovered via heap-snapshot retainer walk in a downstream app (kolu) that mounts/unmounts
<Terminal>across layout-mode toggles. Full methodology + heap-analysis scripts: juspay/kolu#606, docs/perf-investigations/mode-toggle-leak.md.The three gaps
1.
addon-webgl/src/WebglRenderer.ts:33Every sibling
MutableDisposablefield inWebglRendereris wrapped inthis._register(...)— this one isn't.CursorBlinkStateManagerruns asetIntervalfor the cursor blink and itsdispose()correctly clears it, butdispose()is never invoked because theMutableDisposableis not in the renderer's disposable store. ThesetIntervalsurvivesWebglRenderer.dispose()and keeps the renderer reachable via its_renderCallbackclosure.2.
src/common/TaskQueue.ts—DebouncedIdleTaskClass has no
dispose()method. Any pending idle callback it scheduled viathis._queue.enqueue(...)survives its "owner's" disposal.3.
src/browser/services/RenderService.ts:71Declared without
this._register(...). Same class of bug as (1).Retention chain (observed via V8 heap snapshot BFS from root)
Impact in the downstream app
Repro: 6 Focus↔Canvas layout toggles with 4
<Terminal>components.After forced GC,
yn._store._isDisposed === truefor all 24 retained instances — i.e.Terminal.dispose()ran, but memory was not reclaimable because the live interval/idle task held the graph.Proposed fix
Three small edits following the pattern of surrounding code. See PR (to follow) for the exact diff — 6 lines changed across 3 files. Zero behavior change; the setup just gains disposal propagation where it was already intended.
I'll open the PR shortly and link back to this issue.