From 1e6f65cf5e136510287a8935d01a1a12a4be75f6 Mon Sep 17 00:00:00 2001 From: YonkoSam <161728760+YonkoSam@users.noreply.github.com> Date: Fri, 12 Dec 2025 03:35:24 +0100 Subject: [PATCH] fix Memory leak in makeMutex - Promise never gets garbage collected (#2151) * Memory leak in makeMutex - Promise never gets garbage collected Hey, I've been debugging a memory leak in my application and traced it back to the makeMutex implementation. The current implementation chains promises indefinitely without ever breaking the chain: Every call to mutex() creates a new Promise that awaits the previous task, then becomes the new task. The problem is the old promises never get released because each one holds a reference to the previous through the closure. What I found Took a heap snapshot after running for a while and found hundreds of Promises from make-mutex.js holding ~15MB and growing. The retainer graph shows a long chain of Promises all pointing back to each other. Since processingMutex handles every incoming message/notification, this chain grows constantly and never shrinks. This keeps the same mutex behavior but lets the GC clean up old promises every 50 tasks instead of holding them forever. * Refactor makeMutex to use AsyncMutex directly * lint * revert --- src/Socket/messages-recv.ts | 2 +- src/Utils/make-mutex.ts | 51 +++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 7f787c6d..cd237792 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -153,7 +153,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await delay(5000) - if (!await placeholderResendCache.get(messageKey?.id!)) { + if (!(await placeholderResendCache.get(messageKey?.id!))) { logger.debug({ messageKey }, 'message received while resend requested') return 'RESOLVED' } diff --git a/src/Utils/make-mutex.ts b/src/Utils/make-mutex.ts index c7601568..13b34880 100644 --- a/src/Utils/make-mutex.ts +++ b/src/Utils/make-mutex.ts @@ -1,29 +1,11 @@ -export const makeMutex = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let task = Promise.resolve() as Promise +import { Mutex as AsyncMutex } from 'async-mutex' - let taskTimeout: NodeJS.Timeout | undefined +export const makeMutex = () => { + const mutex = new AsyncMutex() return { mutex(code: () => Promise | T): Promise { - task = (async () => { - // wait for the previous task to complete - // if there is an error, we swallow so as to not block the queue - try { - await task - } catch {} - - try { - // execute the current task - const result = await code() - return result - } finally { - clearTimeout(taskTimeout) - } - })() - // we replace the existing task, appending the new piece of execution to it - // so the next task will have to wait for this one to finish - return task + return mutex.runExclusive(code) } } } @@ -31,15 +13,30 @@ export const makeMutex = () => { export type Mutex = ReturnType export const makeKeyedMutex = () => { - const map: { [id: string]: Mutex } = {} + const map = new Map() return { - mutex(key: string, task: () => Promise | T): Promise { - if (!map[key]) { - map[key] = makeMutex() + async mutex(key: string, task: () => Promise | T): Promise { + let entry = map.get(key) + + if (!entry) { + entry = { mutex: new AsyncMutex(), refCount: 0 } + map.set(key, entry) } - return map[key].mutex(task) + entry.refCount++ + + try { + return await entry.mutex.runExclusive(task) + } finally { + entry.refCount-- + // only delete it if this is still the current entry + if (entry.refCount === 0 && map.get(key) === entry) { + map.delete(key) + } + } } } } + +export type KeyedMutex = ReturnType