Skip to content

Memory leak in AbortSignal.timeout and aborted #48951

Closed
@rluvaton

Description

@rluvaton

Version

20.5.0,18.16.0

Platform

Darwin razluvaXFX99QJK 22.5.0 Darwin Kernel Version 22.5.0: Thu Jun 8 22:22:20 PDT 2023; root:xnu-8796.121.3~7/RELEASE_ARM64_T6000 arm64

Subsystem

events

What steps will reproduce the bug?

Run this with the flag --expose-gc

// Flags: --expose-gc

const {setImmediate} = require('timers/promises');
const {aborted} = require('util');

const formatMemoryUsage = (data) => `${Math.round(data / 1024 / 1024 * 100) / 100} MB`;

function logMemory() {

  const memoryData = process.memoryUsage();

  const memoryUsage = {
    rss: `${formatMemoryUsage(memoryData.rss)} -> Resident Set Size - total memory allocated for the process execution`,
    heapTotal: `${formatMemoryUsage(memoryData.heapTotal)} -> total size of the allocated heap`,
    heapUsed: `${formatMemoryUsage(memoryData.heapUsed)} -> actual memory used during the execution`,
    external: `${formatMemoryUsage(memoryData.external)} -> V8 external memory`,
  };

  console.log(memoryUsage);
}

(async () => {

  while (true) {
    for (let i = 0; i < 10000; i++) {
      function lis() {

      }

      const timeoutSignal = AbortSignal.timeout(1_000_000_000);
      timeoutSignal.addEventListener('abort', lis);
      aborted(timeoutSignal, {});
      timeoutSignal.removeEventListener('abort', lis);
    }

    await setImmediate();
    global.gc();
  }

})().catch(console.error)

setInterval(() => {
  logMemory();
}, 1000);

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

always.

required conditions are to add the aborted after the regular listener and remove the regular listener after the aborted

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

no memory leak

What do you see instead?

memory leak

Additional information

this is happening because:

  1. the regular listener goes to here which add to the map the aborted function add weak listener:
    gcPersistentSignals.add(this);
  2. calling the aborted function add the listener but as weak listener
  3. when the listener is garbage collected we call remove on the listener:
    (listener) => listener.remove(),
  4. the remove does not call the removeEventListener which decreases the size:
  5. because the size is not decreased it will never reach 0 so the abort signal won't get GCed
    if (isTimeoutOrNonEmptyCompositeSignal && type === 'abort' && size === 0) {
    gcPersistentSignals.delete(this);
    }

This is also the reason why calling aborted on the same signal and garbage collecting still emit the max listener warning

Metadata

Metadata

Assignees

No one assigned

    Labels

    abortcontrollerIssues and PRs related to the AbortController APIconfirmed-bugIssues with confirmed bugs.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions