memory-leak-audit について
VS Code のコードにおけるメモリリークの発見と予防を支援するレビュー用スキルです。ライフサイクル、イベントリスナー、Disposable、プール、テストの観点から反復処理と後片付けを確認します。
主な機能
- 01DOM イベントリスナーの登録を監査
- 02ライフサイクルイベントに一度限りのハンドラーを使用
- 03MutableDisposable で反復購読を管理
- 04DisposableStore をモデルのライフサイクルに連動
- 05プール内の各項目に後片付けを設定
- 06テストで Disposable のリークを確認
活用シーン
- 01イベントまたは DOM ハンドラーを登録するコードのレビュー
- 02時間とともに増えるリスナー数の調査
- 03繰り返し呼ばれるメソッドの後片付けを確認
- 04テストスイートで Disposable の後片付けを検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ microsoft/ vscode --skill memory-leak-auditインストール後、Claude Code で/memory-leak-auditを入力して読み込まれたか確認します。
詳細
メモリリーク監査
VS Code で最も多いバグのカテゴリです。このスキルでは、リークを防止および修正するパターンをまとめています。
使用するタイミング
- イベントリスナーや DOM ハンドラーを登録するコードをレビューするとき
- 報告されたメモリリークを修正するとき(リスナー数が時間とともに増加する場合)
- 繰り返し呼び出されるメソッド内でオブジェクトを作成するとき
- モデルのライフサイクルイベント(onWillDispose、onDidClose)を扱うとき
- コンストラクターやセットアップメソッドにイベントのサブスクリプションを追加するとき
監査チェックリスト
各チェックを順番に確認してください。パターンを1つ見落とすだけで、何千ものオブジェクトがリークする可能性があります。
ステップ 1:DOM イベントリスナー
ルール:.onload、.onclick、addEventListener() を直接使用しないでください。必ず addDisposableListener() を使用してください。
// BAD — leaks a listener every call
this.iconElement.onload = () => { ... };
// GOOD — tracked and disposable
this._register(addDisposableListener(this.iconElement, 'load', () => { ... }));
検証事例:PR #280566 — 拡張機能アイコンウィジェットで、37 回の切り替え後に 185 個のリスナーがリークしました。
ステップ 2:一度だけ発生するイベント
ルール:一度だけ発生させるイベント(ライフサイクルイベント、クローズイベント、初回変更イベント)には Event.once() を使用してください。
// BAD — listener stays registered forever after first fire
model.onDidDispose(() => store.dispose());
// GOOD — auto-removes after first invocation
Event.once(model.onDidDispose)(() => store.dispose());
検証事例:PR #285657、#285661 — ターミナルのライフサイクルに関するハックを Event.once() に置き換えました。
ステップ 3:繰り返し呼び出されるメソッド
ルール:複数回呼び出されるメソッド内で作成したオブジェクトを、クラスの this._register() に登録してはいけません。MutableDisposable を使用するか、呼び出し元に IDisposable を返してください。
// 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(() => { ... });
}
メソッドの呼び出しごとにイベントを一度だけ発生させる場合は、Event.once() と MutableDisposable を組み合わせてください。これにより、繰り返し呼び出されても安全を保ちながら、初回の呼び出し後にリスナーが自動的に削除されます。
private readonly _searchListener = this._register(new MutableDisposable());
startSearch() {
this._searchListener.value = Event.once(this.model.onResults)(() => { ... });
}
検証事例:PR #283466 — ターミナルの検索ウィジェットで、検索ごとにリスナーが 1 つずつリークしました。
ステップ 4:モデルにひも付いた DisposableStore
ルール:モデルのライフタイムにひも付いた DisposableStore を作成するときは、model.onWillDispose(() => store.dispose()) をそのストア自体に登録してください。
const store = new DisposableStore();
store.add(model.onWillDispose(() => store.dispose()));
store.add(model.onDidChange(() => { ... }));
検証事例:chatEditingSession.ts、fileBasedRecommendations.ts、testingContentProvider.ts で使用されているパターンです。
ステップ 5:リソースプールのパターン
ルール:プールされたオブジェクト(リスト、ツリー)を作成するファクトリーメソッドを使用する場合、破棄可能なオブジェクトはプールクラスではなく個々のアイテムに登録する必要があります。
// 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() };
}
検証事例:PR #290505 — チャットコンテンツのパーツで、CollapsibleListPool と TreePool の破棄可能なオブジェクトがリークしました。
ステップ 6:テストでの検証
ルール:破棄可能なオブジェクトを作成するすべてのテストスイートで、ensureNoDisposablesAreLeakedInTestSuite() を呼び出してください。
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
suite('MyFeature', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('does something', () => {
// test disposables are tracked automatically
});
});
クイックリファレンス
| シナリオ | パターン | アンチパターン |
|---|---|---|
| DOM イベント | addDisposableListener() | .onclick =、addEventListener() |
| 一度だけ発生するイベント | Event.once(event)(handler) | ライフサイクルイベントでの event(handler) |
| 繰り返し呼び出されるメソッド | MutableDisposable または IDisposable を返す | コンストラクター以外での this._register() |
| モデルのライフサイクル | store.add(model.onWillDispose(...)) | クリーンアップの漏れ |
| プールされたオブジェクト | アイテム単位の DisposableStore | プール単位の this._register() |
| テスト | ensureNoDisposablesAreLeakedInTestSuite() | リークチェックなし |
検証
リークを修正した後、次の手順で検証してください。
- 操作を繰り返す前後でリスナー数を確認する
- テストで
ensureNoDisposablesAreLeakedInTestSuite()を実行する - オブジェクト数が安定することを確認する(使用量に比例して増加しないこと)
- チャット固有のリークの場合:
npm run perf:chat-leakを使ってチャットのメモリリークチェッカーを実行します(chat-perfスキルを参照)。単一のセッションで N 件のメッセージを送信し、各メッセージの間に GC を強制実行して、ヒープおよび DOM のサンプルに対する線形回帰を用いてメッセージごとの増加を検出します。傾きが 2 MB/msg を超える場合はリークを示しています。より正確な結果を得るには、--messages 20 --verboseを使用してください。