Skip to content

fix: guard retryer key propagation when parent has no key - #199

Closed
simonmeyerrr wants to merge 6 commits into
TanStack:mainfrom
simonmeyerrr:fix-async-queuer-retryer-key-propagation
Closed

fix: guard retryer key propagation when parent has no key#199
simonmeyerrr wants to merge 6 commits into
TanStack:mainfrom
simonmeyerrr:fix-async-queuer-retryer-key-propagation

Conversation

@simonmeyerrr

@simonmeyerrr simonmeyerrr commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

🎯 Changes

Fixes #198

When AsyncQueuer, AsyncThrottler, AsyncRateLimiter, or AsyncDebouncer are created without a key (the common case), they propagate a truthy key like "undefined-retryer-1" to every child AsyncRetryer via template literal coercion of undefined. This causes pacerEventClient.emit() to be called for every execution, queuing events into a module-level singleton that is never flushed in Node.js — resulting in unbounded heap growth (+583 MB over 100k items with AsyncQueuer).

Same one-line fix in all four classes:

- key: `${this.key}-retryer-${currentExecuteCount}`,
+ key: this.key ? `${this.key}-retryer-${currentExecuteCount}` : undefined,

Benchmark (1,000 rounds × 100 items, Node.js 22, --expose-gc):

Scenario Heap round 1 Heap round 1,000 Growth
Before fix 6.7 MB 590.0 MB +583.3 MB
After fix 5.7 MB 6.0 MB +0.2 MB

You can checkout to the first commit to test the script before the fix and to the second commit to test the script with the fix: npx tsx --expose-gc reproduce-leak.ts (I removed the script in the last commit as it was just to demonstrate the issue)

16 new tests (4 per class):

  • Child AsyncRetryer gets key: undefined when parent has no key (inspected mid-execution via controlled promise)
  • Child AsyncRetryer gets namespaced key when parent has a key
  • pacerEventClient.emit() is never called with AsyncRetryer events when parent has no key
  • pacerEventClient.emit() is called with AsyncRetryer events when parent has a key

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • Fixed memory leaks in Node.js by preventing unbounded devtools event accumulation.
    • Corrected retryer key propagation across async queuing, throttling, rate-limiting, and debouncing operations when no parent key is configured.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1f32b8e-d242-438a-afff-45d173ac8b93

📥 Commits

Reviewing files that changed from the base of the PR and between 734c1b4 and bf280a7.

📒 Files selected for processing (2)
  • packages/pacer/tests/async-debouncer.test.ts
  • packages/pacer/tests/async-rate-limiter.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/pacer/tests/async-debouncer.test.ts
  • packages/pacer/tests/async-rate-limiter.test.ts

📝 Walkthrough

Walkthrough

The four asynchronous utilities now pass undefined to child AsyncRetryer instances when no parent key exists. Keyed instances retain namespaced retryer keys. Tests cover key propagation and devtools event emission.

Changes

Retryer key propagation

Layer / File(s) Summary
Conditional retryer key assignment
packages/pacer/src/async-queuer.ts, packages/pacer/src/async-throttler.ts, packages/pacer/src/async-rate-limiter.ts, packages/pacer/src/async-debouncer.ts
Each utility now passes undefined when its parent has no key. Keyed utilities continue to create namespaced retryer keys.
Key propagation and event tests
packages/pacer/tests/async-queuer.test.ts, packages/pacer/tests/async-throttler.test.ts, packages/pacer/tests/async-rate-limiter.test.ts, packages/pacer/tests/async-debouncer.test.ts
Tests verify child keys and AsyncRetryer devtools events for keyed and unkeyed utilities.
Release changeset
.changeset/fluffy-spies-guess.md
Adds a patch release entry for @tanstack/pacer describing the fix.

Estimated code review effort: 2 (Simple) | ~15 minutes

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary fix: preventing retryer key propagation when the parent has no key.
Description check ✅ Passed The description explains the bug, fix, impact, tests, benchmark, checklist completion, and changeset release impact.
Linked Issues check ✅ Passed The changes address issue #198 by preventing unintended retryer keys without a parent key while preserving namespaced keys and adding coverage.
Out of Scope Changes check ✅ Passed All source, test, and changeset changes directly support the retryer key propagation fix and its validation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
packages/pacer/tests/async-debouncer.test.ts (1)

1364-1446: Tests look good — minor placement nit.

This retryer key propagation block is nested inside the describe('asyncDebounce helper function', ...) suite, but the tests exercise the AsyncDebouncer class directly (not the asyncDebounce helper). Consider moving it into the outer describe('AsyncDebouncer', ...) block alongside the other class-level suites for better organization. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pacer/tests/async-debouncer.test.ts` around lines 1364 - 1446, The
"retryer key propagation" describe block tests AsyncDebouncer directly but is
currently nested under the asyncDebounce helper suite; move that entire
describe('retryer key propagation', ...) block out of the asyncDebounce helper
describe and into the outer describe('AsyncDebouncer', ...) suite so the tests
sit alongside other class-level tests for AsyncDebouncer and clearly reference
AsyncDebouncer (and not the asyncDebounce helper).
packages/pacer/src/async-queuer.ts (1)

619-622: LGTM — fix is correct.

Minor optional consistency observation: packages/pacer/src/async-batcher.ts (around lines 389–396) constructs AsyncRetryer by passing this.options.asyncRetryerOptions directly, without the ${this.key}-retryer-${...} namespacing used here. It does not have the memory-leak bug (no truthy key is ever propagated), but a keyed AsyncBatcher won't produce namespaced retryer keys for devtools either. Consider applying the same conditional pattern there for consistency — can be deferred.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pacer/src/async-queuer.ts` around lines 619 - 622, In AsyncBatcher
(the class/method that constructs new AsyncRetryer in
packages/pacer/src/async-batcher.ts), apply the same conditional key namespacing
used in async-queuer.ts: when creating the AsyncRetryer with
this.options.asyncRetryerOptions, set the key option to this.key ?
`${this.key}-retryer-${currentExecuteCount}` : undefined (or equivalent) instead
of passing the options directly so keyed AsyncBatcher produces consistent
namespaced retryer keys for devtools; update the AsyncRetryer constructor call
within AsyncBatcher to merge options and conditionally add the namespaced key.
packages/pacer/tests/async-rate-limiter.test.ts (2)

825-841: Consider also asserting no listener registration / leak.

Filtering emit calls by event name 'AsyncRetryer' is sufficient to prove the immediate leak path is gone, but the underlying issue #198 was that retryers were also registered as devtools instances and listeners. If you want to harden this against future regressions, consider additionally asserting that pacerEventClient.on/registerPacerDevtoolsInstance is not invoked for the child retryer when the parent has no key. Optional — current assertion already catches the memory-growth scenario.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pacer/tests/async-rate-limiter.test.ts` around lines 825 - 841,
Extend the test for AsyncRateLimiter.maybeExecute to also assert that no
devtools listener/instance registration occurs when the rate limiter has no key:
spy on pacerEventClient.on (and/or the helper registerPacerDevtoolsInstance)
before calling rateLimiter.maybeExecute and assert those spies were not called,
in addition to the existing emit filter; this ensures no listener
registration/leak for child retryers leaking via registerPacerDevtoolsInstance
or pacerEventClient.on.

780-860: Suite is nested under the wrong parent describe.

The new describe('retryer key propagation', ...) block is placed inside describe('asyncRateLimit', ...) (the factory-function suite, which starts at line 694), but every test exercises the AsyncRateLimiter class directly. It should live under describe('AsyncRateLimiter', ...) (which closes at line 692) to keep suite labels accurate in test output and to inherit the same fake-timer setup as the other class tests.

♻️ Proposed relocation

Move the block so it sits alongside the other AsyncRateLimiter sub-suites (e.g., just before the closing }) of the AsyncRateLimiter describe at line 692), rather than inside the asyncRateLimit describe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pacer/tests/async-rate-limiter.test.ts` around lines 780 - 860, The
new describe block describe('retryer key propagation', ...) is nested inside the
asyncRateLimit suite but tests use the AsyncRateLimiter class; move the entire
describe('retryer key propagation', ...) block out of the asyncRateLimit
describe and place it as a sibling inside the AsyncRateLimiter describe so it
shares the same fake-timer setup and context; locate the block by the exact
describe title and relocate it to sit alongside the other AsyncRateLimiter
sub-suites (e.g., just before the closing brace of the AsyncRateLimiter
describe) without changing the test bodies.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/pacer/src/async-queuer.ts`:
- Around line 619-622: In AsyncBatcher (the class/method that constructs new
AsyncRetryer in packages/pacer/src/async-batcher.ts), apply the same conditional
key namespacing used in async-queuer.ts: when creating the AsyncRetryer with
this.options.asyncRetryerOptions, set the key option to this.key ?
`${this.key}-retryer-${currentExecuteCount}` : undefined (or equivalent) instead
of passing the options directly so keyed AsyncBatcher produces consistent
namespaced retryer keys for devtools; update the AsyncRetryer constructor call
within AsyncBatcher to merge options and conditionally add the namespaced key.

In `@packages/pacer/tests/async-debouncer.test.ts`:
- Around line 1364-1446: The "retryer key propagation" describe block tests
AsyncDebouncer directly but is currently nested under the asyncDebounce helper
suite; move that entire describe('retryer key propagation', ...) block out of
the asyncDebounce helper describe and into the outer describe('AsyncDebouncer',
...) suite so the tests sit alongside other class-level tests for AsyncDebouncer
and clearly reference AsyncDebouncer (and not the asyncDebounce helper).

In `@packages/pacer/tests/async-rate-limiter.test.ts`:
- Around line 825-841: Extend the test for AsyncRateLimiter.maybeExecute to also
assert that no devtools listener/instance registration occurs when the rate
limiter has no key: spy on pacerEventClient.on (and/or the helper
registerPacerDevtoolsInstance) before calling rateLimiter.maybeExecute and
assert those spies were not called, in addition to the existing emit filter;
this ensures no listener registration/leak for child retryers leaking via
registerPacerDevtoolsInstance or pacerEventClient.on.
- Around line 780-860: The new describe block describe('retryer key
propagation', ...) is nested inside the asyncRateLimit suite but tests use the
AsyncRateLimiter class; move the entire describe('retryer key propagation', ...)
block out of the asyncRateLimit describe and place it as a sibling inside the
AsyncRateLimiter describe so it shares the same fake-timer setup and context;
locate the block by the exact describe title and relocate it to sit alongside
the other AsyncRateLimiter sub-suites (e.g., just before the closing brace of
the AsyncRateLimiter describe) without changing the test bodies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 94ba8c48-6f48-4361-9d9e-aa3072644a0b

📥 Commits

Reviewing files that changed from the base of the PR and between afc958f and 734c1b4.

📒 Files selected for processing (9)
  • .changeset/fluffy-spies-guess.md
  • packages/pacer/src/async-debouncer.ts
  • packages/pacer/src/async-queuer.ts
  • packages/pacer/src/async-rate-limiter.ts
  • packages/pacer/src/async-throttler.ts
  • packages/pacer/tests/async-debouncer.test.ts
  • packages/pacer/tests/async-queuer.test.ts
  • packages/pacer/tests/async-rate-limiter.test.ts
  • packages/pacer/tests/async-throttler.test.ts

Copilot AI lite review requested due to automatic review settings August 6, 2026 09:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a Node.js memory leak caused by unintentionally propagating a truthy "undefined-retryer-N" devtools key into per-execution AsyncRetryer instances when parent async utilities are created without a key.

Changes:

  • Guard AsyncRetryer key propagation in AsyncQueuer, AsyncThrottler, AsyncRateLimiter, and AsyncDebouncer so children don’t get a truthy key when the parent has no key.
  • Add tests (4 per class) validating child retryer key behavior and whether pacerEventClient.emit() is called for AsyncRetryer events.
  • Add a changeset for a patch release.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/pacer/src/async-queuer.ts Guards child retryer key propagation during execute() to avoid "undefined-..." keys.
packages/pacer/src/async-throttler.ts Guards child retryer key propagation during #execute() to avoid "undefined-..." keys.
packages/pacer/src/async-rate-limiter.ts Guards child retryer key propagation during #execute() to avoid "undefined-..." keys.
packages/pacer/src/async-debouncer.ts Guards child retryer key propagation during #execute() to avoid "undefined-..." keys.
packages/pacer/tests/async-queuer.test.ts Adds retryer key propagation + devtools emit tests for AsyncQueuer.
packages/pacer/tests/async-throttler.test.ts Adds retryer key propagation + devtools emit tests for AsyncThrottler.
packages/pacer/tests/async-rate-limiter.test.ts Adds retryer key propagation + devtools emit tests for AsyncRateLimiter.
packages/pacer/tests/async-debouncer.test.ts Adds retryer key propagation + devtools emit tests for AsyncDebouncer.
.changeset/fluffy-spies-guess.md Patch changeset describing the leak fix and affected classes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/pacer/src/async-queuer.ts
Comment thread packages/pacer/src/async-throttler.ts
Comment thread packages/pacer/src/async-debouncer.ts
Comment thread packages/pacer/src/async-rate-limiter.ts
Copilot AI review requested due to automatic review settings August 6, 2026 09:50
@simonmeyerrr

Copy link
Copy Markdown
Contributor Author

🧹 Nitpick comments (4)

Moved the retryer key propagation suites under the AsyncDebouncer / AsyncRateLimiter class describes.
Skipping the AsyncBatcher key namespacing (out of scope, it never had the leak) and the extra on/registerPacerDevtoolsInstance spies (already covered by the key + emit assertions).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (2)

packages/pacer/tests/async-throttler.test.ts:8

  • vi.useFakeTimers() is enabled in beforeEach, but this suite never switches back to real timers. That can leak fake-timer state into other test files and cause unrelated tests to behave incorrectly depending on execution order. Add a top-level afterEach(() => vi.useRealTimers()) for cleanup (similar to other pacer test files).
describe('AsyncThrottler', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

packages/pacer/src/async-debouncer.ts:372

  • AsyncDebouncer#getAbortSignal() looks up the active retryer using this.store.state.maybeExecuteCount, but #execute() stores the retryer under currentMaybeExecuteCount = this.store.state.maybeExecuteCount + 1. This off-by-one means getAbortSignal() will return null during an in-flight execution, and the generated retryer key suffix is also shifted. Consider aligning the retryer map key/count with maybeExecuteCount (and update the related tests accordingly).
      this.#setState({ isExecuting: true })
      const currentAsyncRetryer = new AsyncRetryer(this.fn, {
        ...this.options.asyncRetryerOptions,
        key: this.key ? `${this.key}-retryer-${currentMaybeExecuteCount}` : undefined,
      })

@KevinVandy

Copy link
Copy Markdown
Member

gave you co-auther credit in a larger PR that fixed this and even more issues at all once

@KevinVandy KevinVandy closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AsyncQueuer leaks memory in Node.js via unintended retryer key propagation

3 participants