fix(ws): stabilization try 5

This commit is contained in:
devthejo 2026-01-17 23:44:00 +01:00
parent f7656beb1a
commit ef643f77cb
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
4 changed files with 169 additions and 53 deletions

View file

@ -43,47 +43,6 @@ const lifecycleLogger = createLogger({
feature: "error-handling", feature: "error-handling",
}); });
// Initialize stores with error handling
const initializeStores = () => {
try {
appLogger.info("Initializing core stores and subscriptions");
// Initialize each store with error handling
const initializeStore = async (name, initFn) => {
try {
await initFn();
appLogger.debug(`${name} initialized successfully`);
} catch (error) {
lifecycleLogger.error(`Failed to initialize ${name}`, {
error: error?.message,
store: name,
});
errorHandler(error);
}
};
// Initialize memory stores first
initializeStore("memorySecureStore", secureStore.init);
initializeStore("memoryAsyncStorage", memoryAsyncStorage.init);
// Then initialize other stores sequentially
initializeStore("authActions", authActions.init);
initializeStore("permissionWizard", permissionWizardActions.init);
initializeStore("paramsActions", paramsActions.init);
initializeStore("storeSubscriptions", storeSubscriptions.init);
appLogger.info("Core initialization complete");
} catch (error) {
lifecycleLogger.error("Critical: Store initialization failed", {
error: error?.message,
});
errorHandler(error);
}
};
// Initialize stores immediately
initializeStores();
// Enhanced error handler with comprehensive error normalization and handling // Enhanced error handler with comprehensive error normalization and handling
const errorHandler = (error, stackTrace) => { const errorHandler = (error, stackTrace) => {
try { try {
@ -219,8 +178,53 @@ const setupGlobalErrorHandlers = () => {
// Initialize error handlers immediately // Initialize error handlers immediately
setupGlobalErrorHandlers(); setupGlobalErrorHandlers();
// Initialize stores with error handling
const initializeStores = async () => {
try {
appLogger.info("Initializing core stores and subscriptions");
// Initialize each store with error handling
const initializeStore = async (name, initFn) => {
try {
await initFn();
appLogger.debug(`${name} initialized successfully`);
} catch (error) {
lifecycleLogger.error(`Failed to initialize ${name}`, {
error: error?.message,
store: name,
});
errorHandler(error);
}
};
// Initialize memory stores first (needed by auth/params stores)
await initializeStore("memorySecureStore", secureStore.init);
await initializeStore("memoryAsyncStorage", memoryAsyncStorage.init);
// Then initialize other stores sequentially
await initializeStore("authActions", authActions.init);
await initializeStore("permissionWizard", permissionWizardActions.init);
await initializeStore("paramsActions", paramsActions.init);
await initializeStore("storeSubscriptions", storeSubscriptions.init);
appLogger.info("Core initialization complete");
} catch (error) {
lifecycleLogger.error("Critical: Store initialization failed", {
error: error?.message,
});
errorHandler(error);
}
};
// Initialize stores immediately (after errorHandler is defined)
void initializeStores();
function AppContent() { function AppContent() {
// Avoid logging inside render (this component re-renders frequently due to store updates).
useEffect(() => {
appLogger.info("Initializing app features"); appLogger.info("Initializing app features");
}, []);
useFcm(); useFcm();
useUpdates(); useUpdates();
useNetworkListener(); useNetworkListener();
@ -293,7 +297,7 @@ function AppContent() {
}; };
}, []); }, []);
appLogger.info("App initialization complete"); // Avoid logging inside render (see effect above).
return ( return (
<> <>
<AppLifecycleListener /> <AppLifecycleListener />

View file

@ -180,22 +180,41 @@ const AppLifecycleListener = () => {
const lastWsRestartAtRef = useRef(0); const lastWsRestartAtRef = useRef(0);
const MIN_WS_RESTART_INTERVAL_MS = 15_000; const MIN_WS_RESTART_INTERVAL_MS = 15_000;
const { completed } = usePermissionWizardState(["completed"]); const { completed } = usePermissionWizardState(["completed"]);
// Important: don't put rapidly-changing network state in the main effect deps,
// otherwise we re-register the AppState listener and rerun the "initial permission check"
// in a tight loop (causing freezes + log spam).
const { hasInternetConnection, wsConnected, wsLastHeartbeatDate } = const { hasInternetConnection, wsConnected, wsLastHeartbeatDate } =
useNetworkState([ useNetworkState([
"hasInternetConnection", "hasInternetConnection",
"wsConnected", "wsConnected",
"wsLastHeartbeatDate", "wsLastHeartbeatDate",
]); ]);
const hasInternetConnectionRef = useRef(hasInternetConnection);
const wsConnectedRef = useRef(wsConnected);
const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate);
useEffect(() => {
hasInternetConnectionRef.current = hasInternetConnection;
}, [hasInternetConnection]);
useEffect(() => {
wsConnectedRef.current = wsConnected;
}, [wsConnected]);
useEffect(() => {
wsLastHeartbeatDateRef.current = wsLastHeartbeatDate;
}, [wsLastHeartbeatDate]);
useEffect(() => { useEffect(() => {
const handleAppStateChange = (nextAppState) => { const handleAppStateChange = (nextAppState) => {
lifecycleLogger.debug("App state changing", { lifecycleLogger.debug("App state changing", {
from: appState.current, from: appState.current,
to: nextAppState, to: nextAppState,
hasInternet: hasInternetConnection, hasInternet: hasInternetConnectionRef.current,
}); });
if (!hasInternetConnection) { if (!hasInternetConnectionRef.current) {
lifecycleLogger.debug("Skipping state change handling - no internet"); lifecycleLogger.debug("Skipping state change handling - no internet");
return; return;
} }
@ -248,17 +267,30 @@ const AppLifecycleListener = () => {
return; return;
} }
const hbMs = wsLastHeartbeatDate const hbMs = wsLastHeartbeatDateRef.current
? Date.parse(wsLastHeartbeatDate) ? Date.parse(wsLastHeartbeatDateRef.current)
: NaN; : NaN;
const heartbeatAgeMs = Number.isFinite(hbMs) ? now - hbMs : null; const heartbeatAgeMs = Number.isFinite(hbMs) ? now - hbMs : null;
lifecycleLogger.info("Foreground WS check", { lifecycleLogger.info("Foreground WS check", {
inactiveTime: timeSinceLastActive, inactiveTime: timeSinceLastActive,
wsConnected, wsConnected: wsConnectedRef.current,
heartbeatAgeMs, heartbeatAgeMs,
}); });
// Only restart the WS transport when it looks unhealthy.
// Restarting while already connected causes unnecessary 4205 closes
// and cascades into subscription teardown/resubscribe loops.
const shouldRestart =
!wsConnectedRef.current ||
heartbeatAgeMs === null ||
heartbeatAgeMs > 45_000 ||
timeSinceLastActive > 30_000;
if (!shouldRestart) {
return;
}
lastWsRestartAtRef.current = now; lastWsRestartAtRef.current = now;
lifecycleLogger.info("Restarting WebSocket connection"); lifecycleLogger.info("Restarting WebSocket connection");
networkActions.WSRecoveryTouch(); networkActions.WSRecoveryTouch();
@ -291,7 +323,7 @@ const AppLifecycleListener = () => {
activeTimeout.current = null; activeTimeout.current = null;
} }
}; };
}, [completed, hasInternetConnection, wsConnected, wsLastHeartbeatDate]); }, [completed]);
return null; return null;
}; };

View file

@ -12,6 +12,13 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
}); });
let activeSocket, pingTimeout; let activeSocket, pingTimeout;
let lastConnectionHadToken = false;
let lastTokenRestartAt = 0;
// If we connect before auth is ready, Hasura will treat the whole WS session as unauthenticated
// (auth is evaluated on `connection_init`). When the user token becomes available later,
// we must restart the WS transport once so `connectionParams` includes the token.
const TOKEN_RESTART_MIN_INTERVAL_MS = 10_000;
const PING_INTERVAL = 10_000; const PING_INTERVAL = 10_000;
const PING_TIMEOUT = 5_000; const PING_TIMEOUT = 5_000;
@ -27,6 +34,8 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
const { userToken } = getAuthState(); const { userToken } = getAuthState();
const headers = {}; const headers = {};
lastConnectionHadToken = !!userToken;
// Important: only attach Authorization when we have a real token. // Important: only attach Authorization when we have a real token.
// Sending `Authorization: Bearer undefined` breaks WS auth on some backends. // Sending `Authorization: Bearer undefined` breaks WS auth on some backends.
if (userToken) { if (userToken) {
@ -48,11 +57,11 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
lazy: false, lazy: false,
keepAlive: PING_INTERVAL, keepAlive: PING_INTERVAL,
retryAttempts: MAX_RECONNECT_ATTEMPTS, retryAttempts: MAX_RECONNECT_ATTEMPTS,
retryWait: async () => { retryWait: async (retries = 0) => {
// `graphql-ws` passes the retry count to `retryWait(retries)`. // `graphql-ws` calls `retryWait(retries)`.
// Use a jittered exponential backoff, capped. // Use a jittered exponential backoff, capped.
const retries = arguments[0] ?? 0; const safeRetries = Number.isFinite(retries) ? retries : 0;
const base = Math.min(1000 * Math.pow(2, retries), 30_000); const base = Math.min(1000 * Math.pow(2, safeRetries), 30_000);
const delay = base * (0.5 + Math.random()); const delay = base * (0.5 + Math.random());
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
}, },
@ -69,6 +78,33 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
networkActions.WSConnected(); networkActions.WSConnected();
networkActions.WSTouch(); networkActions.WSTouch();
// If we connected without a token, and a token is now available, restart once.
// This avoids `ApolloError: no subscriptions exist` caused by an unauthenticated WS session.
const { userToken } = getAuthState();
if (!lastConnectionHadToken && userToken) {
const now = Date.now();
if (now - lastTokenRestartAt >= TOKEN_RESTART_MIN_INTERVAL_MS) {
lastTokenRestartAt = now;
networkActions.WSRecoveryTouch();
wsLogger.warn(
"WS connected before auth; restarting to apply user token",
{
url: GRAPHQL_WS_URL,
},
);
try {
wsLink.client?.restart?.();
} catch (error) {
wsLogger.error(
"Failed to restart WS after token became available",
{
error,
},
);
}
}
}
// Clear any lingering ping timeouts // Clear any lingering ping timeouts
if (pingTimeout) { if (pingTimeout) {
clearTimeout(pingTimeout); clearTimeout(pingTimeout);
@ -131,5 +167,43 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
}, },
}); });
// Also listen to token changes and restart if we already have an unauthenticated WS session.
// This catches the common startup sequence:
// - WS opens/CONNECTS with no token
// - auth store later obtains userToken
if (typeof store?.subscribeAuthState === "function") {
store.subscribeAuthState(
(s) => s?.userToken,
(userToken) => {
if (!userToken) return;
if (lastConnectionHadToken) return;
if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) return;
const now = Date.now();
if (now - lastTokenRestartAt < TOKEN_RESTART_MIN_INTERVAL_MS) return;
lastTokenRestartAt = now;
networkActions.WSRecoveryTouch();
wsLogger.warn(
"Auth token became available; restarting unauthenticated WS",
{
url: GRAPHQL_WS_URL,
},
);
try {
wsLink.client?.restart?.();
} catch (error) {
wsLogger.error("Failed to restart WS on auth token change", {
error,
});
}
},
);
} else {
wsLogger.warn("WS link could not subscribe to auth changes", {
reason: "store.subscribeAuthState is not a function",
});
}
return wsLink; return wsLink;
} }

View file

@ -46,12 +46,17 @@ export default withConnectivity(function Profile({ navigation, route }) {
); );
useEffect(() => { useEffect(() => {
// If the subscription is currently skipped (no userId yet),
// `restart` might not be available depending on Apollo version.
if (!userId) return;
if (typeof restart !== "function") return;
restart(); restart();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]); }, [userId]);
useEffect(() => { useEffect(() => {
if (!wsClosedDate) return; if (!wsClosedDate) return;
if (typeof restart !== "function") return;
// WS was closed/reconnected; restart the subscription to avoid being stuck. // WS was closed/reconnected; restart the subscription to avoid being stuck.
try { try {
profileLogger.info( profileLogger.info(
@ -92,6 +97,7 @@ export default withConnectivity(function Profile({ navigation, route }) {
}); });
try { try {
lastDataAtRef.current = Date.now(); lastDataAtRef.current = Date.now();
if (typeof restart !== "function") return;
restart(); restart();
} catch (_e) { } catch (_e) {
// ignore // ignore