PluginBench
Skill
Official
Review
Audit score 70

memory-leak-audit

microsoft/vscode

Audit and fix memory leaks in event listeners, DOM handlers, and disposable patterns.

What is memory-leak-audit?

Encodes VS Code's patterns for preventing the #1 bug category: memory leaks from untracked event listeners and disposables. Use when reviewing event subscriptions, DOM handlers, lifecycle callbacks, or fixing reported leak growth.

  • Identify DOM event listeners that should use addDisposableListener() instead of raw addEventListener()
  • Detect one-time events that should use Event.once() to auto-remove after first invocation
  • Spot repeated method calls that leak listeners via improper this._register() usage
  • Validate model lifecycle cleanup with DisposableStore and onWillDispose patterns
  • Check pooled object factories for item-scoped vs pool-scoped disposal mistakes
  • Verify test suites with ensureNoDisposablesAreLeakedInTestSuite()

How to install memory-leak-audit

npx skills add https://github.com/microsoft/vscode --skill memory-leak-audit
Claude Code
Cursor
Windsurf
Cline

How to use memory-leak-audit

  1. 1.Review DOM event listeners and replace raw addEventListener() or .onclick with addDisposableListener()
  2. 2.Check one-time events (lifecycle, close, first-change) and wrap with Event.once()
  3. 3.Audit methods called repeatedly and use MutableDisposable or return IDisposable instead of this._register()
  4. 4.Verify DisposableStore instances tied to models register model.onWillDispose() cleanup
  5. 5.Check pooled object factories wrap items with item-scoped DisposableStore, not pool-scoped
  6. 6.Add ensureNoDisposablesAreLeakedInTestSuite() to test suites and verify listener counts stabilize

Use cases

Good for
  • Reviewing code that registers event listeners or DOM handlers for leaks
  • Fixing reported memory leaks where listener counts grow over time
  • Creating objects in methods called repeatedly without proper disposal
  • Working with model lifecycle events like onWillDispose and onDidClose
  • Auditing chat features and pooled UI components for per-operation growth
Who it's for
  • Backend and frontend developers fixing memory leak reports
  • Code reviewers checking event subscription patterns
  • Feature developers working with lifecycle callbacks and disposables
  • Test authors validating that test suites don't leak disposables

memory-leak-audit FAQ

When should I use Event.once() vs MutableDisposable?

Use Event.once() for truly one-time events (lifecycle, close). Use MutableDisposable when a method is called repeatedly but should only have one active listener at a time. Combine both when a repeated method should fire only once per call.

Why can't I use this._register() in non-constructor methods?

this._register() adds to the class-level store, so every call accumulates more listeners. For repeated methods, use MutableDisposable to replace the listener, or return IDisposable to let the caller manage cleanup.

How do I verify a leak is fixed?

Check that listener counts and object counts stabilize after repeated operations (don't grow linearly). Run ensureNoDisposablesAreLeakedInTestSuite() in tests. For chat features, use npm run perf:chat-leak to detect per-message heap growth above 2 MB.

What's the difference between addDisposableListener() and addEventListener()?

addDisposableListener() returns an IDisposable that can be tracked and cleaned up. addEventListener() has no cleanup mechanism and will leak if not manually removed. Always use addDisposableListener() in VS Code.

When should pooled objects wrap with DisposableStore?

When a factory method creates items that are reused or destroyed individually, wrap each item with its own DisposableStore. Never register item listeners to the pool's this._register(), or they'll accumulate across all items.

Full instructions (SKILL.md)

Source of truth, from microsoft/vscode.


name: memory-leak-audit description: 'Audit code for memory leaks and disposable issues. Use when reviewing event listeners, DOM handlers, lifecycle callbacks, or fixing leak reports. Covers addDisposableListener, Event.once, MutableDisposable, DisposableStore, and onWillDispose patterns.'

Memory Leak Audit

The #1 bug category in VS Code. This skill encodes the patterns that prevent and fix leaks.

When to Use

  • Reviewing code that registers event listeners or DOM handlers
  • Fixing reported memory leaks (listener counts growing over time)
  • Creating objects in methods that are called repeatedly
  • Working with model lifecycle events (onWillDispose, onDidClose)
  • Adding event subscriptions in constructors or setup methods

Audit Checklist

Work through each check in order. A single missed pattern can cause thousands of leaked objects.

Step 1: DOM Event Listeners

Rule: Never use raw .onload, .onclick, or addEventListener() directly. Always use addDisposableListener().

// BAD — leaks a listener every call
this.iconElement.onload = () => { ... };

// GOOD — tracked and disposable
this._register(addDisposableListener(this.iconElement, 'load', () => { ... }));

Validated by: PR #280566 — Extension icon widget leaked 185 listeners after 37 toggles.

Step 2: One-Time Events

Rule: Use Event.once() for events that should only fire once (lifecycle events, close events, first-change events).

// BAD — listener stays registered forever after first fire
model.onDidDispose(() => store.dispose());

// GOOD — auto-removes after first invocation
Event.once(model.onDidDispose)(() => store.dispose());

Validated by: PRs #285657, #285661 — Terminal lifecycle hacks replaced with Event.once().

Step 3: Repeated Method Calls

Rule: Objects created in methods called multiple times must NOT be registered to the class this._register(). Use MutableDisposable or return IDisposable to the caller.

// BAD — every call adds another listener to the class store
startSearch() {
    this._register(this.model.onResults(() => { ... }));
}

// GOOD — MutableDisposable ensures max 1 listener
private readonly _searchListener = this._register(new MutableDisposable());

startSearch() {
    this._searchListener.value = this.model.onResults(() => { ... });
}

When the event should only fire once per method call, combine Event.once() with MutableDisposable — this auto-removes the listener after the first invocation while still guarding against repeated calls:

private readonly _searchListener = this._register(new MutableDisposable());

startSearch() {
    this._searchListener.value = Event.once(this.model.onResults)(() => { ... });
}

Validated by: PR #283466 — Terminal find widget leaked 1 listener per search.

Step 4: Model-Tied DisposableStores

Rule: When creating a DisposableStore tied to a model's lifetime, register model.onWillDispose(() => store.dispose()) to the store itself.

const store = new DisposableStore();
store.add(model.onWillDispose(() => store.dispose()));
store.add(model.onDidChange(() => { ... }));

Validated by: Pattern used in chatEditingSession.ts, fileBasedRecommendations.ts, testingContentProvider.ts.

Step 5: Resource Pool Patterns

Rule: When using factory methods that create pooled objects (lists, trees), disposables must be registered to the individual item, not the pool class.

// BAD — registers to pool, never cleaned per item
createItem() {
    const item = new Item();
    this._register(item.onEvent(() => { ... }));
    return item;
}

// GOOD — wrap with item-scoped disposal
createItem(): IDisposable & Item {
    const store = new DisposableStore();
    const item = new Item();
    store.add(item.onEvent(() => { ... }));
    return { ...item, dispose: () => store.dispose() };
}

Validated by: PR #290505 — Chat content parts CollapsibleListPool and TreePool leaked disposables.

Step 6: Test Validation

Rule: Every test suite that creates disposable objects must call ensureNoDisposablesAreLeakedInTestSuite().

import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';

suite('MyFeature', () => {
    ensureNoDisposablesAreLeakedInTestSuite();

    test('does something', () => {
        // test disposables are tracked automatically
    });
});

Quick Reference

ScenarioPatternAnti-Pattern
DOM eventsaddDisposableListener().onclick =, addEventListener()
One-time eventsEvent.once(event)(handler)event(handler) for lifecycle
Repeated methodsMutableDisposable or return IDisposablethis._register() in non-constructor
Model lifecyclestore.add(model.onWillDispose(...))Forgetting cleanup
Pooled objectsItem-scoped DisposableStorePool-scoped this._register()
TestsensureNoDisposablesAreLeakedInTestSuite()No leak checking

Verification

After fixing leaks, verify by:

  1. Checking listener counts before/after repeated operations
  2. Running ensureNoDisposablesAreLeakedInTestSuite() in tests
  3. Confirming object counts stabilize (don't grow linearly with usage)
  4. For chat-specific leaks: Run the chat memory leak checker via npm run perf:chat-leak (see the chat-perf skill). It sends N messages in a single session, forces GC between each, and uses linear regression on heap/DOM samples to detect per-message growth. A slope above 2 MB/msg indicates a leak. Use --messages 20 --verbose for more accurate results.