Skip to content

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

Open
simonmeyerrr wants to merge 4 commits intoTanStack:mainfrom
simonmeyerrr:fix-async-queuer-retryer-key-propagation
Open

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

Conversation

@simonmeyerrr
Copy link
Copy Markdown

@simonmeyerrr simonmeyerrr commented Apr 17, 2026

🎯 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 to avoid invalid keys being passed to child async instances when the parent lacks a key

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

A fix was applied to AsyncQueuer, AsyncThrottler, AsyncRateLimiter, and AsyncDebouncer to prevent propagating a truthy retryer key to child AsyncRetryer instances when the parent instance lacks a key. The change conditionally sets the retryer key to undefined instead of a string interpolation that would coerce undefined to the string "undefined". Comprehensive tests validate the corrected key propagation behavior across all four classes.

Changes

Cohort / File(s) Summary
Changeset Release Documentation
.changeset/fluffy-spies-guess.md
New changeset file documenting a patch release for @tanstack/pacer addressing the retryer key propagation fix and memory leak prevention.
Core Implementation Fixes
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
Conditional AsyncRetryer key assignment: changed from always using a template literal (coercing this.key to a string) to this.key ? \${this.key}-retryer-...` : undefined`, preventing spurious truthy keys when parent instance has no key.
Test Suite Expansions
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
Added retryer key propagation test blocks validating that child AsyncRetryer instances receive undefined keys when parent has no key, receive namespaced keys when parent has a key, and that devtools event emission is suppressed appropriately.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

Poem

🐰 A key that was lost found its way,
Through queuer and throttler's relay,
No more shall undefined masquerade,
As a string in the leaky cascade—
Memory's free, and devtools play fair!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: preventing unintended retryer key propagation when parent instances have no key.
Description check ✅ Passed The PR description is comprehensive and follows the template with all required sections: Changes section details the fix with code examples and benchmarks, Checklist items are completed, and Release Impact indicates a changeset was generated.
Linked Issues check ✅ Passed All code changes directly address Issue #198: the one-line fix is applied to all four classes (AsyncQueuer, AsyncThrottler, AsyncRateLimiter, AsyncDebouncer) to guard key propagation, and 16 comprehensive tests verify the fix prevents unintended key coercion and devtools event accumulation.
Out of Scope Changes check ✅ Passed All changes are strictly scoped to Issue #198: four source files implement the fix, a changeset file documents the release, and test files add verification. No unrelated changes detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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

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

1 participant