Skip to content

AbortSignal.any memory leak when circular dependencies #57584

Description

@CGNonofr

Version

23.10.0

Platform

6.8.0-55-generic #57-Ubuntu SMP PREEMPT_DYNAMIC Wed Feb 12 23:42:21 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

Subsystem

No response

What steps will reproduce the bug?

class Test {
  private abortController = new AbortController()

  public test(cb: (abortSignal: AbortSignal) => void, abortSignal: AbortSignal) {
    const signal = AbortSignal.any([abortSignal, this.abortController.signal])

    cb(signal)
  }
}

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`${heldValue} has been collected`)
})

;(() => {
  const test = new Test()
  registry.register(test, 'test')

  const abortController = new AbortController()

  test.test((abortSignal) => {
    abortSignal.addEventListener('abort', () => {
      console.log(test)
    })
  }, abortController.signal)
})()

global.gc?.()

setTimeout(() => {
  console.log('the end')
}, 2000)

run with node --expose-gc test.ts

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

Every time

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

the output should be

test has been collected
the end

because the test object is not referenced anymore

What do you see instead?

output:

the end

the test object is not garbage collected

Additional information

The issues seems to be caused by the cyclic dependency: test object => abortController field => abortSignal => AbortSignal.any result => cb function => test object

AbortSignal.any seems to prevent node from garbage collecting them all at once

Activity

  1. added
    abortcontrollerIssues and PRs related to the AbortController and AbortSignal APIs.
    on Mar 26, 2025
  2. jakecastelli commented on Mar 29, 2025

    @jakecastelli
    Member

    @jazelly and myself have spent some time digging into this issue.

    We found that when a new signal is created using AbortSignal.any it will internally create a weak ref

    const resultSignalWeakRef = new SafeWeakRef(resultSignal);

    When you attempt to hold a reference by

    console.log(test)

    It would prevent each other from being collected.

    Couldn't say this is a bug but happy to hear your thoughts and discuss further.

  3. CGNonofr commented on Mar 31, 2025

    @CGNonofr
    Author

    Couldn't say this is a bug

    I fail to understand how it couldn't be considered as a bug 🤔 I had a big memory leak in my app, spent hours investigating the issue, found this, had to workaround it.

    It's completely unpredictable and leads to a memory leak

  4. gurgunday commented on Apr 15, 2025

    @gurgunday
    Member

    This is not a bug?

    In your example, you never actually abort the signal, why would it get gc'd?

  5. CGNonofr commented on Apr 15, 2025

    @CGNonofr
    Author

    I'm not sure to understand what the gc has to do with aborting the signal 🤔 aborting a signal is an exception, not the rule

    My point is: I have a legit function, that doesn't assign anything globally, and just because it needs to be abortable, there is no way to prevent it from leaking every time it's called.

    Do you think there is anything wrong with the code I've provided? because it was making my app go OOM in less than half a day, with nothing obvious to me about what's wrong

  6. gurgunday commented on Apr 15, 2025

    @gurgunday
    Member

    Oh I see the problem, sorry, I was already working on an issue about AbortSignal.any and thought this was linked to it 😆

    So yeah I think this is being caused by any adding the resulting signal to gcPersistentSignals, and it can't detect that it's out of scope

  7. jakecastelli commented on Apr 17, 2025

    @jakecastelli
    Member

    I am interested to learn what your work around is if you're willing to share of course.

    What my previous comment was trying to say that nodejs internally uses weakRef to hold a reference of the signal and when you have a circular dep (at least the V8 engine) it would give up on gc, you can reproduce it with just a WeakRef and circular deps without using AbortSignal. Unlike other circular deps (that does not evlove WeakRef that V8 can detect and reclaim the memory.

    I am also interested to see how other runtimes are dealing with this issue (e.g. deno, bun, chrome etc.), if they all have this memory issue, perhaps we should seek some help from the whatwg.

  8. CGNonofr commented on Apr 17, 2025

    @CGNonofr
    Author

    at the end, my workaround is to use a simpler custom version of the AbortSignal.any:

    function abortSignalAny(signals: AbortSignal[]): AbortSignal {
      return {
        get aborted() {
          return signals.some((s) => s.aborted)
        },
        addEventListener(type: 'abort', callback, options) {
          for (const signal of signals) {
            signal.addEventListener(type, callback, options)
          }
        },
        removeEventListener(type: 'abort', callback, options) {
          for (const signal of signals) {
            signal.removeEventListener(type, callback, options)
          }
        },
        dispatchEvent() {
          throw new Error('Unsupported')
        },
        onabort() {
          throw new Error('Unsupported')
        },
        get reason() {
          return signals.find((s) => s.reason != null)?.reason
        },
        throwIfAborted() {
          for (const signal of signals) {
            signal.throwIfAborted()
          }
        }
      }
    }

    There are some downsides, which are not really an issue for me though:

    • signal.reason (and throwIfAborted) returns the reason of the first signal in the list, instead of the first triggered signal
    • probably others?
  9. alexey-sh commented on Jun 1, 2025

    @alexey-sh

    Does it mean that removing abort stuff from an app would fix unexpected memory growing?

  10. CGNonofr commented on Jun 1, 2025

    @CGNonofr
    Author

    YES

  11. github-actions commented on Apr 20, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 210 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  12. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Apr 20, 2026
  13. github-actions commented on May 21, 2026

    @github-actions
    Contributor

    This issue has been automatically closed after 30 days of inactivity following its stale status (no activity for a total of 240 days).
    If this is still relevant, feel free to reopen it or leave a comment with additional details so we can continue the discussion.

  14. CGNonofr commented on May 21, 2026

    @CGNonofr
    Author

    All the details are already there 🤔

  15. removed
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on May 25, 2026
  16. github-actions commented on Aug 24, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 90 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  17. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Aug 24, 2026
  18. CGNonofr commented on Aug 24, 2026

    @CGNonofr
    Author

    No reason to think it's not relevant anymore 🤷

  19. removed
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    abortcontrollerIssues and PRs related to the AbortController and AbortSignal APIs.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions