feat(chord): the reload suite, kit timeouts and base marks, four reload fixes

An end-to-end reload suite (a kit app, a worker, a real Chromium page; one test per row of design's reload table).
The kit gains timeouts and base (base bundles' inputs and worker entries mark base). Fixes: a call crossing a failed
or a removal gets the cause, not not_ready; a call sent with an old method index is decoded by the names its consumer
had until the new bound is acked; a page retries a failed script fetch; applyChanges runs one at a time.
This commit is contained in:
Mario Zechner 2026-11-04 16:47:07 +00:00
parent fcebc09956
commit edc9c69892
30 changed files with 2410 additions and 45 deletions

View file

@ -33,6 +33,8 @@ const app = await startServer<P>({
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 `<dataDir>/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

View file

@ -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<P extends Principal> = {
* send (the principal of their link). Each worker's is its `startWorker`'s.
*/
readonly principalAttributes?: PrincipalAttributes<P>;
/**
* 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<WebSurfaceOptions, "entry" | "page" | "paths" | "public">;
@ -182,7 +193,10 @@ export interface App<P extends Principal> {
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<RolloutResult>;
/**
* 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<P extends Principal>(options: AppOptions<P>, 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<P extends Principal>(options: AppOptions<P>, 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<P extends Principal>(options: AppOptions<P>, c
]),
),
install: options.install,
base: baseDirs,
publicPath: `${WEB_PREFIX}assets/`,
onError: (error) => onError({ kind: "plugins", error }),
},
@ -300,6 +323,8 @@ export async function startServer<P extends Principal>(options: AppOptions<P>, 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<P extends Principal> = {
readonly facet: Facet<P>;
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<P extends Principal>(
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<P extends Principal> implements App<P> {
readonly #options: AppOptions<P>;
#link = "";
#closing: Promise<void> | undefined;
/** The latest `applyChanges`, settled to nothing: the next one starts after it. */
#applying: Promise<void> = Promise.resolve();
constructor(
host: Host<P>,
@ -498,7 +528,19 @@ class RunningApp<P extends Principal> implements App<P> {
return this.#surfaces.find(({ name }) => name === "tui");
}
async applyChanges(context: Context): Promise<RolloutResult> {
applyChanges(context: Context): Promise<RolloutResult> {
// 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<RolloutResult> {
const plugins = this.#component;
const built = await plugins.build(plugins.state.value.dirty, context);
const steps = [

View file

@ -9,6 +9,7 @@ export {
type AppKind,
type AppOptions,
type AppPlugins,
type AppTimeouts,
type AppWorkerKind,
Server,
startServer,

View file

@ -65,6 +65,7 @@ export function startWorker<P extends Principal>(options: WorkerOptions<P>): voi
store,
telemetry: recorder,
...(principalAttributes === undefined ? {} : { principalAttributes }),
...kit.timeouts,
onError: options.onError,
});
onClose((ctx) => host.close(ctx));

View file

@ -133,6 +133,11 @@ export interface Plugins {
rollout(steps: readonly RolloutStep[], built: Built, context: Context): Promise<RolloutResult>;
/** Installs plugin `name`'s dependencies (`installDependencies`): its install mark clears, and it is dirty. */
installDependencies(name: string, context: Context): Promise<void>;
/**
* 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<void> {
if (!this.#discovered) await this.#discover(context);
const plugin = this.#discovery.plugins.find((each) => each.name === name);

View file

@ -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<string>();
/** The watches of the directories holding those files, each of the directory alone. */
readonly #baseWatchers = new Map<string, FSWatcher>();
/** The trees it watches: the roots, the base code, and plugin directories linked from outside the roots. */
readonly #trees = new Set<string>();
/** 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;

View file

@ -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<string, ServedFile>;
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 };
}

View file

@ -30,6 +30,8 @@ export type TuiSurface<P extends Principal> = {
readonly facet: Facet<P>;
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<P extends Principal>(
},
});
});
return { facet, step: (name) => published.step(name), base: base.hash };
return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs };
}

View file

@ -48,6 +48,8 @@ export type WebSurface<P extends Principal> = {
/** 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<P extends Principal>(
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. */

View file

@ -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

View file

@ -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<HoldState, { readonly kind: "bound" }>;
/** 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;

View file

@ -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<OriginMessage, { readonly kind: "bound" | "state" }>): 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);

View file

@ -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<P extends Principal> {
readonly #host: Host<P>;
@ -170,6 +178,13 @@ class Updates<P extends Principal> {
readonly #scripts = new Map<string, RegisteredScript>();
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<void> = 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<P>, loaded: LoadedFacets, options: ClientOptions<P>) {
this.#host = host;
@ -184,22 +199,54 @@ class Updates<P extends Principal> {
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<void> {
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<void> {
/** Applies `set` after the updates before it, unless a newer set replaced it meanwhile. */
#queue(set: ScriptSet, context: Context): Promise<void> {
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<boolean> {
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<P extends Principal> {
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<P extends Principal> {
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;
}
}
}

View file

@ -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<void>();
const gate = Promise.withResolvers<void>();
// 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<void> => {
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();

View file

@ -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<ReturnType<typeof testApp>>;
/**
* 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/<dir>`) 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<Record<string, Readonly<Record<string, string>>>>,
more: { readonly entry?: string; readonly port?: number; readonly dataDir?: string; readonly root?: string } = {},
): Promise<ReloadApp> {
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<string> {
const at = dir ?? (await tempDir());
const text = `import { start } from ${JSON.stringify(MAIN)};\n(globalThis as Record<string, unknown>).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<Record<string, string>>,
): Promise<RolloutResult> {
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<Record<string, string>>): Promise<void> {
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<RolloutResult> {
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 `<step>/<host>: <plugin>`, 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 `<step>/<host>: <object> <kind>`. */
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<void>;
/** Opens latch `name`: the call waiting on it goes on. */
open(name: string): Promise<void>;
/** What the counter's natives did: opens by code, closes as `<code>:<reason>`. */
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<Latches> {
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<void>; readonly open: PromiseWithResolvers<void> };
/** 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<string, unknown>;
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<string, Latch>;
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<void> {
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<void> {
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<string | null> {
return this.browser.evaluate<string | null>(
`document.querySelector('[data-view="${view}"] ${selector}')?.textContent ?? null`,
);
}
/** The value of input `selector` within view `view`. */
value(view: string, selector: string): Promise<string> {
return this.browser.evaluate<string>(`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<void> {
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<void> {
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<string[]> {
return this.browser.evaluate<string[]>(`globalThis.reload.states[${JSON.stringify(name)}] ?? []`);
}
/** Every value its watched member had. */
frames(name: string): Promise<unknown[]> {
return this.browser.evaluate<unknown[]>(`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<void> {
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<void> {
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<number> {
const list = [JSON.stringify(name), JSON.stringify(method), ...args.map((arg) => JSON.stringify(arg))].join(", ");
return this.browser.evaluate<number>(`globalThis.reload.call(${list})`);
}
/** Call `index`'s answer now (pending or not). */
peek(index: number): Promise<CallResult> {
return this.browser.evaluate<CallResult>(`globalThis.reload.results[${index}]`);
}
/** Call `index`'s answer, once it has one. */
async result(index: number, timeoutMs = 30_000): Promise<CallResult> {
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<number> {
return this.browser.evaluate<number>("globalThis.page.loads");
}
}
/** The answer of a call that succeeded with `value`. */
export function ok(value: unknown): CallResult {
return { status: "ok", value };
}

View file

@ -0,0 +1,5 @@
// The reload suite's web entry: starts the suite's page (`main.ts`).
import { start } from "./main.ts";
start();

View file

@ -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<boolean>;
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<boolean>();
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)));
},
});
}

View file

@ -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<void>; readonly open: PromiseWithResolvers<void> };
const latches: Map<string, Latch> = ((globalThis as Record<string, unknown>).reloadLatches ??= new Map()) as Map<string, Latch>;
/** 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 \`<version>:<reason>\`. */
const natives = ((globalThis as Record<string, unknown>).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<string, string> {
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<Record<string, string>> = {
"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 (`<name>:<code>`): 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<string, string> {
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 `<code>(<counter's count>)`. 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<string, string> {
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 `<code>-finish(<outcome>)` 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<string, string> {
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<void>((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"),
};
}

View file

@ -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<View, readonly View[]>("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>): void }>;
};
readonly release: () => void;
} & Record<string, unknown>;
/** A state member as the probe watches it. */
type ProbedMember = {
watch(
context: typeof BACKGROUND_CONTEXT,
): Promise<{ start(listener: (call: WatchCall<unknown>) => Promise<void>): void }>;
};
/** A reference's state as one string: `live`, `loading`, `failed:<kind>:<reason or code>`, `ended:<code>`. */
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<string, ProbedRef> = {};
/** Every state each tracked reference went through, in order; `remount` where a new reference took the name. */
readonly states: Record<string, string[]> = {};
/** Every value (or `failed:<reason>`) of each tracked reference's watched member. */
readonly frames: Record<string, unknown[]> = {};
readonly results: CallResult[] = [];
/** The contracts web plugins registered, by name, for references the tests make themselves (`hold`). */
readonly contracts: Record<string, Contract> = {};
/** 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<unknown>;
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();

View file

@ -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"));

View file

@ -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<ReturnType<typeof startReloadApp>> | 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);
});

View file

@ -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,
);
});

View file

@ -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<void> {
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);
});

View file

@ -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,
);
});

View file

@ -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<Placement>(["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,
);
});

View file

@ -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<Placement>(["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<Placement>(["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,
);
});

View file

@ -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,
);
});

View file

@ -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<number>("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<string>('document.getElementById("base-draft").value')).toBe("base draft");
expect(await browser.evaluate<number>("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);
});

View file

@ -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<void>();
let self: Self<TestPrincipal> | undefined;
const server = createHost<TestPrincipal>({ 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<TestPrincipal>((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", {

View file

@ -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<TestPrincipal>({
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([]);