From d6fc1c34ded7fb80d5f7d95213bf4876f75e1b3e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 11 Oct 2026 05:02:48 -0400 Subject: [PATCH 1/4] fix(mobile): show a server workspace as disconnected while its server is down The phone read the desktop's connection state for a server workspace, so a stopped server kept a green dot and "Loading tabs" spun forever. The workspace route now narrows that state by the server's health from mobileRelay.hosts.list, reusing the existing disconnected session UI and refetching tabs when the server returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...ile-web-shell-agent-history-route.test.tsx | 3 +- .../mobile-web-shell-files-route.test.tsx | 3 +- .../mobile-web-shell-session-route.test.tsx | 3 +- .../shell-switch-build-kind.test.tsx | 3 +- .../src/navigation/workspace-route.test.tsx | 76 ++++++++++++++++++- mobile/src/navigation/workspace-route.tsx | 19 +++-- mobile/src/transport/use-workspace-client.ts | 11 ++- mobile/src/transport/workspace-server.ts | 74 ++++++++++++++++++ 8 files changed, 174 insertions(+), 18 deletions(-) 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..13156e5685c 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: ['setInterval', 'clearInterval'] }) + 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..32b52274331 100644 --- a/mobile/src/transport/workspace-server.ts +++ b/mobile/src/transport/workspace-server.ts @@ -1,4 +1,9 @@ +import { useEffect, useState } from 'react' +import { AppState } from 'react-native' import type { ExecutionHostId } from '../../../src/shared/execution-host' +import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' +import { serverHostHealth } from '../worktree/server-workspaces' +import { relayHostsListRead } from '../worktree/server-workspace-operations' import { scopeRpcClientToExecutionHost, workspaceRouteExecutionHost @@ -28,3 +33,72 @@ export function useWorkspaceServer(args: { const gates = useHostStatusGates({ hostId: undefined, client: serverClient, connState }) return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } } } + +// The host list's poll cadence, so a workspace screen and its row agree within one tick. +const SERVER_HEALTH_POLL_MS = 3000 + +/** + * 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 + const [reported, setReported] = useState<{ + client: RpcClient + routeHost: ExecutionHostId + health: ExecutionHostHealth + } | null>(null) + + useEffect(() => { + if (!client || connState !== 'connected' || !routeHost) { + return + } + let stopped = false + const read = async (): Promise => { + if (AppState.currentState !== 'active') { + return + } + try { + const listed = relayHostsListRead.interpret(await relayHostsListRead.request(client)) + if (stopped || !listed.accepted) { + return + } + const host = listed.value.find((entry) => entry.hostId === routeHost) + // A server the desktop no longer lists cannot be reached through it. + setReported({ client, routeHost, health: host ? serverHostHealth(host) : 'disconnected' }) + } catch { + // Keeps the last answer; the next poll retries. + } + } + void read() + const timer = setInterval(() => void read(), SERVER_HEALTH_POLL_MS) + return () => { + stopped = true + clearInterval(timer) + } + }, [client, connState, routeHost]) + + // Why: keyed by client and server so a reused route never shows another server's answer. + if ( + connState !== 'connected' || + reported?.client !== client || + reported.routeHost !== routeHost + ) { + return connState + } + switch (reported.health) { + case 'local': + case 'available': + return 'connected' + case 'connecting': + return 'connecting' + case 'blocked': + case 'disconnected': + case 'error': + return 'disconnected' + } +} From e5cd05ffe695c2040ba3d458add421b723bf209a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 11 Oct 2026 05:09:55 -0400 Subject: [PATCH 2/4] refactor(mobile): share one desktop server-list feed between the host list and workspaces A server workspace polled mobileRelay.hosts.list beside the host list's own read, so the phone could ask the desktop twice per tick and the row badge and the screen could disagree. One feed per desktop client now owns that read; both readers join it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../host-show-pinned-in-groups.test.ts | 2 + .../host-ssh-health-badge-wiring.test.ts | 2 + ...host-worktree-catalog-action-error.test.ts | 2 + .../src/navigation/workspace-route.test.tsx | 2 +- mobile/src/transport/workspace-server.ts | 63 ++------- mobile/src/worktree/relay-hosts-feed.test.ts | 81 +++++++++++ mobile/src/worktree/relay-hosts-feed.ts | 129 ++++++++++++++++++ mobile/src/worktree/server-workspaces.ts | 11 +- 8 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 mobile/src/worktree/relay-hosts-feed.test.ts create mode 100644 mobile/src/worktree/relay-hosts-feed.ts 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/navigation/workspace-route.test.tsx b/mobile/src/navigation/workspace-route.test.tsx index 13156e5685c..6159ef33f9d 100644 --- a/mobile/src/navigation/workspace-route.test.tsx +++ b/mobile/src/navigation/workspace-route.test.tsx @@ -230,7 +230,7 @@ describe('a server workspace', () => { }) it('reads as disconnected while the desktop reports its server down, and recovers', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) try { route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } desktop() diff --git a/mobile/src/transport/workspace-server.ts b/mobile/src/transport/workspace-server.ts index 32b52274331..4df40813200 100644 --- a/mobile/src/transport/workspace-server.ts +++ b/mobile/src/transport/workspace-server.ts @@ -1,9 +1,7 @@ -import { useEffect, useState } from 'react' -import { AppState } from 'react-native' +import { useCallback, useSyncExternalStore } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' -import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' +import { relayHostsFeed } from '../worktree/relay-hosts-feed' import { serverHostHealth } from '../worktree/server-workspaces' -import { relayHostsListRead } from '../worktree/server-workspace-operations' import { scopeRpcClientToExecutionHost, workspaceRouteExecutionHost @@ -34,8 +32,7 @@ export function useWorkspaceServer(args: { return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } } } -// The host list's poll cadence, so a workspace screen and its row agree within one tick. -const SERVER_HEALTH_POLL_MS = 3000 +const noSubscription = (): (() => void) => () => {} /** * The connection state a server workspace's screens see: the desktop's, unless the desktop reports @@ -47,50 +44,20 @@ export function useWorkspaceServerConnState(args: { routeHost: ExecutionHostId | undefined | null }): ConnectionState { const { client, connState, routeHost } = args - const [reported, setReported] = useState<{ - client: RpcClient - routeHost: ExecutionHostId - health: ExecutionHostHealth - } | null>(null) - - useEffect(() => { - if (!client || connState !== 'connected' || !routeHost) { - return - } - let stopped = false - const read = async (): Promise => { - if (AppState.currentState !== 'active') { - return - } - try { - const listed = relayHostsListRead.interpret(await relayHostsListRead.request(client)) - if (stopped || !listed.accepted) { - return - } - const host = listed.value.find((entry) => entry.hostId === routeHost) - // A server the desktop no longer lists cannot be reached through it. - setReported({ client, routeHost, health: host ? serverHostHealth(host) : 'disconnected' }) - } catch { - // Keeps the last answer; the next poll retries. - } - } - void read() - const timer = setInterval(() => void read(), SERVER_HEALTH_POLL_MS) - return () => { - stopped = true - clearInterval(timer) - } - }, [client, connState, routeHost]) - - // Why: keyed by client and server so a reused route never shows another server's answer. - if ( - connState !== 'connected' || - reported?.client !== client || - reported.routeHost !== routeHost - ) { + // 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 } - switch (reported.health) { + 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' 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))) } } From 96863da6f466c75d907e48a0516d2805e533841f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 11 Oct 2026 05:18:20 -0400 Subject: [PATCH 3/4] test(e2e): read a server's reachability from the hosts list, not a dropped stale flag eff410fe303 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's schema never parsed and its first poll timed out. It now waits on the server's relay/health from mobileRelay.hosts.list, the field the phone itself reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../e2e/paired-mobile-desktop-mirror.spec.ts | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) 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 }, From ce71823be31e0716f436c2c0e688d025ae92a073 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 11 Oct 2026 05:18:20 -0400 Subject: [PATCH 4/4] build(mobile): re-measure the page's script sweep for server workspaces Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace route's shared modules split importer sets three ways more, reaching 81. Re-swept per route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../build-mobile-web-app-bundle.test.mjs | 12 ++++---- .../scripts/verify-mobile-web-app-bundle.mjs | 30 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) 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. */