From f766dc2c35cbdfd3a846a72a5a3ebe07d35c0b59 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 12 Sep 2023 18:49:06 +0200 Subject: [PATCH] debt - allow to track disposables in running app (#192871) * debt - allow to track disposables in running app * add snapshot support * add precondition * update * input --- src/vs/base/common/lifecycle.ts | 153 ++++++++++++++++ src/vs/base/test/common/event.test.ts | 4 +- src/vs/base/test/common/utils.ts | 173 ++---------------- .../browser/actions/developerActions.ts | 98 +++++++++- .../browser/extensionsActivationProgress.ts | 3 +- 5 files changed, 271 insertions(+), 160 deletions(-) diff --git a/src/vs/base/common/lifecycle.ts b/src/vs/base/common/lifecycle.ts index 3d5dd09598dca..0afa0bbd79bc9 100644 --- a/src/vs/base/common/lifecycle.ts +++ b/src/vs/base/common/lifecycle.ts @@ -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(); + + 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 { + 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(); + + 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(); + + 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(); + 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; } diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index 54b1e1f48c938..2231acac008a6 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -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 { diff --git a/src/vs/base/test/common/utils.ts b/src/vs/base/test/common/utils.ts index 9c38a6a06df93..36c6873073f18 100644 --- a/src/vs/base/test/common/utils.ts +++ b/src/vs/base/test/common/utils.ts @@ -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 = (value: T | Promise) => 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(); - - 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 { - 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(); - - 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(); - - 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(); - 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 void, logToConsole = tru setDisposableTracker(tracker); body(); setDisposableTracker(null); - tracker.ensureNoLeakingDisposables(logToConsole); + computeLeakingDisposables(tracker, logToConsole); } export async function throwIfDisposablesAreLeakedAsync(body: () => Promise): Promise { @@ -240,5 +93,15 @@ export async function throwIfDisposablesAreLeakedAsync(body: () => Promise 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}`); + } } diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index e226162d5f54e..a070305634e28 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -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(); + +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(); + + 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 diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActivationProgress.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActivationProgress.ts index 6370029fda8be..5e7c228e17ed4 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActivationProgress.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActivationProgress.ts @@ -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;