mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-08-19 21:53:48 +00:00
Remove the raw Session RPC projection and route framed service calls through fenced presentation attachments.
702 lines
23 KiB
TypeScript
702 lines
23 KiB
TypeScript
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 { PiServer } from "../src/server.ts";
|
|
import { Deferred, ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts";
|
|
import type { PiServerHost } from "../src/types.ts";
|
|
|
|
const servers = new Set<PiServer>();
|
|
|
|
function createServer(host: TestServerHost, serverId = "00000000-0000-4000-8000-000000000001"): PiServer {
|
|
const server = new PiServer(host, { listeners: [], serverId });
|
|
servers.add(server);
|
|
return server;
|
|
}
|
|
|
|
function connect(server: PiServer): 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;
|
|
}
|
|
|
|
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: "00000000-0000-4000-8000-000000000001" });
|
|
expect(host.harnesses.size).toBe(0);
|
|
});
|
|
|
|
test("list returns presentation-safe summaries without opening a Session", async () => {
|
|
const host = new TestServerHost();
|
|
const metadata = await host.seed("session-1", "parent-1");
|
|
const client = connect(createServer(host));
|
|
await client.hello();
|
|
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
|
|
).resolves.toEqual({
|
|
type: "response",
|
|
id: "request-1",
|
|
ok: true,
|
|
result: [
|
|
{
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
sessionId: metadata.id,
|
|
createdAt: metadata.createdAt,
|
|
},
|
|
],
|
|
});
|
|
expect(host.harnesses.size).toBe(0);
|
|
});
|
|
|
|
test("create persists a Session without opening a Harness", async () => {
|
|
const host = new TestServerHost();
|
|
const client = connect(createServer(host));
|
|
await client.hello();
|
|
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "create",
|
|
args: [{ id: "session-1" }],
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
sessionId: "session-1",
|
|
createdAt: 1,
|
|
},
|
|
});
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
|
|
).resolves.toMatchObject({ result: [{ sessionId: "session-1" }] });
|
|
expect(host.harnesses.size).toBe(0);
|
|
});
|
|
|
|
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: PiServerHost<BackendMetadata> = {
|
|
sessions: { list: async () => [metadata], create: async () => metadata },
|
|
openSession: async (candidate) => {
|
|
received = candidate;
|
|
return {
|
|
attachClient: async () => ({
|
|
prompt: async () => ({
|
|
ok: true,
|
|
value: { kind: "completed" as const, runId: "run-1", leafId: "leaf-1" },
|
|
}),
|
|
release: () => {},
|
|
}),
|
|
close: async () => {},
|
|
};
|
|
},
|
|
};
|
|
const server = new PiServer(host, {
|
|
listeners: [],
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
});
|
|
servers.add(server);
|
|
const client = connect(server);
|
|
await client.hello();
|
|
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
|
|
).resolves.toEqual({
|
|
type: "response",
|
|
id: "request-1",
|
|
ok: true,
|
|
result: [
|
|
{
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
sessionId: "session-1",
|
|
createdAt: 1,
|
|
},
|
|
],
|
|
});
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
expect(received).toBe(metadata);
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", { method: "watch", args: ["session-1"] }),
|
|
).resolves.toMatchObject({ ok: false, error: { code: "not_supported" } });
|
|
});
|
|
|
|
test("delivers a lane snapshot before buffered and live watch events", async () => {
|
|
const host = new TestServerHost();
|
|
await host.seed("session-1");
|
|
const client = connect(createServer(host));
|
|
await client.hello();
|
|
await client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
});
|
|
|
|
const watched = await client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "watch",
|
|
args: ["session-1"],
|
|
});
|
|
expect(watched).toMatchObject({
|
|
ok: true,
|
|
result: { snapshot: { lane: "main", transcript: [], operation: null } },
|
|
});
|
|
if (
|
|
!watched.ok ||
|
|
typeof watched.result !== "object" ||
|
|
watched.result === null ||
|
|
!("watchId" in watched.result)
|
|
) {
|
|
throw new Error("Missing watch result");
|
|
}
|
|
const watchId = watched.result.watchId;
|
|
if (typeof watchId !== "string") throw new Error("Invalid watch ID");
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "watch",
|
|
args: ["session-1"],
|
|
}),
|
|
).resolves.toMatchObject({ ok: false, error: { code: "watch_in_use" } });
|
|
await host.latestHarness("session-1").emitEvent({
|
|
type: "run_start",
|
|
lane: "main",
|
|
runId: "run-1",
|
|
});
|
|
expect(client.messages.some((message) => message.type === "event")).toBe(false);
|
|
|
|
const messageIndex = client.messages.length;
|
|
const starting = client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "startWatch",
|
|
args: ["session-1", watchId],
|
|
});
|
|
await expect(client.nextFrom(messageIndex, (message) => message.type === "event")).resolves.toMatchObject({
|
|
type: "event",
|
|
watchId,
|
|
event: { type: "run_start", runId: "run-1" },
|
|
});
|
|
await expect(starting).resolves.toMatchObject({ ok: true, result: { watchId } });
|
|
|
|
await host.latestHarness("session-1").emitEvent({
|
|
type: "message_start",
|
|
lane: "main",
|
|
runId: "run-1",
|
|
message: { role: "user", content: "hello", timestamp: 1 },
|
|
});
|
|
await expect(client.nextFrom(messageIndex + 1, (message) => message.type === "event")).resolves.toMatchObject({
|
|
event: { type: "message_start" },
|
|
});
|
|
|
|
await client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "stopWatch",
|
|
args: ["session-1", watchId],
|
|
});
|
|
const stoppedAt = client.messages.length;
|
|
await host.latestHarness("session-1").emitEvent({
|
|
type: "run_resume",
|
|
lane: "main",
|
|
runId: "run-1",
|
|
});
|
|
expect(client.messages.slice(stoppedAt).some((message) => message.type === "event")).toBe(false);
|
|
});
|
|
|
|
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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
await expect(
|
|
first.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
expect(host.latestHarness("session-1").attachedClients).toBe(1);
|
|
await expect(
|
|
second.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
});
|
|
|
|
test("clears connection ownership when attachment release fails", async () => {
|
|
const host = new TestServerHost();
|
|
await host.seed("session-1");
|
|
const errors: Error[] = [];
|
|
const server = new PiServer(host, {
|
|
listeners: [],
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
});
|
|
|
|
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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["Hello"]],
|
|
}),
|
|
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
|
|
await attached.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
});
|
|
await expect(
|
|
attached.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-2", ["Hello"]],
|
|
}),
|
|
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
|
|
|
|
await expect(
|
|
attached.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["Hello"]],
|
|
}),
|
|
).resolves.toEqual({
|
|
type: "response",
|
|
id: "request-3",
|
|
ok: true,
|
|
result: { ok: true, value: { kind: "completed", runId: "run-1", leafId: "leaf-1" } },
|
|
});
|
|
expect(host.latestHarness("session-1").promptCalls).toEqual([["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));
|
|
const serverId = "00000000-0000-4000-8000-000000000001";
|
|
await client.hello();
|
|
const first = await client.request(serverId, { method: "attach", args: ["session-1"] });
|
|
if (
|
|
!first.ok ||
|
|
typeof first.result !== "object" ||
|
|
first.result === null ||
|
|
!("attachmentId" in first.result) ||
|
|
typeof first.result.attachmentId !== "string"
|
|
) {
|
|
throw new Error("Missing first attachment ID");
|
|
}
|
|
await client.request(serverId, { method: "attach", args: ["session-2"] });
|
|
|
|
const response = client.next((message) => message.type === "response" && message.id === "stale-request");
|
|
await client.sendMessage({
|
|
type: "request",
|
|
id: "stale-request",
|
|
target: { serverId, sessionId: "session-1", attachmentId: first.result.attachmentId },
|
|
call: { serviceId: "chat", member: "prompt", args: [["stale"]] },
|
|
});
|
|
await expect(response).resolves.toMatchObject({
|
|
type: "response",
|
|
ok: false,
|
|
error: { code: "session_not_attached" },
|
|
});
|
|
expect(host.latestHarness("session-1").promptCalls).toEqual([]);
|
|
});
|
|
|
|
test("preserves structural Harness failures and bounds adapter defects", async () => {
|
|
const host = new TestServerHost();
|
|
await host.seed("session-1");
|
|
const client = connect(createServer(host));
|
|
await client.hello();
|
|
await client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
});
|
|
const harness = host.latestHarness("session-1");
|
|
harness.nextPromptResult = { ok: false, error: { _tag: "Closed", message: "Harness closed" } };
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["Hello"]],
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
result: { ok: false, error: { _tag: "Closed", message: "Harness closed" } },
|
|
});
|
|
|
|
harness.nextPromptError = new Error("private adapter detail");
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["Hello"]],
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { code: "internal_error", message: "Internal server error" },
|
|
});
|
|
});
|
|
|
|
test("admits concurrent prompts so the Harness owns lane-busy semantics", async () => {
|
|
const host = new TestServerHost();
|
|
await host.seed("session-1");
|
|
const client = connect(createServer(host));
|
|
await client.hello();
|
|
await client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
});
|
|
const harness = host.latestHarness("session-1");
|
|
const gate = harness.gateNextPrompt();
|
|
const first = client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["first"]],
|
|
});
|
|
await gate.entered.promise;
|
|
const second = client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["second"]],
|
|
});
|
|
|
|
await expect(second).resolves.toMatchObject({ ok: true, result: { ok: true } });
|
|
expect(harness.promptCalls).toEqual([["first"], ["second"]]);
|
|
gate.release.resolve(undefined);
|
|
await expect(first).resolves.toMatchObject({ ok: true, result: { ok: true } });
|
|
});
|
|
|
|
test("keeps attachment demand until an accepted prompt settles after disconnect", async () => {
|
|
const host = new TestServerHost();
|
|
await host.seed("session-1");
|
|
const server = createServer(host);
|
|
const client = connect(server);
|
|
await client.hello();
|
|
await client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
});
|
|
const harness = host.latestHarness("session-1");
|
|
const gate = harness.gateNextPrompt();
|
|
const prompting = client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "prompt",
|
|
args: ["session-1", ["Hello"]],
|
|
});
|
|
const disconnectedPrompt = expect(prompting).rejects.toThrow(/closed/i);
|
|
await gate.entered.promise;
|
|
|
|
await client.close();
|
|
expect(harness.attachedClients).toBe(1);
|
|
gate.release.resolve(undefined);
|
|
await disconnectedPrompt;
|
|
await expect.poll(() => harness.attachedClients).toBe(0);
|
|
expect(harness.promptCalls).toEqual([["Hello"]]);
|
|
});
|
|
|
|
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.request("00000000-0000-4000-8000-000000000002", { method: "attach", args: ["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.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["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: PiServerHost<BackendMetadata> = {
|
|
sessions: {
|
|
list: async () => [
|
|
{ id: "duplicate", createdAt: 1, storageVersion: 1, cwd: "/one", path: "/one/session.jsonl" },
|
|
{ id: "duplicate", createdAt: 2, storageVersion: 1, cwd: "/two", path: "/two/session.jsonl" },
|
|
],
|
|
create: async () => {
|
|
throw new Error("must not create a Session in this test");
|
|
},
|
|
},
|
|
openSession: async () => {
|
|
throw new Error("must not create a Harness for an ambiguous session");
|
|
},
|
|
};
|
|
const server = new PiServer(host, {
|
|
listeners: [],
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
});
|
|
servers.add(server);
|
|
const client = connect(server);
|
|
await client.hello();
|
|
|
|
await expect(
|
|
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["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 server = createServer(host);
|
|
const client = connect(server);
|
|
await client.hello();
|
|
await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["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.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
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.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["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: PiServerHost = {
|
|
sessions: { list: async () => [metadata], create: async () => metadata },
|
|
openSession: async () => ({
|
|
terminated: terminated.promise,
|
|
attachClient: async () => {
|
|
acquiring.resolve(undefined);
|
|
await continueAcquiring.promise;
|
|
return {
|
|
prompt: async () => ({
|
|
ok: true,
|
|
value: { kind: "completed" as const, runId: "run-1", leafId: "leaf-1" },
|
|
}),
|
|
release: () => {
|
|
releaseCount += 1;
|
|
},
|
|
};
|
|
},
|
|
close: async () => {},
|
|
}),
|
|
};
|
|
const server = new PiServer(host, {
|
|
listeners: [],
|
|
serverId: "00000000-0000-4000-8000-000000000001",
|
|
});
|
|
servers.add(server);
|
|
const client = connect(server);
|
|
await client.hello();
|
|
const attach = client.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["session-1"],
|
|
});
|
|
await gate.entered.promise;
|
|
const secondAttach = second.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["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.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
|
|
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
|
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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["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.request("00000000-0000-4000-8000-000000000001", {
|
|
method: "attach",
|
|
args: ["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);
|
|
});
|
|
});
|