Skip to content

Commit

Permalink
fix: memleak in timeout w/ abort signal (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
zone117x authored Oct 31, 2024
1 parent 11b9622 commit d56a9ad
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 10 deletions.
52 changes: 52 additions & 0 deletions src/helpers/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as events from 'node:events';
import { timeout } from '../time';

describe('Helper tests', () => {
test('timeout function should not cause memory leak by accumulating abort listeners on abort', async () => {
const controller = new AbortController();
const { signal } = controller;

const countListeners = () => events.getEventListeners(signal, 'abort').length;

// Ensure the initial listener count is zero
expect(countListeners()).toBe(0);

// Run enough iterations to detect a pattern
for (let i = 0; i < 100; i++) {
try {
const sleepPromise = timeout(1000, signal);
controller.abort(); // Abort immediately
await sleepPromise;
} catch (err: any) {
expect(err.toString()).toMatch(/aborted/i);
}

// Assert that listener count does not increase
expect(countListeners()).toBeLessThanOrEqual(1); // 1 listener may temporarily be added and removed
}

// Final check to confirm listeners are cleaned up
expect(countListeners()).toBe(0);
});

test('timeout function should not cause memory leak by accumulating abort listeners on successful completion', async () => {
const controller = new AbortController();
const { signal } = controller;

const countListeners = () => events.getEventListeners(signal, 'abort').length;

// Ensure the initial listener count is zero
expect(countListeners()).toBe(0);

// Run enough iterations to detect a pattern
for (let i = 0; i < 100; i++) {
await timeout(2, signal); // Complete sleep without abort

// Assert that listener count does not increase
expect(countListeners()).toBe(0); // No listeners should remain after successful sleep completion
}

// Final check to confirm listeners are cleaned up
expect(countListeners()).toBe(0);
});
});
22 changes: 12 additions & 10 deletions src/helpers/time.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { addAbortListener } from 'node:events';

/**
* Wait a set amount of milliseconds or until the timer is aborted.
* @param ms - Number of milliseconds to wait
* @param abortController - Abort controller
* @param abort - Abort controller
* @returns Promise
*/
export function timeout(ms: number, abortController?: AbortController): Promise<void> {
export function timeout(ms: number, abort?: AbortController | AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const signal = abort && 'signal' in abort ? abort.signal : abort;
if (signal?.aborted) return reject(signal.reason);
const disposable = signal ? addAbortListener(signal, onAbort) : undefined;
const timeout = setTimeout(() => {
disposable?.[Symbol.dispose ?? (Symbol.for('nodejs.dispose') as typeof Symbol.dispose)]();
resolve();
}, ms);
abortController?.signal.addEventListener(
'abort',
() => {
clearTimeout(timeout);
reject(new Error(`Timeout aborted`));
},
{ once: true }
);
function onAbort() {
clearTimeout(timeout);
reject(signal?.reason);
}
});
}

Expand Down

0 comments on commit d56a9ad

Please sign in to comment.