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
460 lines
17 KiB
TypeScript
460 lines
17 KiB
TypeScript
// 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();
|
|
});
|
|
});
|