pi-mono/packages/chord/test/next/host/object.test.ts
Mario Zechner a14cdf90f0 feat(chord): v2 slice 7c, plugins component and per-plugin load and reload
Plugin discovery, esbuild builds (optional peer dependency), vm evaluation with
pluginFile and mapStack, dependency installs, a watcher with dirty marks and
rollout across hosts; host.load and host.reload per plugin with the closure rule;
examples 35-39 as folders with real plugin directories.
2026-10-30 18:18:23 +00:00

1930 lines
70 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { type Context, BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { arr, evolve, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { broken, ChordError } from "../../../src/next/data/index.ts";
import { defineState, type SingletonHandle } from "../../../src/next/documents/index.ts";
import { Attempt } from "../../../src/next/host/attempt.ts";
import {
createHost,
defineFacet,
type FacetHost,
type FacetSelf,
type Host,
type HostError,
type Self,
type StoreSelf,
untilAborted,
} from "../../../src/next/host/index.ts";
import { ChordObject } from "../../../src/next/host/object.ts";
import { memoryLinkPair } from "../../../src/next/link/index.ts";
import { jsonPart, type Message } from "../../../src/next/link/internal.ts";
import { defineRegistry } from "../../../src/next/registries/index.ts";
import { constant, type Watchable } from "../../../src/next/state/index.ts";
import {
changesOf,
delivered,
expectCollected,
failLive,
failures,
fakeTimers,
flushed,
holdNextCommit,
instancesOf,
loaded,
memoryStore,
metered,
RawPeer,
rawWorkers,
rejection,
relayed,
restartable,
SERVER_ADDRESS,
Server,
Session,
StoreRestartable,
statesOf,
storeRestartable,
testCodec,
transported,
valuesOf,
} from "../support.ts";
const List = defineState("list", { schema: obj({ items: arr(str()) }), version: 1, initial: () => ({ items: [] }) });
const COUNTER = { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) } as const;
const Store = local("app.store", {
version: 1,
params: {},
members: { count: int(), add: method(obj({ text: str() })), clear: method() },
});
const Page = local("app.page", { version: 1, params: { id: int() }, members: { label: str(), slow: method() } });
/** The store: a list document, its count, and methods to add and clear. */
function serveStore(self: Self) {
const list = self.state(List);
return {
count: self.derive([list], ([l]) => l.items.length),
add: async ({ text }: { text: string }, ctx: Context) => {
await list.change((draft) => draft.items.push(text), ctx);
return null;
},
clear: async (ctx: Context) => {
await list.retire(ctx);
return null;
},
};
}
const Svc = local("app.svc", { version: 1, params: { id: int() }, members: { n: int() } });
const Core = local("app.core", { version: 1, params: {}, members: { n: int() } });
/**
* A factory with one native whose open throws "offline" on the attempts `fails` picks (counted from 1), and the times
* (fake, from 0) of its attempts. `gate`, when given, holds every open that does not fail until it resolves.
*/
function flaky(fails: (attempt: number) => boolean, gate?: Promise<void>) {
const times: number[] = [];
const factory = (_: unknown, self: Self) => {
times.push(Date.now());
const attempt = times.length;
self.open(
async () => {
if (fails(attempt)) throw new Error("offline");
await gate;
return null;
},
() => {},
);
return { n: constant(attempt) };
};
return { times, factory };
}
describe("identity and lifetime", () => {
it("one object per params key; a params type lives from its first get to its last release", async () => {
const factory = vi.fn(({ id }: { id: number }, self: Self) => {
const store = self.get(Store, {});
return {
label: self.derive([store.count], ([count]) => `${id}: ${count}`),
slow: async () => null,
};
});
const { host } = await loaded((h) => {
h.serve(Store, (_, self) => serveStore(self));
h.serve(Page, factory);
});
const [a, b, c] = await Promise.all([
host.get(Page, { id: 1 }, context),
host.get(Page, { id: 1 }, context),
host.get(Page, { id: 2 }, context),
]);
expect(factory).toHaveBeenCalledTimes(2);
const watch = await a.label.watch(context);
a.release();
expect(b.label.value).toBe("1: 0");
b.release();
expect(await watch.closed).toEqual({ kind: "ended" });
const again = await host.get(Page, { id: 1 }, context);
expect(factory).toHaveBeenCalledTimes(3);
expect([again.label.value, c.label.value]).toEqual(["1: 0", "2: 0"]);
});
it("keeps a type with empty params across releases", async () => {
const factory = vi.fn((_: object, self: Self) => serveStore(self));
const { host } = await loaded((h) => h.serve(Store, factory));
const first = await host.get(Store, {}, context);
await first.add({ text: "milk" }, context);
first.release();
const second = await host.get(Store, {}, context);
expect(second.count.value).toBe(1);
expect(factory).toHaveBeenCalledTimes(1);
});
it("drops an ended object's documents and detaches its derives from other objects' members", async () => {
const telemetry = await metered();
const { host } = await loaded(
(h) => {
h.serve(Store, (_, self) => serveStore(self));
h.serve(Page, ({ id }, self) => ({
label: self.derive([self.get(Store, {}).count], ([count]) => `${id}: ${count}`),
slow: async () => null,
}));
},
{ telemetry: telemetry.telemetry },
);
const store = await host.get(Store, {}, context);
const page = await host.get(Page, { id: 1 }, context);
const watch = await page.label.watch(context);
page.release();
expect(await watch.closed).toEqual({ kind: "ended" });
const before = telemetry.counts()["chord.derive.computes"];
await store.add({ text: "x" }, context);
expect(telemetry.counts()["chord.derive.computes"]).toBe(before);
});
});
describe("retries", () => {
fakeTimers();
it("retry a transient failure after 1 s, doubling up to 60 s, and start over after a success", async () => {
const { times, factory } = flaky((attempt) => attempt <= 8 || attempt === 10);
const { host } = await loaded((h) => h.serve(Svc, factory));
const got = host.get(Svc, { id: 1 }, context, { readyTimeout: 1_000_000 });
await vi.advanceTimersByTimeAsync(183_000);
(await got).release();
expect(times).toEqual([0, 1000, 3000, 7000, 15000, 31000, 63000, 123000, 183000]);
// The success deleted the backoff: a new object tries at once and waits 1 s after its failure.
const again = host.get(Svc, { id: 1 }, context);
await vi.advanceTimersByTimeAsync(1000);
expect((await again).n.value).toBe(11);
expect(times.slice(9)).toEqual([183000, 184000]);
});
it("share one attempt among concurrent gets", async () => {
const { times, factory } = flaky(() => false);
const { host } = await loaded((h) => h.serve(Svc, factory));
const refs = await Promise.all([1, 2, 3].map(() => host.get(Svc, { id: 1 }, context)));
expect([times, refs.map(({ n }) => n.value)]).toEqual([[0], [1, 1, 1]]);
});
it("never retry a broken failure; a new object after its end tries at once", async () => {
const bug = vi.fn(() => {
throw new Error("bug");
});
const { host } = await loaded((h) => {
h.serve(Svc, bug);
h.serve(Core, bug);
});
expect(await rejection(host.get(Svc, { id: 1 }, context))).toEqual({
code: "unavailable",
message: "app.svc(1): bug",
});
await vi.advanceTimersByTimeAsync(120_000);
expect(bug).toHaveBeenCalledTimes(2);
expect(await rejection(host.get(Core, {}, context))).toEqual({ code: "unavailable", message: "app.core: bug" });
await expect(host.get(Svc, { id: 1 }, context)).rejects.toThrow("bug");
expect(bug).toHaveBeenCalledTimes(3);
});
it("end a failed object with its last reference; its backoff entry expires on its own", async () => {
const { times, factory } = flaky(() => true);
const { host } = await loaded((h) => h.serve(Svc, factory));
const first = rejection(host.get(Svc, { id: 1 }, context, { readyTimeout: 500 }));
await vi.advanceTimersByTimeAsync(500);
expect(await first).toEqual({ code: "unavailable", message: "app.svc(1): offline" });
await vi.advanceTimersByTimeAsync(4500);
expect([times, vi.getTimerCount()]).toEqual([[0], 0]);
// The entry expired: a new object tries at once and its window starts over at 1 s.
const second = rejection(host.get(Svc, { id: 1 }, context, { readyTimeout: 1500 }));
await vi.advanceTimersByTimeAsync(1500);
await second;
expect(times).toEqual([0, 5000, 6000]);
});
it("make a get right after a failure wait for the scheduled retry", async () => {
const { times, factory } = flaky((attempt) => attempt === 1);
const { host } = await loaded((h) => h.serve(Svc, factory));
const first = rejection(host.get(Svc, { id: 1 }, context, { readyTimeout: 500 }));
await vi.advanceTimersByTimeAsync(500);
await first;
const second = host.get(Svc, { id: 1 }, context);
await vi.advanceTimersByTimeAsync(499);
expect(times).toEqual([0]);
await vi.advanceTimersByTimeAsync(1);
expect((await second).n.value).toBe(2);
expect(times).toEqual([0, 1000]);
});
it("start a new instance only after the previous one closed", async () => {
const closing = Promise.withResolvers<void>();
const factory = vi.fn((_: { id: number }, self: Self) => {
self.open(
async () => null,
() => closing.promise,
);
return { n: constant(factory.mock.calls.length) };
});
const { host } = await loaded((h) => h.serve(Svc, factory));
(await host.get(Svc, { id: 1 }, context)).release();
const again = host.get(Svc, { id: 1 }, context);
await flushed();
expect(factory).toHaveBeenCalledTimes(1);
closing.resolve();
expect((await again).n.value).toBe(2);
});
});
describe("parked on dependencies (dependency model §3)", () => {
fakeTimers();
const View = local("app.view", { version: 1, params: { id: int() }, members: { n: int() } });
/** A view over `Type`'s `n`, and the times of its attempts. */
function view(Type: typeof Svc | typeof Core) {
const times: number[] = [];
const factory = ({ id }: { id: number }, self: Self) => {
times.push(Date.now());
return { n: Type === Core ? self.get(Core, {}).n : self.get(Svc, { id }).n };
};
return { times, factory };
}
it("parks a dependent of a failed dependency failed { waiting } naming it, with no timer; it goes on when the dependency is live", async () => {
const core = flaky((attempt) => attempt === 1);
const viewer = view(Core);
const { host, errors } = await loaded((h) => {
h.serve(Core, core.factory);
h.serve(View, viewer.factory);
});
await vi.advanceTimersByTimeAsync(500);
const held = host.hold(View, { id: 1 }, context);
const got = host.get(View, { id: 1 }, context);
await flushed();
expect(held.state.value).toEqual({
kind: "failed",
cause: {
kind: "waiting",
error: expect.objectContaining({ message: "waiting for app.core: app.core: offline" }),
},
retryAt: undefined,
});
// A parked attempt is no failure of its own: nothing is reported for the view.
expect(failures(errors)).toEqual(["app.core: transient"]);
// The core's own backoff retries it at 1 s; the view goes on then, its factory not run again.
await vi.advanceTimersByTimeAsync(500);
expect((await got).n.value).toBe(2);
await vi.advanceTimersByTimeAsync(120_000);
expect(viewer.times).toEqual([500]);
held.release();
});
it("keeps its references while parked; a getter rejects with the waiting cause at its deadline; releasing ends the wait", async () => {
const svc = flaky(() => true);
const viewer = view(Svc);
const { host } = await loaded((h) => {
h.serve(Svc, svc.factory);
h.serve(View, viewer.factory);
});
const got = rejection(host.get(View, { id: 1 }, context, { readyTimeout: 1500 }));
await vi.advanceTimersByTimeAsync(1500);
expect(await got).toEqual({
code: "unavailable",
message: "waiting for app.svc(1): app.svc(1): offline",
});
await vi.advanceTimersByTimeAsync(10_000);
// Held by the parked view, the dependency was retried at 1 s; released with it at 1.5 s, it is not any more.
expect(svc.times).toEqual([0, 1000]);
expect(viewer.times).toEqual([0]);
});
it("shows every change of a dependency: loading, failed, a new cause, loading again, live", async () => {
let cores = 0;
const viewer = view(Core);
const { host } = await loaded((h) => {
h.serve(Core, (_, self) => {
const attempt = ++cores;
self.open(
async () => {
if (attempt < 3) throw new Error(`offline ${attempt}`);
},
() => {},
);
return { n: constant(attempt) };
});
h.serve(View, viewer.factory);
});
const held = host.hold(View, { id: 1 }, context);
await flushed();
expect(held.state.value).toMatchObject({
kind: "failed",
cause: { kind: "waiting", error: { message: "waiting for app.core: app.core: offline 1" } },
});
const states = statesOf(await held.state.watch(context));
await vi.advanceTimersByTimeAsync(10_000);
// The core's retries at 1 s and 3 s: each attempt shows as loading, each failure as a new cause.
expect(states).toEqual(["loading", "failed: waiting for app.core: app.core: offline 2", "loading", "live"]);
expect(viewer.times).toEqual([0]);
held.release();
});
it("passes a retry on to its broken dependency, once through a diamond", async () => {
const Top = local("app.top", { version: 1, params: {}, members: { n: int() } });
const Left = local("app.left", { version: 1, params: { id: int() }, members: { n: int() } });
let cores = 0;
const { host } = await loaded((h) => {
h.serve(Core, () => {
if (++cores === 1) throw new Error("bug");
return { n: constant(cores) };
});
h.serve(Left, (_, self) => ({ n: self.get(Core, {}).n }));
h.serve(Top, (_, self) => {
const a = self.get(Left, { id: 1 }).n;
const b = self.get(Left, { id: 2 }).n;
return { n: self.derive([a, b], ([x, y]) => x + y) };
});
});
const top = host.hold(Top, {}, context);
await flushed();
expect(top.state.value).toMatchObject({
kind: "failed",
cause: { kind: "waiting", error: { message: "waiting for app.left(1): app.core: bug" } },
});
await vi.advanceTimersByTimeAsync(60_000);
// A broken dependency is not retried by any timer.
expect(cores).toBe(1);
top.retry();
await flushed();
expect(cores).toBe(2);
expect(top.state.value).toEqual({ kind: "live" });
expect(top.n.value).toBe(4);
top.release();
});
it("walks a diamond on one host once: a shared parked dependency passes a retry on once (audit A5)", async () => {
const Top = local("app.top", { version: 1, params: {}, members: { n: int() } });
const Left = local("app.left", { version: 1, params: { id: int() }, members: { n: int() } });
const Mid = local("app.mid", { version: 1, params: {}, members: { n: int() } });
let cores = 0;
const { host } = await loaded((h) => {
h.serve(Core, () => {
if (++cores === 1) throw new Error("bug");
return { n: constant(cores) };
});
h.serve(Mid, (_, self) => ({ n: self.get(Core, {}).n }));
h.serve(Left, (_, self) => ({ n: self.get(Mid, {}).n }));
h.serve(Top, (_, self) => {
const a = self.get(Left, { id: 1 }).n;
const b = self.get(Left, { id: 2 }).n;
return { n: self.derive([a, b], ([x, y]) => x + y) };
});
});
const top = host.hold(Top, {}, context);
await flushed();
const passed = vi.spyOn(Attempt.prototype, "retryDependencies");
try {
top.retry();
// Top, both lefts, and the shared mid once (its second visit through the other left is ignored).
expect(passed).toHaveBeenCalledTimes(4);
} finally {
passed.mockRestore();
}
await flushed();
expect([cores, top.n.value]).toEqual([2, 4]);
top.release();
});
it("fails every object of a cycle broken; none is retried", async () => {
const Ping = local("app.ping", { version: 1, params: {}, members: { n: int() } });
const Pong = local("app.pong", { version: 1, params: {}, members: { n: int() } });
const factories = { ping: vi.fn(), pong: vi.fn() };
const { host, errors } = await loaded((h) => {
h.serve(Ping, (_, self) => {
factories.ping();
return { n: self.get(Pong, {}).n };
});
h.serve(Pong, (_, self) => {
factories.pong();
return { n: self.get(Ping, {}).n };
});
});
expect(failures(errors).sort()).toEqual(["app.ping: broken", "app.pong: broken"]);
await vi.advanceTimersByTimeAsync(120_000);
expect([factories.ping.mock.calls.length, factories.pong.mock.calls.length]).toEqual([1, 1]);
expect(await rejection(host.get(Pong, {}, context))).toEqual({
code: "invalid",
reason: "cycle",
message: "dependency cycle: app.ping → app.pong → app.ping",
});
});
});
describe("cycle checks", () => {
it("stay linear on shared dependencies (a layered graph, every object depending on the whole next layer)", async () => {
const Layer = local("app.layer", {
version: 1,
params: { layer: int(), index: int() },
members: { depth: int() },
});
const layers = 28;
const { host } = await loaded((h) =>
h.serve(Layer, ({ layer }, self) => {
if (layer === layers) return { depth: constant(0) };
const next = [0, 1].map((index) => self.get(Layer, { layer: layer + 1, index }).depth);
return { depth: self.derive(next, ([a]) => (a as number) + 1) };
}),
);
const telemetry = await metered();
const top = await host.get(Layer, { layer: 0, index: 0 }, telemetry.context);
expect(top.depth.value).toBe(layers);
// 55 objects, 2 waits each: a search per wait visits at most every object once (an exponential one 2^28 chains).
expect(telemetry.counts()["chord.cycle.visits"]).toBeLessThanOrEqual(2 * 55 * 55);
});
});
describe("GC", () => {
it("collects an ended object with its documents, derives and watches", async () => {
const refs: WeakRef<object>[] = [];
const { host } = await loaded((h) => {
h.serve(Store, (_, self) => serveStore(self));
h.serve(Page, (_, self) => {
const list = self.state(List);
const label = self.derive([list, self.get(Store, {}).count], ([l, c]) => `${l.items.length}/${c}`);
refs.push(new WeakRef(self), new WeakRef(list), new WeakRef(label));
return { label, slow: async () => null };
});
});
const store = await host.get(Store, {}, context);
await (async () => {
const page = await host.get(Page, { id: 1 }, context);
const watch = await page.label.watch(context);
refs.push(new WeakRef(watch), new WeakRef(page));
page.release();
})();
await store.add({ text: "x" }, context);
await delivered();
await expectCollected(...refs);
});
it("keeps nothing in a released reference or a stopped watch", async () => {
const { host } = await loaded((h) => h.serve(Store, (_, self) => serveStore(self)));
const store = await host.get(Store, {}, context);
const held = await host.get(Store, {}, context);
const refs = await (async () => {
const watch = await store.count.watch(context);
await watch.stop();
const reference = await host.get(Store, {}, context);
const watched: Watchable<number> = reference.count;
await watched.watch(context);
reference.release();
return [new WeakRef(watch), new WeakRef(reference)];
})();
await held.add({ text: "x" }, context);
await expectCollected(...refs);
});
});
describe("GC of released references", () => {
it("keeps neither the declaring object nor the host in a released self.get reference", async () => {
const { reference, refs } = await (async () => {
const refs: WeakRef<object>[] = [];
let declared: unknown;
const { host } = await loaded((h) => {
h.serve(Store, (_, self) => serveStore(self));
h.serve(Page, (_, self) => {
declared = self.get(Store, {});
refs.push(new WeakRef(self));
return { label: constant(""), slow: async () => null };
});
});
(await host.get(Page, { id: 1 }, context)).release();
await flushed();
refs.push(new WeakRef(host));
return { reference: declared, refs };
})();
await expectCollected(...refs);
expect(reference).toBeDefined();
});
});
describe("GC of failed objects", () => {
fakeTimers();
it("collects one after its last reference while its backoff entry exists; the entry expires on its own", async () => {
const refs: WeakRef<object>[] = [];
const { host, errors } = await loaded((h) => {
h.serve(Store, (_, self) => serveStore(self));
h.serve(Page, (_, self) => {
const list = self.state(List);
const label = self.derive([list, self.get(Store, {}).count], ([l, c]) => `${l.items.length}/${c}`);
const native = self.open(
async () => ({ buffer: new Uint8Array(16) }),
() => {},
);
self.open(
async () => {
throw new Error("offline");
},
() => {},
);
const slow = async () => null;
refs.push(new WeakRef(self), new WeakRef(list), new WeakRef(label), new WeakRef(native), new WeakRef(slow));
return { label, slow };
});
});
const got = rejection(host.get(Page, { id: 1 }, context, { readyTimeout: 100 }));
await vi.advanceTimersByTimeAsync(100);
await got;
expect(vi.getTimerCount()).toBe(1);
// The reports' errors keep their stack frames' receivers until formatted (V8): an app logs and drops them.
errors.length = 0;
await expectCollected(...refs);
await vi.advanceTimersByTimeAsync(900);
expect(vi.getTimerCount()).toBe(0);
});
});
describe("GC of a parked chain", () => {
it("collects a chain parked on a type not served, released from its top: objects, attempts and index entries", async () => {
const Top = local("app.top", { version: 1, params: { id: int() }, members: { n: int() } });
const Mid = local("app.mid", { version: 1, params: { id: int() }, members: { n: int() } });
const Missing = local("app.missing", { version: 1, params: {}, members: { n: int() } });
const refs: WeakRef<object>[] = [];
const { host } = await loaded((h) => {
h.serve(Mid, (_, self) => {
refs.push(new WeakRef(self));
return { n: self.get(Missing, {}).n };
});
h.serve(Top, ({ id }, self) => {
refs.push(new WeakRef(self));
return { n: self.get(Mid, { id }).n };
});
});
const top = host.hold(Top, { id: 1 }, context);
await flushed();
expect(top.state.value).toMatchObject({
kind: "failed",
cause: {
kind: "waiting",
error: { message: "waiting for app.mid(1): app.missing is not served here" },
},
});
top.release();
await flushed();
expect(refs).toHaveLength(2);
await expectCollected(...refs);
expect(host).toBeDefined();
});
});
describe("ref.retry()", () => {
fakeTimers();
it("skips a transient failure's backoff window: an attempt now", async () => {
const { times, factory } = flaky((attempt) => attempt === 1);
const { host } = await loaded((h) => h.serve(Svc, factory));
const svc = host.hold(Svc, { id: 1 }, context);
await flushed();
expect(svc.state.value.kind).toBe("failed");
vi.advanceTimersByTime(200);
svc.retry();
await flushed();
expect([times, svc.state.value, svc.n.value]).toEqual([[0, 200], { kind: "live" }, 2]);
});
it("starts an object waiting for its identity's backoff window at once", async () => {
const { times, factory } = flaky((attempt) => attempt === 1);
const { host } = await loaded((h) => h.serve(Svc, factory));
const first = host.hold(Svc, { id: 1 }, context);
await flushed();
// The failed object ends with its reference; its identity's window (1 s) still runs.
first.release();
vi.advanceTimersByTime(100);
const second = host.hold(Svc, { id: 1 }, context);
await flushed();
expect([times, second.state.value]).toEqual([[0], { kind: "loading" }]);
second.retry();
await flushed();
expect([times, second.state.value]).toEqual([[0, 100], { kind: "live" }]);
});
it("is ignored while an attempt runs and once live (race 11)", async () => {
const gate = Promise.withResolvers<void>();
const { times, factory } = flaky((attempt) => attempt === 1, gate.promise);
const { host } = await loaded((h) => h.serve(Svc, factory));
const svc = host.hold(Svc, { id: 1 }, context);
await flushed();
svc.retry();
svc.retry();
await flushed();
expect(times).toEqual([0, 0]);
gate.resolve();
await flushed();
svc.retry();
await vi.advanceTimersByTimeAsync(5_000);
expect([times, svc.state.value]).toEqual([[0, 0], { kind: "live" }]);
});
it("renews a broken object for every reference (race 13)", async () => {
let fixed = false;
const factory = vi.fn((_: { id: number }, self: Self) => {
self.open(
async () => {
if (!fixed) throw broken(new Error("no configuration"));
},
() => {},
);
return { n: constant(1) };
});
const { host } = await loaded((h) => h.serve(Svc, factory));
const one = host.hold(Svc, { id: 1 }, context);
const two = host.hold(Svc, { id: 1 }, context);
await vi.advanceTimersByTimeAsync(120_000);
expect([one.state.value.kind, two.state.value.kind, factory.mock.calls.length]).toEqual(["failed", "failed", 1]);
fixed = true;
one.retry();
expect(two.state.value).toEqual({ kind: "loading" });
await flushed();
expect([one.state.value, two.state.value]).toEqual([{ kind: "live" }, { kind: "live" }]);
});
});
describe("linger", () => {
fakeTimers();
const Tally = local("app.tally", { version: 1, params: { id: int() }, members: { n: int(), bump: method() } });
const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
/** Serves `Tally` with `linger`: a document, a derive that fails at 2, a native whose closes `closes` records. */
async function tallies(linger: number, open: () => Promise<void> = async () => {}) {
const closes: string[] = [];
const factory = vi.fn(({ id }: { id: number }, self: Self) => {
const doc = self.state(N);
self.open(open, (_, reason) => void closes.push(`${id} ${reason.kind}`));
return {
n: self.derive([doc], ([{ n }]) => {
if (n === 2) throw new Error("two");
return n;
}),
bump: async (ctx: Context) => {
await doc.change((draft) => {
draft.n++;
}, ctx);
return null;
},
};
});
const { host } = await loaded((h) => h.serve(Tally, factory, { linger }));
return { host, factory, closes };
}
it("keeps a live object live for linger after its last release: a get within it reuses it, with its documents", async () => {
const { host, factory, closes } = await tallies(500);
const first = await host.get(Tally, { id: 1 }, context);
await first.bump(context);
first.release();
await vi.advanceTimersByTimeAsync(499);
const second = await host.get(Tally, { id: 1 }, context);
expect([second.n.value, factory.mock.calls.length, closes]).toEqual([1, 1, []]);
second.release();
await vi.advanceTimersByTimeAsync(500);
expect(closes).toEqual(["1 end"]);
expect((await host.get(Tally, { id: 1 }, context)).n.value).toBe(0);
expect(factory).toHaveBeenCalledTimes(2);
});
it("a reuse cancels the expiry: the holder count decides, a later release starts a new window (race 8)", async () => {
const { host, closes } = await tallies(500);
(await host.get(Tally, { id: 1 }, context)).release();
await vi.advanceTimersByTimeAsync(499);
const held = await host.get(Tally, { id: 1 }, context);
await vi.advanceTimersByTimeAsync(5_000);
held.release();
await vi.advanceTimersByTimeAsync(499);
expect(closes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(closes).toEqual(["1 end"]);
});
it("only a live object lingers: an activating or failed one ends with its last reference (race 9)", async () => {
const gate = Promise.withResolvers<void>();
const activating = await tallies(500, () => gate.promise);
const opening = activating.host.hold(Tally, { id: 1 }, context);
await flushed();
opening.release();
gate.resolve();
await flushed();
// Its open's late value was closed at once.
expect(activating.closes).toEqual(["1 end"]);
const failing = await tallies(500, async () => {
throw broken(new Error("no configuration"));
});
const failed = failing.host.hold(Tally, { id: 1 }, context);
await flushed();
failed.release();
failing.host.hold(Tally, { id: 1 }, context);
await flushed();
expect(failing.factory).toHaveBeenCalledTimes(2);
});
it("a failed state member does not end a lingering object", async () => {
const { host, factory, closes } = await tallies(500);
const tally = await host.get(Tally, { id: 1 }, context);
await tally.bump(context);
await tally.bump(context);
expect(() => tally.n.value).toThrow("two");
tally.release();
await vi.advanceTimersByTimeAsync(400);
await host.get(Tally, { id: 1 }, context);
expect([factory.mock.calls.length, closes]).toEqual([1, []]);
});
it("host.close ends a lingering object at once", async () => {
const { host, closes } = await tallies(60_000);
(await host.get(Tally, { id: 1 }, context)).release();
await host.close(context);
expect([closes, vi.getTimerCount()]).toEqual([["1 close"], 0]);
});
it("a lingering object is collected after its expiry", async () => {
const refs: WeakRef<object>[] = [];
const { host } = await loaded((h) =>
h.serve(
Svc,
({ id }, self) => {
refs.push(new WeakRef(self));
return { n: constant(id) };
},
{ linger: 100 },
),
);
(await host.get(Svc, { id: 1 }, context)).release();
await vi.advanceTimersByTimeAsync(100);
vi.useRealTimers();
await expectCollected(...refs);
});
});
/** Every object attempted in this file, weakly: the objects of the cycles among them. */
const attempted = instancesOf(ChordObject, "attempt");
/** On the server: its activation holds `Far` on the session's worker. */
const Near = remote("app.near", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
/** On the session's worker: its activation holds `Near` back on the server, or, with `local`, `Middle`. */
const Far = remote("app.far", {
version: 1,
params: { session: str() },
hostAddress: ({ session }) => Session({ id: session }),
variants: { v: { state: { n: int() } } },
});
/** On the server, held by `Far`: its activation holds `Hub` on its own host (a cycle closed on one host). */
const Middle = remote("app.middle", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
/** On the server, a local type (no principal in its identity): its activation holds `Far` as the host. */
const Hub = local("app.hub", { version: 1, params: { session: str() }, members: { n: int() } });
/** `Loop({ here, there })` lives on `here`'s worker and holds `Loop({ here: there, there: here })`. */
const Loop = remote("app.loop", {
version: 1,
params: { here: str(), there: str() },
hostAddress: ({ here }) => Session({ id: here }),
variants: { v: { state: { n: int() } } },
});
/** A tab, a server and its workers over trusted route links, serving the cycle types (`Far` holds `back`). */
async function cyclic(back: "near" | "middle" = "near", trusted = true) {
return relayed({
trusted,
server: (h) => {
h.serve(Near, ({ session }, _principal, self) => ({
variant: "v",
members: { n: self.get(Far, { session }).n },
}));
h.serve(Middle, ({ session }, _principal, self) => ({
variant: "v",
members: { n: self.get(Hub, { session }).n },
}));
h.serve(Hub, ({ session }, self) => ({ n: self.get(Far, { session }).n }));
},
worker: (h) => {
h.serve(Far, ({ session }, _principal, self) => ({
variant: "v",
members: { n: back === "near" ? self.get(Near, { session }).n : self.get(Middle, { session }).n },
}));
h.serve(Loop, ({ here, there }, _principal, self) => ({
variant: "v",
members: { n: self.get(Loop, { here: there, there: here }).n },
}));
},
});
}
describe("cycles across hosts (Q5)", () => {
it("refuse a hold whose chain names the activation it would bind: server → worker → server", async () => {
const { tab, errors } = await cyclic();
const error = await rejection(tab.get(Near, { session: "S1" }, context));
// The worker's hold back acts as the worker (upward boundary): it meets the cycle one turn later, at that
// principal's `Near`.
const message = "dependency cycle: app.near(S1) → … → app.near(S1)";
expect(error).toEqual({ code: "invalid", reason: "cycle", message });
// Proposal: every object on the cycle fails broken with the same error, none transient (nothing retries it).
const failed = new Set(failures([...errors.server, ...errors.worker]));
expect([...failed].sort()).toEqual(["app.far(S1): broken", "app.near(S1): broken"]);
});
it("find a cycle closed on one host through another: server → worker → server, then a local get", async () => {
const { server } = await cyclic("middle");
const error = await rejection(server.get(Hub, { session: "S1" }, context));
expect(error).toEqual({
code: "invalid",
reason: "cycle",
message: "dependency cycle: app.hub(S1) → … → app.middle(S1) → app.hub(S1)",
});
});
it("carry the chain through a relay (race 7): worker → server → worker → server → worker", async () => {
const { tab, errors } = await cyclic();
const error = await rejection(tab.get(Loop, { here: "S1", there: "S2" }, context));
expect(error).toMatchObject({ code: "invalid", reason: "cycle" });
expect([...new Set(failures(errors.worker))].sort()).toEqual([
"app.loop(S1|S2): broken",
"app.loop(S2|S1): broken",
]);
});
it("drop the chain from holds arriving on untrusted links, and send none for holds made outside an activation", async () => {
const workers: RawPeer[] = [];
const relay = await relayed({ resolve: rawWorkers(workers) });
const [a, b] = memoryLinkPair();
relay.server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context);
const tab = new RawPeer(b);
const forged = "0190f5a2-7c3e-7d4a-9b1c-2d3e4f5a6b7c";
tab.send({ ...farHold(0), chain: [forged] });
await transported();
await transported();
const held = await workers[0]!.next("hold");
expect(held.chain).toBeUndefined();
relay.tab.hold(Far, { session: "S1" }, context);
expect((await workers[0]!.next("hold")).chain).toBeUndefined();
});
it("never match a restarted object: its new instance has a new id (race 8)", async () => {
// The raw worker holds `Near` on the server itself (as the worker), whose activation holds `Far` back with its
// chain. The raw worker never binds `Far`.
const { worker } = await nearBehindRawWorker();
const chainOf = async () => (await worker.next("hold")).chain!;
worker.send(nearHold(0));
const [old] = await chainOf();
// Released: that instance ends. The next hold makes a new instance with a new id.
worker.send({ kind: "release", hold: 0 });
worker.send(nearHold(1));
const [renewed] = await chainOf();
expect(renewed).not.toBe(old);
// A hold naming the old instance binds to the new one (it is pending there); one naming the new one is a cycle.
worker.send({ ...nearHold(2), chain: [old!] });
worker.send({ ...nearHold(3), chain: [renewed!] });
const failed = await worker.next("failed");
expect(failed).toMatchObject({
hold: 3,
cause: { kind: "broken", error: { kind: "chord", error: { reason: "cycle" } } },
});
await transported();
expect(worker.received.filter((message) => message.kind === "failed")).toEqual([]);
});
it("keep the last 32 entries of a longer chain: the oldest is dropped (a longer cycle goes undetected)", async () => {
const { worker } = await nearBehindRawWorker();
const forged = Array.from(
{ length: 32 },
(_, index) => `0190f5a2-7c3e-7d4a-9b1c-${String(index).padStart(12, "0")}`,
);
worker.send({ ...nearHold(0), chain: forged });
const { chain } = await worker.next("hold");
expect(chain).toHaveLength(32);
expect(chain!.slice(0, 31)).toEqual(forged.slice(1));
expect(forged).not.toContain(chain![31]);
});
it("miss a cycle entering an activation that started already (documented limitation): both wait, transient", async () => {
const { relay, worker } = await nearBehindRawWorker();
// The server's `Near` activates for the tab and holds `Far` with its chain.
const getting = rejection(relay.tab.get(Near, { session: "S1" }, context, { readyTimeout: 100 }));
const { chain } = await worker.next("hold");
// The worker's own activation, started at the same moment, holds that `Near` back with a chain of its own.
worker.send({ ...nearHold(0), chain: ["0190f5a2-7c3e-7d4a-9b1c-000000000001"] });
await transported();
expect(chain).toHaveLength(1);
expect(worker.received.filter((message) => message.kind === "failed")).toEqual([]);
expect(await getting).toMatchObject({ code: "unavailable" });
});
it("keep no object, and so no chain, once released (GC)", async () => {
const refs = await (async () => {
const made = attempted.length;
const { tab, server } = await cyclic();
await rejection(tab.get(Near, { session: "S1" }, context));
await transported();
const refs = attempted.slice(made);
expect(refs.length).toBeGreaterThanOrEqual(4);
await tab.close(context);
await server.close(context);
return refs;
})();
await expectCollected(...refs);
});
});
/**
* A server serving `Near` (its activation holds `Far`) and a raw worker for S1 over a trusted route link, made by a
* first hold of `Far` and with that hold taken.
*/
async function nearBehindRawWorker() {
const workers: RawPeer[] = [];
const relay = await relayed({
resolve: rawWorkers(workers, { trusted: true }),
server: (h) =>
h.serve(Near, ({ session }, _principal, self) => ({
variant: "v",
members: { n: self.get(Far, { session }).n },
})),
});
relay.tab.hold(Far, { session: "S1" }, context);
const worker = await (async () => {
for (let tries = 0; workers[0] === undefined && tries < 10; tries++) await transported();
return workers[0]!;
})();
await worker.next("hold");
return { relay, worker };
}
/** A hold of `Far` for S1, as a tab sends it. */
function farHold(hold: number): Extract<Message, { kind: "hold" }> {
const { name: type, version, fingerprint } = Far;
const address = { kind: "session", params: { id: "S1" } };
return { kind: "hold", hold, type, version, fingerprint, params: jsonPart({ session: "S1" }), address };
}
/** A hold of `Near` for S1, as a worker sends it to the server. */
function nearHold(hold: number): Extract<Message, { kind: "hold" }> {
const { name: type, version, fingerprint } = Near;
return {
kind: "hold",
hold,
type,
version,
fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
};
}
describe("a failed activation's cleanup (audit)", () => {
it("closes the natives that opened before it releases its references: a close may use them", async () => {
const Dependent = local("app.dependent", { version: 1, params: { id: int() }, members: { n: int() } });
const seen: unknown[] = [];
const { host, errors } = await loaded((h) => {
h.serve(Core, () => ({ n: constant(5) }));
h.serve(Dependent, (_, self) => {
const core = self.get(Core, {});
self.open(
async () => null,
() => {
try {
seen.push(core.n.value);
} catch (error) {
seen.push(String(error));
}
},
);
self.open(
async () => {
throw broken(new Error("open failed"));
},
() => {},
);
return { n: core.n };
});
});
await expect(host.get(Dependent, { id: 1 }, context)).rejects.toThrow("open failed");
await flushed();
expect(errors.find(({ kind }) => kind === "activation_failed")).toMatchObject({ failure: { kind: "broken" } });
expect(seen).toEqual([5]);
await host.close(context);
});
});
describe("a live object failing (slice 6a §1.4)", () => {
it("fails broken: calls reject with the cause, one object_failed report, natives close, then references go", async () => {
const events: string[] = [];
let list: Watchable<unknown> | undefined;
const { reference, fail, errors } = await restartable(
attempted,
(_, self) => {
list = self.state(List);
const core = self.get(Core, {});
self.open(
async () => null,
() => {
events.push(`close sees core ${core.n.value}`);
},
);
},
{ serve: (h) => h.serve(Core, () => ({ n: constant(1) })) },
);
const states: string[] = [];
(await reference.state.watch(context)).start(async (call) => {
if (call.kind === "value") states.push(call.value.kind);
});
fail();
const touched = rejection(reference.touch(context));
expect(await touched).toMatchObject({ code: "unavailable", message: "boom" });
expect(errors).toEqual([expect.objectContaining({ kind: "object_failed", object: "app.restartable(1)" })]);
// A failure of the sealed attempt is ignored.
fail();
await flushed();
expect(events).toEqual(["close sees core 1"]);
expect(errors.length).toBe(1);
expect(list!.value).toEqual({ items: [] });
await delivered();
expect(states).toEqual(["failed"]);
});
it("keeps its state members' last values for same-host reads; a live dependent's derive over them stays live", async () => {
const Viewer = local("app.count-viewer", { version: 1, params: {}, members: { tens: int() } });
let store: Self | undefined;
const { host, errors } = await loaded((h) => {
h.serve(Store, (_, self) => {
store = self;
return serveStore(self);
});
h.serve(Viewer, (_, self) => ({ tens: self.derive([self.get(Store, {}).count], ([n]) => n * 10) }));
});
const ref = await host.get(Store, {}, context);
await ref.add({ text: "a" }, context);
const viewer = await host.get(Viewer, {}, context);
const tens = changesOf(await viewer.tens.watch(context));
// A change committed in the turn of the failure: the frozen member computes it.
await ref.add({ text: "b" }, context);
failLive(attempted, store!, new ChordError({ code: "unavailable", message: "boom" }));
expect(ref.count.value).toBe(2);
expect(await rejection(ref.add({ text: "c" }, context))).toMatchObject({ message: "boom" });
expect(viewer.state.value).toEqual({ kind: "live" });
expect(viewer.tens.value).toBe(20);
await delivered();
expect(valuesOf(tens)).toEqual([20]);
expect(errors.map(({ kind }) => kind)).toEqual(["object_failed"]);
// A retry takes the new attempt's members.
ref.retry();
(await host.get(Store, {}, context)).release();
expect(ref.count.value).toBe(2);
await host.close(context);
});
it("lets a commit admitted before the failure adopt and publish once, derives and dependents included", async () => {
const Counter = defineState("counter", { ...COUNTER, persist: true, checkpointWhen: () => false });
const Doubled = local("app.doubled", { version: 1, params: {}, members: { double: int() } });
const Viewer = local("app.double-viewer", { version: 1, params: {}, members: { tens: int() } });
const store = await memoryStore();
let self: Self | undefined;
let counter: SingletonHandle<{ n: number }> | undefined;
const { host } = await loaded(
(h) => {
h.serve(Doubled, (_, s) => {
self = s;
counter = s.state(Counter);
return { double: s.derive([counter], ([c]) => c.n * 2) };
});
h.serve(Viewer, (_, s) => ({ tens: s.derive([s.get(Doubled, {}).double], ([n]) => n * 10) }));
},
{ store },
);
const ref = await host.get(Doubled, {}, context);
const viewer = await host.get(Viewer, {}, context);
const doubles = changesOf(await ref.double.watch(context));
const tens = changesOf(await viewer.tens.watch(context));
const held = holdNextCommit(store);
const change = counter!.change((draft) => {
draft.n = 1;
}, context);
await held.entered;
failLive(attempted, self!, new ChordError({ code: "unavailable", message: "boom" }));
held.release();
await change;
await delivered();
expect(ref.state.value.kind).toBe("failed");
expect([counter!.value, ref.double.value, viewer.tens.value]).toEqual([{ n: 1 }, 2, 20]);
// The admitted commit publishes once; then the reference's member holds it (the failure once). A derive over it
// keeps its last value.
expect(valuesOf(doubles)).toEqual([2, { failed: "boom" }]);
expect(valuesOf(tens)).toEqual([20]);
await host.close(context);
await store.close(context);
});
it("starts a retry's factory only once a commit admitted before the failure adopted", async () => {
const V1 = defineState("counter", { ...COUNTER, persist: true, checkpointWhen: () => false });
const V2 = defineState("counter", {
schema: obj({ m: int() }),
version: 2,
initial: () => ({ m: 0 }),
migrate: (old) => ({ m: (old as { n: number }).n }),
persist: true,
checkpointWhen: () => false,
});
const store = await memoryStore();
const counters: SingletonHandle<{ n: number } | { m: number }>[] = [];
const { host, reference, attempts } = await storeRestartable(
attempted,
(attempt, self) => void counters.push(self.state(attempt === 1 ? V1 : V2)),
{ store, closeTimeout: 5 },
);
const v1 = counters[0] as SingletonHandle<{ n: number }>;
await v1.change((draft) => {
draft.n = 1;
}, context);
const held = holdNextCommit(store);
const change = v1.change((draft) => {
draft.n = 2;
}, context);
await held.entered;
failLive(attempted, attempts[0]!, new ChordError({ code: "unavailable", message: "boom" }));
// Past the drain: only the admitted step holds the retry.
await new Promise((resolve) => setTimeout(resolve, 20));
reference.retry();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(counters).toHaveLength(1);
held.release();
await change;
(await host.get(StoreRestartable, {}, context)).release();
expect(counters[1]!.value).toEqual({ m: 2 });
await host.close(context);
await store.close(context);
});
it("is recovered by ref.retry(): a new attempt, the documents as they were", async () => {
const lists: SingletonHandle<{ items: string[] }>[] = [];
const { restart } = await restartable(attempted, (_, self) => {
lists.push(self.state(List));
});
await lists[0]!.change((draft) => void draft.items.push("a"), context);
await restart();
expect(lists[1]!.value).toEqual({ items: ["a"] });
});
});
/** Reloads `host`'s plugin `@test/app` (as `loaded` names it) with a facet whose setup is `setup`. */
function reloadApp(host: Host, setup: (host: FacetHost, self: FacetSelf) => void) {
return host.reload([{ name: "@test/app", facet: defineFacet(setup) }], context);
}
/** A local object with empty params: a counter document, `bump` (which waits for `gate` when given) and `label`. */
const Pure = local("app.pure", {
version: 1,
params: {},
members: { n: int(), label: str(), bump: method(undefined, int()) },
});
/** Serves `Pure` labelled `label`; `bump` adds `by`, first awaiting `gates.shift()` when one is queued. */
function servePure(h: FacetHost, label: string, by: number, gates: Promise<void>[] = []): void {
h.serve(Pure, (_, self) => {
const doc = self.state(defineState("pure", COUNTER));
return {
n: self.derive([doc], ([d]) => d.n),
label: constant(label),
bump: async (ctx: Context) => {
await gates.shift();
await doc.change((draft) => {
draft.n += by;
}, ctx);
return doc.value.n;
},
};
});
}
describe("reload: fates (slice 7b §4)", () => {
it("swaps a pure object in place: same documents and instance id, new code for new calls", async () => {
const { host } = await loaded((h) => servePure(h, "v1", 1));
const pure = await host.get(Pure, {}, context);
await pure.bump(context);
const objects = instancesOf(ChordObject, "reloadFate");
const report = await reloadApp(host, (h) => servePure(h, "v2", 10));
expect(report.objects).toEqual([{ object: "app.pure", kind: "swapped" }]);
expect(objects).toHaveLength(1);
await flushed();
expect(pure.state.value).toEqual({ kind: "live" });
expect(pure.label.value).toBe("v2");
expect(await pure.bump(context)).toBe(11);
await host.close(context);
});
it("restarts an object with natives: the drain first, its natives closed with reason reload, a new instance", async () => {
const closes: string[] = [];
const serve = (h: FacetHost, label: string, gates: Promise<void>[] = []) =>
h.serve(Pure, (_, self) => {
const doc = self.state(defineState("pure", COUNTER));
self.open(
async () => label,
(value, reason) => void closes.push(`${value} ${reason.kind}`),
);
return {
n: self.derive([doc], ([d]) => d.n),
label: constant(label),
bump: async (ctx: Context) => {
await gates.shift();
await doc.change((draft) => {
draft.n += 1;
}, ctx);
closes.push(`bump done on ${label}`);
return doc.value.n;
},
};
});
const gate = Promise.withResolvers<void>();
const { host } = await loaded((h) => serve(h, "v1", [gate.promise]));
const pure = await host.get(Pure, {}, context);
const running = pure.bump(context);
await flushed();
const report = await reloadApp(host, (h) => serve(h, "v2"));
expect(report.objects).toEqual([{ object: "app.pure", kind: "restarted" }]);
expect(pure.state.value).toEqual({ kind: "loading" });
// A new call parks until the new attempt is live.
const parked = pure.bump(context);
await flushed();
expect(closes).toEqual([]);
gate.resolve();
expect(await running).toBe(1);
expect(await parked).toBe(2);
expect(closes).toEqual(["bump done on v1", "v1 reload", "bump done on v2"]);
expect(pure.label.value).toBe("v2");
await host.close(context);
});
it("ends the objects of a removed type: its holders wait not_served, and bind again when it is served again", async () => {
const { host, errors } = await loaded((h) => servePure(h, "v1", 1));
const pure = host.hold(Pure, {}, context);
await flushed();
await pure.bump(context);
const label = await pure.label.watch(context);
const frames = changesOf(label);
const report = await host.reload([], context);
expect(report).toEqual({
plugins: { added: [], changed: [], removed: ["@test/app"] },
failed: [],
objects: [{ object: "app.pure", kind: "ended" }],
});
expect(pure.state.value).toMatchObject({
kind: "failed",
cause: { kind: "waiting", error: { code: "unavailable", message: "app.pure is not served here" } },
});
// The member keeps its last value while waiting.
expect(pure.label.value).toBe("v1");
await host.reload([{ name: "@test/app", facet: defineFacet((h) => servePure(h, "v2", 1)) }], context);
await flushed();
expect(pure.state.value).toEqual({ kind: "live" });
// A new object: in-memory documents went with the old one.
expect(pure.n.value).toBe(0);
await delivered();
// The removal's failure once, then the new object's value, whole.
expect(valuesOf(frames)).toEqual([{ failed: "app.pure is not served here" }, "v2"]);
expect(failures(errors)).toEqual([]);
await host.close(context);
});
it("a live local dependent of a removed type keeps its last value; served again, its derive heals (one frame)", async () => {
const Panel = local("app.panel", { version: 1, params: {}, members: { text: str() } });
const panel = defineFacet((h) =>
h.serve(Panel, (_, self) => {
const pure = self.get(Pure, {});
return { text: self.derive([pure.label], ([label]) => `panel ${label}`) };
}),
);
const host = createHost({ onError: () => {} });
await host.load(
[
{ name: "@test/app", facet: defineFacet((h) => servePure(h, "v1", 1)) },
{ name: "@test/panel", facet: panel },
],
context,
);
const view = await host.get(Panel, {}, context);
const text = changesOf(await view.text.watch(context));
await host.reload([{ name: "@test/panel", facet: panel }], context);
expect(view.state.value).toEqual({ kind: "live" });
expect(view.text.value).toBe("panel v1");
await host.reload(
[
{ name: "@test/app", facet: defineFacet((h) => servePure(h, "v2", 1)) },
{ name: "@test/panel", facet: panel },
],
context,
);
await flushed();
expect(view.text.value).toBe("panel v2");
await delivered();
expect(valuesOf(text)).toEqual(["panel v2"]);
await host.close(context);
});
it("retries a failed object on the new code", async () => {
const { host } = await loaded((h) =>
h.serve(Pure, () => {
throw new Error("bug");
}),
);
const pure = host.hold(Pure, {}, context);
await flushed();
expect(pure.state.value).toMatchObject({ kind: "failed", cause: { kind: "broken" } });
const report = await reloadApp(host, (h) => servePure(h, "fixed", 1));
expect(report.objects).toEqual([{ object: "app.pure", kind: "retried" }]);
await flushed();
expect(pure.state.value).toEqual({ kind: "live" });
expect(pure.label.value).toBe("fixed");
await host.close(context);
});
it("parks a swap whose new factory needs a missing dependency; it goes live when the dependency is served", async () => {
const Dep = local("app.dep", { version: 1, params: {}, members: { name: str() } });
const { host } = await loaded((h) => servePure(h, "v1", 1));
const pure = await host.get(Pure, {}, context);
const needsDep = (h: FacetHost) =>
h.serve(Pure, (_, self) => {
const dep = self.get(Dep, {});
return {
n: constant(0),
label: self.derive([dep.name], ([name]) => `with ${name}`),
bump: async () => 0,
};
});
const report = await reloadApp(host, needsDep);
expect(report.objects).toEqual([
{
object: "app.pure",
kind: "parked",
cause: { kind: "waiting", error: expect.objectContaining({ message: expect.stringContaining("app.dep") }) },
},
]);
expect(pure.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } });
expect(await rejection(pure.bump(context))).toMatchObject({ code: "unavailable" });
await host.reload(
[
{ name: "@test/app", facet: defineFacet(needsDep) },
{ name: "@test/dep", facet: defineFacet((h) => h.serve(Dep, () => ({ name: constant("dep") }))) },
],
context,
);
await flushed();
expect(pure.state.value).toEqual({ kind: "live" });
expect(pure.label.value).toBe("with dep");
await host.close(context);
});
});
describe("reload: the pure swap (slice 7b §5)", () => {
it("lets a running call finish on the old attempt's handles; a new call waits at the gate for the new code", async () => {
const gate = Promise.withResolvers<void>();
const { host } = await loaded((h) => servePure(h, "v1", 1, [gate.promise]));
const pure = await host.get(Pure, {}, context);
const old = pure.bump(context);
await flushed();
await reloadApp(host, (h) => servePure(h, "v2", 10));
// The old call commits through its old handle after the swap.
gate.resolve();
expect(await old).toBe(1);
expect(await pure.bump(context)).toBe(11);
await host.close(context);
});
it("an old call touching a document whose version rose throws once (R15); the document is migrated", async () => {
const V1 = defineState("doc", COUNTER);
const V2 = defineState("doc", {
schema: obj({ n: int(), unit: str() }),
version: 2,
initial: () => ({ n: 0, unit: "" }),
migrate: (old) => ({ n: old.n as number, unit: "pcs" }),
});
const gate = Promise.withResolvers<void>();
const serve = (h: FacetHost, definition: typeof V1 | typeof V2, gates: Promise<void>[]) =>
h.serve(Pure, (_, self) => {
const doc = self.state(definition as typeof V1);
return {
n: self.derive([doc], ([d]) => d.n),
label: self.derive([doc], ([d]) => JSON.stringify(d)),
bump: async (ctx: Context) => {
await gates.shift();
await doc.change((draft) => {
draft.n += 1;
}, ctx);
return doc.value.n;
},
};
});
const { host } = await loaded((h) => serve(h, V1, [Promise.resolve(), gate.promise]));
const pure = await host.get(Pure, {}, context);
await pure.bump(context);
const old = pure.bump(context);
await flushed();
await reloadApp(host, (h) => serve(h, V2, []));
await flushed();
expect(pure.label.value).toBe('{"n":1,"unit":"pcs"}');
gate.resolve();
expect(await rejection(old)).toMatchObject({ code: "released", reason: "version" });
expect(await pure.bump(context)).toBe(2);
await host.close(context);
});
it("binds the new attempt's requests before releasing the old one's: a dependency held only by it never ends", async () => {
const Dep = local("app.dep", {
version: 1,
params: { id: int() },
members: { n: int(), set: method(obj({ n: int() })) },
});
const dep = defineFacet((h) =>
h.serve(Dep, (_, self) => {
const doc = self.state(defineState("dep", COUNTER));
return {
n: self.derive([doc], ([d]) => d.n),
set: async ({ n }: { n: number }, ctx: Context) => {
await doc.change((draft) => {
draft.n = n;
}, ctx);
return null;
},
};
}),
);
const app = (label: string) =>
defineFacet((h) =>
h.serve(Pure, (_, self) => {
const held = self.get(Dep, { id: 1 });
return {
n: self.derive([held.n], ([n]) => n),
label: constant(label),
bump: async (ctx: Context) => {
await held.set({ n: 7 }, ctx);
return 7;
},
};
}),
);
const host = createHost({ onError: () => {} });
await host.load(
[
{ name: "@test/app", facet: app("v1") },
{ name: "@test/dep", facet: dep },
],
context,
);
const pure = await host.get(Pure, {}, context);
await pure.bump(context);
await host.reload(
[
{ name: "@test/app", facet: app("v2") },
{ name: "@test/dep", facet: dep },
],
context,
);
await flushed();
// The dependency's in-memory document is still there: its object never ended in between.
expect(pure.label.value).toBe("v2");
expect(pure.n.value).toBe(7);
await host.close(context);
});
it("re-binds members by name (one frame from the last value) and rebuilds internal derives", async () => {
const { host } = await loaded((h) => servePure(h, "v1", 1));
const pure = await host.get(Pure, {}, context);
const label = changesOf(await pure.label.watch(context));
const n = changesOf(await pure.n.watch(context));
await pure.bump(context);
await reloadApp(host, (h) => servePure(h, "v2", 1));
await delivered();
expect(label).toEqual([{ kind: "value", value: "v2", ops: [["r", "v2"]], retired: false }]);
// Its derive over the same document gave the same value: no frame besides the commit's.
expect(valuesOf(n)).toEqual([1]);
await host.close(context);
});
it("hands its routines over: the old ones aborted, the new ones start after the old calls finished", async () => {
const events: string[] = [];
const release = Promise.withResolvers<void>();
const serve = (h: FacetHost, label: string) =>
h.serve(Pure, (_, self) => {
self.onLive(async (ctx) => {
events.push(`${label} started`);
await untilAborted(ctx);
if (label === "v1") await release.promise;
events.push(`${label} stopped`);
});
return { n: constant(0), label: constant(label), bump: async () => 0 };
});
const { host } = await loaded((h) => serve(h, "v1"));
await host.get(Pure, {}, context);
await flushed();
await reloadApp(host, (h) => serve(h, "v2"));
await flushed();
expect(events).toEqual(["v1 started"]);
release.resolve();
await flushed();
expect(events).toEqual(["v1 started", "v1 stopped", "v2 started"]);
await host.close(context);
});
it("starts a writable at its new initial value and replaces the contributions in one batch", async () => {
const Tools = defineRegistry<string[], string[]>("app.tools", { version: 1 });
const compositions: string[][] = [];
const registry = defineFacet((h) =>
h.serve(Tools, {
compose: (table) => {
const value = table.flatMap((entry) => entry.value);
compositions.push(value);
return value;
},
}),
);
const app = (tool: string) =>
defineFacet((h) =>
h.serve(Pure, (_, own) => {
const tools = own.writable([tool]);
(own as unknown as StoreSelf).contribute(Tools, tools);
return { n: constant(0), label: constant(tool), bump: async () => tools.value.length };
}),
);
const host = createHost({ onError: () => {} });
await host.load(
[
{ name: "@test/app", facet: app("a") },
{ name: "@test/tools", facet: registry },
],
context,
);
const tools = await host.registry(Tools, context);
expect(tools.value).toEqual(["a"]);
compositions.length = 0;
await host.reload(
[
{ name: "@test/app", facet: app("b") },
{ name: "@test/tools", facet: registry },
],
context,
);
await flushed();
expect(tools.value).toEqual(["b"]);
// One composition with the new table, never one without a contribution in between.
expect(compositions).toEqual([["b"]]);
await host.close(context);
});
});
describe("reload: a stuck routine call past the handover", () => {
fakeTimers();
it("an old routine call stuck past the handover: drain_timeout, the old attempt sealed, its commit refused (audit A12)", async () => {
const stuck = Promise.withResolvers<void>();
let late: Promise<unknown> | undefined;
const errors: HostError[] = [];
const serve = (h: FacetHost, label: string) =>
h.serve(Pure, (_, self) => {
const doc = self.state(defineState("pure", COUNTER));
self.onLive(async (ctx) => {
if (label !== "v1") return;
await stuck.promise;
late = rejection(
self.tx(async (tx) => {
(await tx.draft(doc)).n = 99;
}, ctx),
);
});
return { n: self.derive([doc], ([d]) => d.n), label: constant(label), bump: async () => 0 };
});
const host = createHost({ closeTimeout: 1_000, onError: (error) => errors.push(error) });
await host.load([{ name: "@test/app", facet: defineFacet((h) => serve(h, "v1")) }], context);
const pure = await host.get(Pure, {}, context);
await reloadApp(host, (h) => serve(h, "v2"));
await vi.advanceTimersByTimeAsync(1_000);
expect(errors.map(({ kind }) => kind)).toContain("drain_timeout");
stuck.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(await late).toMatchObject({ code: "released" });
expect(pure.n.value).toBe(0);
await host.close(context);
});
});
describe("reload: GC", () => {
const attempts = instancesOf(Attempt, "activate");
it("collects a swapped-out attempt and its closures once its calls settled", async () => {
const gate = Promise.withResolvers<void>();
const { host } = await loaded((h) => servePure(h, "v1", 1, [gate.promise]));
const pure = await host.get(Pure, {}, context);
const old = pure.bump(context);
const from = attempts.length - 1;
await reloadApp(host, (h) => servePure(h, "v2", 1));
gate.resolve();
await old;
await flushed();
await expectCollected(attempts[from]!);
expect(pure.state.value).toEqual({ kind: "live" });
await host.close(context);
});
it("collects a removed type's objects, its provider, its registry entries and the old facet's code", async () => {
const Names = defineRegistry<string, string[]>("app.names", { version: 1 });
const objects = instancesOf(ChordObject, "remove");
const host = createHost({ onError: () => {} });
/** Loads the plugin; only the host keeps its facet. */
const load = (): [WeakRef<object>, Promise<unknown>] => {
const made = defineFacet((h, self) => {
servePure(h, "v1", 1);
h.serve(Names, { compose: (table) => table.map(({ value }) => value) });
self.contribute(Names, "x");
});
return [new WeakRef(made), host.load([{ name: "@test/app", facet: made }], context)];
};
const [facet, loading] = load();
await loading;
await host.get(Pure, {}, context).then((pure) => pure.release());
await host.reload([], context);
await flushed();
expect(objects).toHaveLength(1);
await expectCollected(objects[0]!, facet);
await host.close(context);
});
});
describe("reload: a parked attempt's handover (fix round 1)", () => {
it("keeps a dependency held only by a parked attempt alive through reload", async () => {
const A = local("astra.a", { version: 1, params: {}, members: {} });
const Dep = local("astra.dep", { version: 1, params: { id: int() }, members: {} });
const Missing = local("astra.missing", { version: 1, params: {}, members: {} });
let opens = 0;
let closes = 0;
const host = createHost({ onError: () => {} });
const depFacet = defineFacet((h) =>
h.serve(Dep, (_, self) => {
self.open(
async () => {
opens++;
},
() => {
closes++;
},
);
return {};
}),
);
const aFacet = () =>
defineFacet((h) =>
h.serve(A, (_, self) => {
self.get(Dep, { id: 1 });
self.get(Missing, {});
return {};
}),
);
await host.load(
[
{ name: "@astra/dep", facet: depFacet },
{ name: "@astra/a", facet: aFacet() },
],
context,
);
await flushed();
expect(opens).toBe(1);
await host.reload(
[
{ name: "@astra/dep", facet: depFacet },
{ name: "@astra/a", facet: aFacet() },
],
context,
);
await flushed();
const counts = { opens, closes };
await host.close(context);
expect(counts).toEqual({ opens: 1, closes: 0 });
});
it("binds replacement requests before releasing the parked predecessor's", async () => {
const A = local("sol.parked", { version: 1, params: {}, members: {} });
const B = local("sol.native", { version: 1, params: { id: int() }, members: {} });
const Missing = local("sol.missing", { version: 1, params: {}, members: {} });
let opens = 0;
let closes = 0;
const b = defineFacet((h) =>
h.serve(B, (_, self) => {
self.open(
async () => ++opens,
() => {
closes++;
},
);
return {};
}),
);
const a = () =>
defineFacet((h) =>
h.serve(A, (_, self) => {
self.get(B, { id: 1 });
self.get(Missing, {});
return {};
}),
);
const host = createHost({ onError: () => {} });
try {
await host.load(
[
{ name: "@sol/b", facet: b },
{ name: "@sol/a", facet: a() },
],
context,
);
expect({ opens, closes }).toEqual({ opens: 1, closes: 0 });
await host.reload(
[
{ name: "@sol/b", facet: b },
{ name: "@sol/a", facet: a() },
],
context,
);
await delivered();
expect({ opens, closes }).toEqual({ opens: 1, closes: 0 });
} finally {
await host.close(context);
}
});
});
describe("reload: swaps one after another (fix round 1)", () => {
it("a second swap does not wait for the first swap's old call; that call still finishes on its old code", async () => {
const gate = Promise.withResolvers<void>();
const { host, errors } = await loaded((h) => servePure(h, "v1", 1, [gate.promise]), { closeTimeout: 60_000 });
const pure = await host.get(Pure, {}, context);
const old = pure.bump(context);
await flushed();
await reloadApp(host, (h) => servePure(h, "v2", 10));
let resolved = false;
const second = reloadApp(host, (h) => servePure(h, "v3", 100)).then(() => {
resolved = true;
});
await flushed();
// Not held up by v1's call (Fable, Opus F1): resolved within a few turns, the gate open for new calls.
expect(resolved).toBe(true);
await second;
expect(pure.label.value).toBe("v3");
gate.resolve();
expect(await old).toBe(1);
expect(await pure.bump(context)).toBe(101);
expect(errors).toEqual([]);
await host.close(context);
});
});
describe("reload: a reference detaching from a parked swap's old instance (Opus G1)", () => {
it("follows the old instance while its call drains, then keeps its last value with the failure once", async () => {
const Missing = local("app.missing", { version: 1, params: {}, members: {} });
const gate = Promise.withResolvers<void>();
const { host } = await loaded((h) => servePure(h, "v1", 1, [gate.promise]), { closeTimeout: 60_000 });
const pure = await host.get(Pure, {}, context);
const calls = changesOf(await pure.n.watch(context));
const bumping = pure.bump(context);
await flushed();
const parked = defineFacet((h) =>
h.serve(Pure, (_, self) => {
self.get(Missing, {});
return { n: constant(9), label: constant("v2"), bump: async () => 0 };
}),
);
const report = await host.reload([{ name: "@test/app", facet: parked }], context);
expect(report.objects[0]).toMatchObject({ kind: "parked" });
expect(pure.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } });
// The old instance's call still commits: the reference follows it until it retires.
gate.resolve();
expect(await bumping).toBe(1);
await delivered();
expect([pure.n.value, calls.map((call) => call.kind), valuesOf(calls)[0]]).toEqual([1, ["value", "failed"], 1]);
await host.close(context);
});
});
describe("a live failure: an older version's mapping follows the admitted commits (final review)", () => {
it("stops an older reference after its instance's admitted final commit published (Astra, G1)", async () => {
const V1 = local("app.admitted", { version: 1, params: {}, members: { n: int() } });
const V2 = evolve(V1, (e) => e.state("n", obj({ n: int() })));
const State = defineState("state", {
version: 1,
schema: obj({ n: int() }),
initial: () => ({ n: 0 }),
persist: true,
checkpointWhen: () => false,
});
const store = await memoryStore();
let own: SingletonHandle<{ n: number }> | undefined;
let self: StoreSelf | undefined;
const facet = defineFacet((h) =>
h.serve(
V2,
(_, declared) => {
self = declared;
own = declared.state(State);
return { n: own };
},
{ accepts: { 1: { state: { n: (value) => value.n } } } },
),
);
const host = createHost({ store, onError: () => {} });
await host.load([{ name: "@app/admitted", facet }], context);
const old = await host.get(V1, {}, context);
const current = await host.get(V2, {}, context);
const calls = changesOf(await old.n.watch(context));
// A commit admitted before the failure: v1's mapping and v2's member both get it, then the failure.
const held = holdNextCommit(store);
const change = own!.change((draft) => {
draft.n = 5;
}, context);
await held.entered;
failLive(attempted, self!, new ChordError({ code: "unavailable", message: "boom" }));
held.release();
await change;
await delivered();
expect({ old: old.n.value, current: current.n.value, calls: valuesOf(calls) }).toEqual({
old: 5,
current: { n: 5 },
calls: [5, { failed: "boom" }],
});
old.release();
current.release();
await host.close(context);
await store.close(context);
});
});
describe("a native restart: references take their last values after the admitted commits (final review)", () => {
it("keeps the value of a commit admitted before the restart's seal while the new instance opens (Sol, G1)", async () => {
const Type = local("app.restart-admitted", { version: 1, params: {}, members: { x: obj({ n: int() }) } });
const Doc = defineState("doc", {
version: 1,
schema: obj({ n: int() }),
initial: () => ({ n: 0 }),
persist: true,
checkpointWhen: () => false,
});
const facet = (open: () => Promise<number>) =>
defineFacet((h) =>
h.serve(Type, (_, self) => {
counter = self.state(Doc);
self.open(open, () => {});
return { x: counter };
}),
);
const store = await memoryStore();
const host = createHost({ store, onError: () => {} });
const opening = Promise.withResolvers<number>();
let counter: SingletonHandle<{ n: number }> | undefined;
await host.load([{ name: "@app/restart", facet: facet(async () => 0) }], context);
const ref = await host.get(Type, {}, context);
// A commit admitted to the store (no running call's promise), then a restart whose new native stays opening.
const held = holdNextCommit(store);
const outgoing = counter!;
const change = outgoing.change((draft) => {
draft.n = 1;
}, context);
await held.entered;
await host.reload([{ name: "@app/restart", facet: facet(() => opening.promise) }], context);
await delivered();
held.release();
await change;
await delivered();
expect([ref.state.value.kind, ref.x.value]).toEqual(["loading", { n: 1 }]);
opening.resolve(1);
await host.close(context);
await store.close(context);
});
});