From e093eb3f64c717f950e28aec2ae16408270a1df2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 29 Jan 2026 12:08:28 +0530 Subject: [PATCH] Enhance TelegramProvider and MTProtoService for improved connection management - Introduced exponential backoff for connection retries in TelegramProvider, enhancing resilience during connection failures. - Refactored MTProtoService to manage concurrent initialization and connection calls, preventing duplicate requests. - Updated Redux store configuration to reset volatile fields on rehydrate, ensuring clean state management for Telegram sessions. - Enhanced error handling in Telegram thunks to ensure proper initialization before connection attempts. This update significantly improves the reliability and user experience of the Telegram connection process, ensuring smoother interactions and better state management. --- .gitignore | 1 + src/providers/TelegramProvider.tsx | 85 ++++++++++++++++++++++-------- src/services/mtprotoService.ts | 59 ++++++++++++++++++--- src/store/index.ts | 31 +++++++++++ src/store/telegram/thunks.ts | 21 ++++++++ 5 files changed, 167 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index c4a1bf9cf..1ec9a9cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr *.njsproj *.sln *.sw? +references/ diff --git a/src/providers/TelegramProvider.tsx b/src/providers/TelegramProvider.tsx index bbd3ca31c..26cd7f3dd 100644 --- a/src/providers/TelegramProvider.tsx +++ b/src/providers/TelegramProvider.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, ReactNode, + useCallback, } from "react"; import { useAppSelector, useAppDispatch } from "../store/hooks"; import { @@ -39,11 +40,14 @@ interface TelegramProviderProps { children: ReactNode; } +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 1000; + /** * TelegramProvider manages the Telegram MTProto connection * - Initializes when app-authenticated (JWT), or has Telegram session / authenticated * - Starts init+connect in parallel with login (token) so connect modal is ready sooner - * - Connects automatically + * - Connects automatically with exponential backoff on failure * - Provides Telegram context to children */ const TelegramProvider = ({ children }: TelegramProviderProps) => { @@ -54,22 +58,30 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { const isInitialized = useAppSelector(selectIsInitialized); const connectionStatus = useAppSelector(selectConnectionStatus); const sessionString = useAppSelector(selectSessionString); - const initializedRef = useRef(false); - const connectedRef = useRef(false); + const setupInProgressRef = useRef(false); const setupCompleteRef = useRef(false); + const retryCountRef = useRef(0); + const retryTimeoutRef = useRef | null>(null); const hasSession = !!sessionString; - const shouldSetupTelegram = !!token && !!userId; - // Initialize Telegram when app-authenticated (token), or have session / Telegram auth + const clearRetryTimeout = useCallback(() => { + if (retryTimeoutRef.current !== null) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }, []); + + // Initialize and connect Telegram with exponential backoff on failure useEffect(() => { if (!shouldSetupTelegram) { - initializedRef.current = false; - connectedRef.current = false; + // Reset all state when conditions no longer met setupInProgressRef.current = false; setupCompleteRef.current = false; + retryCountRef.current = 0; + clearRetryTimeout(); return; } @@ -77,6 +89,7 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { if ( setupCompleteRef.current && isInitialized && + mtprotoService.isReady() && connectionStatus === "connected" ) { return; @@ -87,48 +100,69 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { } const setupTelegram = async () => { - // Mark setup as in progress setupInProgressRef.current = true; try { - // Initialize if not already initialized - if (!isInitialized && !initializedRef.current) { - initializedRef.current = true; + // Stale-state guard: Redux says isInitialized but the service has no client + // (happens after page reload — persist restores isInitialized: true but mtprotoService starts fresh) + const needsInit = !mtprotoService.isReady(); + + if (needsInit) { await dispatch(initializeTelegram(userId)).unwrap(); + // After init, let the effect re-fire to handle connect setupInProgressRef.current = false; return; } - if ( - isInitialized && - connectionStatus !== "connected" && - !connectedRef.current - ) { - connectedRef.current = true; + if (connectionStatus !== "connected") { await dispatch(connectTelegram(userId)).unwrap(); setupInProgressRef.current = false; - return; // Exit early, will re-run when connectionStatus updates + return; } - // Setup complete - mark as done + // Setup complete setupInProgressRef.current = false; setupCompleteRef.current = true; + retryCountRef.current = 0; } catch (error) { console.error("Failed to setup Telegram:", error); - initializedRef.current = false; - connectedRef.current = false; setupInProgressRef.current = false; setupCompleteRef.current = false; + + // Exponential backoff + retryCountRef.current += 1; + if (retryCountRef.current <= MAX_RETRIES) { + const delay = BASE_DELAY_MS * Math.pow(2, retryCountRef.current - 1); + console.log( + `Telegram setup retry ${retryCountRef.current}/${MAX_RETRIES} in ${delay}ms`, + ); + clearRetryTimeout(); + retryTimeoutRef.current = setTimeout(() => { + retryTimeoutRef.current = null; + // Re-trigger by calling setupTelegram again + // The effect deps won't have changed, so we call directly + setupTelegram(); + }, delay); + } else { + console.error( + `Telegram setup failed after ${MAX_RETRIES} retries. Giving up.`, + ); + } } }; setupTelegram(); + + return () => { + clearRetryTimeout(); + }; }, [ shouldSetupTelegram, isInitialized, connectionStatus, dispatch, userId, + clearRetryTimeout, ]); // Check connection status once when Telegram reports as connected @@ -141,8 +175,13 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { return; } + // Only check if the service is actually ready + if (!mtprotoService.isReady()) { + return; + } + const uid = userId; - const checkConnection = async () => { + const doCheck = async () => { try { await mtprotoService.checkConnection(uid); } catch (error) { @@ -150,7 +189,7 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { } }; - checkConnection(); + doCheck(); }, [shouldSetupTelegram, isInitialized, connectionStatus, userId]); const checkConnection = async (): Promise => { diff --git a/src/services/mtprotoService.ts b/src/services/mtprotoService.ts index be7610bfd..a5a0d8713 100644 --- a/src/services/mtprotoService.ts +++ b/src/services/mtprotoService.ts @@ -18,6 +18,11 @@ class MTProtoService { private readonly apiId: number; private readonly apiHash: string; + // In-flight promise guards — concurrent callers await the same promise + private initializePromise: Promise | null = null; + private connectPromise: Promise | null = null; + private checkConnectionPromise: Promise | null = null; + private constructor() { // Private constructor to enforce singleton // Load API credentials from config once @@ -40,11 +45,24 @@ class MTProtoService { /** * Initialize the MTProto client with API credentials. * Session is stored in Redux (telegram.byUser[userId].sessionString). + * Concurrent calls for the same userId await the same in-flight promise. */ async initialize(userId: string): Promise { if (this.isInitialized && this.client && this.userId === userId) { return; } + // If already in-flight for the same user, deduplicate + if (this.initializePromise && this.userId === userId) { + return this.initializePromise; + } + + this.initializePromise = this._doInitialize(userId).finally(() => { + this.initializePromise = null; + }); + return this.initializePromise; + } + + private async _doInitialize(userId: string): Promise { if (this.isInitialized && this.userId !== null && this.userId !== userId) { await this.clearSessionAndDisconnect(this.userId); } @@ -76,7 +94,8 @@ class MTProtoService { } /** - * Connect to Telegram servers + * Connect to Telegram servers. + * Concurrent calls await the same in-flight promise. */ async connect(): Promise { if (!this.client) { @@ -86,17 +105,27 @@ class MTProtoService { } if (this.isConnected) { - console.log("Already connected to Telegram"); return; } + if (this.connectPromise) { + return this.connectPromise; + } + + this.connectPromise = this._doConnect().finally(() => { + this.connectPromise = null; + }); + return this.connectPromise; + } + + private async _doConnect(): Promise { try { - await this.client.connect(); + await this.client!.connect(); this.isConnected = true; console.log("Connected to Telegram successfully"); // Save session string if it changed - const newSessionString = this.client.session.save() as string | undefined; + const newSessionString = this.client!.session.save() as string | undefined; if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); @@ -254,11 +283,23 @@ class MTProtoService { } /** - * Check connection status and update user online status - * This calls getMe() which also updates the user's online status on Telegram - * Automatically initializes and connects if needed + * Check connection status and update user online status. + * This calls getMe() which also updates the user's online status on Telegram. + * Automatically initializes and connects if needed. + * Concurrent calls await the same in-flight promise. */ async checkConnection(userId?: string): Promise { + if (this.checkConnectionPromise) { + return this.checkConnectionPromise; + } + + this.checkConnectionPromise = this._doCheckConnection(userId).finally(() => { + this.checkConnectionPromise = null; + }); + return this.checkConnectionPromise; + } + + private async _doCheckConnection(userId?: string): Promise { try { if (!this.isInitialized || !this.client) { if (!userId) return false; @@ -326,8 +367,12 @@ class MTProtoService { await this.disconnect(); this.client = undefined; this.isInitialized = false; + this.isConnected = false; this.sessionString = ""; this.userId = null; + this.initializePromise = null; + this.connectPromise = null; + this.checkConnectionPromise = null; } /** diff --git a/src/store/index.ts b/src/store/index.ts index 64595f6b8..f03c047d7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,6 +2,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { persistStore, persistReducer, + createTransform, FLUSH, REHYDRATE, PAUSE, @@ -16,6 +17,8 @@ import userReducer from "./userSlice"; import telegramReducer from "./telegram"; import { createLogger } from "redux-logger"; import { IS_DEV } from "../utils/config"; +import type { TelegramRootState, TelegramState } from "./telegram/types"; +import { initialState as telegramInitialState } from "./telegram/types"; // Persist config for auth only const authPersistConfig = { @@ -24,11 +27,39 @@ const authPersistConfig = { whitelist: ["token", "isOnboardedByUser"], }; +// Strip volatile runtime fields from each per-user Telegram state on rehydrate. +// These fields reflect in-memory MTProto client state and must start fresh on reload. +const telegramVolatileTransform = createTransform< + TelegramRootState["byUser"], + TelegramRootState["byUser"] +>( + // inbound (state -> storage): pass through as-is + (inboundState) => inboundState, + // outbound (storage -> state): reset volatile fields per user + (outboundState) => { + const cleaned: Record = {}; + for (const [userId, userState] of Object.entries(outboundState)) { + cleaned[userId] = { + ...userState, + isInitialized: telegramInitialState.isInitialized, + connectionStatus: telegramInitialState.connectionStatus, + connectionError: telegramInitialState.connectionError, + isLoadingChats: telegramInitialState.isLoadingChats, + isLoadingMessages: telegramInitialState.isLoadingMessages, + isLoadingThreads: telegramInitialState.isLoadingThreads, + }; + } + return cleaned; + }, + { whitelist: ["byUser"] }, +); + // Persist config for telegram state (scoped by user in byUser) const telegramPersistConfig = { key: "telegram", storage, whitelist: ["byUser"], + transforms: [telegramVolatileTransform], }; const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); diff --git a/src/store/telegram/thunks.ts b/src/store/telegram/thunks.ts index c42bce87d..d3befc949 100644 --- a/src/store/telegram/thunks.ts +++ b/src/store/telegram/thunks.ts @@ -28,6 +28,11 @@ export const initializeTelegram = createAsyncThunk( export const connectTelegram = createAsyncThunk( "telegram/connect", async (_userId: string, { rejectWithValue }) => { + if (!mtprotoService.isReady()) { + return rejectWithValue( + "MTProto client not initialized. Call initialize() first.", + ); + } try { await mtprotoService.connect(); return true; @@ -44,6 +49,12 @@ export const connectTelegram = createAsyncThunk( export const checkAuthStatus = createAsyncThunk( "telegram/checkAuthStatus", async (userId: string, { rejectWithValue, getState }) => { + if (!mtprotoService.isReady()) { + return rejectWithValue( + "MTProto client not initialized. Call initialize() first.", + ); + } + const now = Date.now(); if (isCheckingAuth && now - lastCheckTime < MIN_CHECK_INTERVAL) { const state = getState() as RootState; @@ -92,6 +103,11 @@ export const checkAuthStatus = createAsyncThunk( export const fetchChats = createAsyncThunk( "telegram/fetchChats", async (_userId: string, { rejectWithValue }) => { + if (!mtprotoService.isReady()) { + return rejectWithValue( + "MTProto client not initialized. Call initialize() first.", + ); + } try { const client = mtprotoService.getClient(); const dialogs = await mtprotoService.withFloodWaitHandling(async () => { @@ -117,6 +133,11 @@ export const fetchMessages = createAsyncThunk( }: { userId: string; chatId: string; limit?: number; offsetId?: number }, { rejectWithValue }, ) => { + if (!mtprotoService.isReady()) { + return rejectWithValue( + "MTProto client not initialized. Call initialize() first.", + ); + } try { void _userId; const client = mtprotoService.getClient();