Skip to content

Commit d6643d7

Browse files
mshamis-metameta-codesync[bot]
authored andcommitted
Stop RateLimiter from registering a listener per queued task
Summary: ## Why Every task that `RateLimiter.enqueueRun` had to queue parked itself on a fresh `'run'` listener registered on the limiter's private `TypedEventEmitter`, and nothing ever removed it -- there was no `.off()` anywhere in `RateLimiter.ts`. The listener set therefore grew with every task the limiter had *ever* queued and only died with the limiter itself. Three instances leak: | Site | Concurrency | Scope | | --- | --- | --- | | `isl-server/src/Repository.ts` `configRateLimiter` | 1 | per `Repository` | | `isl-server/src/Repository.ts` `catLimiter` | 4 | per `Repository` | | `isl-server/src/facebook/phabricator/queryGraphql.ts` | 7 | module-level, process-wide singleton | The visible symptom on every isl-server spawn, on both macOS and Linux, in production: ``` MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 run listeners added to EventTarget. MaxListeners is 10. ``` It says `EventTarget` rather than `EventEmitter` because `TypedEventEmitter` wraps a plain `EventTarget`. The warning badly under-reports the problem. Node latches a warned-once flag per `(target, eventType)` pair, so it fires exactly once per process no matter how far past 11 the count climbs -- the log can never show the growth. And memory is not the only cost: every stale listener is still invoked on each subsequent `emit('run')`, so dequeuing one task is O(tasks ever queued) and a long session is O(n^2). Calling `off` in a `finally` would fix the leak but leave the warning reachable, because the listener set would still be one live `EventTarget` listener per *currently waiting* task. That is enough to re-emit the byte-identical warning on ordinary backpressure: `vscode/extension/facebook/comments/LineRealigner.ts` issues one `repo.cat` per unique commented `(hash, path)` inside a single `Promise.all`, into the per-`Repository` `catLimiter` whose concurrency is `MAX_SIMULTANEOUS_CAT_CALLS = 4`, so a diff with more than 14 distinct commented pairs trips it; the process-wide GraphQL limiter (concurrency 7) does the same at 18 concurrent queries. Such a warning would be a false positive on transient backpressure, indistinguishable in a log from the leak this diff exists to eliminate, and would cost the next engineer the same investigation over again. ## What `RateLimiter` no longer uses `TypedEventEmitter` at all; the import and the `runs` field are gone. `queued` changes from `Array<Id>` to `Array<{id, allowedToRun}>`, where `allowedToRun` is that waiter's own `Deferred`, and `run` resolves it directly instead of broadcasting `emit('run', id)` for every waiter to filter on by id. This makes the `EventTarget` flavor of `MaxListenersExceededWarning` structurally impossible rather than merely less likely: it can only be raised from `addEventListener`, and the limiter now never calls it, at any concurrency, for any number of waiters. (Node raises the same warning text from `EventEmitter.addListener` as well, so parking waiters on a `node:events` emitter would reintroduce it -- this removes the reachable path, not the warning from the runtime.) Handing out a turn also stops walking the whole waiter set, and the `TypedEventEmitter` listener-to-wrapper `Map` that was leaking alongside the `EventTarget` is gone with it. The deferred is created before the task is pushed onto `queued`, so `run` can hand out a turn whether or not `enqueueRun` has reached its `await` yet. Correctness therefore no longer rests on the "`queued` non-empty implies `running` is at max" ordering argument that the emitter version needed to guarantee a waiter had subscribed before its own wake-up fired. The public API, the concurrency limits, the FIFO ordering and the `log` callback strings are all unchanged. The one timing difference is that `now allowing ID:N to run` is logged by the resumed waiter rather than from inside the dequeue, one microtask later. `setMaxListeners` was deliberately *not* used, since that only silences the warning. Reviewed By: evangrayk Differential Revision: D115241638 fbshipit-source-id: 098ba01734776ce092c98ed17eebae602508500b
1 parent 2b18be9 commit d6643d7

2 files changed

Lines changed: 171 additions & 15 deletions

File tree

addons/shared/RateLimiter.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import {TypedEventEmitter} from './TypedEventEmitter';
8+
import type {Deferred} from './utils';
9+
10+
import {defer} from './utils';
911

1012
type Id = number;
1113

14+
type QueuedTask = {
15+
id: Id;
16+
/** Resolved by the limiter to hand this task its turn. */
17+
allowedToRun: Deferred<void>;
18+
};
19+
1220
/**
1321
* Rate limits requests to run an arbitrary task.
1422
* Up to `maxSimultaneousRunning` tasks can run at once,
@@ -23,9 +31,8 @@ type Id = number;
2331
* ```
2432
*/
2533
export class RateLimiter {
26-
private queued: Array<Id> = [];
34+
private queued: Array<QueuedTask> = [];
2735
private running: Array<Id> = [];
28-
private runs = new TypedEventEmitter<'run', Id>();
2936

3037
constructor(
3138
private maxSimultaneousRunning: number,
@@ -39,20 +46,17 @@ export class RateLimiter {
3946

4047
async enqueueRun<T>(runner: () => Promise<T>): Promise<T> {
4148
const id = this.generateId();
49+
// Created before the task is queued so that `run` can always hand out the turn, whether or not
50+
// this function has reached the `await` below by the time the turn is granted.
51+
const task: QueuedTask = {id, allowedToRun: defer<void>()};
4252

43-
this.queued.push(id);
53+
this.queued.push(task);
4454
this.tryDequeueNext();
4555

4656
if (!this.running.includes(id)) {
4757
this.log?.(`${this.running.length} tasks are already running, enqueuing ID:${id}`);
48-
await new Promise(res => {
49-
this.runs.on('run', ran => {
50-
if (ran === id) {
51-
this.log?.(`now allowing ID:${id} to run`);
52-
res(undefined);
53-
}
54-
});
55-
});
58+
await task.allowedToRun.promise;
59+
this.log?.(`now allowing ID:${id} to run`);
5660
}
5761

5862
try {
@@ -76,8 +80,8 @@ export class RateLimiter {
7680
}
7781
}
7882

79-
private run(id: Id) {
80-
this.running.push(id);
81-
this.runs.emit('run', id);
83+
private run(task: QueuedTask) {
84+
this.running.push(task.id);
85+
task.allowedToRun.resolve(undefined);
8286
}
8387
}

addons/shared/__tests__/RateLimiter.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ import {RateLimiter} from '../RateLimiter';
99
import {nextTick} from '../testUtils';
1010
import {defer} from '../utils';
1111

12+
/**
13+
* Number of tasks parked in `RateLimiter`'s queue waiting for a turn. Each one owns a `Deferred`
14+
* the limiter still has to resolve, so this array is the only place a waiter can accumulate. It is
15+
* reachable only through private fields, but asserting on it is the only way to prove queued tasks
16+
* don't leak.
17+
*/
18+
function waitingTaskCount(rateLimiter: RateLimiter): number {
19+
const {queued} = rateLimiter as unknown as {queued: Array<unknown>};
20+
return queued.length;
21+
}
22+
1223
describe('RateLimiter', () => {
1324
it('immediately invokes if less than max simultaneous requests are running', () => {
1425
const d1 = defer();
@@ -130,4 +141,145 @@ describe('RateLimiter', () => {
130141
expect(hasId3Resolved).toBe(true);
131142
expect(sawError).toBe(true);
132143
});
144+
145+
it('parks one waiter per queued task and releases it once that task may run', async () => {
146+
const rateLimiter = new RateLimiter(1);
147+
expect(waitingTaskCount(rateLimiter)).toBe(0);
148+
149+
const total = 5;
150+
const deferreds = Array.from({length: total}, () => defer<number>());
151+
const results = deferreds.map((deferred, i) =>
152+
rateLimiter.enqueueRun(() => deferred.promise.then(() => i)),
153+
);
154+
155+
await nextTick();
156+
// every task but the running one is waiting for its turn
157+
expect(waitingTaskCount(rateLimiter)).toBe(total - 1);
158+
159+
// letting one task through only releases that task
160+
deferreds[0].resolve(0);
161+
await nextTick();
162+
expect(waitingTaskCount(rateLimiter)).toBe(total - 2);
163+
164+
deferreds.forEach((deferred, i) => deferred.resolve(i));
165+
expect(await Promise.all(results)).toEqual([0, 1, 2, 3, 4]);
166+
expect(waitingTaskCount(rateLimiter)).toBe(0);
167+
});
168+
169+
it('does not accumulate waiters over the lifetime of the limiter', async () => {
170+
const rateLimiter = new RateLimiter(1);
171+
// Guards this design's own accumulator, NOT the per-waiter subscription leak that motivated it
172+
// — `queued` drained correctly even in the leaking version, so this passes against it. The
173+
// regression guard for that leak is the `addEventListener` spy in the next test.
174+
// Runs one task while a second waits, so the number of waiters alive at any moment is 1
175+
// no matter how many pairs run: the count can only grow if finished tasks stay in the queue.
176+
const runPair = async (remaining: number): Promise<void> => {
177+
if (remaining === 0) {
178+
return;
179+
}
180+
const first = defer<void>();
181+
const second = defer<void>();
182+
const runs = [
183+
rateLimiter.enqueueRun(() => first.promise),
184+
rateLimiter.enqueueRun(() => second.promise),
185+
];
186+
187+
await nextTick();
188+
expect(waitingTaskCount(rateLimiter)).toBe(1);
189+
190+
first.resolve(undefined);
191+
second.resolve(undefined);
192+
await Promise.all(runs);
193+
expect(waitingTaskCount(rateLimiter)).toBe(0);
194+
195+
return runPair(remaining - 1);
196+
};
197+
198+
await runPair(20);
199+
});
200+
201+
it('adds no EventTarget listener for a burst of waiters past the warning threshold', async () => {
202+
// This is the regression guard for the per-waiter subscription leak. The EventTarget flavor of
203+
// `MaxListenersExceededWarning` can only come from `addEventListener`, and `TypedEventEmitter`
204+
// wraps an `EventTarget`, so never calling it is what makes that warning unreachable rather
205+
// than merely rare. (Node raises the same warning from `EventEmitter.addListener` too, which
206+
// this does not cover — parking waiters on a `node:events` emitter would reintroduce it.)
207+
// Capturing `process.on('warning')` cannot prove this here: jest-environment-node gives each
208+
// test a copy of `process`, and warnings raised by the real one never reach that copy.
209+
const addEventListener = jest.spyOn(EventTarget.prototype, 'addEventListener');
210+
try {
211+
const maxSimultaneous = 4;
212+
const total = 20; // leaves 16 tasks waiting at once, well past the threshold of 10
213+
const rateLimiter = new RateLimiter(maxSimultaneous);
214+
const deferreds = Array.from({length: total}, () => defer<void>());
215+
const started: Array<number> = [];
216+
const results = deferreds.map((deferred, i) =>
217+
rateLimiter.enqueueRun(async () => {
218+
started.push(i);
219+
await deferred.promise;
220+
return i;
221+
}),
222+
);
223+
224+
await nextTick();
225+
expect(waitingTaskCount(rateLimiter)).toBe(total - maxSimultaneous);
226+
expect(addEventListener).not.toHaveBeenCalled();
227+
228+
deferreds.forEach(deferred => deferred.resolve(undefined));
229+
const inOrder = Array.from({length: total}, (_, i) => i);
230+
expect(await Promise.all(results)).toEqual(inOrder);
231+
expect(started).toEqual(inOrder);
232+
expect(waitingTaskCount(rateLimiter)).toBe(0);
233+
expect(addEventListener).not.toHaveBeenCalled();
234+
} finally {
235+
addEventListener.mockRestore();
236+
}
237+
});
238+
239+
it('still runs every task in order without exceeding the concurrency limit', async () => {
240+
const rateLimiter = new RateLimiter(2);
241+
const deferreds = Array.from({length: 6}, () => defer<number>());
242+
const started: Array<number> = [];
243+
let running = 0;
244+
let maxRunning = 0;
245+
246+
const results = deferreds.map((deferred, i) =>
247+
rateLimiter.enqueueRun(async () => {
248+
started.push(i);
249+
running++;
250+
maxRunning = Math.max(maxRunning, running);
251+
const result = await deferred.promise;
252+
running--;
253+
return result;
254+
}),
255+
);
256+
257+
await nextTick();
258+
expect(started).toEqual([0, 1]);
259+
260+
deferreds.forEach((deferred, i) => deferred.resolve(i * 10));
261+
expect(await Promise.all(results)).toEqual([0, 10, 20, 30, 40, 50]);
262+
expect(started).toEqual([0, 1, 2, 3, 4, 5]);
263+
expect(maxRunning).toBe(2);
264+
});
265+
266+
it('still logs when a task is enqueued and when it is allowed to run', async () => {
267+
const log = jest.fn();
268+
const rateLimiter = new RateLimiter(1, log);
269+
const d1 = defer<void>();
270+
const d2 = defer<void>();
271+
272+
const first = rateLimiter.enqueueRun(() => d1.promise);
273+
const second = rateLimiter.enqueueRun(() => d2.promise);
274+
expect(log).toHaveBeenCalledWith('1 tasks are already running, enqueuing ID:2');
275+
expect(log).not.toHaveBeenCalledWith('now allowing ID:2 to run');
276+
277+
d1.resolve(undefined);
278+
await nextTick();
279+
expect(log).toHaveBeenCalledWith('now allowing ID:2 to run');
280+
281+
d2.resolve(undefined);
282+
await Promise.all([first, second]);
283+
expect(log).toHaveBeenCalledTimes(2);
284+
});
133285
});

0 commit comments

Comments
 (0)