Skip to content

Racing immediately-resolving Promises leads to memory leak #51452

@cefn

Description

@cefn

Version

21.5.0

Platform

Linux penguin 6.1.55-06877-gc83437f2949f #1 SMP PREEMPT_DYNAMIC Thu Dec 14 19:17:39 PST 2023 x86_64 GNU/Linux

Subsystem

async_hooks or async/await

What steps will reproduce the bug?

Run the following code. Failure is quicker and less likely to interfere with system stability if you run with a low heap ceiling like this, but it will fail without...

node --max-old-space-size=16 test/examples/ephemeralPromiseMemoryLeak.js
//ephemeralPromiseMemoryLeak.js

async function promiseValue(value) {
  return value;
}

async function run() {
  for (;;) {
    await Promise.race([promiseValue("foo"), promiseValue("bar")]);
  }
}

run();

An equivalent OOM is created if you substitute Promise.any for Promise.race...

async function promiseValue(value) {
  return value;
}

async function run() {
  for (;;) {
    await Promise.any([promiseValue("foo"), promiseValue("bar")]);
  }
}

run();

How often does it reproduce? Is there a required condition?

It always fails.

What is the expected behavior? Why is that the expected behavior?

I would expect it not to accumulate references in memory and fail.

What do you see instead?

Fails with the following error

✗ node test/examples/ephemeralPromiseMemoryLeak.js

<--- Last few GCs --->

[31511:0x6de4330]    39874 ms: Mark-Compact (reduce) 2048.2 (2083.6) -> 2047.3 (2083.9) MB, 1308.67 / 0.00 ms  (average mu = 0.071, current mu = 0.001) allocation failure; scavenge might not succeed


<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----

 1: 0xcc062a node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]
 2: 0x104eb90 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node]
 3: 0x104ee77 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node]
 4: 0x126e0b5  [node]
 5: 0x126e58e  [node]
 6: 0x12837b6 v8::internal::Heap::PerformGarbageCollection(v8::internal::GarbageCollector, v8::internal::GarbageCollectionReason, char const*) [node]
 7: 0x12842d9  [node]
 8: 0x12848e8  [node]
 9: 0x19d4311  [node]
[1]    31511 abort (core dumped)  node test/examples/ephemeralPromiseMemoryLeak.js

Additional information

If the promiseValue call incorporates an explicit scheduling on the event loop, there is no memory leak...

// setImmediateNoLeak.js

function promiseValue(value) {
  return new Promise((resolve) => {
    setImmediate(() => resolve(value));
  });
}

async function run() {
  for (;;) {
    await Promise.race([promiseValue("foo"), promiseValue("bar")]);
  }
}

run();

If the promiseValue call isn't composed via a Promise.race there is no leak...

// noRaceNoLeak.js

async function promiseValue(value) {
  return value;
}

async function run() {
  for (;;) {
    await promiseValue("foo");
    await promiseValue("bar");
  }
}

run();

Maybe obviously, but putting it here for completeness, if you don't use an async await loop, but compose the loop itself with setImmediate there is no leak...

// noLoopNoLeak.js

async function promiseValue(value) {
  return value;
}

async function doRace() {
  await Promise.race([promiseValue("foo"), promiseValue("bar")]);
}

function run() {
  doRace().then(() => setImmediate(run));
}

run();

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-bugIssues with confirmed bugs.performanceIssues and PRs related to the performance of Node.js.promisesIssues and PRs related to ECMAScript promises.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions