Skip to content

Three dispose-registration gaps leak Terminal instances past host unmount #5818

@srid

Description

@srid

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.tsDebouncedIdleTask

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.

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