Skip to content

Commit

Permalink
debt - allow to track disposables in running app (#192871)
Browse files Browse the repository at this point in the history
* debt - allow to track disposables in running app

* add snapshot support

* add precondition

* update

* input
  • Loading branch information
bpasero committed Sep 12, 2023
1 parent 206341d commit f766dc2
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 160 deletions.
153 changes: 153 additions & 0 deletions src/vs/base/common/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/vs/base/test/common/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
173 changes: 18 additions & 155 deletions src/vs/base/test/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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}`);
}
}
});

Expand All @@ -232,13 +85,23 @@ 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> {
const tracker = new DisposableTracker();
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}`);
}
}
Loading

0 comments on commit f766dc2

Please sign in to comment.