diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index 475f365136..5601e16f35 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -20,7 +20,7 @@ Two concepts, deliberately independent: |---|---|---| | What it is | A live control link to a peer | The one device this window draws | | How many | Any number, concurrently | Exactly one | -| Ends when | Explicit disconnect, peer offline, logout | Replaced by the next switch | +| Ends when | Explicit disconnect or logout | Replaced by the next switch | | Effect on the peer's agent | Keeps it running and fanning out | None | This split is what makes several devices usable at once: dispatch a turn on B, @@ -69,10 +69,23 @@ requests coalesce to the last target, a committed-but-superseded hydrate is invalidated before the next target proceeds, and a real activation failure rolls back to the previously rendered reachable surface. Separately, `PeerConnectionManager` owns each attachment's -`connecting`/`ready`/`degraded`/`lost` lifecycle, keepalive and bounded backoff; +`connecting`/`ready`/`degraded` lifecycle, keepalive and capped backoff; React only subscribes to snapshots. Attachment disposal is the only operation that discards a peer's cached surface state. +Presence gaps and product RPC transport failures move an established attachment +into `degraded`; they never select the local surface. Only a dedicated +`peer_mode_ping` plus recovery `peer_control_attach` handshake changes it back +to `ready`. Product timeouts do not count as independent failed health checks. +Recovery uses one in-flight handshake per device, retries with exponential +backoff capped at 15 seconds, and continues until explicit disconnect/logout. +A device returning to account presence accelerates a pending retry without +claiming the control link is already restored. Cached capabilities, the surface +epoch, requests' target device and session projections stay with that peer; +recovery neither reboots the surface nor resubmits a Turn. The window displays +a persistent reconnecting notice with a manual return-to-local action while its +selected peer is degraded. Background peers recover without switching the view. + Because the local surface can now miss its own events while another device is rendered, Session attachment is no longer Peer-only. After this window's first surface switch, `isSurfaceReconcileEnabled()` attaches whichever surface is diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index f7c72cd21d..6118a6d4f2 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -140,6 +140,7 @@ Run the smallest checks matching the changed path: ```bash cargo check -p openbitfun-cli cargo test -p openbitfun-cli +cargo test -p openbitfun-cli --bin openbitfun system_info_home_contract ``` For streaming `exec` retry, context recovery, and final-event contracts: diff --git a/src/apps/cli/src/peer_host/commands/system.rs b/src/apps/cli/src/peer_host/commands/system.rs index 5d4c7e91a5..3efa00a59d 100644 --- a/src/apps/cli/src/peer_host/commands/system.rs +++ b/src/apps/cli/src/peer_host/commands/system.rs @@ -12,6 +12,7 @@ pub(crate) async fn get_system_info() -> Result { "platform": info.platform, "arch": info.arch, "osVersion": info.os_version, + "homeDir": info.home_dir, })) } @@ -32,3 +33,15 @@ pub(crate) async fn get_token_usage_statistics( .map_err(|error| error.to_string())?; serde_json::to_value(statistics).map_err(|error| error.to_string()) } + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn system_info_home_contract_reports_serving_host_in_camel_case() { + let response = super::get_system_info().await.unwrap(); + let info = openbitfun_core::service::system::get_system_info(); + assert_eq!(response["homeDir"], serde_json::json!(info.home_dir)); + assert!(response.get("home_dir").is_none()); + assert_eq!(response["platform"], info.platform); + } +} diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 6d1f3e66bd..554b95c683 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -100,6 +100,8 @@ cargo check -p openbitfun-desktop && cargo test -p openbitfun-desktop For staged application-update cache and signature behavior, use `cargo test -p openbitfun-desktop --lib api::update_api::tests`. +For peer system-info response compatibility, run +`cargo test -p openbitfun-desktop --lib system_info_home_contract`. After changing updater command registration, also run `cargo test -p openbitfun-desktop --lib remote_workspace_policy`. diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index a4153b14a8..fe4511a6b6 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -238,6 +238,8 @@ pub struct SystemInfoResponse { pub platform: String, pub arch: String, pub os_version: Option, + #[serde(default)] + pub home_dir: Option, } #[tauri::command] @@ -248,6 +250,7 @@ pub async fn get_system_info() -> Result { platform: info.platform, arch: info.arch, os_version: info.os_version, + home_dir: info.home_dir, }) } @@ -1031,6 +1034,25 @@ fn activate_main_window_from_notification(app: &tauri::AppHandle) { #[cfg(test)] mod tests { + #[tokio::test] + async fn system_info_home_contract_accepts_legacy_and_reports_serving_host() { + let legacy = + serde_json::json!({"platform": "windows", "arch": "x86_64", "osVersion": null}); + let old: super::SystemInfoResponse = serde_json::from_value(legacy).unwrap(); + assert!(old.home_dir.is_none()); + let round_trip: super::SystemInfoResponse = + serde_json::from_value(serde_json::to_value(old).unwrap()).unwrap(); + assert_eq!(round_trip.platform, "windows"); + assert!(round_trip.home_dir.is_none()); + + let response = serde_json::to_value(super::get_system_info().await.unwrap()).unwrap(); + assert_eq!( + response["homeDir"], + serde_json::json!(super::system::get_system_info().home_dir) + ); + assert!(response.get("home_dir").is_none()); + } + #[test] fn startup_window_control_contract_exposes_the_native_maximize_state() { let request: super::StartupWindowControlRequest = diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 919d369a94..7662fc2da9 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -83,6 +83,7 @@ target. Representative stable entry points are: ```bash cargo check -p openbitfun-services-core --no-default-features +cargo test -p openbitfun-services-core --no-default-features --features process-runtime --lib system::info::tests cargo test -p openbitfun-services-core --no-default-features --features credential-vault --lib credential_vault::tests:: cargo check -p openbitfun-services-core --no-default-features --features filesystem cargo test -p openbitfun-services-core --no-default-features --features diagnostics --lib diagnostics::contract_tests:: diff --git a/src/crates/services/services-core/src/system/info.rs b/src/crates/services/services-core/src/system/info.rs index 1692d17269..ba256286c3 100644 --- a/src/crates/services/services-core/src/system/info.rs +++ b/src/crates/services/services-core/src/system/info.rs @@ -11,6 +11,9 @@ pub struct SystemInfo { pub arch: String, /// OS version pub os_version: Option, + /// User home on the host serving this request, never on its controller. + #[serde(default)] + pub home_dir: Option, } /// Gets system info. @@ -42,5 +45,31 @@ pub fn get_system_info() -> SystemInfo { platform: platform.to_string(), arch: arch.to_string(), os_version: None, + home_dir: std::env::home_dir().and_then(|path| path.into_os_string().into_string().ok()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn older_system_info_without_home_remains_readable() { + let legacy = + serde_json::json!({"platform": "windows", "arch": "x86_64", "os_version": null}); + let info: SystemInfo = serde_json::from_value(legacy.clone()).unwrap(); + assert!(info.home_dir.is_none()); + let round_trip: SystemInfo = + serde_json::from_value(serde_json::to_value(info).unwrap()).unwrap(); + assert_eq!(round_trip.platform, legacy["platform"]); + assert!(round_trip.home_dir.is_none()); + } + + #[test] + fn reports_the_serving_hosts_home_directory() { + assert_eq!( + get_system_info().home_dir, + std::env::home_dir().and_then(|path| path.into_os_string().into_string().ok()) + ); } } diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index cf8a4c2e79..7fcbaf462d 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -20,7 +20,6 @@ import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneStore } from '../../../stores/sceneStore'; import { activateProductAction } from '@/app/global-search/productActionActivator'; import { useToolbarModeContext } from '@/flow_chat/components/toolbar-mode/ToolbarModeContext'; -import { useNotification } from '@/shared/notification-system'; import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import NotificationButton from '../../TitleBar/NotificationButton'; import { RemoteConnectDisclaimerContent } from '../../RemoteConnectDialog/RemoteConnectDisclaimer'; @@ -43,22 +42,6 @@ const PersistentFooterActions: React.FC = () => { const { t } = useI18n('common'); const activeTabId = useSceneStore((s) => s.activeTabId); const { enableToolbarMode } = useToolbarModeContext(); - const { warning } = useNotification(); - - useEffect(() => { - const onAutoExit = (event: Event) => { - const detail = (event as CustomEvent<{ deviceName?: string; reason?: string }>).detail; - const name = detail?.deviceName || 'peer'; - if (detail?.reason === 'peer_offline') { - warning(t('accountLogin.peerAutoExitOffline', { name })); - } else if (detail?.reason === 'rpc_failures') { - warning(t('accountLogin.peerAutoExitRpc', { name })); - } - }; - window.addEventListener('peer-mode:auto-exit', onAutoExit); - return () => window.removeEventListener('peer-mode:auto-exit', onAutoExit); - }, [t, warning]); - const [menuOpen, setMenuOpen] = useState(false); const [menuClosing, setMenuClosing] = useState(false); const [appearanceSubmenuOpen, setAppearanceSubmenuOpen] = useState(false); diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 79b0d9fae8..75bfea4a0a 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -40,6 +40,7 @@ import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { notificationService } from '@/shared/notification-system'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { AppearanceBackgroundMediaLayer, appearanceRuntime, useAppearance } from '@/infrastructure/appearance'; +import { PeerConnectionStatus } from '@/infrastructure/peer-device/PeerConnectionStatus'; import './AppLayout.scss'; type TransitionDirection = 'entering' | 'returning' | null; @@ -710,6 +711,7 @@ const AppLayout: React.FC = ({ className = '' }) => { + ); @@ -756,6 +758,7 @@ const AppLayout: React.FC = ({ className = '' }) => { isExiting={transitionDir === 'returning'} /> + {/* Hello stays available across every client scene, including Welcome. */} diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index fa144e62d2..28c15fecbf 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -32,9 +32,17 @@ export interface ToggleMainWindowFullscreenResponse { /** Close-button behavior values (matches `app.close_button_behavior` config key). */ export type CloseBehavior = 'quit' | 'minimize_to_tray' | 'ask'; +export interface SystemInfo { + platform: string; + arch: string; + osVersion?: string | null; + /** Absent on older peers. Always belongs to the host serving the request. */ + homeDir?: string | null; +} + export class SystemAPI { - async getSystemInfo(): Promise { + async getSystemInfo(): Promise { try { return await api.invoke('get_system_info', { request: {} diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts index 57dd594d8b..792272e66c 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts @@ -210,7 +210,7 @@ describe('PeerConnectionManager health', () => { it('reconnects with exponential backoff while degraded', async () => { const rpc = createRpc(); - const manager = createManager(rpc.deviceRpc, { maxKeepaliveFailures: 4 }); + const manager = createManager(rpc.deviceRpc); await manager.connect('peer-1', 'Studio'); rpc.failNext(2); @@ -238,7 +238,7 @@ describe('PeerConnectionManager health', () => { it('caps the reconnect delay', async () => { const rpc = createRpc(); - const manager = createManager(rpc.deviceRpc, { maxKeepaliveFailures: 6 }); + const manager = createManager(rpc.deviceRpc); await manager.connect('peer-1', 'Studio'); rpc.failNext(5); @@ -253,43 +253,50 @@ describe('PeerConnectionManager health', () => { expect(manager.get('peer-1')?.getState().consecutiveFailures).toBe(5); }); - it('loses a peer after repeated failures and stops retrying', async () => { + it('retains the attachment through a long outage and resumes capped retries', async () => { const rpc = createRpc(); const manager = createManager(rpc.deviceRpc); - await manager.connect('peer-1', 'Studio'); + const connection = await manager.connect('peer-1', 'Studio'); rpc.failAll(); - await vi.advanceTimersByTimeAsync(KEEPALIVE_MS + RECONNECT_BASE_MS + RECONNECT_BASE_MS * 2); + await vi.advanceTimersByTimeAsync(KEEPALIVE_MS + 120_000); - expect(manager.get('peer-1')?.getState()).toMatchObject({ - health: 'lost', - lostReason: 'keepalive', - consecutiveFailures: 3, - }); + expect(manager.get('peer-1')).toBe(connection); + expect(connection.getState().health).toBe('degraded'); + expect(connection.getState().consecutiveFailures).toBeGreaterThan(3); + expect(connection.adapter.isDisposed()).toBe(false); + expect(rpc.commands()).not.toContain('peer_control_detach'); - const afterLoss = rpc.commands().length; - await vi.advanceTimersByTimeAsync(KEEPALIVE_MS * 5); - expect(rpc.commands().length).toBe(afterLoss); + rpc.failNext(0); + await vi.advanceTimersByTimeAsync(RECONNECT_MAX_MS); + expect(manager.get('peer-1')).toBe(connection); + expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 }); + expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2); + await manager.disposeAll(); }); - it('loses a peer that dropped out of account presence', async () => { + it('recovers a 2.3 second presence gap on the same attachment', async () => { const rpc = createRpc(); const manager = createManager(rpc.deviceRpc); - await manager.connect('peer-1', 'Studio'); + const connection = await manager.connect('peer-1', 'Studio'); + rpc.failAll(); manager.reportPresence(['peer-2']); + expect(connection.getState()).toMatchObject({ health: 'degraded', consecutiveFailures: 0 }); + await vi.advanceTimersByTimeAsync(2_300); - expect(manager.get('peer-1')?.getState()).toMatchObject({ - health: 'lost', - lostReason: 'presence', - }); + rpc.failNext(0); + manager.reportPresence(['peer-1', 'peer-2']); + await vi.advanceTimersByTimeAsync(0); - const afterLoss = rpc.commands().length; - await vi.advanceTimersByTimeAsync(KEEPALIVE_MS * 3); - expect(rpc.commands().length).toBe(afterLoss); + expect(manager.get('peer-1')).toBe(connection); + expect(connection.adapter.isDisposed()).toBe(false); + expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 }); + expect(rpc.commands()).not.toContain('peer_control_detach'); + expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2); }); - it('treats successful product traffic as proof the link is alive', async () => { + it('waits for a recovery handshake even when a product request succeeds', async () => { const rpc = createRpc(); const manager = createManager(rpc.deviceRpc); const connection = await manager.connect('peer-1', 'Studio'); @@ -300,27 +307,51 @@ describe('PeerConnectionManager health', () => { await connection.adapter.request('get_opened_workspaces', { request: {} }); + expect(connection.getState().health).toBe('degraded'); + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS); expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 }); await vi.waitFor(() => { expect(rpc.commands().filter(c => c === 'peer_control_attach')).toHaveLength(2); }); }); - it('degrades after a product transport failure instead of waiting for keepalive', async () => { + it('coalesces product failures into a probe without consuming health retries', async () => { const rpc = createRpc(); const manager = createManager(rpc.deviceRpc); const connection = await manager.connect('peer-1', 'Studio'); - rpc.failNext(1); - await expect(connection.adapter.request('set_config', { - request: { path: 'ui.theme', value: 'dark' }, - })).rejects.toThrow('relay unavailable'); + rpc.failNext(8); + await Promise.all(Array.from({ length: 8 }, async () => { + await expect(connection.adapter.request('subscribe_permission_requests', {})) + .rejects.toThrow('relay unavailable'); + })); - expect(connection.getState()).toMatchObject({ - health: 'degraded', - consecutiveFailures: 1, - }); + expect(connection.getState()).toMatchObject({ health: 'degraded', consecutiveFailures: 0 }); + expect(rpc.commands().filter(command => command === 'peer_mode_ping')).toHaveLength(1); + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS); + expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 }); + expect(rpc.commands().filter(command => command === 'peer_mode_ping')).toHaveLength(2); + expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2); + }); + + it('does not postpone a health retry when more product requests or presence updates fail', async () => { + const rpc = createRpc(); + const manager = createManager(rpc.deviceRpc); + const connection = await manager.connect('peer-1', 'Studio'); + rpc.failAll(); + await vi.advanceTimersByTimeAsync(KEEPALIVE_MS); + expect(connection.getState().consecutiveFailures).toBe(1); + + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS / 2); + manager.reportPresence([]); + manager.reportPresence([]); + await expect(connection.adapter.request('subscribe_permission_requests', {})).rejects.toThrow(); + expect(connection.getState().consecutiveFailures).toBe(1); + + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS / 2); + expect(connection.getState().consecutiveFailures).toBe(2); }); + }); describe('PeerConnectionManager disposal', () => { @@ -402,6 +433,7 @@ describe('PeerConnectionManager disposal', () => { failNextPing = true; await vi.advanceTimersByTimeAsync(KEEPALIVE_MS); await connection.adapter.request('get_opened_workspaces', { request: {} }); + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS); await vi.waitFor(() => { expect(commands.filter(command => command === 'peer_control_attach')).toHaveLength(2); }); @@ -520,7 +552,6 @@ describe('PeerConnectionManager disposal', () => { function createManager( deviceRpc: ReturnType['deviceRpc'], - overrides: { maxKeepaliveFailures?: number } = {}, ): PeerConnectionManager { return new PeerConnectionManager({ deviceRpc, @@ -528,7 +559,6 @@ function createManager( keepaliveIntervalMs: KEEPALIVE_MS, reconnectBaseDelayMs: RECONNECT_BASE_MS, reconnectMaxDelayMs: RECONNECT_MAX_MS, - maxKeepaliveFailures: overrides.maxKeepaliveFailures ?? 3, }); } @@ -666,28 +696,69 @@ function observe(manager: PeerConnectionManager) { }; } -describe('PeerConnectionManager presence recovery', () => { - it('clears a presence-lost attachment once the device is reachable again', async () => { - // `lost` is terminal and `connect` refuses a lost entry, so one presence - // blip during a burst of switching used to strand a healthy device for the - // rest of the session. - const manager = new PeerConnectionManager({ - deviceRpc: async () => JSON.stringify({ - resp: 'host_invoke_result', - ok: true, - value: { capabilities: {} }, - }), - getControllerDeviceId: async () => 'controller-1', +describe('PeerConnectionManager recovery races', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('serializes presence recovery and only marks ready after re-attach succeeds', async () => { + const rpc = createRpc(); + const reattach = deferred(); + let attachCount = 0; + const deviceRpc = vi.fn(async (target: string, commandJson: string) => { + const command = JSON.parse(commandJson).command; + if (command === 'peer_control_attach' && ++attachCount === 2) return reattach.promise; + return rpc.deviceRpc(target, commandJson); }); + const manager = createManager(deviceRpc); + const connection = await manager.connect('peer-1', 'Studio'); + manager.reportPresence([]); + manager.reportPresence(['peer-1']); + await vi.advanceTimersByTimeAsync(0); + expect(attachCount).toBe(2); - await manager.connect('device-b', 'B'); + // These hints used to start competing recovery work or clear the failure + // state before event delivery was actually attached again. manager.reportPresence([]); - expect(manager.get('device-b')?.getState().health).toBe('lost'); + manager.reportPresence(['peer-1']); + await connection.adapter.request('get_opened_workspaces', { request: {} }); + await vi.advanceTimersByTimeAsync(KEEPALIVE_MS * 2); + expect(attachCount).toBe(2); + expect(connection.getState().health).toBe('degraded'); - manager.reportPresence(['device-b']); + reattach.resolve(JSON.stringify({ resp: 'host_invoke_result', ok: true, value: null })); + await vi.advanceTimersByTimeAsync(0); + expect(connection.getState().health).toBe('ready'); + }); - expect(manager.has('device-b')).toBe(false); - await expect(manager.connect('device-b', 'B')).resolves.toBeDefined(); + it('keeps a retry scheduled when online presence arrives as a failed probe settles', async () => { + const rpc = createRpc(); + const manager = createManager(rpc.deviceRpc); + const connection = await manager.connect('peer-1', 'Studio'); + manager.subscribe(states => { + if (states[0]?.consecutiveFailures === 1) manager.reportPresence(['peer-1']); + }); + manager.reportPresence([]); + rpc.failNext(1); + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS); + expect(connection.getState().health).toBe('degraded'); + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS); + expect(connection.getState().health).toBe('ready'); }); + it('re-attaches when presence drops during an already running keepalive', async () => { + const rpc = createRpc(); + const ping = deferred(); + let pingCount = 0; + const manager = createManager(vi.fn(async (target: string, commandJson: string) => { + if (JSON.parse(commandJson).command === 'peer_mode_ping' && ++pingCount === 2) return ping.promise; + return rpc.deviceRpc(target, commandJson); + })); + const connection = await manager.connect('peer-1', 'Studio'); + await vi.advanceTimersByTimeAsync(KEEPALIVE_MS); + manager.reportPresence([]); + ping.resolve(JSON.stringify({ resp: 'host_invoke_result', ok: true, value: { host_type: 'desktop', capabilities: {} } })); + await vi.advanceTimersByTimeAsync(0); + expect(connection.getState().health).toBe('ready'); + expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2); + }); }); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts index 38d27d5dea..a43da39712 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts @@ -25,20 +25,16 @@ const log = createLogger('PeerConnectionManager'); export const PEER_CONTROL_RPC_TIMEOUT_MS = 15_000; export const PEER_KEEPALIVE_INTERVAL_MS = 20_000; -/** Consecutive keepalive/reconnect failures tolerated before a peer is lost. */ -export const PEER_MAX_KEEPALIVE_FAILURES = 3; export const PEER_RECONNECT_BASE_DELAY_MS = 1_000; export const PEER_RECONNECT_MAX_DELAY_MS = 15_000; /** * `connecting` → first handshake. `ready` → the peer answers. `degraded` → it - * missed at least one ping and we are retrying; the attachment is still valid - * and its work still runs. `lost` → terminal, nothing is retried; the consumer - * decides whether to dispose. + * needs its control link checked/re-attached. Recovery keeps retrying with a + * capped delay until explicit disposal; connectivity never selects a different + * device surface or discards its cached work. */ -export type PeerConnectionHealth = 'connecting' | 'ready' | 'degraded' | 'lost'; - -export type PeerConnectionLostReason = 'keepalive' | 'presence'; +export type PeerConnectionHealth = 'connecting' | 'ready' | 'degraded'; export type PeerHostKind = 'desktop' | 'cli'; @@ -100,9 +96,8 @@ export interface PeerConnectionState { readonly surfaceId: DeviceSurfaceId; readonly health: PeerConnectionHealth; readonly capabilities: PeerHostCapabilities; - /** Failures since the last answered ping; drives the backoff schedule. */ + /** Failed health checks since the last completed handshake; drives backoff. */ readonly consecutiveFailures: number; - readonly lostReason: PeerConnectionLostReason | null; } /** Live handle. `adapter` is the product transport for this device. */ @@ -126,7 +121,6 @@ export interface PeerConnectionManagerOptions { getControllerDeviceId?: () => Promise; controlRpcTimeoutMs?: number; keepaliveIntervalMs?: number; - maxKeepaliveFailures?: number; reconnectBaseDelayMs?: number; reconnectMaxDelayMs?: number; } @@ -192,9 +186,10 @@ interface ConnectionEntry { health: PeerConnectionHealth; capabilities: PeerHostCapabilities; consecutiveFailures: number; - lostReason: PeerConnectionLostReason | null; timer: ReturnType | null; disposed: boolean; + presenceOnline: boolean | null; + healthCheckInFlight: Promise | null; reattachInFlight: Promise | null; handle: PeerConnection; } @@ -215,7 +210,6 @@ export class PeerConnectionManager { private readonly resolveControllerDeviceId: () => Promise; private readonly controlRpcTimeoutMs: number; private readonly keepaliveIntervalMs: number; - private readonly maxKeepaliveFailures: number; private readonly reconnectBaseDelayMs: number; private readonly reconnectMaxDelayMs: number; private controllerDeviceId: string | null = null; @@ -229,7 +223,6 @@ export class PeerConnectionManager { ?? (async () => (await remoteConnectAPI.getDeviceInfo()).device_id); this.controlRpcTimeoutMs = options.controlRpcTimeoutMs ?? PEER_CONTROL_RPC_TIMEOUT_MS; this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? PEER_KEEPALIVE_INTERVAL_MS; - this.maxKeepaliveFailures = options.maxKeepaliveFailures ?? PEER_MAX_KEEPALIVE_FAILURES; this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? PEER_RECONNECT_BASE_DELAY_MS; this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? PEER_RECONNECT_MAX_DELAY_MS; } @@ -247,9 +240,6 @@ export class PeerConnectionManager { } const existing = this.entries.get(deviceId); if (existing) { - if (existing.health === 'lost') { - return Promise.reject(new Error(`Peer device '${deviceId}' is no longer reachable`)); - } this.renameEntry(existing, deviceName); return Promise.resolve(existing.handle); } @@ -339,29 +329,20 @@ export class PeerConnectionManager { } /** - * Account presence is the authority on reachability: a peer that dropped off - * cannot be running our work, so no amount of backoff will help it. + * Presence is a hint, not a lifetime boundary: a relay reconnect can briefly + * omit an otherwise running host. Retain the attachment and verify it with + * a handshake. Repeated roster updates must not postpone an existing retry. */ reportPresence(onlineDeviceIds: Iterable): void { const online = new Set(onlineDeviceIds); - for (const entry of Array.from(this.entries.values())) { - if (!online.has(entry.deviceId)) { - this.markLost(entry, 'presence'); - continue; - } - // The device is back. `lost` is terminal and `connect` refuses a lost - // entry, so leaving it in place would keep a reachable device - // permanently unselectable — one presence blip during a burst of - // switching was enough to strand it for the rest of the session. Drop - // the dead entry so the next switch attaches a fresh one. - if (entry.health === 'lost' && entry.lostReason === 'presence') { - this.entries.delete(entry.deviceId); - this.legacyHostKinds.delete(entry.deviceId); - entry.adapter.disconnect().catch(() => undefined); - log.info('Peer device is reachable again; cleared its lost attachment', { - deviceId: entry.deviceId, - }); - this.publish(); + for (const entry of this.entries.values()) { + const wasOnline = entry.presenceOnline; + entry.presenceOnline = online.has(entry.deviceId); + if (!entry.presenceOnline && wasOnline !== false) { + this.requestRecovery(entry, 'presence'); + } else if (entry.presenceOnline && wasOnline === false && entry.health === 'degraded') { + this.cancelTimer(entry); + void this.runHealthCheck(entry); } } } @@ -371,12 +352,12 @@ export class PeerConnectionManager { deviceId, (target, commandJson, timeoutMs) => this.deviceRpc(target, commandJson, timeoutMs), { - // Successful product traffic proves the link as well as a ping does. - onHostInvokeSuccess: () => this.noteHealthy(deviceId), - onHostInvokeTransportFailure: error => { + // Product timeouts request a probe; they are not independent evidence + // of host loss and must not consume the health-check retry counter. + onHostInvokeTransportFailure: (_error, meta) => { const current = this.entries.get(deviceId); - if (current) { - this.noteFailure(current, error); + if (current?.adapter === adapter) { + this.requestRecovery(current, 'request', meta?.action); } }, }, @@ -388,9 +369,10 @@ export class PeerConnectionManager { health: 'connecting', capabilities: NO_CAPABILITIES, consecutiveFailures: 0, - lostReason: null, timer: null, disposed: false, + presenceOnline: null, + healthCheckInFlight: null, reattachInFlight: null, handle: { deviceId, @@ -553,19 +535,40 @@ export class PeerConnectionManager { * `peer_control_attach` because the host may have pruned this controller * during the gap that made us degraded in the first place. */ - private async runHealthCheck(entry: ConnectionEntry): Promise { - if (this.entries.get(entry.deviceId) !== entry || entry.health === 'lost') { - return; + private runHealthCheck(entry: ConnectionEntry): Promise { + if (this.entries.get(entry.deviceId) !== entry || entry.disposed) { + return Promise.resolve(); + } + if (entry.healthCheckInFlight) { + return entry.healthCheckInFlight; } - const reconnecting = entry.health === 'degraded'; + this.cancelTimer(entry); + const check = this.checkHealth(entry).finally(() => { + if (entry.healthCheckInFlight !== check) return; + entry.healthCheckInFlight = null; + if (this.entries.get(entry.deviceId) !== entry || entry.disposed) return; + if (entry.health === 'ready') this.scheduleKeepalive(entry); + else this.scheduleReconnect(entry); + }); + entry.healthCheckInFlight = check; + return check; + } + + private async checkHealth(entry: ConnectionEntry): Promise { try { const capabilities = await this.probeCapabilities(entry); - if (reconnecting) { - await this.sendAttach(entry.deviceId); - } - if (this.entries.get(entry.deviceId) !== entry) { - return; + // A request/presence failure may have arrived while the ping was in + // flight. Check the current state, not the state at probe start. + if (entry.health === 'degraded') { + const reattach = this.sendAttach(entry.deviceId); + entry.reattachInFlight = reattach; + try { + await reattach; + } finally { + if (entry.reattachInFlight === reattach) entry.reattachInFlight = null; + } } + this.assertEntryActive(entry); const previousCapabilities = entry.capabilities; entry.capabilities = capabilities; entry.adapter.setHostCapabilities({ @@ -579,7 +582,6 @@ export class PeerConnectionManager { entry.consecutiveFailures = 0; const recovered = entry.health !== 'ready'; entry.health = 'ready'; - this.scheduleKeepalive(entry); // Publish when the host's advertised capabilities changed too, not only // on recovery: a peer that stayed `ready` but restarted on a different // build mid-session must push a fresh React snapshot, or UI keeps gating @@ -596,65 +598,34 @@ export class PeerConnectionManager { } } - /** - * A single miss on a weak link must not drop a peer that is mid-turn, so the - * first failure only degrades the connection. Only repeated failures mean the - * peer is really unreachable. - */ + /** The dedicated handshake is the only source of the retry counter. */ private noteFailure(entry: ConnectionEntry, error: unknown): void { - if (this.entries.get(entry.deviceId) !== entry || entry.health === 'lost') { + if (this.entries.get(entry.deviceId) !== entry || entry.disposed) { return; } entry.consecutiveFailures += 1; - log.warn('Peer keepalive failed', { + log.warn('Peer health check failed; retrying', { deviceId: entry.deviceId, consecutiveFailures: entry.consecutiveFailures, error, }); - if (entry.consecutiveFailures >= this.maxKeepaliveFailures) { - this.markLost(entry, 'keepalive'); - return; - } entry.health = 'degraded'; - this.scheduleReconnect(entry); - this.publish(); - } - - private noteHealthy(deviceId: string): void { - const entry = this.entries.get(deviceId); - // A lost connection is terminal: it is disposed, not silently revived. - if (!entry || entry.health !== 'degraded') { - return; - } - entry.consecutiveFailures = 0; - entry.health = 'ready'; - this.scheduleKeepalive(entry); this.publish(); - - // HostInvoke itself does not require an attachment, so successful product - // traffic proves reachability but not that DeviceEvents are still fanned - // out. Re-attach in the background before treating recovery as durable. - if (!entry.reattachInFlight) { - const reattach = this.sendAttach(deviceId) - .catch(error => this.noteFailure(entry, error)) - .finally(() => { - if (entry.reattachInFlight === reattach) { - entry.reattachInFlight = null; - } - }); - entry.reattachInFlight = reattach; - } } - private markLost(entry: ConnectionEntry, reason: PeerConnectionLostReason): void { - if (entry.health === 'lost') { + private requestRecovery(entry: ConnectionEntry, reason: 'presence' | 'request', action?: string): void { + if (this.entries.get(entry.deviceId) !== entry || entry.disposed || entry.health !== 'ready') { return; } - this.cancelTimer(entry); - this.legacyHostKinds.delete(entry.deviceId); - entry.health = 'lost'; - entry.lostReason = reason; - log.warn('Peer connection lost', { deviceId: entry.deviceId, reason }); + entry.health = 'degraded'; + log.warn('Peer connection degraded; checking control link', { + deviceId: entry.deviceId, + reason, + action, + }); + // One timer/probe owns recovery. A burst of failed product requests must + // neither start overlapping handshakes nor push the retry further away. + if (!entry.healthCheckInFlight) this.scheduleReconnect(entry); this.publish(); } @@ -714,7 +685,6 @@ export class PeerConnectionManager { health: entry.health, capabilities: entry.capabilities, consecutiveFailures: entry.consecutiveFailures, - lostReason: entry.lostReason, }; } diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss new file mode 100644 index 0000000000..9badbadf1d --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss @@ -0,0 +1,12 @@ +.peer-connection-status { + flex-shrink: 0; + margin: 8px 12px; + + &__content { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 16px; + } +} diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx new file mode 100644 index 0000000000..185ee07597 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PeerConnectionStatus } from './PeerConnectionStatus'; +import { PeerDeviceContext, type PeerDeviceContextValue } from './peerDeviceContextState'; + +const t = (key: string, values?: { name?: string }) => ( + key === 'peerConnection.reconnecting' ? `Reconnecting to ${values?.name}` : key +); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t }) })); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('PeerConnectionStatus', () => { + let container: HTMLDivElement; + let root: Root; + let peer: PeerDeviceContextValue; + const render = async (value: PeerDeviceContextValue | null = peer) => { + await act(async () => root.render( + , + )); + }; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + peer = { + peerMode: { active: true, deviceId: 'peer-a', deviceName: 'Studio' }, + attachments: [{ deviceId: 'peer-a', deviceName: 'Studio', health: 'degraded', capabilities: null }], + currentPeerCapabilities: null, + switchToLocal: vi.fn().mockResolvedValue('activated'), + switchToDevice: vi.fn(), + disconnectDevice: vi.fn(), + disconnectAllDevices: vi.fn(), + }; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('shows the current peer and a manual return action until recovery', async () => { + await render(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Reconnecting to Studio'); + await act(async () => container.querySelector('button')!.click()); + expect(peer.switchToLocal).toHaveBeenCalledExactlyOnceWith('manual'); + expect(peer.disconnectDevice).not.toHaveBeenCalled(); + + peer = { ...peer, attachments: [{ ...peer.attachments[0], health: 'ready' }] }; + await render(); + expect(container.textContent).toBe(''); + }); + + it('does not warn for a background peer or a local-only surface', async () => { + await render({ ...peer, peerMode: { active: true, deviceId: 'peer-b', deviceName: 'Other' } }); + expect(container.textContent).toBe(''); + await render({ ...peer, peerMode: { active: false } }); + expect(container.textContent).toBe(''); + await render(null); + expect(container.textContent).toBe(''); + }); + + it('keeps a failed return action visible and re-enables the button for retry', async () => { + peer.switchToLocal = vi.fn().mockRejectedValue(new Error('Local surface could not be restored')); + await render(); + await act(async () => container.querySelector('button')!.click()); + expect(container.textContent).toContain('Local surface could not be restored'); + expect(container.querySelector('button')!.disabled).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx new file mode 100644 index 0000000000..d2fec8645e --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { Alert, Button } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { usePeerDeviceModeOptional } from './peerDeviceContextState'; +import './PeerConnectionStatus.scss'; + +/** Keep the selected host visible while its control link recovers. */ +export const PeerConnectionStatus: React.FC = () => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + const [returning, setReturning] = useState(false); + const [error, setError] = useState(null); + const deviceId = peer?.peerMode.active ? peer.peerMode.deviceId : null; + const connection = peer?.attachments.find(item => item.deviceId === deviceId); + + useEffect(() => { setError(null); }, [deviceId, connection?.health]); + + if (!peer?.peerMode.active || connection?.health !== 'degraded') return null; + + const returnLocal = async () => { + setReturning(true); + setError(null); + try { + await peer.switchToLocal('manual'); + } catch (switchError) { + setError(switchError instanceof Error ? switchError.message : String(switchError)); + } finally { + setReturning(false); + } + }; + + return ( +
+ + {t('peerConnection.reconnecting', { name: peer.peerMode.deviceName })} + + + )} + description={error ?? undefined} + /> +
+ ); +}; diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts index 3892bde8b1..c24e340479 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts @@ -77,7 +77,7 @@ class FakeConnectionManager { const online = new Set(onlineDeviceIds); for (const state of this.states.values()) { if (!online.has(state.deviceId)) { - this.lose(state.deviceId, 'presence'); + this.setHealth(state.deviceId, 'degraded'); } } } @@ -97,15 +97,14 @@ class FakeConnectionManager { pending.resolve(this.ensure(deviceId, deviceName)); } - lose(deviceId: string, reason: 'presence' | 'keepalive'): void { + setHealth(deviceId: string, health: 'degraded' | 'ready'): void { const previous = this.states.get(deviceId); if (!previous) { return; } this.states.set(deviceId, { ...previous, - health: 'lost', - lostReason: reason, + health, }); this.publish(); } @@ -135,10 +134,10 @@ class FakeConnectionManager { miniAppAgentContextFilesV1: true, cancelTool: true, toolCatalog: true, + userQuestionResponse: true, hostKind: 'desktop', }, consecutiveFailures: 0, - lostReason: null, }); this.publish(); return connection; @@ -159,10 +158,10 @@ function createHarness(options: { const commits: string[] = []; const invalidations: string[] = []; const discarded: string[] = []; - const autoExits: Array<{ deviceId: string; reason: string }> = []; const peerModeEvents: Array<{ active: boolean; deviceId?: string }> = []; const controllerFlags: boolean[] = []; let latestSnapshot: PeerDeviceSurfaceSnapshot | null = null; + let loginListener: ((loggedIn: boolean) => void) | undefined; const controller = new PeerDeviceSurfaceController({ connectionManager: manager, @@ -186,9 +185,11 @@ function createHarness(options: { markSurfaceSwitched: vi.fn(), discardSurfaceState: surfaceId => discarded.push(surfaceId), clearDeviceActivity: vi.fn(), - emitAutoExit: detail => autoExits.push(detail), listenPresence: () => () => undefined, - listenLoginState: () => () => undefined, + listenLoginState: listener => { + loginListener = listener; + return () => { loginListener = undefined; }; + }, }); controller.subscribe(snapshot => { latestSnapshot = snapshot; @@ -200,10 +201,10 @@ function createHarness(options: { commits, invalidations, discarded, - autoExits, peerModeEvents, controllerFlags, snapshot: () => latestSnapshot!, + reportLogin: (loggedIn: boolean) => loginListener?.(loggedIn), }; } @@ -339,24 +340,58 @@ describe('PeerDeviceSurfaceController connection loss', () => { resetDeviceSurfaceForTest(); }); - it('returns to local, disposes a lost peer, and emits one auto-exit', async () => { + it('preserves the rendered surface and epoch throughout disconnection and recovery', async () => { + const harness = createHarness(); + harness.controller.start(); + await harness.controller.switchToDevice('peer-a', 'A'); + const originalScope = getActiveSurfaceScope(); + const originalConnection = harness.manager.get('peer-a'); + + harness.manager.reportPresence([]); + await harness.controller.waitForIdle(); + + expect(harness.snapshot().peerMode).toEqual({ active: true, deviceId: 'peer-a', deviceName: 'A' }); + expect(harness.snapshot().attachments[0].health).toBe('degraded'); + expect(harness.manager.dispose).not.toHaveBeenCalled(); + expect(harness.discarded).toEqual([]); + expect(getActiveSurfaceScope().epoch).toBe(originalScope.epoch); + expect(getActiveSurfaceId()).toBe('peer-a'); + + harness.manager.setHealth('peer-a', 'ready'); + expect(harness.commits).toEqual(['peer-a']); + expect(harness.manager.get('peer-a')).toBe(originalConnection); + expect(harness.snapshot().attachments[0].health).toBe('ready'); + harness.controller.stop(); + }); + + it('returns locally and stops peer attachments when the account logs out during recovery', async () => { const harness = createHarness(); harness.controller.start(); await harness.controller.switchToDevice('peer-a', 'A'); + harness.manager.reportPresence([]); - harness.manager.lose('peer-a', 'presence'); + harness.reportLogin(false); + await vi.waitFor(() => expect(harness.manager.dispose).toHaveBeenCalled()); - await vi.waitFor(() => { - expect(harness.snapshot().peerMode).toEqual({ active: false }); - expect(harness.manager.dispose).toHaveBeenCalledWith('peer-a', { notifyPeer: false }); - }); + expect(harness.snapshot().peerMode).toEqual({ active: false }); + expect(harness.snapshot().attachments).toEqual([]); expect(harness.discarded).toEqual(['peer-a']); - expect(harness.autoExits).toEqual([{ - deviceId: 'peer-a', - deviceName: 'A', - reason: 'peer_offline', - }]); + harness.controller.stop(); + }); + + it('still allows explicit disconnect while a peer is reconnecting', async () => { + const harness = createHarness(); + harness.controller.start(); + await harness.controller.switchToDevice('peer-a', 'A'); + harness.manager.reportPresence([]); + + await harness.controller.disconnectDevice('peer-a'); + + expect(harness.snapshot().peerMode).toEqual({ active: false }); + expect(harness.discarded).toEqual(['peer-a']); + expect(harness.manager.dispose).toHaveBeenCalled(); expect(getActiveSurfaceId()).toBe(LOCAL_SURFACE_ID); harness.controller.stop(); }); + }); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts index 8f8b810b7e..9f420eaede 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts @@ -77,11 +77,6 @@ export interface PeerDeviceSurfaceControllerDependencies { markSurfaceSwitched: () => void; discardSurfaceState: (surfaceId: DeviceSurfaceId) => void; clearDeviceActivity: (deviceId: string) => void; - emitAutoExit: (detail: { - deviceId: string; - deviceName: string; - reason: 'peer_offline' | 'rpc_failures'; - }) => void; listenPresence: ( listener: (onlineDeviceIds: readonly string[]) => void, ) => () => void; @@ -120,7 +115,6 @@ function peerModeFor(target: SurfaceTarget): PeerModeState { export class PeerDeviceSurfaceController { private readonly dependencies: PeerDeviceSurfaceControllerDependencies; private readonly listeners = new Set(); - private readonly lostConnectionsInFlight = new Set(); private currentTarget: SurfaceTarget = localTarget(); /** False while the committed target still needs (or has lost) its hydrate. */ private currentTargetReady = true; @@ -179,7 +173,6 @@ export class PeerDeviceSurfaceController { deviceId: connection.deviceId, deviceName: connection.deviceName, health: connection.health, - lostReason: connection.lostReason, capabilities: connection.capabilities, })), }; @@ -483,9 +476,6 @@ export class PeerDeviceSurfaceController { scope, 'connect peer device surface', ); - if (connection.getState().health === 'lost') { - throw new Error(`Peer device '${target.deviceId}' is no longer reachable`); - } return { ...target, adapter: connection.adapter }; } @@ -507,7 +497,7 @@ export class PeerDeviceSurfaceController { let fallback = original; if (fallback.deviceId !== null) { const connection = this.dependencies.connectionManager.get(fallback.deviceId); - if (!connection || connection.getState().health === 'lost') { + if (!connection) { fallback = localTarget(); } } @@ -611,60 +601,8 @@ export class PeerDeviceSurfaceController { } this.publish(); - for (const connection of connections) { - if (connection.health === 'lost' && !this.lostConnectionsInFlight.has(connection.deviceId)) { - this.lostConnectionsInFlight.add(connection.deviceId); - void this.handleLostConnection(connection) - .catch(error => { - log.warn('Failed to dispose a lost peer connection', { - deviceId: connection.deviceId, - error, - }); - }) - .finally(() => { - this.lostConnectionsInFlight.delete(connection.deviceId); - }); - } - } - } - - private async handleLostConnection(connection: PeerConnectionState): Promise { - const wasRendered = this.currentTarget.deviceId === connection.deviceId; - const reason = connection.lostReason === 'presence' - ? 'peer_offline' - : 'rpc_failures'; - - if (wasRendered) { - try { - await this.switchToLocal(reason); - await this.waitForIdle(); - } catch (error) { - log.warn('Failed to leave a lost peer device surface', { - deviceId: connection.deviceId, - error, - }); - } - } - - if (this.currentTarget.deviceId === connection.deviceId) { - return; - } - - try { - await this.dependencies.connectionManager.dispose(connection.deviceId, { - notifyPeer: false, - }); - } finally { - this.releaseDeviceState(connection.surfaceId, connection.deviceId); - } - - if (wasRendered) { - this.dependencies.emitAutoExit({ - deviceId: connection.deviceId, - deviceName: connection.deviceName || connection.deviceId, - reason, - }); - } + // Connectivity changes update status only. The user owns surface selection; + // a peer that is reconnecting retains its transport, epoch and cached state. } private releaseDeviceState(surfaceId: DeviceSurfaceId, deviceId: string): void { diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss index c1f51de853..d77408f193 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss +++ b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss @@ -91,27 +91,9 @@ } &__path-input-field { - display: block; - width: 100%; - } - - &__path-display, - &__path-input { width: 100%; + min-width: 0; box-sizing: border-box; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 6px; - background: var(--openbitfun-color-surface-canvas); - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-sm-font-size); - padding: 6px 10px; - text-align: left; - } - - &__path-display { - overflow: hidden; - white-space: nowrap; - cursor: text; } &__body { diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.test.tsx b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.test.tsx new file mode 100644 index 0000000000..e78ecaff72 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PeerDirectoryBrowser } from './PeerDirectoryBrowser'; + +const mocks = vi.hoisted(() => ({ + getSystemInfo: vi.fn(), + getDirectoryChildren: vi.fn(), + t: (key: string) => key, +})); +vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: mocks })); +vi.mock('@/infrastructure/api', () => ({ workspaceAPI: mocks })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t }) })); +vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ + getAppearanceOverlayHost: () => document.body, +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('PeerDirectoryBrowser', () => { + let container: HTMLDivElement; + let root: Root; + let onSelect: ReturnType; + const input = () => document.querySelector('input')!; + const select = () => Array.from(document.querySelectorAll('button')) + .find((button) => button.textContent === 'peerDirectoryPicker.select')!; + const render = async (initialPath?: string) => { + await act(async () => root.render( + , + )); + }; + const typePath = async (value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), value); + input().dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + const key = async (value: string, isComposing = false) => { + await act(async () => input().dispatchEvent(new KeyboardEvent('keydown', { + key: value, bubbles: true, isComposing, + }))); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSystemInfo.mockResolvedValue({ platform: 'linux', arch: 'x86_64', homeDir: '/home/peer' }); + mocks.getDirectoryChildren.mockResolvedValue([]); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + onSelect = vi.fn(); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it.each([ + ['linux', '/home/remote-user'], + ['macos', '/Users/remote-user'], + ['windows', 'D:\\Users\\remote-user'], + ])('starts at the %s peer home without guessing a root', async (platform, homeDir) => { + mocks.getSystemInfo.mockResolvedValue({ platform, homeDir }); + await render(); + expect(mocks.getDirectoryChildren).toHaveBeenCalledExactlyOnceWith(homeDir); + expect(input().value).toBe(homeDir); + await act(async () => select().click()); + expect(onSelect).toHaveBeenCalledWith(homeDir); + }); + + it('preserves an explicit initial path but Home goes to the peer user directory', async () => { + await render('/projects/existing'); + expect(mocks.getSystemInfo).not.toHaveBeenCalled(); + expect(input().value).toBe('/projects/existing'); + await act(async () => document.querySelector('[title="peerDirectoryPicker.home"]')!.click()); + expect(input().value).toBe('/home/peer'); + }); + + it('keeps the same full-width input mounted through focus, typing, Enter and blur', async () => { + await render(); + const original = input(); + const field = original.parentElement!; + expect(field.classList.contains('peer-directory-browser__path-input-field')).toBe(true); + await act(async () => original.focus()); + await typePath('/projects/new'); + expect(select().disabled).toBe(true); + await key('Enter'); + await act(async () => original.blur()); + expect(input()).toBe(original); + expect(input().parentElement).toBe(field); + expect(mocks.getDirectoryChildren.mock.calls).toEqual([['/home/peer'], ['/projects/new']]); + expect(select().disabled).toBe(false); + }); + + it('leaves IME Enter/Escape to composition and restores the path on ordinary Escape', async () => { + await render(); + await typePath('/draft'); + await key('Enter', true); + await key('Escape', true); + expect(input().value).toBe('/draft'); + expect(mocks.getDirectoryChildren).toHaveBeenCalledTimes(1); + await key('Escape'); + expect(input().value).toBe('/home/peer'); + }); + + it('lets an older peer recover through manual input when homeDir is absent', async () => { + mocks.getSystemInfo.mockResolvedValue({ platform: 'windows', arch: 'x86_64' }); + await render(); + expect(document.body.textContent).toContain('peerDirectoryPicker.homeUnavailable'); + expect(mocks.getDirectoryChildren).not.toHaveBeenCalled(); + expect(select().disabled).toBe(true); + await typePath('E:\\Projects'); + await key('Enter'); + expect(input().value).toBe('E:\\Projects'); + expect(select().disabled).toBe(false); + }); + + it('shows home lookup failures without falling back to a controller path', async () => { + mocks.getSystemInfo.mockRejectedValue(new Error('Peer offline')); + await render(); + expect(document.body.textContent).toContain('Peer offline'); + expect(mocks.getDirectoryChildren).not.toHaveBeenCalled(); + expect(select().disabled).toBe(true); + }); + + it('does not confirm a stale selection after navigation fails', async () => { + await render(); + mocks.getDirectoryChildren.mockRejectedValueOnce(new Error('Permission denied')); + await typePath('/restricted'); + await key('Enter'); + expect(document.body.textContent).toContain('Permission denied'); + expect(select().disabled).toBe(true); + await act(async () => select().click()); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores a slow home response after the user navigates manually', async () => { + let resolveHome!: (info: { homeDir: string }) => void; + mocks.getSystemInfo.mockReturnValue(new Promise((resolve) => { resolveHome = resolve; })); + await render(); + await typePath('/manual'); + await key('Enter'); + await act(async () => resolveHome({ homeDir: '/home/late' })); + expect(input().value).toBe('/manual'); + expect(mocks.getDirectoryChildren).toHaveBeenCalledExactlyOnceWith('/manual'); + }); +}); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.tsx b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.tsx index 7ad45f4247..bcdf528562 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.tsx +++ b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.tsx @@ -10,7 +10,6 @@ import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/Ap import { Home, Loader2 } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { workspaceAPI } from '@/infrastructure/api'; -import { globalAPI } from '@/infrastructure/api/service-api/GlobalAPI'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { createLogger } from '@/shared/utils/logger'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; @@ -35,32 +34,6 @@ interface DirectoryEntry { path: string; } -async function resolveStartPath(preferred?: string): Promise { - if (preferred && preferred.trim()) { - return preferred.trim(); - } - try { - const opened = await globalAPI.getOpenedWorkspaces(); - const first = Array.isArray(opened) ? opened[0] : null; - const rootPath = first && typeof first.rootPath === 'string' ? first.rootPath : null; - if (rootPath) { - return rootPath; - } - } catch (error) { - log.debug('Failed to resolve peer start path from opened workspaces', error); - } - try { - const info = await systemAPI.getSystemInfo(); - const platform = typeof info?.platform === 'string' ? info.platform.toLowerCase() : ''; - if (platform.includes('win')) { - return 'C:\\'; - } - } catch (error) { - log.debug('Failed to resolve peer start path from system info', error); - } - return '/'; -} - export const PeerDirectoryBrowser: React.FC = ({ visible = true, title, @@ -69,24 +42,28 @@ export const PeerDirectoryBrowser: React.FC = ({ onCancel, }) => { const { t } = useI18n('common'); - const [currentPath, setCurrentPath] = useState(initialPath || '/'); - const [pathInputValue, setPathInputValue] = useState(initialPath || '/'); - const [isEditingPath, setIsEditingPath] = useState(false); + const [currentPath, setCurrentPath] = useState(initialPath?.trim() || ''); + const [pathInputValue, setPathInputValue] = useState(initialPath?.trim() || ''); const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedPath, setSelectedPath] = useState(initialPath || null); - const pathInputRef = useRef(null); + const [selectedPath, setSelectedPath] = useState(null); + const inputRevisionRef = useRef(0); const pathInputCompositionActiveRef = useRef(false); const loadSeqRef = useRef(0); const parentPath = useMemo(() => parentDirectoryPath(currentPath), [currentPath]); - const loadDirectory = useCallback(async (path: string) => { + const loadDirectory = useCallback(async (requestedPath?: string) => { const seq = ++loadSeqRef.current; + const inputRevision = inputRevisionRef.current; setLoading(true); setError(null); + setSelectedPath(null); try { + const path = requestedPath || (await systemAPI.getSystemInfo()).homeDir; + if (seq !== loadSeqRef.current) return; + if (!path) throw new Error(t('peerDirectoryPicker.homeUnavailable')); const children = await workspaceAPI.getDirectoryChildren(path); if (seq !== loadSeqRef.current) { return; @@ -100,14 +77,14 @@ export const PeerDirectoryBrowser: React.FC = ({ .sort((a, b) => a.name.localeCompare(b.name)); setEntries(directories); setCurrentPath(path); - setPathInputValue(path); + if (inputRevision === inputRevisionRef.current) setPathInputValue(path); setSelectedPath(path); } catch (loadError) { if (seq !== loadSeqRef.current) { return; } const message = loadError instanceof Error ? loadError.message : String(loadError); - log.warn('Failed to list peer directory', { path, error: loadError }); + log.warn('Failed to list peer directory', { path: requestedPath, error: loadError }); setError(message); setEntries([]); } finally { @@ -115,29 +92,15 @@ export const PeerDirectoryBrowser: React.FC = ({ setLoading(false); } } - }, []); + }, [t]); useEffect(() => { - let cancelled = false; - void (async () => { - const start = await resolveStartPath(initialPath); - if (!cancelled) { - await loadDirectory(start); - } - })(); + void loadDirectory(initialPath?.trim()); return () => { - cancelled = true; loadSeqRef.current += 1; }; }, [initialPath, loadDirectory]); - useEffect(() => { - if (isEditingPath) { - pathInputRef.current?.focus(); - pathInputRef.current?.select(); - } - }, [isEditingPath]); - const handleGoParent = useCallback(() => { if (!parentPath) { return; @@ -146,11 +109,8 @@ export const PeerDirectoryBrowser: React.FC = ({ }, [loadDirectory, parentPath]); const handleGoHome = useCallback(() => { - void (async () => { - const start = await resolveStartPath(initialPath); - await loadDirectory(start); - })(); - }, [initialPath, loadDirectory]); + void loadDirectory(); + }, [loadDirectory]); const handleRefresh = useCallback(() => { void loadDirectory(currentPath); @@ -162,8 +122,7 @@ export const PeerDirectoryBrowser: React.FC = ({ const handleCommitPathInput = useCallback(() => { const next = pathInputValue.trim(); - setIsEditingPath(false); - if (!next || next === currentPath) { + if (!next) { setPathInputValue(currentPath); return; } @@ -171,12 +130,12 @@ export const PeerDirectoryBrowser: React.FC = ({ }, [currentPath, loadDirectory, pathInputValue]); const handleConfirm = useCallback(() => { - const path = selectedPath || currentPath; - if (!path) { + const path = selectedPath; + if (!path || loading || error || pathInputValue.trim() !== currentPath) { return; } onSelect(path); - }, [currentPath, onSelect, selectedPath]); + }, [currentPath, error, loading, onSelect, pathInputValue, selectedPath]); return createPortal(
= ({ - )} + /> +
@@ -397,7 +346,7 @@ export const PeerDirectoryBrowser: React.FC = ({ variant="fill" size="sm" onClick={handleConfirm} - disabled={!(selectedPath || currentPath)} + disabled={!selectedPath || loading || !!error || pathInputValue.trim() !== currentPath} > {t('peerDirectoryPicker.select')} diff --git a/src/web-ui/src/infrastructure/peer-device/README.md b/src/web-ui/src/infrastructure/peer-device/README.md index 725779541f..3903922135 100644 --- a/src/web-ui/src/infrastructure/peer-device/README.md +++ b/src/web-ui/src/infrastructure/peer-device/README.md @@ -50,7 +50,7 @@ Still to migrate, in order: the interaction mailbox, then history positions. request dedup and capability caches must therefore use `(DeviceSurfaceId, local identity)`. `activateSurface` commits transport, event routing and container selection before notifying observers. A normal - switch preserves every container; only explicit/lost attachment disposal + switch preserves every container; only explicit attachment disposal may call `discardSurfaceState`. - **In-flight submissions must survive the switch.** `startTurn` has an async window between adding the projection turn and re-reading the @@ -255,8 +255,16 @@ Still to migrate, in order: the interaction mailbox, then history positions. interaction that can suspend execution is incomplete until its owner exposes equivalent replayable attach state and a negotiated response path. -13. **Weak links use bounded, idempotency-aware recovery.** Default Peer - HostInvoke concurrency is four with one slot reserved from normal/low +13. **Weak links use bounded, idempotency-aware recovery.** Presence gaps + and product RPC timeouts keep an attached peer's surface selected and show + a reconnecting notice. A single dedicated handshake owns recovery and its + retry counter; concurrent product failures must not consume it or postpone + the timer. Retry delay is capped, not retry lifetime. A successful recovery + re-attaches event delivery before publishing `ready`, without changing the + surface epoch, discarding state, or resubmitting work. Only explicit + disconnect or logout disposes an attachment. + + Default Peer HostInvoke concurrency is four with one slot reserved from normal/low traffic. Read-only commands have a real 10s deadline and four exponential-backoff retries. Mutations have a 30s deadline and are never replayed automatically without an idempotency contract. Dialog submission diff --git a/src/web-ui/src/infrastructure/peer-device/appearance.ts b/src/web-ui/src/infrastructure/peer-device/appearance.ts index 7fddadcbaf..edbcffa3b7 100644 --- a/src/web-ui/src/infrastructure/peer-device/appearance.ts +++ b/src/web-ui/src/infrastructure/peer-device/appearance.ts @@ -15,6 +15,8 @@ export const peerDeviceAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'pathDisplay' }, { id: 'body' }, { id: 'status' }, + { id: 'connectionStatus' }, + { id: 'connectionStatusContent' }, { id: 'list' }, { id: 'item' }, { id: 'footer' }, diff --git a/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts b/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts index 178f8afc05..9291ae4b78 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts @@ -1,7 +1,6 @@ import { createContext, useContext } from 'react'; import type { PeerConnectionHealth, - PeerConnectionLostReason, PeerHostCapabilities, } from './PeerConnectionManager'; @@ -20,7 +19,6 @@ export interface PeerAttachmentState { deviceId: string; deviceName: string; health: PeerConnectionHealth; - lostReason: PeerConnectionLostReason | null; /** * Host capabilities probed via `peer_mode_ping`. Null when the host has not * answered yet; consumers must fall back to a safe default (unsupported). diff --git a/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts b/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts index 31206f07fa..569f872180 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts @@ -173,9 +173,6 @@ function createDependencies(): PeerDeviceSurfaceControllerDependencies { workspaceManager.discardDeviceSurface(surfaceId); }, clearDeviceActivity, - emitAutoExit: detail => { - window.dispatchEvent(new CustomEvent('peer-mode:auto-exit', { detail })); - }, listenPresence: listener => api.listen<{ devices: Array<{ device_id: string }>; }>('account://device-presence', payload => { diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 60b53b8189..2a781e2e1e 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -689,11 +689,17 @@ "errorEnterName": "Please enter a project name", "errorCreateFailed": "Failed to create project" }, + "peerConnection": { + "reconnecting": "Reconnecting to {{name}}. Content may be out of date." + }, "peerDirectoryPicker": { "loading": "Loading remote directories…", "empty": "No subfolders in this directory", "parent": "Parent directory", - "home": "Start directory", + "home": "Home directory", + "path": "Directory path", + "pathHint": "Enter a path and press Enter", + "homeUnavailable": "This device did not provide a home directory. Enter a path manually or update OpenBitFun on that device.", "refresh": "Refresh", "selected": "Selected: {{path}}", "cancel": "Cancel", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 4d13976e8c..f5a0f3a1b9 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -689,11 +689,17 @@ "errorEnterName": "请输入工作区名称", "errorCreateFailed": "创建工作区失败" }, + "peerConnection": { + "reconnecting": "正在重新连接「{{name}}」。内容可能暂未更新。" + }, "peerDirectoryPicker": { "loading": "正在加载远程目录…", "empty": "此目录下没有子文件夹", "parent": "上级目录", - "home": "起始目录", + "home": "用户目录", + "path": "目录路径", + "pathHint": "输入路径后按 Enter", + "homeUnavailable": "此设备未提供用户目录。请手动输入路径,或更新该设备上的 OpenBitFun。", "refresh": "刷新", "selected": "已选:{{path}}", "cancel": "取消", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 825a60a1c1..43c2728bd6 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -689,11 +689,17 @@ "errorEnterName": "請輸入工作區名稱", "errorCreateFailed": "建立工作區失敗" }, + "peerConnection": { + "reconnecting": "正在重新連線至「{{name}}」。內容可能尚未更新。" + }, "peerDirectoryPicker": { "loading": "正在載入遠端目錄…", "empty": "此目錄下沒有子資料夾", "parent": "上層目錄", - "home": "起始目錄", + "home": "使用者目錄", + "path": "目錄路徑", + "pathHint": "輸入路徑後按 Enter", + "homeUnavailable": "此裝置未提供使用者目錄。請手動輸入路徑,或更新該裝置上的 OpenBitFun。", "refresh": "重新整理", "selected": "已選:{{path}}", "cancel": "取消",