diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 9080cdedcf9..81cda2e1422 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -13,6 +13,7 @@ import { useOpenNotificationRoute } from '../src/notifications/use-open-notifica import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' +import { NotificationHostConnectionOwner } from '../src/notifications/notification-host-connection-owner' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -159,6 +160,7 @@ export default function RootLayout() { return ( + totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) - const allClients = useAllHostClients(hostIds, { + const allClients = useAllHostClients(autoConnectHostIds, { autoConnectHostIds, closeUnusedOnRelease: true }) @@ -432,7 +431,7 @@ export default function HomeScreen() { }) }, [allClients, hostCatalog]) - // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body + // Accounts sub + a snapshot read per connect for one host. Lives outside the effect body // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the // returned disposer owns every handle allocated here. const wireHostSubscriptions = (entry: { @@ -440,15 +439,11 @@ export default function HomeScreen() { client: RpcClient state: ConnectionState }) => { - let unsubNotif: (() => void) | null = null let unsubAccounts: (() => void) | null = null const refetchGate = createHostConnectRefetchGate() const wireUp = (state: ConnectionState) => { const reconnected = refetchGate.observe(state) if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } if (!unsubAccounts) { unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { if (!payload || typeof payload !== 'object') { @@ -474,10 +469,6 @@ export default function HomeScreen() { fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) } } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } if (unsubAccounts) { unsubAccounts() unsubAccounts = null @@ -488,7 +479,6 @@ export default function HomeScreen() { const unsubState = entry.client.onStateChange(wireUp) return () => { unsubState() - unsubNotif?.() unsubAccounts?.() } } diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 0621caa4f01..0b7e3b666a1 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -9,7 +9,7 @@ import { type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry } from '../src/transport/types' -import { useCloseHost } from '../src/transport/client-context' +import { useCloseHost, useForceReconnect } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' import { @@ -29,6 +29,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000 export default function PairConfirmScreen() { const router = useRouter() const closeHost = useCloseHost() + const forceReconnect = useForceReconnect() const insets = useSafeAreaInsets() const params = useLocalSearchParams<{ code?: string }>() const [status, setStatus] = useState('awaiting-confirm') @@ -113,9 +114,10 @@ export default function PairConfirmScreen() { // (STA-1840 dedup), so a client cached under that id from an earlier // pairing would keep the stale endpoint/relay. Close it so the // destination screen opens a fresh client with the newly-paired - // profile — the removeHost() path already refreshes on re-pair, and a - // brand-new host has no cached entry so this is a no-op. + // profile. Reopen immediately so root notification ownership does not + // stay clientless while onboarding is visible. closeHost(hostId) + await forceReconnect(hostId) const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 892c1b359a1..0f9037571ab 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -18,7 +18,7 @@ import { type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' -import { useCloseHost } from '../src/transport/client-context' +import { useCloseHost, useForceReconnect } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -48,6 +48,7 @@ function Step({ number, text }: { number: number; text: string }) { export default function PairScanScreen() { const router = useRouter() const closeHost = useCloseHost() + const forceReconnect = useForceReconnect() const insets = useSafeAreaInsets() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') @@ -157,9 +158,10 @@ export default function PairScanScreen() { // (STA-1840 dedup), so a client cached under that id from an earlier // pairing would keep the stale endpoint/relay. Close it so the // destination screen opens a fresh client with the newly-paired - // profile — the removeHost() path already refreshes on re-pair, and a - // brand-new host has no cached entry so this is a no-op. + // profile. Reopen immediately so root notification ownership does not + // stay clientless while onboarding is visible. closeHost(hostId) + await forceReconnect(hostId) const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return diff --git a/mobile/src/notifications/local-notification-scheduling.ts b/mobile/src/notifications/local-notification-scheduling.ts index f511346250e..e74b4bae3ef 100644 --- a/mobile/src/notifications/local-notification-scheduling.ts +++ b/mobile/src/notifications/local-notification-scheduling.ts @@ -35,6 +35,8 @@ const scheduledNotificationsByHostAndNotificationId = new Map { + notificationChannelConfigured = true + }) + .catch(() => {}) + .finally(() => { + notificationChannelConfiguring = false + }) } export async function showLocalNotification( diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 0043762e3ec..d536e5b9b00 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -5,7 +5,10 @@ export { getNotificationPermissionState, type NotificationPermissionState } from './notification-permissions' -export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling' +export { + resetNotificationChannelConfigurationForTests, + setScheduledNotificationsMaxForTests +} from './local-notification-scheduling' import { configureNotificationChannel, dismissLocalNotification, @@ -65,6 +68,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } return enqueueHostDelivery(session, async () => { try { + if (disposed) { + return + } await deliverLive(type, event) } finally { if (type === 'notification') { @@ -88,6 +94,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } else { await dismissLocalNotification(event as DismissNotificationEvent, hostId) } + if (session.retired) { + return + } // Why after the await, exactly like the watermark below: `seen` asserts this event // reached the user (#8129). Marked before, a rejected show leaves the key behind and // every later replay is dropped as a duplicate — loss the quarantine cannot recover, @@ -141,13 +150,13 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap). async function fetchMissed(): Promise { - if (disposed) { + if (session.retired) { return } // Captured before the request: everything at or below it is known delivered, so // it is the floor the watermark falls back to if this catch-up never completes. const askFrom = catchUpWatermarkSeq(session) - const missed = await client + const catchUp = await client .sendRequest('notifications.getMissedSince', { lastSeenSeq: askFrom, // Why: sending the epoch lets the desktop reject a watermark from a counter @@ -159,17 +168,24 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin return null } const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined - adoptNotificationEpoch(session, hostId, result?.epoch) - return Array.isArray(result?.notifications) ? result.notifications : [] + return { + epoch: result?.epoch, + notifications: Array.isArray(result?.notifications) ? result.notifications : [] + } }) .catch(() => null) - if (missed == null) { + if (session.retired) { + return + } + if (catchUp == null) { // Why quarantine rather than retry: the range this catch-up abandoned stays // unrecovered until SOME later one succeeds, and a live seq persisting past it // meanwhile would make the desktop cut it forever. quarantineCatchUpWatermark(session, hostId, askFrom) return } + adoptNotificationEpoch(session, hostId, catchUp.epoch) + const missed = catchUp.notifications // Why the whole batch is ONE queue entry (#8591): awaiting per event returns to // the event loop between replays, so a live seq 11 slots into the chain between // seq 6 and 7 and persists a watermark past a notification still unshown. Why the @@ -181,6 +197,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin let contiguousSeq = askFrom let drained = false try { + if (disposed) { + return + } for (const raw of missed) { // Re-checked per event: the batch can start before a teardown and still be // draining after it, and a torn-down host must stop pushing. @@ -195,7 +214,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } finally { if (drained) { resolveCatchUpQuarantine(session, hostId) - } else { + } else if (!session.retired) { quarantineCatchUpWatermark(session, hostId, contiguousSeq) } } diff --git a/mobile/src/notifications/notification-channel-configuration.test.ts b/mobile/src/notifications/notification-channel-configuration.test.ts new file mode 100644 index 00000000000..87c7ab623ad --- /dev/null +++ b/mobile/src/notifications/notification-channel-configuration.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { + configureNotificationChannel, + resetNotificationChannelConfigurationForTests +} from './local-notification-scheduling' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ Platform: { OS: 'android', Version: 35 } })) + +describe('Android notification channel configuration', () => { + beforeEach(() => { + vi.clearAllMocks() + resetNotificationChannelConfigurationForTests() + }) + + it('configures the process-wide channel once across host subscriptions', async () => { + configureNotificationChannel() + configureNotificationChannel() + await Promise.resolve() + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/notifications/notification-host-connection-owner.tsx b/mobile/src/notifications/notification-host-connection-owner.tsx new file mode 100644 index 00000000000..bb82da24fc3 --- /dev/null +++ b/mobile/src/notifications/notification-host-connection-owner.tsx @@ -0,0 +1,198 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { AppState } from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeHostCollectionChanges } from '../transport/host-collection-changes' +import { selectConnectableHostProfiles } from '../transport/host-catalog-selection' +import { loadHostCatalog } from '../transport/host-store' +import { usePrimeHosts } from '../transport/client-context' +import { connectionLogStore } from '../transport/connection-log-buffer' +import type { ConnectionState, HostProfile } from '../transport/types' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { retireHostNotificationState } from './notification-reconnect-catchup' + +const HOST_REFRESH_RETRY_DELAYS_MS = [250, 1_000, 5_000, 15_000] as const + +const clientIdentities = new WeakMap() +let nextClientIdentity = 1 + +function clientIdentity(client: RpcClient): number { + let identity = clientIdentities.get(client) + if (identity == null) { + identity = nextClientIdentity++ + clientIdentities.set(client, identity) + } + return identity +} + +function subscribeToHostNotificationLifecycle(client: RpcClient, hostId: string): () => void { + let unsubscribeNotifications: (() => void) | null = null + const wire = (state: ConnectionState) => { + if (state === 'connected' && !unsubscribeNotifications) { + unsubscribeNotifications = subscribeToDesktopNotifications(client, hostId) + } else if (state !== 'connected') { + unsubscribeNotifications?.() + unsubscribeNotifications = null + } + } + const unsubscribeState = client.onStateChange(wire) + wire(client.getState()) + return () => { + unsubscribeState() + unsubscribeNotifications?.() + } +} + +function NotificationHostClientOwner({ client, hostId }: { client: RpcClient; hostId: string }) { + useEffect(() => subscribeToHostNotificationLifecycle(client, hostId), [client, hostId]) + return null +} + +function subscribeNotificationHostCatalog( + updateHosts: (hosts: HostProfile[]) => void, + retireHosts: (hostIds: readonly string[]) => void +): () => void { + let disposed = false + let loadRevision = 0 + let retryIndex = 0 + let retryTimer: ReturnType | null = null + let profilesByHostId = new Map() + const scheduleRetry = () => { + if (retryTimer || disposed) { + return + } + const delay = HOST_REFRESH_RETRY_DELAYS_MS[Math.min(retryIndex, 3)] + retryIndex += 1 + retryTimer = setTimeout(() => { + retryTimer = null + refreshHosts() + }, delay) + } + const refreshHosts = (resetRetry = false) => { + if (resetRetry) { + retryIndex = 0 + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + } + const revision = ++loadRevision + void loadHostCatalog() + .then((catalog) => { + if (!disposed && revision === loadRevision) { + const nextHosts = selectConnectableHostProfiles(catalog) + const readyHostIds = new Set(nextHosts.map(({ id }) => id)) + for (const entry of catalog) { + if (entry.credentialStatus === 'temporarily-unavailable') { + const retained = profilesByHostId.get(entry.id) + if (retained && !readyHostIds.has(entry.id)) { + nextHosts.push(retained) + } + } + } + profilesByHostId = new Map(nextHosts.map((host) => [host.id, host])) + updateHosts(nextHosts) + if ( + catalog.some(({ credentialStatus }) => credentialStatus === 'temporarily-unavailable') + ) { + scheduleRetry() + } else { + retryIndex = 0 + } + } + }) + .catch(scheduleRetry) + } + const unsubscribeHosts = subscribeHostCollectionChanges(({ retiredHostIds }) => { + if (retiredHostIds.length > 0) { + for (const hostId of retiredHostIds) { + profilesByHostId.delete(hostId) + } + retireHosts(retiredHostIds) + } + refreshHosts(true) + }) + const appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refreshHosts(true) + } + }) + refreshHosts() + return () => { + disposed = true + if (retryTimer) { + clearTimeout(retryTimer) + } + unsubscribeHosts() + appStateSubscription.remove() + } +} + +export function NotificationHostConnectionOwner() { + const [hosts, setHosts] = useState([]) + const [retiringHostIds, setRetiringHostIds] = useState([]) + const retirementStartedRef = useRef(new Set()) + const mountedRef = useRef(true) + const primeHosts = usePrimeHosts() + + useEffect( + () => () => { + mountedRef.current = false + }, + [] + ) + + useEffect( + () => + subscribeNotificationHostCatalog(setHosts, (retiredHostIds) => { + const retired = new Set(retiredHostIds) + setHosts((current) => current.filter(({ id }) => !retired.has(id))) + setRetiringHostIds((current) => [...new Set([...current, ...retiredHostIds])]) + }), + [] + ) + + const retiringHostIdSet = useMemo(() => new Set(retiringHostIds), [retiringHostIds]) + const activeHosts = useMemo( + () => hosts.filter(({ id }) => !retiringHostIdSet.has(id)), + [hosts, retiringHostIdSet] + ) + + useLayoutEffect(() => { + primeHosts(activeHosts) + }, [activeHosts, primeHosts]) + + const hostIds = useMemo(() => activeHosts.map(({ id }) => id), [activeHosts]) + const clients = useAllHostClients(hostIds, { + closeUnusedOnRelease: true, + observeConnectionState: false + }) + + useEffect(() => { + for (const hostId of retiringHostIds) { + if (retirementStartedRef.current.has(hostId)) { + continue + } + retirementStartedRef.current.add(hostId) + connectionLogStore.clear(hostId) + void retireHostNotificationState(hostId).finally(() => { + retirementStartedRef.current.delete(hostId) + if (mountedRef.current) { + setRetiringHostIds((current) => current.filter((id) => id !== hostId)) + } + }) + } + }, [retiringHostIds]) + + return ( + <> + {clients.map(({ client, hostId }) => ( + + ))} + + ) +} diff --git a/mobile/src/notifications/notification-host-ownership.test.ts b/mobile/src/notifications/notification-host-ownership.test.ts new file mode 100644 index 00000000000..e17d5e1146b --- /dev/null +++ b/mobile/src/notifications/notification-host-ownership.test.ts @@ -0,0 +1,669 @@ +import { createElement, useEffect } from 'react' +import * as Notifications from 'expo-notifications' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, HostProfile } from '../transport/types' + +const connectMock = vi.fn() +const loadHostsMock = vi.fn() +const loadHostCatalogMock = vi.fn() +const hostCollectionChange = vi.hoisted(() => ({ + listener: null as null | ((change: { retiredHostIds: readonly string[] }) => void) +})) + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn(), + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })) + }, + Platform: { OS: 'ios', Version: 18 } +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) + } +})) + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn(async () => true) +})) + +vi.mock('../transport/host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) + +vi.mock('../transport/host-store', () => ({ + loadHostCatalog: () => loadHostCatalogMock(), + loadHosts: () => loadHostsMock() +})) + +vi.mock('../transport/host-collection-changes', () => ({ + subscribeHostCollectionChanges: vi.fn( + (listener: (change: { retiredHostIds: readonly string[] }) => void) => { + hostCollectionChange.listener = listener + return () => { + hostCollectionChange.listener = null + } + } + ) +})) + +vi.mock('../transport/connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) + +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { NotificationHostConnectionOwner } from './notification-host-connection-owner' +import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' +import { RpcClientProvider, useCloseHost, useForceReconnect } from '../transport/client-context' +import { useAllHostClients } from '../transport/use-all-host-clients' + +type FakeClient = RpcClient & { + emitState: (state: ConnectionState) => void + emitNotification: (event: unknown) => void + emitNotificationReady: () => void + getMissedCalls: () => number + notificationSubscribeCalls: () => number + closeCalls: () => number +} + +function makeHost( + id: string, + lastConnected: number, + extra: Partial = {} +): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}.internal:8787`, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected, + ...extra + } +} + +const HOSTS = [ + makeHost('local-recent', 40), + makeHost('relay-recent', 30, { relayHostId: 'AbCdEf0123_-xyZ9' }), + makeHost('ssh-recent', 20), + makeHost('older-fourth', 10) +] + +function makeFakeClient( + initialState: ConnectionState = 'connected', + connectDuringListenerRegistration = false +): FakeClient { + let state = initialState + let notificationReady: ((data: unknown) => void) | null = null + let notificationSubscribes = 0 + let missedCalls = 0 + const stateListeners = new Set<(next: ConnectionState) => void>() + const close = vi.fn() + const client = { + sendRequest: vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + missedCalls += 1 + return { ok: true, result: { notifications: [], epoch: 'epoch-1' } } + } + return { ok: true, result: undefined } + }), + subscribe: vi.fn((method: string, _params: unknown, listener: (data: unknown) => void) => { + if (method === 'notifications.subscribe') { + notificationSubscribes += 1 + notificationReady = listener + } + return () => { + if (notificationReady === listener) { + notificationReady = null + } + } + }), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => state, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: (listener: (next: ConnectionState) => void) => { + stateListeners.add(listener) + if (connectDuringListenerRegistration) { + state = 'connected' + } + return () => stateListeners.delete(listener) + }, + notifyForeground: vi.fn(), + close, + emitState: (next: ConnectionState) => { + state = next + for (const listener of stateListeners) { + listener(next) + } + }, + emitNotification: (event: unknown) => { + notificationReady?.(event) + }, + emitNotificationReady: () => { + notificationReady?.({ type: 'ready', subscriptionId: 'subscription-1', epoch: 'epoch-1' }) + }, + getMissedCalls: () => missedCalls, + notificationSubscribeCalls: () => notificationSubscribes, + closeCalls: () => close.mock.calls.length + } + return client as unknown as FakeClient +} + +function HomeNotificationProbe(): null { + const hostIds = HOSTS.map(({ id }) => id) + const clients = useAllHostClients(hostIds, { + autoConnectHostIds: hostIds.slice(0, 3), + closeUnusedOnRelease: true + }) + const clientKey = clients + .map(({ hostId }) => hostId) + .sort() + .join(',') + + useEffect(() => { + const cleanups = clients.map((entry) => { + let unsubscribeNotifications: (() => void) | null = null + const wire = (state: ConnectionState) => { + if (state === 'connected' && !unsubscribeNotifications) { + unsubscribeNotifications = subscribeToDesktopNotifications(entry.client, entry.hostId) + } else if (state !== 'connected') { + unsubscribeNotifications?.() + unsubscribeNotifications = null + } + } + wire(entry.state) + const unsubscribeState = entry.client.onStateChange(wire) + return () => { + unsubscribeState() + unsubscribeNotifications?.() + } + }) + return () => { + for (const cleanup of cleanups) { + cleanup() + } + } + }, [clientKey]) + return null +} + +function HomeDataProbe({ onRender }: { onRender: () => void }): null { + onRender() + useAllHostClients( + HOSTS.slice(0, 3).map(({ id }) => id), + { closeUnusedOnRelease: true } + ) + return null +} + +let closeOwnedHost: ((hostId: string) => void) | null = null +let reconnectOwnedHost: ((hostId: string) => Promise) | null = null + +function RePairProbe(): null { + closeOwnedHost = useCloseHost() + reconnectOwnedHost = useForceReconnect() + return null +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('paired-host notification ownership', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + resetHostNotificationSessionsForTests() + hostCollectionChange.listener = null + closeOwnedHost = null + reconnectOwnedHost = null + loadHostCatalogMock.mockImplementation(async () => + (await loadHostsMock()).map((profile: HostProfile) => ({ + ...profile, + credentialStatus: 'ready', + profile + })) + ) + }) + + it('keeps live and reconnect catch-up delivery for the fourth paired host', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient() + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + const notificationOwner = + process.env.ORCA_TEST_DISABLE_NOTIFICATION_OWNER === '1' + ? null + : createElement(NotificationHostConnectionOwner) + renderer = create( + createElement( + RpcClientProvider, + null, + notificationOwner, + createElement(HomeNotificationProbe) + ) + ) + await flushAsync() + }) + + await act(async () => { + for (const client of clients.values()) { + client.emitNotificationReady() + client.emitState('reconnecting') + client.emitState('connected') + client.emitNotificationReady() + } + await flushAsync() + }) + + expect( + HOSTS.map(({ id }) => ({ + hostId: id, + liveSubscribed: (clients.get(id)?.notificationSubscribeCalls() ?? 0) > 0, + reconnectCatchUp: (clients.get(id)?.getMissedCalls() ?? 0) > 0 + })) + ).toEqual( + HOSTS.map(({ id }) => ({ hostId: id, liveSubscribed: true, reconnectCatchUp: true })) + ) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('subscribes every paired host without adding Home data streams at scale', async () => { + vi.useFakeTimers() + const hosts = Array.from({ length: 1_000 }, (_, index) => + makeHost(`host-${index}`, 1_000 - index) + ) + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient() + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue(hosts) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(NotificationHostConnectionOwner)) + ) + await Promise.resolve() + }) + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(clients).toHaveLength(1_000) + expect(loadHostCatalogMock).toHaveBeenCalledOnce() + expect(loadHostsMock).toHaveBeenCalledOnce() + expect(clients.every((client) => client.notificationSubscribeCalls() === 1)).toBe(true) + expect( + clients.some((client) => + vi.mocked(client.subscribe).mock.calls.some(([method]) => method === 'accounts.subscribe') + ) + ).toBe(false) + } finally { + restore() + act(() => renderer?.unmount()) + vi.useRealTimers() + } + }) + + it('acquires newly paired hosts and closes removed notification clients', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient() + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(NotificationHostConnectionOwner)) + ) + await flushAsync() + }) + + const newlyPaired = makeHost('newly-paired', 50) + loadHostsMock.mockResolvedValue([...HOSTS, newlyPaired]) + await act(async () => { + hostCollectionChange.listener?.({ retiredHostIds: [] }) + await flushAsync() + }) + expect(clients.get(newlyPaired.id)?.notificationSubscribeCalls()).toBe(1) + expect(clients.get(HOSTS[0]!.id)?.notificationSubscribeCalls()).toBe(1) + + loadHostsMock.mockResolvedValue(HOSTS.slice(0, 3)) + await act(async () => { + hostCollectionChange.listener?.({ + retiredHostIds: ['older-fourth', newlyPaired.id] + }) + await flushAsync() + }) + expect(clients.get('older-fourth')?.closeCalls()).toBe(1) + expect(clients.get(newlyPaired.id)?.closeCalls()).toBe(1) + expect(clients.get(HOSTS[0]!.id)?.notificationSubscribeCalls()).toBe(1) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('waits for same-id retirement before binding the replacement subscription', async () => { + let finishSchedule: (identifier: string) => void = () => {} + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockReturnValueOnce( + new Promise((resolve) => { + finishSchedule = resolve + }) + ) + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient() + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue([HOSTS[0]]) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(NotificationHostConnectionOwner)) + ) + await flushAsync() + }) + clients[0]?.emitNotification({ + type: 'notification', + source: 'agent-task-complete', + title: 'Held delivery', + body: 'Keep retirement open', + notificationId: 'agent:held' + }) + await flushAsync() + + loadHostsMock.mockResolvedValue([]) + await act(async () => { + hostCollectionChange.listener?.({ retiredHostIds: [HOSTS[0]!.id] }) + await flushAsync() + }) + + expect(clients).toHaveLength(1) + expect(clients[0]?.closeCalls()).toBe(1) + + loadHostsMock.mockResolvedValue([HOSTS[0]]) + await act(async () => { + hostCollectionChange.listener?.({ retiredHostIds: [] }) + await flushAsync() + }) + expect(clients).toHaveLength(1) + + await act(async () => { + finishSchedule('scheduled-held') + await flushAsync() + }) + await vi.waitFor(() => expect(clients).toHaveLength(2)) + + await act(async () => { + clients[1]?.emitNotificationReady() + clients[1]?.emitState('reconnecting') + clients[1]?.emitState('connected') + clients[1]?.emitNotificationReady() + await flushAsync() + }) + + expect(clients[1]?.notificationSubscribeCalls()).toBe(2) + expect(clients[1]?.getMissedCalls()).toBe(1) + } finally { + finishSchedule('scheduled-held') + restore() + act(() => renderer?.unmount()) + } + }) + + it('binds after a client connects during listener registration', async () => { + const client = makeFakeClient('connecting', true) + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([HOSTS[0]]) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(NotificationHostConnectionOwner)) + ) + await flushAsync() + }) + expect(client.notificationSubscribeCalls()).toBe(1) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('reopens root notification ownership immediately after re-pair closes the old client', async () => { + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient() + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue([HOSTS[0]]) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement( + RpcClientProvider, + null, + createElement(NotificationHostConnectionOwner), + createElement(RePairProbe) + ) + ) + await flushAsync() + }) + expect(clients[0]?.notificationSubscribeCalls()).toBe(1) + + await act(async () => { + closeOwnedHost?.(HOSTS[0]!.id) + await reconnectOwnedHost?.(HOSTS[0]!.id) + await flushAsync() + }) + + expect(clients).toHaveLength(2) + expect(clients[0]?.closeCalls()).toBe(1) + expect(clients[1]?.notificationSubscribeCalls()).toBe(1) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('retains a live notification client during a temporary credential read', async () => { + const host = HOSTS[0]! + const client = makeFakeClient() + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([host]) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(NotificationHostConnectionOwner)) + ) + await flushAsync() + }) + + loadHostCatalogMock.mockResolvedValueOnce([ + { ...host, credentialStatus: 'temporarily-unavailable', profile: null } + ]) + await act(async () => { + hostCollectionChange.listener?.({ retiredHostIds: [] }) + await flushAsync() + }) + + expect(client.closeCalls()).toBe(0) + expect(client.notificationSubscribeCalls()).toBe(1) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it.each(['temporary credential read', 'catalog rejection'])( + 'retries a %s while the app stays foregrounded', + async (failure) => { + vi.useFakeTimers() + const host = HOSTS[0]! + if (failure === 'temporary credential read') { + loadHostCatalogMock.mockResolvedValueOnce([ + { ...host, credentialStatus: 'temporarily-unavailable', profile: null } + ]) + } else { + loadHostCatalogMock.mockRejectedValueOnce(new Error('storage unavailable')) + } + loadHostCatalogMock.mockResolvedValueOnce([ + { ...host, credentialStatus: 'ready', profile: host } + ]) + const client = makeFakeClient() + connectMock.mockReturnValue(client) + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(NotificationHostConnectionOwner)) + ) + await Promise.resolve() + }) + expect(client.notificationSubscribeCalls()).toBe(0) + + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(client.notificationSubscribeCalls()).toBe(1) + } finally { + restore() + act(() => renderer?.unmount()) + vi.useRealTimers() + } + } + ) + + it('does not rerender Home for notification-only host state changes', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient() + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + let homeRenders = 0 + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement( + RpcClientProvider, + null, + createElement(NotificationHostConnectionOwner), + createElement(HomeDataProbe, { onRender: () => (homeRenders += 1) }) + ) + ) + await flushAsync() + }) + const settledRenders = homeRenders + + await act(async () => { + clients.get('older-fourth')?.emitState('reconnecting') + await Promise.resolve() + }) + expect(homeRenders).toBe(settledRenders) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('does not rerender Home when notification-only clients are added at scale', async () => { + const initialHosts = HOSTS.slice(0, 3) + const expandedHosts = [ + ...initialHosts, + ...Array.from({ length: 997 }, (_, index) => makeHost(`notification-only-${index}`, 0)) + ] + connectMock.mockImplementation(() => makeFakeClient()) + loadHostsMock.mockResolvedValue(initialHosts) + let homeRenders = 0 + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement( + RpcClientProvider, + null, + createElement(NotificationHostConnectionOwner), + createElement(HomeDataProbe, { onRender: () => (homeRenders += 1) }) + ) + ) + await flushAsync() + }) + const settledRenders = homeRenders + + loadHostsMock.mockResolvedValue(expandedHosts) + await act(async () => { + hostCollectionChange.listener?.({ retiredHostIds: [] }) + await flushAsync() + }) + + expect(connectMock).toHaveBeenCalledTimes(1_000) + expect(homeRenders).toBe(settledRenders) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) +}) diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts index de05ed69505..dd2785e1075 100644 --- a/mobile/src/notifications/notification-reconnect-catchup.ts +++ b/mobile/src/notifications/notification-reconnect-catchup.ts @@ -129,6 +129,7 @@ export function createSeenNotificationGuard(): { // at module scope is what makes the catch-up recognise a reconnect (instead of // mistaking it for a cold open) and keeps dedup effective across the teardown. export type HostNotificationSession = { + retired: boolean // Highest desktop seq delivered for this host in this app process. Outranks // the persisted value, which lags because saveLastSeenSeq is fire-and-forget. lastDeliveredSeq: number @@ -157,11 +158,13 @@ export type HostNotificationSession = { } const sessionsByHost = new Map() +const retirementByHost = new Map>() export function getHostNotificationSession(hostId: string): HostNotificationSession { let session = sessionsByHost.get(hostId) if (!session) { session = { + retired: false, lastDeliveredSeq: 0, lastDeliveredEpoch: null, catchUpQuarantineSeq: null, @@ -241,6 +244,7 @@ export function releaseQueuedShowNotificationId( /** Test-only: drop per-host session state so each test starts from a cold open. */ export function resetHostNotificationSessionsForTests(): void { sessionsByHost.clear() + retirementByHost.clear() } /** @@ -257,6 +261,9 @@ export function quarantineCatchUpWatermark( hostId: string, contiguousSeq: number ): void { + if (session.retired) { + return + } session.catchUpQuarantineSeq = session.catchUpQuarantineSeq == null ? contiguousSeq @@ -272,7 +279,7 @@ export function quarantineCatchUpWatermark( /** Lift the quarantine once a catch-up completes, persisting what it held back. */ export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void { - if (session.catchUpQuarantineSeq == null) { + if (session.retired || session.catchUpQuarantineSeq == null) { return } session.catchUpQuarantineSeq = null @@ -302,7 +309,7 @@ export function adoptNotificationEpoch( hostId: string, epoch: string | undefined ): void { - if (!epoch || epoch === session.lastDeliveredEpoch) { + if (session.retired || !epoch || epoch === session.lastDeliveredEpoch) { return } // Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded @@ -357,6 +364,9 @@ export function seedWatermarkFromStorage(session: HostNotificationSession, hostI return } const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => { + if (session.retired) { + return + } // Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists // `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for // this host reloads as seq 0. Keying on the seq would read that as a first pairing @@ -388,6 +398,31 @@ export function forgetHostNotificationSession(hostId: string): void { sessionsByHost.delete(hostId) } +export function retireHostNotificationState(hostId: string): Promise { + const existing = retirementByHost.get(hostId) + if (existing) { + return existing + } + const session = sessionsByHost.get(hostId) + if (session) { + session.retired = true + } + const retirement = (async () => { + await session?.deliveryTail + if (!session || sessionsByHost.get(hostId) === session) { + sessionsByHost.delete(hostId) + } + await clearWatermark(hostId) + })() + retirementByHost.set(hostId, retirement) + void retirement.finally(() => { + if (retirementByHost.get(hostId) === retirement) { + retirementByHost.delete(hostId) + } + }) + return retirement +} + // Why: key for the replay dedup guard. Uses notificationId when present, but // disambiguates by seq so a legitimate live re-delivery of the same id at a // NEW seq (content refresh, allowed by the existing behaviour) is NOT treated diff --git a/mobile/src/notifications/notification-retired-live-delivery.test.ts b/mobile/src/notifications/notification-retired-live-delivery.test.ts new file mode 100644 index 00000000000..de235f71892 --- /dev/null +++ b/mobile/src/notifications/notification-retired-live-delivery.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { + resetHostNotificationSessionsForTests, + retireHostNotificationState +} from './notification-reconnect-catchup' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn(), + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ Platform: { OS: 'ios', Version: 18 } })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorage +})) + +vi.mock('../storage/preferences', () => ({ loadPushNotificationsEnabled: vi.fn() })) + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('retired host live delivery', () => { + beforeEach(() => { + vi.clearAllMocks() + resetHostNotificationSessionsForTests() + }) + + it('drops queued delivery after the host subscription is retired', async () => { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + let finishFirstSchedule: (identifier: string) => void = () => {} + vi.mocked(Notifications.scheduleNotificationAsync).mockReturnValueOnce( + new Promise((resolve) => { + finishFirstSchedule = resolve + }) + ) + let onEvent: ((data: unknown) => void) | null = null + const client = { + subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { + onEvent = callback + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn() + } as unknown as RpcClient + + const unsubscribe = subscribeToDesktopNotifications(client, 'host-retired') + onEvent?.({ + type: 'notification', + source: 'agent-task-complete', + title: 'First', + body: 'First body', + notificationId: 'agent:first' + }) + onEvent?.({ + type: 'notification', + source: 'agent-task-complete', + title: 'Second', + body: 'Second body', + notificationId: 'agent:second' + }) + await flushAsync() + unsubscribe() + finishFirstSchedule('scheduled-first') + await flushAsync() + + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce() + }) + + it('does not recreate a watermark when catch-up resolves after retirement', async () => { + let onEvent: ((data: unknown) => void) | null = null + let resolveCatchUp: (response: { ok: true; result: unknown }) => void = () => {} + const catchUp = new Promise<{ ok: true; result: unknown }>((resolve) => { + resolveCatchUp = resolve + }) + const client = { + subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { + onEvent = callback + return () => { + if (onEvent === callback) { + onEvent = null + } + } + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn((method: string) => + method === 'notifications.getMissedSince' + ? catchUp + : Promise.resolve({ ok: true, result: undefined }) + ) + } as unknown as RpcClient + + const firstUnsubscribe = subscribeToDesktopNotifications(client, 'host-retired-catch-up') + onEvent?.({ type: 'ready', subscriptionId: 'first' }) + await flushAsync() + firstUnsubscribe() + + const secondUnsubscribe = subscribeToDesktopNotifications(client, 'host-retired-catch-up') + onEvent?.({ type: 'ready', subscriptionId: 'second' }) + await vi.waitFor(() => + expect(client.sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', { + lastSeenSeq: 0 + }) + ) + asyncStorage.setItem.mockClear() + + secondUnsubscribe() + await retireHostNotificationState('host-retired-catch-up') + resolveCatchUp({ ok: true, result: { notifications: [], epoch: 'retired-epoch' } }) + await flushAsync() + + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index b08899f5333..9750df0c452 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -390,6 +390,36 @@ describe('useHostClient', () => { }) describe('useAllHostClients', () => { + it('observes a client opened by an earlier sibling effect in the same commit', async () => { + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST]) + let observerHasClient = false + + function Acquirer(): null { + useAllHostClients([HOST.id]) + return null + } + function Observer(): null { + observerHasClient = useAllHostClients([HOST.id]).length === 1 + return null + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(Acquirer), createElement(Observer)) + ) + await Promise.resolve() + }) + expect(observerHasClient).toBe(true) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + it('only opens the requested startup subset', async () => { const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } connectMock.mockReturnValue(makeFakeClient('connected')) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 18ee0b7782a..e1dfdcec838 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -12,12 +12,11 @@ import { } from 'react' import type { RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' -import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' import { decrementPendingAcquisition } from './host-client-acquisition-count' +import { subscribeHostClientRevival } from './host-client-revival-scheduler' import { clientActivePath, - listHostClients, notifyAllHostListeners, notifyHostStateListeners, primeHostProfiles, @@ -51,8 +50,8 @@ export type RpcClientContextValue = { // Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt. getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath + getClient: (hostId: string) => RpcClient | null subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void - getAllClients: () => Array<{ hostId: string; client: RpcClient }> subscribeAllHosts: (listener: () => void) => () => void // Why: lets the home screen feed already-loaded HostProfiles so we don't pay loadHosts() latency twice. primeHosts: (hosts: HostProfile[]) => void @@ -302,14 +301,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { return clientActivePath(storeRef.current.get(hostId)?.client) }, []) + const getClient = useCallback( + (hostId: string) => storeRef.current.get(hostId)?.client ?? null, + [] + ) + const subscribeHostState = useCallback( (hostId: string, listener: (state: ConnectionState) => void) => subscribeHostStateListener(stateListenersRef.current, hostId, listener), [] ) - const getAllClients = useCallback(() => listHostClients(storeRef.current), []) - const subscribeAllHosts = useCallback( (listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener), [] @@ -330,11 +332,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049). useEffect(() => { - return subscribeConnectionRevivalTriggers((reason) => { - for (const entry of storeRef.current.values()) { - entry.client.notifyForeground(reason) - } - }) + return subscribeHostClientRevival(() => + [...storeRef.current.values()].map(({ client }) => client) + ) }, []) const value = useMemo( @@ -350,8 +350,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { getReconnectAttempt, getLastConnectedAt, getActivePath, + getClient, subscribeHostState, - getAllClients, subscribeAllHosts, primeHosts }), @@ -367,8 +367,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { getReconnectAttempt, getLastConnectedAt, getActivePath, + getClient, subscribeHostState, - getAllClients, subscribeAllHosts, primeHosts ] @@ -416,9 +416,9 @@ export function useHostClient(hostId: string | undefined): { } setState(next) // Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one. - const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) - if (found && found.client !== clientRef.current) { - clientRef.current = found.client + const found = ctx.getClient(hostId) + if (found && found !== clientRef.current) { + clientRef.current = found force((n) => n + 1) } else if (!found && clientRef.current) { // Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511). diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts index e7ce662534b..8dcf2487cda 100644 --- a/mobile/src/transport/connection-log-buffer.test.ts +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -53,4 +53,16 @@ describe('connection log buffer', () => { store.append('host-a', entry(2)) expect(onA).toHaveBeenCalledTimes(1) }) + + it('evicts a retired host and notifies its active observer', () => { + const store = createConnectionLogStore() + const listener = vi.fn() + store.append('host-a', entry(1)) + store.subscribe('host-a', listener) + + store.clear('host-a') + + expect(store.get('host-a')).toEqual([]) + expect(listener).toHaveBeenCalledOnce() + }) }) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts index 1b223ad8e1f..c281d1a9e6c 100644 --- a/mobile/src/transport/connection-log-buffer.ts +++ b/mobile/src/transport/connection-log-buffer.ts @@ -11,6 +11,7 @@ const MAX_ENTRIES_PER_HOST = 200 export type ConnectionLogStore = { append: (hostId: string, entry: ConnectionLogEntry) => void + clear: (hostId: string) => void get: (hostId: string) => readonly ConnectionLogEntry[] subscribe: (hostId: string, listener: () => void) => () => void } @@ -46,6 +47,17 @@ export function createConnectionLogStore( } }, + clear(hostId) { + entriesByHost.delete(hostId) + snapshotByHost.delete(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } + }, + get(hostId) { const cached = snapshotByHost.get(hostId) if (cached) { diff --git a/mobile/src/transport/host-client-revival-scheduler.test.ts b/mobile/src/transport/host-client-revival-scheduler.test.ts new file mode 100644 index 00000000000..183af785b95 --- /dev/null +++ b/mobile/src/transport/host-client-revival-scheduler.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import { HostClientRevivalScheduler } from './host-client-revival-scheduler' + +vi.mock('./connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: vi.fn(() => () => {}) +})) + +describe('host client revival scheduler', () => { + afterEach(() => vi.useRealTimers()) + + it('paces a large foreground recovery wave without dropping clients', async () => { + vi.useFakeTimers() + const notifyForeground = Array.from({ length: 1_000 }, () => vi.fn()) + const clients = notifyForeground.map( + (notify) => ({ notifyForeground: notify }) as unknown as RpcClient + ) + + const scheduler = new HostClientRevivalScheduler() + scheduler.schedule(clients, 'app-resume') + expect(notifyForeground.filter((notify) => notify.mock.calls.length > 0)).toHaveLength(32) + + await vi.runAllTimersAsync() + expect(notifyForeground.every((notify) => notify.mock.calls[0]?.[0] === 'app-resume')).toBe( + true + ) + }) + + it('cancels batches that have not started', async () => { + vi.useFakeTimers() + const notifyForeground = Array.from({ length: 64 }, () => vi.fn()) + const clients = notifyForeground.map( + (notify) => ({ notifyForeground: notify }) as unknown as RpcClient + ) + + const scheduler = new HostClientRevivalScheduler() + scheduler.schedule(clients, 'network-change') + scheduler.stop() + await vi.runAllTimersAsync() + + expect(notifyForeground.filter((notify) => notify.mock.calls.length > 0)).toHaveLength(32) + }) +}) diff --git a/mobile/src/transport/host-client-revival-scheduler.ts b/mobile/src/transport/host-client-revival-scheduler.ts new file mode 100644 index 00000000000..b9c036173a8 --- /dev/null +++ b/mobile/src/transport/host-client-revival-scheduler.ts @@ -0,0 +1,60 @@ +import type { RpcClient } from './rpc-client' +import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' + +const REVIVAL_BATCH_SIZE = 32 +const REVIVAL_BATCH_DELAY_MS = 25 + +type RevivalReason = 'app-resume' | 'network-change' + +export class HostClientRevivalScheduler { + private readonly pending = new Map() + private timer: ReturnType | null = null + private stopped = false + + schedule(clients: readonly RpcClient[], reason: RevivalReason): void { + if (this.stopped) { + return + } + for (const client of clients) { + this.pending.set(client, reason) + } + if (!this.timer) { + this.notifyNextBatch() + } + } + + stop(): void { + this.stopped = true + this.pending.clear() + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + private notifyNextBatch(): void { + this.timer = null + if (this.stopped) { + return + } + const batch = [...this.pending].slice(0, REVIVAL_BATCH_SIZE) + for (const [client, reason] of batch) { + this.pending.delete(client) + client.notifyForeground(reason) + } + if (this.pending.size > 0) { + this.timer = setTimeout(() => this.notifyNextBatch(), REVIVAL_BATCH_DELAY_MS) + } + } +} + +export function subscribeHostClientRevival(listClients: () => readonly RpcClient[]): () => void { + const scheduler = new HostClientRevivalScheduler() + const unsubscribe = subscribeConnectionRevivalTriggers((reason) => { + scheduler.schedule(listClients(), reason) + }) + return () => { + scheduler.stop() + unsubscribe() + } +} diff --git a/mobile/src/transport/host-collection-changes.test.ts b/mobile/src/transport/host-collection-changes.test.ts new file mode 100644 index 00000000000..351cc1895b3 --- /dev/null +++ b/mobile/src/transport/host-collection-changes.test.ts @@ -0,0 +1,22 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + notifyHostCollectionChanged, + resetHostCollectionChangeListenersForTests, + subscribeHostCollectionChanges +} from './host-collection-changes' + +describe('host collection changes', () => { + beforeEach(() => resetHostCollectionChangeListenersForTests()) + + it('notifies active listeners only', () => { + const listener = vi.fn() + const unsubscribe = subscribeHostCollectionChanges(listener) + + notifyHostCollectionChanged({ retiredHostIds: ['retired-host'] }) + unsubscribe() + notifyHostCollectionChanged({ retiredHostIds: [] }) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith({ retiredHostIds: ['retired-host'] }) + }) +}) diff --git a/mobile/src/transport/host-collection-changes.ts b/mobile/src/transport/host-collection-changes.ts new file mode 100644 index 00000000000..40774d16813 --- /dev/null +++ b/mobile/src/transport/host-collection-changes.ts @@ -0,0 +1,20 @@ +export type HostCollectionChange = { retiredHostIds: readonly string[] } + +const listeners = new Set<(change: HostCollectionChange) => void>() + +export function notifyHostCollectionChanged(change: HostCollectionChange): void { + for (const listener of listeners) { + listener(change) + } +} + +export function subscribeHostCollectionChanges( + listener: (change: HostCollectionChange) => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetHostCollectionChangeListenersForTests(): void { + listeners.clear() +} diff --git a/mobile/src/transport/host-last-connected.test.ts b/mobile/src/transport/host-last-connected.test.ts new file mode 100644 index 00000000000..b13c60430b3 --- /dev/null +++ b/mobile/src/transport/host-last-connected.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { StoredHostProfile } from './types' +import { recordHostLastConnected } from './host-last-connected' + +const HOST: StoredHostProfile = { + id: 'host-1', + name: 'Host 1', + endpoint: 'ws://host-1', + publicKeyB64: 'key-1', + lastConnected: 1 +} + +describe('recordHostLastConnected', () => { + it('updates only the matching host without mutating the input', () => { + const other = { ...HOST, id: 'host-2' } + const hosts = [HOST, other] + + expect(recordHostLastConnected(hosts, HOST.id, 42)).toEqual([ + { ...HOST, lastConnected: 42 }, + other + ]) + expect(hosts).toEqual([HOST, other]) + }) + + it('returns the same list when the host is absent', () => { + const hosts = [HOST] + expect(recordHostLastConnected(hosts, 'missing', 42)).toBe(hosts) + }) +}) diff --git a/mobile/src/transport/host-last-connected.ts b/mobile/src/transport/host-last-connected.ts new file mode 100644 index 00000000000..1ba1a1e04c6 --- /dev/null +++ b/mobile/src/transport/host-last-connected.ts @@ -0,0 +1,15 @@ +import type { StoredHostProfile } from './types' + +export function recordHostLastConnected( + hosts: StoredHostProfile[], + hostId: string, + connectedAt: number +): StoredHostProfile[] { + const index = hosts.findIndex((host) => host.id === hostId) + if (index < 0) { + return hosts + } + const next = hosts.slice() + next[index] = { ...next[index]!, lastConnected: connectedAt } + return next +} diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index 6c96ef1c446..cbbc3497579 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -83,9 +83,26 @@ describe('host removal lifecycle', () => { removeHostMock.mockResolvedValue(undefined) await removeHostAndCloseClient('host-1', vi.fn()) - // clearWatermark is fire-and-forget; let its microtask land. - await Promise.resolve() expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1') }) + + it('drains in-flight notification delivery before retiring a host for re-pair', async () => { + removeHostMock.mockResolvedValue(undefined) + const session = getHostNotificationSession('host-1') + let finishDelivery: () => void = () => {} + session.deliveryTail = new Promise((resolve) => { + finishDelivery = resolve + }) + + const removal = removeHostAndCloseClient('host-1', vi.fn()) + await Promise.resolve() + expect(getHostNotificationSession('host-1')).toBe(session) + + finishDelivery() + await removal + + expect(getHostNotificationSession('host-1')).not.toBe(session) + expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1') + }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index 64007cb9552..2a8f3ddec30 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,7 +1,5 @@ -import { - clearWatermark, - forgetHostNotificationSession -} from '../notifications/notification-reconnect-catchup' +import { retireHostNotificationState } from '../notifications/notification-reconnect-catchup' +import { connectionLogStore } from './connection-log-buffer' import { removeHost } from './host-store' export async function removeHostAndCloseClient( @@ -12,9 +10,9 @@ export async function removeHostAndCloseClient( // storage failure; closing immediately after success prevents socket leaks. await removeHost(hostId) closeHostClient(hostId) + connectionLogStore.clear(hostId) // Why: the notification session outlives the socket by design (it must survive // reconnects), so removal is the only thing that can retire it. Left behind, a // re-pair of the same host would inherit a watermark for a counter it never saw. - forgetHostNotificationSession(hostId) - void clearWatermark(hostId) + await retireHostNotificationState(hostId) } diff --git a/mobile/src/transport/host-store-collection-notification.test.ts b/mobile/src/transport/host-store-collection-notification.test.ts new file mode 100644 index 00000000000..8ea7b2a0685 --- /dev/null +++ b/mobile/src/transport/host-store-collection-notification.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) +const secureStore = vi.hoisted(() => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStore +})) +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('./host-credential-cleanup', () => ({ + cancelPendingHostCredentialCleanup: vi.fn(async () => undefined), + recordHostCredentialCleanupIntent: vi.fn(async () => undefined), + scheduleHostCredentialCleanup: vi.fn(async () => undefined), + retryPendingHostCredentialCleanups: vi.fn() +})) + +import { loadHostCatalog, resetHostStoreForTests, saveHost } from './host-store' +import { subscribeHostCollectionChanges } from './host-collection-changes' +import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' + +const HOSTS_STORAGE_KEY = 'orca:hosts' +const HOST = { + id: 'host-1', + name: 'Host 1', + endpoint: 'ws://127.0.0.1:1', + publicKeyB64: 'key-1', + lastConnected: 0 +} + +describe('host store collection notifications', () => { + let storedHosts: Array + let credentialByHostId: Map + + beforeEach(() => { + vi.clearAllMocks() + resetHostStoreForTests() + resetMobileRelayHostOverlayStoreForTests() + storedHosts = [HOST] + credentialByHostId = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => + key === HOSTS_STORAGE_KEY ? JSON.stringify(storedHosts) : null + ) + asyncStorage.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === HOSTS_STORAGE_KEY) { + storedHosts = JSON.parse(raw) + } + }) + secureStore.getItemAsync.mockImplementation(async (key: string) => { + const hostId = [...credentialByHostId.keys()].find((id) => key.endsWith(id)) + return hostId ? (credentialByHostId.get(hostId) ?? null) : null + }) + secureStore.setItemAsync.mockImplementation(async (key: string, token: string) => { + const hostId = key.split('.').at(-1) + if (hostId) { + credentialByHostId.set(hostId, token) + } + }) + }) + + it('announces new membership and restored credentials', async () => { + await loadHostCatalog() + const listener = vi.fn() + subscribeHostCollectionChanges(listener) + + await saveHost({ ...HOST, deviceToken: 'restored-token' }) + await saveHost({ + ...HOST, + id: 'host-2', + name: 'Host 2', + publicKeyB64: 'key-2', + deviceToken: 'new-token' + }) + + expect(listener).toHaveBeenNthCalledWith(1, { retiredHostIds: [] }) + expect(listener).toHaveBeenNthCalledWith(2, { retiredHostIds: [] }) + }) + + it('announces duplicate identities retired by an authoritative save', async () => { + storedHosts = [HOST, { ...HOST, id: 'host-duplicate', name: 'Duplicate' }] + const listener = vi.fn() + subscribeHostCollectionChanges(listener) + + await saveHost({ ...HOST, deviceToken: 'replacement-token' }) + + expect(listener).toHaveBeenCalledWith({ retiredHostIds: ['host-duplicate'] }) + }) +}) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 99421ffd192..27f4a5d0d15 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -30,6 +30,8 @@ import { toStoredHostProfile, writeStoredHostProfiles } from './host-metadata-store' +import * as hostCollectionChanges from './host-collection-changes' +import { recordHostLastConnected } from './host-last-connected' async function commitDeviceToken(hostId: string, token: string): Promise { markHostCredentialWrite(hostId) @@ -169,6 +171,7 @@ export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise = async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) const stored = toStoredHostProfile(validated) + const credentialWasCached = tokenCache.has(stored.id) const duplicateHostIds = new Set() let updatedExistingHost = false let cleanupIntentRecordedBeforeMetadata = false @@ -252,6 +255,9 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise // Metadata is already deduplicated; orphan-token recovery is best-effort. } } + if (!updatedExistingHost || duplicateHostIds.size > 0 || !credentialWasCached) { + hostCollectionChanges.notifyHostCollectionChanged({ retiredHostIds: [...duplicateHostIds] }) + } } export async function removeHost(hostId: string): Promise { @@ -285,6 +291,7 @@ export async function removeHost(hostId: string): Promise { } catch { // Metadata is already committed; orphan-token recovery is best-effort. } + hostCollectionChanges.notifyHostCollectionChanged({ retiredHostIds: [hostId] }) } export async function retryPendingHostCredentialCleanup(): Promise<{ @@ -319,15 +326,7 @@ export async function updateHostNameAndEndpoint( export async function updateLastConnected(hostId: string): Promise { try { - await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === hostId) - if (index < 0) { - return hosts - } - const next = hosts.slice() - next[index] = { ...next[index]!, lastConnected: Date.now() } - return next - }) + await mutateStoredHosts((hosts) => recordHostLastConnected(hosts, hostId, Date.now())) } catch { // Why: best-effort timestamp fired with void; swallow so unreadable storage doesn't reject. } @@ -340,4 +339,5 @@ export function resetHostStoreForTests(): void { resetHostCredentialWriteRevisionsForTests() hostListLoads.dropSharedHostListLoad() resetPairingKeychainForTests() + hostCollectionChanges.resetHostCollectionChangeListenersForTests() } diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts index 7a23c86ac71..c599e0e0617 100644 --- a/mobile/src/transport/settings-host-client-lifecycle.test.ts +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -196,10 +196,8 @@ function activeHostIds(): string[] { if (!context) { throw new Error('client context was not captured') } - return context - .getAllClients() - .map(({ hostId }) => hostId) - .sort() + const openedHostIds = connectMock.mock.calls.map(([profile]) => profile.id as string) + return [...new Set(openedHostIds)].filter((hostId) => context?.getClient(hostId)).sort() } beforeEach(() => { diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index 1000916113b..c5d6238eea4 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -7,24 +7,28 @@ import { useRpcClientContext } from './client-context' type UseAllHostClientsOptions = { autoConnectHostIds?: readonly string[] closeUnusedOnRelease?: boolean + observeConnectionState?: boolean } export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) { const ctx = useRpcClientContext() const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false + const observeConnectionState = options?.observeConnectionState ?? true const key = useMemo( () => [ [...hostIds].sort().join(','), [...autoConnectHostIds].sort().join(','), - closeUnusedOnRelease ? 'close' : 'keep' + closeUnusedOnRelease ? 'close' : 'keep', + observeConnectionState ? 'state' : 'client' ].join('|'), - [autoConnectHostIds, closeUnusedOnRelease, hostIds] + [autoConnectHostIds, closeUnusedOnRelease, hostIds, observeConnectionState] ) const [tick, setTick] = useState(0) const acquiredHostIdsRef = useRef>(new Set()) const hostUnsubscribesRef = useRef void>>(new Map()) + const observedClientsRef = useRef>(new Map()) const closeUnusedRef = useRef(closeUnusedOnRelease) useEffect(() => { @@ -32,9 +36,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients }, [closeUnusedOnRelease]) useEffect(() => { - const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1)) return () => { - unsubscribeAllHosts() const trackedHostIds = [...hostUnsubscribesRef.current.keys()] const acquiredHostIds = new Set(acquiredHostIdsRef.current) for (const unsubscribe of hostUnsubscribesRef.current.values()) { @@ -56,6 +58,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients } } acquiredHostIdsRef.current.clear() + observedClientsRef.current.clear() } }, [ctx]) @@ -63,23 +66,38 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients const trackedHostIds = new Set(hostIds) const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id))) const removedTrackedHostIds: string[] = [] + let foundClientOpenedBeforeSubscription = false for (const [id, unsubscribe] of hostUnsubscribesRef.current) { if (!trackedHostIds.has(id)) { unsubscribe() hostUnsubscribesRef.current.delete(id) + observedClientsRef.current.delete(id) removedTrackedHostIds.push(id) } } for (const id of trackedHostIds) { if (!hostUnsubscribesRef.current.has(id)) { + const existingClient = ctx.getClient(id) + observedClientsRef.current.set(id, existingClient) + foundClientOpenedBeforeSubscription ||= existingClient !== null hostUnsubscribesRef.current.set( id, - ctx.subscribeHostState(id, () => setTick((value) => value + 1)) + ctx.subscribeHostState(id, () => { + const nextClient = ctx.getClient(id) + if (observeConnectionState || observedClientsRef.current.get(id) !== nextClient) { + observedClientsRef.current.set(id, nextClient) + setTick((value) => value + 1) + } + }) ) } } + if (foundClientOpenedBeforeSubscription) { + setTick((value) => value + 1) + } + for (const id of acquiredHostIdsRef.current) { if (!nextAcquiredHostIds.has(id)) { if (closeUnusedOnRelease) { @@ -108,16 +126,13 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients }, [ctx, key]) return useMemo(() => { - const clientsByHostId = new Map( - ctx.getAllClients().map((entry) => [entry.hostId, entry.client]) - ) return hostIds.flatMap<{ hostId: string client: RpcClient state: ConnectionState path: MobileConnectionPath }>((hostId) => { - const client = clientsByHostId.get(hostId) + const client = ctx.getClient(hostId) return client ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] : []