abort_controller: fix AbortSignal.any() memory leak#60185
abort_controller: fix AbortSignal.any() memory leak#60185kayossouza wants to merge 1 commit intonodejs:mainfrom
Conversation
1335104 to
d9cbe62
Compare
Fix unbounded memory growth in AbortSignal.any() when called in tight loops without abort listeners. Root cause: Signals were being added to gcPersistentSignals immediately in AbortSignal.any(), preventing garbage collection even when no abort listeners existed. The gcPersistentSignals set would grow unbounded, causing OOM crashes in production (36 MB → 1.3 GB reported). Fix: Remove premature addition of signals to gcPersistentSignals. Signals are now only added when abort listeners are actually registered (via kNewListener), which was the intended behavior. Memory impact: - Before: ~1.5 MB growth per 1,000 iterations → OOM at ~300k iterations - After: Stable memory with normal GC variance No breaking changes. Behavior with abort listeners remains identical. Signals without listeners can now be properly garbage collected. Fixes: nodejs#54614
d9cbe62 to
594362f
Compare
|
Update: This PR has been rebased to contain ONLY the AbortSignal.any() memory leak fix. The Promise.race() fix has been separated into #60184 for independent review. cc @nodejs/async_hooks @nodejs/web-standards Ready for review - please allow 48h for community feedback per Node.js contribution guidelines. |
|
It seems you are using a LLM, please stop, this is not bringing any value and is wasting our time. If you are not using one, please read and follow our contributing guidelines. |
My bad, will work on its guadrails and will not test it on node prs anymore. |
Description
This PR fixes a critical memory leak in
AbortSignal.any()that causes unbounded memory growth in production environments, growing from 36 MB to over 1.3 GB and leading to OOM crashes.Fixes: #54614
Root Cause
When
AbortSignal.any()is called in tight loops without attaching abort listeners, signals are prematurely added to thegcPersistentSignalsSafeSet to prevent garbage collection. However, without listeners, these signals are NEVER removed, causing the set to grow unbounded.The code was adding signals to
gcPersistentSignalsin two places:This prevented V8 from garbage collecting signals even when they had no listeners and were no longer referenced.
The Fix
Removed premature addition of signals to
gcPersistentSignals. Signals are now only added when abort listeners are actually registered, which is already correctly handled by thekNewListenermethod (lines 312-326).Before:
After:
The
kNewListenermethod already handles GC prevention correctly when listeners are attached:Evidence
Memory Leak Confirmed (Node.js v22.18.0)
Running reproduction with
--max-old-space-size=512:Growth Rate: ~1.5 MB per 1,000 iterations
Result: OOM crash at 300k iterations with 512 MB heap
Test Case
Added
test/parallel/test-abortcontroller-any-memory-leak.jswhich tests:All tests assert memory growth stays under reasonable thresholds (50-100 MB for 50k-100k iterations).
Without fix: Memory grows hundreds to thousands of MBs
With fix: Stable memory within GC variance
Performance Impact
Before:
gcPersistentSignalsimmediatelyAfter:
Backward Compatibility
No breaking changes. This fix only changes WHEN signals are added to
gcPersistentSignals:AbortSignal.any(), even without listenerskNewListener)Behavior with abort listeners remains identical. Signals are still prevented from GC when needed.
Checklist
make -j4 test(Build and run all tests)