diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 4c8f97a9..820737ff 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -60,6 +60,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { fireInitQueries: true, auth: undefined as unknown as AuthenticationState, markOnlineOnConnect: true, + // Set to false if you don't need full message history (reduces bandwidth/storage) syncFullHistory: true, patchMessageBeforeSending: msg => msg, shouldSyncHistoryMessage: () => true, @@ -79,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 } = { @@ -123,14 +128,50 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MIN_PREKEY_COUNT = 5 -export const INITIAL_PREKEY_COUNT = 812 +// 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 -export const MIN_UPLOAD_INTERVAL = 5000 // 5 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) + */ 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 + * + * 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, + 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 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 } 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