From 251cfa25ec0346db2a0de3e720f43cd841cd463a Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 20 Feb 2026 22:27:16 -0300 Subject: [PATCH] fix(tctoken): fix 3 bugs found during code review fix(tctoken): fix 3 bugs found during code review --- src/Socket/messages-recv.ts | 45 +++++++++++++++++++++++++++++-------- src/Socket/messages-send.ts | 39 ++++++++++---------------------- src/Utils/tc-token-utils.ts | 6 +++++ 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 1d584c3c..6266f489 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1085,6 +1085,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { continue } + // Don't store timestamp-less tokens — they expire immediately and would + // corrupt a valid existing entry if one is already present + if(!incomingTs) { + continue + } + await authState.keys.set({ tctoken: { [storageJid]: { @@ -1878,22 +1884,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if(attrs.error) { if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) { const msgId = attrs.id - const jid = attrs.from + const jid = jidNormalizedUser(attrs.from) logTcToken('error_463', { jid, msgId }) // Single-retry: wait 1.5s for the server's tctoken notification to arrive, // then resend. A Set prevents infinite retry loops. - if(msgId && jid && !tcTokenRetriedMsgIds.has(msgId)) { - tcTokenRetriedMsgIds.add(msgId) - // Safety cap — prevent unbounded memory growth - if(tcTokenRetriedMsgIds.size > 500) { - tcTokenRetriedMsgIds.clear() - } + // Composite key (jid:msgId) ensures retries are isolated per destination. + const retryKey = `${jid}:${msgId}` + if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { + tcTokenRetriedMsgIds.add(retryKey) + // Each entry auto-expires after 60s — naturally bounded under normal use + setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000) ;(async () => { try { await delay(1500) - const msg = await getMessage(key) + const msg = + (await getMessage(key)) ?? + // Fallback: ack can arrive <30ms after send, before store persists + messageRetryManager?.getRecentMessage(jid, msgId)?.message if(msg) { await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true }) logTcToken('retry_463_ok', { jid, msgId }) @@ -2078,7 +2087,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } }) - ev.on('connection.update', ({ isOnline }) => { + ev.on('connection.update', ({ isOnline, connection }) => { + // Flush pending tctoken index save on disconnect to avoid writing after close + if(connection === 'close' && tcTokenIndexSaveTimer) { + clearTimeout(tcTokenIndexSaveTimer) + tcTokenIndexSaveTimer = undefined + // Await index load first — prevents overwriting a more complete persisted index + // if the connection closes before the initial load finishes. + tcTokenIndexLoaded.then(() => { + Promise.resolve(authState.keys.set({ + tctoken: { + [TC_TOKEN_INDEX_KEY]: { + token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'), + timestamp: unixTimestampSeconds().toString() + } + } + })).catch(() => { /* non-critical */ }) + }).catch(() => { /* non-critical */ }) + } + if(typeof isOnline !== 'undefined') { sendActiveReceipts = isOnline logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 303ccb8d..5a54c976 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1521,31 +1521,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { result: fetchResult, fallbackJid: destinationJid, keys: authState.keys, - getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping), - onNewJidStored: (storedJid) => { - // Fire-and-forget: persist JID into tctoken index for pruning - (async () => { - try { - const TC_IDX = '__index' - const idxData = await authState.keys.get('tctoken', [TC_IDX]) - const idxEntry = idxData[TC_IDX] - const existing: string[] = idxEntry?.token - ? JSON.parse(Buffer.from(idxEntry.token).toString('utf8')) - : [] - if(!existing.includes(storedJid)) { - existing.push(storedJid) - await authState.keys.set({ - tctoken: { - [TC_IDX]: { - token: Buffer.from(JSON.stringify(existing), 'utf8'), - timestamp: unixTimestampSeconds().toString() - } - } - }) - } - } catch { /* non-critical — index rebuilt on next pruning cycle */ } - })() - } + getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + // onNewJidStored not passed — the pruning index lives in messages-recv (higher layer) }) }) .catch(err => { @@ -1654,7 +1631,15 @@ export const makeMessagesSocket = (config: SocketConfig) => { // This ensures failed issuance allows re-issuance on the next message // rather than blocking it for up to 7 days (one bucket duration). getPrivacyTokens([destinationJid], issueTimestamp) - .then(async () => { + .then(async result => { + // Store any tokens received in the IQ response + await storeTcTokensFromIqResult({ + result, + fallbackJid: tcTokenJid, + keys: authState.keys, + getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + }) + // Re-read entry to avoid overwriting concurrent notification handler updates const currentData = await authState.keys.get('tctoken', [tcTokenJid]) const currentEntry = currentData[tcTokenJid] @@ -1701,7 +1686,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Add message to retry cache if enabled if (messageRetryManager && !participant) { - messageRetryManager.addRecentMessage(destinationJid, msgId, message) + messageRetryManager.addRecentMessage(jidNormalizedUser(destinationJid), msgId, message) } // Track session activity for cleanup (all target JIDs) diff --git a/src/Utils/tc-token-utils.ts b/src/Utils/tc-token-utils.ts index 8d3fc956..27caa7bd 100644 --- a/src/Utils/tc-token-utils.ts +++ b/src/Utils/tc-token-utils.ts @@ -148,6 +148,12 @@ export async function storeTcTokensFromIqResult({ continue } + // Don't store timestamp-less tokens at all — isTcTokenExpired treats them + // as immediately expired regardless of whether an existing entry is present + if(!incomingTs) { + continue + } + await keys.set({ tctoken: { [storageJid]: {