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 <sim@local>
This commit is contained in:
Brennan Benson
2026-08-31 12:25:17 -07:00
committed by GitHub
co-authored by Merge Sim
parent db84894eef
commit aabcc57366
34 changed files with 926 additions and 198 deletions
+1
View File
@@ -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')
+5
View File
@@ -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
+1
View File
@@ -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' })]
+6
View File
@@ -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: {
@@ -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()
})
})
@@ -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().
}
}
}
@@ -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)
+1
View File
@@ -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`,
+2
View File
@@ -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 }
+8
View File
@@ -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<RuntimeRpcResponse<RuntimeStatus>>
retryControlConnection?: (args: { selector: string }) => Promise<void>
onSharedControlDiagnostics?: (
callback: (event: {
environmentId: string
transportGeneration: number
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
}) => void
) => () => void
prepareBrowserClientHostPlacement: (
args: BrowserClientHostPlacementPreparationRequest
) => Promise<BrowserPageCreationPlacement>
+20
View File
@@ -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<void> =>
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<void> =>
@@ -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')
@@ -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'
@@ -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 (
@@ -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,
@@ -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))
+2
View File
@@ -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",
@@ -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'
@@ -0,0 +1,24 @@
const connectionGenerationByEnvironment = new Map<string, number>()
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<string> {
const environmentIds = [...connectionGenerationByEnvironment.keys()]
connectionGenerationByEnvironment.clear()
return environmentIds
}
@@ -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<string, number>()
export function updateRuntimeEnvironmentStatusOverlay(
state: Map<string, RuntimeEnvironmentStatus>,
environmentId: string,
status: RuntimeEnvironmentStatus
): Map<string, RuntimeEnvironmentStatus> {
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 }
})
}
@@ -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<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
): AppState | Pick<AppState, 'runtimeStatusByEnvironmentId'> {
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<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
) => 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<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
) => 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)
})
}
@@ -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> = {}): 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<RuntimeStatusSlice>()((...a) => ({
...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>))
}))
}
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)
})
})
@@ -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<string, RecheckState>()
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) {
+34 -34
View File
@@ -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<void>
}
const connectionGenerationByEnvironment = new Map<string, number>()
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<AppState, [], [], RuntimeStatusSlice> = (
@@ -287,19 +286,9 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
...(environmentsChanged ? { runtimeEnvironments } : {})
}
})
runtimeStatusRecheck.reconcileRuntimeStatusRecheck({
environmentId,
status: status.status,
connectionGeneration: getRuntimeEnvironmentConnectionGeneration(environmentId),
environmentExists: () =>
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<AppState, [], [], RuntimeSta
}
},
publishRuntimeEnvironmentDiagnostics:
runtimeStatusDiagnosticsPublish.createRuntimeEnvironmentDiagnosticsPublisher({
getCurrent: (environmentId) => 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)
@@ -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<string, SharedControlPendingRequest<unknown>>
requestId: string
state: Parameters<typeof sharedControlProtocol.sendSharedControlEncryptedSerialized>[0]['state']
ws: Parameters<typeof sharedControlProtocol.sendSharedControlEncryptedSerialized>[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<string, SharedControlLogicalSubscription<unknown>>
subscription: SharedControlLogicalSubscription<unknown>
deviceToken: string
send: (payload: unknown) => boolean
}): void {
sharedControlSend.sendSharedControlSubscription(args)
}
export function replaySharedControlSubscriptions(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
send: (subscription: SharedControlLogicalSubscription<unknown>) => void
tagReplayedResponses: boolean
}): boolean {
sharedControlSubscriptions.replaySharedControlSubscriptions(args)
return true
}
export function replayRuntimeControlSubscriptions(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
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<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
requestId: string
deviceToken: string
send: (payload: unknown) => boolean
}): void {
closeSharedControlConnectionSubscription(args)
}
export function closeRuntimeControlSubscription(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
requestId: string
deviceToken: string
send: (payload: unknown) => boolean
clearWhenIdle: (isIdle: boolean) => void
}): void {
closeSharedControlSubscription(args)
args.clearWhenIdle(args.subscriptions.size === 0)
}
@@ -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<string, SharedControlPendingRequest<unknown>>
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
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
})
}
@@ -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()
})
})
@@ -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<unknown>
type LogicalSubscription = SharedControlTypes.SharedControlLogicalSubscription<unknown>
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<string, PendingRequest>()
private readonly subscriptions = new Map<string, LogicalSubscription>()
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<TResult>(
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<TResult>(
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<void> {
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()
}
}
@@ -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<SharedControlDiagnosticsTracker['get']>[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)
}
}
}
@@ -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<void> {
if (isSharedControlReady(args)) {
return Promise.resolve()
}
return waitForSharedControlReadyWithTimeout({
readyWaiters: args.readyWaiters,
timeoutMs: args.timeoutMs,
signal: args.signal,
open: args.open
})
}
@@ -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
}
@@ -0,0 +1,2 @@
export const RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL =
'runtimeEnvironments:sharedControlDiagnostics'
@@ -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'
}
}
+3
View File
@@ -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