Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 16, 2025

The task system leaked memory by registering terminal event listeners to TerminalTaskSystem's global disposal store via this._register(). When terminals disposed, listeners remained in the global store, maintaining references to disposed terminals indefinitely.

Changes

Added per-terminal disposal tracking via DisposableStore to ITerminalData:

interface ITerminalData {
	terminal: ITerminalInstance;
	lastTask: string;
	group?: string;
	shellIntegrationNonce?: string;
+	disposables: DisposableStore;  // Tracks terminal-specific listeners
}

Listener registration changes:

  • _doCreateTerminal: Removed global registration of onDisposed listeners (lines 1418, 1430, 1440)
  • _createTerminal: Creates DisposableStore per terminal, registers onDisposed listener to it
  • _reconnectToTerminals: Creates DisposableStore for reconnected terminals, registers listeners
  • terminate: Uses terminal-specific store when available, falls back to global store
  • Problem matcher: Uses terminal-specific store when available, falls back to global store

Disposal:

  • _deleteTaskAndTerminal: Calls terminalData.disposables.dispose() to clean up all terminal-specific listeners

This ensures listeners are disposed when their terminal is disposed, preventing indefinite retention.

Original prompt

This section details on the original issue you should resolve

<issue_title>Tasks leaks terminals</issue_title>
<issue_description>Tasks ends up holding on to terminal instances indefinitely, leaking a bunch of memory.

Examples:

This listener is disposed if the terminal is disposed, but a reference to it is kept indefinitely as it's not deleted via this._store.delete:

const onDisposedListener = this._register(terminal.onDisposed(() => {
this._deleteTaskAndTerminal(terminal, terminalData);
onDisposedListener.dispose();
}));

This type of thing happens in several places where the terminal is registered against TerminalTaskSystem but never released:

this._register(reconnectedTerminal.onDisposed(onDisposed));

Needs a proper audit by capturing heap snapshots to chase down all the leaks.</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

- Add DisposableStore to ITerminalData to track terminal-specific disposables
- Update _doCreateTerminal to remove leaked onDisposed listeners
- Update _createTerminal to use terminal-specific disposable store
- Update _reconnectToTerminals to properly track disposal listeners
- Update terminate method to avoid leaking onDisposed listener
- Update problem matcher listener to use terminal-specific store
- Dispose terminal-specific disposables in _deleteTaskAndTerminal

Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix memory leaks in terminal task management Fix terminal listener leaks in task system Dec 16, 2025
Copilot AI requested a review from meganrogge December 16, 2025 22:32
@meganrogge meganrogge marked this pull request as ready for review December 17, 2025 01:45
Copilot AI review requested due to automatic review settings December 17, 2025 01:45
Copy link
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 addresses memory leaks in the task system by adding per-terminal disposal tracking. Previously, terminal event listeners were registered globally via TerminalTaskSystem's disposal store, causing disposed terminal references to be retained indefinitely.

Key changes:

  • Added disposables: DisposableStore property to ITerminalData interface to track terminal-specific listeners
  • Created per-terminal disposal stores in _createTerminal and _reconnectToTerminals
  • Updated listener registration to use terminal-specific stores with fallback to global store
  • Modified _deleteTaskAndTerminal to dispose terminal-specific listeners

Comment on lines 1595 to 1601
const terminalKey = terminal.instanceId.toString();
const terminalData = { terminal: terminal, lastTask: taskKey, group, shellIntegrationNonce: terminal.shellLaunchConfig.shellIntegrationNonce };
const onDisposedListener = this._register(terminal.onDisposed(() => {
const terminalDisposables = new DisposableStore();
const terminalData = { terminal: terminal, lastTask: taskKey, group, shellIntegrationNonce: terminal.shellLaunchConfig.shellIntegrationNonce, disposables: terminalDisposables };
terminalDisposables.add(terminal.onDisposed(() => {
this._deleteTaskAndTerminal(terminal, terminalData);
onDisposedListener.dispose();
}));
this._terminals[terminalKey] = terminalData;
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

Memory leak: If a reconnected terminal is returned by _doCreateTerminal (line 1426-1432) and that terminal was already added to _terminals by _reconnectToTerminals (line 1472), this code will create a new DisposableStore and overwrite the existing entry. The old DisposableStore from the reconnection will never be disposed, leaking its listeners. Before creating a new entry, check if the terminal already exists in _terminals and reuse its DisposableStore, or dispose the old one before replacing it.

See below for a potential fix:

		const existingTerminalData = this._terminals[terminalKey];
		let terminalDisposables: DisposableStore;
		let terminalData: ITerminalData;
		if (existingTerminalData) {
			// Reuse the existing DisposableStore to avoid leaking listeners created during reconnection.
			terminalDisposables = existingTerminalData.disposables;
			existingTerminalData.terminal = terminal;
			existingTerminalData.lastTask = taskKey;
			existingTerminalData.group = group;
			existingTerminalData.shellIntegrationNonce = terminal.shellLaunchConfig.shellIntegrationNonce;
			terminalData = existingTerminalData;
		} else {
			terminalDisposables = new DisposableStore();
			terminalData = { terminal: terminal, lastTask: taskKey, group, shellIntegrationNonce: terminal.shellLaunchConfig.shellIntegrationNonce, disposables: terminalDisposables };
			terminalDisposables.add(terminal.onDisposed(() => {
				this._deleteTaskAndTerminal(terminal, terminalData);
			}));
			this._terminals[terminalKey] = terminalData;
		}

Copilot uses AI. Check for mistakes.
const terminalDisposables = new DisposableStore();
const terminalData = { lastTask: data.lastTask, group: data.group, terminal, shellIntegrationNonce: data.shellIntegrationNonce, disposables: terminalDisposables };
terminalDisposables.add(terminal.onDisposed(() => {
this._deleteTaskAndTerminal(terminal, terminalData);
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

Behavioral change: The removed onDisposed listeners from _doCreateTerminal used to fire TaskEvent.terminated when terminals were disposed. Now with the new implementation calling _deleteTaskAndTerminal, this event is no longer fired when terminals are disposed by users (outside of explicit task termination). Consider adding this._fireTaskEvent(TaskEvent.terminated(...)) to the onDisposed handler or within _deleteTaskAndTerminal to maintain the original behavior.

Suggested change
this._deleteTaskAndTerminal(terminal, terminalData);
this._deleteTaskAndTerminal(terminal, terminalData);
this._fireTaskEvent(TaskEvent.terminated());

Copilot uses AI. Check for mistakes.
const onDisposedListener = this._register(terminal.onDisposed(() => {
const terminalDisposables = new DisposableStore();
const terminalData = { terminal: terminal, lastTask: taskKey, group, shellIntegrationNonce: terminal.shellLaunchConfig.shellIntegrationNonce, disposables: terminalDisposables };
terminalDisposables.add(terminal.onDisposed(() => {
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

Behavioral change: The removed onDisposed listeners from _doCreateTerminal used to fire TaskEvent.terminated when terminals were disposed. Now with the new implementation calling _deleteTaskAndTerminal, this event is no longer fired when terminals are disposed by users (outside of explicit task termination). Consider adding this._fireTaskEvent(TaskEvent.terminated(...)) to the onDisposed handler or within _deleteTaskAndTerminal to maintain the original behavior.

Suggested change
terminalDisposables.add(terminal.onDisposed(() => {
terminalDisposables.add(terminal.onDisposed(() => {
const event: ITaskEvent = {
kind: TaskEventKind.Terminated,
task
};
this._fireTaskEvent(event);

Copilot uses AI. Check for mistakes.
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.

Tasks leaks terminals

2 participants