From f341ec3b12573ede650782d90eec8cc4e6a48640 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:52:10 +0000 Subject: [PATCH 1/5] Implement bounded auto-recovery for active sessions after signaling disconnects --- opennow-stable/src/renderer/src/App.tsx | 442 ++++++++++++++++++------ 1 file changed, 342 insertions(+), 100 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 8bef22e4..170e9c02 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -173,7 +173,16 @@ type QueueAdReportOptions = { errorInfo?: QueueAdErrorInfo; }; +type SignalingRecoveryState = { + attemptCount: number; + inFlight: Promise | null; + explicitShutdown: boolean; + appId: number | null; +}; + const APP_PAGE_ORDER: AppPage[] = ["home", "library", "settings"]; +const RECOVERABLE_STREAM_STATUSES: readonly StreamStatus[] = ["queue", "setup", "starting", "connecting", "streaming"]; +const SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS = [0, 3000] as const; const isMac = navigator.platform.toLowerCase().includes("mac"); @@ -770,6 +779,13 @@ function toLaunchErrorState(error: unknown, stage: StreamLoadingStatus): LaunchE }; } +function streamStatusToLoadingStage(status: StreamStatus): StreamLoadingStatus { + if (status === "queue" || status === "setup" || status === "starting" || status === "connecting") { + return status; + } + return "connecting"; +} + export function App(): JSX.Element { // Auth State @@ -1132,6 +1148,12 @@ export function App(): JSX.Element { const launchInFlightRef = useRef(false); const launchAbortRef = useRef(false); const streamStatusRef = useRef(streamStatus); + const signalingRecoveryRef = useRef({ + attemptCount: 0, + inFlight: null, + explicitShutdown: false, + appId: null, + }); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const adReportQueueRef = useRef>(Promise.resolve()); const adReportStateRef = useRef>({}); @@ -1350,6 +1372,9 @@ export function App(): JSX.Element { keepLaunchError?: boolean; keepStreamingContext?: boolean; }): void => { + signalingRecoveryRef.current.attemptCount = 0; + signalingRecoveryRef.current.inFlight = null; + signalingRecoveryRef.current.appId = null; setSession(null); setStreamStatus("idle"); setQueuePosition(undefined); @@ -1370,6 +1395,22 @@ export function App(): JSX.Element { } }, [diagnosticsStore, resetStatsOverlayToPreference]); + const resetSignalingRecoveryState = useCallback((options?: { + keepExplicitShutdown?: boolean; + }): void => { + signalingRecoveryRef.current.attemptCount = 0; + signalingRecoveryRef.current.inFlight = null; + signalingRecoveryRef.current.appId = null; + if (!options?.keepExplicitShutdown) { + signalingRecoveryRef.current.explicitShutdown = false; + } + }, []); + + const markExplicitSignalingShutdown = useCallback((): void => { + signalingRecoveryRef.current.explicitShutdown = true; + signalingRecoveryRef.current.inFlight = null; + }, []); + // Session ref sync useEffect(() => { sessionRef.current = session; @@ -2149,87 +2190,6 @@ export function App(): JSX.Element { return () => window.clearTimeout(timer); }, [remoteStreamWarning]); - // Signaling events - useEffect(() => { - const unsubscribe = window.openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { - console.log(`[App] Signaling event: ${event.type}`, event.type === "offer" ? `(SDP ${event.sdp.length} chars)` : "", event.type === "remote-ice" ? event.candidate : ""); - try { - if (event.type === "offer") { - const activeSession = sessionRef.current; - if (!activeSession) { - console.warn("[App] Received offer but no active session in sessionRef!"); - return; - } - console.log("[App] Active session for offer:", JSON.stringify({ - sessionId: activeSession.sessionId, - serverIp: activeSession.serverIp, - signalingServer: activeSession.signalingServer, - mediaConnectionInfo: activeSession.mediaConnectionInfo, - iceServersCount: activeSession.iceServers?.length, - })); - - if (!clientRef.current && videoRef.current && audioRef.current) { - clientRef.current = new GfnWebRtcClient({ - videoElement: videoRef.current, - audioElement: audioRef.current, - autoFullScreen: settings.autoFullScreen, - microphoneMode: settings.microphoneMode, - microphoneDeviceId: settings.microphoneDeviceId || undefined, - mouseSensitivity: settings.mouseSensitivity, - mouseAcceleration: settings.mouseAcceleration, - onLog: (line: string) => console.log(`[WebRTC] ${line}`), - onStats: (stats) => diagnosticsStore.set(stats), - onEscHoldProgress: (visible, progress) => { - setEscHoldReleaseIndicator({ visible, progress }); - }, - onTimeWarning: (warning) => { - setRemoteStreamWarning({ - code: warning.code, - message: warningMessage(warning.code), - tone: warningTone(warning.code), - secondsLeft: warning.secondsLeft, - }); - }, - onMicStateChange: (state) => { - console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); - }, - }); - // Auto-start microphone if mode is enabled - if (settings.microphoneMode !== "disabled") { - void clientRef.current.startMicrophone(); - } - } - - if (clientRef.current) { - await clientRef.current.handleOffer(event.sdp, activeSession, { - codec: settings.codec, - colorQuality: settings.colorQuality, - resolution: settings.resolution, - fps: settings.fps, - maxBitrateKbps: settings.maxBitrateMbps * 1000, - }); - setLaunchError(null); - setStreamStatus("streaming"); - } - } else if (event.type === "remote-ice") { - await clientRef.current?.addRemoteCandidate(event.candidate); - } else if (event.type === "disconnected") { - console.warn("Signaling disconnected:", event.reason); - clientRef.current?.dispose(); - clientRef.current = null; - resetLaunchRuntime(); - launchInFlightRef.current = false; - } else if (event.type === "error") { - console.error("Signaling error:", event.message); - } - } catch (error) { - console.error("Signaling event error:", error); - } - }); - - return () => unsubscribe(); - }, [resetLaunchRuntime, settings]); - // Save settings when changed const updateSetting = useCallback(async (key: K, value: Settings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })); @@ -2450,6 +2410,40 @@ export function App(): JSX.Element { }); }, []); + const resolveSessionClaimAppId = useCallback((existingSession: ActiveSessionInfo): string => { + const trackedAppId = signalingRecoveryRef.current.appId; + if (Number.isFinite(existingSession.appId) && existingSession.appId > 0) { + return String(existingSession.appId); + } + if (trackedAppId && Number.isFinite(trackedAppId)) { + return String(trackedAppId); + } + throw new Error("Active session is missing app metadata required for resume."); + }, []); + + const applyClaimedSessionAndConnect = useCallback(async (claimed: SessionInfo): Promise => { + console.log("Claimed session:", { + sessionId: claimed.sessionId, + signalingServer: claimed.signalingServer, + signalingUrl: claimed.signalingUrl, + status: claimed.status, + }); + + await sleep(1000); + + setSession(claimed); + sessionRef.current = claimed; + setQueuePosition(undefined); + setLaunchError(null); + setStreamStatus("connecting"); + signalingRecoveryRef.current.explicitShutdown = false; + await window.openNow.connectSignaling({ + sessionId: claimed.sessionId, + signalingServer: claimed.signalingServer, + signalingUrl: claimed.signalingUrl, + }); + }, []); + const claimAndConnectSession = useCallback(async (existingSession: ActiveSessionInfo): Promise => { const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; if (!token) { @@ -2472,7 +2466,7 @@ export function App(): JSX.Element { streamingBaseUrl: effectiveStreamingBaseUrl, serverIp: existingSession.serverIp, sessionId: existingSession.sessionId, - appId: String(existingSession.appId), + appId: resolveSessionClaimAppId(existingSession), settings: { resolution: settings.resolution, fps: settings.fps, @@ -2486,25 +2480,265 @@ export function App(): JSX.Element { }, }); - console.log("Claimed session:", { - sessionId: claimed.sessionId, - signalingServer: claimed.signalingServer, - signalingUrl: claimed.signalingUrl, - status: claimed.status, - }); + await applyClaimedSessionAndConnect(claimed); + }, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveSessionClaimAppId, settings]); - await sleep(1000); + const attemptSessionRecovery = useCallback(async (reason: string): Promise => { + const recoveryState = signalingRecoveryRef.current; + const currentStatus = streamStatusRef.current; + const currentSession = sessionRef.current; - setSession(claimed); - sessionRef.current = claimed; - setQueuePosition(undefined); - setStreamStatus("connecting"); - await window.openNow.connectSignaling({ - sessionId: claimed.sessionId, - signalingServer: claimed.signalingServer, - signalingUrl: claimed.signalingUrl, + if (recoveryState.explicitShutdown) { + console.log("[Recovery] Skipping signaling recovery after explicit shutdown"); + return false; + } + if (!RECOVERABLE_STREAM_STATUSES.includes(currentStatus)) { + console.log("[Recovery] Stream status is not recoverable:", currentStatus); + return false; + } + if (!currentSession) { + console.warn("[Recovery] No active session available for signaling recovery"); + return false; + } + if (recoveryState.inFlight) { + console.log("[Recovery] Reusing in-flight signaling recovery attempt"); + return recoveryState.inFlight; + } + + const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; + if (!token) { + throw new Error("Connection to the running session was lost and your login token is no longer available for resume."); + } + + if (recoveryState.attemptCount >= SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length) { + console.warn("[Recovery] Recovery budget exhausted"); + return false; + } + + const attemptPromise = (async (): Promise => { + clientRef.current?.dispose(); + clientRef.current = null; + setStreamStatus("connecting"); + await window.openNow.disconnectSignaling().catch(() => {}); + + let lastError: Error | null = null; + while (recoveryState.attemptCount < SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length) { + const attemptIndex = recoveryState.attemptCount; + recoveryState.attemptCount += 1; + const attemptNumber = recoveryState.attemptCount; + const attemptDelayMs = SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS[attemptIndex] ?? 0; + + console.warn( + `[Recovery] Attempt ${attemptNumber}/${SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length} after signaling disconnect: ${reason}`, + ); + + if (attemptDelayMs > 0) { + await sleep(attemptDelayMs); + } + if (recoveryState.explicitShutdown) { + console.log("[Recovery] Aborting attempt after explicit shutdown"); + return false; + } + + try { + const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); + const previousAppId = recoveryState.appId; + const currentSessionId = currentSession.sessionId; + const sameSessionCandidate = + activeSessions.find((entry) => entry.sessionId === currentSessionId && entry.serverIp && isSessionReadyForConnect(entry.status)) ?? + null; + + let candidate = sameSessionCandidate; + if (!candidate && previousAppId !== null) { + candidate = + activeSessions.find((entry) => ( + entry.appId === previousAppId && + entry.serverIp && + isSessionReadyForConnect(entry.status) && + entry.sessionId === currentSessionId + )) ?? + activeSessions.find((entry) => ( + entry.appId === previousAppId && + entry.serverIp && + isSessionReadyForConnect(entry.status) + )) ?? + null; + } + + if (!candidate) { + const hasQueueOnlyMatch = activeSessions.some((entry) => entry.sessionId === currentSessionId && entry.status === 1); + if (hasQueueOnlyMatch) { + throw new Error("The session is still queued and cannot be reclaimed until the server marks it ready again."); + } + throw new Error("The running session could not be found anymore, so resume was not possible."); + } + + if (!candidate.serverIp) { + throw new Error("The running session is missing a server address, so resume was not possible."); + } + + const claimed = await window.openNow.claimSession({ + token, + streamingBaseUrl: effectiveStreamingBaseUrl, + serverIp: candidate.serverIp, + sessionId: candidate.sessionId, + appId: resolveSessionClaimAppId(candidate), + settings: { + resolution: settings.resolution, + fps: settings.fps, + maxBitrateMbps: settings.maxBitrateMbps, + codec: settings.codec, + colorQuality: settings.colorQuality, + keyboardLayout: settings.keyboardLayout, + gameLanguage: settings.gameLanguage, + enableL4S: settings.enableL4S, + enableCloudGsync: settings.enableCloudGsync, + }, + }); + + const matchedContext = findGameContextForSession(candidate); + if (matchedContext) { + setStreamingGame(matchedContext.game); + setStreamingStore(matchedContext.variant?.store ?? null); + } else { + setStreamingStore(null); + } + + await applyClaimedSessionAndConnect(claimed); + recoveryState.attemptCount = 0; + return true; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.warn(`[Recovery] Attempt ${attemptNumber} failed:`, lastError.message); + } + } + + if (lastError) { + throw lastError; + } + return false; + })(); + + recoveryState.inFlight = attemptPromise; + try { + return await attemptPromise; + } finally { + if (signalingRecoveryRef.current.inFlight === attemptPromise) { + signalingRecoveryRef.current.inFlight = null; + } + } + }, [ + applyClaimedSessionAndConnect, + authSession, + effectiveStreamingBaseUrl, + findGameContextForSession, + resolveSessionClaimAppId, + settings, + ]); + + // Signaling events + useEffect(() => { + const unsubscribe = window.openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { + console.log(`[App] Signaling event: ${event.type}`, event.type === "offer" ? `(SDP ${event.sdp.length} chars)` : "", event.type === "remote-ice" ? event.candidate : ""); + try { + if (event.type === "offer") { + const activeSession = sessionRef.current; + if (!activeSession) { + console.warn("[App] Received offer but no active session in sessionRef!"); + return; + } + console.log("[App] Active session for offer:", JSON.stringify({ + sessionId: activeSession.sessionId, + serverIp: activeSession.serverIp, + signalingServer: activeSession.signalingServer, + mediaConnectionInfo: activeSession.mediaConnectionInfo, + iceServersCount: activeSession.iceServers?.length, + })); + + if (!clientRef.current && videoRef.current && audioRef.current) { + clientRef.current = new GfnWebRtcClient({ + videoElement: videoRef.current, + audioElement: audioRef.current, + autoFullScreen: settings.autoFullScreen, + microphoneMode: settings.microphoneMode, + microphoneDeviceId: settings.microphoneDeviceId || undefined, + mouseSensitivity: settings.mouseSensitivity, + mouseAcceleration: settings.mouseAcceleration, + onLog: (line: string) => console.log(`[WebRTC] ${line}`), + onStats: (stats) => diagnosticsStore.set(stats), + onEscHoldProgress: (visible, progress) => { + setEscHoldReleaseIndicator({ visible, progress }); + }, + onTimeWarning: (warning) => { + setRemoteStreamWarning({ + code: warning.code, + message: warningMessage(warning.code), + tone: warningTone(warning.code), + secondsLeft: warning.secondsLeft, + }); + }, + onMicStateChange: (state) => { + console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); + }, + }); + if (settings.microphoneMode !== "disabled") { + void clientRef.current.startMicrophone(); + } + } + + if (clientRef.current) { + await clientRef.current.handleOffer(event.sdp, activeSession, { + codec: settings.codec, + colorQuality: settings.colorQuality, + resolution: settings.resolution, + fps: settings.fps, + maxBitrateKbps: settings.maxBitrateMbps * 1000, + }); + setLaunchError(null); + setStreamStatus("streaming"); + resetSignalingRecoveryState({ keepExplicitShutdown: true }); + } + } else if (event.type === "remote-ice") { + await clientRef.current?.addRemoteCandidate(event.candidate); + } else if (event.type === "disconnected") { + console.warn("Signaling disconnected:", event.reason); + const recovered = await attemptSessionRecovery(event.reason).catch((error) => { + console.error("[Recovery] Signaling recovery failed:", error); + throw error; + }); + if (!recovered) { + clientRef.current?.dispose(); + clientRef.current = null; + setLaunchError({ + stage: streamStatusToLoadingStage(streamStatusRef.current), + title: "Session Connection Lost", + description: "The connection to your running session was lost and could not be restored automatically. Try resuming it again from the app.", + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + } + } else if (event.type === "error") { + console.error("Signaling error:", event.message); + } + } catch (error) { + console.error("Signaling event error:", error); + clientRef.current?.dispose(); + clientRef.current = null; + const message = error instanceof Error ? error.message : "The connection to the running session was lost and resume failed."; + setLaunchError({ + stage: streamStatusToLoadingStage(streamStatusRef.current), + title: "Session Connection Lost", + description: message, + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + } }); - }, [authSession, effectiveStreamingBaseUrl, findGameContextForSession, resetStatsOverlayToPreference, settings]); + + return () => unsubscribe(); + }, [attemptSessionRecovery, diagnosticsStore, refreshNavbarActiveSession, resetLaunchRuntime, resetSignalingRecoveryState, settings]); // Play game handler const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string }) => { @@ -2527,6 +2761,7 @@ export function App(): JSX.Element { launchInFlightRef.current = true; launchAbortRef.current = false; + resetSignalingRecoveryState(); let loadingStep: StreamLoadingStatus = "queue"; const updateLoadingStep = (next: StreamLoadingStatus): void => { loadingStep = next; @@ -2575,6 +2810,7 @@ export function App(): JSX.Element { } const numericAppId = Number(appId); + signalingRecoveryRef.current.appId = numericAppId; const matchedGameContext = findSessionContextForAppId(allKnownGames, variantByGameId, numericAppId) ?? { game, variant: selectedVariant, @@ -2796,6 +3032,7 @@ export function App(): JSX.Element { claimAndConnectSession, effectiveStreamingBaseUrl, refreshNavbarActiveSession, + resetSignalingRecoveryState, resetLaunchRuntime, resetStatsOverlayToPreference, selectedProvider, @@ -2887,6 +3124,7 @@ export function App(): JSX.Element { } launchInFlightRef.current = true; + resetSignalingRecoveryState(); setIsResumingNavbarSession(true); let loadingStep: StreamLoadingStatus = "setup"; const updateLoadingStep = (next: StreamLoadingStatus): void => { @@ -2910,6 +3148,7 @@ export function App(): JSX.Element { updateLoadingStep("setup"); try { + signalingRecoveryRef.current.appId = navbarActiveSession.appId; await claimAndConnectSession(navbarActiveSession); setNavbarActiveSession(null); } catch (error) { @@ -2930,6 +3169,7 @@ export function App(): JSX.Element { navbarActiveSession, findGameContextForSession, refreshNavbarActiveSession, + resetSignalingRecoveryState, resetLaunchRuntime, resetStatsOverlayToPreference, selectedProvider, @@ -2944,6 +3184,7 @@ export function App(): JSX.Element { if (status !== "idle" && status !== "streaming") { launchAbortRef.current = true; } + markExplicitSignalingShutdown(); await window.openNow.disconnectSignaling(); const current = sessionRef.current; @@ -2969,7 +3210,7 @@ export function App(): JSX.Element { } catch (error) { console.error("Stop failed:", error); } - }, [authSession, endPlaytimeSession, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, streamingGame]); + }, [authSession, endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, streamingGame]); const handleSwitchGame = useCallback(async (game: GameInfo) => { setControllerOverlayOpen(false); @@ -3020,12 +3261,13 @@ export function App(): JSX.Element { }, [handleStopStream, handlePlayGame]); const handleDismissLaunchError = useCallback(async () => { + markExplicitSignalingShutdown(); await window.openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); void refreshNavbarActiveSession(); - }, [refreshNavbarActiveSession, resetLaunchRuntime]); + }, [markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime]); const releasePointerLockIfNeeded = useCallback(async () => { if (document.pointerLockElement) { From 8cf2b9657776d7a1ba108ee4cf91deaf4d5023da Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 23 Apr 2026 23:26:09 -0500 Subject: [PATCH 2/5] Enhance signaling recovery mechanism by introducing generation tracking. Added generation state to manage recovery attempts and prevent stale session applications. Updated relevant functions to utilize generation checks for improved session handling. --- opennow-stable/src/renderer/src/App.tsx | 58 ++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 95300460..9b569546 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -180,6 +180,7 @@ type SignalingRecoveryState = { inFlight: Promise | null; explicitShutdown: boolean; appId: number | null; + generation: number; }; const APP_PAGE_ORDER: AppPage[] = ["home", "library", "settings"]; @@ -1158,6 +1159,7 @@ export function App(): JSX.Element { inFlight: null, explicitShutdown: false, appId: null, + generation: 0, }); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const adReportQueueRef = useRef>(Promise.resolve()); @@ -1403,6 +1405,7 @@ export function App(): JSX.Element { const resetSignalingRecoveryState = useCallback((options?: { keepExplicitShutdown?: boolean; }): void => { + signalingRecoveryRef.current.generation += 1; signalingRecoveryRef.current.attemptCount = 0; signalingRecoveryRef.current.inFlight = null; signalingRecoveryRef.current.appId = null; @@ -1412,10 +1415,16 @@ export function App(): JSX.Element { }, []); const markExplicitSignalingShutdown = useCallback((): void => { + signalingRecoveryRef.current.generation += 1; signalingRecoveryRef.current.explicitShutdown = true; signalingRecoveryRef.current.inFlight = null; }, []); + const isRecoveryGenerationCurrent = useCallback((generation: number): boolean => { + const state = signalingRecoveryRef.current; + return state.generation === generation && !state.explicitShutdown; + }, []); + // Session ref sync useEffect(() => { sessionRef.current = session; @@ -2449,7 +2458,18 @@ export function App(): JSX.Element { throw new Error("Active session is missing app metadata required for resume."); }, []); - const applyClaimedSessionAndConnect = useCallback(async (claimed: SessionInfo): Promise => { + const applyClaimedSessionAndConnect = useCallback(async ( + claimed: SessionInfo, + expectedRecoveryGeneration?: number, + ): Promise => { + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + console.log("[Recovery] Skipping claimed session apply due to stale recovery generation"); + return; + } + console.log("Claimed session:", { sessionId: claimed.sessionId, signalingServer: claimed.signalingServer, @@ -2458,6 +2478,13 @@ export function App(): JSX.Element { }); await sleep(1000); + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + console.log("[Recovery] Skipping reconnect due to stale recovery generation after delay"); + return; + } setSession(claimed); sessionRef.current = claimed; @@ -2465,12 +2492,19 @@ export function App(): JSX.Element { setLaunchError(null); setStreamStatus("connecting"); signalingRecoveryRef.current.explicitShutdown = false; + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + console.log("[Recovery] Skipping signaling connect due to stale recovery generation"); + return; + } await window.openNow.connectSignaling({ sessionId: claimed.sessionId, signalingServer: claimed.signalingServer, signalingUrl: claimed.signalingUrl, }); - }, []); + }, [isRecoveryGenerationCurrent]); const claimAndConnectSession = useCallback(async (existingSession: ActiveSessionInfo): Promise => { const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; @@ -2513,10 +2547,11 @@ export function App(): JSX.Element { const attemptSessionRecovery = useCallback(async (reason: string): Promise => { const recoveryState = signalingRecoveryRef.current; + const recoveryGeneration = recoveryState.generation; const currentStatus = streamStatusRef.current; const currentSession = sessionRef.current; - if (recoveryState.explicitShutdown) { + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { console.log("[Recovery] Skipping signaling recovery after explicit shutdown"); return false; } @@ -2563,13 +2598,17 @@ export function App(): JSX.Element { if (attemptDelayMs > 0) { await sleep(attemptDelayMs); } - if (recoveryState.explicitShutdown) { + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { console.log("[Recovery] Aborting attempt after explicit shutdown"); return false; } try { const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Aborting attempt after active session lookup due to stale generation"); + return false; + } const previousAppId = recoveryState.appId; const currentSessionId = currentSession.sessionId; const sameSessionCandidate = @@ -2623,6 +2662,10 @@ export function App(): JSX.Element { enableCloudGsync: settings.enableCloudGsync, }, }); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Discarding claimed session due to stale recovery generation"); + return false; + } const matchedContext = findGameContextForSession(candidate); if (matchedContext) { @@ -2632,7 +2675,11 @@ export function App(): JSX.Element { setStreamingStore(null); } - await applyClaimedSessionAndConnect(claimed); + await applyClaimedSessionAndConnect(claimed, recoveryGeneration); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Recovery generation changed before connect completed"); + return false; + } recoveryState.attemptCount = 0; return true; } catch (error) { @@ -2660,6 +2707,7 @@ export function App(): JSX.Element { authSession, effectiveStreamingBaseUrl, findGameContextForSession, + isRecoveryGenerationCurrent, resolveSessionClaimAppId, settings, ]); From d4f6f3b7f4d3706c459df57dacb77df6954016d8 Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 23 Apr 2026 23:28:51 -0500 Subject: [PATCH 3/5] Refactor claimed session handling to improve recovery logic. Introduced a helper function to determine if a claimed reconnect can proceed based on recovery generation and explicit shutdown status, enhancing session management and logging clarity. --- opennow-stable/src/renderer/src/App.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 9b569546..67013fef 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -2462,6 +2462,19 @@ export function App(): JSX.Element { claimed: SessionInfo, expectedRecoveryGeneration?: number, ): Promise => { + const canProceedWithClaimedReconnect = (): boolean => { + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + return false; + } + if (signalingRecoveryRef.current.explicitShutdown) { + return false; + } + return true; + }; + if ( expectedRecoveryGeneration !== undefined && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) @@ -2485,18 +2498,18 @@ export function App(): JSX.Element { console.log("[Recovery] Skipping reconnect due to stale recovery generation after delay"); return; } + if (!canProceedWithClaimedReconnect()) { + console.log("[Recovery] Skipping claimed session apply due to explicit shutdown"); + return; + } setSession(claimed); sessionRef.current = claimed; setQueuePosition(undefined); setLaunchError(null); setStreamStatus("connecting"); - signalingRecoveryRef.current.explicitShutdown = false; - if ( - expectedRecoveryGeneration !== undefined - && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) - ) { - console.log("[Recovery] Skipping signaling connect due to stale recovery generation"); + if (!canProceedWithClaimedReconnect()) { + console.log("[Recovery] Skipping signaling connect due to explicit shutdown"); return; } await window.openNow.connectSignaling({ From 6f56a75d1e0301db043e938603204e1734a94dc9 Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 23 Apr 2026 23:35:45 -0500 Subject: [PATCH 4/5] Enhance signaling recovery logic by adding checks for explicit shutdown and non-recoverable stream statuses. This prevents unnecessary error handling and improves logging clarity during disconnect scenarios. --- opennow-stable/src/renderer/src/App.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 67013fef..75869e9d 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -2796,6 +2796,13 @@ export function App(): JSX.Element { throw error; }); if (!recovered) { + if ( + signalingRecoveryRef.current.explicitShutdown + || !RECOVERABLE_STREAM_STATUSES.includes(streamStatusRef.current) + ) { + console.log("[Recovery] Ignoring disconnect after explicit shutdown or non-recoverable status"); + return; + } clientRef.current?.dispose(); clientRef.current = null; setLaunchError({ @@ -2811,6 +2818,13 @@ export function App(): JSX.Element { console.error("Signaling error:", event.message); } } catch (error) { + if ( + signalingRecoveryRef.current.explicitShutdown + || !RECOVERABLE_STREAM_STATUSES.includes(streamStatusRef.current) + ) { + console.log("[Recovery] Suppressing signaling error after explicit shutdown or non-recoverable status"); + return; + } console.error("Signaling event error:", error); clientRef.current?.dispose(); clientRef.current = null; From 3a46e5ec47980a4e610d95ebacd60e14b5ddb056 Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 23 Apr 2026 23:41:46 -0500 Subject: [PATCH 5/5] Remove explicit shutdown check from signaling connection logic to streamline recovery process. This change simplifies the connection flow during recovery attempts. --- opennow-stable/src/renderer/src/App.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 75869e9d..c260938a 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -2508,10 +2508,6 @@ export function App(): JSX.Element { setQueuePosition(undefined); setLaunchError(null); setStreamStatus("connecting"); - if (!canProceedWithClaimedReconnect()) { - console.log("[Recovery] Skipping signaling connect due to explicit shutdown"); - return; - } await window.openNow.connectSignaling({ sessionId: claimed.sessionId, signalingServer: claimed.signalingServer,