Describe the bug
BackgroundStackRefresh.noOlderThan() in packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.ts races two promises without cleaning up the loser:
return Promise.race([
new Promise(resolve => this.queuedPromises.push(resolve)),
new Promise((_, reject) => setTimeout(() => reject(new ToolkitError(...)), ms)),
]);
Two problems:
- When the "wait for the background refresh" side wins (the common case --
cdk gc calls noOlderThan() repeatedly while polling), the setTimeout handle for the losing "reject after ms" branch is never captured, so it's never cleared. It keeps the event loop alive and its reject closure retained until it eventually fires on its own.
- When the "reject after
ms" side wins instead (a slow/stuck background refresh), the resolve callback that was pushed into this.queuedPromises for the losing branch is never removed. justRefreshedStacks() is the only thing that ever drains queuedPromises, so that stale resolver sits there forever. Under cdk gc's polling loop with many noOlderThan() calls timing out, this array grows without bound.
Expected Behavior
Whichever side of the race wins, the loser's timer should be cleared and, on a timeout, the loser's queued resolver should be removed from queuedPromises.
Observed Behavior
Neither cleanup happens today -- confirmed by reading the code and by the existing test suite's timer-count assertions used elsewhere in the same test file (jest.getTimerCount() after stop()), which show the pattern the codebase already expects timers to follow.
What's the environment?
- aws-cdk-cli main branch
- N/A (reproducible via unit test asserting
jest.getTimerCount() / queuedPromises.length across repeated noOlderThan() calls, not environment-specific)
Other
I'll follow up with a PR that restructures noOlderThan() to clear the timeout on a successful refresh and to remove the queued resolver on a timeout, plus regression tests for both.
Describe the bug
BackgroundStackRefresh.noOlderThan()inpackages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.tsraces two promises without cleaning up the loser:Two problems:
cdk gccallsnoOlderThan()repeatedly while polling), thesetTimeouthandle for the losing "reject afterms" branch is never captured, so it's never cleared. It keeps the event loop alive and itsrejectclosure retained until it eventually fires on its own.ms" side wins instead (a slow/stuck background refresh), theresolvecallback that was pushed intothis.queuedPromisesfor the losing branch is never removed.justRefreshedStacks()is the only thing that ever drainsqueuedPromises, so that stale resolver sits there forever. Undercdk gc's polling loop with manynoOlderThan()calls timing out, this array grows without bound.Expected Behavior
Whichever side of the race wins, the loser's timer should be cleared and, on a timeout, the loser's queued resolver should be removed from
queuedPromises.Observed Behavior
Neither cleanup happens today -- confirmed by reading the code and by the existing test suite's timer-count assertions used elsewhere in the same test file (
jest.getTimerCount()afterstop()), which show the pattern the codebase already expects timers to follow.What's the environment?
jest.getTimerCount()/queuedPromises.lengthacross repeatednoOlderThan()calls, not environment-specific)Other
I'll follow up with a PR that restructures
noOlderThan()to clear the timeout on a successful refresh and to remove the queued resolver on a timeout, plus regression tests for both.