From 147e514d03a43e74b5e7d6361183b4006dc1186d Mon Sep 17 00:00:00 2001 From: devthejo Date: Thu, 15 Jan 2026 19:17:58 +0100 Subject: [PATCH] fix(ws): stabilization try 1 --- src/app/index.js | 2 + src/hooks/useLatestWithSubscription.js | 38 ++++++----- src/hooks/useStreamQueryWithSubscription.js | 39 ++++++----- src/hooks/useWsWatchdog.js | 74 +++++++++++++++++++++ src/network/wsLink.js | 3 + src/scenes/Params/index.js | 5 +- src/scenes/Profile/index.js | 16 ++++- src/stores/network.js | 8 +++ 8 files changed, 150 insertions(+), 35 deletions(-) create mode 100644 src/hooks/useWsWatchdog.js diff --git a/src/app/index.js b/src/app/index.js index 228ad6c..440afe5 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -29,6 +29,7 @@ import { } from "react-native-safe-area-context"; import useTrackLocation from "~/hooks/useTrackLocation"; +import useWsWatchdog from "~/hooks/useWsWatchdog"; // import { initializeBackgroundFetch } from "~/services/backgroundFetch"; import useMount from "~/hooks/useMount"; @@ -224,6 +225,7 @@ function AppContent() { useUpdates(); useNetworkListener(); useTrackLocation(); + useWsWatchdog(); // useMount(() => { // const setupBackgroundFetch = async () => { diff --git a/src/hooks/useLatestWithSubscription.js b/src/hooks/useLatestWithSubscription.js index e0c2574..71bb564 100644 --- a/src/hooks/useLatestWithSubscription.js +++ b/src/hooks/useLatestWithSubscription.js @@ -47,6 +47,7 @@ export default function useLatestWithSubscription( const retryCountRef = useRef(0); const subscriptionErrorRef = useRef(null); const timeoutIdRef = useRef(null); + const unsubscribeRef = useRef(null); useEffect(() => { const currentVarsHash = JSON.stringify(variables); @@ -134,6 +135,17 @@ export default function useLatestWithSubscription( if (!subscribeToMore) return; if (highestIdRef.current === null) return; // Wait until we have the highest ID + // Always cleanup any previous active subscription before creating a new one. + // React only runs the cleanup returned directly from the effect. + if (unsubscribeRef.current) { + try { + unsubscribeRef.current(); + } catch (_e) { + // ignore + } + unsubscribeRef.current = null; + } + // Check if max retries reached and we have an error if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) { console.error( @@ -283,15 +295,7 @@ export default function useLatestWithSubscription( }, }); - // Cleanup on unmount or re-run - return () => { - console.log(`[${subscriptionKey}] Cleaning up subscription`); - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = null; - } - unsubscribe(); - }; + unsubscribeRef.current = unsubscribe; } catch (error) { // Handle setup errors (like malformed queries) console.error( @@ -331,22 +335,24 @@ export default function useLatestWithSubscription( console.error("Failed to report to Sentry:", sentryError); } } - - return () => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = null; - } - }; } }, backoffDelay); // Cleanup function that will run when component unmounts or effect re-runs return () => { + console.log(`[${subscriptionKey}] Cleaning up subscription`); if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); timeoutIdRef.current = null; } + if (unsubscribeRef.current) { + try { + unsubscribeRef.current(); + } catch (_e) { + // ignore + } + unsubscribeRef.current = null; + } }; }, [ skip, diff --git a/src/hooks/useStreamQueryWithSubscription.js b/src/hooks/useStreamQueryWithSubscription.js index 9b86fa2..1474dfe 100644 --- a/src/hooks/useStreamQueryWithSubscription.js +++ b/src/hooks/useStreamQueryWithSubscription.js @@ -40,6 +40,7 @@ export default function useStreamQueryWithSubscription( const retryCountRef = useRef(0); const subscriptionErrorRef = useRef(null); const timeoutIdRef = useRef(null); + const unsubscribeRef = useRef(null); useEffect(() => { const currentVarsHash = JSON.stringify(variables); @@ -124,6 +125,18 @@ export default function useStreamQueryWithSubscription( if (skip) return; // If skipping, do nothing if (!subscribeToMore) return; + // If we're about to (re)subscribe, always cleanup any previous subscription first. + // This is critical because React effect cleanups must be returned synchronously + // from the effect, not from inside async callbacks. + if (unsubscribeRef.current) { + try { + unsubscribeRef.current(); + } catch (_e) { + // ignore + } + unsubscribeRef.current = null; + } + // Check if max retries reached and we have an error - this check must be done regardless of other conditions if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) { console.error( @@ -289,15 +302,7 @@ export default function useStreamQueryWithSubscription( }, }); - // Cleanup on unmount or re-run - return () => { - console.log(`[${subscriptionKey}] Cleaning up subscription`); - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = null; - } - unsubscribe(); - }; + unsubscribeRef.current = unsubscribe; } catch (error) { // Handle setup errors (like malformed queries) console.error( @@ -337,22 +342,24 @@ export default function useStreamQueryWithSubscription( console.error("Failed to report to Sentry:", sentryError); } } - - return () => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = null; - } - }; } }, backoffDelay); // Cleanup function that will run when component unmounts or effect re-runs return () => { + console.log(`[${subscriptionKey}] Cleaning up subscription`); if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); timeoutIdRef.current = null; } + if (unsubscribeRef.current) { + try { + unsubscribeRef.current(); + } catch (_e) { + // ignore + } + unsubscribeRef.current = null; + } }; }, [ skip, diff --git a/src/hooks/useWsWatchdog.js b/src/hooks/useWsWatchdog.js new file mode 100644 index 0000000..dec52c5 --- /dev/null +++ b/src/hooks/useWsWatchdog.js @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { useNetworkState, networkActions } from "~/stores"; +import network from "~/network"; +import { createLogger } from "~/lib/logger"; +import { NETWORK_SCOPES } from "~/lib/logger/scopes"; + +const watchdogLogger = createLogger({ + module: NETWORK_SCOPES.WEBSOCKET, + feature: "watchdog", +}); + +const HEARTBEAT_STALE_MS = 45_000; +const CHECK_EVERY_MS = 10_000; +const MIN_RESTART_INTERVAL_MS = 30_000; + +export default function useWsWatchdog({ enabled = true } = {}) { + const { wsConnected, wsLastHeartbeatDate, hasInternetConnection } = + useNetworkState([ + "wsConnected", + "wsLastHeartbeatDate", + "hasInternetConnection", + ]); + + const lastRestartRef = useRef(0); + + useEffect(() => { + if (!enabled) return; + + const interval = setInterval(() => { + if (!hasInternetConnection) return; + if (!wsConnected) return; + if (!wsLastHeartbeatDate) return; + + const last = Date.parse(wsLastHeartbeatDate); + if (!Number.isFinite(last)) return; + + const age = Date.now() - last; + if (age < HEARTBEAT_STALE_MS) return; + + const now = Date.now(); + if (now - lastRestartRef.current < MIN_RESTART_INTERVAL_MS) return; + lastRestartRef.current = now; + + watchdogLogger.warn("WS heartbeat stale, triggering recovery", { + ageMs: age, + lastHeartbeatDate: wsLastHeartbeatDate, + }); + + try { + // First line recovery: restart websocket transport + network.apolloClient?.restartWS?.(); + } catch (error) { + watchdogLogger.error("WS restart failed", { error }); + } + + // Second line recovery: if WS stays stale, do a full client reload + setTimeout(() => { + const last2 = Date.parse(wsLastHeartbeatDate); + const age2 = Number.isFinite(last2) ? Date.now() - last2 : Infinity; + if (age2 >= HEARTBEAT_STALE_MS) { + watchdogLogger.warn( + "WS still stale after restart, triggering reload", + { + ageMs: age2, + }, + ); + networkActions.triggerReload(); + } + }, 10_000); + }, CHECK_EVERY_MS); + + return () => clearInterval(interval); + }, [enabled, hasInternetConnection, wsConnected, wsLastHeartbeatDate]); +} diff --git a/src/network/wsLink.js b/src/network/wsLink.js index 655be13..2d4a9bd 100644 --- a/src/network/wsLink.js +++ b/src/network/wsLink.js @@ -81,6 +81,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) { activeSocket = socket; reconnectAttempts = 0; // Reset attempts on successful connection networkActions.WSConnected(); + networkActions.WSTouch(); cancelReconnect(); // Cancel any pending reconnects // Clear any lingering ping timeouts @@ -114,6 +115,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) { }, ping: (received) => { // wsLogger.debug("WebSocket ping", { received }); + networkActions.WSTouch(); if (!received) { // Clear any existing ping timeout if (pingTimeout) { @@ -138,6 +140,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) { }, pong: (received) => { // wsLogger.debug("WebSocket pong", { received }); + networkActions.WSTouch(); if (received) { clearTimeout(pingTimeout); // pong is received, clear connection close timeout } diff --git a/src/scenes/Params/index.js b/src/scenes/Params/index.js index 9639475..976f105 100644 --- a/src/scenes/Params/index.js +++ b/src/scenes/Params/index.js @@ -17,11 +17,14 @@ export default withConnectivity(function Params() { deviceId, }, }); - if (loading || !data) { + if (loading) { return ; } if (error) { return ; } + if (!data) { + return ; + } return ; }); diff --git a/src/scenes/Profile/index.js b/src/scenes/Profile/index.js index a4ddb2d..5c8e734 100644 --- a/src/scenes/Profile/index.js +++ b/src/scenes/Profile/index.js @@ -4,6 +4,7 @@ import { ScrollView, View } from "react-native"; import Loader from "~/components/Loader"; import { useSubscription } from "@apollo/client"; +import Error from "~/components/Error"; import { LOAD_PROFILE_SUBSCRIPTION } from "./gql"; @@ -23,7 +24,7 @@ const profileLogger = createLogger({ export default withConnectivity(function Profile({ navigation, route }) { const { userId } = useSessionState(["userId"]); // profileLogger.debug("Profile user ID", { userId }); - const { data, loading, restart } = useSubscription( + const { data, loading, error, restart } = useSubscription( LOAD_PROFILE_SUBSCRIPTION, { variables: { @@ -44,10 +45,21 @@ export default withConnectivity(function Profile({ navigation, route }) { }); }, [navigation]); - if (loading || !data?.selectOneUser) { + if (loading) { return ; } + if (error) { + profileLogger.error("Profile subscription error", { error }); + return ; + } + + if (!data?.selectOneUser) { + // No error surfaced, but no payload either. Avoid infinite loader. + profileLogger.error("Profile subscription returned no user", { userId }); + return ; + } + return ( { wsConnected: false, wsConnectedDate: null, wsClosedDate: null, + wsLastHeartbeatDate: null, triggerReload: false, initialized: true, hasInternetConnection: true, @@ -27,6 +28,7 @@ export default createAtom(({ merge, get }) => { merge({ wsConnected: true, wsConnectedDate: new Date().toISOString(), + wsLastHeartbeatDate: new Date().toISOString(), }); }, WSClosed: () => { @@ -40,6 +42,12 @@ export default createAtom(({ merge, get }) => { wsClosedDate: new Date().toISOString(), }); }, + WSTouch: () => { + // Update whenever we get any WS-level signal: connected, ping/pong, or a message. + merge({ + wsLastHeartbeatDate: new Date().toISOString(), + }); + }, setHasInternetConnection: (status) => merge({ hasInternetConnection: status }), },