From e5c8e0048fe95d2cdc17160cb7583d96bcb3e37d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 20:50:28 -0400 Subject: [PATCH 1/7] refactor(startup): replace handshake waiter machine with bootstrap orchestrator startHandshake is now a retry loop that loads bootstrap status then awaits injected gating steps; handshakeWaiters/handshakeVersion/wait() and the loadOnStartPhase enum are gone. Fixes auto-retry, which never re-ran the handshake and reset its own counter, leaving the splash stuck on non-fatal failures with no Reload button. - DaemonHandshakeState: loading | done | failed - bootstrap-status and account RPCs deduped (2-3x at cold start -> 1x) - loggedInCausedbyStartup/fromMenubar were write-only; deleted --- shared/constants/config.tsx | 1 - shared/constants/init/index.tsx | 70 +++------ shared/constants/init/shared.tsx | 171 ++++++++------------- shared/constants/types/config.tsx | 2 +- shared/login/loading.tsx | 6 +- shared/menubar/index.desktop.tsx | 2 +- shared/stores/config.tsx | 98 +++++------- shared/stores/daemon.tsx | 236 ++++++++++------------------- shared/stores/tests/daemon.test.ts | 165 ++++++++++++++------ 9 files changed, 327 insertions(+), 424 deletions(-) diff --git a/shared/constants/config.tsx b/shared/constants/config.tsx index 86fd6cae67bf..4a58d3da8fbd 100644 --- a/shared/constants/config.tsx +++ b/shared/constants/config.tsx @@ -5,7 +5,6 @@ export const invalidPasswordErrorString = 'Bad password: Invalid password. Serve export const defaultKBFSPath = runMode === 'prod' ? '/keybase' : `/keybase.${runMode}` export const defaultPrivatePrefix = '/private/' export const defaultPublicPrefix = '/public/' -export const noKBFSFailReason = "Can't connect to KBFS" const defaultTeamPrefix = '/team/' export const privateFolderWithUsers = (users: ReadonlyArray) => diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index a41fb47aa5f9..925d3468a2d9 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -2,7 +2,7 @@ import * as Chat from '@/constants/chat' import {ignorePromise, neverThrowPromiseFunc} from '@/constants/utils' import {useConfigState} from '@/stores/config' -import {useDaemonState} from '@/stores/daemon' +import {FatalHandshakeError, useDaemonState} from '@/stores/daemon' import {useRouterState} from '@/stores/router' import {useShellState, type ConnectionType} from '@/stores/shell' import {useSettingsContactsState} from '@/stores/settings-contacts' @@ -16,7 +16,6 @@ import {afterKbfsDaemonRpcStatusChanged} from '@/fs/common/lifecycle' import {logState, setThreadInputCommandStatus} from '@/constants/router' import {initSharedSubscriptions, _onEngineIncoming, onEngineConnected as onSharedEngineConnected} from './shared' import {noConversationIDKey} from '../types/chat/common' -import {noKBFSFailReason} from '@/constants/config' import {dumpLogs, persistRoute} from '@/util/storeless-actions' // ─── Desktop-only imports (runtime-guarded) ────────────────────────────────── @@ -450,16 +449,6 @@ const _initNativePlatformListener = () => { } } - useDaemonState.subscribe((s, old) => { - const versionChanged = s.handshakeVersion !== old.handshakeVersion - const stateChanged = s.handshakeState !== old.handshakeState - const justBecameReady = stateChanged && s.handshakeState === 'done' && old.handshakeState !== 'done' - - if (versionChanged || justBecameReady) { - configureAndroidCacheDir() - } - }) - useConfigState.subscribe((s, old) => { if (s.loggedIn === old.loggedIn) return const f = async () => { @@ -554,14 +543,23 @@ const _initNativePlatformListener = () => { ignorePromise(setupAudioMode(false)) if (isAndroid) { - const daemonState = useDaemonState.getState() - if (daemonState.handshakeState === 'done' || daemonState.handshakeVersion > 0) { + // HMR re-init after the handshake already finished: the bootstrap step won't run again + if (useDaemonState.getState().handshakeState === 'done') { configureAndroidCacheDir() } afterKbfsDaemonRpcStatusChanged() } - initSharedSubscriptions() + initSharedSubscriptions( + isAndroid + ? [ + async () => { + configureAndroidCacheDir() + return Promise.resolve() + }, + ] + : [] + ) } const _initDesktopPlatformListener = () => { @@ -660,30 +658,6 @@ const _initDesktopPlatformListener = () => { } _platformUnsubs.push(useDaemonState.subscribe((s, old) => { - if (s.handshakeVersion !== old.handshakeVersion) { - if (!isWindows) return - - const f = async () => { - const waitKey = 'pipeCheckFail' - const version = s.handshakeVersion - const {wait} = s.dispatch - wait(waitKey, version, true) - try { - logger.info('Checking RPC ownership') - if (KB2.functions.winCheckRPCOwnership) { - await KB2.functions.winCheckRPCOwnership() - } - wait(waitKey, version, false) - } catch (error_) { - // error will be logged in bootstrap check - getEngine().reset() - const error = error_ as {message?: string} - wait(waitKey, version, false, error.message || 'windows pipe owner fail', true) - } - } - ignorePromise(f()) - } - if (s.handshakeState !== old.handshakeState && s.handshakeState === 'done') { useConfigState.getState().dispatch.setStartupDetails({ conversation: Chat.noConversationIDKey, @@ -694,20 +668,24 @@ const _initDesktopPlatformListener = () => { } })) - useDaemonState.setState(s => { - s.dispatch.onRestartHandshakeNative = () => { - const {handshakeFailedReason} = useDaemonState.getState() - if (isWindows && handshakeFailedReason === noKBFSFailReason) { - KB2.functions.requestWindowsStartService?.() + const winPipeCheckStep = async () => { + try { + logger.info('Checking RPC ownership') + if (KB2.functions.winCheckRPCOwnership) { + await KB2.functions.winCheckRPCOwnership() } + } catch (error_) { + getEngine().reset() + const error = error_ as {message?: string} + throw new FatalHandshakeError(error.message || 'windows pipe owner fail') } - }) + } if (!isLinux) { afterKbfsDaemonRpcStatusChanged() } - initSharedSubscriptions() + initSharedSubscriptions(isWindows ? [winPipeCheckStep] : []) } export {onEngineConnected, onEngineDisconnected} from './shared' diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 704b5983e427..c946979249da 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -5,8 +5,6 @@ import isEqual from 'lodash/isEqual' import logger from '@/logger' import * as Tabs from '@/constants/tabs' declare global { - var __hmr_startupOnce: boolean | undefined - var __hmr_sharedUnsubs: Array<() => void> | undefined var __hmr_platformUnsubs: Array<() => void> | undefined @@ -21,13 +19,13 @@ import type * as UseUsersStateType from '@/stores/users' import {notifyEngineActionListeners} from '@/engine/action-listener' import {serviceStaticConfigToStaticConfig} from '@/constants/chat/static-config' import {emitDeepLink} from '@/router-v2/linking' -import {ignorePromise} from '../utils' +import {ignorePromise, timeoutPromise} from '../utils' import {isPhone, serverConfigFileName} from '../platform' import {useAvatarState} from '@/common-adapters/avatar/store' import {useInboxLayoutState} from '@/chat/inbox/layout-state' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' -import {useDaemonState} from '@/stores/daemon' +import {useDaemonState, type BootstrapStep} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' import {useFollowerState} from '@/stores/followers' import {useShellState} from '@/stores/shell' @@ -52,10 +50,7 @@ import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' import {clearNavBadges} from '@/teams/actions' -let _emitStartupOnLoadDaemonConnectedOnce: boolean = __DEV__ ? (globalThis.__hmr_startupOnce ?? false) : false - const _sharedUnsubs: Array<() => void> = __DEV__ ? (globalThis.__hmr_sharedUnsubs ??= []) : [] -const getAccountsWaitKey = 'config.getAccounts' type SubscribeStore = { subscribe: (listener: (state: State, previousState: State) => void) => () => void @@ -78,51 +73,40 @@ type ConfigState = ReturnType type DaemonState = ReturnType type RouterState = ReturnType -const bootstrapStatusKey = (bootstrap?: DaemonState['bootstrapStatus']) => - bootstrap - ? [ - bootstrap.registered ? '1' : '0', - bootstrap.loggedIn ? '1' : '0', - bootstrap.uid, - bootstrap.username, - bootstrap.deviceID, - bootstrap.deviceName, - bootstrap.fullname, - bootstrap.httpSrvInfo?.address ?? '', - bootstrap.httpSrvInfo?.token ?? '', - ].join('\x00') - : '' - -const loadConfiguredAccountsForBootstrap = () => { - const configState = useConfigState.getState() - if (configState.configuredAccounts.length) { - return - } +// ─── Bootstrap steps ────────────────────────────────────────────────────────── +// Gating steps for the daemon handshake, run by useDaemonState.dispatch.startHandshake after +// bootstrapStatus loads. Throwing fails the attempt and triggers a retry. - const version = useDaemonState.getState().handshakeVersion - const handshakeWait = !configState.loggedIn - const refreshAccounts = configState.dispatch.refreshAccounts - const {wait} = useDaemonState.getState().dispatch - - const f = async () => { - try { - if (handshakeWait) { - wait(getAccountsWaitKey, version, true) - } - - await refreshAccounts() +const loadDarkPrefsStep = async () => { + useDarkModeState.getState().dispatch.loadDarkPrefs() + return Promise.resolve() +} - if (handshakeWait && useDaemonState.getState().handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false) - } - } catch { - if (handshakeWait && useDaemonState.getState().handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false, "Can't get accounts") - } - } +const loadChatStaticConfigStep = async () => { + const {chatBuiltinCommands, chatDeletableByDeleteHistory} = useConfigState.getState() + if (chatBuiltinCommands && chatDeletableByDeleteHistory) { + return } + const staticConfig = serviceStaticConfigToStaticConfig(await T.RPCChat.localGetStaticConfigRpcPromise()) + if (!staticConfig) { + logger.error('chat.loadStaticConfig: missing required static config') + return + } + useConfigState.getState().dispatch.setChatStaticConfig(staticConfig) +} - ignorePromise(f()) +const loadAccountsStep = async () => { + const refreshAccounts = useConfigState.getState().dispatch.refreshAccounts + if (useDaemonState.getState().bootstrapStatus?.loggedIn) { + // logged in: the account list only feeds the switcher, don't gate startup on it + ignorePromise(refreshAccounts().catch(() => {})) + return + } + try { + await refreshAccounts() + } catch { + throw new Error("Can't get accounts") + } } const requestFollowerInfoForStartup = () => { @@ -158,22 +142,25 @@ const refreshStartupChat = () => { } } -const onLoadOnStartPhaseChanged = (loadOnStartPhase: ConfigState['loadOnStartPhase']) => { - if (loadOnStartPhase !== 'startupOrReloginButNotInARush') { - return +// Loads that want a logged-in user but shouldn't compete with first paint +const scheduleStartupOrReloginWork = () => { + const f = async () => { + await timeoutPromise(1000) + requestAnimationFrame(() => { + requestFollowerInfoForStartup() + ignorePromise(updateServerConfigForStartup()) + loadStartupSettings() + refreshStartupChat() + }) } - - requestFollowerInfoForStartup() - ignorePromise(updateServerConfigForStartup()) - loadStartupSettings() - refreshStartupChat() + ignorePromise(f()) } const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable']) => { // Re-get info about our account if you log in/we're done handshaking/became reachable if ( gregorReachable === T.RPCGen.Reachable.yes && - useDaemonState.getState().handshakeWaiters.size === 0 && + useDaemonState.getState().handshakeState === 'done' && !useConfigState.getState().userSwitching ) { ignorePromise(useDaemonState.getState().dispatch.loadDaemonBootstrapStatus()) @@ -182,7 +169,10 @@ const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable' const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { if (loggedIn) { + // runtime login: refresh bootstrap status. During the handshake this is already in + // flight, and the store dedupes it. ignorePromise(useDaemonState.getState().dispatch.loadDaemonBootstrapStatus()) + scheduleStartupOrReloginWork() } else { clearSignupEmail() clearSignupDeviceNameDraft() @@ -191,14 +181,11 @@ const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { ) as typeof UseBlockButtonsStateType useBlockButtonsState.getState().dispatch.resetState() } - loadConfiguredAccountsForBootstrap() - if (!useConfigState.getState().loggedInCausedbyStartup) { - ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) - } + ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) } const onRevokedTriggerChanged = () => { - loadConfiguredAccountsForBootstrap() + ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) } const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configuredAccounts']) => { @@ -211,35 +198,6 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -const loadChatStaticConfig = () => { - const {chatBuiltinCommands, chatDeletableByDeleteHistory} = useConfigState.getState() - if (chatBuiltinCommands && chatDeletableByDeleteHistory) { - return - } - const {handshakeVersion, dispatch} = useDaemonState.getState() - const f = async () => { - const name = 'chat.loadStatic' - dispatch.wait(name, handshakeVersion, true) - try { - const staticConfig = serviceStaticConfigToStaticConfig(await T.RPCChat.localGetStaticConfigRpcPromise()) - if (!staticConfig) { - logger.error('chat.loadStaticConfig: missing required static config') - return - } - useConfigState.getState().dispatch.setChatStaticConfig(staticConfig) - } finally { - dispatch.wait(name, handshakeVersion, false) - } - } - ignorePromise(f()) -} - -const onHandshakeVersionChanged = () => { - useDarkModeState.getState().dispatch.loadDarkPrefs() - loadChatStaticConfig() - loadConfiguredAccountsForBootstrap() -} - const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { if (!bootstrap) { return @@ -259,20 +217,11 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') return } - configDispatch.setLoggedIn(loggedIn, false) + configDispatch.setLoggedIn(loggedIn) if (bootstrap.httpSrvInfo) { configDispatch.setHTTPSrvInfo(bootstrap.httpSrvInfo.address, bootstrap.httpSrvInfo.token) } - -} - -const onHandshakeStateChanged = (handshakeState: DaemonState['handshakeState']) => { - if (handshakeState === 'done' && !_emitStartupOnLoadDaemonConnectedOnce) { - _emitStartupOnLoadDaemonConnectedOnce = true - if (__DEV__) globalThis.__hmr_startupOnce = true - useConfigState.getState().dispatch.loadOnStart('connectedToDaemonForFirstTime') - } } const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { @@ -354,25 +303,27 @@ export const onEngineDisconnected = () => { useDaemonState.getState().dispatch.setError(new Error('Disconnected')) } -export const initSharedSubscriptions = () => { +export const initSharedSubscriptions = (platformBootstrapSteps: Array = []) => { + useDaemonState + .getState() + .dispatch.initBootstrapSteps([ + loadDarkPrefsStep, + loadChatStaticConfigStep, + loadAccountsStep, + ...platformBootstrapSteps, + ]) + // HMR cleanup: unsubscribe old store subscriptions before re-subscribing for (const unsub of _sharedUnsubs) unsub() _sharedUnsubs.length = 0 _sharedUnsubs.push( - subscribeValue(useConfigState, s => s.loadOnStartPhase, onLoadOnStartPhaseChanged), subscribeValue(useConfigState, s => s.gregorReachable, onGregorReachableChanged), subscribeValue(useConfigState, s => s.loggedIn, onLoggedInChanged), subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged) ) - _sharedUnsubs.push( - subscribeValue(useDaemonState, s => s.handshakeVersion, onHandshakeVersionChanged), - subscribeValue(useDaemonState, s => bootstrapStatusKey(s.bootstrapStatus), () => - onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus) - ), - subscribeValue(useDaemonState, s => s.handshakeState, onHandshakeStateChanged) - ) + _sharedUnsubs.push(subscribeValue(useDaemonState, s => s.bootstrapStatus, onBootstrapStatusChanged)) _sharedUnsubs.push( subscribeValue(useRouterState, s => s.navState, onNavStateChanged) diff --git a/shared/constants/types/config.tsx b/shared/constants/types/config.tsx index 3d2714d906e0..809a87127ebf 100644 --- a/shared/constants/types/config.tsx +++ b/shared/constants/types/config.tsx @@ -4,7 +4,7 @@ export type OutOfDate = { updating: boolean outOfDate: boolean } -export type DaemonHandshakeState = 'starting' | 'waitingForWaiters' | 'done' +export type DaemonHandshakeState = 'loading' | 'done' | 'failed' export type ConfiguredAccount = { fullname?: string hasStoredSecret: boolean diff --git a/shared/login/loading.tsx b/shared/login/loading.tsx index 4310fa3d4a3e..37293f1fa2db 100644 --- a/shared/login/loading.tsx +++ b/shared/login/loading.tsx @@ -6,12 +6,12 @@ import {useDaemonState} from '@/stores/daemon' const SplashContainer = () => { const failedReason = useDaemonState(s => s.handshakeFailedReason) const retriesLeft = useDaemonState(s => s.handshakeRetriesLeft) + const handshakeFailed = useDaemonState(s => s.handshakeState === 'failed') const startHandshake = useDaemonState(s => s.dispatch.startHandshake) let status = '' let failed = '' - // Totally failed - if (retriesLeft === 0) { + if (handshakeFailed) { failed = failedReason } else if (retriesLeft === C.maxHandshakeTries) { // First try @@ -26,7 +26,7 @@ const SplashContainer = () => { C.Router2.navigateAppend({name: 'feedback', params: {}}) } : undefined - const onRetry = retriesLeft === 0 ? startHandshake : undefined + const onRetry = handshakeFailed ? startHandshake : undefined return } diff --git a/shared/menubar/index.desktop.tsx b/shared/menubar/index.desktop.tsx index 3540824207a7..dc521e3e78f7 100644 --- a/shared/menubar/index.desktop.tsx +++ b/shared/menubar/index.desktop.tsx @@ -499,7 +499,7 @@ const LoggedOut = (p: {daemonHandshakeState: T.Config.DaemonHandshakeState; logg const text = fullyLoggedOut ? 'You are logged out of Keybase.' - : daemonHandshakeState === 'waitingForWaiters' + : daemonHandshakeState === 'loading' ? 'Connecting interface to crypto engine... This may take a few seconds.' : 'Starting up Keybase...' diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 0d5cb81e6e84..d563271d541b 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -37,13 +37,6 @@ type Store = T.Immutable<{ justDeletedSelf: string justRevokedSelf: string loggedIn: boolean - loggedInCausedbyStartup: boolean - loadOnStartPhase: - | 'notStarted' - | 'initialStartupAsEarlyAsPossible' - | 'connectedToDaemonForFirstTime' - | 'reloggedIn' - | 'startupOrReloginButNotInARush' outOfDate: T.Config.OutOfDate remoteWindowNeedsProps: Map> revokedTrigger: number @@ -82,9 +75,7 @@ const initialStore: Store = { isOnline: true, justDeletedSelf: '', justRevokedSelf: '', - loadOnStartPhase: 'notStarted', loggedIn: false, - loggedInCausedbyStartup: false, loginError: undefined, outOfDate: { critical: false, @@ -110,7 +101,6 @@ export type State = Store & { initAppUpdateLoop: () => void installerRan: () => void loadIsOnline: () => void - loadOnStart: (phase: State['loadOnStartPhase']) => void login: (username: string, password: string) => void setLoginError: (error?: RPCError) => void logoutToLoggedOutFlow: () => void @@ -133,7 +123,7 @@ export type State = Store & { setHTTPSrvInfo: (address: string, token: string) => void setIncomingShareUseOriginal: (use: boolean) => void setJustDeletedSelf: (s: string) => void - setLoggedIn: (l: boolean, causedByStartup: boolean, fromMenubar?: boolean) => void + setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void setOutOfDate: (outOfDate: T.Config.OutOfDate) => void setUpdating: () => void @@ -144,6 +134,8 @@ export type State = Store & { } export const useConfigState = Z.createZustand('config', (set, get) => { + let inflightRefreshAccounts: Promise | undefined + const _checkForUpdate = async () => { try { const {status, message} = await T.RPCGen.configGetUpdateInfoRpcPromise() @@ -237,12 +229,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } ignorePromise(f()) }, - loadOnStart: phase => { - if (phase === get().loadOnStartPhase) return - set(s => { - s.loadOnStartPhase = phase - }) - }, login: (username, passphrase) => { const cancelDesc = 'Canceling RPC' const cancelOnCallback = (_: unknown, response: CommonResponseHandler) => { @@ -300,13 +286,13 @@ export const useConfigState = Z.createZustand('config', (set, get) => { waitingKey: waitingKeyConfigLogin, }) logger.info('login call succeeded') - get().dispatch.setLoggedIn(true, false) + get().dispatch.setLoggedIn(true) } catch (error) { if (!(error instanceof RPCError)) { return } if (error.code === T.RPCGen.StatusCode.scalreadyloggedin) { - get().dispatch.setLoggedIn(true, false) + get().dispatch.setLoggedIn(true) } else if (error.desc !== cancelDesc) { // If we're canceling then ignore the error error.desc = niceError(error) @@ -363,7 +349,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(registerForGregorNotifications()) onEngineConnectedInPlatform() - get().dispatch.loadOnStart('initialStartupAsEarlyAsPossible') }, onEngineIncoming: action => { switch (action.type) { @@ -400,7 +385,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { // only send this if we think we're not logged in const {loggedIn, dispatch} = get() if (!loggedIn) { - dispatch.setLoggedIn(true, false) + dispatch.setLoggedIn(true) } break } @@ -409,7 +394,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { const {loggedIn, dispatch} = get() // only send this if we think we're logged in (errors on provison can trigger this and mess things up) if (loggedIn) { - dispatch.setLoggedIn(false, false) + dispatch.setLoggedIn(false) } break } @@ -428,28 +413,39 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(f()) }, refreshAccounts: async () => { - const defaultUsername = get().defaultUsername - const configuredAccounts = (await T.RPCGen.loginGetConfiguredAccountsRpcPromise()) ?? [] - const {setAccounts, setDefaultUsername} = get().dispatch + if (inflightRefreshAccounts) { + return inflightRefreshAccounts + } + const f = async () => { + const defaultUsername = get().defaultUsername + const configuredAccounts = (await T.RPCGen.loginGetConfiguredAccountsRpcPromise()) ?? [] + const {setAccounts, setDefaultUsername} = get().dispatch - let existingDefaultFound = false as boolean - let currentName = '' - const nextConfiguredAccounts: Array = [] + let existingDefaultFound = false as boolean + let currentName = '' + const nextConfiguredAccounts: Array = [] - configuredAccounts.forEach(account => { - const {username, isCurrent, fullname, hasStoredSecret, uid} = account - if (username === defaultUsername) { - existingDefaultFound = true - } - if (isCurrent) { - currentName = account.username + configuredAccounts.forEach(account => { + const {username, isCurrent, fullname, hasStoredSecret, uid} = account + if (username === defaultUsername) { + existingDefaultFound = true + } + if (isCurrent) { + currentName = account.username + } + nextConfiguredAccounts.push({fullname, hasStoredSecret, uid, username}) + }) + if (!existingDefaultFound) { + setDefaultUsername(currentName) } - nextConfiguredAccounts.push({fullname, hasStoredSecret, uid, username}) - }) - if (!existingDefaultFound) { - setDefaultUsername(currentName) + setAccounts(nextConfiguredAccounts) + } + inflightRefreshAccounts = f() + try { + await inflightRefreshAccounts + } finally { + inflightRefreshAccounts = undefined } - setAccounts(nextConfiguredAccounts) }, remoteWindowNeedsProps: (component, params) => { set(s => { @@ -555,30 +551,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.justDeletedSelf = self }) }, - setLoggedIn: (loggedIn, causedByStartup, fromMenubar = false) => { + setLoggedIn: loggedIn => { const changed = get().loggedIn !== loggedIn set(s => { s.loggedIn = loggedIn - s.loggedInCausedbyStartup = causedByStartup }) - - if (fromMenubar) return - - if (!changed) return - - const {loadOnStart} = get().dispatch - if (loggedIn) { - if (!causedByStartup) { - loadOnStart('reloggedIn') - const f = async () => { - await timeoutPromise(1000) - requestAnimationFrame(() => { - loadOnStart('startupOrReloginButNotInARush') - }) - } - ignorePromise(f()) - } - } else { + if (changed && !loggedIn) { Z.resetAllStores() } }, diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index b1fe1d64871f..fee4687b7013 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -1,152 +1,86 @@ import logger from '@/logger' -import {ignorePromise} from '@/constants/utils' +import isEqual from 'lodash/isEqual' +import {ignorePromise, timeoutPromise} from '@/constants/utils' import * as T from '@/constants/types' import * as Z from '@/util/zustand' import {maxHandshakeTries} from '@/constants/values' +// A bootstrap step gates the handshake: the app stays on the splash screen until every step +// resolves. Throwing fails the whole attempt (FatalHandshakeError skips the remaining retries). +// Steps are injected by initSharedSubscriptions since stores can't import the init layer. +export type BootstrapStep = () => Promise + +export class FatalHandshakeError extends Error {} + type Store = T.Immutable<{ bootstrapStatus?: T.RPCGen.BootstrapStatus error?: Error handshakeFailedReason: string handshakeRetriesLeft: number handshakeState: T.Config.DaemonHandshakeState - handshakeVersion: number - handshakeWaiters: Map - // if we ever restart handshake up this so we can ignore any waiters for old things }> const initialStore: Store = { bootstrapStatus: undefined, + error: undefined, handshakeFailedReason: '', handshakeRetriesLeft: maxHandshakeTries, - handshakeState: 'starting', - handshakeVersion: 0, - handshakeWaiters: new Map(), + handshakeState: 'loading', } export type State = Store & { dispatch: { + initBootstrapSteps: (steps: Array) => void loadDaemonBootstrapStatus: () => Promise resetState: () => void setError: (e?: Error) => void - setFailed: (r: string) => void - setState: (s: T.Config.DaemonHandshakeState) => void - updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void - wait: ( - name: string, - version: number, - increment: boolean, - failedReason?: string, - failedFatal?: true - ) => void startHandshake: () => void - daemonHandshake: (version: number) => void - daemonHandshakeDone: () => void - onRestartHandshakeNative: () => void + updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void } } -export const useDaemonState = Z.createZustand('daemon', (set, get) => { - const restartHandshake = () => { - get().dispatch.onRestartHandshakeNative() - set(s => { - s.handshakeState = 'starting' - s.handshakeFailedReason = '' - s.handshakeRetriesLeft = maxHandshakeTries - }) - } +const retryDelayMs = 1000 - let _firstTimeBootstrapDone = true - const maybeDoneWithDaemonHandshake = (version: number) => { - if (version !== get().handshakeVersion) { - // ignore out of date actions - return - } - const {handshakeWaiters, handshakeFailedReason, handshakeRetriesLeft} = get() - if (handshakeWaiters.size === 0) { - if (handshakeFailedReason) { - if (handshakeRetriesLeft) { - restartHandshake() - } - } else { - if (_firstTimeBootstrapDone) { - _firstTimeBootstrapDone = false - logger.info('First bootstrap ended') - } - get().dispatch.daemonHandshakeDone() - } - } - } - - // When there are no more waiters, we can show the actual app +export const useDaemonState = Z.createZustand('daemon', (set, get) => { + let bootstrapSteps: Array = [] + // bumped on every startHandshake (engine reconnect, splash Reload) so a stale in-flight + // run can't write results over a newer one + let generation = 0 + let inflightBootstrapStatus: Promise | undefined const dispatch: State['dispatch'] = { - daemonHandshake: version => { - get().dispatch.setState('waitingForWaiters') - const changed = get().handshakeVersion !== version - set(s => { - s.handshakeVersion = version - s.handshakeWaiters = new Map() - }) - - if (!changed) return - - const f = async () => { - const name = 'config.getBootstrapStatus' - const {wait} = get().dispatch - wait(name, version, true) - logger.info('[Bootstrap] loadDaemonBootstrapStatus: starting') - try { - await get().dispatch.loadDaemonBootstrapStatus() - } finally { - wait(name, version, false) - } - } - ignorePromise(f()) + initBootstrapSteps: steps => { + bootstrapSteps = steps }, - daemonHandshakeDone: () => { - get().dispatch.setState('done') - }, - // set to true so we reget status when we're reachable again loadDaemonBootstrapStatus: async () => { - const version = get().handshakeVersion - const {wait} = get().dispatch - - const s = await T.RPCGen.configGetBootstrapStatusRpcPromise() - set(state => { - state.bootstrapStatus = T.castDraft(s) - }) - - logger.info(`[Bootstrap] loggedIn: ${s.loggedIn ? 1 : 0}`) - - // set HTTP srv info - if (s.httpSrvInfo) { - logger.info(`[Bootstrap] http server: addr: ${s.httpSrvInfo.address} token: ${s.httpSrvInfo.token}`) - } else { - logger.info(`[Bootstrap] http server: no info given`) + if (inflightBootstrapStatus) { + return inflightBootstrapStatus } - - // if we're logged in act like getAccounts is done already - if (s.loggedIn) { - const {handshakeWaiters} = get() - if (handshakeWaiters.get('config.getAccounts')) { - wait('config.getAccounts', version, false) + const f = async () => { + const bs = await T.RPCGen.configGetBootstrapStatusRpcPromise() + logger.info( + `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'}` + ) + if (isEqual(bs, get().bootstrapStatus)) { + return } + set(s => { + s.bootstrapStatus = T.castDraft(bs) + }) + } + inflightBootstrapStatus = f() + try { + await inflightBootstrapStatus + } finally { + inflightBootstrapStatus = undefined } - }, - onRestartHandshakeNative: () => { - // overriden on desktop }, resetState: () => { set(s => ({ ...s, ...initialStore, - dispatch: { - ...s.dispatch, - onRestartHandshakeNative: s.dispatch.onRestartHandshakeNative, - }, + dispatch: s.dispatch, handshakeState: s.handshakeState, - handshakeVersion: s.handshakeVersion, })) }, setError: e => { @@ -157,26 +91,49 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.error = e }) }, - setFailed: r => { - set(s => { - s.handshakeFailedReason = r - }) - }, - setState: ds => { - if (ds === get().handshakeState) return - set(s => { - s.handshakeState = ds - }) - }, startHandshake: () => { - const nextVersion = get().handshakeVersion + 1 + const gen = ++generation set(s => { s.error = undefined - s.handshakeState = 'starting' s.handshakeFailedReason = '' - s.handshakeRetriesLeft = Math.max(0, s.handshakeRetriesLeft - 1) + s.handshakeRetriesLeft = maxHandshakeTries + s.handshakeState = 'loading' }) - get().dispatch.daemonHandshake(nextVersion) + const run = async () => { + while (gen === generation) { + try { + await get().dispatch.loadDaemonBootstrapStatus() + await Promise.all(bootstrapSteps.map(async step => step())) + if (gen !== generation) { + return + } + set(s => { + s.handshakeFailedReason = '' + s.handshakeState = 'done' + }) + logger.info('[Bootstrap] handshake done') + return + } catch (error) { + if (gen !== generation) { + return + } + const fatal = error instanceof FatalHandshakeError + logger.warn('[Bootstrap] handshake attempt failed:', error) + set(s => { + s.handshakeFailedReason = error instanceof Error ? error.message : String(error) + s.handshakeRetriesLeft = fatal ? 0 : Math.max(0, s.handshakeRetriesLeft - 1) + }) + if (get().handshakeRetriesLeft === 0) { + set(s => { + s.handshakeState = 'failed' + }) + return + } + await timeoutPromise(retryDelayMs) + } + } + } + ignorePromise(run()) }, updateUserReacjis: userReacjis => { set(s => { @@ -185,41 +142,6 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { } }) }, - wait: (name, version, increment, failedReason, failedFatal) => { - const {handshakeState, handshakeFailedReason, handshakeVersion} = get() - if (handshakeState !== 'waitingForWaiters') { - throw new Error("Should only get a wait while we're waiting") - } - if (version !== handshakeVersion) { - logger.info('Ignoring handshake wait due to version mismatch', version, handshakeVersion) - return - } - set(s => { - const oldCount = s.handshakeWaiters.get(name) || 0 - const newCount = oldCount + (increment ? 1 : -1) - if (newCount === 0) { - s.handshakeWaiters.delete(name) - } else { - s.handshakeWaiters.set(name, newCount) - } - }) - const remaining = get().handshakeWaiters.size - logger.info(`[Bootstrap] waiter ${increment ? '+' : '-'} ${name} v${version}, remaining: ${remaining}`) - - if (failedFatal) { - set(s => { - s.handshakeFailedReason = failedReason || '' - s.handshakeRetriesLeft = 0 - }) - } else { - // Keep the first error - const f = failedReason || '' - if (f && !handshakeFailedReason) { - get().dispatch.setFailed(f) - } - } - maybeDoneWithDaemonHandshake(version) - }, } return { ...initialStore, diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index 8c67056013b1..229dca4c4c03 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -1,52 +1,127 @@ /// +import * as T from '@/constants/types' import {maxHandshakeTries} from '@/constants/values' import {resetAllStores} from '@/util/zustand' -import {useDaemonState} from '../daemon' +import {FatalHandshakeError, useDaemonState} from '../daemon' -afterEach(() => { - jest.restoreAllMocks() - resetAllStores() -}) +const bootstrapStatus = { + deviceID: 'd1', + deviceName: 'testuser-mac', + fullname: 'Test User', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', +} as unknown as T.RPCGen.BootstrapStatus -test('wait tracks handshake waiters and keeps the first failure reason', () => { - const store = useDaemonState - - store.setState( - { - ...store.getState(), - handshakeState: 'waitingForWaiters', - handshakeVersion: 1, - }, - true - ) - store.getState().dispatch.wait('config.getBootstrapStatus', 1, true, 'first') - store.getState().dispatch.wait('config.getBootstrapStatus', 1, true, 'second') - store.getState().dispatch.wait('config.getBootstrapStatus', 1, false, 'ignored later') - - expect(store.getState().handshakeWaiters.get('config.getBootstrapStatus')).toBe(1) - expect(store.getState().handshakeFailedReason).toBe('first') -}) +describe('daemon store', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() + }) + + test('startHandshake runs bootstrap steps and finishes', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + + expect(step).toHaveBeenCalledTimes(1) + expect(store.getState().handshakeState).toBe('done') + expect(store.getState().bootstrapStatus?.username).toBe('testuser') + }) + + test('a failing step retries and can recover', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}).mockRejectedValueOnce(new Error('flaky')) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + + expect(store.getState().handshakeState).toBe('loading') + expect(store.getState().handshakeFailedReason).toBe('flaky') + expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries - 1) + + await jest.advanceTimersByTimeAsync(1000) + + expect(store.getState().handshakeState).toBe('done') + expect(store.getState().handshakeFailedReason).toBe('') + }) + + test('exhausting retries fails the handshake', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}).mockRejectedValue(new Error('down')) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + for (let i = 1; i < maxHandshakeTries; i++) { + await jest.advanceTimersByTimeAsync(1000) + } + + expect(step).toHaveBeenCalledTimes(maxHandshakeTries) + expect(store.getState().handshakeState).toBe('failed') + expect(store.getState().handshakeRetriesLeft).toBe(0) + expect(store.getState().handshakeFailedReason).toBe('down') + }) + + test('a fatal error skips retries', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}).mockRejectedValue(new FatalHandshakeError('pipe owner fail')) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + + expect(step).toHaveBeenCalledTimes(1) + expect(store.getState().handshakeState).toBe('failed') + expect(store.getState().handshakeRetriesLeft).toBe(0) + }) + + test('loadDaemonBootstrapStatus dedupes concurrent loads', async () => { + const spy = jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValue(bootstrapStatus) + const store = useDaemonState + + await Promise.all([ + store.getState().dispatch.loadDaemonBootstrapStatus(), + store.getState().dispatch.loadDaemonBootstrapStatus(), + ]) + + expect(spy).toHaveBeenCalledTimes(1) + expect(store.getState().bootstrapStatus?.uid).toBe('u1') + }) + + test('resetState preserves the handshake state but clears transient values', () => { + const store = useDaemonState + store.setState( + { + ...store.getState(), + error: new Error('boom'), + handshakeFailedReason: 'bad', + handshakeRetriesLeft: 0, + handshakeState: 'done', + }, + true + ) + + store.getState().dispatch.resetState() -test('resetState preserves the handshake session but clears transient values', () => { - const store = useDaemonState - - store.setState( - { - ...store.getState(), - error: new Error('boom'), - handshakeFailedReason: 'bad', - handshakeRetriesLeft: 0, - handshakeState: 'waitingForWaiters', - handshakeVersion: 7, - }, - true - ) - - store.getState().dispatch.resetState() - - expect(store.getState().handshakeState).toBe('waitingForWaiters') - expect(store.getState().handshakeVersion).toBe(7) - expect(store.getState().error).toMatchObject({message: 'boom'}) - expect(store.getState().handshakeFailedReason).toBe('') - expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries) + expect(store.getState().handshakeState).toBe('done') + expect(store.getState().error).toBe(undefined) + expect(store.getState().handshakeFailedReason).toBe('') + expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries) + }) }) From aae4ed59ddef787507527f0467e53767e166cbc4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 20:50:37 -0400 Subject: [PATCH 2/7] fix(app): make disconnected overlay reachable and full-screen GlobalError returned early on size === 'Closed', which only tracks globalError, so daemon-error-only renders (service died) never reached the overlay branch. The overlay Box2s also lacked fullWidth/fullHeight and shrink-wrapped to content. --- shared/app/global-errors.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/shared/app/global-errors.tsx b/shared/app/global-errors.tsx index 816a4ffa4733..205a1b45fd7c 100644 --- a/shared/app/global-errors.tsx +++ b/shared/app/global-errors.tsx @@ -112,14 +112,8 @@ const GlobalError = () => { const {daemonError, error, onDismiss, onFeedback} = d const {cachedDetails, cachedSummary, size, onExpandClick} = d - if (size === 'Closed') { - return null - } - - if (!daemonError && !error) { - return null - } - + // daemonError first: size only tracks globalError, so checking it before this + // branch would hide the disconnect overlay if (daemonError) { if (isMobile) { return null @@ -131,19 +125,23 @@ const GlobalError = () => { const message = daemonError.message || 'Keybase is currently unreachable. Trying to reconnect you…' return ( - - + + {message} - + ) } + if (size === 'Closed') { + return null + } + if (isMobile) { return ( Date: Thu, 2 Jul 2026 20:50:37 -0400 Subject: [PATCH 3/7] fix(fs): notice kbfs dying while idle waitForKbfsDaemon returned once connected, so a kbfs crash went unnoticed until an fs RPC failed or the tab was re-entered. Keep the loop alive for the login session: poll every 5s while connected, reuse the 60s wait while disconnected. --- shared/fs/common/daemon.tsx | 57 +++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/shared/fs/common/daemon.tsx b/shared/fs/common/daemon.tsx index a489e8a7c907..9461cfc8de81 100644 --- a/shared/fs/common/daemon.tsx +++ b/shared/fs/common/daemon.tsx @@ -3,6 +3,7 @@ import * as Constants from '@/constants/fs' import * as React from 'react' import * as RouterConstants from '@/constants/router' import * as T from '@/constants/types' +import {timeoutPromise} from '@/constants/utils' import {useConfigState} from '@/stores/config' import {useRouterState} from '@/stores/router' import {afterKbfsDaemonRpcStatusChanged, fsUserIn, fsUserOut} from './lifecycle' @@ -22,6 +23,8 @@ const emptyFsDaemonActions: FsDaemonActions = { const FsDaemonStatusContext = React.createContext(undefined) const FsDaemonActionsContext = React.createContext(undefined) +const connectedPollIntervalMs = 5000 + const waitForKbfsDaemon = async ( generation: number, isCurrentAsyncGeneration: (generation: number) => boolean, @@ -30,36 +33,40 @@ const waitForKbfsDaemon = async ( waitForKbfsDaemonInProgressRef: {current: boolean}, asyncGenerationRef: {current: number} ) => { - while (isCurrentAsyncGeneration(generation)) { - try { - const connected = await T.RPCGen.configWaitForClientRpcPromise({ - clientType: T.RPCGen.ClientType.kbfs, - timeout: 0, // Don't wait; just check if it's there. - }) - if (!isCurrentAsyncGeneration(generation)) { - return - } - const newStatus = connected ? T.FS.KbfsDaemonRpcStatus.Connected : T.FS.KbfsDaemonRpcStatus.Waiting - if (kbfsDaemonStatusRef.current.rpcStatus !== newStatus) { - kbfsDaemonRpcStatusChanged(newStatus) - } - if (newStatus === T.FS.KbfsDaemonRpcStatus.Connected) { - return - } - waitForKbfsDaemonInProgressRef.current = true + // The service has no notification for a client detaching, so this loop runs for the whole + // login session: it polls while connected to notice kbfs dying, and waits while disconnected. + waitForKbfsDaemonInProgressRef.current = true + try { + while (isCurrentAsyncGeneration(generation)) { try { - await T.RPCGen.configWaitForClientRpcPromise({ + const connected = await T.RPCGen.configWaitForClientRpcPromise({ clientType: T.RPCGen.ClientType.kbfs, - timeout: 60, // 1min. This is arbitrary since we're gonna check again anyway if we're not connected. + timeout: 0, // Don't wait; just check if it's there. }) - } catch { - } finally { - if (generation === asyncGenerationRef.current) { - waitForKbfsDaemonInProgressRef.current = false + if (!isCurrentAsyncGeneration(generation)) { + return } + const newStatus = connected ? T.FS.KbfsDaemonRpcStatus.Connected : T.FS.KbfsDaemonRpcStatus.Waiting + if (kbfsDaemonStatusRef.current.rpcStatus !== newStatus) { + kbfsDaemonRpcStatusChanged(newStatus) + } + if (newStatus === T.FS.KbfsDaemonRpcStatus.Connected) { + await timeoutPromise(connectedPollIntervalMs) + continue + } + try { + await T.RPCGen.configWaitForClientRpcPromise({ + clientType: T.RPCGen.ClientType.kbfs, + timeout: 60, // 1min. This is arbitrary since we're gonna check again anyway if we're not connected. + }) + } catch {} + } catch { + return } - } catch { - return + } + } finally { + if (generation === asyncGenerationRef.current) { + waitForKbfsDaemonInProgressRef.current = false } } } From ba62005bde7ad9e954c7aaa179a497cde062a514 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 21:45:52 -0400 Subject: [PATCH 4/7] refactor(logout): drop waiter store for direct async logout The logout store was a version/waiter machine with exactly one real waiter (push:deleteToken) plus a fake 10ms 'nullhandshake' waiter to handle the no-waiter case. Await the token delete then the logout RPC instead; store, push-listener version subscription, and waiter calls all go. --- .../constants/init/push-listener.native.tsx | 7 -- shared/settings/use-request-logout.tsx | 14 +++- shared/stores/logout.tsx | 84 ------------------- shared/stores/push.tsx | 44 ++++------ shared/stores/tests/logout.test.ts | 47 ----------- shared/stores/tests/push.desktop.test.ts | 2 +- 6 files changed, 30 insertions(+), 168 deletions(-) delete mode 100644 shared/stores/logout.tsx delete mode 100644 shared/stores/tests/logout.test.ts diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index a7a11b6f70d6..9170cefae566 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -12,7 +12,6 @@ import { } from 'react-native-kb' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' -import {useLogoutState} from '@/stores/logout' import {usePushState} from '@/stores/push' import {useShellState} from '@/stores/shell' @@ -240,12 +239,6 @@ export const initPushListener = () => { .catch(() => {}) }) - // Token handling - useLogoutState.subscribe((s, old) => { - if (s.version === old.version) return - usePushState.getState().dispatch.deleteToken(s.version) - }) - let lastCount = -1 useConfigState.subscribe((s, old) => { if (s.badgeState === old.badgeState) return diff --git a/shared/settings/use-request-logout.tsx b/shared/settings/use-request-logout.tsx index 52bc16fdb319..9590ebc215a4 100644 --- a/shared/settings/use-request-logout.tsx +++ b/shared/settings/use-request-logout.tsx @@ -1,9 +1,10 @@ import * as C from '@/constants' +import {ignorePromise} from '@/constants/utils' import {navigateAppend, switchTab} from '@/constants/router' import {settingsPasswordTab} from '@/constants/settings' import * as T from '@/constants/types' import * as Tabs from '@/constants/tabs' -import {useLogoutState} from '@/stores/logout' +import {usePushState} from '@/stores/push' const navigateToLogoutPassword = () => { if (isMobile) { @@ -14,8 +15,15 @@ const navigateToLogoutPassword = () => { } } +const logout = async () => { + // Unregister the push token first; the API call needs the still-logged-in session + await usePushState.getState().dispatch.deleteTokenForLogout() + try { + await T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: false}) + } catch {} +} + export const useRequestLogout = () => { - const start = useLogoutState(s => s.dispatch.start) const canLogout = C.useRPC(T.RPCGen.userCanLogoutRpcPromise) return () => { @@ -23,7 +31,7 @@ export const useRequestLogout = () => { [undefined], canLogoutRes => { if (canLogoutRes.canLogout) { - start() + ignorePromise(logout()) } else { navigateToLogoutPassword() } diff --git a/shared/stores/logout.tsx b/shared/stores/logout.tsx deleted file mode 100644 index 43ab125cbbbd..000000000000 --- a/shared/stores/logout.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import logger from '@/logger' -import {ignorePromise, timeoutPromise} from '@/constants/utils' -import * as T from '@/constants/types' -// normally util.container but it re-exports from us so break the cycle -import * as Z from '@/util/zustand' - -// This store has no dependencies on other stores and is safe to import directly from other stores. -type Store = T.Immutable<{ - waiters: Map - // if we ever restart handshake up this so we can ignore any waiters for old things - version: number -}> - -const initialStore: Store = { - version: 1, - waiters: new Map(), -} - -export type State = Store & { - dispatch: { - resetState: () => void - wait: (name: string, version: number, increment: boolean) => void - start: () => void - } -} - -export const useLogoutState = Z.createZustand('logout', (set, get) => { - const dispatch: State['dispatch'] = { - resetState: () => { - set(s => ({ - ...s, - ...initialStore, - version: s.version, - waiters: s.waiters, - })) - }, - start: () => { - const version = get().version + 1 - set(s => { - s.version = version - }) - - // Give time for all waiters to register and allow the case where there are no waiters - const f = async () => { - const waitKey = 'nullhandshake' - get().dispatch.wait(waitKey, version, true) - await timeoutPromise(10) - get().dispatch.wait(waitKey, version, false) - } - ignorePromise(f()) - }, - wait: (name, _version, increment) => { - const {version} = get() - - if (version !== _version) { - logger.info('Ignoring handshake wait due to version mismatch', version, _version) - return - } - - set(s => { - const oldCount = s.waiters.get(name) || 0 - const newCount = oldCount + (increment ? 1 : -1) - if (newCount === 0) { - s.waiters.delete(name) - } else { - s.waiters.set(name, newCount) - } - }) - - const {waiters} = get() - if (waiters.size > 0) { - // still waiting for things to finish - } else { - T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: false}) - .then(() => {}) - .catch(() => {}) - } - }, - } - return { - ...initialStore, - dispatch, - } -}) diff --git a/shared/stores/push.tsx b/shared/stores/push.tsx index 1f2d73b95ff5..2a9fe8a019a7 100644 --- a/shared/stores/push.tsx +++ b/shared/stores/push.tsx @@ -9,7 +9,6 @@ import {emitDeepLink} from '@/router-v2/linking' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' -import {useLogoutState} from '@/stores/logout' import {useWaitingState} from '@/stores/waiting' import {openAppSettings} from '@/util/storeless-actions' type Store = { @@ -24,7 +23,7 @@ type State = Store & { dispatch: { checkPermissions: () => Promise clearPendingPushNotification: () => void - deleteToken: (version: number) => void + deleteTokenForLogout: () => Promise handlePush: (notification: T.Push.PushNotification) => void initialPermissionsCheck: () => void rejectPermissions: () => void @@ -66,7 +65,7 @@ export const usePushState = Z.createZustand('push', (set, get) => { return Promise.resolve(false) }, clearPendingPushNotification: () => {}, - deleteToken: () => {}, + deleteTokenForLogout: async () => {}, handlePush: () => {}, initialPermissionsCheck: () => {}, rejectPermissions: () => {}, @@ -172,31 +171,24 @@ export const usePushState = Z.createZustand('push', (set, get) => { s.pendingPushNotification = undefined }) }, - deleteToken: version => { - const f = async () => { - const waitKey = 'push:deleteToken' - useLogoutState.getState().dispatch.wait(waitKey, version, true) - try { - const deviceID = useCurrentUserState.getState().deviceID - if (!deviceID) { - logger.info('[PushToken] no device id') - return - } - await T.RPCGen.apiserverDeleteRpcPromise({ - args: [ - {key: 'device_id', value: deviceID}, - {key: 'token_type', value: tokenType}, - ], - endpoint: 'device/push_token', - }) - logger.info('[PushToken] deleted from server') - } catch (e) { - logger.error('[PushToken] delete failed', e) - } finally { - useLogoutState.getState().dispatch.wait(waitKey, version, false) + deleteTokenForLogout: async () => { + try { + const deviceID = useCurrentUserState.getState().deviceID + if (!deviceID) { + logger.info('[PushToken] no device id') + return } + await T.RPCGen.apiserverDeleteRpcPromise({ + args: [ + {key: 'device_id', value: deviceID}, + {key: 'token_type', value: tokenType}, + ], + endpoint: 'device/push_token', + }) + logger.info('[PushToken] deleted from server') + } catch (e) { + logger.error('[PushToken] delete failed', e) } - ignorePromise(f()) }, handlePush: notification => { const f = async () => { diff --git a/shared/stores/tests/logout.test.ts b/shared/stores/tests/logout.test.ts deleted file mode 100644 index d71fbc1b235d..000000000000 --- a/shared/stores/tests/logout.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/// -import * as T from '../../constants/types' -import {resetAllStores} from '../../util/zustand' -import {useLogoutState} from '../logout' - -describe('logout store', () => { - afterEach(() => { - jest.restoreAllMocks() - resetAllStores() - }) - - test('wait ignores version mismatches', () => { - const store = useLogoutState - store.getState().dispatch.wait('sync', 999, true) - - expect(store.getState().waiters.size).toBe(0) - }) - - test('wait tracks outstanding work and logs out when the last waiter finishes', async () => { - const logoutSpy = jest.spyOn(T.RPCGen, 'loginLogoutRpcPromise').mockResolvedValue(undefined) - const store = useLogoutState - const version = store.getState().version - - store.getState().dispatch.wait('sync', version, true) - store.getState().dispatch.wait('sync', version, true) - expect(store.getState().waiters.get('sync')).toBe(2) - - store.getState().dispatch.wait('sync', version, false) - expect(store.getState().waiters.get('sync')).toBe(1) - expect(logoutSpy).not.toHaveBeenCalled() - - store.getState().dispatch.wait('sync', version, false) - await Promise.resolve() - - expect(store.getState().waiters.has('sync')).toBe(false) - expect(logoutSpy).toHaveBeenCalledWith({force: false, keepSecrets: false}) - }) - - test('start increments the handshake version', () => { - const store = useLogoutState - const version = store.getState().version - - store.getState().dispatch.start() - - expect(store.getState().version).toBe(version + 1) - }) -}) diff --git a/shared/stores/tests/push.desktop.test.ts b/shared/stores/tests/push.desktop.test.ts index 5a0c2882629d..8f640c662f53 100644 --- a/shared/stores/tests/push.desktop.test.ts +++ b/shared/stores/tests/push.desktop.test.ts @@ -12,7 +12,7 @@ test('desktop push store reports resettable defaults', async () => { await expect(dispatch.checkPermissions()).resolves.toBe(false) dispatch.clearPendingPushNotification() - dispatch.deleteToken(1) + await dispatch.deleteTokenForLogout() dispatch.initialPermissionsCheck() dispatch.rejectPermissions() dispatch.requestPermissions() From b37acec5a8c9ad4194f21b2939bfaca158ad6580 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 21:46:13 -0400 Subject: [PATCH 5/7] refactor(router): dedupe splash gating into useHandshakeEverDone Four hand-rolled copies of the sticky everLoadedRef pattern become one hook. Also drops a redundant NavigationContainer type argument. --- shared/router-v2/router.tsx | 47 ++++++++++++++----------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index 30ed2dcb5c57..c76bcd5a0138 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -108,6 +108,17 @@ type LeftTabNavigatorType = { }> } +// Sticky: once the handshake finishes we never go back to the splash, even if it +// restarts later (engine reconnect); the disconnected overlay covers that case. +const useHandshakeEverDone = () => { + const everDoneRef = React.useRef(false) + return useDaemonState(s => { + const done = everDoneRef.current || s.handshakeState === 'done' + everDoneRef.current = done + return done + }) +} + let desktopTab: LeftTabNavigatorType | undefined const desktopTabComponents: Record = {} let DesktopRootComponent: React.ComponentType @@ -187,35 +198,18 @@ if (!isMobile) { title: '', } satisfies NativeStackNavigationOptions - const useIsLoadingDesktop = () => { - const everLoadedRef = React.useRef(false) - return !useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) - } + const useIsLoadingDesktop = () => !useHandshakeEverDone() const useIsLoggedInDesktop = () => { - const everLoadedRef = React.useRef(false) - const loggedInLoaded = useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) + const loaded = useHandshakeEverDone() const loggedIn = useConfigState(s => s.loggedIn) - return loggedInLoaded && loggedIn + return loaded && loggedIn } const useIsLoggedOutDesktop = () => { - const everLoadedRef = React.useRef(false) - const loggedInLoaded = useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) + const loaded = useHandshakeEverDone() const loggedIn = useConfigState(s => s.loggedIn) - return loggedInLoaded && !loggedIn + return loaded && !loggedIn } const desktopModalScreensConfig = routeMapToStaticScreens(modalRoutes, makeLayout, true, false, false) @@ -618,12 +612,7 @@ if (isMobile) { const nativeLinkingConfig = isMobile ? createLinkingConfig(handleAppLink) : undefined function NativeRouter() { - const everLoadedRef = React.useRef(false) - const loggedInLoaded = useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) + const loggedInLoaded = useHandshakeEverDone() const {loggedIn, startupLoaded} = useConfigState( C.useShallow(s => ({loggedIn: s.loggedIn, startupLoaded: s.startup.loaded})) @@ -687,7 +676,7 @@ function NativeRouter() { return ( {bar} - + } linking={loggedIn ? nativeLinkingConfig : undefined} onReady={onReady} From fe40cdcde939845d0b32bf41bb2c2e9e4f0f518f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 21:46:13 -0400 Subject: [PATCH 6/7] refactor(init): single share-intent flush trigger, tidy startup details shareListenersRegistered was called from two places on Android. Native flushes pending share intents on the first call and emitDeepLink has no queue, so the early push-listener call could drop a cold-start share before the router was listening. Keep only the nav+logged-in gated call. Also parse the persisted routeState synchronously instead of wrapping a JSON.parse in a promise race. --- shared/constants/init/index.tsx | 16 +++++++--------- shared/constants/init/push-listener.native.tsx | 5 +++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 925d3468a2d9..c1fd38583335 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -189,15 +189,13 @@ const loadStartupDetails = async () => { const {guiConfig, Linking} = _getNative() const {getStartupDetailsFromInitialPush} = await import('./push-listener.native') - const [routeState, initialUrl, push] = await Promise.all([ - neverThrowPromiseFunc(async () => { - try { - const config = JSON.parse(guiConfig) as {ui?: {routeState2?: string}} | undefined - return Promise.resolve(config?.ui?.routeState2 ?? '') - } catch { - return Promise.resolve('') - } - }), + let routeState = '' + try { + const config = JSON.parse(guiConfig) as {ui?: {routeState2?: string}} | undefined + routeState = config?.ui?.routeState2 ?? '' + } catch {} + + const [initialUrl, push] = await Promise.all([ neverThrowPromiseFunc(async () => { const linkingStart = Date.now() logger.info('[Startup] loadStartupDetails: calling Linking.getInitialURL') diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index 9170cefae566..b2dd8e0986d0 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -8,7 +8,6 @@ import { getNativeEmitter, getInitialNotification, removeAllPendingNotificationRequests, - shareListenersRegistered, } from 'react-native-kb' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' @@ -344,7 +343,9 @@ export const initPushListener = () => { } emitDeepLink('keybase://incoming-share') }) - shareListenersRegistered() + // shareListenersRegistered() is deliberately NOT called here: it makes native flush + // pending share intents, and emitDeepLink has no queue. The init/index.tsx router + // subscriber calls it once we're logged in with the router mounted. } } catch (e) { logger.error('[Push] failed to set up listeners: ', e) From 3375bb8c7114ac7b0637216cab9e093daef3dc5b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 21:46:13 -0400 Subject: [PATCH 7/7] fix(fs): re-kick kbfs watcher when handshake completes The watcher loop exits if the service dies; a completed handshake means RPCs work again, so restart it then. Replaces the native installerRan() kick, which native abused as a startup trigger despite having no installer. --- shared/app/index.native.tsx | 2 -- shared/fs/common/daemon.tsx | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 4605e72aca20..ae59e4e0d555 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -1,6 +1,5 @@ /// import * as C from '@/constants' -import {useConfigState} from '@/stores/config' import {useShellState} from '@/stores/shell' import * as Kb from '@/common-adapters' import * as React from 'react' @@ -143,7 +142,6 @@ const useInit = () => { }, onEngineIncoming) initPlatformListener() eng.listenersAreReady() - useConfigState.getState().dispatch.installerRan() }, []) } diff --git a/shared/fs/common/daemon.tsx b/shared/fs/common/daemon.tsx index 9461cfc8de81..e1c038ab506e 100644 --- a/shared/fs/common/daemon.tsx +++ b/shared/fs/common/daemon.tsx @@ -5,6 +5,7 @@ import * as RouterConstants from '@/constants/router' import * as T from '@/constants/types' import {timeoutPromise} from '@/constants/utils' import {useConfigState} from '@/stores/config' +import {useDaemonState} from '@/stores/daemon' import {useRouterState} from '@/stores/router' import {afterKbfsDaemonRpcStatusChanged, fsUserIn, fsUserOut} from './lifecycle' @@ -75,6 +76,9 @@ export const FsDaemonProvider = ({children}: {children: React.ReactNode}) => { const loggedIn = useConfigState(s => s.loggedIn) const userSwitching = useConfigState(s => s.userSwitching) const installerRanCount = useConfigState(s => s.installerRanCount) + // Re-kick the watcher when the daemon handshake (re)completes: the watch loop exits + // if the service dies, and a new handshake means RPCs work again. + const handshakeDone = useDaemonState(s => s.handshakeState === 'done') const navState = useRouterState(s => s.navState as RouterConstants.NavState | undefined) const [kbfsDaemonStatus, setKbfsDaemonStatus] = React.useState( Constants.unknownKbfsDaemonStatus @@ -158,7 +162,7 @@ export const FsDaemonProvider = ({children}: {children: React.ReactNode}) => { return } checkKbfsDaemonRpcStatus() - }, [installerRanCount, shouldRunBackgroundFSRPC]) + }, [installerRanCount, shouldRunBackgroundFSRPC, handshakeDone]) React.useEffect(() => { const previousNavState = previousNavStateRef.current