| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { randomUUID } from 'node:crypto'; |
| import type { ClientRequest, IncomingMessage } from 'node:http'; |
| import { connect } from 'node:net'; |
| import { performance } from 'node:perf_hooks'; |
| import WebSocket from 'ws'; |
| import { |
| discoverMarkedStorageRoot, |
| prepareStorageRootControlDirectory, |
| resolveExistingStorageRootControlDirectory, |
| resolveStorageRoot, |
| type StorageRootCapability, |
| } from '@maka/storage/root-authority'; |
| import { readHostRegistration, RuntimeHostRegistrationError } from '../control/registration.js'; |
| import { |
| decodeHostFrame, |
| encodeProtocolMessage, |
| isClientCapabilityHostFrameKind, |
| type ClientFrame, |
| type ClientCapabilityHostFrame, |
| type ClientCapabilityReplaceResult, |
| type ClientCapabilityUnregisterResult, |
| type ClientHello, |
| type ConfigurationChangedFrame, |
| type ConnectionCatalogChangedFrame, |
| type HostOperationErrorCode, |
| type HostIncompatible, |
| isHostActivityIdle, |
| type HostRegistration, |
| type HostStatusResult, |
| HOST_OPERATION_SPECS, |
| INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, |
| RUNTIME_HOST_COMPATIBILITY_EPOCH, |
| RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS, |
| RUNTIME_HOST_MAX_MESSAGE_BYTES, |
| type OperationInput, |
| type OperationKey, |
| type OperationOutput, |
| type ProjectCatalogChangedFrame, |
| type ProtocolRange, |
| type RequestFrame, |
| type ResponseFrame, |
| type SessionCatalogChangedFrame, |
| type ScheduledTaskChangedFrame, |
| type SubscriptionFrame, |
| type SubscriptionOpenInput, |
| requireClientInstanceId, |
| requireHostCompositionId, |
| requireHostGeneration, |
| requireHostRootId, |
| validateProtocolRange, |
| } from '../protocol/index.js'; |
| import { FramedTransport, RuntimeHostTransportError } from '../transport/framed-transport.js'; |
| import type { RuntimeHostMessageTransport } from '../transport/message-transport.js'; |
| import type { RuntimeHostPeerConnectionPath } from '../transport/peer-native.js'; |
| export type { RuntimeHostPeerConnectionPath } from '../transport/peer-native.js'; |
| import { WebSocketTransport } from '../transport/websocket-transport.js'; |
| import type { OperationMode, OperationSpec } from '../protocol/operation-spec.js'; |
| import { |
| ClientSessionSubscription, |
| RuntimeHostSubscriptionError, |
| type RuntimeHostSessionSubscription, |
| } from './session-subscription.js'; |
| import { ClientCapabilityChannel } from './client-capability-channel.js'; |
| import type { ClientCapabilityProvider } from './client-capability.js'; |
| import { |
| readRuntimeHostProcessIdentity, |
| type RuntimeHostProcessIdentity, |
| } from './process-identity.js'; |
| |
| const DEFAULT_CONNECT_TIMEOUT_MS = 500; |
| const DEFAULT_HANDSHAKE_TIMEOUT_MS = 2_000; |
| const DEFAULT_LIVENESS_INTERVAL_MS = 2_000; |
| // A bounded round trip tolerates short transport/Host stalls. Unrelated inbound |
| // traffic must never extend it: receiving events does not prove requests work. |
| const DEFAULT_LIVENESS_TIMEOUT_MS = 8_000; |
| // Peer byte-stream recovery owns a bounded 30-second reattachment budget. |
| // Its independent path probes detect failures; Host probes still bound a hung Host. |
| const PEER_LIVENESS_TIMEOUT_MS = 45_000; |
| const MAX_WEBSOCKET_FRAGMENTS = 256; |
| const MAX_WEBSOCKET_BUFFERED_CHUNKS = 256; |
| |
| export interface ConnectRuntimeHostInput { |
| rootPath: string; |
| protocol: ProtocolRange; |
| compositionId?: string; |
| generation?: string; |
| takeoverHostEpoch?: string; |
| clientInstanceId?: string; |
| connectTimeoutMs?: number; |
| handshakeTimeoutMs?: number; |
| /** |
| * Interval between end-to-end Host liveness probes. Probes continue while |
| * inbound traffic is active; only a matching response proves a round trip. |
| * Injectable so tests can exercise the cadence without waiting 2 seconds. |
| */ |
| livenessIntervalMs?: number; |
| /** |
| * Invoked after each liveness probe round-trips and validates its Host |
| * Epoch. Test observability: lets a probe-crossing test prove probes |
| * actually fired inside its window instead of assuming the cadence took. |
| * Diagnostics only — exceptions it throws are swallowed and never affect |
| * connection health. |
| */ |
| onLivenessProbe?: () => void; |
| /** Receives each identity-validated Host status observation. */ |
| onHostStatus?: (status: HostStatusResult) => void; |
| } |
| |
| export type RuntimeHostUnavailableReason = |
| | 'not_registered' |
| | 'invalid_registration' |
| | 'root_mismatch' |
| | 'composition_mismatch' |
| | 'connect_failed' |
| | 'handshake_failed' |
| | 'epoch_mismatch'; |
| |
| export type ConnectRuntimeHostResult = |
| | { |
| kind: 'connected'; |
| connection: RuntimeHostConnection; |
| registration: HostRegistration; |
| } |
| | { |
| kind: 'incompatible'; |
| handshake: HostIncompatible; |
| registration: HostRegistration; |
| processIdentity?: RuntimeHostProcessIdentity; |
| } |
| | { |
| kind: 'upgrade_required'; |
| registration: HostRegistration; |
| restartable: true; |
| handshake: HostIncompatible; |
| processIdentity?: RuntimeHostProcessIdentity; |
| } |
| | { |
| kind: 'upgrade_required'; |
| registration: HostRegistration; |
| restartable: false; |
| handshake?: HostIncompatible; |
| processIdentity?: RuntimeHostProcessIdentity; |
| } |
| | { kind: 'draining'; registration: HostRegistration } |
| | { |
| kind: 'unavailable'; |
| reason: RuntimeHostUnavailableReason; |
| registration?: HostRegistration; |
| }; |
| |
| export interface ConnectRemoteRuntimeHostInput { |
| readonly url: string; |
| readonly allowInsecureRemote?: boolean; |
| readonly credential: string; |
| readonly expectedRootId: string; |
| readonly compositionId: string; |
| readonly protocol: ProtocolRange; |
| readonly clientInstanceId?: string; |
| readonly connectTimeoutMs?: number; |
| readonly handshakeTimeoutMs?: number; |
| readonly livenessIntervalMs?: number; |
| readonly onLivenessProbe?: () => void; |
| readonly onHostStatus?: (status: HostStatusResult) => void; |
| readonly connectionResource?: RuntimeHostConnectionResource; |
| } |
| |
| export interface ConnectRuntimeHostMessageTransportInput { |
| readonly transport: RuntimeHostMessageTransport; |
| readonly expectedRootId: string; |
| readonly compositionId: string; |
| readonly protocol: ProtocolRange; |
| readonly clientInstanceId?: string; |
| readonly handshakeTimeoutMs?: number; |
| readonly livenessIntervalMs?: number; |
| readonly onLivenessProbe?: () => void; |
| readonly onHostStatus?: (status: HostStatusResult) => void; |
| readonly connectionResource?: RuntimeHostConnectionResource; |
| readonly peerPath?: RuntimeHostPeerConnectionPath; |
| readonly getPeerPath?: () => RuntimeHostPeerConnectionPath | undefined; |
| } |
| |
| export interface RuntimeHostConnectionResource { |
| readonly closed: Promise<void>; |
| close(): Promise<void>; |
| } |
| |
| export type ConnectRemoteRuntimeHostResult = |
| | { kind: 'connected'; connection: RuntimeHostConnection } |
| | { kind: 'incompatible'; handshake: HostIncompatible } |
| | { kind: 'draining' } |
| | { |
| kind: 'unavailable'; |
| reason: |
| | 'authentication_failed' |
| | 'tls_failed' |
| | 'unreachable' |
| | 'connect_failed' |
| | 'handshake_failed' |
| | 'handshake_timed_out' |
| | 'root_mismatch' |
| | 'composition_mismatch'; |
| }; |
| |
| type ConnectResolvedRuntimeHostResult = |
| | Exclude<ConnectRuntimeHostResult, { kind: 'unavailable' }> |
| | (Extract<ConnectRuntimeHostResult, { kind: 'unavailable' }> & { |
| endpointConnected: boolean; |
| }) |
| | { |
| kind: 'election_deadline_elapsed'; |
| endpointConnected: boolean; |
| registration?: HostRegistration; |
| }; |
| |
| class ElectionDeadlineElapsedError extends Error { |
| constructor() { |
| super('Runtime Host election deadline elapsed'); |
| this.name = 'ElectionDeadlineElapsedError'; |
| } |
| } |
| |
| interface ConnectResolvedRuntimeHostInput |
| extends Omit<ConnectRuntimeHostInput, 'rootPath' | 'clientInstanceId'> { |
| capability: StorageRootCapability<'interactive'>; |
| clientInstanceId: string; |
| controlDirectory: string; |
| electionDeadline?: number; |
| readProcessIdentity?: typeof readRuntimeHostProcessIdentity; |
| } |
| |
| export interface RuntimeHostConnection { |
| readonly cooperativeHandoff?: true; |
| readonly rootId: string; |
| readonly hostEpoch: string; |
| readonly connectionId: string; |
| readonly selectedProtocol: number; |
| readonly compositionId: string; |
| readonly compositionRevision: string; |
| readonly peerPath?: RuntimeHostPeerConnectionPath; |
| readonly closed: Promise<void>; |
| request<K extends DirectRequestOperationKey>( |
| operation: K, |
| input: OperationInput<K>, |
| timeoutMs?: number, |
| ): Promise<OperationOutput<K>>; |
| status(timeoutMs?: number): Promise<HostStatusResult>; |
| openSessionSubscription( |
| input: SubscriptionOpenInput, |
| timeoutMs?: number, |
| ): Promise<RuntimeHostSessionSubscription>; |
| close(): Promise<void>; |
| replaceClientCapabilities( |
| provider: ClientCapabilityProvider, |
| timeoutMs?: number, |
| ): Promise<ClientCapabilityReplaceResult>; |
| unregisterClientCapabilities(timeoutMs?: number): Promise<ClientCapabilityUnregisterResult>; |
| subscribeConfigurationChanges(listener: (revision: number) => void): () => void; |
| subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void; |
| subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void; |
| subscribeSessionCatalogChanges(listener: (frame: SessionCatalogChangedFrame) => void): () => void; |
| subscribeScheduledTaskChanges(listener: (frame: ScheduledTaskChangedFrame) => void): () => void; |
| } |
| |
| export type DirectRequestOperationKey = Exclude< |
| OperationKey, |
| | 'host.status' |
| | 'subscription.open' |
| | 'subscription.close' |
| | 'client.capability.replace' |
| | 'client.capability.unregister' |
| >; |
| |
| export class RuntimeHostOperationError extends Error { |
| constructor( |
| readonly operation: OperationKey, |
| readonly code: HostOperationErrorCode, |
| message: string, |
| ) { |
| super(message); |
| this.name = 'RuntimeHostOperationError'; |
| } |
| } |
| |
| export type RuntimeHostRequestDispatch = 'not_dispatched' | 'dispatched'; |
| export type RuntimeHostRequestInterruptionReason = 'connection_lost' | 'timeout'; |
| |
| export class RuntimeHostRequestInterruptedError extends Error { |
| readonly retryable: boolean; |
| |
| constructor( |
| readonly operation: OperationKey, |
| readonly mode: OperationMode, |
| readonly dispatch: RuntimeHostRequestDispatch, |
| readonly reason: RuntimeHostRequestInterruptionReason, |
| options: ErrorOptions = {}, |
| ) { |
| const outcome = |
| mode === 'query' |
| ? reason === 'connection_lost' |
| ? 'the query may be retried on another connection' |
| : 'the query timed out and may be retried' |
| : dispatch === 'not_dispatched' |
| ? 'the operation was not dispatched' |
| : 'the operation outcome is unknown; do not retry it automatically'; |
| super(`Runtime Host ${operation} was interrupted: ${outcome}`, options); |
| this.name = 'RuntimeHostRequestInterruptedError'; |
| this.retryable = mode === 'query'; |
| } |
| } |
| |
| interface PendingRequest { |
| operation: OperationKey; |
| accept(value: unknown): unknown; |
| resolve(value: unknown): void; |
| reject(error: Error): void; |
| domainState?: 'queued' | 'in_flight'; |
| timer?: NodeJS.Timeout; |
| } |
| |
| interface RetiredRequest { |
| operation: OperationKey; |
| domainState?: 'in_flight'; |
| } |
| |
| interface QueuedDomainFrame { |
| requestId: string; |
| frame: RequestFrame; |
| } |
| |
| type RequestTimeoutScope = 'request' | 'connection'; |
| |
| // A Host response can reach the Client before the Host's transport write |
| // promise resumes and retires that request. Leave one slot free so replacing |
| // the observed response cannot transiently cross the Host's hard limit. The |
| // Host serializes outbound writes, so at most one response occupies this |
| // acknowledgement window. |
| const CLIENT_MAX_IN_FLIGHT_DOMAIN_REQUESTS = RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS - 1; |
| |
| class RuntimeHostConnectionImpl implements RuntimeHostConnection { |
| readonly cooperativeHandoff?: true; |
| readonly rootId: string; |
| readonly hostEpoch: string; |
| readonly connectionId: string; |
| readonly selectedProtocol: number; |
| readonly compositionId: string; |
| readonly compositionRevision: string; |
| readonly #getPeerPath: () => RuntimeHostPeerConnectionPath | undefined; |
| get peerPath(): RuntimeHostPeerConnectionPath | undefined { |
| return this.#getPeerPath(); |
| } |
| readonly closed: Promise<void>; |
| readonly #transport: RuntimeHostMessageTransport; |
| readonly #pendingRequests = new Map<string, PendingRequest>(); |
| readonly #retiredRequests = new Map<string, RetiredRequest>(); |
| readonly #queuedDomainFrames: QueuedDomainFrame[] = []; |
| readonly #subscriptions = new Map<string, ClientSessionSubscription>(); |
| readonly #retiredSubscriptionIds = new Set<string>(); |
| readonly #clientCapabilities: ClientCapabilityChannel; |
| readonly #configurationChangeListeners = new Set<(revision: number) => void>(); |
| readonly #connectionCatalogChangeListeners = new Set<(revision: number) => void>(); |
| readonly #projectCatalogChangeListeners = new Set<(revision: number) => void>(); |
| readonly #sessionCatalogChangeListeners = new Set<(frame: SessionCatalogChangedFrame) => void>(); |
| readonly #scheduledTaskChangeListeners = new Set<(frame: ScheduledTaskChangedFrame) => void>(); |
| #livenessTimer: NodeJS.Timeout | undefined; |
| #livenessProbeDeadline: NodeJS.Timeout | undefined; |
| #livenessProbePending = false; |
| #inFlightDomainRequests = 0; |
| #terminalError: Error | undefined; |
| readonly #livenessIntervalMs: number; |
| readonly #onLivenessProbe: (() => void) | undefined; |
| readonly #onHostStatus: ((status: HostStatusResult) => void) | undefined; |
| |
| constructor( |
| transport: RuntimeHostMessageTransport, |
| accepted: { |
| rootId: string; |
| hostEpoch: string; |
| connectionId: string; |
| selectedProtocol: number; |
| compositionId: string; |
| compositionRevision: string; |
| cooperativeHandoff?: true; |
| }, |
| // livenessIntervalMs is validated by connectResolvedRuntimeHost alongside |
| // the other connect timeouts, before any transport work happens. |
| options?: { |
| livenessIntervalMs?: number; |
| onLivenessProbe?: () => void; |
| onHostStatus?: (status: HostStatusResult) => void; |
| connectionResource?: RuntimeHostConnectionResource; |
| peerPath?: RuntimeHostPeerConnectionPath; |
| getPeerPath?: () => RuntimeHostPeerConnectionPath | undefined; |
| }, |
| ) { |
| this.#livenessIntervalMs = options?.livenessIntervalMs ?? DEFAULT_LIVENESS_INTERVAL_MS; |
| this.#onLivenessProbe = options?.onLivenessProbe; |
| this.#onHostStatus = options?.onHostStatus; |
| this.#transport = transport; |
| this.rootId = accepted.rootId; |
| this.hostEpoch = accepted.hostEpoch; |
| this.connectionId = accepted.connectionId; |
| this.selectedProtocol = accepted.selectedProtocol; |
| this.cooperativeHandoff = accepted.cooperativeHandoff; |
| this.compositionId = accepted.compositionId; |
| this.compositionRevision = accepted.compositionRevision; |
| this.#getPeerPath = options?.getPeerPath ?? (() => options?.peerPath); |
| const connectionResource = options?.connectionResource; |
| if (connectionResource) { |
| const abortForResourceClosure = (cause: Error) => |
| this.#transport.abort( |
| new RuntimeHostTransportError('closed', 'Runtime Host connection resource closed', { |
| cause, |
| }), |
| ); |
| void connectionResource.closed.then( |
| () => abortForResourceClosure(new Error('Runtime Host connection resource closed')), |
| (error) => abortForResourceClosure(asError(error)), |
| ); |
| this.closed = this.#transport.closed.finally(() => connectionResource.close()); |
| } else { |
| this.closed = this.#transport.closed; |
| } |
| this.#clientCapabilities = new ClientCapabilityChannel({ |
| write: (frame) => writeClientFrame(this.#transport, frame), |
| replace: (input, timeoutMs) => |
| this.#requestOperation( |
| 'client.capability.replace', |
| input, |
| timeoutMs, |
| (result) => result, |
| 'connection', |
| ), |
| unregister: (input, timeoutMs) => |
| this.#requestOperation( |
| 'client.capability.unregister', |
| input, |
| timeoutMs, |
| (result) => result, |
| 'connection', |
| ), |
| onFailure: (error) => this.#fail(error), |
| }); |
| void this.#readResponses(); |
| this.#scheduleLivenessCheck(); |
| } |
| |
| request<K extends DirectRequestOperationKey>( |
| operation: K, |
| input: OperationInput<K>, |
| timeoutMs?: number, |
| ): Promise<OperationOutput<K>> { |
| if (isHostStatusOperation(operation)) { |
| return Promise.reject(new Error('Runtime Host status requires the validated status() API')); |
| } |
| if (isClientCapabilityMutation(operation)) { |
| return Promise.reject( |
| new Error('Client Capability mutations require the dedicated capability channel'), |
| ); |
| } |
| return this.#requestOperation(operation, input, timeoutMs, (result) => result, 'request'); |
| } |
| |
| #requestOperation<K extends OperationKey, Result>( |
| operation: K, |
| input: OperationInput<K>, |
| timeoutMs: number | undefined, |
| accept: (result: OperationOutput<K>) => Result, |
| timeoutScope: RequestTimeoutScope, |
| ): Promise<Result> { |
| const boundedTimeoutMs = |
| timeoutMs === undefined ? undefined : requireTimeout(timeoutMs, 'timeoutMs'); |
| const spec = HOST_OPERATION_SPECS[operation] as OperationSpec< |
| OperationInput<K>, |
| OperationOutput<K>, |
| HostOperationErrorCode |
| >; |
| let canonicalInput: OperationInput<K>; |
| try { |
| canonicalInput = spec.decodeInput(input); |
| } catch (error) { |
| return Promise.reject(asError(error)); |
| } |
| if (this.#terminalError) { |
| return Promise.reject( |
| this.#terminalError instanceof RuntimeHostTransportError |
| ? interruptedRequestError( |
| operation, |
| 'not_dispatched', |
| 'connection_lost', |
| this.#terminalError, |
| ) |
| : this.#terminalError, |
| ); |
| } |
| const requestId = randomUUID(); |
| const isDomainRequest = operation !== 'host.status'; |
| const result = new Promise<Result>((resolve, reject) => { |
| const timer = |
| boundedTimeoutMs === undefined |
| ? undefined |
| : setTimeout(() => { |
| const error = requestTimeoutError(operation); |
| if (timeoutScope === 'connection') this.#fail(error); |
| else this.#retireRequest(requestId, error); |
| }, boundedTimeoutMs); |
| this.#pendingRequests.set(requestId, { |
| operation, |
| accept: (value) => { |
| const output = value as OperationOutput<K>; |
| spec.assertOutputForInput?.(canonicalInput, output); |
| return accept(output); |
| }, |
| resolve: (value) => resolve(value as Result), |
| reject, |
| ...(isDomainRequest ? { domainState: 'queued' as const } : {}), |
| timer, |
| }); |
| }); |
| const frame = { |
| requestId, |
| operation, |
| input: canonicalInput, |
| } as RequestFrame; |
| if (isDomainRequest) { |
| this.#queuedDomainFrames.push({ requestId, frame }); |
| this.#drainDomainRequests(); |
| } else { |
| void writeClientFrame(this.#transport, frame).catch((error: unknown) => |
| this.#fail(asError(error)), |
| ); |
| } |
| return result; |
| } |
| |
| #drainDomainRequests(): void { |
| while ( |
| !this.#terminalError && |
| this.#inFlightDomainRequests < CLIENT_MAX_IN_FLIGHT_DOMAIN_REQUESTS |
| ) { |
| const queued = this.#queuedDomainFrames.shift(); |
| if (!queued) return; |
| const pending = this.#pendingRequests.get(queued.requestId); |
| if (!pending || pending.domainState !== 'queued') continue; |
| pending.domainState = 'in_flight'; |
| this.#inFlightDomainRequests += 1; |
| void writeClientFrame(this.#transport, queued.frame).catch((error: unknown) => |
| this.#fail(asError(error)), |
| ); |
| } |
| } |
| |
| async status(timeoutMs?: number): Promise<HostStatusResult> { |
| return this.#requestOperation( |
| 'host.status', |
| {}, |
| timeoutMs ?? (this.peerPath ? PEER_LIVENESS_TIMEOUT_MS : DEFAULT_LIVENESS_TIMEOUT_MS), |
| (status) => this.#validateHostStatusIdentity(status), |
| 'connection', |
| ); |
| } |
| |
| #validateHostStatusIdentity(status: HostStatusResult): HostStatusResult { |
| if ( |
| status.hostEpoch !== this.hostEpoch || |
| status.compositionId !== this.compositionId || |
| status.compositionRevision !== this.compositionRevision |
| ) { |
| const error = new Error('Runtime Host returned status for a different Host identity'); |
| this.#fail(error); |
| throw error; |
| } |
| try { |
| this.#onHostStatus?.(status); |
| } catch { |
| // Observation cannot control the authenticated connection it watches. |
| } |
| return status; |
| } |
| |
| async openSessionSubscription( |
| input: SubscriptionOpenInput, |
| timeoutMs?: number, |
| ): Promise<RuntimeHostSessionSubscription> { |
| const deadline = |
| Date.now() + (timeoutMs === undefined ? 30_000 : requireTimeout(timeoutMs, 'timeoutMs')); |
| for (;;) { |
| try { |
| return await this.#openSessionSubscription( |
| input, |
| Math.max(1, deadline - Date.now()), |
| timeoutMs, |
| ); |
| } catch (error) { |
| if ( |
| !(error instanceof RuntimeHostOperationError) || |
| error.code !== 'transcript_preparing' || |
| input.transcript.kind !== 'tail' || |
| this.#terminalError || |
| Date.now() >= deadline |
| ) |
| throw error; |
| // Each refusal committed a bounded, resumable index batch. Keep the |
| // caller in its loading state and yield before requesting more work. |
| await new Promise<void>((resolve) => setTimeout(resolve, 25)); |
| } |
| } |
| } |
| |
| #openSessionSubscription( |
| input: SubscriptionOpenInput, |
| openTimeoutMs: number, |
| requestTimeoutMs?: number, |
| ): Promise<RuntimeHostSessionSubscription> { |
| const expectedSessionId = input.sessionId; |
| return this.#requestOperation( |
| 'subscription.open', |
| input, |
| openTimeoutMs, |
| (result) => { |
| if (result.hostEpoch !== this.hostEpoch) { |
| throw new RuntimeHostSubscriptionError( |
| 'host_epoch_changed', |
| 'Session subscription opened for a different Host Epoch', |
| ); |
| } |
| if (result.snapshot.session.sessionId !== expectedSessionId) { |
| throw new RuntimeHostSubscriptionError( |
| 'correlation_changed', |
| 'Runtime Host opened a subscription for a different Session', |
| ); |
| } |
| if (this.#subscriptions.has(result.subscriptionId)) { |
| throw new RuntimeHostSubscriptionError( |
| 'correlation_changed', |
| 'Runtime Host returned a duplicate subscription identity', |
| ); |
| } |
| const subscription = new ClientSessionSubscription( |
| result, |
| () => this.#closeSessionSubscription(result.subscriptionId), |
| (query) => this.request('session.transcript.page', query, requestTimeoutMs), |
| async () => { |
| try { |
| await this.request( |
| 'session.transcript.overlay.release', |
| { subscriptionId: result.subscriptionId }, |
| requestTimeoutMs, |
| ); |
| } catch (error) { |
| this.#fail(asError(error)); |
| throw error; |
| } |
| }, |
| ); |
| this.#subscriptions.set(result.subscriptionId, subscription); |
| return subscription; |
| }, |
| 'connection', |
| ); |
| } |
| |
| async close(): Promise<void> { |
| if (!this.peerPath) { |
| this.#clientCapabilities.close(new Error('Runtime Host connection closed by Client')); |
| this.#transport.abort(); |
| await this.closed; |
| return; |
| } |
| // An intentional peer close must send logical FIN. Aborting its raw path |
| // instead leaves the Host retaining a recoverable session and its quota. |
| this.#fail( |
| new RuntimeHostTransportError('closed', 'Runtime Host connection closed by Client'), |
| true, |
| ); |
| await this.closed; |
| } |
| |
| async replaceClientCapabilities( |
| provider: ClientCapabilityProvider, |
| timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, |
| ): Promise<ClientCapabilityReplaceResult> { |
| return this.#clientCapabilities.replace(provider, timeoutMs); |
| } |
| |
| async unregisterClientCapabilities( |
| timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, |
| ): Promise<ClientCapabilityUnregisterResult> { |
| return this.#clientCapabilities.unregister(timeoutMs); |
| } |
| |
| subscribeConfigurationChanges(listener: (revision: number) => void): () => void { |
| this.#configurationChangeListeners.add(listener); |
| return () => this.#configurationChangeListeners.delete(listener); |
| } |
| |
| subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void { |
| this.#connectionCatalogChangeListeners.add(listener); |
| return () => this.#connectionCatalogChangeListeners.delete(listener); |
| } |
| |
| subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void { |
| this.#projectCatalogChangeListeners.add(listener); |
| return () => this.#projectCatalogChangeListeners.delete(listener); |
| } |
| |
| subscribeSessionCatalogChanges( |
| listener: (frame: SessionCatalogChangedFrame) => void, |
| ): () => void { |
| this.#sessionCatalogChangeListeners.add(listener); |
| return () => this.#sessionCatalogChangeListeners.delete(listener); |
| } |
| |
| subscribeScheduledTaskChanges(listener: (frame: ScheduledTaskChangedFrame) => void): () => void { |
| this.#scheduledTaskChangeListeners.add(listener); |
| return () => this.#scheduledTaskChangeListeners.delete(listener); |
| } |
| |
| async #readResponses(): Promise<void> { |
| try { |
| while (true) { |
| const frame = decodeHostFrame(await this.#transport.read(0)); |
| this.#scheduleLivenessCheck(); |
| if ('kind' in frame) { |
| if (isClientCapabilityHostFrameKind(frame.kind)) { |
| this.#clientCapabilities.accept(frame as ClientCapabilityHostFrame); |
| continue; |
| } |
| switch (frame.kind) { |
| case 'configuration.changed': |
| this.#acceptConfigurationChanged(frame); |
| continue; |
| case 'connection.catalog.changed': |
| this.#acceptConnectionCatalogChanged(frame); |
| continue; |
| case 'project.catalog.changed': |
| this.#acceptProjectCatalogChanged(frame); |
| continue; |
| case 'session.catalog.changed': |
| this.#acceptSessionCatalogChanged(frame); |
| continue; |
| case 'scheduled-task.changed': |
| this.#acceptScheduledTaskChanged(frame); |
| continue; |
| case 'subscription.session_projection': |
| case 'subscription.session_delta': |
| case 'subscription.session_event': |
| case 'subscription.transcript_advanced': |
| case 'subscription.session_domain_changed': |
| case 'subscription.runtime_resource_pty_data': |
| case 'subscription.agent_graph_changed': |
| case 'subscription.closed': |
| this.#acceptSubscriptionFrame(frame); |
| continue; |
| default: |
| throw new Error('Runtime Host returned a handshake frame after acceptance'); |
| } |
| } |
| this.#acceptResponse(frame); |
| } |
| } catch (error) { |
| this.#fail(asError(error)); |
| } |
| } |
| |
| #acceptResponse(frame: ResponseFrame): void { |
| const pending = this.#pendingRequests.get(frame.requestId); |
| if (!pending) { |
| const retired = this.#retiredRequests.get(frame.requestId); |
| if (retired?.operation === frame.operation) { |
| this.#retiredRequests.delete(frame.requestId); |
| this.#releaseDomainSlot(retired); |
| return; |
| } |
| this.#fail(new Error('Runtime Host returned an unmatched operation response')); |
| return; |
| } |
| if (pending.operation !== frame.operation) { |
| this.#fail(new Error('Runtime Host returned an unmatched operation response')); |
| return; |
| } |
| this.#pendingRequests.delete(frame.requestId); |
| if (pending.timer) clearTimeout(pending.timer); |
| if (frame.ok) { |
| try { |
| const accepted = pending.accept(frame.result); |
| this.#releaseDomainSlot(pending); |
| pending.resolve(accepted); |
| } catch (error) { |
| const failure = asError(error); |
| pending.reject(failure); |
| this.#fail(failure); |
| } |
| return; |
| } |
| this.#releaseDomainSlot(pending); |
| pending.reject( |
| new RuntimeHostOperationError(frame.operation, frame.error.code, frame.error.message), |
| ); |
| } |
| |
| #acceptConfigurationChanged(frame: ConfigurationChangedFrame): void { |
| for (const listener of this.#configurationChangeListeners) { |
| try { |
| listener(frame.revision); |
| } catch { |
| // A presentation listener cannot invalidate the Host connection. |
| } |
| } |
| } |
| |
| #acceptConnectionCatalogChanged(frame: ConnectionCatalogChangedFrame): void { |
| for (const listener of this.#connectionCatalogChangeListeners) { |
| try { |
| listener(frame.revision); |
| } catch { |
| // A presentation listener cannot invalidate the Host connection. |
| } |
| } |
| } |
| |
| #acceptProjectCatalogChanged(frame: ProjectCatalogChangedFrame): void { |
| for (const listener of this.#projectCatalogChangeListeners) { |
| try { |
| listener(frame.revision); |
| } catch { |
| // A presentation listener cannot invalidate the Host connection. |
| } |
| } |
| } |
| |
| #acceptSessionCatalogChanged(frame: SessionCatalogChangedFrame): void { |
| for (const listener of this.#sessionCatalogChangeListeners) { |
| try { |
| listener(frame); |
| } catch { |
| // A presentation listener cannot invalidate the Host connection. |
| } |
| } |
| } |
| |
| #acceptScheduledTaskChanged(frame: ScheduledTaskChangedFrame): void { |
| for (const listener of this.#scheduledTaskChangeListeners) { |
| try { |
| listener(frame); |
| } catch { |
| // A presentation listener cannot invalidate the Host connection. |
| } |
| } |
| } |
| |
| #retireRequest(requestId: string, error: Error): void { |
| const pending = this.#pendingRequests.get(requestId); |
| if (!pending) return; |
| this.#pendingRequests.delete(requestId); |
| if (pending.domainState === 'queued') { |
| const index = this.#queuedDomainFrames.findIndex((queued) => queued.requestId === requestId); |
| if (index !== -1) this.#queuedDomainFrames.splice(index, 1); |
| pending.reject( |
| interruptedRequestError(pending.operation, 'not_dispatched', 'timeout', error), |
| ); |
| return; |
| } |
| this.#retiredRequests.set(requestId, { |
| operation: pending.operation, |
| ...(pending.domainState === 'in_flight' ? { domainState: pending.domainState } : {}), |
| }); |
| pending.reject(interruptedRequestError(pending.operation, 'dispatched', 'timeout', error)); |
| } |
| |
| #releaseDomainSlot(request: PendingRequest | RetiredRequest): void { |
| if (request.domainState !== 'in_flight') return; |
| request.domainState = undefined; |
| this.#inFlightDomainRequests -= 1; |
| this.#drainDomainRequests(); |
| } |
| |
| #scheduleLivenessCheck(): void { |
| // Inbound events do not postpone the next bidirectional probe. |
| if (this.#terminalError || this.#livenessTimer || this.#livenessProbePending) { |
| return; |
| } |
| this.#livenessTimer = setTimeout(() => { |
| this.#livenessTimer = undefined; |
| this.#startLivenessProbe(); |
| }, this.#livenessIntervalMs); |
| } |
| |
| #startLivenessProbe(): void { |
| if (this.#terminalError || this.#livenessProbePending) return; |
| this.#livenessProbePending = true; |
| this.#livenessProbeDeadline = setTimeout( |
| () => { |
| this.#livenessProbeDeadline = undefined; |
| this.#fail(requestTimeoutError('host.status')); |
| }, |
| this.peerPath ? PEER_LIVENESS_TIMEOUT_MS : DEFAULT_LIVENESS_TIMEOUT_MS, |
| ); |
| void this.#requestOperation( |
| 'host.status', |
| {}, |
| undefined, |
| (status) => { |
| this.#validateHostStatusIdentity(status); |
| try { |
| this.#onLivenessProbe?.(); |
| } catch { |
| // Diagnostics hook: an observer exception must never fail the |
| // connection it is watching. |
| } |
| }, |
| 'request', |
| ) |
| .catch((error: unknown) => this.#fail(asError(error))) |
| .finally(() => { |
| if (this.#livenessProbeDeadline) clearTimeout(this.#livenessProbeDeadline); |
| this.#livenessProbeDeadline = undefined; |
| this.#livenessProbePending = false; |
| this.#scheduleLivenessCheck(); |
| }); |
| } |
| |
| #acceptSubscriptionFrame(frame: SubscriptionFrame): void { |
| const subscription = this.#subscriptions.get(frame.subscriptionId); |
| if (!subscription) { |
| if (this.#retiredSubscriptionIds.has(frame.subscriptionId)) return; |
| this.#fail(new Error('Runtime Host returned an unmatched subscription frame')); |
| return; |
| } |
| try { |
| subscription.accept(frame); |
| if (frame.kind === 'subscription.closed') { |
| this.#subscriptions.delete(frame.subscriptionId); |
| } |
| } catch (error) { |
| const failure = asError(error); |
| if (failure instanceof RuntimeHostSubscriptionError) { |
| this.#invalidateSubscription(subscription, failure); |
| return; |
| } |
| this.#fail(failure); |
| } |
| } |
| |
| async #closeSessionSubscription(subscriptionId: string): Promise<void> { |
| const subscription = this.#subscriptions.get(subscriptionId); |
| if (!subscription) return; |
| await this.#requestOperation( |
| 'subscription.close', |
| { subscriptionId }, |
| DEFAULT_HANDSHAKE_TIMEOUT_MS, |
| (result) => { |
| if (result.subscriptionId !== subscriptionId) { |
| throw new Error('Runtime Host closed a different subscription'); |
| } |
| }, |
| 'connection', |
| ); |
| this.#subscriptions.delete(subscriptionId); |
| subscription.finish(); |
| } |
| |
| #invalidateSubscription( |
| subscription: ClientSessionSubscription, |
| error: RuntimeHostSubscriptionError, |
| ): void { |
| const { subscriptionId } = subscription; |
| if (this.#subscriptions.get(subscriptionId) !== subscription) return; |
| this.#subscriptions.delete(subscriptionId); |
| this.#retiredSubscriptionIds.add(subscriptionId); |
| subscription.fail(error); |
| if (this.#terminalError) return; |
| void this.#requestOperation( |
| 'subscription.close', |
| { subscriptionId }, |
| DEFAULT_HANDSHAKE_TIMEOUT_MS, |
| () => this.#retiredSubscriptionIds.delete(subscriptionId), |
| 'connection', |
| ).catch((failure: unknown) => this.#fail(asError(failure))); |
| } |
| |
| #fail(error: Error, gracefulPeerClose = false): void { |
| if (this.#terminalError) return; |
| this.#terminalError = error; |
| if (this.#livenessTimer) clearTimeout(this.#livenessTimer); |
| this.#livenessTimer = undefined; |
| if (this.#livenessProbeDeadline) clearTimeout(this.#livenessProbeDeadline); |
| this.#livenessProbeDeadline = undefined; |
| this.#queuedDomainFrames.length = 0; |
| this.#inFlightDomainRequests = 0; |
| for (const pending of this.#pendingRequests.values()) { |
| if (pending.timer) clearTimeout(pending.timer); |
| pending.reject( |
| error instanceof RuntimeHostTransportError |
| ? interruptedRequestError( |
| pending.operation, |
| pending.domainState === 'queued' ? 'not_dispatched' : 'dispatched', |
| 'connection_lost', |
| error, |
| ) |
| : error, |
| ); |
| } |
| this.#pendingRequests.clear(); |
| this.#retiredRequests.clear(); |
| const subscriptionError = new RuntimeHostSubscriptionError( |
| 'connection_closed', |
| `Runtime Host connection closed: ${error.message}`, |
| ); |
| for (const subscription of this.#subscriptions.values()) { |
| subscription.fail(subscriptionError); |
| } |
| this.#subscriptions.clear(); |
| this.#retiredSubscriptionIds.clear(); |
| this.#clientCapabilities.close(error); |
| this.#configurationChangeListeners.clear(); |
| this.#sessionCatalogChangeListeners.clear(); |
| this.#scheduledTaskChangeListeners.clear(); |
| if (gracefulPeerClose) this.#transport.closeAfterFlush(); |
| else this.#transport.abort(); |
| } |
| } |
| |
| function isClientCapabilityMutation(operation: unknown): boolean { |
| return operation === 'client.capability.replace' || operation === 'client.capability.unregister'; |
| } |
| |
| function isHostStatusOperation(operation: unknown): boolean { |
| return operation === 'host.status'; |
| } |
| |
| export async function connectRuntimeHost( |
| input: ConnectRuntimeHostInput, |
| ): Promise<ConnectRuntimeHostResult> { |
| const normalized = normalizeConnectRuntimeHostInput(input); |
| const capability = await resolveStorageRoot({ |
| path: input.rootPath, |
| kind: 'interactive', |
| }); |
| const { controlDirectory } = await prepareStorageRootControlDirectory(capability); |
| return finalizeConnectRuntimeHostResult( |
| await connectResolvedRuntimeHost({ |
| ...input, |
| ...normalized, |
| capability, |
| controlDirectory, |
| }), |
| ); |
| } |
| |
| /** Connects only through an already published Host control plane and performs no filesystem writes. */ |
| export async function connectExistingRuntimeHost( |
| input: ConnectRuntimeHostInput, |
| ): Promise<ConnectRuntimeHostResult> { |
| const normalized = normalizeConnectRuntimeHostInput(input); |
| const discovered = await discoverMarkedStorageRoot({ path: input.rootPath }); |
| if (discovered.kind !== 'interactive') { |
| return { kind: 'unavailable', reason: 'root_mismatch' }; |
| } |
| const capability = discovered; |
| const { controlDirectory } = await resolveExistingStorageRootControlDirectory(capability); |
| return finalizeConnectRuntimeHostResult( |
| await connectResolvedRuntimeHost({ |
| ...input, |
| ...normalized, |
| capability, |
| controlDirectory, |
| }), |
| ); |
| } |
| |
| export async function connectRemoteRuntimeHost( |
| input: ConnectRemoteRuntimeHostInput, |
| ): Promise<ConnectRemoteRuntimeHostResult> { |
| try { |
| const normalized = normalizeConnectRuntimeHostInput(input); |
| const url = normalizeRemoteRuntimeHostUrl(input.url, { |
| allowInsecureRemote: input.allowInsecureRemote === true, |
| }); |
| let transport: WebSocketTransport; |
| try { |
| transport = await openWebSocketTransport(url, input.credential, normalized.connectTimeoutMs); |
| } catch (error) { |
| await input.connectionResource?.close().catch(() => undefined); |
| return { kind: 'unavailable', reason: classifyRemoteRuntimeHostConnectFailure(error) }; |
| } |
| return connectRuntimeHostMessageTransport({ |
| transport, |
| expectedRootId: input.expectedRootId, |
| compositionId: input.compositionId, |
| protocol: input.protocol, |
| clientInstanceId: normalized.clientInstanceId, |
| handshakeTimeoutMs: normalized.handshakeTimeoutMs, |
| livenessIntervalMs: normalized.livenessIntervalMs, |
| onLivenessProbe: input.onLivenessProbe, |
| onHostStatus: input.onHostStatus, |
| connectionResource: input.connectionResource, |
| }); |
| } catch (error) { |
| await input.connectionResource?.close().catch(() => undefined); |
| throw error; |
| } |
| } |
| |
| export async function connectRuntimeHostMessageTransport( |
| input: ConnectRuntimeHostMessageTransportInput, |
| ): Promise<ConnectRemoteRuntimeHostResult> { |
| let resourceTransferred = false; |
| let timer: ReturnType<typeof setTimeout> | undefined; |
| let handshakeTimedOut = false; |
| try { |
| const normalized = normalizeConnectRuntimeHostInput(input); |
| const compositionId = requireHostCompositionId(input.compositionId); |
| const expectedRootId = requireHostRootId(input.expectedRootId); |
| timer = setTimeout(() => { |
| handshakeTimedOut = true; |
| input.transport.abort(new Error('Timed out handshaking with Runtime Host')); |
| }, normalized.handshakeTimeoutMs); |
| const result = await exchangeRuntimeHostHandshake({ |
| transport: input.transport, |
| protocol: input.protocol, |
| clientInstanceId: normalized.clientInstanceId, |
| compositionId, |
| expectedRootId, |
| livenessIntervalMs: normalized.livenessIntervalMs, |
| onLivenessProbe: input.onLivenessProbe, |
| onHostStatus: input.onHostStatus, |
| connectionResource: input.connectionResource, |
| ...(input.peerPath ? { peerPath: input.peerPath } : {}), |
| ...(input.getPeerPath ? { getPeerPath: input.getPeerPath } : {}), |
| }); |
| if (result.kind === 'connected') { |
| resourceTransferred = true; |
| return result; |
| } |
| input.transport.abort(); |
| return result.kind === 'incompatible' ? result : { kind: 'draining' }; |
| } catch (error) { |
| input.transport.abort(); |
| if (error instanceof RuntimeHostRootMismatchError) { |
| return { kind: 'unavailable', reason: 'root_mismatch' }; |
| } |
| if (error instanceof RuntimeHostCompositionMismatchError) { |
| return { kind: 'unavailable', reason: 'composition_mismatch' }; |
| } |
| return { |
| kind: 'unavailable', |
| reason: handshakeTimedOut ? 'handshake_timed_out' : 'handshake_failed', |
| }; |
| } finally { |
| if (timer) clearTimeout(timer); |
| if (!resourceTransferred) await input.connectionResource?.close().catch(() => undefined); |
| } |
| } |
| |
| function normalizeConnectRuntimeHostInput( |
| input: Pick< |
| ConnectRuntimeHostInput, |
| | 'protocol' |
| | 'clientInstanceId' |
| | 'connectTimeoutMs' |
| | 'handshakeTimeoutMs' |
| | 'livenessIntervalMs' |
| >, |
| ): { |
| clientInstanceId: string; |
| connectTimeoutMs: number; |
| handshakeTimeoutMs: number; |
| livenessIntervalMs: number; |
| } { |
| validateProtocolRange(input.protocol); |
| return { |
| clientInstanceId: requireClientInstanceId(input.clientInstanceId ?? randomUUID()), |
| connectTimeoutMs: requireTimeout( |
| input.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, |
| 'connectTimeoutMs', |
| ), |
| handshakeTimeoutMs: requireTimeout( |
| input.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, |
| 'handshakeTimeoutMs', |
| ), |
| livenessIntervalMs: requireTimeout( |
| input.livenessIntervalMs ?? DEFAULT_LIVENESS_INTERVAL_MS, |
| 'livenessIntervalMs', |
| ), |
| }; |
| } |
| |
| function finalizeConnectRuntimeHostResult( |
| result: ConnectResolvedRuntimeHostResult, |
| ): ConnectRuntimeHostResult { |
| if (result.kind === 'election_deadline_elapsed') { |
| return { |
| kind: 'unavailable', |
| reason: result.endpointConnected ? 'handshake_failed' : 'connect_failed', |
| }; |
| } |
| if (result.kind === 'unavailable') { |
| return { |
| kind: 'unavailable', |
| reason: result.reason, |
| ...(result.registration ? { registration: result.registration } : {}), |
| }; |
| } |
| return result; |
| } |
| |
| export async function connectResolvedRuntimeHost( |
| input: ConnectResolvedRuntimeHostInput, |
| ): Promise<ConnectResolvedRuntimeHostResult> { |
| validateProtocolRange(input.protocol); |
| requireClientInstanceId(input.clientInstanceId); |
| const generation = |
| input.generation === undefined ? undefined : requireHostGeneration(input.generation); |
| if (input.takeoverHostEpoch !== undefined) { |
| requireHostGeneration(input.takeoverHostEpoch); |
| if (generation === undefined) { |
| throw new TypeError('takeoverHostEpoch requires a Runtime Host generation'); |
| } |
| } |
| const connectTimeoutMs = requireTimeout( |
| input.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, |
| 'connectTimeoutMs', |
| ); |
| const handshakeTimeoutMs = requireTimeout( |
| input.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, |
| 'handshakeTimeoutMs', |
| ); |
| const livenessIntervalMs = requireTimeout( |
| input.livenessIntervalMs ?? DEFAULT_LIVENESS_INTERVAL_MS, |
| 'livenessIntervalMs', |
| ); |
| const compositionId = requireHostCompositionId( |
| input.compositionId ?? INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, |
| ); |
| let registration: HostRegistration | undefined; |
| try { |
| registration = await readRegistrationBeforeDeadline( |
| input.controlDirectory, |
| input.electionDeadline, |
| ); |
| } catch (error) { |
| if (error instanceof ElectionDeadlineElapsedError) { |
| return { kind: 'election_deadline_elapsed', endpointConnected: false, registration }; |
| } |
| if (error instanceof RuntimeHostRegistrationError && error.code === 'invalid_registration') { |
| return { kind: 'unavailable', reason: 'invalid_registration', endpointConnected: false }; |
| } |
| return { kind: 'unavailable', reason: 'connect_failed', endpointConnected: false }; |
| } |
| if (!registration) { |
| return { kind: 'unavailable', reason: 'not_registered', endpointConnected: false }; |
| } |
| if (registration.rootId !== input.capability.rootId) { |
| return { |
| kind: 'unavailable', |
| reason: 'root_mismatch', |
| endpointConnected: false, |
| registration, |
| }; |
| } |
| // Observe the candidate before opening its endpoint. Besides keeping this |
| // potentially slow OS query outside the Host's handshake window, the later |
| // root/epoch-validated handshake binds this evidence to the registration we |
| // actually reached. Query failure deliberately leaves recovery unavailable. |
| const processIdentity = shouldObserveProcessIdentity(registration, generation, input.protocol) |
| ? await (input.readProcessIdentity ?? readRuntimeHostProcessIdentity)(registration.pid).catch( |
| () => undefined, |
| ) |
| : undefined; |
| const processEvidence = processIdentity === undefined ? {} : { processIdentity }; |
| const connectDeadline = phaseDeadline(connectTimeoutMs, input.electionDeadline); |
| const connectBudget = remainingTimeout(connectDeadline.at); |
| if (connectBudget === undefined) { |
| if (connectDeadline.exhaustsElection) { |
| return { kind: 'election_deadline_elapsed', endpointConnected: false, registration }; |
| } |
| return { |
| kind: 'unavailable', |
| reason: 'connect_failed', |
| endpointConnected: false, |
| registration, |
| }; |
| } |
| let transport: FramedTransport; |
| try { |
| transport = await openTransport( |
| registration.endpoint, |
| connectBudget, |
| connectDeadline.exhaustsElection, |
| ); |
| } catch (error) { |
| if (error instanceof ElectionDeadlineElapsedError) { |
| return { kind: 'election_deadline_elapsed', endpointConnected: false, registration }; |
| } |
| return { |
| kind: 'unavailable', |
| reason: 'connect_failed', |
| endpointConnected: false, |
| registration, |
| }; |
| } |
| const handshakeDeadline = phaseDeadline(handshakeTimeoutMs, input.electionDeadline); |
| const handshakeBudget = remainingTimeout(handshakeDeadline.at); |
| if (handshakeBudget === undefined) { |
| transport.abort(); |
| if (handshakeDeadline.exhaustsElection) { |
| return { kind: 'election_deadline_elapsed', endpointConnected: true, registration }; |
| } |
| return { |
| kind: 'unavailable', |
| reason: 'handshake_failed', |
| endpointConnected: true, |
| registration, |
| }; |
| } |
| let handshakeTimeoutError: Error | undefined; |
| const handshakeTimer = setTimeout(() => { |
| handshakeTimeoutError = handshakeDeadline.exhaustsElection |
| ? new ElectionDeadlineElapsedError() |
| : new Error('Timed out handshaking with Runtime Host'); |
| transport.abort(handshakeTimeoutError); |
| }, handshakeBudget); |
| try { |
| const staleCompatibility = registration.compatibilityEpoch !== RUNTIME_HOST_COMPATIBILITY_EPOCH; |
| const helloProtocol = staleCompatibility |
| ? { |
| min: Math.min(Number.MAX_SAFE_INTEGER, registration.protocolMax + 1), |
| max: Math.min(Number.MAX_SAFE_INTEGER, registration.protocolMax + 1), |
| } |
| : input.protocol; |
| const result = await exchangeRuntimeHostHandshake({ |
| transport, |
| protocol: input.protocol, |
| helloProtocol, |
| clientInstanceId: input.clientInstanceId, |
| compositionId, |
| ...(generation === undefined ? {} : { generation }), |
| ...(input.takeoverHostEpoch === undefined |
| ? {} |
| : { takeoverHostEpoch: input.takeoverHostEpoch }), |
| expectedHostEpoch: registration.hostEpoch, |
| expectedRootId: registration.rootId, |
| expectedCompositionRevision: staleCompatibility |
| ? undefined |
| : registration.compositionRevision, |
| hostProtocol: { min: registration.protocolMin, max: registration.protocolMax }, |
| livenessIntervalMs, |
| onLivenessProbe: input.onLivenessProbe, |
| onHostStatus: input.onHostStatus, |
| }); |
| if (result.kind === 'connected') { |
| if ( |
| generation !== undefined && |
| registration.lifecycleMode !== 'service' && |
| registration.generation !== generation |
| ) { |
| await result.connection.close().catch(() => undefined); |
| return { |
| kind: 'upgrade_required', |
| registration, |
| restartable: false, |
| ...processEvidence, |
| }; |
| } |
| return { ...result, registration }; |
| } |
| transport.abort(); |
| if ( |
| result.kind === 'incompatible' && |
| generation !== undefined && |
| result.handshake.compatibilityEpoch === RUNTIME_HOST_COMPATIBILITY_EPOCH && |
| result.handshake.compositionId === compositionId && |
| result.handshake.generation !== generation |
| ) { |
| return registration.lifecycleMode === 'ephemeral' && |
| result.handshake.state === 'ready' && |
| result.handshake.activity !== undefined && |
| isHostActivityIdle(result.handshake.activity) |
| ? { |
| kind: 'upgrade_required', |
| registration, |
| restartable: true, |
| handshake: result.handshake, |
| ...processEvidence, |
| } |
| : { |
| kind: 'upgrade_required', |
| registration, |
| restartable: false, |
| handshake: result.handshake, |
| ...processEvidence, |
| }; |
| } |
| return result.kind === 'incompatible' |
| ? { ...result, registration, ...processEvidence } |
| : { kind: 'draining', registration }; |
| } catch (error) { |
| transport.abort(); |
| const failure = handshakeTimeoutError ?? error; |
| if (failure instanceof RuntimeHostEpochMismatchError) { |
| return { |
| kind: 'unavailable', |
| reason: 'epoch_mismatch', |
| endpointConnected: true, |
| registration, |
| }; |
| } |
| if (failure instanceof RuntimeHostRootMismatchError) { |
| return { |
| kind: 'unavailable', |
| reason: 'root_mismatch', |
| endpointConnected: true, |
| registration, |
| }; |
| } |
| if (failure instanceof RuntimeHostCompositionMismatchError) { |
| return { |
| kind: 'unavailable', |
| reason: 'composition_mismatch', |
| endpointConnected: true, |
| registration, |
| }; |
| } |
| if (failure instanceof ElectionDeadlineElapsedError) { |
| return { kind: 'election_deadline_elapsed', endpointConnected: true, registration }; |
| } |
| return { |
| kind: 'unavailable', |
| reason: 'handshake_failed', |
| endpointConnected: true, |
| registration, |
| }; |
| } finally { |
| clearTimeout(handshakeTimer); |
| } |
| } |
| |
| function shouldObserveProcessIdentity( |
| registration: HostRegistration, |
| generation: string | undefined, |
| protocol: ProtocolRange, |
| ): boolean { |
| return ( |
| registration.lifecycleMode === 'ephemeral' && |
| (registration.compatibilityEpoch !== RUNTIME_HOST_COMPATIBILITY_EPOCH || |
| registration.protocolMax < protocol.min || |
| registration.protocolMin > protocol.max || |
| (generation !== undefined && registration.generation !== generation)) |
| ); |
| } |
| |
| interface ExchangeRuntimeHostHandshakeInput { |
| readonly transport: RuntimeHostMessageTransport; |
| readonly protocol: ProtocolRange; |
| readonly helloProtocol?: ProtocolRange; |
| readonly hostProtocol?: ProtocolRange; |
| readonly clientInstanceId: string; |
| readonly compositionId: string; |
| readonly generation?: string; |
| readonly takeoverHostEpoch?: string; |
| readonly expectedHostEpoch?: string; |
| readonly expectedRootId?: string; |
| readonly expectedCompositionRevision?: string; |
| readonly livenessIntervalMs?: number; |
| readonly onLivenessProbe?: () => void; |
| readonly onHostStatus?: (status: HostStatusResult) => void; |
| readonly connectionResource?: RuntimeHostConnectionResource; |
| readonly peerPath?: RuntimeHostPeerConnectionPath; |
| readonly getPeerPath?: () => RuntimeHostPeerConnectionPath | undefined; |
| } |
| |
| interface LegacySurfaceClientHello extends ClientHello { |
| /** |
| * Released Hosts through v0.1.11 require this field while decoding the |
| * bootstrap hello, before compatibility negotiation can run. Keep the |
| * sentinel private until the minimum supported Host release has a tolerant |
| * decoder. Tracked by #3297. This is not part of the Client identity seen by |
| * current Hosts. |
| */ |
| readonly surface: 'desktop'; |
| } |
| |
| async function exchangeRuntimeHostHandshake( |
| input: ExchangeRuntimeHostHandshakeInput, |
| ): Promise< |
| | { kind: 'connected'; connection: RuntimeHostConnection } |
| | { kind: 'incompatible'; handshake: HostIncompatible } |
| | { kind: 'draining' } |
| > { |
| const helloProtocol = input.helloProtocol ?? input.protocol; |
| const hello: LegacySurfaceClientHello = { |
| kind: 'hello', |
| activitySnapshotVersion: 2, |
| clientInstanceId: input.clientInstanceId, |
| surface: 'desktop', |
| protocolMin: helloProtocol.min, |
| protocolMax: helloProtocol.max, |
| compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, |
| compositionId: input.compositionId, |
| ...(input.generation === undefined ? {} : { generation: input.generation }), |
| ...(input.takeoverHostEpoch === undefined |
| ? {} |
| : { takeover: { expectedHostEpoch: input.takeoverHostEpoch } }), |
| }; |
| await writeClientFrame(input.transport, hello); |
| const handshake = decodeHostFrame(await input.transport.read(0)); |
| if (!('kind' in handshake)) { |
| throw new Error('Runtime Host returned an operation response before handshake'); |
| } |
| if ( |
| handshake.kind !== 'accepted' && |
| handshake.kind !== 'incompatible' && |
| handshake.kind !== 'draining' |
| ) { |
| throw new Error('Runtime Host returned a non-handshake frame before acceptance'); |
| } |
| if (input.expectedHostEpoch && handshake.hostEpoch !== input.expectedHostEpoch) { |
| throw new RuntimeHostEpochMismatchError(); |
| } |
| if (handshake.kind === 'incompatible') return { kind: 'incompatible', handshake }; |
| if ( |
| handshake.compositionId !== input.compositionId || |
| (input.expectedCompositionRevision !== undefined && |
| handshake.compositionRevision !== input.expectedCompositionRevision) |
| ) { |
| throw new RuntimeHostCompositionMismatchError(); |
| } |
| if (handshake.kind === 'draining') return { kind: 'draining' }; |
| if (input.expectedRootId && handshake.rootId !== input.expectedRootId) { |
| throw new RuntimeHostRootMismatchError(); |
| } |
| if (handshake.compatibilityEpoch !== RUNTIME_HOST_COMPATIBILITY_EPOCH) { |
| throw new Error('Runtime Host accepted an incompatible schema epoch'); |
| } |
| if ( |
| handshake.selectedProtocol < input.protocol.min || |
| handshake.selectedProtocol > input.protocol.max || |
| (input.hostProtocol !== undefined && |
| (handshake.selectedProtocol < input.hostProtocol.min || |
| handshake.selectedProtocol > input.hostProtocol.max)) |
| ) { |
| throw new Error('Runtime Host selected a protocol outside the negotiated range'); |
| } |
| return { |
| kind: 'connected', |
| connection: new RuntimeHostConnectionImpl(input.transport, handshake, { |
| livenessIntervalMs: input.livenessIntervalMs, |
| onLivenessProbe: input.onLivenessProbe, |
| onHostStatus: input.onHostStatus, |
| connectionResource: input.connectionResource, |
| ...(input.peerPath ? { peerPath: input.peerPath } : {}), |
| ...(input.getPeerPath ? { getPeerPath: input.getPeerPath } : {}), |
| }), |
| }; |
| } |
| |
| class RuntimeHostEpochMismatchError extends Error {} |
| class RuntimeHostRootMismatchError extends Error {} |
| class RuntimeHostCompositionMismatchError extends Error {} |
| |
| export function normalizeRemoteRuntimeHostUrl( |
| value: string, |
| options: { readonly allowInsecureRemote?: boolean } = {}, |
| ): URL { |
| const url = new URL(value); |
| if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { |
| throw new Error('Remote Runtime Host URL must use ws or wss'); |
| } |
| if (url.username || url.password || url.search || url.hash) { |
| throw new Error('Remote Runtime Host URL must not contain credentials, a query, or a fragment'); |
| } |
| if ( |
| url.protocol === 'ws:' && |
| url.hostname !== '127.0.0.1' && |
| url.hostname !== '[::1]' && |
| url.hostname !== '::1' && |
| options.allowInsecureRemote !== true |
| ) { |
| throw new Error('Plain remote Runtime Host WebSocket URLs must use loopback'); |
| } |
| return url; |
| } |
| |
| function openWebSocketTransport( |
| url: URL, |
| credential: string, |
| timeoutMs: number, |
| ): Promise<WebSocketTransport> { |
| if (!credential || /\s/u.test(credential)) { |
| return Promise.reject(new RemoteRuntimeHostConnectError('authentication_failed')); |
| } |
| return new Promise((resolve, reject) => { |
| const webSocketOptions = { |
| headers: { authorization: `Bearer ${credential}` }, |
| handshakeTimeout: timeoutMs, |
| maxPayload: RUNTIME_HOST_MAX_MESSAGE_BYTES, |
| maxFragments: MAX_WEBSOCKET_FRAGMENTS, |
| maxBufferedChunks: MAX_WEBSOCKET_BUFFERED_CHUNKS, |
| perMessageDeflate: false, |
| }; |
| const socket = new WebSocket(url, webSocketOptions); |
| const onOpen = () => { |
| cleanup(); |
| resolve(new WebSocketTransport(socket)); |
| }; |
| const onError = (error: Error) => { |
| cleanup(); |
| socket.terminate(); |
| reject(error); |
| }; |
| const onUnexpectedResponse = (_request: ClientRequest, response: IncomingMessage) => { |
| cleanup(); |
| response.resume(); |
| socket.once('error', () => undefined); |
| reject( |
| new RemoteRuntimeHostConnectError( |
| response.statusCode === 401 ? 'authentication_failed' : 'connect_failed', |
| ), |
| ); |
| socket.terminate(); |
| }; |
| const cleanup = () => { |
| socket.off('open', onOpen); |
| socket.off('error', onError); |
| socket.off('unexpected-response', onUnexpectedResponse); |
| }; |
| socket.once('open', onOpen); |
| socket.once('error', onError); |
| socket.once('unexpected-response', onUnexpectedResponse); |
| }); |
| } |
| |
| type RemoteRuntimeHostConnectFailureReason = Extract< |
| ConnectRemoteRuntimeHostResult, |
| { kind: 'unavailable' } |
| >['reason']; |
| |
| const REMOTE_NETWORK_ERROR_CODES = new Set([ |
| 'EAI_AGAIN', |
| 'ECONNREFUSED', |
| 'ECONNRESET', |
| 'EHOSTUNREACH', |
| 'ENETUNREACH', |
| 'ENOTFOUND', |
| 'ETIMEDOUT', |
| ]); |
| const REMOTE_TLS_ERROR_CODES = new Set([ |
| 'CERT_HAS_EXPIRED', |
| 'CERT_NOT_YET_VALID', |
| 'CERT_REVOKED', |
| 'CERT_SIGNATURE_FAILURE', |
| 'DEPTH_ZERO_SELF_SIGNED_CERT', |
| 'SELF_SIGNED_CERT_IN_CHAIN', |
| 'UNABLE_TO_GET_ISSUER_CERT', |
| 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', |
| 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', |
| ]); |
| |
| class RemoteRuntimeHostConnectError extends Error { |
| constructor(readonly reason: RemoteRuntimeHostConnectFailureReason) { |
| super(`Remote Runtime Host connection failed (${reason})`); |
| this.name = 'RemoteRuntimeHostConnectError'; |
| } |
| } |
| |
| export function classifyRemoteRuntimeHostConnectFailure( |
| error: unknown, |
| ): RemoteRuntimeHostConnectFailureReason { |
| if (error instanceof RemoteRuntimeHostConnectError) return error.reason; |
| const code = |
| typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' |
| ? error.code |
| : undefined; |
| if (code && REMOTE_NETWORK_ERROR_CODES.has(code)) return 'unreachable'; |
| if ( |
| code && |
| (REMOTE_TLS_ERROR_CODES.has(code) || code.startsWith('ERR_TLS_') || code.startsWith('ERR_SSL_')) |
| ) { |
| return 'tls_failed'; |
| } |
| return 'connect_failed'; |
| } |
| |
| function openTransport( |
| path: string, |
| timeoutMs: number, |
| exhaustsElection: boolean, |
| ): Promise<FramedTransport> { |
| return new Promise((resolve, reject) => { |
| const socket = connect(path); |
| const timer = setTimeout(() => { |
| cleanup(); |
| socket.destroy(); |
| reject( |
| exhaustsElection |
| ? new ElectionDeadlineElapsedError() |
| : new Error('Timed out connecting to Runtime Host'), |
| ); |
| }, timeoutMs); |
| const onConnect = () => { |
| const transport = new FramedTransport(socket); |
| cleanup(); |
| resolve(transport); |
| }; |
| const onError = (error: Error) => { |
| cleanup(); |
| socket.destroy(); |
| reject(error); |
| }; |
| const cleanup = () => { |
| clearTimeout(timer); |
| socket.off('connect', onConnect); |
| socket.off('error', onError); |
| }; |
| socket.once('connect', onConnect); |
| socket.once('error', onError); |
| }); |
| } |
| |
| function requireTimeout(value: number, label: string): number { |
| if (!Number.isSafeInteger(value) || value < 1 || value > 120_000) { |
| throw new RangeError(`${label} must be an integer between 1 and 120000`); |
| } |
| return value; |
| } |
| |
| interface PhaseDeadline { |
| at: number; |
| exhaustsElection: boolean; |
| } |
| |
| function phaseDeadline(timeoutMs: number, outerDeadline: number | undefined): PhaseDeadline { |
| const phaseTimeout = performance.now() + timeoutMs; |
| if (outerDeadline !== undefined && outerDeadline <= phaseTimeout) { |
| return { at: outerDeadline, exhaustsElection: true }; |
| } |
| return { at: phaseTimeout, exhaustsElection: false }; |
| } |
| |
| function remainingTimeout(deadline: number): number | undefined { |
| const remaining = deadline - performance.now(); |
| return remaining <= 0 ? undefined : Math.max(1, Math.ceil(remaining)); |
| } |
| |
| function readRegistrationBeforeDeadline( |
| controlDirectory: string, |
| deadline: number | undefined, |
| ): Promise<HostRegistration | undefined> { |
| if (deadline === undefined) return readHostRegistration(controlDirectory); |
| const remaining = remainingTimeout(deadline); |
| if (remaining === undefined) { |
| return Promise.reject(new ElectionDeadlineElapsedError()); |
| } |
| const operation = readHostRegistration(controlDirectory); |
| return new Promise((resolve, reject) => { |
| const timer = setTimeout(() => reject(new ElectionDeadlineElapsedError()), remaining); |
| operation.then( |
| (registration) => { |
| clearTimeout(timer); |
| resolve(registration); |
| }, |
| (error: unknown) => { |
| clearTimeout(timer); |
| reject(error); |
| }, |
| ); |
| }); |
| } |
| |
| function asError(error: unknown): Error { |
| return error instanceof Error ? error : new Error(String(error)); |
| } |
| |
| function writeClientFrame( |
| transport: RuntimeHostMessageTransport, |
| frame: ClientFrame, |
| ): Promise<void> { |
| try { |
| return transport.write(encodeProtocolMessage(frame)); |
| } catch (error) { |
| return Promise.reject(error); |
| } |
| } |
| |
| function requestTimeoutError(operation: OperationKey): RuntimeHostTransportError { |
| return new RuntimeHostTransportError( |
| 'read_timeout', |
| `Timed out waiting for Runtime Host ${operation} response`, |
| ); |
| } |
| |
| function interruptedRequestError( |
| operation: OperationKey, |
| dispatch: RuntimeHostRequestDispatch, |
| reason: RuntimeHostRequestInterruptionReason, |
| cause: Error, |
| ): RuntimeHostRequestInterruptedError { |
| return new RuntimeHostRequestInterruptedError( |
| operation, |
| HOST_OPERATION_SPECS[operation].mode, |
| dispatch, |
| reason, |
| { cause }, |
| ); |
| } |