mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
51bb8f5c06
commit
5bd014b1c7
32 changed files with 893 additions and 239 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
("call <code>host.load</code> first"): 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'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) =>
|
|||
});</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'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) => {
|
||||
<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>(() => {}); <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) => 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'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'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's principal; <strong><code>ctx.get</code> in a method acts as the method's caller</strong>. 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.</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'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) => {
|
||||
<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) => {
|
||||
<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's factory gets <code>{ kind: "owner" }</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'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) =>
|
||||
server.<span class="tk-f">root</span>(<span class="tk-k">async</span> (own) => (<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'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'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's entry do.</p>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
76
packages/chord/src/host/unbound-clock.ts
Normal file
76
packages/chord/src/host/unbound-clock.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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})`),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
195
packages/chord/test/host/unbound-clock.test.ts
Normal file
195
packages/chord/test/host/unbound-clock.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue