blob: cf0e4e521770a610b5e68b9a2261aebd81cadf57 [file]
/*
* 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 {
decodeClientFrame,
isClientCapabilityClientFrameKind,
RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS,
type ClientCapabilityClientFrame,
type HostOperationErrorCode,
type RequestFrame,
} from '../protocol/index.js';
import { runtimeHostLogBuffer } from '../process-diagnostics.js';
import type { RuntimeHostMessageTransport } from '../transport/message-transport.js';
import {
dispatchOperation,
operationFailureResponse,
type ConnectionContext,
type OperationHandlerMap,
type OperationResidency,
} from './operation-dispatcher.js';
import { BoundedSerialOutboundWriter } from './serial-outbound-writer.js';
import { RuntimeHostTransportError } from '../transport/framed-transport.js';
import type {
SessionContinuityConnection,
SessionContinuityService,
} from './session-continuity-service.js';
import type {
ClientCapabilityConnection,
ClientCapabilityService,
} from './client-capability-service.js';
import type {
HostChangeFeed,
HostChangeSubscription,
HostChangeSubscriptionMask,
} from './host-change-feed.js';
import type { RuntimeHostConnectionAuthority } from './connection-authority.js';
import {
authorizeClientCapabilityFrame,
authorizeRuntimeHostOperation,
hasRuntimeHostOperationGrant,
} from './connection-authority.js';
import { boundedFailureDiagnostic } from './failure-diagnostic.js';
type AcceptedConnectionContext = Omit<
ConnectionContext,
'acquireResidency' | 'principal' | 'inputClosedSignal'
> & {
readonly clientInstanceId: string;
readonly authority: RuntimeHostConnectionAuthority;
};
export interface ConnectionOperationLease {
acquireResidency(): OperationResidency;
seal(): void;
finish(): void;
}
export interface RuntimeHostConnectionSessionOptions {
transport: RuntimeHostMessageTransport;
connection: AcceptedConnectionContext;
resolveHandlers(): OperationHandlerMap;
resolveContinuity(): SessionContinuityService | undefined;
resolveClientCapabilities?(): ClientCapabilityService | undefined;
resolveHostChanges?(): HostChangeFeed | undefined;
resolveSharedSessionId?(): string | undefined;
beginOperation(frame: RequestFrame): Promise<ConnectionOperationLease | HostOperationErrorCode>;
onDiagnostic?(diagnostic: string): void;
onTeardown(): void;
}
export class RuntimeHostConnectionSession {
readonly #options: RuntimeHostConnectionSessionOptions;
readonly #writer: BoundedSerialOutboundWriter;
readonly #onDiagnostic: (diagnostic: string) => void;
readonly #requests = new Map<string, Promise<void>>();
#transcriptPageTail: Promise<void> = Promise.resolve();
#inFlightStatusRequests = 0;
#continuityService: SessionContinuityService | undefined;
#continuity: SessionContinuityConnection | undefined;
#clientCapabilityService: ClientCapabilityService | undefined;
#clientCapabilities: ClientCapabilityConnection | undefined;
#clientCapabilityCloseTask: Promise<void> | undefined;
#hostChanges: HostChangeSubscription | undefined;
readonly #inputClosedAbort = new AbortController();
#closed = false;
constructor(options: RuntimeHostConnectionSessionOptions) {
this.#options = options;
this.#onDiagnostic =
options.onDiagnostic ?? ((diagnostic) => runtimeHostLogBuffer.append('error', diagnostic));
this.#writer = new BoundedSerialOutboundWriter(options.transport, (error) => this.#fail(error));
}
async run(): Promise<void> {
this.attachGlobalChanges();
try {
try {
await this.#pumpInbound();
} catch (error) {
if (!isReadEof(error)) throw error;
await this.#closeAfterDispatchedReplies();
}
} catch (error) {
this.#fail(error);
} finally {
this.#teardown();
await Promise.allSettled(this.#requests.values());
await Promise.all([
this.#writer.settled(),
this.#options.transport.closed,
this.#clientCapabilityCloseTask?.catch(() => undefined),
]);
}
}
async #closeAfterDispatchedReplies(): Promise<void> {
this.#inputClosedAbort.abort();
this.#detachContinuity();
this.#detachClientCapabilities();
this.#detachHostChanges();
const outcome = await Promise.race([
Promise.allSettled([...this.#requests.values()]).then(() => 'drained' as const),
this.#options.transport.closed.then(() => 'closed' as const),
]);
if (outcome === 'closed') {
this.#teardown();
return;
}
if (this.#closed) return;
await this.#writer.settled();
if (this.#closed) return;
this.#closed = true;
this.#writer.close();
this.#options.transport.closeAfterFlush();
this.#options.onTeardown();
}
async #pumpInbound(): Promise<void> {
while (!this.#closed) {
const frame = decodeClientFrame(await this.#options.transport.read(0));
if ('kind' in frame) {
if (isClientCapabilityClientFrameKind(frame.kind)) {
const capabilityFrame = frame as ClientCapabilityClientFrame;
if (
!authorizeClientCapabilityFrame(this.#options.connection.authority, capabilityFrame)
) {
this.#fail(new Error('Runtime Host Client Capability frame is not authorized'));
return;
}
this.#ensureClientCapabilities()?.accept(capabilityFrame);
continue;
}
throw new Error('Unexpected handshake frame after acceptance');
}
const usesLivenessReserve =
this.#requests.size === RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS &&
(frame.operation === 'host.status' || this.#inFlightStatusRequests > 0);
if (this.#requests.has(frame.requestId)) {
this.#fail(new Error('Runtime Host Client reused an active request id'));
return;
}
if (
this.#requests.size >= RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS &&
!usesLivenessReserve
) {
this.#fail(new Error('Runtime Host Client exceeded the in-flight request limit'));
return;
}
this.#dispatch(frame);
}
}
#dispatch(frame: RequestFrame): void {
if (frame.operation === 'host.status') this.#inFlightStatusRequests += 1;
const handling =
frame.operation === 'session.transcript.page'
? this.#transcriptPageTail.then(() => this.#handleRequest(frame))
: this.#handleRequest(frame);
const task = handling
.catch((error: unknown) => this.#fail(error))
.finally(() => {
if (this.#requests.get(frame.requestId) === task) {
this.#requests.delete(frame.requestId);
if (frame.operation === 'host.status') this.#inFlightStatusRequests -= 1;
}
});
this.#requests.set(frame.requestId, task);
if (frame.operation === 'session.transcript.page') {
this.#transcriptPageTail = task.catch(() => undefined);
}
}
async #handleRequest(frame: RequestFrame): Promise<void> {
if (this.#closed) return;
if (!authorizeRuntimeHostOperation(this.#options.connection.authority, frame)) {
if (this.#closed) return;
await this.#writer.enqueue(
operationFailureResponse(frame, 'unauthorized', 'Runtime Host operation is not authorized'),
).flushed;
return;
}
const admission = await this.#options.beginOperation(frame);
if (typeof admission === 'string') {
if (this.#closed) return;
await this.#writer.enqueue(
operationFailureResponse(
frame,
admission,
admission === 'host_draining' ? 'Runtime Host is draining' : 'Runtime Host is not ready',
),
).flushed;
return;
}
try {
if (this.#closed) return;
this.#ensureClientCapabilities();
const continuity =
frame.operation === 'subscription.open' ||
frame.operation === 'subscription.close' ||
frame.operation === 'session.transcript.page'
? this.#ensureContinuity()
: undefined;
const response = await dispatchOperation(frame, this.#options.resolveHandlers(), {
...this.#options.connection,
inputClosedSignal: this.#inputClosedAbort.signal,
principal: this.#options.connection.authority.principalId,
principalKind: this.#options.connection.authority.principalKind,
...(this.#options.connection.authority.credentialId
? { credentialId: this.#options.connection.authority.credentialId }
: {}),
...(this.#options.connection.authority.clientInstanceId
? { credentialClientInstanceId: this.#options.connection.authority.clientInstanceId }
: {}),
acquireResidency: () => admission.acquireResidency(),
});
admission.seal();
const receipt = this.#writer.enqueue(response);
const openedSubscriptionId =
response.ok && response.operation === 'subscription.open'
? response.result.subscriptionId
: undefined;
try {
await receipt.flushed;
// Subscriber-local queues retain pre-activation events. Expose them
// only after the open result leaves the connection-wide writer, or a
// restore fan-out can make legal responses and first frames overflow it.
if (openedSubscriptionId) continuity?.activate(openedSubscriptionId);
} catch (error) {
if (openedSubscriptionId) continuity?.abort(openedSubscriptionId);
throw error;
}
} finally {
admission.finish();
}
}
#ensureContinuity(): SessionContinuityConnection | undefined {
if (this.#closed || this.#inputClosedAbort.signal.aborted) return;
const service = this.#options.resolveContinuity();
if (!service) return;
if (this.#continuityService && this.#continuityService !== service) {
throw new Error('Runtime Host continuity service changed within one connection');
}
if (!this.#continuity) {
this.#continuityService = service;
this.#continuity = service.attachConnection(this.#options.connection.connectionId, {
send: (frame) => {
try {
return this.#writer.enqueue(frame).flushed;
} catch (error) {
return Promise.reject(error);
}
},
});
}
return this.#continuity;
}
#detachContinuity(): void {
this.#continuity?.close();
this.#continuity = undefined;
this.#continuityService = undefined;
}
#ensureClientCapabilities(): ClientCapabilityConnection | undefined {
if (this.#closed || this.#inputClosedAbort.signal.aborted) return;
// Guests observe and submit approval requests; they cannot provide Client
// Capabilities or directly admit a Turn. In particular, their pending and
// finalized connections may overlap while the credential becomes bound to
// the Client. Neither connection owns a capability-provider registration.
if (this.#options.connection.authority.principalKind === 'session_guest') return;
const service = this.#options.resolveClientCapabilities?.();
if (!service) return;
if (this.#clientCapabilityService && this.#clientCapabilityService !== service) {
throw new Error('Runtime Host Client Capability service changed within one connection');
}
if (!this.#clientCapabilities) {
this.#clientCapabilityService = service;
this.#clientCapabilities = service.attachConnection(
{
connectionId: this.#options.connection.connectionId,
principalId: this.#options.connection.authority.principalId,
clientInstanceId: this.#options.connection.clientInstanceId,
...(this.#options.connection.authority.clientInstanceId
? {
credentialBoundClientInstanceId:
this.#options.connection.authority.clientInstanceId,
}
: {}),
principalKind: this.#options.connection.authority.principalKind,
...(this.#options.connection.authority.capabilityOwner
? { capabilityOwner: this.#options.connection.authority.capabilityOwner }
: {}),
},
{
send: (frame) => {
try {
return this.#writer.enqueue(frame).flushed;
} catch (error) {
return Promise.reject(error);
}
},
},
);
}
return this.#clientCapabilities;
}
#detachClientCapabilities(): void {
const connection = this.#clientCapabilities;
this.#clientCapabilities = undefined;
this.#clientCapabilityService = undefined;
if (!connection || this.#clientCapabilityCloseTask) return;
this.#clientCapabilityCloseTask = Promise.resolve().then(() => connection.close());
void this.#clientCapabilityCloseTask.catch(() => undefined);
}
attachGlobalChanges(): void {
if (this.#closed || this.#inputClosedAbort.signal.aborted) return;
const service = this.#options.resolveHostChanges?.();
if (!service || this.#hostChanges) return;
const sharedSessionId =
this.#options.connection.authority.principalKind === 'session_guest' &&
hasRuntimeHostOperationGrant(this.#options.connection.authority, 'session.shared.query')
? this.#options.resolveSharedSessionId?.()
: undefined;
const sessionCatalog: HostChangeSubscriptionMask['sessionCatalog'] =
sharedSessionId !== undefined
? {
sessionId: sharedSessionId,
principalId: this.#options.connection.authority.principalId,
}
: hasRuntimeHostOperationGrant(this.#options.connection.authority, 'session.catalog.query')
? true
: undefined;
this.#hostChanges = service.attachConnection(
this.#options.connection.connectionId,
{
configuration: hasRuntimeHostOperationGrant(
this.#options.connection.authority,
'runtime.policy.query',
),
connectionCatalog: hasRuntimeHostOperationGrant(
this.#options.connection.authority,
'connection.catalog.query',
),
projectCatalog: hasRuntimeHostOperationGrant(
this.#options.connection.authority,
'project.catalog.query',
),
sessionCatalog,
scheduledTask: hasRuntimeHostOperationGrant(
this.#options.connection.authority,
'scheduled-task.query',
),
},
{
send: (frame) => {
try {
return this.#writer.enqueue(frame).flushed;
} catch (error) {
return Promise.reject(error);
}
},
},
);
}
#detachHostChanges(): void {
this.#hostChanges?.close();
this.#hostChanges = undefined;
}
#fail(error: unknown): void {
if (this.#closed) return;
try {
this.#onDiagnostic(
`[runtime-host] connection session failed: ${boundedFailureDiagnostic(error)}`,
);
} catch {
// Diagnostics cannot keep an invalid connection alive.
}
this.#teardown();
}
#teardown(): void {
if (this.#closed) return;
this.#closed = true;
this.#inputClosedAbort.abort();
this.#detachContinuity();
this.#detachClientCapabilities();
this.#detachHostChanges();
this.#writer.close();
this.#options.transport.abort();
this.#options.onTeardown();
}
}
function isReadEof(error: unknown): boolean {
return error instanceof RuntimeHostTransportError && error.code === 'read_eof';
}