fix: guard retryer key propagation when parent has no key#199
fix: guard retryer key propagation when parent has no key#199simonmeyerrr wants to merge 4 commits intoTanStack:mainfrom
Conversation
📝 WalkthroughWalkthroughA fix was applied to Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~15 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
packages/pacer/tests/async-debouncer.test.ts (1)
1364-1446: Tests look good — minor placement nit.This
retryer key propagationblock is nested inside thedescribe('asyncDebounce helper function', ...)suite, but the tests exercise theAsyncDebouncerclass directly (not theasyncDebouncehelper). Consider moving it into the outerdescribe('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) constructsAsyncRetryerby passingthis.options.asyncRetryerOptionsdirectly, without the${this.key}-retryer-${...}namespacing used here. It does not have the memory-leak bug (no truthy key is ever propagated), but a keyedAsyncBatcherwon'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
emitcalls by event name'AsyncRetryer'is sufficient to prove the immediate leak path is gone, but the underlying issue#198was that retryers were also registered as devtools instances and listeners. If you want to harden this against future regressions, consider additionally asserting thatpacerEventClient.on/registerPacerDevtoolsInstanceis 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 parentdescribe.The new
describe('retryer key propagation', ...)block is placed insidedescribe('asyncRateLimit', ...)(the factory-function suite, which starts at line 694), but every test exercises theAsyncRateLimiterclass directly. It should live underdescribe('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
AsyncRateLimitersub-suites (e.g., just before the closing})of theAsyncRateLimiterdescribe at line 692), rather than inside theasyncRateLimitdescribe.🤖 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
📒 Files selected for processing (9)
.changeset/fluffy-spies-guess.mdpackages/pacer/src/async-debouncer.tspackages/pacer/src/async-queuer.tspackages/pacer/src/async-rate-limiter.tspackages/pacer/src/async-throttler.tspackages/pacer/tests/async-debouncer.test.tspackages/pacer/tests/async-queuer.test.tspackages/pacer/tests/async-rate-limiter.test.tspackages/pacer/tests/async-throttler.test.ts
🎯 Changes
Fixes #198
When
AsyncQueuer,AsyncThrottler,AsyncRateLimiter, orAsyncDebouncerare created without akey(the common case), they propagate a truthy key like"undefined-retryer-1"to every childAsyncRetryervia template literal coercion ofundefined. This causespacerEventClient.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:
Benchmark (1,000 rounds × 100 items, Node.js 22,
--expose-gc):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):
AsyncRetryergetskey: undefinedwhen parent has no key (inspected mid-execution via controlled promise)AsyncRetryergets namespaced key when parent has a keypacerEventClient.emit()is never called withAsyncRetryerevents when parent has no keypacerEventClient.emit()is called withAsyncRetryerevents when parent has a key✅ Checklist
pnpm run test:pr.🚀 Release Impact
Summary by CodeRabbit