feat(chord): mistakes that wait are reported at once

- get, hold and registry on a host that has not loaded throw not_ready at once.
- A registry with contributions and no provider is reported (registry_undeclared) at publication.
- A request waiting unbound is reported once after 5 s (unbound_long; one clock per host), and keeps waiting.
- An invalid plugin name makes load and reload throw before any change.
- A transaction cancelled by the end of its context is reported (transaction_cancelled) besides rejecting.
- Docs: who a reference acts as, no per-link call limit, await what you start in a root or a handler.
This commit is contained in:
Mario Zechner 2026-11-03 07:19:51 +00:00
parent 51bb8f5c06
commit 5bd014b1c7
32 changed files with 893 additions and 239 deletions

View file

@ -14,10 +14,13 @@
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`@earendil-works/chord/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`@earendil-works/chord/web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`@earendil-works/chord/react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`@earendil-works/chord/tui/launcher`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`@earendil-works/chord/tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook.
- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client).
- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
- Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting.
### Changed
- Chord v2's HTTP component denies by default (slice 13): a request or plugin socket without a principal gets 401 before plugin code runs unless its route or socket is marked `public: true` (`{ handler, public: true }`; a `crossSite` route is public), and the `/chord` links endpoint refuses requests without a principal.
- Chord v2's `host.get`, `host.hold`, `host.registry` and a root's `ctx.get` on a host whose `load` was not called throw `not_ready` at once ("call host.load first") instead of waiting for a publication; a client with nothing to load calls `load([])`. Holds arriving on the host's links before `load` still wait for its publication. The HTTP component answers a request before `load` 503 at once.
- Chord v2's `host.load` and `host.reload` throw for a plugin name that is no npm package name, before they change anything (it was listed in the report's `failed`).
- Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's `exports`, pinned in `dependencies`; slice 10's imports by plugin name (`@app/notes/contract`) are removed, and a relative path leaving the root is a build error.
## [1.5.0] - 2026-11-02

View file

@ -147,9 +147,12 @@ export const Catalog = remote("catalog", { hostAddress: () => Server(), … });
A `hostAddress` or `authorize` that throws fails the request broken with that error; an address whose params fail
the kind's check is broken `invalid/host_address`.
- **The publication gate** (slice 7a): a request made on this host, or a hold arriving on its links, before the
host's first publication (`load`'s batch of declarations) is not placed yet: it is `loading`, and the publication
places it. The gate lasts only for the synchronous setups, never for activation, so it cannot deadlock.
- **The publication gate** (slice 7a): a hold arriving on its links before the host's first publication (`load`'s
batch of declarations) is not placed yet: it is `loading`, and the publication places it (a worker connects first,
W6). The gate lasts only for the synchronous setups, never for activation, so it cannot deadlock. **This host's own
requests before `load` is called** (`get`, `hold`, `registry`, a root's `ctx.get`) would wait for a publication
nobody started: they throw `not_ready` at once, "call `host.load` first"; a client host with nothing to load calls
`load([])` (decision "Mistakes that wait are reported at once" (a), 2026-11-03; footgun 1 of the p2 port).
- **Then the selected provider is checked** (here: served, the version and fingerprint, the params, the provider's
own host-address check; over a path: at the provider host). Version checks never look at a same-name registration
elsewhere. What the check finds:
@ -170,6 +173,17 @@ export const Catalog = remote("catalog", { hostAddress: () => Server(), … });
the names it serves, slice 7b) wakes its requests, which run their check again
(a refused hold sends `loading`, then `bound` or `failed`); no new message kind. Index entries go when their request
ends (GC-tested). The consumer never re-holds a request by itself: no backoff, no polling.
- **A request waiting unbound is reported** (decision "Mistakes that wait are reported at once" (c), 2026-11-03): one
indexed with a cause after the publication (`not_served`, `version`: a reference made here, a hold refused here for a
name) and a registry value waited for while it has none (a parked `self.registry`, a `host.registry`: `not_served`,
`version`, `compose`; one wait per value, not per waiter) is reported once through `onError` after 5 s as
`unbound_long { waitingFor, error, since }` (`waitingFor` the type's name or `registry <name>`, `error` the cause,
`since` `Date.now()` time); it keeps waiting until its `readyTimeout` as before. One clock per host, one timer armed for
the earliest wait not reported yet (no timer per request); a wait that binds, ends or is woken leaves it, and one
woken and left unbound again starts anew. Reported on the host that decided it: a consumer whose hold a provider
host refused sees `failed { waiting }`, the provider host reports. Not `parked_long`, which reports an attempt
(an object) parked past its `readyTimeout` whatever its blocker (loading included); a `self.get` waiting unbound
gets both, `unbound_long` for the request after 5 s and `parked_long` for its object later.
### The low-level host API
@ -234,17 +248,17 @@ host). The workers component (§23) does this.
| Call | Meaning |
| --- | --- |
| `createHost<P>({ address?, principal?, ...options })` | with an address: addressable, serves only objects placed there, acts as `principal` on its own host; without: a client. `P` is the app's principal type |
| `load([{ name, facet }], context)` | facets with their plugin names (the plugin's `package.json` name, npm style; duplicates and invalid names are refused); runs every facet's setup, publishes all of them in one batch (types and registries served, registries composed once, contributions; requests waiting for the publication are placed after it), then waits for the start barrier (section 21: the first outcomes of its host-lifetime objects, slice 7a); a plugin whose setup throws is left out and reported (`load` resolves with `{ failed: [{ plugin, error }] }`, decision "Per plugin at startup and reload"); runs once. Loaders are adapters; base code passes its core facet the same way |
| `reload([{ name, facet }], context)` | the complete new plugin set after `load` (a facet passed as the same object is an unchanged plugin): staging, then one commit, then every object of a changed or removed plugin takes its fate; resolves with a report (a plugin whose new code fails keeps its old code, listed in `failed`); one at a time (section 21, slice 7b) |
| `load([{ name, facet }], context)` | facets with their plugin names (the plugin's `package.json` name, npm style: an invalid name is a programming error, `load` throws before it changes anything; a duplicate is refused and listed in `failed`); runs every facet's setup, publishes all of them in one batch (types and registries served, registries composed once, contributions; requests waiting for the publication are placed after it; a registry with contributions and no provider is reported `registry_undeclared`), then waits for the start barrier (section 21: the first outcomes of its host-lifetime objects, slice 7a); a plugin whose setup throws is left out and reported (`load` resolves with `{ failed: [{ plugin, error }] }`, decision "Per plugin at startup and reload"); runs once. Loaders are adapters; base code passes its core facet the same way |
| `reload([{ name, facet }], context)` | the complete new plugin set after `load` (a facet passed as the same object is an unchanged plugin): staging, then one commit, then every object of a changed or removed plugin takes its fate; resolves with a report (a plugin whose new code fails keeps its old code, listed in `failed`); an invalid plugin name throws before anything changes; one at a time (section 21, slice 7b) |
| `connect(link, { principal, trusted?, codec? }, context)` | the link to the parent, at most one open; once it closed (a drop), `connect` takes the next, and every remote reference over the parent is held again on it. `principal` = who the parent is |
| `accept(link, { principal, trusted?, codec? }, context)` | a link from a child; holds and calls on it act as `principal` |
| `route(Kind, { authorize, resolve }, context)` | how to reach hosts of a kind (below); called before `load`, once per kind; work arriving on its links runs under `context`; returns a `RouteHandle`: `retry(address)` resolves the address again (D10b), `suspend(address)` stops resolving it until `retry` (R1) |
| `get(Type, params, context, { readyTimeout? })` | a reference once its object is live, waiting through `loading`, `failed { transient }` and `failed { waiting }` up to its `readyTimeout` (section 7) |
| `hold(Type, params, context, { readyTimeout? })` | a reference at once that follows its object until released (section 7) |
| `get(Type, params, context, { readyTimeout? })` | a reference once its object is live, waiting through `loading`, `failed { transient }` and `failed { waiting }` up to its `readyTimeout` (section 7); before `load` it rejects `not_ready` at once |
| `hold(Type, params, context, { readyTimeout? })` | a reference at once that follows its object until released (section 7); before `load` it throws `not_ready` |
| `close(context)` | closes the links (children, routes, then the parent), then ends every object at once and tears them down, the last to go live first (reason `close`); rejects with an `AggregateError` of the native closes that threw |
| `revoke(r, context)` | revocation (section 4; slice 4b) |
| `root(fn, context, { principal? })` | a call scope for code outside objects (a Durable hook, a CLI, an HTTP request): `fn` gets a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes (slice 8 D3, section 8); an omitted `principal` acts as the host, an explicit `undefined` runs anonymous (slice 10) |
| `registry(R, context, { readyTimeout? })` | registry `R`'s composed value for code outside objects once it is usable; waits for the publication, then while it has none up to its `readyTimeout`, then rejects with the last error (slice 8, 7a, section 17) |
| `registry(R, context, { readyTimeout? })` | registry `R`'s composed value for code outside objects once it is usable; while it has none it waits up to its `readyTimeout`, then rejects with the last error (slice 8, 7a, section 17); before `load` it rejects `not_ready` at once |
**Options of `createHost`:** `address`, `principal`, `principalKey` (section 4; the same function on every host),
`telemetry` (section 29), `onError`, `closeTimeout`, `readyTimeout`, `heartbeat: { ping?, close? }`; later `revokedBy`
@ -253,7 +267,7 @@ and closed by the app with `store.close(ctx)` after `host.close`; the host never
background, as a tagged `HostError`: `listener_error` (a watch listener threw), `delivery_failed` (a frame delivery
threw), `activation_failed { object, failure }`, `close_failed { object, native, error }`, `close_timeout { object,
native }`, `drain_timeout { object }`, `protocol_error { error }`, `resolve_failed { address, error }`; later routine
errors (slice 5), `derive_failed { object, member?, error }` (a derive's compute threw, once per failure episode, slice 6a), the registries' `registry_conflict { registry, message, plugins }`, `registry_failed { registry, error }`, `contribution_failed { registry, plugin, type?, error }` and `contribution_refused { registry, plugin, type?, version }` (each once per episode, slice 8), `object_failed { object, failure }` (a live object failing, slice 6a, `broken`; `transient` when a `self.follow` source failed, slice 6b R4; `failure` where the value is a `FailureCause`, `error` otherwise, 2026-10-27), `job_interrupted { object, job, id, restarts }` (a persisted run ended at the restart limit, slice 6b D6), `parked_long { object, waitingFor, state, since }` (an attempt parked longer than its `readyTimeout`, whatever its blocker, once per parking episode: a diagnostic of a ring across hosts started at the same moment or a long wait, slice 7a, audit A11, §6) and `storage_failed { error }`
errors (slice 5), `derive_failed { object, member?, error }` (a derive's compute threw, once per failure episode, slice 6a), the registries' `registry_conflict { registry, message, plugins }`, `registry_failed { registry, error }`, `contribution_failed { registry, plugin, type?, error }` and `contribution_refused { registry, plugin, type?, version }` (each once per episode, slice 8), `object_failed { object, failure }` (a live object failing, slice 6a, `broken`; `transient` when a `self.follow` source failed, slice 6b R4; `failure` where the value is a `FailureCause`, `error` otherwise, 2026-10-27), `job_interrupted { object, job, id, restarts }` (a persisted run ended at the restart limit, slice 6b D6), `parked_long { object, waitingFor, state, since }` (an attempt parked longer than its `readyTimeout`, whatever its blocker, once per parking episode: a diagnostic of a ring across hosts started at the same moment or a long wait, slice 7a, audit A11, §6), the reports of mistakes that would otherwise look like slowness (decision "Mistakes that wait are reported at once", 2026-11-03): `registry_undeclared { registry, plugins }` (at a publication, or when a host-lifetime object's contributions enter, a registry with contributions and no provider here, naming its contributing plugins; again only once they change), `unbound_long { waitingFor, error, since }` (a request waiting unbound after the publication, below) and `transaction_cancelled { object, error }` (§8), and `storage_failed { error }`
(slice 6a), which every app must handle: Chord never ends the process,
the app exits with its own code for its supervisor or reopens.
@ -375,8 +389,10 @@ host.route(Session, {
`abort` (when sent, at the caller; when received, at the provider), its hold's `release`, `gone`, its origin's
`closed` or the link closing. A call still queued when its hold rebinds (`loading`) is rejected `unavailable` (it may
name a method of the old variant). A provider seeing more than N running calls of one origin closes the link
(protocol error); a call naming a hold it does not know is ignored before it is counted (Q1). A per-link N, if ever
needed, must bound a relay's forwarding too.
(protocol error); a call naming a hold it does not know is ignored before it is counted (Q1). **There is no limit on
calls in progress per link** (owner, 2026-11-03; v1's `maxCalls` has no counterpart): the call window N per origin
and the part limits (section 22) are the bounds; 200 concurrent calls over one socket all run, N at a time in
flight. A per-link N, if ever needed, must bound a relay's forwarding too.
- Holds are not limited by count (authorization and params checks protect them); size limits are a separate check
(section 22). Byte-heavy traffic (terminals) uses separate channels (section 23), so a large state message never
holds up keystrokes.
@ -477,7 +493,11 @@ drops, folds or resets.
| a relayed hold | the arriving hold's principal | the arriving hold's incarnation |
So a guest's call on the server whose handler `ctx.get`s a worker type reaches the worker as the guest and gets the
guest's variant; two references with different principals are two remote objects.
guest's variant; two references with different principals are two remote objects. A server object that acts as
itself towards a worker takes the reference with `self.get` in its own object or in a server root (`host.root`,
acting as the host), never with the method's `ctx.get` (owner, 2026-11-03; p2's footgun 7): the owner calls
`Search.rebuildIndex`, whose `ctx.get(IndexScan, { session })` reaches the worker as the owner, and a worker
factory checking `principal.kind === "server"` refuses it.
- **Upward is a boundary:** a worker's hold of a server type goes over its parent link, and the server ignores
principal fields from a child, so it acts as the worker, whoever the worker's handler serves. A worker that acts for
a user towards the server passes the user as data and the server object decides.
@ -794,9 +814,8 @@ location are dropped.
cause; only `failed { broken }` rejects at once (owner, 2026-10-29, decision "Getters wait through `waiting`"). A
getter that gives up releases its reference. **`host.hold`** returns a reference at once that follows its object
until released (loading, live, failed and retried as its object is, loading again while its link is down) and never
rejects; only invalid params throw (checked at once, also before `load`: design audit 7, 2026-10-27). A type not
served where it goes is `loading` before the host's publication and `failed { waiting }` after it, before and after
`load` alike (§3; slice 7a). Its `variant` is `undefined` until its first binding (slice 3b). A UI holds and shows
rejects; only invalid params throw (checked at once: design audit 7, 2026-10-27), and a use before `load` (`not_ready`,
§3). A type not served where it goes is `failed { waiting }` (§3; slice 7a), reported `unbound_long` after 5 s. Its `variant` is `undefined` until its first binding (slice 3b). A UI holds and shows
`ref.state`; code that needs the object now gets it, with the patience it passes. There is no `state: false` option
(C8) and no `optional` flag.
- **Narrowing by variant:** `ref.variant` is the discriminant (C15: no `inVariant`/`expectVariant`); `RefIn<C, V>`
@ -895,6 +914,17 @@ span (slice 9, cut C11 to decide); in a job's `run` also `jobId` and `state` (sl
on. Code outside objects gets a `ctx` from `host.root` (slice 8). Durable's `Context` is the same module
(`../context`); bridging is explicit. No code versions, no request ids.
**Await what you start in a root or a handler** (decision "Mistakes that wait are reported at once" (e), owner,
2026-11-03; footgun 10 of the p2 port). What is started with a context ends with it: a root's scope seals when `fn`
settles, a handler's context aborts when its caller cancels or its object ends, an attempt's at its seal. A document
change or transaction (`handle.change`, `retire`, `self.tx`, `job.start`) that did not commit because its context
ended first (it waited on its object's line, or `fn` returned before it committed) rejects with that context's abort
reason, and is also reported through `onError` as `transaction_cancelled { object, error }` (`error` the abort
reason, naming the scope that ended): an empty `catch` cannot hide a change that never happened.
`host.root(async (ctx) => { void handle.change(fn, ctx).catch(() => {}); }, context)` loses the change and reports it;
`await handle.change(fn, ctx)` inside `fn` commits it. Only the cancelled path pays (the check runs where the
transaction failed); Chord's own transactions run under contexts that never abort.
**Contexts are required** (owner, 2026-10-22): every function that takes a context requires it, as the parameter after
the required ones (context last); optional only on error-reporting paths. A getter that cannot take one (a lazy derive
read) uses its owner's.
@ -1660,8 +1690,9 @@ const router = self.registry(Http); // a declared dependency; read rou
never bring their own `compose`). A reload of the defining plugin replaces the declaration atomically (`serve`
replaces; a removed defining plugin's provider goes, its readers wait `not_served`) and the table
recomposes. Without a declaration (the defining plugin is not loaded) the registry has no composed value: readers
wait `failed { waiting }` (`unavailable/undeclared`), like a request for a type nobody serves; contributions wait in
the table.
wait `failed { waiting }` (`unavailable/undeclared`), like a request for a type nobody serves, reported
`unbound_long` after 5 s (§3); contributions wait in the table, and the registry is reported `registry_undeclared
{ registry, plugins }` at the publication (decision "Mistakes that wait are reported at once" (b)).
- **Versions (Q8, the owner-adapter pattern):** a contribution carries the registry version its plugin was built
against; older ones are adapted with the defining facet's pure `accepts` when the table is built. A contribution
without a matching version or adapter (older without adapter, or newer) is left out of the table and reported (an
@ -1823,7 +1854,8 @@ changed (Chord compares no code); one passed as the very same facet object is un
report once the commit happened and every object took its fate (swaps and retries had their first dependency check;
it does not wait for objects to go live); a plugin whose new code fails keeps its old code and is listed in the
report's `failed` (per plugin, below).
Reloads run one after another per host; before `load` resolved it throws, after `close` it rejects `released/closed`;
Reloads run one after another per host; before `load` resolved it throws, and so does a plugin set with an invalid
name (before anything changes); after `close` it rejects `released/closed`;
`context` bounds it (decision 4: no install watchdog): its abort before the commit rejects `aborted` and changes
nothing, after the commit it only ends the wait: it rejects `aborted` with `details.committed`, a `CommittedReload`
`{ added, changed, removed, failed }` naming what the commit applied (the plugins component adopts it, so a later
@ -2170,7 +2202,7 @@ await http.close(ctx); // before host.close: drai
(`compose` builds the router) and `Authenticators`, and with `listen` the listener: a host-lifetime object whose
native is Chord's Node server, opened right after `load`'s publication, so a reload never closes it (decisions "The
HTTP server is a native object", "Settings are arguments, extensions are registries"). `fetch` resolves the
registries once; requests before the publication wait (503 past `readyTimeout`).
registries once; requests before the host's `load` answer 503 at once (`host.registry` throws `not_ready` then).
- **Contributions to `Http`:** `{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }`, a value
or a writable (a change recomposes the router). A request uses the router as it was when it started.
- **The security baseline** (decision "The HTTP component's security baseline", on for every request and upgrade

View file

@ -130,6 +130,13 @@ The **facet** says this host serves
The factory only declares: it is synchronous and opens nothing. Chord activates the object (loads its documents, opens
what it asked to open) and then serves calls. That is why the same factory can run again when the code changes.
**`load` comes first.** A `get`, `hold` or `registry` on a host whose `load` was not called throws `not_ready` at once
("call `host.load` first"): it would wait for types nobody published. A client with nothing to load calls
`load([])`. Mistakes that would otherwise look like slowness are reported through `onError` instead: a request
waiting for a type or registry nobody serves (`unbound_long`, after 5 s; it keeps waiting until its `readyTimeout`),
a registry with contributions and no provider (`registry_undeclared`, when `load` publishes), and a plugin name that is
no npm name makes `load` throw.
# An Object's Parts {#parts lede="Documents, tables, transactions, computed values, watching, and outside resources: the vocabulary of every factory."}
A factory builds an object from a handful of parts, each declared with one call on `self`. The notes object grows to
@ -207,6 +214,20 @@ self.onLive(async (ctx) => { // a routine start
Natives are why some objects restart when their plugin changes: an open connection cannot be moved to new code, so
Chord closes it and opens it again. Objects without natives are swapped in place.
**Await what you start in a root or a handler.** What is started with a context ends with it. Code outside objects
gets its `ctx` from `host.root(fn, ctx)`, and that scope seals when `fn` returns; a handler's context ends when its
caller cancels or its object ends. A change still waiting when its context ends is cancelled before it commits:
```ts
await server.root(async (ctx) => {
void notes.add({ text: "lost" }, ctx).catch(() => {}); // fn returns, the scope seals: the call's change is cancelled
}, ctx);
await server.root(async (ctx) => notes.add({ text: "kept" }, ctx), ctx); // awaited: it commits
```
The cancelled change rejects, and since an empty `catch` would hide that it never happened, Chord also reports it
through `onError` as `transaction_cancelled`, naming the object and, in its error, the scope that ended.
# Several Hosts {#hosts lede="A server, a worker per project, browser tabs: how they find each other's objects."}
Now give each project its own process. Declare the kinds of addressable host, and make notes a `remote` type that
@ -265,6 +286,46 @@ tab.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server
`P` is your app's type of **principal**, who is calling: an owner, a guest, the server, a worker. Links stamp it;
a `remote` object is made per principal, so a guest can get a read-only variant of the same notes.
## Who a reference acts as
A reference acts as whoever made it. `host.get` and `self.get` in a `local` object act as the host; `self.get` in a
`remote` object acts as that object's principal; **`ctx.get` in a method acts as the method's caller**. That is what
lets a guest's call reach a worker as the guest and get the guest's variant, never more than the guest may see.
It surprises one case: a server object that should act as itself towards a worker. The owner calls
`Search.rebuildIndex`, which asks each project's worker to scan its files:
```ts
// server.ts: wrong, the worker sees the owner
rebuildIndex: async ({ project }, ctx) => {
const scan = await ctx.get(IndexScan, { project }); // acts as ctx.principal: the owner, the method's caller
return scan.run(ctx);
},
// worker.ts: the factory serves only the server
host.serve(IndexScan, (params, principal, self) => {
if (principal.kind !== "server") throw new Error("IndexScan serves the server only"); // refuses the owner
…
});
```
The worker's factory gets `{ kind: "owner" }` and refuses. To act as the server, take the reference where the server
is the maker: `self.get(IndexScan, { project })` in a `local` server object of its own (a dependency of that object,
which acts as the host), or, in base code that has the host, a root of the server's host:
```ts
// base code: a root acts as the host (the server), not as the method's caller
rebuildIndex: async ({ project }, ctx) =>
server.root(async (own) => (await own.get(IndexScan, { project })).run(own), ctx),
```
## Calls in progress
There is no limit on calls in progress per link. A tab sending 200 calls at once over its socket gets all 200 run:
at most N (256 by default, announced in the handshake) are in flight per origin, the rest wait in order in the link's
call queue, none is refused for being too many. The bounds are that call window and the part limits on what arrives
(sizes, not counts). An object that must limit work per caller does so in its methods, or runs the work as a job.
:::note | The app kit does this wiring
Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components
(`createWorkers`, `chord.http`). An app built with the app kit (`chord/app`, the last chapter) does not wire them:

View file

@ -114,8 +114,9 @@ table{max-width:100%}
<p class="subtitle">Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs</p>
<div class="cover-stats"><div class="stat"><b>3</b><span>kinds of host in an app: server, worker, client</span></div><div class="stat"><b>1</b><span>file per host kind in a plugin</span></div><div class="stat"><b>0</b><span>restarts to apply a plugin edit</span></div><div class="stat"><b>1</b><span>rule: plugins talk only through object types and registries</span></div></div>
<div class="cover-note"><p>A tutorial for people building apps on Chord v2, written by the redesign session from <code>design.md</code>, <code>slice-7c.md</code>,
<code>slice-10.md</code>, <code>slice-12.md</code>, <code>slice-13.md</code>, the decision log and the code in <code>packages/chord/src</code>. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last
chapter shows the app kit (<code>chord/app</code>), which does that wiring for an app.</p>
<code>slice-10.md</code>, <code>slice-12.md</code>, <code>slice-13.md</code>, the decision log and the code in <code>packages/chord/src</code>. API names
are the current ones. The early chapters wire hosts by hand to show the parts; the last chapter shows the app kit
(<code>chord/app</code>), which does that wiring for an app.</p>
</div>
</section><div class="inner">
<nav class="toc"><h2>Contents</h2><ol>
@ -217,6 +218,12 @@ The <strong>facet</strong> says this host serves
<code>load</code> publishes the facet. <code>get</code> returns a <strong>reference</strong> once the object is live; its members are the contract&#39;s.</p>
<p>The factory only declares: it is synchronous and opens nothing. Chord activates the object (loads its documents, opens
what it asked to open) and then serves calls. That is why the same factory can run again when the code changes.</p>
<p><strong><code>load</code> comes first.</strong> A <code>get</code>, <code>hold</code> or <code>registry</code> on a host whose <code>load</code> was not called throws <code>not_ready</code> at once
(&quot;call <code>host.load</code> first&quot;): it would wait for types nobody published. A client with nothing to load calls
<code>load([])</code>. Mistakes that would otherwise look like slowness are reported through <code>onError</code> instead: a request
waiting for a type or registry nobody serves (<code>unbound_long</code>, after 5 s; it keeps waiting until its <code>readyTimeout</code>),
a registry with contributions and no provider (<code>registry_undeclared</code>, when <code>load</code> publishes), and a plugin name that is
no npm name makes <code>load</code> throw.</p>
</section>
<section class="chapter" id="parts"><header class="opener"><div class="numeral">II</div><h2>An Object&#39;s Parts</h2><p class="lede">Documents, tables, transactions, computed values, watching, and outside resources: the vocabulary of every factory.</p></header>
@ -271,6 +278,15 @@ self.<span class="tk-f">onLive</span>(<span class="tk-k">async</span> (ctx) =&gt
});</code></pre>
<p>Natives are why some objects restart when their plugin changes: an open connection cannot be moved to new code, so
Chord closes it and opens it again. Objects without natives are swapped in place.</p>
<p><strong>Await what you start in a root or a handler.</strong> What is started with a context ends with it. Code outside objects
gets its <code>ctx</code> from <code>host.root(fn, ctx)</code>, and that scope seals when <code>fn</code> returns; a handler&#39;s context ends when its
caller cancels or its object ends. A change still waiting when its context ends is cancelled before it commits:</p>
<pre><code><span class="tk-k">await</span> server.<span class="tk-f">root</span>(<span class="tk-k">async</span> (ctx) =&gt; {
<span class="tk-k">void</span> notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"lost"</span> }, ctx).<span class="tk-k">catch</span>(() =&gt; {}); <span class="tk-c">// fn returns, the scope seals: the call's change is cancelled</span>
}, ctx);
<span class="tk-k">await</span> server.<span class="tk-f">root</span>(<span class="tk-k">async</span> (ctx) =&gt; notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"kept"</span> }, ctx), ctx); <span class="tk-c">// awaited: it commits</span></code></pre>
<p>The cancelled change rejects, and since an empty <code>catch</code> would hide that it never happened, Chord also reports it
through <code>onError</code> as <code>transaction_cancelled</code>, naming the object and, in its error, the scope that ended.</p>
</section>
<section class="chapter" id="hosts"><header class="opener"><div class="numeral">III</div><h2>Several Hosts</h2><p class="lede">A server, a worker per project, browser tabs: how they find each other&#39;s objects.</p></header>
@ -306,6 +322,34 @@ worker.<span class="tk-f">connect</span>(parentLink, { principal: { kind: <span
tab.<span class="tk-f">connect</span>(<span class="tk-f">webSocketLink</span>(<span class="tk-k">new</span> <span class="tk-t">WebSocket</span>(<span class="tk-s">"/chord"</span>)), { principal: { kind: <span class="tk-s">"server"</span> } }, ctx);</code></pre>
<p><code>P</code> is your app&#39;s type of <strong>principal</strong>, who is calling: an owner, a guest, the server, a worker. Links stamp it;
a <code>remote</code> object is made per principal, so a guest can get a read-only variant of the same notes.</p>
<h3 id="who-a-reference-acts-as">Who a reference acts as</h3>
<p>A reference acts as whoever made it. <code>host.get</code> and <code>self.get</code> in a <code>local</code> object act as the host; <code>self.get</code> in a
<code>remote</code> object acts as that object&#39;s principal; <strong><code>ctx.get</code> in a method acts as the method&#39;s caller</strong>. That is what
lets a guest&#39;s call reach a worker as the guest and get the guest&#39;s variant, never more than the guest may see.</p>
<p>It surprises one case: a server object that should act as itself towards a worker. The owner calls
<code>Search.rebuildIndex</code>, which asks each project&#39;s worker to scan its files:</p>
<pre><code><span class="tk-c">// server.ts: wrong, the worker sees the owner</span>
rebuildIndex: <span class="tk-k">async</span> ({ project }, ctx) =&gt; {
<span class="tk-k">const</span> scan = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">IndexScan</span>, { project }); <span class="tk-c">// acts as ctx.principal: the owner, the method's caller</span>
<span class="tk-k">return</span> scan.<span class="tk-f">run</span>(ctx);
},
<span class="tk-c">// worker.ts: the factory serves only the server</span>
host.<span class="tk-f">serve</span>(<span class="tk-t">IndexScan</span>, (params, principal, self) =&gt; {
<span class="tk-k">if</span> (principal.kind !== <span class="tk-s">"server"</span>) <span class="tk-k">throw</span> <span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">"IndexScan serves the server only"</span>); <span class="tk-c">// refuses the owner</span>
…
});</code></pre>
<p>The worker&#39;s factory gets <code>{ kind: &quot;owner&quot; }</code> and refuses. To act as the server, take the reference where the server
is the maker: <code>self.get(IndexScan, { project })</code> in a <code>local</code> server object of its own (a dependency of that object,
which acts as the host), or, in base code that has the host, a root of the server&#39;s host:</p>
<pre><code><span class="tk-c">// base code: a root acts as the host (the server), not as the method's caller</span>
rebuildIndex: <span class="tk-k">async</span> ({ project }, ctx) =&gt;
server.<span class="tk-f">root</span>(<span class="tk-k">async</span> (own) =&gt; (<span class="tk-k">await</span> own.<span class="tk-f">get</span>(<span class="tk-t">IndexScan</span>, { project })).<span class="tk-f">run</span>(own), ctx),</code></pre>
<h3 id="calls-in-progress">Calls in progress</h3>
<p>There is no limit on calls in progress per link. A tab sending 200 calls at once over its socket gets all 200 run:
at most N (256 by default, announced in the handshake) are in flight per origin, the rest wait in order in the link&#39;s
call queue, none is refused for being too many. The bounds are that call window and the part limits on what arrives
(sizes, not counts). An object that must limit work per caller does so in its methods, or runs the work as a job.</p>
<div class="note"><b class="tag">The app kit does this wiring</b><p>Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10&#39;s components
(<code>createWorkers</code>, <code>chord.http</code>). An app built with the app kit (<code>chord/app</code>, the last chapter) does not wire them:
<code>startServer(...)</code> on the server and <code>startWorker(...)</code> in a worker&#39;s entry do.</p>

View file

@ -195,10 +195,6 @@ deleted (the slice's spec and code are the record). Moved here from `carry-over.
### After slice 11
- **Mistakes that wait are reported at once** (decision of 2026-11-03): (a) use before `load` throws, (b) an unserved
registry with contributions reported at publication, (c) a request waiting unbound reported after 5 s, (d) an
invalid plugin name throws; tests for each; the guide documents no per-link call limit and `ctx.get` acting as the
caller (the `Search.rebuildIndex` example).
- **Messages and languages in the app kit** (decision "Messages and languages in the app kit", proposal
`docs/i18n/`): `defineMessages`, `t.message`/`t.render`, `MessagesOf`, the `Translations` registry with
fallbacks and conflicts, the locale preference per principal (default from the device, live switch, `lang`/`dir` on

View file

@ -16,7 +16,22 @@ import { Residency } from "./residency.ts";
import { type AnyDefinition, SingletonSlot } from "./slot.ts";
import { Transaction } from "./transaction.ts";
/** Who owns an object's documents: the object's name, batch and context, its store address, its live failure. */
/**
* A transaction (a document change or retirement, `self.tx`, a job start) that did not commit because the context it
* was started with ended first: a root whose `fn` settled, a handler or routine whose object ended, a caller that
* cancelled. `error` is that context's abort reason, naming the scope that ended (decision "Mistakes that wait are
* reported at once" (e)).
*/
export type TransactionCancelled = {
readonly kind: "transaction_cancelled";
readonly object: string;
readonly error: unknown;
};
/**
* Who owns an object's documents: the object's name, batch and context, its store address, its live failure, and where
* a cancelled transaction is reported.
*/
export type DocumentsOwner = {
/** The owner's name in errors. */
readonly label: string;
@ -27,6 +42,8 @@ export type DocumentsOwner = {
readonly store: { readonly store: ChordStore; readonly owner: string } | undefined;
/** A document the code cannot read fails the live object `broken` (slice 6a §1.4); ignored when it is not live. */
fail(error: ChordError): void;
/** Reports a transaction its context's end cancelled (the host's `onError`). */
report(error: TransactionCancelled): void;
};
/**
@ -145,8 +162,9 @@ export class Documents {
/**
* Runs `fn` as one transaction on the line under `token` and commits what it drafted (§9): a throw, an abort of
* `context`, the token's seal or the object's end discards everything. `persistence` defers storing a run state's
* change (D8).
* `context`, the token's seal or the object's end discards everything; one that did not commit while `context` was
* aborted is also reported (`transaction_cancelled`): an empty `catch` cannot hide a change that never happened.
* `persistence` defers storing a run state's change (D8).
*/
async tx<R>(
fn: (tx: Transaction) => R | Promise<R>,
@ -168,6 +186,10 @@ export class Documents {
return result;
} catch (error) {
tx.discard();
// Only a transaction that did not commit reads this; Chord's own run under contexts that never abort.
const signal = context.abortSignal;
if (signal?.aborted)
this.#owner.report({ kind: "transaction_cancelled", object: this.label, error: signal.reason });
throw error;
} finally {
if (this.#holder === tx) this.#holder = undefined;

View file

@ -1,7 +1,7 @@
// Documents for Chord's layers: an object's documents and slots, the handles' classes, the seal token, transactions.
export { AddressSource } from "./address-source.ts";
export { Documents, type DocumentsOwner } from "./documents.ts";
export { Documents, type DocumentsOwner, type TransactionCancelled } from "./documents.ts";
export { SealToken } from "./fence.ts";
export { type KeyDeclarer, Keyed, Singleton } from "./handles.ts";
export { type KeyedSlot, KeySource } from "./keyed-slot.ts";

View file

@ -202,7 +202,15 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
`job_dropped` (`object`, `job`, `id`: a blocked run dropped without `finish`, by `abort` or its object's end),
`parked_long` (`object`, `waitingFor`: the first blocker's label in declaration order, a reference's or `registry
<name>`, `state`: `loading` or `failed`, `since`: when it parked; once per parking episode, its pinned keys' loading
included, slice 7a, audit A11), and
included, slice 7a, audit A11), `unbound_long` (`waitingFor`: a type's name or `registry <name>`, `error`: the
cause, `since`: a request waiting unbound after the publication, once after 5 s: a reference made here or a hold
refused here for a name, in the unbound index, and a registry value waited for while it has none, one wait per
value; one timer per host, `unbound-clock.ts`), `transaction_cancelled` (`object`, `error`: the abort reason of the
context a document change or transaction was started with, which ended before it committed: a root whose `fn`
returned, a caller that cancelled, its object's end; besides the rejection), `registry_undeclared` (`registry`,
`plugins`: at a publication, or when a host-lifetime object's contributions enter, a registry with contributions and
no provider here; again only once its contributing plugins change) (decision "Mistakes that wait are reported at
once"), and
the registries' (slice 8, each once per episode) `registry_conflict` (`registry`, `message`, `plugins`: a conflict
`compose` resolved), `registry_failed` (`registry`, `error`: `compose` or an older version's `value` adapter threw),
`contribution_failed` (`registry`, `plugin`, `type?`, `error`: a watchable contribution failed or ended, or its
@ -221,8 +229,8 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
`self.registry(R)` is a declared dependency: without a usable value at its version (no provider:
`unavailable/undeclared`; a first `compose` or the version mapping that threw; a version the provider does not serve:
`contract_mismatch`) the attempt parks `failed { waiting }` naming the registry, and every change of whether the value
is usable wakes it. `host.registry(R, ctx, { readyTimeout? })` waits for the publication, then the same way up to its
deadline, and rejects with the last error. The last good composition is kept only while every contribution it was
is usable wakes it. `host.registry(R, ctx, { readyTimeout? })` waits the same way up to its deadline (before `load`:
`not_ready` at once), and rejects with the last error. The last good composition is kept only while every contribution it was
built from is still in the table (a contributor that fails or ends drops it: `.value` throws until `compose`
succeeds).
- **Wake signals** (`self.retryOn(...sources)`, D2): registry values (constants are accepted and never wake; anything
@ -241,15 +249,17 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
`fn`; `host.close` rejects it at once `released/closed`, as a method's caller when its object ends; after `close`
it rejects `released/closed`. Not counted in `host.activity`.
- `host.load([{ name, facet }], ctx)`: once per host; runs every setup and publishes them in one batch (types and
registries served, registries composed once, contributions); requests made here and holds arriving before that
waited in the unbound index (`loading`) and are placed after the batch (they never wait for the barrier: two hosts
registries served, registries composed once, contributions; then every registry with contributions and no provider
is reported `registry_undeclared`); holds arriving before that waited in the unbound index (`loading`) and are placed
after the batch (they never wait for the barrier: two hosts
whose objects need each other's types start without a deadlock). Then it activates every type with empty params and
resolves when each has its first outcome (decision (a)): live, failed by its own cause, parked with no blocker that
is an object here still without its first outcome (a keyed pin still loading counts as here; a wait on another host
or on a failed dependency ends the first pass), or ended (`close` settles a waiting `load`); later retries never
hold it. Per plugin (decision "Per plugin at startup and reload"): a plugin whose setup throws or is refused at
declaration (an invalid or repeated name, a type or registry an earlier plugin serves, a remote type here without
principal or address, `checkAccepts`) is left out; `load` resolves with `{ failed: [{ plugin, error }] }` and never
declaration (a repeated name, a type or registry an earlier plugin serves, a remote type here without principal or
address, `checkAccepts`) is left out (a name that is no npm name is a programming error: `load` and `reload` throw
before anything changes); `load` resolves with `{ failed: [{ plugin, error }] }` and never
closes the host; its consumers wait `failed { waiting }`.
- `host.reload([{ name, facet }], ctx)` (slice 7b, per plugin since 7c): the complete new plugin set; staging runs the
new code per plugin (unchanged and failed plugins keep their loaded entries, their setups not run again). A plugin
@ -263,11 +273,13 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
changes nothing; after it, it ends only the wait for the report: `aborted` with `details.committed`
(`CommittedReload`: `added`, `changed`, `removed`, `failed` by plugin name), the commit standing.
- `host.hold(Type, params, ctx, { readyTimeout? })`: a reference at once (`Held<C>`: its `variant` is `undefined` until
its first binding), which follows its object until released and never rejects: `loading` before the publication,
its first binding), which follows its object until released and never rejects: `loading`
while the object activates, retries or is parked on dependencies under way, while there is no parent link or it is
down; `failed` while the object is failed and no attempt runs, or while nothing fits it (`waiting`: not served where
it goes, another version; bound when a publication serves it) or its object is parked on a failed dependency;
`live`. Throws at once only for invalid params (`invalid/params`, also before `load`). A `hostAddress` or
`live`. Throws at once only for invalid params (`invalid/params`) and before `load` (`not_ready`: "call host.load
first"; a client with nothing to load calls `load([])`; the same for `get`, `host.registry` and a root's `ctx.get`).
Holds arriving on its links before `load` wait for the publication (`loading`). A `hostAddress` or
`authorize` that throws or refuses, or a host address whose params fail its kind's check (`invalid/host_address`),
fails the reference `broken`. A local type never crosses a link. Where a reference made here (`host.get`/`hold`,
`self.get`, `ctx.get`) or a hold arriving on a link goes, by address alone (`Destination`; the address is the
@ -425,7 +437,7 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
Calls made after the reference went live never overtake queued ones (a call a handler makes while they are sent
queues behind them). A parked call is checked against its deadline when it is about to be sent, and against the
variant it is sent on; it also leaves the queue when its caller cancels (rejected with the reason), the reference
fails (the cause's error) or is released (`released`). A `host.hold` released before the publication makes nothing.
fails (the cause's error) or is released (`released`).
- References are the only indirection (decision of 2026-10-29): a consumer's watches and derives over `ref.member`
attach through the reference's member (`reference-source.ts`), which passes them straight to the object instance's
own document or derive. When the reference's target changes (a new instance after a reload, retry or restart; a new
@ -568,6 +580,7 @@ Inside the directory: `ChordHost` (types, objects by identity, facets, the batch
instances wait for, its links and the heartbeat timer, its routes and the parent path, the publication and the
unbound index; `reference` makes a request's binding, `bind` binds it to an object here or a remote object over a
path or leaves it waiting, `acquire` gets one for a caller), `UnboundIndex` (requests that cannot bind yet, by name),
`UnboundClock` (the `unbound_long` report: the waits not reported yet in start order, one timer for the earliest),
`checkDependencies` and `ParkedLong` (what a parked attempt's dependencies say; the `parked_long` report),
`Destination` (where a reference or an arriving hold goes, by address: here, a route's path or the parent's; whether
a hold served here is placed here), `Route` (one per routed kind: per address its `Path`, at most one running

View file

@ -24,6 +24,7 @@ import {
invalid,
principalKey,
} from "../data/internal.ts";
import type { TransactionCancelled } from "../documents/internal.ts";
import type { JsonValue } from "../json.ts";
import { binaryCodec, type Codec, type Compression, type Link, type PartLimits } from "../link/index.ts";
import { linkLimits } from "../link/internal.ts";
@ -58,6 +59,7 @@ import { type RootOptions, RootScopes } from "./root.ts";
import { Route, type RouteHandle, type RouteOptions } from "./route.ts";
import {
addStaged,
checkPluginNames,
emptyStaged,
type PluginFacet,
type Recorded,
@ -70,6 +72,7 @@ import {
stagePlugin,
} from "./staging.ts";
import { UnboundIndex, type UnboundRequest } from "./unbound.ts";
import { UnboundClock, type UnboundLong } from "./unbound-clock.ts";
import { uuidv7 } from "./uuid.ts";
/** What `createHost` takes; `R` is the app's revocation type (JSON). */
@ -121,6 +124,8 @@ export type Position = { readonly kind: "on" | "onLive" | "native"; readonly pos
export type HostError =
| BatchError
| RegistryError
| UnboundLong
| TransactionCancelled
| { readonly kind: "activation_failed"; readonly object: string; readonly failure: FailureCause }
/** A live object failed at runtime (a `migrate` failing on access, a stored version newer than the code). */
| { readonly kind: "object_failed"; readonly object: string; readonly failure: FailureCause }
@ -210,12 +215,13 @@ export type AnyHost = Omit<ChordHost, "load" | "reload" | "accept" | "connect" |
export interface Host<P extends Principal = Principal, R extends JsonValue = JsonValue> {
/**
* Runs every facet's setup and publishes what they declared in one batch (the types and registries served, the
* registries composed once, the contributions); requests that waited for the publication are placed after that
* batch. Then activates every served type with empty params and resolves when each has had its first outcome (the
* start barrier, decision (a)): live, failed by its own cause, parked with no blocker here still without its first
* outcome, or ended (failures go to `onError`). A plugin whose setup throws or is refused at declaration (its name,
* a type or registry another plugin serves, a remote type here without principal or address, `checkAccepts`) is
* left out and reported (`failed`); it never closes the host. Runs once.
* registries composed once, the contributions); holds that waited for the publication are placed after that batch,
* and a registry with contributions and no provider is reported (`registry_undeclared`). Then activates every
* served type with empty params and resolves when each has had its first outcome (the start barrier, decision (a)):
* live, failed by its own cause, parked with no blocker here still without its first outcome, or ended (failures go
* to `onError`). A plugin whose setup throws or is refused at declaration (a name given twice, a type or registry
* another plugin serves, a remote type here without principal or address, `checkAccepts`) is left out and reported
* (`failed`); it never closes the host. A name that is no npm name throws before anything changes. Runs once.
*/
load(facets: readonly PluginFacet<P>[], context: Context): Promise<LoadReport>;
/**
@ -230,23 +236,24 @@ export interface Host<P extends Principal = Principal, R extends JsonValue = Jso
* contributions; a publication waking the requests for the names it serves) and every object of a changed or
* removed plugin takes its fate: swapped in place, restarted after its drain, retried, or ended (its type
* removed). Resolves with the report once every fate started (swaps and retries checked their dependencies; it
* does not wait for objects to go live). Reloads run one after another. Throws before `load` resolved; rejects
* `released/closed` after `close`; `context`'s abort before the commit rejects `aborted` and changes nothing, after
* it only ends the wait (`aborted`, its `details.committed` naming what the commit applied: `CommittedReload`).
* does not wait for objects to go live). Reloads run one after another. Throws before `load` resolved and for a
* name that is no npm name (before anything changes); rejects `released/closed` after `close`; `context`'s abort
* before the commit rejects `aborted` and changes nothing, after it only ends the wait (`aborted`, its
* `details.committed` naming what the commit applied: `CommittedReload`).
*/
reload(facets: readonly PluginFacet<P>[], context: Context, options?: ReloadOptions): Promise<ReloadReport>;
/**
* A reference once its object is live (and hydrated). It acts as the host's principal and goes where its type's host
* address puts it: here, over a route, or over the parent link (`Destination`). It waits while the reference is
* `loading` (before the host's publication too), `failed { transient }` or `failed { waiting }` until its
* `readyTimeout`, then rejects with the last cause and releases the reference; a broken failure rejects at once.
* `loading`, `failed { transient }` or `failed { waiting }` until its `readyTimeout`, then rejects with the last
* cause and releases the reference; a broken failure rejects at once, and so does a get before `load` (`not_ready`).
*/
get<C extends Contract>(Type: C, params: ParamsOf<C>, context: Context, options?: GetOptions): Promise<Reference<C>>;
/**
* A reference at once, following its object until released: loading, live, failed (retried as its object is) and
* loading again while its link is down; it never rejects. Throws at once only for invalid params (also before
* `load`). A type not served where it goes is `loading` before the host's publication and `failed { waiting }` after
* it (bound when a publication serves it); a `hostAddress` or route that refuses fails it broken. Its `variant` is
* loading again while its link is down; it never rejects. Throws at once only for invalid params and before `load`
* (`not_ready`). A type not served where it goes is `failed { waiting }` (bound when a publication serves it;
* reported `unbound_long` after 5 s); a `hostAddress` or route that refuses fails it broken. Its `variant` is
* `undefined` until its first binding.
*/
hold<C extends Contract>(Type: C, params: ParamsOf<C>, context: Context, options?: GetOptions): Held<C>;
@ -293,10 +300,10 @@ export interface Host<P extends Principal = Principal, R extends JsonValue = Jso
*/
readonly activity: Watchable<ActivityCounts>;
/**
* Registry `R`'s composed value as `R`'s version sees it, for code outside objects, once it is usable: waits for the
* host's publication, then while it has no value (`unavailable/undeclared` without a provider, a first `compose`
* that threw, a version not served here: `contract_mismatch`) until its `readyTimeout`, then rejects with the last
* error.
* Registry `R`'s composed value as `R`'s version sees it, for code outside objects, once it is usable: waits while it
* has no value (`unavailable/undeclared` without a provider, a first `compose` that threw, a version not served
* here: `contract_mismatch`) until its `readyTimeout`, then rejects with the last error; before `load` it rejects
* `not_ready` at once.
*/
registry<C, V>(R: Registry<C, V>, context: Context, options?: GetOptions): Promise<RegistryValue<V>>;
/**
@ -453,8 +460,10 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
readonly #objects = new Map<string, ChordObject>();
/** Teardowns still running, by identity: the next instance of an identity waits for its previous ones. */
readonly #endings = new Map<string, Promise<void>>();
/** The requests waiting unbound after the publication (in the index, and registry readers), reported after 5 s. */
readonly #clock = new UnboundClock((error) => this.report(error));
/** The requests that cannot bind yet, by name: before the publication all of them, then those left unbound. */
readonly #unbound = new UnboundIndex();
readonly #unbound = new UnboundIndex(this.#clock);
/** Resolved at the host's first publication (`load`'s batch), or its close. */
readonly #publication = Promise.withResolvers<void>();
#published = false;
@ -506,6 +515,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
this.batch.assertOutside("load");
this.#assertOpen();
if (this.#state !== "new") throw new Error("load runs once per host");
checkPluginNames(facets);
this.#state = "loading";
const staged = emptyStaged();
const loaded = new Map<string, Facet<P>>();
@ -530,6 +540,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
// After the batch: what waited for the publication is placed (the gate lasted only for the setups).
this.#published = true;
this.#publication.resolve();
this.#reportUndeclared();
this.#unbound.wakeAll();
const objects = [...this.#types.values()]
.filter((served) => served.kind === "local" && served.hostLifetime)
@ -574,6 +585,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
this.batch.assertOutside("reload");
if (this.#state === "closed") return Promise.reject(this.#closedError());
if (this.#state !== "loaded") throw new Error("reload runs after load");
checkPluginNames(facets);
const reload = this.#reloading.then(() => this.#reload(facets, options.failed ?? [], context));
// The tail settles to nothing: a report kept as its value would keep a rejected facet's closure (its error).
this.#reloading = reload.then(
@ -765,6 +777,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
}
}
}, context);
this.#reportUndeclared();
for (const name of this.#references.keys()) if (!types.has(name)) this.#references.delete(name);
const names = new Set<string>();
for (const [name, served] of types) if (published.has(served.plugin)) names.add(name);
@ -932,6 +945,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
async registry<C, V>(R: Registry<C, V>, context: Context, options: GetOptions = {}): Promise<RegistryValue<V>> {
this.batch.assertOutside("registry");
this.#assertOpen();
this.#assertLoadCalled(`registry ${R.name}`);
const timeout = options.readyTimeout ?? this.timeouts.ready;
const expired = new AbortController();
const waiting = withLinkedAbortSignal(expired.signal, context);
@ -1000,7 +1014,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
registryRecord(name: string): RegistryRecord {
let record = this.#registries.get(name);
if (record === undefined) {
record = new RegistryRecord(name, this.batch, (error) => this.report(error));
record = new RegistryRecord(name, this.batch, (error) => this.report(error), this.#clock);
this.#registries.set(name, record);
}
return record;
@ -1020,9 +1034,12 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
return this.#published;
}
/** `request` cannot bind yet: indexed under `name` (the type or registry it waits for) until woken or ended. */
index(name: string, request: UnboundRequest): void {
this.#unbound.add(name, request);
/**
* `request` cannot bind yet: indexed under `name` (the type or registry it waits for) until woken or ended, waiting
* for the publication (no `cause`) or for a provider that fits (`cause`).
*/
index(name: string, request: UnboundRequest, cause: FailureCause | undefined): void {
this.#unbound.add(name, request, cause);
}
/**
@ -1267,6 +1284,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
*/
#hold(Type: Contract, params: unknown, caller: Caller, context: Context, options: GetOptions): Binding {
this.#assertOpen();
this.#assertLoadCalled(Type.name);
const checked = checkParams(Type, params, context);
const binding = this.#binding(Type, undefined, caller, options);
const place = (): void => this.#place(binding, Type, checked, context);
@ -1308,7 +1326,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
/** Leaves `binding` unbound in the index under `name`: `cause` (`waiting`), or none before the publication. */
#wait(binding: Binding, name: string, cause: FailureCause | undefined, place: () => void): void {
binding.wait(cause, place, () => this.#unbound.delete(name, binding));
this.#unbound.add(name, binding);
this.#unbound.add(name, binding, cause);
}
/** A new reference's binding made by `caller`; its parked calls wait its `readyTimeout`. */
@ -1399,6 +1417,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
// At the fence too: requests still waiting for a publication or a provider end as the others did, and gets
// waiting for a publication that will not come fail now, not after the slow closes below (fix round 1).
this.#unbound.close(closed);
this.#clock.close();
this.#publication.resolve();
this.backoff.clear();
// Every object is sealed before any teardown releases a reference, so none ends in the background.
@ -1469,6 +1488,26 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
if (this.#state === "closed") throw this.#closedError();
}
/**
* A request of this host's own (`get`, `hold`, `registry`, a root's `ctx.get`) for `name` before its first `load`
* would wait for a publication nobody started: it throws `not_ready` at once (decision "Mistakes that wait are
* reported at once" (a)). Holds arriving on its links before `load` still wait for the publication (a worker
* connects first, W6).
*/
#assertLoadCalled(name: string): void {
if (this.#state !== "new") return;
const fix = "call host.load first (a host with nothing to load calls load([]))";
throw new ChordError({ code: "not_ready", message: `${name}: the host has not loaded; ${fix}` });
}
/**
* At each publication (`load`'s, a reload's commit), after its batch: every registry with contributions and no
* provider here is reported `registry_undeclared`, naming its contributors; again only once they change.
*/
#reportUndeclared(): void {
for (const record of this.#registries.values()) record.reportUndeclared();
}
#closedError(): ChordError {
return new ChordError({ code: "released", reason: "closed", message: "the host is closed" });
}

View file

@ -252,6 +252,7 @@ export class ChordObject implements Target {
fail: (error) => {
if (this.#state.kind === "live") this.failLive(this.#state.attempt, { kind: "broken", error });
},
report: (error) => host.report(error),
});
}
@ -917,6 +918,8 @@ export class ChordObject implements Target {
for (const contribution of leaving) contribution.record.remove(contribution);
for (const contribution of contributions) contribution.record.add(contribution);
}, this.#context);
// Contributions entering a registry nobody serves are reported as at a publication.
for (const contribution of contributions) contribution.record.reportUndeclared();
}
this.#lastFailure = undefined;
this.host.wentLive(this);

View file

@ -68,18 +68,25 @@ export function isPluginName(name: string): boolean {
return PLUGIN_NAME.test(name);
}
/**
* Throws for the first plugin of `facets` whose name is no npm package name: a programming error, so `load` and
* `reload` throw before they change anything (decision "Mistakes that wait are reported at once" (d)).
*/
export function checkPluginNames(facets: readonly { readonly name: string }[]): void {
for (const { name } of facets) if (!isPluginName(name)) throw new Error(`"${name}" is not a valid plugin name`);
}
/** A staged table with nothing in it. */
export function emptyStaged(): Staged {
return { types: new Map(), registries: new Map(), plugins: new Map() };
}
/**
* Runs one plugin's setup and checks what it declared: its name (an npm name), a type or registry it serves twice, a
* remote type on a host without a principal or address, every provider's `accepts` (`checkAccepts`) and every registry
* provider's shape. Throws the first error, naming the plugin.
* Runs one plugin's setup (its name checked by `checkPluginNames` before) and checks what it declared: a type or
* registry it serves twice, a remote type on a host without a principal or address, every provider's `accepts`
* (`checkAccepts`) and every registry provider's shape. Throws the first error, naming the plugin.
*/
export function stagePlugin<P extends Principal>({ name, facet }: PluginFacet<P>, host: StagingHost): StagedPlugin {
if (!isPluginName(name)) throw new Error(`"${name}" is not a valid plugin name`);
// The host's presence lists are typed for its `P`.
const references = host.references as <C extends Contract>(Type: C) => Watchable<readonly Presence<C, P>[]>;
const types = new Map<string, Served>();

View file

@ -0,0 +1,76 @@
// The `unbound_long` report (decision "Mistakes that wait are reported at once" (c)): a request waiting unbound after
// the host's publication (a type or registry not served here, not at its version, a registry whose first `compose`
// threw) is reported once through `onError` after 5 s, naming what it waits for and why; it keeps waiting as before.
// One clock per host, one timer armed for the earliest wait not reported yet: no timer per request.
import type { ChordError } from "../data/index.ts";
/** What the clock reports: a request waiting unbound for `waitingFor` since `since` (`Date.now()` time), and why. */
export type UnboundLong = {
readonly kind: "unbound_long";
/** The type's name, or `registry <name>`. */
readonly waitingFor: string;
readonly error: ChordError;
readonly since: number;
};
/** One wait not reported yet: what it waits for, its latest error, since when. */
type Wait = { readonly waitingFor: string; error: ChordError; readonly since: number };
/** How long a request waits unbound before it is reported (decided: 5 s). */
const REPORTED_AFTER = 5_000;
/** A host's waits not reported yet and the one timer of their sweep. */
export class UnboundClock {
/** By request, in the order they started, so by `since`: the sweep reads from the front and stops at the first due later. */
readonly #waits = new Map<object, Wait>();
readonly #report: (error: UnboundLong) => void;
#timer: ReturnType<typeof setTimeout> | undefined;
constructor(report: (error: UnboundLong) => void) {
this.#report = report;
}
/** `request` waits unbound for `waitingFor` with `error`: reported after 5 s unless it stops before. */
start(request: object, waitingFor: string, error: ChordError): void {
if (this.#waits.has(request)) return;
this.#waits.set(request, { waitingFor, error, since: Date.now() });
this.#timer ??= setTimeout(() => this.#sweep(), REPORTED_AFTER);
}
/** The wait of `request` has a new error (a registry's failure changed): the report names the latest. */
failed(request: object, error: ChordError): void {
const wait = this.#waits.get(request);
if (wait !== undefined) wait.error = error;
}
/** `request` bound, ended or was woken: no report for this wait. */
stop(request: object): void {
if (!this.#waits.delete(request) || this.#waits.size > 0) return;
clearTimeout(this.#timer);
this.#timer = undefined;
}
/** The host closed: nothing is reported any more. */
close(): void {
this.#waits.clear();
clearTimeout(this.#timer);
this.#timer = undefined;
}
/** Reports every wait due, each once, then arms the timer for the next one, if any. */
#sweep(): void {
this.#timer = undefined;
const now = Date.now();
for (const [request, wait] of this.#waits) {
const due = wait.since + REPORTED_AFTER;
if (due > now) {
this.#timer = setTimeout(() => this.#sweep(), due - now);
return;
}
this.#waits.delete(request);
const { waitingFor, error, since } = wait;
this.#report({ kind: "unbound_long", waitingFor, error, since });
}
}
}

View file

@ -1,11 +1,13 @@
// The unbound index (dependency model §1, §4): the requests a host decided cannot bind yet, by the name they wait for.
// Before the host's first publication every request made here, and every hold arriving on its links, waits here
// (`unpublished`); after it, those its check left unbound (`not_served`, `version`): a reference made here, a hold that
// arrived over a link and was refused for a name. A publication wakes the requests of the names it serves (`load`'s
// every one, a reload's those of its names); a request
// leaves when it ends (released, settled, given up, its link or origin closed, revoked) or binds.
// Before the host's first publication every hold arriving on its links, and a request made here while `load` runs the
// setups (before `load` this host's own requests throw), waits here (`unpublished`); after it, those its check left
// unbound (`not_served`, `version`): a reference made here, a hold that arrived over a link and was refused for a name.
// A publication wakes the requests of the names it serves (`load`'s every one, a reload's those of its names); a
// request leaves when it ends (released, settled, given up, its link or origin closed, revoked) or binds. A request
// waiting with a cause (after the publication) is on the host's `UnboundClock` while it is indexed: reported after 5 s.
import type { ChordError, Principal } from "../data/index.ts";
import type { ChordError, FailureCause, Principal } from "../data/index.ts";
import type { UnboundClock } from "./unbound-clock.ts";
/** A request in the index: a reference made here or a hold that arrived over a link. */
export interface UnboundRequest {
@ -20,21 +22,31 @@ export interface UnboundRequest {
/** A host's unbound requests by name. */
export class UnboundIndex {
readonly #byName = new Map<string, Set<UnboundRequest>>();
readonly #clock: UnboundClock;
/** Indexes `request` under `name` (once; again after a wake). */
add(name: string, request: UnboundRequest): void {
constructor(clock: UnboundClock) {
this.#clock = clock;
}
/**
* Indexes `request` under `name` (once; again after a wake), waiting for the publication (no `cause`) or for a
* provider that fits (`cause`, on the clock).
*/
add(name: string, request: UnboundRequest, cause: FailureCause | undefined): void {
let requests = this.#byName.get(name);
if (requests === undefined) {
requests = new Set();
this.#byName.set(name, requests);
}
requests.add(request);
if (cause !== undefined) this.#clock.start(request, name, cause.error);
}
/** `request` ended or binds: it leaves the index. */
delete(name: string, request: UnboundRequest): void {
const requests = this.#byName.get(name);
if (requests === undefined || !requests.delete(request)) return;
this.#clock.stop(request);
if (requests.size === 0) this.#byName.delete(name);
}
@ -47,6 +59,7 @@ export class UnboundIndex {
wakeAll(): void {
const all = [...this.#byName.values()].flatMap((requests) => [...requests]);
this.#byName.clear();
for (const request of all) this.#clock.stop(request);
for (const request of all) request.wake();
}
@ -62,6 +75,7 @@ export class UnboundIndex {
this.#byName.delete(name);
woken.push(...requests);
}
for (const request of woken) this.#clock.stop(request);
for (const request of woken) request.wake();
}
@ -78,6 +92,7 @@ export class UnboundIndex {
close(error: ChordError): void {
const all = [...this.#byName.values()].flatMap((requests) => [...requests]);
this.#byName.clear();
for (const request of all) this.#clock.stop(request);
for (const request of all) request.end(error);
}
}

View file

@ -72,8 +72,8 @@ await http.close(ctx); // before host.close
- **Order:** `http.facet` (plugin `chord.http`) serves `Http` (`compose`: the router), `Authenticators` (every
contribution in order) and, with `listen`, the listener, a host-lifetime object whose native is Chord's Node server:
it opens right after the publication. The facet is the same object at every reload, so a reload never closes it.
`fetch` resolves the registries once; requests before the publication wait (503 past the host's `readyTimeout`; a
`close` meanwhile: 503).
`fetch` resolves the registries once; requests before the host's `load` answer 503 at once (`host.registry` throws
`not_ready` before it; decision "Mistakes that wait are reported at once" (a)).
- **Admission** (every request and upgrade, first): local only when the connection's local address is loopback, `Host`
is a loopback name with the listening port (`127.0.0.1`, `localhost`, `[::1]`; without it on port 80) and no
`Forwarded`/`X-Forwarded-*` header; a forwarded request on loopback only when a contributed relay verifies it

View file

@ -76,7 +76,7 @@ export interface HttpComponent<P extends Principal> {
/**
* Answers a Web `Request` (an app's own server mounts it: `hono.all("/*", (c) => http.fetch(c.req.raw, …))`);
* `connection` is what the server knows of the socket: without `localAddress` and `localPort` no request is local.
* Requests before the publication wait for it (503 past the host's `readyTimeout`).
* Requests before the host's `load` answer 503 at once (its `host.registry` throws `not_ready` then).
*/
fetch(request: Request, connection?: Connection): Promise<Response>;
/** Node's upgrade adapter (`server.on("upgrade", http.upgrade)`): the links endpoint and plugin sockets. */
@ -189,7 +189,7 @@ class Component<P extends Principal> implements HttpComponent<P>, Handler {
/**
* Serves `request`: its answer, and what its consumer calls once the body is written or abandoned. Never throws:
* whatever fails answers (503 before the publication or while closing, 403, 404, 500 reported).
* whatever fails answers (503 before the host's `load` or while closing, 403, 404, 500 reported).
*/
async serve(request: Incoming, connection: Connection): Promise<Served> {
const controller = new AbortController();
@ -340,7 +340,7 @@ class Component<P extends Principal> implements HttpComponent<P>, Handler {
this.#onError({ kind: "hook_failed", hook, ...(plugin === undefined ? {} : { plugin }), error });
}
/** The registries: resolved once, waiting for the publication; a failed try is tried again later. */
/** The registries: resolved once, after the host's `load` (`not_ready` before it); a failed try is tried again later. */
#resolve(): Promise<Registries> {
this.#resolving ??= Promise.all([
this.#host.registry(Http, this.#context),

View file

@ -1,10 +1,11 @@
// A registry on a host (slice 8 §1): its provider (replaced or removed by a reload, slice 7b §3), its contributions in table order (`contribution.ts`), the table
// and its lazy composition (marked when the table changes, composed when read or followed, in the batch's
// deliveries), the reports of conflicts and failures (once per episode), and the values its readers follow by
// version.
// deliveries), the reports of conflicts and failures (once per episode) and of contributions nobody serves, and the
// values its readers follow by version.
import { ChordError } from "../data/index.ts";
import { invalid } from "../data/internal.ts";
import type { UnboundClock } from "../host/unbound-clock.ts";
import { type Batch, handedOut, type NativeStart } from "../state/internal.ts";
import type { RegistryContribution } from "./contribution.ts";
import type { ComposeContext, RegistryConflict, RegistryEntry, RegistryProvider, RegistryTable } from "./registry.ts";
@ -36,7 +37,13 @@ export type RegistryError =
readonly plugin: string;
readonly type?: string;
readonly version: number;
};
}
/**
* At a publication, or when a host-lifetime object's contributions enter, the registry has contributions and no
* plugin here serves it (`unavailable/undeclared` for its readers): its contributing plugins in table order; again
* only once they change.
*/
| { readonly kind: "registry_undeclared"; readonly registry: string; readonly plugins: readonly string[] };
/** The provider as served: the defining plugin's version, `compose` and adapters. */
type Provider = { readonly version: number; readonly provider: RegistryProvider<unknown, unknown> };
@ -78,11 +85,16 @@ export class RegistryRecord {
#conflicts = new Set<string>();
/** The values readers follow, by version; made by the first reader of a version. */
readonly #values = new Map<number, RegistryValueSource>();
/** The host's clock of unbound waits: a value of it waited for while it has none is on it. */
readonly clock: UnboundClock;
/** The contributing plugins `registry_undeclared` named last (joined); `undefined` since a provider served it. */
#undeclared: string | undefined;
constructor(name: string, batch: Batch, report: (error: RegistryError) => void) {
constructor(name: string, batch: Batch, report: (error: RegistryError) => void, clock: UnboundClock) {
this.name = name;
this.#batch = batch;
this.#report = report;
this.clock = clock;
}
/** The newest registry version its contributions were built against; `undefined` without contributions. */
@ -133,6 +145,24 @@ export class RegistryRecord {
this.start();
}
/**
* After a publication's batch, or a batch where an object's contributions entered: with contributions and no
* provider, reports `registry_undeclared` naming the contributing plugins, unless the same ones were reported since
* a provider last served it.
*/
reportUndeclared(): void {
if (this.#provider !== undefined) {
this.#undeclared = undefined;
return;
}
if (this.#contributions.length === 0) return;
const plugins = [...new Set(this.#contributions.map(({ contributor }) => contributor.plugin))];
const key = plugins.join("\u0000");
if (key === this.#undeclared) return;
this.#undeclared = key;
this.#report({ kind: "registry_undeclared", registry: this.name, plugins });
}
/** Adds a live contributor's contribution, inside a batch: the table changes. */
add(contribution: RegistryContribution): void {
const at = this.#contributions.findIndex((other) => compareContributors(contribution, other) < 0);

View file

@ -3,9 +3,10 @@
// `accepts[old].value`, once per composition, kept only while someone reads or follows it; a version the provider
// cannot serve fails `contract_mismatch`. Its readers (attempts that declared it) keep the mapping; its waiters
// (attempts parked on it, a waiting `host.registry`) are woken at every change of whether it is usable, and of its
// failure while it is not (dependency model §9).
// failure while it is not (dependency model §9); waited for while it is not, it is on the host's `UnboundClock`.
import { ChordError, isChordError } from "../data/index.ts";
import { asChordError } from "../data/internal.ts";
import { type Batch, FollowedSource, type NativeStart } from "../state/internal.ts";
import type { RegistryContribution } from "./contribution.ts";
import type { RegistryRecord } from "./record.ts";
@ -89,7 +90,7 @@ export class RegistryValueSource extends FollowedSource<unknown> {
const registered = this.#registered;
if (registered.kind === "closed") return registered.last;
const start = this.#start(registered.record);
if (this.#waiters.size > 0) this.#tell(start);
if (this.#waiters.size > 0) this.#tell(start, registered.record);
return start;
}
@ -125,7 +126,10 @@ export class RegistryValueSource extends FollowedSource<unknown> {
this.#waiters.add(token);
return () => {
this.#waiters.delete(token);
if (this.#waiters.size === 0) this.#told = undefined;
if (this.#waiters.size > 0) return;
this.#told = undefined;
const registered = this.#registered;
if (registered.kind === "open") registered.record.clock.stop(this);
};
}
@ -145,12 +149,15 @@ export class RegistryValueSource extends FollowedSource<unknown> {
/** The host closed: its watches and observers end, its readers and mapping go; it keeps its last start only. */
close(): void {
const registered = this.#registered;
let last: NativeStart<unknown>;
try {
last = this.start();
} catch (error) {
last = { kind: "failed", error, last: undefined };
}
// After the last start, which a waiter put on the clock again.
if (registered.kind === "open") registered.record.clock.stop(this);
this.#registered = { kind: "closed", last };
this.#readers.clear();
const waiters = [...this.#waiters];
@ -167,12 +174,24 @@ export class RegistryValueSource extends FollowedSource<unknown> {
if (this.#readers.size === 0) this.#dropMapping();
}
/** Wakes the waiters when `start` says something else than they were told (by usability, then by the error). */
#tell(start: NativeStart<unknown>): void {
/**
* Wakes the waiters when `start` says something else than they were told (by usability, then by the error); while
* it is not usable, the wait is on its host's clock (`record.clock`).
*/
#tell(start: NativeStart<unknown>, record: RegistryRecord): void {
const told = this.#told;
const usable = start.kind === "value" || start.last !== undefined;
if (usable ? told?.kind === "usable" : told?.kind === "failed" && sameError(told.error, start.error)) return;
this.#told = usable ? USABLE : { kind: "failed", error: (start as { readonly error: unknown }).error };
if (start.kind === "value" || start.last !== undefined) {
if (told?.kind === "usable") return;
this.#told = USABLE;
record.clock.stop(this);
} else {
if (told?.kind === "failed" && sameError(told.error, start.error)) return;
this.#told = { kind: "failed", error: start.error };
const label = `registry ${this.name}`;
const error = asChordError(start.error, label);
if (told?.kind === "failed") record.clock.failed(this, error);
else record.clock.start(this, label, error);
}
// The first start of a wait wakes nobody: the waiter reads it itself.
if (told === undefined) return;
for (const { waiter } of this.#waiters) waiter.dependencyMoved();

View file

@ -182,7 +182,7 @@ export class Hold implements Holder, UnboundRequest {
const { link } = this.#provider;
if (!link.host.published) {
this.#state = { kind: "unplaced", request: owned(request) };
link.host.index(request.type, this);
link.host.index(request.type, this, undefined);
return;
}
let served: Serving;
@ -592,7 +592,7 @@ export class Hold implements Holder, UnboundRequest {
*/
#refuse(request: HoldRequest, cause: FailureCause): void {
this.#state = { kind: "refused", request: owned(request), cause };
if (cause.kind === "waiting") this.#provider.link.host.index(request.type, this);
if (cause.kind === "waiting") this.#provider.link.host.index(request.type, this, cause);
this.#provider.send({
kind: "failed",
hold: this.id,

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
import { describe, expect, it, vi } from "vitest";
import { BACKGROUND_CONTEXT as context, withCancel } from "../../src/context/index.ts";
import { arr, int, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState, type KeyedHandle, type SingletonHandle } from "../../src/documents/index.ts";
import { delivered, framesOf, loaded, rejection } from "../support.ts";
@ -17,10 +17,10 @@ const Owner = local("app.owner", {
members: { list: obj({ items: arr(str()) }), touch: method() },
});
/** Serves `Owner` and hands out its handles. */
/** Serves `Owner` and hands out its handles; `errors` collects the host's background errors. */
async function owner() {
const handles: { list?: SingletonHandle<{ items: string[] }>; drafts?: KeyedHandle<{ body: string }> } = {};
const { host } = await loaded((h) =>
const { host, errors } = await loaded((h) =>
h.serve(Owner, (_, self) => {
handles.list = self.state(List);
handles.drafts = self.state(Drafts);
@ -28,7 +28,7 @@ async function owner() {
}),
);
const reference = await host.get(Owner, { id: 1 }, context);
return { host, reference, list: handles.list!, drafts: handles.drafts! };
return { host, errors, reference, list: handles.list!, drafts: handles.drafts! };
}
/** The keys a keyed handle lists. */
@ -109,3 +109,65 @@ describe("when the owner ends", () => {
expect(await rejection(list.change(() => {}, context))).toEqual(released);
});
});
describe('transactions their context\'s end cancelled (decision "Mistakes that wait are reported at once" (e))', () => {
it("are reported besides rejecting: a change started in a root and not awaited, its fn settled first", async () => {
const { host, errors, list } = await owner();
let rejected: unknown;
await host.root(async (ctx) => {
// Footgun 10: the empty catch would hide a change that never happened.
void list
.change((draft) => draft.items.push("lost"), ctx)
.catch((error) => {
rejected = error;
});
}, context);
await delivered();
expect(list.value).toEqual({ items: [] });
expect(rejected).toMatchObject({ code: "released", reason: "settled" });
expect(errors).toEqual([
{
kind: "transaction_cancelled",
object: "app.owner(1)",
error: expect.objectContaining({ message: "host.root: its fn settled" }),
},
]);
// Awaited inside the root (the rule: await what you start in a root or a handler), it commits.
await host.root((ctx) => list.change((draft) => draft.items.push("kept"), ctx), context);
expect([list.value, errors.length]).toEqual([{ items: ["kept"] }, 1]);
await host.close(context);
});
it("are reported for a caller's cancellation while the change waits on the line", async () => {
const { host, errors, list, drafts } = await owner();
const gate = Promise.withResolvers<void>();
const holding = drafts.change("a", () => gate.promise, context);
const cancel = withCancel(context);
const waiting = rejection(list.change((draft) => draft.items.push("lost"), cancel.context));
cancel.cancel();
gate.resolve();
await holding;
expect(await waiting).toMatchObject({ name: "AbortError" });
expect(errors.map(({ kind }) => kind)).toEqual(["transaction_cancelled"]);
await host.close(context);
});
it("cost nothing on the committed path: no check of the context beyond the commit's own (cost guard)", async () => {
const { host, errors, list } = await owner();
const reads = vi.spyOn(AbortSignal.prototype, "aborted", "get");
const live = withCancel(context);
try {
for (let n = 0; n < 50; n++) await list.change((draft) => draft.items.push(String(n)), live.context);
const committed = reads.mock.calls.length;
await host.root(async (ctx) => {
void list.change(() => {}, ctx).catch(() => {});
}, context);
await delivered();
// The one cancelled change read its context once to be reported; the 50 committed ones never did.
expect([committed, reads.mock.calls.length - committed, errors.length]).toEqual([0, 1, 1]);
} finally {
reads.mockRestore();
}
await host.close(context);
});
});

View file

@ -79,8 +79,8 @@ const host = createHost({
},
});
// 5. A reader before `load` waits: the provider comes with `load`, which composes every registry before any object.
const early = host.registry(Http, context);
// 5. A reader before `load` throws at once ("call host.load first"); `load` composes every registry before any object.
await host.registry(Http, context).catch((error: Error) => console.log("before load:", error.message));
await host.load(
[
{ name: "@example/http", facet: httpPlugin },
@ -90,7 +90,7 @@ await host.load(
],
context,
);
const router = await early;
const router = await host.registry(Http, context);
console.log(
"routes:",
[...router.value].map(([path, { plugin }]) => `${path} (${plugin})`),

View file

@ -2,7 +2,9 @@
// method handler's: the host's principal (or one a trusted caller names), this host's incarnation, the context's
// telemetry and signal, and `ctx.get` for references. When `fn` settles, or the context aborts, or the host closes, the
// scope is sealed: its signal aborts, its references are released, and a late `ctx.get` is refused `released`; so code
// that ignores its signal fails at its next use of Chord. (p3 wraps it as `chord(fn, context)` for Durable's tools.)
// that ignores its signal fails at its next use of Chord. The rule: await what you start in a root or a handler; a
// change left running when the scope seals is cancelled, rejects and is reported (`transaction_cancelled`). (p3 wraps
// it as `chord(fn, context)` for Durable's tools.)
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/30-host-root.ts
import { BACKGROUND_CONTEXT, withCancel } from "../../src/context/index.ts";
@ -41,12 +43,20 @@ const facet = defineFacet<Principal>((host) => {
});
});
const host = createHost<Principal>({ principal: { kind: "server" }, onError: (error) => console.log(error.kind) });
const host = createHost<Principal>({
principal: { kind: "server" },
onError: (error) =>
console.log(
"onError:",
error.kind,
error.kind === "transaction_cancelled" ? `${error.object}: ${error.error}` : "",
),
});
// 1. A scope made before `load` resolved: its `ctx.get` parks until the host is loaded.
// 1. A scope made before `load`: its `ctx.get` throws at once (`not_ready`, "call host.load first").
const early = host.root(async (ctx) => (await ctx.get(Notes, { session: "s1" })).list.value, context);
await early.catch((error: Error) => console.log("before load:", error.message));
await host.load([{ name: "@example/notes", facet }], context);
console.log("before load, read after it:", await early);
// 2. A tool's call: get, call, return plain data. The reference is released when fn settles.
const addNote = (text: string) =>
@ -79,7 +89,18 @@ const stubborn = host.root(async (ctx) => {
cancel(new Error("user pressed stop"));
await stubborn.catch((error: Error) => console.log("cancelled:", error.message));
// 6. Closing the host seals the scopes still open and rejects their callers at once: an fn sleeping on its ctx ends.
// 6. The rule: await what you start in a root or a handler. A call left running when fn returns is cancelled with the
// scope, and the change it was about to make never commits: it rejects, and onError gets `transaction_cancelled`
// naming the object and the scope that ended (an empty `catch` would hide the rejection, not the report).
let unawaited: Promise<unknown> | undefined;
await host.root(async (ctx) => {
const notes = await ctx.get(Notes, { session: "s1" });
unawaited = notes.add({ text: "lost" }, ctx);
}, context);
await unawaited!.catch((error: Error) => console.log("the unawaited call:", error.message));
await new Promise((resolve) => setTimeout(resolve, 0));
// 7. Closing the host seals the scopes still open and rejects their callers at once: an fn sleeping on its ctx ends.
const sleeping = host.root((ctx) => sleep(60_000, ctx), context).catch((error: Error) => `closed: ${error.message}`);
await host.close(context);
console.log(await sleeping);

View file

@ -905,7 +905,7 @@ describe("parked attempts (dependency model §3)", () => {
/** A view with params: no host lifetime, so `load` does not wait for it. */
const Viewer = local("app.viewer", { version: 1, params: { id: int() }, members: { n: int() } });
it("run no timer while parked but parked_long's: no dependency deadline, no backoff, the factory run once", async () => {
it("run no timer while parked but the reports': no dependency deadline, no backoff, the factory run once", async () => {
const factories = vi.fn();
const { host, errors } = await loaded((h) =>
h.serve(View, (_, self) => {
@ -921,11 +921,15 @@ describe("parked attempts (dependency model §3)", () => {
cause: { kind: "waiting", error: { message: "waiting for app.missing: app.missing is not served here" } },
retryAt: undefined,
});
// `parked_long`'s one timer fired (once, after readyTimeout); nothing else runs.
// The unbound reference's report fired after 5 s, `parked_long`'s after readyTimeout, each once; nothing else runs.
expect(vi.getTimerCount()).toBe(0);
const unserved = expect.objectContaining({ message: "app.missing is not served here" });
expect([factories.mock.calls.length, errors]).toEqual([
1,
[{ kind: "parked_long", object: "app.view", waitingFor: "app.missing", state: "failed", since: 0 }],
[
{ kind: "unbound_long", waitingFor: "app.missing", error: unserved, since: 0 },
{ kind: "parked_long", object: "app.view", waitingFor: "app.missing", state: "failed", since: 0 },
],
]);
view.release();
});

View file

@ -175,7 +175,8 @@ describe("parked_long (dependency model §7, audit A11)", () => {
{ kind: "parked_long", object: "app.view(1)", waitingFor: "app.missing", state: "failed", since: 0 },
]);
await vi.advanceTimersByTimeAsync(60_000);
expect(errors).toHaveLength(1);
// Its unbound reference was reported too, after 5 s (`unbound_long`, unbound-clock.test.ts); no second parked_long.
expect(errors.map(({ kind }) => kind)).toEqual(["parked_long", "unbound_long"]);
expect(view.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } });
view.release();
await host.close(context);
@ -313,7 +314,7 @@ describe("parked_long (dependency model §7, audit A11)", () => {
readyTimeout: 1_000,
});
const view = host.hold(View, { id: 1 }, context);
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(4_000);
expect(errors.map(({ kind }) => kind)).toEqual(["parked_long"]);
// A reload of its plugin replaces the parked attempt: a new episode, its clock from its own parking.
const changed = defineFacet((h) => h.serve(View, (_, self) => ({ n: self.get(Missing, {}).n })));
@ -326,7 +327,7 @@ describe("parked_long (dependency model §7, audit A11)", () => {
object: "app.view(1)",
waitingFor: "app.missing",
state: "failed",
since: 5_000,
since: 4_000,
});
view.release();
await host.close(context);

View file

@ -72,7 +72,7 @@ describe("load", () => {
await host.close(context);
});
it("leaves out and reports invalid and duplicate plugin names and double serves (the later); refuses a second load", async () => {
it("leaves out and reports duplicate plugin names and double serves (the later); refuses a second load", async () => {
const empty = defineFacet(() => {});
const info = defineFacet((host) => host.serve(Info, () => ({ title: constant("t") })));
const loaded = async (facets: { readonly name: string; readonly facet: typeof empty }[]) => {
@ -81,9 +81,6 @@ describe("load", () => {
await host.close(context);
return failed.map(({ plugin, error }) => [plugin, (error as Error).message]);
};
expect(await loaded([{ name: "Bad Name", facet: empty }])).toEqual([
["Bad Name", '"Bad Name" is not a valid plugin name'],
]);
const twice = [
{ name: "a", facet: empty },
{ name: "a", facet: empty },
@ -117,14 +114,47 @@ describe("load", () => {
await expect(host.load([], context)).rejects.toThrow("load runs once per host");
});
it("makes a get before it resolves wait for it", async () => {
it("throws for an invalid plugin name before it changes anything; the host loads afterwards (decision (d))", async () => {
const empty = defineFacet(() => {});
const info = defineFacet((h) => h.serve(Info, () => ({ title: constant("t") })));
const host = createHost(noErrors);
const got = host.get(Info, {}, context);
await host.load(
[{ name: "a", facet: defineFacet((h) => h.serve(Info, () => ({ title: constant("t") }))) }],
context,
await expect(
host.load(
[
{ name: "@app/info", facet: info },
{ name: "tui:core", facet: empty },
],
context,
),
).rejects.toThrow('"tui:core" is not a valid plugin name');
// Nothing was staged or published: the same host loads the fixed set.
expect(await host.load([{ name: "@app/info", facet: info }], context)).toEqual({ failed: [] });
expect(() => host.reload([{ name: "Bad Name", facet: empty }], context)).toThrow(
'"Bad Name" is not a valid plugin name',
);
expect((await got).title.value).toBe("t");
// The reload changed nothing: the loaded plugin still serves.
expect((await host.get(Info, {}, context)).title.value).toBe("t");
await host.close(context);
});
it("is called first: get, hold, registry and a root's ctx.get throw not_ready before it (decision (a))", async () => {
const Names = defineRegistry<string, readonly string[]>("app.names", { version: 1 });
const host = createHost(noErrors);
const fix = "the host has not loaded; call host.load first (a host with nothing to load calls load([]))";
await expect(host.get(Info, {}, context)).rejects.toMatchObject({
code: "not_ready",
message: `app.info: ${fix}`,
});
expect(() => host.hold(Info, {}, context)).toThrow(`app.info: ${fix}`);
await expect(host.registry(Names, context)).rejects.toMatchObject({
code: "not_ready",
message: `registry app.names: ${fix}`,
});
await expect(host.root((ctx) => ctx.get(Info, {}), context)).rejects.toMatchObject({ code: "not_ready" });
// A client with nothing to load calls `load([])`: then its requests wait as any does.
await host.load([], context);
expect(host.hold(Info, {}, context).state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } });
await host.close(context);
});
});
@ -393,16 +423,12 @@ describe("startup across hosts (dependency model §6)", () => {
params: jsonPart({ session: "S1" }),
address: { kind: "session", params: { id: "S1" } },
});
const mine = worker.hold(PlacedNotes, { session: "S1" }, context);
await transported();
expect(mine.state.value).toEqual({ kind: "loading" });
await worker.load(
[{ name: "@test/w", facet: defineFacet<TestPrincipal>((h) => serveNotes(h, PlacedNotes)) }],
context,
);
expect((await child.next("bound")).holds[0]).toMatchObject({ hold: 0 });
await transported();
expect(mine.state.value).toEqual({ kind: "live" });
expect(parent.kinds()).toEqual(["hello"]);
});
@ -488,13 +514,6 @@ describe("deadlines of gets", () => {
expect(await waiting).toEqual({ code: "unavailable", message: "app.svc(1): did not become live within 500 ms" });
});
it("include the wait for load", async () => {
const host = createHost(noErrors);
const waiting = rejection(host.get(Info, {}, context, { readyTimeout: 100 }));
await vi.advanceTimersByTimeAsync(100);
expect(await waiting).toEqual({ code: "unavailable", message: "app.info: did not become live within 100 ms" });
});
it("are per getter: the first to pass releases only its own reference, the attempt goes on for the other", async () => {
const gate = Promise.withResolvers<void>();
const closes: string[] = [];
@ -531,13 +550,6 @@ describe("close", () => {
expect(() => info.title.value).toThrow("app.info: ended");
});
it("fails a get waiting for load", async () => {
const host = createHost(noErrors);
const got = host.get(Info, {}, context);
await host.close(context);
expect(await rejection(got)).toEqual({ code: "released", reason: "closed", message: "the host is closed" });
});
it("ends every object at once: calls fail released before any close ran", async () => {
const closing = Promise.withResolvers<void>();
const { host } = await loaded((h) =>
@ -983,13 +995,6 @@ describe("host.registry and the host's close (fix round 1)", () => {
await flushed();
expect(outcome).toMatchObject({ code: "released", reason: "closed" });
});
it("ends one still waiting for the publication at the close", async () => {
const host = createHost(noErrors);
const waiting = rejection(host.registry(Absent, context, { readyTimeout: 60_000 }));
await host.close(context);
expect(await waiting).toMatchObject({ code: "released", reason: "closed" });
});
});
describe("the selected provider's own checks for a request made here (fix round 1)", () => {
@ -1116,49 +1121,6 @@ describe("acquiring: hold and get", () => {
expect(await got).toEqual(unserved);
});
it("before load resolved: hold is loading, get waits; once it resolved both bind, or an unserved type waits", async () => {
const host = createHost(noErrors);
const held = host.hold(Info, {}, context);
const unserved = host.hold(Page, { id: 1 }, context);
const got = host.get(Info, {}, context);
const refused = rejection(host.get(Page, { id: 1 }, context, { readyTimeout: 50 }));
expect([held.state.value, unserved.state.value]).toEqual([{ kind: "loading" }, { kind: "loading" }]);
await host.load(
[{ name: "@test/app", facet: defineFacet((h) => h.serve(Info, () => ({ title: constant("t") }))) }],
context,
);
await flushed();
expect(held.state.value).toEqual({ kind: "live" });
expect((await got).title.value).toBe("t");
const error = { code: "unavailable", message: "app.page is not served here" };
expect(unserved.state.value).toMatchObject({
kind: "failed",
cause: { kind: "waiting", error },
retryAt: undefined,
});
await vi.advanceTimersByTimeAsync(50);
expect(await refused).toEqual(error);
});
it("invalid params before load resolved: hold throws and get rejects invalid/params at once (audit, 7a)", async () => {
const host = createHost(noErrors);
expect(() => host.hold(Page, { id: "x" } as never, context)).toThrow("params");
expect(await rejection(host.get(Page, { id: "x" } as never, context))).toMatchObject({
code: "invalid",
reason: "params",
});
await host.load([], context);
});
it("a hold released before load resolved makes nothing once it did", async () => {
const factory = vi.fn(({ id }: { id: number }) => ({ id: constant(id) }));
const host = createHost(noErrors);
host.hold(Page, { id: 1 }, context).release();
await host.load([{ name: "@test/app", facet: defineFacet((h) => h.serve(Page, factory)) }], context);
await flushed();
expect(factory).not.toHaveBeenCalled();
});
it("through transient failures: hold is failed with retryAt, then loading; get waits, then rejects with the last cause", async () => {
const { host } = await loaded((h) => serveFlaky(h, { left: 5, broken: false }));
const held = host.hold(Svc, { id: 1 }, context);

View file

@ -236,8 +236,9 @@ describe("GC", () => {
expect(reference.state.value).toMatchObject({ kind: "ended", error: { code: "released" } });
});
it("a hold released before load keeps nothing: the reference and its contract go while the host waits", async () => {
it("a hold released while it waits unbound keeps nothing: the reference and its contract leave the index and its clock", async () => {
const host = createHost({ onError: () => {} });
await host.load([], context);
const refs = (() => {
const Same = local("app.viewer", { version: 1, params: { id: int() }, members: { n: int() } });
const reference = host.hold(Same, { id: 1 }, context);

View file

@ -126,12 +126,12 @@ describe("host.root", () => {
await host.close(context);
});
it("a `ctx.get` before `load` resolved parks until it did", async () => {
it("a `ctx.get` before `load` throws not_ready (decision (a)); after it, it gets", async () => {
const host = createHost({ onError: () => {} });
const result = host.root(async (ctx) => (await ctx.get(Info, { id: 3 })).id.value, context);
await flushed();
const get = (ctx: Ctx) => ctx.get(Info, { id: 3 });
expect(await rejection(host.root(get, context))).toMatchObject({ code: "not_ready" });
await host.load([{ name: "@app/info", facet: defineFacet(serveInfo) }], context);
expect(await result).toBe(3);
expect(await host.root(async (ctx) => (await get(ctx)).id.value, context)).toBe(3);
await host.close(context);
});

View file

@ -0,0 +1,195 @@
import { describe, expect, it, vi } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
import { int, local } from "../../src/contracts/index.ts";
import { ChordError } from "../../src/data/index.ts";
import { createHost, defineFacet, type HostError } from "../../src/host/index.ts";
import { UnboundClock, type UnboundLong } from "../../src/host/unbound-clock.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
import { jsonPart } from "../../src/link/internal.ts";
import { defineRegistry } from "../../src/registries/index.ts";
import { constant } from "../../src/state/index.ts";
import {
expectCollected,
fakeTimers,
flushed,
loaded,
RawPeer,
rejection,
SERVER_ADDRESS,
Server,
type TestPrincipal,
testCodec,
transported,
} from "../support.ts";
const Missing = local("app.missing", { version: 1, params: { id: int() }, members: { n: int() } });
const unserved = (name: string) =>
expect.objectContaining({ code: "unavailable", message: `${name} is not served here` });
describe("the clock", () => {
fakeTimers();
const error = new ChordError({ code: "unavailable", message: "x" });
it("reports each wait once, 5 s after it started, in the order they started; a stopped one never", async () => {
const reports: UnboundLong[] = [];
const clock = new UnboundClock((report) => reports.push(report));
const [a, b, c] = [{}, {}, {}];
clock.start(a, "app.a", error);
await vi.advanceTimersByTimeAsync(2_000);
clock.start(b, "app.b", error);
clock.start(c, "app.c", error);
clock.stop(c);
await vi.advanceTimersByTimeAsync(2_999);
expect(reports).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(reports).toEqual([{ kind: "unbound_long", waitingFor: "app.a", error, since: 0 }]);
await vi.advanceTimersByTimeAsync(2_000);
expect(reports.map(({ waitingFor }) => waitingFor)).toEqual(["app.a", "app.b"]);
// Reported waits leave the clock: no timer runs, a start again is a new wait.
expect(vi.getTimerCount()).toBe(0);
});
it("names the latest error of a wait; a wait started twice keeps its start", async () => {
const reports: UnboundLong[] = [];
const clock = new UnboundClock((report) => reports.push(report));
const request = {};
const later = new ChordError({ code: "contract_mismatch", message: "y" });
clock.start(request, "registry app.r", error);
await vi.advanceTimersByTimeAsync(1_000);
clock.start(request, "registry app.r", error);
clock.failed(request, later);
await vi.advanceTimersByTimeAsync(4_000);
expect(reports).toEqual([{ kind: "unbound_long", waitingFor: "registry app.r", error: later, since: 0 }]);
});
it("keeps no request once it stopped or the clock closed (GC)", async () => {
const clock = new UnboundClock(() => {});
const refs = (() => {
const [a, b] = [{}, {}];
clock.start(a, "app.a", error);
clock.start(b, "app.b", error);
clock.stop(a);
clock.close();
return [new WeakRef(a), new WeakRef(b)];
})();
vi.useRealTimers();
await expectCollected(...refs);
expect(clock).toBeDefined();
});
});
describe('requests waiting unbound (decision "Mistakes that wait are reported at once" (c))', () => {
fakeTimers();
it("reports a host.hold and a host.get of a type nobody serves once after 5 s; the get waits until its readyTimeout", async () => {
const { host, errors } = await loaded(() => {});
const held = host.hold(Missing, { id: 1 }, context);
const got = rejection(host.get(Missing, { id: 2 }, context, { readyTimeout: 20_000 }));
await vi.advanceTimersByTimeAsync(4_999);
expect(errors).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
const report = { kind: "unbound_long", waitingFor: "app.missing", error: unserved("app.missing"), since: 0 };
expect(errors).toEqual([report, report]);
await vi.advanceTimersByTimeAsync(14_999);
expect(errors).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
expect(await got).toMatchObject({ code: "unavailable", message: "app.missing is not served here" });
expect(held.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } });
held.release();
await host.close(context);
});
it("reports nothing for a request that binds or ends within 5 s", async () => {
const errors: HostError[] = [];
const host = createHost({ onError: (error) => errors.push(error) });
await host.load([], context);
const released = host.hold(Missing, { id: 1 }, context);
const bound = host.hold(Missing, { id: 2 }, context);
await vi.advanceTimersByTimeAsync(1_000);
released.release();
const facet = defineFacet((h) => h.serve(Missing, ({ id }) => ({ n: constant(id) })));
await host.reload([{ name: "@test/missing", facet }], context);
await flushed();
expect(bound.state.value).toEqual({ kind: "live" });
await vi.advanceTimersByTimeAsync(60_000);
expect(errors).toEqual([]);
bound.release();
await host.close(context);
});
it("reports a hold arriving over a link and refused for a type not served, on the host that refused it", async () => {
const errors: HostError[] = [];
const host = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
onError: (error) => errors.push(error),
});
const [a, b] = memoryLinkPair();
host.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context);
const peer = new RawPeer(b);
// Before the publication it waits for it without a report; the clock starts once it is refused.
peer.send({
kind: "hold",
hold: 0,
type: "app.remote-missing",
version: 1,
fingerprint: "0123456789abcdef",
params: jsonPart({}),
address: SERVER_ADDRESS,
});
await transported();
await vi.advanceTimersByTimeAsync(10_000);
expect(errors).toEqual([]);
await host.load([], context);
await peer.next("failed");
await vi.advanceTimersByTimeAsync(5_000);
expect(errors).toEqual([
{
kind: "unbound_long",
waitingFor: "app.remote-missing",
error: unserved("app.remote-missing"),
since: 10_000,
},
]);
await host.close(context);
});
it("reports a registry nobody serves once per value waited for: a parked reader and host.registry", async () => {
const Absent = defineRegistry<number, number>("app.absent", { version: 1 });
const Reader = local("app.reader", { version: 1, params: { id: int() }, members: { n: int() } });
const { host, errors } = await loaded((h) =>
h.serve(Reader, (_, self) => {
const absent = self.registry(Absent);
return { n: self.derive([absent], ([n]) => n) };
}),
);
const reader = host.hold(Reader, { id: 1 }, context);
const waiting = rejection(host.registry(Absent, context, { readyTimeout: 30_000 }));
await vi.advanceTimersByTimeAsync(5_000);
expect(errors).toEqual([
{
kind: "unbound_long",
waitingFor: "registry app.absent",
error: expect.objectContaining({ code: "unavailable", reason: "undeclared" }),
since: 0,
},
]);
await vi.advanceTimersByTimeAsync(25_000);
expect(await waiting).toMatchObject({ reason: "undeclared" });
reader.release();
await host.close(context);
});
it("arm one timer for the host whatever the number of requests waiting (cost guard: no timer per request)", async () => {
const { host, errors } = await loaded(() => {});
const before = vi.getTimerCount();
const held = Array.from({ length: 100 }, (_, id) => host.hold(Missing, { id }, context));
expect(vi.getTimerCount() - before).toBe(1);
await vi.advanceTimersByTimeAsync(5_000);
expect(errors).toHaveLength(100);
expect(vi.getTimerCount()).toBe(before);
for (const reference of held) reference.release();
await host.close(context);
});
});

View file

@ -6,6 +6,7 @@ import { createHost, defineFacet } from "../../src/host/index.ts";
import type { ChordHost } from "../../src/host/internal.ts";
import { Binding } from "../../src/host/reference.ts";
import { UnboundIndex, type UnboundRequest } from "../../src/host/unbound.ts";
import { UnboundClock } from "../../src/host/unbound-clock.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
import { jsonPart } from "../../src/link/internal.ts";
import { Hold } from "../../src/remote/hold.ts";
@ -37,11 +38,11 @@ function request(principal?: Principal): UnboundRequest & { readonly log: string
describe("the index", () => {
it("wakes every request at the publication, out of the index first; a deleted one is not woken", () => {
const index = new UnboundIndex();
const index = new UnboundIndex(new UnboundClock(() => {}));
const [a, b, c] = [request(), request(), request()];
index.add("app.a", a);
index.add("app.a", b);
index.add("app.b", c);
index.add("app.a", a, undefined);
index.add("app.a", b, undefined);
index.add("app.b", c, undefined);
index.delete("app.a", b);
index.wakeAll();
expect([a.log, b.log, c.log]).toEqual([["wake"], [], ["wake"]]);
@ -51,11 +52,11 @@ describe("the index", () => {
});
it("wakes the requests of the names a reload serves only (carry-over: publications wake per name)", () => {
const index = new UnboundIndex();
const index = new UnboundIndex(new UnboundClock(() => {}));
const [a, b, c] = [request(), request(), request()];
index.add("app.a", a);
index.add("app.b", b);
index.add("app.c", c);
index.add("app.a", a, undefined);
index.add("app.b", b, undefined);
index.add("app.c", c, undefined);
index.wake(["app.a", "app.c", "app.unknown"]);
expect([a.log, b.log, c.log]).toEqual([["wake"], [], ["wake"]]);
// The others stay indexed until their own names are served.
@ -67,11 +68,11 @@ describe("the index", () => {
});
it("ends the requests of a revoked principal only, and every request at the host's close", () => {
const index = new UnboundIndex();
const index = new UnboundIndex(new UnboundClock(() => {}));
const guest = request({ kind: "guest" });
const owner = request({ kind: "owner" });
const own = request();
for (const each of [guest, owner, own]) index.add("app.a", each);
for (const each of [guest, owner, own]) index.add("app.a", each, undefined);
const revoked = new ChordError({ code: "forbidden", reason: "revoked", message: "revoked" });
index.revoke((principal) => principal.kind === "guest", revoked);
expect([guest.log, owner.log, own.log]).toEqual([["end revoked"], [], []]);
@ -81,15 +82,14 @@ describe("the index", () => {
});
describe("references waiting in the index", () => {
it("before the publication: loading, placed by it; a type not served then waits failed { waiting }", async () => {
it("after the publication: a type served binds; one not served waits failed { waiting }", async () => {
const host = createHost({ onError: () => {} });
const info = host.hold(Info, {}, context);
const missing = host.hold(Missing, { id: 1 }, context);
expect([info.state.value, missing.state.value]).toEqual([{ kind: "loading" }, { kind: "loading" }]);
await host.load(
[{ name: "@test/a", facet: defineFacet((h) => h.serve(Info, () => ({ title: constant("t") }))) }],
context,
);
const info = host.hold(Info, {}, context);
const missing = host.hold(Missing, { id: 1 }, context);
await delivered();
expect(info.state.value).toEqual({ kind: "live" });
expect(missing.state.value).toMatchObject({
@ -104,9 +104,8 @@ describe("references waiting in the index", () => {
expect(missing.state.value).toMatchObject({ kind: "ended", error: { reason: "closed" } });
});
it("leave nothing behind once released, before and after the publication (GC)", async () => {
it("leave nothing behind once released (GC)", async () => {
const host = createHost({ onError: () => {} });
host.hold(Missing, { id: 1 }, context).release();
await host.load([], context);
host.hold(Missing, { id: 2 }, context).release();
const got = host.get(Missing, { id: 3 }, context, { readyTimeout: 1 }).catch(() => {});
@ -114,6 +113,7 @@ describe("references waiting in the index", () => {
await expectCollected(...bindings);
expect(host).toBeDefined();
});
it("end at the start of the host's close, not after its slow native closes (fix round 1, Sol 6)", async () => {
const Core = local("app.core", { version: 1, params: {}, members: { n: int() } });
const closeGate = Promise.withResolvers<void>();
@ -189,9 +189,9 @@ describe("holds waiting in the index", () => {
describe("what waits unserved (slice 12 §6, testPlugin)", () => {
it("lists the index's names sorted, and the host the names no plugin serves", async () => {
const index = new UnboundIndex();
index.add("b", request());
index.add("a", request());
const index = new UnboundIndex(new UnboundClock(() => {}));
index.add("b", request(), undefined);
index.add("a", request(), undefined);
expect(index.names()).toEqual(["a", "b"]);
const host = createHost({ onError: () => {} });
await host.load(

View file

@ -41,27 +41,18 @@ function sleepThen<T>(ms: number, value: T): Promise<T> {
const LOOPBACK = { localAddress: "127.0.0.1", localPort: 8788 };
describe("startup", () => {
it("fetch waits for the publication, then serves; the registries are resolved once", async () => {
it("fetch answers 503 before load at once, then serves; the registries are resolved once", async () => {
const server = await served({ "@app/who": whoami }, { load: false });
const registry = vi.spyOn(server.host, "registry");
const early = server.http.fetch(loopback("/whoami"), LOOPBACK);
await new Promise((resolve) => setTimeout(resolve, 50));
expect((await server.http.fetch(loopback("/whoami"), LOOPBACK)).status).toBe(503);
await server.load();
expect(await (await early).text()).toBe("null");
expect(await (await server.http.fetch(loopback("/whoami"), LOOPBACK)).text()).toBe("null");
const calls = registry.mock.calls.length;
for (let i = 0; i < 3; i++) await server.http.fetch(loopback("/whoami"), LOOPBACK);
expect(registry.mock.calls.length).toBe(calls);
await server.close();
});
it("answers 503 to a request waiting past the host's readyTimeout", async () => {
const server = await served({}, { load: false, readyTimeout: 50 });
expect((await server.http.fetch(loopback("/x"), LOOPBACK)).status).toBe(503);
await server.load();
expect((await server.http.fetch(loopback("/x"), LOOPBACK)).status).toBe(404);
await server.close();
});
it("listens once published where its listen setting says; without it no listener", async () => {
const server = await served({ "@app/who": whoami });
expect(server.port).toBeGreaterThan(0);
@ -655,7 +646,7 @@ describe("cancellation", () => {
});
// Astra's recheck R4, Sol's N1: a close before the first resolution started a stopped watch; its throw ended the process.
it("a close before the publication leaves nothing running: the waiting request answers 503", async () => {
it("a close before load leaves nothing running: the early request answers 503", async () => {
const server = await served({}, { load: false, config: { listen: undefined } });
const waiting = server.http.fetch(loopback("/x"), LOOPBACK);
await server.http.close(withAbortSignal(AbortSignal.abort(), context));

View file

@ -750,3 +750,69 @@ describe("registries without a provider (slice 12 §6, testPlugin)", () => {
await host.close(context);
});
});
describe('registry_undeclared (decision "Mistakes that wait are reported at once" (b))', () => {
const undeclared = (errors: readonly HostError[]) => errors.filter(({ kind }) => kind === "registry_undeclared");
const contributing = (plugin: string) => ({
name: plugin,
facet: defineFacet((_host, self) => self.contribute(Names, plugin)),
});
const serving = { name: "serving", facet: defineFacet((h) => h.serve(Names, { compose: listed })) };
it("reports at load's publication a registry with contributions and no provider, naming its contributors", async () => {
const errors: HostError[] = [];
const host = createHost({ onError: (error) => errors.push(error) });
const loading = host.load([contributing("b"), contributing("a")], context);
// At once: before the start barrier, before any reader waits.
expect(undeclared(errors)).toEqual([{ kind: "registry_undeclared", registry: "app.names", plugins: ["a", "b"] }]);
await loading;
await host.close(context);
});
it("reports again at a reload only when the contributors change; a provider ends the episode", async () => {
const errors: HostError[] = [];
const host = createHost({ onError: (error) => errors.push(error) });
const a = contributing("a");
await host.load([a], context);
// Unchanged contributors: no repeat per reload.
await host.reload([a, { name: "other", facet: defineFacet(() => {}) }], context);
expect(undeclared(errors)).toHaveLength(1);
const b = contributing("b");
await host.reload([a, b], context);
expect(undeclared(errors)[1]).toEqual({
kind: "registry_undeclared",
registry: "app.names",
plugins: ["a", "b"],
});
// Served: nothing reported; removed again: a new episode, reported again.
await host.reload([a, b, serving], context);
expect(undeclared(errors)).toHaveLength(2);
await host.reload([a, b], context);
expect(undeclared(errors)[2]).toEqual({
kind: "registry_undeclared",
registry: "app.names",
plugins: ["a", "b"],
});
await host.close(context);
});
it("reports a host-lifetime object's contribution entering a registry nobody serves, once across its restarts", async () => {
const { host, errors } = await loadedPlugins({ "@app/setter": setter(Names, "x", String) });
expect(undeclared(errors)).toEqual([
{ kind: "registry_undeclared", registry: "app.names", plugins: ["@app/setter"] },
]);
await host.reload([{ name: "@app/setter", facet: defineFacet(setter(Names, "y", String)) }], context);
await flushed();
expect(undeclared(errors)).toHaveLength(1);
await host.close(context);
});
it("reports nothing for a served registry or one without contributions", async () => {
const errors: HostError[] = [];
const host = createHost({ onError: (error) => errors.push(error) });
await host.load([contributing("a"), serving], context);
await host.reload([serving], context);
expect(undeclared(errors)).toEqual([]);
await host.close(context);
});
});

View file

@ -51,14 +51,13 @@ describe("providers", () => {
await host.close(context);
});
it("a registry whose provider comes with `load` heals a `host.registry` made before it", async () => {
it("a registry whose provider comes with `load` gives `host.registry` its value", async () => {
const host = createHost({ onError: () => {} });
const waiting = host.registry(Names, context);
await host.load(
[{ name: "@app/a", facet: defineFacet((h) => h.serve(Names, { compose: (t) => t.length })) }],
context,
);
const value = await waiting;
const value = await host.registry(Names, context);
expectTypeOf(value).toEqualTypeOf<RegistryValue<number>>();
expect(value.value).toBe(0);
await host.close(context);

View file

@ -304,14 +304,6 @@ describe("readers (S3)", () => {
await host.close(context);
});
it("host.registry before the publication waits for it, then resolves with the provider it served", async () => {
const host = createHost({ onError: () => {} });
const waiting = host.registry(Sum, context);
await host.load([{ name: "@app/sum", facet: defineFacet((h) => h.serve(Sum, { compose: total })) }], context);
expect((await waiting).value).toEqual({ total: 0 });
await host.close(context);
});
it("host.registry waits through no value until a first good composition, woken by it, its wait then gone", async () => {
const { host } = await loadedPlugins({
"@app/sum": (host) => {