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",
|
||||
});
|
||||
|
||||
// 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
|
||||
const errorHandler = (error, stackTrace) => {
|
||||
try {
|
||||
|
|
@ -219,8 +178,53 @@ const setupGlobalErrorHandlers = () => {
|
|||
// Initialize error handlers immediately
|
||||
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() {
|
||||
appLogger.info("Initializing app features");
|
||||
// Avoid logging inside render (this component re-renders frequently due to store updates).
|
||||
useEffect(() => {
|
||||
appLogger.info("Initializing app features");
|
||||
}, []);
|
||||
|
||||
useFcm();
|
||||
useUpdates();
|
||||
useNetworkListener();
|
||||
|
|
@ -293,7 +297,7 @@ function AppContent() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
appLogger.info("App initialization complete");
|
||||
// Avoid logging inside render (see effect above).
|
||||
return (
|
||||
<>
|
||||
<AppLifecycleListener />
|
||||
|
|
|
|||
|
|
@ -180,22 +180,41 @@ const AppLifecycleListener = () => {
|
|||
const lastWsRestartAtRef = useRef(0);
|
||||
const MIN_WS_RESTART_INTERVAL_MS = 15_000;
|
||||
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 } =
|
||||
useNetworkState([
|
||||
"hasInternetConnection",
|
||||
"wsConnected",
|
||||
"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(() => {
|
||||
const handleAppStateChange = (nextAppState) => {
|
||||
lifecycleLogger.debug("App state changing", {
|
||||
from: appState.current,
|
||||
to: nextAppState,
|
||||
hasInternet: hasInternetConnection,
|
||||
hasInternet: hasInternetConnectionRef.current,
|
||||
});
|
||||
|
||||
if (!hasInternetConnection) {
|
||||
if (!hasInternetConnectionRef.current) {
|
||||
lifecycleLogger.debug("Skipping state change handling - no internet");
|
||||
return;
|
||||
}
|
||||
|
|
@ -248,17 +267,30 @@ const AppLifecycleListener = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const hbMs = wsLastHeartbeatDate
|
||||
? Date.parse(wsLastHeartbeatDate)
|
||||
const hbMs = wsLastHeartbeatDateRef.current
|
||||
? Date.parse(wsLastHeartbeatDateRef.current)
|
||||
: NaN;
|
||||
const heartbeatAgeMs = Number.isFinite(hbMs) ? now - hbMs : null;
|
||||
|
||||
lifecycleLogger.info("Foreground WS check", {
|
||||
inactiveTime: timeSinceLastActive,
|
||||
wsConnected,
|
||||
wsConnected: wsConnectedRef.current,
|
||||
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;
|
||||
lifecycleLogger.info("Restarting WebSocket connection");
|
||||
networkActions.WSRecoveryTouch();
|
||||
|
|
@ -291,7 +323,7 @@ const AppLifecycleListener = () => {
|
|||
activeTimeout.current = null;
|
||||
}
|
||||
};
|
||||
}, [completed, hasInternetConnection, wsConnected, wsLastHeartbeatDate]);
|
||||
}, [completed]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
|
|||
});
|
||||
|
||||
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_TIMEOUT = 5_000;
|
||||
|
|
@ -27,6 +34,8 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
|
|||
const { userToken } = getAuthState();
|
||||
const headers = {};
|
||||
|
||||
lastConnectionHadToken = !!userToken;
|
||||
|
||||
// Important: only attach Authorization when we have a real token.
|
||||
// Sending `Authorization: Bearer undefined` breaks WS auth on some backends.
|
||||
if (userToken) {
|
||||
|
|
@ -48,11 +57,11 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
|
|||
lazy: false,
|
||||
keepAlive: PING_INTERVAL,
|
||||
retryAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||
retryWait: async () => {
|
||||
// `graphql-ws` passes the retry count to `retryWait(retries)`.
|
||||
retryWait: async (retries = 0) => {
|
||||
// `graphql-ws` calls `retryWait(retries)`.
|
||||
// Use a jittered exponential backoff, capped.
|
||||
const retries = arguments[0] ?? 0;
|
||||
const base = Math.min(1000 * Math.pow(2, retries), 30_000);
|
||||
const safeRetries = Number.isFinite(retries) ? retries : 0;
|
||||
const base = Math.min(1000 * Math.pow(2, safeRetries), 30_000);
|
||||
const delay = base * (0.5 + Math.random());
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
},
|
||||
|
|
@ -69,6 +78,33 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
|
|||
networkActions.WSConnected();
|
||||
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
|
||||
if (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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,12 +46,17 @@ export default withConnectivity(function Profile({ navigation, route }) {
|
|||
);
|
||||
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsClosedDate) return;
|
||||
if (typeof restart !== "function") return;
|
||||
// WS was closed/reconnected; restart the subscription to avoid being stuck.
|
||||
try {
|
||||
profileLogger.info(
|
||||
|
|
@ -92,6 +97,7 @@ export default withConnectivity(function Profile({ navigation, route }) {
|
|||
});
|
||||
try {
|
||||
lastDataAtRef.current = Date.now();
|
||||
if (typeof restart !== "function") return;
|
||||
restart();
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue