diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 489599d7bf3..4f523892d7e 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -539,10 +539,10 @@ describe('the Phase C budget', () => { key ).toBeGreaterThanOrEqual(measured) } - // 1 to 10 per route, which is why four per route was a bound rather than a fit and why the + // 1 to 11 per route, which is why four per route was a bound rather than a fit and why the // envelope cannot be a line through the measurement either. expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1) - expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(10) + expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(11) }) it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => { @@ -614,13 +614,13 @@ describe('the Phase C budget', () => { it('fails the build when the derived ceiling passes what the phone will accept', async () => { // The shell hands back null for a manifest over its own ceiling, so a derived ceiling above // that ships a green build no device can open. At the 42 images the tree carries, the envelope - // plus 42 plus the document crosses 256 at 30 routes, which Phase C reaches. The crossing came + // plus 42 plus the document crosses 256 at 28 routes, which Phase C reaches. The crossing came // in from 50 with the envelope: it grants the worst swept route to each one past the sweep, // where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) - expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251) - expect(() => assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( - /261 .*256/ + expect(assertAssetCeilingFitsShell(27, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(249) + expect(() => assertAssetCeilingFitsShell(28, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( + /260 .*256/ ) }) }) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index 48917d294ce..f9dd0ce7c66 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -50,9 +50,9 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 * A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is * what it fails to share rather than what it weighs. Re-measured on this head by building * `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than - * fitted to. The spread it shows is 1 to 10: `pr` and `web` add one script each, `session` adds ten. + * fitted to. The spread it shows is 1 to 11: `pr` and `web` add one script each, `tasks` adds eleven. * The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the - * swept tree reads 74 scripts at 16 routes. + * swept tree reads 81 scripts at 16 routes. * * This table is the fence's only input, so a route added to the tree stales it and the pins beside * the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped. @@ -60,20 +60,20 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [ ['./_layout.tsx', 3], ['./h/[hostId]/[...page].tsx', 7], - ['./h/[hostId]/accounts.tsx', 9], - ['./h/[hostId]/agent-history/[worktreeId].tsx', 14], + ['./h/[hostId]/accounts.tsx', 10], + ['./h/[hostId]/agent-history/[worktreeId].tsx', 15], ['./h/[hostId]/edit.tsx', 19], - ['./h/[hostId]/files/[worktreeId].tsx', 24], - ['./h/[hostId]/files/preview/[worktreeId].tsx', 32], + ['./h/[hostId]/files/[worktreeId].tsx', 23], + ['./h/[hostId]/files/preview/[worktreeId].tsx', 31], ['./h/[hostId]/history/[worktreeId].tsx', 34], - ['./h/[hostId]/index.tsx', 39], - ['./h/[hostId]/pr/[worktreeId].tsx', 40], - ['./h/[hostId]/review/[worktreeId].tsx', 48], - ['./h/[hostId]/session/[worktreeId].tsx', 58], - ['./h/[hostId]/source-control/[worktreeId].tsx', 63], - ['./h/[hostId]/tasks.tsx', 71], - ['./h/[hostId]/web.tsx', 72], - ['./h/_layout.tsx', 74] + ['./h/[hostId]/index.tsx', 41], + ['./h/[hostId]/pr/[worktreeId].tsx', 42], + ['./h/[hostId]/review/[worktreeId].tsx', 51], + ['./h/[hostId]/session/[worktreeId].tsx', 61], + ['./h/[hostId]/source-control/[worktreeId].tsx', 66], + ['./h/[hostId]/tasks.tsx', 77], + ['./h/[hostId]/web.tsx', 78], + ['./h/_layout.tsx', 81] ] const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts) @@ -191,7 +191,7 @@ export async function readMobileWebBundleMaxAssets() { * shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the * extra assets, so a route count that pushes the chunk envelope plus images plus the document past * it would pass this build and fail on the device with nothing to read. At today's 42 images that - * is 29 routes, inside what Phase C adds, which is why this is a build failure and not a comment. + * is 28 routes, inside what Phase C adds, which is why this is a build failure and not a comment. * The envelope grants the worst swept route to each one past the sweep, so re-measuring a tree * whose routes share more moves that crossing out again. */ diff --git a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts index 376b8dbebc2..a225a1a48bd 100644 --- a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts +++ b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts @@ -2,6 +2,8 @@ import { createElement } from 'react' import { act, create } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' +// The desktop's server-list feed loads react-native; its timer never runs without a subscriber. +vi.mock('react-native', () => ({})) vi.mock('expo-router', () => ({ useFocusEffect: () => {} })) vi.mock('../cache/worktree-cache', () => ({ getCachedWorktrees: () => null, diff --git a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts index ffd7e73f63a..a47f15d359d 100644 --- a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts +++ b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts @@ -2,6 +2,8 @@ import { createElement } from 'react' import { act, create } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' +// The desktop's server-list feed loads react-native; its timer never runs without a subscriber. +vi.mock('react-native', () => ({})) vi.mock('../cache/worktree-cache', () => ({ getCachedWorktrees: () => null, setCachedWorktrees: () => {} diff --git a/mobile/src/host-screen/host-worktree-catalog-action-error.test.ts b/mobile/src/host-screen/host-worktree-catalog-action-error.test.ts index 79edb5209e1..1cd6df0f5fc 100644 --- a/mobile/src/host-screen/host-worktree-catalog-action-error.test.ts +++ b/mobile/src/host-screen/host-worktree-catalog-action-error.test.ts @@ -2,6 +2,8 @@ import { createElement } from 'react' import { act, create } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' +// The desktop's server-list feed loads react-native; its timer never runs without a subscriber. +vi.mock('react-native', () => ({})) vi.mock('expo-router', () => ({ useFocusEffect: () => {} })) vi.mock('../cache/worktree-cache', () => ({ setCachedWorktrees: () => {} })) vi.mock('../storage/preferences', () => ({ savePinnedIds: async () => {} })) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx index 0432bb5d36c..25bc9a7210e 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx @@ -32,7 +32,8 @@ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ - useWorkspaceServer: () => ({ routeHost: undefined, server: null }) + useWorkspaceServer: () => ({ routeHost: undefined, server: null }), + useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState })) vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params })) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx index 818f798e189..9da93eedc30 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx @@ -34,7 +34,8 @@ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ - useWorkspaceServer: () => ({ routeHost: undefined, server: null }) + useWorkspaceServer: () => ({ routeHost: undefined, server: null }), + useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState })) vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params })) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx index 1ebaf6da334..b07899745ed 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx @@ -35,7 +35,8 @@ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ - useWorkspaceServer: () => ({ routeHost: undefined, server: null }) + useWorkspaceServer: () => ({ routeHost: undefined, server: null }), + useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState })) vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params, diff --git a/mobile/src/mobile-web-shell/shell-switch-build-kind.test.tsx b/mobile/src/mobile-web-shell/shell-switch-build-kind.test.tsx index 6d7762a3711..4fea8f3a771 100644 --- a/mobile/src/mobile-web-shell/shell-switch-build-kind.test.tsx +++ b/mobile/src/mobile-web-shell/shell-switch-build-kind.test.tsx @@ -42,7 +42,8 @@ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ - useWorkspaceServer: () => ({ routeHost: undefined, server: null }) + useWorkspaceServer: () => ({ routeHost: undefined, server: null }), + useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState })) vi.mock('expo-router', () => ({ Redirect: 'Redirect', diff --git a/mobile/src/navigation/workspace-route.test.tsx b/mobile/src/navigation/workspace-route.test.tsx index df6e98a67d3..6159ef33f9d 100644 --- a/mobile/src/navigation/workspace-route.test.tsx +++ b/mobile/src/navigation/workspace-route.test.tsx @@ -30,6 +30,7 @@ vi.mock('expo-router', () => ({ } })) vi.mock('react-native', () => ({ + AppState: { currentState: 'active' }, StyleSheet: { create: (styles: unknown) => styles }, View: 'View', Text: 'Text', @@ -46,7 +47,7 @@ vi.mock('../transport/host-descriptor-recorder', () => ({ vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) })) // The native switch always hands the page its route, so the census reads what the page is told. vi.mock('../mobile-web-shell/shell-switch-decision', () => ({ - useShellSwitchDecision: (shellRoute: unknown) => + shellSwitchDecision: (shellRoute: unknown) => shellRoute ? { kind: 'shell', route: shellRoute } : { kind: 'native' } })) vi.mock('../mobile-web-shell/ShellSwitchPendingScreen', () => ({ @@ -73,7 +74,10 @@ import { HostStatusGatesContext, useOptionalHostProtocolGates } from '../components/host-protocol-gates-context' +import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' +import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract' import type { HostStatusGates } from '../transport/host-status-gates' +import type { ConnectionState } from '../transport/types' import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import type { RpcClient } from '../transport/rpc-client' import { useWorkspaceClient } from '../transport/use-workspace-client' @@ -84,14 +88,21 @@ const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] const VAULT = 'aiVault.v1' /** What a screen under the route sees: its server, its client and its feature gates. */ -const below: { client: RpcClient | null; capabilities: readonly string[] | null } = { +const below: { + client: RpcClient | null + state: ConnectionState | null + capabilities: readonly string[] | null +} = { client: null, + state: null, capabilities: null } function Probe(): null { route.seen.push(useWorkspaceExecutionHost()) - below.client = useWorkspaceClient('host-1').client + const workspace = useWorkspaceClient('host-1') + below.client = workspace.client + below.state = workspace.state below.capabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? null return null } @@ -108,13 +119,25 @@ function desktopGates(hostCapabilities: string[], statusPending = false): HostSt } } +/** The server's health as the desktop reports it in `mobileRelay.hosts.list`. */ +const serverHealth: { current: ExecutionHostHealth } = { current: 'available' } + /** A desktop whose own status is settled as given; a status it is asked for is the server's. */ function desktop(serverCapabilities: string[] = []): FakeSession { const session = new FakeSession('connected') session.sendRequest.mockImplementation(async (method: string) => ({ id: 'reply', ok: true, - result: method === 'status.get' ? { capabilities: serverCapabilities } : {}, + result: + method === 'status.get' + ? { capabilities: serverCapabilities } + : method === MOBILE_RELAY_HOSTS_LIST_METHOD + ? { + hosts: [ + { hostId: 'runtime:vm', label: 'vm', health: serverHealth.current, relay: 'ready' } + ] + } + : {}, _meta: { runtimeId: 'runtime-1' } })) route.client = session @@ -133,7 +156,9 @@ async function render(element: ReactElement, gates: HostStatusGates): Promise { renderer = create(createElement(HostStatusGatesContext.Provider, { value: gates }, element)) @@ -204,6 +229,45 @@ describe('a server workspace', () => { }) }) + it('reads as disconnected while the desktop reports its server down, and recovers', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + try { + route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } + desktop() + await render(screen, desktopGates(RELAYS)) + expect(below.state).toBe('connected') + serverHealth.current = 'connecting' + await act(async () => { + vi.advanceTimersByTime(3000) + }) + expect(below.state).toBe('connecting') + serverHealth.current = 'error' + await act(async () => { + vi.advanceTimersByTime(3000) + }) + expect(below.state).toBe('disconnected') + serverHealth.current = 'available' + await act(async () => { + vi.advanceTimersByTime(3000) + }) + expect(below.state).toBe('connected') + } finally { + vi.useRealTimers() + } + }) + + it('opens disconnected, not loading, when its server is already down', async () => { + route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } + desktop() + serverHealth.current = 'disconnected' + await act(async () => { + create( + createElement(HostStatusGatesContext.Provider, { value: desktopGates(RELAYS) }, screen) + ) + }) + expect(below.state).toBe('disconnected') + }) + it('says so once the desktop has answered that it cannot reach the server', async () => { route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } const session = desktop() @@ -235,6 +299,10 @@ describe('the desktop’s own workspace', () => { ) expect([...new Set(route.seen)]).toEqual([undefined]) expect(below.client).toBe(session) + expect(below.state).toBe('connected') expect(below.capabilities).toEqual([...RELAYS, VAULT]) + expect(session.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + MOBILE_RELAY_HOSTS_LIST_METHOD + ) }) }) diff --git a/mobile/src/navigation/workspace-route.tsx b/mobile/src/navigation/workspace-route.tsx index b738334b6d1..2a8265923e6 100644 --- a/mobile/src/navigation/workspace-route.tsx +++ b/mobile/src/navigation/workspace-route.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { useMemo, type ReactNode } from 'react' import { useLocalSearchParams } from 'expo-router' import { normalizeExecutionHostId } from '../../../src/shared/execution-host' import { firstParam } from './route-param-reader' @@ -9,8 +9,8 @@ import { } from '../components/host-protocol-gates-context' import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen' import { useHostClient } from '../transport/client-context' -import { WorkspaceServerClientContext } from '../transport/use-workspace-client' -import { useWorkspaceServer } from '../transport/workspace-server' +import { WorkspaceServerContext } from '../transport/use-workspace-client' +import { useWorkspaceServer, useWorkspaceServerConnState } from '../transport/workspace-server' const NO_CAPABILITIES: readonly string[] = [] const UNREACHABLE_SERVER_MESSAGE = @@ -37,6 +37,13 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) { desktopCapabilities: desktopGates?.hostCapabilities ?? NO_CAPABILITIES, executionHost }) + const serverConnState = useWorkspaceServerConnState({ client, connState: state, routeHost }) + const serverClient = server ? server.client : undefined + const serverContext = useMemo( + () => + serverClient === undefined ? undefined : { client: serverClient, state: serverConnState }, + [serverClient, serverConnState] + ) // Why: once the desktop has answered, a server it can't reach for this phone is a dead end, not a spinner. if (state === 'connected' && desktopGates && !desktopGates.statusPending && routeHost === null) { return @@ -46,12 +53,12 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) { {children} ) - return server ? ( - + return server && serverContext ? ( + {screens} - + ) : ( screens ) diff --git a/mobile/src/transport/use-workspace-client.ts b/mobile/src/transport/use-workspace-client.ts index 61aa443a2ea..198982c18ab 100644 --- a/mobile/src/transport/use-workspace-client.ts +++ b/mobile/src/transport/use-workspace-client.ts @@ -1,13 +1,16 @@ import { createContext, useContext } from 'react' import { useHostClient } from './client-context' import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' -// Provided by `WorkspaceRoute` for a server's workspace (null until reachable); absent otherwise. -export const WorkspaceServerClientContext = createContext(undefined) +// Provided by `WorkspaceRoute` for a server's workspace (client null until reachable); absent otherwise. +export const WorkspaceServerContext = createContext< + { client: RpcClient | null; state: ConnectionState } | undefined +>(undefined) /** `useHostClient` for a workspace screen: its calls run where the workspace does. */ export function useWorkspaceClient(hostId: string | undefined): ReturnType { const host = useHostClient(hostId) - const serverClient = useContext(WorkspaceServerClientContext) - return serverClient === undefined ? host : { ...host, client: serverClient } + const server = useContext(WorkspaceServerContext) + return server === undefined ? host : { ...host, ...server } } diff --git a/mobile/src/transport/workspace-server.ts b/mobile/src/transport/workspace-server.ts index 2b0d6fbb8fe..4df40813200 100644 --- a/mobile/src/transport/workspace-server.ts +++ b/mobile/src/transport/workspace-server.ts @@ -1,4 +1,7 @@ +import { useCallback, useSyncExternalStore } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { relayHostsFeed } from '../worktree/relay-hosts-feed' +import { serverHostHealth } from '../worktree/server-workspaces' import { scopeRpcClientToExecutionHost, workspaceRouteExecutionHost @@ -28,3 +31,41 @@ export function useWorkspaceServer(args: { const gates = useHostStatusGates({ hostId: undefined, client: serverClient, connState }) return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } } } + +const noSubscription = (): (() => void) => () => {} + +/** + * The connection state a server workspace's screens see: the desktop's, unless the desktop reports + * that server down. Desktop-owned workspaces (`routeHost` absent) keep the desktop's state untouched. + */ +export function useWorkspaceServerConnState(args: { + client: RpcClient | null + connState: ConnectionState + routeHost: ExecutionHostId | undefined | null +}): ConnectionState { + const { client, connState, routeHost } = args + // The same feed the host list reads, so a row's badge and its screens agree. + const feed = client && routeHost ? relayHostsFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readHosts = () => feed?.getSnapshot() ?? null + const hosts = useSyncExternalStore(subscribe, readHosts, readHosts) + if (connState !== 'connected' || !routeHost || !hosts) { + return connState + } + const host = hosts.find((entry) => entry.hostId === routeHost) + // A server the desktop no longer lists cannot be reached through it. + switch (host ? serverHostHealth(host) : 'disconnected') { + case 'local': + case 'available': + return 'connected' + case 'connecting': + return 'connecting' + case 'blocked': + case 'disconnected': + case 'error': + return 'disconnected' + } +} diff --git a/mobile/src/worktree/relay-hosts-feed.test.ts b/mobile/src/worktree/relay-hosts-feed.test.ts new file mode 100644 index 00000000000..ee5f00d6a96 --- /dev/null +++ b/mobile/src/worktree/relay-hosts-feed.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) + +import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import { relayHostsFeed } from './relay-hosts-feed' + +function desktop(health: () => string): FakeSession { + const session = new FakeSession('connected') + session.sendRequest.mockImplementation(async () => ({ + id: 'reply', + ok: true, + result: { hosts: [{ hostId: 'runtime:vm', label: 'vm', health: health(), relay: 'ready' }] }, + _meta: { runtimeId: 'runtime-1' } + })) + return session +} + +function listCalls(session: FakeSession): number { + return session.sendRequest.mock.calls.filter( + ([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD + ).length +} + +describe('the desktop’s server list', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('is read once per poll however many readers share it', async () => { + const session = desktop(() => 'available') + const feed = relayHostsFeed(session) + // The host list reads on its own poll; an open workspace subscribes a second later. + await feed.read() + await vi.advanceTimersByTimeAsync(1000) + const unsubscribe = feed.subscribe(() => {}) + const unsubscribeAgain = feed.subscribe(() => {}) + expect(listCalls(session)).toBe(1) + // Its timed read waits out the host list's, then both readers share each one. + await vi.advanceTimersByTimeAsync(1999) + expect(listCalls(session)).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(listCalls(session)).toBe(2) + await vi.advanceTimersByTimeAsync(3000) + expect(listCalls(session)).toBe(3) + unsubscribe() + unsubscribeAgain() + await vi.advanceTimersByTimeAsync(9000) + expect(listCalls(session)).toBe(3) + }) + + it('joins a read already in flight', async () => { + const session = desktop(() => 'available') + const feed = relayHostsFeed(session) + const [first, second] = await Promise.all([feed.read(), feed.read()]) + expect(second).toBe(first) + expect(listCalls(session)).toBe(1) + }) + + it('forgets its answer on a drop, and reads again on reconnect', async () => { + let health = 'available' + const session = desktop(() => health) + const feed = relayHostsFeed(session) + const unsubscribe = feed.subscribe(() => {}) + await vi.advanceTimersByTimeAsync(0) + expect(feed.getSnapshot()?.[0]?.health).toBe('available') + session.publishState('reconnecting') + expect(feed.getSnapshot()).toBeNull() + await vi.advanceTimersByTimeAsync(9000) + expect(listCalls(session)).toBe(1) + health = 'error' + session.publishState('connected') + await vi.advanceTimersByTimeAsync(0) + expect(feed.getSnapshot()?.[0]?.health).toBe('error') + unsubscribe() + }) +}) diff --git a/mobile/src/worktree/relay-hosts-feed.ts b/mobile/src/worktree/relay-hosts-feed.ts new file mode 100644 index 00000000000..adca7ae3f1a --- /dev/null +++ b/mobile/src/worktree/relay-hosts-feed.ts @@ -0,0 +1,129 @@ +// The paired desktop's servers (`mobileRelay.hosts.list`), one feed per desktop client: the host +// list and every open server workspace share one poll and one answer, so a row's badge and its +// workspace screen never disagree and two readers never double the desktop's load. + +import { AppState } from 'react-native' +import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract' +import type { RpcClient } from '../transport/rpc-client' +import { relayHostsListRead } from './server-workspace-operations' + +// The host list's own poll cadence; a read by any reader pushes the next timed one back. +const RELAY_HOSTS_POLL_MS = 3000 + +export type RelayHostsFeed = { + subscribe: (listener: () => void) => () => void + /** The last answer on this connection; null before one lands. */ + getSnapshot: () => readonly MobileRelayHost[] | null + /** Reads now, or joins the read in flight; null when the desktop did not answer. */ + read: () => Promise +} + +const feeds = new WeakMap() + +function createFeed(client: RpcClient): RelayHostsFeed { + let snapshot: readonly MobileRelayHost[] | null = null + let inFlight: Promise | null = null + let lastReadAt = Number.NEGATIVE_INFINITY + // Bumped on every drop, so a read from an earlier connection never lands. + let generation = 0 + let timer: ReturnType | null = null + const listeners = new Set<() => void>() + + const publish = (next: readonly MobileRelayHost[] | null): void => { + // Why: an unchanged poll keeps its identity, so readers do not re-render every tick. + if (JSON.stringify(next) === JSON.stringify(snapshot)) { + return + } + snapshot = next + for (const listener of listeners) { + listener() + } + } + const stopTimer = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + // Timed reads run only while someone subscribes and the desktop is connected. + const schedule = (): void => { + stopTimer() + if (listeners.size === 0 || client.getState() !== 'connected') { + return + } + const delay = lastReadAt + RELAY_HOSTS_POLL_MS - Date.now() + if (delay <= 0) { + tick() + return + } + timer = setTimeout(tick, delay) + } + const tick = (): void => { + timer = null + if (AppState.currentState === 'active') { + void read() + return + } + timer = setTimeout(tick, RELAY_HOSTS_POLL_MS) + } + const fetchHosts = async (readGeneration: number): Promise => { + try { + const listed = relayHostsListRead.interpret(await relayHostsListRead.request(client)) + // A reply from before a drop is not this connection's answer. + if (!listed.accepted || readGeneration !== generation) { + return null + } + publish(listed.value) + return listed.value + } catch { + return null + } + } + const read = (): Promise => { + if (inFlight) { + return inFlight + } + lastReadAt = Date.now() + const readGeneration = generation + inFlight = fetchHosts(readGeneration).finally(() => { + if (readGeneration === generation) { + inFlight = null + } + }) + schedule() + return inFlight + } + + client.onStateChange((state) => { + if (state !== 'connected') { + generation += 1 + inFlight = null + lastReadAt = Number.NEGATIVE_INFINITY + publish(null) + } + schedule() + }) + return { + subscribe: (listener) => { + listeners.add(listener) + schedule() + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + stopTimer() + } + } + }, + getSnapshot: () => snapshot, + read + } +} + +export function relayHostsFeed(client: RpcClient): RelayHostsFeed { + let feed = feeds.get(client) + if (!feed) { + feed = createFeed(client) + feeds.set(client, feed) + } + return feed +} diff --git a/mobile/src/worktree/server-workspaces.ts b/mobile/src/worktree/server-workspaces.ts index b1eea4d0816..7ad1ffda130 100644 --- a/mobile/src/worktree/server-workspaces.ts +++ b/mobile/src/worktree/server-workspaces.ts @@ -3,7 +3,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract' import type { RpcOperationSender } from '../transport/rpc-operation-sender' -import { relayHostsListRead, relayHostWorktreesRead } from './server-workspace-operations' +import type { RpcClient } from '../transport/rpc-client' +import { relayHostsFeed } from './relay-hosts-feed' +import { relayHostWorktreesRead } from './server-workspace-operations' import type { Worktree } from './workspace-list-types' import { areWorktreeListsEqual } from './worktree-list-snapshot' @@ -60,13 +62,12 @@ export type FetchedServerWorkspaces = { /** The servers' rows through the desktop's one listing path; null when it did not answer. */ export async function fetchServerWorkspaces( - client: RpcOperationSender + client: RpcClient ): Promise { - const listed = relayHostsListRead.interpret(await relayHostsListRead.request(client)) - if (!listed.accepted) { + const hosts = await relayHostsFeed(client).read() + if (!hosts) { return null } - const hosts = listed.value return { hosts, rows: await Promise.all(hosts.map((host) => fetchHostRows(client, host.hostId))) } } diff --git a/tests/e2e/paired-mobile-desktop-mirror.spec.ts b/tests/e2e/paired-mobile-desktop-mirror.spec.ts index 746a6218ca4..29165752e9a 100644 --- a/tests/e2e/paired-mobile-desktop-mirror.spec.ts +++ b/tests/e2e/paired-mobile-desktop-mirror.spec.ts @@ -21,15 +21,14 @@ const WorktreeListSchema = z.object({ const CreatedTerminalSchema = z.object({ tab: z.looseObject({ terminal: z.string().nullable() }) }) const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId: z.number() }) const HostsSchema = z.object({ - hosts: z.array(z.looseObject({ hostId: z.string(), relay: z.string() })) + hosts: z.array(z.looseObject({ hostId: z.string(), health: z.string(), relay: z.string() })) }) const DirectorySchema = z.array(z.looseObject({ name: z.string() })) const GitStatusSchema = z.looseObject({ entries: z.array(z.looseObject({ path: z.string() })) }) const HostWorktreesSchema = z.looseObject({ worktrees: z.array( z.looseObject({ worktreeId: z.string(), path: z.string(), hostId: z.string() }) - ), - stale: z.boolean() + ) }) let pollId = 0 @@ -42,6 +41,18 @@ async function hostWorktrees(socket: PairedMobileSocket, hostId: string) { return listed.success ? listed.data : null } +let hostsId = 0 +/** The server as the desktop reports it to the phone: its health and whether it relays. */ +async function listedHost(socket: PairedMobileSocket, hostId: string) { + hostsId += 1 + const { hosts } = await call( + socket, + { id: `hosts-${hostsId}`, method: 'mobileRelay.hosts.list', params: {} }, + HostsSchema + ) + return hosts.find((entry) => entry.hostId === hostId) +} + async function reply(socket: PairedMobileSocket, id: string, timeout = 30_000): Promise { let found: Frame | undefined await expect @@ -196,7 +207,7 @@ test('phone paired with a desktop lists, opens and types into a workspace on its await expect .poll(async () => { listed = await hostWorktrees(socket, serverHostId) - return listed?.stale === false && listed.worktrees.some((row) => row.path === serverRepo) + return listed?.worktrees.some((row) => row.path === serverRepo) ?? false }) .toBe(true) const serverRow = listed!.worktrees.find((row) => row.path === serverFolder) @@ -258,18 +269,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its await host.restartServeProcess({ betweenProcesses: async () => { - // The server is down: its workspace stays listed, marked stale, never synthesized away. + // The server is down: the desktop says so, and its workspace stays listed from its last rows. await expect - .poll( - async () => { - const stale = await hostWorktrees(socket, serverHostId) - return ( - stale?.stale === true && stale.worktrees.some((row) => row.path === serverFolder) - ) - }, - { timeout: 60_000, intervals: [1_000] } - ) - .toBe(true) + .poll(async () => (await listedHost(socket, serverHostId))?.relay, { + timeout: 60_000, + intervals: [1_000] + }) + .toBe('unavailable') + const lastRows = await hostWorktrees(socket, serverHostId) + expect(lastRows?.worktrees.map((row) => row.path)).toContain(serverFolder) // A relayed call fails as unavailable, and never runs on the desktop instead. socket.send('ps-stopped', 'worktree.ps', { limit: 1_000 }, serverHostId) const stopped = await reply(socket, 'ps-stopped', 60_000) @@ -286,13 +294,13 @@ test('phone paired with a desktop lists, opens and types into a workspace on its } } }) - // Back up: the listing is fresh again, and the same phone socket reaches the server. + // Back up: the desktop reports it available again, and the same phone socket reaches it. await expect - .poll(async () => (await hostWorktrees(socket, serverHostId))?.stale, { + .poll(async () => (await listedHost(socket, serverHostId))?.health, { timeout: 90_000, intervals: [2_000] }) - .toBe(false) + .toBe('available') const reopened = await call( socket, { id: 'ps-restarted', ...list, executionHost: serverHostId },