Skip to content

Fix dispose leaks: register CursorBlinkStateManager + _pausedResizeTask#5817

Open
srid wants to merge 1 commit intoxtermjs:masterfrom
juspay:fix/dispose-leaks
Open

Fix dispose leaks: register CursorBlinkStateManager + _pausedResizeTask#5817
srid wants to merge 1 commit intoxtermjs:masterfrom
juspay:fix/dispose-leaks

Conversation

@srid
Copy link
Copy Markdown

@srid srid commented Apr 17, 2026

Fixes #5818.

Three small edits that add missing this._register(...) wrappers to disposables that were created but never registered. Full problem description with heap-snapshot evidence in the linked issue.

Diff

 addons/addon-webgl/src/WebglRenderer.ts | 2 +-
 src/browser/services/RenderService.ts   | 2 +-
 src/common/TaskQueue.ts                 | 4 ++++
 3 files changed, 6 insertions(+), 2 deletions(-)

Downstream verification

Reproduced in kolu (SolidJS + xterm.js terminal workspace) with a Focus ↔ Canvas layout toggle that synchronously remounts all <Terminal> components:

Stage Retained disposed xterm Terminals
Pre-fix (after forced GC) 24 / 28
After downstream-only workaround 6 / 28
After this PR (no downstream workaround) 0 / 28

Downstream PR exercising the fork via git-URL override (and passing CI across both the host typecheck and full Nix build): juspay/kolu#609.

Risk

Zero behavior change for hosts that already dispose the renderer correctly — this PR just ensures disposal actually propagates where it was already intended to. The three edits follow the same pattern as surrounding sibling fields in each file.

Happy to address any feedback. Thanks for xterm.js.

Three related disposable-registration gaps that leak xterm Terminal
instances when the host application unmounts a Terminal. Evidence +
full writeup at juspay/kolu#606.

1. addon-webgl/WebglRenderer._cursorBlinkStateManager was declared
   `= new MutableDisposable()` without the `this._register(...)`
   wrapper that every sibling MutableDisposable in the class uses.
   CursorBlinkStateManager runs a setInterval for the cursor blink
   and its `dispose()` correctly clears it — but dispose() was never
   called because the disposable wasn't registered. The live timer
   kept the renderer (and thus the Terminal) alive past host
   unmount.

2. common/TaskQueue.DebouncedIdleTask had no `dispose()` method.
   Added one that clears the internal IdleTaskQueue (cancelling any
   pending idle callback).

3. browser/services/RenderService._pausedResizeTask (a
   DebouncedIdleTask) was not registered. Wrapped with
   `this._register(...)` so RenderService.dispose propagates.

Verified in a downstream app (kolu) with a repro that toggles Focus
vs Canvas layout modes:

- Pre-fix: 24/28 disposed xterm Terminals retained past host unmount
  + forced GC.
- Post-fix: 0/28 retained.

Retention chain (before fix):

    DOMTimer → ScheduledAction → V8Function
      → CursorBlinkStateManager._renderCallback → WebglRenderer
      → Terminal
@srid srid marked this pull request as draft April 17, 2026 17:36
srid added a commit to juspay/kolu that referenced this pull request Apr 17, 2026
Replaces the minified-mjs pnpm patches from the previous commit with
a git-URL override pointing at the juspay/xterm.js fork. The fork's
`fix/dispose-leaks-built` branch commits both:

  1. The source-level .ts fix (upstream-ready; PR at xtermjs/xterm.js#5817,
     tracking issue xtermjs/xterm.js#5818).
  2. Pre-built .mjs bundles so pnpm can consume the fork directly
     without running xterm's build toolchain at install time.

Changes:

  - package.json: drop the two xterm patches from `patchedDependencies`;
    add `overrides` for `@xterm/xterm` and `@xterm/addon-webgl`
    pointing at `github:juspay/xterm.js#fix/dispose-leaks-built` (the
    addon-webgl entry uses `&path:/addons/addon-webgl` to select the
    monorepo subdirectory).
  - patches/: delete both @xterm patch files.
  - default.nix: bump the `fetchPnpmDeps` hash.
  - pnpm-lock.yaml: regenerated — pins the fork via its tarball URL
    + commit SHA, so the build is reproducible across machines.

Verified: `nix build .#default` succeeds end-to-end; dev-server heap
reproduction still shows `aliveDetached: 0, gced: 7/8` after 6 mode
toggles. Previous commit (the .mjs-patch approach) is preserved in
git history for record of the interim fix.

Once xtermjs/xterm.js#5817 merges + an xterm release ships, this
override can be dropped in favor of a plain version bump.
@srid srid marked this pull request as ready for review April 17, 2026 17:42
srid added a commit to juspay/kolu that referenced this pull request Apr 17, 2026
Replaces the minified-mjs pnpm patches from the previous commit with
a git-URL override pointing at the juspay/xterm.js fork. The fork's
`fix/dispose-leaks-built` branch commits both:

  1. The source-level .ts fix (upstream-ready; PR at xtermjs/xterm.js#5817,
     tracking issue xtermjs/xterm.js#5818).
  2. Pre-built .mjs bundles so pnpm can consume the fork directly
     without running xterm's build toolchain at install time.

Changes:

  - package.json: drop the two xterm patches from `patchedDependencies`;
    add `overrides` for `@xterm/xterm` and `@xterm/addon-webgl`
    pointing at `github:juspay/xterm.js#fix/dispose-leaks-built` (the
    addon-webgl entry uses `&path:/addons/addon-webgl` to select the
    monorepo subdirectory).
  - patches/: delete both @xterm patch files.
  - default.nix: bump the `fetchPnpmDeps` hash.
  - pnpm-lock.yaml: regenerated — pins the fork via its tarball URL
    + commit SHA, so the build is reproducible across machines.

Verified: `nix build .#default` succeeds end-to-end; dev-server heap
reproduction still shows `aliveDetached: 0, gced: 7/8` after 6 mode
toggles. Previous commit (the .mjs-patch approach) is preserved in
git history for record of the interim fix.

Once xtermjs/xterm.js#5817 merges + an xterm release ships, this
override can be dropped in favor of a plain version bump.
srid added a commit to juspay/kolu that referenced this pull request Apr 17, 2026
…ermjs/xterm.js#5817) (#609)

Closes #606. Part of #610 (master tracking).

Follow-up to #607. After the Kolu-side addon-null cleanup reduced
orphaned xterm Terminals from 24 → 6, heap-snapshot BFS-from-root traced
all 6 survivors to one xterm-internal retainer chain:

```
DOMTimer → ScheduledAction → V8Function
  → CursorBlinkStateManager._renderCallback → WebglRenderer → Terminal
```

Three disposables in xterm.js are created but never registered:

1. **`addon-webgl/WebglRenderer._cursorBlinkStateManager`** — declared
`= new MutableDisposable()` without `this._register(...)`. Its internal
`setInterval` for the cursor blink survives `WebglRenderer.dispose()`,
pinning the renderer.
2. **`xterm/TaskQueue.DebouncedIdleTask`** — no `dispose()` method at
all.
3. **`xterm/RenderService._pausedResizeTask`** — not registered.

Full writeup:
[`docs/perf-investigations/mode-toggle-leak.md`](https://github.com/juspay/kolu/blob/master/docs/perf-investigations/mode-toggle-leak.md).

## Upstream

- **Issue**: xtermjs/xterm.js#5818 — problem
description + heap evidence
- **PR**: xtermjs/xterm.js#5817 — 6-line source
fix, ready for review

## Approach — fork via pnpm overrides

This PR consumes the fix via the **juspay/xterm.js** fork until upstream
merges + releases. Two commits on this branch tell the story:

1. **`98df21e` — interim fix via `pnpm patch`** (historical record;
reverted in the next commit). Large minified-`.mjs` diff files, worked
but not maintainable.
2. **`dfa2b5b` — switch to fork via git URL** (final approach):
- `package.json` adds `pnpm.overrides` for `@xterm/xterm` and
`@xterm/addon-webgl` pointing at
`github:juspay/xterm.js#fix/dispose-leaks-built` (the
`&path:/addons/addon-webgl` selector pulls the addon from the monorepo
subdirectory).
- Fork branch `fix/dispose-leaks-built` commits both the `.ts` source
fix and pre-built `.mjs` bundles so pnpm consumes them directly — no
build step during `pnpm install`, works in Nix's restricted-network
sandbox.
   - `patches/@xterm__*.patch` deleted.
   - `default.nix` `fetchPnpmDeps` hash bumped.

Once `xtermjs/xterm.js#5817` merges and a new xterm release ships, the
overrides collapse to a one-line version bump in `package.json`.

## Verification

| Stage                                  | Rn  | yn disposed-retained |
| -------------------------------------- | --- | -------------------- |
| Pre-any-fix (after forced GC)          | 29  | 24                   |
| After #607 only                        | 11  | 6                    |
| **After this PR (full fix via fork)**  | 5   | **0**                |

Residual 1 `Rn` above live-count is a transitional instance freshly
created during restore.

- `nix build .#default` ✓ end-to-end build with fork via `fetchPnpmDeps`
tarball URL.
- `just check`, `just fmt` ✓
- Production verified on pre-fork-patch builds: `aliveDetached: 0, gced:
54/55` after extensive mode toggling.
- Dev-server repro with fork: 6 mode toggles → `aliveDetached: 0`,
`gced: 7/8`.

## Test plan

- [x] typecheck + fmt
- [x] heap-snapshot reproduction against fork confirms zero
disposed-retained xterm Terminals
- [x] full `nix build .#default` succeeds
- [x] upstream issue + PR filed
- [ ] CI green across all systems

🤖 Generated with [Claude Code](https://claude.com/claude-code)
srid added a commit to juspay/xterm.js that referenced this pull request Apr 17, 2026
Downstream kolu (juspay/kolu) consumes this fork via pnpm.overrides
github URL. The main fix/kolu-xterm-fixes branch is source-only;
this branch adds the pre-built .mjs bundles so no build step runs
at pnpm install time.

Built output contains both fixes:
  - xtermjs#5817: register CursorBlinkStateManager + _pausedResizeTask
  - xtermjs#5821: WeakRef this in IntersectionObserver callback (xtermjs#5820)
srid added a commit to juspay/kolu that referenced this pull request Apr 17, 2026
…fork

Bumps the pnpm override pointer from `fix/dispose-leaks-built` to
`fix/kolu-xterm-fixes-built`, which stacks a second fix on top of
the existing dispose-leaks patches:

- xtermjs/xterm.js#5817 (`fix/dispose-leaks`): register
  CursorBlinkStateManager + _pausedResizeTask disposables. Already
  in production.
- xtermjs/xterm.js#5821 (new, `fix/intersection-observer-weakref`):
  wrap `this` in a `WeakRef` inside `RenderService`'s IntersectionObserver
  callback. `observer.disconnect()` on our side wasn't releasing the
  callback in practice — heap snapshot showed 175,594 retained
  Uint32Arrays (~220 MB / 30 mode-toggles with 7 terminals) traced
  through the global IntersectionObserver registry → callback closure
  → RenderService → BufferService → BufferLines. WeakRef breaks that
  chain regardless of why the native registry held the callback.

Local measurement on kolu@zest (fresh tab, 30 canvas↔focus toggles,
7 terminals restored):

                                 Before fix  →  After fix
  Task Manager Memory Footprint    +367 MB   →   -3 MB
  BufferLine (Z1) instances Δ      +175,594  →   ~0

Upstream tracking: xtermjs/xterm.js#5820 (issue),
xtermjs/xterm.js#5821 (PR). Fork consumption will collapse to a plain
version bump once upstream merges + releases.
srid added a commit to juspay/kolu that referenced this pull request Apr 17, 2026
…fork

Bumps the pnpm override pointer from `fix/dispose-leaks-built` to
`fix/kolu-xterm-fixes-built`, which stacks a second fix on top of
the existing dispose-leaks patches:

- xtermjs/xterm.js#5817 (`fix/dispose-leaks`): register
  CursorBlinkStateManager + _pausedResizeTask disposables. Already
  in production.
- xtermjs/xterm.js#5821 (new, `fix/intersection-observer-weakref`):
  wrap `this` in a `WeakRef` inside `RenderService`'s IntersectionObserver
  callback. `observer.disconnect()` on our side wasn't releasing the
  callback in practice — heap snapshot showed 175,594 retained
  Uint32Arrays (~220 MB / 30 mode-toggles with 7 terminals) traced
  through the global IntersectionObserver registry → callback closure
  → RenderService → BufferService → BufferLines. WeakRef breaks that
  chain regardless of why the native registry held the callback.

Local measurement on kolu@zest (fresh tab, 30 canvas↔focus toggles,
7 terminals restored):

                                 Before fix  →  After fix
  Task Manager Memory Footprint    +367 MB   →   -3 MB
  BufferLine (Z1) instances Δ      +175,594  →   ~0

Upstream tracking: xtermjs/xterm.js#5820 (issue),
xtermjs/xterm.js#5821 (PR). Fork consumption will collapse to a plain
version bump once upstream merges + releases.
srid added a commit to juspay/kolu that referenced this pull request Apr 18, 2026
Draft blog post for review. Push revisions as additional commits for
iteration.

Preview on GitHub by clicking the file in the diff, or view raw for a
cleaner markdown render:

-
[docs/perf-investigations/the-leak-that-wasnt-in-any-context.md](https://github.com/juspay/kolu/blob/docs/leak-blog-post/docs/perf-investigations/the-leak-that-wasnt-in-any-context.md)

Shape of the post:

- Intro — what Kolu is (cockpit for coding agents), canvas mode (linked
to [@sridca tweet](https://x.com/sridca/status/2044953014100726221)),
symptom
- Wrong turn — compressed, not storied: six commits chasing the
`Context` count proxy on #614, 89% reduction, zero Task Manager movement
- What I was actually measuring — brief explainer on Task Manager vs
`performance.memory` for readers who aren't web-perf fluent
- The one-line fix — byte-delta diff, retainer chain, WeakRef diff
- The xterm.js side — highlights both contributions
(xtermjs/xterm.js#5817 + xtermjs/xterm.js#5821) as "laughably small,
hours to find"
- What I'd tell past-me — three takeaways aimed at backend/systems
readers
- Links to master-branch docs/skill/scripts for anyone who wants the
full investigation record

Attribution: I drove; [Claude Code](https://claude.com/claude-code) did
the agent-side work.

Not for merge as-is — draft is to iterate on wording before deciding
where it's actually published.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three dispose-registration gaps leak Terminal instances past host unmount

1 participant