chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import type { ServiceStateEncoder } from "@earendil-works/chord";
import type { ClientMessageDecoder, RpcTarget } from "@earendil-works/pi-protocol";
import type { MaybePromise, RoutedServerServiceAttachment } from "./types.ts";
/** An established, authorized ordered byte connection. */
export interface ByteConnection {
readonly closed: boolean;
send(chunk: Uint8Array): Promise<void>;
close(finalChunk?: Uint8Array): MaybePromise<void>;
}
export interface ByteConnectionHandler {
onData(chunk: Uint8Array): void;
onClose(): void;
onError(error: Error): void;
}
export type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler;
export type ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed";
export interface ConnectionState {
connection: ByteConnection;
decoder: ClientMessageDecoder;
serviceStateEncoders: Map<string, ServiceStateEncoder>;
stage: ConnectionStage;
disconnected: boolean;
handshake?: Promise<void>;
handshakeTimeout: NodeJS.Timeout;
serverServices?: RoutedServerServiceAttachment;
activeRequests: Map<string, { controller: AbortController; target: RpcTarget }>;
}
export function isTerminalConnection(state: ConnectionState): boolean {
return state.disconnected || state.stage === "closing" || state.stage === "closed";
}

View File

@@ -0,0 +1,57 @@
import type { RemoteServiceErrorCode } from "@earendil-works/chord";
type ServerOperationErrorCode =
| RemoteServiceErrorCode
| "wrong_server"
| "session_not_found"
| "session_ambiguous"
| "session_not_attached"
| "server_draining";
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
/** A host or lifecycle error that can safely cross the protocol boundary. */
export class ServerError extends Error {
readonly code: ServerOperationErrorCode;
constructor(code: ServerOperationErrorCode, message: string) {
super(message);
this.name = "ServerError";
this.code = code;
}
}
export class WrongServerError extends ServerError {
constructor() {
super("wrong_server", "Request was addressed to another server");
this.name = "WrongServerError";
}
}
export class SessionNotFoundError extends ServerError {
constructor(message = "Session was not found") {
super("session_not_found", message);
this.name = "SessionNotFoundError";
}
}
export class SessionAmbiguousError extends ServerError {
constructor() {
super("session_ambiguous", "Session ID matches more than one session");
this.name = "SessionAmbiguousError";
}
}
export class SessionNotAttachedError extends ServerError {
constructor() {
super("session_not_attached", "Session is not attached to this client");
this.name = "SessionNotAttachedError";
}
}
export class ServerDrainingError extends ServerError {
constructor() {
super("server_draining", "Server is draining");
this.name = "ServerDrainingError";
}
}

View File

@@ -0,0 +1,4 @@
export * from "./errors.ts";
export * from "./listener.ts";
export * from "./server.ts";
export * from "./types.ts";

View File

@@ -0,0 +1,8 @@
import type { ByteConnectionAcceptor } from "./connection.ts";
/** Supplies established byte connections after any required transport authentication. */
export interface ServerListener {
/** Starts listening and passes authorized connections to accept. */
start(accept: ByteConnectionAcceptor): Promise<void>;
close(): Promise<void>;
}

View File

@@ -0,0 +1,576 @@
import {
createServiceStateEncoder,
decodeServiceControlCall,
type JsonValue,
parseServiceCall,
parseServiceSubscriptionSnapshot,
RemoteServiceError,
type ServiceCall,
type ServiceProviderUpdate,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core";
import {
type CancelEnvelope,
type ClientHello,
type ClientMessage,
ClientMessageDecoder,
DEFAULT_MAX_FRAME_LENGTH,
encodeServerMessage,
isServerId,
isSupportedProtocolVersion,
PROTOCOL_VERSION,
type ProtocolError,
ProtocolValidationError,
type RequestEnvelope,
type ResponseEnvelope,
type RpcTarget,
type ServerHello,
type ServerHelloError,
type ServerMessage,
} from "@earendil-works/pi-protocol";
import {
type ByteConnection,
type ByteConnectionHandler,
type ConnectionState,
isTerminalConnection,
} from "./connection.ts";
import { INTERNAL_SERVER_ERROR_MESSAGE, ServerError, WrongServerError } from "./errors.ts";
import type { ServerListener } from "./listener.ts";
import { SessionRouter } from "./session-router.ts";
import type { ServerHost, ServerOptions } from "./types.ts";
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
const MAX_UINT32 = 0xffff_ffff;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
readonly serverId: string;
/** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */
readonly closed: Promise<void>;
private readonly host: ServerHost<TMetadata>;
private readonly listeners: readonly ServerListener[];
private readonly maxFrameLength: number;
private readonly handshakeTimeoutMs: number;
private readonly onConnectionCountChanged: ((count: number) => void) | undefined;
private readonly onError: ((error: Error) => void) | undefined;
private readonly connections = new Set<ConnectionState>();
private readonly sessions: SessionRouter<TMetadata>;
private closing = false;
private closePromise?: Promise<void>;
private closedSettled = false;
private rejectClosed!: (error: unknown) => void;
private resolveClosed!: () => void;
private startPromise?: Promise<this>;
private started = false;
constructor(host: ServerHost<TMetadata>, options: ServerOptions) {
const resolved = resolveOptions(options);
this.host = host;
this.listeners = options.listeners;
this.serverId = options.serverId;
this.maxFrameLength = resolved.maxFrameLength;
this.handshakeTimeoutMs = resolved.handshakeTimeoutMs;
this.onConnectionCountChanged = options.onConnectionCountChanged;
this.onError = options.onError;
this.sessions = new SessionRouter({
host,
serverId: this.serverId,
isClosing: () => this.closing,
publishAttachment: async (client, attachment) => {
await this.sendMessage(client as ConnectionState, {
type: "attachment",
attachment: attachment ?? null,
});
},
reportError: (error) => this.reportError(error),
});
this.closed = new Promise((resolve, reject) => {
this.resolveClosed = resolve;
this.rejectClosed = reject;
});
void this.closed.catch(() => {});
}
start(): Promise<this> {
if (this.started) return Promise.reject(new Error("Server is already started"));
if (this.startPromise) return Promise.reject(new Error("Server is already starting"));
if (this.closing) return Promise.reject(new Error("Server is closing or closed"));
this.startPromise = this.startInternal();
return this.startPromise;
}
private async startInternal(): Promise<this> {
const started: ServerListener[] = [];
try {
for (const listener of this.listeners) {
await listener.start((connection) => this.accept(connection));
started.push(listener);
}
this.started = true;
return this;
} catch (error) {
this.closing = true;
const cleanupErrors: unknown[] = [];
const listenerResults = await Promise.allSettled(started.map((listener) => listener.close()));
for (const result of listenerResults) {
if (result.status === "rejected") cleanupErrors.push(result.reason);
}
try {
await this.closeServerState();
} catch (cleanupError) {
cleanupErrors.push(cleanupError);
}
if (cleanupErrors.length > 0) {
const failure = new AggregateError([error, ...cleanupErrors], "Server startup and cleanup failed");
this.settleClosed(failure);
throw failure;
}
this.settleClosed();
throw error;
} finally {
this.startPromise = undefined;
}
}
accept(connection: ByteConnection): ByteConnectionHandler {
if (this.closing) {
void this.closeConnection(connection);
return {
onData: () => {},
onClose: () => {},
onError: (error) => this.reportError(error),
};
}
let state: ConnectionState;
const handshakeTimeout = setTimeout(() => {
void this.failProtocol(state, {
code: "invalid_request",
message: "Handshake timeout",
});
}, this.handshakeTimeoutMs);
handshakeTimeout.unref();
state = {
connection,
decoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }),
serviceStateEncoders: new Map(),
stage: "awaitingHello",
disconnected: false,
handshakeTimeout,
activeRequests: new Map(),
};
this.connections.add(state);
this.notifyConnectionCountChanged();
return {
onData: (chunk) => this.receive(state, chunk),
onClose: () => this.transportClosed(state),
onError: (error) => {
this.reportError(error);
void this.closeConnection(connection).then(() => this.disconnect(state));
},
};
}
async close(): Promise<void> {
if (this.closePromise) return this.closePromise;
this.closing = true;
this.closePromise = this.closeInternal();
return this.closePromise;
}
private async closeInternal(): Promise<void> {
const starting = this.startPromise;
if (starting) await starting.catch(() => {});
const errors: unknown[] = [];
const listenerResults = await Promise.allSettled(this.listeners.map((listener) => listener.close()));
for (const result of listenerResults) {
if (result.status === "rejected") errors.push(result.reason);
}
try {
await this.closeServerState();
} catch (error) {
errors.push(error);
}
this.started = false;
if (errors.length > 0) {
const failure =
errors.length === 1 && errors[0] instanceof Error
? errors[0]
: new AggregateError(errors, "Server shutdown failed");
this.settleClosed(failure);
throw failure;
}
this.settleClosed();
}
private receive(state: ConnectionState, chunk: Uint8Array): void {
if (isTerminalConnection(state)) return;
let messages: ClientMessage[];
try {
messages = state.decoder.push(chunk);
} catch (error) {
void this.failProtocol(state, this.toProtocolError(error));
return;
}
for (const message of messages) {
if (isTerminalConnection(state)) return;
this.dispatchMessage(state, message);
}
}
private dispatchMessage(state: ConnectionState, message: ClientMessage): void {
if (state.stage === "awaitingHello") {
if (message.type !== "hello") {
void this.failProtocol(state, {
code: "invalid_request",
message: "The first client message must be hello",
});
return;
}
state.stage = "handshaking";
state.handshake = this.finishHandshake(state, message).catch((error: unknown) =>
this.failProtocol(state, this.toProtocolError(error)),
);
return;
}
if (message.type === "hello") {
void this.failProtocol(state, {
code: "invalid_request",
message: "hello may only be sent as the first message",
});
return;
}
if (state.stage === "ready") {
if (message.type === "cancel") this.handleCancel(state, message);
else void this.handleRequest(state, message);
return;
}
if (state.stage !== "handshaking") return;
const handshake = state.handshake;
if (!handshake) return;
void handshake.then(() => {
if (state.stage !== "ready" || state.disconnected) return;
if (message.type === "cancel") this.handleCancel(state, message);
else void this.handleRequest(state, message);
});
}
private async finishHandshake(state: ConnectionState, hello: ClientHello): Promise<void> {
if (!isSupportedProtocolVersion(hello.version)) {
await this.failProtocol(state, {
code: "version",
message: `Unsupported protocol version ${hello.version}; expected ${PROTOCOL_VERSION}`,
});
return;
}
if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) return;
const services = await this.host.serverServices.attachClient(
{
attachSession: async (sessionId, context) => {
await this.sessions.attachClient(state, sessionId, context);
},
detachSession: (context) => this.sessions.detachClient(state, context),
prepareSessionRemoval: (sessionId, context) => this.sessions.removeSession(sessionId, context),
},
TODO_CONTEXT,
);
if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) {
await services.release(TODO_CONTEXT);
return;
}
state.serverServices = services;
const sent = await this.sendMessage(state, {
type: "hello",
version: PROTOCOL_VERSION,
serverId: this.serverId,
} satisfies ServerHello);
if (sent && !state.disconnected && state.stage === "handshaking") {
state.stage = "ready";
clearTimeout(state.handshakeTimeout);
}
}
private handleCancel(state: ConnectionState, envelope: CancelEnvelope): void {
if (envelope.target.serverId !== this.serverId) return;
const active = state.activeRequests.get(envelope.id);
if (active !== undefined && sameTarget(active.target, envelope.target)) {
active.controller.abort(new DOMException("RPC request cancelled", "AbortError"));
}
}
private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise<void> {
if (state.activeRequests.has(envelope.id)) {
await this.sendMessage(state, {
type: "response",
id: envelope.id,
ok: false,
error: { code: "invalid_request", message: "Request ID is already active" },
} satisfies ResponseEnvelope);
return;
}
let call: ServiceCall;
try {
call = parseServiceCall(envelope.call);
} catch {
await this.sendMessage(state, {
type: "response",
id: envelope.id,
ok: false,
error: { code: "invalid_request", message: "Invalid service call" },
} satisfies ResponseEnvelope);
return;
}
const controller = new AbortController();
const active = { controller, target: envelope.target };
state.activeRequests.set(envelope.id, active);
const context = withAbortSignal(controller.signal, TODO_CONTEXT);
const control = decodeServiceControlCall(call);
const subscribing = control?.type === "subscribe" ? control : undefined;
const pendingUpdates: { readonly update: ServiceProviderUpdate }[] = [];
let subscriptionReady = subscribing === undefined;
let installedSubscriptionEncoder = false;
let responded = false;
const publish = async (subscriptionId: string, update: ServiceProviderUpdate): Promise<void> => {
if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {
pendingUpdates.push({ update });
return;
}
await this.sendServiceUpdate(state, subscriptionId, update);
};
try {
if (envelope.target.serverId !== this.serverId) throw new WrongServerError();
if (subscribing !== undefined && state.serviceStateEncoders.has(subscribing.subscriptionId)) {
throw new ProtocolValidationError(`Duplicate service subscription ${subscribing.subscriptionId}`);
}
let result: JsonValue | undefined;
if ("sessionId" in envelope.target) {
result = await this.sessions.executeServiceCall(call, envelope.target, state, publish, context);
} else if (state.serverServices !== undefined) {
result = await state.serverServices.invokeService(call, publish, context);
} else {
throw new ProtocolValidationError(`Unknown service member ${call.serviceId}.${call.member}`);
}
if (subscribing !== undefined) {
if (result === undefined)
throw new ProtocolValidationError("Service subscription did not return a snapshot");
const stateEncoder = createServiceStateEncoder();
result = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result)) as unknown as JsonValue;
state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);
installedSubscriptionEncoder = true;
} else if (control?.type === "unsubscribe") {
state.serviceStateEncoders.delete(control.subscriptionId);
}
await this.sendMessage(
state,
result === undefined
? { type: "response", id: envelope.id, ok: true }
: { type: "response", id: envelope.id, ok: true, result },
);
responded = true;
if (subscribing !== undefined) {
while (pendingUpdates.length > 0) {
const pending = pendingUpdates.shift();
if (pending !== undefined)
await this.sendServiceUpdate(state, subscribing.subscriptionId, pending.update);
}
subscriptionReady = true;
}
} catch (error) {
if (subscribing !== undefined && installedSubscriptionEncoder && !responded) {
state.serviceStateEncoders.delete(subscribing.subscriptionId);
}
if (responded) {
this.reportError(error);
await this.closeConnection(state.connection);
this.disconnect(state);
} else {
await this.sendMessage(state, {
type: "response",
id: envelope.id,
ok: false,
error: controller.signal.aborted
? { code: "cancelled", message: "RPC request cancelled" }
: this.toProtocolError(error),
} satisfies ResponseEnvelope);
}
} finally {
if (state.activeRequests.get(envelope.id) === active) state.activeRequests.delete(envelope.id);
}
}
private transportClosed(connection: ConnectionState): void {
if (!connection.disconnected && connection.stage !== "closing") {
try {
connection.decoder.end();
} catch (error) {
this.reportError(error);
}
}
this.disconnect(connection);
}
private disconnect(connection: ConnectionState): void {
if (connection.disconnected) return;
connection.disconnected = true;
connection.stage = "closed";
clearTimeout(connection.handshakeTimeout);
for (const { controller } of connection.activeRequests.values()) {
controller.abort(new Error("Client disconnected"));
}
connection.activeRequests.clear();
connection.serviceStateEncoders.clear();
if (this.connections.delete(connection)) this.notifyConnectionCountChanged();
const serverServices = connection.serverServices;
delete connection.serverServices;
void Promise.allSettled([
this.sessions.disconnect(connection, TODO_CONTEXT),
serverServices?.release(TODO_CONTEXT),
]).then((results) => {
for (const result of results) if (result.status === "rejected") this.reportError(result.reason);
});
}
private async sendServiceUpdate(
connection: ConnectionState,
subscriptionId: string,
update: ServiceProviderUpdate,
): Promise<void> {
const stateEncoder = connection.serviceStateEncoders.get(subscriptionId);
if (stateEncoder === undefined) return;
await this.sendMessage(connection, {
type: "service_update",
subscriptionId,
update: stateEncoder.encodeUpdate(update) as unknown as JsonValue,
});
}
private async sendMessage(connection: ConnectionState, message: ServerMessage): Promise<boolean> {
if (connection.disconnected || connection.connection.closed) return false;
let frame: Uint8Array;
try {
frame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });
} catch (error) {
this.reportError(error);
await this.closeConnection(connection.connection);
this.disconnect(connection);
return false;
}
try {
await connection.connection.send(frame);
return true;
} catch (error) {
this.reportError(error);
await this.closeConnection(connection.connection);
this.disconnect(connection);
return false;
}
}
private async failProtocol(connection: ConnectionState, error: ProtocolError): Promise<void> {
if (connection.disconnected || connection.stage === "closing" || connection.stage === "closed") return;
connection.stage = "closing";
clearTimeout(connection.handshakeTimeout);
const message: ServerHelloError = { type: "hello_error", error };
let finalFrame: Uint8Array | undefined;
try {
finalFrame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });
} catch (encodeError) {
this.reportError(encodeError);
}
await this.closeConnection(connection.connection, finalFrame);
this.disconnect(connection);
}
private async closeServerState(): Promise<void> {
const connections = [...this.connections];
for (const connection of connections) {
connection.stage = "closing";
clearTimeout(connection.handshakeTimeout);
}
await Promise.all(connections.map((connection) => this.closeConnection(connection.connection)));
for (const connection of connections) this.disconnect(connection);
const cleanup = await Promise.allSettled([this.sessions.close(BACKGROUND_CONTEXT)]);
this.connections.clear();
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to close server Sessions");
}
private async closeConnection(connection: ByteConnection, finalChunk?: Uint8Array): Promise<void> {
try {
await connection.close(finalChunk);
} catch (error) {
this.reportError(error);
}
}
private toProtocolError(error: unknown): ProtocolError {
if (error instanceof ServerError || error instanceof RemoteServiceError) {
return { code: error.code, message: error.message };
}
if (error instanceof ProtocolValidationError) {
return { code: "invalid_request", message: error.message };
}
this.reportError(error);
return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE };
}
private notifyConnectionCountChanged(): void {
try {
this.onConnectionCountChanged?.(this.connections.size);
} catch (error) {
this.reportError(error);
}
}
private reportError(error: unknown): void {
try {
this.onError?.(error instanceof Error ? error : new Error(String(error)));
} catch {
// Error observers cannot affect server state.
}
}
private settleClosed(error?: unknown): void {
if (this.closedSettled) return;
this.closedSettled = true;
if (error === undefined) this.resolveClosed();
else this.rejectClosed(error);
}
}
function sameTarget(left: RpcTarget, right: RpcTarget): boolean {
if (left.serverId !== right.serverId) return false;
if (!("sessionId" in left) || !("sessionId" in right)) {
return !("sessionId" in left) && !("sessionId" in right);
}
return left.sessionId === right.sessionId && left.attachmentId === right.attachmentId;
}
function resolveOptions(options: ServerOptions): {
maxFrameLength: number;
handshakeTimeoutMs: number;
} {
if (!Array.isArray(options.listeners)) throw new TypeError("Server listeners must be an array");
if (!isServerId(options.serverId)) {
throw new TypeError("serverId must be a canonical lowercase UUIDv4");
}
const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {
throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
}
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
if (
!Number.isSafeInteger(handshakeTimeoutMs) ||
handshakeTimeoutMs <= 0 ||
handshakeTimeoutMs > MAX_TIMER_DELAY_MS
) {
throw new TypeError(`Server handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
}
return { maxFrameLength, handshakeTimeoutMs };
}

View File

@@ -0,0 +1,312 @@
import { randomUUID } from "node:crypto";
import type { JsonValue, ServiceCall, ServiceProviderUpdate } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, type Context, type SessionMetadata } from "@earendil-works/pi-agent-core";
import type { RpcTarget, SessionTarget } from "@earendil-works/pi-protocol";
import { ServerDrainingError, SessionNotAttachedError } from "./errors.ts";
import type { RoutedSessionAttachment, RoutedSessionHandle, ServerHost } from "./types.ts";
class SessionCleanupError extends AggregateError {}
interface ClientAttachment {
readonly id: string;
readonly client: object;
readonly session: HostedSession;
readonly operations: Set<Promise<unknown>>;
acquiring?: Promise<RoutedSessionAttachment>;
lease?: RoutedSessionAttachment;
releasing?: Promise<void>;
}
interface HostedSession {
readonly id: string;
readonly handle: RoutedSessionHandle;
readonly attachments: Set<ClientAttachment>;
}
interface SessionRouterOptions<TMetadata extends SessionMetadata> {
host: ServerHost<TMetadata>;
serverId: string;
isClosing: () => boolean;
publishAttachment(client: object, attachment: SessionTarget | undefined, context: Context): Promise<void>;
reportError: (error: unknown) => void;
}
export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata> {
private readonly options: SessionRouterOptions<TMetadata>;
private readonly hostedSessions = new Map<string, HostedSession>();
private readonly openingSessions = new Map<string, Promise<HostedSession>>();
private readonly attachmentsByClient = new Map<object, ClientAttachment>();
private readonly disconnectedClients = new Set<object>();
private readonly clientOperations = new Map<object, Promise<void>>();
private closePromise?: Promise<void>;
constructor(options: SessionRouterOptions<TMetadata>) {
this.options = options;
}
async executeServiceCall(
call: ServiceCall,
target: RpcTarget,
client: object,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise<void>,
context: Context,
): Promise<JsonValue | undefined> {
const admitted = await this.runForClient(client, () =>
this.startServiceCall(client, target, call, publish, context),
);
return admitted.result;
}
attachClient(client: object, sessionId: string, context: Context): Promise<void> {
if (this.options.isClosing()) return Promise.reject(new ServerDrainingError());
return this.runForClient(client, () => this.attachClientNow(client, sessionId, context));
}
detachClient(client: object, context: Context): Promise<void> {
return this.runForClient(client, async () => {
const attachment = this.attachmentsByClient.get(client);
if (attachment) await this.releaseAttachment(attachment, context);
});
}
async removeSession(sessionId: string, context: Context): Promise<void> {
if (this.options.isClosing()) throw new ServerDrainingError();
const hosted = this.hostedSessions.get(sessionId);
if (hosted === undefined) return;
const errors: unknown[] = [];
const releases = await Promise.allSettled(
[...hosted.attachments].map((attachment) => this.releaseAttachment(attachment, context)),
);
for (const result of releases) if (result.status === "rejected") errors.push(result.reason);
try {
await hosted.handle.close(context);
} catch (error) {
errors.push(error);
}
if (this.hostedSessions.get(sessionId) === hosted) this.hostedSessions.delete(sessionId);
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, `Failed to close Session ${sessionId}`);
}
async disconnect(client: object, context: Context): Promise<void> {
this.disconnectedClients.add(client);
try {
await this.runForClient(client, async () => {
const attachment = this.attachmentsByClient.get(client);
if (attachment) await this.releaseAttachment(attachment, context, false);
});
} finally {
this.disconnectedClients.delete(client);
}
}
close(context: Context): Promise<void> {
this.closePromise ??= this.closeInternal(context);
return this.closePromise;
}
private async closeInternal(context: Context): Promise<void> {
const operationPromises = [...this.clientOperations.values()];
const openingPromises = [...this.openingSessions.values()];
const [operationResults, openingResults] = await Promise.all([
Promise.allSettled(operationPromises),
Promise.allSettled(openingPromises),
]);
const closeErrors: unknown[] = [];
for (const result of [...operationResults, ...openingResults]) {
if (result.status !== "rejected") continue;
this.options.reportError(result.reason);
if (result.reason instanceof SessionCleanupError) closeErrors.push(result.reason);
}
const attachmentResults = await Promise.allSettled(
[...this.hostedSessions.values()].flatMap((session) =>
[...session.attachments].map((attachment) => this.releaseAttachment(attachment, context)),
),
);
for (const result of attachmentResults) {
if (result.status === "rejected") closeErrors.push(result.reason);
}
const hosted = [...this.hostedSessions.values()];
const closeResults = await Promise.allSettled(hosted.map(({ handle }) => handle.close(context)));
for (let index = 0; index < closeResults.length; index++) {
const result = closeResults[index]!;
const session = hosted[index]!;
if (result.status === "fulfilled") {
if (this.hostedSessions.get(session.id) === session) this.hostedSessions.delete(session.id);
continue;
}
this.options.reportError(result.reason);
closeErrors.push(result.reason);
}
this.attachmentsByClient.clear();
this.clientOperations.clear();
if (closeErrors.length > 0) throw new AggregateError(closeErrors, "Failed to close routed Sessions");
}
private runForClient<T>(client: object, operation: () => Promise<T>): Promise<T> {
const previous = this.clientOperations.get(client) ?? Promise.resolve();
const result = previous.catch(() => {}).then(operation);
const tail = result.then(
() => undefined,
() => undefined,
);
this.clientOperations.set(client, tail);
void tail.finally(() => {
if (this.clientOperations.get(client) === tail) this.clientOperations.delete(client);
});
return result;
}
private async attachClientNow(client: object, sessionId: string, context: Context): Promise<void> {
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
const current = this.attachmentsByClient.get(client);
if (current?.session.id === sessionId) return;
const hosted = await this.acquire(sessionId, context);
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
if (current) await this.releaseAttachment(current, context, false);
const attachment: ClientAttachment = {
id: randomUUID(),
client,
session: hosted,
operations: new Set(),
};
hosted.attachments.add(attachment);
try {
const acquiring = Promise.resolve(hosted.handle.attachClient(context));
attachment.acquiring = acquiring;
attachment.lease = await acquiring;
} catch (error) {
hosted.attachments.delete(attachment);
throw error;
}
if (
this.hostedSessions.get(hosted.id) !== hosted ||
!hosted.attachments.has(attachment) ||
this.disconnectedClients.has(client) ||
this.options.isClosing()
) {
await this.releaseAttachment(attachment, context);
throw new ServerDrainingError();
}
this.attachmentsByClient.set(client, attachment);
await this.options.publishAttachment(
client,
{ serverId: this.options.serverId, sessionId, attachmentId: attachment.id },
context,
);
}
private async startServiceCall(
client: object,
target: RpcTarget,
call: ServiceCall,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise<void>,
context: Context,
): Promise<{ result: Promise<JsonValue | undefined> }> {
const attachment = this.requireAttachment(client, target);
const result = attachment.lease!.invokeService(
call,
(subscriptionId, update, updateContext) => publish(subscriptionId, update, updateContext),
context,
);
this.trackOperation(attachment, result);
return { result };
}
private trackOperation(attachment: ClientAttachment, result: Promise<unknown>): void {
attachment.operations.add(result);
const remove = (): void => {
attachment.operations.delete(result);
};
void result.then(remove, remove);
}
private requireAttachment(client: object, target: RpcTarget): ClientAttachment {
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
if (!("sessionId" in target)) throw new SessionNotAttachedError();
const attachment = this.attachmentsByClient.get(client);
if (!attachment || attachment.session.id !== target.sessionId || attachment.id !== target.attachmentId) {
throw new SessionNotAttachedError();
}
return attachment;
}
private releaseAttachment(attachment: ClientAttachment, context: Context, publish = true): Promise<void> {
attachment.releasing ??= (async () => {
const errors: unknown[] = [];
try {
await Promise.allSettled(attachment.operations);
try {
const lease = attachment.lease ?? (await attachment.acquiring);
if (lease) await lease.release(context);
} catch (error) {
errors.push(error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to release Session attachment");
} finally {
await this.clearAttachment(attachment, context, publish);
}
})();
return attachment.releasing;
}
private async clearAttachment(attachment: ClientAttachment, context: Context, publish: boolean): Promise<void> {
attachment.session.attachments.delete(attachment);
if (this.attachmentsByClient.get(attachment.client) === attachment) {
this.attachmentsByClient.delete(attachment.client);
if (publish) await this.options.publishAttachment(attachment.client, undefined, context);
}
}
private async acquire(sessionId: string, context: Context): Promise<HostedSession> {
const existing = this.hostedSessions.get(sessionId);
if (existing) return existing;
const opening = this.openingSessions.get(sessionId);
if (opening) return opening;
const pending = this.open(sessionId, context);
this.openingSessions.set(sessionId, pending);
try {
return await pending;
} finally {
if (this.openingSessions.get(sessionId) === pending) this.openingSessions.delete(sessionId);
}
}
private async open(sessionId: string, context: Context): Promise<HostedSession> {
const metadata = await this.options.host.resolveSession(sessionId, context);
const handle = await this.options.host.openSession(metadata, context);
if (this.options.isClosing()) {
try {
await handle.close(context);
} catch (error) {
this.options.reportError(error);
throw new SessionCleanupError(
[new ServerDrainingError(), error],
"Failed to close routed Session acquired while draining",
);
}
throw new ServerDrainingError();
}
const hosted: HostedSession = { id: metadata.id, handle, attachments: new Set() };
this.hostedSessions.set(hosted.id, hosted);
if (handle.terminated) {
void handle.terminated.then(
(error) => this.invalidate(hosted, error),
(error: unknown) => this.invalidate(hosted, error instanceof Error ? error : new Error(String(error))),
);
}
return hosted;
}
private invalidate(hosted: HostedSession, error: Error | undefined): void {
if (this.hostedSessions.get(hosted.id) !== hosted) return;
this.hostedSessions.delete(hosted.id);
for (const attachment of hosted.attachments) {
void this.releaseAttachment(attachment, BACKGROUND_CONTEXT).catch((releaseError: unknown) =>
this.options.reportError(releaseError),
);
}
if (error) this.options.reportError(error);
}
}

View File

@@ -0,0 +1,183 @@
import { once } from "node:events";
import { createConnection, type Socket } from "node:net";
import type { JsonValue, ServiceCall } from "@earendil-works/chord";
import {
type ClientMessage,
encodeClientMessage,
PROTOCOL_VERSION,
type ResponseEnvelope,
type RpcTarget,
type ServerMessage,
ServerMessageDecoder,
} from "@earendil-works/pi-protocol";
import { Deferred } from "./host.ts";
interface MessageWaiter {
predicate: (message: ServerMessage) => boolean;
resolve: (message: ServerMessage) => void;
reject: (error: Error) => void;
}
export interface WireChannel {
send(chunk: Uint8Array): Promise<void>;
sendFragmented(chunk: Uint8Array, splitAt: number): Promise<void>;
close(): Promise<void>;
}
export class ProtocolTestClient {
readonly messages: ServerMessage[] = [];
private readonly channel: WireChannel;
private readonly decoder = new ServerMessageDecoder();
private readonly waiters = new Set<MessageWaiter>();
private readonly closedDeferred = new Deferred<void>();
private requestSequence = 0;
private attachment: { sessionId: string; attachmentId: string } | undefined;
private closedValue = false;
constructor(channel: WireChannel) {
this.channel = channel;
}
get closed(): boolean {
return this.closedValue;
}
hello(version: number = PROTOCOL_VERSION): Promise<ServerMessage> {
const response = this.next((message) => message.type === "hello" || message.type === "hello_error");
void this.sendMessage({ type: "hello", version });
return response;
}
async requestService(
target: RpcTarget,
call: ServiceCall,
id = `request-${++this.requestSequence}`,
): Promise<ResponseEnvelope> {
const response = this.next(
(message): message is ResponseEnvelope => message.type === "response" && message.id === id,
);
await this.sendMessage({ type: "request", id, target, call: call as unknown as JsonValue });
return (await response) as ResponseEnvelope;
}
attach(serverId: string, sessionId: string): Promise<ResponseEnvelope> {
return this.requestService(
{ serverId },
{ serviceId: "pi.session-management", member: "attach", args: [sessionId] },
);
}
requestSessionService(
serverId: string,
sessionId: string,
call: ServiceCall,
id?: string,
): Promise<ResponseEnvelope> {
const attachment = this.attachment;
const target: RpcTarget =
attachment === undefined || attachment.sessionId !== sessionId
? { serverId, sessionId, attachmentId: "missing-attachment" }
: { serverId, ...attachment };
return this.requestService(target, call, id);
}
sendMessage(message: ClientMessage): Promise<void> {
return this.channel.send(encodeClientMessage(message));
}
sendBytes(chunk: Uint8Array): Promise<void> {
return this.channel.send(chunk);
}
sendFragmentedMessage(message: ClientMessage, splitAt: number): Promise<void> {
return this.channel.sendFragmented(encodeClientMessage(message), splitAt);
}
next(predicate: (message: ServerMessage) => boolean): Promise<ServerMessage> {
return this.nextFrom(0, predicate);
}
nextFrom(index: number, predicate: (message: ServerMessage) => boolean): Promise<ServerMessage> {
const existing = this.messages.slice(index).find(predicate);
if (existing) return Promise.resolve(existing);
if (this.closedValue) return Promise.reject(new Error("Wire client is closed"));
return new Promise((resolve, reject) => this.waiters.add({ predicate, resolve, reject }));
}
waitForClose(): Promise<void> {
return this.closedValue ? Promise.resolve() : this.closedDeferred.promise;
}
close(): Promise<void> {
return this.channel.close();
}
receive(chunk: Uint8Array): void {
try {
for (const message of this.decoder.push(chunk)) {
if (message.type === "attachment") {
this.attachment =
message.attachment === null
? undefined
: {
sessionId: message.attachment.sessionId,
attachmentId: message.attachment.attachmentId,
};
}
this.messages.push(message);
for (const waiter of this.waiters) {
if (!waiter.predicate(message)) continue;
this.waiters.delete(waiter);
waiter.resolve(message);
}
}
} catch (error) {
this.fail(error instanceof Error ? error : new Error(String(error)));
}
}
markClosed(): void {
if (this.closedValue) return;
this.closedValue = true;
this.closedDeferred.resolve(undefined);
this.fail(new Error("Wire connection closed"));
}
fail(error: Error): void {
for (const waiter of this.waiters) waiter.reject(error);
this.waiters.clear();
}
}
export async function connectUnixTestClient(path: string): Promise<ProtocolTestClient> {
const socket = createConnection(path);
await once(socket, "connect");
const client = new ProtocolTestClient({
send: (chunk) => writeSocket(socket, chunk),
async sendFragmented(chunk, splitAt) {
await writeSocket(socket, chunk.subarray(0, splitAt));
await writeSocket(socket, chunk.subarray(splitAt));
},
async close() {
if (socket.destroyed) return;
const closed = once(socket, "close");
socket.destroy();
await closed;
},
});
socket.on("data", (chunk) => {
client.receive(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
});
socket.on("error", (error) => client.fail(error));
socket.once("close", () => client.markClosed());
return client;
}
function writeSocket(socket: Socket, chunk: Uint8Array): Promise<void> {
return new Promise<void>((resolve, reject) => {
socket.write(chunk, (error) => {
if (error) reject(error);
else resolve();
});
});
}

View File

@@ -0,0 +1,215 @@
import type { JsonValue, ServiceCall } from "@earendil-works/chord";
import type { Context, Session, SessionMetadata } from "@earendil-works/pi-agent-core";
import { BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { SessionAmbiguousError, SessionNotFoundError } from "../errors.ts";
import type { RoutedServerServiceHost, RoutedSessionHandle, ServerHost } from "../types.ts";
export class Deferred<T> {
readonly promise: Promise<T>;
private resolvePromise!: (value: T) => void;
constructor() {
this.promise = new Promise<T>((resolve) => {
this.resolvePromise = resolve;
});
}
resolve(value: T): void {
this.resolvePromise(value);
}
}
interface OpenGate {
entered: Deferred<void>;
release: Deferred<void>;
}
export class TestHarness {
readonly session: Session;
readonly closed = new Deferred<void>();
readonly #termination = new Deferred<Error | undefined>();
readonly terminated = this.#termination.promise;
attachedClients = 0;
attachmentReleaseCount = 0;
closeCount = 0;
readonly serviceCalls: ServiceCall[] = [];
failAttachmentRelease?: Error;
failClose?: Error;
nextServiceError?: Error;
nextServiceResult: JsonValue | undefined = { ok: true };
private nextCloseGate?: OpenGate;
private nextServiceGate?: OpenGate;
constructor(session: Session) {
this.session = session;
}
attachClient(_context: Context): {
invokeService: TestHarness["invokeService"];
release(context: Context): void;
} {
this.attachedClients += 1;
let released = false;
return {
invokeService: (call) => this.invokeService(call),
release: (_context) => {
if (released) return;
this.attachmentReleaseCount += 1;
if (this.failAttachmentRelease) throw this.failAttachmentRelease;
released = true;
this.attachedClients -= 1;
},
};
}
async invokeService(call: ServiceCall): Promise<JsonValue | undefined> {
this.serviceCalls.push(call);
if (this.nextServiceError) {
const error = this.nextServiceError;
this.nextServiceError = undefined;
throw error;
}
const gate = this.nextServiceGate;
if (gate) {
this.nextServiceGate = undefined;
gate.entered.resolve(undefined);
await gate.release.promise;
}
const result = this.nextServiceResult;
this.nextServiceResult = { ok: true };
return result;
}
async close(context: Context): Promise<void> {
this.closeCount += 1;
const gate = this.nextCloseGate;
if (gate) {
this.nextCloseGate = undefined;
gate.entered.resolve(undefined);
await gate.release.promise;
}
if (this.failClose) {
const error = this.failClose;
this.failClose = undefined;
throw error;
}
await this.session.close(context);
this.closed.resolve(undefined);
this.#termination.resolve(undefined);
}
async terminate(error: Error): Promise<void> {
await this.session.close(BACKGROUND_CONTEXT);
this.#termination.resolve(error);
}
gateNextClose(): OpenGate {
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
this.nextCloseGate = gate;
return gate;
}
gateNextServiceCall(): OpenGate {
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
this.nextServiceGate = gate;
return gate;
}
}
export function createTestServerServices(): RoutedServerServiceHost {
return {
attachClient(presentation) {
return {
async invokeService(call, _publish, context) {
if (
call.instance === undefined &&
call.serviceId === "pi.session-management" &&
call.member === "attach" &&
call.args.length === 1 &&
typeof call.args[0] === "string"
) {
await presentation.attachSession(call.args[0], context);
return null;
}
if (
call.instance === undefined &&
call.serviceId === "pi.session-management" &&
call.member === "detach" &&
call.args.length === 0
) {
await presentation.detachSession(context);
return null;
}
throw new Error(`Unsupported test server service ${call.serviceId}.${call.member}`);
},
release() {},
};
},
};
}
export class TestServerHost implements ServerHost {
readonly serverServices = createTestServerServices();
readonly repo = new MemorySessionRepo({ now: () => 1 });
readonly harnesses = new Map<string, TestHarness[]>();
openSessionCount = 0;
nextOpenSessionError?: Error;
nextHarnessCloseError?: Error;
private nextOpenSessionGate?: OpenGate;
async resolveSession(sessionId: string, context: Context): Promise<SessionMetadata> {
const matches = (await this.repo.list(undefined, context)).filter(({ id }) => id === sessionId);
if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
if (matches.length > 1) throw new SessionAmbiguousError();
return matches[0]!;
}
async openSession(metadata: SessionMetadata, context: Context): Promise<RoutedSessionHandle> {
this.openSessionCount += 1;
const gate = this.nextOpenSessionGate;
if (gate) {
this.nextOpenSessionGate = undefined;
gate.entered.resolve(undefined);
await gate.release.promise;
}
const session = await this.repo.open(metadata, context);
try {
if (this.nextOpenSessionError) {
const error = this.nextOpenSessionError;
this.nextOpenSessionError = undefined;
throw error;
}
const harness = new TestHarness(session);
if (this.nextHarnessCloseError) {
harness.failClose = this.nextHarnessCloseError;
this.nextHarnessCloseError = undefined;
}
const harnesses = this.harnesses.get(metadata.id) ?? [];
harnesses.push(harness);
this.harnesses.set(metadata.id, harnesses);
return harness;
} catch (error) {
await session.close(context);
throw error;
}
}
async seed(id = "session-1", parentSessionId?: string): Promise<SessionMetadata> {
const session = await this.repo.create({ id, parentSessionId }, BACKGROUND_CONTEXT);
const metadata = session.metadata;
await session.close(BACKGROUND_CONTEXT);
return metadata;
}
gateNextOpenSession(): OpenGate {
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
this.nextOpenSessionGate = gate;
return gate;
}
latestHarness(id: string): TestHarness {
const harnesses = this.harnesses.get(id);
if (!harnesses?.length) throw new Error(`No harness for ${id}`);
return harnesses.at(-1)!;
}
}

View File

@@ -0,0 +1,5 @@
export type { WireChannel } from "./client.ts";
export { connectUnixTestClient, ProtocolTestClient } from "./client.ts";
export { createTestServerServices, Deferred, TestHarness, TestServerHost } from "./host.ts";
export type { TestServer, TestServerOptions } from "./server.ts";
export { createTestServer } from "./server.ts";

View File

@@ -0,0 +1,28 @@
import { Server } from "../server.ts";
import type { ServerHost, ServerOptions } from "../types.ts";
import { TestServerHost } from "./host.ts";
export interface TestServerOptions extends Omit<ServerOptions, "serverId"> {
host?: ServerHost;
serverId?: string;
}
export interface TestServer {
server: Server;
host: ServerHost;
}
/** Create an unstarted Server with deterministic defaults for transport conformance tests. */
export function createTestServer(options: TestServerOptions): TestServer {
const host = options.host ?? new TestServerHost();
return {
server: new Server(host, {
listeners: options.listeners,
maxFrameLength: options.maxFrameLength,
handshakeTimeoutMs: options.handshakeTimeoutMs,
serverId: options.serverId ?? "00000000-0000-4000-8000-000000000001",
onError: options.onError,
}),
host,
};
}

View File

@@ -0,0 +1,9 @@
import { join } from "node:path";
/** Derive the local Unix socket path for one logical server identity. */
export function getUnixSocketPath(serverId: string, serverDirectory: string): string {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(serverId)) {
throw new TypeError("Unix serverId must be a canonical lowercase UUIDv4");
}
return join(serverDirectory, `${serverId}.sock`);
}

View File

@@ -0,0 +1,4 @@
export { getUnixSocketPath } from "./address.ts";
export { createUnixListener } from "./listener.ts";
export { createUnixServer } from "./preset.ts";
export type { UnixListenerOptions, UnixServerOptions } from "./types.ts";

View File

@@ -0,0 +1,421 @@
import { createHash, randomUUID } from "node:crypto";
import type { Stats } from "node:fs";
import { chmod, link, lstat, mkdir, rename, unlink } from "node:fs/promises";
import { createConnection, createServer, type Server, type Socket } from "node:net";
import { dirname, join } from "node:path";
import { DEFAULT_MAX_FRAME_LENGTH } from "@earendil-works/pi-protocol";
import type { ByteConnection, ByteConnectionAcceptor } from "../../connection.ts";
import type { ServerListener } from "../../listener.ts";
import type { UnixListenerOptions } from "./types.ts";
const DEFAULT_SOCKET_MODE = 0o600;
const DEFAULT_GRACEFUL_CLOSE_TIMEOUT_MS = 5_000;
const MAX_UINT32 = 0xffff_ffff;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const SOCKET_PROBE_TIMEOUT_MS = 1_000;
interface ResolvedUnixListenerOptions {
path: string;
mode: number;
gracefulCloseTimeoutMs: number;
maxPendingBytes: number;
onError?: (error: Error) => void;
}
interface FileIdentity {
dev: number;
ino: number;
}
class UnixListener implements ServerListener {
private readonly options: ResolvedUnixListenerOptions;
private readonly path: string;
private readonly mode: number;
private readonly connections = new Set<UnixByteConnection>();
private server?: Server;
private socketIdentity?: FileIdentity;
private ownedBindPath?: string;
private closing = false;
private closePromise?: Promise<void>;
private accept?: ByteConnectionAcceptor;
constructor(options: UnixListenerOptions) {
this.options = resolveUnixListenerOptions(options);
this.path = this.options.path;
this.mode = this.options.mode;
}
async start(accept: ByteConnectionAcceptor): Promise<void> {
if (this.server) throw new Error("Unix listener is already started");
if (this.closing) throw new Error("Unix listener is closing or closed");
this.accept = accept;
const ownedBindPath = getOwnedBindPath(this.path);
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
await removeStaleSocket(this.path);
await removeStaleSocket(ownedBindPath);
this.ownedBindPath = ownedBindPath;
const server = createServer((socket) => this.acceptSocket(socket));
server.on("error", (error) => this.reportError(error));
this.server = server;
try {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
server.off("listening", onListening);
reject(error);
};
const onListening = (): void => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(ownedBindPath);
});
const stats = await lstat(ownedBindPath);
if (!stats.isSocket()) throw new Error(`Unix listener path is not a socket after binding: ${ownedBindPath}`);
this.socketIdentity = { dev: stats.dev, ino: stats.ino };
await link(ownedBindPath, this.path);
await setSocketMode(this.path, this.mode);
await removePath(ownedBindPath);
this.ownedBindPath = undefined;
} catch (error) {
await this.closeServerAndCleanup(server);
this.server = undefined;
throw error;
}
}
async close(): Promise<void> {
if (this.closePromise) return this.closePromise;
this.closing = true;
this.closePromise = this.closeInternal();
return this.closePromise;
}
private acceptSocket(socket: Socket): void {
if (this.closing) {
socket.destroy();
return;
}
const connection = new UnixByteConnection(
socket,
this.options.gracefulCloseTimeoutMs,
this.options.maxPendingBytes,
);
this.connections.add(connection);
const accept = this.accept;
if (!accept) {
socket.destroy();
return;
}
const handler = accept(connection);
socket.on("data", (chunk) => {
handler.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
});
socket.on("error", (error) => {
handler.onError(error);
socket.destroy();
});
socket.once("close", () => {
connection.markClosed();
this.connections.delete(connection);
handler.onClose();
});
}
private async closeInternal(): Promise<void> {
const serverClosed = this.server ? this.closeServerAndCleanup(this.server) : this.cleanupOwnedSocket();
await Promise.all([...this.connections].map((connection) => connection.close()));
await serverClosed;
if (this.ownedBindPath) await removePath(this.ownedBindPath);
this.ownedBindPath = undefined;
this.connections.clear();
this.server = undefined;
}
private async closeServerAndCleanup(server: Server): Promise<void> {
try {
await closeNetServer(server, (error) => this.reportError(error));
} finally {
// Remove an unpublished startup bind path before the public route.
if (this.ownedBindPath) await removePath(this.ownedBindPath);
this.ownedBindPath = undefined;
await this.cleanupOwnedSocket();
}
}
private async cleanupOwnedSocket(): Promise<void> {
const identity = this.socketIdentity;
this.socketIdentity = undefined;
if (!identity) return;
let current: Stats;
try {
current = await lstat(this.path);
} catch (error) {
if (isErrorCode(error, "ENOENT")) return;
throw error;
}
if (!current.isSocket() || current.dev !== identity.dev || current.ino !== identity.ino) return;
const preserved = join(dirname(this.path), `cleanup-${randomUUID().slice(0, 6)}`);
try {
await rename(this.path, preserved);
} catch (error) {
if (isErrorCode(error, "ENOENT")) return;
throw error;
}
const moved = await lstat(preserved);
if (moved.isSocket() && moved.dev === identity.dev && moved.ino === identity.ino) {
await removePath(preserved);
return;
}
try {
await lstat(this.path);
} catch (error) {
if (isErrorCode(error, "ENOENT")) await rename(preserved, this.path);
else throw error;
}
throw new Error(`Unix listener path changed during cleanup; preserved replacement at ${preserved}`);
}
private reportError(error: unknown): void {
try {
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
} catch {
// Error observers cannot affect listener state.
}
}
}
/** @internal Exported only for transport-level verification. */
export class UnixByteConnection implements ByteConnection {
private readonly socket: Socket;
private readonly gracefulCloseTimeoutMs: number;
private readonly maxPendingBytes: number;
private pendingBytes = 0;
private closedValue = false;
private closing = false;
private writeTail: Promise<void> = Promise.resolve();
private closePromise?: Promise<void>;
private resolveClose?: () => void;
constructor(socket: Socket, gracefulCloseTimeoutMs: number, maxPendingBytes: number) {
this.socket = socket;
this.gracefulCloseTimeoutMs = gracefulCloseTimeoutMs;
this.maxPendingBytes = maxPendingBytes;
}
get closed(): boolean {
return this.closedValue;
}
send(chunk: Uint8Array): Promise<void> {
if (!(chunk instanceof Uint8Array)) {
return Promise.reject(new TypeError("Unix connection chunks must be Uint8Array"));
}
if (this.closedValue || this.closing) return Promise.reject(new Error("Unix connection is closed"));
if (this.pendingBytes + chunk.byteLength > this.maxPendingBytes) {
return Promise.reject(new Error("Unix connection exceeded its pending byte limit"));
}
this.pendingBytes += chunk.byteLength;
const bytes = chunk.slice();
const write = this.writeTail.then(() => this.write(bytes));
const tracked = write.finally(() => {
this.pendingBytes -= bytes.byteLength;
});
this.writeTail = tracked.catch(() => {});
return tracked;
}
close(finalChunk?: Uint8Array): Promise<void> {
if (this.closedValue || this.socket.destroyed) {
this.markClosed();
return Promise.resolve();
}
if (this.closePromise) return this.closePromise;
this.closing = true;
const finalBytes = finalChunk?.slice();
this.closePromise = new Promise<void>((resolve) => {
this.resolveClose = resolve;
const timer = setTimeout(() => {
if (!this.socket.destroyed) this.socket.destroy();
this.markClosed();
}, this.gracefulCloseTimeoutMs);
timer.unref();
this.socket.once("close", () => clearTimeout(timer));
void this.writeTail.then(() => {
if (this.socket.destroyed) {
this.markClosed();
return;
}
try {
if (finalBytes) this.socket.end(finalBytes);
else this.socket.end();
} catch {
this.socket.destroy();
}
});
});
return this.closePromise;
}
markClosed(): void {
if (this.closedValue) return;
this.closedValue = true;
this.closing = true;
this.resolveClose?.();
this.resolveClose = undefined;
}
private write(chunk: Uint8Array): Promise<void> {
if (this.closedValue || this.closing || !this.socket.writable) {
return Promise.reject(new Error("Unix connection is closed"));
}
return new Promise<void>((resolve, reject) => {
let settled = false;
const onClose = (): void => finish(new Error("Unix connection closed during write"));
const finish = (error?: Error | null): void => {
if (settled) return;
settled = true;
this.socket.off("close", onClose);
if (error) reject(error);
else resolve();
};
this.socket.once("close", onClose);
try {
this.socket.write(chunk, finish);
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
});
}
}
function getOwnedBindPath(path: string): string {
const suffix = createHash("sha256").update(path).digest("hex").slice(0, 8);
return join(dirname(path), `bind-${suffix}`);
}
async function removeStaleSocket(path: string): Promise<void> {
let original: Stats;
try {
original = await lstat(path);
} catch (error) {
if (isErrorCode(error, "ENOENT")) return;
throw error;
}
if (!original.isSocket()) throw new Error(`Refusing to remove non-socket Unix listener path: ${path}`);
if (await isSocketLive(path)) throw new Error(`Unix listener is already running: ${path}`);
const preserved = join(dirname(path), `stale-${randomUUID().slice(0, 6)}`);
try {
await rename(path, preserved);
} catch (error) {
if (isErrorCode(error, "ENOENT")) return;
throw error;
}
const current = await lstat(preserved);
if (!current.isSocket() || current.dev !== original.dev || current.ino !== original.ino) {
try {
await lstat(path);
} catch (error) {
if (isErrorCode(error, "ENOENT")) await rename(preserved, path);
else throw error;
}
throw new Error(`Unix listener path changed while checking for a stale socket: ${path}`);
}
await removePath(preserved);
}
async function removePath(path: string): Promise<void> {
try {
await unlink(path);
} catch (error) {
if (!isErrorCode(error, "ENOENT")) throw error;
}
}
function isSocketLive(path: string): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
const socket = createConnection(path);
let settled = false;
let timer: NodeJS.Timeout | undefined;
const finish = (result: boolean, error?: Error): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
socket.removeAllListeners();
socket.destroy();
if (error) reject(error);
else resolve(result);
};
socket.once("connect", () => finish(true));
socket.once("error", (error: NodeJS.ErrnoException) => {
if (["ECONNREFUSED", "ENOENT", "EPIPE", "ECONNRESET"].includes(error.code ?? "")) {
finish(false);
return;
}
finish(false, error);
});
timer = setTimeout(() => finish(true), SOCKET_PROBE_TIMEOUT_MS);
timer.unref();
});
}
async function setSocketMode(path: string, mode: number): Promise<void> {
if (process.platform === "win32") return;
try {
await chmod(path, mode);
} catch (error) {
if (!isErrorCode(error, "ENOSYS") && !isErrorCode(error, "ENOTSUP")) throw error;
}
}
function closeNetServer(server: Server, reportError: (error: Error) => void): Promise<void> {
if (!server.listening) return Promise.resolve();
return new Promise<void>((resolve) => {
server.close((error) => {
if (error) reportError(error);
resolve();
});
});
}
function isErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
export function createUnixListener(options: UnixListenerOptions): ServerListener {
return new UnixListener(options);
}
function resolveUnixListenerOptions(options: UnixListenerOptions): ResolvedUnixListenerOptions {
if (!options.path) throw new TypeError("Server Unix socket path must not be empty");
const mode = options.mode ?? DEFAULT_SOCKET_MODE;
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw new TypeError("Server Unix socket mode must be an integer between 0 and 0o777");
}
const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {
throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
}
const maxPendingBytes = options.maxPendingBytes ?? maxFrameLength * 4;
if (!Number.isSafeInteger(maxPendingBytes) || maxPendingBytes < maxFrameLength + 4) {
throw new TypeError("Server maxPendingBytes must be a safe integer at least maxFrameLength + 4");
}
const gracefulCloseTimeoutMs = options.gracefulCloseTimeoutMs ?? DEFAULT_GRACEFUL_CLOSE_TIMEOUT_MS;
if (
!Number.isSafeInteger(gracefulCloseTimeoutMs) ||
gracefulCloseTimeoutMs <= 0 ||
gracefulCloseTimeoutMs > MAX_TIMER_DELAY_MS
) {
throw new TypeError(`Server gracefulCloseTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
}
return {
path: options.path,
mode,
maxPendingBytes,
gracefulCloseTimeoutMs,
onError: options.onError,
};
}

View File

@@ -0,0 +1,28 @@
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
import { Server } from "../../server.ts";
import type { ServerHost } from "../../types.ts";
import { createUnixListener } from "./listener.ts";
import type { UnixServerOptions } from "./types.ts";
/** Compose Server with one Unix-domain socket listener. */
export function createUnixServer<TMetadata extends SessionMetadata>(
host: ServerHost<TMetadata>,
options: UnixServerOptions,
): Server<TMetadata> {
const listener = createUnixListener({
path: options.path,
mode: options.mode,
maxFrameLength: options.maxFrameLength,
maxPendingBytes: options.maxPendingBytes,
gracefulCloseTimeoutMs: options.gracefulCloseTimeoutMs,
onError: options.onError,
});
return new Server(host, {
listeners: [listener],
maxFrameLength: options.maxFrameLength,
handshakeTimeoutMs: options.handshakeTimeoutMs,
onConnectionCountChanged: options.onConnectionCountChanged,
serverId: options.serverId,
onError: options.onError,
});
}

View File

@@ -0,0 +1,15 @@
import type { ServerOptions } from "../../types.ts";
export interface UnixListenerOptions {
path: string;
/** Socket filesystem permissions. Defaults to owner read/write only (0o600). */
mode?: number;
/** Maximum framed bytes queued per connection before a slow peer is disconnected. */
maxPendingBytes?: number;
gracefulCloseTimeoutMs?: number;
/** Used to derive and validate maxPendingBytes. Must match the server when customized. */
maxFrameLength?: number;
onError?: (error: Error) => void;
}
export interface UnixServerOptions extends Omit<ServerOptions, "listeners">, UnixListenerOptions {}

View File

@@ -0,0 +1,64 @@
import type { JsonValue, ServiceCall, ServiceProviderUpdate } from "@earendil-works/chord";
import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core";
import type { ServerListener } from "./listener.ts";
export interface ServerOptions {
listeners: readonly ServerListener[];
/** Stable logical server identity supplied by the installation or profile. */
serverId: string;
maxFrameLength?: number;
handshakeTimeoutMs?: number;
onConnectionCountChanged?: (count: number) => void;
onError?: (error: Error) => void;
}
export type MaybePromise<T> = T | Promise<T>;
/** One presentation connection's live capability for a hosted Session. */
export interface RoutedSessionAttachment {
/** Route one contract-agnostic service operation to the attached Session endpoint. */
invokeService(
call: ServiceCall,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise<void>,
context: Context,
): Promise<JsonValue | undefined>;
release(context: Context): MaybePromise<void>;
}
/** Presentation-scoped routing capabilities available to server service implementations. */
export interface RoutedServerPresentation {
attachSession(sessionId: string, context: Context): Promise<void>;
detachSession(context: Context): Promise<void>;
/** Release routed attachments and handles before the application deletes durable metadata. */
prepareSessionRemoval(sessionId: string, context: Context): Promise<void>;
}
/** One connection's server-scoped service endpoint. */
export interface RoutedServerServiceAttachment {
invokeService(
call: ServiceCall,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise<void>,
context: Context,
): Promise<JsonValue | undefined>;
release(context: Context): MaybePromise<void>;
}
export interface RoutedServerServiceHost {
attachClient(presentation: RoutedServerPresentation, context: Context): MaybePromise<RoutedServerServiceAttachment>;
}
/** A process-safe handle that acquires presentation-scoped Session capabilities. */
export interface RoutedSessionHandle {
attachClient(context: Context): MaybePromise<RoutedSessionAttachment>;
/** Resolves with an error for unexpected termination, or undefined after an expected close. */
readonly terminated?: Promise<Error | undefined>;
close(context: Context): Promise<void>;
}
/** Application capabilities used by server-wide management and Session routing. */
export interface ServerHost<TMetadata extends SessionMetadata = SessionMetadata> {
readonly serverServices: RoutedServerServiceHost;
/** Resolve one durable Session ID or throw a bounded routing error. */
resolveSession(sessionId: string, context: Context): Promise<TMetadata>;
openSession(metadata: TMetadata, context: Context): Promise<RoutedSessionHandle>;
}