[] = [];
const probe = { made: (closure: object) => void made.push(new WeakRef(closure)) };
const source = (label: string) =>
[
'import { constant, defineFacet, local, str } from "@earendil-works/chord";',
'import { made } from "@test/probe";',
'const Item = local("test.gc", { version: 1, params: {}, members: { label: str() } });',
// A closure per evaluation, which only this evaluation's code holds.
`const state = { label: ${JSON.stringify(label)}, big: new Array(10_000).fill(${JSON.stringify(label)}) };`,
"made(state);",
"export default defineFacet((host) => host.serve(Item, () => ({ label: constant(state.label) })));",
].join("\n");
await writePlugin(root, "gc", "@test/gc", { "worker.ts": source("a") });
const modules = { ...HOST_PACKAGES, "@test/probe": probe };
const plugins = createPlugins({ roots: [root], kinds: kindsOf(modules), onError: () => {} }, context);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules }, context);
const first = await plugins.build(["@test/gc"], context);
await writeFiles(join(root, "gc"), { "worker.ts": source("b") });
const second = await plugins.build(["@test/gc"], context);
const reload = async (count: number) => {
for (let i = 0; i < count; i++) await plugins.reload(host, i % 2 === 0 ? second : first, context);
};
await reload(20);
const warm = await settledHeap();
await reload(200);
const after = await settledHeap();
expect(after - warm).toBeLessThan(2 * 1024 * 1024);
await expectCollected(...made.slice(0, made.length - 2));
await host.close(context);
plugins.close();
});
});
describe("end to end: a server and a worker", () => {
it("loads plugin directories, rolls an edit out (server, then worker), and keeps a broken edit old on both", async () => {
type P = { readonly kind: "server" } | { readonly kind: "worker" };
const root = await tempRoot(["contracts", "notes"]);
const { plugins } = component(root);
const [up, down] = memoryLinkPair();
const worker = createHost({ address: Worker(), principal: { kind: "worker" }, onError: () => {} });
worker.connect(up, { principal: { kind: "server" } }, context);
const server = createHost
({ principal: { kind: "server" }, onError: () => {} });
server.route(
Worker,
{ authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) },
context,
);
await plugins.load(worker, { kind: "worker", core: [], modules: HOST_PACKAGES }, context);
await plugins.load(server, { kind: "server", core: [], modules: HOST_PACKAGES }, context);
const notes = await server.get(Notes, {}, context);
const index = await server.get(Index, {}, context);
expect([notes.label.value, index.label.value]).toEqual(["v1", "v1"]);
await notes.add(context);
const frames = changesOf(await notes.label.watch(context));
const steps = [plugins.step("server", server as Host
), plugins.step("worker", worker as Host
)];
await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n');
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 });
const rolled = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context);
expect(
rolled.steps.map(({ name, hosts }) => [name, reportOf(hosts).plugins.changed, reportOf(hosts).objects]),
).toEqual([
["server", ["@test/notes"], [{ object: "test.index", kind: "restarted" }]],
["worker", ["@test/notes"], [{ object: "test.notes", kind: "restarted" }]],
]);
await transported();
await delivered();
expect(valuesOf(frames)).toEqual(["v2"]);
expect([notes.label.value, notes.count.value, index.label.value]).toEqual(["v2", 1, "v2"]);
expect(plugins.state.value).toMatchObject({ dirty: [] });
await writeFile(join(root, "notes", "label.ts"), "export const label = ;\n");
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 });
const broken = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context);
expect(broken.steps.map(({ name, hosts }) => [name, failedNames(reportOf(hosts))])).toEqual([
["server", ["@test/notes"]],
["worker", ["@test/notes"]],
]);
await transported();
expect([notes.label.value, index.label.value, notes.state.value]).toEqual(["v2", "v2", { kind: "live" }]);
expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] });
await server.close(context);
await worker.close(context);
plugins.close();
});
});
describe("fix round 1", () => {
it("serializes reloads of one host: two started together keep both changes (Astra, Sol, Opus S1)", async () => {
const root = await tempRoot();
await itemPlugin(root, "a", "a1");
await itemPlugin(root, "b", "b1");
const { plugins } = component(root);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context);
await itemPlugin(root, "a", "a2");
const builtA = await plugins.build(["@test/a"], context);
await itemPlugin(root, "b", "b2");
const builtB = await plugins.build(["@test/b"], context);
const reports = await Promise.all([plugins.reload(host, builtA, context), plugins.reload(host, builtB, context)]);
expect(reports.map(({ plugins: { changed } }) => changed)).toEqual([["@test/a"], ["@test/b"]]);
const label = async (dir: string) =>
(await host.get(local(`test.${dir}`, { version: 1, params: {}, members: { label: str() } }), {}, context))
.label.value;
expect([await label("a"), await label("b")]).toEqual(["a2", "b2"]);
await host.close(context);
plugins.close();
});
it("applies the removals of the build it was given, never a later discovery's (Astra)", async () => {
const root = await tempRoot();
await itemPlugin(root, "a", "a1");
await itemPlugin(root, "b", "b1");
const { plugins } = component(root);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context);
await itemPlugin(root, "a", "a2");
const first = await plugins.build(["@test/a"], context);
await rm(join(root, "b"), { recursive: true });
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 });
const later = await plugins.build([], context);
expect([first.removed, later.removed]).toEqual([[], ["@test/b"]]);
const report = await plugins.reload(host, first, context);
expect(report.plugins).toEqual({ added: [], changed: ["@test/a"], removed: [] });
expect((await plugins.reload(host, later, context)).plugins.removed).toEqual(["@test/b"]);
await host.close(context);
plugins.close();
});
it("adopts what a reload committed when its wait aborts after the commit: a later reload keeps it (Astra)", async () => {
const root = await tempRoot();
await itemPlugin(root, "a", "a1");
await itemPlugin(root, "b", "b1");
const { plugins } = component(root);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context);
const Item = local("test.a", { version: 1, params: {}, members: { label: str() } });
const item = await host.get(Item, {}, context);
await itemPlugin(root, "a", "a2");
const first = await plugins.build(["@test/a"], context);
// The abort lands right after the reload's commit (its publication batch), while it waits for its report.
const { batch } = host as ChordHost;
const commit = batch.commit.bind(batch);
const controller = new AbortController();
const aborting = withAbortSignal(controller.signal, context);
const hook = vi.spyOn(batch, "commit").mockImplementation((adopt, ctx) => {
commit(adopt, ctx);
if (ctx === aborting) controller.abort();
});
expect(await rejection(plugins.reload(host, first, aborting))).toMatchObject({
code: "aborted",
details: { committed: { added: [], changed: ["@test/a"], removed: [], failed: [] } },
});
hook.mockRestore();
// Restarted: the new attempt goes live once the old one ended.
await flushed();
expect(item.label.value).toBe("a2");
await itemPlugin(root, "b", "b2");
await plugins.reload(host, await plugins.build(["@test/b"], context), context);
await flushed();
expect(item.label.value).toBe("a2");
await host.close(context);
plugins.close();
});
it("leaves a plugin out of a host whose kind its rebuilt facets no longer have (Opus recheck R1)", async () => {
const root = await tempRoot();
await itemPlugin(root, "a", "a1");
const { plugins } = component(root);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context);
// The plugin keeps a server facet only.
await rm(join(root, "a", "worker.ts"));
await writeFiles(join(root, "a"), {
"server.ts": 'import { defineFacet } from "@earendil-works/chord";\nexport default defineFacet(() => {});',
});
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 });
const built = await plugins.build(["@test/a"], context);
expect(built.removed).toEqual([]);
expect((await plugins.reload(host, built, context)).plugins).toEqual({
added: [],
changed: [],
removed: ["@test/a"],
});
await host.close(context);
plugins.close();
});
it("reloads a structured clone of a build as the build itself: it is plain data (Astra)", async () => {
const root = await tempRoot();
await itemPlugin(root, "a", "a1");
const { plugins } = component(root);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context);
const item = await host.get(local("test.a", { version: 1, params: {}, members: { label: str() } }), {}, context);
await itemPlugin(root, "a", "a2");
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
const built = await plugins.build(["@test/a"], context);
expect((await plugins.reload(host, structuredClone(built), context)).plugins.changed).toEqual(["@test/a"]);
await flushed();
expect(item.label.value).toBe("a2");
// The marks clear only through the build object itself (a copy carries no snapshot).
const result = await plugins.rollout([plugins.step("worker", host)], structuredClone(built), context);
expect(result.stopped).toBeUndefined();
expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] });
await host.close(context);
plugins.close();
});
it("reads a changed package.json again at the next build: a new dependency is accepted (Astra, Sol)", async () => {
const root = await tempRoot();
const tools = await tempRoot();
await writeFiles(tools, { "npm.mjs": "process.exit(0);" });
const dir = await writePlugin(root, "a", "@test/a", {
"worker.ts": 'import { label } from "./helper.ts";\nexport default { setup: () => {}, label };',
"helper.ts": 'export const label = "old";',
});
const plugins = createPlugins(
{
roots: [root],
kinds: kindsOf(HOST_PACKAGES),
install: { npmCommand: [process.execPath, join(tools, "npm.mjs")] },
onError: () => {},
},
context,
);
expect((await plugins.build([], context)).facets.map(({ kind }) => kind)).toEqual(["script"]);
await writeFiles(dir, {
"package.json": JSON.stringify({ name: "@test/a", type: "module", dependencies: { "new-dep": "1.0.0" } }),
});
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ install: ["@test/a"] }), { timeout: 5_000 });
await writePackage(dir, "new-dep", { "index.js": 'exports.label = "new";' });
await writeFiles(dir, { "helper.ts": 'export { label } from "new-dep";' });
await plugins.installDependencies("@test/a", context);
expect(plugins.state.value).toMatchObject({ set: false });
const built = await plugins.build(["@test/a"], context);
expect(built.facets).toEqual([expect.objectContaining({ kind: "script", plugin: "@test/a" })]);
expect(plugins.discovery.plugins[0]!.dependencies).toEqual(["new-dep"]);
plugins.close();
});
});
describe("the reload queues and garbage collection", () => {
it("lets a rejected new facet go once its report is dropped, the kept old one live (Sol)", async () => {
const root = await tempRoot();
const made: WeakRef[] = [];
const probe = { made: (value: object) => made.push(new WeakRef(value)) };
const source = (bad: boolean) =>
[
'import { constant, defineFacet, int, local } from "@earendil-works/chord";',
'import { made } from "@test/probe";',
`const state = { bad: ${bad}, big: new Array(10000).fill("payload") };`,
"made(state);",
'const Type = local("test.failed-gc", { version: 1, params: {}, members: { n: int() } });',
"export default defineFacet((h) => {",
'\tif (state.bad) throw new Error("bad setup");',
"\th.serve(Type, () => ({ n: constant(state.big.length) }));",
"});",
].join("\n");
const dir = await writePlugin(root, "failed", "@test/failed", { "worker.ts": source(false) });
const modules = { ...HOST_PACKAGES, "@test/probe": probe };
const plugins = createPlugins({ roots: [root], kinds: kindsOf(modules), onError: () => {} }, context);
const host = createHost({ onError: () => {} });
await plugins.load(host, { kind: "worker", core: [], modules }, context);
await writeFiles(dir, { "worker.ts": source(true) });
const built = await plugins.build(["@test/failed"], context);
// The report (its failure's error holds the new facet's closure) is dropped here; the queues must not keep it.
expect((await plugins.reload(host, built, context)).failed.map(({ plugin }) => plugin)).toEqual(["@test/failed"]);
await expectCollected(made[1]!);
expect(made[0]!.deref()).toBeDefined();
await host.close(context);
plugins.close();
});
});