From 08cad354a728362300bda843646bea46970c12f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Feb 2026 03:11:26 +0000 Subject: [PATCH 1/3] fix(auth-utils): prevent transactions after destroy with destroyed flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL FIX: Adds destroyed flag to transaction capability to prevent use-after-free crashes when transactions are initiated after socket.end() is called. Changes: - Add destroyed flag set to true at start of destroy() - Check destroyed flag in transaction() and throw error if set - Reorder destroy() to check locked mutexes BEFORE destroying resources - Skip resource destruction if any mutexes are locked (prevents corrupted state) This prevents 4 critical race conditions: 1. sendMessage() after end() (messages-send.ts:868) 2. sendRetryRequest() after end() (messages-recv.ts:498) 3. resyncAppState() after end() (chats.ts:476, 769) 4. LID mapping operations after end() (lid-mapping.ts:417) Timeline before fix: T0: sendMessage() called T1: end() sets closed=true T2: end() awaits uploadPreKeysPromise (5s) T3: sendMessage() calls keys.transaction() ← NO GUARD T4: keys.destroy() executes T5: Transaction crashes (resources destroyed) Timeline after fix: T0: sendMessage() called T1: end() → destroy() sets destroyed=true T2: sendMessage() → transaction() → throws error ✓ https://claude.ai/code/session_VMxqX --- src/Utils/auth-utils.ts | 58 +++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src/Utils/auth-utils.ts b/src/Utils/auth-utils.ts index a65cf403..715e2ea9 100644 --- a/src/Utils/auth-utils.ts +++ b/src/Utils/auth-utils.ts @@ -129,6 +129,9 @@ export const addTransactionCapability = ( // Pre-key manager for specialized operations const preKeyManager = new PreKeyManager(state, logger) + // Destroyed flag to prevent operations after cleanup + let destroyed = false + /** * Get or create a queue for a specific key type */ @@ -300,6 +303,11 @@ export const addTransactionCapability = ( isInTransaction, transaction: async (work, key) => { + // CRITICAL: Prevent transactions after destroy to avoid use-after-free + if (destroyed) { + throw new Error('Transaction capability destroyed - socket closed') + } + const existing = txStorage.getStore() // Nested transaction - reuse existing context @@ -346,7 +354,36 @@ export const addTransactionCapability = ( * Should be called during connection cleanup */ destroy: () => { + // CRITICAL: Set destroyed flag FIRST to prevent new transactions + destroyed = true logger.debug('🗑️ Cleaning up transaction capability resources') + + // Count locked mutexes BEFORE destroying resources + let clearedCount = 0 + let lockedCount = 0 + const lockedKeys: string[] = [] + + txMutexes.forEach((mutex, key) => { + if (!mutex.isLocked()) { + txMutexes.delete(key) + txMutexRefCounts.delete(key) + clearedCount++ + } else { + lockedCount++ + lockedKeys.push(key) + } + }) + + // If there are locked mutexes, log error and skip resource destruction + if (lockedCount > 0) { + logger.error( + { lockedCount, lockedKeys }, + '❌ Cannot destroy resources - transactions still active! Resources will be cleaned up by GC.' + ) + return + } + + // Safe to destroy resources (no active transactions) preKeyManager.destroy() // Clear all key queues @@ -357,26 +394,7 @@ export const addTransactionCapability = ( }) keyQueues.clear() - // Clear transaction mutexes and reference counts - // CRITICAL: Only delete unlocked mutexes to prevent corrupting active transactions - let clearedCount = 0 - let lockedCount = 0 - txMutexes.forEach((mutex, key) => { - if (!mutex.isLocked()) { - txMutexes.delete(key) - txMutexRefCounts.delete(key) - clearedCount++ - } else { - lockedCount++ - logger.warn( - { key }, - 'Transaction mutex still locked during cleanup - transaction may be in progress' - ) - } - }) - logger.debug({ clearedCount, lockedCount }, 'Transaction mutexes cleanup completed') - - logger.debug('Transaction capability cleanup completed') + logger.debug({ clearedCount }, 'Transaction capability cleanup completed') } } } From f2e9701b9cbf87989fcde600a9f506b31c3c6283 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Feb 2026 03:11:41 +0000 Subject: [PATCH 2/3] fix(socket): move PreKey sync reschedule check inside finally block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimizes race window between isRunning=false and reschedule check by moving the setTimeout reschedule logic inside the finally block. Changes: - Move reschedule check (if !closed && !cleanedUp && ws.isOpen) from after finally block to inside finally block - Reduces race window where end() could be called between finally and check Timeline before fix: T0: syncLoop finally executes → isRunning = false T1: end() called → closed = true T2: syncLoop checks if (!closed) ← sees false T3: setTimeout scheduled ← orphaned timer T4: cleanupPreKeyAutoSync() clears it Timeline after fix: T0: syncLoop finally executes → isRunning = false T1: Immediately checks flags INSIDE finally (atomic) T2: Window too small for race condition Risk: LOW (cleanup function already handles orphaned timers) Impact: Cleaner code, minimizes theoretical race window https://claude.ai/code/session_VMxqX --- src/Socket/socket.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 7467e8f0..64bea367 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -766,12 +766,12 @@ export const makeSocket = (config: SocketConfig) => { logger.error({ error }, '🔑 PreKey auto-sync failed') } finally { isRunning = false - } - // PROTECTION 3: Prevent timer accumulation and post-cleanup rescheduling - // Check cleanedUp flag atomically to prevent race condition - if (!closed && !cleanedUp && ws.isOpen) { - syncTimer = setTimeout(syncLoop, SYNC_INTERVAL) + // PROTECTION 3: Prevent timer accumulation and post-cleanup rescheduling + // Check cleanedUp flag atomically INSIDE finally to minimize race window + if (!closed && !cleanedUp && ws.isOpen) { + syncTimer = setTimeout(syncLoop, SYNC_INTERVAL) + } } } From 209a55a8b7865101b2ac171b2caf223650731539 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Feb 2026 03:11:57 +0000 Subject: [PATCH 3/3] fix(messages-recv): wrap session deletions in transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL FIX: Wraps session deletion operations in transactions to prevent race conditions with concurrent session operations. Changes: - Wrap session deletion at line 468 (incoming retry) in transaction - Wrap session deletion at line 1026 (outgoing retry) in transaction - Use transaction key format: delete-session-${sessionId} Problem before fix: Session deletions happened OUTSIDE transactions while other operations INSIDE transactions could be reading/writing the same session key. Timeline of race condition: T0: Message A arrives → processingMutex.mutex() T1: Transaction started → reads session X T2: Message B (retry) → shouldRecreateSession() T3: Message B deletes session X ← OUTSIDE transaction T4: Message A tries to use session X in transaction T5: Session doesn't exist → decryption failure T6: Message A lost After fix: All session operations (read/write/delete) are serialized via transactions, preventing concurrent access and data corruption. https://claude.ai/code/session_VMxqX --- src/Socket/messages-recv.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index d499a88c..60acb489 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -465,7 +465,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if (shouldRecreateSession) { logger.debug({ fromJid, retryCount, reason: recreateReason, errorCode }, 'recreating session for retry') // Delete existing session to force recreation - await authState.keys.set({ session: { [sessionId]: null } }) + // CRITICAL: Wrap in transaction to prevent race with other session operations + await authState.keys.transaction(async () => { + await authState.keys.set({ session: { [sessionId]: null } }) + }, `delete-session-${sessionId}`) forceIncludeKeys = true } } catch (error) { @@ -1023,7 +1026,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if (shouldRecreateSession) { logger.debug({ participant, retryCount, reason: recreateReason, errorCode }, 'recreating session for outgoing retry') - await authState.keys.set({ session: { [sessionId]: null } }) + // CRITICAL: Wrap in transaction to prevent race with other session operations + await authState.keys.transaction(async () => { + await authState.keys.set({ session: { [sessionId]: null } }) + }, `delete-session-${sessionId}`) } } catch (error) { logger.warn({ error, participant }, 'failed to check session recreation for outgoing retry')