From 682b62acbf8c7490346c472dc0d93da5c8e40737 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 05:12:05 +0000 Subject: [PATCH 1/3] feat(defaults): apply RSocket's battle-tested configurations - Add DEFAULT_CACHE_MAX_KEYS with limits per store type (prevents memory leaks) - Add RETRY_BACKOFF_DELAYS [1s, 2s, 5s, 10s, 20s] for exponential backoff - Add RETRY_JITTER_FACTOR (0.15) to prevent thundering herd - Change INITIAL_PREKEY_COUNT from 812 to 30 (safer for rate limiting) - Change MIN_UPLOAD_INTERVAL from 5s to 60s (avoids rate limiting) - Change syncFullHistory default to false (conservative approach) Based on RSocket fork's production-tested configuration. --- src/Defaults/index.ts | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 4c8f97a9..ea0c3c5d 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -60,7 +60,8 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { fireInitQueries: true, auth: undefined as unknown as AuthenticationState, markOnlineOnConnect: true, - syncFullHistory: true, + // Conservative default - set to true only if you need full message history + syncFullHistory: false, patchMessageBeforeSending: msg => msg, shouldSyncHistoryMessage: () => true, shouldIgnoreJid: () => false, @@ -123,14 +124,45 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MIN_PREKEY_COUNT = 5 -export const INITIAL_PREKEY_COUNT = 812 +// Conservative prekey count (upstream uses 812, but 30 is safer for rate limiting) +export const INITIAL_PREKEY_COUNT = 30 export const UPLOAD_TIMEOUT = 30000 // 30 seconds -export const MIN_UPLOAD_INTERVAL = 5000 // 5 seconds minimum between uploads +// Conservative upload interval to avoid rate limiting (was 5000) +export const MIN_UPLOAD_INTERVAL = 60_000 // 60 seconds minimum between uploads +/** + * Cache TTL configuration (in seconds) + */ export const DEFAULT_CACHE_TTLS = { SIGNAL_STORE: 5 * 60, // 5 minutes MSG_RETRY: 60 * 60, // 1 hour CALL_OFFER: 5 * 60, // 5 minutes USER_DEVICES: 5 * 60 // 5 minutes } + +/** + * Maximum cache keys per store type - prevents memory leaks + * Based on RSocket's battle-tested configuration + */ +export const DEFAULT_CACHE_MAX_KEYS = { + SIGNAL_STORE: 10_000, + MSG_RETRY: 10_000, + CALL_OFFER: 500, + USER_DEVICES: 5_000, + PLACEHOLDER_RESEND: 5_000, + LID_PER_SOCKET: 2_000, + LID_GLOBAL: 10_000 +} + +/** + * Retry configuration with exponential backoff + * Delays in milliseconds: 1s, 2s, 5s, 10s, 20s + */ +export const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] + +/** + * Jitter factor for retry delays (0.15 = ±15% randomization) + * Helps prevent thundering herd problem + */ +export const RETRY_JITTER_FACTOR = 0.15 From 6f75e5ac21c741a0f5ce8268fb1033a9a29bdc0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 05:19:22 +0000 Subject: [PATCH 2/3] feat(socket): add configurable maxListeners to prevent memory leaks Based on RSocket's battle-tested configuration: - Add maxWebSocketListeners config option (default: 20) - 8 base WS events + 10 dynamic listeners + 2 buffer slots - Add maxSocketClientListeners config option (default: 50) - Replace dangerous setMaxListeners(0) with configurable limits - Add warning log if user explicitly sets limit to 0 BREAKING: Previous behavior used setMaxListeners(0) which removed all limits. Now defaults to safe limits but can be overridden via config. --- src/Defaults/index.ts | 6 +++++- src/Socket/Client/types.ts | 8 +++++++- src/Socket/Client/websocket.ts | 8 +++++++- src/Types/Socket.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index ea0c3c5d..006f9bb4 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -80,7 +80,11 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { cachedGroupMetadata: async () => undefined, makeSignalRepository: makeLibSignalRepository, // Circuit breaker configuration - enableCircuitBreaker: true + enableCircuitBreaker: true, + // Listener limits (memory leak prevention) + // 8 base WS events + 10 dynamic listeners + 2 buffer slots + maxWebSocketListeners: 20, + maxSocketClientListeners: 50 } export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { diff --git a/src/Socket/Client/types.ts b/src/Socket/Client/types.ts index 3a6ee818..cc7c7024 100644 --- a/src/Socket/Client/types.ts +++ b/src/Socket/Client/types.ts @@ -13,7 +13,13 @@ export abstract class AbstractSocketClient extends EventEmitter { public config: SocketConfig ) { super() - this.setMaxListeners(0) + // Set max listeners from config (default: 50) + // WARNING: 0 disables limit and allows potential memory leaks + const maxListeners = this.config.maxSocketClientListeners ?? 50 + if (maxListeners === 0) { + this.config.logger?.warn('SocketClient setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!') + } + this.setMaxListeners(maxListeners) } abstract connect(): void diff --git a/src/Socket/Client/websocket.ts b/src/Socket/Client/websocket.ts index 6aab1e3c..59632cd2 100644 --- a/src/Socket/Client/websocket.ts +++ b/src/Socket/Client/websocket.ts @@ -31,7 +31,13 @@ export class WebSocketClient extends AbstractSocketClient { agent: this.config.agent }) - this.socket.setMaxListeners(0) + // Set max listeners from config (default: 20) + // WARNING: 0 disables limit and allows potential memory leaks + const maxListeners = this.config.maxWebSocketListeners ?? 20 + if (maxListeners === 0) { + this.config.logger?.warn('WebSocket setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!') + } + this.socket.setMaxListeners(maxListeners) const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response'] diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 62ab06b5..5d98c075 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -165,4 +165,19 @@ export type SocketConfig = { /** Circuit breaker configuration for message operations */ messageCircuitBreaker?: Partial + + // === Listener Limits (Memory Leak Prevention) === + + /** + * Max WebSocket event listeners (default: 20) + * Calculated as: 8 base WS events + 10 dynamic listeners + 2 buffer slots + * WARNING: Setting to 0 disables limit and allows potential memory leaks! + */ + maxWebSocketListeners?: number + + /** + * Max SocketClient event listeners (default: 50) + * WARNING: Setting to 0 disables limit and allows potential memory leaks! + */ + maxSocketClientListeners?: number } From e605a025286227d49147f1c02df9a312ac9508e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 05:33:03 +0000 Subject: [PATCH 3/3] fix(defaults): address PR #10 code review feedback - Integrate RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR into retry-utils.ts - Add retryConfigs.rsocket using the default delays - Add getRetryDelayWithJitter() helper function - Add getAllRetryDelaysWithJitter() for planning - Document DEFAULT_CACHE_MAX_KEYS with usage example - Adjust INITIAL_PREKEY_COUNT from 30 to 200 (moderate value) - Adjust MIN_UPLOAD_INTERVAL from 60s to 10s (balance rate limiting/responsiveness) - Revert syncFullHistory to true (avoid breaking change) --- src/Defaults/index.ts | 17 +++++++++++------ src/Utils/retry-utils.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 006f9bb4..820737ff 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -60,8 +60,8 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { fireInitQueries: true, auth: undefined as unknown as AuthenticationState, markOnlineOnConnect: true, - // Conservative default - set to true only if you need full message history - syncFullHistory: false, + // Set to false if you don't need full message history (reduces bandwidth/storage) + syncFullHistory: true, patchMessageBeforeSending: msg => msg, shouldSyncHistoryMessage: () => true, shouldIgnoreJid: () => false, @@ -128,12 +128,12 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MIN_PREKEY_COUNT = 5 -// Conservative prekey count (upstream uses 812, but 30 is safer for rate limiting) -export const INITIAL_PREKEY_COUNT = 30 +// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability) +export const INITIAL_PREKEY_COUNT = 200 export const UPLOAD_TIMEOUT = 30000 // 30 seconds -// Conservative upload interval to avoid rate limiting (was 5000) -export const MIN_UPLOAD_INTERVAL = 60_000 // 60 seconds minimum between uploads +// Moderate upload interval to balance rate limiting and responsiveness (was 5000) +export const MIN_UPLOAD_INTERVAL = 10_000 // 10 seconds minimum between uploads /** * Cache TTL configuration (in seconds) @@ -148,6 +148,11 @@ export const DEFAULT_CACHE_TTLS = { /** * Maximum cache keys per store type - prevents memory leaks * Based on RSocket's battle-tested configuration + * + * Usage: Use these limits when initializing LRU caches to prevent unbounded growth + * Example: + * import { DEFAULT_CACHE_MAX_KEYS } from './Defaults' + * const cache = new LRUCache({ max: DEFAULT_CACHE_MAX_KEYS.SIGNAL_STORE }) */ export const DEFAULT_CACHE_MAX_KEYS = { SIGNAL_STORE: 10_000, diff --git a/src/Utils/retry-utils.ts b/src/Utils/retry-utils.ts index 68edcb88..72677105 100644 --- a/src/Utils/retry-utils.ts +++ b/src/Utils/retry-utils.ts @@ -16,6 +16,7 @@ import { EventEmitter } from 'events' import { metrics } from './prometheus-metrics.js' import type { CircuitBreaker } from './circuit-breaker.js' +import { RETRY_BACKOFF_DELAYS, RETRY_JITTER_FACTOR } from '../Defaults/index.js' /** * Backoff strategies @@ -631,6 +632,41 @@ export const retryConfigs = { jitter: 0.1, shouldRetry: retryPredicates.onNetworkError, }, + + /** + * RSocket-style retry (uses RETRY_BACKOFF_DELAYS from Defaults) + * Delays: 1s, 2s, 5s, 10s, 20s with jitter + */ + rsocket: { + maxAttempts: RETRY_BACKOFF_DELAYS.length, + baseDelay: RETRY_BACKOFF_DELAYS[0], + maxDelay: RETRY_BACKOFF_DELAYS[RETRY_BACKOFF_DELAYS.length - 1], + backoffStrategy: 'exponential' as const, + jitter: RETRY_JITTER_FACTOR, + }, +} + +/** + * Get retry delay with jitter applied + * Uses RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR from Defaults + * + * @param attempt - Current attempt number (1-based) + * @returns Delay in ms with jitter applied + */ +export function getRetryDelayWithJitter(attempt: number): number { + const index = Math.min(Math.max(attempt - 1, 0), RETRY_BACKOFF_DELAYS.length - 1) + const baseDelay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? 1000 + const jitterRange = baseDelay * RETRY_JITTER_FACTOR + const jitter = (Math.random() * 2 - 1) * jitterRange // ±15% + return Math.round(baseDelay + jitter) +} + +/** + * Get all retry delays with jitter for planning + * @returns Array of delays with jitter applied + */ +export function getAllRetryDelaysWithJitter(): number[] { + return RETRY_BACKOFF_DELAYS.map((_, i) => getRetryDelayWithJitter(i + 1)) } export default retry