Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Aggressive memory leak in Repeater.race #65

Closed
brainkim opened this issue Aug 30, 2020 · 2 comments · Fixed by #66
Closed

Aggressive memory leak in Repeater.race #65

brainkim opened this issue Aug 30, 2020 · 2 comments · Fixed by #66

Comments

@brainkim
Copy link
Member

brainkim commented Aug 30, 2020

Reported by @elderapo.
When Repeater.race is called with an iterator and a promise which doesn’t settle, memory usage grows until the process crashes.

Reproduction:

import * as crypto from "crypto";
import {Repeater} from "@repeaterjs/repeater";
async function randomString(): Promise<string> {
  await new Promise((resolve) => setTimeout(resolve, 1));
  return crypto.randomBytes(10000).toString("hex")
}

async function *createStrings() {
  while (true) {
    yield randomString();
  }
}

function map<T, U>(iter: AsyncIterable<T>, fn: (value: T) => U): AsyncGenerator<U> {
  return new Repeater(async (push, stop) => {
    for await (const value of Repeater.race([iter, stop])) {
      push(fn(value as T) as U);
    }

    stop();
  });
}


(async function main() {
  let i = 0;
  for await (const value of map(createStrings(), (value) => value.split(""))) {
    if (i++ % 1000 === 0) {
      const usage = process.memoryUsage();
      const rssMiB = Math.round(usage.rss / (1024 ** 2) * 100) / 100;
      console.log(`Resident Set: ${rssMiB} MiB`);
    }
  }
})();

This will log an increasing resident set size until the process runs out of memory. It seems to be exacerbated by what is passed through the iterator which is racing the stop promise.

@brainkim
Copy link
Member Author

Initial investigation shows that calling Promise.race with an iteration and the stop promise (or any promise which does not settle) spawns a promise reaction for the stop promise, and this reaction retains the paired iteration. This is a consequence of the implementation of Promise.race, which internally is implemented by calling then on each of its contenders. What seems to be happening is that even if a promise loses, the reaction which was created by Promise.race is retained until the original promise settles, and that reaction was created in the context of the other promise.

Part of me wonders if Repeater.race is fundamentally flawed, another part of me is frustrated to come across yet another promise weirdness. More investigation needed.

Relevant issues:
nodejs/node#17469
https://bugs.chromium.org/p/v8/issues/detail?id=9858

@brainkim
Copy link
Member Author

Here’s a reproduction of the leak without repeaters:

async function randomString(): Promise<string> {
  await new Promise((resolve) => setTimeout(resolve, 1));
  return crypto.randomBytes(10000).toString("hex")
}

async function *createStrings() {
  while (true) {
    yield randomString();
  }
}

(async function main() {
  let i = 0;
  let iteration: IteratorResult<string>;
  const iterator = createStrings();
  const pending: Promise<never> = new Promise(() => {});
  do {
    iteration = await Promise.race([pending, iterator.next()]);
    if (i++ % 1000 === 0) {
      const usage = process.memoryUsage();
      const rssMiB = Math.round(usage.rss / (1024 ** 2) * 100) / 100;
      console.log(`Resident Set: ${rssMiB} MiB`);
    }
  } while (!iteration.done);
})();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant