Squashed 'third_party/pi/' content from commit d981de122

git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:40:08 -04:00
commit 67b361f0e9
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
# Changelog
## [0.85.1] - 2026-09-05
## [0.85.0] - 2026-09-04
## [0.84.4] - 2026-08-28
## [0.84.3] - 2026-08-24
## [0.84.2] - 2026-08-14
## [0.84.1] - 2026-08-07
## [0.84.0] - 2026-08-06
### Breaking Changes
- Changed `toProtocolToolResultMessage()` to require the original `ToolCall` and verify tool result association.
- Changed `PiServerService.listSessions()` to return durable `SessionMetadata` instead of runtime `SessionSummary` values ([#7708](https://github.com/earendil-works/pi/pull/7708)).
### Fixed
- Hardened protocol adapters against contradictory lifecycle states, invalid identifiers and timestamps, sparse execution arrays, and additive `pi-ai` contract drift.
- Sanitized service and runtime failures into stable `not_implemented` and `internal_error` responses without exposing private error details ([#7644](https://github.com/earendil-works/pi/pull/7644)).
## [0.83.0] - 2026-07-29
## [0.82.1] - 2026-07-25
## [0.82.0] - 2026-07-24
## [0.81.1] - 2026-07-21
## [0.81.0] - 2026-07-21
### Changed
- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/earendil-works/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)).
## [0.80.10] - 2026-07-16
## [0.80.9] - 2026-07-16
## [0.80.8] - 2026-07-16
## [0.80.7] - 2026-07-14
## [0.80.6] - 2026-07-09
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
## [0.80.3] - 2026-06-30

79
packages/server/README.md Normal file
View File

@@ -0,0 +1,79 @@
# @earendil-works/pi-server
Experimental local server for the new durable Session and Agent Harness interfaces.
The current slice supports server- and Session-scoped facet-service routing and multi-presentation attachment. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the facet contract.
- server service calls and subscriptions route opaquely through the connection's `RoutedServerServiceAttachment`;
- the application-owned `SessionDirectory` projects the private catalog into replicated presentation-safe state;
- the application-owned `SessionManagement` creates, removes, attaches, and detaches Sessions without exposing route IDs in business results;
- attachment changes are published out of band after the router installs or clears the live route;
- Session service calls route through `invokeService` without server-side business-payload decoding;
- service subscription updates remain scoped to the requesting attachment;
- application observations such as transcripts route as ordinary service state without server-owned business schemas.
A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId` delivered only as routing control data. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted service calls settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership.
```ts
import { randomUUID } from "node:crypto";
import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core";
import {
type RoutedServerServiceHost,
type RoutedSessionHandle,
type ServerHost,
SessionAmbiguousError,
SessionNotFoundError,
} from "@earendil-works/pi-server";
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
async function startServer(
serverServices: RoutedServerServiceHost,
openRoutedSession: (session: Session) => Promise<RoutedSessionHandle>,
) {
const sessions = new MemorySessionRepo();
const host: ServerHost = {
serverServices,
async resolveSession(sessionId, context) {
const matches = (await sessions.list(undefined, context))
.filter((metadata) => metadata.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, context) {
const session = await sessions.open(metadata, context);
try {
return await openRoutedSession(session);
} catch (error) {
try {
await session.close(context);
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"Harness creation and Session cleanup failed",
);
}
throw error;
}
},
};
const serverId = randomUUID();
const server = createUnixServer(host, {
serverId,
path: getUnixSocketPath(serverId, "/run/user/1000/pi"),
});
await server.start();
return server;
}
```
Applications supply a required server service host, a bounded Session resolver, and a routed Session factory. Session discovery and management are application-owned services; the protocol server only asks the resolver for metadata when routing an attachment. The host owns acquiring the worker-local Session and Harness. Failures are cleaned up in that worker. Neither an open JavaScript Session nor a Harness crosses the process boundary.
`serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process.
`Server` composes transports through `ServerListener`; peer authentication remains application policy and is not implemented by the experimental Unix transport. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level routed-envelope validation, CBOR, and framing come from `@earendil-works/pi-protocol`; Chord owns service-control parsing, error codes, snapshots and updates, and each subscription's replicated-state encoder.
Server and worker lifecycle is managed outside the public Pi protocol. The replaceable application server converts connection attachments into private demand updates; the worker combines generation-tagged demand with authoritative Harness activity. The experimental coordinator only supplies stable routing and reports generic server-generation connection changes.

View File

@@ -0,0 +1,58 @@
{
"name": "@earendil-works/pi-server",
"version": "0.85.1",
"description": "experimental server package for pi",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing/index.d.ts",
"import": "./dist/testing/index.js"
},
"./unix": {
"types": "./dist/transports/unix/index.d.ts",
"import": "./dist/transports/unix/index.js"
}
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"clean": "shx rm -rf dist",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"build": "tsgo -p tsconfig.build.json",
"test": "vitest --run",
"typecheck": "tsgo -p tsconfig.test.json",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"pi",
"server"
],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/server"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^0.85.1",
"@earendil-works/pi-agent-core": "^0.85.1",
"@earendil-works/pi-protocol": "^0.85.1"
},
"devDependencies": {
"shx": "0.4.0",
"vitest": "4.1.9"
}
}

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>;
}

View File

@@ -0,0 +1,502 @@
import type { ServiceCall } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, type SessionMetadata } from "@earendil-works/pi-agent-core";
import { afterEach, describe, expect, test } from "vitest";
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
import { SessionAmbiguousError } from "../src/errors.ts";
import { Server } from "../src/server.ts";
import {
createTestServerServices,
Deferred,
ProtocolTestClient,
TestServerHost,
type WireChannel,
} from "../src/testing/index.ts";
import type { ServerHost } from "../src/types.ts";
const serverId = "00000000-0000-4000-8000-000000000001";
const servers = new Set<Server>();
function createServer(host: ServerHost, id = serverId): Server {
const server = new Server(host, { listeners: [], serverId: id });
servers.add(server);
return server;
}
function connect(server: Server): ProtocolTestClient {
let handler: ByteConnectionHandler;
let client: ProtocolTestClient;
let closed = false;
const connection: ByteConnection = {
get closed() {
return closed;
},
async send(chunk) {
client.receive(chunk);
},
close(finalChunk) {
if (finalChunk) client.receive(finalChunk);
closed = true;
client.markClosed();
},
};
const channel: WireChannel = {
async send(chunk) {
handler.onData(chunk);
},
async sendFragmented(chunk, splitAt) {
handler.onData(chunk.subarray(0, splitAt));
handler.onData(chunk.subarray(splitAt));
},
async close() {
if (closed) return;
closed = true;
handler.onClose();
client.markClosed();
},
};
client = new ProtocolTestClient(channel);
handler = server.accept(connection);
return client;
}
function sessionCall(member: string, args: ServiceCall["args"] = []): ServiceCall {
return { serviceId: "test.session", member, args };
}
function latestAttachmentId(client: ProtocolTestClient, sessionId: string): string {
for (const message of [...client.messages].reverse()) {
if (message.type === "attachment" && message.attachment?.sessionId === sessionId) {
return message.attachment.attachmentId;
}
}
throw new Error(`Missing attachment for ${sessionId}`);
}
afterEach(async () => {
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
});
describe("Session protocol", () => {
test("handshake identifies the logical server without listing sessions", async () => {
const host = new TestServerHost();
await host.seed();
const client = connect(createServer(host));
expect(await client.hello()).toMatchObject({ type: "hello", serverId });
expect(host.harnesses.size).toBe(0);
});
test("rejects a semantically invalid service call after envelope decoding", async () => {
const host = new TestServerHost();
const client = connect(createServer(host));
await client.hello();
const response = client.next((message) => message.type === "response" && message.id === "invalid-call");
await client.sendMessage({
type: "request",
id: "invalid-call",
target: { serverId },
call: { arbitrary: true },
});
expect(await response).toMatchObject({ ok: false, error: { code: "invalid_request" } });
});
test("attach passes concrete repository metadata to the Harness host", async () => {
type BackendMetadata = SessionMetadata & { path: string; modifiedAt: number };
const metadata: BackendMetadata = {
id: "session-1",
createdAt: 1,
storageVersion: 1,
cwd: "/workspace",
path: "/sessions/session-1.jsonl",
modifiedAt: 2,
};
let received: BackendMetadata | undefined;
const host: ServerHost<BackendMetadata> = {
serverServices: createTestServerServices(),
resolveSession: async () => metadata,
openSession: async (candidate) => {
received = candidate;
return {
attachClient: () => ({ invokeService: async () => undefined, release() {} }),
close: async () => {},
};
},
};
const client = connect(createServer(host));
await client.hello();
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
expect(received).toBe(metadata);
});
test("routes opaque server services and publishes attachment changes out of band", async () => {
const backing = new TestServerHost();
await backing.seed("session-1");
let releaseCount = 0;
const host: ServerHost = {
resolveSession: (sessionId, context) => backing.resolveSession(sessionId, context),
openSession: (metadata, context) => backing.openSession(metadata, context),
serverServices: {
attachClient(presentation) {
return {
async invokeService(call, _publish, context) {
if (call.serviceId !== "pi.session-management") throw new Error("Unexpected service");
if (call.member === "attach" && typeof call.args[0] === "string") {
await presentation.attachSession(call.args[0], context);
return null;
}
if (call.member === "detach") {
await presentation.detachSession(context);
return null;
}
throw new Error("Unexpected service member");
},
release() {
releaseCount += 1;
},
};
},
},
};
const client = connect(createServer(host));
await client.hello();
const attached = client.next((message) => message.type === "attachment" && message.attachment !== null);
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
await expect(attached).resolves.toMatchObject({
type: "attachment",
attachment: { sessionId: "session-1", attachmentId: expect.any(String) },
});
expect(backing.latestHarness("session-1").attachedClients).toBe(1);
const detached = client.next((message) => message.type === "attachment" && message.attachment === null);
await expect(
client.requestService({ serverId }, { serviceId: "pi.session-management", member: "detach", args: [] }),
).resolves.toMatchObject({ ok: true });
await expect(detached).resolves.toMatchObject({ type: "attachment", attachment: null });
expect(backing.latestHarness("session-1").attachedClients).toBe(0);
await client.close();
await expect.poll(() => releaseCount).toBe(1);
});
test("permits multiple client attachments per Session", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
const first = connect(server);
const second = connect(server);
await Promise.all([first.hello(), second.hello()]);
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
expect(host.latestHarness("session-1").attachedClients).toBe(1);
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
expect(host.harnesses.get("session-1")).toHaveLength(1);
expect(host.latestHarness("session-1").attachedClients).toBe(2);
await first.close();
await expect.poll(() => host.latestHarness("session-1").attachedClients).toBe(1);
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
});
test("clears connection ownership when attachment release fails", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const errors: Error[] = [];
const server = new Server(host, { listeners: [], serverId, onError: (error) => errors.push(error) });
servers.add(server);
const first = connect(server);
const second = connect(server);
await Promise.all([first.hello(), second.hello()]);
await first.attach(serverId, "session-1");
const harness = host.latestHarness("session-1");
const releaseError = new Error("release failed");
harness.failAttachmentRelease = releaseError;
await first.close();
await expect.poll(() => harness.attachmentReleaseCount).toBe(1);
await expect.poll(() => errors).toContain(releaseError);
harness.failAttachmentRelease = undefined;
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
});
test("requires the requesting client to hold the targeted Session attachment", async () => {
const host = new TestServerHost();
await Promise.all([host.seed("session-1"), host.seed("session-2")]);
const server = createServer(host);
const attached = connect(server);
const unattached = connect(server);
await Promise.all([attached.hello(), unattached.hello()]);
await expect(unattached.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
ok: false,
error: { code: "session_not_attached" },
});
await attached.attach(serverId, "session-1");
await expect(attached.requestSessionService(serverId, "session-2", sessionCall("run"))).resolves.toMatchObject({
ok: false,
error: { code: "session_not_attached" },
});
await expect(
attached.requestSessionService(serverId, "session-1", sessionCall("run", ["Hello"])),
).resolves.toMatchObject({ ok: true, result: { ok: true } });
expect(host.latestHarness("session-1").serviceCalls).toEqual([sessionCall("run", ["Hello"])]);
});
test("rejects a stale attachment route after switching Sessions", async () => {
const host = new TestServerHost();
await Promise.all([host.seed("session-1"), host.seed("session-2")]);
const client = connect(createServer(host));
await client.hello();
await client.attach(serverId, "session-1");
const firstAttachmentId = latestAttachmentId(client, "session-1");
await client.attach(serverId, "session-2");
await expect(
client.requestService(
{ serverId, sessionId: "session-1", attachmentId: firstAttachmentId },
sessionCall("run", ["stale"]),
),
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
expect(host.latestHarness("session-1").serviceCalls).toEqual([]);
});
test("preserves opaque service results and bounds adapter defects", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const client = connect(createServer(host));
await client.hello();
await client.attach(serverId, "session-1");
const harness = host.latestHarness("session-1");
harness.nextServiceResult = { accepted: false, reason: "closed" };
await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
ok: true,
result: { accepted: false, reason: "closed" },
});
harness.nextServiceError = new Error("private adapter detail");
await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
ok: false,
error: { code: "internal_error", message: "Internal server error" },
});
});
test("admits concurrent service calls to the attached Session", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const client = connect(createServer(host));
await client.hello();
await client.attach(serverId, "session-1");
const harness = host.latestHarness("session-1");
const gate = harness.gateNextServiceCall();
const first = client.requestSessionService(serverId, "session-1", sessionCall("run", ["first"]));
await gate.entered.promise;
const second = client.requestSessionService(serverId, "session-1", sessionCall("run", ["second"]));
await expect(second).resolves.toMatchObject({ ok: true });
expect(harness.serviceCalls).toEqual([sessionCall("run", ["first"]), sessionCall("run", ["second"])]);
gate.release.resolve(undefined);
await expect(first).resolves.toMatchObject({ ok: true });
});
test("keeps attachment demand until an accepted service call settles after disconnect", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const client = connect(createServer(host));
await client.hello();
await client.attach(serverId, "session-1");
const harness = host.latestHarness("session-1");
const gate = harness.gateNextServiceCall();
const calling = client.requestSessionService(serverId, "session-1", sessionCall("run"));
const disconnectedCall = expect(calling).rejects.toThrow(/closed/i);
await gate.entered.promise;
await client.close();
expect(harness.attachedClients).toBe(1);
gate.release.resolve(undefined);
await disconnectedCall;
await expect.poll(() => harness.attachedClients).toBe(0);
});
test("rejects requests addressed to another server before repository access", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const client = connect(createServer(host));
await client.hello();
await expect(client.attach("00000000-0000-4000-8000-000000000002", "session-1")).resolves.toMatchObject({
ok: false,
error: { code: "wrong_server" },
});
expect(host.harnesses.size).toBe(0);
});
test("reports an unknown session without creating a Harness", async () => {
const host = new TestServerHost();
const client = connect(createServer(host));
await client.hello();
await expect(client.attach(serverId, "missing")).resolves.toMatchObject({
ok: false,
error: { code: "session_not_found" },
});
expect(host.harnesses.size).toBe(0);
});
test("rejects an ambiguous session ID without creating a Harness", async () => {
type BackendMetadata = SessionMetadata & { path: string };
const host: ServerHost<BackendMetadata> = {
serverServices: createTestServerServices(),
resolveSession: async () => {
throw new SessionAmbiguousError();
},
openSession: async () => {
throw new Error("must not create a Harness for an ambiguous session");
},
};
const client = connect(createServer(host));
await client.hello();
await expect(client.attach(serverId, "duplicate")).resolves.toMatchObject({
ok: false,
error: { code: "session_ambiguous" },
});
});
test("invalidates a terminated Harness handle and allows a later attach", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const client = connect(createServer(host));
await client.hello();
await client.attach(serverId, "session-1");
const firstHarness = host.latestHarness("session-1");
await firstHarness.terminate(new Error("worker crashed"));
await firstHarness.terminated;
await expect.poll(() => firstHarness.attachedClients).toBe(0);
expect(firstHarness.attachmentReleaseCount).toBe(1);
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
expect(host.harnesses.get("session-1")).toHaveLength(2);
});
test("connection loss releases its attachment, while server shutdown closes the Harness", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
const client = connect(server);
await client.hello();
await client.attach(serverId, "session-1");
const harness = host.latestHarness("session-1");
await client.close();
await expect.poll(() => harness.attachedClients).toBe(0);
expect(harness.closeCount).toBe(0);
await server.close();
expect(harness.closeCount).toBe(1);
});
});
describe("routed Session acquisition failures", () => {
test("releases a lease acquired concurrently with Harness termination", async () => {
const metadata: SessionMetadata = { id: "session-1", createdAt: 1, storageVersion: 1 };
const acquiring = new Deferred<void>();
const continueAcquiring = new Deferred<void>();
const terminated = new Deferred<Error | undefined>();
let releaseCount = 0;
const host: ServerHost = {
serverServices: createTestServerServices(),
resolveSession: async () => metadata,
openSession: async () => ({
terminated: terminated.promise,
attachClient: async () => {
acquiring.resolve(undefined);
await continueAcquiring.promise;
return {
invokeService: async () => undefined,
release: () => {
releaseCount += 1;
},
};
},
close: async () => {},
}),
};
const client = connect(createServer(host));
await client.hello();
const attach = client.attach(serverId, "session-1");
await acquiring.promise;
terminated.resolve(new Error("worker crashed"));
continueAcquiring.resolve(undefined);
await expect(attach).resolves.toMatchObject({ ok: false, error: { code: "server_draining" } });
expect(releaseCount).toBe(1);
});
test("shares a Harness creation failure, releases the Session, and allows a later retry", async () => {
const host = new TestServerHost();
await host.seed("session-1");
host.nextOpenSessionError = new Error("Harness creation failed");
const server = createServer(host);
const first = connect(server);
const second = connect(server);
await Promise.all([first.hello(), second.hello()]);
const gate = host.gateNextOpenSession();
const firstAttach = first.attach(serverId, "session-1");
await gate.entered.promise;
const secondAttach = second.attach(serverId, "session-1");
gate.release.resolve(undefined);
await expect(Promise.all([firstAttach, secondAttach])).resolves.toMatchObject([
{ ok: false, error: { code: "internal_error" } },
{ ok: false, error: { code: "internal_error" } },
]);
expect(host.openSessionCount).toBe(1);
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
expect(host.openSessionCount).toBe(2);
expect(host.harnesses.get("session-1")).toHaveLength(1);
});
test("closes a Harness acquired while server shutdown is in progress", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
const client = connect(server);
await client.hello();
const gate = host.gateNextOpenSession();
const attach = client.attach(serverId, "session-1");
await gate.entered.promise;
const closing = server.close();
gate.release.resolve(undefined);
await closing;
await expect(attach).rejects.toThrow(/closed/i);
expect(host.latestHarness("session-1").closeCount).toBe(1);
});
test("fails shutdown when an in-flight acquisition cannot release its Harness", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const cleanupError = new Error("close failed");
host.nextHarnessCloseError = cleanupError;
const server = createServer(host);
const client = connect(server);
await client.hello();
const gate = host.gateNextOpenSession();
const attach = client.attach(serverId, "session-1");
await gate.entered.promise;
const closing = server.close();
gate.release.resolve(undefined);
await expect(closing).rejects.toThrow(/Failed to close routed Sessions/);
await expect(server.closed).rejects.toThrow(/Failed to close routed Sessions/);
await expect(attach).rejects.toThrow(/closed/i);
expect(host.latestHarness("session-1").closeCount).toBe(1);
servers.delete(server);
await host.latestHarness("session-1").close(BACKGROUND_CONTEXT);
});
});

View File

@@ -0,0 +1,9 @@
import { createServer } from "node:net";
const path = process.argv[2];
if (!path) throw new Error("Socket path argument is required");
const server = createServer();
server.listen(path, () => {
if (process.send) process.send("listening");
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, test } from "vitest";
import type { ByteConnectionAcceptor } from "../src/connection.ts";
import type { ServerListener } from "../src/listener.ts";
import { createTestServer } from "../src/testing/index.ts";
class TestListener implements ServerListener {
accept?: ByteConnectionAcceptor;
startCount = 0;
closeCount = 0;
readonly startError: Error | undefined;
constructor(startError?: Error) {
this.startError = startError;
}
async start(accept: ByteConnectionAcceptor): Promise<void> {
this.startCount += 1;
this.accept = accept;
if (this.startError) throw this.startError;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
describe("Server listener composition", () => {
test("starts and closes every configured listener", async () => {
const first = new TestListener();
const second = new TestListener();
const { server } = createTestServer({ listeners: [first, second] });
await server.start();
expect(first.accept).toBeTypeOf("function");
expect(second.accept).toBeTypeOf("function");
await server.close();
expect(first.closeCount).toBe(1);
expect(second.closeCount).toBe(1);
});
test("closes previously started listeners when startup fails", async () => {
const first = new TestListener();
const failure = new Error("listener failed");
const second = new TestListener(failure);
const { server } = createTestServer({ listeners: [first, second] });
await expect(server.start()).rejects.toBe(failure);
expect(first.closeCount).toBe(1);
expect(second.closeCount).toBe(0);
});
});

View File

@@ -0,0 +1,168 @@
import { encodeCbor, encodeClientMessage, encodeFrame, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
import { afterEach, expect, test } from "vitest";
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
import { Server } from "../src/server.ts";
import { ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts";
let server: Server | undefined;
function connect(): ProtocolTestClient {
server = new Server(new TestServerHost(), { listeners: [], serverId: "00000000-0000-4000-8000-000000000001" });
let handler: ByteConnectionHandler;
let client: ProtocolTestClient;
let closed = false;
const connection: ByteConnection = {
get closed() {
return closed;
},
async send(chunk) {
client.receive(chunk);
},
close(finalChunk) {
if (finalChunk) client.receive(finalChunk);
closed = true;
client.markClosed();
},
};
const channel: WireChannel = {
async send(chunk) {
handler.onData(chunk);
},
async sendFragmented(chunk, splitAt) {
handler.onData(chunk.subarray(0, splitAt));
handler.onData(chunk.subarray(splitAt));
},
async close() {
closed = true;
handler.onClose();
client.markClosed();
},
};
client = new ProtocolTestClient(channel);
handler = server.accept(connection);
return client;
}
afterEach(async () => {
await server?.close();
server = undefined;
});
test("requires hello as the first message", async () => {
const client = connect();
await client.sendMessage({
type: "request",
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "pi.session-directory", member: "list", args: [] },
});
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
type: "hello_error",
error: { code: "invalid_request" },
});
await client.waitForClose();
});
test("rejects unsupported protocol versions", async () => {
const client = connect();
await expect(client.hello(PROTOCOL_VERSION + 1)).resolves.toMatchObject({
type: "hello_error",
error: { code: "version" },
});
await client.waitForClose();
});
test("accepts fragmented hello and request frames", async () => {
const client = connect();
const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION });
const helloResponse = client.next((message) => message.type === "hello");
await client.sendFragmentedMessage({ type: "hello", version: PROTOCOL_VERSION }, Math.floor(hello.byteLength / 2));
await expect(helloResponse).resolves.toMatchObject({
type: "hello",
serverId: "00000000-0000-4000-8000-000000000001",
});
const response = client.next((message) => message.type === "response");
const request = {
type: "request" as const,
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "pi.session-directory" as const, member: "list" as const, args: [] as [] },
};
const frame = encodeClientMessage(request);
await client.sendFragmentedMessage(request, Math.floor(frame.byteLength / 2));
await expect(response).resolves.toMatchObject({ ok: false, error: { code: "internal_error" } });
});
test.each([
["malformed CBOR", encodeFrame(Uint8Array.of(0xff))],
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 1, extra: true }))],
["oversized frame", new Uint8Array([1, 0, 0, 1])],
] as const)("rejects hostile framed input: %s", async (_label, bytes) => {
const client = connect();
await client.sendBytes(bytes);
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
type: "hello_error",
error: { code: "invalid_request" },
});
await client.waitForClose();
});
test("rejects a second hello after completing the handshake", async () => {
const client = connect();
await client.hello();
await client.sendMessage({ type: "hello", version: PROTOCOL_VERSION });
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
type: "hello_error",
error: { code: "invalid_request", message: expect.stringMatching(/first message/) },
});
await client.waitForClose();
});
test("processes a hello and request coalesced in one byte chunk", async () => {
const client = connect();
const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION });
const request = encodeClientMessage({
type: "request",
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "pi.session-directory", member: "list", args: [] },
});
const wire = new Uint8Array(hello.byteLength + request.byteLength);
wire.set(hello);
wire.set(request, hello.byteLength);
await client.sendBytes(wire);
await expect(client.next((message) => message.type === "hello")).resolves.toMatchObject({ type: "hello" });
await expect(client.next((message) => message.type === "response")).resolves.toMatchObject({
type: "response",
id: "request-1",
ok: false,
error: { code: "internal_error" },
});
});
test("reports a truncated final frame when the peer closes", async () => {
const errors: Error[] = [];
server = new Server(new TestServerHost(), {
listeners: [],
serverId: "00000000-0000-4000-8000-000000000001",
onError: (error) => errors.push(error),
});
let closed = false;
const connection: ByteConnection = {
get closed() {
return closed;
},
async send() {},
close() {
closed = true;
},
};
const handler: ByteConnectionHandler = server.accept(connection);
handler.onData(new Uint8Array([0, 0, 0, 2, 1]));
handler.onClose();
expect(closed).toBe(false);
expect(errors).toEqual([expect.objectContaining({ message: expect.stringMatching(/truncated/i) })]);
});

View File

@@ -0,0 +1,127 @@
import { lstat, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ServerMessageDecoder } from "@earendil-works/pi-protocol";
import { afterEach, expect, test } from "vitest";
import type { ByteConnection } from "../src/connection.ts";
import { Server } from "../src/index.ts";
import type { ServerListener } from "../src/listener.ts";
import { TestServerHost } from "../src/testing/index.ts";
import { createUnixServer } from "../src/transports/unix/index.ts";
const host = new TestServerHost();
let server: Server | undefined;
let tempDirectory: string | undefined;
async function makeSocketPath(): Promise<string> {
tempDirectory = await mkdtemp(join(tmpdir(), "pss-"));
return join(tempDirectory, "server.sock");
}
afterEach(async () => {
await server?.close();
if (tempDirectory) await rm(tempDirectory, { recursive: true, force: true });
server = undefined;
tempDirectory = undefined;
});
test("requires explicit listeners and a canonical UUIDv4 server identity", () => {
expect(() => Reflect.construct(Server, [host, {}])).toThrow(/listeners/);
expect(() => new Server(host, { listeners: [], serverId: "" })).toThrow(/serverId/);
expect(() => new Server(host, { listeners: [], serverId: "invalid-server" })).toThrow(/serverId/);
});
test("rejects concurrent start calls without leaking the Unix listener", async () => {
const path = await makeSocketPath();
server = createUnixServer(host, { path, serverId: "00000000-0000-4000-8000-000000000001" });
const starting = server.start();
await expect(server.start()).rejects.toThrow(/starting/);
await starting;
await server.close();
await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" });
});
test("handshake timeout closes with a final hello_error frame", async () => {
let resolveClosed: (() => void) | undefined;
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
class TimedOutConnection implements ByteConnection {
closed = false;
finalChunk?: Uint8Array;
send(): Promise<void> {
return Promise.reject(new Error("handshake timeout must use the terminal close frame"));
}
close(finalChunk?: Uint8Array): void {
this.finalChunk = finalChunk;
this.closed = true;
resolveClosed?.();
}
}
const core = new Server(host, {
listeners: [],
serverId: "00000000-0000-4000-8000-000000000001",
maxFrameLength: 1024,
handshakeTimeoutMs: 10,
});
const connection = new TimedOutConnection();
core.accept(connection);
await closed;
expect(connection.closed).toBe(true);
expect(connection.finalChunk).toBeInstanceOf(Uint8Array);
const messages = new ServerMessageDecoder().push(connection.finalChunk!);
expect(messages).toMatchObject([{ type: "hello_error", error: { code: "invalid_request" } }]);
await core.close();
});
test("rejects timeout values above Node's maximum timer delay", () => {
const path = "/tmp/pi-server-timeout-test.sock";
expect(() =>
createUnixServer(host, {
path,
serverId: "00000000-0000-4000-8000-000000000001",
handshakeTimeoutMs: 2_147_483_648,
}),
).toThrow(/handshakeTimeoutMs/);
expect(() =>
createUnixServer(host, {
path,
serverId: "00000000-0000-4000-8000-000000000001",
gracefulCloseTimeoutMs: 2_147_483_648,
}),
).toThrow(/gracefulCloseTimeoutMs/);
});
test("rejects pending-byte limits smaller than one maximum frame", async () => {
const path = await makeSocketPath();
expect(() =>
createUnixServer(host, {
path,
serverId: "00000000-0000-4000-8000-000000000001",
maxFrameLength: 128,
maxPendingBytes: 131,
}),
).toThrow(/maxPendingBytes/);
});
test("rejects close and closed when listener shutdown fails", async () => {
const failure = new Error("listener close failed");
const listener: ServerListener = {
start: async () => {},
close: async () => {
throw failure;
},
};
const core = new Server(host, {
listeners: [listener],
serverId: "00000000-0000-4000-8000-000000000001",
});
await core.start();
await expect(core.close()).rejects.toBe(failure);
await expect(core.closed).rejects.toBe(failure);
});

View File

@@ -0,0 +1,65 @@
import { EventEmitter } from "node:events";
import type { Socket } from "node:net";
import { encodeServerMessage, type ServerHelloError, ServerMessageDecoder } from "@earendil-works/pi-protocol";
import { expect, test, vi } from "vitest";
import { UnixByteConnection } from "../src/transports/unix/listener.ts";
class ControlledSocket extends EventEmitter {
destroyed = false;
writable = true;
writableLength = 0;
ended = false;
finalChunk?: Uint8Array;
private writeCallback?: (error?: Error | null) => void;
write(_chunk: Uint8Array, callback: (error?: Error | null) => void): boolean {
this.writableLength = 1;
this.writeCallback = callback;
return false;
}
end(chunk?: Uint8Array): this {
this.ended = true;
this.finalChunk = chunk?.slice();
return this;
}
destroy(): this {
if (this.destroyed) return this;
this.destroyed = true;
this.writable = false;
this.emit("close");
return this;
}
completeWrite(): void {
const callback = this.writeCallback;
if (!callback) throw new Error("No pending write");
this.writeCallback = undefined;
this.writableLength = 0;
callback(null);
}
}
test("queues a final protocol error behind pending output before closing", async () => {
const socket = new ControlledSocket();
const connection = new UnixByteConnection(socket as unknown as Socket, 1_000, 64 * 1024);
const pendingWrite = connection.send(new Uint8Array([1, 2, 3]));
await vi.waitFor(() => expect(socket.writableLength).toBe(1));
const finalMessage: ServerHelloError = {
type: "hello_error",
error: { code: "invalid_request", message: "Protocol violation" },
};
const closing = connection.close(encodeServerMessage(finalMessage));
expect(socket.ended).toBe(false);
expect(socket.destroyed).toBe(false);
socket.completeWrite();
await pendingWrite;
await vi.waitFor(() => expect(socket.ended).toBe(true));
expect(new ServerMessageDecoder().push(socket.finalChunk!)).toEqual([finalMessage]);
connection.markClosed();
await closing;
});

View File

@@ -0,0 +1,143 @@
import { type ChildProcess, fork } from "node:child_process";
import { once } from "node:events";
import { lstat, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import type { Server } from "../src/index.ts";
import { connectUnixTestClient, type ProtocolTestClient, TestServerHost } from "../src/testing/index.ts";
import { createUnixServer, getUnixSocketPath } from "../src/transports/unix/index.ts";
const servers = new Set<Server>();
const clients = new Set<ProtocolTestClient>();
const children = new Set<ChildProcess>();
const tempDirectories = new Set<string>();
async function makeSocketPath(nested = false): Promise<string> {
const directory = await mkdtemp(join("/tmp", "ps-"));
tempDirectories.add(directory);
return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock");
}
function makeServer(path: string): Server {
const server = createUnixServer(new TestServerHost(), { path, serverId: "00000000-0000-4000-8000-000000000001" });
servers.add(server);
return server;
}
afterEach(async () => {
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}
await Promise.all([...children].map((child) => (child.exitCode === null ? once(child, "exit") : undefined)));
children.clear();
await Promise.all([...clients].map((client) => client.close()));
clients.clear();
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
tempDirectories.clear();
});
test("creates an in-memory server ID and derives its explicit Unix socket path", async () => {
const directory = await mkdtemp(join("/tmp", "pi-server-"));
tempDirectories.add(directory);
const serverId = "00000000-0000-4000-8000-000000000001";
const path = getUnixSocketPath(serverId, directory);
expect(path).toBe(join(directory, `${serverId}.sock`));
const first = createUnixServer(new TestServerHost(), { serverId, path });
servers.add(first);
await first.start();
const firstClient = await connectUnixTestClient(path);
clients.add(firstClient);
expect(await firstClient.hello()).toMatchObject({ serverId });
await firstClient.close();
clients.delete(firstClient);
await first.close();
servers.delete(first);
const replacement = createUnixServer(new TestServerHost(), { serverId, path });
servers.add(replacement);
await replacement.start();
const replacementClient = await connectUnixTestClient(path);
clients.add(replacementClient);
expect(await replacementClient.hello()).toMatchObject({ serverId });
});
describe("Unix listener filesystem lifecycle", () => {
test("rejects a live listener without unlinking it", async () => {
const path = await makeSocketPath();
const first = makeServer(path);
await first.start();
const firstIdentity = await lstat(path);
const second = makeServer(path);
await expect(second.start()).rejects.toThrow(/already running/);
const currentIdentity = await lstat(path);
expect(currentIdentity.isSocket()).toBe(true);
expect({ dev: currentIdentity.dev, ino: currentIdentity.ino }).toEqual({
dev: firstIdentity.dev,
ino: firstIdentity.ino,
});
const client = await connectUnixTestClient(path);
clients.add(client);
expect(await client.hello()).toMatchObject({ type: "hello" });
});
test("never unlinks a regular file at the configured path", async () => {
const path = await makeSocketPath();
await writeFile(path, "do not remove", { mode: 0o640 });
const server = makeServer(path);
await expect(server.start()).rejects.toThrow(/non-socket/);
expect(await readFile(path, "utf8")).toBe("do not remove");
});
test("creates nested temp parents, restricts permissions, and removes its own socket", async () => {
const path = await makeSocketPath(true);
const server = makeServer(path);
await server.start();
const stats = await lstat(path);
expect(stats.isSocket()).toBe(true);
if (process.platform !== "win32") expect(stats.mode & 0o777).toBe(0o600);
expect(await readdir(dirname(path))).toEqual(["server.sock"]);
await server.close();
await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" });
});
test("does not remove a replacement inode during shutdown", async () => {
const path = await makeSocketPath();
const server = makeServer(path);
await server.start();
await unlink(path);
await writeFile(path, "replacement");
const closing = server.close();
expect(await readFile(path, "utf8")).toBe("replacement");
await closing;
expect(await readFile(path, "utf8")).toBe("replacement");
});
test("removes a genuinely stale socket before binding", async () => {
const path = await makeSocketPath();
const child = fork(new URL("fixtures/stale-socket-server.mjs", import.meta.url), [path], {
stdio: ["ignore", "ignore", "inherit", "ipc"],
});
children.add(child);
await once(child, "message");
const staleIdentity = await lstat(path);
expect(staleIdentity.isSocket()).toBe(true);
child.kill("SIGKILL");
await once(child, "exit");
children.delete(child);
const server = makeServer(path);
await server.start();
const liveIdentity = await lstat(path);
expect(liveIdentity.isSocket()).toBe(true);
const client = await connectUnixTestClient(path);
clients.add(client);
expect(await client.hello()).toMatchObject({ type: "hello" });
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node", "vitest"],
"paths": {
"@earendil-works/pi-agent-core": ["../agent/src/index.ts"],
"@earendil-works/pi-ai": ["../ai/src/index.ts"],
"@earendil-works/pi-ai/*": ["../ai/src/*.ts", "../ai/src/providers/*.ts"],
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"],
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View File

@@ -0,0 +1,28 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const src = (path: string): string => fileURLToPath(new URL(path, import.meta.url));
/**
* Exact matches for bare specifiers, plus one rule per package for subpath exports such as
* `@earendil-works/pi-ai/utils/uuid`. A prefix alias would rewrite those onto `index.ts/utils/uuid`.
*/
export default defineConfig({
test: {
globals: true,
environment: "node",
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
},
resolve: {
conditions: ["source"],
alias: [
{ find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") },
{ find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` },
{ find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") },
{ find: /^@earendil-works\/pi-ai\/(.+)$/, replacement: `${src("../ai/src/")}$1.ts` },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: src("../telemetry/src/index.ts") },
{ find: /^@earendil-works\/pi-protocol$/, replacement: src("../protocol/src/index.ts") },
],
},
ssr: { resolve: { conditions: ["source"] } },
});