debt - allow to track disposables in running app (#192871)

* debt - allow to track disposables in running app

* add snapshot support

* add precondition

* update

* input
This commit is contained in:
Benjamin Pasero
2023-09-12 18:49:06 +02:00
committed by GitHub
parent 206341dd9e
commit f766dc2c35
5 changed files with 271 additions and 160 deletions
+153
View File
@@ -3,6 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { compareBy, numberComparator } from 'vs/base/common/arrays';
import { SetMap, groupBy } from 'vs/base/common/collections';
import { once } from 'vs/base/common/functional';
import { Iterable } from 'vs/base/common/iterator';
@@ -41,6 +43,157 @@ export interface IDisposableTracker {
markAsSingleton(disposable: IDisposable): void;
}
export interface DisposableInfo {
value: IDisposable;
source: string | null;
parent: IDisposable | null;
isSingleton: boolean;
idx: number;
}
export class DisposableTracker implements IDisposableTracker {
private static idx = 0;
private readonly livingDisposables = new Map<IDisposable, DisposableInfo>();
private getDisposableData(d: IDisposable): DisposableInfo {
let val = this.livingDisposables.get(d);
if (!val) {
val = { parent: null, source: null, isSingleton: false, value: d, idx: DisposableTracker.idx++ };
this.livingDisposables.set(d, val);
}
return val;
}
trackDisposable(d: IDisposable): void {
const data = this.getDisposableData(d);
if (!data.source) {
data.source =
new Error().stack!;
}
}
setParent(child: IDisposable, parent: IDisposable | null): void {
const data = this.getDisposableData(child);
data.parent = parent;
}
markAsDisposed(x: IDisposable): void {
this.livingDisposables.delete(x);
}
markAsSingleton(disposable: IDisposable): void {
this.getDisposableData(disposable).isSingleton = true;
}
private getRootParent(data: DisposableInfo, cache: Map<DisposableInfo, DisposableInfo>): DisposableInfo {
const cacheValue = cache.get(data);
if (cacheValue) {
return cacheValue;
}
const result = data.parent ? this.getRootParent(this.getDisposableData(data.parent), cache) : data;
cache.set(data, result);
return result;
}
getTrackedDisposables(): IDisposable[] {
const rootParentCache = new Map<DisposableInfo, DisposableInfo>();
const leaking = [...this.livingDisposables.entries()]
.filter(([, v]) => v.source !== null && !this.getRootParent(v, rootParentCache).isSingleton)
.map(([k]) => k)
.flat();
return leaking;
}
computeLeakingDisposables(maxReported = 10, preComputedLeaks?: DisposableInfo[]): { leaks: DisposableInfo[]; details: string } | undefined {
let uncoveredLeakingObjs: DisposableInfo[] | undefined;
if (preComputedLeaks) {
uncoveredLeakingObjs = preComputedLeaks;
} else {
const rootParentCache = new Map<DisposableInfo, DisposableInfo>();
const leakingObjects = [...this.livingDisposables.values()]
.filter((info) => info.source !== null && !this.getRootParent(info, rootParentCache).isSingleton);
if (leakingObjects.length === 0) {
return;
}
const leakingObjsSet = new Set(leakingObjects.map(o => o.value));
// Remove all objects that are a child of other leaking objects. Assumes there are no cycles.
uncoveredLeakingObjs = leakingObjects.filter(l => {
return !(l.parent && leakingObjsSet.has(l.parent));
});
if (uncoveredLeakingObjs.length === 0) {
throw new Error('There are cyclic diposable chains!');
}
}
if (!uncoveredLeakingObjs) {
return undefined;
}
function getStackTracePath(leaking: DisposableInfo): string[] {
function removePrefix(array: string[], linesToRemove: (string | RegExp)[]) {
while (array.length > 0 && linesToRemove.some(regexp => typeof regexp === 'string' ? regexp === array[0] : array[0].match(regexp))) {
array.shift();
}
}
const lines = leaking.source!.split('\n').map(p => p.trim().replace('at ', '')).filter(l => l !== '');
removePrefix(lines, ['Error', /^trackDisposable \(.*\)$/, /^DisposableTracker.trackDisposable \(.*\)$/]);
return lines.reverse();
}
const stackTraceStarts = new SetMap<string, DisposableInfo>();
for (const leaking of uncoveredLeakingObjs) {
const stackTracePath = getStackTracePath(leaking);
for (let i = 0; i <= stackTracePath.length; i++) {
stackTraceStarts.add(stackTracePath.slice(0, i).join('\n'), leaking);
}
}
// Put earlier leaks first
uncoveredLeakingObjs.sort(compareBy(l => l.idx, numberComparator));
let message = '';
let i = 0;
for (const leaking of uncoveredLeakingObjs.slice(0, maxReported)) {
i++;
const stackTracePath = getStackTracePath(leaking);
const stackTraceFormattedLines = [];
for (let i = 0; i < stackTracePath.length; i++) {
let line = stackTracePath[i];
const starts = stackTraceStarts.get(stackTracePath.slice(0, i + 1).join('\n'));
line = `(shared with ${starts.size}/${uncoveredLeakingObjs.length} leaks) at ${line}`;
const prevStarts = stackTraceStarts.get(stackTracePath.slice(0, i).join('\n'));
const continuations = groupBy([...prevStarts].map(d => getStackTracePath(d)[i]), v => v);
delete continuations[stackTracePath[i]];
for (const [cont, set] of Object.entries(continuations)) {
stackTraceFormattedLines.unshift(` - stacktraces of ${set.length} other leaks continue with ${cont}`);
}
stackTraceFormattedLines.unshift(line);
}
message += `\n\n\n==================== Leaking disposable ${i}/${uncoveredLeakingObjs.length}: ${leaking.value.constructor.name} ====================\n${stackTraceFormattedLines.join('\n')}\n============================================================\n\n`;
}
if (uncoveredLeakingObjs.length > maxReported) {
message += `\n\n\n... and ${uncoveredLeakingObjs.length - maxReported} more leaking disposables\n\n`;
}
return { leaks: uncoveredLeakingObjs, details: message };
}
}
export function setDisposableTracker(tracker: IDisposableTracker | null): void {
disposableTracker = tracker;
}
+2 -2
View File
@@ -8,11 +8,11 @@ import { DeferredPromise, timeout } from 'vs/base/common/async';
import { CancellationToken } from 'vs/base/common/cancellation';
import { errorHandler, setUnexpectedErrorHandler } from 'vs/base/common/errors';
import { AsyncEmitter, DebounceEmitter, DynamicListEventMultiplexer, Emitter, Event, EventBufferer, EventMultiplexer, IWaitUntil, MicrotaskEmitter, PauseableEmitter, Relay, createEventDeliveryQueue } from 'vs/base/common/event';
import { DisposableStore, IDisposable, isDisposable, setDisposableTracker, toDisposable } from 'vs/base/common/lifecycle';
import { DisposableStore, IDisposable, isDisposable, setDisposableTracker, toDisposable, DisposableTracker } from 'vs/base/common/lifecycle';
import { observableValue, transaction } from 'vs/base/common/observable';
import { MicrotaskDelay } from 'vs/base/common/symbols';
import { runWithFakedTimers } from 'vs/base/test/common/timeTravelScheduler';
import { DisposableTracker, ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
namespace Samples {
+18 -155
View File
@@ -3,12 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { compareBy, numberComparator } from 'vs/base/common/arrays';
import { SetMap, groupBy } from 'vs/base/common/collections';
import { DisposableStore, IDisposable, IDisposableTracker, setDisposableTracker } from 'vs/base/common/lifecycle';
import { DisposableStore, DisposableTracker, IDisposable, setDisposableTracker } from 'vs/base/common/lifecycle';
import { join } from 'vs/base/common/path';
import { isWindows } from 'vs/base/common/platform';
import { trim } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
export type ValueCallback<T = any> = (value: T | Promise<T>) => void;
@@ -44,154 +41,6 @@ export async function assertThrowsAsync(block: () => any, message: string | Erro
throw err;
}
interface DisposableInfo {
value: IDisposable;
source: string | null;
parent: IDisposable | null;
isSingleton: boolean;
idx: number;
}
export class DisposableTracker implements IDisposableTracker {
private static idx = 0;
private readonly livingDisposables = new Map<IDisposable, DisposableInfo>();
private getDisposableData(d: IDisposable) {
let val = this.livingDisposables.get(d);
if (!val) {
val = { parent: null, source: null, isSingleton: false, value: d, idx: DisposableTracker.idx++ };
this.livingDisposables.set(d, val);
}
return val;
}
trackDisposable(d: IDisposable): void {
const data = this.getDisposableData(d);
if (!data.source) {
data.source =
new Error().stack!;
}
}
setParent(child: IDisposable, parent: IDisposable | null): void {
const data = this.getDisposableData(child);
data.parent = parent;
}
markAsDisposed(x: IDisposable): void {
this.livingDisposables.delete(x);
}
markAsSingleton(disposable: IDisposable): void {
this.getDisposableData(disposable).isSingleton = true;
}
private getRootParent(data: DisposableInfo, cache: Map<DisposableInfo, DisposableInfo>): DisposableInfo {
const cacheValue = cache.get(data);
if (cacheValue) {
return cacheValue;
}
const result = data.parent ? this.getRootParent(this.getDisposableData(data.parent), cache) : data;
cache.set(data, result);
return result;
}
getTrackedDisposables() {
const rootParentCache = new Map<DisposableInfo, DisposableInfo>();
const leaking = [...this.livingDisposables.entries()]
.filter(([, v]) => v.source !== null && !this.getRootParent(v, rootParentCache).isSingleton)
.map(([k]) => k)
.flat();
return leaking;
}
ensureNoLeakingDisposables(logToConsole = true) {
const rootParentCache = new Map<DisposableInfo, DisposableInfo>();
const leakingObjects = [...this.livingDisposables.values()]
.filter((info) => info.source !== null && !this.getRootParent(info, rootParentCache).isSingleton);
if (leakingObjects.length === 0) {
return;
}
const leakingObjsSet = new Set(leakingObjects.map(o => o.value));
// Remove all objects that are a child of other leaking objects. Assumes there are no cycles.
const uncoveredLeakingObjs = leakingObjects.filter(l => {
return !(l.parent && leakingObjsSet.has(l.parent));
});
if (uncoveredLeakingObjs.length === 0) {
throw new Error('There are cyclic diposable chains!');
}
function getStackTracePath(leaking: DisposableInfo): string[] {
function removePrefix(array: string[], linesToRemove: (string | RegExp)[]) {
while (array.length > 0 && linesToRemove.some(regexp => typeof regexp === 'string' ? regexp === array[0] : array[0].match(regexp))) {
array.shift();
}
}
const lines = leaking.source!.split('\n').map(p => trim(p.trim(), 'at ')).filter(l => l !== '');
removePrefix(lines, ['Error', /^trackDisposable \(.*\)$/, /^DisposableTracker.trackDisposable \(.*\)$/]);
return lines.reverse();
}
const stackTraceStarts = new SetMap<string, DisposableInfo>();
for (const leaking of uncoveredLeakingObjs) {
const stackTracePath = getStackTracePath(leaking);
for (let i = 0; i <= stackTracePath.length; i++) {
stackTraceStarts.add(stackTracePath.slice(0, i).join('\n'), leaking);
}
}
// Put earlier leaks first
uncoveredLeakingObjs.sort(compareBy(l => l.idx, numberComparator));
const maxReported = 10;
let message = '';
let i = 0;
for (const leaking of uncoveredLeakingObjs.slice(0, maxReported)) {
i++;
const stackTracePath = getStackTracePath(leaking);
const stackTraceFormattedLines = [];
for (let i = 0; i < stackTracePath.length; i++) {
let line = stackTracePath[i];
const starts = stackTraceStarts.get(stackTracePath.slice(0, i + 1).join('\n'));
line = `(shared with ${starts.size}/${uncoveredLeakingObjs.length} leaks) at ${line}`;
const prevStarts = stackTraceStarts.get(stackTracePath.slice(0, i).join('\n'));
const continuations = groupBy([...prevStarts].map(d => getStackTracePath(d)[i]), v => v);
delete continuations[stackTracePath[i]];
for (const [cont, set] of Object.entries(continuations)) {
stackTraceFormattedLines.unshift(` - stacktraces of ${set.length} other leaks continue with ${cont}`);
}
stackTraceFormattedLines.unshift(line);
}
message += `\n\n\n==================== Leaking disposable ${i}/${uncoveredLeakingObjs.length}: ${leaking.value.constructor.name} ====================\n${stackTraceFormattedLines.join('\n')}\n============================================================\n\n`;
}
if (uncoveredLeakingObjs.length > maxReported) {
message += `\n\n\n... and ${uncoveredLeakingObjs.length - maxReported} more leaking disposables\n\n`;
}
if (logToConsole) {
console.error(message);
}
throw new Error(`There are ${uncoveredLeakingObjs.length} undisposed disposables!${message}`);
}
}
/**
* Use this function to ensure that all disposables are cleaned up at the end of each test in the current suite.
*
@@ -214,7 +63,11 @@ export function ensureNoDisposablesAreLeakedInTestSuite(): Pick<DisposableStore,
store.dispose();
setDisposableTracker(null);
if (this.currentTest?.state !== 'failed') {
tracker!.ensureNoLeakingDisposables();
const result = tracker!.computeLeakingDisposables();
if (result) {
console.error(result.details);
throw new Error(`There are ${result.leaks.length} undisposed disposables!${result.details}`);
}
}
});
@@ -232,7 +85,7 @@ export function throwIfDisposablesAreLeaked(body: () => void, logToConsole = tru
setDisposableTracker(tracker);
body();
setDisposableTracker(null);
tracker.ensureNoLeakingDisposables(logToConsole);
computeLeakingDisposables(tracker, logToConsole);
}
export async function throwIfDisposablesAreLeakedAsync(body: () => Promise<void>): Promise<void> {
@@ -240,5 +93,15 @@ export async function throwIfDisposablesAreLeakedAsync(body: () => Promise<void>
setDisposableTracker(tracker);
await body();
setDisposableTracker(null);
tracker.ensureNoLeakingDisposables();
computeLeakingDisposables(tracker);
}
function computeLeakingDisposables(tracker: DisposableTracker, logToConsole = true) {
const result = tracker.computeLeakingDisposables();
if (result) {
if (logToConsole) {
console.error(result.details);
}
throw new Error(`There are ${result.leaks.length} undisposed disposables!${result.details}`);
}
}
@@ -10,10 +10,10 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { DomEmitter } from 'vs/base/browser/event';
import { Color } from 'vs/base/common/color';
import { Event } from 'vs/base/common/event';
import { IDisposable, toDisposable, dispose, DisposableStore } from 'vs/base/common/lifecycle';
import { IDisposable, toDisposable, dispose, DisposableStore, setDisposableTracker, DisposableTracker, DisposableInfo } from 'vs/base/common/lifecycle';
import { getDomNodePagePosition, createStyleSheet, createCSSRule, append, $ } from 'vs/base/browser/dom';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { Context } from 'vs/platform/contextkey/browser/contextKeyService';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { RunOnceScheduler } from 'vs/base/common/async';
@@ -36,6 +36,8 @@ import { windowLogId } from 'vs/workbench/services/log/common/logConstants';
import { ByteSize } from 'vs/platform/files/common/files';
import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput';
import { IUserDataProfileService } from 'vs/workbench/services/userDataProfile/common/userDataProfile';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import product from 'vs/platform/product/common/product';
class InspectContextKeysAction extends Action2 {
@@ -506,12 +508,104 @@ class RemoveLargeStorageEntriesAction extends Action2 {
}
}
let tracker: DisposableTracker | undefined = undefined;
let trackedDisposables = new Set<IDisposable>();
const DisposablesSnapshotStateContext = new RawContextKey<'started' | 'pending' | 'stopped'>('dirtyWorkingCopies', 'stopped');
class StartTrackDisposables extends Action2 {
constructor() {
super({
id: 'workbench.action.startTrackDisposables',
title: { value: localize('startTrackDisposables', "Start Tracking Disposables"), original: 'Start Tracking Disposables' },
category: Categories.Developer,
f1: true,
precondition: ContextKeyExpr.and(DisposablesSnapshotStateContext.isEqualTo('pending').negate(), DisposablesSnapshotStateContext.isEqualTo('started').negate())
});
}
run(accessor: ServicesAccessor): void {
const disposablesSnapshotStateContext = DisposablesSnapshotStateContext.bindTo(accessor.get(IContextKeyService));
disposablesSnapshotStateContext.set('started');
trackedDisposables.clear();
tracker = new DisposableTracker();
setDisposableTracker(tracker);
}
}
class SnapshotTrackedDisposables extends Action2 {
constructor() {
super({
id: 'workbench.action.snapshotTrackedDisposables',
title: { value: localize('snapshotTrackedDisposables', "Snapshot Tracked Disposables"), original: 'Snapshot Tracked Disposables' },
category: Categories.Developer,
f1: true,
precondition: DisposablesSnapshotStateContext.isEqualTo('started')
});
}
run(accessor: ServicesAccessor): void {
const disposablesSnapshotStateContext = DisposablesSnapshotStateContext.bindTo(accessor.get(IContextKeyService));
disposablesSnapshotStateContext.set('pending');
trackedDisposables = new Set(tracker?.computeLeakingDisposables(1000)?.leaks.map(disposable => disposable.value));
}
}
class StopTrackDisposables extends Action2 {
constructor() {
super({
id: 'workbench.action.stopTrackDisposables',
title: { value: localize('stopTrackDisposables', "Stop Tracking Disposables"), original: 'Stop Tracking Disposables' },
category: Categories.Developer,
f1: true,
precondition: DisposablesSnapshotStateContext.isEqualTo('pending')
});
}
run(accessor: ServicesAccessor): void {
const editorService = accessor.get(IEditorService);
const disposablesSnapshotStateContext = DisposablesSnapshotStateContext.bindTo(accessor.get(IContextKeyService));
disposablesSnapshotStateContext.set('stopped');
if (tracker) {
const disposableLeaks = new Set<DisposableInfo>();
for (const disposable of new Set(tracker.computeLeakingDisposables(1000)?.leaks) ?? []) {
if (trackedDisposables.has(disposable.value)) {
disposableLeaks.add(disposable);
}
}
const leaks = tracker.computeLeakingDisposables(1000, Array.from(disposableLeaks));
if (leaks) {
editorService.openEditor({ resource: undefined, contents: leaks.details });
}
}
setDisposableTracker(null);
tracker = undefined;
trackedDisposables.clear();
}
}
// --- Actions Registration
registerAction2(InspectContextKeysAction);
registerAction2(ToggleScreencastModeAction);
registerAction2(LogStorageAction);
registerAction2(LogWorkingCopiesAction);
registerAction2(RemoveLargeStorageEntriesAction);
if (!product.commit) {
registerAction2(StartTrackDisposables);
registerAction2(SnapshotTrackedDisposables);
registerAction2(StopTrackDisposables);
}
// --- Configuration
@@ -10,6 +10,7 @@ import { localize } from 'vs/nls';
import { IDisposable } from 'vs/base/common/lifecycle';
import { DeferredPromise, timeout } from 'vs/base/common/async';
import { ILogService } from 'vs/platform/log/common/log';
import { CancellationToken } from 'vs/base/common/cancellation';
export class ExtensionActivationProgress implements IWorkbenchContribution {
@@ -39,7 +40,7 @@ export class ExtensionActivationProgress implements IWorkbenchContribution {
count++;
Promise.race([e.activation, timeout(5000)]).finally(() => {
Promise.race([e.activation, timeout(5000, CancellationToken.None)]).finally(() => {
if (--count === 0) {
deferred!.complete(undefined);
deferred = undefined;