chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
183
third_party/pi/packages/server/src/testing/client.ts
vendored
Normal file
183
third_party/pi/packages/server/src/testing/client.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
215
third_party/pi/packages/server/src/testing/host.ts
vendored
Normal file
215
third_party/pi/packages/server/src/testing/host.ts
vendored
Normal 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)!;
|
||||
}
|
||||
}
|
||||
5
third_party/pi/packages/server/src/testing/index.ts
vendored
Normal file
5
third_party/pi/packages/server/src/testing/index.ts
vendored
Normal 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";
|
||||
28
third_party/pi/packages/server/src/testing/server.ts
vendored
Normal file
28
third_party/pi/packages/server/src/testing/server.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user