From 48de941771fcd6173b91a1c7ea8231a3378ef39f Mon Sep 17 00:00:00 2001 From: Simon Meyer Date: Fri, 17 Apr 2026 14:42:49 +0200 Subject: [PATCH 1/4] test: add reproduction script for memory leak --- reproduce-leak.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 reproduce-leak.ts diff --git a/reproduce-leak.ts b/reproduce-leak.ts new file mode 100644 index 00000000..2daf30d5 --- /dev/null +++ b/reproduce-leak.ts @@ -0,0 +1,61 @@ +import { AsyncQueuer } from './packages/pacer/src' + +const ROUNDS = 1000 +const ITEMS_PER_ROUND = 100 + +async function main() { + const queuer = new AsyncQueuer( + async (item) => item * 2, + { + concurrency: 5, + started: false, + throwOnError: false, + }, + ) + + console.log(`AsyncQueuer key: ${queuer.key} (should be undefined)`) + console.log(`Processing ${ROUNDS} rounds x ${ITEMS_PER_ROUND} items = ${ROUNDS * ITEMS_PER_ROUND} total\n`) + + const heapHistory: number[] = [] + + for (let round = 0; round < ROUNDS; round++) { + for (let i = 0; i < ITEMS_PER_ROUND; i++) { + queuer.addItem(round * ITEMS_PER_ROUND + i + 1) + } + queuer.start() + + while (queuer.store.state.items.length > 0 || queuer.store.state.activeItems.length > 0) { + await new Promise((r) => setTimeout(r, 10)) + } + queuer.stop() + + global.gc!() + await new Promise((r) => setTimeout(r, 50)) + global.gc!() + + const heapMB = process.memoryUsage().heapUsed / 1024 / 1024 + heapHistory.push(heapMB) + console.log( + `Round ${String(round + 1).padStart(2)}: ` + + `heap = ${heapMB.toFixed(1)} MB ` + + `(items processed so far: ${(round + 1) * ITEMS_PER_ROUND})`, + ) + } + + const firstHeap = heapHistory[0]! + const lastHeap = heapHistory[heapHistory.length - 1]! + const growth = lastHeap - firstHeap + + console.log(`\n--- Summary ---`) + console.log(`Heap after round 1: ${firstHeap.toFixed(1)} MB`) + console.log(`Heap after round ${ROUNDS}: ${lastHeap.toFixed(1)} MB`) + console.log(`Total growth: ${growth.toFixed(1)} MB`) + + if (growth > 5) { + console.log(`\nLEAK DETECTED — heap grew ${growth.toFixed(1)} MB over ${ROUNDS * ITEMS_PER_ROUND} items`) + } else { + console.log(`\nNo significant leak — heap growth is ${growth.toFixed(1)} MB`) + } +} + +main().catch(console.error) From 627ea06fe8f19e44cf9150cd9e5b69e4ab009969 Mon Sep 17 00:00:00 2001 From: Simon Meyer Date: Fri, 17 Apr 2026 14:43:15 +0200 Subject: [PATCH 2/4] fix: guard retryer key propagation when parent has no key --- packages/pacer/src/async-debouncer.ts | 2 +- packages/pacer/src/async-queuer.ts | 2 +- packages/pacer/src/async-rate-limiter.ts | 2 +- packages/pacer/src/async-throttler.ts | 2 +- packages/pacer/tests/async-debouncer.test.ts | 85 ++++++++++++++++++ packages/pacer/tests/async-queuer.test.ts | 88 ++++++++++++++++++- .../pacer/tests/async-rate-limiter.test.ts | 83 +++++++++++++++++ packages/pacer/tests/async-throttler.test.ts | 85 +++++++++++++++++- 8 files changed, 343 insertions(+), 6 deletions(-) diff --git a/packages/pacer/src/async-debouncer.ts b/packages/pacer/src/async-debouncer.ts index d24d721a..48188634 100644 --- a/packages/pacer/src/async-debouncer.ts +++ b/packages/pacer/src/async-debouncer.ts @@ -368,7 +368,7 @@ export class AsyncDebouncer { this.#setState({ isExecuting: true }) const currentAsyncRetryer = new AsyncRetryer(this.fn, { ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentMaybeExecuteCount}`, + key: this.key ? `${this.key}-retryer-${currentMaybeExecuteCount}` : undefined, }) this.asyncRetryers.set(currentMaybeExecuteCount, currentAsyncRetryer) const result = await currentAsyncRetryer.execute(...args) // EXECUTE! diff --git a/packages/pacer/src/async-queuer.ts b/packages/pacer/src/async-queuer.ts index 22925a17..d606cca8 100644 --- a/packages/pacer/src/async-queuer.ts +++ b/packages/pacer/src/async-queuer.ts @@ -618,7 +618,7 @@ export class AsyncQueuer { try { const currentAsyncRetryer = new AsyncRetryer(this.fn, { ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentExecuteCount}`, + key: this.key ? `${this.key}-retryer-${currentExecuteCount}` : undefined, }) this.asyncRetryers.set(currentExecuteCount, currentAsyncRetryer) const lastResult = await currentAsyncRetryer.execute(item) // EXECUTE! diff --git a/packages/pacer/src/async-rate-limiter.ts b/packages/pacer/src/async-rate-limiter.ts index 5ac9be42..58576fd1 100644 --- a/packages/pacer/src/async-rate-limiter.ts +++ b/packages/pacer/src/async-rate-limiter.ts @@ -395,7 +395,7 @@ export class AsyncRateLimiter { // Create a new AsyncRetryer for this execution to avoid cancelling concurrent executions const currentAsyncRetryer = new AsyncRetryer(this.fn, { ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentMaybeExecute}`, + key: this.key ? `${this.key}-retryer-${currentMaybeExecute}` : undefined, }) this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer) const result = await currentAsyncRetryer.execute(...args) // EXECUTE! diff --git a/packages/pacer/src/async-throttler.ts b/packages/pacer/src/async-throttler.ts index b6bc30b3..a742a96a 100644 --- a/packages/pacer/src/async-throttler.ts +++ b/packages/pacer/src/async-throttler.ts @@ -416,7 +416,7 @@ export class AsyncThrottler { this.#setState({ isExecuting: true }) const currentAsyncRetryer = new AsyncRetryer(this.fn, { ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentMaybeExecute}`, + key: this.key ? `${this.key}-retryer-${currentMaybeExecute}` : undefined, }) this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer) const result = await currentAsyncRetryer.execute(...args) // EXECUTE! diff --git a/packages/pacer/tests/async-debouncer.test.ts b/packages/pacer/tests/async-debouncer.test.ts index 66ac0ca9..eff0c7c4 100644 --- a/packages/pacer/tests/async-debouncer.test.ts +++ b/packages/pacer/tests/async-debouncer.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncDebouncer, asyncDebounce } from '../src/async-debouncer' +import { pacerEventClient } from '../src' describe('AsyncDebouncer', () => { beforeEach(() => { @@ -1359,4 +1360,88 @@ describe('asyncDebounce helper function', () => { expect(debouncer.getAbortSignal()).toBeNull() }) }) + + describe('retryer key propagation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should give child AsyncRetryer key: undefined when debouncer has no key', async () => { + let resolve!: (v: string) => void + const gate = new Promise((r) => { resolve = r }) + + const debouncer = new AsyncDebouncer( + async () => { await gate }, + { wait: 100 }, + ) + + debouncer.maybeExecute() + await vi.advanceTimersByTimeAsync(100) + + const retryer = debouncer.asyncRetryers.get(2) + expect(retryer).toBeDefined() + expect(retryer!.key).toBeUndefined() + + resolve('done') + await vi.advanceTimersByTimeAsync(0) + }) + + it('should give child AsyncRetryer a namespaced key when debouncer has a key', async () => { + let resolve!: (v: string) => void + const gate = new Promise((r) => { resolve = r }) + + const debouncer = new AsyncDebouncer( + async () => { await gate }, + { wait: 100, key: 'my-debouncer' }, + ) + + debouncer.maybeExecute() + await vi.advanceTimersByTimeAsync(100) + + const retryer = debouncer.asyncRetryers.get(2) + expect(retryer).toBeDefined() + expect(retryer!.key).toBe('my-debouncer-retryer-2') + + resolve('done') + await vi.advanceTimersByTimeAsync(0) + }) + + it('should not queue devtools events when debouncer has no key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const debouncer = new AsyncDebouncer( + async () => 'result', + { wait: 100 }, + ) + + emitSpy.mockClear() + + debouncer.maybeExecute() + await vi.advanceTimersByTimeAsync(100) + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits).toHaveLength(0) + }) + + it('should queue devtools events when debouncer has a key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const debouncer = new AsyncDebouncer( + async () => 'result', + { wait: 100, key: 'my-debouncer' }, + ) + + emitSpy.mockClear() + + debouncer.maybeExecute() + await vi.advanceTimersByTimeAsync(100) + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits.length).toBeGreaterThan(0) + }) + }) }) diff --git a/packages/pacer/tests/async-queuer.test.ts b/packages/pacer/tests/async-queuer.test.ts index 5497797b..c1e1bc36 100644 --- a/packages/pacer/tests/async-queuer.test.ts +++ b/packages/pacer/tests/async-queuer.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AsyncQueuer } from '../src' +import { AsyncQueuer, pacerEventClient } from '../src' describe('AsyncQueuer', () => { beforeEach(() => { @@ -1095,4 +1095,90 @@ describe('AsyncQueuer', () => { expect(asyncQueuer.getAbortSignal()).toBeNull() }) }) + + describe('retryer key propagation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should give child AsyncRetryer key: undefined when queuer has no key', async () => { + let resolve!: (v: string) => void + const item = new Promise((r) => { resolve = r }) + + const asyncQueuer = new AsyncQueuer>( + (p) => p, + { started: false }, + ) + + asyncQueuer.addItem(item) + const executePromise = asyncQueuer.execute() + + const retryer = asyncQueuer.asyncRetryers.get(1) + expect(retryer).toBeDefined() + expect(retryer!.key).toBeUndefined() + + resolve('done') + await executePromise + }) + + it('should give child AsyncRetryer a namespaced key when queuer has a key', async () => { + let resolve!: (v: string) => void + const item = new Promise((r) => { resolve = r }) + + const asyncQueuer = new AsyncQueuer>( + (p) => p, + { started: false, key: 'my-queue' }, + ) + + asyncQueuer.addItem(item) + const executePromise = asyncQueuer.execute() + + const retryer = asyncQueuer.asyncRetryers.get(1) + expect(retryer).toBeDefined() + expect(retryer!.key).toBe('my-queue-retryer-1') + + resolve('done') + await executePromise + }) + + it('should not queue devtools events when queuer has no key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const asyncQueuer = new AsyncQueuer( + (item) => Promise.resolve(item), + { started: false }, + ) + + emitSpy.mockClear() + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + await asyncQueuer.execute() + await asyncQueuer.execute() + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits).toHaveLength(0) + }) + + it('should queue devtools events when queuer has a key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const asyncQueuer = new AsyncQueuer( + (item) => Promise.resolve(item), + { started: false, key: 'my-queue' }, + ) + + emitSpy.mockClear() + + asyncQueuer.addItem('a') + await asyncQueuer.execute() + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits.length).toBeGreaterThan(0) + }) + }) }) diff --git a/packages/pacer/tests/async-rate-limiter.test.ts b/packages/pacer/tests/async-rate-limiter.test.ts index 22bcae3c..1e889faf 100644 --- a/packages/pacer/tests/async-rate-limiter.test.ts +++ b/packages/pacer/tests/async-rate-limiter.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncRateLimiter, asyncRateLimit } from '../src/async-rate-limiter' +import { pacerEventClient } from '../src' describe('AsyncRateLimiter', () => { beforeEach(() => { @@ -775,4 +776,86 @@ describe('asyncRateLimit', () => { expect(rateLimiter.getAbortSignal()).toBeNull() }) }) + + describe('retryer key propagation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should give child AsyncRetryer key: undefined when rate limiter has no key', async () => { + let resolve!: (v: string) => void + const gate = new Promise((r) => { resolve = r }) + + const rateLimiter = new AsyncRateLimiter( + async () => { await gate }, + { limit: 5, window: 1000 }, + ) + + const executePromise = rateLimiter.maybeExecute() + await vi.advanceTimersByTimeAsync(0) + + const retryer = rateLimiter.asyncRetryers.get(1) + expect(retryer).toBeDefined() + expect(retryer!.key).toBeUndefined() + + resolve('done') + await executePromise + }) + + it('should give child AsyncRetryer a namespaced key when rate limiter has a key', async () => { + let resolve!: (v: string) => void + const gate = new Promise((r) => { resolve = r }) + + const rateLimiter = new AsyncRateLimiter( + async () => { await gate }, + { limit: 5, window: 1000, key: 'my-limiter' }, + ) + + const executePromise = rateLimiter.maybeExecute() + await vi.advanceTimersByTimeAsync(0) + + const retryer = rateLimiter.asyncRetryers.get(1) + expect(retryer).toBeDefined() + expect(retryer!.key).toBe('my-limiter-retryer-1') + + resolve('done') + await executePromise + }) + + it('should not queue devtools events when rate limiter has no key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const rateLimiter = new AsyncRateLimiter( + async () => 'result', + { limit: 5, window: 1000 }, + ) + + emitSpy.mockClear() + + await rateLimiter.maybeExecute() + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits).toHaveLength(0) + }) + + it('should queue devtools events when rate limiter has a key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const rateLimiter = new AsyncRateLimiter( + async () => 'result', + { limit: 5, window: 1000, key: 'my-limiter' }, + ) + + emitSpy.mockClear() + + await rateLimiter.maybeExecute() + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits.length).toBeGreaterThan(0) + }) + }) }) diff --git a/packages/pacer/tests/async-throttler.test.ts b/packages/pacer/tests/async-throttler.test.ts index 2cb1072e..fe487e4b 100644 --- a/packages/pacer/tests/async-throttler.test.ts +++ b/packages/pacer/tests/async-throttler.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncThrottler } from '../src/async-throttler' +import { pacerEventClient } from '../src' describe('AsyncThrottler', () => { beforeEach(() => { @@ -998,4 +999,86 @@ describe('AsyncThrottler', () => { expect(throttler.getAbortSignal()).toBeNull() }) }) + + describe('retryer key propagation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should give child AsyncRetryer key: undefined when throttler has no key', async () => { + let resolve!: (v: string) => void + const gate = new Promise((r) => { resolve = r }) + + const throttler = new AsyncThrottler( + async () => { await gate }, + { wait: 0 }, + ) + + const executePromise = throttler.maybeExecute() + await vi.advanceTimersByTimeAsync(0) + + const retryer = throttler.asyncRetryers.get(1) + expect(retryer).toBeDefined() + expect(retryer!.key).toBeUndefined() + + resolve('done') + await executePromise + }) + + it('should give child AsyncRetryer a namespaced key when throttler has a key', async () => { + let resolve!: (v: string) => void + const gate = new Promise((r) => { resolve = r }) + + const throttler = new AsyncThrottler( + async () => { await gate }, + { wait: 0, key: 'my-throttler' }, + ) + + const executePromise = throttler.maybeExecute() + await vi.advanceTimersByTimeAsync(0) + + const retryer = throttler.asyncRetryers.get(1) + expect(retryer).toBeDefined() + expect(retryer!.key).toBe('my-throttler-retryer-1') + + resolve('done') + await executePromise + }) + + it('should not queue devtools events when throttler has no key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const throttler = new AsyncThrottler( + async () => 'result', + { wait: 0 }, + ) + + emitSpy.mockClear() + + await throttler.maybeExecute() + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits).toHaveLength(0) + }) + + it('should queue devtools events when throttler has a key', async () => { + const emitSpy = vi.spyOn(pacerEventClient, 'emit') + + const throttler = new AsyncThrottler( + async () => 'result', + { wait: 0, key: 'my-throttler' }, + ) + + emitSpy.mockClear() + + await throttler.maybeExecute() + + const retryerEmits = emitSpy.mock.calls.filter( + ([event]) => event === 'AsyncRetryer', + ) + expect(retryerEmits.length).toBeGreaterThan(0) + }) + }) }) From 31ba67c22e7424e7183d35b8f951ba7716448783 Mon Sep 17 00:00:00 2001 From: Simon Meyer Date: Fri, 17 Apr 2026 14:43:31 +0200 Subject: [PATCH 3/4] chore: remove reproduction script --- reproduce-leak.ts | 61 ----------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 reproduce-leak.ts diff --git a/reproduce-leak.ts b/reproduce-leak.ts deleted file mode 100644 index 2daf30d5..00000000 --- a/reproduce-leak.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { AsyncQueuer } from './packages/pacer/src' - -const ROUNDS = 1000 -const ITEMS_PER_ROUND = 100 - -async function main() { - const queuer = new AsyncQueuer( - async (item) => item * 2, - { - concurrency: 5, - started: false, - throwOnError: false, - }, - ) - - console.log(`AsyncQueuer key: ${queuer.key} (should be undefined)`) - console.log(`Processing ${ROUNDS} rounds x ${ITEMS_PER_ROUND} items = ${ROUNDS * ITEMS_PER_ROUND} total\n`) - - const heapHistory: number[] = [] - - for (let round = 0; round < ROUNDS; round++) { - for (let i = 0; i < ITEMS_PER_ROUND; i++) { - queuer.addItem(round * ITEMS_PER_ROUND + i + 1) - } - queuer.start() - - while (queuer.store.state.items.length > 0 || queuer.store.state.activeItems.length > 0) { - await new Promise((r) => setTimeout(r, 10)) - } - queuer.stop() - - global.gc!() - await new Promise((r) => setTimeout(r, 50)) - global.gc!() - - const heapMB = process.memoryUsage().heapUsed / 1024 / 1024 - heapHistory.push(heapMB) - console.log( - `Round ${String(round + 1).padStart(2)}: ` + - `heap = ${heapMB.toFixed(1)} MB ` + - `(items processed so far: ${(round + 1) * ITEMS_PER_ROUND})`, - ) - } - - const firstHeap = heapHistory[0]! - const lastHeap = heapHistory[heapHistory.length - 1]! - const growth = lastHeap - firstHeap - - console.log(`\n--- Summary ---`) - console.log(`Heap after round 1: ${firstHeap.toFixed(1)} MB`) - console.log(`Heap after round ${ROUNDS}: ${lastHeap.toFixed(1)} MB`) - console.log(`Total growth: ${growth.toFixed(1)} MB`) - - if (growth > 5) { - console.log(`\nLEAK DETECTED — heap grew ${growth.toFixed(1)} MB over ${ROUNDS * ITEMS_PER_ROUND} items`) - } else { - console.log(`\nNo significant leak — heap growth is ${growth.toFixed(1)} MB`) - } -} - -main().catch(console.error) From 734c1b4e3254d2b64dbd8855cb12b76d03386350 Mon Sep 17 00:00:00 2001 From: Simon Meyer Date: Fri, 17 Apr 2026 14:49:04 +0200 Subject: [PATCH 4/4] chore: add changeset for retryer key propagation fix --- .changeset/fluffy-spies-guess.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fluffy-spies-guess.md diff --git a/.changeset/fluffy-spies-guess.md b/.changeset/fluffy-spies-guess.md new file mode 100644 index 00000000..3e802b93 --- /dev/null +++ b/.changeset/fluffy-spies-guess.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': patch +--- + +Guard retryer key propagation in AsyncQueuer, AsyncThrottler, AsyncRateLimiter, and AsyncDebouncer to prevent child AsyncRetryer instances from receiving a truthy key when the parent has no key, which caused unbounded devtools event accumulation and memory leaks in Node.js environments.