Skip to content

Replace @debounce with disposable RunOnceScheduler in TerminalResizeDebouncer#318177

Draft
bryanchen-d wants to merge 1 commit into
mainfrom
brchen/terminal-resize-debouncer-lifecycle
Draft

Replace @debounce with disposable RunOnceScheduler in TerminalResizeDebouncer#318177
bryanchen-d wants to merge 1 commit into
mainfrom
brchen/terminal-resize-debouncer-lifecycle

Conversation

@bryanchen-d
Copy link
Copy Markdown
Contributor

What

Replace the @debounce(100) decorator on _debounceResizeX with a RunOnceScheduler owned by the disposable store.

Why

The @debounce decorator schedules a bare setTimeout and stashes the handle as an instance field ($debounce$_debounceResizeX). The timer is not registered with any DisposableStore, so super.dispose() does not cancel it. The callback can fire after the terminal/xterm has been torn down and crash inside xterm.js' RenderService.get dimensions() — telemetry buckets aaa283a2, 4826565b, 1e83a096.

PR #314795 worked around this with an if (this._store.isDisposed) return; guard inside _debounceResizeX. That closes the buckets but:

  • Every new debounced callsite on this class needs the same guard — forget it once and a new bucket appears (commit d1a4f1b added a similar guard to _resize three weeks later for the same class of race).
  • The stray timer still fires; we just early-return.
  • It's inconsistent with the rest of the codebase, where timers live in a DisposableStore (RunOnceScheduler, ThrottledDelayer, disposableTimeout, …) and are cancelled deterministically at dispose.

As Daniel noted on #314795:

moving off @debounce is the better option, this is a hack that doesn't truly fix the lifecycle.

Changes

terminalResizeDebouncer.ts:

  • Replace @debounce(100) _debounceResizeX(cols) with a RunOnceScheduler registered via this._register(...). The scheduler reads this._latestX lazily (already updated before each schedule).
  • Replace this._debounceResizeX(cols) with this._debounceResizeXScheduler.schedule().
  • Cancel the scheduler in the immediate-resize branch and in flush() so a queued X-debounce cannot fire after a fresh full resize.
  • Drop the import { debounce } and the per-callsite isDisposed guard inside the debounced method.

After this change, disposing the TerminalResizeDebouncer cancels the pending timer through the normal disposable chain — no post-dispose callback can run, so no guard is needed.

How to test

  1. Open a terminal, type or generate >200 lines of output (debounce threshold).
  2. Trigger dispose-during-resize:
    • Close (Ctrl+Shift+W) a terminal while resizing the window.
    • Toggle terminal.integrated.fontFamily immediately after killing a terminal.
    • Run exit in a long-running shell while the panel is mid-resize.
  3. Verify no Cannot read properties of undefined (reading 'dimensions') in DevTools console.
  4. Telemetry: bucket 4826565b (the _debounceResizeX path) should stop firing.

Risk

  • Behavior in the live (non-disposed) path is identical: RunOnceScheduler.schedule() cancels and re-schedules a 100ms timer just like the decorator did, and reads the latest cols from _latestX which the caller updates before calling.
  • The scheduler is cancelled in flush() and on immediate resize, so we cannot land a stale X-resize after a full resize — this was implicit before because clearTimeout did the same on the decorator's stored handle.

Refs #314795, #303546.

…ebouncer

The @debounce decorator schedules a bare setTimeout stored as an instance
field; the timer is not registered with the disposable store, so the
callback can fire after the terminal/xterm is disposed. PR #314795 worked
around this with an isDisposed early-return inside _debounceResizeX, but
each new debounced callsite would need the same defensive guard.

Replace the decorator with a RunOnceScheduler registered on the disposable
store. The pending timer is now cancelled deterministically when the
TerminalResizeDebouncer is disposed, so no post-dispose callbacks can run
and no per-method guard is required.

Also cancel the pending scheduler in the immediate-resize branch and in
flush() so a queued debounced X-resize cannot overwrite a fresh immediate
resize.

Refs #314795.
Copilot AI review requested due to automatic review settings May 24, 2026 20:19
@bryanchen-d bryanchen-d marked this pull request as draft May 24, 2026 20:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the @debounce(100)-based horizontal resize debounce in TerminalResizeDebouncer with a RunOnceScheduler that’s owned by the class’ disposable store, ensuring any pending timer is deterministically cancelled on dispose and avoiding post-dispose resize callbacks into torn-down xterm state.

Changes:

  • Replaces the @debounce(100) decorator with a _debounceResizeXScheduler: RunOnceScheduler registered via this._register(...).
  • Updates call sites to schedule/cancel the scheduler (including cancelling in the immediate-resize path and in flush()).
  • Removes the debounce import and the isDisposed guard inside the previously-decorated method.
Show a summary per file
File Description
src/vs/workbench/contrib/terminal/browser/terminalResizeDebouncer.ts Moves horizontal resize debouncing to a disposable RunOnceScheduler and cancels pending work during flush/immediate resize to avoid post-dispose callbacks.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 1

Comment on lines 85 to +87
this._resizeYCallback(rows);
this._latestX = cols;
this._debounceResizeX(cols);
this._debounceResizeXScheduler.schedule();
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.

2 participants