chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
101
third_party/pi/packages/chord/test/awaiting.test.ts
vendored
Normal file
101
third_party/pi/packages/chord/test/awaiting.test.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
// Entry 8: awaited gets. `host.get` and `ctx.get` resolve once the reference is ready; abort, the ctx closing and the
|
||||
// host's `readyTimeout` reject them and release only that reference.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { type Ctx, createHost, type Host, hostNamed, isChordError, memoryLinkPair, remote } from "../src/index.ts";
|
||||
import { CountingLink } from "./fixtures/counting.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Slow = remote("t.e8-slow", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { n: Type.Number() } } },
|
||||
});
|
||||
|
||||
/** A server whose `Slow` object goes live when `open()` is called; a browser whose link counts frames. */
|
||||
function setup(readyTimeout?: number) {
|
||||
let open = () => {};
|
||||
const opened = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Slow, (_input, _principal, self) => {
|
||||
self.resource("gate", { version: 1, open: () => opened });
|
||||
return { variant: "main", n: self.state("n", Type.Number(), 1) };
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up);
|
||||
const browser: Host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
...(readyTimeout === undefined ? {} : { readyTimeout }),
|
||||
});
|
||||
return { server, browser, link, open };
|
||||
}
|
||||
|
||||
describe("awaited gets", () => {
|
||||
it("an aborted get rejects and releases only its reference; the binding stays for the others", async () => {
|
||||
const { server, browser, link, open } = setup();
|
||||
const abort = new AbortController();
|
||||
const first = browser.get(Slow, {}, { signal: abort.signal });
|
||||
const second = browser.get(Slow, {});
|
||||
await sleep(5);
|
||||
abort.abort();
|
||||
await assert.rejects(first, (error) => isChordError(error, "aborted"));
|
||||
await sleep(5);
|
||||
assert.equal(link.count("sent", "release"), 0, "the other awaiting get keeps the hold");
|
||||
open();
|
||||
const ref = await second;
|
||||
assert.equal(ref.n.value, 1);
|
||||
assert.equal(link.count("sent", "hold"), 1);
|
||||
ref.release();
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a ctx.get still waiting when its call settles rejects aborted and releases its reference", async () => {
|
||||
const { server, browser, link, open } = setup();
|
||||
let pending: Promise<unknown> | undefined;
|
||||
await browser.root(async (ctx: Ctx) => {
|
||||
pending = ctx.get(Slow, {});
|
||||
pending.catch(() => {});
|
||||
});
|
||||
await assert.rejects(pending!, (error) => isChordError(error, "aborted"));
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
open();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a get times out after readyTimeout with ready_timeout and releases its hold", async () => {
|
||||
const { server, browser, link } = setup(30);
|
||||
await assert.rejects(
|
||||
browser.get(Slow, {}),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "ready_timeout",
|
||||
);
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("ctx.get resolves hydrated; the call's reference is released when the call ends", async () => {
|
||||
const { server, browser, link, open } = setup();
|
||||
open();
|
||||
const value = await browser.root(async (ctx: Ctx) => {
|
||||
const slow = await ctx.get(Slow, {});
|
||||
return slow.n.value;
|
||||
});
|
||||
assert.equal(value, 1);
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
459
third_party/pi/packages/chord/test/bindings.test.ts
vendored
Normal file
459
third_party/pi/packages/chord/test/bindings.test.ts
vendored
Normal file
@@ -0,0 +1,459 @@
|
||||
// Entry 8: one shared binding per host and key; a hold implies the state of its variant (units: shape, then
|
||||
// snapshots); method-only references; origins of client holds; protocol version 4.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { HostImpl } from "../src/host.ts";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const NoteSchema = Type.Array(Type.String());
|
||||
const Notes = remote("t.e8-notes", {
|
||||
version: 1,
|
||||
input: Type.Object({ list: Type.Optional(Type.String({ default: "main" })) }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: NoteSchema, title: Type.String() },
|
||||
methods: { add: method(Type.String()), rename: method(Type.String()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function serveNotes(host: Host): void {
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NoteSchema, []);
|
||||
const title = self.state("title", Type.String(), "untitled");
|
||||
return {
|
||||
variant: "main",
|
||||
notes,
|
||||
title,
|
||||
add: (text: string, ctx: Ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(text);
|
||||
});
|
||||
},
|
||||
rename: (text: string, ctx: Ctx) => {
|
||||
title.replace(ctx, text);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A server serving `Notes`, and a browser whose end of the link counts what crosses it. */
|
||||
function setup(
|
||||
options: { readonly window?: number; readonly linger?: { types?: Record<string, number>; cap?: number } } = {},
|
||||
) {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveNotes(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, {
|
||||
principal: { user: "u" },
|
||||
...(options.window === undefined ? {} : { window: options.window }),
|
||||
});
|
||||
const link = new CountingLink(up);
|
||||
const errors: unknown[] = [];
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: (error) => errors.push(error),
|
||||
...(options.linger === undefined ? {} : { linger: options.linger }),
|
||||
});
|
||||
return { server, browser, link, errors };
|
||||
}
|
||||
|
||||
describe("shared bindings: traffic", () => {
|
||||
it("N references to one object: one hold, one snapshot per member, one frame per change, one release", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
const Panel = local("t.e8-panel", { version: 1, input: Type.Object({}), members: { count: Type.Number() } });
|
||||
browser.serve(Panel, (_input, _principal, self) => {
|
||||
const notes = self.get(Notes, {});
|
||||
return { count: self.derive([notes.notes], (list) => list.length) };
|
||||
});
|
||||
const a = browser.hold(Notes, {});
|
||||
const b = browser.hold(Notes, { list: "main" });
|
||||
const panel = browser.hold(Panel, {});
|
||||
await Promise.all([a.ready, b.ready, panel.ready]);
|
||||
const viaCtx = await browser.root(async (ctx) => {
|
||||
const ref = await ctx.get(Notes, {});
|
||||
return ref.title.value;
|
||||
});
|
||||
assert.equal(viaCtx, "untitled");
|
||||
assert.equal(link.count("sent", "hold"), 1, "one hold for every reference");
|
||||
const snaps = stateFrames(link.received).filter((frame) => frame.as === "snap");
|
||||
assert.deepEqual(snaps.map((frame) => frame.m).sort(), ["notes", "title"]);
|
||||
|
||||
const before = stateFrames(link.received).length;
|
||||
await a.add("milk");
|
||||
await until(() => a.notes.value.length === 1 && b.notes.value.length === 1 && panel.count.value === 1);
|
||||
await tick();
|
||||
assert.equal(stateFrames(link.received).length - before, 1, "one delta frame for one change, whatever N");
|
||||
|
||||
const sent = link.traffic("sent").length;
|
||||
a.release();
|
||||
panel.release();
|
||||
await sleep(5);
|
||||
assert.equal(link.traffic("sent").length, sent, "releasing all but one reference sends nothing");
|
||||
b.release();
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a held reference stays subscribed without listeners; values are readable at once after ready", async () => {
|
||||
const { server, browser } = setup();
|
||||
const notes = browser.hold(Notes, {});
|
||||
await notes.ready;
|
||||
assert.deepEqual(notes.notes.value, []);
|
||||
await notes.add("bread");
|
||||
await until(() => notes.notes.value.length === 1);
|
||||
assert.equal(notes.title.value, "untitled");
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared bindings: linger", () => {
|
||||
it("a lingering binding keeps its hold and state; a reference taken meanwhile costs nothing on the link", async () => {
|
||||
const { server, browser, link } = setup({ linger: { types: { [Notes.name]: 40 } } });
|
||||
const first = await browser.get(Notes, {});
|
||||
const frames = link.traffic("received").length;
|
||||
first.release();
|
||||
await sleep(10);
|
||||
assert.equal(link.count("sent", "release"), 0, "not released before the linger");
|
||||
// A hook mounting and unmounting: no frames either way.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const again = await browser.get(Notes, {});
|
||||
assert.deepEqual(again.notes.value, []);
|
||||
again.release();
|
||||
}
|
||||
assert.equal(link.count("sent", "hold"), 1);
|
||||
assert.equal(link.traffic("received").length, frames, "nothing new crossed the link");
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("at most `cap` bindings linger: the least recently released goes first", async () => {
|
||||
const { server, browser, link } = setup({ linger: { types: { [Notes.name]: 10_000 }, cap: 1 } });
|
||||
const a = await browser.get(Notes, { list: "a" });
|
||||
const b = await browser.get(Notes, { list: "b" });
|
||||
a.release();
|
||||
await sleep(5);
|
||||
assert.equal(link.count("sent", "release"), 0);
|
||||
b.release();
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
const release = link.sent.find((frame) => frame.t === "release");
|
||||
const holdOfA = link.sent.find((frame) => frame.t === "hold" && (frame.input as { list: string }).list === "a");
|
||||
assert.equal(release?.t === "release" ? release.id : 0, holdOfA?.t === "hold" ? holdOfA.id : -1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared bindings: identity", () => {
|
||||
it("normalized-equal inputs and equal principal keys share; other principals and fingerprints do not", async () => {
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
});
|
||||
const NotesV2 = remote("t.e8-notes", {
|
||||
version: 2,
|
||||
input: Type.Object({ list: Type.Optional(Type.String({ default: "main" })) }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: NoteSchema, title: Type.String() },
|
||||
methods: { add: method(Type.String()), rename: method(Type.String()), clear: method() },
|
||||
},
|
||||
},
|
||||
});
|
||||
server.serve(
|
||||
NotesV2,
|
||||
(_input, _principal, self) => {
|
||||
const notes = self.state("notes", NoteSchema, []);
|
||||
const title = self.state("title", Type.String(), "untitled");
|
||||
return { variant: "main", notes, title, add: () => {}, rename: () => {}, clear: () => {} };
|
||||
},
|
||||
{ accepts: { 1: (current) => current } },
|
||||
);
|
||||
// A worker holds the server's objects; the principals it forwards key its bindings.
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:w" });
|
||||
const link = new CountingLink(up);
|
||||
const worker = createHost<unknown>({
|
||||
self: { kind: "host" },
|
||||
name: "session:w",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const refs = [
|
||||
worker.hold(Notes, {}),
|
||||
worker.hold(Notes, { list: "main" }),
|
||||
worker.hold(Notes, {}, { principal: { user: "a", grant: 1 } }),
|
||||
worker.hold(Notes, {}, { principal: { grant: 1, user: "a" } }),
|
||||
worker.hold(Notes, {}, { principal: { user: "a", grant: 2 } }),
|
||||
worker.hold(NotesV2, {}),
|
||||
];
|
||||
await Promise.all(refs.map((ref) => ref.ready));
|
||||
// host (shared by two inputs), user a grant 1 (shared by two key orders), user a grant 2, the v2 contract.
|
||||
assert.equal(link.count("sent", "hold"), 4);
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydration over a link", () => {
|
||||
const Matrix = remote("t.e8-matrix", {
|
||||
version: 1,
|
||||
input: Type.Object({ variant: Type.String() }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
values: { state: { none: Type.Null(), empty: Type.Array(Type.String()), maybe: Type.Unknown() } },
|
||||
bare: { methods: { ping: method(undefined, Type.String()) } },
|
||||
broken: { state: { n: Type.Number() } },
|
||||
},
|
||||
});
|
||||
|
||||
function serveMatrix(host: Host): void {
|
||||
host.serve(Matrix, ({ variant }, _principal, self) => {
|
||||
if (variant === "bare") return { variant: "bare", ping: () => "pong" };
|
||||
if (variant === "broken") {
|
||||
const source = self.state("source", Type.String(), "not a number");
|
||||
// A derivation exposed under a number schema: its snapshot fails validation.
|
||||
return { variant: "broken", n: self.derive([source], (text) => text as unknown as number) };
|
||||
}
|
||||
const none = self.state("none", Type.Null(), null);
|
||||
const empty = self.state("empty", Type.Array(Type.String()), []);
|
||||
return { variant: "values", none, empty, maybe: self.derive([none], () => undefined as unknown) };
|
||||
});
|
||||
}
|
||||
|
||||
it("null, empty and undefined values hydrate; a state-free variant is ready; a failed snapshot rejects", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveMatrix(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const values = await browser.get(Matrix, { variant: "values" });
|
||||
assert.ok(values.variant === "values");
|
||||
assert.equal(values.none.value, null);
|
||||
assert.deepEqual(values.empty.value, []);
|
||||
assert.equal(values.maybe.value, undefined);
|
||||
const bare = await browser.get(Matrix, { variant: "bare" });
|
||||
assert.ok(bare.variant === "bare");
|
||||
assert.equal(await bare.ping(), "pong");
|
||||
assert.throws(
|
||||
// @ts-expect-error `none` is not a member of the bare variant (a narrowed reference does not show it).
|
||||
() => bare.none.value,
|
||||
(error) => isChordError(error, "not_in_variant"),
|
||||
);
|
||||
const broken = browser.hold(Matrix, { variant: "broken" });
|
||||
await assert.rejects(broken.ready, (error) => isChordError(error, "invalid"));
|
||||
assert.equal(broken.status, "gone");
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("reading before the first hydration throws not_ready", async () => {
|
||||
const { server, browser } = setup();
|
||||
const notes = browser.hold(Notes, {});
|
||||
assert.throws(
|
||||
() => notes.notes.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
await notes.ready;
|
||||
assert.deepEqual(notes.notes.value, []);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("method-only references (state: false)", () => {
|
||||
it("transfer no state alone; a full reference joining starts it once; its release stops it", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
const caller = browser.hold(Notes, {}, { state: false });
|
||||
await caller.ready;
|
||||
await caller.add("x");
|
||||
const hold = link.sent.find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.state === false);
|
||||
assert.deepEqual(stateFrames(link.received), []);
|
||||
assert.throws(
|
||||
() => caller.notes.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
|
||||
const viewer = browser.hold(Notes, {});
|
||||
await viewer.ready;
|
||||
assert.deepEqual(viewer.notes.value, ["x"]);
|
||||
assert.equal(link.count("sent", "sub"), 1);
|
||||
assert.equal(link.count("sent", "hold"), 1, "the same hold");
|
||||
viewer.release();
|
||||
await until(() => link.count("sent", "unsub") === 1);
|
||||
const after = stateFrames(link.received).length;
|
||||
await caller.add("y");
|
||||
await sleep(5);
|
||||
assert.equal(stateFrames(link.received).length, after, "no state once only method-only references remain");
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("origins of client holds", () => {
|
||||
const Counter = remote("t.e8-counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String() }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { state: { n: Type.Number() }, methods: { add: method(Type.Number()) } } },
|
||||
});
|
||||
|
||||
function cluster() {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
let worker: HostImpl | undefined;
|
||||
let toWorker: CountingLink | undefined;
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
if (toWorker !== undefined) return toWorker;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const created = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
created.serve(Counter, (_input, _principal, self) => {
|
||||
const n = self.state("n", Type.Number(), 0);
|
||||
return {
|
||||
variant: "main",
|
||||
n,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
n.replace(ctx, n.value + delta);
|
||||
},
|
||||
};
|
||||
});
|
||||
worker = created;
|
||||
toWorker = new CountingLink(down);
|
||||
return toWorker;
|
||||
},
|
||||
});
|
||||
const browser = () => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, up };
|
||||
};
|
||||
const origins = () =>
|
||||
[...(worker?.upstream?.served.values() ?? [])].map((served) =>
|
||||
served.kind === "local" ? served.origin : undefined,
|
||||
);
|
||||
return { server, browser, origins, sentToWorker: () => toWorker?.sent ?? [] };
|
||||
}
|
||||
|
||||
it("a relayed hold carries its client connection's id; the server's own holds carry none", async () => {
|
||||
const c = cluster();
|
||||
const one = c.browser();
|
||||
const two = c.browser();
|
||||
await one.host.hold(Counter, { session: "S1" }).ready;
|
||||
await two.host.hold(Counter, { session: "S1" }).ready;
|
||||
const own = c.server.hold(Counter, { session: "S1" });
|
||||
await own.ready;
|
||||
const holds = c.sentToWorker().filter((frame): frame is Extract<Frame, { t: "hold" }> => frame.t === "hold");
|
||||
assert.equal(holds.length, 3);
|
||||
const relayed = holds.filter((frame) => frame.origin !== undefined);
|
||||
assert.equal(relayed.length, 2);
|
||||
assert.notEqual(relayed[0]!.origin, relayed[1]!.origin, "two tabs, two origins");
|
||||
assert.equal(holds.filter((frame) => frame.origin === undefined).length, 1);
|
||||
|
||||
// Disconnect is departure: the worker loses that origin at once; a reconnect holds under a new id.
|
||||
const before = new Set(c.origins());
|
||||
one.up.close();
|
||||
await until(() => c.origins().filter((origin) => origin !== undefined).length === 1);
|
||||
const three = c.browser();
|
||||
await three.host.hold(Counter, { session: "S1" }).ready;
|
||||
const after = c.origins().filter((origin) => origin !== undefined);
|
||||
assert.equal(after.length, 2);
|
||||
assert.ok(
|
||||
after.some((origin) => !before.has(origin)),
|
||||
"a new connection gets a new id",
|
||||
);
|
||||
await c.server.close();
|
||||
});
|
||||
|
||||
it("an origin a client sends is ignored: the server stamps its own", async () => {
|
||||
const c = cluster();
|
||||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({
|
||||
t: "hold",
|
||||
id: 1,
|
||||
at: "session:S1",
|
||||
token: Counter.description,
|
||||
input: { session: "S1" },
|
||||
origin: "spoofed",
|
||||
});
|
||||
await until(() => c.sentToWorker().some((frame) => frame.t === "hold"));
|
||||
const hold = c.sentToWorker().find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.origin !== undefined && hold.origin !== "spoofed");
|
||||
await c.server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol version 4", () => {
|
||||
it("a version 3 peer is refused; shapes carry no code version", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
await browser.hold(Notes, {}).ready;
|
||||
const shape = link.received.find((frame) => frame.t === "shape");
|
||||
assert.ok(shape !== undefined && !("codeVersion" in shape));
|
||||
assert.deepEqual(shape.t === "shape" ? [...shape.state].sort() : [], ["notes", "title"]);
|
||||
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "old" } });
|
||||
const closed = new Promise<string>((resolve) => (raw as Link).onClose(resolve));
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", protocol: 3, name: "old", kind: "client", codeVersion: 0 });
|
||||
assert.equal(await closed, `protocol-version ${PROTOCOL_VERSION}`);
|
||||
assert.equal(PROTOCOL_VERSION, 4);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
259
third_party/pi/packages/chord/test/bundle.test.ts
vendored
259
third_party/pi/packages/chord/test/bundle.test.ts
vendored
@@ -1,259 +0,0 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { bundleFacetPackage, bundleFacets } from "../src/bundler.ts";
|
||||
import { createFacetHost, defineFacet, defineService } from "../src/index.ts";
|
||||
import {
|
||||
createFacetBundleArtifactLoader,
|
||||
createFacetBundleLoader,
|
||||
readFacetBundleArtifact,
|
||||
readFacetBundleManifest,
|
||||
} from "../src/node.ts";
|
||||
|
||||
interface GenerationValue {
|
||||
read(): string;
|
||||
}
|
||||
|
||||
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const temporaryDirectories: string[] = [];
|
||||
const GenerationValue = defineService<GenerationValue>("test.bundle.generation", { local: true });
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
describe("facet bundles", () => {
|
||||
test("builds independent content-addressed entries and loads fresh reloadable generations", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sourceDirectory = join(directory, "src");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await mkdir(sourceDirectory);
|
||||
await writeFile(
|
||||
join(sourceDirectory, "helper.ts"),
|
||||
'export const decorate = (value: string): string => "generation:" + value;\n',
|
||||
);
|
||||
const entryPath = join(sourceDirectory, "entry.ts");
|
||||
const presentationPath = join(sourceDirectory, "presentation.ts");
|
||||
await writeGeneration(entryPath, "A");
|
||||
await writeFile(presentationPath, 'export default { id: "bundle-presentation", setup() {} };\n');
|
||||
const facetEntries = { presentation: presentationPath, worker: entryPath };
|
||||
|
||||
const firstBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "1" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
const firstEntry = firstBuild.manifest.entries.worker!;
|
||||
expect(firstEntry.file).toMatch(/^facet-[a-f0-9]{12}-[A-Z0-9]+\.cjs$/u);
|
||||
expect(firstEntry.sourceMap).toBe(`${firstEntry.file}.map`);
|
||||
expect(firstEntry.externalImports).toEqual(["@earendil-works/chord"]);
|
||||
expect(firstBuild.manifest.entries.presentation!.file).not.toBe(firstEntry.file);
|
||||
expect((await readdir(outputDirectory)).filter((path) => path.endsWith(".cjs"))).toHaveLength(2);
|
||||
const firstSource = await readFile(join(outputDirectory, firstEntry.file), "utf8");
|
||||
expect(firstSource).toContain('require("@earendil-works/chord")');
|
||||
expect(firstSource).toContain("module.exports");
|
||||
expect((await readFile(firstBuild.manifestPath, "utf8")).endsWith("\n")).toBe(true);
|
||||
|
||||
const presentation = await createFacetBundleLoader({
|
||||
manifestPath: firstBuild.manifestPath,
|
||||
entry: "presentation",
|
||||
}).load();
|
||||
expect(presentation.facets.map(({ id }) => id)).toEqual(["bundle-presentation"]);
|
||||
await presentation.dispose();
|
||||
|
||||
const artifact = await readFacetBundleArtifact({
|
||||
manifestPath: firstBuild.manifestPath,
|
||||
entry: "worker",
|
||||
});
|
||||
const materializedDirectory = join(directory, "materialized");
|
||||
const transported = await createFacetBundleArtifactLoader({
|
||||
artifact: structuredClone(artifact),
|
||||
temporaryDirectory: materializedDirectory,
|
||||
}).load();
|
||||
expect(transported.facets.map(({ id }) => id)).toEqual(["bundle-provider"]);
|
||||
expect(await readdir(materializedDirectory)).toHaveLength(1);
|
||||
await transported.dispose();
|
||||
expect(await readdir(materializedDirectory)).toEqual([]);
|
||||
|
||||
const secondBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "1" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
expect(secondBuild.manifest.entries.worker).toEqual(firstEntry);
|
||||
|
||||
const loader = createFacetBundleLoader({ manifestPath: secondBuild.manifestPath, entry: "worker" });
|
||||
const loadedA = await loader.load();
|
||||
const loadedACopy = await loader.load();
|
||||
expect(loadedACopy.facets[0]).not.toBe(loadedA.facets[0]);
|
||||
await loadedACopy.dispose();
|
||||
|
||||
let retained: GenerationValue | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "bundle-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(GenerationValue);
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
|
||||
expect(retained!.read()).toBe("generation:A");
|
||||
|
||||
await writeGeneration(entryPath, "B");
|
||||
const thirdBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "2" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
expect(thirdBuild.manifest.entries.worker!.file).not.toBe(firstEntry.file);
|
||||
const loadedB = await loader.load();
|
||||
await host.reload(loadedB.facets);
|
||||
await loadedA.dispose();
|
||||
expect(retained!.read()).toBe("generation:B");
|
||||
|
||||
await host.dispose();
|
||||
await loadedB.dispose();
|
||||
});
|
||||
|
||||
test("loads host externals through the controlled CommonJS require", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-external-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const externalPath = join(directory, "host.mjs");
|
||||
const entryPath = join(directory, "entry.ts");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await writeFile(externalPath, 'export const named = "host-named";\n');
|
||||
await writeFile(
|
||||
entryPath,
|
||||
'import { named } from "@example/host";\n' +
|
||||
'export const loadDynamic = () => import("@example/dynamic");\n' +
|
||||
'if (named !== "host-named") throw new Error("external mismatch");\n' +
|
||||
'export default { id: "external-facet", setup() {} };\n',
|
||||
);
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: "external-bundle" },
|
||||
entries: { worker: entryPath },
|
||||
external: ["@example/host", "@example/dynamic"],
|
||||
outdir: outputDirectory,
|
||||
});
|
||||
const entry = result.manifest.entries.worker!;
|
||||
const source = await readFile(join(outputDirectory, entry.file), "utf8");
|
||||
expect(source).not.toContain("import(");
|
||||
expect(source).toContain('require("@example/dynamic")');
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: result.manifestPath,
|
||||
entry: "worker",
|
||||
resolveExternal: (specifier) =>
|
||||
specifier === "@example/host" || specifier === "@example/dynamic" ? pathToFileURL(externalPath) : undefined,
|
||||
}).load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["external-facet"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
|
||||
test("builds plugin packages from conventional and configured facet entries", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sourceDirectory = join(directory, "src");
|
||||
await mkdir(sourceDirectory);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@example/conventional-plugin",
|
||||
version: "1.2.3",
|
||||
peerDependencies: { "@example/host": "^1.0.0" },
|
||||
})}\n`,
|
||||
),
|
||||
writeFile(
|
||||
join(sourceDirectory, "session.ts"),
|
||||
'import "@example/host/plugin"; export default { id: "package-session", setup() {} };\n',
|
||||
),
|
||||
writeFile(join(sourceDirectory, "tui.ts"), 'export default { id: "package-tui", setup() {} };\n'),
|
||||
writeFile(join(sourceDirectory, "contract.ts"), "export const ignored = true;\n"),
|
||||
writeFile(join(sourceDirectory, "presentation.ts"), 'export default { id: "configured-tui", setup() {} };\n'),
|
||||
]);
|
||||
|
||||
const conventional = await bundleFacetPackage({
|
||||
packagePath: directory,
|
||||
outdir: join(directory, "build"),
|
||||
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts", browser: "src/browser.ts" },
|
||||
});
|
||||
expect(conventional.packageDirectory).toBe(directory);
|
||||
expect(conventional.manifest.plugin).toEqual({ id: "@example/conventional-plugin", version: "1.2.3" });
|
||||
expect(Object.keys(conventional.manifest.entries)).toEqual(["session", "tui"]);
|
||||
expect(conventional.manifest.entries.session?.externalImports).toEqual(["@example/host/plugin"]);
|
||||
expect(conventional.manifest.entries.tui?.sourceMap).toMatch(/\.cjs\.map$/u);
|
||||
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@example/conventional-plugin",
|
||||
version: "2.0.0",
|
||||
chord: { facets: { session: false, tui: "src/presentation.ts" }, sourceMap: false },
|
||||
})}\n`,
|
||||
);
|
||||
const configured = await bundleFacetPackage({
|
||||
packagePath: join(directory, "package.json"),
|
||||
outdir: join(directory, "build"),
|
||||
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts" },
|
||||
});
|
||||
expect(Object.keys(configured.manifest.entries)).toEqual(["tui"]);
|
||||
expect(configured.manifest.entries.tui?.sourceMap).toBeUndefined();
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: configured.manifestPath,
|
||||
entry: "tui",
|
||||
}).load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["configured-tui"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
|
||||
test("rejects invalid plugin package entry configuration", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "invalid-plugin",
|
||||
version: "1.0.0",
|
||||
chord: { facets: { tui: "../outside.ts" } },
|
||||
})}\n`,
|
||||
);
|
||||
await expect(bundleFacetPackage({ packagePath: directory, outdir: join(directory, "build") })).rejects.toThrow(
|
||||
"escapes the package directory",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects corrupt entries and invalid module exports", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const entryPath = join(directory, "entry.ts");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await writeFile(entryPath, "export default { id: 'missing-setup' };\n");
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: "invalid-bundle" },
|
||||
entries: { invalid: entryPath },
|
||||
outdir: outputDirectory,
|
||||
});
|
||||
const loader = createFacetBundleLoader({ manifestPath: result.manifestPath, entry: "invalid" });
|
||||
await expect(loader.load()).rejects.toThrow("has no setup function");
|
||||
|
||||
const manifest = await readFacetBundleManifest(result.manifestPath);
|
||||
await writeFile(join(outputDirectory, manifest.entries.invalid!.file), "export default {};\n");
|
||||
await expect(loader.load()).rejects.toThrow("integrity check failed");
|
||||
});
|
||||
});
|
||||
|
||||
async function writeGeneration(path: string, generation: string): Promise<void> {
|
||||
await writeFile(
|
||||
path,
|
||||
`import "@earendil-works/chord";\n` +
|
||||
`import { decorate } from "./helper.ts";\n` +
|
||||
`const Value = { id: "test.bundle.generation", local: true };\n` +
|
||||
`export default { id: "bundle-provider", setup(env) {\n` +
|
||||
` env.provide(Value, { read() { return decorate(${JSON.stringify(generation)}); } });\n` +
|
||||
`}};\n`,
|
||||
);
|
||||
}
|
||||
644
third_party/pi/packages/chord/test/cluster.test.ts
vendored
Normal file
644
third_party/pi/packages/chord/test/cluster.test.ts
vendored
Normal file
@@ -0,0 +1,644 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it, onTestFinished } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Facet,
|
||||
type FacetLoader,
|
||||
type Frame,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
type Self,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const Greeter = remote("t.greeter", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String() }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: Type.Object({ n: Type.Number() }) },
|
||||
methods: { greet: method(Type.String(), Type.String()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const GreeterV2 = remote("t.greeter", {
|
||||
version: 2,
|
||||
input: Type.Object({ session: Type.String() }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { methods: { hello: method(Type.String(), Type.String()) } } },
|
||||
});
|
||||
|
||||
/** The server's own greeter: the greeter facet serves this one on the server, `Greeter` on session hosts. */
|
||||
const ServerGreeter = remote("t.server-greeter", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: Type.Object({ n: Type.Number() }) },
|
||||
methods: { greet: method(Type.String(), Type.String()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function greeterFacet(greeting: string, options: { fail?: boolean; stateVersion?: number } = {}): Facet {
|
||||
return {
|
||||
name: "greeter",
|
||||
setup: (api) => {
|
||||
const greeter = (self: Self) => {
|
||||
if (options.fail) throw new Error("worker factory fails");
|
||||
const count = self.state(
|
||||
"count",
|
||||
Type.Object({ n: Type.Number() }),
|
||||
{ n: 0 },
|
||||
{ version: options.stateVersion ?? 1 },
|
||||
);
|
||||
return {
|
||||
variant: "main" as const,
|
||||
count,
|
||||
greet: (name: string, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += 1;
|
||||
});
|
||||
return `${greeting} ${name}`;
|
||||
},
|
||||
};
|
||||
};
|
||||
if (api.name === "server") api.serve(ServerGreeter, (_input, _principal, self) => greeter(self));
|
||||
else api.serve(Greeter, (_input, _principal, self) => greeter(self));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Server + worker over a memory link; the worker loads facets by name from `modules`. */
|
||||
/** `link`, except that frames `drop` selects are never sent (a frame lost when its link dies). */
|
||||
function dropping(link: Link, drop: (frame: Frame) => boolean): Link {
|
||||
return {
|
||||
get closed() {
|
||||
return link.closed;
|
||||
},
|
||||
send: (frame) => (drop(frame) ? undefined : link.send(frame)),
|
||||
receive: (handler) => link.receive(handler),
|
||||
onClose: (handler) => link.onClose(handler),
|
||||
close: (reason) => link.close(reason),
|
||||
};
|
||||
}
|
||||
|
||||
const Ping = remote("t.cluster-ping", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { ping: method(undefined, Type.String()) } } },
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
worker.install(modules.get("greeter")!);
|
||||
return { server, worker, modules, down };
|
||||
}
|
||||
|
||||
describe("cross-host reload", () => {
|
||||
it("prepares every host, commits the server first, then the workers; code versions stay in step", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v1 a");
|
||||
const go = latch();
|
||||
const pinned = server.root(async (ctx) => {
|
||||
await greeter.greet("p", ctx);
|
||||
await go.promise;
|
||||
return greeter.greet("p", ctx);
|
||||
});
|
||||
await tick();
|
||||
const shapes: string[] = [];
|
||||
greeter.onShape((shape) => shapes.push(shape.contract));
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const report = await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
// No waiting: the commit frame precedes this call on the FIFO link, so the worker has committed when it arrives.
|
||||
assert.equal(await greeter.greet("b"), "v2 b");
|
||||
assert.equal(server.codeVersion, report.codeVersion);
|
||||
assert.equal(worker.codeVersion, report.codeVersion);
|
||||
go.open();
|
||||
assert.equal(await pinned, "v1 p");
|
||||
assert.equal(shapes.length, 1, "one shape push at the commit");
|
||||
// The server retires the old code version once its last old root ended, then tells the worker.
|
||||
for (let i = 0; i < 20 && worker.previousCodeVersion !== undefined; i++) await tick();
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
assert.equal(worker.previousCodeVersion, undefined);
|
||||
});
|
||||
|
||||
it("a failing vote aborts every host; the error keeps its code and names the host", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
await greeter.greet("a");
|
||||
const before = [server.codeVersion, worker.codeVersion];
|
||||
modules.set("greeter", greeterFacet("v2", { fail: true }));
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] } }), (error) => {
|
||||
assert.ok(isChordError(error, "unavailable"));
|
||||
assert.match(error.message, /session:S1: .*worker factory fails/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([server.codeVersion, worker.codeVersion], before);
|
||||
assert.equal(await greeter.greet("x"), "v1 x");
|
||||
});
|
||||
|
||||
it("a worker gate blocked past its deadline aborts the whole reload", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
const go = latch();
|
||||
const blocker = server.root(async (ctx) => {
|
||||
await greeter.greet("g", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
const before = [server.codeVersion, worker.codeVersion];
|
||||
const bumped = greeterFacet("v2", { stateVersion: 2 });
|
||||
modules.set("greeter", bumped);
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] }, gateDeadline: 30 }), (error) => {
|
||||
assert.ok(isChordError(error, "aborted") && error.reason === "gate_deadline");
|
||||
assert.match(error.message, /session:S1: .*blocking roots: server#\w+:\d+/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([server.codeVersion, worker.codeVersion], before);
|
||||
go.open();
|
||||
await blocker;
|
||||
assert.equal(await greeter.greet("x"), "v1 x");
|
||||
});
|
||||
|
||||
it("removes facets from server and workers, reconciles removal tombstones, and clears them on re-add", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
onTestFinished(async () => {
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
await server.install(greeterFacet("v1"));
|
||||
await tick();
|
||||
const local = server.hold(ServerGreeter, {});
|
||||
const child = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await local.greet("a"), "v1 a");
|
||||
assert.equal(await child.greet("a"), "v1 a");
|
||||
await server.reload([], { remove: ["greeter"], remoteRemove: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && child.status !== "gone"; i++) await tick();
|
||||
assert.equal(local.status, "gone");
|
||||
assert.equal(child.status, "gone");
|
||||
|
||||
// A child starting with the old set must remove it without trying to import its removed module.
|
||||
const cold = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:cold",
|
||||
loader: async () => {
|
||||
assert.fail("Removed modules must not be imported");
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => cold.close());
|
||||
await cold.install(greeterFacet("v1"));
|
||||
const old = cold.hold(Greeter, { session: "cold" });
|
||||
await old.ready;
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "cold" }, kind: "host", name: "session:cold" });
|
||||
cold.connect(up, { principal: { kind: "server" } });
|
||||
for (let i = 0; i < 20 && old.status !== "gone"; i++) await tick();
|
||||
assert.equal(old.status, "gone");
|
||||
assert.equal(cold.codeVersion, server.codeVersion);
|
||||
// Close this loader-less child before the addition; it tested only the removal reconciliation.
|
||||
await cold.close();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([greeterFacet("v2")], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await server.hold(Greeter, { session: "S1" }).greet("b"), "v2 b");
|
||||
const fresh = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:fresh",
|
||||
loader: async (names) => names.map((name) => modules.get(String(name))!),
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => fresh.close());
|
||||
await fresh.install(greeterFacet("v1"));
|
||||
const [freshDown, freshUp] = memoryLinkPair();
|
||||
server.accept(freshDown, { principal: { worker: "fresh" }, kind: "host", name: "session:fresh" });
|
||||
fresh.connect(freshUp, { principal: { kind: "server" } });
|
||||
assert.equal(await server.hold(Greeter, { session: "fresh" }).greet("c"), "v2 c");
|
||||
});
|
||||
|
||||
it("only the server coordinates reloads", async () => {
|
||||
const { worker } = setup();
|
||||
await tick();
|
||||
await assert.rejects(worker.reload([]), (error) => isChordError(error, "unavailable"));
|
||||
});
|
||||
|
||||
it("browsers are not participants: they see shape and reset pushes, or upgrade_required", async () => {
|
||||
const { server, modules, down: workerLink } = setup();
|
||||
// The route resolves to the already connected worker.
|
||||
server.route("session:", {
|
||||
authorize: () => true,
|
||||
resolve: async () => workerLink,
|
||||
principal: (name) => ({ host: name }),
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const viewer = browser.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await viewer.greet("a"), "v1 a");
|
||||
const shapes: string[] = [];
|
||||
viewer.onShape((shape) => shapes.push(shape.contract));
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && shapes.length === 0; i++) await tick();
|
||||
assert.equal(shapes.length, 1, "one shape push at the commit");
|
||||
assert.equal(await viewer.greet("b"), "v2 b");
|
||||
|
||||
modules.set("greeter", {
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(GreeterV2, () => ({ variant: "main", hello: (name: string) => `hi ${name}` })),
|
||||
});
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && viewer.status !== "gone"; i++) await tick();
|
||||
assert.equal(viewer.status, "gone");
|
||||
assert.ok(isChordError(viewer.error, "upgrade_required"));
|
||||
});
|
||||
|
||||
it("an abort while a worker is still preparing: a late open closes itself and the worker can reload again", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
await greeter.greet("a");
|
||||
const log: string[] = [];
|
||||
const go = latch();
|
||||
const slow: Facet = {
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.resource("late", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
await go.promise;
|
||||
log.push("opened");
|
||||
},
|
||||
close: () => void log.push("closed"),
|
||||
});
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 });
|
||||
return { variant: "main", count, greet: (name: string) => `slow ${name}` };
|
||||
}),
|
||||
};
|
||||
modules.set("greeter", slow);
|
||||
const codeVersion = worker.codeVersion;
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] }, voteDeadline: 50 }), (error) =>
|
||||
isChordError(error, "aborted"),
|
||||
);
|
||||
go.open();
|
||||
for (let i = 0; i < 50 && !log.includes("closed"); i++) await tick();
|
||||
assert.deepEqual(log, ["opened", "closed"]);
|
||||
assert.equal(worker.codeVersion, codeVersion);
|
||||
modules.set("greeter", greeterFacet("v3"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await greeter.greet("b"), "v3 b");
|
||||
});
|
||||
|
||||
it("a child that says hello while a reload runs is reconciled after it, never left in step with the old code", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loading = latch();
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
loader: async (names) => {
|
||||
await loading.promise;
|
||||
return names.map((name) => ({ name: String(name), setup: () => {} }));
|
||||
},
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
const reloading = server.reload(["server-plugin"], { remote: { "session:": ["greeter"] } });
|
||||
// The worker connects while the reload loads its modules: it announces the server's current code version.
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader: async (names) => names.map((name) => modules.get(String(name))!),
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
loading.open();
|
||||
const report = await reloading;
|
||||
assert.deepEqual(Object.keys(report.children ?? {}), [], "it was not a participant");
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v2 a");
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("a child keeps the previous code version until its parent retired it (spec 12)", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v1 a");
|
||||
const first = server.codeVersion;
|
||||
const go = latch();
|
||||
// A server root of the first code version that calls the worker only after the next two reloads started.
|
||||
const pinned = server.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return greeter.greet("p", ctx);
|
||||
});
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(worker.previousCodeVersion, first);
|
||||
const second = server.reload([], { remote: { "session:": [] }, retireDeadline: 2000 });
|
||||
await tick();
|
||||
await tick();
|
||||
// The server still waits for its root, so the worker must not have retired that code version.
|
||||
assert.equal(worker.previousCodeVersion, first);
|
||||
go.open();
|
||||
assert.equal(await pinned, "v1 p");
|
||||
await second;
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
});
|
||||
|
||||
it("a child reconciled after its parent retired old code versions may retire its own previous one at once", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
onTestFinished(() => server.close());
|
||||
const plugin = (): Facet => ({ name: "server-plugin", setup: () => {} });
|
||||
server.install(plugin());
|
||||
await server.reload([plugin()]);
|
||||
await server.reload([plugin()]);
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader: async (names) => names.map((name) => modules.get(String(name))!),
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
for (let i = 0; i < 50 && worker.codeVersion !== server.codeVersion; i++) await tick();
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
// Without the parent's retire watermark it would keep code version 1 until the parent's next retirement.
|
||||
for (let i = 0; i < 20 && worker.previousCodeVersion !== undefined; i++) await tick();
|
||||
assert.equal(worker.previousCodeVersion, undefined);
|
||||
// And the next cluster reload does not wait for a retirement the parent will never announce.
|
||||
const started = Date.now();
|
||||
await server.reload([], { remote: { "session:": ["greeter"] }, retireDeadline: 10_000 });
|
||||
assert.ok(Date.now() - started < 2000);
|
||||
});
|
||||
|
||||
it("a reconciliation running when a reload starts syncs the child only after it, to the committed code version", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const serverLoading = latch();
|
||||
let blockServer = false;
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
loader: async (names) => {
|
||||
if (blockServer) await serverLoading.promise;
|
||||
return names.map((name) => ({ name: String(name), setup: () => {} }));
|
||||
},
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
server.install({ name: "server-plugin", setup: () => {} });
|
||||
await server.reload(["server-plugin"], { remote: { "session:": ["greeter"] } });
|
||||
// The worker connects behind: its reconciliation to code version 2 waits in the worker's module loading.
|
||||
const workerLoading = latch();
|
||||
let blockWorker = true;
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader: async (names) => {
|
||||
if (blockWorker) await workerLoading.promise;
|
||||
return names.map((name) => modules.get(String(name))!);
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
await tick();
|
||||
// A reload starts before the reconciliation committed; the worker is not one of its participants.
|
||||
blockServer = true;
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const reloading = server.reload(["server-plugin"], { remote: { "session:": ["greeter"] } });
|
||||
blockWorker = false;
|
||||
workerLoading.open();
|
||||
for (let i = 0; i < 50 && worker.codeVersion !== 2; i++) await tick();
|
||||
assert.equal(worker.codeVersion, 2);
|
||||
serverLoading.open();
|
||||
const report = await reloading;
|
||||
assert.deepEqual(Object.keys(report.children ?? {}), []);
|
||||
assert.equal(report.codeVersion, 3);
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v2 a");
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("a child behind whose hello waits for a running reload is fenced at once: its calls up are admitted (spec 9)", async () => {
|
||||
const pingFacet: Facet = {
|
||||
name: "ping",
|
||||
setup: (api) => api.serve(Ping, () => ({ variant: "main", ping: () => "pong" })),
|
||||
};
|
||||
const loading = latch();
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
loader: async (names) => {
|
||||
await loading.promise;
|
||||
return names.map((name) => ({ name: String(name), setup: () => {} }));
|
||||
},
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
server.install(pingFacet);
|
||||
await server.reload([pingFacet]);
|
||||
assert.equal(server.previousCodeVersion, undefined, "code version 1 is retired");
|
||||
const reloading = server.reload(["server-plugin"], { remote: { "session:": [] } });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
// While the server's reload still loads its modules, a worker root (code version 1) calls the server.
|
||||
const answer = await worker.root(async (ctx) => (await ctx.get(Ping, {})).ping(ctx));
|
||||
assert.equal(answer, "pong");
|
||||
loading.open();
|
||||
await reloading;
|
||||
for (let i = 0; i < 50 && worker.codeVersion !== server.codeVersion; i++) await tick();
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("a reconnecting child behind its parent, still retaining its previous code version, reconciles without waiting out the retire deadline", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
onTestFinished(() => server.close());
|
||||
const [down, up] = memoryLinkPair();
|
||||
// The `retire` frame is lost: the worker keeps code version 1, not permitted to retire it.
|
||||
server.accept(
|
||||
dropping(down, (frame) => frame.t === "retire"),
|
||||
{ principal: { worker: "w" }, kind: "host", name: "session:S1" },
|
||||
);
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await tick();
|
||||
assert.equal(worker.previousCodeVersion, 1);
|
||||
up.close("gone");
|
||||
await tick();
|
||||
// The server moves on while the worker is away, then the worker reconnects behind it.
|
||||
modules.set("greeter", greeterFacet("v3"));
|
||||
await server.reload([{ name: "server-plugin", setup: () => {} }]);
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const started = Date.now();
|
||||
worker.connect(up2, { principal: { kind: "server" } });
|
||||
for (let i = 0; i < 400 && worker.codeVersion !== server.codeVersion; i++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
assert.ok(Date.now() - started < 2000, `reconciled after ${Date.now() - started} ms`);
|
||||
});
|
||||
|
||||
it("forced retirement cancels a browser call relayed to a worker before the server closes the old code's resources", async () => {
|
||||
const log: string[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
onTestFinished(() => server.close());
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
const never = latch();
|
||||
const Slow = remote("t.cluster-slow", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { wait: method() } } },
|
||||
});
|
||||
worker.serve(Slow, () => ({
|
||||
variant: "main",
|
||||
wait: async (ctx: Ctx) => {
|
||||
ctx.signal.addEventListener("abort", () => log.push("worker aborted"));
|
||||
await never.promise;
|
||||
},
|
||||
}));
|
||||
// Records the cancel the server sends down: the guarantee is that it leaves before the old resources close.
|
||||
const watched = dropping(down, (frame) => {
|
||||
if (frame.t === "cancel") log.push("cancel sent");
|
||||
return false;
|
||||
});
|
||||
server.route("session:", {
|
||||
authorize: () => true,
|
||||
resolve: async () => watched,
|
||||
principal: (name) => ({ host: name }),
|
||||
});
|
||||
const Store = local("t.cluster-store", { version: 1, input: Type.Object({}) });
|
||||
const storeFacet = (version: number): Facet => ({
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Store,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("conn", {
|
||||
version,
|
||||
open: () => ({}),
|
||||
close: () => void log.push(`server closes ${version}`),
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
await server.install(storeFacet(1));
|
||||
const [browserDown, browserUp] = memoryLinkPair();
|
||||
server.accept(browserDown, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: browserUp, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
// The relayed call's root runs on the server's code version 1.
|
||||
const call = browser.hold(Slow, {}).wait();
|
||||
call.catch(() => {});
|
||||
for (let i = 0; i < 50 && log.length === 0 && !worker.codeVersion; i++) await tick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await server.reload([storeFacet(2)]);
|
||||
await server.reload([storeFacet(3)], { retireDeadline: 20 });
|
||||
const settled = await Promise.race([
|
||||
call.then(
|
||||
() => "resolved",
|
||||
() => "rejected",
|
||||
),
|
||||
new Promise((resolve) => setTimeout(() => resolve("pending"), 2000)),
|
||||
]);
|
||||
assert.equal(settled, "rejected", "the browser's call is cancelled with its root");
|
||||
for (let i = 0; i < 50 && !log.includes("worker aborted"); i++) await tick();
|
||||
assert.deepEqual(
|
||||
log.filter((entry) => entry === "cancel sent" || entry === "server closes 1"),
|
||||
["cancel sent", "server closes 1"],
|
||||
);
|
||||
assert.ok(log.includes("worker aborted"));
|
||||
never.open();
|
||||
});
|
||||
});
|
||||
138
third_party/pi/packages/chord/test/contract.test.ts
vendored
Normal file
138
third_party/pi/packages/chord/test/contract.test.ts
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { bind, coverage, hostFamily, hostNamed, isChordError, local, method, opaque, remote } from "../src/index.ts";
|
||||
import { canonicalIdentity, fits, normalize } from "../src/schema.ts";
|
||||
|
||||
const View = Type.Object({ text: Type.String() });
|
||||
|
||||
const chatV1 = remote("t.chat", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String(), conversation: Type.Number({ default: 0 }) }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: {
|
||||
viewer: { state: { view: View } },
|
||||
participant: {
|
||||
state: { view: View },
|
||||
methods: { send: method(Type.Object({ text: Type.String() }), Type.Object({ id: Type.Number() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("contracts", () => {
|
||||
it("normalizes input and omits defaults from the identity", () => {
|
||||
const a = normalize(chatV1.input, { session: "s", extra: 1 }, "input");
|
||||
assert.deepEqual(a, { session: "s", conversation: 0 });
|
||||
const b = normalize(chatV1.input, { conversation: 0, session: "s" }, "input");
|
||||
assert.equal(canonicalIdentity(chatV1.input, a), canonicalIdentity(chatV1.input, b));
|
||||
assert.equal(canonicalIdentity(chatV1.input, a), '{"session":"s"}');
|
||||
assert.throws(
|
||||
() => normalize(chatV1.input, { session: 1 }, "input"),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "input",
|
||||
);
|
||||
});
|
||||
|
||||
it("fingerprints are stable and description-based", () => {
|
||||
const copy = remote("t.chat", chatV1.def);
|
||||
assert.equal(copy.fingerprint, chatV1.fingerprint);
|
||||
assert.notEqual(remote("t.chat", { ...chatV1.def, version: 2 }).fingerprint, chatV1.fingerprint);
|
||||
});
|
||||
|
||||
it("rejects reserved member names and opaque remote members", () => {
|
||||
assert.throws(() => local("t.x", { version: 1, input: Type.Object({}), members: { status: method() } }));
|
||||
assert.throws(() =>
|
||||
remote("t.x", {
|
||||
host: hostNamed("server"),
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
variants: { a: { state: { h: opaque("x@1") as never } } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("covers additive changes and refuses breaking ones", () => {
|
||||
const additive = remote("t.chat", {
|
||||
...chatV1.def,
|
||||
variants: {
|
||||
...chatV1.def.variants,
|
||||
participant: {
|
||||
state: { view: Type.Object({ text: Type.String(), extra: Type.Number() }) },
|
||||
methods: {
|
||||
send: method(
|
||||
Type.Object({ text: Type.String(), urgent: Type.Optional(Type.Boolean()) }),
|
||||
Type.Object({ id: Type.Number(), at: Type.Number() }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(coverage(additive.description, chatV1.description), undefined);
|
||||
assert.equal(bind(additive, undefined, chatV1), undefined);
|
||||
|
||||
const required = remote("t.chat", {
|
||||
...chatV1.def,
|
||||
variants: {
|
||||
...chatV1.def.variants,
|
||||
participant: {
|
||||
state: { view: View },
|
||||
methods: {
|
||||
send: method(
|
||||
Type.Object({ text: Type.String(), urgent: Type.Boolean() }),
|
||||
Type.Object({ id: Type.Number() }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(coverage(required.description, chatV1.description) ?? "", /arguments/);
|
||||
assert.throws(
|
||||
() => bind(required, undefined, chatV1),
|
||||
(error) => isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
|
||||
const retyped = remote("t.chat", {
|
||||
...chatV1.def,
|
||||
variants: { ...chatV1.def.variants, viewer: { state: { view: Type.Object({ text: Type.Number() }) } } },
|
||||
});
|
||||
assert.match(coverage(retyped.description, chatV1.description) ?? "", /state/);
|
||||
});
|
||||
|
||||
it("methods without arguments or result: described without them; their presence must match, a void consumer accepts a result", () => {
|
||||
const of = (members: Parameters<typeof local>[1]["members"]) =>
|
||||
local("t.methods", { version: 1, input: Type.Object({}), members }).description;
|
||||
const none = of({ m: method() });
|
||||
assert.deepEqual(none.variants[""]?.m, { kind: "method" });
|
||||
const withArgs = of({ m: method(Type.String()) });
|
||||
const withResult = of({ m: method(undefined, Type.Number()) });
|
||||
assert.equal(coverage(none, none), undefined);
|
||||
assert.match(coverage(withArgs, none) ?? "", /arguments/, "the consumer sends none, the provider expects some");
|
||||
assert.match(coverage(none, withArgs) ?? "", /arguments/, "the consumer sends some, the provider takes none");
|
||||
assert.equal(coverage(withResult, none), undefined, "a void consumer ignores a result");
|
||||
assert.match(coverage(none, withResult) ?? "", /result/, "a consumer expecting a result needs one");
|
||||
});
|
||||
|
||||
it("binds other versions only through accepts adapters", () => {
|
||||
const v2 = remote("t.chat", { ...chatV1.def, version: 2, variants: { viewer: { state: { body: View } } } });
|
||||
assert.throws(
|
||||
() => bind(v2, undefined, chatV1),
|
||||
(error) => isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
const adapter = (current: Record<string, unknown>) => ({ variant: "viewer", view: current.body });
|
||||
assert.equal(bind(v2, { 1: adapter }, chatV1), adapter);
|
||||
});
|
||||
|
||||
it("compares opaque members by name@version and function members structurally", () => {
|
||||
const a = local("t.idx", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: {
|
||||
harness: opaque("h@1"),
|
||||
onChange: method(Type.Function([Type.String()], Type.Void())),
|
||||
},
|
||||
});
|
||||
const b = local("t.idx", { ...a.def, members: { ...a.def.members, harness: opaque("h@2") } });
|
||||
assert.match(coverage(b.description, a.description) ?? "", /opaque/);
|
||||
assert.ok(fits(Type.Function([Type.Unknown()], Type.Void()), Type.Function([Type.String()], Type.Void())));
|
||||
assert.ok(!fits(Type.Function([Type.String()], Type.Void()), Type.Function([Type.Unknown()], Type.Void())));
|
||||
});
|
||||
});
|
||||
311
third_party/pi/packages/chord/test/delta-apply-immutable.test.ts
vendored
Normal file
311
third_party/pi/packages/chord/test/delta-apply-immutable.test.ts
vendored
Normal file
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
apply,
|
||||
applyImmutable,
|
||||
applyImmutableBatches,
|
||||
type JsonValue,
|
||||
type Op,
|
||||
PathError,
|
||||
track,
|
||||
UnsafePathError,
|
||||
} from "../src/delta/index.ts";
|
||||
|
||||
function freezeDeep<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
for (const child of Object.values(value)) freezeDeep(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
describe("checked immutable operation application", () => {
|
||||
it("copies each touched container once while preserving input and payload ownership", () => {
|
||||
const shared = freezeDeep({ nested: { value: 1 } });
|
||||
const untouched = freezeDeep({ value: 9 });
|
||||
const rowPayload = freezeDeep({ id: 4, label: "placed" });
|
||||
const base = freezeDeep({
|
||||
text: "abcdef",
|
||||
stable: { value: 7 },
|
||||
branch: { value: 1 },
|
||||
copy: null as { value: number } | null,
|
||||
placed: null as typeof shared | null,
|
||||
untouched: null as typeof untouched | null,
|
||||
left: null as typeof shared | null,
|
||||
right: null as typeof shared | null,
|
||||
meta: { count: 0, obsolete: true },
|
||||
rows: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "two" },
|
||||
{ id: 3, label: "three" },
|
||||
],
|
||||
});
|
||||
const operations = freezeDeep([
|
||||
["t", ["text"], 2],
|
||||
["a", ["text"], "!"],
|
||||
["s", ["meta", "count"], 1],
|
||||
["s", ["meta", "count"], 2],
|
||||
["d", ["meta", "obsolete"]],
|
||||
["s", ["copy"], base.branch],
|
||||
["s", ["copy", "value"], 2],
|
||||
["s", ["placed"], shared],
|
||||
["s", ["placed", "nested", "value"], 2],
|
||||
["s", ["untouched"], untouched],
|
||||
["s", ["left"], shared],
|
||||
["s", ["right"], shared],
|
||||
["s", ["left", "nested", "value"], 3],
|
||||
["p", ["rows"], 1, 1, [rowPayload]],
|
||||
["s", ["rows", 1, "label"], "edited"],
|
||||
["m", ["rows"], [1, 0, 2]],
|
||||
["s", ["rows", 0, "label"], "moved"],
|
||||
] satisfies Op[]);
|
||||
|
||||
const result = applyImmutable(base, operations);
|
||||
const mutableResult = apply(clone(base), clone(operations));
|
||||
|
||||
expect(result).toEqual(mutableResult);
|
||||
expect(result.text).toBe("cdef!");
|
||||
expect(result.stable).toBe(base.stable);
|
||||
expect(result.untouched).toBe(untouched);
|
||||
expect(result.copy).not.toBe(base.branch);
|
||||
expect(result.copy).toEqual({ value: 2 });
|
||||
expect(result.placed).not.toBe(shared);
|
||||
expect(result.placed).toEqual({ nested: { value: 2 } });
|
||||
expect(result.left).not.toBe(shared);
|
||||
expect(result.right).toBe(shared);
|
||||
expect(result.rows[0]).not.toBe(rowPayload);
|
||||
expect(result.rows[0]).toEqual({ id: 4, label: "moved" });
|
||||
expect(base.branch.value).toBe(1);
|
||||
expect(shared.nested.value).toBe(1);
|
||||
expect(rowPayload.label).toBe("placed");
|
||||
});
|
||||
|
||||
it("protects root replacement payloads before later object and array edits", () => {
|
||||
const replacement = freezeDeep({ nested: { value: 1 }, values: [1, 2, 3] });
|
||||
const result = applyImmutableBatches<typeof replacement>(undefined, [
|
||||
[["r", replacement]],
|
||||
[["s", ["nested", "value"], 2]],
|
||||
[
|
||||
["p", ["values"], 1, 1, [4, 5]],
|
||||
["m", ["values"], [3, 0, 1, 2]],
|
||||
],
|
||||
]);
|
||||
expect(result).toEqual({ nested: { value: 2 }, values: [3, 1, 4, 5] });
|
||||
expect(replacement).toEqual({ nested: { value: 1 }, values: [1, 2, 3] });
|
||||
|
||||
const array = freezeDeep([1, 2, 3]);
|
||||
const arrayResult = applyImmutable<number[]>(array, [
|
||||
["p", [], 1, 1, [4, 5]],
|
||||
["m", [], [3, 0, 1, 2]],
|
||||
["d", [1]],
|
||||
]);
|
||||
expect(arrayResult).toEqual([3, 4, 5]);
|
||||
expect(array).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("shares one private copy-on-write scope across batch partitions", () => {
|
||||
const base = freezeDeep({
|
||||
text: "abcdef",
|
||||
meta: { count: 0 },
|
||||
values: [
|
||||
{ id: 1, value: 1 },
|
||||
{ id: 2, value: 2 },
|
||||
{ id: 3, value: 3 },
|
||||
],
|
||||
});
|
||||
const batches = [
|
||||
[
|
||||
["s", ["meta", "count"], 1],
|
||||
["p", ["values"], 1, 1, [{ id: 4, value: 4 }]],
|
||||
] satisfies Op[],
|
||||
[] satisfies Op[],
|
||||
[
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["s", ["values", 2, "value"], 40],
|
||||
] satisfies Op[],
|
||||
[
|
||||
["t", ["text"], 2],
|
||||
["a", ["text"], "!"],
|
||||
] satisfies Op[],
|
||||
];
|
||||
|
||||
const intermediate = applyImmutable(base, batches[0]);
|
||||
const intermediateSnapshot = clone(intermediate);
|
||||
let sequential = intermediate;
|
||||
for (let index = 1; index < batches.length; index++) sequential = applyImmutable(sequential, batches[index]!);
|
||||
const streamed = applyImmutableBatches(base, batches);
|
||||
const flattened = applyImmutable(base, batches.flat());
|
||||
|
||||
expect(streamed).toEqual(sequential);
|
||||
expect(streamed).toEqual(flattened);
|
||||
expect(intermediate).toEqual(intermediateSnapshot);
|
||||
expect(base.meta.count).toBe(0);
|
||||
expect(base.values[1]!.id).toBe(2);
|
||||
});
|
||||
|
||||
it("handles tracker-produced batches across arbitrary revision boundaries", () => {
|
||||
type Document = { text: string; values: { id: number; score: number }[]; revision: number };
|
||||
const initial: Document = {
|
||||
text: "start",
|
||||
values: Array.from({ length: 8 }, (_, id) => ({ id, score: 0 })),
|
||||
revision: 0,
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const batches: Array<readonly Op[]> = [];
|
||||
for (let revision = 1; revision <= 40; revision++) {
|
||||
const change = tracker.beginChange();
|
||||
change.state.revision = revision;
|
||||
change.state.text = `${change.state.text.slice(1)}${revision}`;
|
||||
switch (revision % 5) {
|
||||
case 0:
|
||||
change.state.values.reverse();
|
||||
break;
|
||||
case 1:
|
||||
change.state.values.push({ id: 100 + revision, score: revision });
|
||||
break;
|
||||
case 2:
|
||||
change.state.values.shift();
|
||||
break;
|
||||
case 3:
|
||||
change.state.values[revision % change.state.values.length]!.score = revision;
|
||||
break;
|
||||
default:
|
||||
change.state.values.splice(1, 1, { id: 200 + revision, score: revision });
|
||||
}
|
||||
const prepared = change.prepare();
|
||||
batches.push(prepared.ops);
|
||||
tracker.adopt(prepared);
|
||||
}
|
||||
|
||||
expect(applyImmutableBatches(initial, batches)).toEqual(tracker.value);
|
||||
});
|
||||
|
||||
it("does not expose partial application when validation or iteration fails", () => {
|
||||
const base = freezeDeep({ nested: { value: 1 } });
|
||||
const invalidBatches: readonly (readonly Op[])[] = [
|
||||
[["s", ["nested", "value"], 2]],
|
||||
[["s", ["constructor", "prototype", "polluted"], true]],
|
||||
];
|
||||
expect(() => applyImmutableBatches(base, invalidBatches)).toThrow();
|
||||
expect(base.nested.value).toBe(1);
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
|
||||
|
||||
function* throwingBatches(): Generator<readonly Op[]> {
|
||||
yield [["s", ["nested", "value"], 3]];
|
||||
throw new Error("revision stream failed");
|
||||
}
|
||||
expect(() => applyImmutableBatches(base, throwingBatches())).toThrow("revision stream failed");
|
||||
expect(base.nested.value).toBe(1);
|
||||
|
||||
let advancedPastInvalid = false;
|
||||
let iteratorClosed = false;
|
||||
function* invalidOperationBatches(): Generator<readonly Op[]> {
|
||||
try {
|
||||
yield [["s", ["nested", "value"], 4]];
|
||||
yield [["s", ["__proto__", "polluted"], true]];
|
||||
advancedPastInvalid = true;
|
||||
} finally {
|
||||
iteratorClosed = true;
|
||||
}
|
||||
}
|
||||
expect(() => applyImmutableBatches(base, invalidOperationBatches())).toThrow(UnsafePathError);
|
||||
expect(advancedPastInvalid).toBe(false);
|
||||
expect(iteratorClosed).toBe(true);
|
||||
expect(base.nested.value).toBe(1);
|
||||
|
||||
let reads = 0;
|
||||
const target = Object.defineProperty({}, "trap", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads++;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const malformed: Op[] = [["s", "bad-path", 1] as never];
|
||||
expect(() => applyImmutableBatches(target, [malformed])).toThrow(/path/);
|
||||
expect(reads).toBe(0);
|
||||
|
||||
expect(() => applyImmutable({ values: [] }, [["s", ["values", "missing", "value"], 1]])).toThrow(PathError);
|
||||
expect(() => applyImmutable({ values: [{}] }, [["s", ["values", "0", "value"], 1]])).toThrow(UnsafePathError);
|
||||
});
|
||||
|
||||
it("allows one immutable batch to fan out without mutating shared payloads", () => {
|
||||
const payload = freezeDeep({ nested: { value: 1 } });
|
||||
const operations: readonly Op[] = [
|
||||
["s", ["placed"], payload],
|
||||
["s", ["placed", "nested", "value"], 2],
|
||||
];
|
||||
const base = freezeDeep({ placed: null as typeof payload | null });
|
||||
const first = applyImmutable(base, operations);
|
||||
const second = applyImmutable(base, operations);
|
||||
expect(first).toEqual(second);
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.placed).not.toBe(second.placed);
|
||||
expect(payload.nested.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("immutable application complexity", () => {
|
||||
it("appends to an array not yet copied in one copy, sharing everything else", () => {
|
||||
const entries = Array.from({ length: 1_000 }, (_, index) => ({ index }));
|
||||
const base = freezeDeep({ log: { entries, meta: { count: 1_000 } }, other: { value: 1 } });
|
||||
const result = applyImmutable(base, [
|
||||
["p", ["log", "entries"], 1_000, 0, [{ index: 1_000 }]],
|
||||
["p", ["log", "entries"], 1_001, 0, [{ index: 1_001 }]],
|
||||
]);
|
||||
expect(result.log.entries).toHaveLength(1_002);
|
||||
expect(result.log.entries.slice(-2)).toEqual([{ index: 1_000 }, { index: 1_001 }]);
|
||||
expect(base.log.entries).toHaveLength(1_000);
|
||||
expect(result.log.entries[0]).toBe(entries[0]);
|
||||
expect(result.log.meta).toBe(base.log.meta);
|
||||
expect(result.other).toBe(base.other);
|
||||
expect(result.log).not.toBe(base.log);
|
||||
|
||||
const rootArray = freezeDeep([1, 2]);
|
||||
expect(applyImmutable(rootArray, [["p", [], 2, 0, [3]]])).toEqual([1, 2, 3]);
|
||||
expect(rootArray).toEqual([1, 2]);
|
||||
// An appended item that is itself an array stays one element.
|
||||
expect(applyImmutable(rootArray, [["p", [], 2, 0, [[3, 4]]]])).toEqual([1, 2, [3, 4]]);
|
||||
// Invalid paths fail with the general path's errors.
|
||||
const invalid: [Op, typeof PathError | typeof UnsafePathError][] = [
|
||||
[["p", ["values", "missing"], 0, 0, []], PathError],
|
||||
[["p", ["missing"], 0, 0, []], PathError],
|
||||
[["p", ["values", 0], 0, 0, []], PathError],
|
||||
[["p", ["__proto__"], 0, 0, []], UnsafePathError],
|
||||
[["p", ["list", "x"], 0, 0, []], PathError],
|
||||
[["p", ["text"], 0, 0, []], PathError],
|
||||
[["p", ["list", -1], 0, 0, []], UnsafePathError],
|
||||
[["p", ["list", 1.5], 0, 0, []], UnsafePathError],
|
||||
[["p", ["list", 0, "constructor"], 0, 0, []], UnsafePathError],
|
||||
];
|
||||
const document = freezeDeep({ values: [] as JsonValue[], list: [[1]], text: "a" });
|
||||
for (const [op, error] of invalid)
|
||||
expect(() => applyImmutable(document, [op]), JSON.stringify(op)).toThrow(error);
|
||||
// An insert that is not at the end takes the general path.
|
||||
expect(applyImmutable(rootArray, [["p", [], 1, 0, [9]]])).toEqual([1, 9, 2]);
|
||||
});
|
||||
|
||||
it("copies a wide object once rather than once per repeated write", () => {
|
||||
const width = 20_000;
|
||||
const base: Record<string, JsonValue> = {};
|
||||
for (let index = 0; index < width; index++) base[`field${index}`] = index;
|
||||
const operations: Op[] = Array.from({ length: 1_000 }, (_, index): Op => ["s", [`field${index}`], -index]);
|
||||
const originalKeys = Object.keys;
|
||||
let shallowCopies = 0;
|
||||
Object.keys = ((value: object): string[] => {
|
||||
shallowCopies++;
|
||||
return originalKeys(value);
|
||||
}) as typeof Object.keys;
|
||||
let result: Record<string, JsonValue>;
|
||||
try {
|
||||
result = applyImmutable(base, operations);
|
||||
} finally {
|
||||
Object.keys = originalKeys;
|
||||
}
|
||||
|
||||
expect(result.field999).toBe(-999);
|
||||
expect(base.field999).toBe(999);
|
||||
expect(shallowCopies).toBe(1);
|
||||
});
|
||||
});
|
||||
210
third_party/pi/packages/chord/test/delta-benchmark/benchmark.ts
vendored
Normal file
210
third_party/pi/packages/chord/test/delta-benchmark/benchmark.ts
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario =
|
||||
| "import"
|
||||
| "committed-read"
|
||||
| "draft-read"
|
||||
| "sparse"
|
||||
| "queue"
|
||||
| "sort"
|
||||
| "dense"
|
||||
| "unshift"
|
||||
| "watch-authority-control"
|
||||
| "watch-live-borrowed"
|
||||
| "watch-acquire-shared"
|
||||
| "watch-acquire-detached"
|
||||
| "watch-acquire-staggered"
|
||||
| "watch-lag"
|
||||
| "watch-catch-up"
|
||||
| "watch-catch-up-coalesced"
|
||||
| "watch-live-immutable";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
size: number;
|
||||
trial: number;
|
||||
queueOperations: number;
|
||||
watchProfile: string | null;
|
||||
watchCount: number;
|
||||
watchUpdates: number;
|
||||
queuedBatchReferences: number;
|
||||
importMs: number;
|
||||
mutateMs: number;
|
||||
prepareMs: number;
|
||||
serializeMs: number;
|
||||
adoptMs: number;
|
||||
acquireMs: number;
|
||||
enqueueMs: number;
|
||||
deliveryMs: number;
|
||||
scenarioWorkMs: number;
|
||||
opBytes: number;
|
||||
operationCount: number;
|
||||
checksum: number;
|
||||
readyMiB: number;
|
||||
sampledAcquireMiB: number;
|
||||
sampledTransientMiB: number;
|
||||
retainedMiB: number;
|
||||
retainedOverReadyMiB: number;
|
||||
postAcquireRetainedMiB: number;
|
||||
postLagRetainedMiB: number;
|
||||
postDeliveryRetainedMiB: number;
|
||||
releasedRetainedMiB: number;
|
||||
maxRssMiB: number;
|
||||
};
|
||||
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
|
||||
const ordinaryScenarios: readonly Scenario[] = [
|
||||
"import",
|
||||
"committed-read",
|
||||
"draft-read",
|
||||
"sparse",
|
||||
"queue",
|
||||
"sort",
|
||||
"dense",
|
||||
"unshift",
|
||||
];
|
||||
const watchScenarios: readonly Scenario[] = [
|
||||
"watch-authority-control",
|
||||
"watch-live-borrowed",
|
||||
"watch-acquire-shared",
|
||||
"watch-acquire-detached",
|
||||
"watch-acquire-staggered",
|
||||
"watch-lag",
|
||||
"watch-catch-up",
|
||||
"watch-catch-up-coalesced",
|
||||
"watch-live-immutable",
|
||||
];
|
||||
const modes: readonly Mode[] = ["tracker"];
|
||||
const quick = process.argv.includes("--quick");
|
||||
const watchOnly = process.argv.includes("--watch-only");
|
||||
const ordinaryOnly = process.argv.includes("--ordinary-only");
|
||||
if (watchOnly && ordinaryOnly) throw new Error("Choose at most one scenario filter");
|
||||
const scenarios = watchOnly
|
||||
? watchScenarios
|
||||
: ordinaryOnly
|
||||
? ordinaryScenarios
|
||||
: [...ordinaryScenarios, ...watchScenarios];
|
||||
const size = quick ? 10_000 : 100_000;
|
||||
const trials = quick ? 1 : 3;
|
||||
const watchCount = quick ? 4 : 8;
|
||||
const watchUpdates = quick ? 20 : 100;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of scenarios) {
|
||||
for (const mode of modes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=4096",
|
||||
worker,
|
||||
mode,
|
||||
scenario,
|
||||
String(size),
|
||||
String(trial),
|
||||
String(watchCount),
|
||||
String(watchUpdates),
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
} else {
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const average = (values: readonly number[]): number | null =>
|
||||
values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length;
|
||||
const median = (values: readonly number[]): number | null => {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.floor(ordered.length / 2)]!;
|
||||
};
|
||||
const range = (values: readonly number[]): readonly [number, number] | null =>
|
||||
values.length === 0 ? null : [Math.min(...values), Math.max(...values)];
|
||||
const metricNames = [
|
||||
"importMs",
|
||||
"mutateMs",
|
||||
"prepareMs",
|
||||
"serializeMs",
|
||||
"adoptMs",
|
||||
"acquireMs",
|
||||
"enqueueMs",
|
||||
"deliveryMs",
|
||||
"scenarioWorkMs",
|
||||
"opBytes",
|
||||
"operationCount",
|
||||
"readyMiB",
|
||||
"sampledAcquireMiB",
|
||||
"sampledTransientMiB",
|
||||
"retainedMiB",
|
||||
"retainedOverReadyMiB",
|
||||
"postAcquireRetainedMiB",
|
||||
"postLagRetainedMiB",
|
||||
"postDeliveryRetainedMiB",
|
||||
"releasedRetainedMiB",
|
||||
"maxRssMiB",
|
||||
] as const satisfies readonly (keyof Result)[];
|
||||
|
||||
const summary = scenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.mode === mode && result.scenario === scenario);
|
||||
const averages: Record<string, number | null> = {};
|
||||
const medians: Record<string, number | null> = {};
|
||||
const ranges: Record<string, readonly [number, number] | null> = {};
|
||||
for (const metric of metricNames) {
|
||||
const values = selected.map((result) => result[metric]);
|
||||
averages[metric] = average(values);
|
||||
medians[metric] = median(values);
|
||||
ranges[metric] = range(values);
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
scenario,
|
||||
watchProfile: selected[0]?.watchProfile ?? null,
|
||||
watchCount: selected[0]?.watchCount ?? 0,
|
||||
watchUpdates: selected[0]?.watchUpdates ?? 0,
|
||||
averages,
|
||||
medians,
|
||||
ranges,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const output = join(tmpdir(), "chord-delta-benchmark.json");
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify(
|
||||
{ node: process.version, size, trials, watchCount, watchUpdates, results, failures, summary },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
output,
|
||||
node: process.version,
|
||||
size,
|
||||
trials,
|
||||
watchCount,
|
||||
watchUpdates,
|
||||
failures: failures.length,
|
||||
summary,
|
||||
}),
|
||||
);
|
||||
436
third_party/pi/packages/chord/test/delta-benchmark/benchmark.worker.ts
vendored
Normal file
436
third_party/pi/packages/chord/test/delta-benchmark/benchmark.worker.ts
vendored
Normal file
@@ -0,0 +1,436 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { Draft } from "../../src/delta/draft.ts";
|
||||
import { applyImmutable, type Op, track } from "../../src/delta/index.ts";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario =
|
||||
| "import"
|
||||
| "committed-read"
|
||||
| "draft-read"
|
||||
| "sparse"
|
||||
| "queue"
|
||||
| "sort"
|
||||
| "dense"
|
||||
| "unshift"
|
||||
| "watch-authority-control"
|
||||
| "watch-live-borrowed"
|
||||
| "watch-acquire-shared"
|
||||
| "watch-acquire-detached"
|
||||
| "watch-acquire-staggered"
|
||||
| "watch-lag"
|
||||
| "watch-catch-up"
|
||||
| "watch-catch-up-coalesced"
|
||||
| "watch-live-immutable";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
type PreparedLike = { readonly value: Readonly<Document>; readonly ops: readonly Op[] };
|
||||
type ChangeLike = { readonly state: Draft<Document>; prepare(): PreparedLike };
|
||||
type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void };
|
||||
type Frame = { readonly sequence: number; readonly ops: readonly Op[] };
|
||||
type WatchReplica = {
|
||||
value: Document;
|
||||
readonly baseSequence: number;
|
||||
sequence: number;
|
||||
readonly queue: Frame[];
|
||||
};
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const MODES = new Set<Mode>(["tracker"]);
|
||||
const SCENARIOS = new Set<Scenario>([
|
||||
"import",
|
||||
"committed-read",
|
||||
"draft-read",
|
||||
"sparse",
|
||||
"queue",
|
||||
"sort",
|
||||
"dense",
|
||||
"unshift",
|
||||
"watch-authority-control",
|
||||
"watch-live-borrowed",
|
||||
"watch-acquire-shared",
|
||||
"watch-acquire-detached",
|
||||
"watch-acquire-staggered",
|
||||
"watch-lag",
|
||||
"watch-catch-up",
|
||||
"watch-catch-up-coalesced",
|
||||
"watch-live-immutable",
|
||||
]);
|
||||
const WATCH_SCENARIOS = new Set<Scenario>([
|
||||
"watch-authority-control",
|
||||
"watch-live-borrowed",
|
||||
"watch-acquire-shared",
|
||||
"watch-acquire-detached",
|
||||
"watch-acquire-staggered",
|
||||
"watch-lag",
|
||||
"watch-catch-up",
|
||||
"watch-catch-up-coalesced",
|
||||
"watch-live-immutable",
|
||||
]);
|
||||
const mode = process.argv[2] as Mode;
|
||||
const scenario = process.argv[3] as Scenario;
|
||||
if (!MODES.has(mode)) throw new Error(`Unknown benchmark mode ${mode}`);
|
||||
if (!SCENARIOS.has(scenario)) throw new Error(`Unknown benchmark scenario ${scenario}`);
|
||||
const size = Number(process.argv[4] ?? 100_000);
|
||||
const trial = Number(process.argv[5] ?? 0);
|
||||
const requestedWatchCount = Number(process.argv[6] ?? 8);
|
||||
const requestedWatchUpdates = Number(process.argv[7] ?? 100);
|
||||
if (!Number.isSafeInteger(requestedWatchCount) || requestedWatchCount < 1) throw new RangeError("Invalid watch count");
|
||||
if (!Number.isSafeInteger(requestedWatchUpdates) || requestedWatchUpdates < 0)
|
||||
throw new RangeError("Invalid watch update count");
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneTrusted<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map((child) => cloneTrusted(child)) as T;
|
||||
const result = Object.create(Object.getPrototypeOf(value)) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(result, key, {
|
||||
value: cloneTrusted((value as Record<string, unknown>)[key]),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function sum(document: Document | Draft<Document>): number {
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value;
|
||||
return checksum;
|
||||
}
|
||||
|
||||
async function gc(): Promise<void> {
|
||||
assert.ok(global.gc, "benchmark worker requires --expose-gc");
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
await gc();
|
||||
const baselineHeap = process.memoryUsage().heapUsed;
|
||||
let input: Document | undefined = fixture(size);
|
||||
if (scenario === "sort") input.rows.reverse();
|
||||
const importStart = performance.now();
|
||||
const tracker = track(input) as TrackerLike;
|
||||
const importMs = performance.now() - importStart;
|
||||
input = undefined;
|
||||
await gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
let peakHeap = readyHeap;
|
||||
let mutateMs = 0;
|
||||
let prepareMs = 0;
|
||||
let serializeMs = 0;
|
||||
let adoptMs = 0;
|
||||
let acquireMs = 0;
|
||||
let enqueueMs = 0;
|
||||
let deliveryMs = 0;
|
||||
let opBytes = 0;
|
||||
let operationCount = 0;
|
||||
let checksum = 0;
|
||||
let authoritySequence = 0;
|
||||
let postAcquireRetainedMiB = 0;
|
||||
let postLagRetainedMiB = 0;
|
||||
let postDeliveryRetainedMiB = 0;
|
||||
let sampledAcquireMiB = 0;
|
||||
let watchers: WatchReplica[] = [];
|
||||
let borrowedListeners: Array<(value: Document, sequence: number) => number> = [];
|
||||
const queueOperations = Math.min(size, 1_000);
|
||||
const watchCount = WATCH_SCENARIOS.has(scenario) ? requestedWatchCount : 0;
|
||||
const watchUpdateTarget = WATCH_SCENARIOS.has(scenario) ? requestedWatchUpdates : 0;
|
||||
|
||||
const samplePeak = (): void => {
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
};
|
||||
|
||||
const retainedFromBaseline = async (): Promise<number> => {
|
||||
await gc();
|
||||
return (process.memoryUsage().heapUsed - baselineHeap) / MiB;
|
||||
};
|
||||
|
||||
const createWatch = (value: Document): WatchReplica => ({
|
||||
value,
|
||||
baseSequence: authoritySequence,
|
||||
sequence: authoritySequence,
|
||||
queue: [],
|
||||
});
|
||||
|
||||
const acquireSharedWatches = (): void => {
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
const base = tracker.value;
|
||||
watchers = Array.from({ length: watchCount }, () => createWatch(base));
|
||||
acquireMs += performance.now() - start;
|
||||
sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB);
|
||||
for (const watcher of watchers) assert.equal(watcher.value, tracker.value);
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const acquireDetachedWatches = (): void => {
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
watchers = Array.from({ length: watchCount }, () => createWatch(cloneTrusted(tracker.value)));
|
||||
acquireMs += performance.now() - start;
|
||||
sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB);
|
||||
for (let left = 0; left < watchers.length; left++) {
|
||||
assert.notEqual(watchers[left]!.value, tracker.value);
|
||||
for (let right = left + 1; right < watchers.length; right++) {
|
||||
assert.notEqual(watchers[left]!.value, watchers[right]!.value);
|
||||
}
|
||||
}
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const acquireOneWatch = (): void => {
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
watchers.push(createWatch(tracker.value));
|
||||
acquireMs += performance.now() - start;
|
||||
sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB);
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const commitWatchUpdate = (update: number): Frame => {
|
||||
let change: ChangeLike | undefined = tracker.beginChange();
|
||||
const row = ((update + 1) * 997) % size;
|
||||
const mutateStart = performance.now();
|
||||
change.state.rows[row]!.value = -update - 1;
|
||||
mutateMs += performance.now() - mutateStart;
|
||||
samplePeak();
|
||||
const prepareStart = performance.now();
|
||||
let prepared: PreparedLike | undefined = change.prepare();
|
||||
prepareMs += performance.now() - prepareStart;
|
||||
samplePeak();
|
||||
const operations = prepared.ops;
|
||||
operationCount += operations.length;
|
||||
const adoptStart = performance.now();
|
||||
tracker.adopt(prepared);
|
||||
adoptMs += performance.now() - adoptStart;
|
||||
samplePeak();
|
||||
change = undefined;
|
||||
prepared = undefined;
|
||||
authoritySequence += 1;
|
||||
return { sequence: authoritySequence, ops: operations };
|
||||
};
|
||||
|
||||
const enqueueFrame = (frame: Frame): void => {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) {
|
||||
assert.equal(frame.sequence, watcher.sequence + watcher.queue.length + 1);
|
||||
watcher.queue.push(frame);
|
||||
}
|
||||
enqueueMs += performance.now() - start;
|
||||
};
|
||||
|
||||
const applyFrame = (watcher: WatchReplica, frame: Frame): void => {
|
||||
assert.equal(frame.sequence, watcher.sequence + 1);
|
||||
watcher.value = applyImmutable(watcher.value, frame.ops);
|
||||
watcher.sequence = frame.sequence;
|
||||
};
|
||||
|
||||
const drainWatchQueues = (measure: boolean): void => {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) {
|
||||
for (const frame of watcher.queue) applyFrame(watcher, frame);
|
||||
watcher.queue.length = 0;
|
||||
samplePeak();
|
||||
}
|
||||
if (measure) deliveryMs += performance.now() - start;
|
||||
};
|
||||
|
||||
const drainWatchQueuesCoalesced = (): void => {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) {
|
||||
const operations = watcher.queue.flatMap((frame) => frame.ops);
|
||||
watcher.value = applyImmutable(watcher.value, operations);
|
||||
watcher.sequence = watcher.queue.at(-1)?.sequence ?? watcher.sequence;
|
||||
watcher.queue.length = 0;
|
||||
samplePeak();
|
||||
}
|
||||
deliveryMs += performance.now() - start;
|
||||
};
|
||||
|
||||
const assertWatchConvergence = (): void => {
|
||||
for (const watcher of watchers) {
|
||||
assert.equal(watcher.sequence, authoritySequence);
|
||||
assert.deepEqual(watcher.value, tracker.value);
|
||||
}
|
||||
};
|
||||
|
||||
if (scenario === "committed-read") {
|
||||
const start = performance.now();
|
||||
checksum = sum(tracker.value);
|
||||
mutateMs = performance.now() - start;
|
||||
samplePeak();
|
||||
} else if (scenario === "watch-authority-control") {
|
||||
for (let update = 0; update < watchUpdateTarget; update++) commitWatchUpdate(update);
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(tracker.value);
|
||||
} else if (scenario === "watch-live-borrowed") {
|
||||
const start = performance.now();
|
||||
borrowedListeners = Array.from({ length: watchCount }, () => (value, sequence) => value.rows[0]!.value + sequence);
|
||||
for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence);
|
||||
acquireMs = performance.now() - start;
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
for (let update = 0; update < watchUpdateTarget; update++) {
|
||||
commitWatchUpdate(update);
|
||||
const deliveryStart = performance.now();
|
||||
for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence);
|
||||
deliveryMs += performance.now() - deliveryStart;
|
||||
samplePeak();
|
||||
}
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
} else if (scenario === "watch-acquire-shared" || scenario === "watch-acquire-detached") {
|
||||
if (scenario === "watch-acquire-shared") acquireSharedWatches();
|
||||
else acquireDetachedWatches();
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(watchers[0]!.value) + watchers.length;
|
||||
} else if (scenario === "watch-acquire-staggered") {
|
||||
for (let watcher = 0; watcher < watchCount; watcher++) {
|
||||
acquireOneWatch();
|
||||
if (watcher + 1 === watchCount) break;
|
||||
const frame = commitWatchUpdate(watcher);
|
||||
enqueueFrame(frame);
|
||||
}
|
||||
for (let watcher = 0; watcher < watchers.length; watcher++) {
|
||||
assert.equal(watchers[watcher]!.baseSequence, watcher);
|
||||
assert.equal(watchers[watcher]!.queue.length, watchCount - watcher - 1);
|
||||
}
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
drainWatchQueues(false);
|
||||
assertWatchConvergence();
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(watchers[0]!.value) + sum(watchers.at(-1)!.value);
|
||||
} else if (
|
||||
scenario === "watch-lag" ||
|
||||
scenario === "watch-catch-up" ||
|
||||
scenario === "watch-catch-up-coalesced" ||
|
||||
scenario === "watch-live-immutable"
|
||||
) {
|
||||
acquireSharedWatches();
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
for (let update = 0; update < watchUpdateTarget; update++) {
|
||||
const frame = commitWatchUpdate(update);
|
||||
if (scenario === "watch-live-immutable") {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) applyFrame(watcher, frame);
|
||||
deliveryMs += performance.now() - start;
|
||||
samplePeak();
|
||||
} else enqueueFrame(frame);
|
||||
}
|
||||
postLagRetainedMiB = await retainedFromBaseline();
|
||||
if (scenario === "watch-catch-up-coalesced") drainWatchQueuesCoalesced();
|
||||
else if (scenario !== "watch-live-immutable") drainWatchQueues(scenario === "watch-catch-up");
|
||||
assertWatchConvergence();
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(watchers[0]!.value) + sum(tracker.value);
|
||||
} else if (scenario !== "import") {
|
||||
let change: ChangeLike | undefined = tracker.beginChange();
|
||||
const start = performance.now();
|
||||
switch (scenario) {
|
||||
case "draft-read":
|
||||
checksum = sum(change.state);
|
||||
break;
|
||||
case "sparse":
|
||||
for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "queue":
|
||||
for (let index = 0; index < queueOperations; index++) {
|
||||
change.state.rows.shift();
|
||||
change.state.rows.push({ id: size + index, value: index, payload: "queue" });
|
||||
}
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
case "sort":
|
||||
change.state.rows.sort((left, right) => left.id - right.id);
|
||||
checksum = change.state.rows[0]!.id;
|
||||
break;
|
||||
case "dense":
|
||||
for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "unshift": {
|
||||
const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" }));
|
||||
Reflect.apply(change.state.rows.unshift, change.state.rows, inserted);
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unhandled benchmark scenario ${scenario}`);
|
||||
}
|
||||
mutateMs = performance.now() - start;
|
||||
samplePeak();
|
||||
const prepareStart = performance.now();
|
||||
let prepared: PreparedLike | undefined = change.prepare();
|
||||
prepareMs = performance.now() - prepareStart;
|
||||
samplePeak();
|
||||
const serializeStart = performance.now();
|
||||
const serialized = JSON.stringify(prepared.ops);
|
||||
serializeMs = performance.now() - serializeStart;
|
||||
opBytes = Buffer.byteLength(serialized);
|
||||
operationCount = prepared.ops.length;
|
||||
samplePeak();
|
||||
const adoptStart = performance.now();
|
||||
tracker.adopt(prepared);
|
||||
adoptMs = performance.now() - adoptStart;
|
||||
change = undefined;
|
||||
prepared = undefined;
|
||||
}
|
||||
|
||||
const scenarioWorkMs = mutateMs + prepareMs + serializeMs + adoptMs + acquireMs + enqueueMs + deliveryMs;
|
||||
samplePeak();
|
||||
await gc();
|
||||
const retainedHeap = process.memoryUsage().heapUsed;
|
||||
const retainedMiB = (retainedHeap - baselineHeap) / MiB;
|
||||
const retainedOverReadyMiB = (retainedHeap - readyHeap) / MiB;
|
||||
const watcherRoots = watchers.length;
|
||||
const queuedBatchReferences = watchers.reduce((total, watcher) => total + watcher.queue.length, 0);
|
||||
const listenerCount = borrowedListeners.length;
|
||||
watchers = [];
|
||||
borrowedListeners = [];
|
||||
await gc();
|
||||
const releasedRetainedMiB = (process.memoryUsage().heapUsed - baselineHeap) / MiB;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
scenario,
|
||||
size,
|
||||
trial,
|
||||
queueOperations,
|
||||
watchProfile: WATCH_SCENARIOS.has(scenario) ? "local-sparse-array-leaf" : null,
|
||||
watchCount: watcherRoots + listenerCount,
|
||||
watchUpdates: authoritySequence,
|
||||
queuedBatchReferences,
|
||||
importMs,
|
||||
mutateMs,
|
||||
prepareMs,
|
||||
serializeMs,
|
||||
adoptMs,
|
||||
acquireMs,
|
||||
enqueueMs,
|
||||
deliveryMs,
|
||||
scenarioWorkMs,
|
||||
opBytes,
|
||||
operationCount,
|
||||
checksum,
|
||||
readyMiB: (readyHeap - baselineHeap) / MiB,
|
||||
sampledAcquireMiB,
|
||||
sampledTransientMiB: (peakHeap - readyHeap) / MiB,
|
||||
retainedMiB,
|
||||
retainedOverReadyMiB,
|
||||
postAcquireRetainedMiB,
|
||||
postLagRetainedMiB,
|
||||
postDeliveryRetainedMiB,
|
||||
releasedRetainedMiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
}),
|
||||
);
|
||||
337
third_party/pi/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts
vendored
Normal file
337
third_party/pi/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts
vendored
Normal file
@@ -0,0 +1,337 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Provisional Pico5 benchmark schemas. Results are architecture experiments,
|
||||
// not compatibility commitments for public or persisted ConversationView data.
|
||||
const PROVISIONAL_SCHEMA = "pico5.conversation-view.provisional.v0";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario =
|
||||
| "projection-control"
|
||||
| "normal"
|
||||
| "fanout"
|
||||
| "delayed-start-lag"
|
||||
| "churn-reconnect"
|
||||
| "overflow-reset"
|
||||
| "branch-head-replacement";
|
||||
type Distribution = {
|
||||
count: number;
|
||||
totalMs: number;
|
||||
medianMs: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
type PhaseName =
|
||||
| "mutation"
|
||||
| "prepare"
|
||||
| "adopt"
|
||||
| "synthesize"
|
||||
| "projectApply"
|
||||
| "project"
|
||||
| "enqueue"
|
||||
| "sessionLine"
|
||||
| "delivery"
|
||||
| "opSerialize"
|
||||
| "resetSerialize"
|
||||
| "conversationAcquire"
|
||||
| "documentAcquire"
|
||||
| "conversationSerialize"
|
||||
| "documentSerialize"
|
||||
| "resetClone";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
trial: number;
|
||||
provisionalSchema: string;
|
||||
headlineEntries: number;
|
||||
commitTarget: number;
|
||||
initialAggregateBytes: number;
|
||||
conversationWatchCount: number;
|
||||
documentWatchCount: number;
|
||||
queueCapacity: number | null;
|
||||
queueCapacityUnit: "pending-operations";
|
||||
deliveryModel: "async-serialized-ops-only-no-consumer-apply";
|
||||
conversationSequence: number;
|
||||
documentSequences: Record<string, number>;
|
||||
publishedAggregateBatches: number;
|
||||
publishedDocumentBatches: number;
|
||||
conversationRegistrations: number;
|
||||
documentRegistrations: number;
|
||||
conversationBaseBytes: number;
|
||||
documentBaseBytes: number;
|
||||
noOpCommitCount: number;
|
||||
unmountedCommitCount: number;
|
||||
resetCount: number;
|
||||
resetBytes: number;
|
||||
deliveryFrameReferences: number;
|
||||
deliveryOperationReferences: number;
|
||||
aggregateOperationCount: number;
|
||||
aggregateOperationBytes: number;
|
||||
documentOperationCount: number;
|
||||
documentOperationBytes: number;
|
||||
peakQueueReferences: number;
|
||||
peakQueuedOperationReferences: number;
|
||||
peakPendingOperationsPerWatch: number;
|
||||
peakUniqueQueuedBatches: number;
|
||||
finalQueueReferences: number;
|
||||
finalQueuedOperationReferences: number;
|
||||
finalUniqueQueuedBatches: number;
|
||||
phaseDistributions: Record<PhaseName, Distribution>;
|
||||
authorityReadyMiB: number;
|
||||
postAcquireRetainedMiB: number;
|
||||
postWorkRetainedMiB: number;
|
||||
postVerificationRetainedMiB: number;
|
||||
releaseHeapMiB: number;
|
||||
releaseTotalMiB: number;
|
||||
peakHeapMiB: number;
|
||||
peakTotalHeapMiB: number;
|
||||
maxRssMiB: number;
|
||||
checksum: number;
|
||||
};
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
type NumericResultKey =
|
||||
| "initialAggregateBytes"
|
||||
| "conversationSequence"
|
||||
| "publishedAggregateBatches"
|
||||
| "publishedDocumentBatches"
|
||||
| "conversationRegistrations"
|
||||
| "documentRegistrations"
|
||||
| "conversationBaseBytes"
|
||||
| "documentBaseBytes"
|
||||
| "noOpCommitCount"
|
||||
| "unmountedCommitCount"
|
||||
| "resetCount"
|
||||
| "resetBytes"
|
||||
| "deliveryFrameReferences"
|
||||
| "deliveryOperationReferences"
|
||||
| "aggregateOperationCount"
|
||||
| "aggregateOperationBytes"
|
||||
| "documentOperationCount"
|
||||
| "documentOperationBytes"
|
||||
| "peakQueueReferences"
|
||||
| "peakQueuedOperationReferences"
|
||||
| "peakPendingOperationsPerWatch"
|
||||
| "peakUniqueQueuedBatches"
|
||||
| "finalQueueReferences"
|
||||
| "finalQueuedOperationReferences"
|
||||
| "finalUniqueQueuedBatches"
|
||||
| "authorityReadyMiB"
|
||||
| "postAcquireRetainedMiB"
|
||||
| "postWorkRetainedMiB"
|
||||
| "postVerificationRetainedMiB"
|
||||
| "releaseHeapMiB"
|
||||
| "releaseTotalMiB"
|
||||
| "peakHeapMiB"
|
||||
| "peakTotalHeapMiB"
|
||||
| "maxRssMiB";
|
||||
|
||||
type SummaryValue = { median: number | null; range: readonly [number, number] | null };
|
||||
|
||||
const modes: readonly Mode[] = ["tracker"];
|
||||
const scenarios: readonly Scenario[] = [
|
||||
"projection-control",
|
||||
"normal",
|
||||
"fanout",
|
||||
"delayed-start-lag",
|
||||
"churn-reconnect",
|
||||
"overflow-reset",
|
||||
"branch-head-replacement",
|
||||
];
|
||||
const phaseNames: readonly PhaseName[] = [
|
||||
"mutation",
|
||||
"prepare",
|
||||
"adopt",
|
||||
"synthesize",
|
||||
"projectApply",
|
||||
"project",
|
||||
"enqueue",
|
||||
"sessionLine",
|
||||
"delivery",
|
||||
"opSerialize",
|
||||
"resetSerialize",
|
||||
"conversationAcquire",
|
||||
"documentAcquire",
|
||||
"conversationSerialize",
|
||||
"documentSerialize",
|
||||
"resetClone",
|
||||
];
|
||||
const numericMetrics: readonly NumericResultKey[] = [
|
||||
"initialAggregateBytes",
|
||||
"conversationSequence",
|
||||
"publishedAggregateBatches",
|
||||
"publishedDocumentBatches",
|
||||
"conversationRegistrations",
|
||||
"documentRegistrations",
|
||||
"conversationBaseBytes",
|
||||
"documentBaseBytes",
|
||||
"noOpCommitCount",
|
||||
"unmountedCommitCount",
|
||||
"resetCount",
|
||||
"resetBytes",
|
||||
"deliveryFrameReferences",
|
||||
"deliveryOperationReferences",
|
||||
"aggregateOperationCount",
|
||||
"aggregateOperationBytes",
|
||||
"documentOperationCount",
|
||||
"documentOperationBytes",
|
||||
"peakQueueReferences",
|
||||
"peakQueuedOperationReferences",
|
||||
"peakPendingOperationsPerWatch",
|
||||
"peakUniqueQueuedBatches",
|
||||
"finalQueueReferences",
|
||||
"finalQueuedOperationReferences",
|
||||
"finalUniqueQueuedBatches",
|
||||
"authorityReadyMiB",
|
||||
"postAcquireRetainedMiB",
|
||||
"postWorkRetainedMiB",
|
||||
"postVerificationRetainedMiB",
|
||||
"releaseHeapMiB",
|
||||
"releaseTotalMiB",
|
||||
"peakHeapMiB",
|
||||
"peakTotalHeapMiB",
|
||||
"maxRssMiB",
|
||||
];
|
||||
|
||||
function optionNumber(name: string, fallback: number): number {
|
||||
const prefix = `--${name}=`;
|
||||
const raw = process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length);
|
||||
if (raw === undefined) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`Invalid --${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.floor(ordered.length / 2)]!;
|
||||
}
|
||||
|
||||
function range(values: readonly number[]): readonly [number, number] | null {
|
||||
return values.length === 0 ? null : [Math.min(...values), Math.max(...values)];
|
||||
}
|
||||
|
||||
function summarize(values: readonly number[]): SummaryValue {
|
||||
return { median: median(values), range: range(values) };
|
||||
}
|
||||
|
||||
const quick = process.argv.includes("--quick");
|
||||
const selectedScenario = process.argv
|
||||
.find((argument) => argument.startsWith("--scenario="))
|
||||
?.slice("--scenario=".length);
|
||||
if (selectedScenario !== undefined && !(scenarios as readonly string[]).includes(selectedScenario)) {
|
||||
throw new Error(`Unknown scenario ${selectedScenario}`);
|
||||
}
|
||||
const selectedScenarios = selectedScenario === undefined ? scenarios : [selectedScenario as Scenario];
|
||||
const headlineEntries = optionNumber("entries", quick ? 128 : 512);
|
||||
const commits = optionNumber("commits", quick ? 120 : 1_000);
|
||||
const trials = optionNumber("trials", quick ? 1 : 3);
|
||||
const queueOperationCapacity = quick ? 64 : 256;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./conversation-view-benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of selectedScenarios) {
|
||||
const offset = trial % modes.length;
|
||||
const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)];
|
||||
for (const mode of trialModes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=4096",
|
||||
worker,
|
||||
mode,
|
||||
scenario,
|
||||
String(headlineEntries),
|
||||
String(commits),
|
||||
String(trial),
|
||||
String(queueOperationCapacity),
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 20 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
continue;
|
||||
}
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = selectedScenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.scenario === scenario && result.mode === mode);
|
||||
const metrics: Record<string, SummaryValue> = {};
|
||||
for (const metric of numericMetrics) metrics[metric] = summarize(selected.map((result) => result[metric]));
|
||||
const phases: Record<
|
||||
string,
|
||||
{ medianMs: SummaryValue; p95Ms: SummaryValue; p99Ms: SummaryValue; totalMs: SummaryValue }
|
||||
> = {};
|
||||
for (const phase of phaseNames) {
|
||||
phases[phase] = {
|
||||
medianMs: summarize(selected.map((result) => result.phaseDistributions[phase].medianMs)),
|
||||
p95Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p95Ms)),
|
||||
p99Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p99Ms)),
|
||||
totalMs: summarize(selected.map((result) => result.phaseDistributions[phase].totalMs)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
scenario,
|
||||
provisionalSchema: PROVISIONAL_SCHEMA,
|
||||
conversationWatchCount: selected[0]?.conversationWatchCount ?? 0,
|
||||
documentWatchCount: selected[0]?.documentWatchCount ?? 0,
|
||||
queueCapacity: selected[0]?.queueCapacity ?? null,
|
||||
queueCapacityUnit: "pending-operations",
|
||||
deliveryModel: "async-serialized-ops-only-no-consumer-apply",
|
||||
metrics,
|
||||
phases,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const output = join(tmpdir(), "chord-conversation-view-benchmark.json");
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
provisionalSchema: PROVISIONAL_SCHEMA,
|
||||
profile: "local-pico5-provisional-conversation-view",
|
||||
headlineEntries,
|
||||
commits,
|
||||
trials,
|
||||
queueOperationCapacity,
|
||||
results,
|
||||
failures,
|
||||
summary,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
output,
|
||||
node: process.version,
|
||||
provisionalSchema: PROVISIONAL_SCHEMA,
|
||||
headlineEntries,
|
||||
commits,
|
||||
trials,
|
||||
queueOperationCapacity,
|
||||
failures: failures.length,
|
||||
summary,
|
||||
}),
|
||||
);
|
||||
1140
third_party/pi/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts
vendored
Normal file
1140
third_party/pi/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
142
third_party/pi/packages/chord/test/delta-benchmark/memory-benchmark.ts
vendored
Normal file
142
third_party/pi/packages/chord/test/delta-benchmark/memory-benchmark.ts
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
trial: number;
|
||||
size: number;
|
||||
updates: number;
|
||||
watchCount: number;
|
||||
workMs: number;
|
||||
acquisitionMs: number;
|
||||
gcCount: number;
|
||||
gcMs: number;
|
||||
readyHeapMiB: number;
|
||||
readyRssMiB: number;
|
||||
peakOverReadyHeapMiB: number;
|
||||
peakOverProcessBaselineHeapMiB: number;
|
||||
peakOverReadyRssMiB: number;
|
||||
retainedOverReadyHeapMiB: number;
|
||||
retainedOverProcessBaselineHeapMiB: number;
|
||||
retainedOverReadyRssMiB: number;
|
||||
snapshotRetainedHeapMiB: number;
|
||||
afterSnapshotReleaseOverReadyHeapMiB: number;
|
||||
afterSnapshotReleaseOverProcessBaselineHeapMiB: number;
|
||||
releasedOverProcessBaselineHeapMiB: number;
|
||||
releasedOverProcessBaselineRssMiB: number;
|
||||
maxRssMiB: number;
|
||||
checksum: number;
|
||||
};
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
|
||||
const modes: readonly Mode[] = ["tracker"];
|
||||
const scenarios: readonly Scenario[] = ["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"];
|
||||
const metrics = [
|
||||
"workMs",
|
||||
"acquisitionMs",
|
||||
"gcCount",
|
||||
"gcMs",
|
||||
"readyHeapMiB",
|
||||
"readyRssMiB",
|
||||
"peakOverReadyHeapMiB",
|
||||
"peakOverProcessBaselineHeapMiB",
|
||||
"peakOverReadyRssMiB",
|
||||
"retainedOverReadyHeapMiB",
|
||||
"retainedOverProcessBaselineHeapMiB",
|
||||
"retainedOverReadyRssMiB",
|
||||
"snapshotRetainedHeapMiB",
|
||||
"afterSnapshotReleaseOverReadyHeapMiB",
|
||||
"afterSnapshotReleaseOverProcessBaselineHeapMiB",
|
||||
"releasedOverProcessBaselineHeapMiB",
|
||||
"releasedOverProcessBaselineRssMiB",
|
||||
"maxRssMiB",
|
||||
] as const satisfies readonly (keyof Result)[];
|
||||
|
||||
function median(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.floor(ordered.length / 2)]!;
|
||||
}
|
||||
|
||||
function range(values: readonly number[]): readonly [number, number] | null {
|
||||
return values.length === 0 ? null : [Math.min(...values), Math.max(...values)];
|
||||
}
|
||||
|
||||
const quick = process.argv.includes("--quick");
|
||||
const size = quick ? 10_000 : 100_000;
|
||||
const updates = quick ? 20 : 100;
|
||||
const watchCount = quick ? 4 : 8;
|
||||
const trials = quick ? 1 : 3;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./memory-benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of scenarios) {
|
||||
const offset = trial % modes.length;
|
||||
const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)];
|
||||
for (const mode of trialModes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--no-flush-bytecode",
|
||||
"--max-old-space-size=4096",
|
||||
worker,
|
||||
mode,
|
||||
scenario,
|
||||
String(size),
|
||||
String(updates),
|
||||
String(watchCount),
|
||||
String(trial),
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
continue;
|
||||
}
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = scenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.scenario === scenario && result.mode === mode);
|
||||
const values: Record<string, { median: number | null; range: readonly [number, number] | null }> = {};
|
||||
for (const metric of metrics) {
|
||||
const samples = selected.map((result) => result[metric]);
|
||||
values[metric] = { median: median(samples), range: range(samples) };
|
||||
}
|
||||
return { scenario, mode, metrics: values };
|
||||
}),
|
||||
);
|
||||
const output = join(tmpdir(), "chord-delta-memory-benchmark.json");
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify({ node: process.version, size, updates, watchCount, trials, results, failures, summary }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
output,
|
||||
node: process.version,
|
||||
size,
|
||||
updates,
|
||||
watchCount,
|
||||
trials,
|
||||
failures: failures.length,
|
||||
summary,
|
||||
}),
|
||||
);
|
||||
163
third_party/pi/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts
vendored
Normal file
163
third_party/pi/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { PerformanceObserver, performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { Draft } from "../../src/delta/draft.ts";
|
||||
import { type Op, track } from "../../src/delta/index.ts";
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
type PreparedLike = { readonly value: Document; readonly ops: readonly Op[] };
|
||||
type ChangeLike = { readonly state: Draft<Document>; prepare(): PreparedLike };
|
||||
type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void };
|
||||
|
||||
const MODES = new Set<Mode>(["tracker"]);
|
||||
const SCENARIOS = new Set<Scenario>(["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"]);
|
||||
const mode = process.argv[2] as Mode;
|
||||
const scenario = process.argv[3] as Scenario;
|
||||
const size = Number(process.argv[4] ?? 100_000);
|
||||
const updates = Number(process.argv[5] ?? 100);
|
||||
const watchCount = Number(process.argv[6] ?? 8);
|
||||
const trial = Number(process.argv[7] ?? 0);
|
||||
if (!MODES.has(mode)) throw new Error(`Unknown mode ${mode}`);
|
||||
if (!SCENARIOS.has(scenario)) throw new Error(`Unknown scenario ${scenario}`);
|
||||
if (!Number.isSafeInteger(size) || size < 1) throw new RangeError("Invalid size");
|
||||
if (!Number.isSafeInteger(updates) || updates < 1) throw new RangeError("Invalid update count");
|
||||
if (!Number.isSafeInteger(watchCount) || watchCount < 1) throw new RangeError("Invalid watch count");
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return { rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })) };
|
||||
}
|
||||
|
||||
async function collect(): Promise<void> {
|
||||
assert.ok(global.gc, "memory benchmark requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
function createTracker(value: Document): TrackerLike {
|
||||
return track(value) as TrackerLike;
|
||||
}
|
||||
|
||||
function commitUpdate(tracker: TrackerLike, update: number): void {
|
||||
const change = tracker.beginChange();
|
||||
const row = ((update + 1) * 997) % size;
|
||||
change.state.rows[row]!.value = -update - 1;
|
||||
const prepared = change.prepare();
|
||||
tracker.adopt(prepared);
|
||||
}
|
||||
|
||||
function acquireSnapshot(tracker: TrackerLike): Document {
|
||||
return tracker.value;
|
||||
}
|
||||
|
||||
let warmup: TrackerLike | undefined = createTracker(fixture(16));
|
||||
const warmupChange = warmup.beginChange();
|
||||
warmupChange.state.rows[0]!.value = -1;
|
||||
const warmupPrepared = warmupChange.prepare();
|
||||
warmup.adopt(warmupPrepared);
|
||||
warmup = undefined;
|
||||
await collect();
|
||||
const processBaseline = process.memoryUsage();
|
||||
let input: Document | undefined = fixture(size);
|
||||
let tracker: TrackerLike | undefined = createTracker(input);
|
||||
input = undefined;
|
||||
await collect();
|
||||
const ready = process.memoryUsage();
|
||||
let peakHeap = ready.heapUsed;
|
||||
let peakRss = ready.rss;
|
||||
let acquisitionMs = 0;
|
||||
let snapshots: Document[] = [];
|
||||
let gcCount = 0;
|
||||
let gcMs = 0;
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
gcCount += 1;
|
||||
gcMs += entry.duration;
|
||||
}
|
||||
});
|
||||
observer.observe({ entryTypes: ["gc"] });
|
||||
|
||||
const samplePeak = (): void => {
|
||||
const memory = process.memoryUsage();
|
||||
peakHeap = Math.max(peakHeap, memory.heapUsed);
|
||||
peakRss = Math.max(peakRss, memory.rss);
|
||||
};
|
||||
|
||||
const acquire = (): void => {
|
||||
const start = performance.now();
|
||||
snapshots.push(acquireSnapshot(tracker!));
|
||||
acquisitionMs += performance.now() - start;
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const workStart = performance.now();
|
||||
if (scenario === "idle-control") {
|
||||
for (let update = 0; update < updates; update++) samplePeak();
|
||||
} else if (scenario === "sparse-burst") {
|
||||
for (let update = 0; update < updates; update++) {
|
||||
commitUpdate(tracker, update);
|
||||
samplePeak();
|
||||
}
|
||||
} else if (scenario === "staggered-revisions") {
|
||||
for (let watcher = 0; watcher < watchCount; watcher++) {
|
||||
acquire();
|
||||
if (watcher + 1 < watchCount) commitUpdate(tracker, watcher);
|
||||
samplePeak();
|
||||
}
|
||||
} else {
|
||||
for (let watcher = 0; watcher < watchCount; watcher++) acquire();
|
||||
for (let update = 0; update < updates; update++) {
|
||||
commitUpdate(tracker, update);
|
||||
samplePeak();
|
||||
}
|
||||
}
|
||||
const workMs = performance.now() - workStart;
|
||||
for (let attempt = 0; attempt < 3; attempt++) await setImmediate();
|
||||
observer.disconnect();
|
||||
samplePeak();
|
||||
await collect();
|
||||
const retained = process.memoryUsage();
|
||||
const checksum = tracker.value.rows[0]!.value + tracker.value.rows.at(-1)!.value + snapshots.length;
|
||||
|
||||
snapshots = [];
|
||||
await collect();
|
||||
const afterSnapshotRelease = process.memoryUsage();
|
||||
tracker = undefined;
|
||||
await collect();
|
||||
const released = process.memoryUsage();
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
scenario,
|
||||
trial,
|
||||
size,
|
||||
updates: scenario === "staggered-revisions" ? watchCount - 1 : scenario === "idle-control" ? 0 : updates,
|
||||
watchCount: scenario === "staggered-revisions" || scenario === "lagging-watches" ? watchCount : 0,
|
||||
workMs,
|
||||
acquisitionMs,
|
||||
gcCount,
|
||||
gcMs,
|
||||
readyHeapMiB: (ready.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
readyRssMiB: (ready.rss - processBaseline.rss) / MiB,
|
||||
peakOverReadyHeapMiB: (peakHeap - ready.heapUsed) / MiB,
|
||||
peakOverProcessBaselineHeapMiB: (peakHeap - processBaseline.heapUsed) / MiB,
|
||||
peakOverReadyRssMiB: (peakRss - ready.rss) / MiB,
|
||||
retainedOverReadyHeapMiB: (retained.heapUsed - ready.heapUsed) / MiB,
|
||||
retainedOverProcessBaselineHeapMiB: (retained.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
retainedOverReadyRssMiB: (retained.rss - ready.rss) / MiB,
|
||||
snapshotRetainedHeapMiB: (retained.heapUsed - afterSnapshotRelease.heapUsed) / MiB,
|
||||
afterSnapshotReleaseOverReadyHeapMiB: (afterSnapshotRelease.heapUsed - ready.heapUsed) / MiB,
|
||||
afterSnapshotReleaseOverProcessBaselineHeapMiB: (afterSnapshotRelease.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
releasedOverProcessBaselineHeapMiB: (released.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
releasedOverProcessBaselineRssMiB: (released.rss - processBaseline.rss) / MiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
checksum,
|
||||
}),
|
||||
);
|
||||
53
third_party/pi/packages/chord/test/delta-clone.test.ts
vendored
Normal file
53
third_party/pi/packages/chord/test/delta-clone.test.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apply, track } from "../src/delta/index.ts";
|
||||
|
||||
describe("tracker ownership", () => {
|
||||
it("takes immutable ownership of the imported revision in O(1)", () => {
|
||||
const input = {
|
||||
point: { x: 3, y: 7, pressure: 0.1 },
|
||||
rows: [{ values: [0, false, null, "text", { n: 1 }] }],
|
||||
};
|
||||
const tracker = track(input);
|
||||
expect(tracker.value).toBe(input);
|
||||
expect(tracker.value.point).toBe(input.point);
|
||||
expect(tracker.value.rows[0]!.values[4]).toBe(input.rows[0]!.values[4]);
|
||||
expect(Object.isFrozen(tracker.value.rows[0]!.values)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves null prototypes in an alias-free owned root", () => {
|
||||
type Dictionary = { enabled: boolean; child: { n: number } };
|
||||
const dictionary = Object.assign(Object.create(null) as Dictionary, { enabled: true, child: { n: 1 } });
|
||||
const tracker = track({ dictionary, left: { nested: [{ n: 1 }] }, right: { nested: [{ n: 1 }] } });
|
||||
expect(Object.getPrototypeOf(tracker.value.dictionary)).toBeNull();
|
||||
expect(tracker.value.dictionary).toBe(dictionary);
|
||||
expect(tracker.value.left).not.toBe(tracker.value.right);
|
||||
expect(tracker.value.left.nested[0]).not.toBe(tracker.value.right.nested[0]);
|
||||
});
|
||||
|
||||
it("does not traverse a trusted root while taking ownership", () => {
|
||||
let reads = 0;
|
||||
const input = Object.defineProperty({}, "untrustedAccessor", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1;
|
||||
throw new Error("root was traversed");
|
||||
},
|
||||
});
|
||||
const tracker = track(input);
|
||||
expect(tracker.value).toBe(input);
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
it("copies assigned and inserted values immediately", () => {
|
||||
const tracker = track({ rows: [] as { nested: { value: number } }[] });
|
||||
const assigned = { nested: { value: 1 } };
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows.push(assigned);
|
||||
assigned.nested.value = 9;
|
||||
expect(change.state.rows[0]!.nested.value).toBe(1);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.rows[0]!.nested.value).toBe(1);
|
||||
const replica = apply(structuredClone(prepared.base), prepared.ops);
|
||||
expect(replica).toEqual(prepared.value);
|
||||
});
|
||||
});
|
||||
267
third_party/pi/packages/chord/test/delta-diff.test.ts
vendored
Normal file
267
third_party/pi/packages/chord/test/delta-diff.test.ts
vendored
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyImmutable,
|
||||
assertValidOp,
|
||||
assertValidWireOp,
|
||||
decoder,
|
||||
diffRevisions,
|
||||
encoder,
|
||||
type Op,
|
||||
} from "../src/delta/index.ts";
|
||||
import type { JsonValue } from "../src/json.ts";
|
||||
|
||||
const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => {
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual(expected);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
};
|
||||
|
||||
describe("immutable revision diff", () => {
|
||||
it("emits sets and deletes", () => {
|
||||
expectDiff({ keep: 1, change: 1, remove: true }, { keep: 1, change: 2, add: 3 }, [
|
||||
["s", ["change"], 2],
|
||||
["s", ["add"], 3],
|
||||
["d", ["remove"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits string append and front truncation", () => {
|
||||
expectDiff({ text: "hello" }, { text: "hello world" }, [["a", ["text"], " world"]]);
|
||||
expectDiff({ text: "hello world" }, { text: "world" }, [["t", ["text"], 6]]);
|
||||
expectDiff({ text: "abcdefgh" }, { text: "defghxyz" }, [
|
||||
["t", ["text"], 3],
|
||||
["a", ["text"], "xyz"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents array insertion, removal, and shift with splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b] }, { values: [a, c, b] }, [["p", ["values"], 1, 0, [c]]]);
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c] }, [["p", ["values"], 0, 1, []]]);
|
||||
});
|
||||
|
||||
it("collapses a same-length queue update to two splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
const d = { id: "d" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c, d] }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [d]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a permutation for a pure reorder", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [c, a, b] }, [["m", ["values"], [2, 0, 1]]]);
|
||||
});
|
||||
|
||||
it("normalizes reordered distinct deeply-equal objects to a no-op", () => {
|
||||
const first = { nested: { value: 1 } };
|
||||
const second = { nested: { value: 1 } };
|
||||
expect(first).not.toBe(second);
|
||||
expect(diffRevisions({ values: [first, second] }, { values: [second, first] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates and encodes permutations", () => {
|
||||
const operations: Op[] = [
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", ["values"], [1, 2, 0]],
|
||||
];
|
||||
for (const operation of operations) assertValidOp(operation);
|
||||
const wire = encoder().encode(operations);
|
||||
expect(wire).toEqual([
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", [1, 2, 0]],
|
||||
]);
|
||||
for (const operation of wire) assertValidWireOp(operation);
|
||||
expect(decoder().decode(wire)).toEqual(operations);
|
||||
expect(() => assertValidOp(["m", ["values"], [0, 0]])).toThrow(/bijection/);
|
||||
});
|
||||
|
||||
it("emits nothing for deeply equal reconstructed values", () => {
|
||||
expect(diffRevisions({ value: { nested: [1, 2] } }, { value: { nested: [1, 2] } })).toEqual([]);
|
||||
expect(diffRevisions({ values: [{ id: 1 }, { id: 2 }] }, { values: [{ id: 1 }, { id: 2 }] })).toEqual([]);
|
||||
expect(diffRevisions({ values: [true, true, true] }, { values: [true, true, true] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a leaf edit inside a reconstructed array narrow", () => {
|
||||
expectDiff(
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "two" },
|
||||
],
|
||||
},
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "changed" },
|
||||
],
|
||||
},
|
||||
[["s", ["values", 1, "label"], "changed"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("emits payload-free splices for scattered removals", () => {
|
||||
const [a, b, c, d, e] = ["a", "b", "c", "d", "e"].map((id) => ({ id }));
|
||||
expectDiff({ values: [a, b, c, d, e] }, { values: [a, c, e] }, [
|
||||
["p", ["values"], 1, 1, []],
|
||||
["p", ["values"], 2, 1, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["front", [1, 2, 3, 4], [2, 3, 4], [["p", ["values"], 0, 1, []]]],
|
||||
["tail", [1, 2, 3, 4], [1, 2, 3], [["p", ["values"], 3, 1, []]]],
|
||||
["middle", [1, 2, 3, 4], [1, 3, 4], [["p", ["values"], 1, 1, []]]],
|
||||
["all", [1, 2, 3, 4], [], [["p", ["values"], 0, 4, []]]],
|
||||
["none", [1, 2, 3, 4], [1, 2, 3, 4], []],
|
||||
] as const)("encodes %s removal canonically", (_name, before, after, expected) => {
|
||||
expectDiff({ values: [...before] }, { values: [...after] }, expected);
|
||||
});
|
||||
|
||||
it("does not field-diff unrelated shifted objects with common fields", () => {
|
||||
const before = [1, 2, 3].map((value) => ({ type: "row", value }));
|
||||
const after = [2, 3, 4].map((value) => ({ type: "row", value }));
|
||||
expectDiff({ values: before }, { values: after }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [{ type: "row", value: 4 }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat coincidental id or key fields as structural identity", () => {
|
||||
const left = { value: "left" };
|
||||
const right = { value: "right" };
|
||||
const before = [left, { id: 1, key: "a", value: "first" }, { id: 2, key: "b", value: "second" }, right];
|
||||
const replacements = [
|
||||
{ id: 2, key: "b", value: "edited-second" },
|
||||
{ id: 1, key: "a", value: "edited-first" },
|
||||
];
|
||||
const after = [left, ...replacements, right];
|
||||
expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]);
|
||||
});
|
||||
|
||||
it("combines removals, append, and a shared-subtree survivor edit", () => {
|
||||
const a = { id: "a", stable: {}, detail: { text: "a" } };
|
||||
const b = { id: "b", stable: {}, detail: { text: "b" } };
|
||||
const c = { id: "c", stable: {}, detail: { text: "c" } };
|
||||
const d = { id: "d", stable: {}, detail: { text: "d" } };
|
||||
const changedC = { id: "c", stable: c.stable, detail: { text: "changed" } };
|
||||
const appended = { id: "e", stable: {}, detail: { text: "e" } };
|
||||
expectDiff({ values: [a, b, c, d] }, { values: [a, changedC, d, appended] }, [
|
||||
["p", ["values"], 1, 1, []],
|
||||
["a", ["values", 1, "detail", "text"], "hanged"],
|
||||
["p", ["values"], 3, 0, [appended]],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([256 * 1024, 1024 * 1024])("does not retain a %i-byte removed-neighbor payload", (size) => {
|
||||
const payload = "x".repeat(size);
|
||||
const retained = [
|
||||
{ id: "a", payload },
|
||||
{ id: "b", payload },
|
||||
{ id: "c", payload },
|
||||
{ id: "d", payload },
|
||||
{ id: "e", payload },
|
||||
];
|
||||
const before = Object.freeze({ values: Object.freeze(retained.slice()) });
|
||||
const after = Object.freeze({ values: Object.freeze([retained[0]!, retained[2]!, retained[4]!]) });
|
||||
const operations = diffRevisions(before as unknown as JsonValue, after as unknown as JsonValue);
|
||||
expect(operations).toEqual([
|
||||
["p", ["values"], 1, 1, []],
|
||||
["p", ["values"], 2, 1, []],
|
||||
]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(100);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
expect(before.values).toEqual(retained);
|
||||
});
|
||||
|
||||
it.each([1_001, 10_000])("keeps push, pop, and middle removal narrow at %,i items", (size) => {
|
||||
const values = Array.from({ length: size }, (_, value) => ({ value }));
|
||||
const appended = { value: size };
|
||||
expectDiff({ values }, { values: [...values, appended] }, [["p", ["values"], size, 0, [appended]]]);
|
||||
expectDiff({ values }, { values: values.slice(0, -1) }, [["p", ["values"], size - 1, 1, []]]);
|
||||
const middle = Math.floor(size / 2);
|
||||
expectDiff({ values }, { values: [...values.slice(0, middle), ...values.slice(middle + 1)] }, [
|
||||
["p", ["values"], middle, 1, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps forty-thousand-row sparse edits narrow", () => {
|
||||
const values = Array.from({ length: 40_000 }, (_, value) => ({ value, stable: { value } }));
|
||||
const after = values.slice();
|
||||
const changed: number[] = [];
|
||||
for (let index = 100; index < after.length; index += 400) {
|
||||
after[index] = { value: -index, stable: values[index]!.stable };
|
||||
changed.push(index);
|
||||
}
|
||||
const operations = diffRevisions({ values }, { values: after });
|
||||
expect(operations).toEqual(changed.map((index) => ["s", ["values", index, "value"], -index]));
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(7_500);
|
||||
expect(applyImmutable({ values }, operations)).toEqual({ values: after });
|
||||
});
|
||||
|
||||
it("keeps a reconstructed large-array leaf edit narrow", () => {
|
||||
const before = Array.from({ length: 1_000 }, (_, value) => ({ value, label: `row-${value}` }));
|
||||
const after = structuredClone(before);
|
||||
after[700]!.label = "changed";
|
||||
const operations = diffRevisions({ values: before }, { values: after });
|
||||
expect(operations).toEqual([["s", ["values", 700, "label"], "changed"]]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(100);
|
||||
expect(applyImmutable({ values: before }, operations)).toEqual({ values: after });
|
||||
});
|
||||
|
||||
it("splices an ambiguous equal-count moved-and-edited gap", () => {
|
||||
const left = { value: "left" };
|
||||
const right = { value: "right" };
|
||||
const before = [left, { id: 1, value: "a" }, { id: 2, value: "b" }, right];
|
||||
const replacements = [
|
||||
{ id: 2, value: "edited" },
|
||||
{ id: 1, value: "also-edited" },
|
||||
];
|
||||
const after = [left, ...replacements, right];
|
||||
expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]);
|
||||
});
|
||||
|
||||
it("keeps a large rotation payload-free", () => {
|
||||
const values = Array.from({ length: 10_000 }, (_, value) => ({ value }));
|
||||
const rotated = [...values.slice(1_000), ...values.slice(0, 1_000)];
|
||||
const operations = diffRevisions({ values }, { values: rotated });
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations[0]?.[0]).toBe("m");
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(60_000);
|
||||
expect(applyImmutable({ values }, operations)).toEqual({ values: rotated });
|
||||
});
|
||||
|
||||
it("encodes five hundred unshifts without snapshotting retained rows", () => {
|
||||
const retained = Array.from({ length: 10_000 }, (_, value) => ({ value, payload: "x".repeat(100) }));
|
||||
const inserted = Array.from({ length: 500 }, (_, value) => ({ value: -value - 1 }));
|
||||
const operations = diffRevisions({ values: retained }, { values: [...inserted, ...retained] });
|
||||
expect(operations).toEqual([["p", ["values"], 0, 0, inserted]]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(20_000);
|
||||
expect(applyImmutable({ values: retained }, operations)).toEqual({ values: [...inserted, ...retained] });
|
||||
});
|
||||
|
||||
it("bounds wide-object operation emission with a root replacement", () => {
|
||||
const before: Record<string, JsonValue> = {};
|
||||
const after: Record<string, JsonValue> = {};
|
||||
for (let index = 0; index < 20_000; index++) {
|
||||
before[`field${index}`] = 0;
|
||||
after[`field${index}`] = 1;
|
||||
}
|
||||
expect(diffRevisions(before, after)).toEqual([["r", after]]);
|
||||
});
|
||||
|
||||
it("bounds a wide normalized array fallback with a root replacement", () => {
|
||||
const before = { values: Array.from({ length: 40_000 }, () => 0) };
|
||||
const after = { values: Array.from({ length: 40_000 }, () => 1) };
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual([["r", after]]);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
});
|
||||
});
|
||||
34
third_party/pi/packages/chord/test/delta-tracker/retention.test.ts
vendored
Normal file
34
third_party/pi/packages/chord/test/delta-tracker/retention.test.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("delta tracker retention across GC jobs", () => {
|
||||
it.each([
|
||||
"aborted-payload",
|
||||
"unadopted-prepared",
|
||||
"draft-proxies",
|
||||
"settled-lifecycle",
|
||||
"obsolete-revisions",
|
||||
"retained-settled-prepared",
|
||||
"retained-settled-change",
|
||||
"retained-settled-proxy",
|
||||
"retained-large-settled-proxy",
|
||||
"retained-large-placement-proxies",
|
||||
"stale-unprepared-change",
|
||||
"same-job-fast-cleanup",
|
||||
"same-job-folded-ops-cleanup",
|
||||
"lifecycle-churn",
|
||||
])(
|
||||
"validates %s retention semantics",
|
||||
(scenario) => {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario],
|
||||
{ encoding: "utf8", timeout: 60_000 },
|
||||
);
|
||||
expect(child.error, child.stderr).toBeUndefined();
|
||||
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
|
||||
},
|
||||
65_000,
|
||||
);
|
||||
});
|
||||
302
third_party/pi/packages/chord/test/delta-tracker/retention.worker.ts
vendored
Normal file
302
third_party/pi/packages/chord/test/delta-tracker/retention.worker.ts
vendored
Normal file
@@ -0,0 +1,302 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { type Tracker, track } from "../../src/delta/index.ts";
|
||||
|
||||
const retainedSettledObjects: object[] = [];
|
||||
const retainedLargePrepared: object[] = [];
|
||||
const retainedSettledChanges: object[] = [];
|
||||
|
||||
async function collect(ref: WeakRef<object>): Promise<void> {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
if (ref.deref() === undefined) return;
|
||||
}
|
||||
assert.fail("released change data is still retained");
|
||||
}
|
||||
|
||||
function abortedPayload(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef<object> {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const reference = new WeakRef(change.state.payload.rows);
|
||||
change.abort();
|
||||
return reference;
|
||||
}
|
||||
|
||||
function unadoptedPrepared(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef<object> {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const prepared = change.prepare();
|
||||
return new WeakRef(prepared.value.payload!.rows);
|
||||
}
|
||||
|
||||
function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef<object> {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: [{ value: 1 }] };
|
||||
const reference = new WeakRef(change.state.payload.rows[0]!);
|
||||
change.abort();
|
||||
return reference;
|
||||
}
|
||||
|
||||
async function assertRetained(reference: WeakRef<object>): Promise<void> {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
assert.notEqual(reference.deref(), undefined, "retained Prepared must retain its immutable revisions");
|
||||
}
|
||||
|
||||
function settledLifecycle(): { tracker: WeakRef<object>; future: WeakRef<object> } {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: [{ value: 1 }] };
|
||||
const retainedPrepared = change.prepare();
|
||||
tracker.adopt(retainedPrepared);
|
||||
retainedSettledObjects.push(change, retainedPrepared);
|
||||
|
||||
const futurePrepared = tracker.prepareReplace({
|
||||
payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) },
|
||||
});
|
||||
tracker.adopt(futurePrepared);
|
||||
return {
|
||||
tracker: new WeakRef(tracker),
|
||||
future: new WeakRef(futurePrepared.value.payload!.rows),
|
||||
};
|
||||
}
|
||||
|
||||
const scenario = process.argv[2];
|
||||
if (scenario === "settled-lifecycle") {
|
||||
const references = settledLifecycle();
|
||||
await collect(references.tracker);
|
||||
await collect(references.future);
|
||||
} else if (scenario === "retained-settled-prepared") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const retain = (): { base: WeakRef<object>; value: WeakRef<object> } => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload.rows[0]!.value = -1;
|
||||
const prepared = change.prepare();
|
||||
const references = {
|
||||
base: new WeakRef(prepared.base.payload.rows),
|
||||
value: new WeakRef(prepared.value.payload.rows),
|
||||
};
|
||||
tracker.adopt(prepared);
|
||||
retainedLargePrepared.push(prepared);
|
||||
return references;
|
||||
};
|
||||
const references = retain();
|
||||
const replace = (): void => {
|
||||
const replacement = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } });
|
||||
tracker.adopt(replacement);
|
||||
};
|
||||
replace();
|
||||
await assertRetained(references.base);
|
||||
await assertRetained(references.value);
|
||||
retainedLargePrepared.length = 0;
|
||||
await collect(references.base);
|
||||
await collect(references.value);
|
||||
} else if (scenario === "retained-settled-change") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const settleChange = (): { base: WeakRef<object>; value: WeakRef<object> } => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload.rows[0]!.value = -1;
|
||||
const prepared = change.prepare();
|
||||
const references = {
|
||||
base: new WeakRef(prepared.base.payload.rows),
|
||||
value: new WeakRef(prepared.value.payload.rows),
|
||||
};
|
||||
tracker.adopt(prepared);
|
||||
retainedSettledChanges.push(change);
|
||||
return references;
|
||||
};
|
||||
const references = settleChange();
|
||||
const replace = (): void => {
|
||||
const replacement = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } });
|
||||
tracker.adopt(replacement);
|
||||
};
|
||||
replace();
|
||||
await collect(references.base);
|
||||
await collect(references.value);
|
||||
assert.equal(retainedSettledChanges.length, 1);
|
||||
} else if (scenario === "retained-settled-proxy") {
|
||||
const tracker = track({
|
||||
child: { value: 0 },
|
||||
payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) },
|
||||
});
|
||||
const obsolete = new WeakRef(tracker.value.payload.rows);
|
||||
const commit = (): object => {
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.child;
|
||||
held.value = 1;
|
||||
const prepared = change.prepare();
|
||||
tracker.adopt(prepared);
|
||||
return held;
|
||||
};
|
||||
retainedSettledChanges.push(commit());
|
||||
const replace = (): void => {
|
||||
const replacement = tracker.prepareReplace({ child: { value: 2 }, payload: { rows: [{ value: 1 }] } });
|
||||
tracker.adopt(replacement);
|
||||
};
|
||||
replace();
|
||||
await collect(obsolete);
|
||||
assert.throws(() => (retainedSettledChanges[0] as { value: number }).value, /settled/);
|
||||
} else if (scenario === "retained-large-settled-proxy") {
|
||||
const tracker = track({ rows: Array.from({ length: 100_000 }, (_, value) => ({ value })) });
|
||||
const obsolete = new WeakRef(tracker.value.rows);
|
||||
const commit = (): object => {
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.rows[0]!;
|
||||
for (let index = 1; index < 5_000; index++) assert.equal(change.state.rows[index]!.value, index);
|
||||
held.value = -1;
|
||||
const prepared = change.prepare();
|
||||
tracker.adopt(prepared);
|
||||
return held;
|
||||
};
|
||||
retainedSettledChanges.push(commit());
|
||||
const replace = (): void => {
|
||||
const replacement = tracker.prepareReplace({ rows: [{ value: 1 }] });
|
||||
tracker.adopt(replacement);
|
||||
};
|
||||
replace();
|
||||
await collect(obsolete);
|
||||
assert.throws(() => (retainedSettledChanges[0] as { value: number }).value, /settled/);
|
||||
} else if (scenario === "retained-large-placement-proxies") {
|
||||
type Root = {
|
||||
child: { value: number };
|
||||
visited: { value: number }[];
|
||||
first: number[] | null;
|
||||
second: number[] | null;
|
||||
rows: Array<number[] | null>;
|
||||
};
|
||||
const run = (kind: "write" | "writes" | "insert" | "override"): WeakRef<object> => {
|
||||
const tracker = track<Root>({
|
||||
child: { value: 0 },
|
||||
visited: Array.from({ length: 5_000 }, (_, value) => ({ value })),
|
||||
first: null,
|
||||
second: null,
|
||||
rows: kind === "override" ? [null] : [],
|
||||
});
|
||||
const change = tracker.beginChange();
|
||||
for (const value of change.state.visited) assert.equal(value.value >= 0, true);
|
||||
let held: object;
|
||||
if (kind === "write" || kind === "writes") {
|
||||
held = change.state.child;
|
||||
change.state.first = Array<number>(50_000).fill(1);
|
||||
if (kind === "writes") change.state.second = Array<number>(50_000).fill(2);
|
||||
} else {
|
||||
held = change.state.rows;
|
||||
if (kind === "insert") change.state.rows.push(Array<number>(50_000).fill(1));
|
||||
else change.state.rows[0] = Array<number>(50_000).fill(1);
|
||||
}
|
||||
const prepared = change.prepare();
|
||||
const payload = kind === "write" || kind === "writes" ? prepared.value.first : prepared.value.rows[0];
|
||||
assert.ok(payload);
|
||||
const reference = new WeakRef(payload);
|
||||
tracker.adopt(prepared);
|
||||
const replacement = tracker.prepareReplace({
|
||||
child: { value: 1 },
|
||||
visited: [],
|
||||
first: null,
|
||||
second: null,
|
||||
rows: [],
|
||||
});
|
||||
tracker.adopt(replacement);
|
||||
retainedSettledChanges.push(held);
|
||||
return reference;
|
||||
};
|
||||
const references = [run("write"), run("writes"), run("insert"), run("override")];
|
||||
for (const reference of references) await collect(reference);
|
||||
for (const held of retainedSettledChanges) assert.throws(() => Reflect.get(held, "length"), /settled/);
|
||||
} else if (scenario === "stale-unprepared-change") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const stale = tracker.beginChange();
|
||||
stale.state.payload.rows[0]!.value = -1;
|
||||
const obsolete = new WeakRef(tracker.value.payload.rows);
|
||||
const replace = (): void => {
|
||||
const winner = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } });
|
||||
tracker.adopt(winner);
|
||||
};
|
||||
replace();
|
||||
assert.throws(() => stale.prepare(), /settled/);
|
||||
retainedSettledChanges.push(stale);
|
||||
await collect(obsolete);
|
||||
assert.equal(retainedSettledChanges.length, 1);
|
||||
} else if (scenario === "same-job-fast-cleanup") {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
const tracker = track({ rows: Array.from({ length: 100_000 }, (_, value) => ({ value })) });
|
||||
global.gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
const commit = (value: number): void => {
|
||||
const change = tracker.beginChange();
|
||||
const draft = change.state;
|
||||
for (let index = 0; index < 4_094; index++) assert.equal(draft.rows[index]!.value, index);
|
||||
draft.rows.at(-1)!.value = value;
|
||||
const prepared = change.prepare();
|
||||
tracker.adopt(prepared);
|
||||
};
|
||||
for (let value = 0; value < 100; value++) commit(-value - 1);
|
||||
global.gc();
|
||||
const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024);
|
||||
assert.ok(retainedMiB < 16, `same-job cleanup retained ${retainedMiB.toFixed(2)} MiB`);
|
||||
assert.equal(tracker.value.rows.at(-1)!.value, -100);
|
||||
} else if (scenario === "same-job-folded-ops-cleanup") {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
const tracker = track(
|
||||
Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, 0])) as Record<string, number>,
|
||||
);
|
||||
global.gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
const commit = (value: number): void => {
|
||||
const change = tracker.beginChange();
|
||||
for (let index = 0; index < 5_000; index++) change.state[`field${index}`] = value;
|
||||
const prepared = change.prepare();
|
||||
assert.equal(prepared.ops.length, 1);
|
||||
assert.equal(prepared.ops[0]![0], "r");
|
||||
tracker.adopt(prepared);
|
||||
};
|
||||
for (let value = 1; value <= 100; value++) commit(value);
|
||||
global.gc();
|
||||
const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024);
|
||||
assert.ok(retainedMiB < 8, `same-job folded operations retained ${retainedMiB.toFixed(2)} MiB`);
|
||||
assert.equal(tracker.value.field0, 100);
|
||||
} else if (scenario === "lifecycle-churn") {
|
||||
const tracker = track({ value: 0 });
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
const change = tracker.beginChange();
|
||||
change.state.value = index;
|
||||
change.abort();
|
||||
}
|
||||
const prepared = tracker.prepareReplace({ value: 1 });
|
||||
tracker.adopt(prepared);
|
||||
assert.deepEqual(tracker.value, { value: 1 });
|
||||
} else if (scenario === "obsolete-revisions") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const oldRoot = new WeakRef(tracker.value.payload.rows);
|
||||
const replace = (value: number): void => {
|
||||
const prepared = tracker.prepareReplace({ payload: { rows: [{ value }] } });
|
||||
tracker.adopt(prepared);
|
||||
};
|
||||
for (let value = 0; value < 100; value++) replace(value);
|
||||
await collect(oldRoot);
|
||||
assert.deepEqual(tracker.value, { payload: { rows: [{ value: 99 }] } });
|
||||
} else {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
let reference: WeakRef<object>;
|
||||
switch (scenario) {
|
||||
case "aborted-payload":
|
||||
reference = abortedPayload(tracker);
|
||||
break;
|
||||
case "unadopted-prepared":
|
||||
reference = unadoptedPrepared(tracker);
|
||||
break;
|
||||
case "draft-proxies":
|
||||
reference = draftProxies(tracker);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown retention scenario: ${scenario}`);
|
||||
}
|
||||
await collect(reference);
|
||||
assert.deepEqual(tracker.value, { payload: null });
|
||||
}
|
||||
console.log(JSON.stringify({ scenario, passed: true }));
|
||||
1507
third_party/pi/packages/chord/test/delta-tracker/tracker.test.ts
vendored
Normal file
1507
third_party/pi/packages/chord/test/delta-tracker/tracker.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
201
third_party/pi/packages/chord/test/delta-traversal.bench.ts
vendored
Normal file
201
third_party/pi/packages/chord/test/delta-traversal.bench.ts
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
/** Cold/full traversal complements the hot-access and replication benchmarks. */
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { track } from "../src/delta/index.ts";
|
||||
|
||||
type Point = { x: number; y: number; pressure: number };
|
||||
type Drawing = { strokes: { id: number; color: string; width: number; points: Point[] }[] };
|
||||
type Mode = "raw" | "proxy" | "delta";
|
||||
const MiB = 1024 * 1024;
|
||||
async function gc(): Promise<void> {
|
||||
if (!global.gc) throw new Error("run with --expose-gc");
|
||||
// WeakRef targets survive their current job. Allow both target collection
|
||||
// and finalizer cleanup in subsequent jobs before sampling retained memory.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
await setImmediate();
|
||||
}
|
||||
|
||||
function fixture(strokes: number, points: number): Drawing {
|
||||
return {
|
||||
strokes: Array.from({ length: strokes }, (_, id) => ({
|
||||
id,
|
||||
color: id % 2 ? "#405060" : "#102030",
|
||||
width: 2,
|
||||
points: Array.from({ length: points }, (_, p) => ({
|
||||
x: p * 3 + (id % 127),
|
||||
y: p * 7 + (id % 131),
|
||||
pressure: (p % 10) / 10,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
function membrane<T extends object>(input: T): T {
|
||||
const cache = new WeakMap<object, object>();
|
||||
const wrap = (value: object): object => {
|
||||
const existing = cache.get(value);
|
||||
if (existing) return existing;
|
||||
const proxy = new Proxy(value, {
|
||||
get(target, key, receiver) {
|
||||
const child: unknown = Reflect.get(target, key, receiver);
|
||||
return child !== null && typeof child === "object" ? wrap(child) : child;
|
||||
},
|
||||
});
|
||||
cache.set(value, proxy);
|
||||
return proxy;
|
||||
};
|
||||
return wrap(input) as T;
|
||||
}
|
||||
function setup(mode: Mode, input: Drawing): { state: Drawing; finish(): void } {
|
||||
if (mode === "delta") {
|
||||
const tracker = track(input);
|
||||
const change = tracker.beginChange();
|
||||
return {
|
||||
state: change.state,
|
||||
finish() {
|
||||
tracker.adopt(change.prepare());
|
||||
},
|
||||
};
|
||||
}
|
||||
return { state: mode === "proxy" ? membrane(input) : input, finish() {} };
|
||||
}
|
||||
// Keep temporary raw inputs out of the measuring frame.
|
||||
function prepare(mode: Mode, strokes: number, points: number) {
|
||||
return setup(mode, fixture(strokes, points));
|
||||
}
|
||||
function traverse(state: Drawing): number {
|
||||
let sum = 0;
|
||||
const strokes = state.strokes;
|
||||
for (let s = 0; s < strokes.length; s++) {
|
||||
const points = strokes[s].points;
|
||||
for (let p = 0; p < points.length; p++) {
|
||||
const point = points[p];
|
||||
sum += point.x + point.y + point.pressure;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
function warm(mode: Mode): void {
|
||||
const t = prepare(mode, 10, 10);
|
||||
for (let i = 0; i < 10; i++) traverse(t.state);
|
||||
t.finish();
|
||||
}
|
||||
function sources() {
|
||||
return Object.fromEntries(
|
||||
[
|
||||
"../src/delta/index.ts",
|
||||
"../src/delta/tracker.ts",
|
||||
"../src/delta/draft.ts",
|
||||
"../src/delta/diff.ts",
|
||||
"../src/delta/revision-validator.ts",
|
||||
"../src/delta/apply-immutable-trusted.ts",
|
||||
"./delta-traversal.bench.ts",
|
||||
].map((path) => [
|
||||
path,
|
||||
createHash("sha256")
|
||||
.update(readFileSync(new URL(path, import.meta.url)))
|
||||
.digest("hex"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
async function measure(mode: Mode, strokes: number, points: number, trial: number) {
|
||||
warm(mode);
|
||||
await gc();
|
||||
const empty = process.memoryUsage().heapUsed;
|
||||
const t = prepare(mode, strokes, points);
|
||||
await gc();
|
||||
const ready = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
const checksum = traverse(t.state);
|
||||
const coldMs = performance.now() - start;
|
||||
const afterColdBeforeGc = process.memoryUsage().heapUsed;
|
||||
await gc();
|
||||
const afterCold = process.memoryUsage().heapUsed;
|
||||
const warmMs: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const begin = performance.now();
|
||||
const check = traverse(t.state);
|
||||
warmMs.push(performance.now() - begin);
|
||||
assert.equal(check, checksum);
|
||||
}
|
||||
let expected = 0;
|
||||
for (let s = 0; s < strokes; s++) {
|
||||
for (let p = 0; p < points; p++) expected += p * 10 + (s % 127) + (s % 131) + (p % 10) / 10;
|
||||
}
|
||||
assert.ok(Math.abs(expected - checksum) < Math.max(1, Math.abs(expected)) * 1e-9);
|
||||
await gc();
|
||||
const retained = process.memoryUsage().heapUsed;
|
||||
assert.equal(t.state.strokes.length, strokes);
|
||||
t.finish();
|
||||
return {
|
||||
mode,
|
||||
strokes,
|
||||
points,
|
||||
trial,
|
||||
coldMs,
|
||||
warmMs,
|
||||
checksum,
|
||||
readyMiB: (ready - empty) / MiB,
|
||||
afterColdMiB: (afterCold - empty) / MiB,
|
||||
retainedMiB: (retained - empty) / MiB,
|
||||
lazyProxyMiB: (afterCold - ready) / MiB,
|
||||
sampledColdPeakMiB: (afterColdBeforeGc - empty) / MiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
sourceHashes: sources(),
|
||||
};
|
||||
}
|
||||
if (process.argv.includes("--worker")) {
|
||||
const c = JSON.parse(process.argv.at(-1)!) as { mode: Mode; strokes: number; points: number; trial: number };
|
||||
console.log(JSON.stringify(await measure(c.mode, c.strokes, c.points, c.trial)));
|
||||
} else {
|
||||
const quick = process.argv.includes("--quick");
|
||||
const outIndex = process.argv.indexOf("--out");
|
||||
const output = outIndex < 0 ? "/tmp/delta-traversal.json" : process.argv[outIndex + 1];
|
||||
const modeIndex = process.argv.indexOf("--modes");
|
||||
const selected = new Set(modeIndex < 0 ? ["raw", "proxy", "delta"] : process.argv[modeIndex + 1].split(","));
|
||||
for (const mode of selected) if (!["raw", "proxy", "delta"].includes(mode)) throw new Error(`unknown mode: ${mode}`);
|
||||
const hashes = sources();
|
||||
const results: Awaited<ReturnType<typeof measure>>[] = [];
|
||||
const failures: unknown[] = [];
|
||||
for (let trial = 0; trial < (quick ? 1 : 3); trial++) {
|
||||
const modes: Mode[] = trial % 2 ? ["delta", "proxy", "raw"] : ["raw", "proxy", "delta"];
|
||||
for (const mode of modes) {
|
||||
if (!selected.has(mode)) continue;
|
||||
assert.deepStrictEqual(sources(), hashes, "source changed during benchmark");
|
||||
const config = { mode, strokes: quick ? 200 : 20000, points: 100, trial };
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=8192",
|
||||
fileURLToPath(import.meta.url),
|
||||
"--worker",
|
||||
JSON.stringify(config),
|
||||
],
|
||||
{ encoding: "utf8", timeout: 180000, maxBuffer: 5 * MiB },
|
||||
);
|
||||
assert.deepStrictEqual(sources(), hashes, "source changed during benchmark");
|
||||
if (child.status !== 0)
|
||||
failures.push({ config, error: String(child.error ?? ""), stderr: child.stderr, stdout: child.stdout });
|
||||
else results.push(JSON.parse(child.stdout));
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify(
|
||||
{ node: process.version, date: new Date().toISOString(), sourceHashes: hashes, results, failures },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(`${trial} ${mode}: ${child.status === 0 ? "ok" : "FAILED"}`);
|
||||
}
|
||||
}
|
||||
console.log(`Results: ${output}; failures: ${failures.length}`);
|
||||
if (failures.length) process.exitCode = 1;
|
||||
}
|
||||
1489
third_party/pi/packages/chord/test/delta.test.ts
vendored
1489
third_party/pi/packages/chord/test/delta.test.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,394 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
combineFacetLoaders,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
defineService,
|
||||
type FacetLoader,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
interface GenerationValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
const LocalGenerationValue = defineService<GenerationValue>("test.experimental.local-generation-value", {
|
||||
local: true,
|
||||
});
|
||||
const RemoteGenerationValue = defineService<GenerationValue>("test.experimental.remote-generation-value");
|
||||
|
||||
const firstFacet = defineFacet({ id: "first", setup() {} });
|
||||
const secondFacet = defineFacet({ id: "second", setup() {} });
|
||||
|
||||
describe("facet loader", () => {
|
||||
test("combines loaded facets in loader order and disposes generations in reverse", async () => {
|
||||
const trace: string[] = [];
|
||||
const first: FacetLoader = {
|
||||
async load() {
|
||||
trace.push("load first");
|
||||
return {
|
||||
facets: [firstFacet],
|
||||
async dispose() {
|
||||
trace.push("dispose first");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const second: FacetLoader = {
|
||||
async load() {
|
||||
trace.push("load second");
|
||||
return {
|
||||
facets: [secondFacet],
|
||||
async dispose() {
|
||||
trace.push("dispose second");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const loaded = await combineFacetLoaders([first, second]).load();
|
||||
expect(loaded.facets).toEqual([firstFacet, secondFacet]);
|
||||
await loaded.dispose();
|
||||
await loaded.dispose();
|
||||
expect(trace).toEqual(["load first", "load second", "dispose second", "dispose first"]);
|
||||
});
|
||||
|
||||
test("cleans up loaded facets when a later loader fails", async () => {
|
||||
const dispose = vi.fn(async () => {});
|
||||
const failure = new Error("load failed");
|
||||
const first: FacetLoader = {
|
||||
async load() {
|
||||
return { facets: [firstFacet], dispose };
|
||||
},
|
||||
};
|
||||
const second: FacetLoader = {
|
||||
async load() {
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(combineFacetLoaders([first, second]).load()).rejects.toBe(failure);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("keeps local and RPC service handles stable when their provider facet reloads", async () => {
|
||||
const trace: string[] = [];
|
||||
let localValue: GenerationValue | undefined;
|
||||
let generation = 0;
|
||||
let reportReplacementStarted!: () => void;
|
||||
const replacementStarted = new Promise<void>((resolve) => {
|
||||
reportReplacementStarted = resolve;
|
||||
});
|
||||
let continueReplacement!: () => void;
|
||||
const replacementCanContinue = new Promise<void>((resolve) => {
|
||||
continueReplacement = resolve;
|
||||
});
|
||||
const consumer = defineFacet({
|
||||
id: "consumer",
|
||||
setup(env) {
|
||||
trace.push("setup consumer");
|
||||
localValue = env.use(LocalGenerationValue);
|
||||
env.onActivate(async () => {
|
||||
trace.push(`activate consumer:${await localValue!.read(BACKGROUND_CONTEXT)}`);
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("deactivate consumer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const loader: FacetLoader = {
|
||||
async load() {
|
||||
const name = generation++ === 0 ? "A" : "B";
|
||||
trace.push(`load ${name}`);
|
||||
return {
|
||||
facets: [
|
||||
defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
trace.push(`setup provider ${name}`);
|
||||
const implementation: GenerationValue = {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
};
|
||||
env.provide(LocalGenerationValue, implementation);
|
||||
env.provide(RemoteGenerationValue, implementation);
|
||||
env.onActivate(async () => {
|
||||
trace.push(`activate provider ${name}`);
|
||||
if (name === "B") {
|
||||
reportReplacementStarted();
|
||||
await replacementCanContinue;
|
||||
}
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate provider ${name}`);
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
async dispose() {
|
||||
trace.push(`unload ${name}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const loadedA = await loader.load();
|
||||
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
|
||||
const originalLocalHandle = localValue!;
|
||||
const localRead = originalLocalHandle.read;
|
||||
const remoteServices = createRemoteServiceBinding({
|
||||
services: [RemoteGenerationValue],
|
||||
transport: createLoopbackServiceTransport(host.services),
|
||||
});
|
||||
const originalRemoteHandle = remoteServices.use(RemoteGenerationValue);
|
||||
const remoteRead = originalRemoteHandle.read;
|
||||
await remoteServices.ready(BACKGROUND_CONTEXT);
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
env.provide(LocalGenerationValue, {
|
||||
async read() {
|
||||
return "invalid";
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("Reloaded facet provider must preserve its service requirements and provisions");
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
const loadedB = await loader.load();
|
||||
const reload = host.reload(loadedB.facets);
|
||||
await replacementStarted;
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
continueReplacement();
|
||||
await reload;
|
||||
await loadedA.dispose();
|
||||
|
||||
expect(localValue).toBe(originalLocalHandle);
|
||||
expect(remoteServices.use(RemoteGenerationValue)).toBe(originalRemoteHandle);
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
|
||||
|
||||
await remoteServices.dispose(BACKGROUND_CONTEXT);
|
||||
await host.dispose();
|
||||
await loadedB.dispose();
|
||||
expect(trace).toEqual([
|
||||
"load A",
|
||||
"setup consumer",
|
||||
"setup provider A",
|
||||
"activate provider A",
|
||||
"activate consumer:A",
|
||||
"load B",
|
||||
"setup provider B",
|
||||
"activate provider B",
|
||||
"deactivate provider A",
|
||||
"unload A",
|
||||
"deactivate consumer",
|
||||
"deactivate provider B",
|
||||
"unload B",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects remote singleton member shape changes before reload cutover", async () => {
|
||||
let retained: GenerationValue | undefined;
|
||||
let providerDisposed = false;
|
||||
const consumer = defineFacet({
|
||||
id: "shape-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(RemoteGenerationValue);
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "shape-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return "A";
|
||||
},
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
providerDisposed = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "shape-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async renamed() {
|
||||
return "B";
|
||||
},
|
||||
} as unknown as GenerationValue);
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("replacement must preserve its member shape");
|
||||
expect(providerDisposed).toBe(false);
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await host.dispose();
|
||||
expect(providerDisposed).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates the host when old cleanup fails after cutover", async () => {
|
||||
const cleanupFailure = new Error("cleanup failed");
|
||||
const provider = (name: string, failCleanup: boolean) =>
|
||||
defineFacet({
|
||||
id: "cleanup-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
if (failCleanup) throw cleanupFailure;
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A", true)] });
|
||||
|
||||
await expect(host.reload([provider("B", false)])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("cleans failed candidate activation in reverse dependency order", async () => {
|
||||
const failure = new Error("consumer activation failed");
|
||||
const trace: string[] = [];
|
||||
const provider = (name: string) =>
|
||||
defineFacet({
|
||||
id: "ordered-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate provider ${name}`);
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate provider ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const consumer = (name: string, fail: boolean) =>
|
||||
defineFacet({
|
||||
id: "ordered-consumer",
|
||||
setup(env) {
|
||||
env.use(RemoteGenerationValue);
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate consumer ${name}`);
|
||||
if (fail) throw failure;
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate consumer ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer("A", false), provider("A")] });
|
||||
trace.length = 0;
|
||||
|
||||
await expect(host.reload([consumer("B", true), provider("B")])).rejects.toBe(failure);
|
||||
expect(trace).toEqual([
|
||||
"activate provider B",
|
||||
"activate consumer B",
|
||||
"deactivate consumer B",
|
||||
"deactivate provider B",
|
||||
]);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("terminates the host when replacement publication fails after cutover", async () => {
|
||||
const publicationFailure = new Error("publication failed");
|
||||
const provider = (name: string) =>
|
||||
defineFacet({
|
||||
id: "publication-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(RemoteGenerationValue.id, "singleton", () => {
|
||||
throw publicationFailure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("keeps the old generation active when replacement activation fails before cutover", async () => {
|
||||
const failure = new Error("replacement activation failed");
|
||||
const trace: string[] = [];
|
||||
let retained: GenerationValue | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "terminal-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(RemoteGenerationValue);
|
||||
env.onDeactivate(() => {
|
||||
trace.push("deactivate consumer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (name: string, fail: boolean) =>
|
||||
defineFacet({
|
||||
id: "terminal-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate ${name}`);
|
||||
if (fail) throw failure;
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A", false)] });
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await expect(host.reload([provider("B", true)])).rejects.toBe(failure);
|
||||
expect(trace).toEqual(["activate A", "activate B", "deactivate B"]);
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await host.reload([]);
|
||||
await host.dispose();
|
||||
expect(trace).toEqual(["activate A", "activate B", "deactivate B", "deactivate consumer", "deactivate A"]);
|
||||
});
|
||||
|
||||
test("creates a reusable static loader", async () => {
|
||||
const loader = createStaticFacetLoader([firstFacet]);
|
||||
const first = await loader.load();
|
||||
const second = await loader.load();
|
||||
expect(first.facets).toEqual([firstFacet]);
|
||||
expect(second.facets).toEqual([firstFacet]);
|
||||
await Promise.all([first.dispose(), second.dispose()]);
|
||||
});
|
||||
});
|
||||
673
third_party/pi/packages/chord/test/facets.test.ts
vendored
673
third_party/pi/packages/chord/test/facets.test.ts
vendored
@@ -1,673 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
defineFacet,
|
||||
defineService,
|
||||
type MutableReplicatedState,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceSource,
|
||||
type ReplicatedState,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
interface Source {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface Projection {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface KeyedValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface Watched {
|
||||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
interface HostValues {
|
||||
readonly name: string;
|
||||
readonly use: string;
|
||||
}
|
||||
|
||||
interface LocalKeyedValue {
|
||||
readonly metadata: Map<string, string>;
|
||||
read(): string;
|
||||
}
|
||||
|
||||
interface LeftValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface RightValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface CombinedValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
const Source = defineService<Source>("test.experimental.source");
|
||||
const Projection = defineService<Projection>("test.experimental.projection");
|
||||
const KeyedValue = defineService<KeyedValue>("test.experimental.keyed-value");
|
||||
const Watched = defineService<Watched>("test.experimental.watched");
|
||||
const HostValues = defineService<HostValues>("test.experimental.host-values", { local: true });
|
||||
const LocalKeyedValue = defineService<LocalKeyedValue>("test.experimental.local-keyed-value", { local: true });
|
||||
const LeftValue = defineService<LeftValue>("test.experimental.left-value");
|
||||
const RightValue = defineService<RightValue>("test.experimental.right-value");
|
||||
const CombinedValue = defineService<CombinedValue>("test.experimental.combined-value");
|
||||
|
||||
describe("facet host", () => {
|
||||
test("discovers setup dependencies before connecting stable service handles", async () => {
|
||||
const trace: string[] = [];
|
||||
let sourceHandle: Source | undefined;
|
||||
const projection = defineFacet({
|
||||
id: "projection",
|
||||
setup(env) {
|
||||
trace.push("setup projection");
|
||||
sourceHandle = env.use(Source);
|
||||
expect(() => sourceHandle!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet projection service handles cannot be used while setting_up",
|
||||
);
|
||||
env.provide(Projection, {
|
||||
read: (context) => sourceHandle!.read(context),
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate projection");
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("dispose projection");
|
||||
});
|
||||
},
|
||||
});
|
||||
const source = defineFacet({
|
||||
id: "source",
|
||||
setup(env) {
|
||||
trace.push("setup source");
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "value";
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate source");
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("dispose source");
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [projection, source] });
|
||||
|
||||
expect(trace).toEqual(["setup projection", "setup source", "activate source", "activate projection"]);
|
||||
expect(await sourceHandle!.read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
expect(await host.services.use(Projection).read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
|
||||
await host.dispose();
|
||||
expect(trace.slice(-2)).toEqual(["dispose projection", "dispose source"]);
|
||||
});
|
||||
|
||||
test("connects keyed observations only when the observing facet activates", async () => {
|
||||
const trace: string[] = [];
|
||||
const observer = defineFacet({
|
||||
id: "observer",
|
||||
setup(env) {
|
||||
env.observe(KeyedValue, async (service, context) => {
|
||||
trace.push(`observe ${await service.read(context)}`);
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate observer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
trace.push("activate provider");
|
||||
values.spawn("one", {
|
||||
async read() {
|
||||
return "one";
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [observer, provider] });
|
||||
await vi.waitFor(() => expect(trace).toContain("observe one"));
|
||||
|
||||
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
|
||||
const remoteServices = createRemoteServiceBinding({
|
||||
services: [KeyedValue],
|
||||
transport: createLoopbackServiceTransport(host.services),
|
||||
});
|
||||
const remoteValues: string[] = [];
|
||||
remoteServices.observe(KeyedValue, async (service, context) => {
|
||||
remoteValues.push(await service.read(context));
|
||||
});
|
||||
await remoteServices.ready(BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => expect(remoteValues).toEqual(["one"]));
|
||||
|
||||
await remoteServices.dispose(BACKGROUND_CONTEXT);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("routes remotely exposable keyed services through the host provider", async () => {
|
||||
const observed: Array<{ service: KeyedValue; context: Context }> = [];
|
||||
const consumer = defineFacet({
|
||||
id: "remote-keyed-consumer",
|
||||
setup(env) {
|
||||
env.observe(KeyedValue, (service, context) => {
|
||||
observed.push({ service, context });
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "remote-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A")] });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
const first = observed[0]!;
|
||||
await expect(first.service.read(first.context)).resolves.toBe("A");
|
||||
|
||||
await host.reload([provider("B")]);
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(first.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => first.service.read(first.context)).toThrow("observation is closed");
|
||||
await expect(observed[1]!.service.read(observed[1]!.context)).resolves.toBe("B");
|
||||
|
||||
await host.dispose();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates the host when keyed replacement publication fails", async () => {
|
||||
const failure = new Error("spawn publication failed");
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "failing-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
|
||||
if (update.type === "spawned") throw failure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
expect(() => host.services.use(KeyedValue)).toThrow("Remote service provider is disposed");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("terminates the host when keyed retirement publication fails", async () => {
|
||||
const failure = new Error("close publication failed");
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "failing-keyed-retirement-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
|
||||
if (update.type === "closed") throw failure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("keeps unrestricted local keyed services process-local across provider reloads", async () => {
|
||||
const observed: Array<{ service: LocalKeyedValue; context: Context }> = [];
|
||||
const consumer = defineFacet({
|
||||
id: "local-keyed-consumer",
|
||||
setup(env) {
|
||||
env.observe(LocalKeyedValue, (service, context) => {
|
||||
observed.push({ service, context });
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "local-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(LocalKeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
metadata: new Map([["value", value]]),
|
||||
read: () => value,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A")] });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
const first = observed[0]!;
|
||||
expect(first.service.read()).toBe("A");
|
||||
expect(first.service.metadata.get("value")).toBe("A");
|
||||
expect(host.services.catalogue).not.toContainEqual({ serviceId: LocalKeyedValue.id, mode: "keyed" });
|
||||
expect(() => host.services.use(LocalKeyedValue)).toThrow("process-local");
|
||||
|
||||
await host.reload([provider("B")]);
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(first.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => first.service.read()).toThrow(`Keyed service ${LocalKeyedValue.id} observation is closed`);
|
||||
expect(observed[1]!.service.read()).toBe("B");
|
||||
expect(observed[1]!.service.metadata.get("value")).toBe("B");
|
||||
|
||||
await host.dispose();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps remotely exposable local state replicas stable across provider reloads", async () => {
|
||||
const sources: MutableReplicatedState<{ value: number }>[] = [];
|
||||
const revisions: number[] = [];
|
||||
let watched: Watched | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "state-consumer",
|
||||
setup(env) {
|
||||
watched = env.use(Watched);
|
||||
env.onActivate(() => {
|
||||
env.own(watched!.state.subscribe(({ value }) => revisions.push(value)));
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: number) =>
|
||||
defineFacet({
|
||||
id: "state-provider",
|
||||
setup(env) {
|
||||
const state = env.replicatedState({ value });
|
||||
sources.push(state);
|
||||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider(1)] });
|
||||
const retainedState = watched!.state;
|
||||
expect(retainedState.value).toEqual({ value: 1 });
|
||||
expect(revisions).toEqual([1]);
|
||||
|
||||
await host.reload([provider(2)]);
|
||||
expect(watched!.state).toBe(retainedState);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
sources[0]!.state.value = 3;
|
||||
sources[0]!.publish(BACKGROUND_CONTEXT);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await host.dispose();
|
||||
expect(() => retainedState.value).toThrow("Facet state-consumer service handles cannot be used while dead");
|
||||
});
|
||||
|
||||
test("scopes singleton service views to each facet lifecycle", async () => {
|
||||
const consumerHandles: Source[] = [];
|
||||
const cleanupValues: string[] = [];
|
||||
let peerHandle: Source | undefined;
|
||||
const consumer = (generation: string) =>
|
||||
defineFacet({
|
||||
id: "scoped-consumer",
|
||||
setup(env) {
|
||||
const source = env.use(Source);
|
||||
expect(env.use(Source)).toBe(source);
|
||||
consumerHandles.push(source);
|
||||
expect(() => source.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while setting_up",
|
||||
);
|
||||
env.onDeactivate(async () => {
|
||||
cleanupValues.push(`${generation}:${await source.read(BACKGROUND_CONTEXT)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const peer = defineFacet({
|
||||
id: "peer-consumer",
|
||||
setup(env) {
|
||||
peerHandle = env.use(Source);
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "scoped-provider",
|
||||
setup(env) {
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "value";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer("A"), peer, provider] });
|
||||
expect(consumerHandles[0]).not.toBe(peerHandle);
|
||||
const retainedOldRead = consumerHandles[0]!.read;
|
||||
|
||||
await host.reload([consumer("B")]);
|
||||
expect(cleanupValues).toEqual(["A:value"]);
|
||||
expect(Object.is(consumerHandles[1], consumerHandles[0])).toBe(false);
|
||||
expect(() => consumerHandles[0]!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
expect(() => retainedOldRead(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
await expect(consumerHandles[1]!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
|
||||
await expect(peerHandle!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
|
||||
|
||||
await host.dispose();
|
||||
expect(cleanupValues).toEqual(["A:value", "B:value"]);
|
||||
expect(() => consumerHandles[1]!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
expect(() => peerHandle!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet peer-consumer service handles cannot be used while dead",
|
||||
);
|
||||
});
|
||||
|
||||
test("owns resources registered during activation", async () => {
|
||||
let state: MutableReplicatedState<{ value: number }> | undefined;
|
||||
let deliveries = 0;
|
||||
const consumer = defineFacet({
|
||||
id: "consumer",
|
||||
setup(env) {
|
||||
const watched = env.use(Watched);
|
||||
env.onActivate(() => {
|
||||
env.own(
|
||||
watched.state.subscribe(() => {
|
||||
deliveries += 1;
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
state = env.replicatedState({ value: 0 });
|
||||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(deliveries).toBe(1);
|
||||
state!.state.value = 1;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
|
||||
await host.dispose();
|
||||
state!.state.value = 2;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
test("provides arbitrary host services through the facet graph", async () => {
|
||||
const values: HostValues = { name: "session", use: "host value" };
|
||||
const consumer = defineFacet({
|
||||
id: "host-service-consumer",
|
||||
setup(env) {
|
||||
const hostValues = env.use(HostValues);
|
||||
expect(() => hostValues.use).toThrow(
|
||||
"Facet host-service-consumer service handles cannot be used while setting_up",
|
||||
);
|
||||
env.onActivate(() => {
|
||||
expect(hostValues).not.toBe(values);
|
||||
expect(hostValues.name).toBe("session");
|
||||
expect(hostValues.use).toBe("host value");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "host-service-provider",
|
||||
setup(env) {
|
||||
env.provide(HostValues, values);
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(() => host.services.use(HostValues)).toThrow("Service test.experimental.host-values is process-local");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("combines connected services and facet-provided services in one host", async () => {
|
||||
const leftProvider = new RemoteServiceProvider([LeftValue]);
|
||||
leftProvider.provide(LeftValue, {
|
||||
async read() {
|
||||
return "left";
|
||||
},
|
||||
});
|
||||
const rightProvider = new RemoteServiceProvider([RightValue]);
|
||||
rightProvider.provide(RightValue, {
|
||||
async read() {
|
||||
return "right";
|
||||
},
|
||||
});
|
||||
const leftNamespace = createRemoteServiceBinding({
|
||||
services: [LeftValue],
|
||||
transport: createLoopbackServiceTransport(leftProvider),
|
||||
bound: false,
|
||||
});
|
||||
const rightNamespace = createRemoteServiceBinding({
|
||||
services: [RightValue],
|
||||
transport: createLoopbackServiceTransport(rightProvider),
|
||||
bound: false,
|
||||
});
|
||||
const serviceSources = [
|
||||
{ namespace: leftNamespace, provider: leftProvider },
|
||||
{ namespace: rightNamespace, provider: rightProvider },
|
||||
].map(({ namespace, provider }) => {
|
||||
const ready = namespace.ready.bind(namespace);
|
||||
return Object.assign(namespace, {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return provider.catalogue;
|
||||
},
|
||||
open() {
|
||||
return namespace;
|
||||
},
|
||||
async ready(context: Context) {
|
||||
await namespace.rebind(true, context);
|
||||
await ready(context);
|
||||
},
|
||||
});
|
||||
});
|
||||
const facet = defineFacet({
|
||||
id: "combined",
|
||||
setup(env) {
|
||||
const left = env.use(LeftValue);
|
||||
const right = env.use(RightValue);
|
||||
env.provide(CombinedValue, {
|
||||
async read(context) {
|
||||
return `${await left.read(context)} ${await right.read(context)}`;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const host = await createFacetHost({ facets: [facet], serviceSources });
|
||||
await expect(host.services.use(CombinedValue).read(BACKGROUND_CONTEXT)).resolves.toBe("left right");
|
||||
|
||||
await host.dispose();
|
||||
await Promise.all([leftNamespace.dispose(BACKGROUND_CONTEXT), rightNamespace.dispose(BACKGROUND_CONTEXT)]);
|
||||
leftProvider.dispose();
|
||||
rightProvider.dispose();
|
||||
});
|
||||
|
||||
test("rejects a service offered by multiple sources", async () => {
|
||||
const duplicate = {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return [{ serviceId: LeftValue.id, mode: "singleton" as const }];
|
||||
},
|
||||
open() {
|
||||
throw new Error("Ambiguous sources must not open");
|
||||
},
|
||||
} satisfies RemoteServiceSource;
|
||||
const consumer = defineFacet({
|
||||
id: "duplicate-consumer",
|
||||
setup(env) {
|
||||
env.use(LeftValue);
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [consumer], serviceSources: [duplicate, duplicate] })).rejects.toThrow(
|
||||
`Facet host service ${LeftValue.id} is offered by more than one source`,
|
||||
);
|
||||
});
|
||||
|
||||
test("reopens source bindings from changed catalogues for a replacement generation", async () => {
|
||||
const leftProvider = new RemoteServiceProvider([LeftValue]);
|
||||
leftProvider.provide(LeftValue, {
|
||||
async read() {
|
||||
return "left";
|
||||
},
|
||||
});
|
||||
const rightProvider = new RemoteServiceProvider([RightValue]);
|
||||
rightProvider.provide(RightValue, {
|
||||
async read() {
|
||||
return "right";
|
||||
},
|
||||
});
|
||||
let currentProvider = leftProvider;
|
||||
let opened = 0;
|
||||
let disposed = 0;
|
||||
const source = {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return currentProvider.catalogue;
|
||||
},
|
||||
open(options) {
|
||||
opened += 1;
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: options.services,
|
||||
transport: createLoopbackServiceTransport(currentProvider),
|
||||
bound: false,
|
||||
assertAccess: options.assertAccess,
|
||||
onError: options.onError,
|
||||
});
|
||||
const ready = namespace.ready.bind(namespace);
|
||||
const dispose = namespace.dispose.bind(namespace);
|
||||
return Object.assign(namespace, {
|
||||
async ready(context: Context) {
|
||||
await namespace.rebind(true, context);
|
||||
await ready(context);
|
||||
},
|
||||
async dispose(context: Context) {
|
||||
disposed += 1;
|
||||
await dispose(context);
|
||||
},
|
||||
});
|
||||
},
|
||||
} satisfies RemoteServiceSource;
|
||||
const values: string[] = [];
|
||||
const leftConsumer = defineFacet({
|
||||
id: "left-consumer",
|
||||
setup(env) {
|
||||
const left = env.use(LeftValue);
|
||||
env.onActivate(async () => {
|
||||
values.push(await left.read(BACKGROUND_CONTEXT));
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await createFacetHost({ facets: [leftConsumer], serviceSources: [source] });
|
||||
await first.dispose();
|
||||
|
||||
currentProvider = rightProvider;
|
||||
const rightConsumer = defineFacet({
|
||||
id: "right-consumer",
|
||||
setup(env) {
|
||||
const right = env.use(RightValue);
|
||||
env.onActivate(async () => {
|
||||
values.push(await right.read(BACKGROUND_CONTEXT));
|
||||
});
|
||||
},
|
||||
});
|
||||
const second = await createFacetHost({ facets: [rightConsumer], serviceSources: [source] });
|
||||
await second.dispose();
|
||||
|
||||
expect(values).toEqual(["left", "right"]);
|
||||
expect(opened).toBe(2);
|
||||
expect(disposed).toBe(2);
|
||||
leftProvider.dispose();
|
||||
rightProvider.dispose();
|
||||
});
|
||||
|
||||
test("rejects missing dependencies, cycles, and asynchronous setup", async () => {
|
||||
let activated = false;
|
||||
const missing = defineFacet({
|
||||
id: "missing",
|
||||
setup(env) {
|
||||
env.use(Source);
|
||||
env.onActivate(() => {
|
||||
activated = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [missing] })).rejects.toThrow(
|
||||
"Facet missing requires local/test.experimental.source/singleton, but no facet provides it",
|
||||
);
|
||||
expect(activated).toBe(false);
|
||||
|
||||
const first = defineFacet({
|
||||
id: "first",
|
||||
setup(env) {
|
||||
env.use(Projection);
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "first";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const second = defineFacet({
|
||||
id: "second",
|
||||
setup(env) {
|
||||
env.use(Source);
|
||||
env.provide(Projection, {
|
||||
async read() {
|
||||
return "second";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [first, second] })).rejects.toThrow(
|
||||
"Facet dependency cycle: first, second",
|
||||
);
|
||||
|
||||
const asynchronous = defineFacet({
|
||||
id: "asynchronous",
|
||||
async setup() {
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [asynchronous] })).rejects.toThrow(
|
||||
"Facet asynchronous setup must be synchronous",
|
||||
);
|
||||
});
|
||||
});
|
||||
73
third_party/pi/packages/chord/test/fixtures/counting.ts
vendored
Normal file
73
third_party/pi/packages/chord/test/fixtures/counting.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
// A link wrapper that counts what crosses it, per direction: frames by type and bytes (JSON text length). It can also
|
||||
// hold back the acknowledgements its own side sends (`paused`), to fill the peer's flow-control window.
|
||||
import type { Frame, Link } from "../../src/index.ts";
|
||||
|
||||
export class CountingLink implements Link {
|
||||
readonly inner: Link;
|
||||
/** Frames this side sent, in order. */
|
||||
readonly sent: Frame[] = [];
|
||||
/** Frames this side received, in order. */
|
||||
readonly received: Frame[] = [];
|
||||
sentBytes = 0;
|
||||
receivedBytes = 0;
|
||||
/** Acknowledgements are held back (the peer's window fills up). */
|
||||
paused = false;
|
||||
#held: Frame | undefined;
|
||||
|
||||
constructor(inner: Link) {
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.inner.closed;
|
||||
}
|
||||
|
||||
send(frame: Frame): number | undefined {
|
||||
if (frame.t === "ack" && this.paused) {
|
||||
this.#held = frame;
|
||||
return 0;
|
||||
}
|
||||
this.sent.push(frame);
|
||||
this.sentBytes += JSON.stringify(frame).length;
|
||||
return this.inner.send(frame);
|
||||
}
|
||||
|
||||
/** Lets the held acknowledgement through and stops holding them. */
|
||||
release(): void {
|
||||
this.paused = false;
|
||||
const held = this.#held;
|
||||
this.#held = undefined;
|
||||
if (held !== undefined) this.inner.send(held);
|
||||
}
|
||||
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.inner.receive((frame) => {
|
||||
this.received.push(frame);
|
||||
this.receivedBytes += JSON.stringify(frame).length;
|
||||
handler(frame);
|
||||
});
|
||||
}
|
||||
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
this.inner.onClose(handler);
|
||||
}
|
||||
|
||||
close(reason?: string): void {
|
||||
this.inner.close(reason);
|
||||
}
|
||||
|
||||
/** Frames of type `t` this side sent (`sent`) or received (`received`). */
|
||||
count(direction: "sent" | "received", t: Frame["t"]): number {
|
||||
return this[direction].filter((frame) => frame.t === t).length;
|
||||
}
|
||||
|
||||
/** Frames this side sent or received, except pings, pongs and acknowledgements. */
|
||||
traffic(direction: "sent" | "received"): Frame[] {
|
||||
return this[direction].filter((frame) => frame.t !== "ping" && frame.t !== "pong" && frame.t !== "ack");
|
||||
}
|
||||
}
|
||||
|
||||
export type StateFrame = Extract<Frame, { t: "state" }>;
|
||||
|
||||
export const stateFrames = (frames: readonly Frame[]): StateFrame[] =>
|
||||
frames.filter((frame): frame is StateFrame => frame.t === "state");
|
||||
11
third_party/pi/packages/chord/test/fixtures/echo.ts
vendored
Normal file
11
third_party/pi/packages/chord/test/fixtures/echo.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Type } from "typebox";
|
||||
import { hostNamed, method, remote } from "../../src/index.ts";
|
||||
|
||||
export const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("w1"),
|
||||
variants: {
|
||||
main: { methods: { echo: method(Type.String(), Type.String()), pid: method(undefined, Type.Number()) } },
|
||||
},
|
||||
});
|
||||
81
third_party/pi/packages/chord/test/fixtures/loader.ts
vendored
Normal file
81
third_party/pi/packages/chord/test/fixtures/loader.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
// Runs the node loader outside vitest: its module runner bypasses the Node resolve hooks the loader installs.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Type } from "typebox";
|
||||
import { createHost, local, method } from "../../src/index.ts";
|
||||
import { nodeLoader } from "../../src/loader.ts";
|
||||
|
||||
// Plugin modules live under the package so bare imports (typebox) resolve; removed afterwards.
|
||||
const dir = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}/`, import.meta.url));
|
||||
// Separate from `dir`, which becomes a reload root once its facet loads.
|
||||
const plugins = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}-order/`, import.meta.url));
|
||||
const chord = new URL("../../src/index.ts", import.meta.url).href;
|
||||
|
||||
// The test's own copy of the plugin's contract: binding is by description, not object identity.
|
||||
const Hello = local("t.hello", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { greet: method(Type.String(), Type.String()) },
|
||||
});
|
||||
|
||||
const facetSource = `
|
||||
import { Type } from "typebox";
|
||||
import { defineFacet, local, method } from ${JSON.stringify(chord)};
|
||||
import { greeting } from "./greeting.ts";
|
||||
|
||||
const Hello = local("t.hello", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { greet: method(Type.String(), Type.String()) },
|
||||
});
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Hello, () => ({ greet: (name) => greeting + " " + name }));
|
||||
});
|
||||
`;
|
||||
|
||||
const writeGreeting = (text: string) =>
|
||||
writeFileSync(`${dir}greeting.ts`, `export const greeting = ${JSON.stringify(text)};\n`);
|
||||
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(`${dir}facet.ts`, facetSource);
|
||||
writeGreeting("v1");
|
||||
const host = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} });
|
||||
await host.load([`${dir}facet.ts`]);
|
||||
const ref = host.hold(Hello, {});
|
||||
assert.equal(await ref.greet("x"), "v1 x");
|
||||
writeGreeting("v2");
|
||||
const report = await host.reload([`${dir}facet.ts`]);
|
||||
assert.deepEqual(report.changed, ["t.hello"]);
|
||||
assert.equal(await ref.greet("x"), "v2 x");
|
||||
await host.close();
|
||||
|
||||
// A facet listed first that imports a later facet's directory shares that facet's copy of the module.
|
||||
const Shared = local("t.shared", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { same: method(undefined, Type.Boolean()) },
|
||||
});
|
||||
mkdirSync(`${plugins}a`, { recursive: true });
|
||||
mkdirSync(`${plugins}b`, { recursive: true });
|
||||
writeFileSync(`${plugins}b/state.ts`, "export const state = {};\n");
|
||||
writeFileSync(
|
||||
`${plugins}b/facet.ts`,
|
||||
`import { defineFacet } from ${JSON.stringify(chord)};\nimport { state } from "./state.ts";\nexport const own = state;\nexport default defineFacet(() => {});\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
`${plugins}a/facet.ts`,
|
||||
`import { Type } from "typebox";\nimport { defineFacet, local, method } from ${JSON.stringify(chord)};\nimport { state } from "../b/state.ts";\n` +
|
||||
`const Shared = local("t.shared", { version: 1, input: Type.Object({}), members: { same: method(undefined, Type.Boolean()) } });\n` +
|
||||
`export default defineFacet((host) => host.serve(Shared, () => ({ same: async () => (await import(new URL("../b/facet.ts", import.meta.url).href)).own === state })));\n`,
|
||||
);
|
||||
const ordered = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} });
|
||||
await ordered.load([`${plugins}a/facet.ts`, `${plugins}b/facet.ts`]);
|
||||
assert.equal(await ordered.hold(Shared, {}).same(), true);
|
||||
await ordered.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(plugins, { recursive: true, force: true });
|
||||
}
|
||||
41
third_party/pi/packages/chord/test/fixtures/worker.ts
vendored
Normal file
41
third_party/pi/packages/chord/test/fixtures/worker.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
// Child host for manager tests. FIXTURE_MODE: normal | ignore-shutdown | fatal.
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createHost, type IpcChannel, ipcLink } from "../../src/index.ts";
|
||||
import { childControl } from "../../src/manager.ts";
|
||||
import { Echo } from "./echo.ts";
|
||||
|
||||
const mode = process.env.FIXTURE_MODE ?? "normal";
|
||||
const control = childControl();
|
||||
if (mode === "fatal") {
|
||||
control.fatal(new Error("fixture refuses to start"));
|
||||
setTimeout(() => process.exit(1), 200);
|
||||
} else {
|
||||
// Before anything that owns data: the kernel lock (if the manager configured one).
|
||||
await control.lock().catch(() => {
|
||||
setTimeout(() => process.exit(1), 200);
|
||||
return new Promise<never>(() => {});
|
||||
});
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: process.env.HOST_NAME ?? "worker",
|
||||
upstream: { link: ipcLink(process as unknown as IpcChannel), principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const init = (await control.init) as { greeting: string };
|
||||
host.serve(Echo, () => ({
|
||||
variant: "main",
|
||||
echo: (text: string) => `${init.greeting} ${text} from ${process.pid}`,
|
||||
pid: () => process.pid,
|
||||
}));
|
||||
if (process.env.GRANDCHILD_FILE) {
|
||||
const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
||||
writeFileSync(process.env.GRANDCHILD_FILE, String(grandchild.pid));
|
||||
}
|
||||
control.onShutdown(async () => {
|
||||
if (mode === "ignore-shutdown") return;
|
||||
await host.close();
|
||||
process.exit(0);
|
||||
});
|
||||
control.ready();
|
||||
}
|
||||
271
third_party/pi/packages/chord/test/flow-units.test.ts
vendored
Normal file
271
third_party/pi/packages/chord/test/flow-units.test.ts
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
// Entry 8: flow control with units. First units pass the window, so a get never waits behind other streams; waiting
|
||||
// ops are capped per link; relays ask downstream for a reset when they drop ops.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { HostImpl } from "../src/host.ts";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 400): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Blob = remote("t.e8-blob", {
|
||||
version: 1,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { lines: Type.Array(Type.String()) },
|
||||
methods: { push: method(Type.String()), ping: method(undefined, Type.String()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** About `kib` KiB of lines. */
|
||||
const lines = (kib: number, tag: string) => Array.from({ length: kib }, (_, i) => `${tag}${i}:${"x".repeat(1000)}`);
|
||||
|
||||
function serveBlob(host: Host, size: (name: string) => number): void {
|
||||
host.serve(Blob, ({ name }, _principal, self) => {
|
||||
const state = self.state("lines", Type.Array(Type.String()), lines(size(name), name));
|
||||
return {
|
||||
variant: "main",
|
||||
lines: state,
|
||||
push: (line: string, ctx: Ctx) => {
|
||||
state.change(ctx, (draft) => {
|
||||
draft.push(line);
|
||||
});
|
||||
},
|
||||
ping: () => "pong",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function client(server: Host, window: number, pendingCap?: number) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, window, ...(pendingCap === undefined ? {} : { pendingCap }) });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const connection = () => [...(server as HostImpl).connections].find((candidate) => candidate.role === "downstream")!;
|
||||
return { host, link, connection };
|
||||
}
|
||||
|
||||
describe("flow control with units", () => {
|
||||
it("with large streams waiting behind a full window, a new get resolves at once and calls are answered", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, (name) => (name === "small" ? 1 : 500));
|
||||
const { host, link } = client(server, 64 * 1024);
|
||||
const bigs = ["a", "b", "c"].map((name) => host.hold(Blob, { name }));
|
||||
await Promise.all(bigs.map((big) => big.ready));
|
||||
link.paused = true;
|
||||
// Changes to every large stream now wait for acknowledgements that do not come.
|
||||
for (const big of bigs) for (let i = 0; i < 50; i++) await big.push(`${i}:${"y".repeat(2000)}`);
|
||||
const small = host.hold(Blob, { name: "small" });
|
||||
await small.ready;
|
||||
assert.equal(small.lines.value.length, 1);
|
||||
assert.equal(await bigs[0]!.ping(), "pong");
|
||||
assert.ok(bigs[0]!.lines.value.length < 550, "the large streams still wait");
|
||||
link.release();
|
||||
await until(() => bigs.every((big) => big.lines.value.length === 550));
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a snapshot larger than the window arrives whole; later changes wait for acknowledgements", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, () => 300);
|
||||
const { host, link } = client(server, 16 * 1024);
|
||||
link.paused = true;
|
||||
const big = host.hold(Blob, { name: "chat" });
|
||||
await big.ready;
|
||||
assert.equal(big.lines.value.length, 300);
|
||||
for (let i = 0; i < 40; i++) await big.push(`${i}:${"z".repeat(1000)}`);
|
||||
assert.ok(big.lines.value.length < 340);
|
||||
link.release();
|
||||
await until(() => big.lines.value.length === 340);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a link that reports no sizes (IPC) never holds state back", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, () => 1);
|
||||
const [down, up] = memoryLinkPair();
|
||||
// A link without sizes: what Node's IPC channel is.
|
||||
const unsized: Link = {
|
||||
get closed() {
|
||||
return down.closed;
|
||||
},
|
||||
send: (frame: Frame) => {
|
||||
down.send(frame);
|
||||
return undefined;
|
||||
},
|
||||
receive: (handler) => down.receive(handler),
|
||||
onClose: (handler) => down.onClose(handler),
|
||||
close: (reason) => down.close(reason),
|
||||
};
|
||||
server.accept(unsized, { principal: { user: "u" }, window: 1 });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "worker",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
link.paused = true;
|
||||
const blob = host.hold(Blob, { name: "x" });
|
||||
await blob.ready;
|
||||
for (let i = 0; i < 20; i++) await blob.push(`${i}`);
|
||||
await until(() => blob.lines.value.length === 21);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("waiting ops stay under the per-link cap however many streams stall; each then gets one reset", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, () => 1);
|
||||
const cap = 8 * 1024;
|
||||
const { host, link, connection } = client(server, 1024, cap);
|
||||
const K = 6;
|
||||
const blobs = Array.from({ length: K }, (_, i) => host.hold(Blob, { name: `s${i}` }));
|
||||
await Promise.all(blobs.map((blob) => blob.ready));
|
||||
link.paused = true;
|
||||
let peak = 0;
|
||||
for (let round = 0; round < 30; round++) {
|
||||
for (const blob of blobs) await blob.push(`${round}:${"w".repeat(200)}`);
|
||||
peak = Math.max(peak, connection().waiting.bytes);
|
||||
}
|
||||
assert.ok(peak <= cap, `waiting ops ${peak} exceed the cap ${cap}`);
|
||||
const before = link.received.length;
|
||||
link.release();
|
||||
await until(() => blobs.every((blob) => blob.lines.value.length === 31));
|
||||
const resets = stateFrames(link.received.slice(before)).filter((frame) => frame.as === "reset");
|
||||
const per = new Map<number, number>();
|
||||
for (const frame of resets) per.set(frame.hold, (per.get(frame.hold) ?? 0) + 1);
|
||||
assert.ok(resets.length >= 1, "dropped streams came back as resets");
|
||||
for (const count of per.values()) assert.ok(count <= 2, "about one reset per stream");
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay topology", () => {
|
||||
const Counter = remote("t.e8-relayed", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String() }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { state: { n: Type.Number() }, methods: { add: method(Type.Number()) } } },
|
||||
});
|
||||
|
||||
function cluster() {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
let worker: HostImpl | undefined;
|
||||
let toWorker: CountingLink | undefined;
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
if (toWorker !== undefined) return toWorker;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const created = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
created.serve(Counter, (_input, _principal, self) => {
|
||||
const n = self.state("n", Type.Number(), 0);
|
||||
return {
|
||||
variant: "main",
|
||||
n,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
n.replace(ctx, n.value + delta);
|
||||
},
|
||||
};
|
||||
});
|
||||
worker = created;
|
||||
toWorker = new CountingLink(down);
|
||||
return toWorker;
|
||||
},
|
||||
});
|
||||
const browser = (principal: unknown, window?: number) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal, ...(window === undefined ? {} : { window, pendingCap: window * 2 }) });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, link, up };
|
||||
};
|
||||
const served = () => [...(worker?.upstream?.served.values() ?? [])];
|
||||
const relayed = () =>
|
||||
[...(server as HostImpl).connections]
|
||||
.filter((connection) => connection.peer.kind === "host")
|
||||
.reduce((sum, connection) => sum + connection.relayedRefs.size, 0);
|
||||
return { server, browser, served, relayed, toWorker: () => toWorker! };
|
||||
}
|
||||
|
||||
it("each origin hold is one downstream hold; nothing is left after release, close or revoke", async () => {
|
||||
const c = cluster();
|
||||
// Two tabs of one guest (two connections, one principal) and another guest.
|
||||
const one = c.browser({ guest: "g" });
|
||||
const two = c.browser({ guest: "g" });
|
||||
const three = c.browser({ guest: "h" });
|
||||
const a = one.host.hold(Counter, { session: "S1" });
|
||||
const a2 = one.host.hold(Counter, { session: "S1" });
|
||||
const b = two.host.hold(Counter, { session: "S1" });
|
||||
const d = three.host.hold(Counter, { session: "S1" });
|
||||
await Promise.all([a.ready, a2.ready, b.ready, d.ready]);
|
||||
assert.equal(c.toWorker().count("sent", "hold"), 3, "two tabs and another principal: three holds");
|
||||
assert.equal(c.served().length, 3);
|
||||
a.release();
|
||||
a2.release();
|
||||
await until(() => c.served().length === 2 && c.relayed() === 2);
|
||||
two.up.close();
|
||||
await until(() => c.served().length === 1 && c.relayed() === 1);
|
||||
await c.server.invalidatePrincipal({ guest: "h" });
|
||||
await until(() => c.served().length === 0 && c.relayed() === 0);
|
||||
await c.server.close();
|
||||
});
|
||||
|
||||
it("a stalled tab overflows and gets a reset while another tab keeps getting every change", async () => {
|
||||
const c = cluster();
|
||||
const slow = c.browser({ guest: "g" }, 256);
|
||||
const fast = c.browser({ guest: "g" });
|
||||
const stalled = slow.host.hold(Counter, { session: "S1" });
|
||||
const live = fast.host.hold(Counter, { session: "S1" });
|
||||
await Promise.all([stalled.ready, live.ready]);
|
||||
slow.link.paused = true;
|
||||
const seen: number[] = [];
|
||||
live.n.subscribe((value) => seen.push(value));
|
||||
for (let i = 0; i < 200; i++) await live.add(1);
|
||||
await until(() => live.n.value === 200);
|
||||
assert.ok(seen.length > 100, "the fast tab saw the changes as they came");
|
||||
assert.ok(stalled.n.value < 200, "the stalled tab waits");
|
||||
assert.ok(c.toWorker().count("sent", "resync") >= 1, "the relay asked the worker for a reset");
|
||||
slow.link.release();
|
||||
await until(() => stalled.n.value === 200);
|
||||
await c.server.close();
|
||||
});
|
||||
});
|
||||
301
third_party/pi/packages/chord/test/flow.test.ts
vendored
Normal file
301
third_party/pi/packages/chord/test/flow.test.ts
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
// Link flow control: state waits while the peer's acknowledgements lag a window behind, coalesced per subscription;
|
||||
// everything else is sent at once.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
/** A client's end of a link that can hold back its acknowledgements, and records what it receives. */
|
||||
class AckGate implements Link {
|
||||
readonly inner: Link;
|
||||
readonly received: Frame[] = [];
|
||||
paused = false;
|
||||
#held: Frame | undefined;
|
||||
constructor(inner: Link) {
|
||||
this.inner = inner;
|
||||
}
|
||||
get closed(): boolean {
|
||||
return this.inner.closed;
|
||||
}
|
||||
send(frame: Frame): number | undefined {
|
||||
if (frame.t === "ack" && this.paused) {
|
||||
this.#held = frame;
|
||||
return 0;
|
||||
}
|
||||
return this.inner.send(frame);
|
||||
}
|
||||
release(): void {
|
||||
this.paused = false;
|
||||
if (this.#held !== undefined) this.inner.send(this.#held);
|
||||
this.#held = undefined;
|
||||
}
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.inner.receive((frame) => {
|
||||
this.received.push(frame);
|
||||
handler(frame);
|
||||
});
|
||||
}
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
this.inner.onClose(handler);
|
||||
}
|
||||
close(reason?: string): void {
|
||||
this.inner.close(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** A counter on the server, or (`t.flow-worker-counter`) on the worker host `w`. */
|
||||
const counterOn = (name: string, host: string) =>
|
||||
remote(name, {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed(host),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: Type.Object({ n: Type.Number() }) },
|
||||
methods: { add: method(Type.Number(), Type.Number()), wait: method() },
|
||||
},
|
||||
},
|
||||
});
|
||||
const Counter = counterOn("t.flow-counter", "server");
|
||||
const WorkerCounter = counterOn("t.flow-worker-counter", "w");
|
||||
|
||||
let release = () => {};
|
||||
const waiting = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const previous = release;
|
||||
release = () => {
|
||||
previous();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
function serveCounter(host: Host, type = Counter): void {
|
||||
host.serve(type, (_input, _principal, self) => {
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 });
|
||||
return {
|
||||
variant: "main",
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
wait: waiting,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A client of `server` (accepted with `window`) whose acknowledgements can be held back. */
|
||||
function client(server: Host, window: number) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, window });
|
||||
const gate = new AckGate(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: gate, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, gate };
|
||||
}
|
||||
|
||||
const stateFrames = (frames: readonly Frame[]) =>
|
||||
frames.filter((frame): frame is Extract<Frame, { t: "state" }> => frame.t === "state");
|
||||
|
||||
describe("link flow control", () => {
|
||||
it("holds state back while the window is full, answers calls meanwhile, and sends one coalesced frame", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const { host, gate } = client(server, 200);
|
||||
const counter = host.hold(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await until(() => values.at(-1) !== undefined);
|
||||
|
||||
gate.paused = true;
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < 40; i++) results.push((await counter.add(1)) as number);
|
||||
// Every call was answered, though most state is still waiting for an acknowledgement.
|
||||
assert.equal(results.at(-1), 40);
|
||||
assert.ok((values.at(-1) as { n: number }).n < 40, "state waits behind the window");
|
||||
const before = stateFrames(gate.received).length;
|
||||
|
||||
gate.release();
|
||||
await until(() => (values.at(-1) as { n: number }).n === 40);
|
||||
assert.equal(stateFrames(gate.received).length - before, 1, "the waiting ops arrive as one frame");
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("replaces ops that outgrow their slot with the current value", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const { host, gate } = client(server, 64);
|
||||
const counter = host.hold(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await until(() => values.at(-1) !== undefined);
|
||||
|
||||
gate.paused = true;
|
||||
const before = gate.received.length;
|
||||
for (let i = 0; i < 100; i++) await counter.add(1);
|
||||
gate.release();
|
||||
await until(() => (values.at(-1) as { n: number }).n === 100);
|
||||
const sent = stateFrames(gate.received.slice(before));
|
||||
assert.ok(
|
||||
sent.some((frame) => frame.as === "reset"),
|
||||
"a reset replaced the ops",
|
||||
);
|
||||
assert.ok(sent.length < 10, `few state frames, not one per change (${sent.length})`);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("drops what waits for a reference that is released", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const { host, gate } = client(server, 64);
|
||||
const counter = host.hold(Counter, {});
|
||||
await counter.ready;
|
||||
const other = host.hold(Counter, {});
|
||||
await other.ready;
|
||||
|
||||
gate.paused = true;
|
||||
for (let i = 0; i < 10; i++) await counter.add(1);
|
||||
counter.release();
|
||||
other.release();
|
||||
const before = gate.received.length;
|
||||
gate.release();
|
||||
await sleep(10);
|
||||
assert.deepEqual(stateFrames(gate.received.slice(before)), []);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("coalesces relayed state for the origin and asks downstream for a reset when it overflows", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const toWorker: Frame[] = [];
|
||||
server.route("w", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
serveCounter(worker, WorkerCounter);
|
||||
return {
|
||||
get closed() {
|
||||
return down.closed;
|
||||
},
|
||||
send: (frame: Frame) => {
|
||||
toWorker.push(frame);
|
||||
return down.send(frame);
|
||||
},
|
||||
receive: (handler) => down.receive(handler),
|
||||
onClose: (handler) => down.onClose(handler),
|
||||
close: (reason) => down.close(reason),
|
||||
} satisfies Link;
|
||||
},
|
||||
});
|
||||
const { host, gate } = client(server, 64);
|
||||
const counter = host.hold(WorkerCounter, {});
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await until(() => values.at(-1) !== undefined);
|
||||
const resyncs = () => toWorker.filter((frame) => frame.t === "resync").length;
|
||||
assert.equal(resyncs(), 0);
|
||||
|
||||
gate.paused = true;
|
||||
for (let i = 0; i < 100; i++) await counter.add(1);
|
||||
// An overflow drops the waiting ops and asks the worker for a reset (which then waits in the slot).
|
||||
assert.ok(resyncs() >= 1, "the overflowing stream was resynced downstream");
|
||||
assert.ok(!toWorker.some((frame) => frame.t === "sub" || frame.t === "unsub"));
|
||||
gate.release();
|
||||
await until(() => (values.at(-1) as { n: number }).n === 100);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("neither windows nor acknowledges a peer whose hello does not announce acks", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, window: 1 });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "hold", id: 1, at: "server", token: Counter.description, input: {} });
|
||||
for (let i = 0; i < 5; i++) raw.send({ t: "call", id: 3 + i, hold: 1, member: "add", args: 1 });
|
||||
await until(() => frames.filter((frame) => frame.t === "result").length === 5);
|
||||
await sleep(5);
|
||||
assert.equal(stateFrames(frames).length, 6, "the snapshot and one frame per change");
|
||||
assert.ok(!frames.some((frame) => frame.t === "ack"));
|
||||
assert.equal((frames[0] as Extract<Frame, { t: "hello" }>).acks, true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("closes the link on an acknowledgement of frames never sent", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", codeVersion: 0, acks: true });
|
||||
raw.send({ t: "ack", n: 99 });
|
||||
await until(() => raw.closed);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("link call limit", () => {
|
||||
it("answers calls beyond maxCalls at once with too_many_calls and keeps the link", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, maxCalls: 2 });
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const counter = host.hold(Counter, {});
|
||||
await counter.ready;
|
||||
release = () => {};
|
||||
const first = counter.wait();
|
||||
const second = counter.wait();
|
||||
await assert.rejects(
|
||||
counter.add(1),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "too_many_calls",
|
||||
);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(await counter.add(1), 1);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
176
third_party/pi/packages/chord/test/generations.test.ts
vendored
Normal file
176
third_party/pi/packages/chord/test/generations.test.ts
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
// Old code generations and disposed objects must be collectable. A "generation" is the code of one facet module
|
||||
// evaluation: here a facet built by a function call, whose factory closes over a per-generation sentinel registered in a
|
||||
// FinalizationRegistry. A generation is alive while anything Chord keeps references its code.
|
||||
import assert from "node:assert/strict";
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { createHost, defineRegistry, type Facet, local, method } from "../src/index.ts";
|
||||
|
||||
setFlagsFromString("--expose-gc");
|
||||
const gc = runInNewContext("gc") as () => void;
|
||||
|
||||
/** Tracks which sentinels are still reachable. */
|
||||
function tracker() {
|
||||
const alive = new Set<number>();
|
||||
const registry = new FinalizationRegistry<number>((id) => alive.delete(id));
|
||||
return {
|
||||
alive,
|
||||
track<T extends object>(id: number, value: T): T {
|
||||
alive.add(id);
|
||||
registry.register(value, id);
|
||||
return value;
|
||||
},
|
||||
/**
|
||||
* Collects garbage until only `expected` is alive, at most `timeout` ms (finalizers run some time after a
|
||||
* collection), and returns what is alive then.
|
||||
*/
|
||||
async collect(expected: readonly number[], timeout = 5000): Promise<number[]> {
|
||||
const sorted = () => [...alive].sort((a, b) => a - b);
|
||||
const deadline = Date.now() + timeout;
|
||||
do {
|
||||
gc();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (sorted().join() === expected.join()) break;
|
||||
} while (Date.now() < deadline);
|
||||
return sorted();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Store = local("t.gen-store", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { get: method(undefined, Type.Number()), count: Type.Number() },
|
||||
});
|
||||
const Routes = defineRegistry<() => number>("t.gen-routes");
|
||||
const RELOADS = 6;
|
||||
|
||||
describe("generation GC", () => {
|
||||
it("a reference held across reloads keeps only the current generation alive", async () => {
|
||||
const generations = tracker();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const facet = (generation: number): Facet => {
|
||||
const sentinel = generations.track(generation, { generation });
|
||||
return {
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(Store, (_input, _principal, self) => {
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
return { count, get: () => sentinel.generation };
|
||||
}),
|
||||
};
|
||||
};
|
||||
host.install(facet(0));
|
||||
const ref = host.hold(Store, {});
|
||||
await ref.ready;
|
||||
for (let generation = 1; generation <= RELOADS; generation++) {
|
||||
await host.reload([facet(generation)]);
|
||||
assert.equal(await ref.get(), generation);
|
||||
}
|
||||
assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it('a lifetime "host" object with state, a kept resource, a listener, a task and registrations keeps only the current generation alive', async () => {
|
||||
const generations = tracker();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const facet = (generation: number): Facet => {
|
||||
const sentinel = generations.track(generation, { generation });
|
||||
return {
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Store,
|
||||
(_input, _principal, self) => {
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
// Kept across reloads (same version): adopts each new declaration's close.
|
||||
self.resource("conn", {
|
||||
version: 1,
|
||||
open: () => ({ open: true }),
|
||||
close: () => void sentinel.generation,
|
||||
});
|
||||
self.on("count", count, () => void sentinel.generation);
|
||||
self.task(async (ctx) => {
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
void sentinel.generation;
|
||||
});
|
||||
self.register(Routes, { generation: () => sentinel.generation });
|
||||
return { count, get: () => sentinel.generation };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
};
|
||||
await host.install(facet(0));
|
||||
for (let generation = 1; generation <= RELOADS; generation++) {
|
||||
await host.reload([facet(generation)]);
|
||||
assert.equal(host.registry(Routes).get("generation")?.(), generation);
|
||||
}
|
||||
assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("abort listeners that calls and tasks add to their ctx.signal do not outlive them", async () => {
|
||||
const generations = tracker();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const facet = (generation: number): Facet => {
|
||||
const sentinel = generations.track(generation, { generation });
|
||||
return {
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Store,
|
||||
(_input, _principal, self) => {
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
self.task(async (ctx) => {
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
void sentinel.generation;
|
||||
});
|
||||
return {
|
||||
count,
|
||||
get: (ctx) => {
|
||||
ctx.signal.addEventListener("abort", () => void sentinel.generation);
|
||||
return sentinel.generation;
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
};
|
||||
await host.install(facet(0));
|
||||
for (let generation = 1; generation <= RELOADS; generation++) {
|
||||
await host.reload([facet(generation)]);
|
||||
assert.equal(await host.root(async (ctx) => (await ctx.get(Store, {})).get(ctx)), generation);
|
||||
}
|
||||
assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("disposed objects are not kept once their disposal finished", async () => {
|
||||
const objects = tracker();
|
||||
const Item = local("t.gen-item", {
|
||||
version: 1,
|
||||
input: Type.Object({ id: Type.Number() }),
|
||||
members: { id: method(undefined, Type.Number()) },
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Item, (input) => {
|
||||
// The normalized input is held by the object: it stands for the object.
|
||||
const { id } = objects.track(input.id, input);
|
||||
return { id: () => id };
|
||||
});
|
||||
// In a function of its own, so no reference outlives the iteration in this frame.
|
||||
const use = async (id: number): Promise<void> => {
|
||||
const ref = host.hold(Item, { id });
|
||||
assert.equal(await ref.id(), id);
|
||||
ref.release();
|
||||
};
|
||||
for (let id = 0; id < 50; id++) await use(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(await objects.collect([]), []);
|
||||
await host.close();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { createLoopbackServiceTransport } from "../src/services/loopback.ts";
|
||||
345
third_party/pi/packages/chord/test/host-objects-reload.test.ts
vendored
Normal file
345
third_party/pi/packages/chord/test/host-objects-reload.test.ts
vendored
Normal file
@@ -0,0 +1,345 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { onTestFinished, test } from "vitest";
|
||||
import { HostImpl } from "../src/host.ts";
|
||||
import {
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
type Self,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const input = Type.Object({});
|
||||
const flags = Type.Record(Type.String(), Type.Boolean());
|
||||
const SwitchV1 = local("t.eager-switch", {
|
||||
version: 1,
|
||||
input,
|
||||
members: {
|
||||
enabled: Type.Boolean(),
|
||||
set: method(Type.Boolean()),
|
||||
},
|
||||
});
|
||||
const SwitchV2 = local("t.eager-switch", {
|
||||
version: 2,
|
||||
input,
|
||||
members: {
|
||||
enabled: flags,
|
||||
set: method(Type.Object({ conversation: Type.Number(), enabled: Type.Boolean() })),
|
||||
},
|
||||
});
|
||||
const SwitchV3 = local("t.eager-switch", {
|
||||
version: 3,
|
||||
input,
|
||||
members: {
|
||||
enabled: flags,
|
||||
update: method(Type.Object({ conversation: Type.Number(), enabled: Type.Boolean() })),
|
||||
},
|
||||
});
|
||||
const Read = defineRegistry<() => unknown>("t.eager-switch-read");
|
||||
|
||||
function switchFacets(log: string[]) {
|
||||
const declareResource = (self: Self) =>
|
||||
self.resource("watch", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
log.push("open");
|
||||
return null;
|
||||
},
|
||||
close: () => {
|
||||
log.push("close");
|
||||
},
|
||||
});
|
||||
const v1: Facet = {
|
||||
name: "switch",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
SwitchV1,
|
||||
(_input, _principal, self) => {
|
||||
declareResource(self);
|
||||
const enabledState = self.state("enabled", Type.Boolean(), false);
|
||||
self.register(Read, { current: () => enabledState.value });
|
||||
return {
|
||||
enabled: enabledState,
|
||||
set: (enabled, ctx) => {
|
||||
enabledState.replace(ctx, enabled);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
const v2 = (valid: boolean): Facet => ({
|
||||
name: "switch",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
SwitchV2,
|
||||
(_input, _principal, self) => {
|
||||
declareResource(self);
|
||||
const enabledState = self.state(
|
||||
"enabled",
|
||||
flags,
|
||||
{},
|
||||
{
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
assert.equal(from, 1);
|
||||
return valid ? { 1: old as boolean } : ("invalid migration" as never);
|
||||
},
|
||||
},
|
||||
);
|
||||
self.register(Read, { current: () => enabledState.value });
|
||||
return {
|
||||
enabled: enabledState,
|
||||
set: ({ conversation, enabled }, ctx) => {
|
||||
enabledState.change(ctx, (draft) => {
|
||||
draft[conversation] = enabled;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
const v3: Facet = {
|
||||
name: "switch",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
SwitchV3,
|
||||
(_input, _principal, self) => {
|
||||
declareResource(self);
|
||||
const enabledState = self.state("enabled", flags, {}, { version: 2 });
|
||||
self.register(Read, { current: () => enabledState.value });
|
||||
return {
|
||||
enabled: enabledState,
|
||||
update: ({ conversation, enabled }, ctx) => {
|
||||
enabledState.change(ctx, (draft) => {
|
||||
draft[conversation] = enabled;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
return { v1, v2, v3 };
|
||||
}
|
||||
|
||||
test("the host's reference to a host object follows contract bumps at commit without reopening its object or resources; failures retain the old reference", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = new HostImpl({ self: { kind: "host" }, dev: true, onError: (error) => errors.push(error) });
|
||||
onTestFinished(() => host.close());
|
||||
const log: string[] = [];
|
||||
const facets = switchFacets(log);
|
||||
await host.load([facets.v1]);
|
||||
const initial = host.hostObjects.get(SwitchV1.name)!;
|
||||
const object = initial.object;
|
||||
const original = host.hold(SwitchV1, {});
|
||||
await original.set(true);
|
||||
original.release();
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([facets.v2(false)]), (error) => isChordError(error, "invalid"));
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.equal(host.hostObjects.get(SwitchV1.name), initial);
|
||||
assert.equal(initial.status, "live");
|
||||
assert.equal(host.registry(Read).get("current")!(), true);
|
||||
const report = await host.reload([facets.v2(true)]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
const updated = host.hostObjects.get(SwitchV2.name)!;
|
||||
assert.equal(updated.contract, SwitchV2);
|
||||
assert.equal(updated.object, object);
|
||||
assert.equal(initial.status, "released");
|
||||
assert.equal(object?.refs.size, 1, "Attach the replacement before releasing the only old hold");
|
||||
assert.deepEqual(host.registry(Read).get("current")!(), { 1: true });
|
||||
const modern = host.hold(SwitchV2, {});
|
||||
await modern.set({ conversation: 2, enabled: true });
|
||||
modern.release();
|
||||
assert.deepEqual((await host.reload([facets.v3])).gated, []);
|
||||
assert.equal(host.hostObjects.get(SwitchV3.name)?.contract, SwitchV3);
|
||||
assert.equal(host.hostObjects.get(SwitchV3.name)?.object, object);
|
||||
assert.equal(updated.status, "released");
|
||||
assert.deepEqual(host.registry(Read).get("current")!(), { 1: true, 2: true });
|
||||
await host.ready();
|
||||
assert.deepEqual(log, ["open"]);
|
||||
assert.deepEqual(errors, []);
|
||||
await host.close();
|
||||
assert.deepEqual(log, ["open", "close"]);
|
||||
});
|
||||
|
||||
const ReadV1 = local("t.eager-read", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { read: method(undefined, Type.String()) },
|
||||
});
|
||||
const ReadV2 = local("t.eager-read", {
|
||||
version: 2,
|
||||
input: Type.Object({}),
|
||||
members: { fetch: method(undefined, Type.String()) },
|
||||
});
|
||||
const readerV1: Facet = {
|
||||
name: "read",
|
||||
setup: (api) => api.serve(ReadV1, () => ({ read: () => "one" }), { lifetime: "host" }),
|
||||
};
|
||||
const readerV2 = (adapt = false): Facet => ({
|
||||
name: "read",
|
||||
setup: (api) =>
|
||||
api.serve(ReadV2, () => ({ fetch: () => "two" }), {
|
||||
lifetime: "host",
|
||||
...(adapt ? { accepts: { 1: (current: Record<string, unknown>) => ({ read: current.fetch }) } } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
test("external old-version consumers still refuse a host object's upgrade without accepts, and keep their adapter when it is provided", async () => {
|
||||
const host = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
onTestFinished(() => host.close());
|
||||
await host.load([readerV1]);
|
||||
const own = host.hostObjects.get(ReadV1.name)!;
|
||||
const consumer = host.hold(ReadV1, {});
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch"));
|
||||
assert.equal(host.hostObjects.get(ReadV1.name), own);
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await host.reload([readerV2(true)]);
|
||||
assert.equal(host.hostObjects.get(ReadV2.name)?.contract, ReadV2);
|
||||
assert.equal(await consumer.read(), "two");
|
||||
assert.equal(consumer.contract, ReadV1, "Only the bootstrap hold is retokened");
|
||||
assert.equal(await host.hold(ReadV2, {}).fetch(), "two");
|
||||
});
|
||||
|
||||
test("object-owned old-version references still block a host object's upgrade unless their consumer is replaced in the same batch", async () => {
|
||||
const host = new HostImpl({ self: { kind: "host" }, onError: () => {} });
|
||||
onTestFinished(() => host.close());
|
||||
const Consumer = local("t.eager-consumer", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { read: method(undefined, Type.String()) },
|
||||
});
|
||||
const oldConsumer: Facet = {
|
||||
name: "consumer",
|
||||
setup: (api) =>
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const reader = self.get(ReadV1, {});
|
||||
return { read: (ctx) => reader.read(ctx) };
|
||||
}),
|
||||
};
|
||||
const newConsumer: Facet = {
|
||||
name: "consumer",
|
||||
setup: (api) =>
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const reader = self.get(ReadV2, {});
|
||||
return { read: (ctx) => reader.fetch(ctx) };
|
||||
}),
|
||||
};
|
||||
await host.load([readerV1, oldConsumer]);
|
||||
const consumer = host.hold(Consumer, {});
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch"));
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await host.reload([readerV2(), newConsumer]);
|
||||
assert.equal(await consumer.read(), "two");
|
||||
assert.equal(host.hostObjects.get(ReadV2.name)?.contract, ReadV2);
|
||||
});
|
||||
|
||||
test("a forgotten contract bump remains refused even when the only consumer is the host's own reference", async () => {
|
||||
const host = new HostImpl({ self: { kind: "host" }, onError: () => {} });
|
||||
onTestFinished(() => host.close());
|
||||
await host.load([readerV1]);
|
||||
const incompatible = local(ReadV1.name, {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { fetch: method(undefined, Type.String()) },
|
||||
});
|
||||
await assert.rejects(
|
||||
host.reload([
|
||||
{
|
||||
name: "read",
|
||||
setup: (api) => api.serve(incompatible, () => ({ fetch: () => "bad" }), { lifetime: "host" }),
|
||||
},
|
||||
]),
|
||||
(error) => isChordError(error, "contract_mismatch") && /without a version bump/.test(error.message),
|
||||
);
|
||||
assert.equal(host.hostObjects.get(ReadV1.name)?.contract, ReadV1);
|
||||
});
|
||||
|
||||
test("a cluster contract upgrade retokens the server's and worker's references to host objects only after unanimous preparation", async () => {
|
||||
const log: string[] = [];
|
||||
const facets = switchFacets(log);
|
||||
let valid = false;
|
||||
const server = new HostImpl({ self: { kind: "host" }, onError: () => {} });
|
||||
const worker = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
name: "worker",
|
||||
onError: () => {},
|
||||
loader: async () => [facets.v2(valid)],
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
await server.load([facets.v1]);
|
||||
await worker.load([facets.v1]);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { kind: "host", name: "worker", principal: { worker: true } });
|
||||
worker.connect(up, { principal: { kind: "server" } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const serverOwn = server.hostObjects.get(SwitchV1.name);
|
||||
const workerOwn = worker.hostObjects.get(SwitchV1.name);
|
||||
await assert.rejects(server.reload([facets.v2(true)], { remote: { worker: ["switch"] } }), (error) =>
|
||||
isChordError(error, "invalid"),
|
||||
);
|
||||
assert.equal(server.hostObjects.get(SwitchV1.name), serverOwn);
|
||||
assert.equal(worker.hostObjects.get(SwitchV1.name), workerOwn);
|
||||
valid = true;
|
||||
await server.reload([facets.v2(true)], { remote: { worker: ["switch"] } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(server.hostObjects.get(SwitchV2.name)?.contract, SwitchV2);
|
||||
assert.equal(worker.hostObjects.get(SwitchV2.name)?.contract, SwitchV2);
|
||||
assert.deepEqual(server.registry(Read).get("current")!(), { 1: false });
|
||||
assert.deepEqual(worker.registry(Read).get("current")!(), { 1: false });
|
||||
assert.equal(server.codeVersion, worker.codeVersion);
|
||||
assert.deepEqual(log, ["open", "open"]);
|
||||
});
|
||||
|
||||
test("a reload that changes a type's lifetime creates its host object, or lets it go", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Kept = local("t.lifetime-change", { version: 1, input: Type.Object({}) });
|
||||
const log: string[] = [];
|
||||
const facet = (lifetime: "host" | "referenced"): Facet => ({
|
||||
name: "kept",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Kept,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("life", {
|
||||
version: 1,
|
||||
open: () => log.push(`open ${lifetime}`),
|
||||
close: () => {
|
||||
log.push("close");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime },
|
||||
),
|
||||
});
|
||||
try {
|
||||
await host.install(facet("referenced"));
|
||||
assert.deepEqual(log, [], "a referenced object waits for its first reference");
|
||||
await host.reload([facet("host")]);
|
||||
await host.ready();
|
||||
assert.deepEqual(log, ["open host"], "created by the reload that made it a host object");
|
||||
await host.reload([facet("referenced")]);
|
||||
for (let i = 0; i < 50 && log.length < 2; i++) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.deepEqual(log, ["open host", "close"], "nobody else references it: disposed");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
739
third_party/pi/packages/chord/test/host.test.ts
vendored
Normal file
739
third_party/pi/packages/chord/test/host.test.ts
vendored
Normal file
@@ -0,0 +1,739 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
forbidden,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
method,
|
||||
opaque,
|
||||
type Ref,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const quiet = (errors: unknown[] = []) =>
|
||||
createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) });
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200 && !check(); attempt++) await tick();
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Counter = local("t.counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
members: {
|
||||
count: Type.Object({ n: Type.Number() }),
|
||||
add: method(Type.Number(), Type.Number()),
|
||||
},
|
||||
});
|
||||
|
||||
function serveCounter(host: Host, log: string[] = [], options: { linger?: number } = {}) {
|
||||
host.serve(
|
||||
Counter,
|
||||
({ name }, _principal, self) => {
|
||||
log.push(`factory ${name}`);
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 });
|
||||
const conn = self.resource("conn", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
log.push(`open ${name}`);
|
||||
return { name };
|
||||
},
|
||||
close: () => {
|
||||
log.push(`close ${name}`);
|
||||
},
|
||||
});
|
||||
return {
|
||||
count,
|
||||
conn,
|
||||
add: (delta: number, ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
};
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
describe("host: activation and lifetime", () => {
|
||||
it("activates, goes live, binds members late, disposes after release", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const ref = host.hold(Counter, { name: "a" });
|
||||
assert.equal(ref.status, "loading");
|
||||
assert.throws(
|
||||
() => ref.count.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
const pending = ref.add(2);
|
||||
await ref.ready;
|
||||
assert.equal(ref.status, "live");
|
||||
assert.equal(await pending, 2);
|
||||
assert.deepEqual(ref.count.value, { n: 2 });
|
||||
ref.release();
|
||||
assert.equal(ref.status, "released");
|
||||
await assert.rejects(ref.add(1), (error) => isChordError(error, "released"));
|
||||
await tick();
|
||||
assert.deepEqual(log, ["factory a", "open a", "close a"]);
|
||||
});
|
||||
|
||||
it("coalesces concurrent gets and keeps the object while held", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const a = host.hold(Counter, { name: "x" });
|
||||
const b = host.hold(Counter, { name: "x" });
|
||||
await Promise.all([a.ready, b.ready]);
|
||||
assert.deepEqual(log, ["factory x", "open x"]);
|
||||
a.release();
|
||||
await tick();
|
||||
assert.equal(await b.add(1), 1);
|
||||
b.release();
|
||||
await tick();
|
||||
assert.deepEqual(log, ["factory x", "open x", "close x"]);
|
||||
});
|
||||
|
||||
it("lingers after the last release and revives on a new get", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log, { linger: 30 });
|
||||
const a = host.hold(Counter, { name: "l" });
|
||||
await a.add(5);
|
||||
a.release();
|
||||
const b = host.hold(Counter, { name: "l" });
|
||||
await b.ready;
|
||||
assert.deepEqual(b.count.value, { n: 5 });
|
||||
b.release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
assert.deepEqual(log, ["factory l", "open l", "close l"]);
|
||||
});
|
||||
|
||||
it("disposes an abandoned activation once it settles", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
let release = () => {};
|
||||
const Slow = local("t.slow", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Slow, (_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: ({ signal }) =>
|
||||
new Promise<string>((resolve) => {
|
||||
release = () => resolve("r");
|
||||
signal.addEventListener("abort", () => log.push("abort seen"));
|
||||
}),
|
||||
close: () => {
|
||||
log.push("closed");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
const ref = host.hold(Slow, {});
|
||||
await tick();
|
||||
ref.release();
|
||||
await tick();
|
||||
release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.deepEqual(log, ["abort seen", "closed"]);
|
||||
const again = host.hold(Slow, {});
|
||||
await tick();
|
||||
release();
|
||||
await again.ready;
|
||||
assert.equal(again.status, "live");
|
||||
});
|
||||
|
||||
it("propagates activation failures to dependents and retries on the next get", async () => {
|
||||
const host = quiet();
|
||||
let fail = true;
|
||||
const Leaf = local("t.leaf", { version: 1, input: Type.Object({}) });
|
||||
const Parent = local("t.parent", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Leaf, (_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
if (fail) throw new Error("boom");
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
host.serve(Parent, (_input, _principal, self) => {
|
||||
self.get(Leaf, {});
|
||||
return {};
|
||||
});
|
||||
const parent = host.hold(Parent, {});
|
||||
// A plain error from a dependency surfaces as `unavailable`, with the original as cause.
|
||||
await assert.rejects(parent.ready, (error) => isChordError(error, "unavailable") && /boom/.test(error.message));
|
||||
assert.equal(parent.status, "gone");
|
||||
fail = false;
|
||||
const retry = host.hold(Parent, {});
|
||||
await retry.ready;
|
||||
assert.equal(retry.status, "live");
|
||||
});
|
||||
|
||||
it("unwinds declarations when a factory throws and rejects async factories", async () => {
|
||||
const host = quiet();
|
||||
const Leaf = local("t.leaf2", { version: 1, input: Type.Object({}) });
|
||||
const Bad = local("t.bad", { version: 1, input: Type.Object({ async: Type.Boolean() }) });
|
||||
let leafLive = 0;
|
||||
host.serve(Leaf, (_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
leafLive++;
|
||||
},
|
||||
close: () => {
|
||||
leafLive--;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
host.serve(Bad, ({ async }, _principal, self) => {
|
||||
self.get(Leaf, {});
|
||||
if (async) return Promise.resolve({}) as never;
|
||||
throw new Error("factory failed");
|
||||
});
|
||||
await assert.rejects(host.hold(Bad, { async: false }).ready, /factory failed/);
|
||||
await assert.rejects(host.hold(Bad, { async: true }).ready, /synchronous/);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(leafLive, 0);
|
||||
});
|
||||
|
||||
it("rejects duplicate declaration names", async () => {
|
||||
const host = quiet();
|
||||
const Dup = local("t.dup", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Dup, (_input, _principal, self) => {
|
||||
self.state("a", Type.Object({}), {});
|
||||
self.state("a", Type.Object({}), {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Dup, {}).ready, /duplicate state name a/);
|
||||
});
|
||||
|
||||
it("detects hold cycles", async () => {
|
||||
const host = quiet();
|
||||
const A = local("t.cycle-a", { version: 1, input: Type.Object({}) });
|
||||
const B = local("t.cycle-b", { version: 1, input: Type.Object({}) });
|
||||
host.serve(A, (_input, _principal, self) => {
|
||||
self.get(B, {});
|
||||
return {};
|
||||
});
|
||||
host.serve(B, (_input, _principal, self) => {
|
||||
self.get(A, {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(A, {}).ready, /cycle/);
|
||||
});
|
||||
|
||||
it("closes resources in reverse order, runs all steps and aggregates errors", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = quiet(errors);
|
||||
const log: string[] = [];
|
||||
const Multi = local("t.multi", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Multi, (_input, _principal, self) => {
|
||||
const a = self.resource("a", { version: 1, open: () => "a", close: () => void log.push("close a") });
|
||||
self.resource("b", {
|
||||
version: 1,
|
||||
deps: [a],
|
||||
open: () => "b",
|
||||
close: () => {
|
||||
log.push("close b");
|
||||
throw new Error("b failed");
|
||||
},
|
||||
});
|
||||
self.resource("c", {
|
||||
version: 1,
|
||||
open: () => "c",
|
||||
close: () => {
|
||||
log.push("close c");
|
||||
throw new Error("c failed");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
const ref = host.hold(Multi, {});
|
||||
await ref.ready;
|
||||
ref.release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(log.at(-1), "close a");
|
||||
assert.equal(log.length, 3);
|
||||
assert.ok(errors[0] instanceof AggregateError);
|
||||
assert.equal((errors[0] as AggregateError).errors.length, 2);
|
||||
});
|
||||
|
||||
it("releases ctx.get holds when the call settles", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const value = await host.root(async (ctx) => {
|
||||
const counter = await ctx.get(Counter, { name: "c" });
|
||||
return counter.add(3, ctx);
|
||||
});
|
||||
assert.equal(value, 3);
|
||||
await tick();
|
||||
assert.deepEqual(log, ["factory c", "open c", "close c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: declarations and methods", () => {
|
||||
it("refuses a second declaration of one name per kind; kinds have their own names", async () => {
|
||||
const host = quiet();
|
||||
const Twice = local("t.twice", { version: 1, input: Type.Object({ kind: Type.String() }) });
|
||||
host.serve(Twice, ({ kind }, _principal, self) => {
|
||||
const open = { version: 1, open: () => null };
|
||||
self.state("x", Type.Number(), 0);
|
||||
self.resource("x", open);
|
||||
self.on("x", self.state("y", Type.Number(), 0), () => {});
|
||||
if (kind === "state") self.state("x", Type.Number(), 1);
|
||||
if (kind === "resource") self.resource("x", open);
|
||||
if (kind === "listener") self.on("x", self.state("z", Type.Number(), 0), () => {});
|
||||
return {};
|
||||
});
|
||||
await host.hold(Twice, { kind: "none" }).ready;
|
||||
for (const kind of ["state", "resource", "listener"]) {
|
||||
await assert.rejects(
|
||||
host.hold(Twice, { kind }).ready,
|
||||
(error) =>
|
||||
isChordError(error, "invalid") &&
|
||||
error.reason === "duplicate" &&
|
||||
error.message.includes(`${kind} name x`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("methods without arguments are called with ctx alone; void methods resolve to undefined", async () => {
|
||||
const host = quiet();
|
||||
const Tool = local("t.tool", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: {
|
||||
ping: method(),
|
||||
count: method(undefined, Type.Number()),
|
||||
note: method(Type.String()),
|
||||
},
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
host.serve(Tool, () => ({
|
||||
ping: (ctx) => {
|
||||
seen.push(typeof ctx.rootId);
|
||||
return "ignored" as never;
|
||||
},
|
||||
count: () => 3,
|
||||
note: (text, ctx) => {
|
||||
seen.push(text, typeof ctx.rootId);
|
||||
},
|
||||
}));
|
||||
const tool = host.hold(Tool, {});
|
||||
await tool.ready;
|
||||
assert.equal(await tool.ping(), undefined);
|
||||
assert.equal(await host.root((ctx) => tool.count(ctx)), 3);
|
||||
assert.equal(await tool.note("hi"), undefined);
|
||||
assert.deepEqual(seen, ["string", "hi", "string"]);
|
||||
});
|
||||
|
||||
it("a listener on state gets the current value at start and again after a reload replaced its derivation", async () => {
|
||||
const host = quiet();
|
||||
const Doubled = local("t.doubled", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { set: method(Type.Number()) },
|
||||
});
|
||||
const seen: number[] = [];
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "doubled",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Doubled,
|
||||
(_input, _principal, self) => {
|
||||
const n = self.state("n", Type.Number(), 1);
|
||||
const doubled = self.derive([n], (value) => value * 2);
|
||||
self.on("doubled", doubled, (value) => {
|
||||
seen.push(value);
|
||||
void label;
|
||||
});
|
||||
return {
|
||||
set: (value, ctx) => {
|
||||
n.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
await host.install(facet("v1"));
|
||||
await until(() => seen.length === 1);
|
||||
await host.root((ctx) => host.hold(Doubled, {}).set(5, ctx));
|
||||
await until(() => seen.length === 2);
|
||||
assert.deepEqual(seen, [2, 10]);
|
||||
// The reload makes a new derivation: the listener subscribes again and gets its current value once.
|
||||
await host.reload([facet("v2")]);
|
||||
await until(() => seen.length === 3);
|
||||
await tick();
|
||||
assert.deepEqual(seen, [2, 10, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: lifetime", () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: Type.Object({ scope: Type.Optional(Type.String({ default: "all" })) }),
|
||||
members: { seen: opaque<unknown>("t.seen@1") },
|
||||
});
|
||||
|
||||
it('serve refuses a lifetime "host" the type cannot have, at compile time and at runtime', () => {
|
||||
const host = quiet();
|
||||
const Remote = remote("t.remote-store", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const Keyed = local("t.keyed", { version: 1, input: Type.Object({ id: Type.String() }) });
|
||||
// Each refused call is also a type error; the runtime check is the backstop for untyped callers.
|
||||
const refused: [() => void, RegExp][] = [
|
||||
// @ts-expect-error: a remote type is never of lifetime "host"
|
||||
[() => host.serve(Remote, () => ({ variant: "main" }), { lifetime: "host" }), /needs a local type/],
|
||||
// @ts-expect-error: the one object has the empty input, and `id` is required
|
||||
[() => host.serve(Keyed, () => ({}), { lifetime: "host" }), /input that accepts \{\}/],
|
||||
// @ts-expect-error: an object of lifetime "host" does not linger
|
||||
[() => host.serve(Store, () => ({ seen: null }), { lifetime: "host", linger: 10 }), /does not linger/],
|
||||
// @ts-expect-error: no such lifetime
|
||||
[() => host.serve(Store, () => ({ seen: null }), { lifetime: "forever" }), /unknown lifetime/],
|
||||
];
|
||||
for (const [serve, error] of refused) assert.throws(serve, error);
|
||||
// Allowed: a local type whose input has only optional fields, and any type with the default lifetime.
|
||||
const Optional = local("t.optional", { version: 1, input: Type.Object({ id: Type.Optional(Type.String()) }) });
|
||||
host.serve(Optional, () => ({}), { lifetime: "host" });
|
||||
host.serve(Keyed, () => ({}), { lifetime: "referenced", linger: 10 });
|
||||
host.serve(Remote, () => ({ variant: "main" }), { linger: 10 });
|
||||
});
|
||||
|
||||
it("every get reaches the one object, created at serve for the host principal; another input is invalid", async () => {
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
principalKey: (principal) => JSON.stringify(principal),
|
||||
onError: () => {},
|
||||
});
|
||||
const created: unknown[] = [];
|
||||
host.serve(
|
||||
Store,
|
||||
(input, principal) => {
|
||||
created.push({ input, principal });
|
||||
return { seen: principal };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await host.ready();
|
||||
assert.equal(created.length, 1, "created when served, before anyone asked");
|
||||
const guest = host.hold(Store, {}, { principal: { guest: "g" } });
|
||||
const defaults = host.hold(Store, { scope: "all" });
|
||||
await Promise.all([guest.ready, defaults.ready]);
|
||||
assert.equal(created.length, 1, "the same object for every principal and the default input");
|
||||
assert.deepEqual(guest.seen, created[0] && (created[0] as { principal: unknown }).principal);
|
||||
assert.throws(
|
||||
() => host.hold(Store, { scope: "other" }),
|
||||
(error) => isChordError(error, "invalid"),
|
||||
);
|
||||
// References do not change its lifetime: released by everyone else, it stays until the host closes.
|
||||
guest.release();
|
||||
defaults.release();
|
||||
await tick();
|
||||
const again = host.hold(Store, {});
|
||||
await again.ready;
|
||||
assert.equal(created.length, 1);
|
||||
await host.close();
|
||||
assert.equal(again.status, "gone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: principals, variants and disposal", () => {
|
||||
const Doc = remote("t.doc", {
|
||||
version: 1,
|
||||
input: Type.Object({ id: Type.String() }),
|
||||
host: hostNamed("host"),
|
||||
variants: {
|
||||
viewer: { state: { view: Type.Object({ text: Type.String() }) } },
|
||||
editor: {
|
||||
state: { view: Type.Object({ text: Type.String() }) },
|
||||
methods: { edit: method(Type.Object({ text: Type.String() }), Type.Object({ ok: Type.Boolean() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function serveDoc(host: Host, principals: unknown[] = []) {
|
||||
host.serve(Doc, (_input, principal, self) => {
|
||||
principals.push(principal);
|
||||
const view = self.state("view", Type.Object({ text: Type.String() }), { text: "" });
|
||||
if ((principal as { role?: string }).role !== "editor") return { variant: "viewer", view };
|
||||
return {
|
||||
variant: "editor",
|
||||
view,
|
||||
edit: ({ text }, ctx) => {
|
||||
view.replace(ctx, { text });
|
||||
return text === "bad" ? ({ ok: "no" } as never) : { ok: true };
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps objects per principal key and enforces variants", async () => {
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
principalKey: (principal) => (principal as { user: string }).user,
|
||||
onError: () => {},
|
||||
});
|
||||
const principals: unknown[] = [];
|
||||
serveDoc(host, principals);
|
||||
const viewer = host.hold(Doc, { id: "1" }, { principal: { user: "v", role: "viewer" } });
|
||||
const editor = host.hold(Doc, { id: "1" }, { principal: { user: "e", role: "editor" } });
|
||||
await Promise.all([viewer.ready, editor.ready]);
|
||||
assert.equal(principals.length, 2);
|
||||
assert.equal(viewer.variant, "viewer");
|
||||
assert.deepEqual(viewer.shape?.members, ["view"]);
|
||||
// @ts-expect-error A viewer has no edit: TypeScript refuses it, and so does the provider at runtime.
|
||||
await assert.rejects(viewer.edit({ text: "x" }), (error) => isChordError(error, "not_in_variant"));
|
||||
assert.ok(editor.variant === "editor");
|
||||
assert.deepEqual(await editor.edit({ text: "x" }), { ok: true });
|
||||
assert.deepEqual(editor.view.value, { text: "x" });
|
||||
assert.deepEqual(viewer.view.value, { text: "" });
|
||||
});
|
||||
|
||||
it("validates remote args and results at the provider boundary", async () => {
|
||||
const host = quiet();
|
||||
serveDoc(host);
|
||||
const editor = await host.get(Doc, { id: "2" }, { principal: { role: "editor" } });
|
||||
assert.ok(editor.variant === "editor");
|
||||
await assert.rejects(
|
||||
editor.edit({ text: 1 } as never),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "args",
|
||||
);
|
||||
await assert.rejects(
|
||||
editor.edit({ text: "bad" }),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "result",
|
||||
);
|
||||
});
|
||||
|
||||
it("forced disposal makes references gone and aborts in-flight calls", async () => {
|
||||
const host = quiet();
|
||||
let seenAbort = false;
|
||||
const Slow = local("t.inflight", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { wait: method() },
|
||||
});
|
||||
host.serve(Slow, () => ({
|
||||
wait: (ctx) =>
|
||||
new Promise<void>((resolve) =>
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
seenAbort = true;
|
||||
resolve();
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const ref = host.hold(Slow, {}, { principal: { user: "u" } });
|
||||
await ref.ready;
|
||||
const call = ref.wait();
|
||||
await tick();
|
||||
await host.invalidatePrincipal((principal) => (principal as { user: string }).user === "u");
|
||||
assert.equal(ref.status, "gone");
|
||||
await call;
|
||||
assert.ok(seenAbort);
|
||||
await assert.rejects(ref.wait(), (error) => isChordError(error, "gone"));
|
||||
const fresh = host.hold(Slow, {}, { principal: { user: "u" } });
|
||||
await fresh.ready;
|
||||
assert.equal(fresh.status, "live");
|
||||
});
|
||||
|
||||
it("state members are late-bound readables: values, then snapshot/reset/ops events", async () => {
|
||||
const host = quiet();
|
||||
serveDoc(host);
|
||||
const editor = host.hold(Doc, { id: "3" }, { principal: { role: "editor" } }) as Ref<typeof Doc>;
|
||||
const shapes: unknown[] = [];
|
||||
editor.onShape((shape) => shapes.push(shape.variant));
|
||||
const events: unknown[] = [];
|
||||
const values: unknown[] = [];
|
||||
editor.view.events((event) => events.push(event.type === "ops" ? ["ops", event.ops] : [event.type, event.value]));
|
||||
editor.view.subscribe((value) => values.push(value));
|
||||
await editor.ready;
|
||||
assert.ok(editor.variant === "editor");
|
||||
await editor.edit({ text: "hi" });
|
||||
assert.deepEqual(shapes, ["editor"]);
|
||||
// Listeners added while loading get their snapshot once the reference is live.
|
||||
assert.deepEqual(events, [
|
||||
["snap", { text: "" }],
|
||||
["ops", [["r", { text: "hi" }]]],
|
||||
]);
|
||||
assert.deepEqual(values, [{ text: "" }, { text: "hi" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: registries", () => {
|
||||
const Routes = defineRegistry<string>("t.routes");
|
||||
|
||||
it('only objects of lifetime "host" register; tables follow registration order', async () => {
|
||||
const host = quiet();
|
||||
const RoutesA = local("t.routes-a", { version: 1, input: Type.Object({}) });
|
||||
const RoutesB = local("t.routes-b", { version: 1, input: Type.Object({}) });
|
||||
host.serve(
|
||||
RoutesA,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { "GET /a": "a", "GET /c": "c" });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
host.serve(
|
||||
RoutesB,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { "GET /b": "b" });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
const tables: string[][] = [];
|
||||
host.registry(Routes).subscribe((table) => tables.push([...table.keys()]));
|
||||
await tick();
|
||||
await tick();
|
||||
assert.deepEqual([...host.registry(Routes).table().keys()], ["GET /a", "GET /c", "GET /b"]);
|
||||
assert.equal(host.registry(Routes).get("GET /b"), "b");
|
||||
assert.deepEqual(tables.at(-1), ["GET /a", "GET /c", "GET /b"]);
|
||||
|
||||
const Lazy = local("t.lazy-register", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Lazy, (_input, _principal, self) => {
|
||||
self.register(Routes, { x: "x" });
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Lazy, {}).ready, /only objects of lifetime "host" may register/);
|
||||
});
|
||||
|
||||
it("rejects conflicting registrations", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = quiet(errors);
|
||||
for (const name of ["t.conflict-a", "t.conflict-b"]) {
|
||||
host.serve(
|
||||
local(name, { version: 1, input: Type.Object({}) }),
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { same: name });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
}
|
||||
await tick();
|
||||
await tick();
|
||||
assert.ok(errors.some((error) => /registry_conflict/.test(String(error))));
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: composition", () => {
|
||||
const Source = local("t.source", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { n: Type.Number(), set: method(Type.Number()) },
|
||||
});
|
||||
const Mirror = local("t.mirror", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { copy: Type.Number(), doubled: Type.Number() },
|
||||
});
|
||||
|
||||
it("reference state members are sources for listeners and derivations", async () => {
|
||||
const host = quiet();
|
||||
host.serve(Source, (_input, _principal, self) => {
|
||||
const n = self.state("n", Type.Number(), 1);
|
||||
return {
|
||||
n,
|
||||
set: (value: number, ctx: Ctx) => {
|
||||
n.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
host.serve(Mirror, (_input, _principal, self) => {
|
||||
const source = self.get(Source, {});
|
||||
const copy = self.state("copy", Type.Number(), 0);
|
||||
self.on("follow", source.n, (value, ctx) => copy.replace(ctx, value ?? 0));
|
||||
const doubled = self.derive([source.n], (n) => (n ?? 0) * 2);
|
||||
return { copy, doubled };
|
||||
});
|
||||
const mirror = host.hold(Mirror, {});
|
||||
await mirror.ready;
|
||||
await tick();
|
||||
assert.equal(mirror.copy.value, 1);
|
||||
assert.equal(mirror.doubled.value, 2);
|
||||
await host.hold(Source, {}).set(5);
|
||||
await tick();
|
||||
assert.equal(mirror.copy.value, 5);
|
||||
assert.equal(mirror.doubled.value, 10);
|
||||
});
|
||||
|
||||
it("ctx.get acts as the root principal at ingress and as the host inside objects", async () => {
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
principalKey: (principal) => JSON.stringify(principal),
|
||||
onError: () => {},
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const Who = local("t.who", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { ping: method() },
|
||||
});
|
||||
const Front = local("t.front", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { relay: method() },
|
||||
});
|
||||
host.serve(Who, (_input, principal) => {
|
||||
seen.push(principal);
|
||||
return { ping: () => {} };
|
||||
});
|
||||
host.serve(Front, (_input, principal) => {
|
||||
seen.push(principal);
|
||||
return { relay: async (ctx: Ctx) => (await ctx.get(Who, {})).ping(ctx) };
|
||||
});
|
||||
await host.root(async (ctx) => (await ctx.get(Front, {})).relay(ctx), { principal: { user: "guest" } });
|
||||
assert.deepEqual(seen, [{ user: "guest" }, { kind: "host" }]);
|
||||
});
|
||||
|
||||
it("calling a reference inside a factory throws not_ready", async () => {
|
||||
const host = quiet();
|
||||
host.serve(Source, (_input, _principal, self) => ({
|
||||
n: self.state("n", Type.Number(), 0),
|
||||
set: () => {},
|
||||
}));
|
||||
const Eager = local("t.eager-call", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Eager, (_input, _principal, self) => {
|
||||
const source = self.get(Source, {});
|
||||
void source.set(1);
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Eager, {}).ready, (error) => isChordError(error, "not_ready"));
|
||||
});
|
||||
|
||||
it("a ChordError from a factory keeps its code through dependents", async () => {
|
||||
const host = quiet();
|
||||
const Secret = local("t.secret", { version: 1, input: Type.Object({}) });
|
||||
const Page = local("t.page", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Secret, () => {
|
||||
throw forbidden("not for you");
|
||||
});
|
||||
host.serve(Page, (_input, _principal, self) => {
|
||||
self.get(Secret, {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Page, {}).ready, (error) => isChordError(error, "forbidden"));
|
||||
});
|
||||
});
|
||||
195
third_party/pi/packages/chord/test/hydration.test.ts
vendored
Normal file
195
third_party/pi/packages/chord/test/hydration.test.ts
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
// Entry 8: a local object goes live only with its state, also where it forwards or derives from a remote reference's
|
||||
// member; activation waits at most `readyTimeout` (a cycle across hosts fails with a hint instead of hanging).
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Lives on the worker `w`; its value is set by `set`. */
|
||||
const Source = remote("t.e8-source", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("w"),
|
||||
variants: { main: { state: { v: Type.String() }, methods: { set: method(Type.String()) } } },
|
||||
});
|
||||
|
||||
/** Lives on the server; forwards (and derives from) the worker's `Source`. */
|
||||
const Forward = remote("t.e8-forward", {
|
||||
version: 1,
|
||||
input: Type.Object({ how: Type.String() }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { v: Type.String(), size: Type.Number() } } },
|
||||
});
|
||||
|
||||
function serveSource(host: Host): void {
|
||||
host.serve(Source, (_input, _principal, self) => {
|
||||
const v = self.state("v", Type.String(), "from the worker");
|
||||
return {
|
||||
variant: "main",
|
||||
v,
|
||||
set: (text: string, ctx: Ctx) => {
|
||||
v.replace(ctx, text);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A server with a worker child `w` (accepted after `delay` ms, as p2's server admits a worker after it is ready). */
|
||||
function cluster(options: { readonly delay?: number; readonly readyTimeout?: number } = {}) {
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }),
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "w",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }),
|
||||
});
|
||||
const admit = () => server.accept(down, { principal: { kind: "worker" }, kind: "host", name: "w" });
|
||||
if (options.delay === undefined) admit();
|
||||
else setTimeout(admit, options.delay);
|
||||
const browser = () => {
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { user: "u" } });
|
||||
return createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up2, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
};
|
||||
return { server, worker, browser };
|
||||
}
|
||||
|
||||
describe("local hydration", () => {
|
||||
it("an object that forwards and derives from a remote member goes live with its value", async () => {
|
||||
const c = cluster();
|
||||
serveSource(c.worker);
|
||||
// Held outside the factory (no `self.get`): activation still waits for its snapshot.
|
||||
const held = c.server.hold(Source, {});
|
||||
c.server.serve(Forward, ({ how }, _principal, self) => {
|
||||
const source = how === "declared" ? self.get(Source, {}) : held;
|
||||
return { variant: "main", v: source.v, size: self.derive([source.v], (text) => text.length) };
|
||||
});
|
||||
const browser = c.browser();
|
||||
for (const how of ["held", "declared"]) {
|
||||
const forward = browser.hold(Forward, { how });
|
||||
await forward.ready;
|
||||
assert.equal(forward.v.value, "from the worker");
|
||||
assert.equal(forward.size.value, "from the worker".length);
|
||||
const local = c.server.hold(Forward, { how });
|
||||
await local.ready;
|
||||
assert.equal(local.v.value, "from the worker");
|
||||
}
|
||||
await c.server.close();
|
||||
await c.worker.close();
|
||||
});
|
||||
|
||||
it("a member read inside a factory throws not_ready; it is a plain value once live", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Inner = local("t.e8-inner", { version: 1, input: Type.Object({}), members: { n: Type.Number() } });
|
||||
const Outer = local("t.e8-outer", { version: 1, input: Type.Object({}), members: { m: Type.Number() } });
|
||||
host.serve(Inner, (_input, _principal, self) => ({ n: self.state("n", Type.Number(), 7) }));
|
||||
const seen: unknown[] = [];
|
||||
host.serve(Outer, (_input, _principal, self) => {
|
||||
const inner = self.get(Inner, {});
|
||||
try {
|
||||
seen.push(inner.n.value);
|
||||
} catch (error) {
|
||||
seen.push(isChordError(error, "not_ready") ? "not_ready" : error);
|
||||
}
|
||||
return { m: self.derive([inner.n], (n) => n * 2) };
|
||||
});
|
||||
const outer = host.hold(Outer, {});
|
||||
await outer.ready;
|
||||
assert.deepEqual(seen, ["not_ready"]);
|
||||
assert.equal(outer.m.value, 14);
|
||||
});
|
||||
|
||||
it("a host object of a child holding the parent's object goes live once the parent admits the child", async () => {
|
||||
const Settings = remote("t.e8-settings", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { theme: Type.String() } } },
|
||||
});
|
||||
const Local = local("t.e8-boot", { version: 1, input: Type.Object({}), members: { theme: Type.String() } });
|
||||
const c = cluster({ delay: 20 });
|
||||
c.server.serve(Settings, (_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
theme: self.state("theme", Type.String(), "dark"),
|
||||
}));
|
||||
await c.worker.install(
|
||||
{
|
||||
name: "boot",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Local,
|
||||
(_input, _principal, self) => {
|
||||
const settings = self.get(Settings, {});
|
||||
return { theme: settings.theme };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
{ awaitHostObjects: false },
|
||||
);
|
||||
await c.worker.ready();
|
||||
const boot = c.worker.hold(Local, {});
|
||||
await boot.ready;
|
||||
assert.equal(boot.theme.value, "dark");
|
||||
await c.server.close();
|
||||
await c.worker.close();
|
||||
});
|
||||
|
||||
it("a reference cycle across hosts fails at readyTimeout with a cycle hint", async () => {
|
||||
const A = remote("t.e8-cycle-a", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const B = remote("t.e8-cycle-b", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("w"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const c = cluster({ readyTimeout: 50 });
|
||||
c.server.serve(A, (_input, _principal, self) => {
|
||||
self.get(B, {});
|
||||
return { variant: "main" };
|
||||
});
|
||||
c.worker.serve(B, (_input, _principal, self) => {
|
||||
self.get(A, {});
|
||||
return { variant: "main" };
|
||||
});
|
||||
const a = c.server.hold(A, {});
|
||||
await assert.rejects(
|
||||
a.ready,
|
||||
(error) =>
|
||||
isChordError(error, "unavailable") && error.reason === "ready_timeout" && /cycle/.test(error.message),
|
||||
);
|
||||
await sleep(10);
|
||||
await c.server.close();
|
||||
await c.worker.close();
|
||||
});
|
||||
});
|
||||
53
third_party/pi/packages/chord/test/json.test.ts
vendored
53
third_party/pi/packages/chord/test/json.test.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isJsonValue } from "../src/index.ts";
|
||||
import { copyJson, isJsonValue } from "../src/index.ts";
|
||||
|
||||
describe("isJsonValue", () => {
|
||||
test("checks strict JSON without normalizing it", () => {
|
||||
@@ -12,3 +12,54 @@ describe("isJsonValue", () => {
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyJson", () => {
|
||||
test("copies strict JSON without retaining aliases", () => {
|
||||
const shared = { value: 1 };
|
||||
const input = { left: shared, right: shared };
|
||||
const copied = copyJson(input) as typeof input;
|
||||
expect(copied).toEqual(input);
|
||||
expect(copied).not.toBe(input);
|
||||
expect(copied.left).not.toBe(shared);
|
||||
expect(copied.right).not.toBe(shared);
|
||||
expect(copied.left).not.toBe(copied.right);
|
||||
});
|
||||
|
||||
test("optionally omits undefined object properties without normalizing arrays", () => {
|
||||
const input = { kept: 1, omitted: undefined, nested: { omitted: undefined, kept: true } };
|
||||
expect(() => copyJson(input)).toThrow(/strict JSON/);
|
||||
expect(copyJson(input, { omitUndefinedProperties: true })).toEqual({ kept: 1, nested: { kept: true } });
|
||||
expect(() => copyJson([undefined], { omitUndefinedProperties: true })).toThrow(/strict JSON/);
|
||||
});
|
||||
|
||||
test("preserves null prototypes and own __proto__ data properties", () => {
|
||||
const input = Object.create(null) as Record<string, unknown>;
|
||||
Object.defineProperty(input, "__proto__", {
|
||||
value: { safe: true },
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const copied = copyJson(input) as Record<string, unknown>;
|
||||
expect(Object.getPrototypeOf(copied)).toBeNull();
|
||||
expect(Object.hasOwn(copied, "__proto__")).toBe(true);
|
||||
expect(copied.__proto__).toEqual({ safe: true });
|
||||
});
|
||||
|
||||
test("rejects cycles and non-strict container properties", () => {
|
||||
const cyclic: { self?: unknown } = {};
|
||||
cyclic.self = cyclic;
|
||||
const sparse: unknown[] = [];
|
||||
sparse[1] = 1;
|
||||
const extra = Object.assign([1], { extra: 2 });
|
||||
class ArraySubclass extends Array<unknown> {}
|
||||
const subclass = new ArraySubclass(1);
|
||||
const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => 1 });
|
||||
const hidden = Object.defineProperty({}, "value", { enumerable: false, value: 1 });
|
||||
const symbol = { [Symbol("value")]: 1 };
|
||||
for (const invalid of [cyclic, sparse, extra, subclass, accessor, hidden, symbol]) {
|
||||
expect(isJsonValue(invalid)).toBe(false);
|
||||
expect(() => copyJson(invalid)).toThrow(/strict JSON/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
631
third_party/pi/packages/chord/test/link.test.ts
vendored
Normal file
631
third_party/pi/packages/chord/test/link.test.ts
vendored
Normal file
@@ -0,0 +1,631 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
type IpcChannel,
|
||||
ipcLink,
|
||||
isChordError,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
type Self,
|
||||
type StateEvent,
|
||||
} from "../src/index.ts";
|
||||
import { Packed, unpack } from "../src/link.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const counterVariants = {
|
||||
main: {
|
||||
state: { count: Type.Object({ n: Type.Number() }) },
|
||||
methods: {
|
||||
add: method(Type.Number(), Type.Number()),
|
||||
who: method(undefined, Type.Unknown()),
|
||||
},
|
||||
},
|
||||
};
|
||||
/** A counter on the server. */
|
||||
const Counter = remote("t.counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
host: hostNamed("server"),
|
||||
variants: counterVariants,
|
||||
});
|
||||
/** A counter on a session host (`session:` + its `session` field). */
|
||||
const SessionCounter = remote("t.session-counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String(), name: Type.String() }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: counterVariants,
|
||||
});
|
||||
|
||||
/** Serves Counter and records the principal of every object it creates. */
|
||||
/** Serves the counter of the host's kind (`Counter` on the server, `SessionCounter` elsewhere). */
|
||||
function serveCounter(host: Host, principals: unknown[] = []) {
|
||||
const counter = (principal: unknown, self: Self) => {
|
||||
principals.push(principal);
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 });
|
||||
return {
|
||||
variant: "main" as const,
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
who: () => principal,
|
||||
};
|
||||
};
|
||||
if (host.name === "server") host.serve(Counter, (_input, principal, self) => counter(principal, self));
|
||||
else host.serve(SessionCounter, (_input, principal, self) => counter(principal, self));
|
||||
}
|
||||
|
||||
/** A server and a worker connected by a memory link; the server accepts the worker as `{ worker: name }`. */
|
||||
function serverWithWorker(workerName = "session:S1") {
|
||||
const errors: unknown[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: (error) => errors.push(error) });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: workerName }, kind: "host", name: workerName });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: workerName,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
return { server, worker, down, up, errors };
|
||||
}
|
||||
|
||||
describe("links: calls and state across hosts", () => {
|
||||
it("a child calls the server; the server stamps the child's principal", async () => {
|
||||
const { server, worker } = serverWithWorker();
|
||||
const principals: unknown[] = [];
|
||||
serveCounter(server, principals);
|
||||
const result = await worker.root(async (ctx) => {
|
||||
const counter = await ctx.get(Counter, { name: "c" });
|
||||
assert.equal(counter.variant, "main");
|
||||
return [await counter.add(2, ctx), await counter.who(ctx)];
|
||||
});
|
||||
assert.deepEqual(result, [2, { worker: "session:S1" }]);
|
||||
assert.deepEqual(principals, [{ worker: "session:S1" }]);
|
||||
});
|
||||
|
||||
it("state members: snapshot, then delta-encoded ops; values decode to the provider's", async () => {
|
||||
const { server, worker } = serverWithWorker();
|
||||
serveCounter(server);
|
||||
const counter = worker.hold(Counter, { name: "s" });
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
const values: unknown[] = [];
|
||||
counter.count.events((event) => events.push(event));
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await counter.ready;
|
||||
await counter.add(1);
|
||||
await counter.add(1);
|
||||
await tick();
|
||||
assert.deepEqual(counter.count.value, { n: 2 });
|
||||
// Listeners added before the hydration get the snapshot then (not `undefined` first).
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["snap", "ops", "ops"],
|
||||
);
|
||||
assert.deepEqual(values, [{ n: 0 }, { n: 1 }, { n: 2 }]);
|
||||
});
|
||||
|
||||
it("the server reaches a connected child by name", async () => {
|
||||
const { server, worker } = serverWithWorker("session:w1");
|
||||
const principals: unknown[] = [];
|
||||
serveCounter(worker, principals);
|
||||
await tick();
|
||||
const counter = server.hold(SessionCounter, { session: "w1", name: "x" });
|
||||
assert.equal(await counter.add(5), 5);
|
||||
// The parent's own requests act as the principal the child gave `connect`.
|
||||
assert.deepEqual(principals, [{ kind: "server" }]);
|
||||
});
|
||||
|
||||
it("rejects local types and unknown hosts", async () => {
|
||||
const { worker } = serverWithWorker();
|
||||
const missing = worker.hold(Counter, { name: "m" });
|
||||
await assert.rejects(missing.ready, (error) => isChordError(error, "unavailable"));
|
||||
});
|
||||
});
|
||||
|
||||
interface Cluster {
|
||||
readonly server: Host;
|
||||
readonly workers: Map<string, Host>;
|
||||
readonly log: string[];
|
||||
browser(
|
||||
principal: unknown,
|
||||
options?: { restamp?: (principal: unknown) => unknown | undefined },
|
||||
): {
|
||||
host: Host;
|
||||
up: ReturnType<typeof memoryLinkPair>[1];
|
||||
reconnect(): void;
|
||||
};
|
||||
allow: (principal: unknown, host: string) => boolean;
|
||||
}
|
||||
|
||||
/** Server routing `session:*` to workers it spawns on demand (memory links); browsers connect as clients. */
|
||||
function cluster(options: { pingInterval?: number; pingTimeout?: number } = {}): Cluster {
|
||||
const log: string[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const workers = new Map<string, Host>();
|
||||
const state: Cluster = {
|
||||
server,
|
||||
workers,
|
||||
log,
|
||||
allow: () => true,
|
||||
browser(principal, browserOptions = {}) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal, restamp: browserOptions.restamp, ...options });
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" }, ...options },
|
||||
onError: () => {},
|
||||
});
|
||||
const ref = { host, up, reconnect: () => {} };
|
||||
ref.reconnect = () => {
|
||||
const [nextDown, nextUp] = memoryLinkPair();
|
||||
server.accept(nextDown, { principal });
|
||||
ref.up = nextUp;
|
||||
host.connect(nextUp, { principal: { kind: "server" } });
|
||||
};
|
||||
return ref;
|
||||
},
|
||||
};
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: (principal, host) => state.allow(principal, host),
|
||||
resolve: async (name) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" }, ...options },
|
||||
onError: () => {},
|
||||
});
|
||||
worker.serve(SessionCounter, (input, principal, self) => {
|
||||
self.resource("probe", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${input.name}`),
|
||||
close: () => void log.push(`close ${input.name}`),
|
||||
});
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 });
|
||||
return {
|
||||
variant: "main",
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
who: () => principal,
|
||||
};
|
||||
});
|
||||
workers.set(name, worker);
|
||||
return down;
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("links: relay, stamping, lifetime", () => {
|
||||
it("relays browser → server → worker; the worker sees the browser's stamped principal", async () => {
|
||||
const c = cluster();
|
||||
const guest = { guest: "g1" };
|
||||
const browser = c.browser(guest);
|
||||
const counter = browser.host.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
assert.equal(await counter.add(3), 3);
|
||||
assert.deepEqual(await counter.who(), guest);
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await counter.add(1);
|
||||
await tick();
|
||||
assert.deepEqual(values.at(-1), { n: 4 });
|
||||
});
|
||||
|
||||
it("ignores payload principals from clients and from child hosts", async () => {
|
||||
const c = cluster();
|
||||
const guest = { guest: "g1" };
|
||||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: guest });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({
|
||||
t: "hold",
|
||||
id: 1,
|
||||
at: "session:S1",
|
||||
token: SessionCounter.description,
|
||||
input: { session: "S1", name: "f" },
|
||||
principal: { owner: true },
|
||||
});
|
||||
raw.send({ t: "call", id: 2, hold: 1, member: "who", args: null });
|
||||
for (let i = 0; i < 50 && !frames.some((frame) => frame.t === "result"); i++) await sleep(5);
|
||||
const result = frames.find((frame) => frame.t === "result");
|
||||
assert.deepEqual(result?.t === "result" ? result.value : undefined, guest);
|
||||
|
||||
// A child host cannot forward principals upward either: the server stamps it.
|
||||
const { server, down: _down, up } = serverWithWorker("w9");
|
||||
serveCounter(server);
|
||||
const childFrames: Frame[] = [];
|
||||
up.receive((frame) => childFrames.push(frame));
|
||||
up.send({
|
||||
t: "hold",
|
||||
id: 7,
|
||||
at: "server",
|
||||
token: Counter.description,
|
||||
input: { name: "f" },
|
||||
principal: { owner: true },
|
||||
});
|
||||
up.send({ t: "call", id: 8, hold: 7, member: "who", args: null });
|
||||
for (let i = 0; i < 50 && !childFrames.some((frame) => frame.t === "result"); i++) await sleep(5);
|
||||
const childResult = childFrames.find((frame) => frame.t === "result");
|
||||
assert.deepEqual(childResult?.t === "result" ? childResult.value : undefined, { worker: "w9" });
|
||||
});
|
||||
|
||||
it("a route that returns an already closed link does not poison the host name", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
let attempt = 0;
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
attempt++;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
serveCounter(worker);
|
||||
// The first worker dies before the server wraps its link.
|
||||
if (attempt === 1) down.close("worker exited");
|
||||
return down;
|
||||
},
|
||||
});
|
||||
const first = server.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
await assert.rejects(first.add(1), (error: Error) => /closed/.test(error.message));
|
||||
// The next call resolves the route again and reaches the new worker.
|
||||
const second = server.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
assert.equal(await second.add(2), 2);
|
||||
assert.equal(attempt, 2);
|
||||
});
|
||||
|
||||
it("authorizes before resolving and again after", async () => {
|
||||
const c = cluster();
|
||||
c.allow = (principal) => (principal as { guest: string }).guest !== "blocked";
|
||||
const blocked = c.browser({ guest: "blocked" });
|
||||
await assert.rejects(blocked.host.hold(SessionCounter, { session: "S1", name: "a" }).ready, (error) =>
|
||||
isChordError(error, "forbidden"),
|
||||
);
|
||||
assert.equal(c.workers.size, 0);
|
||||
let calls = 0;
|
||||
c.allow = () => ++calls === 1;
|
||||
const flaky = c.browser({ guest: "flaky" });
|
||||
await assert.rejects(flaky.host.hold(SessionCounter, { session: "S2", name: "a" }).ready, (error) =>
|
||||
isChordError(error, "forbidden"),
|
||||
);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("a browser disconnect releases exactly its holds; the worker link stays up", async () => {
|
||||
const c = cluster();
|
||||
const one = c.browser({ guest: "g1" });
|
||||
const two = c.browser({ guest: "g2" });
|
||||
await one.host.hold(SessionCounter, { session: "S1", name: "a" }).add(1);
|
||||
const other = two.host.hold(SessionCounter, { session: "S1", name: "b" });
|
||||
await other.add(1);
|
||||
one.up.close();
|
||||
for (let i = 0; i < 20 && !c.log.includes("close a"); i++) await tick();
|
||||
assert.ok(c.log.includes("close a"));
|
||||
assert.ok(!c.log.includes("close b"));
|
||||
assert.equal(await other.add(1), 2);
|
||||
});
|
||||
|
||||
it("a dead link (no pongs) is closed and everything held through it is released", async () => {
|
||||
const c = cluster({ pingInterval: 10, pingTimeout: 40 });
|
||||
const browser = c.browser({ guest: "g1" });
|
||||
await browser.host.hold(SessionCounter, { session: "S1", name: "a" }).add(1);
|
||||
browser.up.sever();
|
||||
for (let i = 0; i < 40 && !c.log.includes("close a"); i++) await sleep(10);
|
||||
assert.ok(c.log.includes("close a"));
|
||||
});
|
||||
|
||||
it("checks contract tokens across links: contract_mismatch for hosts, upgrade_required for browsers", async () => {
|
||||
const CounterV2 = remote("t.counter", {
|
||||
version: 2,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { inc: method(undefined, Type.Number()) } } },
|
||||
});
|
||||
const { server, worker } = serverWithWorker();
|
||||
server.serve(CounterV2, () => ({ variant: "main", inc: () => 1 }));
|
||||
await assert.rejects(worker.hold(Counter, { name: "x" }).ready, (error) =>
|
||||
isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
await assert.rejects(browser.hold(Counter, { name: "x" }).ready, (error) =>
|
||||
isChordError(error, "upgrade_required"),
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidatePrincipal re-stamps the link and propagates to workers", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g1", revision: 1 }, { restamp: (p) => ({ ...(p as object), revision: 2 }) });
|
||||
const counter = browser.host.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
await counter.add(1);
|
||||
await c.server.invalidatePrincipal({ guest: "g1" });
|
||||
// The relay under the old principal is closed at once; the worker disposes its object after.
|
||||
for (let i = 0; i < 20 && counter.status !== "gone"; i++) await tick();
|
||||
assert.equal(counter.status, "gone");
|
||||
for (let i = 0; i < 20 && !c.log.includes("close a"); i++) await tick();
|
||||
assert.ok(c.log.includes("close a"));
|
||||
const again = browser.host.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
assert.deepEqual(await again.who(), { guest: "g1", revision: 2 });
|
||||
});
|
||||
|
||||
it("after a reconnect, state subscribers get a reset and calls work again", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g1" });
|
||||
const counter = browser.host.hold(SessionCounter, { session: "S1", name: "r" });
|
||||
const events: string[] = [];
|
||||
counter.count.events((event) => events.push(event.type));
|
||||
await counter.add(2);
|
||||
browser.up.close();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(counter.status, "loading");
|
||||
browser.reconnect();
|
||||
assert.equal(await counter.add(1), 1);
|
||||
await tick();
|
||||
// The old hold was released with the link, so the worker made a fresh object; the reset carries its value.
|
||||
assert.deepEqual(counter.count.value, { n: 1 });
|
||||
// The first hydration is the listener's snapshot; the re-hold's snapshot is a reset.
|
||||
assert.equal(events[0], "snap");
|
||||
assert.ok(events.includes("reset"));
|
||||
});
|
||||
});
|
||||
|
||||
const Big = remote("t.big", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: Type.Array(Type.String()) },
|
||||
methods: { echo: method(Type.Array(Type.String()), Type.Array(Type.String())) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** About 160,000 characters of JSON: above PACK_THRESHOLD. */
|
||||
const large = (tag: string) => Array.from({ length: 40 }, (_, i) => `${tag}${i}:${"x".repeat(4000)}`);
|
||||
|
||||
describe("links: methods without arguments or result", () => {
|
||||
it("travel without args and without a value, and resolve to the result or undefined", async () => {
|
||||
const Tool = remote("t.tool", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: { methods: { ping: method(), count: method(undefined, Type.Number()), note: method(Type.String()) } },
|
||||
},
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const notes: string[] = [];
|
||||
server.serve(Tool, () => ({
|
||||
variant: "main",
|
||||
ping: () => {},
|
||||
count: () => 2,
|
||||
note: (text) => {
|
||||
notes.push(text);
|
||||
return "dropped" as never;
|
||||
},
|
||||
}));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const sent: Frame[] = [];
|
||||
const send = up.send.bind(up);
|
||||
up.send = (frame) => {
|
||||
sent.push(frame);
|
||||
return send(frame);
|
||||
};
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const tool = browser.hold(Tool, {});
|
||||
await tool.ready;
|
||||
assert.equal(await tool.ping(), undefined);
|
||||
assert.equal(await tool.count(), 2);
|
||||
assert.equal(await tool.note("hi"), undefined);
|
||||
assert.deepEqual(notes, ["hi"]);
|
||||
const calls = sent.filter((frame) => frame.t === "call");
|
||||
assert.deepEqual(
|
||||
calls.map((frame) => (frame.t === "call" ? "args" in frame && frame.args !== undefined : undefined)),
|
||||
[false, false, true],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links: protocol version", () => {
|
||||
const mismatches = () => {
|
||||
const seen: { local: number; peer: number | undefined }[] = [];
|
||||
return {
|
||||
seen,
|
||||
onProtocolMismatch: (versions: { local: number; peer: number | undefined }) => seen.push(versions),
|
||||
};
|
||||
};
|
||||
|
||||
it("a peer without a protocol version (an old client) is told and dropped; nothing it sends is served", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const accepted = mismatches();
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" }, onProtocolMismatch: accepted.onProtocolMismatch });
|
||||
const frames: Frame[] = [];
|
||||
let closed: string | undefined;
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.onClose((reason) => {
|
||||
closed = reason;
|
||||
});
|
||||
raw.send({ t: "hello", name: "old", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "hold", id: 1, at: "server", token: Counter.description, input: { name: "c" } });
|
||||
await tick();
|
||||
assert.deepEqual(accepted.seen, [{ local: PROTOCOL_VERSION, peer: 1 }]);
|
||||
assert.equal(closed, `protocol-version ${PROTOCOL_VERSION}`);
|
||||
assert.equal(down.closed, true);
|
||||
assert(!frames.some((frame) => frame.t === "shape"), "the old client's hold was not served");
|
||||
});
|
||||
|
||||
it("a client learns that its server speaks another version, and stops", async () => {
|
||||
const client = mismatches();
|
||||
const [down, up] = memoryLinkPair();
|
||||
const browser = createHost({ self: { kind: "host" }, name: "browser", kind: "client", onError: () => {} });
|
||||
browser.connect(up, { principal: { kind: "server" }, onProtocolMismatch: client.onProtocolMismatch });
|
||||
// A newer server: its hello announces the next version, and it closes the link the same way Chord does.
|
||||
down.send({ t: "hello", protocol: PROTOCOL_VERSION + 1, name: "server", kind: "host", codeVersion: 1 });
|
||||
await tick();
|
||||
assert.deepEqual(client.seen, [{ local: PROTOCOL_VERSION, peer: PROTOCOL_VERSION + 1 }]);
|
||||
assert.equal(up.closed, true);
|
||||
|
||||
// Told by the peer (it noticed first): the close reason carries its version.
|
||||
const told = mismatches();
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
const other = createHost({ self: { kind: "host" }, name: "browser", kind: "client", onError: () => {} });
|
||||
other.connect(up2, { principal: { kind: "server" }, onProtocolMismatch: told.onProtocolMismatch });
|
||||
down2.close(`protocol-version ${PROTOCOL_VERSION + 1}`);
|
||||
await tick();
|
||||
assert.deepEqual(told.seen, [{ local: PROTOCOL_VERSION, peer: PROTOCOL_VERSION + 1 }]);
|
||||
});
|
||||
|
||||
it("peers of the same version talk; an ordinary close is no mismatch", async () => {
|
||||
const both = mismatches();
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" }, onProtocolMismatch: both.onProtocolMismatch });
|
||||
const browser = createHost({ self: { kind: "host" }, name: "browser", kind: "client", onError: () => {} });
|
||||
browser.connect(up, { principal: { kind: "server" }, onProtocolMismatch: both.onProtocolMismatch });
|
||||
await tick();
|
||||
up.close("bye");
|
||||
await tick();
|
||||
assert.deepEqual(both.seen, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links: packed payloads", () => {
|
||||
it("large args, results and snapshots cross a relay deflated and arrive intact", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
worker.serve(Big, (_input, _principal, self) => {
|
||||
const notesState = self.state("notes", Type.Array(Type.String()), large("a"));
|
||||
return { variant: "main", notes: notesState, echo: (notes: string[]) => notes };
|
||||
});
|
||||
return down;
|
||||
},
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { owner: true } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const big = browser.hold(Big, {});
|
||||
assert.deepEqual(await big.echo(large("c")), large("c"));
|
||||
big.notes.subscribe(() => {});
|
||||
for (let i = 0; i < 100 && big.notes.value === undefined; i++) await sleep(5);
|
||||
assert.deepEqual(big.notes.value, large("a"));
|
||||
});
|
||||
|
||||
it("only payloads above the threshold are packed; the receiver unpacks them", async () => {
|
||||
const [a, b] = memoryLinkPair();
|
||||
const frames: Frame[] = [];
|
||||
b.receive((frame) => frames.push(frame));
|
||||
a.send({ t: "result", id: 1, value: large("v") });
|
||||
a.send({ t: "result", id: 2, value: ["small"] });
|
||||
a.send({ t: "state", hold: 3, m: "notes", as: "snap", value: large("s") });
|
||||
for (let i = 0; i < 50 && frames.length < 3; i++) await tick();
|
||||
const [packed, small, snap] = frames;
|
||||
assert(packed?.t === "result" && packed.value instanceof Packed);
|
||||
assert.deepEqual(unpack(packed.value), large("v"));
|
||||
assert(small?.t === "result");
|
||||
assert.deepEqual(small.value, ["small"]);
|
||||
assert(snap?.t === "state" && snap.value instanceof Packed);
|
||||
assert.deepEqual(unpack(snap.value), large("s"));
|
||||
|
||||
// A relay forwards the packed bytes as they are.
|
||||
const [c, d] = memoryLinkPair();
|
||||
const forwarded: Frame[] = [];
|
||||
d.receive((frame) => forwarded.push(frame));
|
||||
c.send({ ...packed, id: 9 });
|
||||
for (let i = 0; i < 50 && forwarded.length < 1; i++) await tick();
|
||||
const relayed = forwarded[0];
|
||||
assert(relayed?.t === "result" && relayed.value instanceof Packed);
|
||||
assert.deepEqual(relayed.value.bytes, packed.value.bytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links: IPC", () => {
|
||||
it("a write that fails after the peer process exited (EPIPE) ends the link instead of raising an unhandled error", async () => {
|
||||
// Node reports such a failure asynchronously: to the send callback, or else as an `error` event nobody handles.
|
||||
const sent: unknown[] = [];
|
||||
const channel: IpcChannel = {
|
||||
connected: true,
|
||||
send: (message, callback) => {
|
||||
sent.push(message);
|
||||
const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
if (callback === undefined) throw new Error("an IPC send without a callback cannot report EPIPE");
|
||||
process.nextTick(() => callback(error));
|
||||
return false;
|
||||
},
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
};
|
||||
const link = ipcLink(channel);
|
||||
const reasons: string[] = [];
|
||||
link.onClose((reason) => reasons.push(reason));
|
||||
link.send({ t: "ping", n: 1 });
|
||||
await tick();
|
||||
assert.equal(link.closed, true);
|
||||
assert.deepEqual(reasons, ["ipc send failed: EPIPE"]);
|
||||
assert.equal(sent.length, 1);
|
||||
link.send({ t: "ping", n: 2 });
|
||||
assert.equal(sent.length, 1, "a closed link sends nothing more");
|
||||
});
|
||||
});
|
||||
553
third_party/pi/packages/chord/test/links-review.test.ts
vendored
Normal file
553
third_party/pi/packages/chord/test/links-review.test.ts
vendored
Normal file
@@ -0,0 +1,553 @@
|
||||
// Regression tests for Astra's phase 1b review (findings #1-#11) and the shared-worker test gap.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Facet,
|
||||
type FacetLoader,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 100): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
/** A link that logs what it sends and can drop a frame by closing instead. */
|
||||
class WatchedLink implements Link {
|
||||
readonly inner: Link;
|
||||
readonly sent: Frame[] = [];
|
||||
closeOn: ((frame: Frame) => boolean) | undefined;
|
||||
constructor(inner: Link) {
|
||||
this.inner = inner;
|
||||
}
|
||||
get closed(): boolean {
|
||||
return this.inner.closed;
|
||||
}
|
||||
send(frame: Frame): number | undefined {
|
||||
this.sent.push(frame);
|
||||
if (this.closeOn?.(frame)) {
|
||||
this.inner.close("dropped");
|
||||
return undefined;
|
||||
}
|
||||
return this.inner.send(frame);
|
||||
}
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.inner.receive(handler);
|
||||
}
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
this.inner.onClose(handler);
|
||||
}
|
||||
close(reason?: string): void {
|
||||
this.inner.close(reason);
|
||||
}
|
||||
}
|
||||
|
||||
const Counter = remote("t.counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: Type.Object({ n: Type.Number() }) },
|
||||
methods: {
|
||||
add: method(Type.Number(), Type.Number()),
|
||||
who: method(undefined, Type.Unknown()),
|
||||
slow: method(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** A server routing `session:*` to workers it spawns once per name; the server→worker link is watched. */
|
||||
function cluster() {
|
||||
const log: string[] = [];
|
||||
const principals: unknown[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const workers = new Map<string, { host: Host; link: WatchedLink }>();
|
||||
const state = {
|
||||
server,
|
||||
log,
|
||||
principals,
|
||||
workers,
|
||||
resolveGate: undefined as Promise<void> | undefined,
|
||||
slowGate: latch(),
|
||||
browser(principal: unknown, restamp?: (p: unknown) => unknown | undefined) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal, restamp });
|
||||
return {
|
||||
host: createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
}),
|
||||
up,
|
||||
};
|
||||
},
|
||||
raw(principal: unknown) {
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
return { raw, frames };
|
||||
},
|
||||
};
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
await state.resolveGate;
|
||||
const existing = workers.get(name);
|
||||
if (existing !== undefined) return existing.link;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const link = new WatchedLink(down);
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
worker.serve(Counter, (input, principal, self) => {
|
||||
principals.push(principal);
|
||||
self.resource("probe", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${input.name}`),
|
||||
close: () => void log.push(`close ${input.name}`),
|
||||
});
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 });
|
||||
return {
|
||||
variant: "main",
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
who: () => principal,
|
||||
slow: async (ctx: Ctx) => {
|
||||
ctx.signal.addEventListener("abort", () => log.push("slow aborted"));
|
||||
await state.slowGate.promise;
|
||||
},
|
||||
};
|
||||
});
|
||||
workers.set(name, { host: worker, link });
|
||||
return link;
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
const internals = (host: Host) =>
|
||||
host as unknown as {
|
||||
connections: Set<{
|
||||
role: string;
|
||||
peer: { kind: string; name?: string };
|
||||
relayedRefs: Map<number, unknown>;
|
||||
relayedSubs: Map<number, unknown>;
|
||||
relayedCalls: Map<number, unknown>;
|
||||
}>;
|
||||
reloading: boolean;
|
||||
};
|
||||
|
||||
describe("links review: identity and stamping", () => {
|
||||
it("#1 the acceptor decides what a peer is: a client announcing itself as a host is cut off", async () => {
|
||||
const c = cluster();
|
||||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "session:S9", kind: "host", codeVersion: 1 });
|
||||
await until(() => raw.closed);
|
||||
// A host link must have a name and its hello must match it.
|
||||
assert.throws(() => c.server.accept(memoryLinkPair()[0], { principal: {}, kind: "host" }), /needs its name/);
|
||||
});
|
||||
|
||||
it("#1 root_end is honored only from the link the root came over", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { root: method(undefined, Type.String()) } } },
|
||||
});
|
||||
const facet = (version: number): Facet => ({
|
||||
name: "echo",
|
||||
setup: (api) =>
|
||||
api.serve(Echo, (_input, _principal, self) => {
|
||||
self.state("n", Type.Number(), 0, { version });
|
||||
return { variant: "main", root: (ctx: Ctx) => ctx.rootId };
|
||||
}),
|
||||
});
|
||||
server.install(facet(1));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "w" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "w",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const go = latch();
|
||||
let rootId = "";
|
||||
const pinned = worker.root(async (ctx) => {
|
||||
rootId = (await (await ctx.get(Echo, {})).root(ctx)) as string;
|
||||
await go.promise;
|
||||
});
|
||||
await until(() => rootId !== "");
|
||||
// A client forging the worker root's end must not release its lease.
|
||||
const [clientDown, raw] = memoryLinkPair();
|
||||
server.accept(clientDown, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "root_end", rootId });
|
||||
await sleep(10);
|
||||
await assert.rejects(server.reload([facet(2)], { gateDeadline: 40 }), (error) => {
|
||||
assert.ok(isChordError(error, "aborted") && error.reason === "gate_deadline");
|
||||
assert.ok(error.message.includes(rootId));
|
||||
return true;
|
||||
});
|
||||
go.open();
|
||||
await pinned;
|
||||
});
|
||||
|
||||
it("#2 a revocation while the route is still resolving closes the relay; no worker object is made", async () => {
|
||||
const c = cluster();
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const browser = c.browser({ guest: "g", revision: 1 }, (p) => ({ ...(p as object), revision: 2 }));
|
||||
const counter = browser.host.hold(Counter, { name: "a" });
|
||||
await sleep(5);
|
||||
await c.server.invalidatePrincipal({ guest: "g" });
|
||||
await until(() => counter.status === "gone");
|
||||
gate.open();
|
||||
await sleep(10);
|
||||
assert.deepEqual(c.principals, []);
|
||||
const again = browser.host.hold(Counter, { name: "a" });
|
||||
assert.deepEqual(await again.who(), { guest: "g", revision: 2 });
|
||||
});
|
||||
|
||||
it("#10 malformed frames and duplicate ids close the link", async () => {
|
||||
const c = cluster();
|
||||
const first = c.raw({ guest: "g" });
|
||||
first.raw.send({ t: "call", hold: 1 } as never);
|
||||
await until(() => first.raw.closed);
|
||||
const second = c.raw({ guest: "g" });
|
||||
const token = Counter.description;
|
||||
second.raw.send({ t: "hold", id: 1, at: "session:S1", token, input: { name: "a" } });
|
||||
second.raw.send({ t: "hold", id: 1, at: "session:S1", token, input: { name: "b" } });
|
||||
await until(() => second.raw.closed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links review: relay ownership", () => {
|
||||
it("#7 a browser disconnect cancels its call downstream", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g" });
|
||||
const counter = browser.host.hold(Counter, { name: "a" });
|
||||
await counter.ready;
|
||||
void counter.slow().catch(() => {});
|
||||
await sleep(10);
|
||||
browser.up.close();
|
||||
await until(() => c.log.includes("slow aborted"));
|
||||
const down = [...internals(c.server).connections].find((connection) => connection.peer.name === "session:S1")!;
|
||||
assert.equal(down.relayedCalls.size, 0);
|
||||
});
|
||||
|
||||
it("#9 state stopped while the route resolves is forwarded as a hold without state", async () => {
|
||||
const c = cluster();
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const { raw } = c.raw({ guest: "g" });
|
||||
raw.send({ t: "hold", id: 1, at: "session:S1", token: Counter.description, input: { name: "a" } });
|
||||
raw.send({ t: "unsub", hold: 1 });
|
||||
await sleep(5);
|
||||
gate.open();
|
||||
await until(() => c.workers.get("session:S1")?.link.sent.some((frame) => frame.t === "hold") === true);
|
||||
await sleep(10);
|
||||
const sent = c.workers.get("session:S1")!.link.sent;
|
||||
const hold = sent.find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.state === false);
|
||||
assert.ok(!sent.some((frame) => frame.t === "sub" || frame.t === "unsub"));
|
||||
});
|
||||
|
||||
it("#9 a release while the route resolves settles waiting calls and forwards nothing", async () => {
|
||||
const c = cluster();
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const { raw, frames } = c.raw({ guest: "g" });
|
||||
raw.send({ t: "hold", id: 1, at: "session:S1", token: Counter.description, input: { name: "a" } });
|
||||
raw.send({ t: "call", id: 2, hold: 1, member: "add", args: 1 });
|
||||
await sleep(5);
|
||||
raw.send({ t: "release", id: 1 });
|
||||
await until(() => frames.some((frame) => frame.t === "result" && frame.id === 2));
|
||||
const result = frames.find((frame) => frame.t === "result" && frame.id === 2);
|
||||
assert.ok(result?.t === "result" && result.error !== undefined);
|
||||
gate.open();
|
||||
await sleep(10);
|
||||
assert.ok(!(c.workers.get("session:S1")?.link.sent ?? []).some((frame) => frame.t === "hold"));
|
||||
});
|
||||
|
||||
it("#9 an origin disconnect removes its relayed holds and subscriptions downstream", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g" });
|
||||
const counter = browser.host.hold(Counter, { name: "a" });
|
||||
counter.count.subscribe(() => {});
|
||||
await counter.add(1);
|
||||
const down = [...internals(c.server).connections].find((connection) => connection.peer.name === "session:S1")!;
|
||||
assert.equal(down.relayedRefs.size, 1);
|
||||
browser.up.close();
|
||||
await until(() => down.relayedRefs.size === 0);
|
||||
await until(() => c.log.includes("close a"));
|
||||
});
|
||||
|
||||
it("two browsers share one worker connection; one leaving releases only its own holds", async () => {
|
||||
const c = cluster();
|
||||
const one = c.browser({ guest: "g1" });
|
||||
const two = c.browser({ guest: "g2" });
|
||||
const a = one.host.hold(Counter, { name: "same" });
|
||||
const b = two.host.hold(Counter, { name: "same" });
|
||||
assert.equal(await a.add(1), 1);
|
||||
assert.equal(await b.add(5), 5);
|
||||
assert.equal(c.workers.size, 1);
|
||||
assert.deepEqual(c.principals, [{ guest: "g1" }, { guest: "g2" }]);
|
||||
one.up.close();
|
||||
await until(() => c.log.filter((entry) => entry === "close same").length === 1);
|
||||
assert.equal(await b.add(1), 6);
|
||||
});
|
||||
|
||||
it("#11 snapshots through an accepts adapter are validated against the consumer's schema", async () => {
|
||||
const DocV1 = remote("t.doc", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { view: Type.Object({ text: Type.String() }) } } },
|
||||
});
|
||||
const DocV2 = remote("t.doc", {
|
||||
version: 2,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { view: Type.Object({ body: Type.String() }) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(
|
||||
DocV2,
|
||||
(_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
view: self.state("view", Type.Object({ body: Type.String() }), { body: "hi" }),
|
||||
}),
|
||||
{
|
||||
accepts: {
|
||||
1: (current) => {
|
||||
const view = current.view as {
|
||||
value: { body: string };
|
||||
events: (fn: (event: never) => void) => () => void;
|
||||
};
|
||||
const map = (value: { body: string }) => ({ text: value.body });
|
||||
return {
|
||||
variant: "main",
|
||||
view: {
|
||||
get value() {
|
||||
return map(view.value);
|
||||
},
|
||||
subscribe: (fn: (value: unknown) => void) => view.events(() => fn(map(view.value))),
|
||||
events: (fn: (event: unknown) => void) =>
|
||||
view.events((event: { type: string }) =>
|
||||
fn({ type: event.type === "ops" ? "reset" : event.type, value: map(view.value) }),
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const doc = browser.hold(DocV1, {});
|
||||
await doc.ready;
|
||||
assert.deepEqual(doc.view.value, { text: "hi" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("links review: cross-host reload", () => {
|
||||
const Greeter = remote("t.greeter", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { greet: method(Type.String(), Type.String()) } } },
|
||||
});
|
||||
const greeterFacet = (greeting: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(Greeter, () => ({ variant: "main", greet: (name: string) => `${greeting} ${name}` })),
|
||||
});
|
||||
|
||||
function setup(options: { slowLoad?: Promise<void> } = {}) {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loader: FacetLoader = async (names) => {
|
||||
await options.slowLoad;
|
||||
return names.map((name) => modules.get(String(name))!);
|
||||
};
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const connect = () => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const watched = new WatchedLink(down);
|
||||
server.accept(watched, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
return { watched, up };
|
||||
};
|
||||
const first = connect();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: first.up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
worker.install(modules.get("greeter")!);
|
||||
return { server, worker, modules, link: first.watched, connect };
|
||||
}
|
||||
|
||||
it("#3 an abort while the child is still loading modules frees its reload slot; a later reload works", async () => {
|
||||
const gate = latch();
|
||||
const { server, worker, modules } = setup({ slowLoad: gate.promise });
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] }, voteDeadline: 30 }), (error) =>
|
||||
isChordError(error, "aborted"),
|
||||
);
|
||||
gate.open();
|
||||
await until(() => !internals(worker).reloading);
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await server.hold(Greeter, {}).greet("x"), "v2 x");
|
||||
});
|
||||
|
||||
it("#3 the parent link dying while the child prepares unwinds the child", async () => {
|
||||
const gate = latch();
|
||||
const { server, worker, modules, link } = setup({ slowLoad: gate.promise });
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const reload = server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await sleep(5);
|
||||
link.close("gone");
|
||||
await assert.rejects(reload);
|
||||
gate.open();
|
||||
await until(() => !internals(worker).reloading);
|
||||
assert.equal(worker.codeVersion, 1);
|
||||
});
|
||||
|
||||
it("#4 tasks the commit starts cannot reach a child before the child's commit", async () => {
|
||||
const { server, modules } = setup();
|
||||
await tick();
|
||||
const results: unknown[] = [];
|
||||
const Caller = local("t.caller", { version: 1, input: Type.Object({}) });
|
||||
const callerFacet = (active: boolean): Facet => ({
|
||||
name: "caller",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Caller,
|
||||
(_input, _principal, self) => {
|
||||
const greeter = self.get(Greeter, {});
|
||||
if (active) {
|
||||
self.task(async (ctx) => {
|
||||
results.push(
|
||||
await greeter.greet("t", ctx).catch((error: unknown) => (error as { code: string }).code),
|
||||
);
|
||||
});
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
server.install(callerFacet(false));
|
||||
await sleep(10);
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([callerFacet(true)], { remote: { "session:": ["greeter"] } });
|
||||
await until(() => results.length === 1);
|
||||
assert.deepEqual(results, ["v2 t"]);
|
||||
});
|
||||
|
||||
it("#5 a child that missed a commit is reconciled on reconnect, never relabeled", async () => {
|
||||
const { server, worker, modules, link, connect } = setup();
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
// The commit frame is lost: the link dies right after the child voted.
|
||||
link.closeOn = (frame) => frame.t === "commit";
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await until(() => !internals(worker).reloading);
|
||||
assert.notEqual(worker.codeVersion, server.codeVersion);
|
||||
const next = connect();
|
||||
worker.connect(next.up, { principal: { kind: "server" } });
|
||||
const greeter = server.hold(Greeter, {});
|
||||
assert.equal(await greeter.greet("x"), "v2 x");
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("#6 a server root that bounces through a worker and back ends normally (no leaked lease)", async () => {
|
||||
const { server, worker } = setup();
|
||||
await tick();
|
||||
const Echo = remote("t.echo2", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
});
|
||||
const Bouncer = remote("t.bouncer", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { bounce: method() } } },
|
||||
});
|
||||
const echoFacet = (version: number): Facet => ({
|
||||
name: "echo",
|
||||
setup: (api) =>
|
||||
api.serve(Echo, (_input, _principal, self) => {
|
||||
self.state("n", Type.Number(), 0, { version });
|
||||
return { variant: "main", ping: () => {} };
|
||||
}),
|
||||
});
|
||||
server.install(echoFacet(1));
|
||||
// Keeps the Echo object the bounced call reaches (it acts as the worker's principal) alive, so the reload gate
|
||||
// must decide about the bounced root's lease.
|
||||
const echo = server.hold(Echo, {}, { principal: { worker: "w" } });
|
||||
await echo.ready;
|
||||
worker.serve(Bouncer, () => ({
|
||||
variant: "main",
|
||||
bounce: async (ctx: Ctx) => {
|
||||
await (await ctx.get(Echo, {})).ping(ctx);
|
||||
},
|
||||
}));
|
||||
await server.root((ctx) => server.hold(Bouncer, {}).bounce(ctx));
|
||||
await sleep(10);
|
||||
await server.reload([echoFacet(2)], { gateDeadline: 200 });
|
||||
});
|
||||
});
|
||||
21
third_party/pi/packages/chord/test/loader.test.ts
vendored
Normal file
21
third_party/pi/packages/chord/test/loader.test.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it } from "vitest";
|
||||
import { createHost } from "../src/index.ts";
|
||||
|
||||
describe("node loader", () => {
|
||||
it("loads facet modules and reloads them with fresh copies of their relative imports", () => {
|
||||
const child = spawnSync(process.execPath, [fileURLToPath(new URL("./fixtures/loader.ts", import.meta.url))], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
assert.equal(child.error, undefined, child.stderr);
|
||||
assert.equal(child.status, 0, `${child.stdout}\n${child.stderr}`);
|
||||
});
|
||||
|
||||
it("needs a configured loader for modules", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
await assert.rejects(host.load(["./facet.ts"]), /createHost\(\{ loader \}\)/);
|
||||
});
|
||||
});
|
||||
183
third_party/pi/packages/chord/test/manager.test.ts
vendored
Normal file
183
third_party/pi/packages/chord/test/manager.test.ts
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it } from "vitest";
|
||||
import { createHost, ipcLink, isChordError } from "../src/index.ts";
|
||||
import { type ControlMessage, createHostManager, type Driver, forkDriver, processLock } from "../src/manager.ts";
|
||||
import { Echo } from "./fixtures/echo.ts";
|
||||
|
||||
const entry = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url));
|
||||
const scratch = (name: string) => join(tmpdir(), `chord2-${process.pid}-${name}`);
|
||||
|
||||
const alive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const driver = (env: Record<string, string> = {}, lock?: string): Driver =>
|
||||
forkDriver({ entry, env: { HOST_NAME: "w1", ...env }, init: { greeting: "hi" }, lock });
|
||||
|
||||
describe("processLock", () => {
|
||||
// A socket path over 104 bytes failed with EINVAL on macOS when TMPDIR was deep (./test.sh below macOS's TMPDIR).
|
||||
it("admits one holder when the temporary directory is too deep for a socket path", async () => {
|
||||
const deep = join(scratch("deep"), "a".repeat(60), "b".repeat(60));
|
||||
mkdirSync(deep, { recursive: true });
|
||||
const previous = process.env.TMPDIR;
|
||||
process.env.TMPDIR = deep;
|
||||
try {
|
||||
const name = `deep-${process.pid}`;
|
||||
const release = await processLock(name);
|
||||
await assert.rejects(processLock(name), (error) => isChordError(error) && error.reason === "locked");
|
||||
await release();
|
||||
await (await processLock(name))();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TMPDIR;
|
||||
else process.env.TMPDIR = previous;
|
||||
rmSync(scratch("deep"), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("host manager (real child processes)", () => {
|
||||
// A child host that forks its own children used to pass its CHORD2_LOCK on, so they failed with "locked".
|
||||
it("a child without a lock does not inherit the parent's lock", async () => {
|
||||
const name = `chord2-test-${process.pid}-inherited`;
|
||||
const release = await processLock(name);
|
||||
const previous = process.env.CHORD2_LOCK;
|
||||
process.env.CHORD2_LOCK = name;
|
||||
const manager = createHostManager({ driver: driver() });
|
||||
try {
|
||||
await manager.start();
|
||||
assert.equal(manager.status, "running");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CHORD2_LOCK;
|
||||
else process.env.CHORD2_LOCK = previous;
|
||||
await manager.stop();
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a child host, links it, stops it gracefully", async () => {
|
||||
const manager = createHostManager({ driver: driver() });
|
||||
const instance = await manager.start();
|
||||
assert.equal(manager.status, "running");
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.accept(instance.link, { principal: { worker: "w1" }, kind: "host", name: "w1" });
|
||||
const echo = server.hold(Echo, {});
|
||||
assert.equal(await echo.echo("x"), `hi x from ${instance.pid}`);
|
||||
assert.equal(await manager.start(), instance);
|
||||
await manager.stop();
|
||||
assert.equal(manager.status, "stopped");
|
||||
assert.deepEqual(await instance.exited, { code: 0, signal: null });
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a child-owned kernel lock admits one live process; replace waits for the exit before the next start", async () => {
|
||||
const lock = `chord2-test-${process.pid}-lock`;
|
||||
const pids: number[] = [];
|
||||
const base = driver({}, lock);
|
||||
const watched: Driver = {
|
||||
spawn() {
|
||||
// Exit before replace: the previous child is gone when the next one spawns.
|
||||
for (const pid of pids) assert.ok(!alive(pid), `pid ${pid} still alive`);
|
||||
const instance = base.spawn();
|
||||
pids.push(instance.pid);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
const first = createHostManager({ driver: watched });
|
||||
const second = createHostManager({ driver: driver({}, lock) });
|
||||
const a = await first.start();
|
||||
// Both managers race for the same lock; only the kernel decides, so the second child cannot start.
|
||||
await assert.rejects(second.start(), (error) => isChordError(error, "unavailable") && error.reason === "locked");
|
||||
const b = await first.replace();
|
||||
assert.notEqual(a.pid, b.pid);
|
||||
await first.stop();
|
||||
await second.start();
|
||||
await second.stop();
|
||||
});
|
||||
|
||||
it("a graceful exit still takes the child's descendants with it", async () => {
|
||||
const file = scratch("grandchild-graceful");
|
||||
rmSync(file, { force: true });
|
||||
const manager = createHostManager({ driver: driver({ GRANDCHILD_FILE: file }) });
|
||||
await manager.start();
|
||||
const grandchild = Number(readFileSync(file, "utf8"));
|
||||
assert.ok(alive(grandchild));
|
||||
await manager.stop();
|
||||
for (let i = 0; i < 50 && alive(grandchild); i++) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(!alive(grandchild));
|
||||
rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
it("idleFor counts from the busy → idle transition", async () => {
|
||||
const listeners = new Set<(message: ControlMessage) => void>();
|
||||
const fake: Driver = {
|
||||
spawn: () => ({
|
||||
pid: 0,
|
||||
link: ipcLink({ connected: false, send: () => false, on: () => undefined, off: () => undefined }),
|
||||
exited: new Promise(() => {}),
|
||||
send: () => {},
|
||||
killTree: () => {},
|
||||
onControl: (listener) => listeners.add(listener),
|
||||
}),
|
||||
};
|
||||
const emit = (message: ControlMessage) => {
|
||||
for (const listener of listeners) listener(message);
|
||||
};
|
||||
const manager = createHostManager({ driver: fake });
|
||||
const started = manager.start();
|
||||
for (let i = 0; i < 20 && listeners.size === 0; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
emit({ type: "ready" });
|
||||
await started;
|
||||
emit({ type: "status", activeCalls: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.equal(manager.liveness.idleFor, 0);
|
||||
emit({ type: "status", activeCalls: 0 });
|
||||
assert.ok(manager.liveness.idleFor < 60, `idleFor ${manager.liveness.idleFor}`);
|
||||
});
|
||||
|
||||
it("kills the process tree when a child ignores shutdown past the grace period", async () => {
|
||||
const file = scratch("grandchild");
|
||||
rmSync(file, { force: true });
|
||||
const manager = createHostManager({
|
||||
driver: driver({ FIXTURE_MODE: "ignore-shutdown", GRANDCHILD_FILE: file }),
|
||||
grace: 300,
|
||||
});
|
||||
const instance = await manager.start();
|
||||
const grandchild = Number(readFileSync(file, "utf8"));
|
||||
assert.ok(alive(grandchild));
|
||||
await manager.stop();
|
||||
assert.equal((await instance.exited).signal, "SIGKILL");
|
||||
for (let i = 0; i < 50 && alive(grandchild); i++) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(!alive(grandchild));
|
||||
rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
it("reports a fatal child as a failed start", async () => {
|
||||
const manager = createHostManager({ driver: driver({ FIXTURE_MODE: "fatal" }) });
|
||||
await assert.rejects(manager.start(), /fixture refuses to start/);
|
||||
assert.equal(manager.status, "stopped");
|
||||
});
|
||||
|
||||
it("stops an idle child when keepAlive says so", async () => {
|
||||
const exits: unknown[] = [];
|
||||
const manager = createHostManager({
|
||||
driver: driver(),
|
||||
keepAlive: (liveness) => !liveness.booting && liveness.idleFor < 150,
|
||||
checkInterval: 50,
|
||||
onExit: (exit) => exits.push(exit),
|
||||
});
|
||||
await manager.start();
|
||||
for (let i = 0; i < 100 && manager.status !== "stopped"; i++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(manager.status, "stopped");
|
||||
assert.equal(exits.length, 1);
|
||||
});
|
||||
});
|
||||
143
third_party/pi/packages/chord/test/output-skip.test.ts
vendored
Normal file
143
third_party/pi/packages/chord/test/output-skip.test.ts
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OutputBuffer, type OutputLimits, type OutputSkip } from "../src/output.ts";
|
||||
|
||||
/** Deterministic PRNG (mulberry32) so failures reproduce from the printed seed. */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// Decoded shell output: ASCII, multi-byte and astral characters, replacement characters, CR/LF, tabs, and control
|
||||
// characters that sanitizing removes after bounding.
|
||||
const ALPHABET = ["a", "b", "z", " ", "\n", "\n", "\n", "\r\n", "\t", "é", "€", "😀", "\ufffd", "\x01", "\x1b"];
|
||||
|
||||
function randomChunk(next: () => number): string {
|
||||
const length = Math.floor(next() * 12);
|
||||
let text = "";
|
||||
for (let index = 0; index < length; index++) text += ALPHABET[Math.floor(next() * ALPHABET.length)];
|
||||
return text;
|
||||
}
|
||||
|
||||
function measure(text: string): OutputSkip {
|
||||
let newlines = 0;
|
||||
for (const character of text) if (character === "\n") newlines++;
|
||||
return { bytes: Buffer.byteLength(text, "utf8"), newlines, endsWithNewline: text.endsWith("\n") };
|
||||
}
|
||||
|
||||
/** Whether `text` proves that everything before it is outside the tail window. */
|
||||
function exceedsWindow(text: string, limits: OutputLimits): boolean {
|
||||
const { bytes, newlines } = measure(text);
|
||||
return bytes > limits.maxBytes || newlines > limits.maxLines;
|
||||
}
|
||||
|
||||
/** Code-point boundaries of `text`, so a cut never splits a surrogate pair. */
|
||||
function boundaries(text: string): number[] {
|
||||
const result = [0];
|
||||
let offset = 0;
|
||||
for (const character of text) {
|
||||
offset += character.length;
|
||||
result.push(offset);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed random output to one buffer in full and to another through a reference skipper that follows the contract of
|
||||
* `ShellOutputInfo.skipped`: it holds undelivered text, and when it flushes, it may replace a prefix of it by counts if
|
||||
* the rest exceeds the window. Snapshots must agree whenever both buffers have seen the same output.
|
||||
*/
|
||||
function checkSeed(seed: number): void {
|
||||
const next = random(seed);
|
||||
const limits: OutputLimits = {
|
||||
maxBytes: 1 + Math.floor(next() * 40),
|
||||
maxLines: 1 + Math.floor(next() * 5),
|
||||
retain: "tail",
|
||||
};
|
||||
const full = new OutputBuffer(limits);
|
||||
const skipping = new OutputBuffer(limits);
|
||||
let pending = "";
|
||||
let skips = 0;
|
||||
const flush = (): void => {
|
||||
if (pending === "") return;
|
||||
const cuts = boundaries(pending).filter((cut) => cut > 0 && exceedsWindow(pending.slice(cut), limits));
|
||||
if (cuts.length > 0 && next() < 0.7) {
|
||||
const cut = cuts[Math.floor(next() * cuts.length)]!;
|
||||
skipping.push(pending.slice(cut), measure(pending.slice(0, cut)));
|
||||
skips++;
|
||||
} else {
|
||||
skipping.push(pending);
|
||||
}
|
||||
pending = "";
|
||||
// Progress snapshots happen between deliveries and compact stored chunks.
|
||||
if (next() < 0.5) skipping.snapshot();
|
||||
expect(skipping.snapshot(), `seed ${seed}`).toEqual(full.snapshot());
|
||||
};
|
||||
const chunks = Math.floor(next() * 40);
|
||||
for (let index = 0; index < chunks; index++) {
|
||||
const chunk = randomChunk(next);
|
||||
full.push(chunk);
|
||||
if (next() < 0.3) full.snapshot();
|
||||
pending += chunk;
|
||||
if (next() < 0.3) flush();
|
||||
}
|
||||
flush();
|
||||
full.end();
|
||||
skipping.end();
|
||||
expect(skipping.snapshot(), `seed ${seed}`).toEqual(full.snapshot());
|
||||
expect(skips >= 0).toBe(true);
|
||||
}
|
||||
|
||||
describe("OutputBuffer skipped output", () => {
|
||||
// Progress commits snapshot at arbitrary moments; snapshots compact what is stored and must never change the window.
|
||||
// A compaction to exactly the kept window lost the character that decides where a later window's first line starts.
|
||||
it("keeps the same tail whenever progress snapshots happen", () => {
|
||||
for (let seed = 1; seed <= 3000; seed++) {
|
||||
const next = random(seed);
|
||||
const limits: OutputLimits = {
|
||||
maxBytes: 1 + Math.floor(next() * 40),
|
||||
maxLines: 1 + Math.floor(next() * 5),
|
||||
retain: "tail",
|
||||
};
|
||||
const plain = new OutputBuffer(limits);
|
||||
const sampled = new OutputBuffer(limits);
|
||||
const chunks = Math.floor(next() * 30);
|
||||
for (let index = 0; index < chunks; index++) {
|
||||
const chunk = randomChunk(next);
|
||||
plain.push(chunk);
|
||||
sampled.push(chunk);
|
||||
if (next() < 0.4) sampled.snapshot();
|
||||
}
|
||||
expect(sampled.snapshot(), `seed ${seed}`).toEqual(plain.snapshot());
|
||||
}
|
||||
});
|
||||
|
||||
it("matches the full stream for every legal skip pattern", () => {
|
||||
for (let seed = 1; seed <= 3000; seed++) checkSeed(seed);
|
||||
});
|
||||
|
||||
it("counts skipped bytes, lines and the final newline exactly", () => {
|
||||
const limits: OutputLimits = { maxBytes: 1000, maxLines: 2, retain: "tail" };
|
||||
const full = new OutputBuffer(limits);
|
||||
const skipping = new OutputBuffer(limits);
|
||||
// The skipped text ends without a newline, so its last line continues in the delivered text.
|
||||
const omitted = "one\ntwo\nthr";
|
||||
const kept = "ee\nfour\nfive\nsix";
|
||||
full.push(omitted + kept);
|
||||
skipping.push(kept, measure(omitted));
|
||||
expect(skipping.snapshot()).toEqual(full.snapshot());
|
||||
expect(skipping.snapshot()).toEqual({ text: "five\nsix", droppedBytes: 19, droppedLines: 4 });
|
||||
});
|
||||
|
||||
it("refuses skips for head retention", () => {
|
||||
const buffer = new OutputBuffer({ maxBytes: 10, maxLines: 2, retain: "head" });
|
||||
expect(() => buffer.push("x\ny\nz\n", { bytes: 3, newlines: 1, endsWithNewline: true })).toThrow(
|
||||
"tail retention",
|
||||
);
|
||||
});
|
||||
});
|
||||
311
third_party/pi/packages/chord/test/output.test.ts
vendored
Normal file
311
third_party/pi/packages/chord/test/output.test.ts
vendored
Normal file
@@ -0,0 +1,311 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { boundOutput, OutputBuffer, type OutputLimits, Progress, sanitizeOutput } from "../src/output.ts";
|
||||
|
||||
const head = (maxLines: number, maxBytes = 1000): OutputLimits => ({ maxBytes, maxLines, retain: "head" });
|
||||
const tail = (maxLines: number, maxBytes = 1000): OutputLimits => ({ maxBytes, maxLines, retain: "tail" });
|
||||
|
||||
function bufferTail(content: string, maxBytes: number): string {
|
||||
const bytes = Buffer.from(content, "utf8");
|
||||
if (bytes.length <= maxBytes) return content;
|
||||
let start = bytes.length - maxBytes;
|
||||
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++;
|
||||
return bytes.subarray(start).toString("utf8");
|
||||
}
|
||||
|
||||
/** A single line longer than the byte limit is cut like Buffer's tail slice on a character boundary. */
|
||||
function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void {
|
||||
const totalBytes = Buffer.byteLength(input, "utf8");
|
||||
const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes);
|
||||
for (const maxBytes of values) {
|
||||
const kept = boundOutput(input, { maxBytes, maxLines: 10, retain: "tail" }).text;
|
||||
const expected = bufferTail(input, maxBytes);
|
||||
if (kept !== expected) {
|
||||
throw new Error(
|
||||
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(kept)}`,
|
||||
);
|
||||
}
|
||||
if (Buffer.byteLength(kept, "utf8") > maxBytes) throw new Error(`tail output exceeded ${maxBytes} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function sampledByteLimits(input: string): number[] {
|
||||
const totalBytes = Buffer.byteLength(input, "utf8");
|
||||
const candidates = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
8,
|
||||
Math.floor(totalBytes / 2),
|
||||
totalBytes - 4,
|
||||
totalBytes - 1,
|
||||
totalBytes,
|
||||
totalBytes + 1,
|
||||
];
|
||||
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function bound(text: string, limits: OutputLimits) {
|
||||
const { text: kept, droppedBytes, droppedLines } = boundOutput(text, limits);
|
||||
return { kept, droppedBytes, droppedLines };
|
||||
}
|
||||
|
||||
describe("tool output bounds", () => {
|
||||
it("removes control characters but keeps tabs, newlines, and other text", () => {
|
||||
expect(sanitizeOutput("a\0b\tc\nd\re\u0007f\ufff9g\ufffbh😀")).toBe("ab\tc\ndefgh😀");
|
||||
});
|
||||
|
||||
it("keeps output within the limits unchanged", () => {
|
||||
expect(bound("a\nb\n", head(2))).toEqual({ kept: "a\nb\n", droppedBytes: 0, droppedLines: 0 });
|
||||
expect(bound("a\nb", tail(2))).toEqual({ kept: "a\nb", droppedBytes: 0, droppedLines: 0 });
|
||||
expect(bound("", tail(2))).toEqual({ kept: "", droppedBytes: 0, droppedLines: 0 });
|
||||
});
|
||||
|
||||
it("keeps nothing with a zero limit", () => {
|
||||
expect(bound("ab\ncd\n", head(10, 0))).toEqual({ kept: "", droppedBytes: 6, droppedLines: 2 });
|
||||
expect(bound("ab\ncd\n", tail(0))).toEqual({ kept: "", droppedBytes: 6, droppedLines: 2 });
|
||||
});
|
||||
|
||||
it("keeps exact slices of whole lines, trailing newline included", () => {
|
||||
expect(bound("a\nb\nc\n", head(2))).toEqual({ kept: "a\nb\n", droppedBytes: 2, droppedLines: 1 });
|
||||
expect(bound("a\nb\nc\n", tail(2))).toEqual({ kept: "b\nc\n", droppedBytes: 2, droppedLines: 1 });
|
||||
expect(bound("a\nb\nc", tail(2))).toEqual({ kept: "b\nc", droppedBytes: 2, droppedLines: 1 });
|
||||
// Blank lines are lines.
|
||||
expect(bound("a\nb\nc\n\n", tail(3))).toEqual({ kept: "b\nc\n\n", droppedBytes: 2, droppedLines: 1 });
|
||||
});
|
||||
|
||||
it("cuts at the byte limit on whole lines when possible", () => {
|
||||
expect(bound("aa\nbb\ncc\n", head(10, 7))).toEqual({ kept: "aa\nbb\n", droppedBytes: 3, droppedLines: 1 });
|
||||
expect(bound("aa\nbb\ncc\n", tail(10, 7))).toEqual({ kept: "bb\ncc\n", droppedBytes: 3, droppedLines: 1 });
|
||||
});
|
||||
|
||||
it("cuts a single line longer than the byte limit on a character boundary", () => {
|
||||
// "é" is two bytes; five bytes hold two whole characters.
|
||||
expect(bound("ééé\n", head(10, 5))).toEqual({ kept: "éé", droppedBytes: 3, droppedLines: 0 });
|
||||
expect(bound("x\néééé", tail(10, 5))).toEqual({ kept: "éé", droppedBytes: 6, droppedLines: 1 });
|
||||
});
|
||||
|
||||
it("keeps a U+FEFF at the start of a kept slice", () => {
|
||||
expect(bound("x\ufeffa", tail(10, 4))).toEqual({ kept: "\ufeffa", droppedBytes: 1, droppedLines: 0 });
|
||||
});
|
||||
|
||||
it("cuts tails of surrogate edge cases exactly like Buffer", () => {
|
||||
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩💻"];
|
||||
for (const input of inputs) assertMatchesBufferTail(input);
|
||||
});
|
||||
|
||||
it("cuts tails exactly like Buffer across deterministic fuzz cases", () => {
|
||||
const alphabet = [
|
||||
"a",
|
||||
"\u007f",
|
||||
"\u0080",
|
||||
"é",
|
||||
"\u07ff",
|
||||
"\u0800",
|
||||
"中",
|
||||
"\ud7ff",
|
||||
"\ud800",
|
||||
"\ud83d",
|
||||
"\udc00",
|
||||
"\ude42",
|
||||
"🙂",
|
||||
"\ue000",
|
||||
"\uffff",
|
||||
];
|
||||
function checkExhaustive(prefix: string, depth: number): void {
|
||||
assertMatchesBufferTail(prefix, sampledByteLimits(prefix));
|
||||
if (depth === 0) return;
|
||||
for (const character of alphabet) checkExhaustive(prefix + character, depth - 1);
|
||||
}
|
||||
checkExhaustive("", 3);
|
||||
let seed = 0x12345678;
|
||||
const random = (): number => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 0x100000000;
|
||||
};
|
||||
for (let i = 0; i < 1_000; i++) {
|
||||
let input = "";
|
||||
const length = Math.floor(random() * 80);
|
||||
for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)];
|
||||
assertMatchesBufferTail(input, sampledByteLimits(input));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OutputBuffer", () => {
|
||||
it("keeps exact totals across chunks and decodes UTF-8 split across byte chunks", () => {
|
||||
const buffer = new OutputBuffer(tail(2));
|
||||
const bytes = new TextEncoder().encode("😀\n");
|
||||
buffer.push("a\nb\n");
|
||||
buffer.push(bytes.subarray(0, 2));
|
||||
buffer.push(bytes.subarray(2));
|
||||
expect(buffer.snapshot()).toEqual({ text: "b\n😀\n", droppedBytes: 2, droppedLines: 1 });
|
||||
});
|
||||
|
||||
it("sanitizes the retained text but counts the raw stream", () => {
|
||||
const buffer = new OutputBuffer(tail(1));
|
||||
buffer.push("a\u0007\n");
|
||||
expect(buffer.snapshot()).toEqual({ text: "a\n", droppedBytes: 0, droppedLines: 0 });
|
||||
buffer.push("b\u001b\n");
|
||||
expect(buffer.snapshot()).toEqual({ text: "b\n", droppedBytes: 3, droppedLines: 1 });
|
||||
});
|
||||
|
||||
it("drops a byte-order mark only at the start of the output", () => {
|
||||
const bom = Uint8Array.of(0xef, 0xbb, 0xbf);
|
||||
const buffer = new OutputBuffer(tail(10));
|
||||
buffer.push(bom.subarray(0, 1));
|
||||
buffer.push(Uint8Array.of(0xbb, 0xbf, 0x61));
|
||||
buffer.push("b");
|
||||
// A U+FEFF after a string chunk is text.
|
||||
buffer.push(Uint8Array.of(...bom, 0x63));
|
||||
buffer.end();
|
||||
expect(buffer.snapshot().text).toBe("ab\ufeffc");
|
||||
});
|
||||
|
||||
it("flushes an incomplete character before a string chunk and at the end", () => {
|
||||
const buffer = new OutputBuffer(tail(10));
|
||||
const euro = new TextEncoder().encode("€");
|
||||
buffer.push(euro.subarray(0, 1));
|
||||
buffer.push("x");
|
||||
buffer.push(euro.subarray(0, 2));
|
||||
buffer.end();
|
||||
expect(buffer.snapshot().text).toBe("\ufffdx\ufffd");
|
||||
});
|
||||
|
||||
it("matches bounding the whole stream when several chunks arrive between snapshots", () => {
|
||||
for (const limits of [head(3, 40), tail(3, 40), head(50, 25), tail(50, 25)]) {
|
||||
const buffer = new OutputBuffer(limits);
|
||||
let stream = "";
|
||||
for (let index = 0; index < 300; index++) {
|
||||
const chunk = index % 7 === 0 ? `${"é".repeat(index % 30)}\n` : `line ${index}\n`;
|
||||
stream += chunk;
|
||||
buffer.push(chunk);
|
||||
if (index % 5 !== 4) continue;
|
||||
const expected = boundOutput(stream, limits);
|
||||
expect(buffer.snapshot()).toEqual({
|
||||
text: expected.text,
|
||||
droppedBytes: expected.droppedBytes,
|
||||
droppedLines: expected.droppedLines,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("stops storing head output once the window is full", () => {
|
||||
const buffer = new OutputBuffer(head(2));
|
||||
for (let index = 0; index < 1000; index++) buffer.push(`line ${index}\n`);
|
||||
expect(buffer.storedBytes).toBeLessThan(20);
|
||||
expect(buffer.snapshot()).toEqual({ text: "line 0\nline 1\n", droppedBytes: 8876, droppedLines: 998 });
|
||||
});
|
||||
|
||||
it("stores only the tail window after each snapshot", () => {
|
||||
const buffer = new OutputBuffer(tail(3, 100));
|
||||
let stream = "";
|
||||
for (let index = 0; index < 2000; index++) {
|
||||
const chunk = `line ${index}\n\n`;
|
||||
stream += chunk;
|
||||
buffer.push(chunk);
|
||||
const snapshot = buffer.snapshot();
|
||||
expect(buffer.storedBytes).toBeLessThanOrEqual(100);
|
||||
expect(snapshot.text).toBe(boundOutput(stream, tail(3, 100)).text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Progress", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("commits the first change at once, then waits at least 100 ms and the written size at 100 KiB/s", async () => {
|
||||
vi.useFakeTimers({ now: 0 });
|
||||
const commits: number[] = [];
|
||||
let size = 50 * 1024;
|
||||
const progress = new Progress(
|
||||
async () => {
|
||||
commits.push(Date.now());
|
||||
return size;
|
||||
},
|
||||
() => {},
|
||||
100,
|
||||
);
|
||||
progress.mark();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(commits).toEqual([0]);
|
||||
// 50 KiB buys 500 ms; changes meanwhile coalesce into one commit.
|
||||
size = 10;
|
||||
progress.mark();
|
||||
progress.mark();
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(commits).toEqual([0]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(commits).toEqual([0, 500]);
|
||||
// A small commit still waits the minimum 100 ms.
|
||||
progress.mark();
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(commits).toEqual([0, 500]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(commits).toEqual([0, 500, 600]);
|
||||
});
|
||||
|
||||
it("waits the configured minimum interval between small commits", async () => {
|
||||
vi.useFakeTimers({ now: 0 });
|
||||
const commits: number[] = [];
|
||||
const progress = new Progress(
|
||||
async () => {
|
||||
commits.push(Date.now());
|
||||
return 10;
|
||||
},
|
||||
() => {},
|
||||
500,
|
||||
);
|
||||
progress.mark();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
progress.mark();
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(commits).toEqual([0]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(commits).toEqual([0, 500]);
|
||||
});
|
||||
|
||||
it("rejects the waiters of a failed commit and reports its error", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const failure = new Error("commit failed");
|
||||
const progress = new Progress(
|
||||
async () => {
|
||||
throw failure;
|
||||
},
|
||||
(error) => errors.push(error),
|
||||
100,
|
||||
);
|
||||
await expect(progress.markAndWait()).rejects.toBe(failure);
|
||||
expect(errors).toEqual([failure]);
|
||||
});
|
||||
|
||||
it("stops: waits for the commit in flight and hands back waiters no commit covered yet", async () => {
|
||||
vi.useFakeTimers({ now: 0 });
|
||||
let release!: () => void;
|
||||
const inFlight = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const progress = new Progress(
|
||||
async () => {
|
||||
await inFlight;
|
||||
return 0;
|
||||
},
|
||||
() => {},
|
||||
100,
|
||||
);
|
||||
const first = progress.markAndWait();
|
||||
const second = progress.markAndWait();
|
||||
const stopped = progress.stop();
|
||||
release();
|
||||
const pending = await stopped;
|
||||
await first;
|
||||
expect(pending).toHaveLength(1);
|
||||
pending[0]!.resolve();
|
||||
await second;
|
||||
});
|
||||
});
|
||||
198
third_party/pi/packages/chord/test/placement.test.ts
vendored
Normal file
198
third_party/pi/packages/chord/test/placement.test.ts
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
bind,
|
||||
createHost,
|
||||
type Facet,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
/** Lives on `session:` + its input's `session`. */
|
||||
const Note = remote("t.note", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String(), id: Type.String() }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { methods: { where: method(undefined, Type.String()) } } },
|
||||
});
|
||||
|
||||
/** Lives on `server`. */
|
||||
const Catalog = remote("t.catalog", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { where: method(undefined, Type.String()) } } },
|
||||
});
|
||||
|
||||
/** A server with two session hosts as children; every host serves what it may and reports its name. */
|
||||
function cluster() {
|
||||
const server = createHost({ name: "server", self: "host", onError: () => {} });
|
||||
server.serve(Catalog, () => ({ variant: "main", where: () => "server" }));
|
||||
const children = new Map<string, Host>();
|
||||
const links = new Map<string, Link>();
|
||||
const child = (name: string) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
links.set(name, down);
|
||||
server.accept(down, { kind: "host", name, principal: "server-side" });
|
||||
const host = createHost({ name, self: "host", upstream: { link: up, principal: "server" }, onError: () => {} });
|
||||
host.serve(Note, ({ id }) => ({ variant: "main", where: () => `${host.name} ${id}` }));
|
||||
children.set(name, host);
|
||||
return { host, down };
|
||||
};
|
||||
const s1 = child("session:S1");
|
||||
const s2 = child("session:S2");
|
||||
const close = async () => {
|
||||
for (const host of children.values()) await host.close();
|
||||
await server.close();
|
||||
};
|
||||
// Relays need a route (where authorization lives); it finds the connected child.
|
||||
server.route("session:", {
|
||||
authorize: () => true,
|
||||
resolve: async (name) => links.get(name)!,
|
||||
principal: (name) => name,
|
||||
});
|
||||
return { server, s1: s1.host, s2: s2.host, close };
|
||||
}
|
||||
|
||||
describe("placement", () => {
|
||||
it("a get goes where the contract places the input: here, a child, or the parent, never named by the caller", async () => {
|
||||
const { server, s1, close } = cluster();
|
||||
await tick();
|
||||
assert.equal(await server.hold(Note, { session: "S1", id: "a" }).where(), "session:S1 a");
|
||||
assert.equal(await server.hold(Note, { session: "S2", id: "b" }).where(), "session:S2 b");
|
||||
// From a session host: its own notes locally, the other session's through the server, the catalog upstream.
|
||||
assert.equal(await s1.hold(Note, { session: "S1", id: "c" }).where(), "session:S1 c");
|
||||
assert.equal(await s1.hold(Catalog, {}).where(), "server");
|
||||
await close();
|
||||
});
|
||||
|
||||
it("serve refuses a type the host is not the place of, also at a reload", async () => {
|
||||
const host = createHost({ name: "session:S1", self: "host", onError: () => {} });
|
||||
assert.throws(
|
||||
() => host.serve(Catalog, () => ({ variant: "main", where: () => "" })),
|
||||
/t\.catalog is declared for server, but session:S1 serves it/,
|
||||
);
|
||||
const server = createHost({ name: "server", self: "host", onError: () => {} });
|
||||
assert.throws(
|
||||
() => server.serve(Note, () => ({ variant: "main", where: () => "" })),
|
||||
/t\.note is declared for session:<session>, but server serves it/,
|
||||
);
|
||||
const misplaced: Facet = {
|
||||
name: "misplaced",
|
||||
setup: (api) => api.serve(Note, () => ({ variant: "main", where: () => "" })),
|
||||
};
|
||||
await assert.rejects(server.reload([misplaced]), /declared for session:<session>, but server serves it/);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a host refuses a hold its own contract places elsewhere, and a relay one whose target disagrees", async () => {
|
||||
const { server, close } = cluster();
|
||||
await tick();
|
||||
const holds = async (target: string, input: unknown): Promise<Frame | undefined> => {
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: "guest" });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "hold", id: 1, at: target, token: Note.description, input });
|
||||
for (let i = 0; i < 50 && !frames.some((frame) => frame.t === "shape" || frame.t === "gone"); i++)
|
||||
await tick();
|
||||
return frames.find((frame) => frame.t === "shape" || frame.t === "gone");
|
||||
};
|
||||
// A client picks session:S2 for a note of S1: the relay refuses, nothing reaches S2.
|
||||
const relayed = await holds("session:S2", { session: "S1", id: "x" });
|
||||
assert.equal(relayed?.t, "gone");
|
||||
assert.match(relayed?.t === "gone" ? relayed.error.message : "", /placement|does not live on session:S2/);
|
||||
// The right target is served.
|
||||
assert.equal((await holds("session:S1", { session: "S1", id: "x" }))?.t, "shape");
|
||||
// Addressed to the server itself, a note is refused by the server's own contract check.
|
||||
const direct = await holds("server", { session: "S1", id: "x" });
|
||||
assert.equal(direct?.t, "gone");
|
||||
await close();
|
||||
});
|
||||
|
||||
it("the host comes from the normalized input: unknown fields and defaults do not change it", async () => {
|
||||
const Doc = remote("t.doc-default", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String({ default: "S1" }) }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { methods: { where: method(undefined, Type.String()) } } },
|
||||
});
|
||||
const { server, s1, close } = cluster();
|
||||
s1.serve(Doc, () => ({ variant: "main", where: () => "session:S1" }));
|
||||
await tick();
|
||||
// An untyped caller leaves the field out: the default places it.
|
||||
assert.equal(await server.hold(Doc, {} as never).where(), "session:S1");
|
||||
assert.throws(() => server.hold(Note, { session: "", id: "x" }), /input field session names no host/);
|
||||
await close();
|
||||
});
|
||||
|
||||
it("placement is part of the contract: a moved type is a breaking change, never bridged by an adapter", async () => {
|
||||
const at = (host: string) =>
|
||||
remote("t.moved", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed(host),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
});
|
||||
assert.notEqual(at("server").fingerprint, at("session:S1").fingerprint);
|
||||
const host = createHost({ name: "server", self: "host", onError: () => {} });
|
||||
const facet = (version: number, place: string): Facet => ({
|
||||
name: "moved",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
remote("t.moved", {
|
||||
version,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed(place),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
}),
|
||||
() => ({ variant: "main", ping: () => {} }),
|
||||
{ accepts: { 1: (current) => current } },
|
||||
),
|
||||
});
|
||||
await host.install(facet(1, "server"));
|
||||
await host.get(at("server"), {});
|
||||
// Same version, other placement: a forgotten bump.
|
||||
await assert.rejects(host.reload([facet(1, "session:S1")]), /declared for session:S1|without a version bump/);
|
||||
// A new version that `accepts` version 1 but lives elsewhere: no adapter makes version 1 consumers bind.
|
||||
const moved = remote("t.moved", {
|
||||
version: 2,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
});
|
||||
assert.throws(
|
||||
() => bind(moved, { 1: (current) => current }, at("server")),
|
||||
(error) => isChordError(error, "contract_mismatch") && /lives on session:S1/.test(error.message),
|
||||
);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("types: the family's field is a required string of the input, and remote contracts need a host", () => {
|
||||
const input = Type.Object({ session: Type.String(), count: Type.Number(), note: Type.Optional(Type.String()) });
|
||||
const variants = { main: {} };
|
||||
remote("t.ok", { version: 1, input, host: hostFamily("session:", "session"), variants });
|
||||
// @ts-expect-error: no such field
|
||||
remote("t.typo", { version: 1, input, host: hostFamily("session:", "sesion"), variants });
|
||||
// @ts-expect-error: not a string
|
||||
remote("t.number", { version: 1, input, host: hostFamily("session:", "count"), variants });
|
||||
// @ts-expect-error: optional
|
||||
remote("t.optional", { version: 1, input, host: hostFamily("session:", "note"), variants });
|
||||
// @ts-expect-error: a remote contract says where it lives
|
||||
assert.throws(() => remote("t.nowhere", { version: 1, input, variants }), /needs `host`/);
|
||||
assert.throws(() => hostFamily("", "session"), /prefix/);
|
||||
});
|
||||
});
|
||||
136
third_party/pi/packages/chord/test/principals.test.ts
vendored
Normal file
136
third_party/pi/packages/chord/test/principals.test.ts
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
type FacetApi,
|
||||
type Host,
|
||||
hostNamed,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
/** An app's principals: what TypeScript narrows on. */
|
||||
type Principal =
|
||||
| { readonly kind: "owner" }
|
||||
| { readonly kind: "guest"; readonly id: string }
|
||||
| { readonly kind: "server" }
|
||||
| { readonly kind: "host" };
|
||||
|
||||
const principalKey = (principal: Principal): string =>
|
||||
principal.kind === "guest" ? `guest:${principal.id}` : principal.kind;
|
||||
|
||||
const Who = local("t.who", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { who: method(undefined, Type.String()) },
|
||||
});
|
||||
|
||||
describe("principals", () => {
|
||||
it("factories, methods and contexts see the app's principal type", async () => {
|
||||
const host = createHost<Principal>({ self: { kind: "host" }, principalKey });
|
||||
const facet = defineFacet<Principal>((api) => {
|
||||
const plain: FacetApi = api;
|
||||
void plain;
|
||||
api.serve(Who, (_input, principal, self) => {
|
||||
// Narrowing on `kind` needs no cast.
|
||||
const created = principal.kind === "guest" ? `guest ${principal.id}` : principal.kind;
|
||||
self.task(async (ctx) => {
|
||||
const kind: Principal["kind"] = ctx.principal.kind;
|
||||
void kind;
|
||||
});
|
||||
return { who: (ctx) => `${created}, called by ${ctx.principal.kind}` };
|
||||
});
|
||||
});
|
||||
await host.install({ name: "who", ...facet });
|
||||
const guest = host.hold(Who, {}, { principal: { kind: "guest", id: "g1" } });
|
||||
assert.equal(
|
||||
await host.root((ctx) => guest.who(ctx), { principal: { kind: "guest", id: "g1" } }),
|
||||
"guest g1, called by host",
|
||||
);
|
||||
// A typed host, facet API and context are usable where Chord's untyped ones are expected.
|
||||
const untyped: Host = host;
|
||||
void untyped;
|
||||
await host.root(async (ctx) => {
|
||||
const plain: Ctx = ctx;
|
||||
void plain;
|
||||
});
|
||||
// @ts-expect-error: not a principal of this app
|
||||
host.hold(Who, {}, { principal: { kind: "stranger" } });
|
||||
// @ts-expect-error: a pattern names fields of the app's principals
|
||||
void host.invalidatePrincipal({ kind: "guest", name: "g1" }).catch(() => {});
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("the host's own principal is recognized by key: an equal object reaches the same objects", async () => {
|
||||
const host = createHost<Principal>({ self: { kind: "host" }, principalKey });
|
||||
let created = 0;
|
||||
host.serve(Who, () => {
|
||||
created++;
|
||||
return { who: () => "one" };
|
||||
});
|
||||
const own = host.hold(Who, {});
|
||||
const equal = host.hold(Who, {}, { principal: { kind: "host" } });
|
||||
await Promise.all([own.ready, equal.ready]);
|
||||
assert.equal(created, 1);
|
||||
// Never disposed as a matching principal: it is the host's own.
|
||||
await host.invalidatePrincipal({ kind: "host" });
|
||||
assert.equal(own.status, "live");
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("a child's parent acts as the principal the child gave `connect`; relayed callers keep theirs", async () => {
|
||||
const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("worker"),
|
||||
variants: { main: { methods: { who: method(undefined, Type.String()) } } },
|
||||
});
|
||||
const server = createHost<Principal>({ name: "server", self: { kind: "host" }, principalKey });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { kind: "host", name: "worker", principal: { kind: "host" } });
|
||||
const worker = createHost<Principal>({
|
||||
name: "worker",
|
||||
self: { kind: "host" },
|
||||
principalKey,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
});
|
||||
worker.serve(Echo, (_input, principal) => ({ variant: "main", who: () => principalKey(principal) }));
|
||||
const [browserDown, browserUp] = memoryLinkPair();
|
||||
server.accept(browserDown, { principal: { kind: "guest", id: "g1" } });
|
||||
const browser = createHost<Principal>({
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
self: { kind: "host" },
|
||||
upstream: { link: browserUp, principal: { kind: "server" } },
|
||||
});
|
||||
assert.equal(await server.hold(Echo, {}).who(), "server");
|
||||
server.route("worker", {
|
||||
authorize: () => true,
|
||||
resolve: async () => down,
|
||||
principal: () => ({ kind: "host" }),
|
||||
});
|
||||
assert.equal(await browser.hold(Echo, {}).who(), "guest:g1");
|
||||
await browser.close();
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("refuses a host, a parent link or a route without its principal", () => {
|
||||
// @ts-expect-error: `self` is required
|
||||
assert.throws(() => createHost({ name: "x" }), /needs `self`/);
|
||||
const host = createHost({ self: "host" });
|
||||
const [, up] = memoryLinkPair();
|
||||
// @ts-expect-error: `principal` is required
|
||||
assert.throws(() => host.connect(up, {}), /connect needs `principal`/);
|
||||
assert.throws(
|
||||
// @ts-expect-error: `principal` is required
|
||||
() => host.route("w:", { authorize: () => true, resolve: async () => up }),
|
||||
/needs `principal`/,
|
||||
);
|
||||
});
|
||||
});
|
||||
83
third_party/pi/packages/chord/test/provider-cost.test.ts
vendored
Normal file
83
third_party/pi/packages/chord/test/provider-cost.test.ts
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
// Entry 8: what a change costs the provider. The diff is computed once per change; each feed (one per connection and
|
||||
// member) encodes it with its own stateful encoder and its link serializes it.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it, vi } from "vitest";
|
||||
import type * as delta from "../src/delta/index.ts";
|
||||
import { type Ctx, createHost, hostNamed, memoryLinkPair, method, remote } from "../src/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const counts = vi.hoisted(() => ({ encoders: 0, encodes: 0 }));
|
||||
|
||||
vi.mock("../src/delta/index.ts", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof delta>();
|
||||
return {
|
||||
...original,
|
||||
encoder: () => {
|
||||
counts.encoders++;
|
||||
const inner = original.encoder();
|
||||
return {
|
||||
encode: (ops: Parameters<typeof inner.encode>[0]) => {
|
||||
counts.encodes++;
|
||||
return inner.encode(ops);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const List = remote("t.e8-cost", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { items: Type.Array(Type.String()) }, methods: { add: method(Type.String()) } } },
|
||||
});
|
||||
|
||||
describe("provider cost", () => {
|
||||
it("one change to K clients: K encodes (one per feed), one frame per link, the same ops on every link", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(List, (_input, _principal, self) => {
|
||||
const items = self.state("items", Type.Array(Type.String()), []);
|
||||
return {
|
||||
variant: "main",
|
||||
items,
|
||||
add: (text: string, ctx: Ctx) => {
|
||||
items.change(ctx, (draft) => {
|
||||
draft.push(text);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
const K = 4;
|
||||
const clients = Array.from({ length: K }, (_, i) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
// One principal: every client reaches the same object.
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: `browser${i}`,
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, link, ref: host.hold(List, {}) };
|
||||
});
|
||||
await Promise.all(clients.map((client) => client.ref.ready));
|
||||
const encoders = counts.encoders;
|
||||
const encodes = counts.encodes;
|
||||
const before = clients.map((client) => stateFrames(client.link.received).length);
|
||||
await clients[0]!.ref.add("x");
|
||||
await sleep(10);
|
||||
assert.equal(counts.encodes - encodes, K, "one encode per feed");
|
||||
assert.equal(counts.encoders, encoders, "no new streams for a change");
|
||||
const frames = clients.map((client, i) => stateFrames(client.link.received).slice(before[i]));
|
||||
for (const list of frames) assert.equal(list.length, 1);
|
||||
const ops = frames.map((list) => JSON.stringify(list[0]?.ops));
|
||||
assert.equal(new Set(ops).size, 1, "every link carries the same ops");
|
||||
for (const client of clients) assert.deepEqual(client.ref.items.value, ["x"]);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
262
third_party/pi/packages/chord/test/readiness.test.ts
vendored
Normal file
262
third_party/pi/packages/chord/test/readiness.test.ts
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type FacetApi,
|
||||
forbidden,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const unexpectedError = (error: unknown) => assert.fail(String(error));
|
||||
const Eager = local("ready.eager", { version: 1, input: Type.Object({}) });
|
||||
const Lazy = local("ready.lazy", { version: 1, input: Type.Object({}) });
|
||||
|
||||
// Startup must not report ready while an eager object has failed.
|
||||
describe("host: eager readiness", () => {
|
||||
for (const operation of ["install", "load"] as const) {
|
||||
it(`${operation} awaits resource activation and registration, leaving lazy objects lazy`, async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: unexpectedError });
|
||||
const entries = defineRegistry<string>("ready.entries");
|
||||
let opened = () => {};
|
||||
let lazyRuns = 0;
|
||||
let settled = false;
|
||||
const facet = {
|
||||
name: "ready",
|
||||
setup({ serve }: FacetApi) {
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
opened = resolve;
|
||||
}),
|
||||
});
|
||||
self.register(entries, { eager: "live" });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
serve(Lazy, () => {
|
||||
lazyRuns++;
|
||||
return {};
|
||||
});
|
||||
},
|
||||
};
|
||||
try {
|
||||
const loading = operation === "install" ? host.install(facet) : host.load([facet]);
|
||||
void loading.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await tick();
|
||||
assert.equal(settled, false);
|
||||
assert.equal(host.registry(entries).get("eager"), undefined);
|
||||
opened();
|
||||
await loading;
|
||||
assert.equal(host.registry(entries).get("eager"), "live");
|
||||
assert.equal(lazyRuns, 0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const failure of ["factory", "dependency", "resource"] as const) {
|
||||
it(`${operation} rejects eager ${failure} failure with the activation error`, async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) });
|
||||
const cause = forbidden(`${failure} failed`);
|
||||
const facet = {
|
||||
name: "bad",
|
||||
setup({ serve }: FacetApi) {
|
||||
serve(Lazy, () => {
|
||||
throw cause;
|
||||
});
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
if (failure === "factory") throw cause;
|
||||
if (failure === "dependency") self.get(Lazy, {});
|
||||
if (failure === "resource")
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
throw cause;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
},
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
operation === "install" ? host.install(facet) : host.load([facet]),
|
||||
(error) => error === cause,
|
||||
);
|
||||
await assert.rejects(host.ready(), (error) => error === cause);
|
||||
assert.deepEqual(errors, [cause]);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("load installs every facet before awaiting eager consumers", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: unexpectedError });
|
||||
try {
|
||||
await host.load([
|
||||
{
|
||||
name: "consumer",
|
||||
setup: ({ serve }) =>
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
self.get(Lazy, {});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
{ name: "provider", setup: ({ serve }) => serve(Lazy, () => ({})) },
|
||||
]);
|
||||
assert.equal(host.hold(Eager, {}).status, "live");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("missing eager dependencies reject load instead of only reaching onError", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
try {
|
||||
await assert.rejects(
|
||||
host.load([
|
||||
{
|
||||
name: "missing",
|
||||
setup: ({ serve }) =>
|
||||
serve(
|
||||
Eager,
|
||||
(_i, _p, self) => {
|
||||
self.get(Lazy, {});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
]),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "unknown_type",
|
||||
);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ready includes direct host serves, but not later serves", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const First = local("ready.first", { version: 1, input: Type.Object({}) });
|
||||
let created = 0;
|
||||
try {
|
||||
host.serve(
|
||||
First,
|
||||
() => {
|
||||
created++;
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
const first = host.ready();
|
||||
host.serve(
|
||||
Eager,
|
||||
() => {
|
||||
throw forbidden("later");
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await first;
|
||||
assert.equal(created, 1);
|
||||
await assert.rejects(host.ready(), /later/);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const operation of ["install", "load"] as const) {
|
||||
it(`${operation} can defer eager readiness until the parent admits the worker`, { timeout: 2000 }, async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: unexpectedError });
|
||||
const [parent, upstream] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "worker",
|
||||
upstream: { link: upstream, principal: { kind: "server" } },
|
||||
onError: unexpectedError,
|
||||
});
|
||||
const Status = remote("ready.status", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { default: {} },
|
||||
});
|
||||
server.serve(Status, () => ({ variant: "default" }));
|
||||
let bootstrap = false;
|
||||
let reported = false;
|
||||
const facet = {
|
||||
name: "worker",
|
||||
setup({ serve }: FacetApi) {
|
||||
serve(
|
||||
Lazy,
|
||||
() => {
|
||||
bootstrap = true;
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
const status = self.get(Status, {});
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
deps: [status],
|
||||
open: () => {
|
||||
reported = true;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
},
|
||||
};
|
||||
try {
|
||||
await (operation === "install"
|
||||
? worker.install(facet, { awaitHostObjects: false })
|
||||
: worker.load([facet], { awaitHostObjects: false }));
|
||||
await worker.hold(Lazy, {}).ready;
|
||||
assert.equal(bootstrap, true);
|
||||
let settled = false;
|
||||
const ready = worker.ready().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await tick();
|
||||
assert.equal(settled, false);
|
||||
assert.equal(reported, false);
|
||||
// Control-ready announcement allows the parent to accept the queued upstream holds.
|
||||
server.accept(parent, { kind: "host", name: "worker", principal: { host: "worker" } });
|
||||
await ready;
|
||||
assert.equal(reported, true);
|
||||
} finally {
|
||||
await worker.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
738
third_party/pi/packages/chord/test/reload.test.ts
vendored
Normal file
738
third_party/pi/packages/chord/test/reload.test.ts
vendored
Normal file
@@ -0,0 +1,738 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it, onTestFinished, vi } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
method,
|
||||
remote,
|
||||
type Self,
|
||||
type ServeOptions,
|
||||
type StateEvent,
|
||||
type StateOptions,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const quiet = () => createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const Greeter = local("t.greeter", {
|
||||
version: 1,
|
||||
input: Type.Object({ id: Type.Optional(Type.String({ default: "a" })) }),
|
||||
members: { greet: method(Type.String(), Type.String()), count: Type.Unknown() },
|
||||
});
|
||||
|
||||
const CountV1 = Type.Object({ n: Type.Number() });
|
||||
|
||||
interface GreeterSpec {
|
||||
readonly greeting: string;
|
||||
readonly schema?: typeof CountV1 | ReturnType<typeof Type.Object>;
|
||||
readonly initial?: object;
|
||||
readonly bump?: (draft: Record<string, number>) => void;
|
||||
readonly options?: ServeOptions;
|
||||
readonly state?: StateOptions<never>;
|
||||
readonly extra?: (self: Self, input: { id?: string }) => void;
|
||||
}
|
||||
|
||||
function greeterFacet(spec: GreeterSpec): Facet {
|
||||
return {
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Greeter,
|
||||
(input, _principal, self) => {
|
||||
spec.extra?.(self, input);
|
||||
const count = self.state(
|
||||
"count",
|
||||
spec.schema ?? CountV1,
|
||||
(spec.initial ?? { n: 0 }) as never,
|
||||
spec.state,
|
||||
);
|
||||
return {
|
||||
count,
|
||||
greet: (name: string, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
const bump =
|
||||
spec.bump ??
|
||||
((d: Record<string, number>) => {
|
||||
d.n = (d.n ?? 0) + 1;
|
||||
});
|
||||
bump(draft as Record<string, number>);
|
||||
});
|
||||
return `${spec.greeting} ${name}`;
|
||||
},
|
||||
};
|
||||
},
|
||||
spec.options,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function until(check: () => Promise<boolean>, attempts = 200): Promise<void> {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
if (await check()) return;
|
||||
await tick();
|
||||
}
|
||||
throw new Error("condition not reached");
|
||||
}
|
||||
|
||||
describe("reload", () => {
|
||||
it("compatible reload: no gate, pinned roots keep old code, state preserved, previous retires", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
assert.equal(await ref.greet("a"), "hello a");
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
const first = await ref.greet("p", ctx);
|
||||
await go.promise;
|
||||
return [first, await ref.greet("p", ctx)];
|
||||
});
|
||||
await tick();
|
||||
const shapes: string[] = [];
|
||||
ref.onShape((shape) => shapes.push(shape.contract));
|
||||
const report = await host.reload([greeterFacet({ greeting: "hi" })]);
|
||||
assert.deepEqual(report.gated, []);
|
||||
assert.equal(report.prepared.length, 1);
|
||||
assert.equal(await ref.greet("b"), "hi b");
|
||||
assert.equal(shapes.length, 1, "one shape push at the commit");
|
||||
assert.equal(host.previousCodeVersion, report.previous);
|
||||
go.open();
|
||||
assert.deepEqual(await pinned, ["hello p", "hello p"]);
|
||||
assert.equal(host.previousCodeVersion, undefined);
|
||||
assert.deepEqual(ref.count.value, { n: 4 });
|
||||
});
|
||||
|
||||
it("prepare runs in recording mode: writes throw and a failing factory leaves everything unchanged", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const a = host.hold(Greeter, { id: "a" });
|
||||
const b = host.hold(Greeter, { id: "b" });
|
||||
await Promise.all([a.ready, b.ready]);
|
||||
let captured: Ctx | undefined;
|
||||
await host.root((ctx) => {
|
||||
captured = ctx;
|
||||
});
|
||||
|
||||
const writing = greeterFacet({
|
||||
greeting: "hi",
|
||||
extra: (self) => {
|
||||
const probe = self.state("probe", Type.Object({}), {});
|
||||
probe.replace(captured!, {});
|
||||
},
|
||||
});
|
||||
await assert.rejects(host.reload([writing]), /read_only/);
|
||||
|
||||
const log: string[] = [];
|
||||
const failing = greeterFacet({
|
||||
greeting: "hi",
|
||||
extra: (self, input) => {
|
||||
self.resource("extra", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${input.id}`),
|
||||
close: () => void log.push(`close ${input.id}`),
|
||||
});
|
||||
if (input.id === "b") throw new Error("b fails");
|
||||
},
|
||||
});
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(
|
||||
host.reload([failing]),
|
||||
// A plain error from the new factory surfaces as `unavailable`; the message says the reload aborted.
|
||||
(error) => isChordError(error, "unavailable") && /reload aborted.*b fails/.test(error.message),
|
||||
);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.deepEqual(log, ["open a", "close a"]);
|
||||
assert.equal(await a.greet("x"), "hello x");
|
||||
});
|
||||
|
||||
it("refuses a changed cell schema without a version bump", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
await host.hold(Greeter, {}).ready;
|
||||
const changed = greeterFacet({
|
||||
greeting: "hi",
|
||||
schema: Type.Object({ total: Type.Number() }),
|
||||
initial: { total: 0 },
|
||||
});
|
||||
await assert.rejects(
|
||||
host.reload([changed]),
|
||||
(error) =>
|
||||
isChordError(error, "invalid") && error.reason === "state" && /without a version bump/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
it("state bump: gate, migrate(old, fromVersion) into the new schema, reset to watchers, old roots upgrade_conflict", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.greet("a");
|
||||
await ref.greet("a");
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
ref.count.events((event) => events.push(event));
|
||||
const go = latch();
|
||||
// Started before the reload, never touched the object: rejected after the commit.
|
||||
const late = host.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return ref.greet("late", ctx);
|
||||
});
|
||||
const froms: number[] = [];
|
||||
const v2 = greeterFacet({
|
||||
greeting: "hi",
|
||||
schema: Type.Object({ total: Type.Number() }),
|
||||
initial: { total: 0 },
|
||||
bump: (draft) => {
|
||||
draft.total = (draft.total ?? 0) + 1;
|
||||
},
|
||||
state: {
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
froms.push(from);
|
||||
return { total: (old as { n: number }).n } as never;
|
||||
},
|
||||
},
|
||||
});
|
||||
const report = await host.reload([v2]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(froms, [1]);
|
||||
assert.deepEqual(events.at(-1), { type: "reset", value: { total: 2 } });
|
||||
assert.equal(await ref.greet("b"), "hi b");
|
||||
assert.deepEqual(ref.count.value, { total: 3 });
|
||||
go.open();
|
||||
await assert.rejects(late, (error) => isChordError(error, "upgrade_conflict") && error.retryable);
|
||||
});
|
||||
|
||||
it("versions are per cell: a raised one migrates only that cell, a lowered one is refused", async () => {
|
||||
const host = quiet();
|
||||
const Pair = local("t.pair", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { a: Type.Unknown(), b: Type.Unknown(), bump: method() },
|
||||
});
|
||||
const migrated: string[] = [];
|
||||
const facet = (version: number): Facet => ({
|
||||
name: "pair",
|
||||
setup: (api) =>
|
||||
api.serve(Pair, (_input, _principal, self) => {
|
||||
const a = self.state("a", Type.Number(), 1);
|
||||
const b = self.state("b", Type.Number(), 1, {
|
||||
version,
|
||||
migrate: (old, from) => {
|
||||
migrated.push(`b ${from}->${version}`);
|
||||
return (old as number) * 10;
|
||||
},
|
||||
});
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
bump: (ctx) => {
|
||||
a.replace(ctx, a.value + 1);
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const pair = host.hold(Pair, {});
|
||||
await pair.ready;
|
||||
await pair.bump();
|
||||
const report = await host.reload([facet(2)]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(migrated, ["b 1->2"]);
|
||||
assert.deepEqual([pair.a.value, pair.b.value], [2, 10], "a kept as it was, b migrated");
|
||||
await assert.rejects(host.reload([facet(1)]), /version of state b lowered from 2 to 1/);
|
||||
});
|
||||
|
||||
it("gate: late old roots get upgrade_conflict, grandfathered roots re-enter, then commit", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const grandfathered = host.root(async (ctx) => {
|
||||
await ref.greet("g", ctx);
|
||||
await go.promise;
|
||||
return ref.greet("g", ctx);
|
||||
});
|
||||
await tick();
|
||||
const reload = host.reload([greeterFacet({ greeting: "hi", state: { version: 2 } })]);
|
||||
await until(async () => {
|
||||
try {
|
||||
await ref.greet("probe");
|
||||
return false;
|
||||
} catch (error) {
|
||||
return isChordError(error, "upgrade_conflict");
|
||||
}
|
||||
});
|
||||
go.open();
|
||||
assert.equal(await grandfathered, "hello g");
|
||||
const report = await reload;
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.equal(await ref.greet("n"), "hi n");
|
||||
});
|
||||
|
||||
it("gate deadline: reload aborts, reports blocking roots, nothing changes", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const facet = (greeting: string, version: number): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.task(async (ctx) => {
|
||||
log.push(`start ${greeting}`);
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
log.push(`stop ${greeting}`);
|
||||
});
|
||||
const count = self.state("count", Type.Number(), 0, { version });
|
||||
return { count, greet: (name: string) => `${greeting} ${name}` };
|
||||
}),
|
||||
});
|
||||
host.install(facet("hello", 1));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const blocker = host.root(async (ctx) => {
|
||||
await ref.greet("b", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
await assert.rejects(host.reload([facet("hi", 2)], { gateDeadline: 30 }), (error) => {
|
||||
assert.ok(isChordError(error, "aborted") && error.reason === "gate_deadline");
|
||||
assert.match(error.message, /blocking roots: host#\w+:\d+/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(await ref.greet("x"), "hello x");
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start hello", "stop hello", "start hello"]);
|
||||
go.open();
|
||||
await blocker;
|
||||
});
|
||||
|
||||
it("exclusive resources are replaced at the gate; non-exclusive ones at prepare and closed at retirement", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const facet = (rev: number): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
const lock = self.resource("lock", {
|
||||
version: rev,
|
||||
exclusive: true,
|
||||
open: () => {
|
||||
log.push(`open lock ${rev}`);
|
||||
return rev;
|
||||
},
|
||||
close: () => void log.push(`close lock ${rev}`),
|
||||
});
|
||||
self.resource("cache", {
|
||||
version: rev,
|
||||
open: () => void log.push(`open cache ${rev}`),
|
||||
close: () => void log.push(`close cache ${rev}`),
|
||||
});
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
return { count, greet: (name: string) => `${lock.value} ${name}` };
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Greeter, {});
|
||||
assert.equal(await ref.greet("x"), "1 x");
|
||||
log.length = 0;
|
||||
const report = await host.reload([facet(2)]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.equal(await ref.greet("x"), "2 x");
|
||||
await tick();
|
||||
assert.deepEqual(log, ["open cache 2", "close lock 1", "open lock 2", "close cache 1"]);
|
||||
});
|
||||
|
||||
it("two-version rule: the next reload retires the previous code version; its roots get superseded", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "v1" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const first = host.codeVersion;
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await ref.greet("p", ctx);
|
||||
await go.promise;
|
||||
return ref.greet("p", ctx);
|
||||
});
|
||||
await tick();
|
||||
await host.reload([greeterFacet({ greeting: "v2" })]);
|
||||
assert.equal(host.previousCodeVersion, first);
|
||||
await host.reload([greeterFacet({ greeting: "v3" })], { retireDeadline: 20 });
|
||||
go.open();
|
||||
await assert.rejects(pinned, (error) => isChordError(error, "superseded") && error.retryable);
|
||||
await assert.rejects(
|
||||
host.root(() => 1, { codeVersion: first }),
|
||||
(error) => isChordError(error, "superseded"),
|
||||
);
|
||||
assert.equal(await ref.greet("n"), "v3 n");
|
||||
});
|
||||
|
||||
it("forced retirement aborts the leftover roots before it closes the resources only their code used", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const hold = latch();
|
||||
const facet = (version: number): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
const conn = self.resource("conn", {
|
||||
version,
|
||||
open: () => ({ version }),
|
||||
close: () => void log.push(`close ${version}`),
|
||||
});
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return {
|
||||
count,
|
||||
greet: async (name: string, ctx: Ctx) => {
|
||||
if (name === "hold") {
|
||||
await hold.promise;
|
||||
return `held on ${conn.value.version}, aborted ${ctx.signal.aborted}`;
|
||||
}
|
||||
if (name !== "wait") return name;
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
// Still open: the abort comes first, then the grace, then the close.
|
||||
log.push(`aborted, conn ${conn.value.version}`);
|
||||
return name;
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const old = host.root((ctx) => ref.greet("wait", ctx));
|
||||
await tick();
|
||||
await host.reload([facet(2)]);
|
||||
// A root of the code version that becomes the previous one: forced retirement of the older one leaves it alone.
|
||||
const current = host.root((ctx) => ref.greet("hold", ctx));
|
||||
await tick();
|
||||
await host.reload([facet(3)], { retireDeadline: 20 });
|
||||
assert.equal(await old, "wait");
|
||||
for (let i = 0; i < 50 && !log.includes("close 1"); i++) await tick();
|
||||
assert.deepEqual(log, ["aborted, conn 1", "close 1"]);
|
||||
hold.open();
|
||||
assert.equal(await current, "held on 2, aborted false");
|
||||
assert.equal(await ref.greet("x"), "x");
|
||||
});
|
||||
|
||||
it("a kept resource closes with the newest declaration's close", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.resource("conn", { version: 1, open: () => ({}), close: () => void log.push(`close by ${label}`) });
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return { count, greet: (name: string) => `${label} ${name}` };
|
||||
}),
|
||||
});
|
||||
host.install(facet("a"));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
await host.reload([facet("b")]);
|
||||
await host.reload([facet("c")]);
|
||||
assert.equal(await ref.greet("x"), "c x");
|
||||
assert.deepEqual(log, [], "kept, not reopened");
|
||||
ref.release();
|
||||
for (let i = 0; i < 10 && log.length === 0; i++) await tick();
|
||||
assert.deepEqual(log, ["close by c"]);
|
||||
});
|
||||
|
||||
it("a task that ignores its abort delays its replacement at most taskStopDeadline (reported)", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error), taskStopDeadline: 30 });
|
||||
const log: string[] = [];
|
||||
const stuck = latch();
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.task(async () => {
|
||||
log.push(`start ${label}`);
|
||||
if (label === "a") await stuck.promise; // ignores ctx.signal
|
||||
});
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return { count, greet: (name: string) => name };
|
||||
}),
|
||||
});
|
||||
host.install(facet("a"));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
await host.reload([facet("b")]);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start a"], "waits for the old run");
|
||||
assert.equal(errors.length, 0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start a", "start b"]);
|
||||
assert.ok(
|
||||
errors.some((error) => isChordError(error, "aborted") && /still running after 30 ms/.test(error.message)),
|
||||
);
|
||||
stuck.open();
|
||||
});
|
||||
|
||||
it("a facet's signal aborts when its run is replaced or removed, when its reload aborts, and when the host closes", async () => {
|
||||
const host = quiet();
|
||||
const signals = new Map<string, AbortSignal>();
|
||||
const facet = (label: string, fail = false): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => {
|
||||
signals.set(label, api.signal);
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
if (fail) throw new Error("broken");
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return { count, greet: (name: string) => name };
|
||||
});
|
||||
},
|
||||
});
|
||||
const other: Facet = { name: "other", setup: (api) => void signals.set("other", api.signal) };
|
||||
host.install(facet("a"));
|
||||
host.install(other);
|
||||
await host.hold(Greeter, {}).ready;
|
||||
await assert.rejects(host.reload([facet("b", true)]));
|
||||
assert.equal(signals.get("b")?.aborted, true, "the aborted reload's run");
|
||||
assert.equal(signals.get("a")?.aborted, false, "still installed");
|
||||
await host.reload([facet("c")]);
|
||||
assert.equal(signals.get("a")?.aborted, true, "replaced");
|
||||
assert.equal(signals.get("c")?.aborted, false);
|
||||
await host.reload([], { remove: ["other"] });
|
||||
assert.equal(signals.get("other")?.aborted, true, "removed");
|
||||
await host.close();
|
||||
assert.equal(signals.get("c")?.aborted, true, "host closed");
|
||||
});
|
||||
|
||||
it("checks contracts at reload: forgotten bumps, live consumers, accepts adapters", async () => {
|
||||
const DocV1 = remote("t.rdoc", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { read: method(undefined, Type.Object({ text: Type.String() })) } } },
|
||||
});
|
||||
const DocBroken = remote("t.rdoc", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { read: method(undefined, Type.Object({ text: Type.Number() })) } } },
|
||||
});
|
||||
const DocV2 = remote("t.rdoc", {
|
||||
version: 2,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { fetch: method(undefined, Type.Object({ body: Type.String() })) } } },
|
||||
});
|
||||
const host = quiet();
|
||||
host.install({
|
||||
name: "doc",
|
||||
setup: (api) => api.serve(DocV1, () => ({ variant: "main", read: () => ({ text: "v1" }) })),
|
||||
});
|
||||
const consumer = host.hold(DocV1, {});
|
||||
assert.deepEqual(await consumer.read(), { text: "v1" });
|
||||
|
||||
const broken: Facet = {
|
||||
name: "doc",
|
||||
setup: (api) => api.serve(DocBroken, () => ({ variant: "main", read: () => ({ text: 1 }) })),
|
||||
};
|
||||
await assert.rejects(host.reload([broken]), /without a version bump/);
|
||||
|
||||
const fetch = () => ({ body: "v2" });
|
||||
const v2: Facet = { name: "doc", setup: (api) => api.serve(DocV2, () => ({ variant: "main", fetch })) };
|
||||
await assert.rejects(host.reload([v2]), (error) => isChordError(error, "contract_mismatch"));
|
||||
|
||||
const adapted: Facet = {
|
||||
name: "doc",
|
||||
setup: (api) =>
|
||||
api.serve(DocV2, () => ({ variant: "main", fetch }), {
|
||||
accepts: {
|
||||
1: (current) => ({
|
||||
variant: "main",
|
||||
read: () => ({ text: `${(current.fetch as typeof fetch)().body} via adapter` }),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
await host.reload([adapted]);
|
||||
assert.deepEqual(await consumer.read(), { text: "v2 via adapter" });
|
||||
const modern = host.hold(DocV2, {});
|
||||
assert.deepEqual(await modern.fetch(), { body: "v2" });
|
||||
});
|
||||
|
||||
it("rebinds listeners to the new code and restarts tasks", async () => {
|
||||
const host = quiet();
|
||||
const listeners = new Set<(event: number) => void>();
|
||||
const events = {
|
||||
subscribe: (fn: (event: number) => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
const emit = (value: number) => {
|
||||
for (const fn of listeners) fn(value);
|
||||
};
|
||||
const log: string[] = [];
|
||||
const Sum = local("t.sum", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { sum: Type.Object({ v: Type.Number() }) },
|
||||
});
|
||||
const facet = (factor: number): Facet => ({
|
||||
name: "sum",
|
||||
setup: (api) =>
|
||||
api.serve(Sum, (_input, _principal, self) => {
|
||||
const sum = self.state("sum", Type.Object({ v: Type.Number() }), { v: 0 });
|
||||
self.on(
|
||||
"add",
|
||||
events,
|
||||
(source) => source,
|
||||
(value, ctx) =>
|
||||
sum.change(ctx, (draft) => {
|
||||
draft.v += value * factor;
|
||||
}),
|
||||
);
|
||||
self.task(async (ctx) => {
|
||||
log.push(`start ${factor}`);
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
log.push(`stop ${factor}`);
|
||||
});
|
||||
return { sum };
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Sum, {});
|
||||
await ref.ready;
|
||||
emit(1);
|
||||
await tick();
|
||||
await host.reload([facet(10)]);
|
||||
assert.equal(listeners.size, 1);
|
||||
emit(1);
|
||||
await tick();
|
||||
assert.deepEqual(ref.sum.value, { v: 11 });
|
||||
// The replacement starts once the aborted run finished.
|
||||
assert.deepEqual(log, ["start 1", "stop 1", "start 10"]);
|
||||
});
|
||||
|
||||
it("keeps registry tables per code version", async () => {
|
||||
const host = quiet();
|
||||
const Routes = defineRegistry<string>("t.reload-routes");
|
||||
const Router = local("t.router", { version: 1, input: Type.Object({}) });
|
||||
const facet = (value: string): Facet => ({
|
||||
name: "router",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Router,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { "GET /": value });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
host.install(facet("one"));
|
||||
await tick();
|
||||
await tick();
|
||||
const seen: [string | undefined, number][] = [];
|
||||
host.registry(Routes).subscribe((table, codeVersion) => seen.push([table.get("GET /"), codeVersion]));
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return host.registry(Routes).get("GET /", ctx);
|
||||
});
|
||||
const report = await host.reload([facet("two")]);
|
||||
assert.equal(host.registry(Routes).get("GET /"), "two");
|
||||
go.open();
|
||||
assert.equal(await pinned, "one");
|
||||
assert.deepEqual(seen, [
|
||||
["one", report.previous],
|
||||
["two", report.codeVersion],
|
||||
]);
|
||||
});
|
||||
|
||||
it("a reload can add a type together with a consumer that holds it", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const Clock = local("t.clock", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { now: method(undefined, Type.Number()) },
|
||||
});
|
||||
const clockFacet: Facet = { name: "clock", setup: (api) => api.serve(Clock, () => ({ now: () => 42 })) };
|
||||
const consumer = (fail: boolean): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
const clock = self.get(Clock, {});
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
if (fail) throw new Error("consumer fails");
|
||||
return {
|
||||
count,
|
||||
greet: async (name: string, ctx: Ctx) => `${await clock.now(ctx)} ${name}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([clockFacet, consumer(true)]), /consumer fails/);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.equal(await ref.greet("x"), "hello x");
|
||||
await host.reload([clockFacet, consumer(false)]);
|
||||
assert.equal(await ref.greet("x"), "42 x");
|
||||
});
|
||||
|
||||
it("the gate rejects new objects of a changed type; after commit they get the new code", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const grandfathered = host.root(async (ctx) => {
|
||||
await ref.greet("g", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
const later = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await later.promise;
|
||||
return ref.greet("old", ctx);
|
||||
});
|
||||
await tick();
|
||||
const reload = host.reload([greeterFacet({ greeting: "hi", state: { version: 2 } })]);
|
||||
await until(async () => {
|
||||
try {
|
||||
await ref.greet("probe");
|
||||
return false;
|
||||
} catch (error) {
|
||||
return isChordError(error, "upgrade_conflict");
|
||||
}
|
||||
});
|
||||
assert.throws(
|
||||
() => host.hold(Greeter, { id: "late" }),
|
||||
(error) => isChordError(error, "upgrade_conflict") && error.retryable,
|
||||
);
|
||||
go.open();
|
||||
await grandfathered;
|
||||
await reload;
|
||||
assert.equal(await host.hold(Greeter, { id: "late" }).greet("l"), "hi l");
|
||||
// The previous code version is still retained (pinned root), but the gated object dropped its old implementation.
|
||||
assert.notEqual(host.previousCodeVersion, undefined);
|
||||
later.open();
|
||||
await assert.rejects(pinned, (error) => isChordError(error, "upgrade_conflict"));
|
||||
});
|
||||
});
|
||||
248
third_party/pi/packages/chord/test/replicated-state-delivery.test.ts
vendored
Normal file
248
third_party/pi/packages/chord/test/replicated-state-delivery.test.ts
vendored
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT, withCancel } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
type ReplicatedState,
|
||||
type ReplicatedStateDelivery,
|
||||
type ReplicatedStateSourceFrame,
|
||||
replicatedState,
|
||||
} from "../src/index.ts";
|
||||
|
||||
type Value = { value: number };
|
||||
type Kind = "mutable" | "attached";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((accept, fail) => {
|
||||
resolve = accept;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fixture(kind: Kind, onError: (error: Error) => void = () => {}) {
|
||||
let state: ReplicatedState<Value>;
|
||||
let publish: (value: Value, context: Context) => void;
|
||||
if (kind === "mutable") {
|
||||
const mutable = replicatedState({ value: 0 });
|
||||
state = mutable;
|
||||
publish = (value, context) => mutable.replace(context, value);
|
||||
} else {
|
||||
let receive!: (frame: ReplicatedStateSourceFrame<Value>) => void;
|
||||
state = replicatedState<Value>(
|
||||
{
|
||||
attach: () => ({
|
||||
snapshot: { value: { value: 0 }, cursor: 0 },
|
||||
activate: (listener) => {
|
||||
receive = listener;
|
||||
},
|
||||
dispose() {},
|
||||
}),
|
||||
},
|
||||
{ onError },
|
||||
);
|
||||
publish = (value, context) =>
|
||||
receive({ value, cursor: value.value, ops: [["s", ["value"], value.value]], context });
|
||||
}
|
||||
return { state, publish };
|
||||
}
|
||||
|
||||
describe.each<Kind>(["mutable", "attached"])("%s public delivery", (kind) => {
|
||||
test("awaits hydration and each update independently of other subscribers", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const hydration = deferred();
|
||||
const update = deferred();
|
||||
const events: string[] = [];
|
||||
const fast: number[] = [];
|
||||
state.subscribe(async (value) => {
|
||||
events.push(`start:${value.value}`);
|
||||
if (value.value === 0) await hydration.promise;
|
||||
if (value.value === 1) await update.promise;
|
||||
events.push(`end:${value.value}`);
|
||||
});
|
||||
state.subscribe((value) => fast.push(value.value));
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
publish({ value: 2 }, BACKGROUND_CONTEXT);
|
||||
expect(events).toEqual(["start:0"]);
|
||||
expect(fast).toEqual([0, 1, 2]);
|
||||
expect(state.value).toEqual({ value: 2 });
|
||||
hydration.resolve();
|
||||
await vi.waitFor(() => expect(events).toEqual(["start:0", "end:0", "start:1"]));
|
||||
update.resolve();
|
||||
await vi.waitFor(() => expect(events).toEqual(["start:0", "end:0", "start:1", "end:1", "start:2", "end:2"]));
|
||||
});
|
||||
|
||||
test.each([100, 101, 102, 201, 202])("bounds %i pending deliveries", async (count) => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const hydration = deferred();
|
||||
const received: number[] = [];
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return hydration.promise;
|
||||
});
|
||||
for (let value = 1; value <= count; value += 1) publish({ value }, BACKGROUND_CONTEXT);
|
||||
expect(received).toEqual([0]);
|
||||
hydration.resolve();
|
||||
await hydration.promise;
|
||||
const first = Math.floor((count - 1) / 100) * 100 + 1;
|
||||
expect(received).toEqual([0, ...Array.from({ length: count - first + 1 }, (_, index) => first + index)]);
|
||||
});
|
||||
|
||||
test("excludes a running update from overflow and retains exact value/context/delivery", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const gate = deferred();
|
||||
const context = withCancel(BACKGROUND_CONTEXT).context;
|
||||
const received: { value: Value; context: Context; delivery: ReplicatedStateDelivery }[] = [];
|
||||
state.subscribe((value, context, delivery) => {
|
||||
received.push({ value, context, delivery });
|
||||
if (value.value === 1) return gate.promise;
|
||||
});
|
||||
for (let value = 1; value < 102; value += 1) publish({ value }, BACKGROUND_CONTEXT);
|
||||
const newest = { value: 102 };
|
||||
publish(newest, context);
|
||||
const adopted = state.value;
|
||||
publish({ value: 103 }, BACKGROUND_CONTEXT);
|
||||
expect(received.map(({ value }) => value.value)).toEqual([0, 1]);
|
||||
gate.resolve();
|
||||
await gate.promise;
|
||||
expect(received.map(({ value }) => value.value)).toEqual([0, 1, 102, 103]);
|
||||
expect(received[2]?.value).toBe(adopted);
|
||||
expect(received[2]?.context).toBe(context);
|
||||
expect(received[2]?.delivery).toEqual({ kind: "update", sequence: 102 });
|
||||
});
|
||||
|
||||
test("serializes reentrant hydration and update callbacks", () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const events: string[] = [];
|
||||
state.subscribe((value) => {
|
||||
events.push(`start:${value.value}`);
|
||||
if (value.value < 2) publish({ value: value.value + 1 }, BACKGROUND_CONTEXT);
|
||||
events.push(`end:${value.value}`);
|
||||
});
|
||||
expect(events).toEqual(["start:0", "end:0", "start:1", "end:1", "start:2", "end:2"]);
|
||||
});
|
||||
|
||||
test("treats two subscriptions of the same callback independently", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const gate = deferred();
|
||||
const received: number[] = [];
|
||||
const listener = (value: Value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return gate.promise;
|
||||
};
|
||||
const stopFirst = state.subscribe(listener);
|
||||
const stopSecond = state.subscribe(listener);
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
stopFirst();
|
||||
stopFirst();
|
||||
gate.resolve();
|
||||
await gate.promise;
|
||||
expect(received).toEqual([0, 0, 1]);
|
||||
stopSecond();
|
||||
});
|
||||
|
||||
test("unsubscribe drops queued callbacks without joining or aborting the running callback", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const gate = deferred();
|
||||
const { context } = withCancel(BACKGROUND_CONTEXT);
|
||||
const received: number[] = [];
|
||||
let completed = false;
|
||||
const stop = state.subscribe(async (value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 1) {
|
||||
await gate.promise;
|
||||
completed = true;
|
||||
}
|
||||
});
|
||||
await Promise.resolve();
|
||||
publish({ value: 1 }, context);
|
||||
publish({ value: 2 }, context);
|
||||
expect(stop()).toBeUndefined();
|
||||
publish({ value: 3 }, context);
|
||||
expect(context.abortSignal?.aborted).toBe(false);
|
||||
expect(completed).toBe(false);
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(completed).toBe(true));
|
||||
expect(received).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attached listener errors", () => {
|
||||
const kind: Kind = "attached";
|
||||
test("isolates a synchronous hydration failure without removing the subscription", () => {
|
||||
const errors: Error[] = [];
|
||||
const { state, publish } = fixture(kind, (error) => errors.push(error));
|
||||
const received: number[] = [];
|
||||
const failure = new Error("sync hydration");
|
||||
expect(() =>
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) throw failure;
|
||||
}),
|
||||
).not.toThrow();
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
expect(errors).toEqual([failure]);
|
||||
expect(received).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
test("observes hydration rejection, synchronous throw, and update rejection while continuing delivery", async () => {
|
||||
const errors: Error[] = [];
|
||||
const { state, publish } = fixture(kind, (error) => errors.push(error));
|
||||
const gate = deferred();
|
||||
const received: number[] = [];
|
||||
const fast: number[] = [];
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return gate.promise;
|
||||
if (value.value === 1) throw new Error("sync update");
|
||||
if (value.value === 2) return Promise.reject(new Error("async update"));
|
||||
});
|
||||
state.subscribe((value) => fast.push(value.value));
|
||||
for (let value = 1; value <= 3; value += 1) publish({ value }, BACKGROUND_CONTEXT);
|
||||
gate.reject(new Error("async hydration"));
|
||||
await vi.waitFor(() => expect(received).toEqual([0, 1, 2, 3]));
|
||||
expect(errors.map((error) => error.message)).toEqual(["async hydration", "sync update", "async update"]);
|
||||
expect(fast).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
test("still observes a callback rejection after unsubscribe", async () => {
|
||||
const errors: Error[] = [];
|
||||
const { state, publish } = fixture(kind, (error) => errors.push(error));
|
||||
const gate = deferred();
|
||||
const received: number[] = [];
|
||||
const stop = state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
return gate.promise;
|
||||
});
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
stop();
|
||||
const failure = new Error("stopped callback");
|
||||
gate.reject(failure);
|
||||
await vi.waitFor(() => expect(errors).toEqual([failure]));
|
||||
expect(received).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
test("mutable state reports rejected callbacks through its default error reporter", async () => {
|
||||
const reports: (() => void)[] = [];
|
||||
const spy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation((report) => {
|
||||
reports.push(report);
|
||||
});
|
||||
try {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const failure = new Error("async mutable hydration");
|
||||
const received: number[] = [];
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return Promise.reject(failure);
|
||||
});
|
||||
expect(() => state.replace(BACKGROUND_CONTEXT, { value: 1 })).not.toThrow();
|
||||
await Promise.resolve();
|
||||
expect(received).toEqual([0, 1]);
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0]).toThrow(failure);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
351
third_party/pi/packages/chord/test/replicated-state.test.ts
vendored
Normal file
351
third_party/pi/packages/chord/test/replicated-state.test.ts
vendored
Normal file
@@ -0,0 +1,351 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import type { Draft, Op } from "../src/delta/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
type ReplicatedStateSource,
|
||||
type ReplicatedStateSourceAttachment,
|
||||
type ReplicatedStateSourceFrame,
|
||||
replicatedState,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("transactional replicated state", () => {
|
||||
it("publishes one immutable structurally shared revision", () => {
|
||||
const initial = { changed: { value: 1 }, retained: { value: 2 } };
|
||||
const state = replicatedState(initial);
|
||||
const previous = state.value;
|
||||
const deliveries: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.changed.value = 3;
|
||||
draft.changed.value = 4;
|
||||
});
|
||||
expect(state.value).toEqual({ changed: { value: 4 }, retained: { value: 2 } });
|
||||
expect(state.value).not.toBe(previous);
|
||||
expect(state.value.changed).not.toBe(previous.changed);
|
||||
expect(state.value.retained).toBe(previous.retained);
|
||||
expect(Object.isFrozen(state.value)).toBe(false);
|
||||
expect(deliveries).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("rolls back callback failures and revokes escaped drafts", () => {
|
||||
const state = replicatedState({ nested: { value: 1 } });
|
||||
const previous = state.value;
|
||||
let escaped: Draft<{ value: number }> | undefined;
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
escaped = draft.nested;
|
||||
draft.nested.value = 2;
|
||||
throw new Error("stop");
|
||||
}),
|
||||
).toThrow("stop");
|
||||
expect(state.value).toBe(previous);
|
||||
expect(() => escaped?.value).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("rejects PromiseLike callbacks and aborts their draft", () => {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const previous = state.value;
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, ((draft: Draft<{ value: number }>) => {
|
||||
draft.value = 1;
|
||||
return Promise.resolve();
|
||||
}) as (draft: Draft<{ value: number }>) => void),
|
||||
).toThrow(/synchronous/);
|
||||
expect(state.value).toBe(previous);
|
||||
});
|
||||
|
||||
it("rejects nested changes and replacements without losing the outer rollback", () => {
|
||||
const state = replicatedState({ left: 0, right: 0 });
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left = 1;
|
||||
state.change(BACKGROUND_CONTEXT, (nested) => {
|
||||
nested.right = 2;
|
||||
});
|
||||
}),
|
||||
).toThrow(/reentrantly/);
|
||||
expect(state.value).toEqual({ left: 0, right: 0 });
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, () => {
|
||||
state.replace(BACKGROUND_CONTEXT, { left: 1, right: 2 });
|
||||
}),
|
||||
).toThrow(/change callback/);
|
||||
expect(state.value).toEqual({ left: 0, right: 0 });
|
||||
});
|
||||
|
||||
it("queues listener-triggered changes in sequence order", () => {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const deliveries: Array<{ value: number; sequence: number }> = [];
|
||||
const lateDeliveries: Array<{ kind: string; sequence: number }> = [];
|
||||
let nested = false;
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind !== "update" || nested) return;
|
||||
nested = true;
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 2;
|
||||
});
|
||||
state.subscribe((_value, _context, delivery) => lateDeliveries.push(delivery));
|
||||
});
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") deliveries.push({ value: value.value, sequence: delivery.sequence });
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
});
|
||||
expect(deliveries).toEqual([
|
||||
{ value: 1, sequence: 1 },
|
||||
{ value: 2, sequence: 2 },
|
||||
]);
|
||||
expect(lateDeliveries).toEqual([{ kind: "hydrate", sequence: 2 }]);
|
||||
});
|
||||
|
||||
it("isolates listener failures after committing the revision", () => {
|
||||
const reports: (() => void)[] = [];
|
||||
const spy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation((report) => {
|
||||
reports.push(report);
|
||||
});
|
||||
try {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const received: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind === "update") throw new Error("listener failed");
|
||||
});
|
||||
state.subscribe((_value, _context, delivery) => received.push(delivery.sequence));
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(state.value).toEqual({ value: 1 });
|
||||
expect(received).toEqual([0, 1]);
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0]).toThrow("listener failed");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("copies assigned values by value", () => {
|
||||
const external = { value: 1 };
|
||||
const state = replicatedState<{ left: { value: number } | null; right: { value: number } | null }>({
|
||||
left: null,
|
||||
right: null,
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left = external;
|
||||
draft.right = external;
|
||||
draft.left.value = 2;
|
||||
});
|
||||
expect(external.value).toBe(1);
|
||||
expect(state.value).toEqual({ left: { value: 2 }, right: { value: 1 } });
|
||||
expect(state.value.left).not.toBe(state.value.right);
|
||||
});
|
||||
|
||||
it("takes immutable ownership of alias-free replacements", () => {
|
||||
const state = replicatedState({ left: { value: 1 }, right: { value: 2 } });
|
||||
const replacement = { left: { ...state.value.left }, right: { ...state.value.left } };
|
||||
state.replace(BACKGROUND_CONTEXT, replacement);
|
||||
expect(state.value).toBe(replacement);
|
||||
expect(state.value.left).not.toBe(state.value.right);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left.value = 9;
|
||||
});
|
||||
expect(state.value).toEqual({ left: { value: 9 }, right: { value: 1 } });
|
||||
});
|
||||
|
||||
it("replaces atomically and ignores deeply equal replacements", () => {
|
||||
const state = replicatedState({ value: { nested: 1 }, retained: { nested: 2 } });
|
||||
const previous = state.value;
|
||||
state.replace(BACKGROUND_CONTEXT, { value: { nested: 1 }, retained: { nested: 2 } });
|
||||
expect(state.value).toBe(previous);
|
||||
state.replace(BACKGROUND_CONTEXT, { ...state.value, value: { nested: 2 } });
|
||||
expect(state.value).toEqual({ value: { nested: 2 }, retained: { nested: 2 } });
|
||||
expect(state.value.retained).toBe(previous.retained);
|
||||
});
|
||||
});
|
||||
|
||||
type SourceValue = Readonly<{ value: number }>;
|
||||
|
||||
class TestSource<T> implements ReplicatedStateSource<T> {
|
||||
readonly attachments = new Set<TestSourceAttachment<T>>();
|
||||
onAttach: (() => void) | undefined;
|
||||
#value: T;
|
||||
#cursor: number;
|
||||
|
||||
constructor(value: T, cursor = 0) {
|
||||
this.#value = value;
|
||||
this.#cursor = cursor;
|
||||
}
|
||||
|
||||
attach(): ReplicatedStateSourceAttachment<T> {
|
||||
const attachment = new TestSourceAttachment(Object.freeze({ value: this.#value, cursor: this.#cursor }), () =>
|
||||
this.attachments.delete(attachment),
|
||||
);
|
||||
this.attachments.add(attachment);
|
||||
this.onAttach?.();
|
||||
return attachment;
|
||||
}
|
||||
|
||||
commit(value: T, ops: readonly Op[], context: Context = BACKGROUND_CONTEXT, cursor = this.#cursor + 1): void {
|
||||
this.#value = value;
|
||||
this.#cursor = cursor;
|
||||
const frame = Object.freeze({ cursor, value, ops, context });
|
||||
for (const attachment of [...this.attachments]) attachment.publish(frame);
|
||||
}
|
||||
}
|
||||
|
||||
class TestSourceAttachment<T> implements ReplicatedStateSourceAttachment<T> {
|
||||
readonly snapshot: { readonly value: T; readonly cursor: number };
|
||||
readonly #onDispose: () => void;
|
||||
readonly #buffer: ReplicatedStateSourceFrame<T>[] = [];
|
||||
#listener: ((frame: ReplicatedStateSourceFrame<T>) => void) | undefined;
|
||||
#activated = false;
|
||||
#disposed = false;
|
||||
|
||||
constructor(snapshot: { readonly value: T; readonly cursor: number }, onDispose: () => void) {
|
||||
this.snapshot = snapshot;
|
||||
this.#onDispose = onDispose;
|
||||
}
|
||||
|
||||
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void {
|
||||
if (this.#activated) throw new Error("attachment is already active");
|
||||
this.#activated = true;
|
||||
this.#listener = listener;
|
||||
for (const frame of this.#buffer.splice(0)) listener(frame);
|
||||
}
|
||||
|
||||
publish(frame: ReplicatedStateSourceFrame<T>): void {
|
||||
if (this.#disposed) return;
|
||||
if (this.#listener === undefined) this.#buffer.push(frame);
|
||||
else this.#listener(frame);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#buffer.length = 0;
|
||||
this.#onDispose();
|
||||
}
|
||||
}
|
||||
|
||||
describe("authoritative replicated state sources", () => {
|
||||
it("captures before activation and drains queued commits in order", () => {
|
||||
const initial = Object.freeze({ value: 0 });
|
||||
const first = Object.freeze({ value: 1 });
|
||||
const second = Object.freeze({ value: 2 });
|
||||
const firstOps = Object.freeze([["s", ["value"], 1] as const]);
|
||||
const secondOps = Object.freeze([["s", ["value"], 2] as const]);
|
||||
const source = new TestSource<SourceValue>(initial, 10);
|
||||
source.onAttach = () => {
|
||||
source.commit(first, firstOps);
|
||||
source.commit(second, secondOps);
|
||||
};
|
||||
|
||||
const state = replicatedState(source);
|
||||
const deliveries: Array<{ value: SourceValue; sequence: number }> = [];
|
||||
state.subscribe((value, _context, delivery) => deliveries.push({ value, sequence: delivery.sequence }));
|
||||
expect(state.value).toBe(second);
|
||||
expect(deliveries).toEqual([{ value: second, sequence: 2 }]);
|
||||
});
|
||||
|
||||
it("hydrates at sequence zero when attaching after existing commits", () => {
|
||||
const initial = Object.freeze({ value: 0 });
|
||||
const current = Object.freeze({ value: 1 });
|
||||
const source = new TestSource<SourceValue>(initial, 40);
|
||||
source.commit(current, Object.freeze([["s", ["value"], 1] as const]));
|
||||
const state = replicatedState(source);
|
||||
const deliveries: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
||||
expect(state.value).toBe(current);
|
||||
expect(deliveries).toEqual([0]);
|
||||
});
|
||||
|
||||
it("publishes exact source values without applying or re-diffing", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const state = replicatedState(source);
|
||||
const next = Object.freeze({ value: 1 });
|
||||
// Deliberately not the operation that produces `next`: Chord must publish the source value as is.
|
||||
const ops = Object.freeze([["s", ["value"], 7] as const]);
|
||||
let publishedValue: SourceValue | undefined;
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") publishedValue = value;
|
||||
});
|
||||
|
||||
source.commit(next, ops);
|
||||
expect(state.value).toBe(next);
|
||||
expect(publishedValue).toBe(next);
|
||||
});
|
||||
|
||||
it("buffers reentrant frames and skips updates covered by a late hydration", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const state = replicatedState(source);
|
||||
const received: number[] = [];
|
||||
const late: Array<{ kind: string; sequence: number; value: number }> = [];
|
||||
let nested = false;
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind !== "update" || delivery.sequence !== 1 || nested) return;
|
||||
nested = true;
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
||||
state.subscribe((value, _context, delivery) => late.push({ ...delivery, value: value.value }));
|
||||
});
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") received.push(value.value);
|
||||
});
|
||||
|
||||
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const]));
|
||||
expect(received).toEqual([1, 2]);
|
||||
expect(late).toEqual([{ kind: "hydrate", sequence: 2, value: 2 }]);
|
||||
});
|
||||
|
||||
it("reports listener failures without throwing them into the source", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const errors: Error[] = [];
|
||||
const state = replicatedState(source, { onError: (error) => errors.push(error) });
|
||||
const received: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind === "update") throw new Error("listener failed");
|
||||
});
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") received.push(value.value);
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const])),
|
||||
).not.toThrow();
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
||||
expect(received).toEqual([1, 2]);
|
||||
expect(errors.map((error) => error.message)).toEqual(["listener failed", "listener failed"]);
|
||||
});
|
||||
|
||||
it("reports cursor gaps, disposes the broken attachment, and ignores later frames", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }), 5);
|
||||
const errors: Error[] = [];
|
||||
const state = replicatedState(source, { onError: (error) => errors.push(error) });
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]), BACKGROUND_CONTEXT, 7);
|
||||
expect(errors[0]?.message).toContain("expected 6, received 7");
|
||||
expect(source.attachments.size).toBe(0);
|
||||
expect(state.value).toEqual({ value: 0 });
|
||||
source.commit(Object.freeze({ value: 3 }), Object.freeze([["s", ["value"], 3] as const]), BACKGROUND_CONTEXT, 8);
|
||||
expect(state.value).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("keeps attachments independent and disposes each idempotently", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const first = replicatedState(source);
|
||||
const second = replicatedState(source);
|
||||
expect(source.attachments.size).toBe(2);
|
||||
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const]));
|
||||
expect(first.value.value).toBe(1);
|
||||
expect(second.value.value).toBe(1);
|
||||
|
||||
first.dispose();
|
||||
first.dispose();
|
||||
expect(source.attachments.size).toBe(1);
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
||||
expect(first.value.value).toBe(1);
|
||||
expect(second.value.value).toBe(2);
|
||||
second.dispose();
|
||||
expect(source.attachments.size).toBe(0);
|
||||
});
|
||||
});
|
||||
509
third_party/pi/packages/chord/test/requests.test.ts
vendored
Normal file
509
third_party/pi/packages/chord/test/requests.test.ts
vendored
Normal file
@@ -0,0 +1,509 @@
|
||||
// Regression tests for resolved application requests (#1-#10), numbered as in their test names.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { applyImmutable } from "../src/delta/index.ts";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
type FacetApi,
|
||||
type FacetLoader,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
opaque,
|
||||
remote,
|
||||
type StateEvent,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 100): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const View = Type.Object({ text: Type.String() });
|
||||
|
||||
describe("application requests", () => {
|
||||
it("#1 a held object's state member can be returned as this object's state member", async () => {
|
||||
const Conv = remote("t.conv", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { view: View }, methods: { set: method(Type.String()) } } },
|
||||
});
|
||||
const Chat = remote("t.chat", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { viewer: { state: { view: View } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Conv, (_input, _principal, self) => {
|
||||
const view = self.state("view", View, { text: "" });
|
||||
return {
|
||||
variant: "main",
|
||||
view,
|
||||
set: (text, ctx) => {
|
||||
view.replace(ctx, { text });
|
||||
},
|
||||
};
|
||||
});
|
||||
// No derivation, no cast: the reference member is the implementation member.
|
||||
server.serve(Chat, (_input, _principal, self) => {
|
||||
const conv = self.get(Conv, {});
|
||||
return { variant: "viewer", view: conv.view };
|
||||
});
|
||||
const chat = server.hold(Chat, {});
|
||||
const values: unknown[] = [];
|
||||
chat.view.subscribe((value) => values.push(value));
|
||||
await chat.ready;
|
||||
await server.hold(Conv, {}).set("hi");
|
||||
await tick();
|
||||
assert.deepEqual(chat.view.value, { text: "hi" });
|
||||
assert.deepEqual(values.at(-1), { text: "hi" });
|
||||
|
||||
// Across a link: the snapshot and its ops reach a browser.
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const remoteChat = browser.hold(Chat, {});
|
||||
await remoteChat.ready;
|
||||
await server.hold(Conv, {}).set("there");
|
||||
await until(() => (remoteChat.view.value as { text: string } | undefined)?.text === "there");
|
||||
});
|
||||
|
||||
it("#2 facets reach their own host through FacetApi.root and FacetApi.registry", async () => {
|
||||
const Config = defineRegistry<string>("t.config");
|
||||
const Settings = local("t.settings", { version: 1, input: Type.Object({}) });
|
||||
const Reader = local("t.reader", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { read: method(undefined, Type.String()) },
|
||||
});
|
||||
const captured: FacetApi[] = [];
|
||||
const facet = (value: string): Facet => ({
|
||||
name: "config",
|
||||
...defineFacet((api) => {
|
||||
captured.push(api);
|
||||
api.serve(
|
||||
Settings,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Config, { greeting: value });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
api.serve(Reader, () => ({
|
||||
read: (ctx: Ctx) => api.registry(Config).get("greeting", ctx) ?? "",
|
||||
}));
|
||||
}),
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, name: "worker", onError: () => {} });
|
||||
host.install(facet("v1"));
|
||||
await sleep(5);
|
||||
const api = captured[0]!;
|
||||
assert.equal(api.name, "worker");
|
||||
// A callback outside Chord (a Durable tool, say) starts its own root.
|
||||
assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v1");
|
||||
await host.reload([facet("v2")]);
|
||||
await sleep(5);
|
||||
assert.equal(captured[1]!.registry(Config).get("greeting"), "v2");
|
||||
assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v2");
|
||||
});
|
||||
|
||||
it("#3 a listener keeps its subscription across reloads when resource and select are unchanged", async () => {
|
||||
const Watcher = local("t.watcher", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { seen: Type.Array(Type.String()) },
|
||||
});
|
||||
// A source that accepts one subscriber at a time (like a Durable watch).
|
||||
let subscribers = 0;
|
||||
let emit = (_event: string) => {};
|
||||
const single = {
|
||||
start: (fn: (event: string) => void) => {
|
||||
if (subscribers > 0) throw new Error("Watch is already started");
|
||||
subscribers++;
|
||||
emit = fn;
|
||||
return () => {
|
||||
subscribers--;
|
||||
};
|
||||
},
|
||||
};
|
||||
const facet = (tag: string): Facet => ({
|
||||
name: "watcher",
|
||||
setup: (api) =>
|
||||
api.serve(Watcher, (_input, _principal, self) => {
|
||||
const seen = self.state("seen", Type.Array(Type.String()), []);
|
||||
const watchResource = self.resource("watch", { version: 1, open: () => single });
|
||||
// A new adapter object per call, and a new arrow per factory run.
|
||||
self.on(
|
||||
"events",
|
||||
watchResource,
|
||||
(watch) => ({ subscribe: (fn: (event: string) => void) => watch.start(fn) }),
|
||||
(event, ctx) => seen.change(ctx, (draft) => void draft.push(`${tag}:${event}`)),
|
||||
);
|
||||
return { seen };
|
||||
}),
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.install(facet("v1"));
|
||||
const ref = host.hold(Watcher, {});
|
||||
await ref.ready;
|
||||
emit("a");
|
||||
await host.reload([facet("v2")]);
|
||||
await host.reload([facet("v3")]);
|
||||
emit("b");
|
||||
await sleep(5);
|
||||
assert.equal(subscribers, 1);
|
||||
assert.deepEqual(ref.seen.value, ["v1:a", "v3:b"]);
|
||||
});
|
||||
|
||||
it("#3 a changed select resubscribes", async () => {
|
||||
const Watcher = local("t.watcher2", { version: 1, input: Type.Object({}) });
|
||||
let subscribes = 0;
|
||||
const source = {
|
||||
subscribe: () => {
|
||||
subscribes++;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
const facet = (selectText: boolean): Facet => ({
|
||||
name: "watcher2",
|
||||
setup: (api) =>
|
||||
api.serve(Watcher, (_input, _principal, self) => {
|
||||
const sResource = self.resource("s", { version: 1, open: () => source });
|
||||
self.on("s", sResource, selectText ? (s) => s : (value) => value, () => {});
|
||||
return {};
|
||||
}),
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.install(facet(true));
|
||||
await host.hold(Watcher, {}).ready;
|
||||
await host.reload([facet(true)]);
|
||||
assert.equal(subscribes, 1);
|
||||
await host.reload([facet(false)]);
|
||||
assert.equal(subscribes, 2);
|
||||
});
|
||||
|
||||
it("#4 replace diffs a new revision that shares structure; unrelated values replace", async () => {
|
||||
const Mirror = local("t.mirror", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { doc: Type.Unknown(), set: method(Type.Unknown()) },
|
||||
});
|
||||
const Doc = Type.Object({ entries: Type.Array(Type.String()), status: Type.String() });
|
||||
let lastOptions: { diff?: boolean } | undefined;
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Mirror, (_input, _principal, self) => {
|
||||
const doc = self.state("doc", Doc, { entries: ["a", "b"], status: "idle" });
|
||||
return {
|
||||
doc,
|
||||
set: (value: unknown, ctx: Ctx) => {
|
||||
doc.replace(ctx, value as never, lastOptions);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Mirror, {});
|
||||
await ref.ready;
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
let replica: unknown;
|
||||
ref.doc.events((event) => {
|
||||
replica = event.type === "ops" ? applyImmutable(replica, event.ops) : event.value;
|
||||
events.push(event);
|
||||
});
|
||||
const opsOf = (event: StateEvent<unknown> | undefined) => (event?.type === "ops" ? event.ops : []);
|
||||
const current = ref.doc.value as { entries: string[]; status: string };
|
||||
// A new revision reusing `entries`: a diff.
|
||||
await ref.set({ entries: current.entries, status: "running" });
|
||||
assert.deepEqual(opsOf(events.at(-1)), [["s", ["status"], "running"]]);
|
||||
// Unrelated containers: one replacement op.
|
||||
await ref.set({ entries: ["a", "b", "c"], status: "running" });
|
||||
assert.equal(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
// Forced either way.
|
||||
lastOptions = { diff: true };
|
||||
await ref.set({ entries: ["a", "b", "c", "d"], status: "running" });
|
||||
assert.notEqual(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
lastOptions = { diff: false };
|
||||
const shared = ref.doc.value as { entries: string[] };
|
||||
await ref.set({ entries: shared.entries, status: "done" });
|
||||
assert.equal(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
await tick();
|
||||
assert.deepEqual(replica, ref.doc.value);
|
||||
});
|
||||
|
||||
it("#5 roots of a child that connected behind the server are admitted in the server's code version", async () => {
|
||||
const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { codeVersion: method(undefined, Type.Number()) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const echoFacet: Facet = {
|
||||
name: "echo",
|
||||
setup: (api) => api.serve(Echo, () => ({ variant: "main", codeVersion: (ctx: Ctx) => ctx.codeVersion })),
|
||||
};
|
||||
server.install(echoFacet);
|
||||
await server.reload([echoFacet]);
|
||||
assert.equal(server.codeVersion, 2);
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
const loader: FacetLoader = async () => [];
|
||||
const worker = createHost({ self: { kind: "host" }, name: "session:S1", loader, onError: () => {} });
|
||||
const go = latch();
|
||||
// A worker root of code version 1 (an eager object's listener, say) calls the server after connecting.
|
||||
const call = worker.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return (await ctx.get(Echo, {})).codeVersion(ctx);
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
||||
worker.connect(up, { principal: { kind: "server" } });
|
||||
go.open();
|
||||
assert.equal(await call, 2);
|
||||
await until(() => worker.codeVersion === 2);
|
||||
// Unknown code versions from in-step peers stay superseded.
|
||||
await assert.rejects(
|
||||
server.root(() => 0, { codeVersion: 1 }),
|
||||
(error) => isChordError(error, "superseded"),
|
||||
);
|
||||
});
|
||||
|
||||
it("#6 self.expose: an opaque member backed by a resource, readable once live", async () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { db: opaque<{ readonly queries: string[] }>("t.db@1") },
|
||||
});
|
||||
const gate = latch();
|
||||
const native = { queries: [] as string[] };
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Store, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
await gate.promise;
|
||||
return native;
|
||||
},
|
||||
});
|
||||
return { db: self.expose(db) };
|
||||
});
|
||||
const ref = host.hold(Store, {});
|
||||
assert.equal(ref.db, undefined);
|
||||
gate.open();
|
||||
await ref.ready;
|
||||
assert.equal(ref.db, native);
|
||||
|
||||
const Other = local("t.other", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Other, (_input, _principal, self) => {
|
||||
self.expose({ key: "x", value: 1 });
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Other, {}).ready, /expose takes a resource declared by this object/);
|
||||
});
|
||||
|
||||
it("#7 a cluster reload reports what each child prepared", async () => {
|
||||
const Greeter = remote("t.greeter", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { greet: method(Type.String(), Type.String()) } } },
|
||||
});
|
||||
const greeter = (text: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(Greeter, () => ({ variant: "main", greet: (name: string) => `${text} ${name}` })),
|
||||
});
|
||||
const modules = new Map([["greeter", greeter("v1")]]);
|
||||
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
worker.install(greeter("v1"));
|
||||
await tick();
|
||||
const ref = server.hold(Greeter, {});
|
||||
assert.equal(await ref.greet("a"), "v1 a");
|
||||
modules.set("greeter", greeter("v2"));
|
||||
const report = await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.deepEqual(report.changed, []);
|
||||
const child = report.children?.["session:S1"];
|
||||
assert.deepEqual(child?.changed, ["t.greeter"]);
|
||||
assert.equal(child?.prepared.length, 1);
|
||||
assert.equal(child?.codeVersion, report.codeVersion);
|
||||
});
|
||||
|
||||
it("#8 a held remote reference keeps its state without listeners; a later subscriber sees the current value", async () => {
|
||||
const Counter = remote("t.count8", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { n: Type.Number() }, methods: { set: method(Type.Number()) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Counter, (_input, _principal, self) => {
|
||||
const n = self.state("n", Type.Number(), 0);
|
||||
return {
|
||||
variant: "main",
|
||||
n,
|
||||
set: (value, ctx) => {
|
||||
n.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const counter = browser.hold(Counter, {});
|
||||
await counter.ready;
|
||||
assert.equal(counter.n.value, 0);
|
||||
const stop = counter.n.subscribe(() => {});
|
||||
await counter.set(1);
|
||||
await until(() => counter.n.value === 1);
|
||||
stop();
|
||||
await counter.set(2);
|
||||
await until(() => counter.n.value === 2);
|
||||
const seen: unknown[] = [];
|
||||
const again = counter.n.subscribe((value) => seen.push(value));
|
||||
assert.deepEqual(seen, [2]);
|
||||
again();
|
||||
});
|
||||
|
||||
it("#9 references report status changes: live, loading while a link reconnects, gone, released", async () => {
|
||||
const Thing = remote("t.thing9", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Thing, () => ({ variant: "main" }));
|
||||
const localThing = server.hold(Thing, {}, { principal: { user: "u" } });
|
||||
const localStatus: unknown[] = [];
|
||||
localThing.onStatus((status, error) =>
|
||||
localStatus.push(status === "gone" ? [status, (error as { code: string }).code] : status),
|
||||
);
|
||||
await localThing.ready;
|
||||
await tick();
|
||||
await server.invalidatePrincipal({ user: "u" });
|
||||
await tick();
|
||||
assert.deepEqual(localStatus, ["live", ["gone", "gone"]]);
|
||||
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const remoteThing = browser.hold(Thing, {});
|
||||
const remoteStatus: string[] = [];
|
||||
remoteThing.onStatus((status) => remoteStatus.push(status));
|
||||
await remoteThing.ready;
|
||||
up.close();
|
||||
await until(() => remoteStatus.length === 2);
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { guest: "g" } });
|
||||
browser.connect(up2, { principal: { kind: "server" } });
|
||||
await until(() => remoteStatus.length === 3);
|
||||
remoteThing.release();
|
||||
await tick();
|
||||
assert.deepEqual(remoteStatus, ["live", "loading", "live", "released"]);
|
||||
});
|
||||
|
||||
it("#10 host close disposes holders before what they hold, and before closing links", async () => {
|
||||
const log: string[] = [];
|
||||
const Remote = remote("t.remote10", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { note: method(Type.String()) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Remote, () => ({
|
||||
variant: "main",
|
||||
note: (text) => {
|
||||
log.push(`server: ${text}`);
|
||||
},
|
||||
}));
|
||||
const Proc = local("t.proc", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { tracker: opaque<Set<string>>("t.tracker@1") },
|
||||
});
|
||||
const Chat = local("t.chat10", { version: 1, input: Type.Object({}) });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "w" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "w",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: (error) => log.push(`error: ${String(error)}`),
|
||||
});
|
||||
// Eager: activated (and registered with the host) before the object that holds it.
|
||||
worker.serve(
|
||||
Proc,
|
||||
(_input, _principal, self) => {
|
||||
const tracker = self.resource("tracker", {
|
||||
version: 1,
|
||||
open: () => new Set<string>(),
|
||||
close: () => void log.push("proc closed"),
|
||||
});
|
||||
return { tracker: self.expose(tracker) };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await sleep(5);
|
||||
worker.serve(Chat, (_input, _principal, self) => {
|
||||
const proc = self.get(Proc, {});
|
||||
const upstream = self.get(Remote, {});
|
||||
self.resource("session", {
|
||||
version: 1,
|
||||
open: () => proc.tracker?.add("chat"),
|
||||
close: async () => {
|
||||
proc.tracker?.delete("chat");
|
||||
log.push(`chat closed, proc ${proc.status}, tracker ${proc.tracker === undefined ? "gone" : "ok"}`);
|
||||
await upstream.note("bye");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
await worker.hold(Chat, {}).ready;
|
||||
await worker.close();
|
||||
assert.deepEqual(log, ["chat closed, proc live, tracker ok", "server: bye", "proc closed"]);
|
||||
});
|
||||
});
|
||||
686
third_party/pi/packages/chord/test/review.test.ts
vendored
Normal file
686
third_party/pi/packages/chord/test/review.test.ts
vendored
Normal file
@@ -0,0 +1,686 @@
|
||||
// Regression tests for Sol's phase 1a review (findings #1-#11) and the requested race tests.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
type Ref,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
import { fits } from "../src/schema.ts";
|
||||
import { CountingLink } from "./fixtures/counting.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const ticks = async (n = 10) => {
|
||||
for (let i = 0; i < n; i++) await tick();
|
||||
};
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
function emitter<E>(options: { failSubscribe?: boolean } = {}) {
|
||||
const listeners = new Set<(event: E) => void>();
|
||||
return {
|
||||
emit: (event: E) => {
|
||||
for (const fn of [...listeners]) fn(event);
|
||||
},
|
||||
subscribe: (fn: (event: E) => void) => {
|
||||
if (options.failSubscribe) throw new Error("subscribe fails");
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Item = local("t.item", {
|
||||
version: 1,
|
||||
input: Type.Object({ id: Type.String() }),
|
||||
members: { version: method(undefined, Type.String()) },
|
||||
});
|
||||
|
||||
describe("review: reload commit", () => {
|
||||
it("#1 a failing listener subscription fails prepare; the code version and all objects stay unchanged", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const sources = { a: emitter<number>(), b: emitter<number>({ failSubscribe: true }), c: emitter<number>() };
|
||||
const facet = (version: string, listen: boolean): Facet => ({
|
||||
name: "item",
|
||||
setup: (api) =>
|
||||
api.serve(Item, ({ id }, _principal, self) => {
|
||||
if (listen) self.on("events", sources[id as "a"], () => {});
|
||||
return { version: () => version };
|
||||
}),
|
||||
});
|
||||
host.install(facet("v1", false));
|
||||
const refs = ["a", "b", "c"].map((id) => host.hold(Item, { id }));
|
||||
await Promise.all(refs.map((ref) => ref.ready));
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([facet("v2", true)]), /subscribe fails/);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.deepEqual(await Promise.all(refs.map((ref) => ref.version())), ["v1", "v1", "v1"]);
|
||||
assert.equal(sources.a.size, 0);
|
||||
assert.equal(sources.c.size, 0);
|
||||
});
|
||||
|
||||
it("#2 migration is validated against the new remote schemas, not the obsolete ones (dev mode)", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, dev: true, onError: () => {} });
|
||||
const V1 = remote("t.counted", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { state: { count: Type.Object({ n: Type.Number() }) } } },
|
||||
});
|
||||
const V2 = remote("t.counted", {
|
||||
version: 2,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { state: { count: Type.Object({ total: Type.Number() }) } } },
|
||||
});
|
||||
host.install({
|
||||
name: "counted",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
V1,
|
||||
(_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
count: self.state("count", Type.Object({ n: Type.Number() }), { n: 3 }),
|
||||
}),
|
||||
{ linger: 60_000 },
|
||||
),
|
||||
});
|
||||
const first = host.hold(V1, {});
|
||||
await first.ready;
|
||||
first.release();
|
||||
await host.reload([
|
||||
{
|
||||
name: "counted",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
V2,
|
||||
(_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
count: self.state(
|
||||
"count",
|
||||
Type.Object({ total: Type.Number() }),
|
||||
{ total: 0 },
|
||||
{ version: 2, migrate: (old) => ({ total: (old as { n: number }).n }) },
|
||||
),
|
||||
}),
|
||||
{ linger: 60_000 },
|
||||
),
|
||||
},
|
||||
]);
|
||||
const second = host.hold(V2, {});
|
||||
const values: unknown[] = [];
|
||||
second.count.subscribe((value) => values.push(value));
|
||||
await second.ready;
|
||||
await ticks();
|
||||
assert.deepEqual(second.count.value, { total: 3 });
|
||||
});
|
||||
|
||||
it("#1 a commit that still fails midway fails the host terminally (no half-published code version serving work)", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) });
|
||||
const ref = host.hold(Item, { id: "a" });
|
||||
await ref.ready;
|
||||
// Everything fallible runs in prepare now, so break an install step directly (white box).
|
||||
const objects = (host as unknown as { objects: Map<string, { startTasks(): void }> }).objects;
|
||||
for (const object of objects.values()) {
|
||||
object.startTasks = () => {
|
||||
throw new Error("install step fails");
|
||||
};
|
||||
}
|
||||
await assert.rejects(
|
||||
host.reload([{ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v2" })) }]),
|
||||
/install step fails/,
|
||||
);
|
||||
await assert.rejects(
|
||||
ref.version(),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "host_failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("reentrant observers calling another object during a commit see the whole new code version", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Other = local("t.other", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { version: method(undefined, Type.String()) },
|
||||
});
|
||||
const facet = (version: string): Facet => ({
|
||||
name: "pair",
|
||||
setup: (api) => {
|
||||
api.serve(Item, () => ({ version: () => version }));
|
||||
api.serve(Other, () => ({ version: () => version }));
|
||||
},
|
||||
});
|
||||
host.install(facet("v1"));
|
||||
const item = host.hold(Item, { id: "a" });
|
||||
const other = host.hold(Other, {});
|
||||
await Promise.all([item.ready, other.ready]);
|
||||
const seen: Promise<string>[] = [];
|
||||
item.onShape(() => seen.push(other.version()));
|
||||
await host.reload([facet("v2")]);
|
||||
assert.deepEqual(await Promise.all(seen), ["v2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review: leases, invocations, activation", () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { count: Type.Unknown() },
|
||||
});
|
||||
|
||||
it("#4 a running listener holds a lease: the gate waits for it before migrating", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const events = emitter<number>();
|
||||
const go = latch();
|
||||
const seen: unknown[] = [];
|
||||
host.install({
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(Store, (_input, _principal, self) => {
|
||||
const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 1 });
|
||||
self.on("slow", events, async () => {
|
||||
await go.promise;
|
||||
seen.push(count.value);
|
||||
});
|
||||
return { count };
|
||||
}),
|
||||
});
|
||||
const ref = host.hold(Store, {});
|
||||
await ref.ready;
|
||||
events.emit(1);
|
||||
await tick();
|
||||
const codeVersion = host.codeVersion;
|
||||
const reload = host.reload(
|
||||
[
|
||||
{
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(Store, (_input, _principal, self) => ({
|
||||
count: self.state(
|
||||
"count",
|
||||
Type.Object({ total: Type.Number() }),
|
||||
{ total: 0 },
|
||||
{ version: 2, migrate: (old) => ({ total: (old as { n: number }).n }) },
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ gateDeadline: 2000 },
|
||||
);
|
||||
await ticks(20);
|
||||
assert.equal(host.codeVersion, codeVersion, "the gate must wait for the listener");
|
||||
go.open();
|
||||
await reload;
|
||||
assert.deepEqual(seen, [{ n: 1 }]);
|
||||
assert.deepEqual(ref.count.value, { total: 1 });
|
||||
});
|
||||
|
||||
it("#5 disposal waits for running methods before closing resources (released and forced)", async () => {
|
||||
const Worker = local("t.busy", {
|
||||
version: 1,
|
||||
input: Type.Object({ id: Type.String() }),
|
||||
members: { work: method(undefined, Type.Boolean()) },
|
||||
});
|
||||
const log: string[] = [];
|
||||
let go = latch();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Worker, ({ id }, _principal, self) => {
|
||||
const connResource = self.resource("conn", {
|
||||
version: 1,
|
||||
open: () => ({ closed: false }),
|
||||
close: (conn) => {
|
||||
conn.closed = true;
|
||||
log.push(`close ${id}`);
|
||||
},
|
||||
});
|
||||
return {
|
||||
work: async () => {
|
||||
await go.promise;
|
||||
log.push(`done ${id}`);
|
||||
return connResource.value.closed;
|
||||
},
|
||||
};
|
||||
});
|
||||
const released = host.hold(Worker, { id: "r" });
|
||||
const call = released.work();
|
||||
await ticks();
|
||||
released.release();
|
||||
await ticks();
|
||||
go.open();
|
||||
assert.equal(await call, false);
|
||||
await ticks();
|
||||
assert.deepEqual(log, ["done r", "close r"]);
|
||||
|
||||
go = latch();
|
||||
log.length = 0;
|
||||
const forced = host.hold(Worker, { id: "f" }, { principal: { user: "u" } });
|
||||
const forcedCall = forced.work();
|
||||
await ticks();
|
||||
const invalidated = host.invalidatePrincipal((principal) => (principal as { user?: string }).user === "u");
|
||||
await ticks();
|
||||
assert.equal(forced.status, "gone");
|
||||
assert.deepEqual(log, []);
|
||||
go.open();
|
||||
await invalidated;
|
||||
assert.equal(await forcedCall, false);
|
||||
assert.deepEqual(log, ["done f", "close f"]);
|
||||
});
|
||||
|
||||
it("#6 a registry conflict found while finalizing activation unwinds the loser completely", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) });
|
||||
const Routes = defineRegistry<string>("t.review-routes");
|
||||
const log: string[] = [];
|
||||
const types = ["t.eager-a", "t.eager-b"].map((name) => local(name, { version: 1, input: Type.Object({}) }));
|
||||
for (const type of types) {
|
||||
host.serve(
|
||||
type,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${type.name}`),
|
||||
close: () => void log.push(`close ${type.name}`),
|
||||
});
|
||||
self.register(Routes, { same: type.name });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
}
|
||||
await ticks(20);
|
||||
assert.equal(log.filter((entry) => entry.startsWith("open")).length, 2);
|
||||
assert.equal(log.filter((entry) => entry.startsWith("close")).length, 1);
|
||||
assert.equal(host.registry(Routes).table().size, 1);
|
||||
assert.ok(errors.some((error) => isChordError(error, "invalid") && error.reason === "registry_conflict"));
|
||||
});
|
||||
|
||||
it("#6 releasing the last hold while listeners start up does not resurrect the object", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
let ref: Ref<typeof Store> | undefined;
|
||||
let runs = 0;
|
||||
host.serve(Store, (_input, _principal, self) => {
|
||||
runs++;
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => void log.push("open"),
|
||||
close: () => void log.push("close"),
|
||||
});
|
||||
// A readable delivers its current value as the listener starts: release the only hold right then.
|
||||
self.on("drop", count, () => ref?.release());
|
||||
return { count };
|
||||
});
|
||||
ref = host.hold(Store, {});
|
||||
await ref.ready.catch(() => {});
|
||||
await ticks(20);
|
||||
assert.deepEqual(log, ["open", "close"]);
|
||||
const again = host.hold(Store, {});
|
||||
ref = undefined;
|
||||
await again.ready;
|
||||
assert.equal(runs, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review: prepare isolation and contracts", () => {
|
||||
const Db = local("t.db", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { read: method(undefined, Type.Unknown()) },
|
||||
});
|
||||
|
||||
it("#7 a prepare rerun cannot touch retained resources, even open ones", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const native = { mutated: false };
|
||||
const facet = (touch: boolean): Facet => ({
|
||||
name: "db",
|
||||
setup: (api) =>
|
||||
api.serve(Db, (_input, _principal, self) => {
|
||||
const db = self.resource("db", { version: 1, open: () => native });
|
||||
if (touch) db.value.mutated = true;
|
||||
return { read: () => db.value };
|
||||
}),
|
||||
});
|
||||
host.install(facet(false));
|
||||
const ref = host.hold(Db, {});
|
||||
await ref.ready;
|
||||
await assert.rejects(host.reload([facet(true)]), (error) => isChordError(error, "not_ready"));
|
||||
assert.equal(native.mutated, false);
|
||||
assert.deepEqual(await ref.read(), { mutated: false });
|
||||
});
|
||||
|
||||
it("#7 objects of types a reload adds start listeners and tasks only at commit", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
const Fresh = local("t.fresh", { version: 1, input: Type.Object({}) });
|
||||
host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) });
|
||||
await Promise.all(["a", "b"].map((id) => host.hold(Item, { id }).ready));
|
||||
const plugins = (fail: boolean): Facet[] => [
|
||||
{
|
||||
name: "fresh",
|
||||
setup: (api) =>
|
||||
api.serve(Fresh, (_input, _principal, self) => {
|
||||
self.task(async () => {
|
||||
log.push("task started");
|
||||
});
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "item",
|
||||
setup: (api) =>
|
||||
api.serve(Item, ({ id }, _principal, self) => {
|
||||
self.get(Fresh, {});
|
||||
if (fail && id === "b") throw new Error("b fails");
|
||||
return { version: () => "v2" };
|
||||
}),
|
||||
},
|
||||
];
|
||||
await assert.rejects(host.reload(plugins(true)), /b fails/);
|
||||
await ticks();
|
||||
assert.deepEqual(log, []);
|
||||
await host.reload(plugins(false));
|
||||
await ticks();
|
||||
assert.deepEqual(log, ["task started"]);
|
||||
});
|
||||
|
||||
it("#8 a breaking provider change and its updated consumer reload in one batch", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const P1 = remote("t.provider", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { get: method(undefined, Type.String()) } } },
|
||||
});
|
||||
const P2 = remote("t.provider", {
|
||||
version: 2,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { fetch: method(undefined, Type.String()) } } },
|
||||
});
|
||||
const Consumer = local("t.consumer", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { ask: method(undefined, Type.String()) },
|
||||
});
|
||||
host.install({
|
||||
name: "both",
|
||||
setup: (api) => {
|
||||
api.serve(P1, () => ({ variant: "main", get: () => "old" }));
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const provider = self.get(P1, {});
|
||||
return { ask: (ctx: Ctx) => provider.get(ctx) };
|
||||
});
|
||||
},
|
||||
});
|
||||
const consumer = host.hold(Consumer, {});
|
||||
assert.equal(await consumer.ask(), "old");
|
||||
await host.reload([
|
||||
{
|
||||
name: "both",
|
||||
setup: (api) => {
|
||||
api.serve(P2, () => ({ variant: "main", fetch: () => "new" }));
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const provider = self.get(P2, {});
|
||||
return { ask: (ctx: Ctx) => provider.fetch(ctx) };
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(await consumer.ask(), "new");
|
||||
});
|
||||
|
||||
it("#9 an exclusive resource whose close failed is restored on rollback, or marked unavailable", async () => {
|
||||
for (const reopen of ["works", "fails"] as const) {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
let opens = 0;
|
||||
const facet = (rev: number): Facet => ({
|
||||
name: "db",
|
||||
setup: (api) =>
|
||||
api.serve(Db, (_input, _principal, self) => {
|
||||
const port = self.resource("port", {
|
||||
version: rev,
|
||||
exclusive: true,
|
||||
open: () => {
|
||||
if (rev === 2) throw new Error("new port fails");
|
||||
if (++opens > 1 && reopen === "fails") throw new Error("reopen fails");
|
||||
log.push(`open ${rev}`);
|
||||
return { rev };
|
||||
},
|
||||
close: () => {
|
||||
log.push(`close ${rev}`);
|
||||
throw new Error("close fails");
|
||||
},
|
||||
});
|
||||
return { read: () => port.value };
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Db, {});
|
||||
await ref.ready;
|
||||
// The failing close aborts the reload at the gate; the old resource must not stay closed.
|
||||
await assert.rejects(host.reload([facet(2)]), /close fails/);
|
||||
if (reopen === "works") {
|
||||
assert.deepEqual(log, ["open 1", "close 1", "open 1"]);
|
||||
assert.deepEqual(await ref.read(), { rev: 1 });
|
||||
} else {
|
||||
await assert.rejects(ref.read(), (error) => isChordError(error, "unavailable"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("#10 retirement closes dropped resources before releasing dropped holds", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
const Dep = local("t.dep", { version: 1, input: Type.Object({}) });
|
||||
const facet = (withDecls: boolean): Facet => ({
|
||||
name: "item",
|
||||
setup: (api) => {
|
||||
api.serve(Dep, (_input, _principal, self) => {
|
||||
self.resource("r", { version: 1, open: () => {}, close: () => void log.push("dep closed") });
|
||||
return {};
|
||||
});
|
||||
api.serve(Item, (_input, _principal, self) => {
|
||||
if (withDecls) {
|
||||
self.get(Dep, {});
|
||||
// Slow close: releasing the hold first would let the dependency close before it finishes.
|
||||
self.resource("own", {
|
||||
version: 1,
|
||||
open: () => {},
|
||||
close: async () => {
|
||||
await ticks(3);
|
||||
log.push("own closed");
|
||||
},
|
||||
});
|
||||
}
|
||||
return { version: () => (withDecls ? "v1" : "v2") };
|
||||
});
|
||||
},
|
||||
});
|
||||
host.install(facet(true));
|
||||
const ref = host.hold(Item, { id: "a" });
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await ref.version(ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
await host.reload([facet(false)]);
|
||||
assert.deepEqual(log, []);
|
||||
go.open();
|
||||
await pinned;
|
||||
await ticks(20);
|
||||
assert.deepEqual(log, ["own closed", "dep closed"]);
|
||||
});
|
||||
|
||||
it("#11 fits has no false positives for unmodelled keywords; counterexamples checked with Value.Check", () => {
|
||||
const cases = [
|
||||
[Type.String(), Type.String({ not: { const: "x" } }), "x"],
|
||||
[Type.Array(Type.Number()), Type.Array(Type.Number(), { contains: Type.Literal(1) }), [2]],
|
||||
[
|
||||
Type.Object({ a: Type.String() }),
|
||||
Type.Object({ a: Type.String() }, { additionalProperties: Type.Number() }),
|
||||
{ a: "s", b: "t" },
|
||||
],
|
||||
[
|
||||
Type.Object({ x: Type.String() }),
|
||||
Type.Object({}, { patternProperties: { "^x$": Type.Number() } }),
|
||||
{ x: "s" },
|
||||
],
|
||||
[Type.Number(), Type.Intersect([Type.Number(), Type.Number({ minimum: 1 })]), 0],
|
||||
[
|
||||
Type.Object({ a: Type.String() }, { additionalProperties: Type.Number() }),
|
||||
Type.Object({ a: Type.String(), b: Type.Optional(Type.String()) }),
|
||||
{ a: "", b: 1 },
|
||||
],
|
||||
] as const;
|
||||
for (const [sub, sup, value] of cases) {
|
||||
assert.ok(Value.Check(sub, value) && !Value.Check(sup, value), `bad counterexample ${JSON.stringify(value)}`);
|
||||
assert.equal(fits(sub, sup), false, `fits(${JSON.stringify(sub)}, ${JSON.stringify(sup)})`);
|
||||
}
|
||||
// Additive changes still fit.
|
||||
assert.ok(
|
||||
fits(Type.Object({ a: Type.String() }), Type.Object({ a: Type.String(), b: Type.Optional(Type.Number()) })),
|
||||
);
|
||||
assert.ok(fits(Type.Object({ a: Type.String(), extra: Type.Number() }), Type.Object({ a: Type.String() })));
|
||||
});
|
||||
|
||||
it("#11 arguments are stripped to what the consumer knows, then to what the provider knows", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Strict = remote("t.strict", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("host"),
|
||||
variants: {
|
||||
main: {
|
||||
methods: {
|
||||
echo: method(Type.Object({ a: Type.String() }, { additionalProperties: false }), Type.Unknown()),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
host.serve(Strict, () => ({ variant: "main", echo: (args) => args }));
|
||||
const ref = host.hold(Strict, {});
|
||||
assert.deepEqual(await ref.echo({ a: "x", junk: 1 } as never), { a: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("review: ctx races", () => {
|
||||
const Target = local("t.target", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { hit: method() },
|
||||
});
|
||||
|
||||
it("an aborted task ctx cannot start fresh calls", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Runner = local("t.runner", { version: 1, input: Type.Object({}) });
|
||||
let hits = 0;
|
||||
const go = latch();
|
||||
let outcome: unknown;
|
||||
host.serve(Target, () => ({
|
||||
hit: () => {
|
||||
hits++;
|
||||
},
|
||||
}));
|
||||
host.serve(Runner, (_input, _principal, self) => {
|
||||
const target = self.get(Target, {});
|
||||
self.task(async (ctx) => {
|
||||
await go.promise;
|
||||
outcome = await target.hit(ctx).catch((error: unknown) => error);
|
||||
});
|
||||
return {};
|
||||
});
|
||||
const runner = host.hold(Runner, {});
|
||||
await runner.ready;
|
||||
const target = host.hold(Target, {});
|
||||
await target.ready;
|
||||
runner.release();
|
||||
await ticks();
|
||||
go.open();
|
||||
await ticks(20);
|
||||
assert.equal(hits, 0);
|
||||
assert.ok(isChordError(outcome, "aborted"));
|
||||
});
|
||||
|
||||
it("an expired ctx and a foreign ctx are refused", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const other = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Target, () => ({ hit: () => {} }));
|
||||
const target = host.hold(Target, {});
|
||||
await target.ready;
|
||||
const expired = await host.root((ctx) => ctx);
|
||||
await assert.rejects(
|
||||
target.hit(expired),
|
||||
(error) => isChordError(error, "aborted") && error.reason === "expired",
|
||||
);
|
||||
const foreign = await other.root(async (ctx) => {
|
||||
await assert.rejects(
|
||||
target.hit(ctx),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "foreign_ctx",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
assert.ok(foreign);
|
||||
});
|
||||
|
||||
// #10959
|
||||
it("a remote reference refuses a foreign ctx and a ctx not made by Chord, without calling", async () => {
|
||||
const RemoteTarget = remote("t.remote-target", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: {}, methods: { hit: method() } } },
|
||||
});
|
||||
let hits = 0;
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(RemoteTarget, () => ({
|
||||
variant: "main",
|
||||
hit: () => {
|
||||
hits++;
|
||||
},
|
||||
}));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { kind: "host", name: "worker", principal: { worker: true } });
|
||||
const link = new CountingLink(up);
|
||||
const worker = createHost({ self: { kind: "host" }, name: "worker", onError: () => {} });
|
||||
worker.connect(link, { principal: { kind: "server" } });
|
||||
const other = createHost({ self: { kind: "host" }, name: "other", onError: () => {} });
|
||||
const target = worker.hold(RemoteTarget, {});
|
||||
await target.ready;
|
||||
const foreign = await other.root((ctx) => target.hit(ctx).catch((error: unknown) => error));
|
||||
assert.ok(isChordError(foreign, "invalid") && foreign.reason === "foreign_ctx", String(foreign));
|
||||
const forged = { rootId: "forged", codeVersion: 1, signal: new AbortController().signal } as unknown as Ctx;
|
||||
await assert.rejects(
|
||||
target.hit(forged),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "foreign_ctx",
|
||||
);
|
||||
await ticks();
|
||||
assert.equal(hits, 0);
|
||||
assert.equal(link.count("sent", "call"), 0);
|
||||
await worker.root((ctx) => target.hit(ctx));
|
||||
assert.equal(hits, 1);
|
||||
});
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
createRemoteServiceEndpoint,
|
||||
createServiceCatalogueCall,
|
||||
createServiceStateDecoder,
|
||||
createServiceStateEncoder,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
decodeServiceControlCall,
|
||||
defineService,
|
||||
parseServiceCall,
|
||||
parseServiceCatalogue,
|
||||
parseServiceProviderUpdate,
|
||||
parseServiceSubscriptionSnapshot,
|
||||
parseWireServiceProviderUpdate,
|
||||
parseWireServiceSubscriptionSnapshot,
|
||||
RemoteServiceProvider,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("service wire protocol", () => {
|
||||
test("encodes control calls and validates service values", () => {
|
||||
expect(decodeServiceControlCall(createServiceCatalogueCall())).toEqual({ type: "catalogue" });
|
||||
expect(
|
||||
parseServiceCatalogue([
|
||||
{ serviceId: "pi.models", mode: "singleton" },
|
||||
{ serviceId: "pi.dialogs", mode: "keyed" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ serviceId: "pi.models", mode: "singleton" },
|
||||
{ serviceId: "pi.dialogs", mode: "keyed" },
|
||||
]);
|
||||
const subscribe = createServiceSubscribeCall("subscription-1", "pi.models", "singleton");
|
||||
expect(decodeServiceControlCall(subscribe)).toEqual({
|
||||
type: "subscribe",
|
||||
subscriptionId: "subscription-1",
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
});
|
||||
expect(decodeServiceControlCall(createServiceUnsubscribeCall("subscription-1"))).toEqual({
|
||||
type: "unsubscribe",
|
||||
subscriptionId: "subscription-1",
|
||||
});
|
||||
expect(
|
||||
parseServiceCall({
|
||||
serviceId: "pi.question-dialog",
|
||||
instance: { key: "invocation-1", generation: 2 },
|
||||
member: "submit",
|
||||
args: [{ outcome: "selected", index: 0 }],
|
||||
}),
|
||||
).toMatchObject({ member: "submit" });
|
||||
});
|
||||
|
||||
test("rejects malformed service values", () => {
|
||||
expect(() => parseServiceCall({ serviceId: "pi.models", member: "list", args: [], extra: true })).toThrow(
|
||||
"Invalid service call",
|
||||
);
|
||||
expect(() => parseServiceCatalogue([{ serviceId: "pi.models", mode: "unknown" }])).toThrow(
|
||||
"Invalid service catalogue",
|
||||
);
|
||||
expect(() => parseServiceProviderUpdate({ type: "state", member: "state", sequence: 0, ops: [] })).toThrow(
|
||||
"Invalid service state update",
|
||||
);
|
||||
expect(() =>
|
||||
parseWireServiceProviderUpdate({ type: "state", member: "state", sequence: 1, ops: [["?", 0]] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("validates decoded and wire snapshots and updates", () => {
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 1 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(parseServiceSubscriptionSnapshot(snapshot)).toBe(snapshot);
|
||||
const encoder = createServiceStateEncoder();
|
||||
const wireSnapshot = encoder.encodeSnapshot(snapshot);
|
||||
expect(parseWireServiceSubscriptionSnapshot(wireSnapshot)).toBe(wireSnapshot);
|
||||
const update: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
expect(parseServiceProviderUpdate(update)).toBe(update);
|
||||
const wireUpdate = encoder.encodeUpdate(update);
|
||||
expect(parseWireServiceProviderUpdate(wireUpdate)).toBe(wireUpdate);
|
||||
});
|
||||
|
||||
test("keeps one operation codec pair for one subscription state", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
|
||||
const first: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 1]],
|
||||
};
|
||||
const second: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
const firstWire = enc.encodeUpdate(first);
|
||||
const secondWire = enc.encodeUpdate(second);
|
||||
expect(firstWire).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondWire).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(dec.decodeUpdate(firstWire)).toEqual(first);
|
||||
expect(dec.decodeUpdate(secondWire)).toEqual(second);
|
||||
});
|
||||
|
||||
test("isolates operation dictionaries between states and subscriptions", () => {
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.states",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "left", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
{ name: "right", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstEncoder = createServiceStateEncoder();
|
||||
const firstDecoder = createServiceStateDecoder();
|
||||
const secondEncoder = createServiceStateEncoder();
|
||||
const secondDecoder = createServiceStateDecoder();
|
||||
firstDecoder.decodeSnapshot(firstEncoder.encodeSnapshot(snapshot));
|
||||
secondDecoder.decodeSnapshot(secondEncoder.encodeSnapshot(snapshot));
|
||||
|
||||
const update = (member: string, sequence: number, revision: number): ServiceProviderUpdate => ({
|
||||
type: "state",
|
||||
member,
|
||||
sequence,
|
||||
ops: [["s", ["revision"], revision]],
|
||||
});
|
||||
const firstLeft = firstEncoder.encodeUpdate(update("left", 1, 1));
|
||||
const firstRight = firstEncoder.encodeUpdate(update("right", 1, 1));
|
||||
const secondLeft = firstEncoder.encodeUpdate(update("left", 2, 2));
|
||||
const secondRight = firstEncoder.encodeUpdate(update("right", 2, 2));
|
||||
expect(firstLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(firstRight).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondLeft).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(secondRight).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(firstDecoder.decodeUpdate(firstLeft)).toEqual(update("left", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(firstRight)).toEqual(update("right", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(secondLeft)).toEqual(update("left", 2, 2));
|
||||
expect(firstDecoder.decodeUpdate(secondRight)).toEqual(update("right", 2, 2));
|
||||
|
||||
const independentLeft = secondEncoder.encodeUpdate(update("left", 1, 1));
|
||||
expect(independentLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondDecoder.decodeUpdate(independentLeft)).toEqual(update("left", 1, 1));
|
||||
|
||||
const leftBase: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "left",
|
||||
sequence: 3,
|
||||
ops: [["r", { revision: 3 }]],
|
||||
};
|
||||
expect(firstDecoder.decodeUpdate(firstEncoder.encodeUpdate(leftBase))).toEqual(leftBase);
|
||||
const thirdRight = firstEncoder.encodeUpdate(update("right", 3, 3));
|
||||
expect(thirdRight).toMatchObject({ ops: [["s", 0, 3]] });
|
||||
expect(firstDecoder.decodeUpdate(thirdRight)).toEqual(update("right", 3, 3));
|
||||
});
|
||||
|
||||
test("creates and removes keyed instance codecs with their lifecycle", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.dialogs",
|
||||
mode: "keyed",
|
||||
instances: [],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
const address = { key: "dialog-1", generation: 1 };
|
||||
const spawned: ServiceProviderUpdate = {
|
||||
type: "spawned",
|
||||
instance: {
|
||||
instance: address,
|
||||
members: [{ name: "request", kind: "state", sequence: 0, ops: [["r", { value: 0 }]] }],
|
||||
},
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(spawned))).toEqual(spawned);
|
||||
const update: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
instance: address,
|
||||
member: "request",
|
||||
sequence: 1,
|
||||
ops: [["s", ["value"], 1]],
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(update))).toEqual(update);
|
||||
const closed: ServiceProviderUpdate = { type: "closed", instance: address };
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(closed))).toEqual(closed);
|
||||
expect(() => enc.encodeUpdate({ ...update, sequence: 2 })).toThrow("Unknown service state");
|
||||
});
|
||||
});
|
||||
|
||||
interface Counter {
|
||||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
const Counter = defineService<Counter>("test.counter");
|
||||
|
||||
test("remote service endpoints publish and clean up provider subscriptions", async () => {
|
||||
const provider = new RemoteServiceProvider([Counter]);
|
||||
const state = replicatedState({ value: 0 });
|
||||
provider.provide(Counter, { state });
|
||||
const endpoint = createRemoteServiceEndpoint(provider);
|
||||
const updates: ServiceProviderUpdate[] = [];
|
||||
const publish = (_subscriptionId: string, update: ServiceProviderUpdate): void => {
|
||||
updates.push(update);
|
||||
};
|
||||
|
||||
await expect(endpoint.invoke(createServiceCatalogueCall(), publish, BACKGROUND_CONTEXT)).resolves.toEqual([
|
||||
{ serviceId: Counter.id, mode: "singleton" },
|
||||
]);
|
||||
await expect(
|
||||
endpoint.invoke(
|
||||
createServiceSubscribeCall("subscription-1", Counter.id, "singleton"),
|
||||
publish,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toMatchObject({ serviceId: Counter.id, mode: "singleton" });
|
||||
|
||||
state.state.value = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(updates).toEqual([{ type: "state", member: "state", sequence: 1, ops: [["s", ["value"], 1]] }]);
|
||||
endpoint.dispose();
|
||||
state.state.value = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(updates).toHaveLength(1);
|
||||
provider.dispose();
|
||||
});
|
||||
679
third_party/pi/packages/chord/test/services.test.ts
vendored
679
third_party/pi/packages/chord/test/services.test.ts
vendored
@@ -1,679 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
createRemoteServiceBinding,
|
||||
defineService,
|
||||
type JsonValue,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceTransport,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
type ModelRef = { provider: string; modelId: string };
|
||||
type ModelsState = {
|
||||
selected: ModelRef | null;
|
||||
revision: number;
|
||||
};
|
||||
interface Models {
|
||||
readonly state: ReplicatedState<ModelsState>;
|
||||
select(model: ModelRef, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
const Models = defineService<Models>("test.models");
|
||||
|
||||
type Question = { question: string };
|
||||
interface QuestionDialogs {
|
||||
readonly request: ReplicatedState<Question>;
|
||||
submit(answer: string, context: Context): Promise<{ accepted: boolean }>;
|
||||
}
|
||||
|
||||
const QuestionDialogs = defineService<QuestionDialogs>("test.question-dialog");
|
||||
|
||||
type EchoPayload = { value: string };
|
||||
interface Echo {
|
||||
echo(payload: EchoPayload, context: Context): Promise<EchoPayload>;
|
||||
}
|
||||
|
||||
const Echo = defineService<Echo>("test.echo");
|
||||
|
||||
interface Timeline {
|
||||
readonly state: ReplicatedState<{ entries: { id: string }[]; retained: { value: number } }>;
|
||||
}
|
||||
|
||||
const Timeline = defineService<Timeline>("test.timeline");
|
||||
|
||||
interface JsonPassthrough {
|
||||
call(value: JsonValue, context: Context): Promise<JsonValue>;
|
||||
}
|
||||
|
||||
interface NonJsonArgument {
|
||||
call(value: Date, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
interface NonJsonResult {
|
||||
call(context: Context): Promise<bigint>;
|
||||
}
|
||||
|
||||
interface NonJsonState {
|
||||
readonly state: ReplicatedState<{ value: undefined }>;
|
||||
}
|
||||
|
||||
describe("remote services", () => {
|
||||
test("checks remote JSON contracts only at compile time", () => {
|
||||
expect(defineService<JsonPassthrough>("test.json-passthrough").local).toBe(false);
|
||||
const defineInvalidContracts = (): void => {
|
||||
// @ts-expect-error Remote service arguments must be JSON-compatible.
|
||||
defineService<NonJsonArgument>("test.non-json-argument");
|
||||
// @ts-expect-error Remote service results must be JSON-compatible.
|
||||
defineService<NonJsonResult>("test.non-json-result");
|
||||
// @ts-expect-error Replicated state must be JSON-compatible.
|
||||
defineService<NonJsonState>("test.non-json-state");
|
||||
};
|
||||
expect(defineInvalidContracts).not.toThrow();
|
||||
expect(defineService<NonJsonArgument>("test.local-non-json", { local: true }).local).toBe(true);
|
||||
});
|
||||
|
||||
test("marks services remotable by default and reserves Chord service IDs", () => {
|
||||
const local = defineService<{ readonly value: string }>("test.local", { local: true });
|
||||
expect(Models.local).toBe(false);
|
||||
expect(local.local).toBe(true);
|
||||
expect(() => defineService("$chord.internal", { local: true })).toThrow(
|
||||
"Service IDs beginning with $chord. are reserved",
|
||||
);
|
||||
expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely");
|
||||
});
|
||||
|
||||
test("tracks mutable source state while publishing immutable revisions", () => {
|
||||
const initial: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initial);
|
||||
let delivered: ModelsState | undefined;
|
||||
const deliveries: string[] = [];
|
||||
const unsubscribe = state.subscribe((value, _context, delivery) => {
|
||||
delivered = value;
|
||||
deliveries.push(delivery.kind);
|
||||
});
|
||||
expect(state.value).toEqual(initial);
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
const hydrated = delivered;
|
||||
|
||||
state.state.selected = { provider: "test", modelId: "one" };
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(state.value).toEqual({ selected: { provider: "test", modelId: "one" }, revision: 1 });
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
expect(hydrated).toEqual({ selected: null, revision: 0 });
|
||||
expect(deliveries).toEqual(["hydrate", "update"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("flushes pending mutations before hydrating a new state subscriber", () => {
|
||||
const state = replicatedState({ entries: [{ id: "one" }] });
|
||||
const first: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => first.push(value));
|
||||
state.state.entries.push({ id: "two" });
|
||||
|
||||
const second: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => second.push(value));
|
||||
|
||||
expect(first).toEqual([{ entries: [{ id: "one" }] }, { entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
expect(second).toEqual([{ entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
});
|
||||
|
||||
test("does not defensively clone method arguments or results", async () => {
|
||||
const provider = new RemoteServiceProvider([Echo]);
|
||||
let received: EchoPayload | undefined;
|
||||
const response: EchoPayload = { value: "response" };
|
||||
provider.provide(Echo, {
|
||||
async echo(payload) {
|
||||
received = payload;
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Echo],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const echo = namespace.use(Echo);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const request: EchoPayload = { value: "request" };
|
||||
|
||||
await expect(echo.echo(request, BACKGROUND_CONTEXT)).resolves.toBe(response);
|
||||
expect(received).toBe(request);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("provides and consumes one singleton with replicated state", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
expect(provider.catalogue).toEqual([{ serviceId: Models.id, mode: "singleton" }]);
|
||||
const initialState: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initialState);
|
||||
let publishedState: ModelsState | undefined;
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select(model, context) {
|
||||
state.state.selected = model;
|
||||
state.state.revision += 1;
|
||||
state.publish(context);
|
||||
publishedState = state.value;
|
||||
},
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
|
||||
const first = namespace.use(Models);
|
||||
const second = namespace.use(Models);
|
||||
expect(first).toBe(second);
|
||||
expect(first.state.value).toBeUndefined();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toEqual(initialState);
|
||||
|
||||
const updates: ModelsState[] = [];
|
||||
const unsubscribe = second.state.subscribe((value) => updates.push(value));
|
||||
await first.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toEqual(publishedState);
|
||||
expect(first.state.value).toEqual({
|
||||
selected: { provider: "test", modelId: "one" },
|
||||
revision: 1,
|
||||
});
|
||||
expect(updates).toEqual([
|
||||
{ selected: null, revision: 0 },
|
||||
{ selected: { provider: "test", modelId: "one" }, revision: 1 },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const lateNamespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const lateModels = lateNamespace.use(Models);
|
||||
await lateNamespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(lateModels.state.value?.revision).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
await Promise.all([namespace.dispose(BACKGROUND_CONTEXT), lateNamespace.dispose(BACKGROUND_CONTEXT)]);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("publishes compact tracked operations through the remote provider", async () => {
|
||||
const provider = new RemoteServiceProvider([Timeline]);
|
||||
const initial = { entries: [{ id: "one" }], retained: { value: 1 } };
|
||||
const source = replicatedState(initial);
|
||||
provider.provide(Timeline, { state: source });
|
||||
const updates: Array<Parameters<Parameters<typeof provider.subscribe>[2]>[0]> = [];
|
||||
const raw = provider.subscribe(Timeline.id, "singleton", (update) => updates.push(update));
|
||||
expect(raw.snapshot.instances[0]?.members).toEqual([
|
||||
{ name: "state", kind: "state", sequence: 0, ops: [["r", initial]] },
|
||||
]);
|
||||
raw.activate();
|
||||
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Timeline],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const timeline = namespace.use(Timeline);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const previous = timeline.state.value;
|
||||
const next = { entries: [{ id: "one" }, { id: "two" }], retained: initial.retained };
|
||||
source.state.entries.push({ id: "two" });
|
||||
source.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(updates).toContainEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["p", ["entries"], 1, 0, [{ id: "two" }]]],
|
||||
});
|
||||
expect(previous).toEqual({ entries: [{ id: "one" }], retained: { value: 1 } });
|
||||
expect(timeline.state.value).toEqual(next);
|
||||
|
||||
source.state.entries.push({ id: "three" });
|
||||
const late = provider.subscribe(Timeline.id, "singleton", () => {});
|
||||
expect(updates.at(-1)).toEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["p", ["entries"], 2, 0, [{ id: "three" }]]],
|
||||
});
|
||||
expect(late.snapshot.instances[0]?.members).toEqual([
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 2,
|
||||
ops: [["r", { entries: [{ id: "one" }, { id: "two" }, { id: "three" }], retained: { value: 1 } }]],
|
||||
},
|
||||
]);
|
||||
late.close();
|
||||
raw.close();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("keeps singleton facades stable when their provider is replaced", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const state = models.state;
|
||||
const select = models.select;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(state.value?.revision).toBe(1);
|
||||
|
||||
provider.withdraw(Models);
|
||||
expect(state.value).toBeUndefined();
|
||||
await expect(select({ provider: "test", modelId: "unavailable" }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
|
||||
code: "service_not_found",
|
||||
});
|
||||
|
||||
const replacementSelect = vi.fn(async () => {});
|
||||
provider.replace(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
|
||||
select: replacementSelect,
|
||||
});
|
||||
|
||||
expect(namespace.use(Models)).toBe(models);
|
||||
expect(models.state).toBe(state);
|
||||
expect(state.value?.revision).toBe(2);
|
||||
await select({ provider: "test", modelId: "replacement" }, BACKGROUND_CONTEXT);
|
||||
expect(replacementSelect).toHaveBeenCalledOnce();
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
async select() {},
|
||||
} as unknown as Models),
|
||||
).toThrow("replacement must preserve its member shape");
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
async state() {},
|
||||
async select() {},
|
||||
} as unknown as Models),
|
||||
).toThrow("replacement must preserve its member shape");
|
||||
expect(state.value?.revision).toBe(2);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("delivers active subscriber updates before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
let delivered = 0;
|
||||
const failing = provider.subscribe(Models.id, "singleton", () => {
|
||||
throw failure;
|
||||
});
|
||||
const succeeding = provider.subscribe(Models.id, "singleton", () => {
|
||||
delivered += 1;
|
||||
});
|
||||
failing.activate();
|
||||
succeeding.activate();
|
||||
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
|
||||
async select() {},
|
||||
}),
|
||||
).toThrow(failure);
|
||||
expect(delivered).toBe(1);
|
||||
|
||||
failing.close();
|
||||
succeeding.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("replays every buffered update before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, { state, async select() {} });
|
||||
let delivered = 0;
|
||||
const subscription = provider.subscribe(Models.id, "singleton", () => {
|
||||
delivered += 1;
|
||||
throw failure;
|
||||
});
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(() => subscription.activate()).toThrow("Failed to activate remote service subscription");
|
||||
expect(delivered).toBe(2);
|
||||
subscription.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("clears retained facades when providers and bindings are disposed", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const state = models.state;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(state.value?.revision).toBe(1);
|
||||
|
||||
provider.dispose();
|
||||
expect(state.value).toBeUndefined();
|
||||
await expect(models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
"Remote service provider is disposed",
|
||||
);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
expect(() => state.value).toThrow("Remote service binding is disposed");
|
||||
});
|
||||
|
||||
test("applies provider disposal buffered while subscriptions are starting", async () => {
|
||||
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
provider.spawn(QuestionDialogs, "pending", {
|
||||
request: replicatedState<Question>({ question: "Pending?" }),
|
||||
async submit() {
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models, QuestionDialogs],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const observed: QuestionDialogs[] = [];
|
||||
namespace.observe(QuestionDialogs, (service) => {
|
||||
observed.push(service);
|
||||
});
|
||||
|
||||
provider.dispose();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(observed).toEqual([]);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("keeps deferred service handles inaccessible until host activation", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
|
||||
async select() {},
|
||||
});
|
||||
let active = false;
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
bound: false,
|
||||
assertAccess() {
|
||||
if (!active) throw new Error("Service handles are not active");
|
||||
},
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
|
||||
expect(() => models.state.value).toThrow("Service handles are not active");
|
||||
expect(() => models.state.subscribe(() => {})).toThrow("Service handles are not active");
|
||||
expect(() => models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).toThrow(
|
||||
"Service handles are not active",
|
||||
);
|
||||
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
active = true;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("rejects namespace readiness when initial hydration fails", async () => {
|
||||
const failure = new Error("initial hydration failed");
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
subscribe: () => Promise.reject(failure),
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
|
||||
await expect(namespace.ready(BACKGROUND_CONTEXT)).rejects.toBe(failure);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toEqual([failure]);
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("buffers state updates that race subscription hydration", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select() {},
|
||||
});
|
||||
const transport: RemoteServiceTransport = {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, listener);
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
const namespace = createRemoteServiceBinding({ services: [Models], transport });
|
||||
const models = namespace.use(Models);
|
||||
const revisions: number[] = [];
|
||||
models.state.subscribe((value) => revisions.push(value.revision));
|
||||
|
||||
await vi.waitFor(() => expect(revisions).toEqual([0, 1]));
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate", 0],
|
||||
["gap", 2],
|
||||
] as const)("clears replicated state after a %s operation sequence", async (_kind, sequence) => {
|
||||
let sendUpdate: ((update: Parameters<Parameters<RemoteServiceTransport["subscribe"]>[2]>[0]) => void) | undefined;
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
async subscribe(_serviceId, _mode, listener) {
|
||||
sendUpdate = (update) => listener(update, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: {
|
||||
serviceId: Models.id,
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "select", kind: "method" },
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 0,
|
||||
ops: [["r", { selected: null, revision: 0 }]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
activate() {},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
sendUpdate?.({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence,
|
||||
ops: [["r", { selected: null, revision: sequence }]],
|
||||
});
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.message).toContain("sequence has a gap");
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("hydrates cold ReplicatedState replicas and replaces them across rebinds", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
bound: false,
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const revisions: number[] = [];
|
||||
models.state.subscribe((value) => revisions.push(value.revision));
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(revisions).toEqual([]);
|
||||
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
expect(revisions).toEqual([1]);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await namespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
state.state.revision = 3;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(3);
|
||||
expect(revisions).toEqual([1, 2, 3]);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("hydrates keyed state before observe handlers and fences reused keys", async () => {
|
||||
const provider = new RemoteServiceProvider([{ service: QuestionDialogs, mode: "keyed" }]);
|
||||
expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]);
|
||||
const transport = createLoopbackServiceTransport(provider);
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [QuestionDialogs],
|
||||
transport,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const observed: {
|
||||
question: Question | undefined;
|
||||
service: QuestionDialogs;
|
||||
context: Context;
|
||||
}[] = [];
|
||||
const stop = namespace.observe(QuestionDialogs, (service, context) => {
|
||||
observed.push({
|
||||
question: service.request.value,
|
||||
service,
|
||||
context,
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() => expect(errors).toEqual([]));
|
||||
|
||||
const firstRequest = replicatedState<Question>({ question: "First?" });
|
||||
const firstSubmit = vi.fn(async () => ({ accepted: true }));
|
||||
const closeFirst = provider.spawn(QuestionDialogs, "invocation-1", {
|
||||
request: firstRequest,
|
||||
submit: firstSubmit,
|
||||
});
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
expect(observed[0]).toMatchObject({ question: { question: "First?" } });
|
||||
|
||||
const firstService = observed[0]!.service;
|
||||
firstRequest.state.question = "Updated?";
|
||||
firstRequest.publish(BACKGROUND_CONTEXT);
|
||||
expect(firstService.request.value).toEqual({ question: "Updated?" });
|
||||
await expect(firstService.submit("yes", BACKGROUND_CONTEXT)).resolves.toEqual({ accepted: true });
|
||||
expect(firstSubmit).toHaveBeenCalledWith("yes", expect.objectContaining({ abortSignal: undefined }));
|
||||
|
||||
const retainedFirstSubmit = firstService.submit;
|
||||
closeFirst();
|
||||
expect(observed[0]!.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => firstService.request.value).toThrow("observation is closed");
|
||||
expect(() => retainedFirstSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
|
||||
const secondRequest = replicatedState<Question>({ question: "Again?" });
|
||||
const closeSecond = provider.spawn(QuestionDialogs, "invocation-1", {
|
||||
request: secondRequest,
|
||||
async submit() {
|
||||
return { accepted: false };
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(observed[1]).toMatchObject({ question: { question: "Again?" } });
|
||||
expect(Object.is(observed[1]!.service, firstService)).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const secondService = observed[1]!.service;
|
||||
const retainedSecondSubmit = secondService.submit;
|
||||
stop();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => secondService.request.value).toThrow("observation is closed");
|
||||
expect(() => retainedSecondSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
closeSecond();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("rejects mode mixing and unsupported members", () => {
|
||||
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
|
||||
async select() {},
|
||||
});
|
||||
expect(() => provider.spawn(Models, "wrong", {} as Models)).toThrow(/singleton/);
|
||||
expect(() =>
|
||||
provider.spawn(QuestionDialogs, "invalid", {
|
||||
request: new Date() as unknown as ReplicatedState<Question>,
|
||||
async submit() {
|
||||
return { accepted: true };
|
||||
},
|
||||
}),
|
||||
).toThrow(/not remotely exposable/);
|
||||
provider.dispose();
|
||||
});
|
||||
});
|
||||
256
third_party/pi/packages/chord/test/state-diff.test.ts
vendored
Normal file
256
third_party/pi/packages/chord/test/state-diff.test.ts
vendored
Normal file
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyImmutable,
|
||||
assertValidOp,
|
||||
assertValidWireOp,
|
||||
decoder,
|
||||
diffRevisions,
|
||||
encoder,
|
||||
type Op,
|
||||
} from "../src/delta/index.ts";
|
||||
import type { JsonValue } from "../src/json.ts";
|
||||
|
||||
const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => {
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual(expected);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
};
|
||||
|
||||
describe("immutable revision diff", () => {
|
||||
it("emits sets and deletes", () => {
|
||||
expectDiff({ keep: 1, change: 1, remove: true }, { keep: 1, change: 2, add: 3 }, [
|
||||
["s", ["change"], 2],
|
||||
["s", ["add"], 3],
|
||||
["d", ["remove"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits string append and front truncation", () => {
|
||||
expectDiff({ text: "hello" }, { text: "hello world" }, [["a", ["text"], " world"]]);
|
||||
expectDiff({ text: "hello world" }, { text: "world" }, [["t", ["text"], 6]]);
|
||||
expectDiff({ text: "abcdefgh" }, { text: "defghxyz" }, [
|
||||
["t", ["text"], 3],
|
||||
["a", ["text"], "xyz"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents array insertion, removal, and shift with splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b] }, { values: [a, c, b] }, [["p", ["values"], 1, 0, [c]]]);
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c] }, [["p", ["values"], 0, 1, []]]);
|
||||
});
|
||||
|
||||
it("collapses a same-length queue update to two splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
const d = { id: "d" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c, d] }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [d]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a permutation for a pure reorder", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [c, a, b] }, [["m", ["values"], [2, 0, 1]]]);
|
||||
});
|
||||
|
||||
it("normalizes reordered distinct deeply-equal objects to a no-op", () => {
|
||||
const first = { nested: { value: 1 } };
|
||||
const second = { nested: { value: 1 } };
|
||||
expect(first).not.toBe(second);
|
||||
expect(diffRevisions({ values: [first, second] }, { values: [second, first] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates and encodes permutations", () => {
|
||||
const operations: Op[] = [
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", ["values"], [1, 2, 0]],
|
||||
];
|
||||
for (const operation of operations) assertValidOp(operation);
|
||||
const wire = encoder().encode(operations);
|
||||
expect(wire).toEqual([
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", [1, 2, 0]],
|
||||
]);
|
||||
for (const operation of wire) assertValidWireOp(operation);
|
||||
expect(decoder().decode(wire)).toEqual(operations);
|
||||
expect(() => assertValidOp(["m", ["values"], [0, 0]])).toThrow(/bijection/);
|
||||
});
|
||||
|
||||
it("emits nothing for deeply equal reconstructed values", () => {
|
||||
expect(diffRevisions({ value: { nested: [1, 2] } }, { value: { nested: [1, 2] } })).toEqual([]);
|
||||
expect(diffRevisions({ values: [{ id: 1 }, { id: 2 }] }, { values: [{ id: 1 }, { id: 2 }] })).toEqual([]);
|
||||
expect(diffRevisions({ values: [true, true, true] }, { values: [true, true, true] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a leaf edit inside a reconstructed array narrow", () => {
|
||||
expectDiff(
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "two" },
|
||||
],
|
||||
},
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "changed" },
|
||||
],
|
||||
},
|
||||
[["s", ["values", 1, "label"], "changed"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("emits payload-free splices for scattered removals", () => {
|
||||
const [a, b, c, d, e] = ["a", "b", "c", "d", "e"].map((id) => ({ id }));
|
||||
expectDiff({ values: [a, b, c, d, e] }, { values: [a, c, e] }, [
|
||||
["p", ["values"], 1, 1, []],
|
||||
["p", ["values"], 2, 1, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["front", [1, 2, 3, 4], [2, 3, 4], [["p", ["values"], 0, 1, []]]],
|
||||
["tail", [1, 2, 3, 4], [1, 2, 3], [["p", ["values"], 3, 1, []]]],
|
||||
["middle", [1, 2, 3, 4], [1, 3, 4], [["p", ["values"], 1, 1, []]]],
|
||||
["all", [1, 2, 3, 4], [], [["p", ["values"], 0, 4, []]]],
|
||||
["none", [1, 2, 3, 4], [1, 2, 3, 4], []],
|
||||
] as const)("encodes %s removal canonically", (_name, before, after, expected) => {
|
||||
expectDiff({ values: [...before] }, { values: [...after] }, expected);
|
||||
});
|
||||
|
||||
it("does not field-diff unrelated shifted objects with common fields", () => {
|
||||
const before = [1, 2, 3].map((value) => ({ type: "row", value }));
|
||||
const after = [2, 3, 4].map((value) => ({ type: "row", value }));
|
||||
expectDiff({ values: before }, { values: after }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [{ type: "row", value: 4 }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat coincidental id or key fields as structural identity", () => {
|
||||
const left = { value: "left" };
|
||||
const right = { value: "right" };
|
||||
const before = [left, { id: 1, key: "a", value: "first" }, { id: 2, key: "b", value: "second" }, right];
|
||||
const replacements = [
|
||||
{ id: 2, key: "b", value: "edited-second" },
|
||||
{ id: 1, key: "a", value: "edited-first" },
|
||||
];
|
||||
const after = [left, ...replacements, right];
|
||||
expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]);
|
||||
});
|
||||
|
||||
it("combines removals, append, and a shared-subtree survivor edit", () => {
|
||||
const a = { id: "a", stable: {}, detail: { text: "a" } };
|
||||
const b = { id: "b", stable: {}, detail: { text: "b" } };
|
||||
const c = { id: "c", stable: {}, detail: { text: "c" } };
|
||||
const d = { id: "d", stable: {}, detail: { text: "d" } };
|
||||
const changedC = { id: "c", stable: c.stable, detail: { text: "changed" } };
|
||||
const appended = { id: "e", stable: {}, detail: { text: "e" } };
|
||||
expectDiff({ values: [a, b, c, d] }, { values: [a, changedC, d, appended] }, [
|
||||
["p", ["values"], 1, 1, []],
|
||||
["a", ["values", 1, "detail", "text"], "hanged"],
|
||||
["p", ["values"], 3, 0, [appended]],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([256 * 1024, 1024 * 1024])("does not retain a %i-byte removed-neighbor payload", (size) => {
|
||||
const payload = "x".repeat(size);
|
||||
const retained = [
|
||||
{ id: "a", payload },
|
||||
{ id: "b", payload },
|
||||
{ id: "c", payload },
|
||||
{ id: "d", payload },
|
||||
{ id: "e", payload },
|
||||
];
|
||||
const before = Object.freeze({ values: Object.freeze(retained.slice()) });
|
||||
const after = Object.freeze({ values: Object.freeze([retained[0]!, retained[2]!, retained[4]!]) });
|
||||
const operations = diffRevisions(before as unknown as JsonValue, after as unknown as JsonValue);
|
||||
expect(operations).toEqual([
|
||||
["p", ["values"], 1, 1, []],
|
||||
["p", ["values"], 2, 1, []],
|
||||
]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(100);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
expect(before.values).toEqual(retained);
|
||||
});
|
||||
|
||||
it.each([1_001, 10_000])("keeps push, pop, and middle removal narrow at %,i items", (size) => {
|
||||
const values = Array.from({ length: size }, (_, value) => ({ value }));
|
||||
const appended = { value: size };
|
||||
expectDiff({ values }, { values: [...values, appended] }, [["p", ["values"], size, 0, [appended]]]);
|
||||
expectDiff({ values }, { values: values.slice(0, -1) }, [["p", ["values"], size - 1, 1, []]]);
|
||||
const middle = Math.floor(size / 2);
|
||||
expectDiff({ values }, { values: [...values.slice(0, middle), ...values.slice(middle + 1)] }, [
|
||||
["p", ["values"], middle, 1, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps forty-thousand-row sparse edits narrow", () => {
|
||||
const values = Array.from({ length: 40_000 }, (_, value) => ({ value, stable: { value } }));
|
||||
const after = values.slice();
|
||||
const changed: number[] = [];
|
||||
for (let index = 100; index < after.length; index += 400) {
|
||||
after[index] = { value: -index, stable: values[index]!.stable };
|
||||
changed.push(index);
|
||||
}
|
||||
const operations = diffRevisions({ values }, { values: after });
|
||||
expect(operations).toEqual(changed.map((index) => ["s", ["values", index, "value"], -index]));
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(7_500);
|
||||
expect(applyImmutable({ values }, operations)).toEqual({ values: after });
|
||||
});
|
||||
|
||||
it("keeps a reconstructed large-array leaf edit narrow", () => {
|
||||
const before = Array.from({ length: 1_000 }, (_, value) => ({ value, label: `row-${value}` }));
|
||||
const after = structuredClone(before);
|
||||
after[700]!.label = "changed";
|
||||
const operations = diffRevisions({ values: before }, { values: after });
|
||||
expect(operations).toEqual([["s", ["values", 700, "label"], "changed"]]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(100);
|
||||
expect(applyImmutable({ values: before }, operations)).toEqual({ values: after });
|
||||
});
|
||||
|
||||
it("splices an ambiguous equal-count moved-and-edited gap", () => {
|
||||
const left = { value: "left" };
|
||||
const right = { value: "right" };
|
||||
const before = [left, { id: 1, value: "a" }, { id: 2, value: "b" }, right];
|
||||
const replacements = [
|
||||
{ id: 2, value: "edited" },
|
||||
{ id: 1, value: "also-edited" },
|
||||
];
|
||||
const after = [left, ...replacements, right];
|
||||
expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]);
|
||||
});
|
||||
|
||||
it("encodes five hundred unshifts without snapshotting retained rows", () => {
|
||||
const retained = Array.from({ length: 10_000 }, (_, value) => ({ value, payload: "x".repeat(100) }));
|
||||
const inserted = Array.from({ length: 500 }, (_, value) => ({ value: -value - 1 }));
|
||||
const operations = diffRevisions({ values: retained }, { values: [...inserted, ...retained] });
|
||||
expect(operations).toEqual([["p", ["values"], 0, 0, inserted]]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(20_000);
|
||||
expect(applyImmutable({ values: retained }, operations)).toEqual({ values: [...inserted, ...retained] });
|
||||
});
|
||||
|
||||
it("bounds wide-object operation emission with a root replacement", () => {
|
||||
const before: Record<string, JsonValue> = {};
|
||||
const after: Record<string, JsonValue> = {};
|
||||
for (let index = 0; index < 20_000; index++) {
|
||||
before[`field${index}`] = 0;
|
||||
after[`field${index}`] = 1;
|
||||
}
|
||||
expect(diffRevisions(before, after)).toEqual([["r", after]]);
|
||||
});
|
||||
|
||||
it("falls back to a base operation when leaf operations are larger", () => {
|
||||
const before = { values: Array.from({ length: 40_000 }, () => 0) };
|
||||
const after = { values: Array.from({ length: 40_000 }, () => 1) };
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual([["r", after]]);
|
||||
});
|
||||
});
|
||||
117
third_party/pi/packages/chord/test/state-draft.test.ts
vendored
Normal file
117
third_party/pi/packages/chord/test/state-draft.test.ts
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyImmutable, type Draft, type JsonValue, track } from "../src/delta/index.ts";
|
||||
|
||||
describe("transactional overlay drafts", () => {
|
||||
it("copies only changed branches", () => {
|
||||
const tracker = track({
|
||||
changed: { count: 1, sibling: { value: "kept" } },
|
||||
untouched: { value: 2 },
|
||||
});
|
||||
const base = tracker.value;
|
||||
const change = tracker.beginChange();
|
||||
const first = change.state.changed;
|
||||
expect(change.state.changed).toBe(first);
|
||||
change.state.changed.count = 3;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).not.toBe(base);
|
||||
expect(prepared.value.changed).not.toBe(base.changed);
|
||||
expect(prepared.value.changed.sibling).toBe(base.changed.sibling);
|
||||
expect(prepared.value.untouched).toBe(base.untouched);
|
||||
});
|
||||
|
||||
it("supports frozen bases, deletion, and native array mutators", () => {
|
||||
const tracker = track({ optional: "remove" as string | undefined, values: [3, 1, 2] });
|
||||
const change = tracker.beginChange();
|
||||
delete change.state.optional;
|
||||
change.state.values.push(4);
|
||||
expect(change.state.values.pop()).toBe(4);
|
||||
change.state.values.unshift(0);
|
||||
expect(change.state.values.shift()).toBe(0);
|
||||
expect(change.state.values.splice(1, 1, 5, 4)).toEqual([1]);
|
||||
change.state.values.sort((left, right) => left - right);
|
||||
change.state.values.reverse();
|
||||
change.state.values.fill(9, 1, 3);
|
||||
change.state.values.copyWithin(1, 0, 2);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toEqual({ values: [5, 5, 9, 2] });
|
||||
});
|
||||
|
||||
it("copies inserted draft values without aliasing their original reference", () => {
|
||||
const tracker = track({ values: [{ value: 1 }, { value: 2 }] });
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.values[0]!;
|
||||
change.state.values.unshift(held);
|
||||
held.value = 9;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]);
|
||||
expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]);
|
||||
});
|
||||
|
||||
it("keeps comparator edits when sorting object drafts", () => {
|
||||
const tracker = track({
|
||||
rows: [
|
||||
{ rank: 2, comparisons: 0 },
|
||||
{ rank: 1, comparisons: 0 },
|
||||
],
|
||||
});
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows.sort((left, right) => {
|
||||
left.comparisons++;
|
||||
right.comparisons++;
|
||||
return left.rank - right.rank;
|
||||
});
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.rows.map((row) => row.rank)).toEqual([1, 2]);
|
||||
expect(prepared.value.rows.map((row) => row.comparisons)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("drops writes through detached child references", () => {
|
||||
const tracker = track({ child: { value: "removed" }, items: [{ value: "removed" }, { value: "kept" }] });
|
||||
const change = tracker.beginChange();
|
||||
const child = change.state.child;
|
||||
const shifted = change.state.items.shift();
|
||||
delete (change.state as { child?: Draft<{ value: string }>; items: Draft<{ value: string }[]> }).child;
|
||||
child.value = "detached child";
|
||||
if (shifted !== undefined) shifted.value = "detached item";
|
||||
expect(change.prepare().value).toEqual({ items: [{ value: "kept" }] });
|
||||
});
|
||||
|
||||
it.each(["unshift", "splice"] as const)("inserts 100,000 items with %s without argument overflow", (method) => {
|
||||
const tracker = track({ values: [-1] });
|
||||
const change = tracker.beginChange();
|
||||
const items = Array.from({ length: 100_000 }, (_, value) => value);
|
||||
if (method === "unshift") Reflect.apply(change.state.values.unshift, change.state.values, items);
|
||||
else Reflect.apply(change.state.values.splice, change.state.values, [1, 0, ...items]);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toHaveLength(100_001);
|
||||
expect(prepared.value.values[method === "unshift" ? 0 : 1]).toBe(0);
|
||||
expect(prepared.value.values[method === "unshift" ? 99_999 : 100_000]).toBe(99_999);
|
||||
expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value);
|
||||
});
|
||||
|
||||
it("rejects non-strict JSON placements without mutating the draft or base", () => {
|
||||
const tracker = track({ number: 0, payload: null as JsonValue, values: [1, 2] as JsonValue[] });
|
||||
const change = tracker.beginChange();
|
||||
expect(() => {
|
||||
change.state.values[0] = undefined as unknown as JsonValue;
|
||||
}).toThrow(/strict JSON/);
|
||||
expect(() => change.state.values.push({ nested: undefined } as unknown as JsonValue)).toThrow(/strict JSON/);
|
||||
expect(() => {
|
||||
change.state.number = Number.NaN;
|
||||
}).toThrow(/strict JSON/);
|
||||
expect(() => {
|
||||
change.state.payload = new Date() as unknown as JsonValue;
|
||||
}).toThrow(/plain objects/);
|
||||
expect(change.state).toEqual(tracker.value);
|
||||
change.abort();
|
||||
expect(tracker.value).toEqual({ number: 0, payload: null, values: [1, 2] });
|
||||
});
|
||||
|
||||
it("rejects array holes without mutating the committed base", () => {
|
||||
const tracker = track({ values: [1, 2] });
|
||||
const change = tracker.beginChange();
|
||||
expect(() => delete change.state.values[0]).toThrow(/dense|holes/);
|
||||
change.abort();
|
||||
expect(tracker.value.values).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
123
third_party/pi/packages/chord/test/state-fuzz.test.ts
vendored
Normal file
123
third_party/pi/packages/chord/test/state-fuzz.test.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { applyImmutable, type Draft, type Op, track } from "../src/delta/index.ts";
|
||||
|
||||
type Item = { id: number; text: string; score: number };
|
||||
type Document = {
|
||||
items: Item[];
|
||||
text: string;
|
||||
meta: { revision: number; label?: string };
|
||||
};
|
||||
|
||||
type MutableDocument = Document | Draft<Document>;
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
function expectAliasFree(value: unknown): void {
|
||||
const seen = new WeakMap<object, string>();
|
||||
const visit = (current: unknown, path: string): void => {
|
||||
if (current === null || typeof current !== "object") return;
|
||||
const previous = seen.get(current);
|
||||
if (previous !== undefined) throw new Error(`container at ${path} aliases ${previous}`);
|
||||
seen.set(current, path);
|
||||
if (Array.isArray(current)) {
|
||||
for (let index = 0; index < current.length; index++) visit(current[index], `${path}[${index}]`);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(current)) visit((current as Record<string, unknown>)[key], `${path}.${key}`);
|
||||
};
|
||||
visit(value, "$root");
|
||||
}
|
||||
|
||||
const random =
|
||||
(seed: number): (() => number) =>
|
||||
() => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
|
||||
const mutate = (document: MutableDocument, choice: number, value: number): void => {
|
||||
const item = (): Item => ({ id: value, text: `item-${value}`, score: value % 7 });
|
||||
switch (choice) {
|
||||
case 0:
|
||||
document.text += `-${value}`;
|
||||
break;
|
||||
case 1:
|
||||
document.text = `${document.text.slice(Math.min(2, document.text.length))}${value}`;
|
||||
break;
|
||||
case 2:
|
||||
document.items.push(item());
|
||||
break;
|
||||
case 3:
|
||||
document.items.unshift(item());
|
||||
break;
|
||||
case 4:
|
||||
if (document.items.length > 0) document.items.shift();
|
||||
break;
|
||||
case 5:
|
||||
if (document.items.length > 0) document.items.pop();
|
||||
break;
|
||||
case 6: {
|
||||
const index = document.items.length === 0 ? 0 : value % (document.items.length + 1);
|
||||
document.items.splice(index, document.items.length === 0 ? 0 : value % 2, item());
|
||||
break;
|
||||
}
|
||||
case 7:
|
||||
document.items.reverse();
|
||||
break;
|
||||
case 8:
|
||||
document.items.sort((left, right) => left.id - right.id);
|
||||
break;
|
||||
case 9:
|
||||
if (document.items.length > 0) document.items[value % document.items.length]!.score = value;
|
||||
break;
|
||||
case 10:
|
||||
document.meta.revision += 1;
|
||||
document.meta.label = `revision-${value}`;
|
||||
break;
|
||||
case 11:
|
||||
delete document.meta.label;
|
||||
break;
|
||||
case 12:
|
||||
if (document.items.length > 1) document.items[1] = clone(document.items[0]!);
|
||||
break;
|
||||
default:
|
||||
for (let index = 0; index < Math.min(2, document.items.length); index++) document.items[index] = item();
|
||||
}
|
||||
};
|
||||
|
||||
it("converges across randomized prepared revisions", () => {
|
||||
for (let seed = 1; seed <= 100; seed++) {
|
||||
const rng = random(seed);
|
||||
const initial: Document = {
|
||||
items: Array.from({ length: 4 }, (_, id) => ({ id, text: `item-${id}`, score: 0 })),
|
||||
text: "start",
|
||||
meta: { revision: 0 },
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const expected = clone(initial);
|
||||
let replica = clone(tracker.value);
|
||||
for (let step = 0; step < 100; step++) {
|
||||
const choice = Math.floor(rng() * 14);
|
||||
const value = seed * 1_000 + step;
|
||||
const baseRoot = tracker.value;
|
||||
const base = clone(baseRoot);
|
||||
mutate(expected, choice, value);
|
||||
const change = tracker.beginChange();
|
||||
mutate(change.state, choice, value);
|
||||
const prepared = change.prepare();
|
||||
const operations = clone(prepared.ops) as readonly Op[];
|
||||
expect(baseRoot, `prepare base seed ${seed} step ${step} choice ${choice}`).toEqual(base);
|
||||
expect(prepared.base).toBe(baseRoot);
|
||||
replica = applyImmutable(replica, operations);
|
||||
expect(replica, `replay seed ${seed} step ${step} choice ${choice}`).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
expect(baseRoot, `adopt base seed ${seed} step ${step} choice ${choice}`).toEqual(base);
|
||||
expect(tracker.value).toBe(prepared.value);
|
||||
expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected);
|
||||
expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected);
|
||||
expectAliasFree(tracker.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
232
third_party/pi/packages/chord/test/state-init.test.ts
vendored
Normal file
232
third_party/pi/packages/chord/test/state-init.test.ts
vendored
Normal file
@@ -0,0 +1,232 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { type Ctx, createHost, type Facet, isChordError, local, method } from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200 && !check(); attempt++) await tick();
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
function latch<T>() {
|
||||
let open: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const Notes = local("t.notes", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: {
|
||||
notes: Type.Array(Type.String()),
|
||||
count: Type.Number(),
|
||||
has: method(Type.String(), Type.Boolean()),
|
||||
add: method(Type.String()),
|
||||
},
|
||||
});
|
||||
|
||||
const NotesSchema = Type.Array(Type.String());
|
||||
|
||||
describe("state init", () => {
|
||||
it("runs after the resources are open and before the object goes live: nothing sees another value", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const loaded = latch<string[]>();
|
||||
const log: string[] = [];
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
log.push("open");
|
||||
return { load: () => loaded.promise };
|
||||
},
|
||||
});
|
||||
const notes = self.state("notes", NotesSchema, {
|
||||
init: async () => {
|
||||
log.push("init");
|
||||
return await db.value.load();
|
||||
},
|
||||
});
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
self.on("notes", notes, (list) => {
|
||||
log.push(`listener ${list.join(",")}`);
|
||||
});
|
||||
self.task(async () => {
|
||||
log.push(`task ${notes.value.join(",")}`);
|
||||
});
|
||||
return {
|
||||
notes,
|
||||
count,
|
||||
has: (note) => notes.value.includes(note),
|
||||
add: (note, ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(note);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Notes, {});
|
||||
const seen: unknown[] = [];
|
||||
ref.notes.subscribe((value) => seen.push(value));
|
||||
// A call made while init runs waits until the object is live, then reads the loaded value.
|
||||
const has = host.root((ctx) => ref.has("a", ctx));
|
||||
await until(() => log.includes("init"));
|
||||
assert.equal(ref.status, "loading");
|
||||
loaded.open(["a", "b"]);
|
||||
assert.equal(await has, true);
|
||||
assert.equal(ref.count.value, 2);
|
||||
await until(() => log.length === 4);
|
||||
assert.deepEqual(log, ["open", "init", "listener a,b", "task a,b"]);
|
||||
// Before live, a reference's member has no value (undefined); its first value is the loaded one.
|
||||
assert.deepEqual(
|
||||
seen.filter((value) => value !== undefined),
|
||||
[["a", "b"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("an init cell cannot be read inside the factory", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NotesSchema, { init: () => [] });
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
notes.value;
|
||||
return { notes, count, has: () => false, add: () => {} };
|
||||
});
|
||||
await assert.rejects(host.hold(Notes, {}).ready, (error) => isChordError(error) && error.code === "not_ready");
|
||||
});
|
||||
|
||||
it("a throwing or invalid init fails the activation and closes its resources; the next get tries again", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
let result: () => unknown = () => {
|
||||
throw new Error("db down");
|
||||
};
|
||||
const closed: string[] = [];
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
self.resource("db", { version: 1, open: () => "db", close: (db) => void closed.push(db) });
|
||||
const notes = self.state("notes", NotesSchema, { init: () => result() as string[] });
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
return { notes, count, has: () => false, add: () => {} };
|
||||
});
|
||||
await assert.rejects(host.hold(Notes, {}).ready, /db down/);
|
||||
assert.deepEqual(closed, ["db"]);
|
||||
result = () => [1];
|
||||
await assert.rejects(host.hold(Notes, {}).ready, (error) => isChordError(error) && error.code === "invalid");
|
||||
assert.deepEqual(closed, ["db", "db"]);
|
||||
result = () => ["ok"];
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
assert.deepEqual(ref.notes.value, ["ok"]);
|
||||
});
|
||||
|
||||
it("init runs in a root: it can call other objects", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Source = local("t.source", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { all: method(undefined, NotesSchema) },
|
||||
});
|
||||
host.serve(Source, () => ({ all: () => ["from source"] }));
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NotesSchema, {
|
||||
init: async (ctx: Ctx) => (await ctx.get(Source, {})).all(ctx),
|
||||
});
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
return { notes, count, has: () => false, add: () => {} };
|
||||
});
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
assert.deepEqual(ref.notes.value, ["from source"]);
|
||||
});
|
||||
|
||||
describe("reload", () => {
|
||||
const facet = (spec: {
|
||||
readonly inits: string[];
|
||||
readonly extra?: () => string[];
|
||||
readonly countVersion?: number;
|
||||
}): Facet => ({
|
||||
name: "notes",
|
||||
setup: (api) =>
|
||||
api.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NotesSchema, {
|
||||
init: () => {
|
||||
spec.inits.push("notes");
|
||||
return ["loaded"];
|
||||
},
|
||||
});
|
||||
const counter = self.state("counter", Type.Number(), 0, { version: spec.countVersion ?? 1 });
|
||||
const extra = spec.extra;
|
||||
const more =
|
||||
extra === undefined
|
||||
? undefined
|
||||
: self.state("more", NotesSchema, {
|
||||
init: () => {
|
||||
spec.inits.push("more");
|
||||
return extra();
|
||||
},
|
||||
});
|
||||
const count = self.derive(more === undefined ? [notes, counter] : [notes, counter, more], (...lists) =>
|
||||
lists.reduce<number>((sum, list) => sum + (typeof list === "number" ? list : list.length), 0),
|
||||
);
|
||||
return {
|
||||
notes,
|
||||
count,
|
||||
has: (note) => notes.value.includes(note),
|
||||
add: (note, ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(note);
|
||||
});
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
it("keeps an existing cell without running init; a cell the reload adds runs init while it prepares", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const inits: string[] = [];
|
||||
await host.install(facet({ inits }));
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
await host.root((ctx) => ref.add("written", ctx));
|
||||
await host.reload([facet({ inits, extra: () => ["x", "y"] })]);
|
||||
assert.deepEqual(inits, ["notes", "more"]);
|
||||
assert.deepEqual(ref.notes.value, ["loaded", "written"]);
|
||||
// The new derivation counts the kept cell and the added one: 2 + 0 + 2.
|
||||
assert.equal(ref.count.value, 4);
|
||||
});
|
||||
|
||||
it("a failing init of an added cell refuses the reload; the old code keeps running", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const inits: string[] = [];
|
||||
await host.install(facet({ inits }));
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
await assert.rejects(
|
||||
host.reload([
|
||||
facet({
|
||||
inits,
|
||||
extra: () => {
|
||||
throw new Error("cannot load more");
|
||||
},
|
||||
}),
|
||||
]),
|
||||
/cannot load more/,
|
||||
);
|
||||
assert.equal(ref.count.value, 1);
|
||||
assert.equal(await host.root((ctx) => ref.has("loaded", ctx)), true);
|
||||
});
|
||||
|
||||
it("in a gated reload, an added cell runs init at the gate", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const inits: string[] = [];
|
||||
await host.install(facet({ inits }));
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
const report = await host.reload([facet({ inits, countVersion: 2, extra: () => ["x"] })]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(inits, ["notes", "more"]);
|
||||
assert.equal(ref.count.value, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
third_party/pi/packages/chord/test/state-value.test.ts
vendored
Normal file
37
third_party/pi/packages/chord/test/state-value.test.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { track } from "../src/delta/index.ts";
|
||||
|
||||
describe("immutable revision ownership", () => {
|
||||
it("takes ownership of an alias-free mutable JSON root without freezing", () => {
|
||||
const input = { left: { value: 1 }, right: { value: 1 } };
|
||||
const tracker = track(input);
|
||||
expect(tracker.value).toBe(input);
|
||||
expect(tracker.value.left).not.toBe(tracker.value.right);
|
||||
expect(Object.isFrozen(tracker.value)).toBe(false);
|
||||
expect(Object.isFrozen(tracker.value.left)).toBe(false);
|
||||
});
|
||||
|
||||
it("commits transaction copies in place while sharing unchanged branches", () => {
|
||||
const tracker = track({ changed: { value: 1 }, retained: { value: 2 } });
|
||||
const base = tracker.value;
|
||||
const baseSnapshot = structuredClone(base);
|
||||
const change = tracker.beginChange();
|
||||
change.state.changed.value = 3;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.changed).not.toBe(base.changed);
|
||||
expect(prepared.value.retained).toBe(base.retained);
|
||||
expect(Object.isFrozen(prepared.value.changed)).toBe(false);
|
||||
tracker.adopt(prepared);
|
||||
expect(base).toEqual(baseSnapshot);
|
||||
expect(tracker.value).toBe(prepared.value);
|
||||
});
|
||||
|
||||
it("makes repeated placements independent", () => {
|
||||
const tracker = track({ values: [] as { value: number }[] });
|
||||
const change = tracker.beginChange();
|
||||
const shared = { value: 1 };
|
||||
change.state.values.push(shared, shared);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]);
|
||||
});
|
||||
});
|
||||
260
third_party/pi/packages/chord/test/state.test.ts
vendored
Normal file
260
third_party/pi/packages/chord/test/state.test.ts
vendored
Normal file
@@ -0,0 +1,260 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { applyImmutable } from "../src/delta/index.ts";
|
||||
import { type Ctx, createHost, isChordError, local, method, type StateEvent } from "../src/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
function emitter<E>() {
|
||||
const listeners = new Set<(event: E) => void>();
|
||||
return {
|
||||
emit: (event: E) => {
|
||||
for (const fn of listeners) fn(event);
|
||||
},
|
||||
subscribe: (fn: (event: E) => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Item = Type.Object({ id: Type.Number(), label: Type.String() });
|
||||
const Items = Type.Object({ items: Type.Array(Item), count: Type.Number() });
|
||||
|
||||
const List = local("t.list", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: {
|
||||
list: Items,
|
||||
labels: Type.Array(Type.String()),
|
||||
push: method(Item),
|
||||
set: method(Items),
|
||||
},
|
||||
});
|
||||
|
||||
function serveList(host: ReturnType<typeof createHost>) {
|
||||
host.serve(List, (_input, _principal, self) => {
|
||||
const listState = self.state("list", Items, { items: [], count: 0 });
|
||||
const labels = self.derive([listState], (list) => list.items.map((item) => item.label));
|
||||
return {
|
||||
list: listState,
|
||||
labels,
|
||||
push: (item: { id: number; label: string }, ctx: Ctx) => {
|
||||
listState.change(ctx, (draft) => {
|
||||
draft.items.push(item);
|
||||
draft.count += 1;
|
||||
});
|
||||
},
|
||||
set: (value: { items: { id: number; label: string }[]; count: number }, ctx: Ctx) => {
|
||||
listState.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("state cells", () => {
|
||||
it("publishes snapshots then exact ops; derivations follow", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
serveList(host);
|
||||
const ref = host.hold(List, {});
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
const labels: StateEvent<unknown>[] = [];
|
||||
ref.list.events((event) => events.push(event));
|
||||
ref.labels.events((event) => labels.push(event));
|
||||
await ref.push({ id: 1, label: "a" });
|
||||
// Subscribed while loading: the snapshot once live, then exact ops.
|
||||
assert.deepEqual(events[0], { type: "snap", value: { items: [], count: 0 } });
|
||||
const ops = events[1]?.type === "ops" ? events[1].ops.map((op) => JSON.stringify(op)).sort() : [];
|
||||
assert.deepEqual(ops, ['["p",["items"],0,0,[{"id":1,"label":"a"}]]', '["s",["count"],1]']);
|
||||
assert.deepEqual(events[1]?.value, { items: [{ id: 1, label: "a" }], count: 1 });
|
||||
assert.deepEqual(ref.labels.value, ["a"]);
|
||||
assert.deepEqual(
|
||||
labels.map((event) => event.type),
|
||||
["snap", "ops"],
|
||||
);
|
||||
await ref.set({ items: [], count: 0 });
|
||||
assert.deepEqual(ref.labels.value, []);
|
||||
});
|
||||
|
||||
it("supports primitive cells and value-returning changes", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Counter = local("t.prim", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { n: Type.Number(), inc: method(undefined, Type.Number()) },
|
||||
});
|
||||
host.serve(Counter, (_input, _principal, self) => {
|
||||
const nState = self.state("n", Type.Number(), 0);
|
||||
return {
|
||||
n: nState,
|
||||
inc: (ctx: Ctx) => {
|
||||
nState.change(ctx, (n) => n + 1);
|
||||
return nState.value;
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
await ref.ready;
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
ref.n.events((event) => events.push(event));
|
||||
ref.n.subscribe((value) => values.push(value));
|
||||
assert.equal(await ref.inc(), 1);
|
||||
assert.equal(await ref.inc(), 2);
|
||||
assert.deepEqual(values, [0, 1, 2]);
|
||||
assert.deepEqual(events, [
|
||||
{ type: "snap", value: 0 },
|
||||
{ type: "ops", ops: [["r", 1]], value: 1 },
|
||||
{ type: "ops", ops: [["r", 2]], value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a returned value that does not fit the schema, even outside dev mode", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Box = local("t.box", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { put: method() },
|
||||
});
|
||||
host.serve(Box, (_input, _principal, self) => {
|
||||
const list = self.state("list", Type.Array(Type.Number()), []);
|
||||
return {
|
||||
put: (ctx: Ctx) => {
|
||||
// The classic slip: an expression-bodied arrow returns push()'s length.
|
||||
list.change(ctx, (draft) => draft.push(1) as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
await assert.rejects(host.hold(Box, {}).put(), (error) => isChordError(error, "invalid"));
|
||||
});
|
||||
|
||||
it("validates initial values always and writes only in dev mode", async () => {
|
||||
const Bad = local("t.bad-initial", { version: 1, input: Type.Object({}) });
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Bad, (_input, _principal, self) => {
|
||||
self.state("x", Type.Object({ n: Type.Number() }), { n: "no" } as never);
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Bad, {}).ready, /initial value of state x/);
|
||||
|
||||
const lax = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
serveList(lax);
|
||||
await lax.hold(List, {}).set({ items: [], count: "x" } as never);
|
||||
|
||||
const dev = createHost({ self: { kind: "host" }, dev: true, onError: () => {} });
|
||||
serveList(dev);
|
||||
const ref = dev.hold(List, {});
|
||||
await assert.rejects(
|
||||
ref.set({ items: [], count: "x" } as never),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "state",
|
||||
);
|
||||
assert.deepEqual(ref.list.value, { items: [], count: 0 });
|
||||
});
|
||||
|
||||
it("rejects writes from inside a factory and placeholder use before activation", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Writer = local("t.writer", { version: 1, input: Type.Object({}) });
|
||||
let captured: Ctx | undefined;
|
||||
await host.root((ctx) => {
|
||||
captured = ctx;
|
||||
});
|
||||
host.serve(Writer, (_input, _principal, self) => {
|
||||
const x = self.state("x", Type.Object({}), {});
|
||||
const r = self.resource("r", { version: 1, open: () => 1 });
|
||||
assert.throws(
|
||||
() => r.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
x.replace(captured!, {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Writer, {}).ready, /read_only/);
|
||||
});
|
||||
|
||||
it("listeners run each event in a fresh root; tasks get an abortable ctx", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const events = emitter<number>();
|
||||
const Watcher = local("t.watcher", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { seen: Type.Object({ sum: Type.Number() }) },
|
||||
});
|
||||
const roots = new Set<string>();
|
||||
let taskAborted = false;
|
||||
host.serve(Watcher, (_input, _principal, self) => {
|
||||
const seen = self.state("seen", Type.Object({ sum: Type.Number() }), { sum: 0 });
|
||||
const sourceResource = self.resource("source", { version: 1, open: () => events });
|
||||
self.on(
|
||||
"onEvent",
|
||||
sourceResource,
|
||||
(source) => source,
|
||||
(value, ctx) => {
|
||||
roots.add(ctx.rootId);
|
||||
seen.change(ctx, (draft) => {
|
||||
draft.sum += value;
|
||||
});
|
||||
},
|
||||
);
|
||||
self.task(async (ctx) => {
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
taskAborted = true;
|
||||
});
|
||||
return { seen };
|
||||
});
|
||||
const ref = host.hold(Watcher, {});
|
||||
await ref.ready;
|
||||
events.emit(2);
|
||||
events.emit(3);
|
||||
await tick();
|
||||
assert.deepEqual(ref.seen.value, { sum: 5 });
|
||||
assert.equal(roots.size, 2);
|
||||
ref.release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(events.size, 0);
|
||||
assert.ok(taskAborted);
|
||||
});
|
||||
|
||||
it("re-entrant writes publish in adoption order; derivations never go stale (Sol #3)", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Echo = local("t.reentrant", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { n: Type.Number(), d: Type.Number(), set: method(Type.Number()) },
|
||||
});
|
||||
host.serve(Echo, (_input, _principal, self) => {
|
||||
const nState = self.state("n", Type.Number(), 0);
|
||||
const d = self.derive([nState], (n) => n * 2);
|
||||
// Subscribed first: re-enters the write while revision 1 is being published.
|
||||
self.on("bump", nState, (n, ctx) => {
|
||||
if (n === 1) nState.replace(ctx, 2);
|
||||
});
|
||||
return {
|
||||
n: nState,
|
||||
d,
|
||||
set: (value: number, ctx: Ctx) => {
|
||||
nState.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Echo, {});
|
||||
await ref.ready;
|
||||
const values: unknown[] = [];
|
||||
let replica: unknown;
|
||||
ref.n.events((event) => {
|
||||
replica = event.type === "ops" ? applyImmutable(replica, event.ops) : event.value;
|
||||
values.push(replica);
|
||||
});
|
||||
const doubled: unknown[] = [];
|
||||
ref.d.subscribe((value) => doubled.push(value));
|
||||
await ref.set(1);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(values, [0, 1, 2]);
|
||||
assert.equal(ref.d.value, 4);
|
||||
assert.deepEqual(doubled, [0, 2, 4]);
|
||||
});
|
||||
});
|
||||
517
third_party/pi/packages/chord/test/tutorial.test.ts
vendored
Normal file
517
third_party/pi/packages/chord/test/tutorial.test.ts
vendored
Normal file
@@ -0,0 +1,517 @@
|
||||
// example:imports
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { it, onTestFinished } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
type FacetApi,
|
||||
type Factory,
|
||||
forbidden,
|
||||
type Host,
|
||||
hostNamed,
|
||||
invalid,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
opaque,
|
||||
remote,
|
||||
type StateOptions,
|
||||
} from "../src/index.ts";
|
||||
|
||||
// end-example:imports
|
||||
|
||||
// Compare every printed example with its runnable source, not a second handwritten copy.
|
||||
const tutorial = readFileSync(new URL("../docs/tutorial.md", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL(import.meta.url), "utf8");
|
||||
const examples = [...source.matchAll(/^[\t ]*\/\/ example:([\w-]+)\n([\s\S]*?)^[\t ]*\/\/ end-example:\1$/gm)];
|
||||
const printed = [...tutorial.matchAll(/<!-- example:([\w-]+) -->\n```ts\n([\s\S]*?)\n```/g)];
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
function checkExamples(section: string): void {
|
||||
assert.equal(printed.length, examples.length, "every tutorial example must have runnable source");
|
||||
assert.equal((tutorial.match(/```ts\n/g) ?? []).length, printed.length, "no untracked TypeScript examples");
|
||||
for (const match of examples.filter((entry) => entry[1] === "imports" || entry[1]?.startsWith(section))) {
|
||||
const copies = printed.filter((entry) => entry[1] === match[1]);
|
||||
assert.equal(copies.length, 1, `one printed copy of ${match[1]}`);
|
||||
assert.equal(normalize(copies[0]![2]!), normalize(match[2]!), `tutorial drift: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// example:02-contract
|
||||
const TodoLabel = local("tutorial.todos", {
|
||||
version: 1,
|
||||
input: Type.Object({ list: Type.String() }),
|
||||
members: { label: method(undefined, Type.String()) },
|
||||
});
|
||||
// end-example:02-contract
|
||||
|
||||
// example:03-contract
|
||||
const Todo = Type.Object({ text: Type.String(), done: Type.Boolean() });
|
||||
const Rows = Type.Array(Todo);
|
||||
const TodoStore = local("tutorial.todos", {
|
||||
version: 1,
|
||||
input: Type.Object({ list: Type.String() }),
|
||||
members: {
|
||||
label: method(undefined, Type.String()),
|
||||
rows: Rows,
|
||||
openCount: Type.Number(),
|
||||
add: method(Type.Object({ text: Type.String() })),
|
||||
finish: method(Type.Object({ index: Type.Integer() })),
|
||||
reset: method(Rows),
|
||||
},
|
||||
});
|
||||
// end-example:03-contract
|
||||
|
||||
// example:03-factory
|
||||
/** The store's factory; `options` versions its rows (section 9). */
|
||||
const todoStore =
|
||||
(options?: StateOptions<Static<typeof Rows>>): Factory<typeof TodoStore> =>
|
||||
({ list }, _principal, self) => {
|
||||
const rows = self.state("rows", Rows, [], options);
|
||||
const openCount = self.derive([rows], (todos) => todos.filter((todo) => !todo.done).length);
|
||||
return {
|
||||
label: () => `Todos for ${list}`,
|
||||
rows,
|
||||
openCount,
|
||||
add: ({ text }, ctx) => {
|
||||
rows.change(ctx, (draft) => {
|
||||
draft.push({ text, done: false });
|
||||
});
|
||||
},
|
||||
finish: ({ index }, ctx) => {
|
||||
rows.change(ctx, (draft) => {
|
||||
const todo = draft[index];
|
||||
if (!todo) throw invalid("args", "No such todo");
|
||||
todo.done = true;
|
||||
});
|
||||
},
|
||||
reset: (next, ctx) => {
|
||||
rows.replace(ctx, next, { diff: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
const todoFactory = todoStore();
|
||||
// end-example:03-factory
|
||||
|
||||
// example:05-views
|
||||
const Todos = remote("tutorial.todo-view", {
|
||||
version: 1,
|
||||
input: Type.Object({ list: Type.String() }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
viewer: { state: { rows: Rows } },
|
||||
editor: {
|
||||
state: { rows: Rows },
|
||||
methods: { add: method(Type.Object({ text: Type.String() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Who may ask: the owner, a guest, or the server's own code. */
|
||||
type Principal = "owner" | "guest" | "server";
|
||||
|
||||
function serveViews(api: Pick<Host<Principal>, "serve">): void {
|
||||
api.serve(Todos, ({ list }, principal, self) => {
|
||||
if (list !== "family") throw forbidden("This list is not shared");
|
||||
const store = self.get(TodoStore, { list });
|
||||
if (principal === "guest") {
|
||||
return { variant: "viewer", rows: store.rows };
|
||||
}
|
||||
return {
|
||||
variant: "editor",
|
||||
rows: store.rows,
|
||||
add: ({ text }, ctx) => {
|
||||
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
|
||||
return store.add({ text }, ctx);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
// end-example:05-views
|
||||
|
||||
it("01: objects live inside hosts", { timeout: 5000 }, async () => {
|
||||
checkExamples("01");
|
||||
// example:01
|
||||
const host = createHost({ self: "server", name: "todo-server" });
|
||||
assert.equal(host.name, "todo-server");
|
||||
await host.close();
|
||||
// end-example:01
|
||||
});
|
||||
|
||||
it("02: first contract, factory, reference and root", { timeout: 5000 }, async () => {
|
||||
checkExamples("02");
|
||||
onTestFinished(() => host.close());
|
||||
// example:02-call
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` }));
|
||||
const result = await host.root(async (ctx) => {
|
||||
const todos = await ctx.get(TodoLabel, { list: "family" });
|
||||
return todos.label(ctx);
|
||||
});
|
||||
assert.equal(result, "Todos for family");
|
||||
// end-example:02-call
|
||||
});
|
||||
|
||||
it("03: cells publish values and derivations", { timeout: 5000 }, async () => {
|
||||
checkExamples("03");
|
||||
onTestFinished(() => host.close());
|
||||
// example:03-call
|
||||
const host = createHost({ self: "server", dev: true });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const todos = await host.get(TodoStore, { list: "family" });
|
||||
const counts: (number | undefined)[] = [];
|
||||
const stop = todos.openCount.subscribe((value) => counts.push(value));
|
||||
await host.root(async (ctx) => {
|
||||
await todos.add({ text: "Buy milk" }, ctx);
|
||||
await todos.finish({ index: 0 }, ctx);
|
||||
await todos.reset([], ctx);
|
||||
});
|
||||
assert.deepEqual(counts, [0, 1, 0]);
|
||||
assert.deepEqual(todos.rows.value, []);
|
||||
stop();
|
||||
todos.release();
|
||||
// end-example:03-call
|
||||
});
|
||||
|
||||
it("04: holds, asynchronous resources and release", { timeout: 5000 }, async () => {
|
||||
checkExamples("04");
|
||||
onTestFinished(() => host.close());
|
||||
// example:04-database
|
||||
const Database = local("tutorial.database", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { db: opaque<{ saved: string[] }>("tutorial.database@1") },
|
||||
});
|
||||
const SavedTodos = local("tutorial.saved-todos", {
|
||||
version: 1,
|
||||
input: Type.Object({ list: Type.String() }),
|
||||
members: { add: method(Type.Object({ text: Type.String() })) },
|
||||
});
|
||||
const host = createHost({ self: "server" });
|
||||
let opens = 0;
|
||||
const closed = Promise.withResolvers<void>();
|
||||
host.serve(Database, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
opens++;
|
||||
return { saved: [] as string[] };
|
||||
},
|
||||
close: () => closed.resolve(),
|
||||
});
|
||||
return { db: self.expose(db) };
|
||||
});
|
||||
// end-example:04-database
|
||||
// example:04-saving
|
||||
host.serve(TodoStore, todoFactory);
|
||||
host.serve(
|
||||
SavedTodos,
|
||||
({ list }, _principal, self) => {
|
||||
const database = self.get(Database, {});
|
||||
const todos = self.get(TodoStore, { list });
|
||||
return {
|
||||
add: async ({ text }, ctx) => {
|
||||
database.db!.saved.push(text);
|
||||
await todos.add({ text }, ctx);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ linger: 0 },
|
||||
);
|
||||
// end-example:04-saving
|
||||
// example:04-call
|
||||
const saved = await host.get(SavedTodos, { list: "family" });
|
||||
const database = await host.get(Database, {});
|
||||
await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx));
|
||||
assert.deepEqual(database.db!.saved, ["Buy milk"]);
|
||||
assert.equal(opens, 1);
|
||||
const statuses: string[] = [];
|
||||
saved.onStatus((status) => statuses.push(status));
|
||||
database.release();
|
||||
saved.release();
|
||||
await closed.promise;
|
||||
assert.equal(saved.status, "released");
|
||||
assert.ok(statuses.includes("released"));
|
||||
// end-example:04-call
|
||||
});
|
||||
|
||||
it("05: principals select views of one shared store", { timeout: 5000 }, async () => {
|
||||
checkExamples("05");
|
||||
onTestFinished(() => host.close());
|
||||
// example:05-call
|
||||
const host = createHost<Principal>({ name: "server", self: "server", principalKey: (principal) => principal });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
serveViews(host);
|
||||
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
const guest = await host.get(Todos, { list: "family" }, { principal: "guest" });
|
||||
assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant
|
||||
assert.equal(guest.variant, "viewer");
|
||||
await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" });
|
||||
assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider.
|
||||
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
|
||||
await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid"));
|
||||
// end-example:05-call
|
||||
});
|
||||
|
||||
it("06: remote methods and live state over a memory link", { timeout: 5000 }, async () => {
|
||||
checkExamples("06");
|
||||
onTestFinished(() => server.close());
|
||||
onTestFinished(() => browser.close());
|
||||
// example:06
|
||||
const server = createHost<Principal>({ self: "server", name: "server" });
|
||||
server.serve(TodoStore, todoFactory);
|
||||
serveViews(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: "owner", kind: "client" });
|
||||
const browser = createHost({
|
||||
self: "browser",
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: "server" },
|
||||
});
|
||||
const todos = await browser.get(Todos, { list: "family" });
|
||||
assert.ok(todos.variant === "editor");
|
||||
assert.deepEqual(todos.rows.value, []);
|
||||
const updated = Promise.withResolvers<void>();
|
||||
const stop = todos.rows.subscribe((rows) => {
|
||||
if (rows.length === 1) updated.resolve();
|
||||
});
|
||||
await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));
|
||||
await updated.promise;
|
||||
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
// Deliberately bypass TypeScript to simulate a malformed caller.
|
||||
await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid"));
|
||||
stop();
|
||||
// A held reference keeps its state without listeners.
|
||||
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
todos.release();
|
||||
// end-example:06
|
||||
});
|
||||
|
||||
it("07: listeners react and tasks start fresh calls", { timeout: 5000 }, async () => {
|
||||
checkExamples("07");
|
||||
onTestFinished(() => host.close());
|
||||
// example:07
|
||||
const Activity = local("tutorial.activity", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
members: { seen: Type.Number() },
|
||||
});
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const observed = Promise.withResolvers<void>();
|
||||
const seeded = Promise.withResolvers<void>();
|
||||
const stopped = Promise.withResolvers<void>();
|
||||
host.serve(Activity, (_input, _principal, self) => {
|
||||
const todos = self.get(TodoStore, { list: "family" });
|
||||
const seen = self.state("seen", Type.Number(), 0);
|
||||
self.on("rows", todos.rows, (rows, ctx) => {
|
||||
seen.replace(ctx, rows?.length ?? 0);
|
||||
if (rows?.length === 1) observed.resolve();
|
||||
});
|
||||
self.task(async (ctx) => {
|
||||
await todos.add({ text: "Review the list" }, ctx);
|
||||
seeded.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (ctx.signal.aborted) resolve();
|
||||
else ctx.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
stopped.resolve();
|
||||
});
|
||||
return { seen };
|
||||
});
|
||||
const activity = await host.get(Activity, {});
|
||||
await Promise.all([seeded.promise, observed.promise]);
|
||||
assert.equal(activity.seen.value, 1);
|
||||
activity.release();
|
||||
await stopped.promise;
|
||||
// end-example:07
|
||||
});
|
||||
|
||||
it("08: plugins contribute registry entries and expose callbacks", { timeout: 5000 }, async () => {
|
||||
checkExamples("08");
|
||||
onTestFinished(() => host.close());
|
||||
// example:08
|
||||
const Actions = defineRegistry<(ctx: Ctx) => Promise<number>>("tutorial.actions");
|
||||
const Menu = local("tutorial.menu", { version: 1, input: Type.Object({}) });
|
||||
let click: (() => Promise<number>) | undefined;
|
||||
const storeFacet: Facet = {
|
||||
name: "todo-store",
|
||||
...defineFacet((api) => api.serve(TodoStore, todoFactory)),
|
||||
};
|
||||
const menuFacet: Facet = {
|
||||
name: "todo-menu",
|
||||
...defineFacet(
|
||||
(api) => {
|
||||
api.serve(
|
||||
Menu,
|
||||
(_input, _principal, self) => {
|
||||
const todos = self.get(TodoStore, { list: "family" });
|
||||
self.register(Actions, {
|
||||
addSample: async (ctx) => {
|
||||
await todos.add({ text: "Try plugins" }, ctx);
|
||||
return todos.openCount.value;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
|
||||
},
|
||||
{ uses: [TodoStore] },
|
||||
),
|
||||
};
|
||||
const host = createHost({ self: "server" });
|
||||
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
|
||||
await host.ready();
|
||||
assert.equal(await click!(), 1);
|
||||
assert.equal(host.registry(Actions).table().size, 1);
|
||||
// end-example:08
|
||||
});
|
||||
|
||||
it("09: compatible reload, migration and an old-contract adapter", { timeout: 5000 }, async () => {
|
||||
checkExamples("09");
|
||||
onTestFinished(() => host.close());
|
||||
// example:09-compatible
|
||||
const host = createHost({ self: "server" });
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(TodoStore, (input, principal, self) => ({
|
||||
...todoFactory(input, principal, self),
|
||||
label: () => label,
|
||||
})),
|
||||
});
|
||||
await host.load([facet("Original")]);
|
||||
const todos = await host.get(TodoStore, { list: "family" });
|
||||
await todos.add({ text: "Buy milk" });
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const oldRoot = host.root(async (ctx) => {
|
||||
const first = await todos.label(ctx);
|
||||
entered.resolve();
|
||||
await resume.promise; // Test-only pause: pin an old caller during reload.
|
||||
return [first, await todos.label(ctx)];
|
||||
});
|
||||
await entered.promise;
|
||||
const compatible = await host.reload([facet("Updated")]);
|
||||
assert.deepEqual(compatible.gated, []);
|
||||
assert.equal(await todos.label(), "Updated");
|
||||
resume.resolve();
|
||||
assert.deepEqual(await oldRoot, ["Original", "Original"]);
|
||||
assert.equal(todos.rows.value?.[0]?.text, "Buy milk");
|
||||
// end-example:09-compatible
|
||||
// example:09-migrate
|
||||
const events: string[] = [];
|
||||
const stopEvents = todos.rows.events((event) => events.push(event.type));
|
||||
const migrated = await host.reload([
|
||||
{
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
TodoStore,
|
||||
todoStore({
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
assert.equal(from, 1);
|
||||
return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
]);
|
||||
assert.equal(migrated.gated.length, 1);
|
||||
assert.deepEqual(todos.rows.value, [{ text: "BUY MILK", done: false }]);
|
||||
assert.deepEqual(events, ["snap", "reset"]);
|
||||
stopEvents();
|
||||
// end-example:09-migrate
|
||||
// example:09-contract
|
||||
// Remove label from the new provider's contract: it is now called heading.
|
||||
const { label: _label, ...members } = TodoStore.def.members;
|
||||
const RenamedStore = local("tutorial.todos", {
|
||||
version: 2,
|
||||
input: TodoStore.def.input,
|
||||
members: { ...members, heading: method(undefined, Type.String()) },
|
||||
});
|
||||
await host.reload([
|
||||
{
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
RenamedStore,
|
||||
(input, principal, self) => {
|
||||
const { label, ...implementation } = todoStore({ version: 2 })(input, principal, self);
|
||||
return { ...implementation, heading: label };
|
||||
},
|
||||
{
|
||||
accepts: {
|
||||
1: (current) => ({ ...current, label: current.heading }),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
]);
|
||||
assert.equal(await todos.label(), "Todos for family");
|
||||
const newer = await host.get(RenamedStore, { list: "family" });
|
||||
assert.equal(await newer.heading(), "Todos for family");
|
||||
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");
|
||||
// end-example:09-contract
|
||||
});
|
||||
|
||||
it("10: stub a provider with a fake facet and check error codes", { timeout: 5000 }, async () => {
|
||||
checkExamples("10");
|
||||
onTestFinished(() => host.close());
|
||||
// example:10
|
||||
const host = createHost<Principal>({ name: "server", self: "server", dev: true });
|
||||
const received: string[] = [];
|
||||
const fake: Facet = {
|
||||
name: "fake-store",
|
||||
...defineFacet((api: FacetApi) => {
|
||||
api.serve(TodoStore, (input, principal, self) => ({
|
||||
...todoFactory(input, principal, self),
|
||||
add: ({ text }) => {
|
||||
received.push(text);
|
||||
},
|
||||
}));
|
||||
}),
|
||||
};
|
||||
await host.load([fake]);
|
||||
serveViews(host);
|
||||
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
assert.ok(owner.variant === "editor");
|
||||
await owner.add({ text: "Test delegation" });
|
||||
assert.deepEqual(received, ["Test delegation"]);
|
||||
await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid"));
|
||||
owner.release();
|
||||
await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(error, "released"));
|
||||
// end-example:10
|
||||
});
|
||||
|
||||
it("11: short roots release temporary holds", { timeout: 5000 }, async () => {
|
||||
checkExamples("11");
|
||||
onTestFinished(() => host.close());
|
||||
// example:11
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const screen = await host.get(TodoStore, { list: "family" });
|
||||
await host.root(async (ctx) => {
|
||||
const temporary = await ctx.get(TodoStore, { list: "family" });
|
||||
await temporary.add({ text: "Keep roots short" }, ctx);
|
||||
}); // The temporary reference is released here; screen still holds the store.
|
||||
assert.equal(screen.openCount.value, 1);
|
||||
screen.release(); // No reference remains; without persistence, disposal loses these rows.
|
||||
// end-example:11
|
||||
});
|
||||
41
third_party/pi/packages/chord/test/types.test.ts
vendored
Normal file
41
third_party/pi/packages/chord/test/types.test.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
// Entry 8: reference types. State members are plain values (a reference you hold has its state); a remote reference is a
|
||||
// union discriminated by `variant`, so another variant's members are reachable only after narrowing. Checked by tsc.
|
||||
import { Type } from "typebox";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import { type Ctx, type Host, hostNamed, local, method, type Readable, type Ref, remote } from "../src/index.ts";
|
||||
|
||||
const Doc = remote("t.e8-types-doc", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
reader: { state: { text: Type.String() } },
|
||||
editor: { state: { text: Type.String() }, methods: { edit: method(Type.String()) } },
|
||||
},
|
||||
});
|
||||
const Single = remote("t.e8-types-single", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { n: Type.Number() }, methods: { bump: method() } } },
|
||||
});
|
||||
const Local = local("t.e8-types-local", { version: 1, input: Type.Object({}), members: { n: Type.Number() } });
|
||||
|
||||
describe("reference types", () => {
|
||||
it("state members are T; other variants' members need narrowing; gets are awaited", () => {
|
||||
const check = async (host: Host, ctx: Ctx) => {
|
||||
const doc = await host.get(Doc, {});
|
||||
expectTypeOf(doc.text).toEqualTypeOf<Readable<string>>();
|
||||
// @ts-expect-error `edit` exists only in the editor variant.
|
||||
void doc.edit;
|
||||
if (doc.variant === "editor") expectTypeOf(doc.edit).toBeFunction();
|
||||
const single = await ctx.get(Single, {});
|
||||
expectTypeOf(single.n.value).toEqualTypeOf<number>();
|
||||
expectTypeOf(single.bump).toBeFunction();
|
||||
const held = host.hold(Local, {});
|
||||
expectTypeOf(held.n).toEqualTypeOf<Readable<number>>();
|
||||
expectTypeOf(host.get(Single, {})).toEqualTypeOf<Promise<Ref<typeof Single>>>();
|
||||
};
|
||||
expectTypeOf(check).toBeFunction();
|
||||
});
|
||||
});
|
||||
168
third_party/pi/packages/chord/test/variants.test.ts
vendored
Normal file
168
third_party/pi/packages/chord/test/variants.test.ts
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
// Entry 8: a binding applies a shape only once its new state members have snapshots; members outside the variant
|
||||
// throw `not_in_variant`; a re-hold after a link loss keeps the last values readable and sends one fresh unit.
|
||||
import assert from "node:assert/strict";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Facet,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
type Readable,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Doc = remote("t.e8-doc", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
a: { state: { x: Type.Number() }, methods: { bump: method() } },
|
||||
b: { state: { x: Type.Number(), y: Type.String() }, methods: { bump: method() } },
|
||||
c: { state: { y: Type.String() }, methods: { bump: method() } },
|
||||
},
|
||||
});
|
||||
|
||||
/** The server's facet: `Doc` in variant `variant` (the cells survive reloads by name). */
|
||||
function docFacet(variant: "a" | "b" | "c"): Facet {
|
||||
return {
|
||||
name: "doc",
|
||||
setup: (api) =>
|
||||
api.serve(Doc, (_input, _principal, self) => {
|
||||
const x = self.state("x", Type.Number(), 1);
|
||||
const y = self.state("y", Type.String(), "why");
|
||||
const bump = (ctx: Ctx) => {
|
||||
x.replace(ctx, x.value + 1);
|
||||
};
|
||||
if (variant === "a") return { variant, x, bump };
|
||||
if (variant === "b") return { variant, x, y, bump };
|
||||
return { variant, y, bump };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A reference whose variant changes under the test: its members, typed loosely (TypeScript narrows only once). */
|
||||
type View = { readonly variant: string | undefined; readonly x: Readable<number>; readonly y: Readable<string> };
|
||||
const view = (ref: object): View => ref as unknown as View;
|
||||
|
||||
function setup() {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up);
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { server, browser, link };
|
||||
}
|
||||
|
||||
describe("variants", () => {
|
||||
it("a new variant's members arrive with its shape; a member outside the variant throws not_in_variant", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
await server.install(docFacet("a"));
|
||||
const doc = browser.hold(Doc, {});
|
||||
await doc.ready;
|
||||
assert.equal(view(doc).variant, "a");
|
||||
assert.equal(view(doc).x.value, 1);
|
||||
assert.throws(
|
||||
() => view(doc).y.value,
|
||||
(error) => isChordError(error, "not_in_variant"),
|
||||
);
|
||||
// Every shape the reference shows is readable in full, at the moment it is shown.
|
||||
const failures: unknown[] = [];
|
||||
doc.onShape((shape) => {
|
||||
for (const name of ["x", "y"]) {
|
||||
const member = Doc.description.variants[shape.variant ?? ""]?.[name];
|
||||
if (member?.kind !== "state") continue;
|
||||
try {
|
||||
void (view(doc) as unknown as Record<string, Readable<unknown>>)[name]!.value;
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
const before = stateFrames(link.received).length;
|
||||
await server.reload([docFacet("b")]);
|
||||
await until(() => view(doc).variant === "b");
|
||||
assert.equal(view(doc).y.value, "why");
|
||||
assert.equal(view(doc).x.value, 1);
|
||||
const unit = stateFrames(link.received).slice(before);
|
||||
assert.deepEqual(
|
||||
unit.filter((frame) => frame.as === "snap").map((frame) => frame.m),
|
||||
["y"],
|
||||
"only the new member's snapshot",
|
||||
);
|
||||
await server.reload([docFacet("c")]);
|
||||
await until(() => view(doc).variant === "c");
|
||||
assert.throws(
|
||||
() => view(doc).x.value,
|
||||
(error) => isChordError(error, "not_in_variant"),
|
||||
);
|
||||
assert.equal(view(doc).y.value, "why");
|
||||
assert.deepEqual(failures, []);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-hold after a link loss", () => {
|
||||
it("keeps the last values readable, sends one fresh unit, and a reference joining meanwhile waits for it", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
await server.install(docFacet("b"));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const doc = browser.hold(Doc, {});
|
||||
await doc.ready;
|
||||
await doc.bump();
|
||||
await until(() => view(doc).x.value === 2);
|
||||
up.close();
|
||||
await until(() => doc.status === "loading");
|
||||
// The last values stay readable while the binding re-holds.
|
||||
assert.equal(view(doc).x.value, 2);
|
||||
const joining = browser.hold(Doc, {});
|
||||
assert.equal(joining.status, "loading");
|
||||
let joined = false;
|
||||
void joining.ready.then(() => {
|
||||
joined = true;
|
||||
});
|
||||
await sleep(5);
|
||||
assert.equal(joined, false, "a reference joining during the re-hold waits for the fresh unit");
|
||||
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up2);
|
||||
browser.connect(link, { principal: { kind: "server" } });
|
||||
await joining.ready;
|
||||
assert.equal(doc.status, "live");
|
||||
const hold = link.sent.find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.again === true, "a re-hold says again");
|
||||
assert.equal(link.count("sent", "hold"), 1, "one hold for both references");
|
||||
const snaps = stateFrames(link.received).filter((frame) => frame.as === "snap");
|
||||
assert.deepEqual(snaps.map((frame) => frame.m).sort(), ["x", "y"], "one snapshot per member");
|
||||
// The old hold was released with the link: the server made a fresh object (its cells start over).
|
||||
assert.equal(view(doc).x.value, 1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
158
third_party/pi/packages/chord/test/websocket.test.ts
vendored
Normal file
158
third_party/pi/packages/chord/test/websocket.test.ts
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { Type } from "typebox";
|
||||
import { describe, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import {
|
||||
acceptWebSockets,
|
||||
createHost,
|
||||
hostNamed,
|
||||
inflateMessage,
|
||||
isChordError,
|
||||
method,
|
||||
remote,
|
||||
type WebSocketLike,
|
||||
webSocketLink,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const Board = remote("t.board", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: Type.Array(Type.String()) },
|
||||
methods: { add: method(Type.String()), who: method(undefined, Type.Unknown()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("WebSocket links", () => {
|
||||
it("serves a browser-style client over a real socket, stamped by the authenticator", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Board, (_input, principal, self) => {
|
||||
const notes = self.state("notes", Type.Array(Type.String()), []);
|
||||
return {
|
||||
variant: "main",
|
||||
notes,
|
||||
add: (text, ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(text);
|
||||
});
|
||||
},
|
||||
who: () => principal,
|
||||
};
|
||||
});
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
acceptWebSockets(wss as never, server, {
|
||||
principal: (request: IncomingMessage) => {
|
||||
const token = new URL(request.url ?? "/", "http://x").searchParams.get("token");
|
||||
return token === "ok" ? { guest: "web" } : undefined;
|
||||
},
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
const open = (token: string) =>
|
||||
createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: {
|
||||
link: webSocketLink(new WebSocket(`ws://127.0.0.1:${port}/?token=${token}`) as unknown as WebSocketLike),
|
||||
principal: { kind: "server" },
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
try {
|
||||
const browser = open("ok");
|
||||
const board = browser.hold(Board, {});
|
||||
const seen: unknown[] = [];
|
||||
board.notes.subscribe((value) => seen.push(value));
|
||||
await board.add("hello");
|
||||
assert.deepEqual(await board.who(), { guest: "web" });
|
||||
for (let i = 0; i < 50 && board.notes.value?.length !== 1; i++) await new Promise((r) => setTimeout(r, 5));
|
||||
assert.deepEqual(board.notes.value, ["hello"]);
|
||||
assert.deepEqual(seen.at(-1), ["hello"]);
|
||||
await browser.close();
|
||||
|
||||
// WebSocket peers are always clients: one announcing itself as a host is cut off.
|
||||
const impostor = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: {
|
||||
link: webSocketLink(new WebSocket(`ws://127.0.0.1:${port}/?token=ok`) as unknown as WebSocketLike),
|
||||
principal: { kind: "server" },
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
await assert.rejects(
|
||||
impostor.root((ctx) => impostor.hold(Board, {}).add("x", ctx), {
|
||||
signal: AbortSignal.timeout(150),
|
||||
}),
|
||||
(error) => isChordError(error, "aborted"),
|
||||
);
|
||||
await impostor.close();
|
||||
|
||||
const refused = open("bad");
|
||||
const denied = refused.hold(Board, {});
|
||||
// The server closes unauthenticated sockets; calls wait for a reconnect until their ctx gives up.
|
||||
await assert.rejects(
|
||||
refused.root((ctx) => denied.add("x", ctx), { signal: AbortSignal.timeout(100) }),
|
||||
(error) => isChordError(error, "aborted"),
|
||||
);
|
||||
await refused.close();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket links: packed payloads", () => {
|
||||
it("large snapshots and results reach the client as binary messages; clients may not send them", async () => {
|
||||
const notes = Array.from({ length: 40 }, (_, i) => `${i}:${"x".repeat(4000)}`);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Board, (_input, _principal, self) => {
|
||||
const state = self.state("notes", Type.Array(Type.String()), notes);
|
||||
return { variant: "main", notes: state, add: () => {}, who: () => notes };
|
||||
});
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
acceptWebSockets(wss as never, server, { principal: () => ({ guest: "web" }) });
|
||||
const url = `ws://127.0.0.1:${(wss.address() as { port: number }).port}/`;
|
||||
try {
|
||||
const binary: Uint8Array[] = [];
|
||||
const socket = new WebSocket(url);
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (typeof event.data !== "string") binary.push(new Uint8Array(event.data as ArrayBuffer));
|
||||
});
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: webSocketLink(socket as unknown as WebSocketLike), principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const board = browser.hold(Board, {});
|
||||
assert.deepEqual(await board.who(), notes);
|
||||
board.notes.subscribe(() => {});
|
||||
for (let i = 0; i < 100 && board.notes.value === undefined; i++) await new Promise((r) => setTimeout(r, 5));
|
||||
assert.deepEqual(board.notes.value, notes);
|
||||
assert(binary.length >= 2, "the result and the snapshot came as binary messages");
|
||||
// A text-only transport (the TUI launcher) turns a packed message back into its text frame.
|
||||
const frames = binary.map((message) => JSON.parse(inflateMessage(message)) as Record<string, unknown>);
|
||||
assert(frames.some((frame) => frame.t === "result" && JSON.stringify(frame.value) === JSON.stringify(notes)));
|
||||
assert(frames.some((frame) => frame.t === "state" && JSON.stringify(frame.value) === JSON.stringify(notes)));
|
||||
await browser.close();
|
||||
|
||||
// A client's binary message is a protocol error: the server closes the socket.
|
||||
const raw = new WebSocket(url);
|
||||
raw.binaryType = "arraybuffer";
|
||||
await new Promise<void>((resolve) => raw.addEventListener("open", () => resolve()));
|
||||
const closed = new Promise<void>((resolve) => raw.addEventListener("close", () => resolve()));
|
||||
raw.send(new Uint8Array([0, 0, 0, 2, 123, 125]));
|
||||
await closed;
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user