Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -164,6 +165,7 @@ const PersistentFooterActions: React.FC = () => {

return (
<>
<PeerConnectionStatus />
<div className="openbitfun-nav-panel__footer" data-openbitfun-component="nav-panel" data-openbitfun-part="footer">
<div className="openbitfun-nav-panel__footer-left">
<DeviceStatusControl
Expand Down
1 change: 0 additions & 1 deletion src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,6 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
isExiting={transitionDir === 'returning'}
/>
</main>
<PeerConnectionStatus />

{/* Hello stays available across every client scene, including Welcome. */}
<Suspense fallback={null}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,26 @@ 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);
const connection = await manager.connect('peer-1', 'Studio');

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);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 16 additions & 11 deletions src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ interface ConnectionEntry {
timer: ReturnType<typeof setTimeout> | null;
disposed: boolean;
presenceOnline: boolean | null;
probeRequested: boolean;
needsReattach: boolean;
healthCheckInFlight: Promise<void> | null;
reattachInFlight: Promise<void> | null;
handle: PeerConnection;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -379,6 +381,8 @@ export class PeerConnectionManager {
timer: null,
disposed: false,
presenceOnline: null,
probeRequested: false,
needsReattach: false,
healthCheckInFlight: null,
reattachInFlight: null,
handle: {
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
34 changes: 17 additions & 17 deletions src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -35,23 +36,22 @@ export const PeerConnectionStatus: React.FC = () => {
data-openbitfun-component="peer-device"
data-openbitfun-part="connectionStatus"
>
<Alert
tone="warning"
data-testid="peer-connection-status"
message={(
<span
className="peer-connection-status__content"
data-openbitfun-component="peer-device"
data-openbitfun-part="connectionStatusContent"
>
<span>{t('peerConnection.reconnecting', { name: peer.peerMode.deviceName })}</span>
<Button variant="outline" size="sm" disabled={returning} onClick={() => { void returnLocal(); }}>
{t(returning ? 'deviceOverview.returningToThisDevice' : 'deviceOverview.backToThisDevice')}
</Button>
<div
className="peer-connection-status__content"
data-openbitfun-component="peer-device"
data-openbitfun-part="connectionStatusContent"
>
<Tooltip content={t('peerConnection.reconnecting', { name: peer.peerMode.deviceName })}>
<span className="peer-connection-status__label" role="status" aria-live="polite">
<WifiOff size={13} aria-hidden="true" />
<span>{t('peerConnection.reconnectingShort')}</span>
</span>
)}
description={error ?? undefined}
/>
</Tooltip>
<Button variant="text" size="sm" disabled={returning} onClick={() => { void returnLocal(); }}>
{t(returning ? 'peerConnection.returningShort' : 'peerConnection.returnLocalShort')}
</Button>
</div>
{error && <span className="peer-connection-status__error" role="alert">{error}</span>}
</div>
);
};
7 changes: 5 additions & 2 deletions src/web-ui/src/infrastructure/peer-device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,9 @@
"errorCreateFailed": "创建工作区失败"
},
"peerConnection": {
"reconnectingShort": "正在重连…",
"returnLocalShort": "返回本机",
"returningShort": "正在返回…",
"reconnecting": "正在重新连接「{{name}}」。内容可能暂未更新。"
},
"peerDirectoryPicker": {
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,9 @@
"errorCreateFailed": "建立工作區失敗"
},
"peerConnection": {
"reconnectingShort": "正在重新連線…",
"returnLocalShort": "返回本機",
"returningShort": "正在返回…",
"reconnecting": "正在重新連線至「{{name}}」。內容可能尚未更新。"
},
"peerDirectoryPicker": {
Expand Down
Loading