From 23caabca10537205bb913997437736e4b3f5208f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 14 Sep 2026 09:14:11 +0800 Subject: [PATCH] fix(peer): verify connection hints before showing compact reconnect status --- .../components/PersistentFooterActions.tsx | 2 ++ src/web-ui/src/app/layout/AppLayout.tsx | 1 - .../peer-device/PeerConnectionManager.test.ts | 20 ++++++++--- .../peer-device/PeerConnectionManager.ts | 27 +++++++++------ .../peer-device/PeerConnectionStatus.scss | 29 ++++++++++++++-- .../peer-device/PeerConnectionStatus.test.tsx | 2 +- .../peer-device/PeerConnectionStatus.tsx | 34 +++++++++---------- .../src/infrastructure/peer-device/README.md | 7 ++-- src/web-ui/src/locales/en-US/common.json | 3 ++ src/web-ui/src/locales/zh-CN/common.json | 3 ++ src/web-ui/src/locales/zh-TW/common.json | 3 ++ 11 files changed, 92 insertions(+), 39 deletions(-) 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 7fcbaf462d..f8d5efd8a2 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -30,6 +30,7 @@ import { import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; +import { PeerConnectionStatus } from '@/infrastructure/peer-device/PeerConnectionStatus'; import DeviceStatusControl from './DeviceStatusControl'; import AppearanceQuickSwitchMenuItem from './AppearanceQuickSwitchMenuItem'; @@ -164,6 +165,7 @@ const PersistentFooterActions: React.FC = () => { return ( <> +
= ({ className = '' }) => { isExiting={transitionDir === 'returning'} /> - {/* Hello stays available across every client scene, including Welcome. */} 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 dd61b4fc93..16c431856d 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts @@ -282,6 +282,18 @@ describe('PeerConnectionManager health', () => { await manager.disposeAll(); }); + it('does not flash a reconnect warning when the roster misses a healthy host', async () => { + const rpc = createRpc(); + const manager = createManager(rpc.deviceRpc); + const seen = observe(manager); + await manager.connect('peer-1', 'Studio'); + manager.reportPresence([]); + await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS); + expect(seen.healthTimeline()).toEqual(['connecting', 'ready']); + expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2); + await manager.disposeAll(); + }); + it('recovers a 2.3 second presence gap on the same attachment', async () => { const rpc = createRpc(); const manager = createManager(rpc.deviceRpc); @@ -289,7 +301,7 @@ describe('PeerConnectionManager health', () => { rpc.failAll(); manager.reportPresence(['peer-2']); - expect(connection.getState()).toMatchObject({ health: 'degraded', consecutiveFailures: 0 }); + expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 }); await vi.advanceTimersByTimeAsync(2_300); rpc.failNext(0); @@ -333,12 +345,12 @@ describe('PeerConnectionManager health', () => { .rejects.toThrow('relay unavailable'); })); - expect(connection.getState()).toMatchObject({ health: 'degraded', consecutiveFailures: 0 }); + expect(connection.getState()).toMatchObject({ health: 'ready', 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); + expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(1); }); it('does not postpone a health retry when more product requests or presence updates fail', async () => { @@ -732,7 +744,7 @@ describe('PeerConnectionManager recovery races', () => { await connection.adapter.request('get_opened_workspaces', { request: {} }); await vi.advanceTimersByTimeAsync(KEEPALIVE_MS * 2); expect(attachCount).toBe(2); - expect(connection.getState().health).toBe('degraded'); + expect(connection.getState().health).toBe('ready'); reattach.resolve(JSON.stringify({ resp: 'host_invoke_result', ok: true, value: null })); await vi.advanceTimersByTimeAsync(0); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts index fc17138645..2a4ca91c66 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts @@ -196,6 +196,8 @@ interface ConnectionEntry { timer: ReturnType | null; disposed: boolean; presenceOnline: boolean | null; + probeRequested: boolean; + needsReattach: boolean; healthCheckInFlight: Promise | null; reattachInFlight: Promise | null; handle: PeerConnection; @@ -347,7 +349,7 @@ export class PeerConnectionManager { entry.presenceOnline = online.has(entry.deviceId); if (!entry.presenceOnline && wasOnline !== false) { this.requestRecovery(entry, 'presence'); - } else if (entry.presenceOnline && wasOnline === false && entry.health === 'degraded') { + } else if (entry.presenceOnline && wasOnline === false && (entry.health === 'degraded' || entry.probeRequested)) { this.cancelTimer(entry); void this.runHealthCheck(entry); } @@ -379,6 +381,8 @@ export class PeerConnectionManager { timer: null, disposed: false, presenceOnline: null, + probeRequested: false, + needsReattach: false, healthCheckInFlight: null, reattachInFlight: null, handle: { @@ -556,8 +560,9 @@ export class PeerConnectionManager { const check = this.checkHealth(entry).finally(() => { if (entry.healthCheckInFlight !== check) return; entry.healthCheckInFlight = null; + entry.probeRequested = false; if (this.entries.get(entry.deviceId) !== entry || entry.disposed) return; - if (entry.health === 'ready') this.scheduleKeepalive(entry); + if (entry.health === 'ready' && !entry.needsReattach) this.scheduleKeepalive(entry); else this.scheduleReconnect(entry); }); entry.healthCheckInFlight = check; @@ -569,7 +574,8 @@ export class PeerConnectionManager { const capabilities = await this.probeCapabilities(entry); // 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') { + if (entry.health === 'degraded' || entry.needsReattach) { + entry.needsReattach = false; const reattach = this.sendAttach(entry.deviceId); entry.reattachInFlight = reattach; try { @@ -627,16 +633,15 @@ export class PeerConnectionManager { if (this.entries.get(entry.deviceId) !== entry || entry.disposed || entry.health !== 'ready') { return; } - entry.health = 'degraded'; - log.warn('Peer connection degraded; checking control link', { - deviceId: entry.deviceId, - reason, - action, + // Roster omissions and product timeouts are hints, not proof of a broken + // control link. Verify silently; only a failed dedicated probe degrades UI. + if (reason === 'presence') entry.needsReattach = true; + if (entry.probeRequested) return; + entry.probeRequested = true; + log.debug('Checking peer control link after a transport hint', { + 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(); } private cancelTimer(entry: ConnectionEntry): void { diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss index 9badbadf1d..53f1240e9e 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss @@ -1,12 +1,35 @@ .peer-connection-status { flex-shrink: 0; - margin: 8px 12px; + min-width: 0; + max-width: 320px; + margin: 0 12px; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-label-sm-font-size); &__content { display: flex; align-items: center; justify-content: space-between; - flex-wrap: wrap; - gap: 8px 16px; + gap: 8px; + min-height: 32px; + } + + &__label { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + + svg { + flex-shrink: 0; + color: var(--openbitfun-color-status-warning-emphasis); + } + } + + &__error { + display: block; + overflow-wrap: anywhere; + padding-bottom: 8px; + color: var(--openbitfun-color-status-danger-content); } } diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx index 185ee07597..668de8bc0c 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx @@ -45,7 +45,7 @@ describe('PeerConnectionStatus', () => { 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'); + expect(container.querySelector('[role="status"]')?.textContent).toContain('peerConnection.reconnectingShort'); await act(async () => container.querySelector('button')!.click()); expect(peer.switchToLocal).toHaveBeenCalledExactlyOnceWith('manual'); expect(peer.disconnectDevice).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx index d2fec8645e..0dddec910d 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { Alert, Button } from '@openbitfun/ui'; +import { Button, Tooltip } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; import { usePeerDeviceModeOptional } from './peerDeviceContextState'; +import { WifiOff } from 'lucide-react'; import './PeerConnectionStatus.scss'; /** Keep the selected host visible while its control link recovers. */ @@ -35,23 +36,22 @@ export const PeerConnectionStatus: React.FC = () => { data-openbitfun-component="peer-device" data-openbitfun-part="connectionStatus" > - - {t('peerConnection.reconnecting', { name: peer.peerMode.deviceName })} - +
+ + + - )} - description={error ?? undefined} - /> + + +
+ {error && {error}}
); }; diff --git a/src/web-ui/src/infrastructure/peer-device/README.md b/src/web-ui/src/infrastructure/peer-device/README.md index 88b869179f..eef7286dc0 100644 --- a/src/web-ui/src/infrastructure/peer-device/README.md +++ b/src/web-ui/src/infrastructure/peer-device/README.md @@ -273,8 +273,11 @@ Still to migrate, in order: the interaction mailbox, then history positions. exposes equivalent replayable attach state and a negotiated response path. 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 + and product RPC timeouts keep an attached peer's surface selected and request + a silent control probe. Only a failed control ping or re-attach marks the + connection degraded and shows the compact status beside the device controls. + A roster omission still requests event re-attachment even when ping succeeds; + a failed product request alone does not require re-attachment. 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 diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index c8e0083076..3ef6be9e6f 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -719,6 +719,9 @@ "errorCreateFailed": "Failed to create project" }, "peerConnection": { + "reconnectingShort": "Reconnecting…", + "returnLocalShort": "Go local", + "returningShort": "Returning…", "reconnecting": "Reconnecting to {{name}}. Content may be out of date." }, "peerDirectoryPicker": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 33b65577c3..9ac6217de8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -719,6 +719,9 @@ "errorCreateFailed": "创建工作区失败" }, "peerConnection": { + "reconnectingShort": "正在重连…", + "returnLocalShort": "返回本机", + "returningShort": "正在返回…", "reconnecting": "正在重新连接「{{name}}」。内容可能暂未更新。" }, "peerDirectoryPicker": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 593afcba08..58cc951e8f 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -719,6 +719,9 @@ "errorCreateFailed": "建立工作區失敗" }, "peerConnection": { + "reconnectingShort": "正在重新連線…", + "returnLocalShort": "返回本機", + "returningShort": "正在返回…", "reconnecting": "正在重新連線至「{{name}}」。內容可能尚未更新。" }, "peerDirectoryPicker": {