orca/src/shared/remote-runtime-shared-contr...

322 lines
11 KiB
TypeScript

import WebSocket from 'ws'
import type { PairingOffer } from './pairing'
import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
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 {
isSharedControlReady,
waitForSharedControlReadyWithTimeout
} from './remote-runtime-shared-control-ready'
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
import { requestSharedControl } from './remote-runtime-shared-control-requests'
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 type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness'
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 type {
RemoteRuntimeSharedConnectionDiagnostics,
RemoteRuntimeSharedSubscription,
SharedControlConnectionState,
SharedControlLogicalSubscription,
SharedControlPendingRequest,
SharedControlReadyWaiter,
SharedControlSubscriptionCallbacks
} from './remote-runtime-shared-control-types'
export class RemoteRuntimeSharedControlConnection {
private state: SharedControlConnectionState = 'closed'
private ws: WebSocket | null = null
private sharedKey: Uint8Array | null = null
private socketCleanup: (() => void) | null = null
private readonly reconnect = new SharedControlReconnectScheduler()
private readonly readyStableReset: SharedControlReadyStableResetTimer
private intentionallyClosed = false
private lastConnectedAt: number | null = null
private lastClose: { code: number; reason: string } | null = null
private lastError: string | null = null
private readonly pendingRequests = new Map<string, SharedControlPendingRequest<unknown>>()
private readonly subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
private readonly readyWaiters: SharedControlReadyWaiter[] = []
private everReady = false
private readonly socketGeneration = new SharedControlSocketGeneration()
constructor(
private readonly pairing: PairingOffer,
private readonly options: {
environmentId?: string
reconnectStableResetMs?: number
liveness?: RemoteRuntimeSocketLivenessOptions
} = {}
) {
this.readyStableReset = new SharedControlReadyStableResetTimer(
options.reconnectStableResetMs ?? 30_000
)
}
request<TResult>(
method: string,
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
return requestSharedControl({
pendingRequests: this.pendingRequests,
deviceToken: this.pairing.deviceToken,
method,
params,
timeoutMs,
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
send: (requestId) => this.sendRequest(requestId)
})
}
async subscribe<TResult>(
method: string,
params: unknown,
timeoutMs: number,
callbacks: SharedControlSubscriptionCallbacks<TResult>
): Promise<RemoteRuntimeSharedSubscription> {
return startSharedControlSubscription({
subscriptions: this.subscriptions,
deviceToken: this.pairing.deviceToken,
method,
params,
callbacks,
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
sendSubscription: (subscription) => this.sendSubscription(subscription),
closeSubscription: (requestId) => this.closeSubscription(requestId)
})
}
close(error?: Error): void {
this.intentionallyClosed = true
this.socketGeneration.invalidate()
this.reconnect.clear()
for (const subscription of Array.from(this.subscriptions.values())) {
this.closeSubscription(subscription.requestId)
}
this.closeSocket(error)
}
// Why: pending timers only exist while a logical subscription owns reconnect.
readonly retryNow = (): boolean => this.reconnect.retryNow()
getDiagnostics(): RemoteRuntimeSharedConnectionDiagnostics {
return sharedControlState.buildSharedControlDiagnostics({
state: this.state,
reconnecting: this.reconnect.isScheduled,
pendingRequestCount: this.pendingRequests.size,
subscriptionCount: this.subscriptions.size,
reconnectAttempt: this.reconnect.attemptCount,
lastConnectedAt: this.lastConnectedAt,
lastClose: this.lastClose,
lastError: this.lastError
})
}
reconnectNow(): void {
const ready = isSharedControlReady({
state: this.state,
ws: this.ws,
sharedKey: this.sharedKey
})
if (this.intentionallyClosed || ready) {
return
}
// Why: a successful one-shot status probe proves the restarted endpoint is reachable; replace even a stuck CONNECTING/awaiting-ready socket instead of waiting behind stale backoff.
this.closeSocket(
remoteRuntimeUnavailableError('Refreshing remote runtime control transport.'),
true
)
this.open()
}
private ensureReadyWithTimeout(timeoutMs: number): Promise<void> {
if (isSharedControlReady({ state: this.state, ws: this.ws, sharedKey: this.sharedKey })) {
return Promise.resolve()
}
return waitForSharedControlReadyWithTimeout({
readyWaiters: this.readyWaiters,
timeoutMs,
open: () => {
if (
!this.ws ||
this.ws.readyState === WebSocket.CLOSED ||
this.ws.readyState === WebSocket.CLOSING
) {
this.open()
}
}
})
}
private open(): void {
if (this.intentionallyClosed) {
sharedControlState.rejectSharedControlReadyWaiters(
this.readyWaiters,
remoteRuntimeUnavailableError()
)
return
}
this.reconnect.clear()
const socketGeneration = this.socketGeneration.begin()
const opened = openSharedControlSocket(this.pairing, {
getCurrentSocket: () => this.ws,
onClose: (close, error) => {
if (this.socketGeneration.isCurrent(socketGeneration)) {
this.lastClose = close
}
this.handleSocketClosed(error, socketGeneration)
},
onError: (error) => this.handleSocketClosed(error, socketGeneration),
onTextFrame: (frame) => this.handleTextFrame(frame, socketGeneration),
liveness: {
options: this.options.liveness,
onDead: (error) => this.handleSocketClosed(error, socketGeneration)
}
})
if (!opened.ok) {
this.handleSocketClosed(opened.error, socketGeneration)
return
}
this.ws = opened.socket.ws
this.sharedKey = opened.socket.sharedKey
this.socketCleanup = opened.socket.cleanup
this.state = 'awaiting_ready'
}
private handleTextFrame(frame: string, socketGeneration: number): void {
if (!this.socketGeneration.isCurrent(socketGeneration)) {
return
}
handleSharedControlTextFrame({
frame,
state: this.state,
sharedKey: this.sharedKey,
environmentId: this.options.environmentId,
deviceToken: this.pairing.deviceToken,
pendingRequests: this.pendingRequests,
subscriptions: this.subscriptions,
readyWaiters: this.readyWaiters,
setState: (state) => {
this.state = state
},
handleSocketClosed: (error) => this.handleSocketClosed(error, socketGeneration),
sendEncrypted: (payload) => this.sendEncrypted(payload),
markReady: () => {
this.lastConnectedAt = Date.now()
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: SharedControlLogicalSubscription<unknown>): void {
sharedControlSend.sendSharedControlSubscription({
subscriptions: this.subscriptions,
subscription,
deviceToken: this.pairing.deviceToken,
send: (payload) => this.sendEncrypted(payload)
})
}
private replaySubscriptions(): void {
sharedControlSubscriptions.replaySharedControlSubscriptions({
subscriptions: this.subscriptions,
send: (subscription) => this.sendSubscription(subscription),
tagReplayedResponses: this.everReady
})
this.everReady = true
}
private closeSubscription(requestId: string): void {
sharedControlSubscriptions.closeSharedControlLogicalSubscription({
subscriptions: this.subscriptions,
subscription: this.subscriptions.get(requestId),
request: (method, params) => this.sendSubscriptionCleanupRequest(method, params)
})
this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed')
}
private sendEncrypted(payload: unknown): boolean {
return sharedControlProtocol.sendSharedControlEncrypted({
state: this.state,
ws: this.ws,
sharedKey: this.sharedKey,
payload
})
}
private sendSubscriptionCleanupRequest(method: string, params: unknown): void {
sharedControlSubscriptions.sendSharedControlCleanupRequest({
deviceToken: this.pairing.deviceToken,
method,
params,
send: (payload) => this.sendEncrypted(payload)
})
}
private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void {
if (
!this.socketGeneration.acceptClose({
generation: socketGeneration,
error,
everReady: this.everReady,
subscriptions: this.subscriptions,
closeSocket: () => this.closeSocket(error)
})
) {
return
}
this.lastError = error.message
if (this.subscriptions.size > 0 && !this.intentionallyClosed) {
this.reconnect.scheduleWithDefaultBackoff(this.intentionallyClosed, () => this.open())
}
}
private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void {
closeSharedControlSocket({
environmentId: this.options.environmentId,
state: this.state,
pendingRequests: this.pendingRequests,
subscriptions: this.subscriptions,
readyWaiters: this.readyWaiters,
lastClose: this.lastClose,
socketCleanup: this.socketCleanup,
ws: this.ws,
error,
preserveReadyWaitersAndPendingRequests,
clearReadyStableTimer: () => this.readyStableReset.clear()
})
this.ws = this.sharedKey = null
this.socketCleanup = null
this.state = 'closed'
}
}