From edc9c698923151c093abbaabf399f3a2849c9e1c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 4 Nov 2026 16:47:07 +0000 Subject: [PATCH] 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. --- packages/chord/src/node/app/README.md | 10 +- packages/chord/src/node/app/app.ts | 46 ++- packages/chord/src/node/app/index.ts | 1 + packages/chord/src/node/app/worker.ts | 1 + packages/chord/src/node/plugins/plugins.ts | 9 + packages/chord/src/node/plugins/watcher.ts | 38 +- packages/chord/src/node/surfaces/bundle.ts | 14 +- packages/chord/src/node/surfaces/tui.ts | 4 +- packages/chord/src/node/surfaces/web.ts | 4 +- packages/chord/src/remote/README.md | 5 +- packages/chord/src/remote/hold.ts | 60 ++- packages/chord/src/remote/provider.ts | 20 +- packages/chord/src/surfaces/client.ts | 69 +++- packages/chord/test/node/app/app.test.ts | 76 +++- .../chord/test/node/fixtures/reload/app.ts | 306 +++++++++++++++ .../chord/test/node/fixtures/reload/entry.ts | 5 + .../chord/test/node/fixtures/reload/main.ts | 107 ++++++ .../test/node/fixtures/reload/plugins.ts | 349 ++++++++++++++++++ .../test/node/fixtures/reload/web-api.ts | 129 +++++++ .../chord/test/node/plugins/watcher.test.ts | 15 + packages/chord/test/node/reload/base.test.ts | 83 +++++ .../chord/test/node/reload/contracts.test.ts | 157 ++++++++ .../chord/test/node/reload/failed.test.ts | 170 +++++++++ packages/chord/test/node/reload/jobs.test.ts | 71 ++++ packages/chord/test/node/reload/pure.test.ts | 133 +++++++ .../chord/test/node/reload/restart.test.ts | 126 +++++++ .../chord/test/node/reload/rollout.test.ts | 185 ++++++++++ packages/chord/test/node/reload/web.test.ts | 131 +++++++ packages/chord/test/remote/hold.test.ts | 61 ++- packages/chord/test/surfaces/client.test.ts | 70 +++- 30 files changed, 2410 insertions(+), 45 deletions(-) create mode 100644 packages/chord/test/node/fixtures/reload/app.ts create mode 100644 packages/chord/test/node/fixtures/reload/entry.ts create mode 100644 packages/chord/test/node/fixtures/reload/main.ts create mode 100644 packages/chord/test/node/fixtures/reload/plugins.ts create mode 100644 packages/chord/test/node/fixtures/reload/web-api.ts create mode 100644 packages/chord/test/node/reload/base.test.ts create mode 100644 packages/chord/test/node/reload/contracts.test.ts create mode 100644 packages/chord/test/node/reload/failed.test.ts create mode 100644 packages/chord/test/node/reload/jobs.test.ts create mode 100644 packages/chord/test/node/reload/pure.test.ts create mode 100644 packages/chord/test/node/reload/restart.test.ts create mode 100644 packages/chord/test/node/reload/rollout.test.ts create mode 100644 packages/chord/test/node/reload/web.test.ts diff --git a/packages/chord/src/node/app/README.md b/packages/chord/src/node/app/README.md index cbead47df..e823c55c8 100644 --- a/packages/chord/src/node/app/README.md +++ b/packages/chord/src/node/app/README.md @@ -33,6 +33,8 @@ const app = await startServer

({ uploads: 64, importBytes: 4 * 2 ** 30, importEntries: 100_000 }, web: { entry: "./src/web/main.tsx", page: (request, assets) => shell(request.nonce, assets) }, // slice 11 tui: { entry: "./src/tui/main.ts" }, + base: ["./src/app/", "./start.ts"], // base code besides worker entries and the base bundles' inputs + timeouts: { closeTimeout: 30_000, readyTimeout: 60_000 }, // every host's (server and workers; the defaults) onError: (error) => log(error), // AppError: { kind: "host" | "http" | "workers" | "plugins", error } }, ctx); console.log(`Open ${app.ownerLink()}`); @@ -196,9 +198,11 @@ archive leaves nothing. The next hold starts the worker on the imported store an ## Plugins Roots: the app's `root` (required: a plugin of it failing stops `startServer`) and `/plugins/` (the user's: -installs go there). `app.plugins.state` is the plugins component's marks for the app's "apply changes" button; -`applyChanges` builds the dirty plugins (and installed ones) and rolls out to the server, then each worker kind; -nothing is applied by itself. +installs go there). `app.plugins.state` is the plugins component's marks for the app's "apply changes" button; its +`base` turns true when base code changes (the app restarts its hosts): `base`'s files and directories, each worker +kind's `entry`, and every file the web and terminal base bundles were built from (esbuild's metafile), never files under +`node_modules` (an npm upgrade is a restart anyway). `applyChanges` builds the dirty plugins (and installed ones) and +rolls out to the server, then each worker kind; nothing is applied by itself. ## Internals diff --git a/packages/chord/src/node/app/app.ts b/packages/chord/src/node/app/app.ts index 79852d5c2..039c910b2 100644 --- a/packages/chord/src/node/app/app.ts +++ b/packages/chord/src/node/app/app.ts @@ -4,6 +4,7 @@ // the server's plugins with the kit's own facets (the start-data object, sessions, the owner link, pairing, files), // and gives the app the controls it needs: the owner link, `applyChanges`, installs, export and import per address. +import { statSync } from "node:fs"; import { join, resolve } from "node:path"; import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import { type Context, withAbortSignal } from "../../context/index.ts"; @@ -134,6 +135,13 @@ export type AppOptions

= { * send (the principal of their link). Each worker's is its `startWorker`'s. */ readonly principalAttributes?: PrincipalAttributes

; + /** + * The app's base code beside what the kit knows (files or directories: its API modules, its start script): a change + * sets `plugins.state.base`. The kit adds each worker kind's `entry` and every file its base bundles were built from. + */ + readonly base?: readonly string[]; + /** The `closeTimeout` and `readyTimeout` of every host (the server's and each worker's): 30 s and 60 s by default. */ + readonly timeouts?: AppTimeouts; readonly onError: (error: AppError) => void; } & (OwnerPrincipal extends P ? unknown : { readonly "the app's principal type must have { kind: 'owner' }": never }); @@ -152,6 +160,9 @@ export type AppLimits = { readonly importEntries?: number; }; +/** The hosts' timeouts in milliseconds (`HostOptions.closeTimeout`, `HostOptions.readyTimeout`). */ +export type AppTimeouts = { readonly closeTimeout?: number; readonly readyTimeout?: number }; + /** The kit's web surface options (`webSurface`'s, its kind `web` and plugins the kit's). */ export type AppWeb = Pick; @@ -182,7 +193,10 @@ export interface App

{ ownerLink(): string; /** The owner token file's path (a device session of the owner, mode 0600). */ readonly ownerTokenFile: string; - /** Builds the dirty plugins and rolls them out: the server, every worker kind, then the clients; never by itself. */ + /** + * Builds the dirty plugins and rolls them out: the server, every worker kind, then the clients; never by itself. Calls + * run one after another (a later one builds after the earlier one rolled out). + */ applyChanges(context: Context): Promise; /** * Writes the data the worker at `address` created (its store and finished files) as an archive at `path`, stopping @@ -210,6 +224,8 @@ export type KitStart = { readonly telemetry: boolean; /** The cap of uploads under way per `Files` object (`limits.uploads`). */ readonly uploads?: number; + /** The app's host timeouts (`timeouts`), the worker's host takes them too. */ + readonly timeouts: AppTimeouts; }; /** @@ -230,6 +246,7 @@ export async function startServer

(options: AppOptions

, c store, telemetry, ...(principalAttributes === undefined ? {} : { principalAttributes }), + ...options.timeouts, onError: (error) => onError({ kind: "host", error }), }); const http = createHttp( @@ -242,6 +259,11 @@ export async function startServer

(options: AppOptions

, c }, context, ); + // Base code by kind: directories are watched as trees, files one by one (`watchBase`). + const base = [...(options.base ?? []), ...(options.workers ?? []).map(({ entry }) => entry)].map((path) => + resolve(path), + ); + const baseDirs = base.filter((path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true); const plugins = createPlugins( { roots: [{ path: resolve(options.root ?? "plugins"), required: true }, layout.plugins], @@ -253,6 +275,7 @@ export async function startServer

(options: AppOptions

, c ]), ), install: options.install, + base: baseDirs, publicPath: `${WEB_PREFIX}assets/`, onError: (error) => onError({ kind: "plugins", error }), }, @@ -300,6 +323,8 @@ export async function startServer

(options: AppOptions

, c surfaces.push(surface); core.push({ name: `chord.${surface.name}`, facet: surface.facet }); } + const files = base.filter((path) => !baseDirs.includes(path)); + plugins.watchBase([...files, ...surfaces.flatMap(({ inputs }) => inputs)]); await plugins.load(host, { kind: "server", core, modules: modulesOf(api, options.kinds.server) }, context); await ownerTokenFile(layout.ownerToken, host, context); app.started(link.renew(), await http.listening(context)); @@ -316,6 +341,8 @@ type Surface

= { readonly facet: Facet

; step(name: string): RolloutStep; readonly base: string; + /** The files its base bundle was built from. */ + readonly inputs: readonly string[]; }; /** The app's client surfaces, built (their base bundles) in order: the web's, the terminal's. */ @@ -411,6 +438,7 @@ function workersOf

( files: { ...options.files }, telemetry: options.telemetry !== undefined, ...(options.limits?.uploads === undefined ? {} : { uploads: options.limits.uploads }), + timeouts: { ...options.timeouts }, }; return { kit, app: await kind.start(address, ctx) }; }, @@ -453,6 +481,8 @@ class RunningApp

implements App

{ readonly #options: AppOptions

; #link = ""; #closing: Promise | undefined; + /** The latest `applyChanges`, settled to nothing: the next one starts after it. */ + #applying: Promise = Promise.resolve(); constructor( host: Host

, @@ -498,7 +528,19 @@ class RunningApp

implements App

{ return this.#surfaces.find(({ name }) => name === "tui"); } - async applyChanges(context: Context): Promise { + applyChanges(context: Context): Promise { + // One build and rollout at a time: a later build rolled out before an earlier one would leave the older code + // deployed (each host applies the last set it is handed). + const applied = this.#applying.then(() => this.#applyChanges(context)); + this.#applying = applied.then( + () => {}, + () => {}, + ); + return applied; + } + + /** One build of the dirty plugins and its rollout: the server, every worker kind, then the clients. */ + async #applyChanges(context: Context): Promise { const plugins = this.#component; const built = await plugins.build(plugins.state.value.dirty, context); const steps = [ diff --git a/packages/chord/src/node/app/index.ts b/packages/chord/src/node/app/index.ts index 548ae1dc1..68356dd69 100644 --- a/packages/chord/src/node/app/index.ts +++ b/packages/chord/src/node/app/index.ts @@ -9,6 +9,7 @@ export { type AppKind, type AppOptions, type AppPlugins, + type AppTimeouts, type AppWorkerKind, Server, startServer, diff --git a/packages/chord/src/node/app/worker.ts b/packages/chord/src/node/app/worker.ts index 94f5b3fe7..397d9b603 100644 --- a/packages/chord/src/node/app/worker.ts +++ b/packages/chord/src/node/app/worker.ts @@ -65,6 +65,7 @@ export function startWorker

(options: WorkerOptions

): voi store, telemetry: recorder, ...(principalAttributes === undefined ? {} : { principalAttributes }), + ...kit.timeouts, onError: options.onError, }); onClose((ctx) => host.close(ctx)); diff --git a/packages/chord/src/node/plugins/plugins.ts b/packages/chord/src/node/plugins/plugins.ts index 2cbe8cc01..373eab134 100644 --- a/packages/chord/src/node/plugins/plugins.ts +++ b/packages/chord/src/node/plugins/plugins.ts @@ -133,6 +133,11 @@ export interface Plugins { rollout(steps: readonly RolloutStep[], built: Built, context: Context): Promise; /** Installs plugin `name`'s dependencies (`installDependencies`): its install mark clears, and it is dirty. */ installDependencies(name: string, context: Context): Promise; + /** + * Adds `files` to the app's base code (beside `base`): a change of one sets `base`. For files the app learns after + * creating the component, a base bundle's inputs; files under `node_modules` are never watched. + */ + watchBase(files: readonly string[]): void; /** * Installs new plugins from git or npm into `root`, one of the roots (`installPlugin`), and marks `set`: the next * build discovers and builds them. Returns their names. @@ -288,6 +293,10 @@ class PluginsComponent implements Plugins { return { steps: done, stopped: undefined }; } + watchBase(files: readonly string[]): void { + this.#watcher.baseFiles(files); + } + async installDependencies(name: string, context: Context): Promise { if (!this.#discovered) await this.#discover(context); const plugin = this.#discovery.plugins.find((each) => each.name === name); diff --git a/packages/chord/src/node/plugins/watcher.ts b/packages/chord/src/node/plugins/watcher.ts index 5f597d10e..8632b5fa7 100644 --- a/packages/chord/src/node/plugins/watcher.ts +++ b/packages/chord/src/node/plugins/watcher.ts @@ -49,6 +49,10 @@ export class PluginWatcher extends Source { readonly #options: WatcherOptions; readonly #roots: readonly string[]; readonly #baseDirs: readonly string[]; + /** Base code given file by file (`baseFiles`), by real path. */ + readonly #baseFiles = new Set(); + /** The watches of the directories holding those files, each of the directory alone. */ + readonly #baseWatchers = new Map(); /** The trees it watches: the roots, the base code, and plugin directories linked from outside the roots. */ readonly #trees = new Set(); /** Its watches, one per directory of its trees. */ @@ -174,10 +178,23 @@ export class PluginWatcher extends Source { this.#changed(); } + /** + * Adds `files` to the base code: a change of one sets `base`. Each one's directory is watched alone (not its tree); + * files under ignored directories are skipped. + */ + baseFiles(files: readonly string[]): void { + for (const file of files) { + const path = real(file); + if (path.split(sep).some((part) => IGNORED.has(part))) continue; + this.#baseFiles.add(path); + this.#watchBaseDir(dirname(path)); + } + } + /** Marks what a change of the file or directory at `path` (absolute) asks for. */ mark(path: string): void { if (path.split(sep).some((part) => IGNORED.has(part))) return; - if (this.#baseDirs.some((dir) => inside(dir, path))) { + if (this.#baseFiles.has(path) || this.#baseDirs.some((dir) => inside(dir, path))) { this.#baseChanged = true; } else { this.#markPlugin(path, this.#generation++); @@ -187,8 +204,9 @@ export class PluginWatcher extends Source { /** Stops watching. */ close(): void { - for (const watcher of this.#watchers.values()) watcher.close(); + for (const watcher of [...this.#watchers.values(), ...this.#baseWatchers.values()]) watcher.close(); this.#watchers.clear(); + this.#baseWatchers.clear(); this.#trees.clear(); } @@ -223,6 +241,22 @@ export class PluginWatcher extends Source { return this.#options.kinds.some((kind) => FACET_EXTENSIONS.some((extension) => file === `${kind}${extension}`)); } + /** Watches directory `dir` alone (once) for base files: a change of one of them marks `base`. */ + #watchBaseDir(dir: string): void { + if (this.#baseWatchers.has(dir) || this.#reported) return; + let watcher: FSWatcher; + try { + watcher = watch(dir, (_event, file) => { + if (file !== null && this.#baseFiles.has(join(dir, file))) this.mark(join(dir, file)); + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.#failed(error); + return; + } + watcher.on("error", (error) => this.#failed(error)); + this.#baseWatchers.set(dir, watcher); + } + /** Watches the tree at `dir` (once): it and every directory under it but ignored ones; a missing one is skipped. */ #watchTree(dir: string): void { if (this.#trees.has(dir)) return; diff --git a/packages/chord/src/node/surfaces/bundle.ts b/packages/chord/src/node/surfaces/bundle.ts index c10a56387..0e0b6e7c3 100644 --- a/packages/chord/src/node/surfaces/bundle.ts +++ b/packages/chord/src/node/surfaces/bundle.ts @@ -4,19 +4,20 @@ // a terminal's one CommonJS script the sandbox evaluates with its own `require` (its allowed builtins only). import { createHash } from "node:crypto"; -import { basename, extname } from "node:path"; +import { basename, extname, resolve } from "node:path"; import { build } from "esbuild"; import { CSS, JAVASCRIPT, mimeOf, type ServedFile } from "./served.ts"; /** - * A built base bundle: its hash, its script's and stylesheet's paths (under the surface's prefix), and every file by - * path. + * A built base bundle: its hash, its script's and stylesheet's paths (under the surface's prefix), every file by + * path, and the files esbuild read for it (absolute: the app's base code a change of which outdates the bundle). */ export type BaseBundle = { readonly hash: string; readonly script: string; readonly style: string | undefined; readonly files: ReadonlyMap; + readonly inputs: readonly string[]; }; /** Where a base bundle runs: a page (a script, its CSS and assets), or a terminal's sandbox (one CommonJS script). */ @@ -47,6 +48,7 @@ export async function buildBase(entry: string, platform: BasePlatform, prefix: s jsx: "automatic", define: { "process.env.NODE_ENV": '"production"' }, logLevel: "silent", + metafile: true, }); const hash = createHash("sha256"); for (const output of [...result.outputFiles].sort((a, b) => (a.path < b.path ? -1 : 1))) { @@ -67,5 +69,9 @@ export async function buildBase(entry: string, platform: BasePlatform, prefix: s files.set(`${prefix}assets/${basename(output.path)}`, { mime: mimeOf(output.path), bytes: output.contents }); } } - return { hash: digest, script, style, files }; + // Metafile inputs are relative to the working directory; a namespaced one (`ns:path`) is no file. + const inputs = Object.keys(result.metafile.inputs) + .filter((input) => !/^[a-z-]+:/.test(input)) + .map((input) => resolve(input)); + return { hash: digest, script, style, files, inputs }; } diff --git a/packages/chord/src/node/surfaces/tui.ts b/packages/chord/src/node/surfaces/tui.ts index ebc23d3f9..613a15efa 100644 --- a/packages/chord/src/node/surfaces/tui.ts +++ b/packages/chord/src/node/surfaces/tui.ts @@ -30,6 +30,8 @@ export type TuiSurface

= { readonly facet: Facet

; step(name: string): RolloutStep; readonly base: string; + /** The files its base bundle was built from: the app's base code for this surface. */ + readonly inputs: readonly string[]; }; /** Where the terminal surface's files live. */ @@ -63,5 +65,5 @@ export async function tuiSurface

( }, }); }); - return { facet, step: (name) => published.step(name), base: base.hash }; + return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs }; } diff --git a/packages/chord/src/node/surfaces/web.ts b/packages/chord/src/node/surfaces/web.ts index 990aff66d..941b42feb 100644 --- a/packages/chord/src/node/surfaces/web.ts +++ b/packages/chord/src/node/surfaces/web.ts @@ -48,6 +48,8 @@ export type WebSurface

= { /** The rollout step publishing the web kind's current set (after the server's step). */ step(name: string): RolloutStep; readonly base: string; + /** The files its base bundle was built from: the app's base code for this surface. */ + readonly inputs: readonly string[]; }; /** Where the web surface's files live. */ @@ -75,7 +77,7 @@ export async function webSurface

( scripts: [WEB_PREFIX], }); }); - return { facet, step: (name) => published.step(name), base: base.hash }; + return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs }; } /** The surface's routes: its files (served forever), its current base hash (public), and the page. */ diff --git a/packages/chord/src/remote/README.md b/packages/chord/src/remote/README.md index b0e2755bf..9d41b501d 100644 --- a/packages/chord/src/remote/README.md +++ b/packages/chord/src/remote/README.md @@ -51,7 +51,10 @@ within each link's limits, compression at the origin, activation chains and revo it does not know is ignored (Q1), one reusing a live id is a protocol error, and so are more running calls than this side announced in `hello` (N = 256 per origin; a call on an unknown hold is ignored before it is counted). Its table has two kinds of entries: `Hold` (served here) and `RelayedHold` (relayed, never hydrated). A call on a pending hold - is answered `not_ready`; an aborted, released or gone call gets no reply. While the host closes, its holds are only + is answered `not_ready`, one crossing a `failed` or a removal with the cause its consumer was told; a call sent with + the indexes of a binding its consumer has not acknowledged a newer `bound` of yet is decoded by them (another + variant's indexes name other methods; one the new variant lacks fails `invalid/member`); an aborted, released or gone + call gets no reply. While the host closes, its holds are only dropped (the host ends the objects itself). It sends with its origin. A hold's `chain` is kept only from a trusted link and its stamped `limit` only from a parent link; its decoded limit is the smaller of the link's and the stamped one, which a relayed hold carries upstream as `limit`. A call on a relayed hold whose arguments are past the link's diff --git a/packages/chord/src/remote/hold.ts b/packages/chord/src/remote/hold.ts index b96b94b5c..e83692b8f 100644 --- a/packages/chord/src/remote/hold.ts +++ b/packages/chord/src/remote/hold.ts @@ -11,7 +11,7 @@ import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import type { Context } from "../context/index.ts"; import { type VariantData, type VariantNames, variantNames, variantOrder, variantsAt } from "../contracts/internal.ts"; -import { type ChordError, type FailureCause, isChordError, type Principal } from "../data/index.ts"; +import { ChordError, type FailureCause, isChordError, type Principal } from "../data/index.ts"; import { asChordError } from "../data/internal.ts"; import { type Encoder, encoder, type Op, PendingOps } from "../delta/index.ts"; import { @@ -67,8 +67,8 @@ type Followed = { /** * Where a hold stands: `unplaced` in the unbound index until the host's publication places it; `pending` until its - * object is live (the activation, parking and retries; `toldFailed` once the consumer was told of a failure, so the - * next attempt is announced with `loading`); `binding` while its object is live and its `bound` waits for a slot; + * object is live (the activation, parking and retries; `told`: the failure its consumer was told, so the next attempt + * is announced with `loading` and a call that crossed the failure on the wire gets it); `binding` while its object is live and its `bound` waits for a slot; * `bound` with the object's variant and the members it follows. A `pending` hold unbound with `loading` and the * `binding` after it, or a `binding` after a swap in place, keep the names it was bound with, `previous`: its consumer's * calls sent by those names (before it learnt of the `loading`, or still bound) wait at its object's gate and are @@ -76,7 +76,7 @@ type Followed = { */ type HoldState = | { readonly kind: "unplaced"; readonly request: HoldRequest } - | { readonly kind: "pending"; readonly toldFailed: boolean; readonly previous?: BoundNames } + | { readonly kind: "pending"; readonly told?: ChordError; readonly previous?: BoundNames } | { readonly kind: "binding"; readonly previous?: BoundNames } | { readonly kind: "bound"; @@ -88,6 +88,11 @@ type HoldState = readonly followed: Followed[]; /** The instance it was bound to: a swap brings another, which it binds to afresh. */ readonly instance: unknown; + /** + * A rebinding's names before it (`until`: its `bound`, this host's own message number): calls its consumer sent + * before it applied that `bound` carry their indexes, so they are decoded by them until its `ack`. + */ + readonly previous?: BoundNames & { readonly until: number }; } /** * Refused where it arrived, with `cause`: `waiting` (not served here, another version, placed elsewhere by the @@ -103,7 +108,7 @@ type Bound = Extract; /** What calls need of a binding: its variant's data at the hold's version and the names the wire's indexes point into. */ type BoundNames = { readonly variant: VariantData; readonly names: VariantNames }; -const PENDING: HoldState = { kind: "pending", toldFailed: false }; +const PENDING: HoldState = { kind: "pending" }; const BINDING: HoldState = { kind: "binding" }; @@ -163,10 +168,27 @@ export class Hold implements Holder, UnboundRequest { return this.#names() !== undefined; } - /** The names its calls are decoded by: its binding's, or the ones it was bound with while it rebinds. */ + /** + * What a call it does not take is refused with: the failure its consumer was told (refused, or failed while pending: + * the call crossed that `failed` on the wire, and its consumer shows that cause), else `not_ready` (not bound yet). + */ + get refusal(): ChordError { + const state = this.#state; + if (state.kind === "refused") return state.cause.error; + if (state.kind === "pending" && state.told !== undefined) return state.told; + return new ChordError({ code: "not_ready", message: `hold ${this.id}: not bound yet` }); + } + + /** + * The names its calls are decoded by: its binding's (its previous ones until its consumer acknowledged the `bound` + * that brought them: another variant's indexes name other methods), or the ones it was bound with while it rebinds. + */ #names(): BoundNames | undefined { const state = this.#state; - if (state.kind === "bound") return state; + if (state.kind === "bound") { + const { previous } = state; + return previous !== undefined && !this.#provider.acknowledged(previous.until) ? previous : state; + } return state.kind === "pending" || state.kind === "binding" ? state.previous : undefined; } @@ -311,20 +333,25 @@ export class Hold implements Holder, UnboundRequest { return; } this.#provider.unbind(this); - this.#state = previous === undefined ? PENDING : { kind: "pending", toldFailed: false, previous }; + this.#state = + previous === undefined + ? PENDING + : { kind: "pending", previous: { variant: previous.variant, names: previous.names } }; this.#provider.send({ kind: "loading", hold: this.id }); } /** * A swap of its object went live in place (slice 7b §5, audit A9): no longer subscribed to the old instance, it gets * a fresh `bound` of the new one's members (as a restart, a variant or a version change does), answering calls by - * its old names until that is sent. + * the names its consumer has until it applied that. */ #rebind(state: Bound): void { + // The names its consumer has: the old binding's, or older ones while it has not applied that `bound` yet. + const { variant, names } = this.#names()!; unfollow(state); // Its ops still waiting for a slot are the old instance's: the `bound` replaces them. this.#provider.unbind(this); - this.#state = { kind: "binding", previous: { variant: state.variant, names: state.names } }; + this.#state = { kind: "binding", previous: { variant, names } }; this.#provider.bind(this, this.#provider.link.context); } @@ -359,8 +386,8 @@ export class Hold implements Holder, UnboundRequest { const failed = { kind: cause.kind, error: wireError(cause.error) }; this.#provider.send({ kind: "failed", hold: this.id, cause: failed, ...retryIn }); // Told a failure, its consumer rejects what it still waits on: calls by the old names are not taken any more. - this.#state = { kind: "pending", toldFailed: true }; - } else if (state.kind === "loading" && current.toldFailed) { + this.#state = { kind: "pending", told: cause.error }; + } else if (state.kind === "loading" && current.told !== undefined) { this.#provider.send({ kind: "loading", hold: this.id }); this.#state = PENDING; } @@ -505,15 +532,22 @@ export class Hold implements Holder, UnboundRequest { this.#provider.send({ kind: "gone", hold: this.id, error: wireError(error) }); } - /** The binding: its variant and names at the hold's version, with no member followed yet. */ + /** + * The binding: its variant and names at the hold's version, with no member followed yet, and the names its consumer + * still has (a rebinding's) until it acknowledged this `bound`, the next of this host's own messages. + */ #bindTo(object: ChordObject): Bound { const variant = variantsAt(object.served.contract, this.contract.version).get(object.variant!)!; + const previous = this.#names(); const state: Bound = { kind: "bound", variant, names: variantNames(variant), followed: [], instance: object.instance, + ...(previous === undefined + ? {} + : { previous: { variant: previous.variant, names: previous.names, until: this.#provider.nextOwn } }), }; this.#state = state; return state; diff --git a/packages/chord/src/remote/provider.ts b/packages/chord/src/remote/provider.ts index a867d47e2..afce4c7d8 100644 --- a/packages/chord/src/remote/provider.ts +++ b/packages/chord/src/remote/provider.ts @@ -62,6 +62,9 @@ export class ProviderSide { readonly #sent: Sender[] = []; /** This host's own messages in flight. */ #inFlight = 0; + /** How many of this host's own `bound` and `state` messages it sent, and how many its consumer acknowledged. */ + #ownSent = 0; + #ownAcked = 0; /** The holds the current batch touched, in order; its flush sends or queues them. */ #touched: Hold[] = []; /** Holds whose ops wait in their pending queues for a slot. */ @@ -406,7 +409,7 @@ export class ProviderSide { return; } if (!hold.callable) { - this.#refuse(message.call, new ChordError({ code: "not_ready", message: `hold ${hold.id}: not bound yet` })); + this.#refuse(message.call, hold.refusal); return; } const method = hold.method(message.method); @@ -494,9 +497,23 @@ export class ProviderSide { return; } this.#inFlight--; + this.#ownAcked++; this.#slotFreed(); } + /** The number the next of this host's own `bound` and `state` messages gets (counted from 1). */ + get nextOwn(): number { + return this.#ownSent + 1; + } + + /** + * Whether its consumer acknowledged this host's own message number `own`, so applied it: a call it sent after that + * arrives after the `ack` (one direction keeps its order). + */ + acknowledged(own: number): boolean { + return this.#ownAcked >= own; + } + /** * The end of a batch: while a slot is free and nothing waits, one `state` message with an entry per touched hold; * otherwise each touched hold's ops join its pending queues until a slot frees. @@ -578,6 +595,7 @@ export class ProviderSide { /** Sends one of this host's own `bound` or `state` messages, taking a slot. */ #sendOwn(message: Extract): void { this.#inFlight++; + this.#ownSent++; this.#sent.push("local"); if (message.kind === "state") this.link.host.metrics.counter("chord.link.state_messages").add(1); this.send(message); diff --git a/packages/chord/src/surfaces/client.ts b/packages/chord/src/surfaces/client.ts index 39c089b82..eaa1a710b 100644 --- a/packages/chord/src/surfaces/client.ts +++ b/packages/chord/src/surfaces/client.ts @@ -1,9 +1,9 @@ // A client host with its plugins (slice 11 §2, §4, decisions "Slice 11, P4" to "P6"): what a page and a terminal's // sandbox both run. A client host (no address) loads the plugin scripts it started with beside the app's core facets, // keeps its link to the server with the connection loop, and holds the kind's scripts object: a new set fetches the -// changed scripts and reloads the host in place (a failed plugin keeps its old facet); a new base bundle means this -// client's code is outdated, which the app answers by starting over (a page reloads). The surface specifics (how a -// socket opens, how a script is fetched, what is outdated) are given. +// changed scripts and reloads the host in place (a failed plugin keeps its old facet; a failed fetch is tried again); +// a new base bundle means this client's code is outdated, which the app answers by starting over (a page reloads). The +// surface specifics (how a socket opens, how a script is fetched, what is outdated) are given. import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../context/index.ts"; @@ -158,9 +158,17 @@ function entryOf(script: RegisteredScript, modules: ClientModules): SetEntry { }; } +/** The first pause before a failed update is tried again: 1 s, doubling from there. */ +const UPDATE_RETRY_INITIAL_MS = 1000; + +/** The longest pause between tries of a failed update: 30 s. */ +const UPDATE_RETRY_MAX_MS = 30_000; + /** * The client's plugin updates: it holds the scripts object and applies each new set in order (fetching the changed - * scripts, reloading the host with the complete set); a new base calls `outdated` instead. + * scripts, reloading the host with the complete set); a new base calls `outdated` instead. An update that failed (a + * script that did not load) is tried again after a growing pause (1 s doubling to 30 s) until it applies or a newer set + * replaces it: the scripts object shows nothing new until the next rollout or reconnect. */ class Updates

{ readonly #host: Host

; @@ -170,6 +178,13 @@ class Updates

{ readonly #scripts = new Map(); readonly #abort = new AbortController(); #release: (() => void) | undefined; + /** The newest set shown: a retry applies only it. */ + #latest: ScriptSet | undefined; + /** The updates in order: one at a time, a retry's among them. */ + #applying: Promise = Promise.resolve(); + /** The pending retry's cancel and the pause it waited (0: none waited yet). */ + #retry: { cancel: () => void } | undefined; + #pause = 0; constructor(host: Host

, loaded: LoadedFacets, options: ClientOptions

) { this.#host = host; @@ -184,22 +199,54 @@ class Updates

{ this.#release = () => held.release(); const watch = await held.set.watch(withAbortSignal(this.#abort.signal, context)); watch.start(async (call, ctx) => { - if (call.kind === "value") await this.#apply(call.value as ScriptSet, ctx); + if (call.kind !== "value") return; + const set = call.value as ScriptSet; + this.#latest = set; + this.#retry?.cancel(); + this.#retry = undefined; + this.#pause = 0; + await this.#queue(set, ctx); }); } - /** Stops following: the watch ends, the hold is released. */ + /** Stops following: the watch ends, the hold is released, a pending retry is dropped. */ async stop(): Promise { this.#abort.abort(); + this.#retry?.cancel(); + this.#retry = undefined; this.#release?.(); } - /** Applies `set`: a new base calls `outdated`; else the changed scripts fetched and the host reloaded with all. */ - async #apply(set: ScriptSet, context: Context): Promise { + /** Applies `set` after the updates before it, unless a newer set replaced it meanwhile. */ + #queue(set: ScriptSet, context: Context): Promise { + const applied = this.#applying.then(async () => { + if (this.#latest !== set || this.#abort.signal.aborted) return; + if (!(await this.#apply(set, context))) this.#retryLater(set, context); + }); + this.#applying = applied; + return applied; + } + + /** Tries `set` again after the next pause (1 s doubling to 30 s). */ + #retryLater(set: ScriptSet, context: Context): void { + this.#pause = this.#pause === 0 ? UPDATE_RETRY_INITIAL_MS : Math.min(this.#pause * 2, UPDATE_RETRY_MAX_MS); + this.#retry = { + cancel: this.#options.environment.after(this.#pause, () => { + this.#retry = undefined; + void this.#queue(set, context); + }), + }; + } + + /** + * Applies `set`: a new base calls `outdated`; else the changed scripts fetched and the host reloaded with all. + * `false` when it failed (reported `update_failed`): it is tried again. + */ + async #apply(set: ScriptSet, context: Context): Promise { const options = this.#options; if (set.base !== options.base) { options.outdated(); - return; + return true; } // The first frame (and a reconnect's snapshot) usually shows what this client runs already: nothing to reload. const known = this.#scripts; @@ -208,7 +255,7 @@ class Updates

{ set.plugins.every((entry) => known.get(entry.name)?.digest === entry.digest) ) { options.applied?.(set); - return; + return true; } try { const scripts: RegisteredScript[] = []; @@ -222,8 +269,10 @@ class Updates

{ for (const script of scripts) this.#scripts.set(script.plugin, script); if (report.failed.length > 0) options.onError({ kind: "plugins", failed: report.failed }); options.applied?.(set); + return true; } catch (error) { options.onError({ kind: "update_failed", error }); + return false; } } } diff --git a/packages/chord/test/node/app/app.test.ts b/packages/chord/test/node/app/app.test.ts index d32646877..d97d84c28 100644 --- a/packages/chord/test/node/app/app.test.ts +++ b/packages/chord/test/node/app/app.test.ts @@ -5,12 +5,12 @@ import { chmod, mkdir, readdir, stat, symlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { hostKind, local, remote, str, unknown } from "../../../src/contracts/index.ts"; import { addressParam, HostData } from "../../../src/node/app/data.ts"; import { AppInfo } from "../../../src/node/app/index.ts"; -import { rejection } from "../../support.ts"; +import { changesOf, rejection } from "../../support.ts"; import { send } from "../http/support.ts"; import { gitRepository, writePlugin } from "../plugins/support.ts"; import { ECHO_SERVER, NOTES_PLUGIN, Notes, Project, tempDir, testApp, WORKER_ENTRY } from "./support.ts"; @@ -180,6 +180,78 @@ describe("workers", () => { }); }); +describe("applying changes one after another", () => { + it("starts a later applyChanges after the earlier one rolled out, so the newest build stays deployed", async () => { + const { app, root } = await testApp({ plugins: { echo: { "server.ts": ECHO_SERVER } } }); + const echo = await app.host.get(Echo, {}, context); + const component = app.plugins.component; + const build = component.build.bind(component); + const rollout = component.rollout.bind(component); + const events: string[] = []; + const built = Promise.withResolvers(); + const gate = Promise.withResolvers(); + // The first build read its files, then waits: unserialized, the second would build and roll out meanwhile. + component.build = async (names, ctx) => { + const n = events.filter((event) => event.startsWith("build")).length + 1; + events.push(`build ${n}`); + const result = await build(names, ctx); + if (n === 1) { + built.resolve(); + await gate.promise; + } + return result; + }; + component.rollout = async (steps, result, ctx) => { + events.push("rollout"); + return rollout(steps, result, ctx); + }; + const marks = changesOf(await app.plugins.state.watch(context)); + const edit = async (text: string): Promise => { + const seen = marks.length; + await writeFile(join(root, "echo", "server.ts"), ECHO_SERVER.replace('"echo"', JSON.stringify(text))); + await vi.waitFor(() => expect(marks.length).toBeGreaterThan(seen), { timeout: 5_000 }); + }; + try { + await edit("v2"); + const first = app.applyChanges(context); + await built.promise; + await edit("v3"); + const second = app.applyChanges(context); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events).toEqual(["build 1"]); + gate.resolve(); + await first; + await second; + expect(events).toEqual(["build 1", "rollout", "build 2", "rollout"]); + expect(echo.text.value).toBe("v3"); + } finally { + echo.release(); + await app.close(context); + } + }); +}); + +describe("base code", () => { + it("marks base for a change of `base`'s files and directories and of a worker kind's entry", async () => { + const dir = await tempDir(); + await mkdir(join(dir, "api")); + await writeFile(join(dir, "api", "v1.ts"), "export {};"); + await writeFile(join(dir, "start.ts"), "export {};"); + await writeFile(join(dir, "worker.ts"), "export {};"); + const worker = { kind: Project, entry: join(dir, "worker.ts"), start: () => null, authorize: () => true }; + for (const changed of [join(dir, "api", "v1.ts"), join(dir, "start.ts"), join(dir, "worker.ts")]) { + const { app } = await testApp({ base: [join(dir, "api"), join(dir, "start.ts")], workers: [worker] }); + try { + expect(app.plugins.state.value.base).toBe(false); + await writeFile(changed, "export const changed = 1;"); + await vi.waitFor(() => expect(app.plugins.state.value.base).toBe(true), { timeout: 5_000 }); + } finally { + await app.close(context); + } + } + }); +}); + describe("installs", () => { it("installs a plugin from git into the user's root; applying changes loads it", async () => { const tools = await tempDir(); diff --git a/packages/chord/test/node/fixtures/reload/app.ts b/packages/chord/test/node/fixtures/reload/app.ts new file mode 100644 index 000000000..3a14331e3 --- /dev/null +++ b/packages/chord/test/node/fixtures/reload/app.ts @@ -0,0 +1,306 @@ +// The reload suite's app (design §21 "What a user sees during a reload"): a kit app with the web surface (the suite's +// base bundle, `entry.ts` starting `main.ts`) and the tests' project workers, short host timeouts, its root holding the +// suite's plugins; a real page signed in as the owner; and the test's hands: editing a plugin on disk and applying the changes, the latches of calls in +// flight, and what the page shows and answers (`ReloadPage`). + +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { arr, bool, hostKind, method, obj, remote, str } from "../../../../src/contracts/index.ts"; +import { Sessions } from "../../../../src/node/app/index.ts"; +import type { RolloutResult } from "../../../../src/node/plugins/index.ts"; +import { tempDir, testApp } from "../../app/support.ts"; +import { writePlugin } from "../../plugins/support.ts"; +import { type Browser, poll } from "../../web/browser.ts"; +import { freePort, page } from "../../web/support.ts"; +import type { CallResult } from "./web-api.ts"; + +/** The suite's base bundle entry, and its page (what another entry starts). */ +export const ENTRY = fileURLToPath(new URL("./entry.ts", import.meta.url)); + +const MAIN = fileURLToPath(new URL("./main.ts", import.meta.url)); + +/** A started reload app: the kit's app and what it reported, its root, port and data directory. */ +export type ReloadApp = Awaited>; + +/** + * The hosts' timeouts of the suite's apps (the server's and the workers'): a call outliving a restart's drain is cut + * off after 2 s, a call waits at most 5 s for its object to go live. + */ +export const TIMEOUTS = { closeTimeout: 2000, readyTimeout: 5000 } as const; + +/** + * Starts the suite's app with `plugins` (directory name to files; each plugin is `@test/

`) and project workers; + * `entry` is the base bundle's entry (the suite's by default), `port` and `dataDir` fixed for a restart. + */ +export async function startReloadApp( + plugins: Readonly>>>, + more: { readonly entry?: string; readonly port?: number; readonly dataDir?: string; readonly root?: string } = {}, +): Promise { + return testApp({ + kinds: { + server: { platform: "node" }, + worker: { platform: "node" }, + web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } }, + }, + listen: { host: "127.0.0.1", port: more.port ?? (await freePort()) }, + web: { entry: more.entry ?? ENTRY, page }, + timeouts: TIMEOUTS, + plugins, + withWorkers: true, + ...(more.dataDir === undefined ? {} : { dataDir: more.dataDir }), + ...(more.root === undefined ? {} : { root: more.root }), + }); +} + +/** A base bundle entry in a new directory: the suite's, with `version` on `globalThis` (another base bundle). */ +export async function versionedMain(version: string, dir?: string): Promise { + const at = dir ?? (await tempDir()); + const text = `import { start } from ${JSON.stringify(MAIN)};\n(globalThis as Record).version = ${JSON.stringify(version)};\nstart();\n`; + await writeFile(join(at, "main.ts"), text); + return join(at, "main.ts"); +} + +/** + * Writes `files` into plugin `dir` of the app's root (an edit on disk), waits until the watcher marked it dirty, and + * applies the changes (the rollout: server, workers, clients). + */ +export async function editAndApply( + app: ReloadApp, + dir: string, + files: Readonly>, +): Promise { + await edit(app, dir, files); + return app.app.applyChanges(context); +} + +/** + * Writes `files` into plugin `dir` of the app's root and waits until the watcher marked it dirty (or, for a plugin not + * discovered yet, the plugin set changed). + */ +export async function edit(app: ReloadApp, dir: string, files: Readonly>): Promise { + await writePlugin(app.root, dir, `@test/${dir}`, files); + const name = `@test/${dir}`; + const marked = (): boolean => app.app.plugins.state.value.dirty.includes(name) || app.app.plugins.state.value.set; + await poll(marked, 30_000, `${name} marked`); +} + +/** Removes plugin `dir` from the app's root, waits until the watcher saw the plugin set change, and applies it. */ +export async function removeAndApply(app: ReloadApp, dir: string): Promise { + await rm(join(app.root, dir), { recursive: true, force: true }); + await poll(() => app.app.plugins.state.value.set, 30_000, "the plugin set marked changed"); + return app.app.applyChanges(context); +} + +/** The plugins a rollout's hosts kept on their old code, as `/: `, and hosts that failed. */ +export function rolloutFailures(result: RolloutResult): string[] { + const failures: string[] = []; + for (const step of result.steps) { + for (const entry of step.hosts) { + if ("report" in entry) + for (const { plugin } of entry.report.failed) failures.push(`${step.name}/${entry.host}: ${plugin}`); + else if ("error" in entry) failures.push(`${step.name}/${entry.host}: error ${String(entry.error)}`); + } + } + return failures; +} + +/** The fates a rollout's hosts reported, as `/: `. */ +export function rolloutFates(result: RolloutResult): string[] { + const fates: string[] = []; + for (const step of result.steps) { + for (const entry of step.hosts) { + if (!("report" in entry)) continue; + for (const each of entry.report.objects) fates.push(`${step.name}/${entry.host}: ${each.object} ${each.kind}`); + } + } + return fates; +} + +/** The latch plugin's contract, declared again for the test (contracts are data). */ +const Control = remote("reload.control", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => hostKind("project", { id: str() })({ id }), + variants: { + main: { + state: {}, + methods: { + entered: method(str(), bool()), + open: method(str()), + natives: method(undefined, obj({ opens: arr(str()), closes: arr(str()) })), + }, + }, + }, +}); + +/** The test's hand on the latches and the natives' record where the counter runs (a worker's process or this one). */ +export type Latches = { + /** Resolves once a call waiting on latch `name` entered it. */ + entered(name: string): Promise; + /** Opens latch `name`: the call waiting on it goes on. */ + open(name: string): Promise; + /** What the counter's natives did: opens by code, closes as `:`. */ + natives(): Promise<{ readonly opens: readonly string[]; readonly closes: readonly string[] }>; +}; + +/** The latches of project `p1`'s worker (through the latch plugin), or of the server (this process's). */ +export async function latches(app: ReloadApp, placement: "server" | "worker"): Promise { + if (placement === "server") return serverLatches(); + const control = await app.app.host.get(Control, { id: "p1" }, context); + return { + entered: async (name) => { + await control.entered(name, context); + }, + open: async (name) => { + await control.open(name, context); + }, + natives: () => control.natives(context), + }; +} + +type Latch = { readonly entered: PromiseWithResolvers; readonly open: PromiseWithResolvers }; + +/** This process's latches: the server's plugins run here and keep them on `globalThis`, as the plugins write them. */ +function serverLatches(): Latches { + const global = globalThis as Record; + const latch = (name: string): Latch => { + // The plugins' latch map, made by whichever side comes first. + global.reloadLatches ??= new Map(); + const map = global.reloadLatches as Map; + let found = map.get(name); + if (found === undefined) { + found = { entered: Promise.withResolvers(), open: Promise.withResolvers() }; + map.set(name, found); + } + return found; + }; + return { + entered: (name) => latch(name).entered.promise, + open: async (name) => latch(name).open.resolve(), + natives: async () => { + const natives = (global.reloadNatives ?? { opens: [], closes: [] }) as { opens: string[]; closes: string[] }; + return { opens: [...natives.opens], closes: [...natives.closes] }; + }, + }; +} + +/** The page as the tests see and drive it: its DOM, the probe's records, calls from its references. */ +export class ReloadPage { + readonly browser: Browser; + + constructor(browser: Browser) { + this.browser = browser; + } + + /** Opens the owner link (signed in) and waits until the page shows its views and is connected. */ + async signIn(app: ReloadApp, views: readonly string[]): Promise { + await this.browser.open(app.app.ownerLink()); + await this.browser.wait('globalThis.page?.connection === "connected"'); + for (const view of views) await this.browser.wait(`document.querySelector('[data-view="${view}"]')`); + } + + /** + * Signs in with a new owner browser session (the owner link is single use: a second page needs its own session) + * and waits as `signIn` does. + */ + async signInAgain(app: ReloadApp, views: readonly string[]): Promise { + const sessions = await app.app.host.get(Sessions, {}, context); + const { cookie } = await sessions.issue( + { principal: { kind: "owner" }, label: "second page", kind: "browser" }, + context, + ); + sessions.release(); + const [pair] = cookie.split(";"); + const at = pair!.indexOf("="); + const url = `http://127.0.0.1:${app.port}/`; + await this.browser.call("Network.setCookie", { name: pair!.slice(0, at), value: pair!.slice(at + 1), url }); + await this.browser.open(url); + await this.browser.wait('globalThis.page?.connection === "connected"'); + for (const view of views) await this.browser.wait(`document.querySelector('[data-view="${view}"]')`); + } + + /** The text of `selector` within view `view`, `null` when absent. */ + text(view: string, selector: string): Promise { + return this.browser.evaluate( + `document.querySelector('[data-view="${view}"] ${selector}')?.textContent ?? null`, + ); + } + + /** The value of input `selector` within view `view`. */ + value(view: string, selector: string): Promise { + return this.browser.evaluate(`document.querySelector('[data-view="${view}"] ${selector}').value`); + } + + /** Waits until `selector` within view `view` shows `text`. */ + waitText(view: string, selector: string, text: string, timeoutMs = 30_000): Promise { + const expression = `document.querySelector('[data-view="${view}"] ${selector}')?.textContent === ${JSON.stringify(text)}`; + return this.browser.wait(expression, timeoutMs); + } + + /** Types `text` into input `selector` of view `view`. */ + async type(view: string, selector: string, text: string): Promise { + await this.browser.evaluate(`document.querySelector('[data-view="${view}"] ${selector}').focus()`); + await this.browser.type(text); + } + + /** Every state the reference tracked as `name` went through. */ + states(name: string): Promise { + return this.browser.evaluate(`globalThis.reload.states[${JSON.stringify(name)}] ?? []`); + } + + /** Every value its watched member had. */ + frames(name: string): Promise { + return this.browser.evaluate(`globalThis.reload.frames[${JSON.stringify(name)}] ?? []`); + } + + /** Waits until the reference tracked as `name` is in state `state` (as the probe writes it). */ + async waitState(name: string, state: string, timeoutMs = 30_000): Promise { + try { + await this.browser.wait( + `globalThis.reload.states[${JSON.stringify(name)}]?.at(-1) === ${JSON.stringify(state)}`, + timeoutMs, + ); + } catch { + throw new Error(`${name} never reached ${state}: ${JSON.stringify(await this.states(name))}`); + } + } + + /** + * Holds the object of project `id` as the web plugin `contract` registered its contract, as `name`, its calls + * parking `readyTimeout`, its member `member` watched. + */ + async hold(name: string, contract: string, readyTimeout: number, id = "p1", member = "label"): Promise { + const params = JSON.stringify({ id }); + const args = [JSON.stringify(name), JSON.stringify(contract), params, readyTimeout, JSON.stringify(member)]; + await this.browser.evaluate(`globalThis.reload.hold(${args.join(", ")})`); + } + + /** Calls `method` with `args` on the reference tracked as `name`: the number of its answer. */ + call(name: string, method: string, ...args: unknown[]): Promise { + const list = [JSON.stringify(name), JSON.stringify(method), ...args.map((arg) => JSON.stringify(arg))].join(", "); + return this.browser.evaluate(`globalThis.reload.call(${list})`); + } + + /** Call `index`'s answer now (pending or not). */ + peek(index: number): Promise { + return this.browser.evaluate(`globalThis.reload.results[${index}]`); + } + + /** Call `index`'s answer, once it has one. */ + async result(index: number, timeoutMs = 30_000): Promise { + await this.browser.wait(`globalThis.reload.results[${index}].status !== "pending"`, timeoutMs); + return this.peek(index); + } + + /** How often the page loaded (its base bundle ran) since the browser opened it. */ + loads(): Promise { + return this.browser.evaluate("globalThis.page.loads"); + } +} + +/** The answer of a call that succeeded with `value`. */ +export function ok(value: unknown): CallResult { + return { status: "ok", value }; +} diff --git a/packages/chord/test/node/fixtures/reload/entry.ts b/packages/chord/test/node/fixtures/reload/entry.ts new file mode 100644 index 000000000..e1f3fb98a --- /dev/null +++ b/packages/chord/test/node/fixtures/reload/entry.ts @@ -0,0 +1,5 @@ +// The reload suite's web entry: starts the suite's page (`main.ts`). + +import { start } from "./main.ts"; + +start(); diff --git a/packages/chord/test/node/fixtures/reload/main.ts b/packages/chord/test/node/fixtures/reload/main.ts new file mode 100644 index 000000000..a1b34c6c5 --- /dev/null +++ b/packages/chord/test/node/fixtures/reload/main.ts @@ -0,0 +1,107 @@ +// The reload suite's page (the base bundle's code; `entry.ts` starts it): `startPage` with the suite's API modules and React, and a page that +// renders every contributed view (`Views`) in its own section, a draft input of its own (base code: no plugin reload +// touches it), and a tall spacer so the tests can scroll. The tests reach the page through `globalThis.reload` (the +// probe of the web API) and `globalThis.page` (loads, the connection, dropping the link, holding the reconnect). + +import * as React from "react"; +import { createElement as h, useState } from "react"; +import * as jsx from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; +import * as chord from "../../../../src/index.ts"; +import { HostProvider } from "../../../../src/react/index.ts"; +import { startPage } from "../../../../src/web/index.ts"; +import * as webApi from "./web-api.ts"; + +/** What the tests read and drive besides the probe. */ +type PageControl = { + loads: number; + connection?: string; + /** What the page reported (`startPage`'s `onError`): the kind, and the plugins of a `plugins` report. */ + errors: { readonly kind: string; readonly plugins: readonly string[] }[]; + socket?: WebSocket; + /** While set, the page does not reconnect: the tests resolve it. */ + gate?: Promise; + release?: () => void; + /** Closes the page's socket (the connection loop reconnects, after `gate` if set). */ + dropLink(): void; + /** Holds the page's reconnects until `release`. */ + holdReconnect(): void; +}; + +/** The tests' hand on the page, made when it starts. */ +function pageControl(): PageControl { + const page: PageControl = { + loads: 0, + errors: [], + dropLink() { + page.socket?.close(); + }, + holdReconnect() { + const gate = Promise.withResolvers(); + page.gate = gate.promise; + page.release = () => { + page.gate = undefined; + gate.resolve(true); + }; + }, + }; + return page; +} + +/** The base bundle's facet: it serves `Views`, composed in name order. */ +const core = chord.defineFacet((host) => + host.serve(webApi.Views, { + compose: (table) => table.map(({ value }) => value).sort((a, b) => a.name.localeCompare(b.name)), + }), +); + +function App() { + const views = webApi.useRegistry(webApi.Views) ?? []; + const [draft, setDraft] = useState(""); + return h( + "div", + null, + h("input", { + id: "base-draft", + value: draft, + onChange: (event: { target: { value: string } }) => setDraft(event.target.value), + }), + h( + "div", + { id: "views" }, + ...views.map((view) => h("section", { key: view.name, "data-view": view.name }, h(view.component))), + ), + h("div", { id: "spacer", style: { height: "5000px" } }), + ); +} + +/** Starts the page (Chord's package is free of side effects for bundlers: an entry calls this). */ +export function start(): void { + const page = pageControl(); + page.loads++; + (globalThis as unknown as { page: PageControl }).page = page; + (globalThis as unknown as { reload: typeof webApi.probe }).reload = webApi.probe; + void startPage({ + connect: () => { + const socket = new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`); + page.socket = socket; + return socket; + }, + modules: { "@test/app/v1": chord, "@test/app/v1/web": webApi, react: React, "react/jsx-runtime": jsx }, + core: [{ name: "@test/core", facet: core }], + canRetry: () => page.gate ?? Promise.resolve(true), + onError: (error) => { + page.errors.push({ + kind: error.kind, + plugins: error.kind === "plugins" ? error.failed.map(({ plugin }) => plugin) : [], + }); + }, + onConnection: (state) => { + page.connection = state; + }, + render: (host) => { + webApi.probe.host = host; + createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(App))); + }, + }); +} diff --git a/packages/chord/test/node/fixtures/reload/plugins.ts b/packages/chord/test/node/fixtures/reload/plugins.ts new file mode 100644 index 000000000..522eafe3a --- /dev/null +++ b/packages/chord/test/node/fixtures/reload/plugins.ts @@ -0,0 +1,349 @@ +// The reload suite's plugins, as the files a test writes into the app's root and edits on disk: `counter` (a counter +// with a persisted document and a table, on a worker or the server, pure or holding a native, at any contract +// version), `latch` (on every worker: the test's hand on calls that wait), and web plugins showing the counter with +// a draft input (component state) and a kept input (page state). Calls that must be in flight during a reload wait on +// a latch by name: the method marks it entered, then waits until the test opens it. Latches live on `globalThis`, so +// old and new code of a plugin and the latch plugin share them within a process. + +/** Where the counter runs: a project's worker (`p1`), or the server. */ +export type Placement = "server" | "worker"; + +/** + * The counter's contract as a version of its code has it: `v1`; `v2` evolved (its label an object, `add` taking and + * returning objects) for a provider that accepts v1 (`accepted`) or does not (`refused`), the same file either way. + */ +export type ContractVersion = "v1" | "v2"; + +/** The latches, by name: a method marks one entered and waits until the test opens it. */ +const LATCH = ` +type Latch = { readonly entered: PromiseWithResolvers; readonly open: PromiseWithResolvers }; +const latches: Map = ((globalThis as Record).reloadLatches ??= new Map()) as Map; +/** The latch named \`name\`, made on first use. */ +function latch(name: string): Latch { + let found = latches.get(name); + if (found === undefined) { + found = { entered: Promise.withResolvers(), open: Promise.withResolvers() }; + latches.set(name, found); + } + return found; +} +/** What the natives of every version did in this process: opens by version, closes as \`:\`. */ +const natives = ((globalThis as Record).reloadNatives ??= { opens: [], closes: [] }) as { opens: string[]; closes: string[] }; +`; + +/** The counter's contract file at `version`, placed by `placement`. */ +export function counterContract(placement: Placement, version: ContractVersion): string { + const address = + placement === "worker" + ? 'const Project = hostKind("project", { id: str() });\nconst at = ({ id }: { id: string }) => Project({ id });' + : 'const Server = hostKind("server");\nconst at = () => Server();'; + const v1 = `export const CounterV1 = remote("reload.counter", { + version: 1, + params: { id: str() }, + hostAddress: at, + variants: { + main: { + state: { label: str(), count: int() }, + methods: { + add: method(int(), str()), + slow: method(str(), str()), + slowAdd: method(str(), str()), + code: method(undefined, str()), + visits: method(undefined, int()), + }, + }, + }, +});`; + const v2 = `export const CounterV2 = evolve(CounterV1, (e) => + e.state("main", "label", obj({ text: str(), code: str() })).method("add", obj({ n: int() }), obj({ text: str() })), +);`; + return [ + "// The counter's contract.", + 'import { evolve, hostKind, int, method, obj, remote, str } from "@test/app/v1";', + address, + v1, + version === "v2" ? v2 : "", + `export const Counter = ${version === "v2" ? "CounterV2" : "CounterV1"};`, + ].join("\n"); +} + +/** How a version of the counter's code is written. */ +export type CounterCode = { + /** What its label and `code()` say: which code answered. */ + readonly code: string; + readonly placement: Placement; + /** Holds a native (`self.open`): every reload of it restarts the object. */ + readonly native?: boolean; + /** `v2` (the contract file goes with it): accepting v1 consumers through a mapping, or not. */ + readonly contract?: ContractVersion; + readonly accepts?: boolean; + /** A broken version: its build fails, its evaluation throws, its setup throws, or its native's open throws. */ + readonly broken?: "build" | "evaluation" | "setup" | "open"; +}; + +/** The counter plugin's files (its contract and its server or worker facet) for `code`. */ +export function counterPlugin(code: CounterCode): Record { + const contract = code.contract ?? "v1"; + return { + "contract.ts": counterContract(code.placement, contract), + [`${code.placement}.ts`]: counterFacet(code, contract), + }; +} + +/** + * The counter's facet: a store (a local object with empty params: persisted data needs a stable identity) keeping the + * count document and the visits table, and the counter the page holds, its label naming the code. + */ +function counterFacet(code: CounterCode, contract: ContractVersion): string { + const v = JSON.stringify(code.code); + const label = contract === "v2" ? `({ text: \`${code.code}:\${n}\`, code: ${v} })` : `\`${code.code}:\${n}\``; + const add = + contract === "v2" + ? `add: async ({ n }, ctx) => ({ text: await store.add(n, ctx) }),` + : "add: (n, ctx) => store.add(n, ctx),"; + const accepts = + contract === "v2" && code.accepts === true + ? `, { accepts: { 1: { state: { main: { label: (current) => current.text } }, methods: { add: { args: (old) => ({ n: old }), result: (current) => current.text } } } } }` + : ""; + const native = + code.native === true + ? `\t\tself.open(async () => { ${code.broken === "open" ? 'throw new Error("the native does not open");' : ""} natives.opens.push(${v}); return ${v}; }, (value, reason) => { natives.closes.push(\`\${value}:\${reason.kind}\`); });` + : ""; + return [ + `// The counter's ${code.placement} facet, code ${code.code}.`, + 'import { defineFacet, defineState, defineTable, int, local, method, obj, query, str } from "@test/app/v1";', + 'import { Counter } from "./contract.ts";', + code.broken === "build" ? "const broken = ;" : "", + code.broken === "evaluation" ? 'throw new Error("the counter\'s new code throws when evaluated");' : "", + LATCH, + 'const Count = defineState("reload.count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }), persist: true, checkpointWhen: () => false });', + 'const Visits = defineTable("reload.visits", { version: 1, columns: { at: int() }, key: ["at"] });', + 'const Store = local("reload.store", { version: 1, params: {}, members: { count: int(), add: method(int(), str()), visits: method(undefined, int()) } });', + "export default defineFacet((host) => {", + code.broken === "setup" ? '\tthrow new Error("the counter\'s new setup throws");' : "", + "\thost.serve(Store, (_params, self) => {", + "\t\tconst doc = self.state(Count);", + "\t\tconst visits = self.table(Visits);", + "\t\treturn {", + "\t\t\tcount: self.derive([doc], ([value]) => value.n),", + "\t\t\tadd: async (n, ctx) => {", + "\t\t\t\tawait doc.change((draft) => { draft.n += n; }, ctx);", + "\t\t\t\tconst at = doc.value.n;", + "\t\t\t\tawait self.tx(async (tx) => { tx.put(visits, { at }); }, ctx);", + `\t\t\t\treturn \`${code.code}:\${at}\`;`, + "\t\t\t},", + "\t\t\tvisits: async (ctx) => visits.count(query(Visits), ctx),", + "\t\t};", + "\t});", + "\thost.serve(Counter, (_params, _principal, self) => {", + native, + "\t\tconst store = self.get(Store, {});", + '\t\treturn { variant: "main", members: {', + `\t\t\tlabel: self.derive([store.count], ([n]) => ${label}),`, + "\t\t\tcount: self.derive([store.count], ([n]) => n),", + `\t\t\t${add}`, + `\t\t\tslow: async (name) => { const held = latch(name); held.entered.resolve(); await held.open.promise; return \`${code.code}:\${name}\`; },`, + "\t\t\tslowAdd: async (name, ctx) => { const held = latch(name); held.entered.resolve(); await held.open.promise; return store.add(10, ctx); },", + `\t\t\tcode: async () => ${v},`, + "\t\t\tvisits: (ctx) => store.visits(ctx),", + "\t\t} };", + `\t}${accepts});`, + "});", + ].join("\n"); +} + +/** The latch plugin's worker facet: `Control` on each project's worker opens latches and reads them for the test. */ +export const LATCH_PLUGIN: Readonly> = { + "worker.ts": [ + "// The latch plugin: the test's hand on the latches and the natives' record in a worker's process.", + 'import { arr, bool, defineFacet, hostKind, method, obj, remote, str } from "@test/app/v1";', + LATCH, + 'const Project = hostKind("project", { id: str() });', + 'const Control = remote("reload.control", { version: 1, params: { id: str() }, hostAddress: ({ id }) => Project({ id }), variants: { main: { state: {}, methods: {', + "\tentered: method(str(), bool()), open: method(str()), natives: method(undefined, obj({ opens: arr(str()), closes: arr(str()) })),", + "} } } });", + 'export default defineFacet((host) => host.serve(Control, () => ({ variant: "main", members: {', + "\tentered: async (name) => { await latch(name).entered.promise; return true; },", + "\topen: async (name) => { latch(name).open.resolve(); return null; },", + "\tnatives: async () => ({ opens: [...natives.opens], closes: [...natives.closes] }),", + "} })));", + ].join("\n"), +}; + +/** How a version of a web plugin is written. */ +export type WebCode = { + /** Its name: its section's `data-view` and its probe name. */ + readonly name: string; + /** What its view says it is (`:`): which code renders. */ + readonly code: string; + /** The counter it shows, through its own copy of the contract (an old page keeps its copy); none: no reference. */ + readonly counter?: { readonly placement: Placement; readonly contract: ContractVersion }; + /** Shows the server's summary of the counter instead (`summaryPlugin`), through its own contract copy. */ + readonly summary?: boolean; + /** A broken version: its build fails or its evaluation throws. */ + readonly broken?: "build" | "evaluation"; + /** Contributes no view (its contributions changed shape: none now). */ + readonly hidden?: boolean; +}; + +/** A web plugin's files: its view (the counter's label, count and state, a draft and a kept input) and contract copy. */ +export function webPlugin(code: WebCode): Record { + const name = JSON.stringify(code.name); + const counter = code.counter; + const lines = [ + `// The ${code.name} web plugin, code ${code.code}.`, + 'import { createElement as h, useEffect, useState } from "react";', + 'import { defineFacet } from "@test/app/v1";', + 'import { probe, useHold, usePageState, useRefState, useWatch, Views } from "@test/app/v1/web";', + counter === undefined + ? "" + : `import { ${code.summary === true ? "Summary as Counter" : "Counter"} } from "./contract.ts";`, + code.broken === "build" ? "const broken = ;" : "", + code.broken === "evaluation" ? 'throw new Error("the web plugin\'s new code throws when evaluated");' : "", + counter === undefined ? "" : `probe.contracts[${name}] = Counter;`, + "function Shown() {", + counter === undefined + ? "\treturn null;" + : [ + '\tconst ref = useHold(Counter, { id: "p1" });', + "\tconst label = useWatch(ref?.label);", + "\tconst count = useWatch(ref?.count);", + "\tconst state = useRefState(ref);", + `\tuseEffect(() => { if (ref !== undefined) probe.track(${name}, ref, "label"); }, [ref]);`, + '\treturn h("span", null,', + '\t\th("span", { className: "label" }, label === undefined ? "none" : typeof label === "string" ? label : JSON.stringify(label)),', + '\t\th("span", { className: "count" }, String(count ?? "none")),', + '\t\th("span", { className: "state" }, state?.kind ?? "none"),', + "\t);", + ].join("\n"), + "}", + "function View() {", + '\tconst [draft, setDraft] = useState("");', + `\tconst [kept, setKept] = usePageState(${JSON.stringify(`${code.name}.kept`)}, "");`, + '\treturn h("div", null,', + `\t\th("span", { className: "code" }, ${JSON.stringify(`${code.name}:${code.code}`)}),`, + "\t\th(Shown),", + '\t\th("input", { className: "draft", value: draft, onChange: (event: { target: { value: string } }) => setDraft(event.target.value) }),', + '\t\th("input", { className: "kept", value: kept, onChange: (event: { target: { value: string } }) => setKept(event.target.value) }),', + "\t);", + "}", + code.hidden === true + ? "export default defineFacet(() => {});" + : `export default defineFacet((_host, self) => self.contribute(Views, { name: ${name}, component: View }));`, + ]; + return { + "web.ts": lines.join("\n"), + ...(counter === undefined + ? {} + : { + "contract.ts": + code.summary === true ? SUMMARY_CONTRACT : counterContract(counter.placement, counter.contract), + }), + }; +} + +/** The summary's contract: on the server, its label the counter's seen through the summary's code. */ +const SUMMARY_CONTRACT = `// The summary's contract. +import { hostKind, int, method, remote, str } from "@test/app/v1"; +const Server = hostKind("server"); +export const Summary = remote("reload.summary", { + version: 1, + params: { id: str() }, + hostAddress: () => Server(), + variants: { main: { state: { label: str(), count: int() }, methods: { code: method(undefined, str()) } } }, +});`; + +/** + * The summary plugin's files: a pure object on the server whose factory holds the counter of project `id` on its worker + * (a dependency on another host), its label `()`. The counter's contract is the counter + * plugin's file, imported by relative path (copied into the summary's script). + */ +export function summaryPlugin(code: string): Record { + return { + "contract.ts": SUMMARY_CONTRACT, + "server.ts": [ + `// The summary's server facet, code ${code}.`, + 'import { defineFacet } from "@test/app/v1";', + 'import { Counter } from "../counter/contract.ts";', + 'import { Summary } from "./contract.ts";', + "export default defineFacet((host) => host.serve(Summary, ({ id }, _principal, self) => {", + "\tconst counter = self.get(Counter, { id });", + '\treturn { variant: "main", members: {', + `\t\tlabel: self.derive([counter.count], ([count]) => \`${code}(\${count})\`),`, + "\t\tcount: self.derive([counter.count], ([count]) => count),", + `\t\tcode: async () => ${JSON.stringify(code)},`, + "\t} };", + "}));", + ].join("\n"), + }; +} + +/** The tasks' contract: on project `id`'s worker, the finished runs' entries and how many run. */ +const TASKS_CONTRACT = `// The tasks' contract. +import { arr, hostKind, int, method, remote, str } from "@test/app/v1"; +const Project = hostKind("project", { id: str() }); +export const Tasks = remote("reload.tasks", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Project({ id }), + variants: { main: { state: { entries: arr(str()), running: int() }, methods: { start: method(str(), str()) } } }, +});`; + +/** + * The tasks plugin's files: a runner (a local store) with an in-memory job whose run waits on the latch named by its + * argument, and whose `finish` records `-finish()` in a persisted list; `native`: the runner holds a + * native, so a reload restarts it. The page reaches it through `Tasks`. + */ +export function tasksPlugin(code: string, native = false): Record { + const v = JSON.stringify(code); + return { + "contract.ts": TASKS_CONTRACT, + "worker.ts": [ + `// The tasks' worker facet, code ${code}.`, + 'import { arr, defineFacet, defineJob, defineState, int, local, method, obj, str } from "@test/app/v1";', + 'import { Tasks } from "./contract.ts";', + LATCH, + 'const Entries = defineState("reload.entries", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }), persist: true, checkpointWhen: () => false });', + 'const Task = defineJob("reload.task", { version: 1, args: obj({ name: str() }), result: str() });', + 'const Runner = local("reload.runner", { version: 1, params: {}, members: { entries: arr(str()), running: int(), start: method(str(), str()) } });', + "export default defineFacet((host) => {", + "\thost.serve(Runner, (_params, self) => {", + native ? `\t\tself.open(async () => ${v}, () => {});` : "", + "\t\tconst entries = self.state(Entries);", + "\t\tconst task = self.job(Task, {", + "\t\t\trun: async ({ name }, ctx) => {", + "\t\t\t\tconst held = latch(name);", + "\t\t\t\theld.entered.resolve();", + "\t\t\t\tawait new Promise((resolve, reject) => {", + "\t\t\t\t\tvoid held.open.promise.then(resolve);", + '\t\t\t\t\tctx.abortSignal?.addEventListener("abort", () => reject(new Error("aborted")));', + "\t\t\t\t});", + `\t\t\t\treturn \`run-${code}:\${name}\`;`, + "\t\t\t},", + "\t\t\tfinish: async (outcome, tx) => {", + '\t\t\t\tconst what = outcome.kind === "done" ? "done:" + outcome.result : outcome.error.kind === "aborted" ? "aborted:" + outcome.error.reason : outcome.error.kind;', + `\t\t\t\t(await tx.draft(entries)).list.push(\`${code}-finish(\${what})\`);`, + "\t\t\t},", + "\t\t});", + "\t\treturn {", + "\t\t\tentries: self.derive([entries], ([value]) => value.list),", + "\t\t\trunning: self.derive([task.running], ([running]) => running.length),", + "\t\t\tstart: async (name, ctx) => (await task.start({ name }, ctx)).id,", + "\t\t};", + "\t});", + "\thost.serve(Tasks, (_params, _principal, self) => {", + "\t\tconst runner = self.get(Runner, {});", + '\t\treturn { variant: "main", members: { entries: runner.entries, running: runner.running, start: (name, ctx) => runner.start(name, ctx) } };', + "\t});", + "});", + ].join("\n"), + "web.ts": [ + "// The tasks' web facet: registers the contract for the page's probe.", + 'import { defineFacet } from "@test/app/v1";', + 'import { probe } from "@test/app/v1/web";', + 'import { Tasks } from "./contract.ts";', + "probe.contracts.tasks = Tasks;", + "export default defineFacet(() => {});", + ].join("\n"), + }; +} diff --git a/packages/chord/test/node/fixtures/reload/web-api.ts b/packages/chord/test/node/fixtures/reload/web-api.ts new file mode 100644 index 000000000..0184cfc8d --- /dev/null +++ b/packages/chord/test/node/fixtures/reload/web-api.ts @@ -0,0 +1,129 @@ +// The reload suite's web API (`@test/app/v1/web`): the `Views` registry the web plugins contribute their components to, +// Chord's React hooks, and the probe through which the tests see the page (every state a reference went through, every +// value of a tracked member) and call from it (each call's answer kept by number). + +import type { ComponentType } from "react"; +import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts"; +import { type Contract, isChordError, type RefState } from "../../../../src/index.ts"; +import { defineRegistry } from "../../../../src/registries/index.ts"; +import type { WatchCall } from "../../../../src/watch.ts"; + +export { useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "../../../../src/react/index.ts"; + +/** A web plugin's view: its name (the section's `data-view`) and its component. */ +export type View = { readonly name: string; readonly component: ComponentType }; + +/** The views, in name order. */ +export const Views = defineRegistry("reload.views", { version: 1 }); + +/** A call's answer as the tests read it. */ +export type CallResult = + | { readonly status: "pending" } + | { readonly status: "ok"; readonly value: unknown } + | { readonly status: "error"; readonly code: string; readonly reason: string | undefined; readonly message: string }; + +/** What the probe needs of a reference: its state, its members by name (state members and methods). */ +type ProbedRef = { + readonly state: { + readonly value: RefState; + watch( + context: typeof BACKGROUND_CONTEXT, + ): Promise<{ start(listener: (call: { kind: string; value?: RefState }) => Promise): void }>; + }; + readonly release: () => void; +} & Record; + +/** A state member as the probe watches it. */ +type ProbedMember = { + watch( + context: typeof BACKGROUND_CONTEXT, + ): Promise<{ start(listener: (call: WatchCall) => Promise): void }>; +}; + +/** A reference's state as one string: `live`, `loading`, `failed::`, `ended:`. */ +function stateText(state: RefState): string { + if (state.kind === "failed") + return `failed:${state.cause.kind}:${state.cause.error.reason ?? state.cause.error.code}`; + if (state.kind === "ended") return `ended:${state.error.code}`; + return state.kind; +} + +/** The page as the tests see it: tracked references, their states and member values, calls and their answers. */ +class PageProbe { + readonly refs: Record = {}; + /** Every state each tracked reference went through, in order; `remount` where a new reference took the name. */ + readonly states: Record = {}; + /** Every value (or `failed:`) of each tracked reference's watched member. */ + readonly frames: Record = {}; + readonly results: CallResult[] = []; + /** The contracts web plugins registered, by name, for references the tests make themselves (`hold`). */ + readonly contracts: Record = {}; + /** The page's own reference to the host, set by the base bundle. */ + host: + | { + hold( + Type: Contract, + params: unknown, + context: typeof BACKGROUND_CONTEXT, + options: { readyTimeout: number }, + ): unknown; + } + | undefined; + + /** Tracks `ref` under `name`: its states from now on and the values of its member `member`. */ + track(name: string, ref: unknown, member: string): void { + // The references the web plugins hand in are Chord references; the probe reads them by member name. + const probed = ref as ProbedRef; + if (this.refs[name] === probed) return; + const states = this.states[name] ?? []; + const frames = this.frames[name] ?? []; + this.states[name] = states; + this.frames[name] = frames; + if (this.refs[name] !== undefined) states.push("remount"); + this.refs[name] = probed; + states.push(stateText(probed.state.value)); + void probed.state.watch(BACKGROUND_CONTEXT).then((watch) => + watch.start(async (call) => { + if (this.refs[name] === probed && call.kind === "value") states.push(stateText(call.value!)); + }), + ); + void (probed[member] as ProbedMember).watch(BACKGROUND_CONTEXT).then((watch) => + watch.start(async (call) => { + if (this.refs[name] !== probed) return; + frames.push(call.kind === "value" ? call.value : `failed:${failureText(call.error)}`); + }), + ); + } + + /** Holds the contract `contract` registered with `params` as the tracked reference `name`, parking calls `readyTimeout`. */ + hold(name: string, contract: string, params: unknown, readyTimeout: number, member: string): void { + const ref = this.host!.hold(this.contracts[contract]!, params, BACKGROUND_CONTEXT, { readyTimeout }); + this.track(name, ref, member); + } + + /** Calls `method` of the reference tracked as `name` with `args`; the number of its answer in `results`. */ + call(name: string, method: string, ...args: unknown[]): number { + const index = this.results.length; + this.results.push({ status: "pending" }); + const fn = this.refs[name]![method] as (...rest: unknown[]) => Promise; + fn(...args, BACKGROUND_CONTEXT).then( + (value) => { + this.results[index] = { status: "ok", value }; + }, + (error: unknown) => { + this.results[index] = isChordError(error) + ? { status: "error", code: error.code, reason: error.reason, message: error.message } + : { status: "error", code: "thrown", reason: undefined, message: String(error) }; + }, + ); + return index; + } +} + +/** A watch failure's error as text: a Chord error's reason or code, else its message. */ +function failureText(error: unknown): string { + return isChordError(error) ? (error.reason ?? error.code) : String(error); +} + +/** The page's probe (one per page: this module is the page's instance of the web API). */ +export const probe = new PageProbe(); diff --git a/packages/chord/test/node/plugins/watcher.test.ts b/packages/chord/test/node/plugins/watcher.test.ts index 99bc1b418..7607d7a32 100644 --- a/packages/chord/test/node/plugins/watcher.test.ts +++ b/packages/chord/test/node/plugins/watcher.test.ts @@ -82,6 +82,21 @@ describe("the watcher's marks", () => { watcher.close(); }); + it("marks base for a base file given one by one, not for its siblings; never under node_modules", async () => { + const { base, watcher } = await watched(); + const elsewhere = await realpath(await tempRoot()); + await writeFiles(elsewhere, { "entry.ts": "", "sibling.ts": "", "node_modules/react/index.js": "" }); + watcher.baseFiles([join(elsewhere, "entry.ts"), join(elsewhere, "node_modules", "react", "index.js")]); + watcher.mark(join(elsewhere, "sibling.ts")); + watcher.mark(join(elsewhere, "node_modules", "react", "index.js")); + expect(watcher.value).toMatchObject({ base: false }); + // A real event on the file: its directory is watched. + await writeFile(join(elsewhere, "entry.ts"), "export {};"); + await vi.waitFor(() => expect(watcher.value).toMatchObject({ base: true }), { timeout: 5_000 }); + expect(base).not.toBe(elsewhere); + watcher.close(); + }); + it("ignores node_modules and .git", async () => { const { root, watcher } = await watched(); watcher.mark(join(root, "a", "node_modules", "x", "index.js")); diff --git a/packages/chord/test/node/reload/base.test.ts b/packages/chord/test/node/reload/base.test.ts new file mode 100644 index 000000000..1e1070ae4 --- /dev/null +++ b/packages/chord/test/node/reload/base.test.ts @@ -0,0 +1,83 @@ +// Reload suite, row "base code (the app's client entry, Chord, React)" (design §21 "What a user sees during a +// reload"): the page reloads, the only case losing what is on screen; calls in flight end with the link; page state is +// lost, server state unchanged. The base bundle's entry changes on disk, the watcher flags base code, and the app +// restarts its hosts (the kit's answer to base code) on the same data directory and port. + +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { latches, ok, ReloadPage, startReloadApp, versionedMain } from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM, poll } from "../web/browser.ts"; + +/** The counter as the web plugins show it. */ +const COUNTER = { placement: "worker", contract: "v1" } as const; + +describe.skipIf(CHROMIUM === undefined)("a change of base code", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + it("reloads the page, ends calls in flight with the link, loses page state and keeps server state", async () => { + const entry = await versionedMain("1"); + const first = await startReloadApp( + { + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker" }), + alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }), + }, + { entry }, + ); + let second: Awaited> | undefined; + try { + await page.signIn(first, ["alpha"]); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + await page.type("alpha", ".kept", "kept"); + await browser.evaluate("globalThis.marked = true"); + const hand = await latches(first, "worker"); + const running = await page.call("alpha", "slowAdd", "running"); + await hand.entered("running"); + + // Base code changes; the app restarts its hosts. + await versionedMain("2", join(entry, "..")); + await first.app.close(context); + // The call in flight ends with the link. + expect(await page.result(running)).toMatchObject({ status: "error" }); + second = await startReloadApp({}, { entry, port: first.port, dataDir: first.dataDir, root: first.root }); + expect(second.app.web!.base).not.toBe(first.app.web!.base); + + // The page reloads for the new base bundle: what was on screen and in page state is gone. + await browser.wait('globalThis.version === "2" && globalThis.page?.connection === "connected"', 60_000); + expect(await browser.evaluate("globalThis.marked ?? false")).toBe(false); + await page.waitText("alpha", ".state", "live"); + expect(await page.value("alpha", ".kept")).toBe(""); + // Server state unchanged: the count as committed before (the cut call never committed). + expect(await page.text("alpha", ".label")).toBe("v1:2"); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(1)); + } finally { + await first.app.close(context); + await second?.app.close(context); + } + }, 120_000); + + it("flags a change of the web entry as base code (`plugins.state.base`)", async () => { + const entry = await versionedMain("1"); + const app = await startReloadApp({ alpha: webPlugin({ name: "alpha", code: "1" }) }, { entry }); + try { + await versionedMain("2", join(entry, "..")); + await poll(() => app.app.plugins.state.value.base, 5000, "base code flagged"); + } finally { + await app.app.close(context); + } + }, 60_000); +}); diff --git a/packages/chord/test/node/reload/contracts.test.ts b/packages/chord/test/node/reload/contracts.test.ts new file mode 100644 index 000000000..f79961cac --- /dev/null +++ b/packages/chord/test/node/reload/contracts.test.ts @@ -0,0 +1,157 @@ +// Reload suite, rows "a contract change the new code still `accepts`" and "a contract change not accepted, or a type +// removed" (design §21 "What a user sees during a reload"). Accepted: the old page keeps working through the mapping, +// its calls answered through it. Not accepted or removed: the reference waits with its last values (`failed +// { waiting }`: `version`, `not_served`) until the page's code updates or the type returns; a call on it fails at once +// with the cause (`contract_mismatch`, `unavailable` not served; as corrected upstream); state unchanged. The page's +// web plugin keeps its own copy of the contract (an old page) while the counter on the worker moves. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { + editAndApply, + ok, + ReloadPage, + removeAndApply, + rolloutFailures, + startReloadApp, +} from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +/** The page's short reference's `readyTimeout`: a call failing at once answers well before it. */ +const PARK_MS = 1500; + +describe.skipIf(CHROMIUM === undefined)("a reload changing a contract", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + /** The app with the counter at v1 on worker `p1`, the page showing it (`alpha`) with a count of 2. */ + async function oldPage() { + const placement = "worker"; + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement }), + alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }), + }); + await page.signIn(app, ["alpha"]); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + await page.type("alpha", ".draft", "half-typed"); + return app; + } + + it("keeps an old page working through the mapping of a contract the new code accepts", async () => { + const app = await oldPage(); + try { + const settled = (await page.states("alpha")).length; + const result = await editAndApply( + app, + "counter", + counterPlugin({ code: "v2", placement: "worker", contract: "v2", accepts: true }), + ); + expect(rolloutFailures(result)).toEqual([]); + // The v1 page reads v2's label mapped back, and its v1 call is mapped forwards and its result back. + await page.waitText("alpha", ".label", "v2:2"); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3")); + await page.waitText("alpha", ".label", "v2:3"); + expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v2")); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + expect(await page.text("alpha", ".count")).toBe("3"); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2)); + expect(await page.value("alpha", ".draft")).toBe("half-typed"); + } finally { + await app.app.close(context); + } + }, 120_000); + + it("keeps the last values while the version is refused and binds once the page's code updates", async () => { + const app = await oldPage(); + try { + const result = await editAndApply( + app, + "counter", + counterPlugin({ code: "v2", placement: "worker", contract: "v2" }), + ); + expect(rolloutFailures(result)).toEqual([]); + + // Unbound `version` (§3): `failed { waiting }` with `contract_mismatch`. + await page.waitState("alpha", "failed:waiting:contract_mismatch", 5000); + // The last values stay on screen. + expect(await page.text("alpha", ".label")).toBe("v1:2"); + expect(await page.text("alpha", ".count")).toBe("2"); + expect(await page.text("alpha", ".state")).toBe("failed"); + + // The page's code updates (its contract copy at v2): the remounted view binds the new version. + await editAndApply( + app, + "alpha", + webPlugin({ name: "alpha", code: "2", counter: { placement: "worker", contract: "v2" } }), + ); + await page.waitText("alpha", ".code", "alpha:2"); + await page.waitText("alpha", ".label", JSON.stringify({ text: "v2:2", code: "v2" })); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "add", { n: 1 }))).toEqual(ok({ text: "v2:3" })); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2)); + } finally { + await app.app.close(context); + } + }, 120_000); + + it("keeps the last values while the type is removed and binds again when the type returns", async () => { + const app = await oldPage(); + try { + const result = await removeAndApply(app, "counter"); + expect(rolloutFailures(result)).toEqual([]); + + // Unbound `not_served` (§3): `failed { waiting }` with `unavailable` ("X is not served here"). + await page.waitState("alpha", "failed:waiting:unavailable", 5000); + expect(await page.text("alpha", ".label")).toBe("v1:2"); + expect(await page.text("alpha", ".count")).toBe("2"); + + // The type returns: the same reference binds again, the persisted count as it was. + await editAndApply(app, "counter", counterPlugin({ code: "v1", placement: "worker" })); + await page.waitState("alpha", "live"); + await page.waitText("alpha", ".state", "live"); + expect(await page.text("alpha", ".label")).toBe("v1:2"); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3")); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2)); + expect(await page.value("alpha", ".draft")).toBe("half-typed"); + } finally { + await app.app.close(context); + } + }, 120_000); + + it.each([ + ["the version is refused", "failed:waiting:contract_mismatch", "contract_mismatch"], + ["the type is removed", "failed:waiting:unavailable", "unavailable"], + ] as const)( + "fails a call at once with the cause while %s", + async (change, waiting, code) => { + const app = await oldPage(); + try { + await page.hold("short", "alpha", PARK_MS); + await page.waitState("short", "live"); + if (change === "the type is removed") await removeAndApply(app, "counter"); + else await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker", contract: "v2" })); + await page.waitState("short", waiting, 5000); + const started = Date.now(); + const failed = await page.result(await page.call("short", "add", 1)); + expect(failed).toMatchObject({ status: "error", code }); + // Not parked: well before the reference's `readyTimeout`. + expect(Date.now() - started).toBeLessThan(PARK_MS); + } finally { + await app.app.close(context); + } + }, + 120_000, + ); +}); diff --git a/packages/chord/test/node/reload/failed.test.ts b/packages/chord/test/node/reload/failed.test.ts new file mode 100644 index 000000000..4fc47b627 --- /dev/null +++ b/packages/chord/test/node/reload/failed.test.ts @@ -0,0 +1,170 @@ +// Reload suite, row "a plugin whose new build or evaluation fails" (design §21 "What a user sees during a reload"): +// everything stays on screen; the plugin keeps its old code and the error is reported to the app; calls and state are +// unaffected. Through a real page: a worker plugin whose new code fails to build, throws when evaluated, or whose setup +// throws (staging), and a web plugin whose new code fails to build or throws when evaluated. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { editAndApply, ok, ReloadPage, rolloutFailures, rolloutFates, startReloadApp } from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +/** The counter as the web plugins show it. */ +const COUNTER = { placement: "worker", contract: "v1" } as const; + +describe.skipIf(CHROMIUM === undefined)("a reload whose new code fails", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + it.each(["build", "evaluation", "setup"] as const)( + "keeps a worker plugin whose new %s fails on its old code, the page untouched", + async (broken) => { + const placement = "worker"; + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement }), + alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }), + }); + try { + await page.signIn(app, ["alpha"]); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + await page.type("alpha", ".draft", "half-typed"); + const settled = (await page.states("alpha")).length; + const frames = (await page.frames("alpha")).length; + + const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, broken })); + // Reported to the app: the worker kept the plugin's old code, and it stays dirty for the next apply. + expect(rolloutFailures(result)).toEqual(["project/project|p1: @test/counter"]); + expect(app.app.plugins.state.value.dirty).toContain("@test/counter"); + + // The page: nothing moved, calls answered by the old code, the document as it was. + expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v1")); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3")); + await page.waitText("alpha", ".label", "v1:3"); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + expect((await page.frames("alpha")).slice(frames)).toEqual(["v1:3"]); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2)); + expect(await page.value("alpha", ".draft")).toBe("half-typed"); + expect(await page.loads()).toBe(1); + } finally { + await app.app.close(context); + } + }, + 120_000, + ); + + /** The app with the counter, a web plugin `alpha` showing it and a web plugin `beta`, alpha's inputs typed in. */ + async function webApp() { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker" }), + alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }), + beta: webPlugin({ name: "beta", code: "1" }), + }); + await page.signIn(app, ["alpha", "beta"]); + await page.waitText("alpha", ".label", "v1:0"); + await page.type("alpha", ".draft", "half-typed"); + await page.type("alpha", ".kept", "kept"); + return app; + } + + /** The old component still mounted with its state, its reference untouched since `settled` states, calls answered. */ + async function oldComponentKept(settled: number): Promise { + expect(await page.text("alpha", ".code")).toBe("alpha:1"); + expect(await page.value("alpha", ".draft")).toBe("half-typed"); + expect(await page.value("alpha", ".kept")).toBe("kept"); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:1")); + expect(await page.loads()).toBe(1); + } + + it("keeps a web plugin whose new build fails on its old code, its component and state on screen", async () => { + const app = await webApp(); + try { + const settled = (await page.states("alpha")).length; + const result = await editAndApply( + app, + "alpha", + webPlugin({ name: "alpha", code: "2", counter: COUNTER, broken: "build" }), + ); + // Reported to the app: the web step kept the plugin's old script, and it stays dirty for the next apply. + expect(rolloutFailures(result)).toEqual(["web/web: @test/alpha"]); + expect(app.app.plugins.state.value.dirty).toContain("@test/alpha"); + await new Promise((resolve) => setTimeout(resolve, 500)); + await oldComponentKept(settled); + expect(await browser.evaluate("globalThis.page.errors")).toEqual([]); + } finally { + await app.app.close(context); + } + }, 120_000); + + // Open design point (2026-11-04, with the owner): pages don't report kept-old plugins back. The server's web step + // publishes the set without waiting for clients, so it marks the plugin applied (no longer dirty) although the page + // kept its old code; decision "Per plugin at startup and reload" (5) says a plugin kept old on any host stays dirty. + it("keeps a web plugin whose new script throws in the page on its old code, reported in the page; the server marks it applied (open: pages don't report kept-old plugins back)", async () => { + const app = await webApp(); + try { + const settled = (await page.states("alpha")).length; + await editAndApply( + app, + "alpha", + webPlugin({ name: "alpha", code: "2", counter: COUNTER, broken: "evaluation" }), + ); + // The page evaluates the script: it keeps the old one and reports it (`startPage`'s `onError`). + await browser.wait("globalThis.page.errors.length > 0"); + expect(await browser.evaluate("globalThis.page.errors")).toEqual([ + { kind: "plugins", plugins: ["@test/alpha"] }, + ]); + await oldComponentKept(settled); + // Today's behaviour, the open point above: the server considers it applied. + expect(app.app.plugins.state.value.dirty).toEqual([]); + } finally { + await app.app.close(context); + } + }, 120_000); + + // A case the table leaves out (the audit's "new code that fails at activation"): the new code passes staging, so the + // commit applies it; its native then fails to open. A runtime state (§21: failed opens stay per object after the + // commit): the old code is gone, the page shows the failure with the last values, and the next fix applies. + it("shows a failure with the last values when new code passes staging and fails at activation, until a fix", async () => { + const placement = "worker"; + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement, native: true }), + alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }), + }); + try { + await page.signIn(app, ["alpha"]); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + const result = await editAndApply( + app, + "counter", + counterPlugin({ code: "v2", placement, native: true, broken: "open" }), + ); + expect(rolloutFailures(result)).toEqual([]); + expect(rolloutFates(result)).toContain("project/project|p1: reload.counter(p1) restarted"); + expect(app.app.plugins.state.value.dirty).toEqual([]); + await page.waitText("alpha", ".state", "failed"); + expect(await page.text("alpha", ".label")).toBe("v1:2"); + expect(await page.result(await page.call("alpha", "code"))).toMatchObject({ status: "error" }); + + await editAndApply(app, "counter", counterPlugin({ code: "v3", placement, native: true })); + await page.waitText("alpha", ".state", "live"); + await page.waitText("alpha", ".label", "v3:2"); + expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v3")); + } finally { + await app.app.close(context); + } + }, 120_000); +}); diff --git a/packages/chord/test/node/reload/jobs.test.ts b/packages/chord/test/node/reload/jobs.test.ts new file mode 100644 index 000000000..bb61e7303 --- /dev/null +++ b/packages/chord/test/node/reload/jobs.test.ts @@ -0,0 +1,71 @@ +// Reload suite, a case the table leaves out (the audit's "jobs running across a reload"; design §15 "Versions"): a job +// run started from the page, running on a worker while its object reloads. A pure swap: the run finishes on its old +// code and `finish` runs on the new code. A native restart: the in-memory run ends `aborted { reload }` and `finish` +// runs on the new code. The page watches the finished runs' entries. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { + editAndApply, + latches, + ReloadPage, + rolloutFailures, + rolloutFates, + startReloadApp, +} from "../fixtures/reload/app.ts"; +import { LATCH_PLUGIN, tasksPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +describe.skipIf(CHROMIUM === undefined)("a job running across a reload", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + it.each([ + ["a pure swap", false, "swapped", "v2-finish(done:run-v1:a)"], + ["a native restart", true, "restarted", "v2-finish(aborted:reload)"], + ] as const)( + "across %s", + async (_change, native, fate, entry) => { + const app = await startReloadApp({ latch: LATCH_PLUGIN, tasks: tasksPlugin("v1", native) }); + const hand = await latches(app, "worker"); + try { + await page.signIn(app, []); + await page.hold("tasks", "tasks", 10_000, "p1", "entries"); + await page.waitState("tasks", "live"); + const started = await page.result(await page.call("tasks", "start", "a")); + expect(started).toMatchObject({ status: "ok" }); + await hand.entered("a"); + + const result = await editAndApply(app, "tasks", tasksPlugin("v2", native)); + expect(rolloutFailures(result)).toEqual([]); + expect(rolloutFates(result)).toContain(`project/project|p1: reload.runner ${fate}`); + await hand.open("a"); + await browser.wait( + `globalThis.reload.frames.tasks?.some((frame) => Array.isArray(frame) && frame.length > 0)`, + ); + const frames = await page.frames("tasks"); + expect(frames.at(-1)).toEqual([entry]); + // A run started now runs on the new code. + await page.call("tasks", "start", "b"); + await hand.entered("b"); + await hand.open("b"); + await browser.wait("globalThis.reload.frames.tasks?.at(-1)?.length === 2"); + expect((await page.frames("tasks")).at(-1)).toEqual([entry, "v2-finish(done:run-v2:b)"]); + } finally { + await hand.open("a"); + await hand.open("b"); + await app.app.close(context); + } + }, + 120_000, + ); +}); diff --git a/packages/chord/test/node/reload/pure.test.ts b/packages/chord/test/node/reload/pure.test.ts new file mode 100644 index 000000000..ba1fe1b54 --- /dev/null +++ b/packages/chord/test/node/reload/pure.test.ts @@ -0,0 +1,133 @@ +// Reload suite, row "pure code (no natives) on the server or a worker" (design §21 "What a user sees during a reload"): +// the view stays bound, one fresh snapshot once the new code is live, values change only where the new code computes +// differently; a running call finishes on the old code, a call arriving meanwhile waits and runs on the new code, never +// `not_ready`; documents and tables unchanged. Through a real page, for a counter on a project's worker and on the +// server. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { + editAndApply, + latches, + ok, + ReloadPage, + rolloutFailures, + rolloutFates, + startReloadApp, +} from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, type Placement, summaryPlugin, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +describe.skipIf(CHROMIUM === undefined)("a reload of pure code", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + describe.each(["worker", "server"])("on the %s", (placement) => { + it("keeps the view bound, finishes a running call on the old code and runs new calls on the new code", async () => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement }), + alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }), + }); + try { + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".label", "v1:0"); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + await page.type("alpha", ".draft", "half-typed"); + const settled = (await page.states("alpha")).length; + const hand = await latches(app, placement); + const running = await page.call("alpha", "slow", "running"); + await hand.entered("running"); + + // The new code computes the label differently (its code name) and the count the same. + const applying = editAndApply(app, "counter", counterPlugin({ code: "v2", placement })); + // Calls sent while the rollout runs: each answered by the old or the new code, never `not_ready`. + const during: number[] = []; + let applied = false; + void applying.finally(() => { + applied = true; + }); + while (!applied) { + during.push(await page.call("alpha", "code")); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const result = await applying; + expect(rolloutFailures(result)).toEqual([]); + const step = placement === "worker" ? "project/project|p1" : "server/server"; + expect(rolloutFates(result)).toEqual([ + `${step}: reload.store swapped`, + `${step}: reload.counter(p1) swapped`, + ]); + + // Arriving after the commit, while the old call still runs: the new code answers. + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3")); + expect(await page.peek(running)).toEqual({ status: "pending" }); + await hand.open("running"); + expect(await page.result(running)).toEqual(ok("v1:running")); + for (const index of during) { + const answer = await page.result(index); + expect([ok("v1"), ok("v2")]).toContainEqual(answer); + } + + await page.waitText("alpha", ".label", "v2:3"); + // Bound throughout: the reference never left `live`, its label went from the old value to the new. + expect((await page.states("alpha")).slice(settled - 1)).toEqual(["live"]); + const frames = await page.frames("alpha"); + expect(frames.filter((frame) => typeof frame === "string" && frame.startsWith("failed"))).toEqual([]); + expect(frames.slice(frames.indexOf("v1:2"))).toEqual(["v1:2", "v2:2", "v2:3"]); + // Documents and tables unchanged: the count went on from 2, the table kept the old code's rows. + expect(await page.text("alpha", ".count")).toBe("3"); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2)); + // The page itself was not touched. + expect(await page.value("alpha", ".draft")).toBe("half-typed"); + expect(await page.loads()).toBe(1); + } finally { + await app.app.close(context); + } + }, 120_000); + }); + + // The audit's F1: every `self.get` of a type on another host opens a new hold, which starts `loading`, so the new + // attempt of a pure object over a worker object parks instead of swapping in place. + // Expected to fail until the open design point is settled: "a parked swap keeps the old instance serving" (under review). + it.fails( + "keeps the view of a pure server object over a worker object bound when only the server object reloads", + async () => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker" }), + summary: summaryPlugin("s1"), + alpha: webPlugin({ + name: "alpha", + code: "1", + counter: { placement: "server", contract: "v1" }, + summary: true, + }), + }); + try { + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".label", "s1(0)"); + const settled = (await page.states("alpha")).length; + const result = await editAndApply(app, "summary", summaryPlugin("s2")); + expect(rolloutFailures(result)).toEqual([]); + await page.waitText("alpha", ".label", "s2(0)"); + expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("s2")); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + expect(rolloutFates(result)).toEqual(["server/server: reload.summary(p1) swapped"]); + } finally { + await app.app.close(context); + } + }, + 120_000, + ); +}); diff --git a/packages/chord/test/node/reload/restart.test.ts b/packages/chord/test/node/reload/restart.test.ts new file mode 100644 index 000000000..280e70865 --- /dev/null +++ b/packages/chord/test/node/reload/restart.test.ts @@ -0,0 +1,126 @@ +// Reload suite, row "code holding natives (restart)" (design §21 "What a user sees during a reload", as corrected +// 2026-11-04): while the old code drains its running calls (up to `closeTimeout`) the page stays `live` with its last +// values and whatever the draining old code commits; calls sent meanwhile wait at the gate; then `loading` briefly, +// then a fresh snapshot from the new code, which answers the waiting calls; documents and tables unchanged, natives +// reopened. Through a real page, for a counter holding a native on a project's worker and on the server. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { + editAndApply, + latches, + ok, + ReloadPage, + rolloutFailures, + rolloutFates, + startReloadApp, + TIMEOUTS, +} from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, type Placement, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +describe.skipIf(CHROMIUM === undefined)("a reload restarting an object with natives", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + describe.each(["worker", "server"])("on the %s", (placement) => { + it("stays live while the old code drains, then shows loading and a fresh snapshot from the new code", async () => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement, native: true }), + alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }), + }); + const hand = await latches(app, placement); + try { + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + const settled = (await page.states("alpha")).length; + const before = await hand.natives(); + // A running call that commits once the test lets it go on. + const running = await page.call("alpha", "slowAdd", "running"); + await hand.entered("running"); + + const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, native: true })); + expect(rolloutFailures(result)).toEqual([]); + const step = placement === "worker" ? "project/project|p1" : "server/server"; + expect(rolloutFates(result)).toEqual([ + `${step}: reload.store swapped`, + `${step}: reload.counter(p1) restarted`, + ]); + + // The drain: still `live` with the last values; a call sent now waits at the gate. + const arriving = await page.call("alpha", "add", 1); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await page.peek(arriving)).toEqual({ status: "pending" }); + expect(await page.peek(running)).toEqual({ status: "pending" }); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + expect(await page.text("alpha", ".state")).toBe("live"); + expect(await page.text("alpha", ".label")).toBe("v1:2"); + + // The running call commits through the store (swapped: its new code answers) and finishes on the old + // counter; the page sees that commit through the old code before `loading`. + await hand.open("running"); + expect(await page.result(running)).toEqual(ok("v2:12")); + expect(await page.result(arriving)).toEqual(ok("v2:13")); + await page.waitText("alpha", ".label", "v2:13"); + await page.waitText("alpha", ".state", "live"); + expect((await page.states("alpha")).slice(settled)).toEqual(["loading", "live"]); + const frames = await page.frames("alpha"); + const since = frames.slice(frames.lastIndexOf("v1:2")); + expect(since.slice(0, 2)).toEqual(["v1:2", "v1:12"]); + expect(since.at(-1)).toBe("v2:13"); + expect(since.filter((frame) => typeof frame === "string" && frame.startsWith("failed"))).toEqual([]); + // The native closed for the reload and reopened on the new code; documents and tables unchanged. + const after = await hand.natives(); + expect(after.opens.slice(before.opens.length)).toEqual(["v2"]); + expect(after.closes.slice(before.closes.length)).toEqual(["v1:reload"]); + expect(await page.text("alpha", ".count")).toBe("13"); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(3)); + } finally { + // A failed expectation leaves the call waiting: open it so the close does not wait for the drain. + await hand.open("running"); + await app.app.close(context); + } + }, 120_000); + }); + + it.each(["worker", "server"])( + "cuts a running call off after closeTimeout on the %s, then runs the waiting call on the new code", + async (placement) => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement, native: true }), + alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }), + }); + try { + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".state", "live"); + const hand = await latches(app, placement); + const stuck = await page.call("alpha", "slow", "stuck"); + await hand.entered("stuck"); + const started = Date.now(); + await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, native: true })); + const arriving = await page.call("alpha", "code"); + const cut = await page.result(stuck); + expect(Date.now() - started).toBeGreaterThanOrEqual(TIMEOUTS.closeTimeout - 100); + expect(cut).toMatchObject({ status: "error", code: "released" }); + expect(await page.result(arriving)).toEqual(ok("v2")); + await page.waitText("alpha", ".state", "live"); + } finally { + await app.app.close(context); + } + }, + 120_000, + ); +}); diff --git a/packages/chord/test/node/reload/rollout.test.ts b/packages/chord/test/node/reload/rollout.test.ts new file mode 100644 index 000000000..523ea1b88 --- /dev/null +++ b/packages/chord/test/node/reload/rollout.test.ts @@ -0,0 +1,185 @@ +// Reload suite, the rollout across hosts (design §21 "What a user sees during a reload": "the server, then every +// running worker by the same rules, then the clients' scripts") and the cases the table leaves out: a worker not +// running during the rollout, a page connecting while a restart drains, two rollouts back to back, a rollout while the +// page's link is down. Through real pages. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { + edit, + editAndApply, + latches, + ok, + ReloadPage, + rolloutFailures, + rolloutFates, + startReloadApp, +} from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, summaryPlugin, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +/** The counter as the web plugins show it. */ +const COUNTER = { placement: "worker", contract: "v1" } as const; + +describe.skipIf(CHROMIUM === undefined)("a rollout across hosts", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + /** The app with the counter on the workers and `alpha` showing p1's, signed in, its counter at 2. */ + async function started(native = false) { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker", native }), + alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }), + }); + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + return app; + } + + it("reloads the server, then the running workers, then the clients; a worker not running starts on the new code", async () => { + const app = await started(); + try { + const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker" })); + expect(rolloutFailures(result)).toEqual([]); + expect(result.steps.map(({ name, hosts }) => [name, hosts.map(({ host }) => host)])).toEqual([ + ["server", ["server"]], + ["project", ["project|p1"]], + ["web", ["web"]], + ]); + expect(rolloutFates(result)).toEqual([ + "project/project|p1: reload.store swapped", + "project/project|p1: reload.counter(p1) swapped", + ]); + // Project p2 had no worker during the rollout: its first hold starts one on the current set. + await page.hold("p2", "alpha", 10_000, "p2"); + await page.waitState("p2", "live"); + expect(await page.result(await page.call("p2", "code"))).toEqual(ok("v2")); + expect(await page.result(await page.call("p2", "add", 1))).toEqual(ok("v2:1")); + expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v2")); + } finally { + await app.app.close(context); + } + }, 120_000); + + it("binds a page that connects while a restart drains to the new code once it is live", async () => { + const app = await started(true); + const hand = await latches(app, "worker"); + const second = await Browser.start(); + try { + const running = await page.call("alpha", "slowAdd", "running"); + await hand.entered("running"); + await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker", native: true })); + // A second page signs in while the old code drains. + const late = new ReloadPage(second); + await late.signInAgain(app, ["alpha"]); + const call = await late.call("alpha", "code"); + await hand.open("running"); + expect(await page.result(running)).toEqual(ok("v2:12")); + expect(await late.result(call)).toEqual(ok("v2")); + await late.waitText("alpha", ".label", "v2:12"); + await late.waitText("alpha", ".state", "live"); + await page.waitText("alpha", ".label", "v2:12"); + } finally { + await hand.open("running"); + await second.close(); + await app.app.close(context); + } + }, 120_000); + + it("ends two rollouts back to back on the second's code, the page following without failing", async () => { + const app = await started(); + try { + const settled = (await page.states("alpha")).length; + await edit(app, "counter", counterPlugin({ code: "v2", placement: "worker" })); + const first = app.app.applyChanges(context); + await edit(app, "counter", counterPlugin({ code: "v3", placement: "worker" })); + const second = app.app.applyChanges(context); + expect(rolloutFailures(await first)).toEqual([]); + expect(rolloutFailures(await second)).toEqual([]); + await page.waitText("alpha", ".label", "v3:2"); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v3:3")); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + expect(app.app.plugins.state.value.dirty).toEqual([]); + } finally { + await app.app.close(context); + } + }, 120_000); + + it("brings a page whose link was down during the rollout onto the new scripts and the new code", async () => { + const app = await started(); + try { + await page.type("alpha", ".kept", "kept"); + await browser.evaluate("globalThis.page.holdReconnect(); globalThis.page.dropLink()"); + await browser.wait('globalThis.page.connection !== "connected"'); + await page.waitState("alpha", "loading"); + // The last values stay on screen while the link is down. + expect(await page.text("alpha", ".label")).toBe("v1:2"); + + await edit(app, "counter", counterPlugin({ code: "v2", placement: "worker" })); + await edit(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER })); + const result = await app.app.applyChanges(context); + expect(rolloutFailures(result)).toEqual([]); + await browser.evaluate("globalThis.page.release()"); + + await browser.wait('globalThis.page.connection === "connected"'); + await page.waitText("alpha", ".code", "alpha:2"); + await page.waitText("alpha", ".label", "v2:2"); + await page.waitText("alpha", ".state", "live"); + expect(await page.value("alpha", ".kept")).toBe("kept"); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3")); + expect(await page.loads()).toBe(1); + } finally { + await app.app.close(context); + } + }, 120_000); + + // A case the table leaves out (the audit's "a contract change between hosts, consumer first"): the counter's + // contract moves to v2 without `accepts`, and the server's summary, which holds it, changes with it (it copies the + // contract file). The server step runs first: the summary's new request meets the old worker serving v1. + // Expected to fail until the open design point is settled: "a parked swap keeps the old instance serving" (under review). + it.fails( + "keeps the view of a server consumer bound while its worker provider moves to a new version with it", + async () => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker" }), + summary: summaryPlugin("s1"), + alpha: webPlugin({ + name: "alpha", + code: "1", + counter: { placement: "server", contract: "v1" }, + summary: true, + }), + }); + try { + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".label", "s1(0)"); + const settled = (await page.states("alpha")).length; + const result = await editAndApply( + app, + "counter", + counterPlugin({ code: "v2", placement: "worker", contract: "v2" }), + ); + expect(rolloutFailures(result)).toEqual([]); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("s1")); + expect((await page.states("alpha")).slice(settled)).toEqual([]); + } finally { + await app.app.close(context); + } + }, + 120_000, + ); +}); diff --git a/packages/chord/test/node/reload/web.test.ts b/packages/chord/test/node/reload/web.test.ts new file mode 100644 index 000000000..a66281722 --- /dev/null +++ b/packages/chord/test/node/reload/web.test.ts @@ -0,0 +1,131 @@ +// Reload suite, row "a web or terminal plugin" (design §21 "What a user sees during a reload"): the other plugins +// untouched, scroll kept; the reloaded plugin's components remount, their state (`useState`) starts over while page +// state (`usePageState`) survives; the reloaded plugin's calls as above. Through a real page with two web plugins and +// the base bundle's own input. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { editAndApply, latches, ok, ReloadPage, rolloutFailures, startReloadApp } from "../fixtures/reload/app.ts"; +import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts"; +import { Browser, CHROMIUM } from "../web/browser.ts"; + +/** The counter as the web plugins show it. */ +const COUNTER = { placement: "worker", contract: "v1" } as const; + +describe.skipIf(CHROMIUM === undefined)("a reload of a web plugin", () => { + let browser: Browser; + let page: ReloadPage; + + beforeAll(async () => { + browser = await Browser.start(); + page = new ReloadPage(browser); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + it("remounts only the reloaded plugin's components, keeping page state, the other plugins and the scroll", async () => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker" }), + alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }), + beta: webPlugin({ name: "beta", code: "1" }), + }); + try { + await page.signIn(app, ["alpha", "beta"]); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2")); + await page.waitText("alpha", ".label", "v1:2"); + await page.type("alpha", ".draft", "alpha draft"); + await page.type("alpha", ".kept", "alpha kept"); + await page.type("beta", ".draft", "beta draft"); + await page.type("beta", ".kept", "beta kept"); + await browser.evaluate('document.getElementById("base-draft").focus()'); + await browser.type("base draft"); + // Marks on the DOM nodes that must survive, and a scroll position. + await browser.evaluate( + 'document.querySelector("[data-view=beta] .draft").marked = true; document.querySelector("[data-view=alpha] .draft").marked = true; window.scrollTo(0, 1000)', + ); + expect(await browser.evaluate("window.scrollY")).toBe(1000); + + const result = await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER })); + expect(rolloutFailures(result)).toEqual([]); + expect(result.steps.map(({ name }) => name)).toEqual(["server", "project", "web"]); + + await page.waitText("alpha", ".code", "alpha:2"); + // The reloaded plugin: a new component (its state starts over), its page state kept, its counter shown again. + expect(await page.value("alpha", ".draft")).toBe(""); + expect(await browser.evaluate('document.querySelector("[data-view=alpha] .draft").marked ?? false')).toBe( + false, + ); + expect(await page.value("alpha", ".kept")).toBe("alpha kept"); + await page.waitText("alpha", ".label", "v1:2"); + await page.waitText("alpha", ".state", "live"); + expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3")); + // Everything else untouched: the other plugin's component (the same DOM node), the base's input, the scroll. + expect(await page.text("beta", ".code")).toBe("beta:1"); + expect(await page.value("beta", ".draft")).toBe("beta draft"); + expect(await browser.evaluate('document.querySelector("[data-view=beta] .draft").marked ?? false')).toBe(true); + expect(await page.value("beta", ".kept")).toBe("beta kept"); + expect(await browser.evaluate('document.getElementById("base-draft").value')).toBe("base draft"); + expect(await browser.evaluate("window.scrollY")).toBe(1000); + expect(await page.loads()).toBe(1); + } finally { + await app.app.close(context); + } + }, 120_000); + + // Open design point (2026-11-04, with the owner): `useHold` releases the old component's reference when a plugin + // reload remounts it, and the release aborts its calls in flight on the worker; yet "a component unmounted by a + // reload has not left" (§21). Asserted as it is today. + it("ends a call the reloaded plugin's old component made `released`, nothing committed (open: useHold's release on a reload's unmount aborts it)", async () => { + const app = await startReloadApp({ + latch: LATCH_PLUGIN, + counter: counterPlugin({ code: "v1", placement: "worker" }), + alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }), + }); + const hand = await latches(app, "worker"); + try { + await page.signIn(app, ["alpha"]); + await page.waitText("alpha", ".state", "live"); + const running = await page.call("alpha", "slowAdd", "running"); + await hand.entered("running"); + await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER })); + await page.waitText("alpha", ".code", "alpha:2"); + await hand.open("running"); + expect(await page.result(running)).toMatchObject({ status: "error", code: "released" }); + // Aborted on the worker: nothing committed. + await page.waitText("alpha", ".state", "live"); + expect(await page.text("alpha", ".label")).toBe("v1:0"); + expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(0)); + } finally { + await hand.open("running"); + await app.app.close(context); + } + }, 120_000); + + // A case the table leaves out (the audit's "a plugin whose contributions change shape"): a web plugin's new code + // contributes no view, then contributes it again; the other plugin's component stays mounted throughout. + it("removes and restores a view whose plugin's contributions change, the other plugins untouched", async () => { + const app = await startReloadApp({ + alpha: webPlugin({ name: "alpha", code: "1" }), + beta: webPlugin({ name: "beta", code: "1" }), + }); + try { + await page.signIn(app, ["alpha", "beta"]); + await page.type("beta", ".draft", "beta draft"); + await page.type("alpha", ".kept", "alpha kept"); + await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", hidden: true })); + await browser.wait('document.querySelector("[data-view=alpha]") === null'); + expect(await page.value("beta", ".draft")).toBe("beta draft"); + await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "3" })); + await page.waitText("alpha", ".code", "alpha:3"); + expect(await page.value("alpha", ".kept")).toBe("alpha kept"); + expect(await page.value("beta", ".draft")).toBe("beta draft"); + expect(await page.loads()).toBe(1); + } finally { + await app.app.close(context); + } + }, 120_000); +}); diff --git a/packages/chord/test/remote/hold.test.ts b/packages/chord/test/remote/hold.test.ts index 57dec16a4..129a11538 100644 --- a/packages/chord/test/remote/hold.test.ts +++ b/packages/chord/test/remote/hold.test.ts @@ -1905,7 +1905,7 @@ describe('calls in flight across a `loading` unbind (decision "Calls in flight s await server.close(context); }); - it("a failed unbind still rejects: a live failure answers its calls with the failure, later ones not_ready", async () => { + it("a failed unbind still rejects: a live failure answers its calls with the failure, and calls crossing it too", async () => { const gate = Promise.withResolvers(); let self: Self | undefined; const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); @@ -1933,13 +1933,68 @@ describe('calls in flight across a `loading` unbind (decision "Calls in flight s const rejected = await peer.next("error"); expect(rejected).toMatchObject({ call: 0, error: { error: { message: "it broke" } } }); await peer.next("failed"); - // Told a failure, the hold takes no calls by the old names. + // Told a failure, the hold takes no calls by the old names: one that crossed the `failed` gets that cause. call(peer, 1, "ping"); - expect(await peer.next("error")).toMatchObject({ call: 1, error: { error: { code: "not_ready" } } }); + expect(await peer.next("error")).toMatchObject({ call: 1, error: { error: { message: "it broke" } } }); gate.resolve(); await server.close(context); }); + it("a call by the old variant's index crossing a fresh bound of another variant is decoded by the old names (audit F4)", async () => { + const ran: string[] = []; + const { server, peer, reload } = await keptServer({ n: 1, ran }, { ack: false }); + peer.ack(); + await reload({ n: 2, variant: "b", ran }); + const bound = await peer.next("bound"); + expect(bound.holds[0]!.variant).toBe(1); + // Sent before the consumer applied that `bound`: by variant a's indexes (`bump` is 0, which is `ping` in b). + call(peer, 0, "bump"); + call(peer, 1, "ping"); + await transported(); + expect(answersTo(peer, 0)).toMatchObject([ + { kind: "error", error: { error: { code: "invalid", reason: "member" } } }, + ]); + expect(answersTo(peer, 1).map((answer) => answer.kind === "result" && partValue(answer.value!))).toEqual([2]); + expect(ran).toEqual(["ping"]); + // Applied and acknowledged: now by variant b's indexes. + peer.ack(); + peer.send({ kind: "call", call: 2, hold: 0, method: wireNames(Kept, "b").methods.ping! }); + expect(partValue((await peer.next("result")).value!)).toBe(2); + expect(ran).toEqual(["ping", "ping"]); + expect(peer.closed).toBe(false); + await server.close(context); + }); + + it("a call crossing a removal's failed gets the removal's cause, never not_ready (audit F3)", async () => { + const { server, peer } = await keptServer({ n: 1 }); + await server.reload([], context); + const failed = await peer.next("failed"); + expect(failed.cause).toMatchObject({ kind: "waiting", error: { error: { code: "unavailable" } } }); + // Sent as a consumer sends it before the `failed` reaches it. + call(peer, 0, "ping"); + const answer = await peer.next("error"); + expect(answer.error).toEqual(failed.cause.error); + await server.close(context); + }); + + it("a call crossing a version refusal's failed gets contract_mismatch, never not_ready (audit F3)", async () => { + const { server, peer, reload } = await keptServer({ n: 1 }); + // The new code serves version 2 only: the version 1 hold is refused. + const answer = async () => 2; + const v2 = defineFacet((h) => + h.serve(KeptV2, () => ({ + variant: "a", + members: { label: constant({ text: "v2" }), bump: answer, ping: answer }, + })), + ); + await reload(v2); + const failed = await peer.next("failed"); + expect(failed.cause).toMatchObject({ kind: "waiting", error: { error: { code: "contract_mismatch" } } }); + call(peer, 0, "ping"); + expect(await peer.next("error")).toMatchObject({ call: 0, error: { error: { code: "contract_mismatch" } } }); + await server.close(context); + }); + describe("through a relay", () => { /** `Kept` placed on a session's worker. */ const PlacedKept = remote("app.placed-kept", { diff --git a/packages/chord/test/surfaces/client.test.ts b/packages/chord/test/surfaces/client.test.ts index fc5801654..c43e40242 100644 --- a/packages/chord/test/surfaces/client.test.ts +++ b/packages/chord/test/surfaces/client.test.ts @@ -48,8 +48,19 @@ function setOf(base: string, scripts: readonly RegisteredScript[]): ScriptSet { }; } -/** A server serving `WebScripts` with `initial`, and a client started against it with `scripts` available to fetch. */ -async function setup(initial: readonly RegisteredScript[], available: readonly RegisteredScript[] = []) { +/** A timer of the client's environment the test fires itself: its pause and its callback. */ +type ManualTimer = { readonly ms: number; readonly fire: () => void }; + +/** + * A server serving `WebScripts` with `initial`, and a client started against it with `scripts` available to fetch; + * `failing` fetches fail first (a network error), `manual` collects the environment's timers instead of running them. + */ +async function setup( + initial: readonly RegisteredScript[], + available: readonly RegisteredScript[] = [], + more: { readonly failing?: number; readonly manual?: ManualTimer[] } = {}, +) { + let failing = more.failing ?? 0; const server = createHost({ address: hostKind("server")(), principal: { kind: "server" }, @@ -81,6 +92,10 @@ async function setup(initial: readonly RegisteredScript[], available: readonly R }, fetchScript: async (entry) => { fetched.push(entry.url); + if (failing > 0) { + failing--; + throw new Error(`${entry.url}: network error`); + } const found = available.find(({ plugin, digest }) => plugin === entry.name && digest === entry.digest); if (found === undefined) throw new Error(`no ${entry.url}`); return found; @@ -90,6 +105,14 @@ async function setup(initial: readonly RegisteredScript[], available: readonly R environment: { now: () => Date.now(), after: (ms, callback) => { + if (more.manual !== undefined) { + const timer = { ms, fire: callback }; + more.manual.push(timer); + return () => { + const at = more.manual?.indexOf(timer) ?? -1; + if (at >= 0) more.manual?.splice(at, 1); + }; + } const timer = setTimeout(callback, ms); return () => clearTimeout(timer); }, @@ -157,6 +180,49 @@ describe("applying a new set in place", () => { }); }); +describe("a failed update (audit F7)", () => { + it("tries a set whose script did not load again after 1 s, then 2 s, until it applies", async () => { + const a1 = script("@test/a", "d1", "hello"); + const a2 = script("@test/a", "d2", "hello again"); + const timers: ManualTimer[] = []; + const { client, server, errors, fetched } = await setup([a1], [a2], { failing: 2, manual: timers }); + const greeting = await client.host.get(Greeting, {}, context); + const retries = () => timers.filter(({ ms }) => ms === 1000 || ms === 2000); + await publishTo(server, "web", setOf("B1", [a2]), context); + await until(() => retries().length === 1); + expect(errors.map(({ kind }) => kind)).toEqual(["update_failed"]); + expect(retries()[0]!.ms).toBe(1000); + retries()[0]!.fire(); + await until(() => retries().some(({ ms }) => ms === 2000)); + expect(errors.map(({ kind }) => kind)).toEqual(["update_failed", "update_failed"]); + retries() + .find(({ ms }) => ms === 2000)! + .fire(); + await until(() => greeting.text.value === "hello again"); + expect(fetched).toEqual(["/@test/a.d2.js", "/@test/a.d2.js", "/@test/a.d2.js"]); + await client.stop(context); + await server.close(context); + }); + + it("drops the retry of a set a newer one replaced, and applies the newer one", async () => { + const a1 = script("@test/a", "d1", "hello"); + const a2 = script("@test/a", "d2", "hello again"); + const a3 = script("@test/a", "d3", "hello at last"); + const timers: ManualTimer[] = []; + const { client, server, fetched } = await setup([a1], [a2, a3], { failing: 1, manual: timers }); + const greeting = await client.host.get(Greeting, {}, context); + await publishTo(server, "web", setOf("B1", [a2]), context); + await until(() => timers.some(({ ms }) => ms === 1000)); + await publishTo(server, "web", setOf("B1", [a3]), context); + await until(() => greeting.text.value === "hello at last"); + // The retry was cancelled with the newer set: nothing is left to fire. + expect(timers.some(({ ms }) => ms === 1000)).toBe(false); + expect(fetched).toEqual(["/@test/a.d2.js", "/@test/a.d3.js"]); + await client.stop(context); + await server.close(context); + }); +}); + describe("outdated clients", () => { it("calls outdated for a set with another base", async () => { const { client, server, outdated } = await setup([]);