From 94692b463b0c76aec97720ea4de1c4d2eaf511ca Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 07:43:09 -0500 Subject: [PATCH 1/2] Improve stream signaling recovery and reconnect resilience. Use staged recovery that retries signaling before full session reclaim, add jittered backoff with token refresh, and trigger recovery on sustained ICE/PC degradation while preserving existing gameplay state when possible. Co-authored-by: Cursor --- opennow-stable/package.json | 2 +- opennow-stable/src/main/gfn/signaling.ts | 3 + opennow-stable/src/main/index.ts | 5 +- opennow-stable/src/renderer/src/App.tsx | 110 ++++++++++++++++-- .../src/renderer/src/gfn/inputProtocol.ts | 8 +- .../src/renderer/src/gfn/webrtcClient.ts | 67 +++++++++++ .../src/lib/signalingRecovery.test.ts | 23 ++++ .../src/renderer/src/lib/signalingRecovery.ts | 18 +++ 8 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 opennow-stable/src/renderer/src/lib/signalingRecovery.test.ts create mode 100644 opennow-stable/src/renderer/src/lib/signalingRecovery.ts diff --git a/opennow-stable/package.json b/opennow-stable/package.json index dd7446c00..e9fea2f4e 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -21,7 +21,7 @@ "dist": "npm run build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder", "dist:signed": "npm run build && electron-builder", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json", - "test": "tsx --test src/shared/gfn.test.ts src/renderer/src/lib/launchOwnership.test.ts src/renderer/src/components/GameCard.test.ts src/renderer/src/gfn/inputProtocol.test.ts src/renderer/src/gfn/webrtcClient.test.ts" + "test": "tsx --test src/shared/gfn.test.ts src/renderer/src/lib/launchOwnership.test.ts src/renderer/src/lib/signalingRecovery.test.ts src/renderer/src/components/GameCard.test.ts src/renderer/src/gfn/inputProtocol.test.ts src/renderer/src/gfn/webrtcClient.test.ts" }, "dependencies": { "discord-rpc": "^4.0.1", diff --git a/opennow-stable/src/main/gfn/signaling.ts b/opennow-stable/src/main/gfn/signaling.ts index a09af67f5..a7ec694cf 100644 --- a/opennow-stable/src/main/gfn/signaling.ts +++ b/opennow-stable/src/main/gfn/signaling.ts @@ -115,6 +115,9 @@ export class GfnSignalingClient { async connect(): Promise { if (this.ws && this.ws.readyState === WebSocket.OPEN) { + // Renderer recovery waits for this event after connectSignaling; emit even when + // the socket was already open so Stage A can complete without a timeout. + this.emit({ type: "connected" }); return; } diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 340eadade..60b99c849 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1370,7 +1370,10 @@ function registerIpcHandlers(): void { async (_event, payload: SignalingConnectRequest): Promise => { const nextKey = `${payload.sessionId}|${payload.signalingServer}|${payload.signalingUrl ?? ""}`; if (signalingClient && signalingClientKey === nextKey) { - console.log("[Signaling] Reuse existing signaling connection (duplicate connect request ignored)"); + // WebSocket may have closed while the session key is unchanged; always + // run connect() so a dead socket is reopened (connect() no-ops if OPEN). + console.log("[Signaling] Same session key — ensuring signaling WebSocket is connected"); + await signalingClient.connect(); return; } diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 68fd6e308..0337392a3 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -51,6 +51,10 @@ import { usePlaytime } from "./utils/usePlaytime"; import { createStreamDiagnosticsStore } from "./utils/streamDiagnosticsStore"; import { loadStoredCodecResults, saveStoredCodecResults, testCodecSupport, type CodecTestResult } from "./lib/codecDiagnostics"; import { chooseAccountLinked, getEpicOwnershipLaunchError } from "./lib/launchOwnership"; +import { + SIGNALING_RECOVERY_BASE_DELAYS_MS, + signalingRecoveryDelayMs, +} from "./lib/signalingRecovery"; // UI Components import { LoginScreen } from "./components/LoginScreen"; @@ -186,7 +190,6 @@ type SignalingRecoveryState = { 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"); @@ -1151,6 +1154,9 @@ export function App(): JSX.Element { appId: null, generation: 0, }); + /** Incremented on each signaling `connected` event (generation wait for Stage A). */ + const signalingConnectedGenerationRef = useRef(0); + const attemptSessionRecoveryRef = useRef<(reason: string) => Promise>(async () => false); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const adReportQueueRef = useRef>(Promise.resolve()); const adReportStateRef = useRef>({}); @@ -2621,6 +2627,68 @@ export function App(): JSX.Element { await applyClaimedSessionAndConnect(claimed); }, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveSessionClaimAppId, settings]); + const waitForNextSignalingGeneration = useCallback(async (startGen: number, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (signalingConnectedGenerationRef.current > startGen) { + return true; + } + // eslint-disable-next-line no-await-in-loop + await sleep(50); + } + return signalingConnectedGenerationRef.current > startGen; + }, []); + + const attemptSignalingOnlyRecovery = useCallback(async (recoveryGeneration: number, reason: string): Promise => { + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + return false; + } + const session = sessionRef.current; + const client = clientRef.current; + if (!session || !client) { + console.log("[Recovery] Stage A skipped: no session or WebRTC client"); + return false; + } + const cs = client.getConnectionState(); + if (cs !== "connected" && cs !== "connecting") { + console.log("[Recovery] Stage A skipped: peer connection not healthy:", cs); + return false; + } + + console.log(`[Recovery] Stage A: signaling-only reconnect (${reason})`); + try { + const startGen = signalingConnectedGenerationRef.current; + await window.openNow.connectSignaling({ + sessionId: session.sessionId, + signalingServer: session.signalingServer, + signalingUrl: session.signalingUrl, + }); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + return false; + } + const signalingOk = await waitForNextSignalingGeneration(startGen, 8000); + if (!signalingOk) { + console.warn("[Recovery] Stage A: timed out waiting for signaling connected"); + return false; + } + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + return false; + } + const cs2 = client.getConnectionState(); + if (cs2 !== "connected" && cs2 !== "connecting") { + console.warn("[Recovery] Stage A: peer connection unhealthy after signaling reconnect:", cs2); + return false; + } + resetSignalingRecoveryState({ keepExplicitShutdown: true }); + setStreamStatus("streaming"); + console.log("[Recovery] Stage A succeeded"); + return true; + } catch (error) { + console.warn("[Recovery] Stage A failed:", error); + return false; + } + }, [isRecoveryGenerationCurrent, resetSignalingRecoveryState, waitForNextSignalingGeneration]); + const attemptSessionRecovery = useCallback(async (reason: string): Promise => { const recoveryState = signalingRecoveryRef.current; const recoveryGeneration = recoveryState.generation; @@ -2644,31 +2712,33 @@ export function App(): JSX.Element { 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) { + if (recoveryState.attemptCount >= SIGNALING_RECOVERY_BASE_DELAYS_MS.length) { console.warn("[Recovery] Recovery budget exhausted"); return false; } const attemptPromise = (async (): Promise => { + const stageA = await attemptSignalingOnlyRecovery(recoveryGeneration, reason); + if (stageA) { + recoveryState.attemptCount = 0; + return true; + } + 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) { + while (recoveryState.attemptCount < SIGNALING_RECOVERY_BASE_DELAYS_MS.length) { const attemptIndex = recoveryState.attemptCount; recoveryState.attemptCount += 1; const attemptNumber = recoveryState.attemptCount; - const attemptDelayMs = SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS[attemptIndex] ?? 0; + const baseDelayMs = SIGNALING_RECOVERY_BASE_DELAYS_MS[attemptIndex] ?? 0; + const attemptDelayMs = signalingRecoveryDelayMs(baseDelayMs); console.warn( - `[Recovery] Attempt ${attemptNumber}/${SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length} after signaling disconnect: ${reason}`, + `[Recovery] Stage B attempt ${attemptNumber}/${SIGNALING_RECOVERY_BASE_DELAYS_MS.length} (full reclaim): ${reason}`, ); if (attemptDelayMs > 0) { @@ -2680,6 +2750,12 @@ export function App(): JSX.Element { } try { + const authResult = await window.openNow.getAuthSession({ forceRefresh: true }); + const token = authResult.session?.tokens.idToken ?? authResult.session?.tokens.accessToken; + if (!token) { + throw new Error("Connection to the running session was lost and your login token is no longer available for resume."); + } + const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); if (!isRecoveryGenerationCurrent(recoveryGeneration)) { console.log("[Recovery] Aborting attempt after active session lookup due to stale generation"); @@ -2780,7 +2856,7 @@ export function App(): JSX.Element { } }, [ applyClaimedSessionAndConnect, - authSession, + attemptSignalingOnlyRecovery, effectiveStreamingBaseUrl, findGameContextForSession, isRecoveryGenerationCurrent, @@ -2788,12 +2864,18 @@ export function App(): JSX.Element { settings, ]); + useEffect(() => { + attemptSessionRecoveryRef.current = attemptSessionRecovery; + }, [attemptSessionRecovery]); + // 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") { + if (event.type === "connected") { + signalingConnectedGenerationRef.current += 1; + } else if (event.type === "offer") { const activeSession = sessionRef.current; if (!activeSession) { console.warn("[App] Received offer but no active session in sessionRef!"); @@ -2829,6 +2911,10 @@ export function App(): JSX.Element { onMicStateChange: (state) => { console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); }, + onTransportDegraded: (detail) => { + console.warn("[App] Transport degraded:", detail); + void attemptSessionRecoveryRef.current("peer-connection-degraded"); + }, }); if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); diff --git a/opennow-stable/src/renderer/src/gfn/inputProtocol.ts b/opennow-stable/src/renderer/src/gfn/inputProtocol.ts index db9345672..d7e5e31b1 100644 --- a/opennow-stable/src/renderer/src/gfn/inputProtocol.ts +++ b/opennow-stable/src/renderer/src/gfn/inputProtocol.ts @@ -703,10 +703,10 @@ export class InputEncoder { const bytes = new Uint8Array(GAMEPAD_PACKET_SIZE); const view = new DataView(bytes.buffer); - // Match official GFN client's gl() function exactly (vendor_beautified.js line 13469-13470): - // gl(i, u, m, w, P, L, $=0, ae=0) where: - // i=DataView, u=base offset (0), m=gamepad index, w=buttons, - // P=triggers, L=axes[4], $=timestamp, ae=bitmap + // Match official GFN web client gamepad encoder: webpackChunkgfn_mall + // vendor.48caacc87d5222af.js minifies it as bl(i,d,m,C,x,F,K=0,ae=0) with the same layout + // (type 12 LE, outer 26, index, bitmap, inner 20, buttons, packed LT/RT, four i16 axes, 0, 85, 0, u64 ts LE). + // Older decompiles call this gl() (vendor_beautified.js ~13469): same field order/endianness. // Offset 0x00: Type (u32 LE) - event type 12 view.setUint32(0, INPUT_GAMEPAD, true); diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index eb81d153d..67bb7cd6d 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -166,6 +166,12 @@ export interface StreamTimeWarning { secondsLeft?: number; } +export interface TransportDegradedDetail { + reason: string; + iceConnectionState: string; + connectionState: string; +} + interface ClientOptions { videoElement: HTMLVideoElement; audioElement: HTMLAudioElement; @@ -183,6 +189,8 @@ interface ClientOptions { onStats?: (stats: StreamDiagnostics) => void; onTimeWarning?: (warning: StreamTimeWarning) => void; onMicStateChange?: (state: MicStateChange) => void; + /** Fired once per episode after ICE/PC stays in a bad state (debounced). */ + onTransportDegraded?: (detail: TransportDegradedDetail) => void; } function timestampUs(sourceTimestampMs?: number): bigint { @@ -551,6 +559,8 @@ export class GfnWebRtcClient { private pendingMouseDyFloat = 0; private inputCleanup: Array<() => void> = []; private queuedCandidates: RTCIceCandidateInit[] = []; + private transportDegradeTimer: number | null = null; + private transportDegradeNotified = false; // Input mode: all input types (mouse, keyboard, gamepad) work simultaneously // Removed exclusive mode switching to allow concurrent input @@ -579,6 +589,7 @@ export class GfnWebRtcClient { private static readonly RUMBLE_EFFECT_MS = 500; private static readonly RUMBLE_THROTTLE_MS = 500; private static readonly HAPTICS_LOG_INTERVAL_MS = 5000; + private static readonly TRANSPORT_DEGRADE_DEBOUNCE_MS = 2500; // Gamepad bitmap sent at packet offset 8, matching official client's this.nu field: // bit i (0-3) = connected, bit i+8 = Xbox/xinput style device. @@ -1657,8 +1668,51 @@ export class GfnWebRtcClient { }); } + private resetTransportDegradeEpisode(): void { + if (this.transportDegradeTimer !== null) { + window.clearTimeout(this.transportDegradeTimer); + this.transportDegradeTimer = null; + } + this.transportDegradeNotified = false; + } + + private scheduleTransportDegradeCheck(pc: RTCPeerConnection): void { + if (!this.options.onTransportDegraded) { + return; + } + const ice = pc.iceConnectionState; + const cs = pc.connectionState; + const suspicious = + ice === "disconnected" || ice === "failed" || cs === "disconnected" || cs === "failed"; + if (!suspicious) { + return; + } + if (this.transportDegradeTimer !== null) { + window.clearTimeout(this.transportDegradeTimer); + } + this.transportDegradeTimer = window.setTimeout(() => { + this.transportDegradeTimer = null; + if (!this.pc || this.pc !== pc) { + return; + } + const ice2 = pc.iceConnectionState; + const cs2 = pc.connectionState; + const stillBad = + ice2 === "disconnected" || ice2 === "failed" || cs2 === "disconnected" || cs2 === "failed"; + if (stillBad && !this.transportDegradeNotified) { + this.transportDegradeNotified = true; + this.options.onTransportDegraded?.({ + reason: "peer-connection-degraded-sustained", + iceConnectionState: ice2, + connectionState: cs2, + }); + } + }, GfnWebRtcClient.TRANSPORT_DEGRADE_DEBOUNCE_MS); + } + private cleanupPeerConnection(): void { this.clearTimers(); + this.resetTransportDegradeEpisode(); this.detachInputCapture(); this.closeDataChannels(); this.cleanupAudioRouting(); @@ -1666,6 +1720,7 @@ export class GfnWebRtcClient { this.pc.onicecandidate = null; this.pc.ontrack = null; this.pc.onconnectionstatechange = null; + this.pc.oniceconnectionstatechange = null; this.pc.ondatachannel = null; this.pc.close(); this.pc = null; @@ -3783,6 +3838,10 @@ export class GfnWebRtcClient { this.diagnostics.connectionState = pc.connectionState; this.emitStats(); this.log(`Peer connection state: ${pc.connectionState}`); + if (pc.iceConnectionState === "connected" && pc.connectionState === "connected") { + this.resetTransportDegradeEpisode(); + } + this.scheduleTransportDegradeCheck(pc); }; pc.ondatachannel = (event) => { @@ -3818,6 +3877,10 @@ export class GfnWebRtcClient { pc.oniceconnectionstatechange = () => { this.log(`ICE connection state: ${pc.iceConnectionState}`); + if (pc.iceConnectionState === "connected" && pc.connectionState === "connected") { + this.resetTransportDegradeEpisode(); + } + this.scheduleTransportDegradeCheck(pc); }; pc.onicegatheringstatechange = () => { @@ -4060,6 +4123,10 @@ export class GfnWebRtcClient { await this.pc.addIceCandidate(init); } + getConnectionState(): RTCPeerConnectionState | null { + return this.pc?.connectionState ?? null; + } + dispose(): void { this.cleanupPeerConnection(); diff --git a/opennow-stable/src/renderer/src/lib/signalingRecovery.test.ts b/opennow-stable/src/renderer/src/lib/signalingRecovery.test.ts new file mode 100644 index 000000000..6948a61a8 --- /dev/null +++ b/opennow-stable/src/renderer/src/lib/signalingRecovery.test.ts @@ -0,0 +1,23 @@ +/// + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { SIGNALING_RECOVERY_BASE_DELAYS_MS, signalingRecoveryDelayMs } from "./signalingRecovery"; + +test("signalingRecoveryDelayMs returns 0 for non-positive base", () => { + assert.equal(signalingRecoveryDelayMs(0), 0); + assert.equal(signalingRecoveryDelayMs(-100), 0); +}); + +test("signalingRecoveryDelayMs applies deterministic jitter from rand", () => { + const alwaysLow = () => 0; + const alwaysHigh = () => 0.999999; + assert.equal(signalingRecoveryDelayMs(1000, alwaysLow), Math.floor(1000 * 0.85)); + assert.equal(signalingRecoveryDelayMs(1000, alwaysHigh), Math.floor(1000 * 1.1499997)); +}); + +test("SIGNALING_RECOVERY_BASE_DELAYS_MS has expected length and first step zero", () => { + assert.ok(SIGNALING_RECOVERY_BASE_DELAYS_MS.length >= 3); + assert.equal(SIGNALING_RECOVERY_BASE_DELAYS_MS[0], 0); +}); diff --git a/opennow-stable/src/renderer/src/lib/signalingRecovery.ts b/opennow-stable/src/renderer/src/lib/signalingRecovery.ts new file mode 100644 index 000000000..0472fef62 --- /dev/null +++ b/opennow-stable/src/renderer/src/lib/signalingRecovery.ts @@ -0,0 +1,18 @@ +/** Base backoff steps for full reclaim (Stage B). Jitter applied per attempt in App. */ +export const SIGNALING_RECOVERY_BASE_DELAYS_MS = [0, 1500, 4000, 8000, 15_000] as const; + +const JITTER_MIN = 0.85; +const JITTER_SPAN = 0.3; +const MAX_DELAY_MS = 60_000; + +/** Apply ±15% jitter to a base delay (deterministic when `rand` is fixed — for tests). */ +export function signalingRecoveryDelayMs( + baseMs: number, + rand: () => number = Math.random, +): number { + if (baseMs <= 0) { + return 0; + } + const jitter = JITTER_MIN + rand() * JITTER_SPAN; + return Math.min(MAX_DELAY_MS, Math.floor(baseMs * jitter)); +} From 5347702ec341f14c4578a455a0b7939049cd4923 Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 10:50:45 -0500 Subject: [PATCH 2/2] Enhance session recovery error handling in App component. Implement detailed error logging and user feedback for transport recovery failures. When a session connection is lost, provide a descriptive error message and reset the launch runtime while preserving the streaming context. This improves user experience during connectivity issues. --- opennow-stable/src/renderer/src/App.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 0337392a3..1f9d6465f 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -2913,7 +2913,20 @@ export function App(): JSX.Element { }, onTransportDegraded: (detail) => { console.warn("[App] Transport degraded:", detail); - void attemptSessionRecoveryRef.current("peer-connection-degraded"); + void attemptSessionRecoveryRef.current("peer-connection-degraded").catch((error) => { + console.error("[Recovery] Transport recovery failed:", error); + 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; + }); }, }); if (settings.microphoneMode !== "disabled") {