diff --git a/packages/chord/src/node/app/README.md b/packages/chord/src/node/app/README.md
index cbead47df..e823c55c8 100644
--- a/packages/chord/src/node/app/README.md
+++ b/packages/chord/src/node/app/README.md
@@ -33,6 +33,8 @@ const app = await startServer
({
uploads: 64, importBytes: 4 * 2 ** 30, importEntries: 100_000 },
web: { entry: "./src/web/main.tsx", page: (request, assets) => shell(request.nonce, assets) }, // slice 11
tui: { entry: "./src/tui/main.ts" },
+ base: ["./src/app/", "./start.ts"], // base code besides worker entries and the base bundles' inputs
+ timeouts: { closeTimeout: 30_000, readyTimeout: 60_000 }, // every host's (server and workers; the defaults)
onError: (error) => log(error), // AppError: { kind: "host" | "http" | "workers" | "plugins", error }
}, ctx);
console.log(`Open ${app.ownerLink()}`);
@@ -196,9 +198,11 @@ archive leaves nothing. The next hold starts the worker on the imported store an
## Plugins
Roots: the app's `root` (required: a plugin of it failing stops `startServer`) and `/plugins/` (the user's:
-installs go there). `app.plugins.state` is the plugins component's marks for the app's "apply changes" button;
-`applyChanges` builds the dirty plugins (and installed ones) and rolls out to the server, then each worker kind;
-nothing is applied by itself.
+installs go there). `app.plugins.state` is the plugins component's marks for the app's "apply changes" button; its
+`base` turns true when base code changes (the app restarts its hosts): `base`'s files and directories, each worker
+kind's `entry`, and every file the web and terminal base bundles were built from (esbuild's metafile), never files under
+`node_modules` (an npm upgrade is a restart anyway). `applyChanges` builds the dirty plugins (and installed ones) and
+rolls out to the server, then each worker kind; nothing is applied by itself.
## Internals
diff --git a/packages/chord/src/node/app/app.ts b/packages/chord/src/node/app/app.ts
index 79852d5c2..039c910b2 100644
--- a/packages/chord/src/node/app/app.ts
+++ b/packages/chord/src/node/app/app.ts
@@ -4,6 +4,7 @@
// the server's plugins with the kit's own facets (the start-data object, sessions, the owner link, pairing, files),
// and gives the app the controls it needs: the owner link, `applyChanges`, installs, export and import per address.
+import { statSync } from "node:fs";
import { join, resolve } from "node:path";
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
import { type Context, withAbortSignal } from "../../context/index.ts";
@@ -134,6 +135,13 @@ export type AppOptions = {
* send (the principal of their link). Each worker's is its `startWorker`'s.
*/
readonly principalAttributes?: PrincipalAttributes
;
+ /**
+ * The app's base code beside what the kit knows (files or directories: its API modules, its start script): a change
+ * sets `plugins.state.base`. The kit adds each worker kind's `entry` and every file its base bundles were built from.
+ */
+ readonly base?: readonly string[];
+ /** The `closeTimeout` and `readyTimeout` of every host (the server's and each worker's): 30 s and 60 s by default. */
+ readonly timeouts?: AppTimeouts;
readonly onError: (error: AppError) => void;
} & (OwnerPrincipal extends P ? unknown : { readonly "the app's principal type must have { kind: 'owner' }": never });
@@ -152,6 +160,9 @@ export type AppLimits = {
readonly importEntries?: number;
};
+/** The hosts' timeouts in milliseconds (`HostOptions.closeTimeout`, `HostOptions.readyTimeout`). */
+export type AppTimeouts = { readonly closeTimeout?: number; readonly readyTimeout?: number };
+
/** The kit's web surface options (`webSurface`'s, its kind `web` and plugins the kit's). */
export type AppWeb = Pick;
@@ -182,7 +193,10 @@ export interface App {
ownerLink(): string;
/** The owner token file's path (a device session of the owner, mode 0600). */
readonly ownerTokenFile: string;
- /** Builds the dirty plugins and rolls them out: the server, every worker kind, then the clients; never by itself. */
+ /**
+ * Builds the dirty plugins and rolls them out: the server, every worker kind, then the clients; never by itself. Calls
+ * run one after another (a later one builds after the earlier one rolled out).
+ */
applyChanges(context: Context): Promise;
/**
* Writes the data the worker at `address` created (its store and finished files) as an archive at `path`, stopping
@@ -210,6 +224,8 @@ export type KitStart = {
readonly telemetry: boolean;
/** The cap of uploads under way per `Files` object (`limits.uploads`). */
readonly uploads?: number;
+ /** The app's host timeouts (`timeouts`), the worker's host takes them too. */
+ readonly timeouts: AppTimeouts;
};
/**
@@ -230,6 +246,7 @@ export async function startServer(options: AppOptions
, c
store,
telemetry,
...(principalAttributes === undefined ? {} : { principalAttributes }),
+ ...options.timeouts,
onError: (error) => onError({ kind: "host", error }),
});
const http = createHttp(
@@ -242,6 +259,11 @@ export async function startServer
(options: AppOptions
, c
},
context,
);
+ // Base code by kind: directories are watched as trees, files one by one (`watchBase`).
+ const base = [...(options.base ?? []), ...(options.workers ?? []).map(({ entry }) => entry)].map((path) =>
+ resolve(path),
+ );
+ const baseDirs = base.filter((path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true);
const plugins = createPlugins(
{
roots: [{ path: resolve(options.root ?? "plugins"), required: true }, layout.plugins],
@@ -253,6 +275,7 @@ export async function startServer
(options: AppOptions
, c
]),
),
install: options.install,
+ base: baseDirs,
publicPath: `${WEB_PREFIX}assets/`,
onError: (error) => onError({ kind: "plugins", error }),
},
@@ -300,6 +323,8 @@ export async function startServer
(options: AppOptions
, c
surfaces.push(surface);
core.push({ name: `chord.${surface.name}`, facet: surface.facet });
}
+ const files = base.filter((path) => !baseDirs.includes(path));
+ plugins.watchBase([...files, ...surfaces.flatMap(({ inputs }) => inputs)]);
await plugins.load(host, { kind: "server", core, modules: modulesOf(api, options.kinds.server) }, context);
await ownerTokenFile(layout.ownerToken, host, context);
app.started(link.renew(), await http.listening(context));
@@ -316,6 +341,8 @@ type Surface
= {
readonly facet: Facet
;
step(name: string): RolloutStep;
readonly base: string;
+ /** The files its base bundle was built from. */
+ readonly inputs: readonly string[];
};
/** The app's client surfaces, built (their base bundles) in order: the web's, the terminal's. */
@@ -411,6 +438,7 @@ function workersOf
(
files: { ...options.files },
telemetry: options.telemetry !== undefined,
...(options.limits?.uploads === undefined ? {} : { uploads: options.limits.uploads }),
+ timeouts: { ...options.timeouts },
};
return { kit, app: await kind.start(address, ctx) };
},
@@ -453,6 +481,8 @@ class RunningApp
implements App
{
readonly #options: AppOptions
;
#link = "";
#closing: Promise | undefined;
+ /** The latest `applyChanges`, settled to nothing: the next one starts after it. */
+ #applying: Promise = Promise.resolve();
constructor(
host: Host,
@@ -498,7 +528,19 @@ class RunningApp
implements App
{
return this.#surfaces.find(({ name }) => name === "tui");
}
- async applyChanges(context: Context): Promise {
+ applyChanges(context: Context): Promise {
+ // One build and rollout at a time: a later build rolled out before an earlier one would leave the older code
+ // deployed (each host applies the last set it is handed).
+ const applied = this.#applying.then(() => this.#applyChanges(context));
+ this.#applying = applied.then(
+ () => {},
+ () => {},
+ );
+ return applied;
+ }
+
+ /** One build of the dirty plugins and its rollout: the server, every worker kind, then the clients. */
+ async #applyChanges(context: Context): Promise {
const plugins = this.#component;
const built = await plugins.build(plugins.state.value.dirty, context);
const steps = [
diff --git a/packages/chord/src/node/app/index.ts b/packages/chord/src/node/app/index.ts
index 548ae1dc1..68356dd69 100644
--- a/packages/chord/src/node/app/index.ts
+++ b/packages/chord/src/node/app/index.ts
@@ -9,6 +9,7 @@ export {
type AppKind,
type AppOptions,
type AppPlugins,
+ type AppTimeouts,
type AppWorkerKind,
Server,
startServer,
diff --git a/packages/chord/src/node/app/worker.ts b/packages/chord/src/node/app/worker.ts
index 94f5b3fe7..397d9b603 100644
--- a/packages/chord/src/node/app/worker.ts
+++ b/packages/chord/src/node/app/worker.ts
@@ -65,6 +65,7 @@ export function startWorker(options: WorkerOptions
): voi
store,
telemetry: recorder,
...(principalAttributes === undefined ? {} : { principalAttributes }),
+ ...kit.timeouts,
onError: options.onError,
});
onClose((ctx) => host.close(ctx));
diff --git a/packages/chord/src/node/plugins/plugins.ts b/packages/chord/src/node/plugins/plugins.ts
index 2cbe8cc01..373eab134 100644
--- a/packages/chord/src/node/plugins/plugins.ts
+++ b/packages/chord/src/node/plugins/plugins.ts
@@ -133,6 +133,11 @@ export interface Plugins {
rollout(steps: readonly RolloutStep[], built: Built, context: Context): Promise;
/** Installs plugin `name`'s dependencies (`installDependencies`): its install mark clears, and it is dirty. */
installDependencies(name: string, context: Context): Promise;
+ /**
+ * Adds `files` to the app's base code (beside `base`): a change of one sets `base`. For files the app learns after
+ * creating the component, a base bundle's inputs; files under `node_modules` are never watched.
+ */
+ watchBase(files: readonly string[]): void;
/**
* Installs new plugins from git or npm into `root`, one of the roots (`installPlugin`), and marks `set`: the next
* build discovers and builds them. Returns their names.
@@ -288,6 +293,10 @@ class PluginsComponent implements Plugins {
return { steps: done, stopped: undefined };
}
+ watchBase(files: readonly string[]): void {
+ this.#watcher.baseFiles(files);
+ }
+
async installDependencies(name: string, context: Context): Promise {
if (!this.#discovered) await this.#discover(context);
const plugin = this.#discovery.plugins.find((each) => each.name === name);
diff --git a/packages/chord/src/node/plugins/watcher.ts b/packages/chord/src/node/plugins/watcher.ts
index 5f597d10e..8632b5fa7 100644
--- a/packages/chord/src/node/plugins/watcher.ts
+++ b/packages/chord/src/node/plugins/watcher.ts
@@ -49,6 +49,10 @@ export class PluginWatcher extends Source {
readonly #options: WatcherOptions;
readonly #roots: readonly string[];
readonly #baseDirs: readonly string[];
+ /** Base code given file by file (`baseFiles`), by real path. */
+ readonly #baseFiles = new Set();
+ /** The watches of the directories holding those files, each of the directory alone. */
+ readonly #baseWatchers = new Map();
/** The trees it watches: the roots, the base code, and plugin directories linked from outside the roots. */
readonly #trees = new Set();
/** Its watches, one per directory of its trees. */
@@ -174,10 +178,23 @@ export class PluginWatcher extends Source {
this.#changed();
}
+ /**
+ * Adds `files` to the base code: a change of one sets `base`. Each one's directory is watched alone (not its tree);
+ * files under ignored directories are skipped.
+ */
+ baseFiles(files: readonly string[]): void {
+ for (const file of files) {
+ const path = real(file);
+ if (path.split(sep).some((part) => IGNORED.has(part))) continue;
+ this.#baseFiles.add(path);
+ this.#watchBaseDir(dirname(path));
+ }
+ }
+
/** Marks what a change of the file or directory at `path` (absolute) asks for. */
mark(path: string): void {
if (path.split(sep).some((part) => IGNORED.has(part))) return;
- if (this.#baseDirs.some((dir) => inside(dir, path))) {
+ if (this.#baseFiles.has(path) || this.#baseDirs.some((dir) => inside(dir, path))) {
this.#baseChanged = true;
} else {
this.#markPlugin(path, this.#generation++);
@@ -187,8 +204,9 @@ export class PluginWatcher extends Source {
/** Stops watching. */
close(): void {
- for (const watcher of this.#watchers.values()) watcher.close();
+ for (const watcher of [...this.#watchers.values(), ...this.#baseWatchers.values()]) watcher.close();
this.#watchers.clear();
+ this.#baseWatchers.clear();
this.#trees.clear();
}
@@ -223,6 +241,22 @@ export class PluginWatcher extends Source {
return this.#options.kinds.some((kind) => FACET_EXTENSIONS.some((extension) => file === `${kind}${extension}`));
}
+ /** Watches directory `dir` alone (once) for base files: a change of one of them marks `base`. */
+ #watchBaseDir(dir: string): void {
+ if (this.#baseWatchers.has(dir) || this.#reported) return;
+ let watcher: FSWatcher;
+ try {
+ watcher = watch(dir, (_event, file) => {
+ if (file !== null && this.#baseFiles.has(join(dir, file))) this.mark(join(dir, file));
+ });
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.#failed(error);
+ return;
+ }
+ watcher.on("error", (error) => this.#failed(error));
+ this.#baseWatchers.set(dir, watcher);
+ }
+
/** Watches the tree at `dir` (once): it and every directory under it but ignored ones; a missing one is skipped. */
#watchTree(dir: string): void {
if (this.#trees.has(dir)) return;
diff --git a/packages/chord/src/node/surfaces/bundle.ts b/packages/chord/src/node/surfaces/bundle.ts
index c10a56387..0e0b6e7c3 100644
--- a/packages/chord/src/node/surfaces/bundle.ts
+++ b/packages/chord/src/node/surfaces/bundle.ts
@@ -4,19 +4,20 @@
// a terminal's one CommonJS script the sandbox evaluates with its own `require` (its allowed builtins only).
import { createHash } from "node:crypto";
-import { basename, extname } from "node:path";
+import { basename, extname, resolve } from "node:path";
import { build } from "esbuild";
import { CSS, JAVASCRIPT, mimeOf, type ServedFile } from "./served.ts";
/**
- * A built base bundle: its hash, its script's and stylesheet's paths (under the surface's prefix), and every file by
- * path.
+ * A built base bundle: its hash, its script's and stylesheet's paths (under the surface's prefix), every file by
+ * path, and the files esbuild read for it (absolute: the app's base code a change of which outdates the bundle).
*/
export type BaseBundle = {
readonly hash: string;
readonly script: string;
readonly style: string | undefined;
readonly files: ReadonlyMap;
+ readonly inputs: readonly string[];
};
/** Where a base bundle runs: a page (a script, its CSS and assets), or a terminal's sandbox (one CommonJS script). */
@@ -47,6 +48,7 @@ export async function buildBase(entry: string, platform: BasePlatform, prefix: s
jsx: "automatic",
define: { "process.env.NODE_ENV": '"production"' },
logLevel: "silent",
+ metafile: true,
});
const hash = createHash("sha256");
for (const output of [...result.outputFiles].sort((a, b) => (a.path < b.path ? -1 : 1))) {
@@ -67,5 +69,9 @@ export async function buildBase(entry: string, platform: BasePlatform, prefix: s
files.set(`${prefix}assets/${basename(output.path)}`, { mime: mimeOf(output.path), bytes: output.contents });
}
}
- return { hash: digest, script, style, files };
+ // Metafile inputs are relative to the working directory; a namespaced one (`ns:path`) is no file.
+ const inputs = Object.keys(result.metafile.inputs)
+ .filter((input) => !/^[a-z-]+:/.test(input))
+ .map((input) => resolve(input));
+ return { hash: digest, script, style, files, inputs };
}
diff --git a/packages/chord/src/node/surfaces/tui.ts b/packages/chord/src/node/surfaces/tui.ts
index ebc23d3f9..613a15efa 100644
--- a/packages/chord/src/node/surfaces/tui.ts
+++ b/packages/chord/src/node/surfaces/tui.ts
@@ -30,6 +30,8 @@ export type TuiSurface = {
readonly facet: Facet
;
step(name: string): RolloutStep;
readonly base: string;
+ /** The files its base bundle was built from: the app's base code for this surface. */
+ readonly inputs: readonly string[];
};
/** Where the terminal surface's files live. */
@@ -63,5 +65,5 @@ export async function tuiSurface
(
},
});
});
- return { facet, step: (name) => published.step(name), base: base.hash };
+ return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs };
}
diff --git a/packages/chord/src/node/surfaces/web.ts b/packages/chord/src/node/surfaces/web.ts
index 990aff66d..941b42feb 100644
--- a/packages/chord/src/node/surfaces/web.ts
+++ b/packages/chord/src/node/surfaces/web.ts
@@ -48,6 +48,8 @@ export type WebSurface
= {
/** The rollout step publishing the web kind's current set (after the server's step). */
step(name: string): RolloutStep;
readonly base: string;
+ /** The files its base bundle was built from: the app's base code for this surface. */
+ readonly inputs: readonly string[];
};
/** Where the web surface's files live. */
@@ -75,7 +77,7 @@ export async function webSurface
(
scripts: [WEB_PREFIX],
});
});
- return { facet, step: (name) => published.step(name), base: base.hash };
+ return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs };
}
/** The surface's routes: its files (served forever), its current base hash (public), and the page. */
diff --git a/packages/chord/src/remote/README.md b/packages/chord/src/remote/README.md
index b0e2755bf..9d41b501d 100644
--- a/packages/chord/src/remote/README.md
+++ b/packages/chord/src/remote/README.md
@@ -51,7 +51,10 @@ within each link's limits, compression at the origin, activation chains and revo
it does not know is ignored (Q1), one reusing a live id is a protocol error, and so are more running calls than this
side announced in `hello` (N = 256 per origin; a call on an unknown hold is ignored before it is counted). Its table
has two kinds of entries: `Hold` (served here) and `RelayedHold` (relayed, never hydrated). A call on a pending hold
- is answered `not_ready`; an aborted, released or gone call gets no reply. While the host closes, its holds are only
+ is answered `not_ready`, one crossing a `failed` or a removal with the cause its consumer was told; a call sent with
+ the indexes of a binding its consumer has not acknowledged a newer `bound` of yet is decoded by them (another
+ variant's indexes name other methods; one the new variant lacks fails `invalid/member`); an aborted, released or gone
+ call gets no reply. While the host closes, its holds are only
dropped (the host ends the objects itself). It sends with its origin. A hold's `chain` is kept only from a trusted
link and its stamped `limit` only from a parent link; its decoded limit is the smaller of the link's and the stamped
one, which a relayed hold carries upstream as `limit`. A call on a relayed hold whose arguments are past the link's
diff --git a/packages/chord/src/remote/hold.ts b/packages/chord/src/remote/hold.ts
index b96b94b5c..e83692b8f 100644
--- a/packages/chord/src/remote/hold.ts
+++ b/packages/chord/src/remote/hold.ts
@@ -11,7 +11,7 @@
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
import type { Context } from "../context/index.ts";
import { type VariantData, type VariantNames, variantNames, variantOrder, variantsAt } from "../contracts/internal.ts";
-import { type ChordError, type FailureCause, isChordError, type Principal } from "../data/index.ts";
+import { ChordError, type FailureCause, isChordError, type Principal } from "../data/index.ts";
import { asChordError } from "../data/internal.ts";
import { type Encoder, encoder, type Op, PendingOps } from "../delta/index.ts";
import {
@@ -67,8 +67,8 @@ type Followed = {
/**
* Where a hold stands: `unplaced` in the unbound index until the host's publication places it; `pending` until its
- * object is live (the activation, parking and retries; `toldFailed` once the consumer was told of a failure, so the
- * next attempt is announced with `loading`); `binding` while its object is live and its `bound` waits for a slot;
+ * object is live (the activation, parking and retries; `told`: the failure its consumer was told, so the next attempt
+ * is announced with `loading` and a call that crossed the failure on the wire gets it); `binding` while its object is live and its `bound` waits for a slot;
* `bound` with the object's variant and the members it follows. A `pending` hold unbound with `loading` and the
* `binding` after it, or a `binding` after a swap in place, keep the names it was bound with, `previous`: its consumer's
* calls sent by those names (before it learnt of the `loading`, or still bound) wait at its object's gate and are
@@ -76,7 +76,7 @@ type Followed = {
*/
type HoldState =
| { readonly kind: "unplaced"; readonly request: HoldRequest }
- | { readonly kind: "pending"; readonly toldFailed: boolean; readonly previous?: BoundNames }
+ | { readonly kind: "pending"; readonly told?: ChordError; readonly previous?: BoundNames }
| { readonly kind: "binding"; readonly previous?: BoundNames }
| {
readonly kind: "bound";
@@ -88,6 +88,11 @@ type HoldState =
readonly followed: Followed[];
/** The instance it was bound to: a swap brings another, which it binds to afresh. */
readonly instance: unknown;
+ /**
+ * A rebinding's names before it (`until`: its `bound`, this host's own message number): calls its consumer sent
+ * before it applied that `bound` carry their indexes, so they are decoded by them until its `ack`.
+ */
+ readonly previous?: BoundNames & { readonly until: number };
}
/**
* Refused where it arrived, with `cause`: `waiting` (not served here, another version, placed elsewhere by the
@@ -103,7 +108,7 @@ type Bound = Extract;
/** What calls need of a binding: its variant's data at the hold's version and the names the wire's indexes point into. */
type BoundNames = { readonly variant: VariantData; readonly names: VariantNames };
-const PENDING: HoldState = { kind: "pending", toldFailed: false };
+const PENDING: HoldState = { kind: "pending" };
const BINDING: HoldState = { kind: "binding" };
@@ -163,10 +168,27 @@ export class Hold implements Holder, UnboundRequest {
return this.#names() !== undefined;
}
- /** The names its calls are decoded by: its binding's, or the ones it was bound with while it rebinds. */
+ /**
+ * What a call it does not take is refused with: the failure its consumer was told (refused, or failed while pending:
+ * the call crossed that `failed` on the wire, and its consumer shows that cause), else `not_ready` (not bound yet).
+ */
+ get refusal(): ChordError {
+ const state = this.#state;
+ if (state.kind === "refused") return state.cause.error;
+ if (state.kind === "pending" && state.told !== undefined) return state.told;
+ return new ChordError({ code: "not_ready", message: `hold ${this.id}: not bound yet` });
+ }
+
+ /**
+ * The names its calls are decoded by: its binding's (its previous ones until its consumer acknowledged the `bound`
+ * that brought them: another variant's indexes name other methods), or the ones it was bound with while it rebinds.
+ */
#names(): BoundNames | undefined {
const state = this.#state;
- if (state.kind === "bound") return state;
+ if (state.kind === "bound") {
+ const { previous } = state;
+ return previous !== undefined && !this.#provider.acknowledged(previous.until) ? previous : state;
+ }
return state.kind === "pending" || state.kind === "binding" ? state.previous : undefined;
}
@@ -311,20 +333,25 @@ export class Hold implements Holder, UnboundRequest {
return;
}
this.#provider.unbind(this);
- this.#state = previous === undefined ? PENDING : { kind: "pending", toldFailed: false, previous };
+ this.#state =
+ previous === undefined
+ ? PENDING
+ : { kind: "pending", previous: { variant: previous.variant, names: previous.names } };
this.#provider.send({ kind: "loading", hold: this.id });
}
/**
* A swap of its object went live in place (slice 7b §5, audit A9): no longer subscribed to the old instance, it gets
* a fresh `bound` of the new one's members (as a restart, a variant or a version change does), answering calls by
- * its old names until that is sent.
+ * the names its consumer has until it applied that.
*/
#rebind(state: Bound): void {
+ // The names its consumer has: the old binding's, or older ones while it has not applied that `bound` yet.
+ const { variant, names } = this.#names()!;
unfollow(state);
// Its ops still waiting for a slot are the old instance's: the `bound` replaces them.
this.#provider.unbind(this);
- this.#state = { kind: "binding", previous: { variant: state.variant, names: state.names } };
+ this.#state = { kind: "binding", previous: { variant, names } };
this.#provider.bind(this, this.#provider.link.context);
}
@@ -359,8 +386,8 @@ export class Hold implements Holder, UnboundRequest {
const failed = { kind: cause.kind, error: wireError(cause.error) };
this.#provider.send({ kind: "failed", hold: this.id, cause: failed, ...retryIn });
// Told a failure, its consumer rejects what it still waits on: calls by the old names are not taken any more.
- this.#state = { kind: "pending", toldFailed: true };
- } else if (state.kind === "loading" && current.toldFailed) {
+ this.#state = { kind: "pending", told: cause.error };
+ } else if (state.kind === "loading" && current.told !== undefined) {
this.#provider.send({ kind: "loading", hold: this.id });
this.#state = PENDING;
}
@@ -505,15 +532,22 @@ export class Hold implements Holder, UnboundRequest {
this.#provider.send({ kind: "gone", hold: this.id, error: wireError(error) });
}
- /** The binding: its variant and names at the hold's version, with no member followed yet. */
+ /**
+ * The binding: its variant and names at the hold's version, with no member followed yet, and the names its consumer
+ * still has (a rebinding's) until it acknowledged this `bound`, the next of this host's own messages.
+ */
#bindTo(object: ChordObject): Bound {
const variant = variantsAt(object.served.contract, this.contract.version).get(object.variant!)!;
+ const previous = this.#names();
const state: Bound = {
kind: "bound",
variant,
names: variantNames(variant),
followed: [],
instance: object.instance,
+ ...(previous === undefined
+ ? {}
+ : { previous: { variant: previous.variant, names: previous.names, until: this.#provider.nextOwn } }),
};
this.#state = state;
return state;
diff --git a/packages/chord/src/remote/provider.ts b/packages/chord/src/remote/provider.ts
index a867d47e2..afce4c7d8 100644
--- a/packages/chord/src/remote/provider.ts
+++ b/packages/chord/src/remote/provider.ts
@@ -62,6 +62,9 @@ export class ProviderSide {
readonly #sent: Sender[] = [];
/** This host's own messages in flight. */
#inFlight = 0;
+ /** How many of this host's own `bound` and `state` messages it sent, and how many its consumer acknowledged. */
+ #ownSent = 0;
+ #ownAcked = 0;
/** The holds the current batch touched, in order; its flush sends or queues them. */
#touched: Hold[] = [];
/** Holds whose ops wait in their pending queues for a slot. */
@@ -406,7 +409,7 @@ export class ProviderSide {
return;
}
if (!hold.callable) {
- this.#refuse(message.call, new ChordError({ code: "not_ready", message: `hold ${hold.id}: not bound yet` }));
+ this.#refuse(message.call, hold.refusal);
return;
}
const method = hold.method(message.method);
@@ -494,9 +497,23 @@ export class ProviderSide {
return;
}
this.#inFlight--;
+ this.#ownAcked++;
this.#slotFreed();
}
+ /** The number the next of this host's own `bound` and `state` messages gets (counted from 1). */
+ get nextOwn(): number {
+ return this.#ownSent + 1;
+ }
+
+ /**
+ * Whether its consumer acknowledged this host's own message number `own`, so applied it: a call it sent after that
+ * arrives after the `ack` (one direction keeps its order).
+ */
+ acknowledged(own: number): boolean {
+ return this.#ownAcked >= own;
+ }
+
/**
* The end of a batch: while a slot is free and nothing waits, one `state` message with an entry per touched hold;
* otherwise each touched hold's ops join its pending queues until a slot frees.
@@ -578,6 +595,7 @@ export class ProviderSide {
/** Sends one of this host's own `bound` or `state` messages, taking a slot. */
#sendOwn(message: Extract): void {
this.#inFlight++;
+ this.#ownSent++;
this.#sent.push("local");
if (message.kind === "state") this.link.host.metrics.counter("chord.link.state_messages").add(1);
this.send(message);
diff --git a/packages/chord/src/surfaces/client.ts b/packages/chord/src/surfaces/client.ts
index 39c089b82..eaa1a710b 100644
--- a/packages/chord/src/surfaces/client.ts
+++ b/packages/chord/src/surfaces/client.ts
@@ -1,9 +1,9 @@
// A client host with its plugins (slice 11 §2, §4, decisions "Slice 11, P4" to "P6"): what a page and a terminal's
// sandbox both run. A client host (no address) loads the plugin scripts it started with beside the app's core facets,
// keeps its link to the server with the connection loop, and holds the kind's scripts object: a new set fetches the
-// changed scripts and reloads the host in place (a failed plugin keeps its old facet); a new base bundle means this
-// client's code is outdated, which the app answers by starting over (a page reloads). The surface specifics (how a
-// socket opens, how a script is fetched, what is outdated) are given.
+// changed scripts and reloads the host in place (a failed plugin keeps its old facet; a failed fetch is tried again);
+// a new base bundle means this client's code is outdated, which the app answers by starting over (a page reloads). The
+// surface specifics (how a socket opens, how a script is fetched, what is outdated) are given.
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../context/index.ts";
@@ -158,9 +158,17 @@ function entryOf(script: RegisteredScript, modules: ClientModules): SetEntry {
};
}
+/** The first pause before a failed update is tried again: 1 s, doubling from there. */
+const UPDATE_RETRY_INITIAL_MS = 1000;
+
+/** The longest pause between tries of a failed update: 30 s. */
+const UPDATE_RETRY_MAX_MS = 30_000;
+
/**
* The client's plugin updates: it holds the scripts object and applies each new set in order (fetching the changed
- * scripts, reloading the host with the complete set); a new base calls `outdated` instead.
+ * scripts, reloading the host with the complete set); a new base calls `outdated` instead. An update that failed (a
+ * script that did not load) is tried again after a growing pause (1 s doubling to 30 s) until it applies or a newer set
+ * replaces it: the scripts object shows nothing new until the next rollout or reconnect.
*/
class Updates {
readonly #host: Host
;
@@ -170,6 +178,13 @@ class Updates
{
readonly #scripts = new Map();
readonly #abort = new AbortController();
#release: (() => void) | undefined;
+ /** The newest set shown: a retry applies only it. */
+ #latest: ScriptSet | undefined;
+ /** The updates in order: one at a time, a retry's among them. */
+ #applying: Promise = Promise.resolve();
+ /** The pending retry's cancel and the pause it waited (0: none waited yet). */
+ #retry: { cancel: () => void } | undefined;
+ #pause = 0;
constructor(host: Host, loaded: LoadedFacets, options: ClientOptions
) {
this.#host = host;
@@ -184,22 +199,54 @@ class Updates
{
this.#release = () => held.release();
const watch = await held.set.watch(withAbortSignal(this.#abort.signal, context));
watch.start(async (call, ctx) => {
- if (call.kind === "value") await this.#apply(call.value as ScriptSet, ctx);
+ if (call.kind !== "value") return;
+ const set = call.value as ScriptSet;
+ this.#latest = set;
+ this.#retry?.cancel();
+ this.#retry = undefined;
+ this.#pause = 0;
+ await this.#queue(set, ctx);
});
}
- /** Stops following: the watch ends, the hold is released. */
+ /** Stops following: the watch ends, the hold is released, a pending retry is dropped. */
async stop(): Promise {
this.#abort.abort();
+ this.#retry?.cancel();
+ this.#retry = undefined;
this.#release?.();
}
- /** Applies `set`: a new base calls `outdated`; else the changed scripts fetched and the host reloaded with all. */
- async #apply(set: ScriptSet, context: Context): Promise {
+ /** Applies `set` after the updates before it, unless a newer set replaced it meanwhile. */
+ #queue(set: ScriptSet, context: Context): Promise {
+ const applied = this.#applying.then(async () => {
+ if (this.#latest !== set || this.#abort.signal.aborted) return;
+ if (!(await this.#apply(set, context))) this.#retryLater(set, context);
+ });
+ this.#applying = applied;
+ return applied;
+ }
+
+ /** Tries `set` again after the next pause (1 s doubling to 30 s). */
+ #retryLater(set: ScriptSet, context: Context): void {
+ this.#pause = this.#pause === 0 ? UPDATE_RETRY_INITIAL_MS : Math.min(this.#pause * 2, UPDATE_RETRY_MAX_MS);
+ this.#retry = {
+ cancel: this.#options.environment.after(this.#pause, () => {
+ this.#retry = undefined;
+ void this.#queue(set, context);
+ }),
+ };
+ }
+
+ /**
+ * Applies `set`: a new base calls `outdated`; else the changed scripts fetched and the host reloaded with all.
+ * `false` when it failed (reported `update_failed`): it is tried again.
+ */
+ async #apply(set: ScriptSet, context: Context): Promise {
const options = this.#options;
if (set.base !== options.base) {
options.outdated();
- return;
+ return true;
}
// The first frame (and a reconnect's snapshot) usually shows what this client runs already: nothing to reload.
const known = this.#scripts;
@@ -208,7 +255,7 @@ class Updates {
set.plugins.every((entry) => known.get(entry.name)?.digest === entry.digest)
) {
options.applied?.(set);
- return;
+ return true;
}
try {
const scripts: RegisteredScript[] = [];
@@ -222,8 +269,10 @@ class Updates
{
for (const script of scripts) this.#scripts.set(script.plugin, script);
if (report.failed.length > 0) options.onError({ kind: "plugins", failed: report.failed });
options.applied?.(set);
+ return true;
} catch (error) {
options.onError({ kind: "update_failed", error });
+ return false;
}
}
}
diff --git a/packages/chord/test/node/app/app.test.ts b/packages/chord/test/node/app/app.test.ts
index d32646877..d97d84c28 100644
--- a/packages/chord/test/node/app/app.test.ts
+++ b/packages/chord/test/node/app/app.test.ts
@@ -5,12 +5,12 @@
import { chmod, mkdir, readdir, stat, symlink, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { hostKind, local, remote, str, unknown } from "../../../src/contracts/index.ts";
import { addressParam, HostData } from "../../../src/node/app/data.ts";
import { AppInfo } from "../../../src/node/app/index.ts";
-import { rejection } from "../../support.ts";
+import { changesOf, rejection } from "../../support.ts";
import { send } from "../http/support.ts";
import { gitRepository, writePlugin } from "../plugins/support.ts";
import { ECHO_SERVER, NOTES_PLUGIN, Notes, Project, tempDir, testApp, WORKER_ENTRY } from "./support.ts";
@@ -180,6 +180,78 @@ describe("workers", () => {
});
});
+describe("applying changes one after another", () => {
+ it("starts a later applyChanges after the earlier one rolled out, so the newest build stays deployed", async () => {
+ const { app, root } = await testApp({ plugins: { echo: { "server.ts": ECHO_SERVER } } });
+ const echo = await app.host.get(Echo, {}, context);
+ const component = app.plugins.component;
+ const build = component.build.bind(component);
+ const rollout = component.rollout.bind(component);
+ const events: string[] = [];
+ const built = Promise.withResolvers();
+ const gate = Promise.withResolvers();
+ // The first build read its files, then waits: unserialized, the second would build and roll out meanwhile.
+ component.build = async (names, ctx) => {
+ const n = events.filter((event) => event.startsWith("build")).length + 1;
+ events.push(`build ${n}`);
+ const result = await build(names, ctx);
+ if (n === 1) {
+ built.resolve();
+ await gate.promise;
+ }
+ return result;
+ };
+ component.rollout = async (steps, result, ctx) => {
+ events.push("rollout");
+ return rollout(steps, result, ctx);
+ };
+ const marks = changesOf(await app.plugins.state.watch(context));
+ const edit = async (text: string): Promise => {
+ const seen = marks.length;
+ await writeFile(join(root, "echo", "server.ts"), ECHO_SERVER.replace('"echo"', JSON.stringify(text)));
+ await vi.waitFor(() => expect(marks.length).toBeGreaterThan(seen), { timeout: 5_000 });
+ };
+ try {
+ await edit("v2");
+ const first = app.applyChanges(context);
+ await built.promise;
+ await edit("v3");
+ const second = app.applyChanges(context);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(events).toEqual(["build 1"]);
+ gate.resolve();
+ await first;
+ await second;
+ expect(events).toEqual(["build 1", "rollout", "build 2", "rollout"]);
+ expect(echo.text.value).toBe("v3");
+ } finally {
+ echo.release();
+ await app.close(context);
+ }
+ });
+});
+
+describe("base code", () => {
+ it("marks base for a change of `base`'s files and directories and of a worker kind's entry", async () => {
+ const dir = await tempDir();
+ await mkdir(join(dir, "api"));
+ await writeFile(join(dir, "api", "v1.ts"), "export {};");
+ await writeFile(join(dir, "start.ts"), "export {};");
+ await writeFile(join(dir, "worker.ts"), "export {};");
+ const worker = { kind: Project, entry: join(dir, "worker.ts"), start: () => null, authorize: () => true };
+ for (const changed of [join(dir, "api", "v1.ts"), join(dir, "start.ts"), join(dir, "worker.ts")]) {
+ const { app } = await testApp({ base: [join(dir, "api"), join(dir, "start.ts")], workers: [worker] });
+ try {
+ expect(app.plugins.state.value.base).toBe(false);
+ await writeFile(changed, "export const changed = 1;");
+ await vi.waitFor(() => expect(app.plugins.state.value.base).toBe(true), { timeout: 5_000 });
+ } finally {
+ await app.close(context);
+ }
+ }
+ });
+});
+
describe("installs", () => {
it("installs a plugin from git into the user's root; applying changes loads it", async () => {
const tools = await tempDir();
diff --git a/packages/chord/test/node/fixtures/reload/app.ts b/packages/chord/test/node/fixtures/reload/app.ts
new file mode 100644
index 000000000..3a14331e3
--- /dev/null
+++ b/packages/chord/test/node/fixtures/reload/app.ts
@@ -0,0 +1,306 @@
+// The reload suite's app (design §21 "What a user sees during a reload"): a kit app with the web surface (the suite's
+// base bundle, `entry.ts` starting `main.ts`) and the tests' project workers, short host timeouts, its root holding the
+// suite's plugins; a real page signed in as the owner; and the test's hands: editing a plugin on disk and applying the changes, the latches of calls in
+// flight, and what the page shows and answers (`ReloadPage`).
+
+import { rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts";
+import { arr, bool, hostKind, method, obj, remote, str } from "../../../../src/contracts/index.ts";
+import { Sessions } from "../../../../src/node/app/index.ts";
+import type { RolloutResult } from "../../../../src/node/plugins/index.ts";
+import { tempDir, testApp } from "../../app/support.ts";
+import { writePlugin } from "../../plugins/support.ts";
+import { type Browser, poll } from "../../web/browser.ts";
+import { freePort, page } from "../../web/support.ts";
+import type { CallResult } from "./web-api.ts";
+
+/** The suite's base bundle entry, and its page (what another entry starts). */
+export const ENTRY = fileURLToPath(new URL("./entry.ts", import.meta.url));
+
+const MAIN = fileURLToPath(new URL("./main.ts", import.meta.url));
+
+/** A started reload app: the kit's app and what it reported, its root, port and data directory. */
+export type ReloadApp = Awaited>;
+
+/**
+ * The hosts' timeouts of the suite's apps (the server's and the workers'): a call outliving a restart's drain is cut
+ * off after 2 s, a call waits at most 5 s for its object to go live.
+ */
+export const TIMEOUTS = { closeTimeout: 2000, readyTimeout: 5000 } as const;
+
+/**
+ * Starts the suite's app with `plugins` (directory name to files; each plugin is `@test/`) and project workers;
+ * `entry` is the base bundle's entry (the suite's by default), `port` and `dataDir` fixed for a restart.
+ */
+export async function startReloadApp(
+ plugins: Readonly>>>,
+ more: { readonly entry?: string; readonly port?: number; readonly dataDir?: string; readonly root?: string } = {},
+): Promise {
+ return testApp({
+ kinds: {
+ server: { platform: "node" },
+ worker: { platform: "node" },
+ web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } },
+ },
+ listen: { host: "127.0.0.1", port: more.port ?? (await freePort()) },
+ web: { entry: more.entry ?? ENTRY, page },
+ timeouts: TIMEOUTS,
+ plugins,
+ withWorkers: true,
+ ...(more.dataDir === undefined ? {} : { dataDir: more.dataDir }),
+ ...(more.root === undefined ? {} : { root: more.root }),
+ });
+}
+
+/** A base bundle entry in a new directory: the suite's, with `version` on `globalThis` (another base bundle). */
+export async function versionedMain(version: string, dir?: string): Promise {
+ const at = dir ?? (await tempDir());
+ const text = `import { start } from ${JSON.stringify(MAIN)};\n(globalThis as Record).version = ${JSON.stringify(version)};\nstart();\n`;
+ await writeFile(join(at, "main.ts"), text);
+ return join(at, "main.ts");
+}
+
+/**
+ * Writes `files` into plugin `dir` of the app's root (an edit on disk), waits until the watcher marked it dirty, and
+ * applies the changes (the rollout: server, workers, clients).
+ */
+export async function editAndApply(
+ app: ReloadApp,
+ dir: string,
+ files: Readonly>,
+): Promise {
+ await edit(app, dir, files);
+ return app.app.applyChanges(context);
+}
+
+/**
+ * Writes `files` into plugin `dir` of the app's root and waits until the watcher marked it dirty (or, for a plugin not
+ * discovered yet, the plugin set changed).
+ */
+export async function edit(app: ReloadApp, dir: string, files: Readonly>): Promise {
+ await writePlugin(app.root, dir, `@test/${dir}`, files);
+ const name = `@test/${dir}`;
+ const marked = (): boolean => app.app.plugins.state.value.dirty.includes(name) || app.app.plugins.state.value.set;
+ await poll(marked, 30_000, `${name} marked`);
+}
+
+/** Removes plugin `dir` from the app's root, waits until the watcher saw the plugin set change, and applies it. */
+export async function removeAndApply(app: ReloadApp, dir: string): Promise {
+ await rm(join(app.root, dir), { recursive: true, force: true });
+ await poll(() => app.app.plugins.state.value.set, 30_000, "the plugin set marked changed");
+ return app.app.applyChanges(context);
+}
+
+/** The plugins a rollout's hosts kept on their old code, as `/: `, and hosts that failed. */
+export function rolloutFailures(result: RolloutResult): string[] {
+ const failures: string[] = [];
+ for (const step of result.steps) {
+ for (const entry of step.hosts) {
+ if ("report" in entry)
+ for (const { plugin } of entry.report.failed) failures.push(`${step.name}/${entry.host}: ${plugin}`);
+ else if ("error" in entry) failures.push(`${step.name}/${entry.host}: error ${String(entry.error)}`);
+ }
+ }
+ return failures;
+}
+
+/** The fates a rollout's hosts reported, as `/: `. */
+export function rolloutFates(result: RolloutResult): string[] {
+ const fates: string[] = [];
+ for (const step of result.steps) {
+ for (const entry of step.hosts) {
+ if (!("report" in entry)) continue;
+ for (const each of entry.report.objects) fates.push(`${step.name}/${entry.host}: ${each.object} ${each.kind}`);
+ }
+ }
+ return fates;
+}
+
+/** The latch plugin's contract, declared again for the test (contracts are data). */
+const Control = remote("reload.control", {
+ version: 1,
+ params: { id: str() },
+ hostAddress: ({ id }) => hostKind("project", { id: str() })({ id }),
+ variants: {
+ main: {
+ state: {},
+ methods: {
+ entered: method(str(), bool()),
+ open: method(str()),
+ natives: method(undefined, obj({ opens: arr(str()), closes: arr(str()) })),
+ },
+ },
+ },
+});
+
+/** The test's hand on the latches and the natives' record where the counter runs (a worker's process or this one). */
+export type Latches = {
+ /** Resolves once a call waiting on latch `name` entered it. */
+ entered(name: string): Promise;
+ /** Opens latch `name`: the call waiting on it goes on. */
+ open(name: string): Promise;
+ /** What the counter's natives did: opens by code, closes as `:`. */
+ natives(): Promise<{ readonly opens: readonly string[]; readonly closes: readonly string[] }>;
+};
+
+/** The latches of project `p1`'s worker (through the latch plugin), or of the server (this process's). */
+export async function latches(app: ReloadApp, placement: "server" | "worker"): Promise {
+ if (placement === "server") return serverLatches();
+ const control = await app.app.host.get(Control, { id: "p1" }, context);
+ return {
+ entered: async (name) => {
+ await control.entered(name, context);
+ },
+ open: async (name) => {
+ await control.open(name, context);
+ },
+ natives: () => control.natives(context),
+ };
+}
+
+type Latch = { readonly entered: PromiseWithResolvers; readonly open: PromiseWithResolvers };
+
+/** This process's latches: the server's plugins run here and keep them on `globalThis`, as the plugins write them. */
+function serverLatches(): Latches {
+ const global = globalThis as Record;
+ const latch = (name: string): Latch => {
+ // The plugins' latch map, made by whichever side comes first.
+ global.reloadLatches ??= new Map();
+ const map = global.reloadLatches as Map;
+ let found = map.get(name);
+ if (found === undefined) {
+ found = { entered: Promise.withResolvers(), open: Promise.withResolvers() };
+ map.set(name, found);
+ }
+ return found;
+ };
+ return {
+ entered: (name) => latch(name).entered.promise,
+ open: async (name) => latch(name).open.resolve(),
+ natives: async () => {
+ const natives = (global.reloadNatives ?? { opens: [], closes: [] }) as { opens: string[]; closes: string[] };
+ return { opens: [...natives.opens], closes: [...natives.closes] };
+ },
+ };
+}
+
+/** The page as the tests see and drive it: its DOM, the probe's records, calls from its references. */
+export class ReloadPage {
+ readonly browser: Browser;
+
+ constructor(browser: Browser) {
+ this.browser = browser;
+ }
+
+ /** Opens the owner link (signed in) and waits until the page shows its views and is connected. */
+ async signIn(app: ReloadApp, views: readonly string[]): Promise {
+ await this.browser.open(app.app.ownerLink());
+ await this.browser.wait('globalThis.page?.connection === "connected"');
+ for (const view of views) await this.browser.wait(`document.querySelector('[data-view="${view}"]')`);
+ }
+
+ /**
+ * Signs in with a new owner browser session (the owner link is single use: a second page needs its own session)
+ * and waits as `signIn` does.
+ */
+ async signInAgain(app: ReloadApp, views: readonly string[]): Promise {
+ const sessions = await app.app.host.get(Sessions, {}, context);
+ const { cookie } = await sessions.issue(
+ { principal: { kind: "owner" }, label: "second page", kind: "browser" },
+ context,
+ );
+ sessions.release();
+ const [pair] = cookie.split(";");
+ const at = pair!.indexOf("=");
+ const url = `http://127.0.0.1:${app.port}/`;
+ await this.browser.call("Network.setCookie", { name: pair!.slice(0, at), value: pair!.slice(at + 1), url });
+ await this.browser.open(url);
+ await this.browser.wait('globalThis.page?.connection === "connected"');
+ for (const view of views) await this.browser.wait(`document.querySelector('[data-view="${view}"]')`);
+ }
+
+ /** The text of `selector` within view `view`, `null` when absent. */
+ text(view: string, selector: string): Promise {
+ return this.browser.evaluate(
+ `document.querySelector('[data-view="${view}"] ${selector}')?.textContent ?? null`,
+ );
+ }
+
+ /** The value of input `selector` within view `view`. */
+ value(view: string, selector: string): Promise {
+ return this.browser.evaluate(`document.querySelector('[data-view="${view}"] ${selector}').value`);
+ }
+
+ /** Waits until `selector` within view `view` shows `text`. */
+ waitText(view: string, selector: string, text: string, timeoutMs = 30_000): Promise {
+ const expression = `document.querySelector('[data-view="${view}"] ${selector}')?.textContent === ${JSON.stringify(text)}`;
+ return this.browser.wait(expression, timeoutMs);
+ }
+
+ /** Types `text` into input `selector` of view `view`. */
+ async type(view: string, selector: string, text: string): Promise {
+ await this.browser.evaluate(`document.querySelector('[data-view="${view}"] ${selector}').focus()`);
+ await this.browser.type(text);
+ }
+
+ /** Every state the reference tracked as `name` went through. */
+ states(name: string): Promise {
+ return this.browser.evaluate(`globalThis.reload.states[${JSON.stringify(name)}] ?? []`);
+ }
+
+ /** Every value its watched member had. */
+ frames(name: string): Promise {
+ return this.browser.evaluate(`globalThis.reload.frames[${JSON.stringify(name)}] ?? []`);
+ }
+
+ /** Waits until the reference tracked as `name` is in state `state` (as the probe writes it). */
+ async waitState(name: string, state: string, timeoutMs = 30_000): Promise {
+ try {
+ await this.browser.wait(
+ `globalThis.reload.states[${JSON.stringify(name)}]?.at(-1) === ${JSON.stringify(state)}`,
+ timeoutMs,
+ );
+ } catch {
+ throw new Error(`${name} never reached ${state}: ${JSON.stringify(await this.states(name))}`);
+ }
+ }
+
+ /**
+ * Holds the object of project `id` as the web plugin `contract` registered its contract, as `name`, its calls
+ * parking `readyTimeout`, its member `member` watched.
+ */
+ async hold(name: string, contract: string, readyTimeout: number, id = "p1", member = "label"): Promise {
+ const params = JSON.stringify({ id });
+ const args = [JSON.stringify(name), JSON.stringify(contract), params, readyTimeout, JSON.stringify(member)];
+ await this.browser.evaluate(`globalThis.reload.hold(${args.join(", ")})`);
+ }
+
+ /** Calls `method` with `args` on the reference tracked as `name`: the number of its answer. */
+ call(name: string, method: string, ...args: unknown[]): Promise {
+ const list = [JSON.stringify(name), JSON.stringify(method), ...args.map((arg) => JSON.stringify(arg))].join(", ");
+ return this.browser.evaluate(`globalThis.reload.call(${list})`);
+ }
+
+ /** Call `index`'s answer now (pending or not). */
+ peek(index: number): Promise {
+ return this.browser.evaluate(`globalThis.reload.results[${index}]`);
+ }
+
+ /** Call `index`'s answer, once it has one. */
+ async result(index: number, timeoutMs = 30_000): Promise {
+ await this.browser.wait(`globalThis.reload.results[${index}].status !== "pending"`, timeoutMs);
+ return this.peek(index);
+ }
+
+ /** How often the page loaded (its base bundle ran) since the browser opened it. */
+ loads(): Promise {
+ return this.browser.evaluate("globalThis.page.loads");
+ }
+}
+
+/** The answer of a call that succeeded with `value`. */
+export function ok(value: unknown): CallResult {
+ return { status: "ok", value };
+}
diff --git a/packages/chord/test/node/fixtures/reload/entry.ts b/packages/chord/test/node/fixtures/reload/entry.ts
new file mode 100644
index 000000000..e1f3fb98a
--- /dev/null
+++ b/packages/chord/test/node/fixtures/reload/entry.ts
@@ -0,0 +1,5 @@
+// The reload suite's web entry: starts the suite's page (`main.ts`).
+
+import { start } from "./main.ts";
+
+start();
diff --git a/packages/chord/test/node/fixtures/reload/main.ts b/packages/chord/test/node/fixtures/reload/main.ts
new file mode 100644
index 000000000..a1b34c6c5
--- /dev/null
+++ b/packages/chord/test/node/fixtures/reload/main.ts
@@ -0,0 +1,107 @@
+// The reload suite's page (the base bundle's code; `entry.ts` starts it): `startPage` with the suite's API modules and React, and a page that
+// renders every contributed view (`Views`) in its own section, a draft input of its own (base code: no plugin reload
+// touches it), and a tall spacer so the tests can scroll. The tests reach the page through `globalThis.reload` (the
+// probe of the web API) and `globalThis.page` (loads, the connection, dropping the link, holding the reconnect).
+
+import * as React from "react";
+import { createElement as h, useState } from "react";
+import * as jsx from "react/jsx-runtime";
+import { createRoot } from "react-dom/client";
+import * as chord from "../../../../src/index.ts";
+import { HostProvider } from "../../../../src/react/index.ts";
+import { startPage } from "../../../../src/web/index.ts";
+import * as webApi from "./web-api.ts";
+
+/** What the tests read and drive besides the probe. */
+type PageControl = {
+ loads: number;
+ connection?: string;
+ /** What the page reported (`startPage`'s `onError`): the kind, and the plugins of a `plugins` report. */
+ errors: { readonly kind: string; readonly plugins: readonly string[] }[];
+ socket?: WebSocket;
+ /** While set, the page does not reconnect: the tests resolve it. */
+ gate?: Promise;
+ release?: () => void;
+ /** Closes the page's socket (the connection loop reconnects, after `gate` if set). */
+ dropLink(): void;
+ /** Holds the page's reconnects until `release`. */
+ holdReconnect(): void;
+};
+
+/** The tests' hand on the page, made when it starts. */
+function pageControl(): PageControl {
+ const page: PageControl = {
+ loads: 0,
+ errors: [],
+ dropLink() {
+ page.socket?.close();
+ },
+ holdReconnect() {
+ const gate = Promise.withResolvers();
+ page.gate = gate.promise;
+ page.release = () => {
+ page.gate = undefined;
+ gate.resolve(true);
+ };
+ },
+ };
+ return page;
+}
+
+/** The base bundle's facet: it serves `Views`, composed in name order. */
+const core = chord.defineFacet((host) =>
+ host.serve(webApi.Views, {
+ compose: (table) => table.map(({ value }) => value).sort((a, b) => a.name.localeCompare(b.name)),
+ }),
+);
+
+function App() {
+ const views = webApi.useRegistry(webApi.Views) ?? [];
+ const [draft, setDraft] = useState("");
+ return h(
+ "div",
+ null,
+ h("input", {
+ id: "base-draft",
+ value: draft,
+ onChange: (event: { target: { value: string } }) => setDraft(event.target.value),
+ }),
+ h(
+ "div",
+ { id: "views" },
+ ...views.map((view) => h("section", { key: view.name, "data-view": view.name }, h(view.component))),
+ ),
+ h("div", { id: "spacer", style: { height: "5000px" } }),
+ );
+}
+
+/** Starts the page (Chord's package is free of side effects for bundlers: an entry calls this). */
+export function start(): void {
+ const page = pageControl();
+ page.loads++;
+ (globalThis as unknown as { page: PageControl }).page = page;
+ (globalThis as unknown as { reload: typeof webApi.probe }).reload = webApi.probe;
+ void startPage({
+ connect: () => {
+ const socket = new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`);
+ page.socket = socket;
+ return socket;
+ },
+ modules: { "@test/app/v1": chord, "@test/app/v1/web": webApi, react: React, "react/jsx-runtime": jsx },
+ core: [{ name: "@test/core", facet: core }],
+ canRetry: () => page.gate ?? Promise.resolve(true),
+ onError: (error) => {
+ page.errors.push({
+ kind: error.kind,
+ plugins: error.kind === "plugins" ? error.failed.map(({ plugin }) => plugin) : [],
+ });
+ },
+ onConnection: (state) => {
+ page.connection = state;
+ },
+ render: (host) => {
+ webApi.probe.host = host;
+ createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(App)));
+ },
+ });
+}
diff --git a/packages/chord/test/node/fixtures/reload/plugins.ts b/packages/chord/test/node/fixtures/reload/plugins.ts
new file mode 100644
index 000000000..522eafe3a
--- /dev/null
+++ b/packages/chord/test/node/fixtures/reload/plugins.ts
@@ -0,0 +1,349 @@
+// The reload suite's plugins, as the files a test writes into the app's root and edits on disk: `counter` (a counter
+// with a persisted document and a table, on a worker or the server, pure or holding a native, at any contract
+// version), `latch` (on every worker: the test's hand on calls that wait), and web plugins showing the counter with
+// a draft input (component state) and a kept input (page state). Calls that must be in flight during a reload wait on
+// a latch by name: the method marks it entered, then waits until the test opens it. Latches live on `globalThis`, so
+// old and new code of a plugin and the latch plugin share them within a process.
+
+/** Where the counter runs: a project's worker (`p1`), or the server. */
+export type Placement = "server" | "worker";
+
+/**
+ * The counter's contract as a version of its code has it: `v1`; `v2` evolved (its label an object, `add` taking and
+ * returning objects) for a provider that accepts v1 (`accepted`) or does not (`refused`), the same file either way.
+ */
+export type ContractVersion = "v1" | "v2";
+
+/** The latches, by name: a method marks one entered and waits until the test opens it. */
+const LATCH = `
+type Latch = { readonly entered: PromiseWithResolvers; readonly open: PromiseWithResolvers };
+const latches: Map = ((globalThis as Record).reloadLatches ??= new Map()) as Map;
+/** The latch named \`name\`, made on first use. */
+function latch(name: string): Latch {
+ let found = latches.get(name);
+ if (found === undefined) {
+ found = { entered: Promise.withResolvers(), open: Promise.withResolvers() };
+ latches.set(name, found);
+ }
+ return found;
+}
+/** What the natives of every version did in this process: opens by version, closes as \`:\`. */
+const natives = ((globalThis as Record).reloadNatives ??= { opens: [], closes: [] }) as { opens: string[]; closes: string[] };
+`;
+
+/** The counter's contract file at `version`, placed by `placement`. */
+export function counterContract(placement: Placement, version: ContractVersion): string {
+ const address =
+ placement === "worker"
+ ? 'const Project = hostKind("project", { id: str() });\nconst at = ({ id }: { id: string }) => Project({ id });'
+ : 'const Server = hostKind("server");\nconst at = () => Server();';
+ const v1 = `export const CounterV1 = remote("reload.counter", {
+ version: 1,
+ params: { id: str() },
+ hostAddress: at,
+ variants: {
+ main: {
+ state: { label: str(), count: int() },
+ methods: {
+ add: method(int(), str()),
+ slow: method(str(), str()),
+ slowAdd: method(str(), str()),
+ code: method(undefined, str()),
+ visits: method(undefined, int()),
+ },
+ },
+ },
+});`;
+ const v2 = `export const CounterV2 = evolve(CounterV1, (e) =>
+ e.state("main", "label", obj({ text: str(), code: str() })).method("add", obj({ n: int() }), obj({ text: str() })),
+);`;
+ return [
+ "// The counter's contract.",
+ 'import { evolve, hostKind, int, method, obj, remote, str } from "@test/app/v1";',
+ address,
+ v1,
+ version === "v2" ? v2 : "",
+ `export const Counter = ${version === "v2" ? "CounterV2" : "CounterV1"};`,
+ ].join("\n");
+}
+
+/** How a version of the counter's code is written. */
+export type CounterCode = {
+ /** What its label and `code()` say: which code answered. */
+ readonly code: string;
+ readonly placement: Placement;
+ /** Holds a native (`self.open`): every reload of it restarts the object. */
+ readonly native?: boolean;
+ /** `v2` (the contract file goes with it): accepting v1 consumers through a mapping, or not. */
+ readonly contract?: ContractVersion;
+ readonly accepts?: boolean;
+ /** A broken version: its build fails, its evaluation throws, its setup throws, or its native's open throws. */
+ readonly broken?: "build" | "evaluation" | "setup" | "open";
+};
+
+/** The counter plugin's files (its contract and its server or worker facet) for `code`. */
+export function counterPlugin(code: CounterCode): Record {
+ const contract = code.contract ?? "v1";
+ return {
+ "contract.ts": counterContract(code.placement, contract),
+ [`${code.placement}.ts`]: counterFacet(code, contract),
+ };
+}
+
+/**
+ * The counter's facet: a store (a local object with empty params: persisted data needs a stable identity) keeping the
+ * count document and the visits table, and the counter the page holds, its label naming the code.
+ */
+function counterFacet(code: CounterCode, contract: ContractVersion): string {
+ const v = JSON.stringify(code.code);
+ const label = contract === "v2" ? `({ text: \`${code.code}:\${n}\`, code: ${v} })` : `\`${code.code}:\${n}\``;
+ const add =
+ contract === "v2"
+ ? `add: async ({ n }, ctx) => ({ text: await store.add(n, ctx) }),`
+ : "add: (n, ctx) => store.add(n, ctx),";
+ const accepts =
+ contract === "v2" && code.accepts === true
+ ? `, { accepts: { 1: { state: { main: { label: (current) => current.text } }, methods: { add: { args: (old) => ({ n: old }), result: (current) => current.text } } } } }`
+ : "";
+ const native =
+ code.native === true
+ ? `\t\tself.open(async () => { ${code.broken === "open" ? 'throw new Error("the native does not open");' : ""} natives.opens.push(${v}); return ${v}; }, (value, reason) => { natives.closes.push(\`\${value}:\${reason.kind}\`); });`
+ : "";
+ return [
+ `// The counter's ${code.placement} facet, code ${code.code}.`,
+ 'import { defineFacet, defineState, defineTable, int, local, method, obj, query, str } from "@test/app/v1";',
+ 'import { Counter } from "./contract.ts";',
+ code.broken === "build" ? "const broken = ;" : "",
+ code.broken === "evaluation" ? 'throw new Error("the counter\'s new code throws when evaluated");' : "",
+ LATCH,
+ 'const Count = defineState("reload.count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }), persist: true, checkpointWhen: () => false });',
+ 'const Visits = defineTable("reload.visits", { version: 1, columns: { at: int() }, key: ["at"] });',
+ 'const Store = local("reload.store", { version: 1, params: {}, members: { count: int(), add: method(int(), str()), visits: method(undefined, int()) } });',
+ "export default defineFacet((host) => {",
+ code.broken === "setup" ? '\tthrow new Error("the counter\'s new setup throws");' : "",
+ "\thost.serve(Store, (_params, self) => {",
+ "\t\tconst doc = self.state(Count);",
+ "\t\tconst visits = self.table(Visits);",
+ "\t\treturn {",
+ "\t\t\tcount: self.derive([doc], ([value]) => value.n),",
+ "\t\t\tadd: async (n, ctx) => {",
+ "\t\t\t\tawait doc.change((draft) => { draft.n += n; }, ctx);",
+ "\t\t\t\tconst at = doc.value.n;",
+ "\t\t\t\tawait self.tx(async (tx) => { tx.put(visits, { at }); }, ctx);",
+ `\t\t\t\treturn \`${code.code}:\${at}\`;`,
+ "\t\t\t},",
+ "\t\t\tvisits: async (ctx) => visits.count(query(Visits), ctx),",
+ "\t\t};",
+ "\t});",
+ "\thost.serve(Counter, (_params, _principal, self) => {",
+ native,
+ "\t\tconst store = self.get(Store, {});",
+ '\t\treturn { variant: "main", members: {',
+ `\t\t\tlabel: self.derive([store.count], ([n]) => ${label}),`,
+ "\t\t\tcount: self.derive([store.count], ([n]) => n),",
+ `\t\t\t${add}`,
+ `\t\t\tslow: async (name) => { const held = latch(name); held.entered.resolve(); await held.open.promise; return \`${code.code}:\${name}\`; },`,
+ "\t\t\tslowAdd: async (name, ctx) => { const held = latch(name); held.entered.resolve(); await held.open.promise; return store.add(10, ctx); },",
+ `\t\t\tcode: async () => ${v},`,
+ "\t\t\tvisits: (ctx) => store.visits(ctx),",
+ "\t\t} };",
+ `\t}${accepts});`,
+ "});",
+ ].join("\n");
+}
+
+/** The latch plugin's worker facet: `Control` on each project's worker opens latches and reads them for the test. */
+export const LATCH_PLUGIN: Readonly> = {
+ "worker.ts": [
+ "// The latch plugin: the test's hand on the latches and the natives' record in a worker's process.",
+ 'import { arr, bool, defineFacet, hostKind, method, obj, remote, str } from "@test/app/v1";',
+ LATCH,
+ 'const Project = hostKind("project", { id: str() });',
+ 'const Control = remote("reload.control", { version: 1, params: { id: str() }, hostAddress: ({ id }) => Project({ id }), variants: { main: { state: {}, methods: {',
+ "\tentered: method(str(), bool()), open: method(str()), natives: method(undefined, obj({ opens: arr(str()), closes: arr(str()) })),",
+ "} } } });",
+ 'export default defineFacet((host) => host.serve(Control, () => ({ variant: "main", members: {',
+ "\tentered: async (name) => { await latch(name).entered.promise; return true; },",
+ "\topen: async (name) => { latch(name).open.resolve(); return null; },",
+ "\tnatives: async () => ({ opens: [...natives.opens], closes: [...natives.closes] }),",
+ "} })));",
+ ].join("\n"),
+};
+
+/** How a version of a web plugin is written. */
+export type WebCode = {
+ /** Its name: its section's `data-view` and its probe name. */
+ readonly name: string;
+ /** What its view says it is (`:`): which code renders. */
+ readonly code: string;
+ /** The counter it shows, through its own copy of the contract (an old page keeps its copy); none: no reference. */
+ readonly counter?: { readonly placement: Placement; readonly contract: ContractVersion };
+ /** Shows the server's summary of the counter instead (`summaryPlugin`), through its own contract copy. */
+ readonly summary?: boolean;
+ /** A broken version: its build fails or its evaluation throws. */
+ readonly broken?: "build" | "evaluation";
+ /** Contributes no view (its contributions changed shape: none now). */
+ readonly hidden?: boolean;
+};
+
+/** A web plugin's files: its view (the counter's label, count and state, a draft and a kept input) and contract copy. */
+export function webPlugin(code: WebCode): Record {
+ const name = JSON.stringify(code.name);
+ const counter = code.counter;
+ const lines = [
+ `// The ${code.name} web plugin, code ${code.code}.`,
+ 'import { createElement as h, useEffect, useState } from "react";',
+ 'import { defineFacet } from "@test/app/v1";',
+ 'import { probe, useHold, usePageState, useRefState, useWatch, Views } from "@test/app/v1/web";',
+ counter === undefined
+ ? ""
+ : `import { ${code.summary === true ? "Summary as Counter" : "Counter"} } from "./contract.ts";`,
+ code.broken === "build" ? "const broken = ;" : "",
+ code.broken === "evaluation" ? 'throw new Error("the web plugin\'s new code throws when evaluated");' : "",
+ counter === undefined ? "" : `probe.contracts[${name}] = Counter;`,
+ "function Shown() {",
+ counter === undefined
+ ? "\treturn null;"
+ : [
+ '\tconst ref = useHold(Counter, { id: "p1" });',
+ "\tconst label = useWatch(ref?.label);",
+ "\tconst count = useWatch(ref?.count);",
+ "\tconst state = useRefState(ref);",
+ `\tuseEffect(() => { if (ref !== undefined) probe.track(${name}, ref, "label"); }, [ref]);`,
+ '\treturn h("span", null,',
+ '\t\th("span", { className: "label" }, label === undefined ? "none" : typeof label === "string" ? label : JSON.stringify(label)),',
+ '\t\th("span", { className: "count" }, String(count ?? "none")),',
+ '\t\th("span", { className: "state" }, state?.kind ?? "none"),',
+ "\t);",
+ ].join("\n"),
+ "}",
+ "function View() {",
+ '\tconst [draft, setDraft] = useState("");',
+ `\tconst [kept, setKept] = usePageState(${JSON.stringify(`${code.name}.kept`)}, "");`,
+ '\treturn h("div", null,',
+ `\t\th("span", { className: "code" }, ${JSON.stringify(`${code.name}:${code.code}`)}),`,
+ "\t\th(Shown),",
+ '\t\th("input", { className: "draft", value: draft, onChange: (event: { target: { value: string } }) => setDraft(event.target.value) }),',
+ '\t\th("input", { className: "kept", value: kept, onChange: (event: { target: { value: string } }) => setKept(event.target.value) }),',
+ "\t);",
+ "}",
+ code.hidden === true
+ ? "export default defineFacet(() => {});"
+ : `export default defineFacet((_host, self) => self.contribute(Views, { name: ${name}, component: View }));`,
+ ];
+ return {
+ "web.ts": lines.join("\n"),
+ ...(counter === undefined
+ ? {}
+ : {
+ "contract.ts":
+ code.summary === true ? SUMMARY_CONTRACT : counterContract(counter.placement, counter.contract),
+ }),
+ };
+}
+
+/** The summary's contract: on the server, its label the counter's seen through the summary's code. */
+const SUMMARY_CONTRACT = `// The summary's contract.
+import { hostKind, int, method, remote, str } from "@test/app/v1";
+const Server = hostKind("server");
+export const Summary = remote("reload.summary", {
+ version: 1,
+ params: { id: str() },
+ hostAddress: () => Server(),
+ variants: { main: { state: { label: str(), count: int() }, methods: { code: method(undefined, str()) } } },
+});`;
+
+/**
+ * The summary plugin's files: a pure object on the server whose factory holds the counter of project `id` on its worker
+ * (a dependency on another host), its label `()`. The counter's contract is the counter
+ * plugin's file, imported by relative path (copied into the summary's script).
+ */
+export function summaryPlugin(code: string): Record {
+ return {
+ "contract.ts": SUMMARY_CONTRACT,
+ "server.ts": [
+ `// The summary's server facet, code ${code}.`,
+ 'import { defineFacet } from "@test/app/v1";',
+ 'import { Counter } from "../counter/contract.ts";',
+ 'import { Summary } from "./contract.ts";',
+ "export default defineFacet((host) => host.serve(Summary, ({ id }, _principal, self) => {",
+ "\tconst counter = self.get(Counter, { id });",
+ '\treturn { variant: "main", members: {',
+ `\t\tlabel: self.derive([counter.count], ([count]) => \`${code}(\${count})\`),`,
+ "\t\tcount: self.derive([counter.count], ([count]) => count),",
+ `\t\tcode: async () => ${JSON.stringify(code)},`,
+ "\t} };",
+ "}));",
+ ].join("\n"),
+ };
+}
+
+/** The tasks' contract: on project `id`'s worker, the finished runs' entries and how many run. */
+const TASKS_CONTRACT = `// The tasks' contract.
+import { arr, hostKind, int, method, remote, str } from "@test/app/v1";
+const Project = hostKind("project", { id: str() });
+export const Tasks = remote("reload.tasks", {
+ version: 1,
+ params: { id: str() },
+ hostAddress: ({ id }) => Project({ id }),
+ variants: { main: { state: { entries: arr(str()), running: int() }, methods: { start: method(str(), str()) } } },
+});`;
+
+/**
+ * The tasks plugin's files: a runner (a local store) with an in-memory job whose run waits on the latch named by its
+ * argument, and whose `finish` records `-finish()` in a persisted list; `native`: the runner holds a
+ * native, so a reload restarts it. The page reaches it through `Tasks`.
+ */
+export function tasksPlugin(code: string, native = false): Record {
+ const v = JSON.stringify(code);
+ return {
+ "contract.ts": TASKS_CONTRACT,
+ "worker.ts": [
+ `// The tasks' worker facet, code ${code}.`,
+ 'import { arr, defineFacet, defineJob, defineState, int, local, method, obj, str } from "@test/app/v1";',
+ 'import { Tasks } from "./contract.ts";',
+ LATCH,
+ 'const Entries = defineState("reload.entries", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }), persist: true, checkpointWhen: () => false });',
+ 'const Task = defineJob("reload.task", { version: 1, args: obj({ name: str() }), result: str() });',
+ 'const Runner = local("reload.runner", { version: 1, params: {}, members: { entries: arr(str()), running: int(), start: method(str(), str()) } });',
+ "export default defineFacet((host) => {",
+ "\thost.serve(Runner, (_params, self) => {",
+ native ? `\t\tself.open(async () => ${v}, () => {});` : "",
+ "\t\tconst entries = self.state(Entries);",
+ "\t\tconst task = self.job(Task, {",
+ "\t\t\trun: async ({ name }, ctx) => {",
+ "\t\t\t\tconst held = latch(name);",
+ "\t\t\t\theld.entered.resolve();",
+ "\t\t\t\tawait new Promise((resolve, reject) => {",
+ "\t\t\t\t\tvoid held.open.promise.then(resolve);",
+ '\t\t\t\t\tctx.abortSignal?.addEventListener("abort", () => reject(new Error("aborted")));',
+ "\t\t\t\t});",
+ `\t\t\t\treturn \`run-${code}:\${name}\`;`,
+ "\t\t\t},",
+ "\t\t\tfinish: async (outcome, tx) => {",
+ '\t\t\t\tconst what = outcome.kind === "done" ? "done:" + outcome.result : outcome.error.kind === "aborted" ? "aborted:" + outcome.error.reason : outcome.error.kind;',
+ `\t\t\t\t(await tx.draft(entries)).list.push(\`${code}-finish(\${what})\`);`,
+ "\t\t\t},",
+ "\t\t});",
+ "\t\treturn {",
+ "\t\t\tentries: self.derive([entries], ([value]) => value.list),",
+ "\t\t\trunning: self.derive([task.running], ([running]) => running.length),",
+ "\t\t\tstart: async (name, ctx) => (await task.start({ name }, ctx)).id,",
+ "\t\t};",
+ "\t});",
+ "\thost.serve(Tasks, (_params, _principal, self) => {",
+ "\t\tconst runner = self.get(Runner, {});",
+ '\t\treturn { variant: "main", members: { entries: runner.entries, running: runner.running, start: (name, ctx) => runner.start(name, ctx) } };',
+ "\t});",
+ "});",
+ ].join("\n"),
+ "web.ts": [
+ "// The tasks' web facet: registers the contract for the page's probe.",
+ 'import { defineFacet } from "@test/app/v1";',
+ 'import { probe } from "@test/app/v1/web";',
+ 'import { Tasks } from "./contract.ts";',
+ "probe.contracts.tasks = Tasks;",
+ "export default defineFacet(() => {});",
+ ].join("\n"),
+ };
+}
diff --git a/packages/chord/test/node/fixtures/reload/web-api.ts b/packages/chord/test/node/fixtures/reload/web-api.ts
new file mode 100644
index 000000000..0184cfc8d
--- /dev/null
+++ b/packages/chord/test/node/fixtures/reload/web-api.ts
@@ -0,0 +1,129 @@
+// The reload suite's web API (`@test/app/v1/web`): the `Views` registry the web plugins contribute their components to,
+// Chord's React hooks, and the probe through which the tests see the page (every state a reference went through, every
+// value of a tracked member) and call from it (each call's answer kept by number).
+
+import type { ComponentType } from "react";
+import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
+import { type Contract, isChordError, type RefState } from "../../../../src/index.ts";
+import { defineRegistry } from "../../../../src/registries/index.ts";
+import type { WatchCall } from "../../../../src/watch.ts";
+
+export { useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "../../../../src/react/index.ts";
+
+/** A web plugin's view: its name (the section's `data-view`) and its component. */
+export type View = { readonly name: string; readonly component: ComponentType };
+
+/** The views, in name order. */
+export const Views = defineRegistry("reload.views", { version: 1 });
+
+/** A call's answer as the tests read it. */
+export type CallResult =
+ | { readonly status: "pending" }
+ | { readonly status: "ok"; readonly value: unknown }
+ | { readonly status: "error"; readonly code: string; readonly reason: string | undefined; readonly message: string };
+
+/** What the probe needs of a reference: its state, its members by name (state members and methods). */
+type ProbedRef = {
+ readonly state: {
+ readonly value: RefState;
+ watch(
+ context: typeof BACKGROUND_CONTEXT,
+ ): Promise<{ start(listener: (call: { kind: string; value?: RefState }) => Promise): void }>;
+ };
+ readonly release: () => void;
+} & Record;
+
+/** A state member as the probe watches it. */
+type ProbedMember = {
+ watch(
+ context: typeof BACKGROUND_CONTEXT,
+ ): Promise<{ start(listener: (call: WatchCall) => Promise): void }>;
+};
+
+/** A reference's state as one string: `live`, `loading`, `failed::`, `ended:`. */
+function stateText(state: RefState): string {
+ if (state.kind === "failed")
+ return `failed:${state.cause.kind}:${state.cause.error.reason ?? state.cause.error.code}`;
+ if (state.kind === "ended") return `ended:${state.error.code}`;
+ return state.kind;
+}
+
+/** The page as the tests see it: tracked references, their states and member values, calls and their answers. */
+class PageProbe {
+ readonly refs: Record = {};
+ /** Every state each tracked reference went through, in order; `remount` where a new reference took the name. */
+ readonly states: Record = {};
+ /** Every value (or `failed:`) of each tracked reference's watched member. */
+ readonly frames: Record = {};
+ readonly results: CallResult[] = [];
+ /** The contracts web plugins registered, by name, for references the tests make themselves (`hold`). */
+ readonly contracts: Record = {};
+ /** The page's own reference to the host, set by the base bundle. */
+ host:
+ | {
+ hold(
+ Type: Contract,
+ params: unknown,
+ context: typeof BACKGROUND_CONTEXT,
+ options: { readyTimeout: number },
+ ): unknown;
+ }
+ | undefined;
+
+ /** Tracks `ref` under `name`: its states from now on and the values of its member `member`. */
+ track(name: string, ref: unknown, member: string): void {
+ // The references the web plugins hand in are Chord references; the probe reads them by member name.
+ const probed = ref as ProbedRef;
+ if (this.refs[name] === probed) return;
+ const states = this.states[name] ?? [];
+ const frames = this.frames[name] ?? [];
+ this.states[name] = states;
+ this.frames[name] = frames;
+ if (this.refs[name] !== undefined) states.push("remount");
+ this.refs[name] = probed;
+ states.push(stateText(probed.state.value));
+ void probed.state.watch(BACKGROUND_CONTEXT).then((watch) =>
+ watch.start(async (call) => {
+ if (this.refs[name] === probed && call.kind === "value") states.push(stateText(call.value!));
+ }),
+ );
+ void (probed[member] as ProbedMember).watch(BACKGROUND_CONTEXT).then((watch) =>
+ watch.start(async (call) => {
+ if (this.refs[name] !== probed) return;
+ frames.push(call.kind === "value" ? call.value : `failed:${failureText(call.error)}`);
+ }),
+ );
+ }
+
+ /** Holds the contract `contract` registered with `params` as the tracked reference `name`, parking calls `readyTimeout`. */
+ hold(name: string, contract: string, params: unknown, readyTimeout: number, member: string): void {
+ const ref = this.host!.hold(this.contracts[contract]!, params, BACKGROUND_CONTEXT, { readyTimeout });
+ this.track(name, ref, member);
+ }
+
+ /** Calls `method` of the reference tracked as `name` with `args`; the number of its answer in `results`. */
+ call(name: string, method: string, ...args: unknown[]): number {
+ const index = this.results.length;
+ this.results.push({ status: "pending" });
+ const fn = this.refs[name]![method] as (...rest: unknown[]) => Promise;
+ fn(...args, BACKGROUND_CONTEXT).then(
+ (value) => {
+ this.results[index] = { status: "ok", value };
+ },
+ (error: unknown) => {
+ this.results[index] = isChordError(error)
+ ? { status: "error", code: error.code, reason: error.reason, message: error.message }
+ : { status: "error", code: "thrown", reason: undefined, message: String(error) };
+ },
+ );
+ return index;
+ }
+}
+
+/** A watch failure's error as text: a Chord error's reason or code, else its message. */
+function failureText(error: unknown): string {
+ return isChordError(error) ? (error.reason ?? error.code) : String(error);
+}
+
+/** The page's probe (one per page: this module is the page's instance of the web API). */
+export const probe = new PageProbe();
diff --git a/packages/chord/test/node/plugins/watcher.test.ts b/packages/chord/test/node/plugins/watcher.test.ts
index 99bc1b418..7607d7a32 100644
--- a/packages/chord/test/node/plugins/watcher.test.ts
+++ b/packages/chord/test/node/plugins/watcher.test.ts
@@ -82,6 +82,21 @@ describe("the watcher's marks", () => {
watcher.close();
});
+ it("marks base for a base file given one by one, not for its siblings; never under node_modules", async () => {
+ const { base, watcher } = await watched();
+ const elsewhere = await realpath(await tempRoot());
+ await writeFiles(elsewhere, { "entry.ts": "", "sibling.ts": "", "node_modules/react/index.js": "" });
+ watcher.baseFiles([join(elsewhere, "entry.ts"), join(elsewhere, "node_modules", "react", "index.js")]);
+ watcher.mark(join(elsewhere, "sibling.ts"));
+ watcher.mark(join(elsewhere, "node_modules", "react", "index.js"));
+ expect(watcher.value).toMatchObject({ base: false });
+ // A real event on the file: its directory is watched.
+ await writeFile(join(elsewhere, "entry.ts"), "export {};");
+ await vi.waitFor(() => expect(watcher.value).toMatchObject({ base: true }), { timeout: 5_000 });
+ expect(base).not.toBe(elsewhere);
+ watcher.close();
+ });
+
it("ignores node_modules and .git", async () => {
const { root, watcher } = await watched();
watcher.mark(join(root, "a", "node_modules", "x", "index.js"));
diff --git a/packages/chord/test/node/reload/base.test.ts b/packages/chord/test/node/reload/base.test.ts
new file mode 100644
index 000000000..1e1070ae4
--- /dev/null
+++ b/packages/chord/test/node/reload/base.test.ts
@@ -0,0 +1,83 @@
+// Reload suite, row "base code (the app's client entry, Chord, React)" (design §21 "What a user sees during a
+// reload"): the page reloads, the only case losing what is on screen; calls in flight end with the link; page state is
+// lost, server state unchanged. The base bundle's entry changes on disk, the watcher flags base code, and the app
+// restarts its hosts (the kit's answer to base code) on the same data directory and port.
+
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import { latches, ok, ReloadPage, startReloadApp, versionedMain } from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM, poll } from "../web/browser.ts";
+
+/** The counter as the web plugins show it. */
+const COUNTER = { placement: "worker", contract: "v1" } as const;
+
+describe.skipIf(CHROMIUM === undefined)("a change of base code", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ it("reloads the page, ends calls in flight with the link, loses page state and keeps server state", async () => {
+ const entry = await versionedMain("1");
+ const first = await startReloadApp(
+ {
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker" }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
+ },
+ { entry },
+ );
+ let second: Awaited> | undefined;
+ try {
+ await page.signIn(first, ["alpha"]);
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ await page.type("alpha", ".kept", "kept");
+ await browser.evaluate("globalThis.marked = true");
+ const hand = await latches(first, "worker");
+ const running = await page.call("alpha", "slowAdd", "running");
+ await hand.entered("running");
+
+ // Base code changes; the app restarts its hosts.
+ await versionedMain("2", join(entry, ".."));
+ await first.app.close(context);
+ // The call in flight ends with the link.
+ expect(await page.result(running)).toMatchObject({ status: "error" });
+ second = await startReloadApp({}, { entry, port: first.port, dataDir: first.dataDir, root: first.root });
+ expect(second.app.web!.base).not.toBe(first.app.web!.base);
+
+ // The page reloads for the new base bundle: what was on screen and in page state is gone.
+ await browser.wait('globalThis.version === "2" && globalThis.page?.connection === "connected"', 60_000);
+ expect(await browser.evaluate("globalThis.marked ?? false")).toBe(false);
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.value("alpha", ".kept")).toBe("");
+ // Server state unchanged: the count as committed before (the cut call never committed).
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(1));
+ } finally {
+ await first.app.close(context);
+ await second?.app.close(context);
+ }
+ }, 120_000);
+
+ it("flags a change of the web entry as base code (`plugins.state.base`)", async () => {
+ const entry = await versionedMain("1");
+ const app = await startReloadApp({ alpha: webPlugin({ name: "alpha", code: "1" }) }, { entry });
+ try {
+ await versionedMain("2", join(entry, ".."));
+ await poll(() => app.app.plugins.state.value.base, 5000, "base code flagged");
+ } finally {
+ await app.app.close(context);
+ }
+ }, 60_000);
+});
diff --git a/packages/chord/test/node/reload/contracts.test.ts b/packages/chord/test/node/reload/contracts.test.ts
new file mode 100644
index 000000000..f79961cac
--- /dev/null
+++ b/packages/chord/test/node/reload/contracts.test.ts
@@ -0,0 +1,157 @@
+// Reload suite, rows "a contract change the new code still `accepts`" and "a contract change not accepted, or a type
+// removed" (design §21 "What a user sees during a reload"). Accepted: the old page keeps working through the mapping,
+// its calls answered through it. Not accepted or removed: the reference waits with its last values (`failed
+// { waiting }`: `version`, `not_served`) until the page's code updates or the type returns; a call on it fails at once
+// with the cause (`contract_mismatch`, `unavailable` not served; as corrected upstream); state unchanged. The page's
+// web plugin keeps its own copy of the contract (an old page) while the counter on the worker moves.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import {
+ editAndApply,
+ ok,
+ ReloadPage,
+ removeAndApply,
+ rolloutFailures,
+ startReloadApp,
+} from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+/** The page's short reference's `readyTimeout`: a call failing at once answers well before it. */
+const PARK_MS = 1500;
+
+describe.skipIf(CHROMIUM === undefined)("a reload changing a contract", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ /** The app with the counter at v1 on worker `p1`, the page showing it (`alpha`) with a count of 2. */
+ async function oldPage() {
+ const placement = "worker";
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
+ });
+ await page.signIn(app, ["alpha"]);
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ await page.type("alpha", ".draft", "half-typed");
+ return app;
+ }
+
+ it("keeps an old page working through the mapping of a contract the new code accepts", async () => {
+ const app = await oldPage();
+ try {
+ const settled = (await page.states("alpha")).length;
+ const result = await editAndApply(
+ app,
+ "counter",
+ counterPlugin({ code: "v2", placement: "worker", contract: "v2", accepts: true }),
+ );
+ expect(rolloutFailures(result)).toEqual([]);
+ // The v1 page reads v2's label mapped back, and its v1 call is mapped forwards and its result back.
+ await page.waitText("alpha", ".label", "v2:2");
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3"));
+ await page.waitText("alpha", ".label", "v2:3");
+ expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v2"));
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ expect(await page.text("alpha", ".count")).toBe("3");
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
+ expect(await page.value("alpha", ".draft")).toBe("half-typed");
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ it("keeps the last values while the version is refused and binds once the page's code updates", async () => {
+ const app = await oldPage();
+ try {
+ const result = await editAndApply(
+ app,
+ "counter",
+ counterPlugin({ code: "v2", placement: "worker", contract: "v2" }),
+ );
+ expect(rolloutFailures(result)).toEqual([]);
+
+ // Unbound `version` (§3): `failed { waiting }` with `contract_mismatch`.
+ await page.waitState("alpha", "failed:waiting:contract_mismatch", 5000);
+ // The last values stay on screen.
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+ expect(await page.text("alpha", ".count")).toBe("2");
+ expect(await page.text("alpha", ".state")).toBe("failed");
+
+ // The page's code updates (its contract copy at v2): the remounted view binds the new version.
+ await editAndApply(
+ app,
+ "alpha",
+ webPlugin({ name: "alpha", code: "2", counter: { placement: "worker", contract: "v2" } }),
+ );
+ await page.waitText("alpha", ".code", "alpha:2");
+ await page.waitText("alpha", ".label", JSON.stringify({ text: "v2:2", code: "v2" }));
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "add", { n: 1 }))).toEqual(ok({ text: "v2:3" }));
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ it("keeps the last values while the type is removed and binds again when the type returns", async () => {
+ const app = await oldPage();
+ try {
+ const result = await removeAndApply(app, "counter");
+ expect(rolloutFailures(result)).toEqual([]);
+
+ // Unbound `not_served` (§3): `failed { waiting }` with `unavailable` ("X is not served here").
+ await page.waitState("alpha", "failed:waiting:unavailable", 5000);
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+ expect(await page.text("alpha", ".count")).toBe("2");
+
+ // The type returns: the same reference binds again, the persisted count as it was.
+ await editAndApply(app, "counter", counterPlugin({ code: "v1", placement: "worker" }));
+ await page.waitState("alpha", "live");
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3"));
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
+ expect(await page.value("alpha", ".draft")).toBe("half-typed");
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ it.each([
+ ["the version is refused", "failed:waiting:contract_mismatch", "contract_mismatch"],
+ ["the type is removed", "failed:waiting:unavailable", "unavailable"],
+ ] as const)(
+ "fails a call at once with the cause while %s",
+ async (change, waiting, code) => {
+ const app = await oldPage();
+ try {
+ await page.hold("short", "alpha", PARK_MS);
+ await page.waitState("short", "live");
+ if (change === "the type is removed") await removeAndApply(app, "counter");
+ else await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker", contract: "v2" }));
+ await page.waitState("short", waiting, 5000);
+ const started = Date.now();
+ const failed = await page.result(await page.call("short", "add", 1));
+ expect(failed).toMatchObject({ status: "error", code });
+ // Not parked: well before the reference's `readyTimeout`.
+ expect(Date.now() - started).toBeLessThan(PARK_MS);
+ } finally {
+ await app.app.close(context);
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/chord/test/node/reload/failed.test.ts b/packages/chord/test/node/reload/failed.test.ts
new file mode 100644
index 000000000..4fc47b627
--- /dev/null
+++ b/packages/chord/test/node/reload/failed.test.ts
@@ -0,0 +1,170 @@
+// Reload suite, row "a plugin whose new build or evaluation fails" (design §21 "What a user sees during a reload"):
+// everything stays on screen; the plugin keeps its old code and the error is reported to the app; calls and state are
+// unaffected. Through a real page: a worker plugin whose new code fails to build, throws when evaluated, or whose setup
+// throws (staging), and a web plugin whose new code fails to build or throws when evaluated.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import { editAndApply, ok, ReloadPage, rolloutFailures, rolloutFates, startReloadApp } from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+/** The counter as the web plugins show it. */
+const COUNTER = { placement: "worker", contract: "v1" } as const;
+
+describe.skipIf(CHROMIUM === undefined)("a reload whose new code fails", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ it.each(["build", "evaluation", "setup"] as const)(
+ "keeps a worker plugin whose new %s fails on its old code, the page untouched",
+ async (broken) => {
+ const placement = "worker";
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
+ });
+ try {
+ await page.signIn(app, ["alpha"]);
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ await page.type("alpha", ".draft", "half-typed");
+ const settled = (await page.states("alpha")).length;
+ const frames = (await page.frames("alpha")).length;
+
+ const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, broken }));
+ // Reported to the app: the worker kept the plugin's old code, and it stays dirty for the next apply.
+ expect(rolloutFailures(result)).toEqual(["project/project|p1: @test/counter"]);
+ expect(app.app.plugins.state.value.dirty).toContain("@test/counter");
+
+ // The page: nothing moved, calls answered by the old code, the document as it was.
+ expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v1"));
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3"));
+ await page.waitText("alpha", ".label", "v1:3");
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ expect((await page.frames("alpha")).slice(frames)).toEqual(["v1:3"]);
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
+ expect(await page.value("alpha", ".draft")).toBe("half-typed");
+ expect(await page.loads()).toBe(1);
+ } finally {
+ await app.app.close(context);
+ }
+ },
+ 120_000,
+ );
+
+ /** The app with the counter, a web plugin `alpha` showing it and a web plugin `beta`, alpha's inputs typed in. */
+ async function webApp() {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker" }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
+ beta: webPlugin({ name: "beta", code: "1" }),
+ });
+ await page.signIn(app, ["alpha", "beta"]);
+ await page.waitText("alpha", ".label", "v1:0");
+ await page.type("alpha", ".draft", "half-typed");
+ await page.type("alpha", ".kept", "kept");
+ return app;
+ }
+
+ /** The old component still mounted with its state, its reference untouched since `settled` states, calls answered. */
+ async function oldComponentKept(settled: number): Promise {
+ expect(await page.text("alpha", ".code")).toBe("alpha:1");
+ expect(await page.value("alpha", ".draft")).toBe("half-typed");
+ expect(await page.value("alpha", ".kept")).toBe("kept");
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:1"));
+ expect(await page.loads()).toBe(1);
+ }
+
+ it("keeps a web plugin whose new build fails on its old code, its component and state on screen", async () => {
+ const app = await webApp();
+ try {
+ const settled = (await page.states("alpha")).length;
+ const result = await editAndApply(
+ app,
+ "alpha",
+ webPlugin({ name: "alpha", code: "2", counter: COUNTER, broken: "build" }),
+ );
+ // Reported to the app: the web step kept the plugin's old script, and it stays dirty for the next apply.
+ expect(rolloutFailures(result)).toEqual(["web/web: @test/alpha"]);
+ expect(app.app.plugins.state.value.dirty).toContain("@test/alpha");
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ await oldComponentKept(settled);
+ expect(await browser.evaluate("globalThis.page.errors")).toEqual([]);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ // Open design point (2026-11-04, with the owner): pages don't report kept-old plugins back. The server's web step
+ // publishes the set without waiting for clients, so it marks the plugin applied (no longer dirty) although the page
+ // kept its old code; decision "Per plugin at startup and reload" (5) says a plugin kept old on any host stays dirty.
+ it("keeps a web plugin whose new script throws in the page on its old code, reported in the page; the server marks it applied (open: pages don't report kept-old plugins back)", async () => {
+ const app = await webApp();
+ try {
+ const settled = (await page.states("alpha")).length;
+ await editAndApply(
+ app,
+ "alpha",
+ webPlugin({ name: "alpha", code: "2", counter: COUNTER, broken: "evaluation" }),
+ );
+ // The page evaluates the script: it keeps the old one and reports it (`startPage`'s `onError`).
+ await browser.wait("globalThis.page.errors.length > 0");
+ expect(await browser.evaluate("globalThis.page.errors")).toEqual([
+ { kind: "plugins", plugins: ["@test/alpha"] },
+ ]);
+ await oldComponentKept(settled);
+ // Today's behaviour, the open point above: the server considers it applied.
+ expect(app.app.plugins.state.value.dirty).toEqual([]);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ // A case the table leaves out (the audit's "new code that fails at activation"): the new code passes staging, so the
+ // commit applies it; its native then fails to open. A runtime state (§21: failed opens stay per object after the
+ // commit): the old code is gone, the page shows the failure with the last values, and the next fix applies.
+ it("shows a failure with the last values when new code passes staging and fails at activation, until a fix", async () => {
+ const placement = "worker";
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement, native: true }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
+ });
+ try {
+ await page.signIn(app, ["alpha"]);
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ const result = await editAndApply(
+ app,
+ "counter",
+ counterPlugin({ code: "v2", placement, native: true, broken: "open" }),
+ );
+ expect(rolloutFailures(result)).toEqual([]);
+ expect(rolloutFates(result)).toContain("project/project|p1: reload.counter(p1) restarted");
+ expect(app.app.plugins.state.value.dirty).toEqual([]);
+ await page.waitText("alpha", ".state", "failed");
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+ expect(await page.result(await page.call("alpha", "code"))).toMatchObject({ status: "error" });
+
+ await editAndApply(app, "counter", counterPlugin({ code: "v3", placement, native: true }));
+ await page.waitText("alpha", ".state", "live");
+ await page.waitText("alpha", ".label", "v3:2");
+ expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v3"));
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+});
diff --git a/packages/chord/test/node/reload/jobs.test.ts b/packages/chord/test/node/reload/jobs.test.ts
new file mode 100644
index 000000000..bb61e7303
--- /dev/null
+++ b/packages/chord/test/node/reload/jobs.test.ts
@@ -0,0 +1,71 @@
+// Reload suite, a case the table leaves out (the audit's "jobs running across a reload"; design §15 "Versions"): a job
+// run started from the page, running on a worker while its object reloads. A pure swap: the run finishes on its old
+// code and `finish` runs on the new code. A native restart: the in-memory run ends `aborted { reload }` and `finish`
+// runs on the new code. The page watches the finished runs' entries.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import {
+ editAndApply,
+ latches,
+ ReloadPage,
+ rolloutFailures,
+ rolloutFates,
+ startReloadApp,
+} from "../fixtures/reload/app.ts";
+import { LATCH_PLUGIN, tasksPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+describe.skipIf(CHROMIUM === undefined)("a job running across a reload", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ it.each([
+ ["a pure swap", false, "swapped", "v2-finish(done:run-v1:a)"],
+ ["a native restart", true, "restarted", "v2-finish(aborted:reload)"],
+ ] as const)(
+ "across %s",
+ async (_change, native, fate, entry) => {
+ const app = await startReloadApp({ latch: LATCH_PLUGIN, tasks: tasksPlugin("v1", native) });
+ const hand = await latches(app, "worker");
+ try {
+ await page.signIn(app, []);
+ await page.hold("tasks", "tasks", 10_000, "p1", "entries");
+ await page.waitState("tasks", "live");
+ const started = await page.result(await page.call("tasks", "start", "a"));
+ expect(started).toMatchObject({ status: "ok" });
+ await hand.entered("a");
+
+ const result = await editAndApply(app, "tasks", tasksPlugin("v2", native));
+ expect(rolloutFailures(result)).toEqual([]);
+ expect(rolloutFates(result)).toContain(`project/project|p1: reload.runner ${fate}`);
+ await hand.open("a");
+ await browser.wait(
+ `globalThis.reload.frames.tasks?.some((frame) => Array.isArray(frame) && frame.length > 0)`,
+ );
+ const frames = await page.frames("tasks");
+ expect(frames.at(-1)).toEqual([entry]);
+ // A run started now runs on the new code.
+ await page.call("tasks", "start", "b");
+ await hand.entered("b");
+ await hand.open("b");
+ await browser.wait("globalThis.reload.frames.tasks?.at(-1)?.length === 2");
+ expect((await page.frames("tasks")).at(-1)).toEqual([entry, "v2-finish(done:run-v2:b)"]);
+ } finally {
+ await hand.open("a");
+ await hand.open("b");
+ await app.app.close(context);
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/chord/test/node/reload/pure.test.ts b/packages/chord/test/node/reload/pure.test.ts
new file mode 100644
index 000000000..ba1fe1b54
--- /dev/null
+++ b/packages/chord/test/node/reload/pure.test.ts
@@ -0,0 +1,133 @@
+// Reload suite, row "pure code (no natives) on the server or a worker" (design §21 "What a user sees during a reload"):
+// the view stays bound, one fresh snapshot once the new code is live, values change only where the new code computes
+// differently; a running call finishes on the old code, a call arriving meanwhile waits and runs on the new code, never
+// `not_ready`; documents and tables unchanged. Through a real page, for a counter on a project's worker and on the
+// server.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import {
+ editAndApply,
+ latches,
+ ok,
+ ReloadPage,
+ rolloutFailures,
+ rolloutFates,
+ startReloadApp,
+} from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, type Placement, summaryPlugin, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+describe.skipIf(CHROMIUM === undefined)("a reload of pure code", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ describe.each(["worker", "server"])("on the %s", (placement) => {
+ it("keeps the view bound, finishes a running call on the old code and runs new calls on the new code", async () => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
+ });
+ try {
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".label", "v1:0");
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ await page.type("alpha", ".draft", "half-typed");
+ const settled = (await page.states("alpha")).length;
+ const hand = await latches(app, placement);
+ const running = await page.call("alpha", "slow", "running");
+ await hand.entered("running");
+
+ // The new code computes the label differently (its code name) and the count the same.
+ const applying = editAndApply(app, "counter", counterPlugin({ code: "v2", placement }));
+ // Calls sent while the rollout runs: each answered by the old or the new code, never `not_ready`.
+ const during: number[] = [];
+ let applied = false;
+ void applying.finally(() => {
+ applied = true;
+ });
+ while (!applied) {
+ during.push(await page.call("alpha", "code"));
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ const result = await applying;
+ expect(rolloutFailures(result)).toEqual([]);
+ const step = placement === "worker" ? "project/project|p1" : "server/server";
+ expect(rolloutFates(result)).toEqual([
+ `${step}: reload.store swapped`,
+ `${step}: reload.counter(p1) swapped`,
+ ]);
+
+ // Arriving after the commit, while the old call still runs: the new code answers.
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3"));
+ expect(await page.peek(running)).toEqual({ status: "pending" });
+ await hand.open("running");
+ expect(await page.result(running)).toEqual(ok("v1:running"));
+ for (const index of during) {
+ const answer = await page.result(index);
+ expect([ok("v1"), ok("v2")]).toContainEqual(answer);
+ }
+
+ await page.waitText("alpha", ".label", "v2:3");
+ // Bound throughout: the reference never left `live`, its label went from the old value to the new.
+ expect((await page.states("alpha")).slice(settled - 1)).toEqual(["live"]);
+ const frames = await page.frames("alpha");
+ expect(frames.filter((frame) => typeof frame === "string" && frame.startsWith("failed"))).toEqual([]);
+ expect(frames.slice(frames.indexOf("v1:2"))).toEqual(["v1:2", "v2:2", "v2:3"]);
+ // Documents and tables unchanged: the count went on from 2, the table kept the old code's rows.
+ expect(await page.text("alpha", ".count")).toBe("3");
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
+ // The page itself was not touched.
+ expect(await page.value("alpha", ".draft")).toBe("half-typed");
+ expect(await page.loads()).toBe(1);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+ });
+
+ // The audit's F1: every `self.get` of a type on another host opens a new hold, which starts `loading`, so the new
+ // attempt of a pure object over a worker object parks instead of swapping in place.
+ // Expected to fail until the open design point is settled: "a parked swap keeps the old instance serving" (under review).
+ it.fails(
+ "keeps the view of a pure server object over a worker object bound when only the server object reloads",
+ async () => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker" }),
+ summary: summaryPlugin("s1"),
+ alpha: webPlugin({
+ name: "alpha",
+ code: "1",
+ counter: { placement: "server", contract: "v1" },
+ summary: true,
+ }),
+ });
+ try {
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".label", "s1(0)");
+ const settled = (await page.states("alpha")).length;
+ const result = await editAndApply(app, "summary", summaryPlugin("s2"));
+ expect(rolloutFailures(result)).toEqual([]);
+ await page.waitText("alpha", ".label", "s2(0)");
+ expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("s2"));
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ expect(rolloutFates(result)).toEqual(["server/server: reload.summary(p1) swapped"]);
+ } finally {
+ await app.app.close(context);
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/chord/test/node/reload/restart.test.ts b/packages/chord/test/node/reload/restart.test.ts
new file mode 100644
index 000000000..280e70865
--- /dev/null
+++ b/packages/chord/test/node/reload/restart.test.ts
@@ -0,0 +1,126 @@
+// Reload suite, row "code holding natives (restart)" (design §21 "What a user sees during a reload", as corrected
+// 2026-11-04): while the old code drains its running calls (up to `closeTimeout`) the page stays `live` with its last
+// values and whatever the draining old code commits; calls sent meanwhile wait at the gate; then `loading` briefly,
+// then a fresh snapshot from the new code, which answers the waiting calls; documents and tables unchanged, natives
+// reopened. Through a real page, for a counter holding a native on a project's worker and on the server.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import {
+ editAndApply,
+ latches,
+ ok,
+ ReloadPage,
+ rolloutFailures,
+ rolloutFates,
+ startReloadApp,
+ TIMEOUTS,
+} from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, type Placement, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+describe.skipIf(CHROMIUM === undefined)("a reload restarting an object with natives", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ describe.each(["worker", "server"])("on the %s", (placement) => {
+ it("stays live while the old code drains, then shows loading and a fresh snapshot from the new code", async () => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement, native: true }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
+ });
+ const hand = await latches(app, placement);
+ try {
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ const settled = (await page.states("alpha")).length;
+ const before = await hand.natives();
+ // A running call that commits once the test lets it go on.
+ const running = await page.call("alpha", "slowAdd", "running");
+ await hand.entered("running");
+
+ const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, native: true }));
+ expect(rolloutFailures(result)).toEqual([]);
+ const step = placement === "worker" ? "project/project|p1" : "server/server";
+ expect(rolloutFates(result)).toEqual([
+ `${step}: reload.store swapped`,
+ `${step}: reload.counter(p1) restarted`,
+ ]);
+
+ // The drain: still `live` with the last values; a call sent now waits at the gate.
+ const arriving = await page.call("alpha", "add", 1);
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ expect(await page.peek(arriving)).toEqual({ status: "pending" });
+ expect(await page.peek(running)).toEqual({ status: "pending" });
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ expect(await page.text("alpha", ".state")).toBe("live");
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+
+ // The running call commits through the store (swapped: its new code answers) and finishes on the old
+ // counter; the page sees that commit through the old code before `loading`.
+ await hand.open("running");
+ expect(await page.result(running)).toEqual(ok("v2:12"));
+ expect(await page.result(arriving)).toEqual(ok("v2:13"));
+ await page.waitText("alpha", ".label", "v2:13");
+ await page.waitText("alpha", ".state", "live");
+ expect((await page.states("alpha")).slice(settled)).toEqual(["loading", "live"]);
+ const frames = await page.frames("alpha");
+ const since = frames.slice(frames.lastIndexOf("v1:2"));
+ expect(since.slice(0, 2)).toEqual(["v1:2", "v1:12"]);
+ expect(since.at(-1)).toBe("v2:13");
+ expect(since.filter((frame) => typeof frame === "string" && frame.startsWith("failed"))).toEqual([]);
+ // The native closed for the reload and reopened on the new code; documents and tables unchanged.
+ const after = await hand.natives();
+ expect(after.opens.slice(before.opens.length)).toEqual(["v2"]);
+ expect(after.closes.slice(before.closes.length)).toEqual(["v1:reload"]);
+ expect(await page.text("alpha", ".count")).toBe("13");
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(3));
+ } finally {
+ // A failed expectation leaves the call waiting: open it so the close does not wait for the drain.
+ await hand.open("running");
+ await app.app.close(context);
+ }
+ }, 120_000);
+ });
+
+ it.each(["worker", "server"])(
+ "cuts a running call off after closeTimeout on the %s, then runs the waiting call on the new code",
+ async (placement) => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement, native: true }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
+ });
+ try {
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".state", "live");
+ const hand = await latches(app, placement);
+ const stuck = await page.call("alpha", "slow", "stuck");
+ await hand.entered("stuck");
+ const started = Date.now();
+ await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, native: true }));
+ const arriving = await page.call("alpha", "code");
+ const cut = await page.result(stuck);
+ expect(Date.now() - started).toBeGreaterThanOrEqual(TIMEOUTS.closeTimeout - 100);
+ expect(cut).toMatchObject({ status: "error", code: "released" });
+ expect(await page.result(arriving)).toEqual(ok("v2"));
+ await page.waitText("alpha", ".state", "live");
+ } finally {
+ await app.app.close(context);
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/chord/test/node/reload/rollout.test.ts b/packages/chord/test/node/reload/rollout.test.ts
new file mode 100644
index 000000000..523ea1b88
--- /dev/null
+++ b/packages/chord/test/node/reload/rollout.test.ts
@@ -0,0 +1,185 @@
+// Reload suite, the rollout across hosts (design §21 "What a user sees during a reload": "the server, then every
+// running worker by the same rules, then the clients' scripts") and the cases the table leaves out: a worker not
+// running during the rollout, a page connecting while a restart drains, two rollouts back to back, a rollout while the
+// page's link is down. Through real pages.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import {
+ edit,
+ editAndApply,
+ latches,
+ ok,
+ ReloadPage,
+ rolloutFailures,
+ rolloutFates,
+ startReloadApp,
+} from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, summaryPlugin, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+/** The counter as the web plugins show it. */
+const COUNTER = { placement: "worker", contract: "v1" } as const;
+
+describe.skipIf(CHROMIUM === undefined)("a rollout across hosts", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ /** The app with the counter on the workers and `alpha` showing p1's, signed in, its counter at 2. */
+ async function started(native = false) {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker", native }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
+ });
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ return app;
+ }
+
+ it("reloads the server, then the running workers, then the clients; a worker not running starts on the new code", async () => {
+ const app = await started();
+ try {
+ const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker" }));
+ expect(rolloutFailures(result)).toEqual([]);
+ expect(result.steps.map(({ name, hosts }) => [name, hosts.map(({ host }) => host)])).toEqual([
+ ["server", ["server"]],
+ ["project", ["project|p1"]],
+ ["web", ["web"]],
+ ]);
+ expect(rolloutFates(result)).toEqual([
+ "project/project|p1: reload.store swapped",
+ "project/project|p1: reload.counter(p1) swapped",
+ ]);
+ // Project p2 had no worker during the rollout: its first hold starts one on the current set.
+ await page.hold("p2", "alpha", 10_000, "p2");
+ await page.waitState("p2", "live");
+ expect(await page.result(await page.call("p2", "code"))).toEqual(ok("v2"));
+ expect(await page.result(await page.call("p2", "add", 1))).toEqual(ok("v2:1"));
+ expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v2"));
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ it("binds a page that connects while a restart drains to the new code once it is live", async () => {
+ const app = await started(true);
+ const hand = await latches(app, "worker");
+ const second = await Browser.start();
+ try {
+ const running = await page.call("alpha", "slowAdd", "running");
+ await hand.entered("running");
+ await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker", native: true }));
+ // A second page signs in while the old code drains.
+ const late = new ReloadPage(second);
+ await late.signInAgain(app, ["alpha"]);
+ const call = await late.call("alpha", "code");
+ await hand.open("running");
+ expect(await page.result(running)).toEqual(ok("v2:12"));
+ expect(await late.result(call)).toEqual(ok("v2"));
+ await late.waitText("alpha", ".label", "v2:12");
+ await late.waitText("alpha", ".state", "live");
+ await page.waitText("alpha", ".label", "v2:12");
+ } finally {
+ await hand.open("running");
+ await second.close();
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ it("ends two rollouts back to back on the second's code, the page following without failing", async () => {
+ const app = await started();
+ try {
+ const settled = (await page.states("alpha")).length;
+ await edit(app, "counter", counterPlugin({ code: "v2", placement: "worker" }));
+ const first = app.app.applyChanges(context);
+ await edit(app, "counter", counterPlugin({ code: "v3", placement: "worker" }));
+ const second = app.app.applyChanges(context);
+ expect(rolloutFailures(await first)).toEqual([]);
+ expect(rolloutFailures(await second)).toEqual([]);
+ await page.waitText("alpha", ".label", "v3:2");
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v3:3"));
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ expect(app.app.plugins.state.value.dirty).toEqual([]);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ it("brings a page whose link was down during the rollout onto the new scripts and the new code", async () => {
+ const app = await started();
+ try {
+ await page.type("alpha", ".kept", "kept");
+ await browser.evaluate("globalThis.page.holdReconnect(); globalThis.page.dropLink()");
+ await browser.wait('globalThis.page.connection !== "connected"');
+ await page.waitState("alpha", "loading");
+ // The last values stay on screen while the link is down.
+ expect(await page.text("alpha", ".label")).toBe("v1:2");
+
+ await edit(app, "counter", counterPlugin({ code: "v2", placement: "worker" }));
+ await edit(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER }));
+ const result = await app.app.applyChanges(context);
+ expect(rolloutFailures(result)).toEqual([]);
+ await browser.evaluate("globalThis.page.release()");
+
+ await browser.wait('globalThis.page.connection === "connected"');
+ await page.waitText("alpha", ".code", "alpha:2");
+ await page.waitText("alpha", ".label", "v2:2");
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.value("alpha", ".kept")).toBe("kept");
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3"));
+ expect(await page.loads()).toBe(1);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ // A case the table leaves out (the audit's "a contract change between hosts, consumer first"): the counter's
+ // contract moves to v2 without `accepts`, and the server's summary, which holds it, changes with it (it copies the
+ // contract file). The server step runs first: the summary's new request meets the old worker serving v1.
+ // Expected to fail until the open design point is settled: "a parked swap keeps the old instance serving" (under review).
+ it.fails(
+ "keeps the view of a server consumer bound while its worker provider moves to a new version with it",
+ async () => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker" }),
+ summary: summaryPlugin("s1"),
+ alpha: webPlugin({
+ name: "alpha",
+ code: "1",
+ counter: { placement: "server", contract: "v1" },
+ summary: true,
+ }),
+ });
+ try {
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".label", "s1(0)");
+ const settled = (await page.states("alpha")).length;
+ const result = await editAndApply(
+ app,
+ "counter",
+ counterPlugin({ code: "v2", placement: "worker", contract: "v2" }),
+ );
+ expect(rolloutFailures(result)).toEqual([]);
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("s1"));
+ expect((await page.states("alpha")).slice(settled)).toEqual([]);
+ } finally {
+ await app.app.close(context);
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/chord/test/node/reload/web.test.ts b/packages/chord/test/node/reload/web.test.ts
new file mode 100644
index 000000000..a66281722
--- /dev/null
+++ b/packages/chord/test/node/reload/web.test.ts
@@ -0,0 +1,131 @@
+// Reload suite, row "a web or terminal plugin" (design §21 "What a user sees during a reload"): the other plugins
+// untouched, scroll kept; the reloaded plugin's components remount, their state (`useState`) starts over while page
+// state (`usePageState`) survives; the reloaded plugin's calls as above. Through a real page with two web plugins and
+// the base bundle's own input.
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
+import { editAndApply, latches, ok, ReloadPage, rolloutFailures, startReloadApp } from "../fixtures/reload/app.ts";
+import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
+import { Browser, CHROMIUM } from "../web/browser.ts";
+
+/** The counter as the web plugins show it. */
+const COUNTER = { placement: "worker", contract: "v1" } as const;
+
+describe.skipIf(CHROMIUM === undefined)("a reload of a web plugin", () => {
+ let browser: Browser;
+ let page: ReloadPage;
+
+ beforeAll(async () => {
+ browser = await Browser.start();
+ page = new ReloadPage(browser);
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ it("remounts only the reloaded plugin's components, keeping page state, the other plugins and the scroll", async () => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker" }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
+ beta: webPlugin({ name: "beta", code: "1" }),
+ });
+ try {
+ await page.signIn(app, ["alpha", "beta"]);
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
+ await page.waitText("alpha", ".label", "v1:2");
+ await page.type("alpha", ".draft", "alpha draft");
+ await page.type("alpha", ".kept", "alpha kept");
+ await page.type("beta", ".draft", "beta draft");
+ await page.type("beta", ".kept", "beta kept");
+ await browser.evaluate('document.getElementById("base-draft").focus()');
+ await browser.type("base draft");
+ // Marks on the DOM nodes that must survive, and a scroll position.
+ await browser.evaluate(
+ 'document.querySelector("[data-view=beta] .draft").marked = true; document.querySelector("[data-view=alpha] .draft").marked = true; window.scrollTo(0, 1000)',
+ );
+ expect(await browser.evaluate("window.scrollY")).toBe(1000);
+
+ const result = await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER }));
+ expect(rolloutFailures(result)).toEqual([]);
+ expect(result.steps.map(({ name }) => name)).toEqual(["server", "project", "web"]);
+
+ await page.waitText("alpha", ".code", "alpha:2");
+ // The reloaded plugin: a new component (its state starts over), its page state kept, its counter shown again.
+ expect(await page.value("alpha", ".draft")).toBe("");
+ expect(await browser.evaluate('document.querySelector("[data-view=alpha] .draft").marked ?? false')).toBe(
+ false,
+ );
+ expect(await page.value("alpha", ".kept")).toBe("alpha kept");
+ await page.waitText("alpha", ".label", "v1:2");
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3"));
+ // Everything else untouched: the other plugin's component (the same DOM node), the base's input, the scroll.
+ expect(await page.text("beta", ".code")).toBe("beta:1");
+ expect(await page.value("beta", ".draft")).toBe("beta draft");
+ expect(await browser.evaluate('document.querySelector("[data-view=beta] .draft").marked ?? false')).toBe(true);
+ expect(await page.value("beta", ".kept")).toBe("beta kept");
+ expect(await browser.evaluate('document.getElementById("base-draft").value')).toBe("base draft");
+ expect(await browser.evaluate("window.scrollY")).toBe(1000);
+ expect(await page.loads()).toBe(1);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ // Open design point (2026-11-04, with the owner): `useHold` releases the old component's reference when a plugin
+ // reload remounts it, and the release aborts its calls in flight on the worker; yet "a component unmounted by a
+ // reload has not left" (§21). Asserted as it is today.
+ it("ends a call the reloaded plugin's old component made `released`, nothing committed (open: useHold's release on a reload's unmount aborts it)", async () => {
+ const app = await startReloadApp({
+ latch: LATCH_PLUGIN,
+ counter: counterPlugin({ code: "v1", placement: "worker" }),
+ alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
+ });
+ const hand = await latches(app, "worker");
+ try {
+ await page.signIn(app, ["alpha"]);
+ await page.waitText("alpha", ".state", "live");
+ const running = await page.call("alpha", "slowAdd", "running");
+ await hand.entered("running");
+ await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER }));
+ await page.waitText("alpha", ".code", "alpha:2");
+ await hand.open("running");
+ expect(await page.result(running)).toMatchObject({ status: "error", code: "released" });
+ // Aborted on the worker: nothing committed.
+ await page.waitText("alpha", ".state", "live");
+ expect(await page.text("alpha", ".label")).toBe("v1:0");
+ expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(0));
+ } finally {
+ await hand.open("running");
+ await app.app.close(context);
+ }
+ }, 120_000);
+
+ // A case the table leaves out (the audit's "a plugin whose contributions change shape"): a web plugin's new code
+ // contributes no view, then contributes it again; the other plugin's component stays mounted throughout.
+ it("removes and restores a view whose plugin's contributions change, the other plugins untouched", async () => {
+ const app = await startReloadApp({
+ alpha: webPlugin({ name: "alpha", code: "1" }),
+ beta: webPlugin({ name: "beta", code: "1" }),
+ });
+ try {
+ await page.signIn(app, ["alpha", "beta"]);
+ await page.type("beta", ".draft", "beta draft");
+ await page.type("alpha", ".kept", "alpha kept");
+ await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", hidden: true }));
+ await browser.wait('document.querySelector("[data-view=alpha]") === null');
+ expect(await page.value("beta", ".draft")).toBe("beta draft");
+ await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "3" }));
+ await page.waitText("alpha", ".code", "alpha:3");
+ expect(await page.value("alpha", ".kept")).toBe("alpha kept");
+ expect(await page.value("beta", ".draft")).toBe("beta draft");
+ expect(await page.loads()).toBe(1);
+ } finally {
+ await app.app.close(context);
+ }
+ }, 120_000);
+});
diff --git a/packages/chord/test/remote/hold.test.ts b/packages/chord/test/remote/hold.test.ts
index 57dec16a4..129a11538 100644
--- a/packages/chord/test/remote/hold.test.ts
+++ b/packages/chord/test/remote/hold.test.ts
@@ -1905,7 +1905,7 @@ describe('calls in flight across a `loading` unbind (decision "Calls in flight s
await server.close(context);
});
- it("a failed unbind still rejects: a live failure answers its calls with the failure, later ones not_ready", async () => {
+ it("a failed unbind still rejects: a live failure answers its calls with the failure, and calls crossing it too", async () => {
const gate = Promise.withResolvers();
let self: Self | undefined;
const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} });
@@ -1933,13 +1933,68 @@ describe('calls in flight across a `loading` unbind (decision "Calls in flight s
const rejected = await peer.next("error");
expect(rejected).toMatchObject({ call: 0, error: { error: { message: "it broke" } } });
await peer.next("failed");
- // Told a failure, the hold takes no calls by the old names.
+ // Told a failure, the hold takes no calls by the old names: one that crossed the `failed` gets that cause.
call(peer, 1, "ping");
- expect(await peer.next("error")).toMatchObject({ call: 1, error: { error: { code: "not_ready" } } });
+ expect(await peer.next("error")).toMatchObject({ call: 1, error: { error: { message: "it broke" } } });
gate.resolve();
await server.close(context);
});
+ it("a call by the old variant's index crossing a fresh bound of another variant is decoded by the old names (audit F4)", async () => {
+ const ran: string[] = [];
+ const { server, peer, reload } = await keptServer({ n: 1, ran }, { ack: false });
+ peer.ack();
+ await reload({ n: 2, variant: "b", ran });
+ const bound = await peer.next("bound");
+ expect(bound.holds[0]!.variant).toBe(1);
+ // Sent before the consumer applied that `bound`: by variant a's indexes (`bump` is 0, which is `ping` in b).
+ call(peer, 0, "bump");
+ call(peer, 1, "ping");
+ await transported();
+ expect(answersTo(peer, 0)).toMatchObject([
+ { kind: "error", error: { error: { code: "invalid", reason: "member" } } },
+ ]);
+ expect(answersTo(peer, 1).map((answer) => answer.kind === "result" && partValue(answer.value!))).toEqual([2]);
+ expect(ran).toEqual(["ping"]);
+ // Applied and acknowledged: now by variant b's indexes.
+ peer.ack();
+ peer.send({ kind: "call", call: 2, hold: 0, method: wireNames(Kept, "b").methods.ping! });
+ expect(partValue((await peer.next("result")).value!)).toBe(2);
+ expect(ran).toEqual(["ping", "ping"]);
+ expect(peer.closed).toBe(false);
+ await server.close(context);
+ });
+
+ it("a call crossing a removal's failed gets the removal's cause, never not_ready (audit F3)", async () => {
+ const { server, peer } = await keptServer({ n: 1 });
+ await server.reload([], context);
+ const failed = await peer.next("failed");
+ expect(failed.cause).toMatchObject({ kind: "waiting", error: { error: { code: "unavailable" } } });
+ // Sent as a consumer sends it before the `failed` reaches it.
+ call(peer, 0, "ping");
+ const answer = await peer.next("error");
+ expect(answer.error).toEqual(failed.cause.error);
+ await server.close(context);
+ });
+
+ it("a call crossing a version refusal's failed gets contract_mismatch, never not_ready (audit F3)", async () => {
+ const { server, peer, reload } = await keptServer({ n: 1 });
+ // The new code serves version 2 only: the version 1 hold is refused.
+ const answer = async () => 2;
+ const v2 = defineFacet((h) =>
+ h.serve(KeptV2, () => ({
+ variant: "a",
+ members: { label: constant({ text: "v2" }), bump: answer, ping: answer },
+ })),
+ );
+ await reload(v2);
+ const failed = await peer.next("failed");
+ expect(failed.cause).toMatchObject({ kind: "waiting", error: { error: { code: "contract_mismatch" } } });
+ call(peer, 0, "ping");
+ expect(await peer.next("error")).toMatchObject({ call: 0, error: { error: { code: "contract_mismatch" } } });
+ await server.close(context);
+ });
+
describe("through a relay", () => {
/** `Kept` placed on a session's worker. */
const PlacedKept = remote("app.placed-kept", {
diff --git a/packages/chord/test/surfaces/client.test.ts b/packages/chord/test/surfaces/client.test.ts
index fc5801654..c43e40242 100644
--- a/packages/chord/test/surfaces/client.test.ts
+++ b/packages/chord/test/surfaces/client.test.ts
@@ -48,8 +48,19 @@ function setOf(base: string, scripts: readonly RegisteredScript[]): ScriptSet {
};
}
-/** A server serving `WebScripts` with `initial`, and a client started against it with `scripts` available to fetch. */
-async function setup(initial: readonly RegisteredScript[], available: readonly RegisteredScript[] = []) {
+/** A timer of the client's environment the test fires itself: its pause and its callback. */
+type ManualTimer = { readonly ms: number; readonly fire: () => void };
+
+/**
+ * A server serving `WebScripts` with `initial`, and a client started against it with `scripts` available to fetch;
+ * `failing` fetches fail first (a network error), `manual` collects the environment's timers instead of running them.
+ */
+async function setup(
+ initial: readonly RegisteredScript[],
+ available: readonly RegisteredScript[] = [],
+ more: { readonly failing?: number; readonly manual?: ManualTimer[] } = {},
+) {
+ let failing = more.failing ?? 0;
const server = createHost({
address: hostKind("server")(),
principal: { kind: "server" },
@@ -81,6 +92,10 @@ async function setup(initial: readonly RegisteredScript[], available: readonly R
},
fetchScript: async (entry) => {
fetched.push(entry.url);
+ if (failing > 0) {
+ failing--;
+ throw new Error(`${entry.url}: network error`);
+ }
const found = available.find(({ plugin, digest }) => plugin === entry.name && digest === entry.digest);
if (found === undefined) throw new Error(`no ${entry.url}`);
return found;
@@ -90,6 +105,14 @@ async function setup(initial: readonly RegisteredScript[], available: readonly R
environment: {
now: () => Date.now(),
after: (ms, callback) => {
+ if (more.manual !== undefined) {
+ const timer = { ms, fire: callback };
+ more.manual.push(timer);
+ return () => {
+ const at = more.manual?.indexOf(timer) ?? -1;
+ if (at >= 0) more.manual?.splice(at, 1);
+ };
+ }
const timer = setTimeout(callback, ms);
return () => clearTimeout(timer);
},
@@ -157,6 +180,49 @@ describe("applying a new set in place", () => {
});
});
+describe("a failed update (audit F7)", () => {
+ it("tries a set whose script did not load again after 1 s, then 2 s, until it applies", async () => {
+ const a1 = script("@test/a", "d1", "hello");
+ const a2 = script("@test/a", "d2", "hello again");
+ const timers: ManualTimer[] = [];
+ const { client, server, errors, fetched } = await setup([a1], [a2], { failing: 2, manual: timers });
+ const greeting = await client.host.get(Greeting, {}, context);
+ const retries = () => timers.filter(({ ms }) => ms === 1000 || ms === 2000);
+ await publishTo(server, "web", setOf("B1", [a2]), context);
+ await until(() => retries().length === 1);
+ expect(errors.map(({ kind }) => kind)).toEqual(["update_failed"]);
+ expect(retries()[0]!.ms).toBe(1000);
+ retries()[0]!.fire();
+ await until(() => retries().some(({ ms }) => ms === 2000));
+ expect(errors.map(({ kind }) => kind)).toEqual(["update_failed", "update_failed"]);
+ retries()
+ .find(({ ms }) => ms === 2000)!
+ .fire();
+ await until(() => greeting.text.value === "hello again");
+ expect(fetched).toEqual(["/@test/a.d2.js", "/@test/a.d2.js", "/@test/a.d2.js"]);
+ await client.stop(context);
+ await server.close(context);
+ });
+
+ it("drops the retry of a set a newer one replaced, and applies the newer one", async () => {
+ const a1 = script("@test/a", "d1", "hello");
+ const a2 = script("@test/a", "d2", "hello again");
+ const a3 = script("@test/a", "d3", "hello at last");
+ const timers: ManualTimer[] = [];
+ const { client, server, fetched } = await setup([a1], [a2, a3], { failing: 1, manual: timers });
+ const greeting = await client.host.get(Greeting, {}, context);
+ await publishTo(server, "web", setOf("B1", [a2]), context);
+ await until(() => timers.some(({ ms }) => ms === 1000));
+ await publishTo(server, "web", setOf("B1", [a3]), context);
+ await until(() => greeting.text.value === "hello at last");
+ // The retry was cancelled with the newer set: nothing is left to fire.
+ expect(timers.some(({ ms }) => ms === 1000)).toBe(false);
+ expect(fetched).toEqual(["/@test/a.d2.js", "/@test/a.d3.js"]);
+ await client.stop(context);
+ await server.close(context);
+ });
+});
+
describe("outdated clients", () => {
it("calls outdated for a set with another base", async () => {
const { client, server, outdated } = await setup([]);