" ada adds ' tea ':
console.log(" ada adds ' ':", refused);
9. lists changed, rolled out to: server, list, web, tui
- on list|groceries: todo.store swapped, todo.todos(groceries) parked
+ on list|groceries: todo.store swapped, todo.todos(groceries) swapped
ada adds ' tea ': [ ] tea (ada)
ada adds ' ': { code: 'invalid', reason: 'args' }
The watcher marked @todo/lists dirty when the file changed; applyChanges rebuilt it (one esbuild call per facet)
@@ -1190,8 +1190,10 @@ wait at a gate; calls already running finish on the old code.
and references move to its members by name, each with one frame from its last value.
The old attempt is sealed once its calls settled; its query leaves with it, so later commits run only the new one.
-The views' new attempts need the store, which was still between steps 1 and 2 when they checked it, so they
-parked for that moment and went live with the new code right after. Ada's reference waited a moment, and her next add was trimmed.
+
The views were swapped too, and before the store: Chord starts the fates dependents first, so each view's new
+attempt found the store still live, read its old values until step 2 and moved to its new members with one frame;
+its calls to the store waited at the gate. Had the store gone first, the views would have found it between steps 1
+and 2 and parked for that moment. Ada's reference never showed loading, and her next add was trimmed.
Had the store kept its window as a one-time read in self.open, it would count as holding a native and restart
at every reload of its plugin: running calls drained, natives closed and opened again, a new instance.
Ada's blank todo is now empty after trimming, so the store refused it with
diff --git a/packages/chord/src/host/host.ts b/packages/chord/src/host/host.ts
index ac11ada8e..967dd262c 100644
--- a/packages/chord/src/host/host.ts
+++ b/packages/chord/src/host/host.ts
@@ -757,8 +757,8 @@ export class ChordHost
, context: Context): readonly StartedFate[] {
const { staged, changed, removed } = plan;
@@ -787,7 +787,7 @@ export class ChordHost
();
for (const [name, served] of types) if (published.has(served.plugin)) names.add(name);
for (const [name, { plugin }] of staged.registries) if (published.has(plugin)) names.add(name);
- const started = plan.fates.map((fate) => this.#startFate(fate));
+ const started = this.#startFates(plan.fates);
for (const type of types.values()) {
if (type.kind === "local" && type.hostLifetime) this.#object(type, EMPTY_PARAMS, undefined, context);
}
@@ -795,6 +795,23 @@ export class ChordHost
();
+ for (const fate of fates) if (fate.kind === "end") started.set(fate, this.#startFate(fate));
+ for (const fate of fates.toReversed()) if (fate.kind !== "end") started.set(fate, this.#startFate(fate));
+ // Every fate started above.
+ return fates.map((fate) => started.get(fate)!);
+ }
+
/**
* Starts one object's fate after the commit (slice 7b §4): a removed type's object ends, its holders waiting
* `not_served`; any other swaps, restarts or retries on its new served type.
diff --git a/packages/chord/src/host/object.ts b/packages/chord/src/host/object.ts
index d6971c482..220c2ebed 100644
--- a/packages/chord/src/host/object.ts
+++ b/packages/chord/src/host/object.ts
@@ -109,7 +109,7 @@ export type ReloadFate = "swap" | "restart" | "retry";
/**
* A pure swap in progress (slice 7b §5): the attempt it replaces (its calls run on, then it is sealed), whether the new
* attempt parked (holders then see what it shows, and the old one's contributions left the tables), and the end of
- * the swap (its new attempt live, parked or failed, or the object ended), which calls arriving meanwhile wait for.
+ * the swap (its new attempt live, parked or failed, or the object ended), which the old attempt's retirement waits for.
*/
type Swap = { readonly old: Attempt; parked: boolean; readonly done: PromiseWithResolvers };
@@ -226,6 +226,8 @@ export class ChordObject implements Target {
#retiring: Map> | undefined;
/** Its live queries by table, across its attempts; made by the first table it declares. */
#liveQueries: LiveQueries | undefined;
+ /** Resolves at its next change of state, for calls waiting at the gate; made by the first one. */
+ #changed: PromiseWithResolvers | undefined;
constructor(
host: AnyHost,
@@ -379,11 +381,10 @@ export class ChordObject implements Target {
/**
* Calls a method of the live object for `caller`, bound at `version` (an older one maps its arguments and result,
- * Q10). While a reload's pure swap runs, the call waits for its new attempt (the gate), bounded by `readyTimeout`.
+ * Q10). While an attempt activates (a reload's swap or restart), the call waits for its outcome (the gate).
*/
async call(method: string, args: unknown, context: Context, caller: Caller, version: number): Promise {
- const state = this.#state;
- if (state.kind === "activating" && state.swap !== undefined) await this.#gate(state.swap, context);
+ if (this.#state.kind === "activating") await this.#gate(context);
return this.#live().call(method, args, context, caller, version);
}
@@ -464,11 +465,34 @@ export class ChordObject implements Target {
return this.#live().keyMapping(member, version);
}
- /** Waits until `swap` ended (its new attempt live, parked, failed, or the object ended). */
- async #gate(swap: Swap, context: Context): Promise {
- // No timer of its own: the swap ends when its new attempt is live, parked or failed, and `readyTimeout` bounds
- // the new attempt's own steps (Fable).
- await awaitWithContext(swap.done.promise, context);
+ /**
+ * Waits while an attempt activates until the object is live, failed or ended (`#live` then calls or throws);
+ * rejects with the cause once the attempt parks on a failed dependency (`failed { waiting }`), and with
+ * `unavailable/park_timeout` after `readyTimeout`, as a call parked on a reference. A hold over a link stays bound
+ * while the old instance still changes (a swap, a restart's drain), so its consumer sees `live` and sends calls
+ * that arrive here: they wait for the new attempt, never `not_ready` (decision "Swaps: dependents first").
+ */
+ async #gate(context: Context): Promise {
+ const { ready } = this.host.timeouts;
+ const due = Date.now() + ready;
+ for (;;) {
+ const state = this.#state;
+ if (state.kind !== "activating") return;
+ if (state.shown.kind === "failed") throw state.shown.cause.error;
+ const left = due - Date.now();
+ if (left <= 0) {
+ const message = `${this.label}: a call waited ${ready} ms for its object to go live`;
+ throw new ChordError({ code: "unavailable", reason: "park_timeout", message });
+ }
+ this.#changed ??= Promise.withResolvers();
+ const timer = Promise.withResolvers();
+ const timeout = setTimeout(timer.resolve, left);
+ try {
+ await awaitWithContext(Promise.race([this.#changed.promise, timer.promise]), context);
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
}
/** The keyed member `member` of the live object: its ultimate slot (slice 6a §12). */
@@ -1098,6 +1122,7 @@ export class ChordObject implements Target {
/** Tells every holder that the state changed: references first follow what it shows now (`followed`). */
#notify(): void {
+ this.#wakeGate();
const holders = [...this.#holders];
// In one batch when some reference's members move (one frame each, a diamond over several sees one change); no
// commit at all for references that used none (activation churn).
@@ -1109,6 +1134,12 @@ export class ChordObject implements Target {
for (const holder of holders) holder.moved();
}
+ /** Calls waiting at the gate check the state again. */
+ #wakeGate(): void {
+ this.#changed?.resolve();
+ this.#changed = undefined;
+ }
+
/**
* Marks the object ended: forgotten by the host, waits rejected, references to it ended with `error` (and gone from
* its type's presence), its job runs ended (signals fired, waiters rejected), its transactions refused, its attempt
@@ -1125,6 +1156,7 @@ export class ChordObject implements Target {
// Its swapped-out attempts end before its teardown completes (they share its documents).
const previous = retiring === undefined ? endings : Promise.all([endings, retiring]).then(() => {});
this.#state = { kind: "ended", ended: { kind: "ended", error } };
+ this.#wakeGate();
for (const records of this.#jobs.values()) records.end(error);
if (this.#flush !== undefined) clearTimeout(this.#flush.timer);
this.#flush = undefined;
diff --git a/packages/chord/test/examples/61-tutorial/main.ts b/packages/chord/test/examples/61-tutorial/main.ts
index f5405f49e..ea10299cb 100644
--- a/packages/chord/test/examples/61-tutorial/main.ts
+++ b/packages/chord/test/examples/61-tutorial/main.ts
@@ -204,9 +204,10 @@ if (terminal !== undefined) {
// 9. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
// store refuses it with Chord's `invalid/args`, which reaches Ada as it would a refused argument. The
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
-// the terminal. On the worker each object of the plugin takes its fate: the store opens no native, so it is swapped
-// in place (its live query runs again); the views wait for it, then go on with the new code. Ada's
-// reference follows them.
+// the terminal. On the worker each object of the plugin takes its fate, dependents first: each view swaps in place
+// while the store is still live, then the store, which opens no native, swaps in place too (its live query runs
+// again); the views read the store's old values until its new attempt is live, and their calls wait for it. Ada's
+// reference follows them and never shows `loading`.
// The edit: `add` in the `Todos` view, before and after.
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
diff --git a/packages/chord/test/host/object.test.ts b/packages/chord/test/host/object.test.ts
index 7096fb114..234a0e08c 100644
--- a/packages/chord/test/host/object.test.ts
+++ b/packages/chord/test/host/object.test.ts
@@ -11,6 +11,8 @@ import {
type FacetSelf,
type Host,
type HostError,
+ type PluginFacet,
+ type Reference,
type Self,
type StoreSelf,
untilAborted,
@@ -30,6 +32,7 @@ import {
flushed,
holdNextCommit,
instancesOf,
+ linked,
loaded,
memoryStore,
metered,
@@ -44,6 +47,7 @@ import {
StoreRestartable,
statesOf,
storeRestartable,
+ type TestPrincipal,
testCodec,
transported,
valuesOf,
@@ -1408,6 +1412,234 @@ describe("reload: fates (slice 7b §4)", () => {
});
});
+describe('reload: fates start dependents first (decision "Swaps: dependents first")', () => {
+ /** A provider: its name and `echo`, which answers it. */
+ const Base = local("app.base", { version: 1, params: {}, members: { name: str(), echo: method(undefined, str()) } });
+ /** `Base` v2: a member added, so a consumer of v2 binds only the new code. */
+ const BaseV2 = evolve(Base, (e) => e.addState("extra", str()));
+ /** A dependent with empty params (it contributes): its label over the provider's name, `ask` through `echo`. */
+ const User = local("app.user", { version: 1, params: {}, members: { label: str(), ask: method(undefined, str()) } });
+ /** A remote dependent held over a link. */
+ const Viewer = remote("app.viewer", {
+ version: 1,
+ params: {},
+ hostAddress: () => Server(),
+ variants: { v: { state: { label: str() }, methods: { ask: method(undefined, str()) } } },
+ });
+ const Names = defineRegistry("app.user-names", { version: 1 });
+
+ /** A host loaded with `facets`, its background errors collected. */
+ async function loadedFacets(facets: readonly PluginFacet[]) {
+ const errors: HostError[] = [];
+ const host = createHost({ onError: (error) => errors.push(error) });
+ await host.load(facets, context);
+ return { host, errors };
+ }
+
+ /** Serves `Base` as code `code`; its new code opens a native that waits for `opened` when given (a restart). */
+ function serveBase(h: FacetHost, code: string, opened?: Promise): void {
+ h.serve(Base, (_, self) => {
+ if (opened !== undefined)
+ self.open(
+ async () => await opened,
+ () => {},
+ );
+ return { name: constant(code), echo: async () => code };
+ });
+ }
+
+ /** A dependent's members as code `code` over `base`: its label over the provider's name, `ask` through `echo`. */
+ function over(own: Self, code: string, base: Reference) {
+ return {
+ label: own.derive([base.name], ([name]) => `${code} ${name}`),
+ ask: async (ctx: Context) => `${code}>${await base.echo(ctx)}`,
+ };
+ }
+
+ /** Serves `User` as code `code` over `Base`. */
+ function serveUser(h: FacetHost, code: string): void {
+ h.serve(User, (_, own) => over(own, code, own.get(Base, {})));
+ }
+
+ it("a pure dependent and its provider in one plugin both swap: no parking, its contribution replaced in one batch", async () => {
+ const composed: string[][] = [];
+ const registry = defineFacet((h) =>
+ h.serve(Names, {
+ compose: (table) => {
+ const names = table.map(({ value }) => value);
+ composed.push(names);
+ return names;
+ },
+ }),
+ );
+ const app = (code: string) =>
+ defineFacet((h) => {
+ serveBase(h, code);
+ h.serve(User, (_, own) => {
+ (own as unknown as StoreSelf).contribute(Names, code);
+ return over(own, code, own.get(Base, {}));
+ });
+ });
+ const { host, errors } = await loadedFacets([
+ { name: "@test/app", facet: app("v1") },
+ { name: "@test/names", facet: registry },
+ ]);
+ const user = await host.get(User, {}, context);
+ const names = await host.registry(Names, context);
+ expect(names.value).toEqual(["v1"]);
+ const frames: string[][] = [];
+ (await names.watch(context)).start(async (call) => {
+ if (call.kind === "value") frames.push(call.value);
+ });
+ composed.length = 0;
+ const report = await host.reload(
+ [
+ { name: "@test/app", facet: app("v2") },
+ { name: "@test/names", facet: registry },
+ ],
+ context,
+ );
+ // The report keeps the order of the objects: the provider first.
+ expect(report.objects).toEqual([
+ { object: "app.base", kind: "swapped" },
+ { object: "app.user", kind: "swapped" },
+ ]);
+ await flushed();
+ // The old contribution left in the batch the new one entered: one composition, never a table without it.
+ expect(names.value).toEqual(["v2"]);
+ expect(composed).toEqual([["v2"]]);
+ await delivered();
+ expect(frames).toEqual([["v2"]]);
+ expect(user.label.value).toBe("v2 v2");
+ expect(await user.ask(context)).toBe("v2>v2");
+ expect(failures(errors)).toEqual([]);
+ await host.close(context);
+ });
+
+ it("a remote dependent's holds never see loading; its calls reach the provider's new code", async () => {
+ const serve = (h: FacetHost, code: string) => {
+ serveBase(h as FacetHost, code);
+ h.serve(Viewer, (_, _principal, own) => ({ variant: "v", members: over(own, code, own.get(Base, {})) }));
+ };
+ const { server, client } = await linked((h) => serve(h, "v1"));
+ const viewer = await client.get(Viewer, {}, context);
+ const states = statesOf(await viewer.state.watch(context));
+ const report = await server.reload(
+ [{ name: "@test/app", facet: defineFacet((h) => serve(h, "v2")) }],
+ context,
+ );
+ expect(report.objects).toEqual([
+ { object: "app.base", kind: "swapped" },
+ { object: "app.viewer", kind: "swapped" },
+ ]);
+ expect(await viewer.ask(context)).toBe("v2>v2");
+ await transported();
+ expect(viewer.label.value).toBe("v2 v2");
+ expect(states).toEqual([]);
+ await client.close(context);
+ await server.close(context);
+ });
+
+ it("a dependent whose new code needs the provider's new version parks, then goes live: never on the old instance", async () => {
+ const app = (code: string, next: boolean) =>
+ defineFacet((h) => {
+ if (next) {
+ h.serve(BaseV2, () => ({ name: constant(code), extra: constant("extra"), echo: async () => code }));
+ } else serveBase(h, code);
+ h.serve(User, (_, own) => {
+ if (next) {
+ const base = own.get(BaseV2, {});
+ return {
+ label: own.derive([base.name, base.extra], ([name, extra]) => `${code} ${name} ${extra}`),
+ ask: async (ctx: Context) => `${code}>${await base.echo(ctx)}`,
+ };
+ }
+ return over(own, code, own.get(Base, {}));
+ });
+ });
+ const { host, errors } = await loadedFacets([{ name: "@test/app", facet: app("v1", false) }]);
+ const user = await host.get(User, {}, context);
+ const report = await host.reload([{ name: "@test/app", facet: app("v2", true) }], context);
+ // Started first, the dependent's v2 request does not bind the provider still on v1 (`ChordObject.hold`): it
+ // waits unbound, then binds the provider's swap and parks until its new instance is live.
+ expect(report.objects).toEqual([
+ { object: "app.base", kind: "swapped" },
+ {
+ object: "app.user",
+ kind: "parked",
+ cause: {
+ kind: "waiting",
+ error: expect.objectContaining({ message: expect.stringContaining("app.base v2 is not served here") }),
+ },
+ },
+ ]);
+ await flushed();
+ expect(user.state.value).toEqual({ kind: "live" });
+ expect(user.label.value).toBe("v2 v2 extra");
+ expect(await user.ask(context)).toBe("v2>v2");
+ expect(errors.filter(({ kind }) => kind === "activation_failed" || kind === "object_failed")).toEqual([]);
+ await host.close(context);
+ });
+
+ it("a pure dependent of a provider that restarts swaps; its calls park until the provider's new instance is live", async () => {
+ const opened = Promise.withResolvers();
+ const app = (code: string, natives: boolean) =>
+ defineFacet((h) => {
+ serveBase(h, code, natives ? opened.promise : undefined);
+ serveUser(h, code);
+ });
+ const { host } = await loadedFacets([{ name: "@test/app", facet: app("v1", false) }]);
+ const user = await host.get(User, {}, context);
+ const report = await host.reload([{ name: "@test/app", facet: app("v2", true) }], context);
+ expect(report.objects).toEqual([
+ { object: "app.base", kind: "restarted" },
+ { object: "app.user", kind: "swapped" },
+ ]);
+ await flushed();
+ // Live on its new code over the provider's last values while the provider restarts.
+ expect(user.state.value).toEqual({ kind: "live" });
+ expect(user.label.value).toBe("v2 v1");
+ const asked = user.ask(context);
+ await flushed();
+ opened.resolve();
+ expect(await asked).toBe("v2>v2");
+ await flushed();
+ expect(user.label.value).toBe("v2 v2");
+ await host.close(context);
+ });
+
+ it("a provider that went live after its dependent (a retry after a failure) swaps first: the dependent parks and recovers", async () => {
+ const selves: Self[] = [];
+ const app = (code: string) =>
+ defineFacet((h) => {
+ h.serve(Base, (_, own) => {
+ selves.push(own);
+ return { name: constant(code), echo: async () => code };
+ });
+ serveUser(h, code);
+ });
+ const { host } = await loadedFacets([{ name: "@test/app", facet: app("v1") }]);
+ const user = await host.get(User, {}, context);
+ const base = await host.get(Base, {}, context);
+ failLive(attempted, selves.at(-1)!, new ChordError({ code: "unavailable", message: "boom" }));
+ await flushed();
+ base.retry();
+ await flushed();
+ expect(base.state.value).toEqual({ kind: "live" });
+ const report = await host.reload([{ name: "@test/app", facet: app("v2") }], context);
+ // The objects in the order they went live: the dependent first, so the provider's swap starts before it.
+ expect(report.objects).toEqual([
+ { object: "app.user", kind: "parked", cause: expect.objectContaining({ kind: "waiting" }) },
+ { object: "app.base", kind: "swapped" },
+ ]);
+ await flushed();
+ expect(user.state.value).toEqual({ kind: "live" });
+ expect(user.label.value).toBe("v2 v2");
+ expect(await user.ask(context)).toBe("v2>v2");
+ 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();
diff --git a/packages/chord/test/remote/hold.test.ts b/packages/chord/test/remote/hold.test.ts
index cfb3d900a..6ad32fc6b 100644
--- a/packages/chord/test/remote/hold.test.ts
+++ b/packages/chord/test/remote/hold.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { type Context, BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
-import { bool, evolve, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
+import { bool, evolve, hostKind, int, keyed, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { broken, ChordError } from "../../src/data/index.ts";
import type * as Delta from "../../src/delta/index.ts";
import { decoder } from "../../src/delta/index.ts";
@@ -20,8 +20,10 @@ import {
fakeTimers,
flushed,
forEachCodec,
+ holdNextCommit,
instancesOf,
linked,
+ memoryStore,
PlacedNotes,
partValue,
RawPeer,
@@ -1367,3 +1369,215 @@ describe("a hold across a swap whose mapping fails (final review)", () => {
await server.close(context);
});
});
+
+describe('calls over a link while a swap parks (decision "Swaps: dependents first")', () => {
+ /** A provider on the server: its name, its keys (`entries`) and `set`, which writes key `x`. */
+ const Provider = local("app.gate-provider", {
+ version: 1,
+ params: {},
+ members: { name: str(), entries: keyed(obj({ n: int() })), set: method(int()) },
+ });
+ const ENTRIES = { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }), keyed: true } as const;
+ const Entries = defineState("entries", ENTRIES);
+ const PersistedEntries = defineState("entries", { ...ENTRIES, persist: true, checkpointWhen: () => false });
+ /** A local type whose native opens once `opened` resolves. */
+ const Slow = local("app.gate-slow", { version: 1, params: {}, members: { name: str() } });
+ /** A remote object over the provider: `bump` answers its code's number. */
+ const Gated = remote("app.gated", {
+ version: 1,
+ params: {},
+ hostAddress: () => Server(),
+ variants: { v: { state: { label: str() }, methods: { bump: method(undefined, int()) } } },
+ });
+
+ /** Serves `Slow`, whose native opens once `opened` resolves. */
+ function serveSlow(h: FacetHost, opened: Promise): void {
+ h.serve(Slow, (_, self) => {
+ self.open(
+ async () => {
+ await opened;
+ return null;
+ },
+ () => {},
+ );
+ return { name: constant("slow") };
+ });
+ }
+
+ /** Serves the provider labelled `name`, needing `Slow` when `slow`, its keys persisted when `persisted`. */
+ function serveProvider(h: FacetHost, name: string, options: { slow?: boolean; persisted?: boolean }) {
+ h.serve(Provider, (_, self) => {
+ if (options.slow === true) self.get(Slow, {});
+ const entries = self.state(options.persisted === true ? PersistedEntries : Entries);
+ return {
+ name: constant(name),
+ entries,
+ set: async (n: number, ctx: Context) => {
+ await entries.change(
+ "x",
+ (draft) => {
+ draft.n = n;
+ },
+ ctx,
+ );
+ return null;
+ },
+ };
+ });
+ }
+
+ /**
+ * Serves `Gated` as code `code`: its label over the provider's name (or its key `k`), `bump` first awaiting `gate`;
+ * needing `Slow` too when `slow`.
+ */
+ function serveGated(
+ h: FacetHost,
+ code: number,
+ options: { gate?: Promise; pin?: boolean; slow?: boolean },
+ ) {
+ h.serve(Gated, (_, _principal, self) => {
+ const provider = self.get(Provider, {});
+ if (options.slow === true) self.get(Slow, {});
+ const label =
+ options.pin === true
+ ? self.derive([provider.entries.at("k")], ([entry]) => `v${code} ${entry.n}`)
+ : self.derive([provider.name], ([name]) => `v${code} ${name}`);
+ return {
+ variant: "v",
+ members: {
+ label,
+ bump: async () => {
+ await options.gate;
+ return code;
+ },
+ },
+ };
+ });
+ }
+
+ it("a call arriving after the swap parked, while an old call drains, waits for the new attempt (never not_ready)", async () => {
+ const gate = Promise.withResolvers();
+ const opened = Promise.withResolvers();
+ const { server, client } = await linked((h) => {
+ serveProvider(h, "p1", {});
+ serveGated(h, 1, { gate: gate.promise });
+ });
+ const gated = await client.get(Gated, {}, context);
+ const old = gated.bump(context);
+ await transported();
+ const report = await server.reload(
+ [
+ {
+ name: "@test/app",
+ facet: defineFacet((h) => {
+ serveSlow(h, opened.promise);
+ serveProvider(h, "p2", {});
+ serveGated(h, 2, { slow: true });
+ }),
+ },
+ ],
+ context,
+ );
+ // The new code of `Gated` waits for `Slow`, a new type still opening: its swap parks.
+ expect(report.objects).toEqual([
+ { object: "app.gate-provider", kind: "swapped" },
+ { object: "app.gated", kind: "parked", cause: expect.objectContaining({ kind: "waiting" }) },
+ ]);
+ // The old call still runs, so the hold stays bound: the client sees live and sends.
+ const outcome = gated.bump(context).then(
+ (value) => ({ value }),
+ (error: ChordError) => ({ code: error.code }),
+ );
+ await transported();
+ expect(gated.state.value).toEqual({ kind: "live" });
+ opened.resolve();
+ expect(await outcome).toEqual({ value: 2 });
+ gate.resolve();
+ expect(await old).toBe(1);
+ await transported();
+ expect(gated.label.value).toBe("v2 p2");
+ await client.close(context);
+ await server.close(context);
+ });
+
+ it("a call already waiting at the gate when the swap parks is answered with its hold's loading, never not_ready", async () => {
+ const store = await memoryStore();
+ const server = createHost({
+ address: Server(),
+ principal: { kind: "server" },
+ store,
+ onError: () => {},
+ });
+ const client = createHost({ onError: () => {} });
+ const providerV1 = defineFacet((h) => serveProvider(h, "p1", { persisted: true }));
+ await server.load(
+ [
+ { name: "@test/provider", facet: providerV1 },
+ { name: "@test/app", facet: defineFacet((h) => serveGated(h, 1, {})) },
+ ],
+ context,
+ );
+ await client.load([], context);
+ const [up, down] = memoryLinkPair();
+ server.accept(up, { principal: { kind: "owner" }, codec: testCodec() }, context);
+ client.connect(down, { principal: { kind: "server" }, codec: testCodec() }, context);
+ try {
+ const gated = await client.get(Gated, {}, context);
+ const provider = await server.get(Provider, {}, context);
+ // The provider's line is held by a commit: a key loads only after it.
+ const held = holdNextCommit(store);
+ const setting = provider.set(1, context);
+ await held.entered;
+ // `Gated`'s new code pins the provider's key `k`: its swap stays in place while the key waits for the line.
+ const appV2 = defineFacet((h) => serveGated(h, 2, { pin: true }));
+ const report = await server.reload(
+ [
+ { name: "@test/provider", facet: providerV1 },
+ { name: "@test/app", facet: appV2 },
+ ],
+ context,
+ );
+ expect(report.objects).toEqual([{ object: "app.gated", kind: "swapped" }]);
+ const states = statesOf(await gated.state.watch(context));
+ const outcome = gated.bump(context).then(
+ (value) => ({ value }),
+ (error: ChordError) => ({ code: error.code, message: error.message }),
+ );
+ await transported();
+ // The provider swaps too while the key waits; its new code needs \`Slow\`, still opening. Once the key loaded,
+ // the swap of \`Gated\` checks again, finds the provider loading and parks, with the call waiting at the gate.
+ const opened = Promise.withResolvers();
+ const reloading = server.reload(
+ [
+ {
+ name: "@test/provider",
+ facet: defineFacet((h) => {
+ serveSlow(h, opened.promise);
+ serveProvider(h, "p2", { persisted: true, slow: true });
+ }),
+ },
+ { name: "@test/app", facet: appV2 },
+ ],
+ context,
+ );
+ held.release();
+ await setting;
+ expect((await reloading).objects).toEqual([
+ { object: "app.gate-provider", kind: "parked", cause: expect.objectContaining({ kind: "waiting" }) },
+ ]);
+ await transported();
+ // Parked: its hold left the old instance and tells the client \`loading\`; the call that waited at the gate is
+ // answered as every call in flight on a hold that leaves its object (§21), never \`not_ready\` while live.
+ expect(states).toEqual(["loading"]);
+ expect(await outcome).toEqual({ code: "unavailable", message: "hold 0: its object rebinds" });
+ opened.resolve();
+ await transported();
+ expect(states).toEqual(["loading", "live"]);
+ expect(await gated.bump(context)).toBe(2);
+ expect(gated.label.value).toBe("v2 0");
+ } finally {
+ await client.close(context);
+ await server.close(context);
+ }
+ });
+});