Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
502
packages/server/test/conformance.test.ts
Normal file
502
packages/server/test/conformance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
packages/server/test/fixtures/stale-socket-server.mjs
vendored
Normal file
9
packages/server/test/fixtures/stale-socket-server.mjs
vendored
Normal 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");
|
||||
});
|
||||
52
packages/server/test/listener.test.ts
Normal file
52
packages/server/test/listener.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
168
packages/server/test/protocol.test.ts
Normal file
168
packages/server/test/protocol.test.ts
Normal 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) })]);
|
||||
});
|
||||
127
packages/server/test/server.test.ts
Normal file
127
packages/server/test/server.test.ts
Normal 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);
|
||||
});
|
||||
65
packages/server/test/unix-connection.test.ts
Normal file
65
packages/server/test/unix-connection.test.ts
Normal 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;
|
||||
});
|
||||
143
packages/server/test/unix.test.ts
Normal file
143
packages/server/test/unix.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user