Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix: skip JS callback for settled Promise.race losers
When Promise.race() or Promise.any() settles, V8 fires
kPromiseResolveAfterResolved / kPromiseRejectAfterResolved for each
"losing" promise. The PromiseRejectCallback in node_task_queue.cc was
crossing into JS for these events, but since the multipleResolves
event reached EOL in v25 (PR #58707), the JS handler does nothing.

The unnecessary C++-to-JS boundary crossings accumulate references in
a tight loop, causing OOM when using Promise.race() with
immediately-resolving promises.

Return early in PromiseRejectCallback() for these two events, skipping
the JS callback entirely. Also remove the dead case branches and
unused constant imports from the JS side.

Fixes: #51452
Refs: #60184
Refs: #61960
  • Loading branch information
Felipeness committed Mar 19, 2026
commit 7033537a17ff22c18a94a3057464143123fba12f
13 changes: 3 additions & 10 deletions lib/internal/process/promises.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ const {
promiseRejectEvents: {
kPromiseRejectWithNoHandler,
kPromiseHandlerAddedAfterReject,
kPromiseRejectAfterResolved,
kPromiseResolveAfterResolved,
},
setPromiseRejectCallback,
} = internalBinding('task_queue');
Expand Down Expand Up @@ -168,14 +166,9 @@ function promiseRejectHandler(type, promise, reason) {
case kPromiseHandlerAddedAfterReject: // 1
handledRejection(promise);
break;
case kPromiseRejectAfterResolved: // 2
// Do nothing in this case. Previous we would emit a multipleResolves
// event but that was deprecated then later removed.
break;
case kPromiseResolveAfterResolved: // 3
// Do nothing in this case. Previous we would emit a multipleResolves
// event but that was deprecated then later removed.
break;
// kPromiseRejectAfterResolved and kPromiseResolveAfterResolved are
// handled in C++ (src/node_task_queue.cc) by returning early, so they
// never reach this JS callback. No case branches needed.
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/node_task_queue.cc
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,14 @@ void PromiseRejectCallback(PromiseRejectMessage message) {
"unhandled", unhandledRejections,
"handledAfter", rejectionsHandledAfter);
} else if (event == kPromiseResolveAfterResolved) {
value = message.GetValue();
// Intentional no-op. The multipleResolves event was EOL'd in v25
// (PR #58707), and the JS handler does nothing for these events.
// Skipping the JS callback avoids accumulating C++-to-JS boundary
// references in tight Promise.race()/Promise.any() loops, which
// previously caused OOM (see #51452).
return;
} else if (event == kPromiseRejectAfterResolved) {
value = message.GetValue();
return;
} else {
return;
}
Expand Down
24 changes: 24 additions & 0 deletions test/parallel/test-promise-race-memory-leak.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Flags: --max-old-space-size=20
'use strict';

// Regression test for https://github.com/nodejs/node/issues/51452
// When Promise.race() settles, V8 fires kPromiseResolveAfterResolved /
// kPromiseRejectAfterResolved for each "losing" promise. Before this fix,
// the C++ PromiseRejectCallback crossed into JS for these no-op events,
// accumulating references and causing OOM in tight async loops.
// With --max-old-space-size=20, this test would crash before completing
// if the leak is present.

const common = require('../common');

async function main() {
for (let i = 0; i < 100_000; i++) {
await Promise.race([
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
]);
}
}

main().then(common.mustCall());