515 lines
22 KiB
TypeScript
515 lines
22 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { test } from "node:test";
|
|
import { BrokerRuntime, SessionBroker, type BrokerMessageHooks, type BrokerSession, type SocketSink } from "../server/session-broker.ts";
|
|
|
|
type Message = { type: string };
|
|
const messagesOf = (socket: FakeSocket): Message[] => socket.messages as Message[];
|
|
|
|
class FakeClock {
|
|
time = 0;
|
|
now = (): number => this.time;
|
|
}
|
|
|
|
class FakeSocket implements SocketSink {
|
|
open = true;
|
|
messages: unknown[] = [];
|
|
isOpen = (): boolean => this.open;
|
|
send = (message: unknown): void => { this.messages.push(message); };
|
|
}
|
|
|
|
class FakeSession implements BrokerSession {
|
|
readonly sessionId: string;
|
|
isStreaming = false;
|
|
isIdle = true;
|
|
abortCount = 0;
|
|
disposeCount = 0;
|
|
promptCalls: Array<{ text: string; preflight: (accepted: boolean) => void }> = [];
|
|
private listeners = new Set<(event: unknown) => void>();
|
|
constructor(sessionId: string) { this.sessionId = sessionId; }
|
|
subscribe = (listener: (event: unknown) => void): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
|
|
prompt = async (text: string, options?: { preflightResult?: (accepted: boolean) => void }): Promise<void> => {
|
|
this.isStreaming = true;
|
|
this.isIdle = false;
|
|
this.promptCalls.push({ text, preflight: options?.preflightResult ?? (() => {}) });
|
|
};
|
|
steer = async (): Promise<void> => {};
|
|
followUp = async (): Promise<void> => {};
|
|
abort = async (): Promise<void> => { this.abortCount++; this.isStreaming = false; this.isIdle = true; };
|
|
dispose = (): void => { this.disposeCount++; };
|
|
emit(event: unknown): void { for (const listener of this.listeners) listener(event); }
|
|
finish(): void { this.isStreaming = false; this.isIdle = true; this.emit({ type: "agent_settled" }); }
|
|
}
|
|
|
|
const testMessages: BrokerMessageHooks = {
|
|
ready: (runtime) => ({ type: "ready", runtimeId: runtime.runtimeId, sessionId: runtime.session.sessionId }),
|
|
stateSync: (_runtime, snapshot) => ({ type: "stateSync", state: snapshot }),
|
|
event: (event) => ({ type: "event", event }),
|
|
choiceRequest: (request) => ({ type: "choiceRequest", ...request }),
|
|
choiceResolved: (requestId, value) => ({ type: "choiceResolved", requestId, value }),
|
|
promptAccepted: (requestId) => ({ type: "promptAccepted", requestId }),
|
|
promptRejected: (requestId) => ({ type: "promptRejected", requestId }),
|
|
};
|
|
|
|
function setup(options: Partial<ConstructorParameters<typeof SessionBroker>[0]> = {}) {
|
|
const clock = new FakeClock();
|
|
const sessions = new Map<string, FakeSession>();
|
|
const broker = new SessionBroker({
|
|
clock,
|
|
factory: { keyForContext: (context) => String(context.owner), create: async (key) => { const session = new FakeSession(key); sessions.set(key, session); return session; } },
|
|
serializer: { snapshot: (runtime) => ({ sessionId: runtime.session.sessionId }) },
|
|
onDelete: async () => {},
|
|
onError: () => {},
|
|
messages: testMessages,
|
|
...options,
|
|
});
|
|
return { clock, sessions, broker };
|
|
}
|
|
|
|
async function runtimeAndSocket(broker: SessionBroker, key = "session-1") {
|
|
const runtime = await broker.getOrCreate({ owner: key });
|
|
const socket = new FakeSocket();
|
|
const attachment = runtime.attach(socket);
|
|
return { runtime, socket, attachment, session: runtime.session as FakeSession };
|
|
}
|
|
|
|
test("canonical keys share one in-flight creation and stable runtime identity", async () => {
|
|
let creates = 0;
|
|
let resolve!: (session: FakeSession) => void;
|
|
const broker = new SessionBroker({
|
|
factory: { keyForContext: () => "42", create: async () => { creates++; return new Promise<FakeSession>((done) => { resolve = done; }); } },
|
|
serializer: { snapshot: () => ({}) },
|
|
messages: testMessages,
|
|
onDelete: async () => {},
|
|
onError: () => {},
|
|
});
|
|
const first = broker.getOrCreate({ value: 1 });
|
|
const second = broker.getOrCreate({ value: 2 });
|
|
await Promise.resolve();
|
|
assert.equal(creates, 1);
|
|
const session = new FakeSession("42");
|
|
resolve(session);
|
|
const [a, b] = await Promise.all([first, second]);
|
|
assert.equal(a, b);
|
|
assert.equal(a.runtimeId, b.runtimeId);
|
|
assert.equal(a.context.value, 1);
|
|
});
|
|
|
|
test("attach sends a synchronous snapshot barrier, fanout isolates failures, and detach does not abort", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, socket, attachment, session } = await runtimeAndSocket(broker);
|
|
const second = new FakeSocket();
|
|
runtime.attach(second);
|
|
assert.deepEqual(messagesOf(socket).slice(0, 2), [
|
|
{ type: "ready", runtimeId: runtime.runtimeId, sessionId: "session-1" },
|
|
{ type: "stateSync", state: { sessionId: "session-1" } },
|
|
]);
|
|
session.emit({ type: "delta", value: 1 });
|
|
assert.equal(messagesOf(socket)[2].type, "event");
|
|
attachment.detach();
|
|
assert.equal(session.abortCount, 0);
|
|
session.emit({ type: "delta", value: 2 });
|
|
assert.equal(messagesOf(second).filter((m) => m.type === "event").length, 2);
|
|
const broken = new FakeSocket();
|
|
broken.send = () => { throw new Error("broken socket"); };
|
|
assert.throws(() => runtime.attach(broken), /Socket closed during attach/);
|
|
session.emit({ type: "safe" });
|
|
assert.equal(messagesOf(second).filter((m) => m.type === "event").length, 3);
|
|
});
|
|
|
|
test("turn and agent settlement refresh lifecycle activity", async () => {
|
|
const { broker, clock } = setup();
|
|
const { runtime, session } = await runtimeAndSocket(broker);
|
|
clock.time = 10;
|
|
session.emit({ type: "turn_end" });
|
|
assert.equal(runtime.lastUsedAt, 10);
|
|
clock.time = 20;
|
|
session.emit({ type: "agent_end" });
|
|
assert.equal(runtime.lastUsedAt, 20);
|
|
});
|
|
|
|
test("explicit abort is ordered and idempotent", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment, session } = await runtimeAndSocket(broker);
|
|
await runtime.abort(attachment);
|
|
await runtime.abort(attachment);
|
|
assert.equal(session.abortCount, 1);
|
|
});
|
|
|
|
test("abort idempotence resets for the next accepted prompt", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment, session } = await runtimeAndSocket(broker);
|
|
await runtime.abort(attachment);
|
|
await runtime.prompt(attachment, { requestId: "next", text: "hello" }, "next");
|
|
await runtime.abort(attachment);
|
|
assert.equal(session.abortCount, 2);
|
|
});
|
|
|
|
test("mutating commands preserve order and reject stale attachments", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment } = await runtimeAndSocket(broker);
|
|
const order: string[] = [];
|
|
const first = runtime.invoke(attachment, async () => { order.push("first"); await new Promise((resolve) => setTimeout(resolve, 2)); order.push("first-done"); });
|
|
const second = runtime.invoke(attachment, async () => { order.push("second"); });
|
|
await Promise.all([first, second]);
|
|
assert.deepEqual(order, ["first", "first-done", "second"]);
|
|
attachment.detach();
|
|
await assert.rejects(runtime.invoke(attachment, async () => {}), /Attachment/);
|
|
});
|
|
|
|
test("prompt records prevent duplicates and detect fingerprint conflicts", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment, session, socket } = await runtimeAndSocket(broker);
|
|
const request = { requestId: "req-1", text: "hello" };
|
|
const first = runtime.prompt(attachment, request, "same");
|
|
await Promise.resolve();
|
|
assert.equal(session.promptCalls.length, 1);
|
|
const duplicate = runtime.prompt(attachment, request, "same");
|
|
session.promptCalls[0].preflight(true);
|
|
await Promise.all([first, duplicate]);
|
|
assert.equal(session.promptCalls.length, 1);
|
|
assert.deepEqual(messagesOf(socket).filter((m) => m.type === "promptAccepted"), [
|
|
{ type: "promptAccepted", requestId: "req-1" },
|
|
{ type: "promptAccepted", requestId: "req-1" },
|
|
]);
|
|
await assert.rejects(runtime.prompt(attachment, { ...request, text: "other" }, "different"), /different payload/);
|
|
});
|
|
|
|
test("prompt preflight rejection releases the chain and records the rejection", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment, session, socket } = await runtimeAndSocket(broker);
|
|
const first = runtime.prompt(attachment, { requestId: "reject", text: "no" }, "x");
|
|
await Promise.resolve();
|
|
session.promptCalls[0].preflight(false);
|
|
await assert.rejects(first, /rejected/);
|
|
const duplicate = runtime.prompt(attachment, { requestId: "reject", text: "no" }, "x");
|
|
await assert.rejects(duplicate, /rejected/);
|
|
assert.deepEqual(messagesOf(socket).filter((m) => m.type === "promptRejected"), [
|
|
{ type: "promptRejected", requestId: "reject" },
|
|
{ type: "promptRejected", requestId: "reject" },
|
|
]);
|
|
});
|
|
|
|
test("abort rejects and clears pending choices", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment } = await runtimeAndSocket(broker);
|
|
const pending = runtime.requestChoice({ choiceId: "abort-choice", payload: {} });
|
|
await runtime.abort(attachment);
|
|
await assert.rejects(pending, /Runtime aborted/);
|
|
assert.equal(runtime.hasPendingChoices, false);
|
|
});
|
|
|
|
test("pending choices replay on reconnect and resolve once", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment } = await runtimeAndSocket(broker);
|
|
const choice = runtime.requestChoice({ choiceId: "choice-1", payload: { question: "Continue?" } });
|
|
attachment.detach();
|
|
const socket = new FakeSocket();
|
|
const reconnected = runtime.attach(socket);
|
|
assert.equal(messagesOf(socket).filter((m) => m.type === "choiceRequest").length, 1);
|
|
await runtime.resolveChoice(reconnected, "choice-1", "yes");
|
|
assert.equal(await choice, "yes");
|
|
assert.deepEqual(messagesOf(socket).at(-1), { type: "choiceResolved", requestId: "choice-1", value: "yes" });
|
|
await runtime.resolveChoice(reconnected, "choice-1", "no");
|
|
assert.equal(messagesOf(socket).filter((m) => m.type === "choiceResolved").length, 1);
|
|
});
|
|
|
|
test("choice cancellation rejects the deferred call and clears attached clients", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, socket } = await runtimeAndSocket(broker);
|
|
const pending = runtime.requestChoice({ choiceId: "cancel", payload: {} });
|
|
runtime.rejectChoice("cancel");
|
|
await assert.rejects(pending, /cancelled/);
|
|
assert.equal(messagesOf(socket).filter((message) => message.type === "choiceResolved").length, 1);
|
|
});
|
|
|
|
test("pending choices are bounded per runtime", async () => {
|
|
const { broker } = setup();
|
|
const { runtime } = await runtimeAndSocket(broker);
|
|
const pending: Promise<unknown>[] = [];
|
|
for (let index = 0; index < 16; index++) pending.push(runtime.requestChoice({ choiceId: `choice-${index}`, payload: {} }).catch(() => undefined));
|
|
await assert.rejects(runtime.requestChoice({ choiceId: "choice-17", payload: {} }), /too many/);
|
|
await runtime.dispose();
|
|
await Promise.all(pending);
|
|
});
|
|
|
|
test("deletion is conservative and removes an idle runtime once", async () => {
|
|
const deleted: string[] = [];
|
|
const { broker } = setup({ onDelete: async (key) => { deleted.push(key); } });
|
|
const { runtime, attachment, session } = await runtimeAndSocket(broker);
|
|
const other = new FakeSocket();
|
|
const otherAttachment = runtime.attach(other);
|
|
await assert.rejects(runtime.delete(), /active or has multiple/);
|
|
other.open = false;
|
|
otherAttachment.detach();
|
|
await broker.delete("session-1");
|
|
assert.equal(session.disposeCount, 1);
|
|
assert.deepEqual(deleted, ["session-1"]);
|
|
const replacement = await broker.getOrCreate({ owner: "session-1" });
|
|
assert.notEqual(replacement, runtime);
|
|
});
|
|
|
|
test("sweep retains active work, evicts idle runtimes, and enforces capacity", async () => {
|
|
const { broker, clock, sessions } = setup({ idleTtlMs: 10, activeMaxAgeMs: 100, maxRuntimes: 2 });
|
|
const a = await runtimeAndSocket(broker, "a");
|
|
a.attachment.detach();
|
|
clock.time = 20;
|
|
await broker.sweep();
|
|
assert.equal(a.session.disposeCount, 1);
|
|
const b = await runtimeAndSocket(broker, "b");
|
|
b.attachment.detach();
|
|
const c = await runtimeAndSocket(broker, "c");
|
|
assert.equal(sessions.has("b"), true);
|
|
assert.equal(sessions.has("c"), true);
|
|
assert.equal(b.session.disposeCount, 0);
|
|
c.attachment.detach();
|
|
const d = await runtimeAndSocket(broker, "d");
|
|
assert.equal(b.session.disposeCount, 1);
|
|
d.session.isIdle = false;
|
|
d.session.isStreaming = true;
|
|
d.attachment.detach();
|
|
clock.time = 1000;
|
|
await broker.sweep();
|
|
assert.equal(d.session.abortCount, 1);
|
|
});
|
|
|
|
test("age cleanup aborts an idle session with a pending choice", async () => {
|
|
const { broker, clock } = setup({ activeMaxAgeMs: 10, sweepAbortGraceMs: 5 });
|
|
const { runtime, session, attachment } = await runtimeAndSocket(broker);
|
|
const pending = runtime.requestChoice({ choiceId: "old-choice", payload: {} });
|
|
attachment.detach();
|
|
clock.time = 10;
|
|
await broker.sweep();
|
|
await assert.rejects(pending, /Runtime aborted/);
|
|
assert.equal(session.abortCount, 1);
|
|
assert.equal(runtime.hasPendingChoices, false);
|
|
});
|
|
|
|
test("a new prompt waits for an age abort to settle", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, session, attachment } = await runtimeAndSocket(broker);
|
|
let releaseAbort!: () => void;
|
|
const blockedAbort = new Promise<void>((resolve) => { releaseAbort = resolve; });
|
|
session.isIdle = false;
|
|
session.abort = async () => {
|
|
session.abortCount++;
|
|
await blockedAbort;
|
|
session.isIdle = true;
|
|
};
|
|
attachment.detach();
|
|
await runtime.requestAgeAbort(10);
|
|
const nextSocket = new FakeSocket();
|
|
const nextAttachment = runtime.attach(nextSocket);
|
|
const prompt = runtime.prompt(nextAttachment, { requestId: "after-age-abort", text: "hello" }, "next");
|
|
await Promise.resolve();
|
|
assert.equal(session.promptCalls.length, 0);
|
|
releaseAbort();
|
|
await prompt;
|
|
assert.equal(session.promptCalls.length, 1);
|
|
});
|
|
|
|
test("client preserves input until the runtime is ready", () => {
|
|
const source = readFileSync(new URL("../client/main.ts", import.meta.url), "utf8");
|
|
assert.match(source, /if \(!connected \|\| !agentReady \|\| ws\?\.readyState !== WebSocket\.OPEN\)/);
|
|
assert.match(source, /errorMessage = "Connection is not ready\."/);
|
|
});
|
|
|
|
test("shutdown is bounded, repeated, and disposes late factory results", async () => {
|
|
let resolve!: (session: FakeSession) => void;
|
|
const broker = new SessionBroker({
|
|
factory: { keyForContext: () => "late", create: async () => new Promise<FakeSession>((done) => { resolve = done; }) },
|
|
serializer: { snapshot: () => ({}) },
|
|
});
|
|
const pending = broker.getOrCreate({ owner: "late" });
|
|
const shutting = broker.shutdown(20);
|
|
await Promise.resolve();
|
|
resolve(new FakeSession("late"));
|
|
await assert.rejects(pending, /shut down/);
|
|
await shutting;
|
|
await broker.shutdown(20);
|
|
});
|
|
|
|
test("deletion reservation stays visible until file removal completes", async () => {
|
|
let release!: () => void;
|
|
const { broker } = setup({ onDelete: async () => new Promise<void>((resolve) => { release = resolve; }) });
|
|
const { runtime } = await runtimeAndSocket(broker, "delete-race");
|
|
const deleting = broker.delete("delete-race");
|
|
await Promise.resolve();
|
|
const replacement = broker.getOrCreate({ owner: "delete-race" });
|
|
let created = false;
|
|
void replacement.then(() => { created = true; });
|
|
await Promise.resolve();
|
|
assert.equal(created, false);
|
|
assert.equal(runtime.state, "disposed");
|
|
release();
|
|
assert.notEqual(await replacement, runtime);
|
|
await deleting;
|
|
});
|
|
|
|
test("capacity limits are isolated by an injected group", async () => {
|
|
const broker = new SessionBroker({
|
|
maxRuntimes: 1,
|
|
capacityGroup: (context) => String(context.account),
|
|
factory: { keyForContext: (context) => String(context.key), create: async (key) => new FakeSession(key) },
|
|
serializer: { snapshot: () => ({}) },
|
|
messages: testMessages,
|
|
onError: () => {},
|
|
onDelete: async () => {},
|
|
});
|
|
const a1 = await broker.getOrCreate({ account: "a", key: "a-1" });
|
|
a1.attach(new FakeSocket());
|
|
await broker.getOrCreate({ account: "b", key: "b-1" });
|
|
await assert.rejects(broker.getOrCreate({ account: "a", key: "a-2" }), /capacity/);
|
|
const b2 = await broker.getOrCreate({ account: "b", key: "b-2" });
|
|
assert.equal(b2.context.account, "b");
|
|
});
|
|
|
|
test("capacity reservation rejects concurrent over-limit creation before factory await", async () => {
|
|
let release!: () => void;
|
|
let creates = 0;
|
|
const broker = new SessionBroker({
|
|
maxRuntimes: 1,
|
|
factory: { keyForContext: (context) => String(context.key), create: async (key) => { creates++; return new Promise<FakeSession>((resolve) => { release = () => resolve(new FakeSession(key)); }); } },
|
|
serializer: { snapshot: () => ({}) },
|
|
});
|
|
const first = broker.getOrCreate({ key: "first" });
|
|
await assert.rejects(broker.getOrCreate({ key: "second" }), /capacity/);
|
|
assert.equal(creates, 1);
|
|
release();
|
|
await first;
|
|
});
|
|
|
|
test("synchronous prompt starter failure settles the request record", async () => {
|
|
const { broker } = setup({ promptStarter: () => { throw new Error("starter failed"); } });
|
|
const { runtime, attachment } = await runtimeAndSocket(broker);
|
|
const request = { requestId: "sync-failure", text: "hello" };
|
|
await assert.rejects(runtime.prompt(attachment, request, "throw"), /starter failed/);
|
|
await assert.rejects(runtime.prompt(attachment, request, "throw"), /starter failed/);
|
|
});
|
|
|
|
test("late preflight cannot accept a terminal runtime", async () => {
|
|
let preflight!: (accepted: boolean) => void;
|
|
const { broker } = setup({ promptStarter: (_session, _request, callback) => {
|
|
preflight = callback;
|
|
return new Promise<void>(() => {});
|
|
} });
|
|
const { runtime, attachment } = await runtimeAndSocket(broker);
|
|
const pending = runtime.prompt(attachment, { requestId: "late", text: "hello" }, "late");
|
|
await Promise.resolve();
|
|
await runtime.dispose();
|
|
preflight(true);
|
|
await assert.rejects(pending, /disposed/);
|
|
});
|
|
|
|
test("closed attachments detach and delayed responses require the current generation", async () => {
|
|
const { broker } = setup();
|
|
const { runtime, attachment, session } = await runtimeAndSocket(broker);
|
|
const generation = attachment.generation;
|
|
attachment.attachment.sink.isOpen = () => false;
|
|
session.emit({ type: "delta" });
|
|
assert.equal(runtime.attachmentCount, 0);
|
|
const socket = new FakeSocket();
|
|
const next = runtime.attach(socket);
|
|
assert.equal(runtime.isAttachmentValid(next, generation), false);
|
|
assert.equal(runtime.isAttachmentValid(next, next.generation), true);
|
|
next.detach();
|
|
});
|
|
|
|
test("shutdown disposes a runtime even when abort never settles", async () => {
|
|
let session!: FakeSession;
|
|
const broker = new SessionBroker({
|
|
factory: { keyForContext: () => "hang", create: async (key) => { session = new FakeSession(key); session.isIdle = false; session.isStreaming = true; session.abort = async () => new Promise<void>(() => {}); return session; } },
|
|
serializer: { snapshot: () => ({}) },
|
|
});
|
|
await broker.getOrCreate({ owner: "hang" });
|
|
const keepAlive = setTimeout(() => {}, 50);
|
|
await broker.shutdown(10);
|
|
clearTimeout(keepAlive);
|
|
assert.equal(session.abortCount, 0);
|
|
assert.equal(session.disposeCount, 1);
|
|
await broker.shutdown(10);
|
|
assert.equal(session.disposeCount, 1);
|
|
});
|
|
|
|
test("a throwing event hook does not stop fanout", async () => {
|
|
const errors: unknown[] = [];
|
|
const { broker } = setup({
|
|
onError: (error) => errors.push(error),
|
|
messages: {
|
|
ready: () => ({ type: "ready" }),
|
|
stateSync: () => ({ type: "stateSync" }),
|
|
event: () => { throw new Error("event hook failed"); },
|
|
choiceRequest: (request) => ({ type: "choiceRequest", ...request }),
|
|
choiceResolved: (requestId, value) => ({ type: "choiceResolved", requestId, value }),
|
|
promptAccepted: (requestId) => ({ type: "promptAccepted", requestId }),
|
|
promptRejected: (requestId) => ({ type: "promptRejected", requestId }),
|
|
},
|
|
});
|
|
const { runtime, session } = await runtimeAndSocket(broker);
|
|
const second = new FakeSocket();
|
|
runtime.attach(second);
|
|
session.emit({ type: "event" });
|
|
assert.equal(runtime.attachmentCount, 2);
|
|
assert.equal(errors.length, 1);
|
|
});
|
|
|
|
test("canonical manager key is selected before factory creation", async () => {
|
|
const calls: string[] = [];
|
|
const broker = new SessionBroker({
|
|
factory: {
|
|
keyForContext: (context: { manager: { getSessionId: () => string } }) => context.manager.getSessionId(),
|
|
create: async (key, context) => { calls.push(`${key}:${context.manager.getSessionId()}`); return new FakeSession(key); },
|
|
},
|
|
serializer: { snapshot: () => ({}) },
|
|
});
|
|
await broker.getOrCreate({ manager: { getSessionId: () => "canonical-id" } });
|
|
assert.deepEqual(calls, ["canonical-id:canonical-id"]);
|
|
assert.ok(broker.get("canonical-id"));
|
|
assert.equal(broker.get("requested-path"), undefined);
|
|
});
|
|
|
|
test("disposeAll resets runtimes without shutting down the broker", async () => {
|
|
const { broker, sessions } = setup();
|
|
await runtimeAndSocket(broker, "reset");
|
|
await broker.disposeAll();
|
|
assert.equal(sessions.get("reset")?.disposeCount, 1);
|
|
const replacement = await broker.getOrCreate({ owner: "reset" });
|
|
assert.equal(replacement.session.sessionId, "reset");
|
|
});
|
|
|
|
test("reset clears stale creation reservations and disposes late results", async () => {
|
|
const releases: Array<(session: FakeSession) => void> = [];
|
|
const broker = new SessionBroker({
|
|
factory: {
|
|
keyForContext: (context: { owner: string }) => context.owner,
|
|
create: async () => new Promise<FakeSession>((resolve) => { releases.push(resolve); }),
|
|
},
|
|
serializer: { snapshot: () => ({}) },
|
|
messages: testMessages,
|
|
onDelete: async () => {},
|
|
onError: () => {},
|
|
});
|
|
const pending = broker.getOrCreate({ owner: "old" });
|
|
const reset = broker.disposeAll(1);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
await reset;
|
|
const replacementPending = broker.getOrCreate({ owner: "old" });
|
|
assert.equal(releases.length, 2);
|
|
releases[1](new FakeSession("old"));
|
|
const replacement = await replacementPending;
|
|
const stale = new FakeSession("old");
|
|
releases[0](stale);
|
|
await assert.rejects(pending, /reset/);
|
|
assert.equal(stale.disposeCount, 1);
|
|
assert.equal(broker.get("old"), replacement);
|
|
});
|
|
|
|
test("direct runtime deletion does not leave a disposed registry entry", async () => {
|
|
const { broker } = setup();
|
|
const { runtime } = await runtimeAndSocket(broker, "direct-delete");
|
|
await runtime.delete();
|
|
assert.equal(broker.get("direct-delete"), undefined);
|
|
const replacement = await broker.getOrCreate({ owner: "direct-delete" });
|
|
assert.notEqual(replacement, runtime);
|
|
});
|