blob: 748829e8cb37d3554438d8f526c016d3b657ae26 [file]
import { randomUUID } from 'node:crypto';
import { connect } from 'node:net';
import { performance } from 'node:perf_hooks';
import {
prepareStorageRootControlDirectory,
resolveStorageRoot,
type StorageRootCapability,
} from '@maka/storage/root-authority';
import { readHostRegistration, RuntimeHostRegistrationError } from '../control/registration.js';
import {
decodeHostFrame,
type ClientSurface,
type HostOperationErrorCode,
type HostIncompatible,
type HostRegistration,
type HostStatusResult,
type OperationInput,
type OperationKey,
type OperationOutput,
type ProtocolRange,
type RequestFrame,
type ResponseFrame,
type SubscriptionFrame,
type SubscriptionOpenInput,
type TurnQueryInput,
type TurnSnapshot,
type TurnStartInput,
type TurnStopInput,
requireClientInstanceId,
validateProtocolRange,
} from '../protocol/index.js';
import { FramedTransport, RuntimeHostTransportError } from '../transport/framed-transport.js';
import {
ClientSessionSubscription,
RuntimeHostSubscriptionError,
type RuntimeHostSessionSubscription,
} from './session-subscription.js';
const DEFAULT_CONNECT_TIMEOUT_MS = 500;
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 2_000;
export interface ConnectRuntimeHostInput {
rootPath: string;
surface: ClientSurface;
protocol: ProtocolRange;
clientInstanceId?: string;
connectTimeoutMs?: number;
handshakeTimeoutMs?: number;
}
export type RuntimeHostUnavailableReason =
| 'not_registered'
| 'invalid_registration'
| 'root_mismatch'
| 'connect_failed'
| 'handshake_failed'
| 'epoch_mismatch';
export type ConnectRuntimeHostResult =
| {
kind: 'connected';
connection: RuntimeHostConnection;
registration: HostRegistration;
}
| {
kind: 'incompatible';
handshake: HostIncompatible;
registration: HostRegistration;
}
| { kind: 'draining'; registration: HostRegistration }
| {
kind: 'unavailable';
reason: RuntimeHostUnavailableReason;
registration?: HostRegistration;
};
type ConnectResolvedRuntimeHostResult =
| ConnectRuntimeHostResult
| {
kind: 'election_deadline_elapsed';
endpointConnected: boolean;
};
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;
}
export interface RuntimeHostConnection {
readonly hostEpoch: string;
readonly connectionId: string;
readonly selectedProtocol: number;
readonly closed: Promise<void>;
request<K extends DirectRequestOperationKey>(
operation: K,
input: OperationInput<K>,
timeoutMs?: number,
): Promise<OperationOutput<K>>;
status(timeoutMs?: number): Promise<HostStatusResult>;
startTurn(input: TurnStartInput, timeoutMs?: number): Promise<TurnSnapshot>;
queryTurn(input: TurnQueryInput, timeoutMs?: number): Promise<TurnSnapshot>;
stopTurn(input: TurnStopInput, timeoutMs?: number): Promise<TurnSnapshot>;
openSessionSubscription(
input: SubscriptionOpenInput,
timeoutMs?: number,
): Promise<RuntimeHostSessionSubscription>;
close(): Promise<void>;
}
export type DirectRequestOperationKey = Exclude<
OperationKey,
'subscription.open' | 'subscription.close'
>;
export class RuntimeHostOperationError extends Error {
constructor(
readonly operation: OperationKey,
readonly code: HostOperationErrorCode,
message: string,
) {
super(message);
this.name = 'RuntimeHostOperationError';
}
}
interface PendingRequest {
operation: OperationKey;
accept(value: unknown): unknown;
resolve(value: unknown): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
}
class RuntimeHostConnectionImpl implements RuntimeHostConnection {
readonly hostEpoch: string;
readonly connectionId: string;
readonly selectedProtocol: number;
readonly closed: Promise<void>;
readonly #transport: FramedTransport;
readonly #pendingRequests = new Map<string, PendingRequest>();
readonly #subscriptions = new Map<string, ClientSessionSubscription>();
readonly #retiredSubscriptionIds = new Set<string>();
#terminalError: Error | undefined;
constructor(
transport: FramedTransport,
accepted: {
hostEpoch: string;
connectionId: string;
selectedProtocol: number;
},
) {
this.#transport = transport;
this.hostEpoch = accepted.hostEpoch;
this.connectionId = accepted.connectionId;
this.selectedProtocol = accepted.selectedProtocol;
this.closed = this.#transport.closed;
void this.#readResponses();
}
request<K extends DirectRequestOperationKey>(
operation: K,
input: OperationInput<K>,
timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
): Promise<OperationOutput<K>> {
return this.#requestOperation(operation, input, timeoutMs, (result) => result);
}
#requestOperation<K extends OperationKey, Result>(
operation: K,
input: OperationInput<K>,
timeoutMs: number,
accept: (result: OperationOutput<K>) => Result,
): Promise<Result> {
const boundedTimeoutMs = requireTimeout(timeoutMs, 'timeoutMs');
if (this.#terminalError) return Promise.reject(this.#terminalError);
const requestId = randomUUID();
const result = new Promise<Result>((resolve, reject) => {
const timer = setTimeout(() => {
const error = new RuntimeHostTransportError(
'read_timeout',
`Timed out waiting for Runtime Host ${operation} response`,
);
this.#fail(error);
}, boundedTimeoutMs);
this.#pendingRequests.set(requestId, {
operation,
accept: (value) => accept(value as OperationOutput<K>),
resolve: (value) => resolve(value as Result),
reject,
timer,
});
});
const frame = { requestId, operation, input } as RequestFrame;
void this.#transport.write(frame).catch((error: unknown) => this.#fail(asError(error)));
return result;
}
async status(timeoutMs?: number): Promise<HostStatusResult> {
const status = await this.request('host.status', {}, timeoutMs);
if (status.hostEpoch !== this.hostEpoch) {
const error = new Error('Runtime Host returned status for a different Host Epoch');
this.#fail(error);
throw error;
}
return status;
}
startTurn(input: TurnStartInput, timeoutMs?: number): Promise<TurnSnapshot> {
return this.request('turn.start', input, timeoutMs);
}
queryTurn(input: TurnQueryInput, timeoutMs?: number): Promise<TurnSnapshot> {
return this.request('turn.query', input, timeoutMs);
}
stopTurn(input: TurnStopInput, timeoutMs?: number): Promise<TurnSnapshot> {
return this.request('turn.stop', input, timeoutMs);
}
openSessionSubscription(
input: SubscriptionOpenInput,
timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
): Promise<RuntimeHostSessionSubscription> {
const expectedSessionId = input.sessionId;
return this.#requestOperation('subscription.open', input, timeoutMs, (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),
);
this.#subscriptions.set(result.subscriptionId, subscription);
return subscription;
});
}
async close(): Promise<void> {
this.#transport.destroy();
await this.#transport.closed;
}
async #readResponses(): Promise<void> {
try {
while (true) {
const frame = decodeHostFrame(await this.#transport.read(0));
if ('kind' in frame) {
switch (frame.kind) {
case 'subscription.session_projection':
case 'subscription.session_delta':
case 'subscription.session_event':
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 || pending.operation !== frame.operation) {
this.#fail(new Error('Runtime Host returned an unmatched operation response'));
return;
}
this.#pendingRequests.delete(frame.requestId);
clearTimeout(pending.timer);
if (frame.ok) {
try {
pending.resolve(pending.accept(frame.result));
} catch (error) {
const failure = asError(error);
pending.reject(failure);
this.#fail(failure);
}
return;
}
pending.reject(
new RuntimeHostOperationError(frame.operation, frame.error.code, frame.error.message),
);
}
#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');
}
},
);
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),
).catch((failure: unknown) => this.#fail(asError(failure)));
}
#fail(error: Error): void {
if (this.#terminalError) return;
this.#terminalError = error;
for (const pending of this.#pendingRequests.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.#pendingRequests.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.#transport.destroy();
}
}
export async function connectRuntimeHost(
input: ConnectRuntimeHostInput,
): Promise<ConnectRuntimeHostResult> {
validateProtocolRange(input.protocol);
const connectTimeoutMs = requireTimeout(
input.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
'connectTimeoutMs',
);
const handshakeTimeoutMs = requireTimeout(
input.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,
'handshakeTimeoutMs',
);
const clientInstanceId = requireClientInstanceId(input.clientInstanceId ?? randomUUID());
const capability = await resolveStorageRoot({
path: input.rootPath,
kind: 'interactive',
});
const { controlDirectory } = await prepareStorageRootControlDirectory(capability);
const result = await connectResolvedRuntimeHost({
...input,
clientInstanceId,
connectTimeoutMs,
handshakeTimeoutMs,
capability,
controlDirectory,
});
if (result.kind === 'election_deadline_elapsed') {
return {
kind: 'unavailable',
reason: result.endpointConnected ? 'handshake_failed' : 'connect_failed',
};
}
return result;
}
export async function connectResolvedRuntimeHost(
input: ConnectResolvedRuntimeHostInput,
): Promise<ConnectResolvedRuntimeHostResult> {
validateProtocolRange(input.protocol);
requireClientInstanceId(input.clientInstanceId);
const connectTimeoutMs = requireTimeout(
input.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
'connectTimeoutMs',
);
const handshakeTimeoutMs = requireTimeout(
input.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,
'handshakeTimeoutMs',
);
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 };
}
if (error instanceof RuntimeHostRegistrationError && error.code === 'invalid_registration') {
return { kind: 'unavailable', reason: 'invalid_registration' };
}
return { kind: 'unavailable', reason: 'connect_failed' };
}
if (!registration) return { kind: 'unavailable', reason: 'not_registered' };
if (registration.rootId !== input.capability.rootId) {
return { kind: 'unavailable', reason: 'root_mismatch', registration };
}
const connectDeadline = phaseDeadline(connectTimeoutMs, input.electionDeadline);
const connectBudget = remainingTimeout(connectDeadline.at);
if (connectBudget === undefined) {
if (connectDeadline.exhaustsElection) {
return { kind: 'election_deadline_elapsed', endpointConnected: false };
}
return { kind: 'unavailable', reason: 'connect_failed', 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 };
}
return { kind: 'unavailable', reason: 'connect_failed', registration };
}
const handshakeDeadline = phaseDeadline(handshakeTimeoutMs, input.electionDeadline);
const handshakeBudget = remainingTimeout(handshakeDeadline.at);
if (handshakeBudget === undefined) {
transport.destroy();
if (handshakeDeadline.exhaustsElection) {
return { kind: 'election_deadline_elapsed', endpointConnected: true };
}
return { kind: 'unavailable', reason: 'handshake_failed', registration };
}
let handshakeTimeoutError: Error | undefined;
const handshakeTimer = setTimeout(() => {
handshakeTimeoutError = handshakeDeadline.exhaustsElection
? new ElectionDeadlineElapsedError()
: new Error('Timed out handshaking with Runtime Host');
transport.destroy(handshakeTimeoutError);
}, handshakeBudget);
try {
await transport.write({
kind: 'hello',
clientInstanceId: input.clientInstanceId,
surface: input.surface,
protocolMin: input.protocol.min,
protocolMax: input.protocol.max,
});
if (remainingTimeout(handshakeDeadline.at) === undefined) {
throw handshakeDeadline.exhaustsElection
? new ElectionDeadlineElapsedError()
: new Error('Runtime Host handshake deadline elapsed');
}
// The phase timer owns the full hello write/read deadline and its timeout classification.
const handshake = decodeHostFrame(await transport.read(0));
if (!('kind' in handshake))
throw new Error('Runtime Host returned an operation response before handshake');
if (handshake.hostEpoch !== registration.hostEpoch) {
transport.destroy();
return { kind: 'unavailable', reason: 'epoch_mismatch', registration };
}
if (handshake.kind === 'accepted') {
if (
handshake.selectedProtocol < input.protocol.min ||
handshake.selectedProtocol > input.protocol.max ||
handshake.selectedProtocol < registration.protocolMin ||
handshake.selectedProtocol > registration.protocolMax
) {
throw new Error('Runtime Host selected a protocol outside the negotiated range');
}
return {
kind: 'connected',
registration,
connection: new RuntimeHostConnectionImpl(transport, handshake),
};
}
transport.destroy();
if (handshake.kind === 'incompatible') return { kind: 'incompatible', handshake, registration };
return { kind: 'draining', registration };
} catch (error) {
transport.destroy();
const failure = handshakeTimeoutError ?? error;
if (failure instanceof ElectionDeadlineElapsedError) {
return { kind: 'election_deadline_elapsed', endpointConnected: true };
}
return { kind: 'unavailable', reason: 'handshake_failed', registration };
} finally {
clearTimeout(handshakeTimer);
}
}
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));
}