add ListenerRefusalError and ListenerLeakError which get logged when listener thresholds are exceeded. (#212900)

* add `ListenerRefusalError` and `ListenerLeakError` which get logged when listener thresholds are exceeded.

The `stack` property of these errors will point towards the most frequent listener and how often it is used. If that's a high number there is a leak (same listener is added over and over again), if that's a low number there might be a conceptual flaw that an emitter is simply too prominent.

* rightfully don't use Error.captureStackTrace (v8/nodejs only)
This commit is contained in:
Johannes Rieken 2024-05-16 17:29:17 +02:00 committed by GitHub
parent e65febca09
commit 2d97803568
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 16 deletions

View file

@ -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<T> {
stack?: Stacktrace;
@ -988,7 +1019,9 @@ export class Emitter<T> {
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<T> {
get event(): Event<T> {
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;
}

View file

@ -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<undefined>({
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() {

View file

@ -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;