diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index e468ba9b780..7cefcdcedbd 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -835,6 +835,7 @@ class LeakageMonitor { private _warnCountdown: number = 0; constructor( + private readonly _errorHandler: (err: Error) => void, readonly threshold: number, readonly name: string = Math.random().toString(18).slice(2, 5), ) { } @@ -862,18 +863,13 @@ class LeakageMonitor { // is exceeded by 50% again this._warnCountdown = threshold * 0.5; - // find most frequent listener and print warning - let topStack: string | undefined; - let topCount: number = 0; - for (const [stack, count] of this._stacks) { - if (!topStack || topCount < count) { - topStack = stack; - topCount = count; - } - } - - console.warn(`[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`); + const [topStack, topCount] = this.getMostFrequentStack()!; + const message = `[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`; + console.warn(message); console.warn(topStack!); + + const error = new ListenerLeakError(message, topStack); + this._errorHandler(error); } return () => { @@ -881,12 +877,28 @@ class LeakageMonitor { this._stacks!.set(stack.value, count - 1); }; } + + getMostFrequentStack(): [string, number] | undefined { + if (!this._stacks) { + return undefined; + } + let topStack: [string, number] | undefined; + let topCount: number = 0; + for (const [stack, count] of this._stacks) { + if (!topStack || topCount < count) { + topStack = [stack, count]; + topCount = count; + } + } + return topStack; + } } class Stacktrace { static create() { - return new Stacktrace(new Error().stack ?? ''); + const err = new Error(); + return new Stacktrace(err.stack ?? ''); } private constructor(readonly value: string) { } @@ -896,6 +908,25 @@ class Stacktrace { } } +// error that is logged when going over the configured listener threshold +export class ListenerLeakError extends Error { + constructor(message: string, stack: string) { + super(message); + this.name = 'ListenerLeakError'; + this.stack = stack; + } +} + +// SEVERE error that is logged when having gone way over the configured listener +// threshold so that the emitter refuses to accept more listeners +export class ListenerRefusalError extends Error { + constructor(message: string, stack: string) { + super(message); + this.name = 'ListenerRefusalError'; + this.stack = stack; + } +} + let id = 0; class UniqueContainer { stack?: Stacktrace; @@ -988,7 +1019,9 @@ export class Emitter { constructor(options?: EmitterOptions) { this._options = options; - this._leakageMon = _globalLeakWarningThreshold > 0 || this._options?.leakWarningThreshold ? new LeakageMonitor(this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold) : undefined; + this._leakageMon = (_globalLeakWarningThreshold > 0 || this._options?.leakWarningThreshold) + ? new LeakageMonitor(options?.onListenerError ?? onUnexpectedError, this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold) : + undefined; this._perfMon = this._options?._profName ? new EventProfiling(this._options._profName) : undefined; this._deliveryQueue = this._options?.deliveryQueue as EventDeliveryQueuePrivate | undefined; } @@ -1033,7 +1066,14 @@ export class Emitter { get event(): Event { this._event ??= (callback: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => { if (this._leakageMon && this._size > this._leakageMon.threshold * 3) { - console.warn(`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far`); + const message = `[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size}/${this._leakageMon.threshold})`; + console.warn(message); + + const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; + const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); + const errorHandler = this._options?.onListenerError || onUnexpectedError; + errorHandler(error); + return Disposable.None; } diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index 49962c89d5a..7c33d1f41af 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; import { stub } from 'sinon'; +import { tail2 } from 'vs/base/common/arrays'; 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 { AsyncEmitter, DebounceEmitter, DynamicListEventMultiplexer, Emitter, Event, EventBufferer, EventMultiplexer, IWaitUntil, ListenerLeakError, ListenerRefusalError, MicrotaskEmitter, PauseableEmitter, Relay, createEventDeliveryQueue } from 'vs/base/common/event'; import { DisposableStore, IDisposable, isDisposable, setDisposableTracker, DisposableTracker } from 'vs/base/common/lifecycle'; import { observableValue, transaction } from 'vs/base/common/observable'; import { MicrotaskDelay } from 'vs/base/common/symbols'; @@ -368,6 +369,31 @@ suite('Event', function () { }); + test('throw ListenerLeakError', () => { + + const store = new DisposableStore(); + const allError: any[] = []; + + const a = ds.add(new Emitter({ + onListenerError(e) { allError.push(e); }, + leakWarningThreshold: 1, + })); + + for (let i = 0; i < 5; i++) { + a.event(() => { }, undefined, store); + } + + assert.deepStrictEqual(allError.length, 4); + const [start, tail] = tail2(allError); + assert.ok(tail instanceof ListenerRefusalError); + + for (const item of start) { + assert.ok(item instanceof ListenerLeakError); + } + + store.dispose(); + }); + test('reusing event function and context', function () { let counter = 0; function listener() { diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index 26e45f9ff9d..c9f4dfa1e6a 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -199,7 +199,8 @@ async function loadTests(opts) { 'issue #149130: vscode freezes because of Bracket Pair Colorization', // https://github.com/microsoft/vscode/issues/192440 'property limits', // https://github.com/microsoft/vscode/issues/192443 'Error events', // https://github.com/microsoft/vscode/issues/192443 - 'fetch returns keybinding with user first if title and id matches' // + 'fetch returns keybinding with user first if title and id matches', // + 'throw ListenerLeakError' ]); let _testsWithUnexpectedOutput = false;