fix(ws): stabilization try 5
This commit is contained in:
parent
f7656beb1a
commit
ef643f77cb
4 changed files with 169 additions and 53 deletions
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue