From aabcc57366cba940c1f533b28e63ac34ba6fffa7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:25:17 -0700 Subject: [PATCH] fix(runtime): publish remote control outages to host surfaces (#17531) * fix(runtime): publish remote control diagnostics to renderer * test(runtime): account for diagnostics bridge listener * fix(i18n): add runtime connection state labels * test(runtime): clean up shared control connection * fix(runtime): fence diagnostics by shared-control capability * fix(runtime): preserve authoritative transport state * fix(runtime): preserve diagnostic overlay lifecycle * fix(runtime): avoid publishing unchanged diagnostics state --------- Co-authored-by: Merge Sim --- src/cli/format.ts | 1 + src/cli/runtime/client.ts | 5 + src/cli/runtime/status.test.ts | 1 + src/cli/runtime/status.ts | 6 + ...-environment-diagnostics-broadcast.test.ts | 40 +++++ ...ntime-environment-diagnostics-broadcast.ts | 26 +++ ...runtime-environment-request-connections.ts | 19 ++- src/main/ssh/ssh-remote-cli-format.ts | 1 + src/main/ssh/ssh-remote-orca-cli.ts | 2 + src/preload/api/runtime-api.ts | 8 + src/preload/index.ts | 20 +++ .../settings/RuntimeEnvironmentsPane.test.ts | 30 ++++ .../runtime-environment-host-details.ts | 28 +++- .../settings/runtime-server-row.tsx | 23 ++- .../ipc-events/app-lifetime-ipc-bridge.ts | 8 + .../src/hooks/useIpcEvents-lifecycle.test.ts | 5 +- src/renderer/src/i18n/locales/en.json | 2 + .../runtime/runtime-host-connection-state.ts | 79 +--------- .../runtime-status-connection-generation.ts | 24 +++ .../runtime-status-diagnostics-generation.ts | 52 ++++++ .../runtime-status-diagnostics-publish.ts | 80 ++++++++++ .../slices/runtime-status-diagnostics.test.ts | 89 +++++++++++ .../store/slices/runtime-status-recheck.ts | 29 ++++ .../src/store/slices/runtime-status.ts | 68 ++++---- ...ntime-shared-control-connection-actions.ts | 93 +++++++++++ ...runtime-shared-control-connection-frame.ts | 55 +++++++ ...-runtime-shared-control-connection.test.ts | 13 +- ...emote-runtime-shared-control-connection.ts | 149 +++++++++--------- ...mote-runtime-shared-control-diagnostics.ts | 74 +++++++++ ...emote-runtime-shared-control-ready-wait.ts | 29 ++++ .../remote-runtime-shared-control-types.ts | 2 + src/shared/runtime-environment-diagnostics.ts | 2 + src/shared/runtime-host-connection-state.ts | 58 +++++++ src/shared/runtime-session-contracts.ts | 3 + 34 files changed, 926 insertions(+), 198 deletions(-) create mode 100644 src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts create mode 100644 src/main/ipc/runtime-environment-diagnostics-broadcast.ts create mode 100644 src/renderer/src/store/slices/runtime-status-connection-generation.ts create mode 100644 src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts create mode 100644 src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts create mode 100644 src/renderer/src/store/slices/runtime-status-diagnostics.test.ts create mode 100644 src/shared/remote-runtime-shared-control-connection-actions.ts create mode 100644 src/shared/remote-runtime-shared-control-connection-frame.ts create mode 100644 src/shared/remote-runtime-shared-control-diagnostics.ts create mode 100644 src/shared/remote-runtime-shared-control-ready-wait.ts create mode 100644 src/shared/runtime-environment-diagnostics.ts create mode 100644 src/shared/runtime-host-connection-state.ts diff --git a/src/cli/format.ts b/src/cli/format.ts index 8707ceef092..0a297138364 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -245,6 +245,7 @@ export function formatCliStatus(status: CliStatusResult): string { `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, + `runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, `graphState: ${status.graph.state}` ].join('\n') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9a855ae929..86b4869e9d8 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { isOrchestrationMutation, @@ -207,6 +208,10 @@ export class RuntimeClient { runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, + connectionState: runtimeHostConnectionState({ + hasStatusEntry: true, + status: response.result + }), runtimeId: response.result.runtimeId, ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), ...(response.result.remoteUpdateSupport diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 9cc595122ef..4c62be8d977 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -78,6 +78,7 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { expect(status.result.runtime).toMatchObject({ reachable: true, + connectionState: 'connected', runtimeId: 'runtime-legacy', state: 'ready', degradations: [expect.objectContaining({ code: 'browser_unavailable' })] diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index e4a181a8d51..8736f4cc177 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -1,4 +1,5 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import { findTransport } from '../../shared/runtime-bootstrap' import { tryReadMetadata } from './metadata' import { sendRequest } from './transport' @@ -51,6 +52,10 @@ export async function getCliStatus( runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, + connectionState: runtimeHostConnectionState({ + hasStatusEntry: true, + status: response.result + }), runtimeId: response.result.runtimeId, ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), ...(response.result.remoteUpdateSupport @@ -73,6 +78,7 @@ export async function getCliStatus( runtime: { state: running ? 'starting' : 'stale_bootstrap', reachable: false, + connectionState: 'disconnected', runtimeId: null }, graph: { diff --git a/src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts b/src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts new file mode 100644 index 00000000000..bac6f7b58ff --- /dev/null +++ b/src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getAllWindows = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows } })) + +import { + publishRuntimeEnvironmentDiagnostics, + RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL +} from './runtime-environment-diagnostics-broadcast' + +describe('runtime environment diagnostics broadcast', () => { + beforeEach(() => getAllWindows.mockReset()) + + it('publishes to live renderer windows and skips destroyed windows', () => { + const live = { isDestroyed: () => false, webContents: { send: vi.fn() } } + const destroyed = { isDestroyed: () => true, webContents: { send: vi.fn() } } + getAllWindows.mockReturnValue([live, destroyed]) + const event = { + environmentId: 'env-a', + transportGeneration: 2, + diagnostics: { + state: 'reconnecting' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 1, + lastConnectedAt: 1, + lastClose: null, + lastError: 'offline' + } + } + + publishRuntimeEnvironmentDiagnostics(event) + + expect(live.webContents.send).toHaveBeenCalledWith( + RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, + event + ) + expect(destroyed.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environment-diagnostics-broadcast.ts b/src/main/ipc/runtime-environment-diagnostics-broadcast.ts new file mode 100644 index 00000000000..3701170ee16 --- /dev/null +++ b/src/main/ipc/runtime-environment-diagnostics-broadcast.ts @@ -0,0 +1,26 @@ +import { BrowserWindow } from 'electron' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' + +export { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } + +export type RuntimeEnvironmentDiagnosticsEvent = { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics +} + +export function publishRuntimeEnvironmentDiagnostics( + event: RuntimeEnvironmentDiagnosticsEvent +): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event) + } catch { + // A renderer can disappear between isDestroyed() and send(). + } + } +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 3076405dbf8..c1f855697e5 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -12,6 +12,11 @@ import type { } from '../../shared/remote-runtime-shared-control-types' import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' +import { publishRuntimeEnvironmentDiagnostics } from './runtime-environment-diagnostics-broadcast' +import { + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' type CachedRuntimeConnection = { pairingKey: string @@ -142,14 +147,26 @@ function getSharedControlConnection( const pairingKey = getPairingKey(pairing) let cached = sharedControlConnections.get(environmentId) if (!cached || cached.pairingKey !== pairingKey) { + advanceRuntimeEnvironmentTransportGeneration(environmentId) cached?.connection.close() + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environmentId) cached = { pairingKey, connection: new RemoteRuntimeSharedControlConnection(pairing, { environmentId, clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId), - isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId) + isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId), + onDiagnosticsChanged: (diagnostics) => { + if (getRuntimeEnvironmentTransportGeneration(environmentId) !== transportGeneration) { + return + } + publishRuntimeEnvironmentDiagnostics({ + environmentId, + transportGeneration, + diagnostics + }) + } }) } sharedControlConnections.set(environmentId, cached) diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index 744c9f8e6cf..40cbaf57f27 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -37,6 +37,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, + `runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, `graphState: ${status.graph.state}` ].join('\n')}\n`, diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 9019a10e9b5..e7538a21baf 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -1,4 +1,5 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import { projectRemoteAppStatus } from '../../shared/cli-app-status-projection' import { randomUUID } from 'node:crypto' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' @@ -182,6 +183,7 @@ async function dispatchRemoteCli( runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, + connectionState: runtimeHostConnectionState({ hasStatusEntry: true, status }), runtimeId: status.runtimeId }, graph: { state: status.graphStatus } diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index 353fb3cf74f..f7f553ec2c0 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -13,6 +13,7 @@ import type { BrowserClientHostPlacementPreparationRequest, BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' export type RuntimeEnvironmentSubscriptionHandle = { unsubscribe: () => void @@ -101,6 +102,13 @@ export type RuntimeApi = { observeOnly?: true }) => Promise> retryControlConnection?: (args: { selector: string }) => Promise + onSharedControlDiagnostics?: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ) => () => void prepareBrowserClientHostPlacement: ( args: BrowserClientHostPlacementPreparationRequest ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 4a22185c28c..16f7b82e712 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -197,6 +197,8 @@ import type { RuntimeTerminalPresentation } from '../shared/runtime-types' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../shared/runtime-environment-diagnostics' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' import type { @@ -4784,6 +4786,24 @@ const api = { ipcRenderer.invoke('runtimeEnvironments:getStatus', args), retryControlConnection: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), + onSharedControlDiagnostics: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + } + ): void => callback(data) + ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + }, prepareBrowserClientHostPlacement: (args) => ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), retryConnectionsNow: (): Promise => diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 8c001f95086..2d05324bc92 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -203,6 +203,36 @@ describe('RuntimeEnvironmentsPane host details', () => { ).toBe('disconnected') }) + it.each(['closed', 'reconnecting'] as const)( + 'does not keep a ready details cache green when shared control is %s', + (state) => { + expect( + getRuntimeServerConnectionState( + details({ + status: 'ready', + runtimeStatus: { + runtimeId: 'runtime-live', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + remoteControl: { + state, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 1, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + } + }) + ) + ).not.toBe('connected') + } + ) + it('explains that selecting a saved server is the explicit default Host mode', () => { expect(getActiveServerModeDescription(true)).toContain('Use this computer by default') expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff') diff --git a/src/renderer/src/components/settings/runtime-environment-host-details.ts b/src/renderer/src/components/settings/runtime-environment-host-details.ts index 03030733e97..11a950c3278 100644 --- a/src/renderer/src/components/settings/runtime-environment-host-details.ts +++ b/src/renderer/src/components/settings/runtime-environment-host-details.ts @@ -12,6 +12,10 @@ import { WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { + runtimeHostConnectionState, + type RuntimeHostConnectionState +} from '../../../../shared/runtime-host-connection-state' export type RuntimeHostDetails = { status: 'loading' | 'ready' | 'error' @@ -147,7 +151,7 @@ export function isRuntimeEnvironmentRemovalBlocked( return activeRuntimeEnvironmentId === environmentId } -export type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected' +export type RuntimeServerConnectionState = RuntimeHostConnectionState export function getRuntimeServerConnectionState( details: RuntimeHostDetails | undefined @@ -158,11 +162,11 @@ export function getRuntimeServerConnectionState( if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { return 'disconnected' } - // Why: an attached, reachable, compatible host is "Connected" (and exposes - // Disconnect). Whether it is the default *active* server is a separate concept, - // surfaced by the Advanced > Active Server selector and the row's help text — - // it must not change this connection label, or the dot/label/button disagree. - return 'connected' + // Older clients can report a ready details phase without embedding RuntimeStatus. + if (details.runtimeStatus === null) { + return 'connected' + } + return runtimeHostConnectionState({ hasStatusEntry: true, status: details.runtimeStatus }) } export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string { @@ -172,11 +176,21 @@ export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionSt 'auto.components.settings.RuntimeEnvironmentsPane.serverConnected', 'Connected' ) + case 'workspace-window-closed': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverWorkspaceWindowClosed', + 'Workspace window closed' + ) case 'checking': return translate( 'auto.components.settings.RuntimeEnvironmentsPane.serverChecking', 'Checking…' ) + case 'reconnecting': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverReconnecting', + 'Reconnecting' + ) case 'disconnected': return translate( 'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected', @@ -190,6 +204,8 @@ export function getRuntimeServerDotClass(state: RuntimeServerConnectionState): s case 'connected': return 'bg-emerald-500' case 'checking': + case 'workspace-window-closed': + case 'reconnecting': return 'bg-yellow-500' case 'disconnected': return 'bg-muted-foreground/40' diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx index 965e99d1ffb..eba38caf763 100644 --- a/src/renderer/src/components/settings/runtime-server-row.tsx +++ b/src/renderer/src/components/settings/runtime-server-row.tsx @@ -3,6 +3,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' import { Button } from '../ui/button' import { getHostDetailsDescription, @@ -51,9 +52,27 @@ export function RuntimeServerRow({ onRemove }: RuntimeServerRowProps): React.JSX.Element { const detailsDescription = getHostDetailsDescription(details) - const connectionState = getRuntimeServerConnectionState(details) + const runtimeStatusEntry = useAppStore((state) => + state.runtimeStatusByEnvironmentId.get(environment.id) + ) + const connectionState = + details?.status === 'loading' && !runtimeStatusEntry?.status + ? 'checking' + : runtimeStatusEntry + ? getRuntimeServerConnectionState({ + ...(details ?? { + status: runtimeStatusEntry.status ? 'ready' : 'error', + runtimeStatus: null, + compatibility: null, + error: null + }), + status: runtimeStatusEntry.status ? 'ready' : 'error', + runtimeStatus: runtimeStatusEntry.status + }) + : getRuntimeServerConnectionState(details) // A connected host exposes Disconnect; otherwise Connect. - const isReachable = connectionState === 'connected' + const isReachable = + connectionState === 'connected' || connectionState === 'workspace-window-closed' const actionBusy = connecting || switching || disconnecting || removing return ( diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 5704f8e61f7..7aa5dd418cf 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -60,6 +60,14 @@ export function installAppLifetimeIpcEvents( ) const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) + const onSharedControlDiagnostics = window.api.runtimeEnvironments?.onSharedControlDiagnostics + if (onSharedControlDiagnostics) { + unsubs.push( + onSharedControlDiagnostics((event) => { + useAppStore.getState().publishRuntimeEnvironmentDiagnostics(event) + }) + ) + } const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) registerProjectCatalogIpcBridge( unsubs, diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index ea84f1b2811..5155aa6881f 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -28,6 +28,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'runtime.onNativeChatLaunchDraftResolved', 'runtime.onTerminalDriverChanged', 'runtime.onTerminalFitOverrideChanged', + 'runtimeEnvironments.onSharedControlDiagnostics', 'settings.onChanged', 'ssh.onCredentialRequest', 'ssh.onCredentialResolved', @@ -102,6 +103,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onMobileMarkdownRequest', 'automations.onChanged', + 'runtimeEnvironments.onSharedControlDiagnostics', 'repos.onChanged', 'worktrees.onChanged', 'worktrees.onHeadIdentitiesChanged', @@ -374,8 +376,9 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ).toEqual([ 'ui.onMobileMarkdownRequest', 'automations.onChanged', + 'runtimeEnvironments.onSharedControlDiagnostics', 'runtimeEnvironments.subscribe', - ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(2) + ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(3) ]) const groupOrder = (names: readonly string[]): string[] => registrationOrder.filter((entry) => names.includes(entry)) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 38548079d65..94d2857da25 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7926,7 +7926,9 @@ "3f67e8078a": "Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.", "2c85efb3e8": "Selecting a saved server makes this browser use that paired Orca runtime as its default Host.", "serverConnected": "Connected", + "serverWorkspaceWindowClosed": "Workspace window closed", "serverChecking": "Checking…", + "serverReconnecting": "Reconnecting", "serverDisconnected": "Disconnected", "disconnectedServer": "Disconnected from {{value0}}.", "connectToRemoteServers": "Connect to remote servers", diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index c3e65533f2e..a2ab00b561c 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -1,72 +1,7 @@ -import type { RuntimeStatus } from '../../../shared/runtime-types' -import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability' - -export type HostStatus = 'connected' | 'disconnected' | 'connecting' - -// Why: 'workspace-window-closed' is a reachable host that cannot serve graph-backed -// work — connected for counting purposes, but not interchangeable with 'connected'. -export type RuntimeHostConnectionState = - | 'connected' - | 'workspace-window-closed' - | 'checking' - | 'reconnecting' - | 'disconnected' - -// Why: one derivation for every host surface (status bar + Settings > Available Hosts), -// so a degraded host can never read "Connected" in one place and "Ready" in the other. -export function runtimeHostConnectionState({ - hasStatusEntry, - status -}: { - hasStatusEntry: boolean - status: RuntimeStatus | null | undefined -}): RuntimeHostConnectionState { - if (!hasStatusEntry) { - return 'checking' - } - const remoteControl = status?.remoteControl - if (remoteControl?.state === 'reconnecting') { - return 'reconnecting' - } - if (!status) { - return 'disconnected' - } - // Why no lastError requirement: a clean close (server restart, host sleep, network - // blip) leaves lastError null, and demanding an error string painted those hosts green. - if (remoteControl?.state === 'closed') { - return 'disconnected' - } - // Why: the socket is up but ready/auth has not completed, so nothing can run there yet. - if (remoteControl && remoteControl.state !== 'ready') { - return 'checking' - } - // Why: reachable but graph-less — the transport is fine, so this is not a network - // disconnect, but calling it "Connected" hides that nothing will run there. - if (isRuntimeWorkspaceWindowClosed(status)) { - return 'workspace-window-closed' - } - // Why: "connected" means attached/reachable, NOT "is the active default host". - // Both surfaces must agree on that single definition, or a reachable-but-not-active - // host reads "Connected" in one place and "Available" in the other. Active/default is - // a separate concept (surfaced elsewhere), so it must not change this state. - return 'connected' -} - -export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { - switch (state) { - // Why: a closed workspace window is a degraded host, not a lost connection — - // it must keep counting toward the connected-host total. - case 'connected': - case 'workspace-window-closed': - return 'connected' - case 'checking': - case 'reconnecting': - return 'connecting' - case 'disconnected': - return 'disconnected' - } -} - -export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { - return state === 'connected' || state === 'workspace-window-closed' -} +export { + isConnectedRuntimeHostState, + runtimeHostConnectionState, + runtimeStatusForOverall, + type HostStatus, + type RuntimeHostConnectionState +} from '../../../shared/runtime-host-connection-state' diff --git a/src/renderer/src/store/slices/runtime-status-connection-generation.ts b/src/renderer/src/store/slices/runtime-status-connection-generation.ts new file mode 100644 index 00000000000..2ad1e0d2504 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-connection-generation.ts @@ -0,0 +1,24 @@ +const connectionGenerationByEnvironment = new Map() + +export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { + return connectionGenerationByEnvironment.get(environmentId) ?? 0 +} + +export function setRuntimeEnvironmentConnectionGenerationForTests( + environmentId: string, + generation: number +): void { + connectionGenerationByEnvironment.set(environmentId, generation) +} + +export function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { + const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 + connectionGenerationByEnvironment.set(environmentId, next) + return next +} + +export function clearRuntimeEnvironmentConnectionGenerations(): Iterable { + const environmentIds = [...connectionGenerationByEnvironment.keys()] + connectionGenerationByEnvironment.clear() + return environmentIds +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts new file mode 100644 index 00000000000..22ba23cbbb1 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts @@ -0,0 +1,52 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' +import type { RuntimeEnvironmentStatus } from './runtime-status' + +const diagnosticsGenerationByEnvironment = new Map() + +export function updateRuntimeEnvironmentStatusOverlay( + state: Map, + environmentId: string, + status: RuntimeEnvironmentStatus +): Map { + const current = state.get(environmentId) + if (!current || current.status?.runtimeId !== status.status?.runtimeId) { + return state + } + return new Map(state).set(environmentId, status) +} + +export function acceptRuntimeEnvironmentDiagnosticsGeneration( + environmentId: string, + transportGeneration: number +): boolean { + const previous = diagnosticsGenerationByEnvironment.get(environmentId) + if (previous !== undefined && transportGeneration < previous) { + return false + } + diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration) + return true +} + +export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void { + diagnosticsGenerationByEnvironment.clear() +} + +export function mergePushedRuntimeEnvironmentDiagnostics(args: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + current: RuntimeEnvironmentStatus | undefined + publish: (status: RuntimeEnvironmentStatus) => void +}): void { + if ( + !args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) || + !acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration) + ) { + return + } + args.publish({ + ...args.current, + status: { ...args.current.status, remoteControl: args.diagnostics } + }) +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts new file mode 100644 index 00000000000..59dee33eadd --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts @@ -0,0 +1,80 @@ +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' +import type { AppState } from '../types' +import type { RuntimeEnvironmentStatus } from './runtime-status' +import * as diagnosticsGeneration from './runtime-status-diagnostics-generation' + +export function updateRuntimeStatusStore( + state: AppState, + updater: (state: Map) => Map +): AppState | Pick { + const next = updater(state.runtimeStatusByEnvironmentId) + return next === state.runtimeStatusByEnvironmentId + ? state + : { runtimeStatusByEnvironmentId: next } +} + +export function publishRuntimeEnvironmentDiagnostics(args: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + getCurrent: () => RuntimeEnvironmentStatus | undefined + updateState: (status: RuntimeEnvironmentStatus) => boolean + afterPublish?: (status: RuntimeEnvironmentStatus) => void +}): void { + diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({ + environmentId: args.environmentId, + transportGeneration: args.transportGeneration, + diagnostics: args.diagnostics, + current: args.getCurrent(), + publish: (status) => { + if (args.updateState(status)) { + args.afterPublish?.(status) + } + } + }) +} + +export function applyRuntimeEnvironmentStatusOverlay(args: { + environmentId: string + status: RuntimeEnvironmentStatus + setState: ( + updater: (state: Map) => Map + ) => void +}): boolean { + let updated = false + args.setState((state) => { + const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay( + state, + args.environmentId, + args.status + ) + updated = next !== state + return next + }) + return updated +} + +export function createRuntimeEnvironmentDiagnosticsPublisher(args: { + getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined + setState: ( + updater: (state: Map) => Map + ) => void + afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void +}): (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics +}) => void { + return (event) => + publishRuntimeEnvironmentDiagnostics({ + ...event, + getCurrent: () => args.getCurrent(event.environmentId), + updateState: (status) => + applyRuntimeEnvironmentStatusOverlay({ + environmentId: event.environmentId, + status, + setState: args.setState + }), + afterPublish: (status) => args.afterPublish(event.environmentId, status) + }) +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts new file mode 100644 index 00000000000..9faecdcabf1 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { create } from 'zustand' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' + +function makeStatus(overrides: Partial = {}): RuntimeStatus { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 3, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3, + capabilities: ['browser.screencast.v1'], + ...overrides + } as RuntimeStatus +} + +function createSliceStore() { + return create()((...a) => ({ + ...createRuntimeStatusSlice(...(a as unknown as Parameters)) + })) +} + +describe('runtime-status diagnostics', () => { + it('merges transport diagnostics into the complete status and fences stale pushes', () => { + const store = createSliceStore() + const status = makeStatus({ + capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) + const closed = { + state: 'closed' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 1, + lastClose: { code: 1006, reason: 'network' }, + lastError: 'connection lost' + } + store.getState().publishRuntimeEnvironmentDiagnostics({ + environmentId: 'env-a', + transportGeneration: 3, + diagnostics: closed + }) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({ + runtimeId: 'runtime-a', + capabilities: expect.arrayContaining([ + 'browser.screencast.v1', + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY + ]), + liveTabCount: 3, + remoteControl: closed + }) + store.getState().publishRuntimeEnvironmentDiagnostics({ + environmentId: 'env-a', + transportGeneration: 2, + diagnostics: { ...closed, state: 'ready' } + }) + expect( + store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state + ).toBe('closed') + }) + + it('ignores diagnostics after the latest status drops shared-control support', () => { + const store = createSliceStore() + const status = makeStatus({ capabilities: [] }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) + + store.getState().publishRuntimeEnvironmentDiagnostics({ + environmentId: 'env-a', + transportGeneration: 3, + diagnostics: { + state: 'reconnecting', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 1, + lastClose: { code: 1006, reason: 'network' }, + lastError: 'connection lost' + } + }) + + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status-recheck.ts b/src/renderer/src/store/slices/runtime-status-recheck.ts index bd2d922fd3f..600bb28f796 100644 --- a/src/renderer/src/store/slices/runtime-status-recheck.ts +++ b/src/renderer/src/store/slices/runtime-status-recheck.ts @@ -15,6 +15,14 @@ type RecheckState = { publish: (status: RuntimeStatus | null) => void } +type RuntimeStatusStore = { + runtimeEnvironments: readonly { id: string }[] + setRuntimeEnvironmentStatus: ( + environmentId: string, + status: { status: RuntimeStatus | null; checkedAt: number } + ) => void +} + const rechecks = new Map() export function reconcileRuntimeStatusRecheck(args: { @@ -55,6 +63,27 @@ export function reconcileRuntimeStatusRecheck(args: { armRuntimeStatusRecheck(args.environmentId, state) } +export function reconcileRuntimeStatusForSlice( + environmentId: string, + status: RuntimeStatus | null, + get: () => RuntimeStatusStore, + getConnectionGeneration: () => number +): void { + reconcileRuntimeStatusRecheck({ + environmentId, + status, + connectionGeneration: getConnectionGeneration(), + environmentExists: () => + get().runtimeEnvironments.some((environment) => environment.id === environmentId), + getConnectionGeneration, + publish: (nextStatus) => + get().setRuntimeEnvironmentStatus(environmentId, { + status: nextStatus, + checkedAt: Date.now() + }) + }) +} + export function cancelRuntimeStatusRecheck(environmentId: string): void { const state = rechecks.get(environmentId) if (!state) { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 2dca89c2432..b479b44670f 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' import { clearRecentRuntimeCompatibilityFailure, @@ -16,13 +17,19 @@ import { import { reconcileCatalogRows } from './repo-identity-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' +import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation' +import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish' +import { + advanceRuntimeEnvironmentConnectionGeneration, + clearRuntimeEnvironmentConnectionGenerations, + getRuntimeEnvironmentConnectionGeneration +} from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { ensureBrowserClientHostForRestartedRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' import * as runtimeStatusRecheck from './runtime-status-recheck' - /** Live status for one saved runtime environment, as last observed by the * renderer. `status === null` records a probe that failed or timed out so the * sidebar can still distinguish "unknown/unreachable" from "never checked". */ @@ -75,6 +82,12 @@ export type RuntimeStatusSlice = { status: RuntimeEnvironmentStatus, options?: { suppressDisconnectToast?: boolean } ) => void + /** Merges main-owned transport diagnostics into a complete runtime status snapshot. */ + publishRuntimeEnvironmentDiagnostics: (args: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void /** Drops a removed environment so stale hosts don't linger in the registry. */ clearRuntimeEnvironmentStatus: (environmentId: string) => void /** Drops every entry whose id is not in the saved-environments set. */ @@ -92,28 +105,14 @@ export type RuntimeStatusSlice = { hydrateRuntimeEnvironmentStatuses: () => Promise } -const connectionGenerationByEnvironment = new Map() - -export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { - return connectionGenerationByEnvironment.get(environmentId) ?? 0 -} +export { + getRuntimeEnvironmentConnectionGeneration, + setRuntimeEnvironmentConnectionGenerationForTests +} from './runtime-status-connection-generation' export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => { - runtimeStatusRecheck.cancelRuntimeStatusRechecks(connectionGenerationByEnvironment.keys()) - connectionGenerationByEnvironment.clear() -} - -export const setRuntimeEnvironmentConnectionGenerationForTests = ( - environmentId: string, - generation: number -): void => { - connectionGenerationByEnvironment.set(environmentId, generation) -} - -function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { - const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 - connectionGenerationByEnvironment.set(environmentId, next) - return next + runtimeStatusRecheck.cancelRuntimeStatusRechecks(clearRuntimeEnvironmentConnectionGenerations()) + runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests() } export const createRuntimeStatusSlice: StateCreator = ( @@ -287,19 +286,9 @@ export const createRuntimeStatusSlice: StateCreator - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration: () => getRuntimeEnvironmentConnectionGeneration(environmentId), - publish: (nextStatus) => - get().setRuntimeEnvironmentStatus(environmentId, { - status: nextStatus, - checkedAt: Date.now() - }) - }) + runtimeStatusRecheck.reconcileRuntimeStatusForSlice(environmentId, status.status, get, () => + getRuntimeEnvironmentConnectionGeneration(environmentId) + ) if (runtimeRestarted) { void ensureBrowserClientHostForRestartedRuntime(get(), environmentId) } @@ -312,6 +301,17 @@ export const createRuntimeStatusSlice: StateCreator get().runtimeStatusByEnvironmentId.get(environmentId), + setState: (updater) => + set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)), + afterPublish: (environmentId, status) => + runtimeStatusRecheck.reconcileRuntimeStatusForSlice(environmentId, status.status, get, () => + getRuntimeEnvironmentConnectionGeneration(environmentId) + ) + }), + clearRuntimeEnvironmentStatus: (environmentId) => { runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId) dismissRuntimeDisconnectedToast(environmentId) diff --git a/src/shared/remote-runtime-shared-control-connection-actions.ts b/src/shared/remote-runtime-shared-control-connection-actions.ts new file mode 100644 index 00000000000..301018c47da --- /dev/null +++ b/src/shared/remote-runtime-shared-control-connection-actions.ts @@ -0,0 +1,93 @@ +import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' +import * as sharedControlState from './remote-runtime-shared-control-state' +import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close' +import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' +import * as sharedControlSend from './remote-runtime-shared-control-send' +import type { + SharedControlLogicalSubscription, + SharedControlPendingRequest +} from './remote-runtime-shared-control-types' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' + +export function sendSharedControlRequest(args: { + pendingRequests: Map> + requestId: string + state: Parameters[0]['state'] + ws: Parameters[0]['ws'] + sharedKey: Parameters< + typeof sharedControlProtocol.sendSharedControlEncryptedSerialized + >[0]['sharedKey'] +}): void { + sharedControlSend.sendSharedControlRequest({ + pendingRequests: args.pendingRequests, + requestId: args.requestId, + send: (serialized) => + sharedControlProtocol.sendSharedControlEncryptedSerialized({ + state: args.state, + ws: args.ws, + sharedKey: args.sharedKey, + serialized + }), + reject: (id, error) => + sharedControlState.rejectSharedControlPendingRequest(args.pendingRequests, id, error) + }) +} + +export function sendSharedControlSubscription(args: { + subscriptions: Map> + subscription: SharedControlLogicalSubscription + deviceToken: string + send: (payload: unknown) => boolean +}): void { + sharedControlSend.sendSharedControlSubscription(args) +} + +export function replaySharedControlSubscriptions(args: { + subscriptions: Map> + send: (subscription: SharedControlLogicalSubscription) => void + tagReplayedResponses: boolean +}): boolean { + sharedControlSubscriptions.replaySharedControlSubscriptions(args) + return true +} + +export function replayRuntimeControlSubscriptions(args: { + subscriptions: Map> + deviceToken: string + send: (payload: unknown) => boolean + tagReplayedResponses: boolean +}): boolean { + return replaySharedControlSubscriptions({ + subscriptions: args.subscriptions, + send: (subscription) => + sendSharedControlSubscription({ + subscriptions: args.subscriptions, + subscription, + deviceToken: args.deviceToken, + send: args.send + }), + tagReplayedResponses: args.tagReplayedResponses + }) +} + +export function closeSharedControlSubscription(args: { + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + requestId: string + deviceToken: string + send: (payload: unknown) => boolean +}): void { + closeSharedControlConnectionSubscription(args) +} + +export function closeRuntimeControlSubscription(args: { + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + requestId: string + deviceToken: string + send: (payload: unknown) => boolean + clearWhenIdle: (isIdle: boolean) => void +}): void { + closeSharedControlSubscription(args) + args.clearWhenIdle(args.subscriptions.size === 0) +} diff --git a/src/shared/remote-runtime-shared-control-connection-frame.ts b/src/shared/remote-runtime-shared-control-connection-frame.ts new file mode 100644 index 00000000000..a55dcf3c5a8 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-connection-frame.ts @@ -0,0 +1,55 @@ +import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler' +import type { RemoteRuntimeClientError } from './remote-runtime-client-error' +import type { RuntimeCapability } from './protocol-version' +import type { + SharedControlConnectionState, + SharedControlLogicalSubscription, + SharedControlPendingRequest, + SharedControlReadyWaiter +} from './remote-runtime-shared-control-types' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' + +export function handleRuntimeControlTextFrame(args: { + frame: string + socketGeneration: number + isCurrent: (generation: number) => boolean + getState: () => SharedControlConnectionState + getSharedKey: () => Uint8Array | null + environmentId?: string + deviceToken: string + clientCapabilities: readonly RuntimeCapability[] + pendingRequests: Map> + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + readyWaiters: SharedControlReadyWaiter[] + setState: (state: SharedControlConnectionState) => void + handleSocketClosed: (error: RemoteRuntimeClientError) => void + sendEncrypted: (payload: unknown) => boolean + markReady: () => void + replaySubscriptions: () => void + publishDiagnostics: () => void +}): void { + if (!args.isCurrent(args.socketGeneration)) { + return + } + handleSharedControlTextFrame({ + frame: args.frame, + state: args.getState(), + sharedKey: args.getSharedKey(), + environmentId: args.environmentId, + deviceToken: args.deviceToken, + clientCapabilities: args.clientCapabilities, + pendingRequests: args.pendingRequests, + subscriptions: args.subscriptions, + retiredRequestIds: args.retiredRequestIds, + readyWaiters: args.readyWaiters, + setState: (state) => { + args.setState(state) + args.publishDiagnostics() + }, + handleSocketClosed: args.handleSocketClosed, + sendEncrypted: args.sendEncrypted, + markReady: args.markReady, + replaySubscriptions: args.replaySubscriptions + }) +} diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index f6832ccae08..acd804e1f99 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -22,7 +22,10 @@ afterEach(closeSharedControlTestServers) describe('RemoteRuntimeSharedControlConnection', () => { it('routes multiple one-shot RPCs over one authenticated WebSocket', async () => { const server = await createServer() - const connection = new RemoteRuntimeSharedControlConnection(server.pairing) + const states: string[] = [] + const connection = new RemoteRuntimeSharedControlConnection(server.pairing, { + onDiagnosticsChanged: ({ state }) => states.push(state) + }) const first = await connection.request('worktree.ps', undefined, 1000) const second = await connection.request('session.tabs.listAll', null, 1000) @@ -39,8 +42,9 @@ describe('RemoteRuntimeSharedControlConnection', () => { 'worktree.ps', 'session.tabs.listAll' ]) - - connection.close() + expect((connection.close(), states)).toEqual( + expect.arrayContaining(['awaiting_ready', 'ready', 'closed']) + ) }) it('preserves orchestration authority fields on shared-control requests', async () => { @@ -679,7 +683,8 @@ describe('RemoteRuntimeSharedControlConnection', () => { pendingRequestCount: 0, lastClose: { code: 4001, reason: 'test close' } }) - + connection.pauseStandingRetry() + expect(connection.getDiagnostics()).toMatchObject({ state: 'closed' }) connection.close() }) }) diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 5c6c07e03bc..58375f36d42 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -4,24 +4,30 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import { openSharedControlSocket } from './remote-runtime-shared-control-open' -import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler' -import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import * as sharedControlReady from './remote-runtime-shared-control-ready' +import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect' import { requestSharedControl } from './remote-runtime-shared-control-requests' import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' -import * as sharedControlSend from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' -import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close' -import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start' import { SharedControlSocketGeneration } from './remote-runtime-shared-control-socket-generation' import { refreshRemoteRuntimeSharedControl } from './remote-runtime-shared-control-refresh' +import { SharedControlDiagnosticsTracker } from './remote-runtime-shared-control-diagnostics' +import { ensureSharedControlReady } from './remote-runtime-shared-control-ready-wait' +import { handleRuntimeControlTextFrame } from './remote-runtime-shared-control-connection-frame' +import { + closeSharedControlSubscription, + replayRuntimeControlSubscriptions, + sendSharedControlRequest, + sendSharedControlSubscription +} from './remote-runtime-shared-control-connection-actions' import type * as SharedControlTypes from './remote-runtime-shared-control-types' type PendingRequest = SharedControlTypes.SharedControlPendingRequest type LogicalSubscription = SharedControlTypes.SharedControlLogicalSubscription + export class RemoteRuntimeSharedControlConnection { private state: SharedControlTypes.SharedControlConnectionState = 'closed' private ws: WebSocket | null = null @@ -30,26 +36,23 @@ export class RemoteRuntimeSharedControlConnection { private readonly reconnect = new SharedControlReconnectScheduler() private readonly readyStableReset: SharedControlReadyStableResetTimer private intentionallyClosed = false - private readonly diag = { - lastConnectedAt: null as number | null, - lastClose: null as { code: number; reason: string } | null, - lastError: null as string | null - } + private readonly diagnostics: SharedControlDiagnosticsTracker private readonly pendingRequests = new Map() private readonly subscriptions = new Map() private readonly retiredRequestIds = new SharedControlRetiredRequestIds() private readonly readyWaiters: SharedControlTypes.SharedControlReadyWaiter[] = [] private everReady = false private readonly socketGeneration = new SharedControlSocketGeneration() + constructor( private readonly pairing: PairingOffer, private readonly options: SharedControlTypes.RemoteRuntimeSharedControlConnectionOptions = {} ) { + this.diagnostics = new SharedControlDiagnosticsTracker(options) this.readyStableReset = new SharedControlReadyStableResetTimer( options.reconnectStableResetMs ?? 30_000 ) } - request( method: string, params: unknown, @@ -65,12 +68,18 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, envelope, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs, signal), - send: (requestId) => this.sendRequest(requestId), + send: (requestId) => + sendSharedControlRequest({ + pendingRequests: this.pendingRequests, + requestId, + state: this.state, + ws: this.ws, + sharedKey: this.sharedKey + }), retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId), signal }) } - async subscribe( method: string, params: unknown, @@ -84,11 +93,16 @@ export class RemoteRuntimeSharedControlConnection { params, callbacks, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), - sendSubscription: (subscription) => this.sendSubscription(subscription), + sendSubscription: (subscription) => + sendSharedControlSubscription({ + subscriptions: this.subscriptions, + subscription, + deviceToken: this.pairing.deviceToken, + send: (payload) => this.sendEncrypted(payload) + }), closeSubscription: (requestId) => this.closeSubscription(requestId) }) } - close(error?: Error): void { this.intentionallyClosed = true this.socketGeneration.invalidate() @@ -97,31 +111,42 @@ export class RemoteRuntimeSharedControlConnection { this.closeSubscription(subscription.requestId) } this.closeSocket(error) + this.publishDiagnostics() } - readonly retryNow = (): boolean => this.reconnect.retryNow() - pauseStandingRetry(): void { if (this.subscriptions.size === 0) { this.reconnect.clear() + this.publishDiagnostics() } } - getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics { - return sharedControlState.buildSharedControlDiagnostics({ + private publishDiagnostics(): void { + this.diagnostics.publish({ state: this.state, reconnecting: this.reconnect.isScheduled, pendingRequestCount: this.pendingRequests.size, subscriptionCount: this.subscriptions.size, - reconnectAttempt: this.reconnect.attemptCount, - diag: this.diag + reconnectAttempt: this.reconnect.attemptCount + }) + } + getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics { + return this.diagnostics.get({ + state: this.state, + reconnecting: this.reconnect.isScheduled, + pendingRequestCount: this.pendingRequests.size, + subscriptionCount: this.subscriptions.size, + reconnectAttempt: this.reconnect.attemptCount }) } - reconnectNow(): void { refreshRemoteRuntimeSharedControl({ intentionallyClosed: this.intentionallyClosed, - ready: this.isReady(), + ready: sharedControlReady.isSharedControlReady({ + state: this.state, + ws: this.ws, + sharedKey: this.sharedKey + }), refresh: () => { this.closeSocket( remoteRuntimeUnavailableError('Refreshing remote runtime control transport.'), @@ -131,12 +156,11 @@ export class RemoteRuntimeSharedControlConnection { } }) } - private ensureReadyWithTimeout(timeoutMs: number, signal?: AbortSignal): Promise { - if (this.isReady()) { - return Promise.resolve() - } - return sharedControlReady.waitForSharedControlReadyWithTimeout({ + return ensureSharedControlReady({ + state: this.state, + ws: this.ws, + sharedKey: this.sharedKey, readyWaiters: this.readyWaiters, timeoutMs, signal, @@ -144,14 +168,6 @@ export class RemoteRuntimeSharedControlConnection { }) } - private isReady(): boolean { - return sharedControlReady.isSharedControlReady({ - state: this.state, - ws: this.ws, - sharedKey: this.sharedKey - }) - } - private open(): void { if (this.intentionallyClosed) { sharedControlState.rejectSharedControlReadyWaiters( @@ -166,7 +182,7 @@ export class RemoteRuntimeSharedControlConnection { getCurrentSocket: () => this.ws, onClose: (close, error) => { if (this.socketGeneration.isCurrent(socketGeneration)) { - this.diag.lastClose = close + this.diagnostics.markClose(close) } this.handleSocketClosed(error, socketGeneration) }, @@ -185,16 +201,16 @@ export class RemoteRuntimeSharedControlConnection { this.sharedKey = opened.socket.sharedKey this.socketCleanup = opened.socket.cleanup this.state = 'awaiting_ready' + this.publishDiagnostics() } private handleTextFrame(frame: string, socketGeneration: number): void { - if (!this.socketGeneration.isCurrent(socketGeneration)) { - return - } - handleSharedControlTextFrame({ + handleRuntimeControlTextFrame({ frame, - state: this.state, - sharedKey: this.sharedKey, + socketGeneration, + isCurrent: (generation) => this.socketGeneration.isCurrent(generation), + getState: () => this.state, + getSharedKey: () => this.sharedKey, environmentId: this.options.environmentId, deviceToken: this.pairing.deviceToken, clientCapabilities: remoteRuntimeClientCapabilities(this.options.clientCapabilities), @@ -208,57 +224,32 @@ export class RemoteRuntimeSharedControlConnection { handleSocketClosed: (error) => this.handleSocketClosed(error, socketGeneration), sendEncrypted: (payload) => this.sendEncrypted(payload), markReady: () => { - this.diag.lastConnectedAt = Date.now() + this.diagnostics.markReady() // Why cleared here: these describe the attempt that just succeeded's predecessor. // Left set, a recovered host reads "Connected" next to a stale failure forever. - this.diag.lastError = null - this.diag.lastClose = null + this.publishDiagnostics() this.readyStableReset.schedule({ getState: () => this.state, getSocket: () => this.ws, reset: () => this.reconnect.resetAttempt() }) }, - replaySubscriptions: () => this.replaySubscriptions() - }) - } - - private sendRequest(requestId: string): void { - sharedControlSend.sendSharedControlRequest({ - pendingRequests: this.pendingRequests, - requestId, - send: (serialized) => - sharedControlProtocol.sendSharedControlEncryptedSerialized({ - state: this.state, - ws: this.ws, - sharedKey: this.sharedKey, - serialized - }), - reject: (id, error) => - sharedControlState.rejectSharedControlPendingRequest(this.pendingRequests, id, error) - }) - } - - private sendSubscription(subscription: LogicalSubscription): void { - sharedControlSend.sendSharedControlSubscription({ - subscriptions: this.subscriptions, - subscription, - deviceToken: this.pairing.deviceToken, - send: (payload) => this.sendEncrypted(payload) + replaySubscriptions: () => this.replaySubscriptions(), + publishDiagnostics: () => this.publishDiagnostics() }) } private replaySubscriptions(): void { - sharedControlSubscriptions.replaySharedControlSubscriptions({ + this.everReady = replayRuntimeControlSubscriptions({ subscriptions: this.subscriptions, - send: (subscription) => this.sendSubscription(subscription), + deviceToken: this.pairing.deviceToken, + send: (payload) => this.sendEncrypted(payload), tagReplayedResponses: this.everReady }) - this.everReady = true } private closeSubscription(requestId: string): void { - closeSharedControlConnectionSubscription({ + closeSharedControlSubscription({ subscriptions: this.subscriptions, retiredRequestIds: this.retiredRequestIds, requestId, @@ -288,7 +279,7 @@ export class RemoteRuntimeSharedControlConnection { ) { return } - this.diag.lastError = error.message + this.diagnostics.markError(error.message) this.reconnect.scheduleAfterSocketClose({ intentionallyClosed: this.intentionallyClosed, manuallyDisconnected: this.options.isManuallyDisconnected?.() ?? false, @@ -296,6 +287,7 @@ export class RemoteRuntimeSharedControlConnection { subscriptionCount: this.subscriptions.size, open: () => this.open() }) + this.publishDiagnostics() } private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void { @@ -305,7 +297,7 @@ export class RemoteRuntimeSharedControlConnection { pendingRequests: this.pendingRequests, subscriptions: this.subscriptions, readyWaiters: this.readyWaiters, - lastClose: this.diag.lastClose, + lastClose: this.getDiagnostics().lastClose, socketCleanup: this.socketCleanup, ws: this.ws, error, @@ -315,5 +307,6 @@ export class RemoteRuntimeSharedControlConnection { this.ws = this.sharedKey = null this.socketCleanup = null this.state = 'closed' + this.publishDiagnostics() } } diff --git a/src/shared/remote-runtime-shared-control-diagnostics.ts b/src/shared/remote-runtime-shared-control-diagnostics.ts new file mode 100644 index 00000000000..124a99260ea --- /dev/null +++ b/src/shared/remote-runtime-shared-control-diagnostics.ts @@ -0,0 +1,74 @@ +import type { + RemoteRuntimeSharedConnectionDiagnostics, + RemoteRuntimeSharedControlConnectionOptions, + SharedControlConnectionState +} from './remote-runtime-shared-control-types' + +type DiagnosticClose = { code: number; reason: string } | null + +export class SharedControlDiagnosticsTracker { + private lastConnectedAt: number | null = null + private lastClose: DiagnosticClose = null + private lastError: string | null = null + private lastPublished: RemoteRuntimeSharedConnectionDiagnostics | null = null + + constructor(private readonly options: RemoteRuntimeSharedControlConnectionOptions) {} + + markClose(close: DiagnosticClose): void { + this.lastClose = close + } + + markReady(): void { + this.lastConnectedAt = Date.now() + this.lastError = null + this.lastClose = null + } + + markError(error: string): void { + this.lastError = error + } + + get(args: { + state: SharedControlConnectionState + reconnecting: boolean + pendingRequestCount: number + subscriptionCount: number + reconnectAttempt: number + }): RemoteRuntimeSharedConnectionDiagnostics { + return { + state: args.reconnecting ? 'reconnecting' : args.state, + pendingRequestCount: args.pendingRequestCount, + subscriptionCount: args.subscriptionCount, + reconnectAttempt: args.reconnectAttempt, + lastConnectedAt: this.lastConnectedAt, + lastClose: this.lastClose, + lastError: this.lastError + } + } + + publish(args: Parameters[0]): void { + const diagnostics = this.get(args) + const previous = this.lastPublished + const closeUnchanged = + previous?.lastClose?.code === diagnostics.lastClose?.code && + previous?.lastClose?.reason === diagnostics.lastClose?.reason + if ( + previous && + previous.state === diagnostics.state && + previous.pendingRequestCount === diagnostics.pendingRequestCount && + previous.subscriptionCount === diagnostics.subscriptionCount && + previous.reconnectAttempt === diagnostics.reconnectAttempt && + previous.lastConnectedAt === diagnostics.lastConnectedAt && + closeUnchanged && + previous.lastError === diagnostics.lastError + ) { + return + } + this.lastPublished = diagnostics + try { + this.options.onDiagnosticsChanged?.(diagnostics) + } catch (error) { + console.warn('[remote-runtime.shared-control] diagnostics callback failed:', error) + } + } +} diff --git a/src/shared/remote-runtime-shared-control-ready-wait.ts b/src/shared/remote-runtime-shared-control-ready-wait.ts new file mode 100644 index 00000000000..3dfc0eec512 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-ready-wait.ts @@ -0,0 +1,29 @@ +import type WebSocket from 'ws' +import type { + SharedControlConnectionState, + SharedControlReadyWaiter +} from './remote-runtime-shared-control-types' +import { + isSharedControlReady, + waitForSharedControlReadyWithTimeout +} from './remote-runtime-shared-control-ready' + +export function ensureSharedControlReady(args: { + state: SharedControlConnectionState + ws: WebSocket | null + sharedKey: Uint8Array | null + readyWaiters: SharedControlReadyWaiter[] + timeoutMs: number + signal?: AbortSignal + open: () => void +}): Promise { + if (isSharedControlReady(args)) { + return Promise.resolve() + } + return waitForSharedControlReadyWithTimeout({ + readyWaiters: args.readyWaiters, + timeoutMs: args.timeoutMs, + signal: args.signal, + open: args.open + }) +} diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index 6de0745575f..39d1b01f613 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -77,6 +77,8 @@ export type RemoteRuntimeSharedControlConnectionOptions = { clientCapabilities?: readonly RuntimeCapability[] isManuallyDisconnected?: () => boolean isCapabilityPaused?: () => boolean + /** Publishes local transport diagnostics after a meaningful state transition. */ + onDiagnosticsChanged?: (diagnostics: RemoteRuntimeSharedConnectionDiagnostics) => void reconnectStableResetMs?: number liveness?: RemoteRuntimeSocketLivenessOptions } diff --git a/src/shared/runtime-environment-diagnostics.ts b/src/shared/runtime-environment-diagnostics.ts new file mode 100644 index 00000000000..6a04158db16 --- /dev/null +++ b/src/shared/runtime-environment-diagnostics.ts @@ -0,0 +1,2 @@ +export const RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL = + 'runtimeEnvironments:sharedControlDiagnostics' diff --git a/src/shared/runtime-host-connection-state.ts b/src/shared/runtime-host-connection-state.ts new file mode 100644 index 00000000000..bc5151f1c9c --- /dev/null +++ b/src/shared/runtime-host-connection-state.ts @@ -0,0 +1,58 @@ +import type { RuntimeStatus } from './runtime-session-contracts' +import { isRuntimeWorkspaceWindowClosed } from './runtime-workspace-window-availability' + +export type RuntimeHostConnectionState = + | 'connected' + | 'workspace-window-closed' + | 'checking' + | 'reconnecting' + | 'disconnected' + +/** Derives the runtime transport verdict shared by the renderer and agents. */ +export function runtimeHostConnectionState({ + hasStatusEntry, + status +}: { + hasStatusEntry: boolean + status: RuntimeStatus | null | undefined +}): RuntimeHostConnectionState { + if (!hasStatusEntry) { + return 'checking' + } + const remoteControl = status?.remoteControl + if (remoteControl?.state === 'reconnecting') { + return 'reconnecting' + } + if (!status) { + return 'disconnected' + } + if (remoteControl?.state === 'closed') { + return 'disconnected' + } + if (remoteControl && remoteControl.state !== 'ready') { + return 'checking' + } + if (isRuntimeWorkspaceWindowClosed(status)) { + return 'workspace-window-closed' + } + return 'connected' +} + +export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { + return state === 'connected' || state === 'workspace-window-closed' +} + +export type HostStatus = 'connected' | 'disconnected' | 'connecting' + +export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { + switch (state) { + case 'connected': + case 'workspace-window-closed': + return 'connected' + case 'checking': + case 'reconnecting': + return 'connecting' + case 'disconnected': + return 'disconnected' + } +} diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 0e4711c0bd2..cd0dd7a5cc7 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -1,6 +1,7 @@ import type { AgentStatusOrchestrationContext } from './agent-status-types' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { RuntimeHostConnectionState } from './runtime-host-connection-state' import type { RuntimeCapability } from './protocol-version' import type { RuntimeBrowserUnavailableReason, @@ -111,6 +112,8 @@ export type CliStatusResult = { runtime: { state: CliRuntimeState reachable: boolean + /** Canonical runtime transport verdict, when the caller has runtime evidence. */ + connectionState?: RuntimeHostConnectionState runtimeId: string | null appVersion?: string remoteUpdateSupport?: RemoteServerUpdateSupport