diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 76e987aee..1a95ee247 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -4,7 +4,7 @@ ### Breaking Changes -- Terminology follows the concepts: a reference (what `get` returns) is `Ref` (was `Handle`), its status `RefStatus` (was `HandleStatus`); what a factory returns is `ImplementationOf` (was `BehaviorOf`); a resource's options are a `ResourceDeclaration` (was `ResourceSpec`); epochs are code versions: `Ctx.codeVersion`, `RootOptions.codeVersion`, `Shape.codeVersion`, `Host.codeVersion`/`previousCodeVersion`, and the `codeVersion` fields of frames and reload reports (all were `epoch`). Docs and messages say object (was record) and reference (was handle and hold); the `hold` and `release` frame types keep their names +- Terminology follows the concepts: a reference (what `get` returns) is `Ref` (was `Handle`), its status `RefStatus` (was `HandleStatus`); what a factory returns is `ImplementationOf` (was `BehaviorOf`); a resource's options are a `ResourceDeclaration` (was `ResourceSpec`); epochs are code versions: `Ctx.codeVersion`, `RootOptions.codeVersion`, `Host.codeVersion`/`previousCodeVersion`, and the `codeVersion` fields of frames and reload reports (all were `epoch`). Docs and messages say object (was record) and reference (was handle and hold); the `hold` and `release` frame types keep their names - A remote object's variant is named `variant` (was `kind`): factories return `{ variant: "editor", … }`, references expose `ref.variant` and `shape.variant`, and the `shape` frame carries `variant`. `variant` is reserved as a member name; `kind` no longer is - Links have a protocol version (`PROTOCOL_VERSION`, now 2), announced in `hello`. Peers of different versions do not talk: the side that notices closes the link with reason `protocol-version `, and both sides report it through `LinkOptions.onProtocolMismatch`. Peers without a version (older Chord) are refused - `serve(…, { lifetime })` replaces `eager`: `"referenced"` (the default) creates an object at the first `get` of its address and disposes it after the last reference is released (plus `linger`); `"host"` creates the type's one object when its facet loads and keeps it until the host closes or a reload removes its facet. `lifetime: "host"` is refused at `serve` for remote types, for inputs that do not accept `{}` and together with `linger`; every `get` of such a type reaches its one object, created for the host principal, and another input is `invalid/input`. `eager: { input }` and the extra objects host-principal `get`s with other inputs created are gone. Only `"host"` objects may register (reason `invalid/not_host_lifetime`, was `not_eager`). `LoadOptions.awaitHostObjects` replaces `awaitEager` @@ -16,9 +16,17 @@ - `FacetApi` has a `signal` (below): code that builds a `FacetApi` by hand, for example to wrap `serve`, must pass it on - Principals are typed and supplied by the app: `createHost

`, `Host

`, `defineFacet

`, `FacetApi

`, `Serve

`, `Factory`, `Self

`, `Ctx

`, `GetOptions

`, `RootOptions

`, `Route

`, `AcceptOptions

` and `PrincipalMatch

` take the app's principal type (default `unknown`), so factories, methods, listeners and tasks narrow on it without casts. Chord invents no principals any more: `createHost({ self })` (required) is the principal of the host's own code, replacing the built-in `hostPrincipal` (`{ host: true }`, removed); `connect(link, { principal })` (required) is what the parent's own requests act as, replacing `{ host: }`; `Route.principal` is required, replacing the default `{ host: }`. `HostOptions.upstream` is `{ link, principal, ...LinkOptions }` (was a link, with `upstreamOptions`). The host's own principal is recognized by `principalKey`, not object identity. The `Principal` type is gone; `ConnectOptions` and `PrincipalPattern` are new - `serve(type, factory, { lifetime: "host" })` compiles only for a local type whose input has no required fields, and without `linger`; `serve` still refuses the rest at runtime. `ServeOptions` is a union of `ReferencedServeOptions` and `HostServeOptions`, and `serve` takes `ServeOptionsFor` +- An awaited reference has its state, local or remote. `ctx.get` and `host.get` are async: they resolve once the reference is live and every state member of its variant has its first snapshot, and reject with the activation error, `aborted` (the ctx aborted or its call settled; `host.get`'s `signal`) or `unavailable/ready_timeout` after `HostOptions.readyTimeout` (default 60000 ms), releasing only that reference. `host.hold` is the synchronous form (possibly `loading`) for UI runtimes; `ref.ready` stays. `self.get` stays synchronous: an object goes live only once its references are ready and hydrated, and the state it exposes or derives from (also forwarded from a remote reference held another way) has its values; activation and reload prepare wait at most `readyTimeout` (a reference cycle across hosts fails with a hint instead of hanging) +- State members are typed `T` (was `T | undefined`) and `StateSource` is `Readable`. Reading state before the first hydration throws `not_ready`, outside the current variant `not_in_variant`, after the reference ended its end error. A remote reference is a union discriminated by `variant`: another variant's members are reachable only after narrowing. `MembersOf` is gone. A listener added before a member is readable gets its snapshot then (no `undefined` snapshot first) +- A remote reference stays subscribed to its variant's state while held, with or without listeners (it no longer forgets its value when the last listener leaves). `GetOptions.state: false` makes a method-only reference: no state unless another reference to the object wants it, ready once live +- `Shape.codeVersion` and the `shape` frame's `codeVersion` are gone +- `PROTOCOL_VERSION` is 4: a `hold` implies the state of its variant (`state: false` opts out until `sub`; `unsub` stops it); the provider answers with units, a `shape` listing the members the consumer lacks (`state`) followed by their snapshots; `state` frames are addressed by hold and member (`hold`, `m`) with `as: "snap" | "reset" | "ops" | "error"` and `value` (absent: `undefined`); `sub`/`unsub` take a hold; `resync` asks for a member again; `again` marks a re-hold after a link loss; `origin` (replacing `client`) names the client connection a hold comes from ### Added +- Remote references to one object share one binding per host and key (type, normalized input identity, principal key, contract fingerprint): one hold, one stream per state member, calls over the same hold; every reference keeps its own status, observers and `release`. `HostOptions.linger` (`{ types: { [typeName]: ms }, cap }`, cap default 4) keeps a binding held and hydrated after its last reference is released, so a reference taken again meanwhile costs nothing on the link +- Holds from clients carry an opaque `origin`: the id the accepting host gave the client connection, forwarded by relays and honored only from the parent link, so a host can tell client connections apart; a disconnect releases that client's holds at once +- Flow control by units: the first unit of a new reference passes the window, so a `get` never waits behind other references' state; units for reloads and re-holds wait, built lazily from the current values; waiting ops are capped per link (`LinkOptions.pendingCap`, default four windows), beyond which the oldest streams become resets (a relay asks downstream with `resync`) - `StateOptions`, `EventOf`, `ValueOf` and `Lifetime` types - `self.state(name, schema, { init }, options?)`: a cell whose first value comes from its source. `init(ctx)` (may be async) runs once the object's resources are open and its references live, before the object goes live, so no method, listener, task or subscriber sees another value; a throw or an invalid value fails the activation. A reload keeps the cell without running `init`; a cell a reload adds runs it while the reload prepares (a failure refuses the reload). Until then the cell's `value` throws `not_ready`. `StateInit` type - Links are flow controlled end to end: peers acknowledge what they processed (`ack` frames, announced with `acks` in `hello`), and while a window of frames is unacknowledged (`window` in `LinkOptions`, default 256 KiB), state frames wait at the sender, coalesced per subscription, instead of queuing in front of call results. A stalled peer costs a bounded slot per subscription; a slot that outgrows it becomes a reset. Calls, results and control frames are never held back, so under load a result can arrive before state changes made before it diff --git a/packages/chord/README.md b/packages/chord/README.md index 6db2da7a9..7badaec07 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -52,7 +52,7 @@ host.serve(Counter, ({ name }, _principal, self) => { }); await host.root(async (ctx) => { - const clicks = ctx.get(Counter, { name: "clicks" }); // a reference; released when this root ends + const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends clicks.count.subscribe((n) => console.log("count", n)); await clicks.increment({ by: 2 }, ctx); // logs 0, then 2 }); diff --git a/packages/chord/docs/guide.md b/packages/chord/docs/guide.md index 680ad2c06..59c1414b7 100644 --- a/packages/chord/docs/guide.md +++ b/packages/chord/docs/guide.md @@ -65,7 +65,7 @@ host.serve(Counter, ({ name }, _principal, self) => { }); await host.root(async (ctx) => { - const clicks = ctx.get(Counter, { name: "clicks" }); // a reference; released when this root ends + const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends clicks.count.subscribe((n) => console.log("count", n)); await clicks.increment({ by: 2 }, ctx); // logs 0, then 2 }); @@ -274,7 +274,8 @@ host.serve(Chat, ({ session, conversation }, principal, self) => { ``` **Rules:** -- Inside the factory, never use what you declared: `db.value` throws `not_ready`, and calling a reference throws. Use them in methods, listeners, tasks and `init`. +- Inside the factory, never use what you declared: `db.value` and `index.someState.value` throw `not_ready`, and calling a reference throws. Use them in methods, listeners, tasks and `init`. +- **The object goes live only with its references ready**: live, and (for a remote reference) every state member of its variant hydrated. The same holds for state it forwards or derives from a remote reference it holds some other way: it goes live once that state has its value. A reference that is not ready after the host's `readyTimeout` (default 60 s) fails the activation with `unavailable/ready_timeout`; a reference cycle across hosts ends that way instead of hanging. - **Concurrent `get`s** of one address share one activation, so the database opens once. - **Failure propagates:** if `openSqlite` throws, `Index` fails, `Chat` fails with that cause, and every waiting `get` rejects. The next `get` tries again. - **Abandoned activations:** if everyone releases before activation finishes, the result is disposed. @@ -290,7 +291,7 @@ when abandoning startup. `install` registers synchronously, so setup/registratio `await host.ready()` waits for the host objects present when called, including direct `host.serve(..., { lifetime: "host" })` and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to -finish, or a remote-state hydration barrier. Later installations require another readiness wait. +finish. Later installations require another readiness wait. A worker may need to open its local Harness and announce control readiness **before** the parent accepts its link. A host object reporting to the server's SessionStatus cannot become live before that acceptance. Awaiting the @@ -299,8 +300,7 @@ whole load before announcing control readiness would deadlock. Defer the whole b ```ts // Connect the upstream link first; its outgoing references can queue until the parent accepts it. await worker.load(workerFacets, { awaitHostObjects: false }); // also supported by install -const harness = worker.get(Harness, {}); -await harness.ready; // local bootstrap only; no upstream dependency +const harness = await worker.get(Harness, {}); // local bootstrap only; no upstream dependency announceControlReady(); // parent can now accept the worker link await worker.ready(); // every host object live, including upstream references harness.release(); @@ -310,7 +310,8 @@ harness.release(); automatic classification of upstream dependencies. Choose a local bootstrap reference with no upstream dependency, connect before loading upstream consumers, and let the parent admit the link before the full barrier. Control readiness means admission is possible, not that every host object is live. Keep worker bootstrap counted as busy -until the full barrier settles, and report a later failure through the worker's fatal/startup-error path. +until the full barrier settles, and report a later failure through the worker's fatal/startup-error path. Never await a +reference whose object needs the parent (a remote one, or a local one that holds one) before the parent admits the link. To hand out a native object a resource opens (a local type's `opaque` member), expose the resource. References read its value once the object is live, and `undefined` before: @@ -320,8 +321,7 @@ host.serve(Harness, (_, _host, self) => { return { harness: self.expose(durable) }; // contract member: harness: opaque("pi.durable-harness@1") }); -const harness = ctx.get(Harness, {}); -await harness.ready; +const harness = await ctx.get(Harness, {}); harness.harness.resume(); // the DurableHarness itself ``` @@ -381,9 +381,8 @@ host.serve(Chat, ({ session, conversation }, principal, self) => { ``` ```ts -const chat = ctx.get(Chat, { session: "S1", conversation: 1 }); // on session:S1 -await chat.ready; -if (chat.variant === "participant") showComposer(chat.send); +const chat = await ctx.get(Chat, { session: "S1", conversation: 1 }); // on session:S1 +if (chat.variant === "participant") showComposer(chat.send); // narrows: `send` exists only there else showReadOnly(); ``` @@ -396,24 +395,34 @@ Local types have no variants. Their only callers are inside the host, acting as ## References +| Where | API | Released | +| --- | --- | --- | +| a factory | `self.get(Type, input)`: synchronous; the object goes live after it is ready | with the object (or its replaced declaration) | +| a call | `await ctx.get(Type, input, { state? })` | when the call settles | +| outside Chord | `await host.get(Type, input, { signal?, state? })` | `ref.release()` | +| a UI runtime | `host.hold(Type, input)`: synchronous, possibly `loading` | `ref.release()` | + ```ts -const chat = ctx.get(Chat, input); +const chat = await ctx.get(Chat, input); // ready: live, every state member of its variant hydrated chat.status; // "loading" | "live" | "released" | "gone" -await chat.ready; // live, or rejects with the activation error -chat.variant; // the current variant -chat.shape; // { variant, members, contract, codeVersion } +chat.variant; // the current variant; a multi-variant reference narrows on it +chat.shape; // { variant, members, contract } const send = chat.send; // a stable function: always dispatches to the current code -chat.view.value; // undefined until hydrated (remote: while someone subscribes) +chat.view.value; // the current value, typed T chat.view.subscribe((v) => render(v)); chat.onStatus((status, error) => { if (status === "gone") reconnect(error); }); chat.release(); ``` +- **A reference you hold is usable:** an awaited `get` resolves once the object is live and every state member of its variant has its first snapshot, local or remote. It rejects with the activation error (`forbidden`, unreachable, …), with `aborted` (a `ctx.get` whose ctx is aborted or whose call settles first; `host.get`'s `signal`), or with `unavailable/ready_timeout` after the host's `readyTimeout`. Only that reference is released then. +- **A held reference stays subscribed** to the state of its variant, with or without listeners, until it is released. After a link loss the last values stay readable while it re-holds (status `loading`). +- **Reading state before the first hydration throws `not_ready`** (a `hold` that is still loading, a member read inside a factory). A member outside the current variant throws `not_in_variant`; an ended reference throws its end (`released`, or the `gone` error). A listener added before the hydration gets its snapshot then. +- **`state: false`** gives a method-only reference: it receives no state (unless another reference to the same object wants it) and is ready once its object is live. - **Calls on a `loading` reference wait** until it's live, respecting `ctx` cancellation and a deadline. - A member missing from the current variant rejects with `not_in_variant`. - After `release()`, every member rejects with `released`. - **`gone`** means the object was forced away while referenced: revocation, archive, its host dying. Call `get` again, or show "unavailable". `onStatus` reports every status change (`live`, `loading` while a link reconnects, `released`, `gone` with the error), so a UI needn't poll. -- **A remote state member is hydrated only while subscribed.** When its last subscriber leaves, its `value` goes back to `undefined`, so a later subscriber never sees a stale value: it gets `undefined`, then the current snapshot. +- **References to one remote object share one binding** per host and key (type, normalized input, principal key, contract fingerprint): one hold over the link, one stream per state member, calls over the same hold. Each reference keeps its own status, observers and `release`. With `createHost({ linger: { types: { [typeName]: ms }, cap } })`, a binding whose last reference is released stays held (and hydrated) for `ms`, at most `cap` (default 4) at a time, so taking the reference again costs nothing on the link. - **Return values are values.** Anything long-lived you want to share is an object you reach with `get`. ## State @@ -440,9 +449,9 @@ view.replace(ctx, rebuilt, { diff: true }); // always diff - A `change` is atomic: one revision, or nothing if the callback throws. - `change` only touches the paths you mutate, so fields your code doesn't know about survive. Use `replace` only where your code owns the whole value. - Values are immutable. Never mutate what you read or receive. -- Remote subscribers get a snapshot, then deltas. After a reconnect or a reload they get a `reset` snapshot, and nothing is replayed. +- Remote references get a snapshot of each state member of their variant, then deltas. After a reconnect or a reload they get a `reset` snapshot, and nothing is replayed. - `replace` sends a structural diff when the new value shares containers with the old one (a new revision of the same state), and a single replacement otherwise. `{ diff: true | false }` forces either. The value is the same either way. -- An implementation may expose another object's state member as its own: `return { variant: "viewer", view: conversation.view }`. It reads `undefined` only while that object is loading, which never happens once this object is live. +- An implementation may expose another object's state member as its own: `return { variant: "viewer", view: conversation.view }`. The object goes live only once that member has its value, local or remote. **A cell whose first value comes from a source** declares `{ init }` instead of an initial value: @@ -477,7 +486,7 @@ Every method gets `(args, ctx)`, or `(ctx)` when it takes no arguments. Pass `ct ```ts send: async (input, ctx) => { ctx.signal.throwIfAborted(); - const uploads = ctx.get(Uploads, {}); // a reference owned by this call; released when it settles + const uploads = await ctx.get(Uploads, {}); // a reference owned by this call; released when it settles const files = await uploads.resolve({ ids: input.uploads }, ctx); return c.submit({ ...input, files }, author, ctx); }, @@ -607,7 +616,7 @@ The facet's `setup` argument is its host, scoped to what a plugin needs: `serve` export default defineFacet((host) => { host.serve(Sessions, sessionsFactory); tools.register("rename_session", (args) => - host.root((ctx) => ctx.get(Sessions, {}).rename(args, ctx)), + host.root(async (ctx) => (await ctx.get(Sessions, {})).rename(args, ctx)), ); }); ``` @@ -721,7 +730,7 @@ const worker = createHost({ }); // anywhere -const chat = ctx.get(Chat, { session: "S1", conversation: 1 }); // browser → server → worker session:S1 +const chat = await ctx.get(Chat, { session: "S1", conversation: 1 }); // browser → server → worker session:S1 const sessions = self.get(Sessions, {}); // `hostNamed("server")`: worker → server, acting as worker{S1} ``` @@ -731,7 +740,8 @@ const sessions = self.get(Sessions, {}); // `hostNamed("server")`: worker → s - a forwarded principal is accepted only from the parent link; - calls an object makes itself act as its host; - links ping, and a dead link releases everything held through it. -- **Slow peers:** a peer acknowledges what it processed. While a window (`window`, default 256 KiB) of frames is unacknowledged, state waits and is coalesced per subscription; calls and results are never held back. So under load a result can arrive before the state change it caused: wait for the state you need instead of assuming it is there when the call returns. +- **Client origins:** a hold that comes from a browser carries an opaque id of its connection (`origin`), forwarded by the server when it relays the hold, so a worker can tell its clients apart. A client cannot set it. A disconnect releases that client's holds at once. +- **Slow peers:** a peer acknowledges what it processed. While a window (`window`, default 256 KiB) of frames is unacknowledged, state waits and is coalesced per member's stream; calls, results and the first snapshots of a new reference are never held back, so a `get` does not wait behind other references' state. Waiting ops are capped per link (`pendingCap`, default four windows): beyond, the oldest streams are replaced by their current value. So under load a result can arrive before the state change it caused: wait for the state you need instead of assuming it is there when the call returns. - **Reloads across hosts:** the server coordinates prepare and commit for every host. - **Workers that start behind** (spawned after a reload) are caught up to the server's code version before anything is routed to them. Calls their roots make to the server meanwhile run in the server's current code version instead of failing `superseded`. - **Protocol version:** both ends announce the link protocol version in their hello. A peer of another version is not @@ -777,7 +787,7 @@ A reload changing the guard: Durable phases already running keep the old snapsho | --- | --- | | `forbidden` | The factory or a method refused this principal | | `invalid` | Input, arguments or a result failed validation | -| `not_ready` | A declared resource or reference was used inside a factory | +| `not_ready` | A declared resource or reference was used inside a factory, or state read before its reference's first hydration | | `not_in_variant` | The member doesn't exist in the variant you have | | `released` | The reference was released | | `gone` | The object was forced away while referenced. Call `get` again. | @@ -785,7 +795,7 @@ A reload changing the guard: Durable phases already running keep the old snapsho | `upgrade_conflict` | An old root reached an object migrated meanwhile. Retry as a new root. | | `superseded` | The root's code version is no longer available on this host. Retry. | | `upgrade_required` | A browser's contract is outdated. Reload its web facets. | -| `unavailable` | The target host can't be reached or started | +| `unavailable` | The target host can't be reached or started; reason `ready_timeout`: a reference was not ready within `readyTimeout` | | `aborted` | The call's `ctx` was cancelled | ## Rules of thumb diff --git a/packages/chord/docs/spec.md b/packages/chord/docs/spec.md index b9bb3c1e1..ca9c93b38 100644 --- a/packages/chord/docs/spec.md +++ b/packages/chord/docs/spec.md @@ -130,7 +130,7 @@ Removed vs guide: `self.own`, `holdWhile`, async factories, inferred dependencie 5. attach derivations, start listeners and tasks; 6. object is live: references `live`, `ready` resolves. -Inside the factory, placeholders must not be used (`resource.value` and an `init` cell's `value` throw `not_ready`; reference calls throw). An `init` that throws, or returns a value its schema refuses, fails the activation like a failed `open`. An `init` must not wait for its own object or one that holds it (it is not live yet), and must not read another `init` cell of its object (they run in parallel). `init` gives a first value from a source the object reads anyway; it does not persist anything, and a cell is not saved anywhere by having one. Methods/listeners/tasks run only on live objects, so their dependencies are live. External consumers (UI, roots) holding a `loading` reference: calls wait until live (ctx cancellation, deadline); state members read `undefined` until hydrated. Activation failures propagate to dependents and all waiting gets; the next get retries. +Inside the factory, placeholders must not be used (`resource.value` and an `init` cell's `value` throw `not_ready`; reference calls throw). An `init` that throws, or returns a value its schema refuses, fails the activation like a failed `open`. An `init` must not wait for its own object or one that holds it (it is not live yet), and must not read another `init` cell of its object (they run in parallel). `init` gives a first value from a source the object reads anyway; it does not persist anything, and a cell is not saved anywhere by having one. Methods/listeners/tasks run only on live objects, so their dependencies are live. An object goes live only once its references are ready (live and, for remote ones, hydrated) and the state sources it exposes or derives from have their values, also where they forward a remote reference held some other way; waiting for references is bounded by the host's `readyTimeout` (default 60 s, `unavailable/ready_timeout`, also for a reference cycle across hosts). External consumers holding a `loading` reference (`host.hold`): calls wait until live (ctx cancellation, deadline); state members throw `not_ready` until the first hydration. Activation failures propagate to dependents and all waiting gets; the next get retries. ### Lifetime @@ -176,7 +176,8 @@ Inside the factory, placeholders must not be used (`resource.value` and an `init - Principals are the app's type `P` (`createHost

`, `Host

`, `defineFacet

`, `Factory`, `Ctx

`; default `unknown`). Chord invents none: `createHost({ self })` is the principal of the host's own code (roots, code inside objects, `lifetime: "host"` objects), `connect(link, { principal })` what the parent's own requests act as, `accept(link, { principal })` what a link acts as, `Route.principal(hostName)` what a routed host acts as when it calls back. All are required. - `createHost({ principalKey })` maps principal to a key that includes connection provenance and grant revision. Objects are per principal key. The host's own principal is recognized by key (`principalKey(p) === principalKey(self)`), not by object identity, so an equal principal never creates a second object. - Remote factories return `{ variant, ...members }`; `variant` must be a declared variant; members must match it. Local factories return any object. `variant` is reserved as a member name. -- Reference: `status` (`loading | live | released | gone`), `ready` promise, `variant`, `shape` (`{ variant, members, contract, codeVersion }`), `onStatus(listener)` (every status change, with the error for `gone`), late-bound members, `release()`. A remote reference's state member holds a value only while subscribed (`undefined` after its last subscriber leaves). Shape changes (reload, variant) are pushed; members missing from the current variant reject `not_in_variant`; released references reject `released`; forced disposal makes references `gone`. +- Reference: `status` (`loading | live | released | gone`), `ready` promise, `variant`, `shape` (`{ variant, members, contract }`), `onStatus(listener)` (every status change, with the error for `gone`), late-bound members, `release()`. `self.get` is synchronous (the object goes live after its references are ready); `ctx.get` and `host.get` resolve once the reference is ready: live, and every state member of its variant hydrated (`state: false`: live only). They reject with the activation error, `aborted` (ctx aborted or settled, `signal`), or `unavailable/ready_timeout`, releasing only that reference. `host.hold` is the synchronous form for UI runtimes. A held remote reference stays subscribed to its variant's state until released; after a link loss its last values stay readable while it re-holds. State members are typed `T`; reading before the first hydration throws `not_ready`, outside the variant `not_in_variant`, after the end the end's error. A remote reference type is a union discriminated by `variant`. Shape changes (reload, variant) are pushed; members missing from the current variant reject `not_in_variant`; released references reject `released`; forced disposal makes references `gone`. +- Shared bindings: remote references on a host with the same key (type, normalized input identity, principal key, consumer contract fingerprint) are views onto one binding: one hold, one stream per state member, calls over that hold. Views have their own owner (object, call, external), status, observers and `release`; the binding ends (gone) or re-holds for all of them. Without views a binding closes, or lingers for `linger.types[typeName]` ms (`createHost({ linger: { types, cap } })`, at most `cap`, default 4, least recently released first) while staying held and hydrated. - Methods are `(args, ctx) => Promise`. Factories still check arguments; variants are not complete authorization. - Revocation: `host.invalidatePrincipal(match)` force-disposes matching objects on this host and propagates over links; links re-stamp or close. @@ -192,7 +193,7 @@ Inside the factory, placeholders must not be used (`resource.value` and an `init ## 6. Roots and code versions -- `Ctx = { signal, rootId, codeVersion, get(...) , principal? }`. Explicit; no AsyncLocalStorage dependency (may be used in dev to warn). +- `Ctx = { signal, rootId, codeVersion, get(...): Promise, principal? }`. Explicit; no AsyncLocalStorage dependency (may be used in dev to warn). - A link creates a root for a request that carries none; nested remote calls carry `{rootId, codeVersion}`. - Dispatch: a call with code version E runs the object's implementation for E. A host retains at most `current` and `previous` code versions (two-version rule). A call for a code version the host doesn't have (older than previous, or host started later) is rejected `superseded` (retryable by starting a new root). Never substitute another code version. - Browsers never carry code versions into the server: browser calls start roots at the server. Browsers learn of changes via shape/reset pushes and a server `Manifest` object; incompatible contract fingerprint -> `upgrade_required`. @@ -235,9 +236,10 @@ Per-code-version immutable tables; order = first registration order, kept on rep - Principal stamping: the accepting side stamps the principal of what connects (from its authenticator); forwarded principals accepted only from the parent link; the parent's own requests (no forwarded principal) act as the principal given to `connect`; payload principal fields ignored. - A child that connects behind its parent's code version is fenced (nothing routed to it) and reconciled by a reload of its modules to the parent's code version. Calls its roots of the old code version make upstream are admitted in the parent's current code version (the parent never ran that code version), not rejected `superseded`. - Relay: server forwards a hold to the child link of its target host (`at`, resolved by the consumer from the contract's placement and the normalized input, and checked against both), mapping references per origin connection; authorizes before resolving/spawning and re-checks after. -- Wire frames (JSON): `hello`/`bye`, `hold`/`release` (a reference over a link: `hold` returns `shape` or `gone`; there is no separate `get`), `shape`, `call`/`result`/`cancel`, `sub`/`unsub` and `state` (`snap`/`ops`/`reset` with a per-subscription delta encoder), `gone`, `root_end`, `retire`, `invalidate`, `ping`/`pong`, `ack`, reload control (`prepare`/`vote`/`commit`/`abort`). -- Protocol version: `hello` carries `protocol` (`PROTOCOL_VERSION`, now 3; absent means 1). A side that sees another version processes nothing more, calls `LinkOptions.onProtocolMismatch({ local, peer })` and closes the link with reason `protocol-version `; the other side's connection reads that reason and reports the mismatch too. Peers do not negotiate: the outdated side must load the current code (a browser reloads its page). Frame fields follow the version: the `shape` frame names the variant `variant`, code versions travel as `codeVersion` (2); remote contract tokens carry their placement (`host`), and a `hold` carries the input normalized by the consumer (3). -- Flow control: both ends announce `acks` in their hello and acknowledge what they processed (`ack`, a cumulative count of every frame but `ack`). While the frames not yet acknowledged exceed the window (`window`, default 256 KiB of JSON text), state frames wait at the sender, one slot per subscription: ops are appended, a snapshot or reset replaces the slot, an unsubscription drops it, and a slot that outgrows four windows becomes a reset of the current value (a relay subscribes downstream anew instead). Every other frame is sent at once. Links that report no frame sizes (IPC) are never windowed; a peer without `acks` is neither windowed nor acknowledged. An acceptor can limit the calls a peer has in progress (`maxCalls`); calls beyond it are answered at once with `unavailable/too_many_calls`. Nothing outside the connection may add frames to a link: acknowledgements count them. +- Wire frames (JSON): `hello`/`bye`, `hold`/`release` (a reference over a link: `hold` returns `shape` or `gone`; there is no separate `get`), `shape`, `call`/`result`/`cancel`, `sub`/`unsub`/`resync` and `state`, `gone`, `root_end`, `retire`, `invalidate`, `ping`/`pong`, `ack`, reload control (`prepare`/`vote`/`commit`/`abort`). +- State: a `hold` implies the state of its variant (`state: false`: none until `sub`; `unsub` stops it). The provider answers with units: a `shape` whose `state` lists the members the consumer lacks, followed by one `state` frame `as: "snap"` per listed member; the consumer applies the shape once those snapshots are here. Later `state` frames of a member (addressed by hold id and member `m`) are `ops` (per-stream delta encoder), `reset`, or `error`; a `snap`/`reset` without `value` carries `undefined`. A failed snapshot before the first hydration ends the reference with its error. A reload or variant change sends a unit with the members that are new; members that left stop. `again` marks a re-hold after a link loss (the consumer has values). `origin` names the client connection a hold comes from (stamped by the host that accepted the client, forwarded by relays, honored only from the parent link). +- Protocol version: `hello` carries `protocol` (`PROTOCOL_VERSION`, now 4; absent means 1). A side that sees another version processes nothing more, calls `LinkOptions.onProtocolMismatch({ local, peer })` and closes the link with reason `protocol-version `; the other side's connection reads that reason and reports the mismatch too. Peers do not negotiate: the outdated side must load the current code (a browser reloads its page). Frame fields follow the version: the `shape` frame names the variant `variant`, code versions travel as `codeVersion` (2); remote contract tokens carry their placement (`host`), and a `hold` carries the input normalized by the consumer (3); a hold implies its state, units, `origin`, and `shape` without a code version (4). +- Flow control: both ends announce `acks` in their hello and acknowledge what they processed (`ack`, a cumulative count of every frame but `ack`). While the frames not yet acknowledged exceed the window (`window`, default 256 KiB of JSON text), state waits at the sender in slots flushed in creation order: one per member stream (ops are appended, a snapshot or reset replaces it, a release or `unsub` drops it) and one per hold for a unit (lazy: built from the current values when sent; a newer unit merges into it). Waiting ops are capped per link (`pendingCap`, default four windows): beyond, the oldest streams become a reset of their current value built at flush (a relay sends `resync` downstream and drops that member's ops until the reset arrives). The first unit of a hold that is not `again` and every frame but state are sent at once. Links that report no frame sizes (IPC) are never windowed; a peer without `acks` is neither windowed nor acknowledged. An acceptor can limit the calls a peer has in progress (`maxCalls`); calls beyond it are answered at once with `unavailable/too_many_calls`. Nothing outside the connection may add frames to a link: acknowledgements count them. ## 10. Host manager (library) @@ -278,9 +280,9 @@ The tests cover at least: lifetime/references/linger/forced disposal/gone; activ - **Validation.** Remote calls are validated at reference dispatch, in-host too: a direct binding against the provider's schema, a binding through an adapter against the consumer's. A provider validates each snapshot and reset of a remote state member once per (re)subscription, against the consumer's contract through an `accepts` adapter and the provider's otherwise; ops are validated only in dev mode. At reload, the new provider's description must cover the old provider's when the version is unchanged, in addition to the check against live consumers and facets' `uses`. - **Contracts.** Local contracts list their members, so a loading reference knows whether a member is a method, state or opaque. Canonical identity omits input fields equal to their default; with TypeBox, use `Type.Optional(Type.X({ default }))` to let callers omit them. Provider variants must cover consumer variants; members the consumer's contract does not list are hidden. Schema change detection compares canonical JSON, so reordering `anyOf` branches counts as a change. Fingerprints use a non-cryptographic 64-bit hash: synchronous and browser-safe; contracts are trusted. - **Principals.** A root ctx at ingress acts as its principal (`host.root(fn, { principal })`); contexts inside objects act as `host`. The acceptor decides a link's kind and name (`accept(link, { principal, kind, name })`); only configured host links are addressable, carry roots and may vote, and control frames are accepted only from the parent. Clients never carry roots or code versions. Relaying needs a matching `route`; `authorize(principal, hostName)` runs before `resolve` and again after, and revoking or re-stamping a principal closes its relays at once. `invalidatePrincipal(match)` re-stamps or closes matching links and propagates pattern matches to child hosts. -- **Cells and readables.** A value returned from `change` replaces the value and is always validated; `replace` transfers ownership without copying; by default (`{ diff }` overrides) it publishes a diff when the new value reuses a container of the old one at the same key, else `r`. `subscribe(fn)` delivers values, starting with the current one; `events(fn)` delivers `snap`, then `ops`, or `reset`. Reference state members are stable readables, `undefined` while loading or outside the variant, and reset when their source moves. Remote state members hydrate only while subscribed and forget their value when the last listener leaves. +- **Cells and readables.** A value returned from `change` replaces the value and is always validated; `replace` transfers ownership without copying; by default (`{ diff }` overrides) it publishes a diff when the new value reuses a container of the old one at the same key, else `r`. `subscribe(fn)` delivers values, starting with the current one; `events(fn)` delivers `snap`, then `ops`, or `reset`. Reference state members are stable readables that throw `not_ready` while loading and `not_in_variant` outside the variant, and reset when their source moves; a listener added before they are readable gets its snapshot then. Remote state members stay hydrated while the reference is held. - **References and calls.** A reference's identity is its address; a second `self.get` of the same address in one run returns the same reference. A reference call without ctx, or with a task ctx, starts a fresh root in the current code version; a ctx whose call or root already settled is refused (`aborted/expired`), one from another host `invalid/foreign_ctx`. Calling a reference inside a factory throws `not_ready`. - **Resources and disposal.** New and replaced exclusive resources open at the gate, and so do non-exclusive resources of the same object that depend on them. A new object for an address that is still being disposed waits for that disposal. A resource open that completes after its signal aborted closes its value at once. - **Links and the host manager.** Every received frame is validated against its schema; malformed frames, duplicate ids and control frames from the wrong side close the link. Each connection pings every `pingInterval` and closes after `pingTimeout` without a frame. The child holds a kernel lock (an abstract socket on Linux and Android, a named pipe on Windows, a probed socket file elsewhere), acquired by `childControl().lock()` before storage opens. The manager kills the child's process group on every exit path and derives `booting` and `idleFor` from the child's liveness reports. WebSocket support is structural, so Chord does not depend on `ws`. -- **Flow control holds back state only.** A relay (Cloudflare to a browser) can hide a slow reader, so transport backpressure cannot be relied on; acknowledgements are end to end. Results must not wait behind state, so links are FIFO except that waiting state frames can be overtaken: a result may arrive before state changes made before it. Frames of one subscription stay in order. Waiting state is coalesced rather than queued, so a stalled peer costs at most a bounded slot per subscription, and the next frame it gets is current. +- **Flow control holds back state only.** A relay (Cloudflare to a browser) can hide a slow reader, so transport backpressure cannot be relied on; acknowledgements are end to end. Results must not wait behind state, so links are FIFO except that waiting state frames can be overtaken: a result may arrive before state changes made before it. Frames of one subscription stay in order. Waiting state is coalesced rather than queued and capped per link, so a stalled peer costs bounded memory, and the next frame it gets is current. The first unit of a new reference is not held back, so a `get` never waits behind other streams. - **The state API stays.** Objects keep named cells and unnamed derivations; giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem. diff --git a/packages/chord/docs/tutorial.md b/packages/chord/docs/tutorial.md index cebc19fcb..fe16dce4c 100644 --- a/packages/chord/docs/tutorial.md +++ b/packages/chord/docs/tutorial.md @@ -79,14 +79,14 @@ call from that root. const host = createHost({ self: "server" }); host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` })); const result = await host.root(async (ctx) => { - const todos = ctx.get(TodoLabel, { list: "family" }); + const todos = await ctx.get(TodoLabel, { list: "family" }); return todos.label(ctx); }); assert.equal(result, "Todos for family"); ``` -The result is `"Todos for family"`. `ctx.get` returns a reference immediately; -method calls wait for the object to become live. The reference acquired through +The result is `"Todos for family"`. `await ctx.get` resolves once the object is +live (and, as we will see, has its state). The reference acquired through `ctx.get` is released when this root ends. We never receive the factory's returned object directly. A standalone call without `ctx`, such as `todos.label()`, starts its own root. A method without arguments takes only @@ -185,8 +185,7 @@ receive the same resulting value. ```ts const host = createHost({ self: "server", dev: true }); host.serve(TodoStore, todoFactory); -const todos = host.get(TodoStore, { list: "family" }); -await todos.ready; +const todos = await host.get(TodoStore, { list: "family" }); const counts: (number | undefined)[] = []; const stop = todos.openCount.subscribe((value) => counts.push(value)); await host.root(async (ctx) => { @@ -204,8 +203,8 @@ The count subscriber sees `0, 1, 0`: initially empty, one unfinished todo, then no unfinished todos. Resetting to empty does not add another count notification because the derived count is still zero. The final rows are `[]`. `subscribe` calls back immediately with the current value and returns an -unsubscribe function. We await `ready` first so that value is not a loading -placeholder. +unsubscribe function. The awaited `get` makes sure there is a value: a reference +you hold is live, with its state. > **Why:** Store reloadable state in cells, not closure variables. Treat > `cell.value`, `ref.rows.value`, and subscriber values as immutable. @@ -286,9 +285,8 @@ directly so we can inspect what was saved. ```ts -const saved = host.get(SavedTodos, { list: "family" }); -const database = host.get(Database, {}); -await saved.ready; +const saved = await host.get(SavedTodos, { list: "family" }); +const database = await host.get(Database, {}); await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx)); assert.deepEqual(database.db!.saved, ["Buy milk"]); assert.equal(opens, 1); @@ -305,7 +303,7 @@ After the method, the fake database contains `["Buy milk"]`. It opened once, even though two objects referenced it. `SavedTodos` and `Database` become live only after their resources and declared dependencies are ready. That is why `database.db!` is safe inside this live object's method: activation has -already established that dependency. An outside caller must await readiness. +already established that dependency. An outside caller awaits its `get`. When both external references are released, `SavedTodos` is disposed and releases its own database reference. With no references left, the database closes; we @@ -387,20 +385,22 @@ revision, not display names. const host = createHost({ name: "server", self: "server", principalKey: (principal) => principal }); host.serve(TodoStore, todoFactory); serveViews(host); -const owner = host.get(Todos, { list: "family" }, { principal: "owner" }); -const guest = host.get(Todos, { list: "family" }, { principal: "guest" }); -await Promise.all([owner.ready, guest.ready]); -assert.equal(owner.variant, "editor"); +const owner = await host.get(Todos, { list: "family" }, { principal: "owner" }); +const guest = await host.get(Todos, { list: "family" }, { principal: "guest" }); +assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant assert.equal(guest.variant, "viewer"); await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" }); assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]); +// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider. await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant")); await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid")); ``` The owner gets `editor`; the guest gets `viewer`. Both see the same milk -todo. Calling the guest's `add` rejects with `not_in_variant`, even though -TypeScript exposes methods from all variants on a reference. Whitespace-only text +todo. A reference is typed as the union of its variants: `add` is reachable only +after narrowing on `variant` (`assert.ok(owner.variant === "editor")` does that). +The guest's `add` is refused by TypeScript, and, called anyway, rejects with +`not_in_variant`. Whitespace-only text rejects with `invalid`: it is a string, as the schema demands, but it breaks a rule only the method knows. Use `invalid` for bad input and `forbidden` only for callers who may not do something. @@ -443,35 +443,32 @@ const browser = createHost({ kind: "client", upstream: { link: up, principal: "server" }, }); -const todos = browser.get(Todos, { list: "family" }); -const hydrated = Promise.withResolvers(); +const todos = await browser.get(Todos, { list: "family" }); +assert.ok(todos.variant === "editor"); +assert.deepEqual(todos.rows.value, []); const updated = Promise.withResolvers(); const stop = todos.rows.subscribe((rows) => { - if (rows !== undefined) hydrated.resolve(); - if (rows?.length === 1) updated.resolve(); + if (rows.length === 1) updated.resolve(); }); -await todos.ready; -await hydrated.promise; -assert.deepEqual(todos.rows.value, []); await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx)); await updated.promise; assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]); // Deliberately bypass TypeScript to simulate a malformed caller. await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid")); stop(); -assert.equal(todos.rows.value, undefined); +// A held reference keeps its state without listeners. +assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]); todos.release(); ``` The browser sees an empty snapshot, then the new milk todo. `ready` means -the remote reference has bound to a live object; it does **not** guarantee that -subscribed state has arrived. That is why we separately await `hydrated` and -`updated`. A remote readable starts at `undefined` and returns to -`undefined` when its last subscriber leaves. +the remote reference has bound to a live object **and** has its state: every +state member of its variant has a snapshot. A held reference stays subscribed, +with or without listeners, until it is released. What crosses the wire: the input and consumer's contract description, the -provider's shape (variant and members), method arguments and results, and -subscribed state. State starts with a **snapshot**, the whole current value; +provider's shape (variant and members), method arguments and results, and the +state of the reference's variant. State starts with a **snapshot**, the whole current value; subsequent **deltas** describe changes. Rebinds and reloads can send a `reset`, a fresh whole value. Functions, resource connections, and cell instances do not cross the wire. @@ -528,8 +525,7 @@ host.serve(Activity, (_input, _principal, self) => { }); return { seen }; }); -const activity = host.get(Activity, {}); -await activity.ready; +const activity = await host.get(Activity, {}); await Promise.all([seeded.promise, observed.promise]); assert.equal(activity.seen.value, 1); activity.release(); @@ -591,7 +587,7 @@ const menuFacet: Facet = { self.register(Actions, { addSample: async (ctx) => { await todos.add({ text: "Try plugins" }, ctx); - return todos.openCount.value!; + return todos.openCount.value; }, }); return {}; @@ -664,7 +660,7 @@ const facet = (label: string): Facet => ({ })), }); await host.load([facet("Original")]); -const todos = host.get(TodoStore, { list: "family" }); +const todos = await host.get(TodoStore, { list: "family" }); await todos.add({ text: "Buy milk" }); const entered = Promise.withResolvers(); const resume = Promise.withResolvers(); @@ -770,7 +766,7 @@ await host.reload([ }, ]); assert.equal(await todos.label(), "Todos for family"); -const newer = host.get(RenamedStore, { list: "family" }); +const newer = await host.get(RenamedStore, { list: "family" }); assert.equal(await newer.heading(), "Todos for family"); assert.equal(todos.rows.value?.[0]?.text, "BUY MILK"); ``` @@ -812,7 +808,8 @@ const fake: Facet = { }; await host.load([fake]); serveViews(host); -const owner = host.get(Todos, { list: "family" }, { principal: "owner" }); +const owner = await host.get(Todos, { list: "family" }, { principal: "owner" }); +assert.ok(owner.variant === "editor"); await owner.add({ text: "Test delegation" }); assert.deepEqual(received, ["Test delegation"]); await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid")); @@ -827,8 +824,8 @@ message's wording. Our other tests exercise the real cells (section 3), resource cleanup (4), principal variants (5), a client over a memory link (6), and callers kept through -reload (9). For remote state tests, await the value you need, not just -`ref.ready`. Always unsubscribe and close hosts; leaked references can hide +reload (9). After an awaited `get` the state is there; for later changes, await +the value you need. Always unsubscribe and close hosts; leaked references can hide lifetime bugs or leave background tasks running. > **Tip:** A fake must honor the contract it serves. This fake delegates all @@ -845,10 +842,9 @@ screen should not lose its data merely because a button click finishes. Use ```ts const host = createHost({ self: "server" }); host.serve(TodoStore, todoFactory); -const screen = host.get(TodoStore, { list: "family" }); -await screen.ready; +const screen = await host.get(TodoStore, { list: "family" }); await host.root(async (ctx) => { - const temporary = ctx.get(TodoStore, { list: "family" }); + const temporary = await ctx.get(TodoStore, { list: "family" }); await temporary.add({ text: "Keep roots short" }, ctx); }); // The temporary reference is released here; screen still holds the store. assert.equal(screen.openCount.value, 1); @@ -874,7 +870,8 @@ explicit load/save policy. - Keep roots short and tasks cancellable: migration and shutdown need work to settle. - Check grants and arguments, not just variants: available methods are not a full policy. - Stamp principals at trusted ingress, not from client payloads: names are not identities. -- Subscribe before expecting remote state: readiness is not snapshot hydration. +- Await `get` before reading state: a reference you hold is live and hydrated, a + loading one (`host.hold`) throws `not_ready`. - Release references and unsubscribe: otherwise unused objects remain referenced or observed. - Bump state versions, contract versions, and resource versions for their own changes: each protects a different boundary. diff --git a/packages/chord/src/cell.ts b/packages/chord/src/cell.ts index 3d04b0231..65d391128 100644 --- a/packages/chord/src/cell.ts +++ b/packages/chord/src/cell.ts @@ -59,6 +59,28 @@ class Fanout { } } +/** + * Whether a state source has its value (entry 8): a remote reference's member before its first snapshot has none, and + * neither has anything derived from or forwarding it. Sources without this symbol always have their value. + */ +export const HYDRATION: unique symbol = Symbol("chord.hydration"); + +export interface Hydration { + readonly hydrated: boolean; + /** Resolves once hydrated; rejects if the source ends first. */ + whenHydrated(): Promise; +} + +/** The hydration of a readable, or undefined for a source that always has its value. */ +export function hydrationOf(value: unknown): Hydration | undefined { + if (value === null || typeof value !== "object") return undefined; + return (value as { readonly [HYDRATION]?: Hydration })[HYDRATION]; +} + +/** The hydrations of `sources` that are not hydrated yet. */ +export const unhydrated = (sources: Iterable): Hydration[] => + [...sources].map(hydrationOf).filter((hydration): hydration is Hydration => hydration?.hydrated === false); + /** An object-owned state source (cell or derivation). Writes recompute dependents atomically. */ export abstract class Source implements Readable { readonly key: string; @@ -315,6 +337,16 @@ export class Derived extends Source { return this.#value; } + /** Hydrated once every source is (a derivation over a remote member waits for its snapshot). */ + get [HYDRATION](): Hydration | undefined { + const pending = unhydrated(this.sources); + if (pending.length === 0) return undefined; + return { + hydrated: false, + whenHydrated: () => Promise.all(pending.map((hydration) => hydration.whenHydrated())).then(() => {}), + }; + } + /** * Computes the first value (or takes it from `precomputed`, a reload's validated dry run) and starts following * the sources (at install, never inside a factory). diff --git a/packages/chord/src/connection.ts b/packages/chord/src/connection.ts index c6eb30594..0d1efa974 100644 --- a/packages/chord/src/connection.ts +++ b/packages/chord/src/connection.ts @@ -4,9 +4,9 @@ import { ChordError, invalid } from "./errors.ts"; import type { HostImpl } from "./host.ts"; import { type Link, Packed, unpack } from "./link.ts"; import { type CtxImpl, RefImpl, variantOf } from "./object.ts"; -import type { RemoteChannel, RemoteRef, StateSink } from "./remote.ts"; +import type { RemoteBinding, RemoteChannel } from "./remote.ts"; import { assertValid } from "./schema.ts"; -import type { Ctx, Readable, StateEvent } from "./types.ts"; +import type { Ctx, Readable, Shape, StateEvent } from "./types.ts"; import { type Frame, fromWireError, isFrame, PROTOCOL_VERSION, type RootRef, toWireError } from "./wire.ts"; export interface LinkOptions { @@ -19,6 +19,11 @@ export interface LinkOptions { * applies when both ends acknowledge frames and the link reports sizes; `Infinity` never holds state back. */ readonly window?: number; + /** + * Bytes of state ops that may wait for the peer on this link (default 4 windows). Beyond, the oldest waiting streams + * are replaced by their current value (a reset), so memory stays bounded however long the peer stalls. + */ + readonly pendingCap?: number; /** * The peer speaks another link protocol version (`peer`: undefined if it did not say): the link closes, and should * not be reconnected with the same code. A browser reloads its page, for instance. @@ -27,16 +32,34 @@ export interface LinkOptions { } const DEFAULT_WINDOW = 256 * 1024; -/** A waiting subscription keeps at most this many windows of ops; beyond, its current value replaces them. */ +/** The default cap of waiting state ops per link, in windows. */ const PENDING_WINDOWS = 4; type StateFrame = Extract; /** - * What waits for one subscription: a snapshot, reset or error (`base`), then encoded ops to apply after it, in order. - * Ops stay as they arrived (the receiving decoder validates them). + * How a waiting stream recovers when its ops must be dropped: the provider resends the current value as a reset at + * flush; a relay (which has no value) asks downstream for one. */ -type PendingState = { base: StateFrame | undefined; ops: (readonly unknown[])[]; size: number }; +type Overflow = { readonly resend: () => void } | { readonly resync: () => void }; + +/** + * What waits on a link. A unit slot is lazy: the shape and snapshots are built from the current values when it is sent. + * A state slot keeps a snapshot, reset or error (`base`) and encoded ops to apply after it, in order (as they arrived; + * the receiving decoder validates them), or, once `lazy`, only the promise of a reset built at flush. + */ +type Slot = + | { readonly kind: "unit"; readonly hold: number; readonly flush: () => void } + | { + readonly kind: "state"; + readonly hold: number; + readonly m: string; + base: StateFrame | undefined; + ops: (readonly unknown[])[]; + size: number; + lazy: boolean; + readonly overflow: Overflow; + }; export interface AcceptOptions

extends LinkOptions { /** The principal of whatever connects, from the app's authenticator. Every request on the link acts as it. */ @@ -63,10 +86,34 @@ export interface Peer { readonly name: string | undefined; } +/** A state member's stream to the consumer: its subscription and delta encoder. */ +interface Feed { + unsubscribe: () => void; + encoder: Encoder; + /** Events that arrived while a unit or reset was being built (replayed after it is sent). */ + buffer: StateEvent[] | undefined; +} + +/** A hold the peer made on this host and serves locally. */ +interface ServedLocal { + readonly kind: "local"; + readonly id: number; + readonly ref: RefImpl; + readonly principal: unknown; + /** The client connection it comes from (see `Frame` hold `origin`); undefined for a host's own holds. */ + readonly origin: string | undefined; + /** The consumer wants state (hold without `state: false`, or `sub`). */ + wantsState: boolean; + /** The consumer had values before (re-hold): its first unit waits for the window like any other. */ + readonly again: boolean; + /** A unit was sent. */ + unitSent: boolean; + /** The members whose snapshot the consumer has, with their streams. */ + readonly feeds: Map; +} + /** A hold the peer made on this host: served locally or relayed to another link. */ -type Served = - | { readonly kind: "local"; readonly ref: RefImpl; readonly principal: unknown; readonly feeds: Set } - | { readonly kind: "relay"; readonly relay: RelayRef }; +type Served = ServedLocal | { readonly kind: "relay"; readonly relay: RelayRef }; type Pending = { readonly resolve: (value: unknown) => void; readonly reject: (error: unknown) => void }; @@ -75,29 +122,42 @@ const unavailable = (message: string): ChordError => new ChordError("unavailable // ─── relay ─────────────────────────────────────────────────────────────────── /** - * A hold relayed from an origin link to a downstream link. It owns everything done through it: subscriptions and - * calls queued while the route resolves, and the downstream twins once forwarded. `close` undoes all of it exactly - * once (cancels calls downstream, drops subscriptions, releases the downstream hold, rejects waiters). + * A hold relayed from an origin link to a downstream link. It owns everything done through it: state and calls queued + * while the route resolves, and the downstream twins once forwarded. `close` undoes all of it exactly once (cancels + * calls downstream, drops what waits for the origin, releases the downstream hold, rejects waiters). */ export class RelayRef { readonly origin: Connection; readonly frame: Extract; readonly principal: unknown; - readonly client: boolean; + /** The client connection the hold comes from (forwarded downstream); undefined for a host's own holds. */ + readonly originTag: string | undefined; /** The origin's authorization generation when the hold arrived; a revocation bumps it. */ readonly generation: number; down: Connection | undefined; downId = 0; closed = false; + /** The origin wants state (forwarded as `state: false`, `sub`, `unsub`). */ + stateWanted: boolean; + /** The members of the first unit, still to forward past the origin's window. */ + urgent: Set | undefined; + #shaped = false; + /** Members whose ops this relay dropped: downstream ops are ignored until their reset arrives. */ + readonly awaiting = new Set(); readonly calls = new Map(); - readonly subs = new Map(); readonly #waiters = new Set(); - constructor(origin: Connection, frame: Extract, principal: unknown, client: boolean) { + constructor( + origin: Connection, + frame: Extract, + principal: unknown, + originTag: string | undefined, + ) { this.origin = origin; this.frame = frame; this.principal = principal; - this.client = client; + this.originTag = originTag; + this.stateWanted = frame.state !== false; this.generation = origin.generation; } @@ -105,6 +165,10 @@ export class RelayRef { return this.frame.id; } + get client(): boolean { + return this.originTag !== undefined; + } + /** Still authorized as it was when it arrived. */ get current(): boolean { return !this.closed && this.origin.open && this.origin.generation === this.generation; @@ -119,61 +183,73 @@ export class RelayRef { }); } - /** Forwards the hold (and what queued meanwhile) to `down`. */ + /** Forwards the hold to `down`. */ open(down: Connection): void { if (this.closed) return; this.down = down; this.downId = down.nextId(); down.relayedRefs.set(this.downId, this); + const { at, token, input } = this.frame; down.send({ - ...this.frame, + t: "hold", id: this.downId, + at, + token, + input, principal: this.principal, - ...(this.client ? { client: true } : {}), + ...(this.originTag === undefined ? {} : { origin: this.originTag }), + ...(this.stateWanted ? {} : { state: false as const }), + ...(this.frame.again === true ? { again: true as const } : {}), }); - for (const [originSubId, entry] of this.subs) - if (entry.downId === undefined) this.#forwardSub(originSubId, entry); for (const waiter of this.#waiters) waiter.resolve(down); this.#waiters.clear(); } - subscribe(originSubId: number, member: string): void { - const entry = { member, downId: undefined as number | undefined }; - this.subs.set(originSubId, entry); - this.origin.relaySubs.set(originSubId, this); - if (this.down !== undefined) this.#forwardSub(originSubId, entry); - } - - #forwardSub(originSubId: number, entry: { readonly member: string; downId: number | undefined }): void { - const down = this.down!; - entry.downId = down.nextId(); - down.relayedSubs.set(entry.downId, { relay: this, originSubId }); - down.send({ t: "sub", id: entry.downId, hold: this.downId, member: entry.member }); + /** The origin starts or stops the hold's state. */ + wantState(wanted: boolean): void { + if (this.closed || wanted === this.stateWanted) return; + this.stateWanted = wanted; + if (!wanted) { + this.origin.dropHoldState(this.originId); + this.awaiting.clear(); + } + this.down?.send({ t: wanted ? "sub" : "unsub", hold: this.downId }); } /** - * The origin could not keep up and dropped what waited for this subscription: subscribe downstream anew, so the - * provider's snapshot (a reset for the origin's consumer) replaces the ops that were lost. + * The origin could not keep up and dropped what waited for member `m` (or asked for it again): ask downstream for a + * reset, and ignore its ops until the reset arrives. */ - resync(originSubId: number): void { - const entry = this.subs.get(originSubId); - this.origin.dropState(originSubId); - if (entry?.downId === undefined || this.down === undefined) return; - this.down.relayedSubs.delete(entry.downId); - this.down.send({ t: "unsub", id: entry.downId }); - this.#forwardSub(originSubId, entry); + resync(m: string): void { + if (this.closed) return; + this.awaiting.add(m); + this.down?.send({ t: "resync", hold: this.downId, m }); } - unsubscribe(originSubId: number, forward = true): void { - const entry = this.subs.get(originSubId); - if (entry === undefined) return; - this.subs.delete(originSubId); - this.origin.relaySubs.delete(originSubId); - this.origin.dropState(originSubId); - if (entry.downId !== undefined && this.down !== undefined) { - this.down.relayedSubs.delete(entry.downId); - if (forward) this.down.send({ t: "unsub", id: entry.downId }); + /** A shape from downstream: forwarded at once; the members of the first unit then skip the origin's window. */ + shape(frame: Extract): void { + if (this.closed) return; + if (!this.#shaped) { + this.#shaped = true; + if (this.frame.again !== true) this.urgent = new Set(frame.state); } + this.origin.send({ ...frame, id: this.originId }); + } + + /** A state frame from downstream. */ + state(frame: StateFrame): void { + if (this.closed) return; + const m = frame.m; + if (this.awaiting.has(m)) { + if (frame.as === "ops") return; + this.awaiting.delete(m); + } + const out: StateFrame = { ...frame, hold: this.originId }; + if (this.urgent?.delete(m) === true) { + this.origin.send(out); + return; + } + this.origin.sendState(out, { resync: () => this.resync(m) }); } /** @@ -184,7 +260,7 @@ export class RelayRef { if (this.closed) return; this.closed = true; for (const call of [...this.calls.values()]) call.cancel(error, options.releaseDown); - for (const originSubId of [...this.subs.keys()]) this.unsubscribe(originSubId, options.releaseDown); + this.origin.dropHoldState(this.originId); if (this.origin.served.get(this.originId)?.kind === "relay") this.origin.served.delete(this.originId); if (this.down !== undefined) { this.down.relayedRefs.delete(this.downId); @@ -235,6 +311,14 @@ export class RelayCall { /** * One end of a link on a host: consumer of remote references, provider for the peer's holds, and relay. * + * State + * ----- + * + * A hold implies the state of its variant (unless it says `state: false`). The provider answers with a unit: the shape, + * then a snapshot of each state member the consumer does not have yet (`shape.state` lists them), then the members' + * ops and resets. A reload or a variant change sends another unit with the members that are new; members that left the + * variant stop. The consumer applies a shape once its snapshots are here (`RemoteBinding`). + * * Flow control * ------------ * @@ -244,20 +328,23 @@ export class RelayCall { * result sent next waits 2.4 s, and the snapshot of a transcript the user already left is still sent in full. * * Solution: the peer acknowledges what it processed (`ack`, a cumulative frame count), and the sender counts the sizes - * of the frames not yet acknowledged. While they exceed the window, *state* frames wait here, one slot per - * subscription: ops are appended to the slot (encoded batches of one stream may be concatenated, see `encoder`), and - * a snapshot, reset or error replaces it. Unsubscribing drops the slot. A slot whose ops outgrow `PENDING_WINDOWS` - * windows is replaced by the current value (a reset), or, on a relay, by a fresh downstream subscription (`resync`), - * so memory stays bounded however long the peer stalls. Acknowledgements send the slots in the order they were - * created, as long as the window allows. + * of the frames not yet acknowledged. While they exceed the window, state waits here in slots, flushed in creation + * order as acknowledgements come: + * - a member's stream has one slot: ops are appended (encoded batches of one stream may be concatenated, see + * `encoder`), a snapshot, reset or error replaces it; + * - a hold's unit has one slot, built lazily from the current values when it is sent, so a newer unit merges into it; + * while it waits, the members it introduces have no stream yet. * - * Everything else (results, shapes, gone, reload control, pings) is sent at once: it is bounded by the requests the - * peer makes, and it must not wait behind state. Hence the one ordering exception of a link: while state waits, a - * result can arrive before state changes made before it. Frames of one subscription stay in order. + * The first unit of a hold that is not a re-hold (`again`) is sent at once, past the window: a `get` waits for it, and + * must not wait behind other holds' state. Results, shapes, gone, reload control and pings are sent at once too: they + * are bounded by the requests the peer makes. Hence the one ordering exception of a link: while state waits, a result + * can arrive before state changes made before it. Frames of one stream stay in order. * - * The window bounds what is unacknowledged when a frame is sent, so one frame (a large snapshot) may exceed it. Both - * hellos must announce `acks`; with an older peer nothing is acknowledged and nothing waits. Links that do not report - * sizes (IPC) never fill the window. + * Waiting ops are capped per link (`pendingCap`): beyond, the oldest streams are replaced by their current value (a + * reset built at flush), or, on a relay, by a reset asked of the provider (`resync`), so memory stays bounded however + * long the peer stalls. The window bounds what is unacknowledged when a frame is sent, so one frame (a large snapshot) + * may exceed it. Both hellos must announce `acks`; with an older peer nothing is acknowledged and nothing waits. Links + * that do not report sizes (IPC) never fill the window. */ export class Connection implements RemoteChannel { readonly host: HostImpl; @@ -265,6 +352,8 @@ export class Connection implements RemoteChannel { /** `upstream`: the link to this host's parent. `downstream`: a link this host accepted. */ readonly role: "upstream" | "downstream"; readonly peer: Peer; + /** A client connection's opaque id: holds that come over it (directly or relayed) carry it as their `origin`. */ + readonly originId: string | undefined; principal: unknown; readonly restamp: ((principal: unknown) => unknown | undefined) | undefined; /** Bumped when the stamped principal is revoked or re-stamped: relays from before are refused. */ @@ -285,6 +374,7 @@ export class Connection implements RemoteChannel { readonly #maxCalls: number; // flow control (see the class comment) readonly #window: number; + readonly #pendingCap: number; /** Whether the peer acknowledges frames; until its hello is here, frames are counted in case it does. */ #peerAcks: boolean | undefined; #sent = 0; @@ -292,26 +382,23 @@ export class Connection implements RemoteChannel { /** Sizes of the frames sent after the last acknowledged one. */ #unacked: number[] = []; #unackedBytes = 0; - readonly #pending = new Map(); + readonly #pending = new Map(); + /** Bytes of ops waiting in `#pending`. */ + #pendingBytes = 0; #received = 0; #ackTimer: ReturnType | undefined; readonly #onProtocolMismatch: LinkOptions["onProtocolMismatch"]; /** The peer speaks another protocol version: nothing it sends is processed any more. */ #mismatched = false; // consumer side: what this host asked of the peer - readonly remotes = new Map(); + readonly remotes = new Map(); readonly calls = new Map(); - readonly subs = new Map void>(); // provider side: what the peer asked of this host readonly served = new Map(); readonly incoming = new Map(); - readonly feeds = new Map void>(); - /** Origin side of relays: the peer's sub ids that are relayed. */ - readonly relaySubs = new Map(); // downstream side of relays: ids this host used on this link readonly relayedRefs = new Map(); readonly relayedCalls = new Map(); - readonly relayedSubs = new Map(); /** Reload votes awaited from this (child) peer. */ readonly votes = new Map(); @@ -322,6 +409,7 @@ export class Connection implements RemoteChannel { readonly role: "upstream" | "downstream"; readonly principal: unknown; readonly peer: Peer; + readonly originId?: string; readonly restamp?: (principal: unknown) => unknown | undefined; readonly maxCalls?: number; } & LinkOptions, @@ -330,10 +418,12 @@ export class Connection implements RemoteChannel { this.link = link; this.role = config.role; this.peer = config.peer; + this.originId = config.originId; this.principal = config.principal; this.restamp = config.restamp; this.synced = !(this.role === "downstream" && this.peer.kind === "host"); this.#window = config.window ?? DEFAULT_WINDOW; + this.#pendingCap = config.pendingCap ?? this.#window * PENDING_WINDOWS; this.#maxCalls = config.maxCalls ?? Number.POSITIVE_INFINITY; this.#onProtocolMismatch = config.onProtocolMismatch; link.onClose((reason) => this.#closed(reason)); @@ -375,33 +465,114 @@ export class Connection implements RemoteChannel { this.#unackedBytes += size; } - /** Sends a state frame, or keeps it waiting (see the class comment). False: the slot is full, send a reset. */ - sendState(frame: StateFrame): boolean { - let slot = this.#pending.get(frame.id); + /** Sends a state frame, or keeps it waiting (see the class comment). */ + sendState(frame: StateFrame, overflow: Overflow): void { + const key = stateKey(frame.hold, frame.m); + let slot = this.#pending.get(key); if (slot === undefined && !this.#full) { this.send(frame); - return true; + return; } + if (slot?.kind === "unit") return; if (slot === undefined) { - slot = { base: undefined, ops: [], size: 0 }; - this.#pending.set(frame.id, slot); + slot = { + kind: "state", + hold: frame.hold, + m: frame.m, + base: undefined, + ops: [], + size: 0, + lazy: false, + overflow, + }; + this.#pending.set(key, slot); } - if (frame.ops === undefined) { + if (frame.as !== "ops") { + this.#pendingBytes -= slot.size; slot.base = frame; slot.ops = []; slot.size = 0; - return true; + slot.lazy = false; + return; } + // A reset built at flush already covers these ops. + if (slot.lazy) return; const size = JSON.stringify(frame.ops).length; - if (slot.size + size > this.#window * PENDING_WINDOWS) return false; - for (const op of frame.ops) slot.ops.push(op); + if (this.#pendingBytes + size > this.#pendingCap) this.#makeRoom(size, slot); + if (slot.lazy || this.#pending.get(key) !== slot) return; + for (const op of frame.ops ?? []) slot.ops.push(op); slot.size += size; - return true; + this.#pendingBytes += size; } - /** Forgets what waits for a subscription that ended. */ - dropState(id: number): void { - this.#pending.delete(id); + /** Replaces the oldest waiting streams by resets until `size` more bytes fit; `current` last, if needed. */ + #makeRoom(size: number, current: Extract): void { + for (const slot of [...this.#pending.values()]) { + if (this.#pendingBytes + size <= this.#pendingCap) return; + if (slot.kind === "state" && slot !== current && slot.size > 0) this.#drop(slot); + } + if (this.#pendingBytes + size > this.#pendingCap) this.#drop(current); + } + + /** Drops what waits for a stream: a lazy reset replaces it (provider), or the relay asks downstream for one. */ + #drop(slot: Extract): void { + this.#pendingBytes -= slot.size; + slot.base = undefined; + slot.ops = []; + slot.size = 0; + if ("resend" in slot.overflow) { + slot.lazy = true; + return; + } + this.#pending.delete(stateKey(slot.hold, slot.m)); + slot.overflow.resync(); + } + + /** Sends member `m` of a served hold again as a reset, through the window (`resync`). */ + #resetLater(hold: number, m: string, resend: () => void): void { + const key = stateKey(hold, m); + let slot = this.#pending.get(key); + if (slot?.kind === "unit") return; + if (slot === undefined) { + slot = { kind: "state", hold, m, base: undefined, ops: [], size: 0, lazy: true, overflow: { resend } }; + this.#pending.set(key, slot); + } else { + this.#pendingBytes -= slot.size; + slot.base = undefined; + slot.ops = []; + slot.size = 0; + slot.lazy = true; + } + this.#flush(); + } + + /** Queues a hold's lazy unit (a waiting one already covers this one), and sends what the window allows. */ + #queueUnit(hold: number, flush: () => void): void { + const key = unitKey(hold); + if (!this.#pending.has(key)) this.#pending.set(key, { kind: "unit", hold, flush }); + this.#flush(); + } + + /** Forgets what waits for a stream that ended. */ + dropState(hold: number, m: string): void { + const slot = this.#pending.get(stateKey(hold, m)); + if (slot?.kind !== "state") return; + this.#pendingBytes -= slot.size; + this.#pending.delete(stateKey(hold, m)); + } + + /** Forgets everything that waits for a hold (released, closed, or its state stopped). */ + dropHoldState(hold: number): void { + for (const [key, slot] of this.#pending) { + if (slot.hold !== hold) continue; + if (slot.kind === "state") this.#pendingBytes -= slot.size; + this.#pending.delete(key); + } + } + + /** What waits for the peer: slots and bytes of ops (for tests and diagnostics). */ + get waiting(): { readonly slots: number; readonly bytes: number } { + return { slots: this.#pending.size, bytes: this.#pendingBytes }; } get #full(): boolean { @@ -415,11 +586,25 @@ export class Connection implements RemoteChannel { } for (const size of this.#unacked.splice(0, n - this.#acked)) this.#unackedBytes -= size; this.#acked = n; - for (const [id, slot] of this.#pending) { + this.#flush(); + } + + /** Sends waiting slots in creation order while the window allows. */ + #flush(): void { + for (const [key, slot] of this.#pending) { if (this.#full) return; - this.#pending.delete(id); + this.#pending.delete(key); + if (slot.kind === "unit") { + slot.flush(); + continue; + } + this.#pendingBytes -= slot.size; + if (slot.lazy) { + if ("resend" in slot.overflow) slot.overflow.resend(); + continue; + } if (slot.base !== undefined) this.send(slot.base); - if (slot.ops.length > 0) this.send({ t: "state", id, ops: slot.ops }); + if (slot.ops.length > 0) this.send({ t: "state", hold: slot.hold, m: slot.m, as: "ops", ops: slot.ops }); } } @@ -462,7 +647,6 @@ export class Connection implements RemoteChannel { else this.principal = next; } - /** A protocol violation: the peer cannot be trusted with anything on this link any more. */ /** * Stops talking to a peer of another protocol version: reports it and closes the link. The close reason tells the * peer this side's version (`protocol-version `); a peer that noticed first told us the same way (`#closed`). @@ -478,6 +662,7 @@ export class Connection implements RemoteChannel { if (close) this.link.close(`protocol-version ${PROTOCOL_VERSION}`); } + /** A protocol violation: the peer cannot be trusted with anything on this link any more. */ #violation(reason: string): void { this.host.report(new ChordError("invalid", `link to ${this.name ?? "peer"}: ${reason}`, { reason: "protocol" })); this.link.close(`protocol error: ${reason}`); @@ -485,19 +670,20 @@ export class Connection implements RemoteChannel { // ─── consumer side (RemoteChannel) ─── - hold(ref: RemoteRef): number { + hold(binding: RemoteBinding, again: boolean): number { const id = this.nextId(); - this.remotes.set(id, ref); - ref.bound(this, id); - const principal = ref.principal; + this.remotes.set(id, binding); + binding.bound(this, id); + const principal = binding.principal; this.send({ t: "hold", id, - at: ref.at, - token: ref.contract.description, - input: ref.input, + at: binding.at, + token: binding.contract.description, + input: binding.input, ...(principal === undefined ? {} : { principal }), - ...(ref.owner === "client" ? { client: true } : {}), + ...(binding.stateOn ? {} : { state: false as const }), + ...(again ? { again: true as const } : {}), }); return id; } @@ -507,6 +693,10 @@ export class Connection implements RemoteChannel { this.send({ t: "release", id }); } + wantState(hold: number, wanted: boolean): void { + if (this.remotes.has(hold)) this.send({ t: wanted ? "sub" : "unsub", hold }); + } + call(hold: number, member: string, args: unknown, root: RootRef | undefined, signal: AbortSignal): Promise { const id = this.nextId(); return new Promise((resolve, reject) => { @@ -538,17 +728,6 @@ export class Connection implements RemoteChannel { }); } - sub(hold: number, member: string, sink: (frame: StateSink) => void): number { - const id = this.nextId(); - this.subs.set(id, sink); - this.send({ t: "sub", id, hold, member }); - return id; - } - - unsub(id: number): void { - if (this.subs.delete(id)) this.send({ t: "unsub", id }); - } - // ─── receiving ─── #receive(frame: unknown): void { @@ -596,10 +775,11 @@ export class Connection implements RemoteChannel { this.incoming.get(frame.id)?.abort(new ChordError("aborted", "cancelled by caller")); return; case "sub": - this.#onSub(frame); - return; case "unsub": - this.#onUnsub(frame.id); + this.#onWantState(frame.hold, frame.t === "sub"); + return; + case "resync": + this.#onResync(frame.hold, frame.m); return; case "shape": case "gone": @@ -687,8 +867,12 @@ export class Connection implements RemoteChannel { return frame.principal ?? this.principal; } - #isClient(frame: { readonly client?: boolean }): boolean { - return this.role === "downstream" ? this.peer.kind === "client" : frame.client === true; + /** + * The client connection a hold comes from: this link's own id for a client link (whatever the frame says), the id + * the parent forwarded for a relayed hold, none for a host's own holds. + */ + #originOf(frame: { readonly origin?: string }): string | undefined { + return this.role === "downstream" ? this.originId : frame.origin; } #onHold(frame: Extract): void { @@ -697,13 +881,14 @@ export class Connection implements RemoteChannel { return; } const principal = this.#principalOf(frame); - const client = this.#isClient(frame); + const origin = this.#originOf(frame); + const client = origin !== undefined; if (frame.at !== this.host.name) { if (this.role === "upstream") { this.send({ t: "gone", id: frame.id, error: toWireError(unavailable(`${this.host.name} does not relay`)) }); return; } - const relay = new RelayRef(this, frame, principal, client); + const relay = new RelayRef(this, frame, principal, origin); this.served.set(frame.id, { kind: "relay", relay }); this.host.relay(relay).catch((error: unknown) => relay.close(error, { releaseDown: true, notify: true })); return; @@ -722,27 +907,176 @@ export class Connection implements RemoteChannel { this.send({ t: "gone", id: frame.id, error: toWireError(client ? clientError(error) : error) }); return; } - const served: Served = { kind: "local", ref, principal, feeds: new Set() }; - this.served.set(frame.id, served); - const shape = (): void => { - const current = ref.shape; - if (current === undefined || this.served.get(frame.id) !== served) return; - this.send({ - t: "shape", - id: frame.id, - variant: current.variant, - members: current.members, - contract: current.contract, - codeVersion: current.codeVersion, - }); + const served: ServedLocal = { + kind: "local", + id: frame.id, + ref, + principal, + origin, + wantsState: frame.state !== false, + again: frame.again === true, + unitSent: false, + feeds: new Map(), }; - ref.onShape(shape); + this.served.set(frame.id, served); + ref.onShape(() => this.#unit(served)); ref.goneListeners.add((error) => { if (this.served.get(frame.id) !== served) return; this.#dropServed(frame.id, false); this.send({ t: "gone", id: frame.id, error: toWireError(error) }); }); - if (ref.status === "live") shape(); + if (ref.status === "live") this.#unit(served); + } + + /** The state members of `shape` the consumer's contract knows. */ + #stateMembers(ref: RefImpl, shape: Shape): string[] { + return shape.members.filter((name) => ref.describe(shape.variant, name)?.kind === "state"); + } + + /** + * The shape changed (live, reload, variant) or state was asked for: members that left lose their stream, and a unit + * goes out with the shape and the members the consumer lacks. The first unit of a hold (not a re-hold) is urgent. + */ + #unit(served: ServedLocal): void { + if (this.served.get(served.id) !== served) return; + const shape = served.ref.shape; + if (shape === undefined) return; + const members = new Set(served.wantsState ? this.#stateMembers(served.ref, shape) : []); + for (const [name, feed] of served.feeds) { + if (members.has(name)) continue; + feed.unsubscribe(); + served.feeds.delete(name); + this.dropState(served.id, name); + } + const urgent = served.feeds.size === 0 && !(served.again && !served.unitSent); + if (urgent) this.#sendUnit(served); + else this.#queueUnit(served.id, () => this.#sendUnit(served)); + } + + /** Builds and sends a unit from the current shape and values. */ + #sendUnit(served: ServedLocal): void { + this.#pending.delete(unitKey(served.id)); + if (this.served.get(served.id) !== served) return; + const shape = served.ref.shape; + if (shape === undefined) return; + served.unitSent = true; + const members = served.wantsState ? this.#stateMembers(served.ref, shape) : []; + const listed = members.filter((name) => !served.feeds.has(name)); + const snaps: StateFrame[] = []; + const attached: (readonly [string, Feed])[] = []; + for (const name of listed) { + const attachment = this.#attach(served, name); + if (attachment === undefined) continue; + snaps.push(attachment.frame); + attached.push([name, attachment.feed]); + } + this.send({ + t: "shape", + id: served.id, + ...(shape.variant === undefined ? {} : { variant: shape.variant }), + members: shape.members, + contract: shape.contract, + state: snaps.map((frame) => frame.m), + }); + for (const frame of snaps) this.send(frame); + for (const [name, feed] of attached) this.#replay(served, name, feed); + } + + /** + * Subscribes member `name` for the consumer: its snapshot (as a frame of type `as`), and a feed whose later events + * wait in its buffer until the snapshot is sent (`#replay`). Undefined if the member is not readable state. + */ + #attach( + served: ServedLocal, + name: string, + as: "snap" | "reset" = "snap", + ): { readonly frame: StateFrame; readonly feed: Feed } | undefined { + const readable = served.ref.member(name) as Readable | undefined; + if (readable === undefined || typeof readable.events !== "function") return undefined; + const feed: Feed = { unsubscribe: () => {}, encoder: encoder(), buffer: [] }; + let first: StateEvent | undefined; + feed.unsubscribe = readable.events((event) => { + if (first === undefined) first = event; + else if (feed.buffer !== undefined) feed.buffer.push(event); + else this.#feedEvent(served, name, feed, event); + }); + served.feeds.set(name, feed); + return { frame: this.#snapshot(served, name, as, first?.value), feed }; + } + + /** Sends the events that arrived while the snapshot was built, now that it is sent. */ + #replay(served: ServedLocal, name: string, feed: Feed): void { + const buffered = feed.buffer ?? []; + feed.buffer = undefined; + for (const event of buffered) this.#feedEvent(served, name, feed, event); + } + + /** A member's state frame for a snapshot or reset: validated against the schema the consumer is promised. */ + #snapshot(served: ServedLocal, name: string, as: "snap" | "reset", value: unknown): StateFrame { + try { + validateSnapshot(served.ref, name, value); + } catch (error) { + return { t: "state", hold: served.id, m: name, as: "error", error: toWireError(error) }; + } + return { t: "state", hold: served.id, m: name, as, ...(value === undefined ? {} : { value }) }; + } + + /** A later event of a member's stream: ops are encoded, a reset starts a new stream. */ + #feedEvent(served: ServedLocal, name: string, feed: Feed, event: StateEvent): void { + if (served.feeds.get(name) !== feed) return; + const overflow = { resend: () => this.#resend(served, name) }; + if (event.type === "ops") { + this.sendState( + { t: "state", hold: served.id, m: name, as: "ops", ops: feed.encoder.encode(event.ops) }, + overflow, + ); + return; + } + feed.encoder = encoder(); + this.sendState(this.#snapshot(served, name, "reset", event.value), overflow); + } + + /** Sends member `name` again, now, as a reset from its current value (a lazy slot is flushed, or `resync`). */ + #resend(served: ServedLocal, name: string): void { + const feed = served.feeds.get(name); + if (feed === undefined || this.served.get(served.id) !== served) return; + feed.unsubscribe(); + served.feeds.delete(name); + const attachment = this.#attach(served, name, "reset"); + if (attachment === undefined) return; + this.send(attachment.frame); + this.#replay(served, name, attachment.feed); + } + + /** The consumer starts (`sub`) or stops (`unsub`) the state of a hold. */ + #onWantState(hold: number, wanted: boolean): void { + const served = this.served.get(hold); + if (served === undefined) return; + if (served.kind === "relay") { + served.relay.wantState(wanted); + return; + } + if (served.wantsState === wanted) return; + served.wantsState = wanted; + if (wanted) { + this.#unit(served); + return; + } + for (const feed of served.feeds.values()) feed.unsubscribe(); + served.feeds.clear(); + this.dropHoldState(hold); + } + + /** The consumer (a relay that dropped ops) asks for member `m` again as a reset. */ + #onResync(hold: number, m: string): void { + const served = this.served.get(hold); + if (served === undefined) return; + if (served.kind === "relay") { + served.relay.resync(m); + return; + } + if (!served.feeds.has(m)) return; + this.#resetLater(hold, m, () => this.#resend(served, m)); } /** Forgets a served hold: unsubscribes its feeds and releases it (locally or downstream). */ @@ -757,7 +1091,9 @@ export class Connection implements RemoteChannel { }); return; } - for (const feed of served.feeds) this.#onUnsub(feed); + for (const feed of served.feeds.values()) feed.unsubscribe(); + served.feeds.clear(); + this.dropHoldState(id); if (release) served.ref.release(); } @@ -828,87 +1164,21 @@ export class Connection implements RemoteChannel { return { rootId: root.rootId, codeVersion }; } - #onSub(frame: Extract): void { - if (this.feeds.has(frame.id) || this.relaySubs.has(frame.id)) { - this.#violation(`duplicate sub id ${frame.id}`); - return; - } - const served = this.served.get(frame.hold); - if (served === undefined) { - this.send({ t: "state", id: frame.id, error: toWireError(new ChordError("gone", "unknown hold")) }); - return; - } - if (served.kind === "relay") { - served.relay.subscribe(frame.id, frame.member); - return; - } - const readable = served.ref.member(frame.member) as Readable | undefined; - if (readable === undefined || typeof readable.events !== "function") { - this.send({ - t: "state", - id: frame.id, - error: toWireError(new ChordError("not_in_variant", `${frame.member} is not state`)), - }); - return; - } - let stream: Encoder = encoder(); - const send = (event: StateEvent): void => { - if (event.type === "ops") { - if (this.sendState({ t: "state", id: frame.id, ops: stream.encode(event.ops) })) return; - // Too many ops wait for a slow peer: the current value replaces them. - event = { type: "reset", value: event.value }; - } - // A loading provider reference has nothing yet: the hydrated value follows as a reset. - if (event.value === undefined) return; - // Snapshots are validated against the effective schema once per (re)subscription; ops only in dev mode. - try { - validateSnapshot(served.ref, frame.member, event.value); - } catch (error) { - this.sendState({ t: "state", id: frame.id, error: toWireError(error) }); - return; - } - stream = encoder(); - this.sendState( - event.type === "snap" - ? { t: "state", id: frame.id, snap: event.value } - : { t: "state", id: frame.id, reset: event.value }, - ); - }; - const unsubscribe = readable.events(send); - this.feeds.set(frame.id, unsubscribe); - served.feeds.add(frame.id); - } - - #onUnsub(id: number): void { - this.dropState(id); - const unsubscribe = this.feeds.get(id); - if (unsubscribe !== undefined) { - this.feeds.delete(id); - unsubscribe(); - } - this.relaySubs.get(id)?.unsubscribe(id); - } - #onHoldReply(frame: Extract): void { const relay = this.relayedRefs.get(frame.id); if (relay !== undefined) { if (frame.t === "gone") relay.close(fromWireError(frame.error), { releaseDown: false, notify: true }); - else if (!relay.closed) relay.origin.send({ ...frame, id: relay.originId }); + else relay.shape(frame); return; } - const ref = this.remotes.get(frame.id); - if (ref === undefined) return; + const binding = this.remotes.get(frame.id); + if (binding === undefined) return; if (frame.t === "gone") { this.remotes.delete(frame.id); - ref.gone(fromWireError(frame.error)); + binding.gone(fromWireError(frame.error)); return; } - ref.shaped({ - variant: frame.variant, - members: frame.members, - contract: frame.contract, - codeVersion: frame.codeVersion, - }); + binding.shaped({ variant: frame.variant, members: frame.members, contract: frame.contract }, frame.state); } #onResult(frame: Extract): void { @@ -933,30 +1203,26 @@ export class Connection implements RemoteChannel { pending?.resolve(value); } - #onState(frame: Extract): void { - const relayed = this.relayedSubs.get(frame.id); - if (relayed !== undefined) { - if (!relayed.relay.origin.sendState({ ...frame, id: relayed.originSubId })) { - relayed.relay.resync(relayed.originSubId); - } + #onState(frame: StateFrame): void { + const relay = this.relayedRefs.get(frame.hold); + if (relay !== undefined) { + relay.state(frame); return; } - const sink = this.subs.get(frame.id); - if (sink === undefined) return; - if (!(frame.snap instanceof Packed) && !(frame.reset instanceof Packed)) { - sink(frame); + const binding = this.remotes.get(frame.hold); + if (binding === undefined) return; + if (!(frame.value instanceof Packed)) { + binding.state(frame.m, frame); return; } - let unpacked: StateSink; + let value: unknown; try { - unpacked = - frame.snap !== undefined - ? { ...frame, snap: unpack(frame.snap) } - : { ...frame, reset: unpack(frame.reset) }; + value = unpack(frame.value); } catch (error) { - unpacked = { error: toWireError(error) }; + binding.state(frame.m, { as: "error", error: toWireError(error) }); + return; } - sink(unpacked); + binding.state(frame.m, { ...frame, value }); } // ─── lifetime ─── @@ -967,6 +1233,7 @@ export class Connection implements RemoteChannel { if (this.#pinger !== undefined) clearInterval(this.#pinger); if (this.#ackTimer !== undefined) clearTimeout(this.#ackTimer); this.#pending.clear(); + this.#pendingBytes = 0; const error = unavailable(`link to ${this.name ?? "peer"} closed: ${reason}`); // As origin: everything the peer held here, relayed or not, is released (downstream calls cancelled). for (const id of [...this.served.keys()]) this.#dropServed(id, true); @@ -974,7 +1241,6 @@ export class Connection implements RemoteChannel { this.incoming.clear(); for (const pending of this.calls.values()) pending.reject(error); this.calls.clear(); - this.subs.clear(); for (const vote of this.votes.values()) vote.reject(error); this.votes.clear(); for (const waiter of this.#syncWaiters) waiter.reject(error); @@ -983,17 +1249,20 @@ export class Connection implements RemoteChannel { for (const relay of [...this.relayedRefs.values()]) relay.close(error, { releaseDown: false, notify: true }); this.relayedRefs.clear(); this.relayedCalls.clear(); - this.relayedSubs.clear(); - for (const ref of this.remotes.values()) { - // An upstream link may be replaced (reconnect); a downstream host that went away is gone. - if (this.role === "upstream") ref.detached(); - else ref.gone(error); - } + const bindings = [...this.remotes.values()]; this.remotes.clear(); + for (const binding of bindings) { + // An upstream link may be replaced (reconnect); a downstream host that went away is gone. + if (this.role === "upstream") binding.detached(); + else binding.gone(error); + } this.host.connectionClosed(this); } } +const stateKey = (hold: number, m: string): string => `s${hold}\u0000${m}`; +const unitKey = (hold: number): string => `u${hold}`; + /** * A call through a relay: waits for the route, then forwards with the root it runs in. It settles exactly once; a * cancel (or the origin going away) is forwarded downstream and removes both mappings. diff --git a/packages/chord/src/contract.ts b/packages/chord/src/contract.ts index 343cbf1bf..c2ebd6b96 100644 --- a/packages/chord/src/contract.ts +++ b/packages/chord/src/contract.ts @@ -352,9 +352,6 @@ export function bind(provider: AnyContract, accepts: Accepts | undefined, consum // ─── Static types ──────────────────────────────────────────────────────────── -type UnionToIntersection = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void - ? I - : never; type Empty = Record; type StateOf = V extends { readonly state: infer S extends Readonly> } ? S : Empty; type StaticOf = S extends TSchema ? Static : never; @@ -391,7 +388,13 @@ type VariantImplementation = { readonly variant: K } & { } & { readonly [M in keyof MethodsOf]: MethodImplementation[M], P>; }; -type VariantRef = { readonly [S in keyof StateOf]: Readable[S]> | undefined> } & { +/** + * A reference in one variant: its state members are plain readables (a reference you hold has its state) and its + * methods calls. `variant` discriminates the union `Ref` is for a multi-variant type. + */ +type VariantRef = RefBase & { readonly variant: K } & { + readonly [S in keyof StateOf]: Readable[S]>>; +} & { readonly [M in keyof MethodsOf]: Call[M]>; }; @@ -407,7 +410,7 @@ type LocalRefMember = M extends MethodSpec : M extends OpaqueSpec ? T | undefined : M extends TSchema - ? Readable | undefined> + ? Readable> : never; type LocalMembers = D extends { readonly members: infer M extends Readonly> } ? M @@ -425,11 +428,13 @@ export type ImplementationOf = C extends RemoteContract > : never; -/** Late-bound members a reference exposes for contract C (all variants merged). */ -export type MembersOf = C extends RemoteContract - ? UnionToIntersection<{ [K in keyof D["variants"]]: VariantRef }[keyof D["variants"]]> +/** + * What `get` returns for contract C. A remote type's reference is a union discriminated by `variant`: another variant's + * members are reachable only after narrowing (`if (ref.variant === "editor") ref.add(…)`). A local type's reference has + * its members. + */ +export type Ref = C extends RemoteContract + ? { [K in keyof D["variants"]]: VariantRef }[keyof D["variants"]] : C extends LocalContract - ? { readonly [K in keyof LocalMembers]: LocalRefMember[K]> } + ? RefBase & { readonly [K in keyof LocalMembers]: LocalRefMember[K]> } : never; - -export type Ref = RefBase & MembersOf; diff --git a/packages/chord/src/host.ts b/packages/chord/src/host.ts index 9272537f1..9f96a4909 100644 --- a/packages/chord/src/host.ts +++ b/packages/chord/src/host.ts @@ -17,6 +17,7 @@ import { import { ChordError, invalid, runAll } from "./errors.ts"; import type { Link } from "./link.ts"; import { + awaitReady, CtxImpl, type Decls, type Factory, @@ -34,7 +35,7 @@ import { import { enqueue } from "./publish.ts"; import type { Registry, RegistryTable, RegistryView } from "./registry.ts"; import { type ReloadOptions, type ReloadReport, runReload } from "./reload.ts"; -import { RemoteRef } from "./remote.ts"; +import { RemoteBinding, RemoteRef } from "./remote.ts"; import { canonicalIdentity, canonicalJson, deepEqual, normalize } from "./schema.ts"; import type { Ctx, GetOptions } from "./types.ts"; import type { Frame } from "./wire.ts"; @@ -129,6 +130,18 @@ export interface HostOptions

{ * (reported). Default 5000. */ readonly taskStopDeadline?: number; + /** + * Milliseconds an activation (or a reload's prepare) waits for its references to be ready and hydrated, and an + * awaited `get` for its reference, before failing with `unavailable/ready_timeout`. Default 60000. + */ + readonly readyTimeout?: number; + /** + * Consumer-side linger of remote references: when the last reference to a remote object is released, its binding + * stays held, and hydrated, for `types[typeName]` milliseconds, so a reference taken again meanwhile costs nothing + * on the link. At most `cap` bindings (default 4) linger; beyond, the least recently released go first. Types not + * listed do not linger. (The provider's own `serve(…, { linger })` keeps its object after the last reference.) + */ + readonly linger?: { readonly types?: Readonly>; readonly cap?: number }; /** Needed for `load`/`reload` with modules; `nodeLoader` from loader.ts in Node. */ readonly loader?: FacetLoader; /** `client` (browser): never a reload participant, never carries roots or code versions. Default `host`. */ @@ -187,7 +200,21 @@ export interface Host

{ load(plugins: readonly PluginRef

[], options?: LoadOptions): Promise; /** Awaits the host objects present at call time, including those started by `serve` or deferred loads. */ ready(): Promise; - get(type: C, input: InputOf, options?: GetOptions

): Ref; + /** + * A reference held until `release()`. Resolves once it is ready: live, with every state member of its variant + * (unless `state: false`). Rejects with the activation error, `aborted` (`signal`), or `unavailable/ready_timeout` + * after `readyTimeout`; the reference is released then. + */ + get( + type: C, + input: InputOf, + options?: GetOptions

& { readonly signal?: AbortSignal }, + ): Promise>; + /** + * The same reference, synchronously, possibly still `loading`: for UI runtimes that render its status (hooks, a + * TUI watch). Its state members throw `not_ready` until it is live. + */ + hold(type: C, input: InputOf, options?: GetOptions

): Ref; root(fn: (ctx: Ctx

) => T | Promise, options?: RootOptions

): Promise; registry(registry: Registry): RegistryView; /** @@ -239,8 +266,13 @@ export class HostImpl implements Host, HostCore { readonly kind: "host" | "client"; readonly connections = new Set(); upstream: Connection | undefined; - /** Remote references this host references (re-held after an upstream reconnect). */ - readonly remotes = new Set(); + /** Shared bindings of this host's remote references, by key (re-held after an upstream reconnect). */ + readonly bindings = new Map(); + /** Bindings without references that linger, least recently released first. */ + readonly #lingering = new Map(); + readonly #lingerTypes: Readonly>; + readonly #lingerCap: number; + #originSeq = 0; readonly #routes = new Map(); readonly #childReloads = new ChildReloads(this); /** Child facet modules by host-name prefix, from every committed cluster reload (for reconciling children). */ @@ -266,6 +298,7 @@ export class HostImpl implements Host, HostCore { gating: ReadonlySet | undefined; reloading = false; readonly taskStopDeadline: number; + readonly readyTimeout: number; readonly #principalKey: (principal: unknown) => string; /** `principalKey(self)`: an equal principal is the host's own, whatever object carries it. */ readonly #selfKey: string; @@ -296,6 +329,9 @@ export class HostImpl implements Host, HostCore { this.#onError = options.onError ?? ((error) => console.error("chord:", error)); this.#loader = options.loader; this.taskStopDeadline = options.taskStopDeadline ?? 5000; + this.readyTimeout = options.readyTimeout ?? 60_000; + this.#lingerTypes = options.linger?.types ?? {}; + this.#lingerCap = options.linger?.cap ?? 4; this.current = { id: this.#codeVersionSeq, services: new Map(), @@ -458,8 +494,7 @@ export class HostImpl implements Host, HostCore { addressOf(type: AnyContract, input: unknown, principal: unknown, candidate = false): string { const elsewhere = this.#elsewhere(type, input); - if (elsewhere !== undefined) - return remoteAddress(type, elsewhere.input, this.isSelf(principal) ? undefined : principal); + if (elsewhere !== undefined) return this.#remoteKey(type, elsewhere.input, principal); const service = this.#bindingService(type.name, candidate); if (service === undefined) throw unknownType(type.name, this.name); return this.#locate(service, input, principal).address; @@ -490,8 +525,20 @@ export class HostImpl implements Host, HostCore { throw invalid("input", `${name} has lifetime "host": its one object has the empty input`); principal = this.principal; } - const key = this.isSelf(principal) ? "host" : `p:${this.#principalKey(principal)}`; - return { address: `${name}\u0000${identity}\u0000${key}`, normalized, principal }; + return { address: `${name}\u0000${identity}\u0000${this.#principalPart(principal)}`, normalized, principal }; + } + + #principalPart(principal: unknown): string { + return this.isSelf(principal) ? "host" : `p:${this.#principalKey(principal)}`; + } + + /** + * The key of a remote binding: type, input identity (fields equal to their defaults omitted, as for local objects), + * principal key and the consumer's contract fingerprint. No host name: the type and the input decide where it lives. + */ + #remoteKey(type: AnyContract, normalized: unknown, principal: unknown): string { + const identity = canonicalIdentity(type.input, normalized); + return `@${type.name}\u0000${identity}\u0000${this.#principalPart(principal)}\u0000${type.fingerprint}`; } acquire( @@ -851,7 +898,17 @@ export class HostImpl implements Host, HostCore { // ─── public API ─── - get(type: C, input: InputOf, options?: GetOptions): Ref { + async get( + type: C, + input: InputOf, + options?: GetOptions & { readonly signal?: AbortSignal }, + ): Promise> { + const ref = this.acquire(type, input, options, "external"); + await awaitReady(ref, options?.signal === undefined ? [] : [options.signal], this.readyTimeout); + return ref.proxy as Ref; + } + + hold(type: C, input: InputOf, options?: GetOptions): Ref { return this.acquire(type, input, options, "external").proxy as Ref; } @@ -888,6 +945,8 @@ export class HostImpl implements Host, HostCore { role: "downstream", principal: options.principal, peer: { kind, name: kind === "host" ? options.name : undefined }, + // Holds over a client link carry its id as their origin (also when relayed), so hosts can tell clients apart. + ...(kind === "client" ? { originId: `${this.#incarnation}:${++this.#originSeq}` } : {}), }); } @@ -902,8 +961,9 @@ export class HostImpl implements Host, HostCore { peer: { kind: "host", name: undefined }, }); this.upstream = connection; - // Remote references that lost their link re-hold over the new one; state subscribers get a reset. - for (const ref of this.remotes) if (ref.binding === undefined) connection.hold(ref); + // Bindings that lost their link re-hold over the new one (`again`: they keep their values meanwhile). + for (const binding of this.bindings.values()) + if (binding.hold === undefined && !binding.ended) connection.hold(binding, binding.applied); } #addConnection(link: Link, config: ConstructorParameters[2]): Connection { @@ -986,8 +1046,28 @@ export class HostImpl implements Host, HostCore { } } - forgetRemote(ref: RemoteRef): void { - this.remotes.delete(ref); + /** The last reference to `binding` was released: it lingers if its type does (`HostOptions.linger`), else closes. */ + bindingIdle(binding: RemoteBinding): void { + const ms = Object.hasOwn(this.#lingerTypes, binding.contract.name) + ? this.#lingerTypes[binding.contract.name]! + : 0; + // A binding that never had a shape has nothing worth keeping. + if (ms <= 0 || binding.ended || !binding.applied || this.#lingerCap <= 0) { + binding.close(); + return; + } + binding.lingerTimer = setTimeout(() => binding.close(), ms); + (binding.lingerTimer as { unref?: () => void }).unref?.(); + this.#lingering.set(binding.key, binding); + for (const oldest of this.#lingering.values()) { + if (this.#lingering.size <= this.#lingerCap) break; + oldest.close(); + } + } + + forgetBinding(binding: RemoteBinding): void { + if (this.bindings.get(binding.key) === binding) this.bindings.delete(binding.key); + if (this.#lingering.get(binding.key) === binding) this.#lingering.delete(binding.key); } /** A downstream host connection by its configured name (never by what a peer announced). */ @@ -1001,7 +1081,10 @@ export class HostImpl implements Host, HostCore { ); } - /** A reference to an object on host `at`; `input` is normalized with `type` (the hold carries it so). */ + /** + * A reference to an object on host `at`: a view onto the binding for its key, created (and held) if there is none. + * `input` is normalized with `type` (the hold carries it so). + */ #acquireRemote( type: AnyContract, at: string, @@ -1011,32 +1094,38 @@ export class HostImpl implements Host, HostCore { ): RefCore { const principal = options?.principal === undefined || this.isSelf(options.principal) ? undefined : options.principal; - const ref = new RemoteRef(this, { - contract: type, - at, - input, - principal, - owner, - address: remoteAddress(type, input, principal), - }); - this.remotes.add(ref); + const key = this.#remoteKey(type, input, principal ?? this.principal); + let binding = this.bindings.get(key); + const fresh = binding === undefined || binding.ended; + if (binding === undefined || binding.ended) { + binding = new RemoteBinding(this, { key, contract: type, at, input, principal }); + this.bindings.set(key, binding); + } + const ref = new RemoteRef(binding, owner, options?.state !== false); + this.#lingering.delete(key); + binding.addView(ref); + if (fresh) this.#hold(binding); + return ref; + } + + /** Holds `binding` over the link to its host: the parent's, a child's, or one a route resolves. */ + #hold(binding: RemoteBinding): void { const bindWhenSynced = (connection: Promise): void => { connection.then( (target) => { - if (ref.status === "loading" && ref.binding === undefined) target.hold(ref); + if (!binding.ended && binding.hold === undefined) target.hold(binding, false); }, - (error: unknown) => ref.gone(error), + (error: unknown) => binding.gone(error), ); }; - const direct = this.#childNamed(at); - if (this.upstream !== undefined) this.upstream.hold(ref); + const direct = this.#childNamed(binding.at); + if (this.upstream !== undefined) this.upstream.hold(binding, false); else if (direct !== undefined) bindWhenSynced(direct.whenSynced().then(() => direct)); else { - const route = this.#routeFor(at); - if (route === undefined) ref.gone(new ChordError("unavailable", `no route to ${at}`)); - else bindWhenSynced(this.#resolve(route, at, principal ?? this.principal)); + const route = this.#routeFor(binding.at); + if (route === undefined) binding.gone(new ChordError("unavailable", `no route to ${binding.at}`)); + else bindWhenSynced(this.#resolve(route, binding.at, binding.principal ?? this.principal)); } - return ref; } #routeFor(hostName: string): Route | undefined { @@ -1140,6 +1229,7 @@ export class HostImpl implements Host, HostCore { * references, local or remote), then closes the links. */ async close(): Promise { + for (const binding of [...this.#lingering.values()]) binding.close(); this.hostObjects.clear(); for (const name of [...this.#facetRuns.keys()]) this.facetRunChanged(name, undefined); const reason = new ChordError("gone", `host ${this.name} closed`); @@ -1192,11 +1282,6 @@ const matchesPattern = (pattern: Readonly>, principal: u typeof principal === "object" && Object.entries(pattern).every(([key, value]) => deepEqual((principal as Record)[key], value)); -/** Address of a remote reference, as the consumer sees it (the provider normalizes the input). */ -/** A remote object's address here: no host name, since the type and its input decide where it lives. */ -const remoteAddress = (type: AnyContract, input: unknown, principal: unknown | undefined): string => - `@${type.name}\u0000${canonicalJson(input)}\u0000${principal === undefined ? "host" : canonicalJson(principal)}`; - const unknownType = (type: string, host: string): ChordError => new ChordError("unavailable", `${type} is not served by ${host}`, { reason: "unknown_type" }); diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 9f59e05a2..6fb71ae10 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -16,7 +16,6 @@ export { type InputOf, type LocalContract, local, - type MembersOf, method, opaque, type Placement, diff --git a/packages/chord/src/link.ts b/packages/chord/src/link.ts index b13ddde90..029bd7dc2 100644 --- a/packages/chord/src/link.ts +++ b/packages/chord/src/link.ts @@ -131,13 +131,12 @@ export function unpack(value: unknown): unknown { return JSON.parse(zlib.inflateRawSync(value.bytes).toString("utf8")); } -type PayloadField = "args" | "value" | "snap" | "reset"; -const PAYLOAD_FIELDS: ReadonlySet = new Set(["args", "value", "snap", "reset"]); +type PayloadField = "args" | "value"; +const PAYLOAD_FIELDS: ReadonlySet = new Set(["args", "value"]); function payloadField(frame: Frame): PayloadField | undefined { if (frame.t === "call") return frame.args === undefined ? undefined : "args"; - if (frame.t === "result") return frame.value === undefined ? undefined : "value"; - if (frame.t === "state") return frame.snap !== undefined ? "snap" : frame.reset !== undefined ? "reset" : undefined; + if (frame.t === "result" || frame.t === "state") return frame.value === undefined ? undefined : "value"; return undefined; } diff --git a/packages/chord/src/object.ts b/packages/chord/src/object.ts index 817e36c2f..2d72017fd 100644 --- a/packages/chord/src/object.ts +++ b/packages/chord/src/object.ts @@ -1,6 +1,17 @@ import type { Static, TSchema } from "typebox"; import { Value } from "typebox/value"; -import { Cell, Derived, isSource, notReady, type Sources, type SourceValues } from "./cell.ts"; +import { + Cell, + Derived, + HYDRATION, + type Hydration, + hydrationOf, + isSource, + notReady, + type Sources, + type SourceValues, + unhydrated, +} from "./cell.ts"; import { type Accepts, type Adapter, @@ -376,6 +387,11 @@ export interface HostCore { disposed(object: ObjectImpl): void; /** Milliseconds a reload waits for aborted tasks to finish before starting their replacements. */ readonly taskStopDeadline: number; + /** + * Milliseconds an activation or a reload's prepare waits for its references to be ready and hydrated before it fails + * (`unavailable/ready_timeout`; a reference cycle across hosts never resolves). + */ + readonly readyTimeout: number; checkRegistrations(object: ObjectImpl, decls: Decls): void; /** Rebuilds the current registry tables from live objects (plus `including`, which is finishing activation). */ registriesChanged(including?: ObjectImpl): void; @@ -393,6 +409,8 @@ export class CtxImpl implements Ctx { readonly holder: boolean; readonly #refs: RefCore[] = []; readonly #release: () => void; + /** Aborted when the ctx closes: awaited `get`s give up. */ + readonly #closing = new AbortController(); #closed = false; /** True after the call or root that received this ctx settled: it may no longer start work. */ @@ -434,10 +452,11 @@ export class CtxImpl implements Ctx { this.holder = fields.holder ?? false; } - get(type: C, input: InputOf, options?: GetOptions): Ref { + async get(type: C, input: InputOf, options?: GetOptions): Promise> { this.check(this.host); const ref = this.host.acquire(type, input, { principal: this.principal, ...options }, "call"); this.#refs.push(ref); + await awaitReady(ref, [this.signal, this.#closing.signal], this.host.readyTimeout); return ref.proxy as Ref; } @@ -456,6 +475,7 @@ export class CtxImpl implements Ctx { close(): void { this.#closed = true; + this.#closing.abort(new ChordError("aborted", "ctx expired: its call already settled", { reason: "expired" })); this.#release(); for (const ref of this.#refs.splice(0)) ref.release(); } @@ -488,6 +508,38 @@ export function linkedSignal(sources: readonly AbortSignal[]): { readonly signal return { signal: controller.signal, release }; } +/** + * Waits until `ref` is ready, at most `ms` and until a signal aborts; on failure the reference is released (only it: a + * binding other references share stays) and the error is rethrown: the activation error, `aborted`, or + * `unavailable/ready_timeout`. + */ +export async function awaitReady(ref: RefCore, signals: readonly AbortSignal[], ms: number): Promise { + const timeout = new AbortController(); + const timer = Number.isFinite(ms) + ? setTimeout( + () => + timeout.abort( + new ChordError("unavailable", `${ref.address} was not ready after ${ms} ms`, { + reason: "ready_timeout", + }), + ), + ms, + ) + : undefined; + (timer as { unref?: () => void } | undefined)?.unref?.(); + const linked = linkedSignal([...signals, timeout.signal]); + try { + await raceSignal(ref.ready, linked.signal); + } catch (error) { + ref.release(); + const reason: unknown = linked.signal.reason; + throw reason instanceof ChordError && reason.reason === "ready_timeout" ? reason : error; + } finally { + clearTimeout(timer); + linked.release(); + } +} + export async function raceSignal(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) throw new ChordError("aborted", "call aborted", { cause: signal.reason }); let onAbort = noop; @@ -559,18 +611,21 @@ export const refOf = (value: unknown): RefCore | undefined => /** * A reference's state member: a stable readable that follows whatever source the current implementation exposes under - * that name. `undefined` until the reference is live (or while the variant lacks the member). Rebinding (reload, - * variant) delivers a reset. Upstream is subscribed only while someone listens. + * that name. Reading throws `not_ready` until the reference is live, `not_in_variant` while the variant lacks the + * member. A listener added before the member is readable gets its snapshot then. Rebinding (reload, variant) delivers a + * reset. Upstream is subscribed only while someone listens. */ class MemberReadable implements Readable { readonly #ref: RefImpl; readonly #name: string; readonly #values = new Set<(value: unknown) => void>(); readonly #events = new Set<(event: StateEvent) => void>(); + /** Event listeners that have not had their snapshot yet (added while the member was not readable). */ + readonly #unseen = new Set<(event: StateEvent) => void>(); #source: Readable | undefined; #upstream = noop; /** The value listeners last saw; a first upstream snapshot equal to it is not re-announced. */ - #seen: unknown; + #seen: { readonly value: unknown } | undefined; constructor(ref: RefImpl, name: string) { this.#ref = ref; @@ -578,23 +633,58 @@ class MemberReadable implements Readable { } get value(): unknown { - return this.#current()?.value; + const ref = this.#ref; + if (ref.status === "released" || ref.status === "gone") throw ref.terminalError(); + if (ref.status !== "live") throw notReady(`${ref.address}.${this.#name}`); + const view = ref.currentView(); + if (view === undefined) throw notReady(`${ref.address}.${this.#name}`); + const source = this.#current(); + if (source === undefined) { + throw new ChordError( + "not_in_variant", + `${ref.address}: ${this.#name} is not a state member of variant ${variantOf(view) ?? "(local)"}`, + ); + } + return source.value; + } + + /** Not hydrated while the reference is not live, or while its source (a forwarded remote member) is not. */ + get [HYDRATION](): Hydration | undefined { + const ref = this.#ref; + if (ref.status === "live") return hydrationOf(this.#current()); + return { + hydrated: false, + whenHydrated: async () => { + await ref.ready; + await hydrationOf(this.#current())?.whenHydrated(); + }, + }; } subscribe(listener: (value: unknown) => void): () => void { this.#values.add(listener); - this.#seen = this.value; - this.#safe(() => listener(this.#seen)); + const source = this.#readable(); + if (source !== undefined) { + this.#seen = { value: source.value }; + this.#safe(() => listener(source.value)); + } this.bind(); return () => this.#remove(this.#values, listener); } events(listener: (event: StateEvent) => void): () => void { this.#events.add(listener); - this.#seen = this.value; - this.#safe(() => listener({ type: "snap", value: this.#seen })); + const source = this.#readable(); + if (source !== undefined) { + const value = source.value; + this.#seen = { value }; + this.#safe(() => listener({ type: "snap", value })); + } else this.#unseen.add(listener); this.bind(); - return () => this.#remove(this.#events, listener); + return () => { + this.#unseen.delete(listener); + this.#remove(this.#events, listener); + }; } /** (Re)subscribes upstream to the current source; a changed source is announced as a reset. */ @@ -609,14 +699,22 @@ class MemberReadable implements Readable { this.#upstream = source.events((event) => { const isFirst = first; first = false; - if (isFirst && event.value === this.#seen) return; - this.#seen = event.value; + if (isFirst && this.#unseen.size === 0 && this.#seen !== undefined && event.value === this.#seen.value) return; + this.#seen = { value: event.value }; const translated: StateEvent = isFirst ? { type: "reset", value: event.value } : event; const events = [...this.#events]; const values = [...this.#values]; + const unseen = new Set(this.#unseen); + this.#unseen.clear(); // Queued: a rebind during a commit must not run observers before the commit completes. enqueue(() => { - for (const listener of events) if (this.#events.has(listener)) this.#safe(() => listener(translated)); + for (const listener of events) { + if (!this.#events.has(listener)) continue; + const delivered: StateEvent = unseen.has(listener) + ? { type: "snap", value: event.value } + : translated; + this.#safe(() => listener(delivered)); + } for (const listener of values) if (this.#values.has(listener)) this.#safe(() => listener(event.value)); }); }); @@ -625,6 +723,7 @@ class MemberReadable implements Readable { stop(): void { this.#values.clear(); this.#events.clear(); + this.#unseen.clear(); this.bind(); } @@ -636,6 +735,12 @@ class MemberReadable implements Readable { return isSource(value) || isReadableLike(value) ? (value as Readable) : undefined; } + /** The current source if its value can be read now (live, in the variant, hydrated). */ + #readable(): Readable | undefined { + const source = this.#current(); + return source === undefined || hydrationOf(source)?.hydrated === false ? undefined : source; + } + #remove(set: Set, listener: T): void { set.delete(listener); this.bind(); @@ -707,7 +812,6 @@ export class RefImpl { variant: variantOf(view), members: Object.keys(view).filter((key) => key !== "variant"), contract: installed.service.contract.fingerprint, - codeVersion: installed.codeVersion, }; } @@ -1000,6 +1104,8 @@ export class ObjectImpl { try { await this.open(run.created.resources, run.created.refs, signal); if (signal.aborted) throw abandoned(); + await this.waitHydrated(run); + if (signal.aborted) throw abandoned(); await this.initCells(run, signal); if (signal.aborted) throw abandoned(); this.host.checkRegistrations(this, run.decls); @@ -1101,11 +1207,58 @@ export class ObjectImpl { } return promise; }; - const results = await Promise.allSettled([...resources.map(openOne), ...refs.map((ref) => ref.ready)]); + const ready = this.withinReadyTimeout(Promise.all(refs.map((ref) => ref.ready)), () => + refs.filter((ref) => ref.status === "loading").map((ref) => ref.address), + ); + const results = await Promise.allSettled([...resources.map(openOne), ready]); const failure = results.find((result) => result.status === "rejected"); if (failure !== undefined) throw failure.reason; } + /** + * Waits until the state sources a run exposes or derives from have their values: a local object goes live only with + * its state, also where it forwards or derives from a remote reference's member. + */ + async waitHydrated(run: Run): Promise { + const exposed = Object.entries(run.implementation ?? {}); + const pending = unhydrated([...run.created.derives, ...exposed.map(([, value]) => value)]); + if (pending.length === 0) return; + await this.withinReadyTimeout(Promise.all(pending.map((hydration) => hydration.whenHydrated())), () => [ + ...exposed + .filter(([, value]) => hydrationOf(value)?.hydrated === false) + .map(([name]) => `${this.address}.${name}`), + ...run.created.derives + .filter((derived) => hydrationOf(derived) !== undefined) + .map((derived) => `derive ${derived.key}`), + ]); + } + + /** `work` within the host's `readyTimeout`; a timeout names what is still awaited. */ + async withinReadyTimeout(work: Promise, waiting: () => readonly string[]): Promise { + const ms = this.host.readyTimeout; + if (!Number.isFinite(ms)) return work; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const list = waiting(); + reject( + new ChordError( + "unavailable", + `${this.address} was not ready after ${ms} ms${list.length === 0 ? "" : `, waiting on ${list.join(", ")}`} (a reference cycle across hosts?)`, + { reason: "ready_timeout" }, + ), + ); + }, ms); + (timer as { unref?: () => void }).unref?.(); + }); + timeout.catch(noop); + try { + return await Promise.race([work, timeout]); + } finally { + clearTimeout(timer); + } + } + /** * Fills the cells `run` created with `init`, each in a root of its own, in parallel; settles all before failing. Runs * after the run's resources are open and its references live, before anything reads the cells. diff --git a/packages/chord/src/reload.ts b/packages/chord/src/reload.ts index 6f135ef61..38e9b4c1e 100644 --- a/packages/chord/src/reload.ts +++ b/packages/chord/src/reload.ts @@ -1,6 +1,6 @@ import type { Cell } from "./cell.ts"; import { type AnyContract, bind, coverage } from "./contract.ts"; -import { asChordError, ChordError, invalid } from "./errors.ts"; +import { asChordError, ChordError, invalid, isChordError } from "./errors.ts"; import type { CodeVersionState, Facet, HostImpl, PluginRef, Serve } from "./host.ts"; import { type Factory, @@ -18,6 +18,7 @@ import { import { holdPublications } from "./publish.ts"; import type { RegistryTable } from "./registry.ts"; import { assertValid, deepEqual } from "./schema.ts"; +import type { Readable } from "./types.ts"; export interface ReloadOptions { /** Facet names to remove. */ @@ -82,7 +83,7 @@ function dryRun(candidate: Candidate): void { const values = new Map(migrated); const derives = [...run.decls.derives].sort(([, a], [, b]) => a.seq - b.seq); for (const [name, derived] of derives) { - const value = derived.compute((source) => (values.has(source) ? values.get(source) : source.value)); + const value = derived.compute((source) => (values.has(source) ? values.get(source) : candidateValue(source))); for (const schema of exposed.get(derived) ?? []) assertValid("state", `${object.address}: derivation ${name}`, schema, value); values.set(derived, value); @@ -90,6 +91,19 @@ function dryRun(candidate: Candidate): void { candidate.derived = values; } +/** + * A source's value for a dry run. A reference to a provider this reload upgrades too reads nothing before the commit + * (its new contract does not bind the old code): the dry run computes without it, the commit with the real value. + */ +function candidateValue(source: Readable): unknown { + try { + return source.value; + } catch (error) { + if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant")) return undefined; + throw error; + } +} + /** A reload error keeps the underlying code (`contract_mismatch`, `invalid`, ...) and says it aborted the reload. */ export function reloadError(error: unknown): ChordError { const cause = asChordError(error, "reload"); @@ -398,6 +412,7 @@ export class Reload { run.created.refs, this.abort.signal, ); + await object.waitHydrated(run); // Cells this reload adds read their first value once the new resources are open (gated objects: at the gate); // the derivations over them are checked after. if (candidate.reasons.length === 0) { diff --git a/packages/chord/src/remote.ts b/packages/chord/src/remote.ts index 7f48a1250..80226a534 100644 --- a/packages/chord/src/remote.ts +++ b/packages/chord/src/remote.ts @@ -1,4 +1,5 @@ import { Value } from "typebox/value"; +import { HYDRATION, type Hydration } from "./cell.ts"; import { type AnyContract, takesNoArgs } from "./contract.ts"; import { applyImmutable, type Decoder, decoder } from "./delta/index.ts"; import { ChordError } from "./errors.ts"; @@ -17,177 +18,490 @@ import { fromWireError, type RootRef, type WireError } from "./wire.ts"; const noop = (): void => {}; -export interface StateSink { - snap?: unknown; - reset?: unknown; - ops?: readonly unknown[]; - error?: WireError; +/** One state frame for a member, as the binding receives it (payload unpacked). */ +export interface StateIn { + readonly as: "snap" | "reset" | "ops" | "error"; + readonly value?: unknown; + readonly ops?: readonly unknown[]; + readonly error?: WireError; } -/** What a remote reference needs from the link it is bound to (implemented by Connection). */ +/** What a binding needs from the link it is bound to (implemented by Connection). */ export interface RemoteChannel { - hold(ref: RemoteRef): number; + /** Sends a hold for `binding`; `again`: the binding already has values (a re-hold), its unit is not urgent. */ + hold(binding: RemoteBinding, again: boolean): number; release(id: number): void; call(hold: number, member: string, args: unknown, root: RootRef | undefined, signal: AbortSignal): Promise; - sub(hold: number, member: string, sink: (frame: StateSink) => void): number; - unsub(id: number): void; + /** Starts (`sub`) or stops (`unsub`) the state of a hold. */ + wantState(hold: number, wanted: boolean): void; } -/** What a remote reference needs from its host. */ +/** What a binding needs from its host. */ export interface RemoteHost { readonly inFactory: number; report(error: unknown): void; root(fn: (ctx: Ctx) => T | Promise, options?: { signal?: AbortSignal }): Promise; - forgetRemote(ref: RemoteRef): void; + /** The binding has no views left: the host lingers it or closes it. */ + bindingIdle(binding: RemoteBinding): void; + /** The binding ended or was closed: the host forgets it. */ + forgetBinding(binding: RemoteBinding): void; +} + +type Settle = { resolve: () => void; reject: (error: unknown) => void }; + +function deferred(): { promise: Promise } & Settle { + let resolve = noop; + let reject: (error: unknown) => void = noop; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + promise.catch(noop); + return { promise, resolve, reject }; +} + +/** A member's value with the decoder of its current stream. */ +interface MemberState { + value: unknown; + decoder: Decoder; } /** - * A state member of a remote reference: hydrated while subscribed (snapshot, then decoded ops); `undefined` before. - * Re-subscribing after a reconnect delivers the new snapshot as a reset. + * One hold per host and key (type, normalized input, principal key, contract fingerprint), shared by every reference to + * it: one hold over the link, one stream per state member, calls over the same hold. References are views onto it. + * + * A hold implies the state of its variant (unless no view wants state): the provider answers with a unit, the shape + * followed by a snapshot of each state member the binding lacks. A shape is applied only once all of its snapshots are + * here, so a live binding always has every state member of its variant. After the first hydration the values stay + * readable while the binding re-holds (link loss): status `loading`, last values kept. */ -class RemoteReadable implements Readable { - readonly #ref: RemoteRef; +export class RemoteBinding { + readonly host: RemoteHost; + readonly key: string; + readonly contract: AnyContract; + readonly at: string; + readonly input: unknown; + /** Forwarded principal (omitted for the host's own principal). */ + readonly principal: unknown | undefined; + /** `ended`: gone with `error`, or closed (no views, no linger). */ + status: "loading" | "live" | "ended" = "loading"; + error: unknown; + /** The applied shape (kept while re-holding). */ + shape: Shape | undefined; + /** Every state member of the applied shape has a value (stays true while re-holding). */ + hydrated = false; + /** The current hold; undefined while resolving the route or after a link loss. */ + hold: { readonly channel: RemoteChannel; readonly id: number } | undefined; + /** What the provider was told: state for this hold (`state: false` / `sub` / `unsub`). */ + stateOn = false; + readonly views = new Set(); + readonly members = new Map(); + /** Lingering since (no views): set by the host. */ + lingerTimer: ReturnType | undefined; + readonly #listeners = new Map>(); + /** A shape waiting for its snapshots. */ + #pending: { shape: Shape; readonly missing: Set; readonly values: Map } | undefined; + /** Resolved while a shape is applied for the current hold (calls wait for it). */ + #live = deferred(); + /** A shape was applied once (a re-hold says `again`). */ + #applied = false; + /** Waiting for the first hydration (`whenHydrated`). */ + readonly #hydrationWaiters = new Set(); + + constructor( + host: RemoteHost, + fields: { key: string; contract: AnyContract; at: string; input: unknown; principal: unknown | undefined }, + ) { + this.host = host; + this.key = fields.key; + this.contract = fields.contract; + this.at = fields.at; + this.input = fields.input; + this.principal = fields.principal; + } + + get ended(): boolean { + return this.status === "ended"; + } + + /** Views that want state. */ + get stateViews(): number { + let count = 0; + for (const view of this.views) if (view.wantsState) count++; + return count; + } + + /** Whether the next hold should carry state: some view wants it, or nobody is left (lingering keeps it). */ + get wantsState(): boolean { + return this.views.size === 0 ? this.stateOn : this.stateViews > 0; + } + + /** Whether a re-hold is `again` (the binding already has a shape). */ + get applied(): boolean { + return this.#applied; + } + + // ─── views ─── + + addView(view: RemoteRef): void { + this.views.add(view); + if (this.lingerTimer !== undefined) { + clearTimeout(this.lingerTimer); + this.lingerTimer = undefined; + } + this.#syncState(); + view.update(); + } + + removeView(view: RemoteRef): void { + if (!this.views.delete(view)) return; + for (const set of this.#listeners.values()) + for (const readable of set) if (readable.view === view) set.delete(readable); + if (this.views.size === 0) { + this.host.bindingIdle(this); + return; + } + this.#syncState(); + } + + /** Tells the provider to start or stop state when the views' wish changed. */ + #syncState(): void { + if (this.ended || this.views.size === 0) return; + const wanted = this.stateViews > 0; + if (wanted === this.stateOn) return; + this.stateOn = wanted; + if (!wanted) { + this.hydrated = false; + this.members.clear(); + if (this.#pending !== undefined) this.#pending.missing.clear(); + } + if (this.hold !== undefined) this.hold.channel.wantState(this.hold.id, wanted); + for (const view of this.views) view.update(); + } + + listen(name: string, readable: ViewReadable): void { + let set = this.#listeners.get(name); + if (set === undefined) { + set = new Set(); + this.#listeners.set(name, set); + } + set.add(readable); + } + + unlisten(name: string, readable: ViewReadable): void { + this.#listeners.get(name)?.delete(readable); + } + + /** Whether `name` is a state member of the applied variant (as the consumer's contract describes it). */ + inVariant(name: string): boolean { + const variant = this.shape?.variant; + return variant !== undefined && this.contract.description.variants[variant]?.[name]?.kind === "state"; + } + + /** The members' value as views read it: throws `not_ready` before the first hydration, `not_in_variant` outside it. */ + read(name: string): unknown { + if (this.status === "ended") throw this.terminalError(); + if (!this.hydrated) throw new ChordError("not_ready", `${this.key}.${name} is not hydrated yet`); + if (!this.inVariant(name)) + throw new ChordError( + "not_in_variant", + `${this.key}: ${name} is not a state member of variant ${this.shape?.variant}`, + ); + return this.members.get(name)?.value; + } + + /** Resolves once the binding has its state (rejects if it ends first). */ + whenHydrated(): Promise { + if (this.hydrated) return Promise.resolve(); + if (this.ended) return Promise.reject(this.terminalError()); + return new Promise((resolve, reject) => this.#hydrationWaiters.add({ resolve, reject })); + } + + #settleHydration(error?: unknown): void { + const waiters = [...this.#hydrationWaiters]; + this.#hydrationWaiters.clear(); + for (const waiter of waiters) { + if (error === undefined) waiter.resolve(); + else waiter.reject(error); + } + } + + readable(name: string): boolean { + return this.status !== "ended" && this.hydrated && this.inVariant(name); + } + + terminalError(): unknown { + return this.error ?? new ChordError("released", `reference ${this.key} released`); + } + + // ─── driven by the host and the link ─── + + bound(channel: RemoteChannel, id: number): void { + this.hold = { channel, id }; + this.stateOn = this.wantsState; + } + + /** A unit begins: `listed` are the members whose snapshots follow. */ + shaped(shape: Shape, listed: readonly string[]): void { + if (this.ended) return; + const previous = this.#pending; + const missing = new Set(listed); + // A newer shape before the older one's snapshots (relays may reorder them): wait for both. + if (previous !== undefined) for (const name of previous.missing) missing.add(name); + const wanted = new Set(this.#stateMembers(shape)); + for (const name of [...missing]) if (!wanted.has(name) || !this.stateOn) missing.delete(name); + this.#pending = { shape, missing, values: previous?.values ?? new Map() }; + if (missing.size === 0) this.#apply(); + } + + /** A state frame for member `name` of the current hold. */ + state(name: string, frame: StateIn): void { + if (this.ended) return; + if (frame.as === "error") { + const error = fromWireError(frame.error!); + // Before the first hydration nobody could read anything: the binding fails instead of waiting forever. + if (!this.hydrated && this.stateOn) { + this.gone(error); + this.close(); + } else this.host.report(error); + return; + } + const pending = this.#pending; + if (frame.as === "ops") { + const target = pending?.values.get(name) ?? this.members.get(name); + if (target === undefined) return; + const ops = target.decoder.decode(frame.ops ?? []); + target.value = applyImmutable(target.value, ops); + if (target === this.members.get(name)) this.#deliver(name, { type: "ops", ops, value: target.value }); + return; + } + const fresh: MemberState = { value: frame.value, decoder: decoder() }; + if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) { + pending.values.set(name, fresh); + pending.missing.delete(name); + if (pending.missing.size === 0) this.#apply(); + return; + } + if (!this.inVariant(name)) return; + this.members.set(name, fresh); + this.#deliver(name, { type: "reset", value: fresh.value }); + } + + /** The provider ended the object (or refused the hold): every view is gone. */ + gone(error: unknown): void { + if (this.ended) return; + this.status = "ended"; + this.error = error; + this.hold = undefined; + this.#pending = undefined; + this.#live.reject(error); + this.#settleHydration(error); + for (const view of [...this.views]) view.end(error); + this.#listeners.clear(); + this.host.forgetBinding(this); + } + + /** The link went down but may come back (upstream reconnect): keep the values, wait for the re-hold. */ + detached(): void { + if (this.ended) return; + this.hold = undefined; + this.#pending = undefined; + if (this.status === "live") { + this.status = "loading"; + this.#live = deferred(); + } + for (const view of this.views) view.update(); + } + + /** No views and no linger: releases the hold and forgets the binding. */ + close(): void { + if (this.lingerTimer !== undefined) clearTimeout(this.lingerTimer); + this.lingerTimer = undefined; + if (this.hold !== undefined) this.hold.channel.release(this.hold.id); + this.hold = undefined; + if (this.status !== "ended") { + this.status = "ended"; + const error = new ChordError("released", `reference ${this.key} released`); + this.#live.reject(error); + this.#settleHydration(error); + for (const view of [...this.views]) view.end(error); + } + this.#pending = undefined; + this.#listeners.clear(); + this.host.forgetBinding(this); + } + + /** Calls `name` over the current hold, waiting for it while the binding (re)holds. */ + async call(name: string, args: unknown, ctx: Ctx): Promise { + for (;;) { + if (this.ended) throw this.terminalError(); + if (this.status !== "live" || this.hold === undefined) await raceSignal(this.#live.promise, ctx.signal); + const hold = this.hold; + if (hold === undefined || this.status !== "live") continue; + const variant = this.shape?.variant; + const description = variant === undefined ? undefined : this.contract.description.variants[variant]?.[name]; + if (description?.kind !== "method") { + throw new ChordError("not_in_variant", `${this.key}: ${name} is not a method of variant ${variant}`); + } + // Send only what the consumer's own schema knows (the additive rule's premise; see ObjectImpl.call). + const sent = description.args === undefined ? undefined : Value.Clean(description.args, Value.Clone(args)); + return hold.channel.call( + hold.id, + name, + sent, + { rootId: ctx.rootId, codeVersion: ctx.codeVersion }, + ctx.signal, + ); + } + } + + #stateMembers(shape: Shape): string[] { + const members = shape.variant === undefined ? undefined : this.contract.description.variants[shape.variant]; + if (members === undefined) return []; + return shape.members.filter((name) => members[name]?.kind === "state"); + } + + /** Applies the pending shape with its snapshots: members outside the variant go, new values are announced. */ + #apply(): void { + const pending = this.#pending; + if (pending === undefined) return; + this.#pending = undefined; + this.shape = pending.shape; + const wanted = new Set(this.#stateMembers(pending.shape)); + for (const name of [...this.members.keys()]) if (!wanted.has(name)) this.members.delete(name); + for (const [name, state] of pending.values) if (wanted.has(name)) this.members.set(name, state); + this.hydrated = this.stateOn && [...wanted].every((name) => this.members.has(name)); + if (this.hydrated) this.#settleHydration(); + this.status = "live"; + this.#applied = true; + this.#live.resolve(); + for (const view of this.views) view.update(); + for (const [name, state] of pending.values) { + if (wanted.has(name)) this.#deliver(name, { type: "reset", value: state.value }); + } + for (const view of [...this.views]) view.shapeChanged(); + } + + #deliver(name: string, event: StateEvent): void { + const readables = this.#listeners.get(name); + if (readables === undefined) return; + for (const readable of [...readables]) readable.deliver(event); + } +} + +/** + * A state member of a remote reference: the binding's value. Reading throws `not_ready` before the first hydration and + * `not_in_variant` outside the variant. A listener added before the hydration gets its snapshot then. + */ +class ViewReadable implements Readable { + readonly view: RemoteRef; readonly #name: string; readonly #values = new Set<(value: unknown) => void>(); readonly #events = new Set<(event: StateEvent) => void>(); - #sub: { readonly channel: RemoteChannel; readonly id: number } | undefined; - #decoder: Decoder = decoder(); - #value: unknown; + /** Event listeners that have not had their snapshot yet. */ + readonly #unseen = new Set<(event: StateEvent) => void>(); - constructor(ref: RemoteRef, name: string) { - this.#ref = ref; + constructor(view: RemoteRef, name: string) { + this.view = view; this.#name = name; } get value(): unknown { - return this.#value; + if (this.view.ended) throw this.view.terminalError(); + return this.view.binding.read(this.#name); + } + + /** Not hydrated before the binding's first snapshots (a local object forwarding this member waits for them). */ + get [HYDRATION](): Hydration | undefined { + const binding = this.view.binding; + if (binding.hydrated) return undefined; + return { hydrated: false, whenHydrated: () => binding.whenHydrated() }; } subscribe(listener: (value: unknown) => void): () => void { this.#values.add(listener); - this.#safe(() => listener(this.#value)); - this.bind(); + this.#attach(); + if (this.#readable()) this.#safe(() => listener(this.view.binding.members.get(this.#name)?.value)); return () => this.#remove(this.#values, listener); } events(listener: (event: StateEvent) => void): () => void { this.#events.add(listener); - this.#safe(() => listener({ type: "snap", value: this.#value })); - this.bind(); - return () => this.#remove(this.#events, listener); + this.#attach(); + if (this.#readable()) { + const value = this.view.binding.members.get(this.#name)?.value; + this.#safe(() => listener({ type: "snap", value })); + } else this.#unseen.add(listener); + return () => { + this.#unseen.delete(listener); + this.#remove(this.#events, listener); + }; } - /** Subscribes upstream while someone listens and the reference is bound; drops a subscription on an old link. */ - bind(): void { - const listening = this.#values.size + this.#events.size > 0; - // Nobody listens: the value would go stale, so forget it. The next subscriber sees `undefined`, then the - // snapshot. - if (!listening) this.#value = undefined; - const binding = listening ? this.#ref.binding : undefined; - if (binding !== undefined && this.#sub?.channel === binding.channel) return; - if (this.#sub !== undefined) { - if (this.#sub.channel !== binding?.channel || binding === undefined) this.#sub.channel.unsub(this.#sub.id); - this.#sub = undefined; + deliver(event: StateEvent): void { + for (const listener of [...this.#events]) { + if (!this.#unseen.delete(listener)) this.#safe(() => listener(event)); + else if (event.type !== "ops") this.#safe(() => listener({ type: "snap", value: event.value })); } - if (binding === undefined) return; - const channel = binding.channel; - this.#decoder = decoder(); - const id = channel.sub(binding.id, this.#name, (frame) => this.#receive(frame)); - this.#sub = { channel, id }; - } - - /** The link is gone: keep the last value, resubscribe on the next binding. */ - detach(): void { - this.#sub = undefined; + for (const listener of [...this.#values]) this.#safe(() => listener(event.value)); } stop(): void { this.#values.clear(); this.#events.clear(); - this.bind(); + this.#unseen.clear(); + this.view.binding.unlisten(this.#name, this); } - #receive(frame: StateSink): void { - if (frame.error !== undefined) { - this.#ref.host.report(fromWireError(frame.error)); - return; - } - let event: StateEvent; - if (frame.ops === undefined && !("snap" in frame) && !("reset" in frame)) return; - if (frame.ops !== undefined) { - const ops = this.#decoder.decode(frame.ops); - this.#value = applyImmutable(this.#value, ops); - event = { type: "ops", ops, value: this.#value }; - } else { - // Listeners already saw a snapshot (`undefined` or an older value), so every upstream snapshot is a reset. - this.#decoder = decoder(); - this.#value = "snap" in frame ? frame.snap : frame.reset; - event = { type: "reset", value: this.#value }; - } - for (const listener of [...this.#events]) this.#safe(() => listener(event)); - for (const listener of [...this.#values]) this.#safe(() => listener(this.#value)); + #readable(): boolean { + return !this.view.ended && this.view.binding.readable(this.#name); + } + + #attach(): void { + if (!this.view.ended) this.view.binding.listen(this.#name, this); } #remove(set: Set, listener: T): void { set.delete(listener); - this.bind(); + if (this.#values.size + this.#events.size === 0) this.view.binding.unlisten(this.#name, this); } #safe(fn: () => void): void { try { fn(); } catch (error) { - this.#ref.host.report(error); + this.view.binding.host.report(error); } } } -/** A reference on an object on another host, bound to a link. Late-bound like a local reference. */ +/** + * A reference to an object on another host: a view onto the host's shared binding for its key. Each view has its own + * owner, status, observers and `release`; ending by the provider and re-holding belong to the binding. + */ export class RemoteRef implements RefCore { readonly object = undefined; - readonly host: RemoteHost; - readonly contract: AnyContract; - readonly at: string; - readonly input: unknown; - /** Forwarded principal (omitted for the host's own principal). */ - readonly principal: unknown | undefined; + readonly binding: RemoteBinding; readonly owner: RefOwner; - readonly address: string; + /** False: a method-only view (`state: false`), ready once live. */ + readonly wantsState: boolean; readonly proxy: object; readonly ready: Promise; status: RefStatus = "loading"; error: unknown; - shape: Shape | undefined; - binding: { readonly channel: RemoteChannel; readonly id: number } | undefined; readonly shapeListeners = new Set<(shape: Shape) => void>(); - readonly #readables = new Map(); + readonly #statusListeners = new Set(); + readonly #readables = new Map(); readonly #callers = new Map Promise>(); - #settleReady: { resolve: () => void; reject: (error: unknown) => void } | undefined; - #live: { promise: Promise; resolve: () => void; reject: (error: unknown) => void }; + #settleReady: Settle | undefined; - constructor( - host: RemoteHost, - fields: { - contract: AnyContract; - at: string; - input: unknown; - principal: unknown | undefined; - owner: RefOwner; - address: string; - }, - ) { - this.host = host; - this.contract = fields.contract; - this.at = fields.at; - this.input = fields.input; - this.principal = fields.principal; - this.owner = fields.owner; - this.address = fields.address; - this.ready = new Promise((resolve, reject) => { - this.#settleReady = { resolve, reject }; - }); - this.ready.catch(noop); - this.#live = this.#newLive(); + constructor(binding: RemoteBinding, owner: RefOwner, wantsState: boolean) { + this.binding = binding; + this.owner = owner; + this.wantsState = wantsState; + const ready = deferred(); + this.ready = ready.promise; + this.#settleReady = ready; this.proxy = new Proxy(this, { get: (target, property) => { if (REF_BASE.has(property)) { @@ -201,8 +515,24 @@ export class RemoteRef implements RefCore { refsByProxy.set(this.proxy, this); } + get contract(): AnyContract { + return this.binding.contract; + } + + get address(): string { + return this.binding.key; + } + + get ended(): boolean { + return this.status === "released" || this.status === "gone"; + } + get variant(): string | undefined { - return this.shape?.variant; + return this.binding.shape?.variant; + } + + get shape(): Shape | undefined { + return this.binding.shape; } onShape(listener: (shape: Shape) => void): () => void { @@ -215,74 +545,62 @@ export class RemoteRef implements RefCore { return () => this.#statusListeners.delete(listener); } - readonly #statusListeners = new Set(); - - #statusChanged(): void { - notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.host.report(error)); + terminalError(): unknown { + return this.status === "gone" ? this.error : new ChordError("released", `reference ${this.address} released`); } release(): void { - if (this.status === "released" || this.status === "gone") return; + if (this.ended) return; this.status = "released"; - this.#statusChanged(); - const error = new ChordError("released", `reference ${this.address} released`); - this.#settleReady?.reject(error); - this.#live.reject(error); + this.#notify(); + this.#settleReady?.reject(new ChordError("released", `reference ${this.address} released`)); + this.#settleReady = undefined; for (const readable of this.#readables.values()) readable.stop(); - if (this.binding !== undefined) this.binding.channel.release(this.binding.id); - this.binding = undefined; - this.host.forgetRemote(this); + this.binding.removeView(this); } - // ─── driven by the link ─── + // ─── driven by the binding ─── - bound(channel: RemoteChannel, id: number): void { - this.binding = { channel, id }; - } - - shaped(shape: Shape): void { - if (this.status === "released" || this.status === "gone") return; - this.shape = shape; - const wasLoading = this.status === "loading"; - this.status = "live"; - if (wasLoading) this.#statusChanged(); - if (wasLoading) { + /** Follows the binding: live once it has a shape (and, for a state view, its state) over a current hold. */ + update(): void { + if (this.ended) return; + const binding = this.binding; + const live = binding.status === "live" && binding.hold !== undefined && (!this.wantsState || binding.hydrated); + const next: RefStatus = live ? "live" : "loading"; + if (next === this.status) return; + this.status = next; + this.#notify(); + if (live) { this.#settleReady?.resolve(); this.#settleReady = undefined; - this.#live.resolve(); } - for (const readable of this.#readables.values()) readable.bind(); + } + + end(error: unknown): void { + if (this.ended) return; + const released = error instanceof ChordError && error.code === "released"; + this.status = released ? "released" : "gone"; + if (!released) this.error = error; + this.#notify(); + this.#settleReady?.reject(error); + this.#settleReady = undefined; + for (const readable of this.#readables.values()) readable.stop(); + } + + shapeChanged(): void { + const shape = this.binding.shape; + if (shape === undefined || this.ended) return; for (const listener of [...this.shapeListeners]) { try { listener(shape); } catch (error) { - this.host.report(error); + this.binding.host.report(error); } } } - gone(error: unknown): void { - if (this.status === "released" || this.status === "gone") return; - this.status = "gone"; - this.error = error; - this.#statusChanged(); - this.binding = undefined; - this.#settleReady?.reject(error); - this.#live.reject(error); - for (const readable of this.#readables.values()) readable.stop(); - this.host.forgetRemote(this); - } - - /** The link went down but may come back (upstream reconnect): wait, then re-hold. */ - detached(): void { - if (this.status === "released" || this.status === "gone") return; - this.binding = undefined; - for (const readable of this.#readables.values()) readable.detach(); - if (this.status === "live") { - this.status = "loading"; - this.#live = this.#newLive(); - this.#statusChanged(); - } + #notify(): void { + notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.binding.host.report(error)); } // ─── members ─── @@ -295,7 +613,7 @@ export class RemoteRef implements RefCore { if (description?.kind === "state") { let readable = this.#readables.get(name); if (readable === undefined) { - readable = new RemoteReadable(this, name); + readable = new ViewReadable(this, name); this.#readables.set(name, readable); } return readable; @@ -310,7 +628,8 @@ export class RemoteRef implements RefCore { caller = (...given) => { // A method without arguments is called as `ref.name(ctx)`. const [args, ctx] = (noArgs ? [undefined, given[0]] : given) as [unknown, Ctx | undefined]; - if (this.host.inFactory > 0) + const host = this.binding.host; + if (host.inFactory > 0) throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`); if (ctx instanceof CtxImpl) { try { @@ -320,7 +639,7 @@ export class RemoteRef implements RefCore { } } if (ctx === undefined || (ctx instanceof CtxImpl && ctx.holder)) { - return this.host.root((root) => this.#call(name, args, root), { signal: ctx?.signal }); + return host.root((root) => this.#call(name, args, root), { signal: ctx?.signal }); } return this.#call(name, args, ctx); }; @@ -329,46 +648,8 @@ export class RemoteRef implements RefCore { return caller; } - async #call(name: string, args: unknown, ctx: Ctx): Promise { - for (;;) { - if (this.status === "released" || this.status === "gone") { - throw this.status === "gone" - ? this.error - : new ChordError("released", `reference ${this.address} released`); - } - if (this.status === "loading") await raceSignal(this.#live.promise, ctx.signal); - const binding = this.binding; - if (binding === undefined) continue; - const variant = this.shape?.variant; - if (variant === undefined || this.contract.description.variants[variant]?.[name]?.kind !== "method") { - throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${variant}`); - } - // Send only what the consumer's own schema knows (the additive rule's premise; see RecordImpl.call). - const description = this.contract.description.variants[variant]?.[name]; - const sent = - description?.kind !== "method" - ? args - : description.args === undefined - ? undefined - : Value.Clean(description.args, Value.Clone(args)); - return binding.channel.call( - binding.id, - name, - sent, - { rootId: ctx.rootId, codeVersion: ctx.codeVersion }, - ctx.signal, - ); - } - } - - #newLive(): { promise: Promise; resolve: () => void; reject: (error: unknown) => void } { - let resolve = noop; - let reject: (error: unknown) => void = noop; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - promise.catch(noop); - return { promise, resolve, reject }; + #call(name: string, args: unknown, ctx: Ctx): Promise { + if (this.ended) return Promise.reject(this.terminalError()); + return this.binding.call(name, args, ctx); } } diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts index a19b238d4..3ca754aaa 100644 --- a/packages/chord/src/types.ts +++ b/packages/chord/src/types.ts @@ -8,6 +8,11 @@ import type { Op } from "./delta/index.ts"; export interface GetOptions

{ /** Principal the object is created for. Defaults to the host's own principal (`createHost({ self })`). */ readonly principal?: P; + /** + * False: a method-only reference. It receives no state (unless another reference to the same object wants it), and + * is ready once its object is live. + */ + readonly state?: boolean; } /** @@ -20,7 +25,13 @@ export interface Ctx

{ readonly codeVersion: number; /** Who the root acts as: the caller at ingress, the host's own principal inside objects. */ readonly principal: P; - get(type: C, input: InputOf, options?: GetOptions

): Ref; + /** + * A reference for this call, released when the call settles. Resolves once it is ready: live, with every state + * member of its variant (unless `state: false`). Rejects with the activation error, `aborted` when the ctx is + * aborted or the call settles first, or `unavailable/ready_timeout` after the host's `readyTimeout`; the reference + * is released then. + */ + get(type: C, input: InputOf, options?: GetOptions

): Promise>; } export type StateEvent = @@ -38,10 +49,10 @@ export interface Readable { } /** - * What an implementation may expose as a state member: a cell, a derivation, or a held object's state member (whose - * value is `undefined` only while that object is loading; an object is live only after its references are). + * What an implementation may expose as a state member: a cell, a derivation, or a held object's state member (an + * object goes live only once the members it exposes have their values). */ -export type StateSource = Readable | Readable; +export type StateSource = Readable; declare const EXPOSED: unique symbol; @@ -57,7 +68,6 @@ export interface Shape { readonly members: readonly string[]; /** Fingerprint of the provider contract. */ readonly contract: string; - readonly codeVersion: number; } export interface RefBase { diff --git a/packages/chord/src/wire.ts b/packages/chord/src/wire.ts index 28cc27d3b..b9271f130 100644 --- a/packages/chord/src/wire.ts +++ b/packages/chord/src/wire.ts @@ -5,8 +5,10 @@ import { CHORD_ERROR_CODES, ChordError } from "./errors.ts"; // JSON frames between two hosts (or a host and a client). Ids are chosen by the side that creates the hold, call or // subscription; every frame type has one direction, so ids never collide: -// consumer → provider: hold, release, call, cancel, sub, unsub +// consumer → provider: hold, release, call, cancel, sub, unsub, resync // provider → consumer: shape, gone, result, state +// State is addressed by (hold id, member): a hold implies the state of its variant, sent as units (a shape followed by +// a snapshot of each member the consumer lacks; see Connection). // both: hello, ping, pong, bye, root_end, ack; parent → child: invalidate, prepare, commit, abort, retire; // child → parent: vote // @@ -19,7 +21,7 @@ import { CHORD_ERROR_CODES, ChordError } from "./errors.ts"; * closes the link with reason `protocol-version `, so the other side learns it too * (`LinkOptions.onProtocolMismatch`). */ -export const PROTOCOL_VERSION = 3; +export const PROTOCOL_VERSION = 4; const Id = Type.Integer({ minimum: 1 }); const Name = Type.String({ minLength: 1 }); @@ -82,8 +84,15 @@ const FrameSchema = Type.Union([ input: Type.Unknown(), /** A forwarded principal. Honored only on frames from the parent link. */ principal: Type.Optional(Type.Unknown()), - /** Set by a relay when the hold originates from a client (browser): not a reload participant. */ - client: Type.Optional(Type.Boolean()), + /** + * The client connection the hold comes from: an opaque id the server assigned to it, forwarded by relays. Honored + * only on frames from the parent link; present means the hold is a client's (not a reload participant). + */ + origin: Type.Optional(Name), + /** False: no state for this hold until `sub` (a method-only binding). */ + state: Type.Optional(Type.Literal(false)), + /** The consumer already has values (a re-hold after a link loss): its unit is not urgent. */ + again: Type.Optional(Type.Literal(true)), }), Type.Object({ t: Type.Literal("release"), id: Id }), Type.Object({ @@ -92,7 +101,8 @@ const FrameSchema = Type.Union([ variant: Type.Optional(Type.String()), members: List(Type.String()), contract: Type.String(), - codeVersion: Type.Integer(), + /** The state members whose snapshots follow this frame; the consumer applies the shape once they are here. */ + state: List(Name), }), Type.Object({ t: Type.Literal("gone"), id: Id, error: WireErrorSchema }), Type.Object({ @@ -111,14 +121,20 @@ const FrameSchema = Type.Union([ error: Type.Optional(WireErrorSchema), }), Type.Object({ t: Type.Literal("cancel"), id: Id }), - Type.Object({ t: Type.Literal("sub"), id: Id, hold: Id, member: Name }), - Type.Object({ t: Type.Literal("unsub"), id: Id }), + /** Starts the state of a hold opened with `state: false` (answered with a unit). */ + Type.Object({ t: Type.Literal("sub"), hold: Id }), + /** Stops the state of a hold. */ + Type.Object({ t: Type.Literal("unsub"), hold: Id }), + /** Sends member `m` again as a reset (a relay that had to drop what waited for it). */ + Type.Object({ t: Type.Literal("resync"), hold: Id, m: Name }), Type.Object({ t: Type.Literal("state"), - id: Id, - snap: Type.Optional(Type.Unknown()), - reset: Type.Optional(Type.Unknown()), - /** Encoded with the subscription's delta encoder; the decoder validates each op. */ + hold: Id, + m: Name, + /** `snap`/`reset` without `value`: the value is `undefined`. */ + as: Type.Union([Type.Literal("snap"), Type.Literal("reset"), Type.Literal("ops"), Type.Literal("error")]), + value: Type.Optional(Type.Unknown()), + /** Encoded with the stream's delta encoder; the decoder validates each op. */ ops: Type.Optional(List(List(Type.Unknown()))), error: Type.Optional(WireErrorSchema), }), diff --git a/packages/chord/test/awaiting.test.ts b/packages/chord/test/awaiting.test.ts new file mode 100644 index 000000000..655d66736 --- /dev/null +++ b/packages/chord/test/awaiting.test.ts @@ -0,0 +1,101 @@ +// Entry 8: awaited gets. `host.get` and `ctx.get` resolve once the reference is ready; abort, the ctx closing and the +// host's `readyTimeout` reject them and release only that reference. +import assert from "node:assert/strict"; +import { Type } from "typebox"; +import { describe, it } from "vitest"; +import { type Ctx, createHost, type Host, hostNamed, isChordError, memoryLinkPair, remote } from "../src/index.ts"; +import { CountingLink } from "./fixtures/counting.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(check: () => boolean, attempts = 200): Promise { + for (let i = 0; i < attempts && !check(); i++) await sleep(2); + assert.ok(check(), "condition not reached"); +} + +const Slow = remote("t.e8-slow", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { main: { state: { n: Type.Number() } } }, +}); + +/** A server whose `Slow` object goes live when `open()` is called; a browser whose link counts frames. */ +function setup(readyTimeout?: number) { + let open = () => {}; + const opened = new Promise((resolve) => { + open = resolve; + }); + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + server.serve(Slow, (_input, _principal, self) => { + self.resource("gate", { version: 1, open: () => opened }); + return { variant: "main", n: self.state("n", Type.Number(), 1) }; + }); + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { user: "u" } }); + const link = new CountingLink(up); + const browser: Host = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + ...(readyTimeout === undefined ? {} : { readyTimeout }), + }); + return { server, browser, link, open }; +} + +describe("awaited gets", () => { + it("an aborted get rejects and releases only its reference; the binding stays for the others", async () => { + const { server, browser, link, open } = setup(); + const abort = new AbortController(); + const first = browser.get(Slow, {}, { signal: abort.signal }); + const second = browser.get(Slow, {}); + await sleep(5); + abort.abort(); + await assert.rejects(first, (error) => isChordError(error, "aborted")); + await sleep(5); + assert.equal(link.count("sent", "release"), 0, "the other awaiting get keeps the hold"); + open(); + const ref = await second; + assert.equal(ref.n.value, 1); + assert.equal(link.count("sent", "hold"), 1); + ref.release(); + await until(() => link.count("sent", "release") === 1); + await server.close(); + }); + + it("a ctx.get still waiting when its call settles rejects aborted and releases its reference", async () => { + const { server, browser, link, open } = setup(); + let pending: Promise | undefined; + await browser.root(async (ctx: Ctx) => { + pending = ctx.get(Slow, {}); + pending.catch(() => {}); + }); + await assert.rejects(pending!, (error) => isChordError(error, "aborted")); + await until(() => link.count("sent", "release") === 1); + open(); + await server.close(); + }); + + it("a get times out after readyTimeout with ready_timeout and releases its hold", async () => { + const { server, browser, link } = setup(30); + await assert.rejects( + browser.get(Slow, {}), + (error) => isChordError(error, "unavailable") && error.reason === "ready_timeout", + ); + await until(() => link.count("sent", "release") === 1); + await server.close(); + }); + + it("ctx.get resolves hydrated; the call's reference is released when the call ends", async () => { + const { server, browser, link, open } = setup(); + open(); + const value = await browser.root(async (ctx: Ctx) => { + const slow = await ctx.get(Slow, {}); + return slow.n.value; + }); + assert.equal(value, 1); + await until(() => link.count("sent", "release") === 1); + await server.close(); + }); +}); diff --git a/packages/chord/test/bindings.test.ts b/packages/chord/test/bindings.test.ts new file mode 100644 index 000000000..6e7d9b9b6 --- /dev/null +++ b/packages/chord/test/bindings.test.ts @@ -0,0 +1,459 @@ +// Entry 8: one shared binding per host and key; a hold implies the state of its variant (units: shape, then +// snapshots); method-only references; origins of client holds; protocol version 4. +import assert from "node:assert/strict"; +import { Type } from "typebox"; +import { describe, it } from "vitest"; +import { HostImpl } from "../src/host.ts"; +import { + type Ctx, + createHost, + type Frame, + type Host, + hostFamily, + hostNamed, + isChordError, + type Link, + local, + memoryLinkPair, + method, + PROTOCOL_VERSION, + remote, +} from "../src/index.ts"; +import { CountingLink, stateFrames } from "./fixtures/counting.ts"; + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(check: () => boolean, attempts = 200): Promise { + for (let i = 0; i < attempts && !check(); i++) await sleep(2); + assert.ok(check(), "condition not reached"); +} + +const NoteSchema = Type.Array(Type.String()); +const Notes = remote("t.e8-notes", { + version: 1, + input: Type.Object({ list: Type.Optional(Type.String({ default: "main" })) }), + host: hostNamed("server"), + variants: { + main: { + state: { notes: NoteSchema, title: Type.String() }, + methods: { add: method(Type.String()), rename: method(Type.String()) }, + }, + }, +}); + +function serveNotes(host: Host): void { + host.serve(Notes, (_input, _principal, self) => { + const notes = self.state("notes", NoteSchema, []); + const title = self.state("title", Type.String(), "untitled"); + return { + variant: "main", + notes, + title, + add: (text: string, ctx: Ctx) => { + notes.change(ctx, (draft) => { + draft.push(text); + }); + }, + rename: (text: string, ctx: Ctx) => { + title.replace(ctx, text); + }, + }; + }); +} + +/** A server serving `Notes`, and a browser whose end of the link counts what crosses it. */ +function setup( + options: { readonly window?: number; readonly linger?: { types?: Record; cap?: number } } = {}, +) { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + serveNotes(server); + const [down, up] = memoryLinkPair(); + server.accept(down, { + principal: { user: "u" }, + ...(options.window === undefined ? {} : { window: options.window }), + }); + const link = new CountingLink(up); + const errors: unknown[] = []; + const browser = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: (error) => errors.push(error), + ...(options.linger === undefined ? {} : { linger: options.linger }), + }); + return { server, browser, link, errors }; +} + +describe("shared bindings: traffic", () => { + it("N references to one object: one hold, one snapshot per member, one frame per change, one release", async () => { + const { server, browser, link } = setup(); + const Panel = local("t.e8-panel", { version: 1, input: Type.Object({}), members: { count: Type.Number() } }); + browser.serve(Panel, (_input, _principal, self) => { + const notes = self.get(Notes, {}); + return { count: self.derive([notes.notes], (list) => list.length) }; + }); + const a = browser.hold(Notes, {}); + const b = browser.hold(Notes, { list: "main" }); + const panel = browser.hold(Panel, {}); + await Promise.all([a.ready, b.ready, panel.ready]); + const viaCtx = await browser.root(async (ctx) => { + const ref = await ctx.get(Notes, {}); + return ref.title.value; + }); + assert.equal(viaCtx, "untitled"); + assert.equal(link.count("sent", "hold"), 1, "one hold for every reference"); + const snaps = stateFrames(link.received).filter((frame) => frame.as === "snap"); + assert.deepEqual(snaps.map((frame) => frame.m).sort(), ["notes", "title"]); + + const before = stateFrames(link.received).length; + await a.add("milk"); + await until(() => a.notes.value.length === 1 && b.notes.value.length === 1 && panel.count.value === 1); + await tick(); + assert.equal(stateFrames(link.received).length - before, 1, "one delta frame for one change, whatever N"); + + const sent = link.traffic("sent").length; + a.release(); + panel.release(); + await sleep(5); + assert.equal(link.traffic("sent").length, sent, "releasing all but one reference sends nothing"); + b.release(); + await until(() => link.count("sent", "release") === 1); + await browser.close(); + await server.close(); + }); + + it("a held reference stays subscribed without listeners; values are readable at once after ready", async () => { + const { server, browser } = setup(); + const notes = browser.hold(Notes, {}); + await notes.ready; + assert.deepEqual(notes.notes.value, []); + await notes.add("bread"); + await until(() => notes.notes.value.length === 1); + assert.equal(notes.title.value, "untitled"); + await browser.close(); + await server.close(); + }); +}); + +describe("shared bindings: linger", () => { + it("a lingering binding keeps its hold and state; a reference taken meanwhile costs nothing on the link", async () => { + const { server, browser, link } = setup({ linger: { types: { [Notes.name]: 40 } } }); + const first = await browser.get(Notes, {}); + const frames = link.traffic("received").length; + first.release(); + await sleep(10); + assert.equal(link.count("sent", "release"), 0, "not released before the linger"); + // A hook mounting and unmounting: no frames either way. + for (let i = 0; i < 3; i++) { + const again = await browser.get(Notes, {}); + assert.deepEqual(again.notes.value, []); + again.release(); + } + assert.equal(link.count("sent", "hold"), 1); + assert.equal(link.traffic("received").length, frames, "nothing new crossed the link"); + await until(() => link.count("sent", "release") === 1); + await browser.close(); + await server.close(); + }); + + it("at most `cap` bindings linger: the least recently released goes first", async () => { + const { server, browser, link } = setup({ linger: { types: { [Notes.name]: 10_000 }, cap: 1 } }); + const a = await browser.get(Notes, { list: "a" }); + const b = await browser.get(Notes, { list: "b" }); + a.release(); + await sleep(5); + assert.equal(link.count("sent", "release"), 0); + b.release(); + await until(() => link.count("sent", "release") === 1); + const release = link.sent.find((frame) => frame.t === "release"); + const holdOfA = link.sent.find((frame) => frame.t === "hold" && (frame.input as { list: string }).list === "a"); + assert.equal(release?.t === "release" ? release.id : 0, holdOfA?.t === "hold" ? holdOfA.id : -1); + await browser.close(); + await server.close(); + }); +}); + +describe("shared bindings: identity", () => { + it("normalized-equal inputs and equal principal keys share; other principals and fingerprints do not", async () => { + const server = createHost({ + self: { kind: "host" }, + name: "server", + onError: () => {}, + }); + const NotesV2 = remote("t.e8-notes", { + version: 2, + input: Type.Object({ list: Type.Optional(Type.String({ default: "main" })) }), + host: hostNamed("server"), + variants: { + main: { + state: { notes: NoteSchema, title: Type.String() }, + methods: { add: method(Type.String()), rename: method(Type.String()), clear: method() }, + }, + }, + }); + server.serve( + NotesV2, + (_input, _principal, self) => { + const notes = self.state("notes", NoteSchema, []); + const title = self.state("title", Type.String(), "untitled"); + return { variant: "main", notes, title, add: () => {}, rename: () => {}, clear: () => {} }; + }, + { accepts: { 1: (current) => current } }, + ); + // A worker holds the server's objects; the principals it forwards key its bindings. + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:w" }); + const link = new CountingLink(up); + const worker = createHost({ + self: { kind: "host" }, + name: "session:w", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + }); + const refs = [ + worker.hold(Notes, {}), + worker.hold(Notes, { list: "main" }), + worker.hold(Notes, {}, { principal: { user: "a", grant: 1 } }), + worker.hold(Notes, {}, { principal: { grant: 1, user: "a" } }), + worker.hold(Notes, {}, { principal: { user: "a", grant: 2 } }), + worker.hold(NotesV2, {}), + ]; + await Promise.all(refs.map((ref) => ref.ready)); + // host (shared by two inputs), user a grant 1 (shared by two key orders), user a grant 2, the v2 contract. + assert.equal(link.count("sent", "hold"), 4); + await worker.close(); + await server.close(); + }); +}); + +describe("hydration over a link", () => { + const Matrix = remote("t.e8-matrix", { + version: 1, + input: Type.Object({ variant: Type.String() }), + host: hostNamed("server"), + variants: { + values: { state: { none: Type.Null(), empty: Type.Array(Type.String()), maybe: Type.Unknown() } }, + bare: { methods: { ping: method(undefined, Type.String()) } }, + broken: { state: { n: Type.Number() } }, + }, + }); + + function serveMatrix(host: Host): void { + host.serve(Matrix, ({ variant }, _principal, self) => { + if (variant === "bare") return { variant: "bare", ping: () => "pong" }; + if (variant === "broken") { + const source = self.state("source", Type.String(), "not a number"); + // A derivation exposed under a number schema: its snapshot fails validation. + return { variant: "broken", n: self.derive([source], (text) => text as unknown as number) }; + } + const none = self.state("none", Type.Null(), null); + const empty = self.state("empty", Type.Array(Type.String()), []); + return { variant: "values", none, empty, maybe: self.derive([none], () => undefined as unknown) }; + }); + } + + it("null, empty and undefined values hydrate; a state-free variant is ready; a failed snapshot rejects", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + serveMatrix(server); + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { user: "u" } }); + const browser = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link: up, principal: { kind: "server" } }, + onError: () => {}, + }); + const values = await browser.get(Matrix, { variant: "values" }); + assert.ok(values.variant === "values"); + assert.equal(values.none.value, null); + assert.deepEqual(values.empty.value, []); + assert.equal(values.maybe.value, undefined); + const bare = await browser.get(Matrix, { variant: "bare" }); + assert.ok(bare.variant === "bare"); + assert.equal(await bare.ping(), "pong"); + assert.throws( + // @ts-expect-error `none` is not a member of the bare variant (a narrowed reference does not show it). + () => bare.none.value, + (error) => isChordError(error, "not_in_variant"), + ); + const broken = browser.hold(Matrix, { variant: "broken" }); + await assert.rejects(broken.ready, (error) => isChordError(error, "invalid")); + assert.equal(broken.status, "gone"); + await browser.close(); + await server.close(); + }); + + it("reading before the first hydration throws not_ready", async () => { + const { server, browser } = setup(); + const notes = browser.hold(Notes, {}); + assert.throws( + () => notes.notes.value, + (error) => isChordError(error, "not_ready"), + ); + await notes.ready; + assert.deepEqual(notes.notes.value, []); + await browser.close(); + await server.close(); + }); +}); + +describe("method-only references (state: false)", () => { + it("transfer no state alone; a full reference joining starts it once; its release stops it", async () => { + const { server, browser, link } = setup(); + const caller = browser.hold(Notes, {}, { state: false }); + await caller.ready; + await caller.add("x"); + const hold = link.sent.find((frame) => frame.t === "hold"); + assert.ok(hold?.t === "hold" && hold.state === false); + assert.deepEqual(stateFrames(link.received), []); + assert.throws( + () => caller.notes.value, + (error) => isChordError(error, "not_ready"), + ); + + const viewer = browser.hold(Notes, {}); + await viewer.ready; + assert.deepEqual(viewer.notes.value, ["x"]); + assert.equal(link.count("sent", "sub"), 1); + assert.equal(link.count("sent", "hold"), 1, "the same hold"); + viewer.release(); + await until(() => link.count("sent", "unsub") === 1); + const after = stateFrames(link.received).length; + await caller.add("y"); + await sleep(5); + assert.equal(stateFrames(link.received).length, after, "no state once only method-only references remain"); + await browser.close(); + await server.close(); + }); +}); + +describe("origins of client holds", () => { + const Counter = remote("t.e8-counter", { + version: 1, + input: Type.Object({ session: Type.String() }), + host: hostFamily("session:", "session"), + variants: { main: { state: { n: Type.Number() }, methods: { add: method(Type.Number()) } } }, + }); + + function cluster() { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + let worker: HostImpl | undefined; + let toWorker: CountingLink | undefined; + server.route("session:", { + principal: (name) => ({ host: name }), + authorize: () => true, + resolve: async (name) => { + if (toWorker !== undefined) return toWorker; + const [down, up] = memoryLinkPair(); + const created = new HostImpl({ + self: { kind: "host" }, + name, + upstream: { link: up, principal: { kind: "server" } }, + onError: () => {}, + }); + created.serve(Counter, (_input, _principal, self) => { + const n = self.state("n", Type.Number(), 0); + return { + variant: "main", + n, + add: (delta: number, ctx: Ctx) => { + n.replace(ctx, n.value + delta); + }, + }; + }); + worker = created; + toWorker = new CountingLink(down); + return toWorker; + }, + }); + const browser = () => { + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { guest: "g" } }); + const host = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link: up, principal: { kind: "server" } }, + onError: () => {}, + }); + return { host, up }; + }; + const origins = () => + [...(worker?.upstream?.served.values() ?? [])].map((served) => + served.kind === "local" ? served.origin : undefined, + ); + return { server, browser, origins, sentToWorker: () => toWorker?.sent ?? [] }; + } + + it("a relayed hold carries its client connection's id; the server's own holds carry none", async () => { + const c = cluster(); + const one = c.browser(); + const two = c.browser(); + await one.host.hold(Counter, { session: "S1" }).ready; + await two.host.hold(Counter, { session: "S1" }).ready; + const own = c.server.hold(Counter, { session: "S1" }); + await own.ready; + const holds = c.sentToWorker().filter((frame): frame is Extract => frame.t === "hold"); + assert.equal(holds.length, 3); + const relayed = holds.filter((frame) => frame.origin !== undefined); + assert.equal(relayed.length, 2); + assert.notEqual(relayed[0]!.origin, relayed[1]!.origin, "two tabs, two origins"); + assert.equal(holds.filter((frame) => frame.origin === undefined).length, 1); + + // Disconnect is departure: the worker loses that origin at once; a reconnect holds under a new id. + const before = new Set(c.origins()); + one.up.close(); + await until(() => c.origins().filter((origin) => origin !== undefined).length === 1); + const three = c.browser(); + await three.host.hold(Counter, { session: "S1" }).ready; + const after = c.origins().filter((origin) => origin !== undefined); + assert.equal(after.length, 2); + assert.ok( + after.some((origin) => !before.has(origin)), + "a new connection gets a new id", + ); + await c.server.close(); + }); + + it("an origin a client sends is ignored: the server stamps its own", async () => { + const c = cluster(); + const [down, raw] = memoryLinkPair(); + c.server.accept(down, { principal: { guest: "g" } }); + raw.receive(() => {}); + raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 }); + raw.send({ + t: "hold", + id: 1, + at: "session:S1", + token: Counter.description, + input: { session: "S1" }, + origin: "spoofed", + }); + await until(() => c.sentToWorker().some((frame) => frame.t === "hold")); + const hold = c.sentToWorker().find((frame) => frame.t === "hold"); + assert.ok(hold?.t === "hold" && hold.origin !== undefined && hold.origin !== "spoofed"); + await c.server.close(); + }); +}); + +describe("protocol version 4", () => { + it("a version 3 peer is refused; shapes carry no code version", async () => { + const { server, browser, link } = setup(); + await browser.hold(Notes, {}).ready; + const shape = link.received.find((frame) => frame.t === "shape"); + assert.ok(shape !== undefined && !("codeVersion" in shape)); + assert.deepEqual(shape.t === "shape" ? [...shape.state].sort() : [], ["notes", "title"]); + + const [down, raw] = memoryLinkPair(); + server.accept(down, { principal: { user: "old" } }); + const closed = new Promise((resolve) => (raw as Link).onClose(resolve)); + raw.receive(() => {}); + raw.send({ t: "hello", protocol: 3, name: "old", kind: "client", codeVersion: 0 }); + assert.equal(await closed, `protocol-version ${PROTOCOL_VERSION}`); + assert.equal(PROTOCOL_VERSION, 4); + await browser.close(); + await server.close(); + }); +}); diff --git a/packages/chord/test/cluster.test.ts b/packages/chord/test/cluster.test.ts index 0b25b4497..1547c3e1f 100644 --- a/packages/chord/test/cluster.test.ts +++ b/packages/chord/test/cluster.test.ts @@ -131,7 +131,7 @@ describe("cross-host reload", () => { it("prepares every host, commits the server first, then the workers; code versions stay in step", async () => { const { server, worker, modules } = setup(); await tick(); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); assert.equal(await greeter.greet("a"), "v1 a"); const go = latch(); const pinned = server.root(async (ctx) => { @@ -140,8 +140,8 @@ describe("cross-host reload", () => { return greeter.greet("p", ctx); }); await tick(); - const shapes: number[] = []; - greeter.onShape((shape) => shapes.push(shape.codeVersion)); + const shapes: string[] = []; + greeter.onShape((shape) => shapes.push(shape.contract)); modules.set("greeter", greeterFacet("v2")); const report = await server.reload([], { remote: { "session:": ["greeter"] } }); // No waiting: the commit frame precedes this call on the FIFO link, so the worker has committed when it arrives. @@ -150,7 +150,7 @@ describe("cross-host reload", () => { assert.equal(worker.codeVersion, report.codeVersion); go.open(); assert.equal(await pinned, "v1 p"); - assert.deepEqual(shapes, [report.codeVersion]); + assert.equal(shapes.length, 1, "one shape push at the commit"); // The server retires the old code version once its last old root ended, then tells the worker. for (let i = 0; i < 20 && worker.previousCodeVersion !== undefined; i++) await tick(); assert.equal(server.previousCodeVersion, undefined); @@ -160,7 +160,7 @@ describe("cross-host reload", () => { it("a failing vote aborts every host; the error keeps its code and names the host", async () => { const { server, worker, modules } = setup(); await tick(); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); await greeter.greet("a"); const before = [server.codeVersion, worker.codeVersion]; modules.set("greeter", greeterFacet("v2", { fail: true })); @@ -176,7 +176,7 @@ describe("cross-host reload", () => { it("a worker gate blocked past its deadline aborts the whole reload", async () => { const { server, worker, modules } = setup(); await tick(); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); const go = latch(); const blocker = server.root(async (ctx) => { await greeter.greet("g", ctx); @@ -205,8 +205,8 @@ describe("cross-host reload", () => { }); await server.install(greeterFacet("v1")); await tick(); - const local = server.get(ServerGreeter, {}); - const child = server.get(Greeter, { session: "S1" }); + const local = server.hold(ServerGreeter, {}); + const child = server.hold(Greeter, { session: "S1" }); assert.equal(await local.greet("a"), "v1 a"); assert.equal(await child.greet("a"), "v1 a"); await server.reload([], { remove: ["greeter"], remoteRemove: { "session:": ["greeter"] } }); @@ -225,7 +225,7 @@ describe("cross-host reload", () => { }); onTestFinished(() => cold.close()); await cold.install(greeterFacet("v1")); - const old = cold.get(Greeter, { session: "cold" }); + const old = cold.hold(Greeter, { session: "cold" }); await old.ready; const [down, up] = memoryLinkPair(); server.accept(down, { principal: { worker: "cold" }, kind: "host", name: "session:cold" }); @@ -237,7 +237,7 @@ describe("cross-host reload", () => { await cold.close(); modules.set("greeter", greeterFacet("v2")); await server.reload([greeterFacet("v2")], { remote: { "session:": ["greeter"] } }); - assert.equal(await server.get(Greeter, { session: "S1" }).greet("b"), "v2 b"); + assert.equal(await server.hold(Greeter, { session: "S1" }).greet("b"), "v2 b"); const fresh = createHost({ self: { kind: "host" }, name: "session:fresh", @@ -249,7 +249,7 @@ describe("cross-host reload", () => { const [freshDown, freshUp] = memoryLinkPair(); server.accept(freshDown, { principal: { worker: "fresh" }, kind: "host", name: "session:fresh" }); fresh.connect(freshUp, { principal: { kind: "server" } }); - assert.equal(await server.get(Greeter, { session: "fresh" }).greet("c"), "v2 c"); + assert.equal(await server.hold(Greeter, { session: "fresh" }).greet("c"), "v2 c"); }); it("only the server coordinates reloads", async () => { @@ -275,14 +275,14 @@ describe("cross-host reload", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const viewer = browser.get(Greeter, { session: "S1" }); + const viewer = browser.hold(Greeter, { session: "S1" }); assert.equal(await viewer.greet("a"), "v1 a"); - const shapes: number[] = []; - viewer.onShape((shape) => shapes.push(shape.codeVersion)); + const shapes: string[] = []; + viewer.onShape((shape) => shapes.push(shape.contract)); modules.set("greeter", greeterFacet("v2")); - const report = await server.reload([], { remote: { "session:": ["greeter"] } }); + await server.reload([], { remote: { "session:": ["greeter"] } }); for (let i = 0; i < 20 && shapes.length === 0; i++) await tick(); - assert.deepEqual(shapes, [report.codeVersion]); + assert.equal(shapes.length, 1, "one shape push at the commit"); assert.equal(await viewer.greet("b"), "v2 b"); modules.set("greeter", { @@ -298,7 +298,7 @@ describe("cross-host reload", () => { it("an abort while a worker is still preparing: a late open closes itself and the worker can reload again", async () => { const { server, worker, modules } = setup(); await tick(); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); await greeter.greet("a"); const log: string[] = []; const go = latch(); @@ -363,7 +363,7 @@ describe("cross-host reload", () => { loading.open(); const report = await reloading; assert.deepEqual(Object.keys(report.children ?? {}), [], "it was not a participant"); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); assert.equal(await greeter.greet("a"), "v2 a"); assert.equal(worker.codeVersion, server.codeVersion); }); @@ -371,7 +371,7 @@ describe("cross-host reload", () => { it("a child keeps the previous code version until its parent retired it (spec 12)", async () => { const { server, worker, modules } = setup(); await tick(); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); assert.equal(await greeter.greet("a"), "v1 a"); const first = server.codeVersion; const go = latch(); @@ -471,7 +471,7 @@ describe("cross-host reload", () => { const report = await reloading; assert.deepEqual(Object.keys(report.children ?? {}), []); assert.equal(report.codeVersion, 3); - const greeter = server.get(Greeter, { session: "S1" }); + const greeter = server.hold(Greeter, { session: "S1" }); assert.equal(await greeter.greet("a"), "v2 a"); assert.equal(worker.codeVersion, server.codeVersion); }); @@ -506,7 +506,7 @@ describe("cross-host reload", () => { }); onTestFinished(() => worker.close()); // While the server's reload still loads its modules, a worker root (code version 1) calls the server. - const answer = await worker.root((ctx) => ctx.get(Ping, {}).ping(ctx)); + const answer = await worker.root(async (ctx) => (await ctx.get(Ping, {})).ping(ctx)); assert.equal(answer, "pong"); loading.open(); await reloading; @@ -619,7 +619,7 @@ describe("cross-host reload", () => { }); onTestFinished(() => browser.close()); // The relayed call's root runs on the server's code version 1. - const call = browser.get(Slow, {}).wait(); + const call = browser.hold(Slow, {}).wait(); call.catch(() => {}); for (let i = 0; i < 50 && log.length === 0 && !worker.codeVersion; i++) await tick(); await new Promise((resolve) => setTimeout(resolve, 10)); diff --git a/packages/chord/test/fixtures/counting.ts b/packages/chord/test/fixtures/counting.ts new file mode 100644 index 000000000..4e83be6f0 --- /dev/null +++ b/packages/chord/test/fixtures/counting.ts @@ -0,0 +1,73 @@ +// A link wrapper that counts what crosses it, per direction: frames by type and bytes (JSON text length). It can also +// hold back the acknowledgements its own side sends (`paused`), to fill the peer's flow-control window. +import type { Frame, Link } from "../../src/index.ts"; + +export class CountingLink implements Link { + readonly inner: Link; + /** Frames this side sent, in order. */ + readonly sent: Frame[] = []; + /** Frames this side received, in order. */ + readonly received: Frame[] = []; + sentBytes = 0; + receivedBytes = 0; + /** Acknowledgements are held back (the peer's window fills up). */ + paused = false; + #held: Frame | undefined; + + constructor(inner: Link) { + this.inner = inner; + } + + get closed(): boolean { + return this.inner.closed; + } + + send(frame: Frame): number | undefined { + if (frame.t === "ack" && this.paused) { + this.#held = frame; + return 0; + } + this.sent.push(frame); + this.sentBytes += JSON.stringify(frame).length; + return this.inner.send(frame); + } + + /** Lets the held acknowledgement through and stops holding them. */ + release(): void { + this.paused = false; + const held = this.#held; + this.#held = undefined; + if (held !== undefined) this.inner.send(held); + } + + receive(handler: (frame: Frame) => void): void { + this.inner.receive((frame) => { + this.received.push(frame); + this.receivedBytes += JSON.stringify(frame).length; + handler(frame); + }); + } + + onClose(handler: (reason: string) => void): void { + this.inner.onClose(handler); + } + + close(reason?: string): void { + this.inner.close(reason); + } + + /** Frames of type `t` this side sent (`sent`) or received (`received`). */ + count(direction: "sent" | "received", t: Frame["t"]): number { + return this[direction].filter((frame) => frame.t === t).length; + } + + /** Frames this side sent or received, except pings, pongs and acknowledgements. */ + traffic(direction: "sent" | "received"): Frame[] { + return this[direction].filter((frame) => frame.t !== "ping" && frame.t !== "pong" && frame.t !== "ack"); + } +} + +export type StateFrame = Extract; + +export const stateFrames = (frames: readonly Frame[]): StateFrame[] => + frames.filter((frame): frame is StateFrame => frame.t === "state"); diff --git a/packages/chord/test/fixtures/loader.ts b/packages/chord/test/fixtures/loader.ts index 62bffe595..cded6d162 100644 --- a/packages/chord/test/fixtures/loader.ts +++ b/packages/chord/test/fixtures/loader.ts @@ -44,7 +44,7 @@ try { writeGreeting("v1"); const host = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} }); await host.load([`${dir}facet.ts`]); - const ref = host.get(Hello, {}); + const ref = host.hold(Hello, {}); assert.equal(await ref.greet("x"), "v1 x"); writeGreeting("v2"); const report = await host.reload([`${dir}facet.ts`]); @@ -73,7 +73,7 @@ try { ); const ordered = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} }); await ordered.load([`${plugins}a/facet.ts`, `${plugins}b/facet.ts`]); - assert.equal(await ordered.get(Shared, {}).same(), true); + assert.equal(await ordered.hold(Shared, {}).same(), true); await ordered.close(); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/packages/chord/test/flow-units.test.ts b/packages/chord/test/flow-units.test.ts new file mode 100644 index 000000000..ab578f178 --- /dev/null +++ b/packages/chord/test/flow-units.test.ts @@ -0,0 +1,271 @@ +// Entry 8: flow control with units. First units pass the window, so a get never waits behind other streams; waiting +// ops are capped per link; relays ask downstream for a reset when they drop ops. +import assert from "node:assert/strict"; +import { Type } from "typebox"; +import { describe, it } from "vitest"; +import { HostImpl } from "../src/host.ts"; +import { + type Ctx, + createHost, + type Frame, + type Host, + hostFamily, + hostNamed, + type Link, + memoryLinkPair, + method, + remote, +} from "../src/index.ts"; +import { CountingLink, stateFrames } from "./fixtures/counting.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(check: () => boolean, attempts = 400): Promise { + for (let i = 0; i < attempts && !check(); i++) await sleep(2); + assert.ok(check(), "condition not reached"); +} + +const Blob = remote("t.e8-blob", { + version: 1, + input: Type.Object({ name: Type.String() }), + host: hostNamed("server"), + variants: { + main: { + state: { lines: Type.Array(Type.String()) }, + methods: { push: method(Type.String()), ping: method(undefined, Type.String()) }, + }, + }, +}); + +/** About `kib` KiB of lines. */ +const lines = (kib: number, tag: string) => Array.from({ length: kib }, (_, i) => `${tag}${i}:${"x".repeat(1000)}`); + +function serveBlob(host: Host, size: (name: string) => number): void { + host.serve(Blob, ({ name }, _principal, self) => { + const state = self.state("lines", Type.Array(Type.String()), lines(size(name), name)); + return { + variant: "main", + lines: state, + push: (line: string, ctx: Ctx) => { + state.change(ctx, (draft) => { + draft.push(line); + }); + }, + ping: () => "pong", + }; + }); +} + +function client(server: Host, window: number, pendingCap?: number) { + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { user: "u" }, window, ...(pendingCap === undefined ? {} : { pendingCap }) }); + const link = new CountingLink(up); + const host = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + }); + const connection = () => [...(server as HostImpl).connections].find((candidate) => candidate.role === "downstream")!; + return { host, link, connection }; +} + +describe("flow control with units", () => { + it("with large streams waiting behind a full window, a new get resolves at once and calls are answered", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + serveBlob(server, (name) => (name === "small" ? 1 : 500)); + const { host, link } = client(server, 64 * 1024); + const bigs = ["a", "b", "c"].map((name) => host.hold(Blob, { name })); + await Promise.all(bigs.map((big) => big.ready)); + link.paused = true; + // Changes to every large stream now wait for acknowledgements that do not come. + for (const big of bigs) for (let i = 0; i < 50; i++) await big.push(`${i}:${"y".repeat(2000)}`); + const small = host.hold(Blob, { name: "small" }); + await small.ready; + assert.equal(small.lines.value.length, 1); + assert.equal(await bigs[0]!.ping(), "pong"); + assert.ok(bigs[0]!.lines.value.length < 550, "the large streams still wait"); + link.release(); + await until(() => bigs.every((big) => big.lines.value.length === 550)); + await server.close(); + }); + + it("a snapshot larger than the window arrives whole; later changes wait for acknowledgements", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + serveBlob(server, () => 300); + const { host, link } = client(server, 16 * 1024); + link.paused = true; + const big = host.hold(Blob, { name: "chat" }); + await big.ready; + assert.equal(big.lines.value.length, 300); + for (let i = 0; i < 40; i++) await big.push(`${i}:${"z".repeat(1000)}`); + assert.ok(big.lines.value.length < 340); + link.release(); + await until(() => big.lines.value.length === 340); + await server.close(); + }); + + it("a link that reports no sizes (IPC) never holds state back", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + serveBlob(server, () => 1); + const [down, up] = memoryLinkPair(); + // A link without sizes: what Node's IPC channel is. + const unsized: Link = { + get closed() { + return down.closed; + }, + send: (frame: Frame) => { + down.send(frame); + return undefined; + }, + receive: (handler) => down.receive(handler), + onClose: (handler) => down.onClose(handler), + close: (reason) => down.close(reason), + }; + server.accept(unsized, { principal: { user: "u" }, window: 1 }); + const link = new CountingLink(up); + const host = createHost({ + self: { kind: "host" }, + name: "worker", + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + }); + link.paused = true; + const blob = host.hold(Blob, { name: "x" }); + await blob.ready; + for (let i = 0; i < 20; i++) await blob.push(`${i}`); + await until(() => blob.lines.value.length === 21); + await server.close(); + }); + + it("waiting ops stay under the per-link cap however many streams stall; each then gets one reset", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + serveBlob(server, () => 1); + const cap = 8 * 1024; + const { host, link, connection } = client(server, 1024, cap); + const K = 6; + const blobs = Array.from({ length: K }, (_, i) => host.hold(Blob, { name: `s${i}` })); + await Promise.all(blobs.map((blob) => blob.ready)); + link.paused = true; + let peak = 0; + for (let round = 0; round < 30; round++) { + for (const blob of blobs) await blob.push(`${round}:${"w".repeat(200)}`); + peak = Math.max(peak, connection().waiting.bytes); + } + assert.ok(peak <= cap, `waiting ops ${peak} exceed the cap ${cap}`); + const before = link.received.length; + link.release(); + await until(() => blobs.every((blob) => blob.lines.value.length === 31)); + const resets = stateFrames(link.received.slice(before)).filter((frame) => frame.as === "reset"); + const per = new Map(); + for (const frame of resets) per.set(frame.hold, (per.get(frame.hold) ?? 0) + 1); + assert.ok(resets.length >= 1, "dropped streams came back as resets"); + for (const count of per.values()) assert.ok(count <= 2, "about one reset per stream"); + await server.close(); + }); +}); + +describe("relay topology", () => { + const Counter = remote("t.e8-relayed", { + version: 1, + input: Type.Object({ session: Type.String() }), + host: hostFamily("session:", "session"), + variants: { main: { state: { n: Type.Number() }, methods: { add: method(Type.Number()) } } }, + }); + + function cluster() { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + let worker: HostImpl | undefined; + let toWorker: CountingLink | undefined; + server.route("session:", { + principal: (name) => ({ host: name }), + authorize: () => true, + resolve: async (name) => { + if (toWorker !== undefined) return toWorker; + const [down, up] = memoryLinkPair(); + const created = new HostImpl({ + self: { kind: "host" }, + name, + upstream: { link: up, principal: { kind: "server" } }, + onError: () => {}, + }); + created.serve(Counter, (_input, _principal, self) => { + const n = self.state("n", Type.Number(), 0); + return { + variant: "main", + n, + add: (delta: number, ctx: Ctx) => { + n.replace(ctx, n.value + delta); + }, + }; + }); + worker = created; + toWorker = new CountingLink(down); + return toWorker; + }, + }); + const browser = (principal: unknown, window?: number) => { + const [down, up] = memoryLinkPair(); + server.accept(down, { principal, ...(window === undefined ? {} : { window, pendingCap: window * 2 }) }); + const link = new CountingLink(up); + const host = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + }); + return { host, link, up }; + }; + const served = () => [...(worker?.upstream?.served.values() ?? [])]; + const relayed = () => + [...(server as HostImpl).connections] + .filter((connection) => connection.peer.kind === "host") + .reduce((sum, connection) => sum + connection.relayedRefs.size, 0); + return { server, browser, served, relayed, toWorker: () => toWorker! }; + } + + it("each origin hold is one downstream hold; nothing is left after release, close or revoke", async () => { + const c = cluster(); + // Two tabs of one guest (two connections, one principal) and another guest. + const one = c.browser({ guest: "g" }); + const two = c.browser({ guest: "g" }); + const three = c.browser({ guest: "h" }); + const a = one.host.hold(Counter, { session: "S1" }); + const a2 = one.host.hold(Counter, { session: "S1" }); + const b = two.host.hold(Counter, { session: "S1" }); + const d = three.host.hold(Counter, { session: "S1" }); + await Promise.all([a.ready, a2.ready, b.ready, d.ready]); + assert.equal(c.toWorker().count("sent", "hold"), 3, "two tabs and another principal: three holds"); + assert.equal(c.served().length, 3); + a.release(); + a2.release(); + await until(() => c.served().length === 2 && c.relayed() === 2); + two.up.close(); + await until(() => c.served().length === 1 && c.relayed() === 1); + await c.server.invalidatePrincipal({ guest: "h" }); + await until(() => c.served().length === 0 && c.relayed() === 0); + await c.server.close(); + }); + + it("a stalled tab overflows and gets a reset while another tab keeps getting every change", async () => { + const c = cluster(); + const slow = c.browser({ guest: "g" }, 256); + const fast = c.browser({ guest: "g" }); + const stalled = slow.host.hold(Counter, { session: "S1" }); + const live = fast.host.hold(Counter, { session: "S1" }); + await Promise.all([stalled.ready, live.ready]); + slow.link.paused = true; + const seen: number[] = []; + live.n.subscribe((value) => seen.push(value)); + for (let i = 0; i < 200; i++) await live.add(1); + await until(() => live.n.value === 200); + assert.ok(seen.length > 100, "the fast tab saw the changes as they came"); + assert.ok(stalled.n.value < 200, "the stalled tab waits"); + assert.ok(c.toWorker().count("sent", "resync") >= 1, "the relay asked the worker for a reset"); + slow.link.release(); + await until(() => stalled.n.value === 200); + await c.server.close(); + }); +}); diff --git a/packages/chord/test/flow.test.ts b/packages/chord/test/flow.test.ts index 87cf897e7..80c8268a7 100644 --- a/packages/chord/test/flow.test.ts +++ b/packages/chord/test/flow.test.ts @@ -127,7 +127,7 @@ describe("link flow control", () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); serveCounter(server); const { host, gate } = client(server, 200); - const counter = host.get(Counter, {}); + const counter = host.hold(Counter, {}); const values: unknown[] = []; counter.count.subscribe((value) => values.push(value)); await until(() => values.at(-1) !== undefined); @@ -151,7 +151,7 @@ describe("link flow control", () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); serveCounter(server); const { host, gate } = client(server, 64); - const counter = host.get(Counter, {}); + const counter = host.hold(Counter, {}); const values: unknown[] = []; counter.count.subscribe((value) => values.push(value)); await until(() => values.at(-1) !== undefined); @@ -163,7 +163,7 @@ describe("link flow control", () => { await until(() => (values.at(-1) as { n: number }).n === 100); const sent = stateFrames(gate.received.slice(before)); assert.ok( - sent.some((frame) => "reset" in frame), + sent.some((frame) => frame.as === "reset"), "a reset replaced the ops", ); assert.ok(sent.length < 10, `few state frames, not one per change (${sent.length})`); @@ -171,27 +171,28 @@ describe("link flow control", () => { await server.close(); }); - it("drops what waits for a subscription that ends", async () => { + it("drops what waits for a reference that is released", async () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); serveCounter(server); const { host, gate } = client(server, 64); - const counter = host.get(Counter, {}); - const stop = counter.count.subscribe(() => {}); - await until(() => counter.count.value !== undefined); + const counter = host.hold(Counter, {}); + await counter.ready; + const other = host.hold(Counter, {}); + await other.ready; gate.paused = true; for (let i = 0; i < 10; i++) await counter.add(1); - stop(); + counter.release(); + other.release(); const before = gate.received.length; gate.release(); - await counter.add(1); await sleep(10); assert.deepEqual(stateFrames(gate.received.slice(before)), []); await host.close(); await server.close(); }); - it("coalesces relayed state for the origin and resubscribes downstream when the slot overflows", async () => { + it("coalesces relayed state for the origin and asks downstream for a reset when it overflows", async () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); const toWorker: Frame[] = []; server.route("w", { @@ -221,18 +222,18 @@ describe("link flow control", () => { }, }); const { host, gate } = client(server, 64); - const counter = host.get(WorkerCounter, {}); + const counter = host.hold(WorkerCounter, {}); const values: unknown[] = []; counter.count.subscribe((value) => values.push(value)); await until(() => values.at(-1) !== undefined); - const subs = () => toWorker.filter((frame) => frame.t === "sub").length; - assert.equal(subs(), 1); + const resyncs = () => toWorker.filter((frame) => frame.t === "resync").length; + assert.equal(resyncs(), 0); gate.paused = true; for (let i = 0; i < 100; i++) await counter.add(1); - // Each overflow replaces the slot with a fresh downstream subscription (its snapshot then waits in the slot). - assert.ok(subs() > 1, "the overflowing slot was resubscribed downstream"); - assert.equal(toWorker.filter((frame) => frame.t === "unsub").length, subs() - 1); + // An overflow drops the waiting ops and asks the worker for a reset (which then waits in the slot). + assert.ok(resyncs() >= 1, "the overflowing stream was resynced downstream"); + assert.ok(!toWorker.some((frame) => frame.t === "sub" || frame.t === "unsub")); gate.release(); await until(() => (values.at(-1) as { n: number }).n === 100); await host.close(); @@ -248,7 +249,6 @@ describe("link flow control", () => { raw.receive((frame) => frames.push(frame)); raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 }); raw.send({ t: "hold", id: 1, at: "server", token: Counter.description, input: {} }); - raw.send({ t: "sub", id: 2, hold: 1, member: "count" }); for (let i = 0; i < 5; i++) raw.send({ t: "call", id: 3 + i, hold: 1, member: "add", args: 1 }); await until(() => frames.filter((frame) => frame.t === "result").length === 5); await sleep(5); @@ -283,7 +283,7 @@ describe("link call limit", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const counter = host.get(Counter, {}); + const counter = host.hold(Counter, {}); await counter.ready; release = () => {}; const first = counter.wait(); diff --git a/packages/chord/test/generations.test.ts b/packages/chord/test/generations.test.ts index dd8d4b504..3f20b95cc 100644 --- a/packages/chord/test/generations.test.ts +++ b/packages/chord/test/generations.test.ts @@ -63,7 +63,7 @@ describe("generation GC", () => { }; }; host.install(facet(0)); - const ref = host.get(Store, {}); + const ref = host.hold(Store, {}); await ref.ready; for (let generation = 1; generation <= RELOADS; generation++) { await host.reload([facet(generation)]); @@ -143,7 +143,7 @@ describe("generation GC", () => { await host.install(facet(0)); for (let generation = 1; generation <= RELOADS; generation++) { await host.reload([facet(generation)]); - assert.equal(await host.root((ctx) => ctx.get(Store, {}).get(ctx)), generation); + assert.equal(await host.root(async (ctx) => (await ctx.get(Store, {})).get(ctx)), generation); } assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]); await host.close(); @@ -164,7 +164,7 @@ describe("generation GC", () => { }); // In a function of its own, so no reference outlives the iteration in this frame. const use = async (id: number): Promise => { - const ref = host.get(Item, { id }); + const ref = host.hold(Item, { id }); assert.equal(await ref.id(), id); ref.release(); }; diff --git a/packages/chord/test/host-objects-reload.test.ts b/packages/chord/test/host-objects-reload.test.ts index 5bcf7e976..6e762e701 100644 --- a/packages/chord/test/host-objects-reload.test.ts +++ b/packages/chord/test/host-objects-reload.test.ts @@ -137,7 +137,7 @@ test("the host's reference to a host object follows contract bumps at commit wit await host.load([facets.v1]); const initial = host.hostObjects.get(SwitchV1.name)!; const object = initial.object; - const original = host.get(SwitchV1, {}); + const original = host.hold(SwitchV1, {}); await original.set(true); original.release(); const codeVersion = host.codeVersion; @@ -154,7 +154,7 @@ test("the host's reference to a host object follows contract bumps at commit wit assert.equal(initial.status, "released"); assert.equal(object?.refs.size, 1, "Attach the replacement before releasing the only old hold"); assert.deepEqual(host.registry(Read).get("current")!(), { 1: true }); - const modern = host.get(SwitchV2, {}); + const modern = host.hold(SwitchV2, {}); await modern.set({ conversation: 2, enabled: true }); modern.release(); assert.deepEqual((await host.reload([facets.v3])).gated, []); @@ -202,7 +202,7 @@ test("external old-version consumers still refuse a host object's upgrade withou onTestFinished(() => host.close()); await host.load([readerV1]); const own = host.hostObjects.get(ReadV1.name)!; - const consumer = host.get(ReadV1, {}); + const consumer = host.hold(ReadV1, {}); assert.equal(await consumer.read(), "one"); await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch")); assert.equal(host.hostObjects.get(ReadV1.name), own); @@ -211,7 +211,7 @@ test("external old-version consumers still refuse a host object's upgrade withou assert.equal(host.hostObjects.get(ReadV2.name)?.contract, ReadV2); assert.equal(await consumer.read(), "two"); assert.equal(consumer.contract, ReadV1, "Only the bootstrap hold is retokened"); - assert.equal(await host.get(ReadV2, {}).fetch(), "two"); + assert.equal(await host.hold(ReadV2, {}).fetch(), "two"); }); test("object-owned old-version references still block a host object's upgrade unless their consumer is replaced in the same batch", async () => { @@ -239,7 +239,7 @@ test("object-owned old-version references still block a host object's upgrade un }), }; await host.load([readerV1, oldConsumer]); - const consumer = host.get(Consumer, {}); + const consumer = host.hold(Consumer, {}); assert.equal(await consumer.read(), "one"); await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch")); assert.equal(await consumer.read(), "one"); diff --git a/packages/chord/test/host.test.ts b/packages/chord/test/host.test.ts index 8f1985edf..eb68b5f6b 100644 --- a/packages/chord/test/host.test.ts +++ b/packages/chord/test/host.test.ts @@ -70,9 +70,12 @@ describe("host: activation and lifetime", () => { const host = quiet(); const log: string[] = []; serveCounter(host, log); - const ref = host.get(Counter, { name: "a" }); + const ref = host.hold(Counter, { name: "a" }); assert.equal(ref.status, "loading"); - assert.equal(ref.count.value, undefined); + assert.throws( + () => ref.count.value, + (error) => isChordError(error, "not_ready"), + ); const pending = ref.add(2); await ref.ready; assert.equal(ref.status, "live"); @@ -89,8 +92,8 @@ describe("host: activation and lifetime", () => { const host = quiet(); const log: string[] = []; serveCounter(host, log); - const a = host.get(Counter, { name: "x" }); - const b = host.get(Counter, { name: "x" }); + const a = host.hold(Counter, { name: "x" }); + const b = host.hold(Counter, { name: "x" }); await Promise.all([a.ready, b.ready]); assert.deepEqual(log, ["factory x", "open x"]); a.release(); @@ -105,10 +108,10 @@ describe("host: activation and lifetime", () => { const host = quiet(); const log: string[] = []; serveCounter(host, log, { linger: 30 }); - const a = host.get(Counter, { name: "l" }); + const a = host.hold(Counter, { name: "l" }); await a.add(5); a.release(); - const b = host.get(Counter, { name: "l" }); + const b = host.hold(Counter, { name: "l" }); await b.ready; assert.deepEqual(b.count.value, { n: 5 }); b.release(); @@ -135,7 +138,7 @@ describe("host: activation and lifetime", () => { }); return {}; }); - const ref = host.get(Slow, {}); + const ref = host.hold(Slow, {}); await tick(); ref.release(); await tick(); @@ -143,7 +146,7 @@ describe("host: activation and lifetime", () => { await tick(); await tick(); assert.deepEqual(log, ["abort seen", "closed"]); - const again = host.get(Slow, {}); + const again = host.hold(Slow, {}); await tick(); release(); await again.ready; @@ -169,12 +172,12 @@ describe("host: activation and lifetime", () => { self.get(Leaf, {}); return {}; }); - const parent = host.get(Parent, {}); + const parent = host.hold(Parent, {}); // A plain error from a dependency surfaces as `unavailable`, with the original as cause. await assert.rejects(parent.ready, (error) => isChordError(error, "unavailable") && /boom/.test(error.message)); assert.equal(parent.status, "gone"); fail = false; - const retry = host.get(Parent, {}); + const retry = host.hold(Parent, {}); await retry.ready; assert.equal(retry.status, "live"); }); @@ -201,8 +204,8 @@ describe("host: activation and lifetime", () => { if (async) return Promise.resolve({}) as never; throw new Error("factory failed"); }); - await assert.rejects(host.get(Bad, { async: false }).ready, /factory failed/); - await assert.rejects(host.get(Bad, { async: true }).ready, /synchronous/); + await assert.rejects(host.hold(Bad, { async: false }).ready, /factory failed/); + await assert.rejects(host.hold(Bad, { async: true }).ready, /synchronous/); await tick(); await tick(); assert.equal(leafLive, 0); @@ -216,7 +219,7 @@ describe("host: activation and lifetime", () => { self.state("a", Type.Object({}), {}); return {}; }); - await assert.rejects(host.get(Dup, {}).ready, /duplicate state name a/); + await assert.rejects(host.hold(Dup, {}).ready, /duplicate state name a/); }); it("detects hold cycles", async () => { @@ -231,7 +234,7 @@ describe("host: activation and lifetime", () => { self.get(A, {}); return {}; }); - await assert.rejects(host.get(A, {}).ready, /cycle/); + await assert.rejects(host.hold(A, {}).ready, /cycle/); }); it("closes resources in reverse order, runs all steps and aggregates errors", async () => { @@ -260,7 +263,7 @@ describe("host: activation and lifetime", () => { }); return {}; }); - const ref = host.get(Multi, {}); + const ref = host.hold(Multi, {}); await ref.ready; ref.release(); await tick(); @@ -276,7 +279,7 @@ describe("host: activation and lifetime", () => { const log: string[] = []; serveCounter(host, log); const value = await host.root(async (ctx) => { - const counter = ctx.get(Counter, { name: "c" }); + const counter = await ctx.get(Counter, { name: "c" }); return counter.add(3, ctx); }); assert.equal(value, 3); @@ -299,10 +302,10 @@ describe("host: declarations and methods", () => { if (kind === "listener") self.on("x", self.state("z", Type.Number(), 0), () => {}); return {}; }); - await host.get(Twice, { kind: "none" }).ready; + await host.hold(Twice, { kind: "none" }).ready; for (const kind of ["state", "resource", "listener"]) { await assert.rejects( - host.get(Twice, { kind }).ready, + host.hold(Twice, { kind }).ready, (error) => isChordError(error, "invalid") && error.reason === "duplicate" && @@ -333,7 +336,7 @@ describe("host: declarations and methods", () => { seen.push(text, typeof ctx.rootId); }, })); - const tool = host.get(Tool, {}); + const tool = host.hold(Tool, {}); await tool.ready; assert.equal(await tool.ping(), undefined); assert.equal(await host.root((ctx) => tool.count(ctx)), 3); @@ -372,7 +375,7 @@ describe("host: declarations and methods", () => { }); await host.install(facet("v1")); await until(() => seen.length === 1); - await host.root((ctx) => host.get(Doubled, {}).set(5, ctx)); + await host.root((ctx) => host.hold(Doubled, {}).set(5, ctx)); await until(() => seen.length === 2); assert.deepEqual(seen, [2, 10]); // The reload makes a new derivation: the listener subscribes again and gets its current value once. @@ -435,20 +438,20 @@ describe("host: lifetime", () => { ); await host.ready(); assert.equal(created.length, 1, "created when served, before anyone asked"); - const guest = host.get(Store, {}, { principal: { guest: "g" } }); - const defaults = host.get(Store, { scope: "all" }); + const guest = host.hold(Store, {}, { principal: { guest: "g" } }); + const defaults = host.hold(Store, { scope: "all" }); await Promise.all([guest.ready, defaults.ready]); assert.equal(created.length, 1, "the same object for every principal and the default input"); assert.deepEqual(guest.seen, created[0] && (created[0] as { principal: unknown }).principal); assert.throws( - () => host.get(Store, { scope: "other" }), + () => host.hold(Store, { scope: "other" }), (error) => isChordError(error, "invalid"), ); // References do not change its lifetime: released by everyone else, it stays until the host closes. guest.release(); defaults.release(); await tick(); - const again = host.get(Store, {}); + const again = host.hold(Store, {}); await again.ready; assert.equal(created.length, 1); await host.close(); @@ -494,13 +497,15 @@ describe("host: principals, variants and disposal", () => { }); const principals: unknown[] = []; serveDoc(host, principals); - const viewer = host.get(Doc, { id: "1" }, { principal: { user: "v", role: "viewer" } }); - const editor = host.get(Doc, { id: "1" }, { principal: { user: "e", role: "editor" } }); + const viewer = host.hold(Doc, { id: "1" }, { principal: { user: "v", role: "viewer" } }); + const editor = host.hold(Doc, { id: "1" }, { principal: { user: "e", role: "editor" } }); await Promise.all([viewer.ready, editor.ready]); assert.equal(principals.length, 2); assert.equal(viewer.variant, "viewer"); assert.deepEqual(viewer.shape?.members, ["view"]); + // @ts-expect-error A viewer has no edit: TypeScript refuses it, and so does the provider at runtime. await assert.rejects(viewer.edit({ text: "x" }), (error) => isChordError(error, "not_in_variant")); + assert.ok(editor.variant === "editor"); assert.deepEqual(await editor.edit({ text: "x" }), { ok: true }); assert.deepEqual(editor.view.value, { text: "x" }); assert.deepEqual(viewer.view.value, { text: "" }); @@ -509,7 +514,8 @@ describe("host: principals, variants and disposal", () => { it("validates remote args and results at the provider boundary", async () => { const host = quiet(); serveDoc(host); - const editor = host.get(Doc, { id: "2" }, { principal: { role: "editor" } }); + const editor = await host.get(Doc, { id: "2" }, { principal: { role: "editor" } }); + assert.ok(editor.variant === "editor"); await assert.rejects( editor.edit({ text: 1 } as never), (error) => isChordError(error, "invalid") && error.reason === "args", @@ -537,7 +543,7 @@ describe("host: principals, variants and disposal", () => { }), ), })); - const ref = host.get(Slow, {}, { principal: { user: "u" } }); + const ref = host.hold(Slow, {}, { principal: { user: "u" } }); await ref.ready; const call = ref.wait(); await tick(); @@ -546,7 +552,7 @@ describe("host: principals, variants and disposal", () => { await call; assert.ok(seenAbort); await assert.rejects(ref.wait(), (error) => isChordError(error, "gone")); - const fresh = host.get(Slow, {}, { principal: { user: "u" } }); + const fresh = host.hold(Slow, {}, { principal: { user: "u" } }); await fresh.ready; assert.equal(fresh.status, "live"); }); @@ -554,7 +560,7 @@ describe("host: principals, variants and disposal", () => { it("state members are late-bound readables: values, then snapshot/reset/ops events", async () => { const host = quiet(); serveDoc(host); - const editor = host.get(Doc, { id: "3" }, { principal: { role: "editor" } }) as Ref; + const editor = host.hold(Doc, { id: "3" }, { principal: { role: "editor" } }) as Ref; const shapes: unknown[] = []; editor.onShape((shape) => shapes.push(shape.variant)); const events: unknown[] = []; @@ -562,14 +568,15 @@ describe("host: principals, variants and disposal", () => { editor.view.events((event) => events.push(event.type === "ops" ? ["ops", event.ops] : [event.type, event.value])); editor.view.subscribe((value) => values.push(value)); await editor.ready; + assert.ok(editor.variant === "editor"); await editor.edit({ text: "hi" }); assert.deepEqual(shapes, ["editor"]); + // Listeners added while loading get their snapshot once the reference is live. assert.deepEqual(events, [ - ["snap", undefined], - ["reset", { text: "" }], + ["snap", { text: "" }], ["ops", [["r", { text: "hi" }]]], ]); - assert.deepEqual(values, [undefined, { text: "" }, { text: "hi" }]); + assert.deepEqual(values, [{ text: "" }, { text: "hi" }]); }); }); @@ -609,7 +616,7 @@ describe("host: registries", () => { self.register(Routes, { x: "x" }); return {}; }); - await assert.rejects(host.get(Lazy, {}).ready, /only objects of lifetime "host" may register/); + await assert.rejects(host.hold(Lazy, {}).ready, /only objects of lifetime "host" may register/); }); it("rejects conflicting registrations", async () => { @@ -661,12 +668,12 @@ describe("host: composition", () => { const doubled = self.derive([source.n], (n) => (n ?? 0) * 2); return { copy, doubled }; }); - const mirror = host.get(Mirror, {}); + const mirror = host.hold(Mirror, {}); await mirror.ready; await tick(); assert.equal(mirror.copy.value, 1); assert.equal(mirror.doubled.value, 2); - await host.get(Source, {}).set(5); + await host.hold(Source, {}).set(5); await tick(); assert.equal(mirror.copy.value, 5); assert.equal(mirror.doubled.value, 10); @@ -695,9 +702,9 @@ describe("host: composition", () => { }); host.serve(Front, (_input, principal) => { seen.push(principal); - return { relay: (ctx: Ctx) => ctx.get(Who, {}).ping(ctx) }; + return { relay: async (ctx: Ctx) => (await ctx.get(Who, {})).ping(ctx) }; }); - await host.root((ctx) => ctx.get(Front, {}).relay(ctx), { principal: { user: "guest" } }); + await host.root(async (ctx) => (await ctx.get(Front, {})).relay(ctx), { principal: { user: "guest" } }); assert.deepEqual(seen, [{ user: "guest" }, { kind: "host" }]); }); @@ -713,7 +720,7 @@ describe("host: composition", () => { void source.set(1); return {}; }); - await assert.rejects(host.get(Eager, {}).ready, (error) => isChordError(error, "not_ready")); + await assert.rejects(host.hold(Eager, {}).ready, (error) => isChordError(error, "not_ready")); }); it("a ChordError from a factory keeps its code through dependents", async () => { @@ -727,6 +734,6 @@ describe("host: composition", () => { self.get(Secret, {}); return {}; }); - await assert.rejects(host.get(Page, {}).ready, (error) => isChordError(error, "forbidden")); + await assert.rejects(host.hold(Page, {}).ready, (error) => isChordError(error, "forbidden")); }); }); diff --git a/packages/chord/test/hydration.test.ts b/packages/chord/test/hydration.test.ts new file mode 100644 index 000000000..2c80255cc --- /dev/null +++ b/packages/chord/test/hydration.test.ts @@ -0,0 +1,195 @@ +// Entry 8: a local object goes live only with its state, also where it forwards or derives from a remote reference's +// member; activation waits at most `readyTimeout` (a cycle across hosts fails with a hint instead of hanging). +import assert from "node:assert/strict"; +import { Type } from "typebox"; +import { describe, it } from "vitest"; +import { + type Ctx, + createHost, + type Host, + hostNamed, + isChordError, + local, + memoryLinkPair, + method, + remote, +} from "../src/index.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Lives on the worker `w`; its value is set by `set`. */ +const Source = remote("t.e8-source", { + version: 1, + input: Type.Object({}), + host: hostNamed("w"), + variants: { main: { state: { v: Type.String() }, methods: { set: method(Type.String()) } } }, +}); + +/** Lives on the server; forwards (and derives from) the worker's `Source`. */ +const Forward = remote("t.e8-forward", { + version: 1, + input: Type.Object({ how: Type.String() }), + host: hostNamed("server"), + variants: { main: { state: { v: Type.String(), size: Type.Number() } } }, +}); + +function serveSource(host: Host): void { + host.serve(Source, (_input, _principal, self) => { + const v = self.state("v", Type.String(), "from the worker"); + return { + variant: "main", + v, + set: (text: string, ctx: Ctx) => { + v.replace(ctx, text); + }, + }; + }); +} + +/** A server with a worker child `w` (accepted after `delay` ms, as p2's server admits a worker after it is ready). */ +function cluster(options: { readonly delay?: number; readonly readyTimeout?: number } = {}) { + const server = createHost({ + self: { kind: "host" }, + name: "server", + onError: () => {}, + ...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }), + }); + const [down, up] = memoryLinkPair(); + const worker = createHost({ + self: { kind: "host" }, + name: "w", + upstream: { link: up, principal: { kind: "server" } }, + onError: () => {}, + ...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }), + }); + const admit = () => server.accept(down, { principal: { kind: "worker" }, kind: "host", name: "w" }); + if (options.delay === undefined) admit(); + else setTimeout(admit, options.delay); + const browser = () => { + const [down2, up2] = memoryLinkPair(); + server.accept(down2, { principal: { user: "u" } }); + return createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link: up2, principal: { kind: "server" } }, + onError: () => {}, + }); + }; + return { server, worker, browser }; +} + +describe("local hydration", () => { + it("an object that forwards and derives from a remote member goes live with its value", async () => { + const c = cluster(); + serveSource(c.worker); + // Held outside the factory (no `self.get`): activation still waits for its snapshot. + const held = c.server.hold(Source, {}); + c.server.serve(Forward, ({ how }, _principal, self) => { + const source = how === "declared" ? self.get(Source, {}) : held; + return { variant: "main", v: source.v, size: self.derive([source.v], (text) => text.length) }; + }); + const browser = c.browser(); + for (const how of ["held", "declared"]) { + const forward = browser.hold(Forward, { how }); + await forward.ready; + assert.equal(forward.v.value, "from the worker"); + assert.equal(forward.size.value, "from the worker".length); + const local = c.server.hold(Forward, { how }); + await local.ready; + assert.equal(local.v.value, "from the worker"); + } + await c.server.close(); + await c.worker.close(); + }); + + it("a member read inside a factory throws not_ready; it is a plain value once live", async () => { + const host = createHost({ self: { kind: "host" }, onError: () => {} }); + const Inner = local("t.e8-inner", { version: 1, input: Type.Object({}), members: { n: Type.Number() } }); + const Outer = local("t.e8-outer", { version: 1, input: Type.Object({}), members: { m: Type.Number() } }); + host.serve(Inner, (_input, _principal, self) => ({ n: self.state("n", Type.Number(), 7) })); + const seen: unknown[] = []; + host.serve(Outer, (_input, _principal, self) => { + const inner = self.get(Inner, {}); + try { + seen.push(inner.n.value); + } catch (error) { + seen.push(isChordError(error, "not_ready") ? "not_ready" : error); + } + return { m: self.derive([inner.n], (n) => n * 2) }; + }); + const outer = host.hold(Outer, {}); + await outer.ready; + assert.deepEqual(seen, ["not_ready"]); + assert.equal(outer.m.value, 14); + }); + + it("a host object of a child holding the parent's object goes live once the parent admits the child", async () => { + const Settings = remote("t.e8-settings", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { main: { state: { theme: Type.String() } } }, + }); + const Local = local("t.e8-boot", { version: 1, input: Type.Object({}), members: { theme: Type.String() } }); + const c = cluster({ delay: 20 }); + c.server.serve(Settings, (_input, _principal, self) => ({ + variant: "main", + theme: self.state("theme", Type.String(), "dark"), + })); + await c.worker.install( + { + name: "boot", + setup: (api) => + api.serve( + Local, + (_input, _principal, self) => { + const settings = self.get(Settings, {}); + return { theme: settings.theme }; + }, + { lifetime: "host" }, + ), + }, + { awaitHostObjects: false }, + ); + await c.worker.ready(); + const boot = c.worker.hold(Local, {}); + await boot.ready; + assert.equal(boot.theme.value, "dark"); + await c.server.close(); + await c.worker.close(); + }); + + it("a reference cycle across hosts fails at readyTimeout with a cycle hint", async () => { + const A = remote("t.e8-cycle-a", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { main: {} }, + }); + const B = remote("t.e8-cycle-b", { + version: 1, + input: Type.Object({}), + host: hostNamed("w"), + variants: { main: {} }, + }); + const c = cluster({ readyTimeout: 50 }); + c.server.serve(A, (_input, _principal, self) => { + self.get(B, {}); + return { variant: "main" }; + }); + c.worker.serve(B, (_input, _principal, self) => { + self.get(A, {}); + return { variant: "main" }; + }); + const a = c.server.hold(A, {}); + await assert.rejects( + a.ready, + (error) => + isChordError(error, "unavailable") && error.reason === "ready_timeout" && /cycle/.test(error.message), + ); + await sleep(10); + await c.server.close(); + await c.worker.close(); + }); +}); diff --git a/packages/chord/test/link.test.ts b/packages/chord/test/link.test.ts index 3aa032efe..31c0c9229 100644 --- a/packages/chord/test/link.test.ts +++ b/packages/chord/test/link.test.ts @@ -90,8 +90,7 @@ describe("links: calls and state across hosts", () => { const principals: unknown[] = []; serveCounter(server, principals); const result = await worker.root(async (ctx) => { - const counter = ctx.get(Counter, { name: "c" }); - await counter.ready; + const counter = await ctx.get(Counter, { name: "c" }); assert.equal(counter.variant, "main"); return [await counter.add(2, ctx), await counter.who(ctx)]; }); @@ -102,7 +101,7 @@ describe("links: calls and state across hosts", () => { it("state members: snapshot, then delta-encoded ops; values decode to the provider's", async () => { const { server, worker } = serverWithWorker(); serveCounter(server); - const counter = worker.get(Counter, { name: "s" }); + const counter = worker.hold(Counter, { name: "s" }); const events: StateEvent[] = []; const values: unknown[] = []; counter.count.events((event) => events.push(event)); @@ -112,11 +111,12 @@ describe("links: calls and state across hosts", () => { await counter.add(1); await tick(); assert.deepEqual(counter.count.value, { n: 2 }); + // Listeners added before the hydration get the snapshot then (not `undefined` first). assert.deepEqual( events.map((event) => event.type), - ["snap", "reset", "ops", "ops"], + ["snap", "ops", "ops"], ); - assert.deepEqual(values, [undefined, { n: 0 }, { n: 1 }, { n: 2 }]); + assert.deepEqual(values, [{ n: 0 }, { n: 1 }, { n: 2 }]); }); it("the server reaches a connected child by name", async () => { @@ -124,7 +124,7 @@ describe("links: calls and state across hosts", () => { const principals: unknown[] = []; serveCounter(worker, principals); await tick(); - const counter = server.get(SessionCounter, { session: "w1", name: "x" }); + const counter = server.hold(SessionCounter, { session: "w1", name: "x" }); assert.equal(await counter.add(5), 5); // The parent's own requests act as the principal the child gave `connect`. assert.deepEqual(principals, [{ kind: "server" }]); @@ -132,7 +132,7 @@ describe("links: calls and state across hosts", () => { it("rejects local types and unknown hosts", async () => { const { worker } = serverWithWorker(); - const missing = worker.get(Counter, { name: "m" }); + const missing = worker.hold(Counter, { name: "m" }); await assert.rejects(missing.ready, (error) => isChordError(error, "unavailable")); }); }); @@ -224,7 +224,7 @@ describe("links: relay, stamping, lifetime", () => { const c = cluster(); const guest = { guest: "g1" }; const browser = c.browser(guest); - const counter = browser.host.get(SessionCounter, { session: "S1", name: "a" }); + const counter = browser.host.hold(SessionCounter, { session: "S1", name: "a" }); assert.equal(await counter.add(3), 3); assert.deepEqual(await counter.who(), guest); const values: unknown[] = []; @@ -295,10 +295,10 @@ describe("links: relay, stamping, lifetime", () => { return down; }, }); - const first = server.get(SessionCounter, { session: "S1", name: "a" }); + const first = server.hold(SessionCounter, { session: "S1", name: "a" }); await assert.rejects(first.add(1), (error: Error) => /closed/.test(error.message)); // The next call resolves the route again and reaches the new worker. - const second = server.get(SessionCounter, { session: "S1", name: "a" }); + const second = server.hold(SessionCounter, { session: "S1", name: "a" }); assert.equal(await second.add(2), 2); assert.equal(attempt, 2); }); @@ -307,14 +307,14 @@ describe("links: relay, stamping, lifetime", () => { const c = cluster(); c.allow = (principal) => (principal as { guest: string }).guest !== "blocked"; const blocked = c.browser({ guest: "blocked" }); - await assert.rejects(blocked.host.get(SessionCounter, { session: "S1", name: "a" }).ready, (error) => + await assert.rejects(blocked.host.hold(SessionCounter, { session: "S1", name: "a" }).ready, (error) => isChordError(error, "forbidden"), ); assert.equal(c.workers.size, 0); let calls = 0; c.allow = () => ++calls === 1; const flaky = c.browser({ guest: "flaky" }); - await assert.rejects(flaky.host.get(SessionCounter, { session: "S2", name: "a" }).ready, (error) => + await assert.rejects(flaky.host.hold(SessionCounter, { session: "S2", name: "a" }).ready, (error) => isChordError(error, "forbidden"), ); assert.equal(calls, 2); @@ -324,8 +324,8 @@ describe("links: relay, stamping, lifetime", () => { const c = cluster(); const one = c.browser({ guest: "g1" }); const two = c.browser({ guest: "g2" }); - await one.host.get(SessionCounter, { session: "S1", name: "a" }).add(1); - const other = two.host.get(SessionCounter, { session: "S1", name: "b" }); + await one.host.hold(SessionCounter, { session: "S1", name: "a" }).add(1); + const other = two.host.hold(SessionCounter, { session: "S1", name: "b" }); await other.add(1); one.up.close(); for (let i = 0; i < 20 && !c.log.includes("close a"); i++) await tick(); @@ -337,7 +337,7 @@ describe("links: relay, stamping, lifetime", () => { it("a dead link (no pongs) is closed and everything held through it is released", async () => { const c = cluster({ pingInterval: 10, pingTimeout: 40 }); const browser = c.browser({ guest: "g1" }); - await browser.host.get(SessionCounter, { session: "S1", name: "a" }).add(1); + await browser.host.hold(SessionCounter, { session: "S1", name: "a" }).add(1); browser.up.sever(); for (let i = 0; i < 40 && !c.log.includes("close a"); i++) await sleep(10); assert.ok(c.log.includes("close a")); @@ -352,7 +352,7 @@ describe("links: relay, stamping, lifetime", () => { }); const { server, worker } = serverWithWorker(); server.serve(CounterV2, () => ({ variant: "main", inc: () => 1 })); - await assert.rejects(worker.get(Counter, { name: "x" }).ready, (error) => + await assert.rejects(worker.hold(Counter, { name: "x" }).ready, (error) => isChordError(error, "contract_mismatch"), ); const [down, up] = memoryLinkPair(); @@ -364,7 +364,7 @@ describe("links: relay, stamping, lifetime", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - await assert.rejects(browser.get(Counter, { name: "x" }).ready, (error) => + await assert.rejects(browser.hold(Counter, { name: "x" }).ready, (error) => isChordError(error, "upgrade_required"), ); }); @@ -372,7 +372,7 @@ describe("links: relay, stamping, lifetime", () => { it("invalidatePrincipal re-stamps the link and propagates to workers", async () => { const c = cluster(); const browser = c.browser({ guest: "g1", revision: 1 }, { restamp: (p) => ({ ...(p as object), revision: 2 }) }); - const counter = browser.host.get(SessionCounter, { session: "S1", name: "a" }); + const counter = browser.host.hold(SessionCounter, { session: "S1", name: "a" }); await counter.add(1); await c.server.invalidatePrincipal({ guest: "g1" }); // The relay under the old principal is closed at once; the worker disposes its object after. @@ -380,14 +380,14 @@ describe("links: relay, stamping, lifetime", () => { assert.equal(counter.status, "gone"); for (let i = 0; i < 20 && !c.log.includes("close a"); i++) await tick(); assert.ok(c.log.includes("close a")); - const again = browser.host.get(SessionCounter, { session: "S1", name: "a" }); + const again = browser.host.hold(SessionCounter, { session: "S1", name: "a" }); assert.deepEqual(await again.who(), { guest: "g1", revision: 2 }); }); it("after a reconnect, state subscribers get a reset and calls work again", async () => { const c = cluster(); const browser = c.browser({ guest: "g1" }); - const counter = browser.host.get(SessionCounter, { session: "S1", name: "r" }); + const counter = browser.host.hold(SessionCounter, { session: "S1", name: "r" }); const events: string[] = []; counter.count.events((event) => events.push(event.type)); await counter.add(2); @@ -400,7 +400,9 @@ describe("links: relay, stamping, lifetime", () => { await tick(); // The old hold was released with the link, so the worker made a fresh object; the reset carries its value. assert.deepEqual(counter.count.value, { n: 1 }); - assert.ok(events.filter((type) => type === "reset").length >= 2); + // The first hydration is the listener's snapshot; the re-hold's snapshot is a reset. + assert.equal(events[0], "snap"); + assert.ok(events.includes("reset")); }); }); @@ -455,7 +457,7 @@ describe("links: methods without arguments or result", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const tool = browser.get(Tool, {}); + const tool = browser.hold(Tool, {}); await tool.ready; assert.equal(await tool.ping(), undefined); assert.equal(await tool.count(), 2); @@ -564,7 +566,7 @@ describe("links: packed payloads", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const big = browser.get(Big, {}); + const big = browser.hold(Big, {}); assert.deepEqual(await big.echo(large("c")), large("c")); big.notes.subscribe(() => {}); for (let i = 0; i < 100 && big.notes.value === undefined; i++) await sleep(5); @@ -577,15 +579,15 @@ describe("links: packed payloads", () => { b.receive((frame) => frames.push(frame)); a.send({ t: "result", id: 1, value: large("v") }); a.send({ t: "result", id: 2, value: ["small"] }); - a.send({ t: "state", id: 3, snap: large("s") }); + a.send({ t: "state", hold: 3, m: "notes", as: "snap", value: large("s") }); for (let i = 0; i < 50 && frames.length < 3; i++) await tick(); const [packed, small, snap] = frames; assert(packed?.t === "result" && packed.value instanceof Packed); assert.deepEqual(unpack(packed.value), large("v")); assert(small?.t === "result"); assert.deepEqual(small.value, ["small"]); - assert(snap?.t === "state" && snap.snap instanceof Packed); - assert.deepEqual(unpack(snap.snap), large("s")); + assert(snap?.t === "state" && snap.value instanceof Packed); + assert.deepEqual(unpack(snap.value), large("s")); // A relay forwards the packed bytes as they are. const [c, d] = memoryLinkPair(); diff --git a/packages/chord/test/links-review.test.ts b/packages/chord/test/links-review.test.ts index 9b55c0568..c607d673f 100644 --- a/packages/chord/test/links-review.test.ts +++ b/packages/chord/test/links-review.test.ts @@ -214,7 +214,7 @@ describe("links review: identity and stamping", () => { const go = latch(); let rootId = ""; const pinned = worker.root(async (ctx) => { - rootId = (await ctx.get(Echo, {}).root(ctx)) as string; + rootId = (await (await ctx.get(Echo, {})).root(ctx)) as string; await go.promise; }); await until(() => rootId !== ""); @@ -239,14 +239,14 @@ describe("links review: identity and stamping", () => { const gate = latch(); c.resolveGate = gate.promise; const browser = c.browser({ guest: "g", revision: 1 }, (p) => ({ ...(p as object), revision: 2 })); - const counter = browser.host.get(Counter, { name: "a" }); + const counter = browser.host.hold(Counter, { name: "a" }); await sleep(5); await c.server.invalidatePrincipal({ guest: "g" }); await until(() => counter.status === "gone"); gate.open(); await sleep(10); assert.deepEqual(c.principals, []); - const again = browser.host.get(Counter, { name: "a" }); + const again = browser.host.hold(Counter, { name: "a" }); assert.deepEqual(await again.who(), { guest: "g", revision: 2 }); }); @@ -267,7 +267,7 @@ describe("links review: relay ownership", () => { it("#7 a browser disconnect cancels its call downstream", async () => { const c = cluster(); const browser = c.browser({ guest: "g" }); - const counter = browser.host.get(Counter, { name: "a" }); + const counter = browser.host.hold(Counter, { name: "a" }); await counter.ready; void counter.slow().catch(() => {}); await sleep(10); @@ -277,19 +277,21 @@ describe("links review: relay ownership", () => { assert.equal(down.relayedCalls.size, 0); }); - it("#9 a subscription dropped while the route resolves is never forwarded", async () => { + it("#9 state stopped while the route resolves is forwarded as a hold without state", async () => { const c = cluster(); const gate = latch(); c.resolveGate = gate.promise; const { raw } = c.raw({ guest: "g" }); raw.send({ t: "hold", id: 1, at: "session:S1", token: Counter.description, input: { name: "a" } }); - raw.send({ t: "sub", id: 2, hold: 1, member: "count" }); - raw.send({ t: "unsub", id: 2 }); + raw.send({ t: "unsub", hold: 1 }); await sleep(5); gate.open(); await until(() => c.workers.get("session:S1")?.link.sent.some((frame) => frame.t === "hold") === true); await sleep(10); - assert.ok(!c.workers.get("session:S1")!.link.sent.some((frame) => frame.t === "sub")); + const sent = c.workers.get("session:S1")!.link.sent; + const hold = sent.find((frame) => frame.t === "hold"); + assert.ok(hold?.t === "hold" && hold.state === false); + assert.ok(!sent.some((frame) => frame.t === "sub" || frame.t === "unsub")); }); it("#9 a release while the route resolves settles waiting calls and forwards nothing", async () => { @@ -312,13 +314,13 @@ describe("links review: relay ownership", () => { it("#9 an origin disconnect removes its relayed holds and subscriptions downstream", async () => { const c = cluster(); const browser = c.browser({ guest: "g" }); - const counter = browser.host.get(Counter, { name: "a" }); + const counter = browser.host.hold(Counter, { name: "a" }); counter.count.subscribe(() => {}); await counter.add(1); const down = [...internals(c.server).connections].find((connection) => connection.peer.name === "session:S1")!; - assert.equal(down.relayedSubs.size, 1); + assert.equal(down.relayedRefs.size, 1); browser.up.close(); - await until(() => down.relayedRefs.size === 0 && down.relayedSubs.size === 0); + await until(() => down.relayedRefs.size === 0); await until(() => c.log.includes("close a")); }); @@ -326,8 +328,8 @@ describe("links review: relay ownership", () => { const c = cluster(); const one = c.browser({ guest: "g1" }); const two = c.browser({ guest: "g2" }); - const a = one.host.get(Counter, { name: "same" }); - const b = two.host.get(Counter, { name: "same" }); + const a = one.host.hold(Counter, { name: "same" }); + const b = two.host.hold(Counter, { name: "same" }); assert.equal(await a.add(1), 1); assert.equal(await b.add(5), 5); assert.equal(c.workers.size, 1); @@ -391,9 +393,8 @@ describe("links review: relay ownership", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const doc = browser.get(DocV1, {}); - doc.view.subscribe(() => {}); - await until(() => doc.view.value !== undefined); + const doc = browser.hold(DocV1, {}); + await doc.ready; assert.deepEqual(doc.view.value, { text: "hi" }); }); }); @@ -446,7 +447,7 @@ describe("links review: cross-host reload", () => { gate.open(); await until(() => !internals(worker).reloading); await server.reload([], { remote: { "session:": ["greeter"] } }); - assert.equal(await server.get(Greeter, {}).greet("x"), "v2 x"); + assert.equal(await server.hold(Greeter, {}).greet("x"), "v2 x"); }); it("#3 the parent link dying while the child prepares unwinds the child", async () => { @@ -506,7 +507,7 @@ describe("links review: cross-host reload", () => { assert.notEqual(worker.codeVersion, server.codeVersion); const next = connect(); worker.connect(next.up, { principal: { kind: "server" } }); - const greeter = server.get(Greeter, {}); + const greeter = server.hold(Greeter, {}); assert.equal(await greeter.greet("x"), "v2 x"); assert.equal(worker.codeVersion, server.codeVersion); }); @@ -537,15 +538,15 @@ describe("links review: cross-host reload", () => { server.install(echoFacet(1)); // Keeps the Echo object the bounced call reaches (it acts as the worker's principal) alive, so the reload gate // must decide about the bounced root's lease. - const echo = server.get(Echo, {}, { principal: { worker: "w" } }); + const echo = server.hold(Echo, {}, { principal: { worker: "w" } }); await echo.ready; worker.serve(Bouncer, () => ({ variant: "main", bounce: async (ctx: Ctx) => { - await ctx.get(Echo, {}).ping(ctx); + await (await ctx.get(Echo, {})).ping(ctx); }, })); - await server.root((ctx) => server.get(Bouncer, {}).bounce(ctx)); + await server.root((ctx) => server.hold(Bouncer, {}).bounce(ctx)); await sleep(10); await server.reload([echoFacet(2)], { gateDeadline: 200 }); }); diff --git a/packages/chord/test/manager.test.ts b/packages/chord/test/manager.test.ts index 016bff798..4e48ebe85 100644 --- a/packages/chord/test/manager.test.ts +++ b/packages/chord/test/manager.test.ts @@ -69,7 +69,7 @@ describe("host manager (real child processes)", () => { assert.equal(manager.status, "running"); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.accept(instance.link, { principal: { worker: "w1" }, kind: "host", name: "w1" }); - const echo = server.get(Echo, {}); + const echo = server.hold(Echo, {}); assert.equal(await echo.echo("x"), `hi x from ${instance.pid}`); assert.equal(await manager.start(), instance); await manager.stop(); diff --git a/packages/chord/test/placement.test.ts b/packages/chord/test/placement.test.ts index 779e57999..869bfa655 100644 --- a/packages/chord/test/placement.test.ts +++ b/packages/chord/test/placement.test.ts @@ -69,11 +69,11 @@ describe("placement", () => { it("a get goes where the contract places the input: here, a child, or the parent, never named by the caller", async () => { const { server, s1, close } = cluster(); await tick(); - assert.equal(await server.get(Note, { session: "S1", id: "a" }).where(), "session:S1 a"); - assert.equal(await server.get(Note, { session: "S2", id: "b" }).where(), "session:S2 b"); + assert.equal(await server.hold(Note, { session: "S1", id: "a" }).where(), "session:S1 a"); + assert.equal(await server.hold(Note, { session: "S2", id: "b" }).where(), "session:S2 b"); // From a session host: its own notes locally, the other session's through the server, the catalog upstream. - assert.equal(await s1.get(Note, { session: "S1", id: "c" }).where(), "session:S1 c"); - assert.equal(await s1.get(Catalog, {}).where(), "server"); + assert.equal(await s1.hold(Note, { session: "S1", id: "c" }).where(), "session:S1 c"); + assert.equal(await s1.hold(Catalog, {}).where(), "server"); await close(); }); @@ -134,8 +134,8 @@ describe("placement", () => { s1.serve(Doc, () => ({ variant: "main", where: () => "session:S1" })); await tick(); // An untyped caller leaves the field out: the default places it. - assert.equal(await server.get(Doc, {} as never).where(), "session:S1"); - assert.throws(() => server.get(Note, { session: "", id: "x" }), /input field session names no host/); + assert.equal(await server.hold(Doc, {} as never).where(), "session:S1"); + assert.throws(() => server.hold(Note, { session: "", id: "x" }), /input field session names no host/); await close(); }); @@ -164,8 +164,7 @@ describe("placement", () => { ), }); await host.install(facet(1, "server")); - const ref = host.get(at("server"), {}); - await ref.ready; + await host.get(at("server"), {}); // Same version, other placement: a forgotten bump. await assert.rejects(host.reload([facet(1, "session:S1")]), /declared for session:S1|without a version bump/); // A new version that `accepts` version 1 but lives elsewhere: no adapter makes version 1 consumers bind. diff --git a/packages/chord/test/principals.test.ts b/packages/chord/test/principals.test.ts index dc4e65bb2..4aef22cb3 100644 --- a/packages/chord/test/principals.test.ts +++ b/packages/chord/test/principals.test.ts @@ -47,7 +47,7 @@ describe("principals", () => { }); }); await host.install({ name: "who", ...facet }); - const guest = host.get(Who, {}, { principal: { kind: "guest", id: "g1" } }); + const guest = host.hold(Who, {}, { principal: { kind: "guest", id: "g1" } }); assert.equal( await host.root((ctx) => guest.who(ctx), { principal: { kind: "guest", id: "g1" } }), "guest g1, called by host", @@ -60,7 +60,7 @@ describe("principals", () => { void plain; }); // @ts-expect-error: not a principal of this app - host.get(Who, {}, { principal: { kind: "stranger" } }); + host.hold(Who, {}, { principal: { kind: "stranger" } }); // @ts-expect-error: a pattern names fields of the app's principals void host.invalidatePrincipal({ kind: "guest", name: "g1" }).catch(() => {}); await host.close(); @@ -73,8 +73,8 @@ describe("principals", () => { created++; return { who: () => "one" }; }); - const own = host.get(Who, {}); - const equal = host.get(Who, {}, { principal: { kind: "host" } }); + const own = host.hold(Who, {}); + const equal = host.hold(Who, {}, { principal: { kind: "host" } }); await Promise.all([own.ready, equal.ready]); assert.equal(created, 1); // Never disposed as a matching principal: it is the host's own. @@ -108,13 +108,13 @@ describe("principals", () => { self: { kind: "host" }, upstream: { link: browserUp, principal: { kind: "server" } }, }); - assert.equal(await server.get(Echo, {}).who(), "server"); + assert.equal(await server.hold(Echo, {}).who(), "server"); server.route("worker", { authorize: () => true, resolve: async () => down, principal: () => ({ kind: "host" }), }); - assert.equal(await browser.get(Echo, {}).who(), "guest:g1"); + assert.equal(await browser.hold(Echo, {}).who(), "guest:g1"); await browser.close(); await worker.close(); await server.close(); diff --git a/packages/chord/test/provider-cost.test.ts b/packages/chord/test/provider-cost.test.ts new file mode 100644 index 000000000..470c74b20 --- /dev/null +++ b/packages/chord/test/provider-cost.test.ts @@ -0,0 +1,83 @@ +// Entry 8: what a change costs the provider. The diff is computed once per change; each feed (one per connection and +// member) encodes it with its own stateful encoder and its link serializes it. +import assert from "node:assert/strict"; +import { Type } from "typebox"; +import { describe, it, vi } from "vitest"; +import type * as delta from "../src/delta/index.ts"; +import { type Ctx, createHost, hostNamed, memoryLinkPair, method, remote } from "../src/index.ts"; +import { CountingLink, stateFrames } from "./fixtures/counting.ts"; + +const counts = vi.hoisted(() => ({ encoders: 0, encodes: 0 })); + +vi.mock("../src/delta/index.ts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + encoder: () => { + counts.encoders++; + const inner = original.encoder(); + return { + encode: (ops: Parameters[0]) => { + counts.encodes++; + return inner.encode(ops); + }, + }; + }, + }; +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const List = remote("t.e8-cost", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { main: { state: { items: Type.Array(Type.String()) }, methods: { add: method(Type.String()) } } }, +}); + +describe("provider cost", () => { + it("one change to K clients: K encodes (one per feed), one frame per link, the same ops on every link", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + server.serve(List, (_input, _principal, self) => { + const items = self.state("items", Type.Array(Type.String()), []); + return { + variant: "main", + items, + add: (text: string, ctx: Ctx) => { + items.change(ctx, (draft) => { + draft.push(text); + }); + }, + }; + }); + const K = 4; + const clients = Array.from({ length: K }, (_, i) => { + const [down, up] = memoryLinkPair(); + // One principal: every client reaches the same object. + server.accept(down, { principal: { user: "u" } }); + const link = new CountingLink(up); + const host = createHost({ + self: { kind: "host" }, + name: `browser${i}`, + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + }); + return { host, link, ref: host.hold(List, {}) }; + }); + await Promise.all(clients.map((client) => client.ref.ready)); + const encoders = counts.encoders; + const encodes = counts.encodes; + const before = clients.map((client) => stateFrames(client.link.received).length); + await clients[0]!.ref.add("x"); + await sleep(10); + assert.equal(counts.encodes - encodes, K, "one encode per feed"); + assert.equal(counts.encoders, encoders, "no new streams for a change"); + const frames = clients.map((client, i) => stateFrames(client.link.received).slice(before[i])); + for (const list of frames) assert.equal(list.length, 1); + const ops = frames.map((list) => JSON.stringify(list[0]?.ops)); + assert.equal(new Set(ops).size, 1, "every link carries the same ops"); + for (const client of clients) assert.deepEqual(client.ref.items.value, ["x"]); + await server.close(); + }); +}); diff --git a/packages/chord/test/readiness.test.ts b/packages/chord/test/readiness.test.ts index 50e666498..a781a6e34 100644 --- a/packages/chord/test/readiness.test.ts +++ b/packages/chord/test/readiness.test.ts @@ -129,7 +129,7 @@ describe("host: eager readiness", () => { }, { name: "provider", setup: ({ serve }) => serve(Lazy, () => ({})) }, ]); - assert.equal(host.get(Eager, {}).status, "live"); + assert.equal(host.hold(Eager, {}).status, "live"); } finally { await host.close(); } @@ -240,7 +240,7 @@ describe("host: eager readiness", () => { await (operation === "install" ? worker.install(facet, { awaitHostObjects: false }) : worker.load([facet], { awaitHostObjects: false })); - await worker.get(Lazy, {}).ready; + await worker.hold(Lazy, {}).ready; assert.equal(bootstrap, true); let settled = false; const ready = worker.ready().then(() => { diff --git a/packages/chord/test/reload.test.ts b/packages/chord/test/reload.test.ts index fbd185a89..f2931f0c9 100644 --- a/packages/chord/test/reload.test.ts +++ b/packages/chord/test/reload.test.ts @@ -92,7 +92,7 @@ describe("reload", () => { it("compatible reload: no gate, pinned roots keep old code, state preserved, previous retires", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); assert.equal(await ref.greet("a"), "hello a"); const go = latch(); const pinned = host.root(async (ctx) => { @@ -101,13 +101,13 @@ describe("reload", () => { return [first, await ref.greet("p", ctx)]; }); await tick(); - const shapes: number[] = []; - ref.onShape((shape) => shapes.push(shape.codeVersion)); + const shapes: string[] = []; + ref.onShape((shape) => shapes.push(shape.contract)); const report = await host.reload([greeterFacet({ greeting: "hi" })]); assert.deepEqual(report.gated, []); assert.equal(report.prepared.length, 1); assert.equal(await ref.greet("b"), "hi b"); - assert.deepEqual(shapes, [report.codeVersion]); + assert.equal(shapes.length, 1, "one shape push at the commit"); assert.equal(host.previousCodeVersion, report.previous); go.open(); assert.deepEqual(await pinned, ["hello p", "hello p"]); @@ -118,8 +118,8 @@ describe("reload", () => { it("prepare runs in recording mode: writes throw and a failing factory leaves everything unchanged", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - const a = host.get(Greeter, { id: "a" }); - const b = host.get(Greeter, { id: "b" }); + const a = host.hold(Greeter, { id: "a" }); + const b = host.hold(Greeter, { id: "b" }); await Promise.all([a.ready, b.ready]); let captured: Ctx | undefined; await host.root((ctx) => { @@ -161,7 +161,7 @@ describe("reload", () => { it("refuses a changed cell schema without a version bump", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - await host.get(Greeter, {}).ready; + await host.hold(Greeter, {}).ready; const changed = greeterFacet({ greeting: "hi", schema: Type.Object({ total: Type.Number() }), @@ -177,7 +177,7 @@ describe("reload", () => { it("state bump: gate, migrate(old, fromVersion) into the new schema, reset to watchers, old roots upgrade_conflict", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.greet("a"); await ref.greet("a"); const events: StateEvent[] = []; @@ -244,7 +244,7 @@ describe("reload", () => { }), }); host.install(facet(1)); - const pair = host.get(Pair, {}); + const pair = host.hold(Pair, {}); await pair.ready; await pair.bump(); const report = await host.reload([facet(2)]); @@ -257,7 +257,7 @@ describe("reload", () => { it("gate: late old roots get upgrade_conflict, grandfathered roots re-enter, then commit", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; const go = latch(); const grandfathered = host.root(async (ctx) => { @@ -299,7 +299,7 @@ describe("reload", () => { }), }); host.install(facet("hello", 1)); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; const go = latch(); const blocker = host.root(async (ctx) => { @@ -345,7 +345,7 @@ describe("reload", () => { }), }); host.install(facet(1)); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); assert.equal(await ref.greet("x"), "1 x"); log.length = 0; const report = await host.reload([facet(2)]); @@ -358,7 +358,7 @@ describe("reload", () => { it("two-version rule: the next reload retires the previous code version; its roots get superseded", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "v1" })); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; const first = host.codeVersion; const go = latch(); @@ -411,7 +411,7 @@ describe("reload", () => { }), }); host.install(facet(1)); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; const old = host.root((ctx) => ref.greet("wait", ctx)); await tick(); @@ -441,7 +441,7 @@ describe("reload", () => { }), }); host.install(facet("a")); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; await host.reload([facet("b")]); await host.reload([facet("c")]); @@ -474,7 +474,7 @@ describe("reload", () => { }), }); host.install(facet("a")); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; await host.reload([facet("b")]); await vi.advanceTimersByTimeAsync(29); @@ -507,7 +507,7 @@ describe("reload", () => { const other: Facet = { name: "other", setup: (api) => void signals.set("other", api.signal) }; host.install(facet("a")); host.install(other); - await host.get(Greeter, {}).ready; + await host.hold(Greeter, {}).ready; await assert.rejects(host.reload([facet("b", true)])); assert.equal(signals.get("b")?.aborted, true, "the aborted reload's run"); assert.equal(signals.get("a")?.aborted, false, "still installed"); @@ -544,7 +544,7 @@ describe("reload", () => { name: "doc", setup: (api) => api.serve(DocV1, () => ({ variant: "main", read: () => ({ text: "v1" }) })), }); - const consumer = host.get(DocV1, {}); + const consumer = host.hold(DocV1, {}); assert.deepEqual(await consumer.read(), { text: "v1" }); const broken: Facet = { @@ -571,7 +571,7 @@ describe("reload", () => { }; await host.reload([adapted]); assert.deepEqual(await consumer.read(), { text: "v2 via adapter" }); - const modern = host.get(DocV2, {}); + const modern = host.hold(DocV2, {}); assert.deepEqual(await modern.fetch(), { body: "v2" }); }); @@ -616,7 +616,7 @@ describe("reload", () => { }), }); host.install(facet(1)); - const ref = host.get(Sum, {}); + const ref = host.hold(Sum, {}); await ref.ready; emit(1); await tick(); @@ -668,7 +668,7 @@ describe("reload", () => { it("a reload can add a type together with a consumer that holds it", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; const Clock = local("t.clock", { version: 1, @@ -700,7 +700,7 @@ describe("reload", () => { it("the gate rejects new objects of a changed type; after commit they get the new code", async () => { const host = quiet(); host.install(greeterFacet({ greeting: "hello" })); - const ref = host.get(Greeter, {}); + const ref = host.hold(Greeter, {}); await ref.ready; const go = latch(); const grandfathered = host.root(async (ctx) => { @@ -723,13 +723,13 @@ describe("reload", () => { } }); assert.throws( - () => host.get(Greeter, { id: "late" }), + () => host.hold(Greeter, { id: "late" }), (error) => isChordError(error, "upgrade_conflict") && error.retryable, ); go.open(); await grandfathered; await reload; - assert.equal(await host.get(Greeter, { id: "late" }).greet("l"), "hi l"); + assert.equal(await host.hold(Greeter, { id: "late" }).greet("l"), "hi l"); // The previous code version is still retained (pinned root), but the gated object dropped its old implementation. assert.notEqual(host.previousCodeVersion, undefined); later.open(); diff --git a/packages/chord/test/requests.test.ts b/packages/chord/test/requests.test.ts index faf0440c9..fef1f2a30 100644 --- a/packages/chord/test/requests.test.ts +++ b/packages/chord/test/requests.test.ts @@ -68,11 +68,11 @@ describe("application requests", () => { const conv = self.get(Conv, {}); return { variant: "viewer", view: conv.view }; }); - const chat = server.get(Chat, {}); + const chat = server.hold(Chat, {}); const values: unknown[] = []; chat.view.subscribe((value) => values.push(value)); await chat.ready; - await server.get(Conv, {}).set("hi"); + await server.hold(Conv, {}).set("hi"); await tick(); assert.deepEqual(chat.view.value, { text: "hi" }); assert.deepEqual(values.at(-1), { text: "hi" }); @@ -87,10 +87,9 @@ describe("application requests", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const remoteChat = browser.get(Chat, {}); - remoteChat.view.subscribe(() => {}); - await until(() => remoteChat.view.value !== undefined); - await server.get(Conv, {}).set("there"); + const remoteChat = browser.hold(Chat, {}); + await remoteChat.ready; + await server.hold(Conv, {}).set("there"); await until(() => (remoteChat.view.value as { text: string } | undefined)?.text === "there"); }); @@ -126,11 +125,11 @@ describe("application requests", () => { const api = captured[0]!; assert.equal(api.name, "worker"); // A callback outside Chord (a Durable tool, say) starts its own root. - assert.equal(await api.root((ctx) => ctx.get(Reader, {}).read(ctx)), "v1"); + assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v1"); await host.reload([facet("v2")]); await sleep(5); assert.equal(captured[1]!.registry(Config).get("greeting"), "v2"); - assert.equal(await api.root((ctx) => ctx.get(Reader, {}).read(ctx)), "v2"); + assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v2"); }); it("#3 a listener keeps its subscription across reloads when resource and select are unchanged", async () => { @@ -170,7 +169,7 @@ describe("application requests", () => { }); const host = createHost({ self: { kind: "host" }, onError: () => {} }); host.install(facet("v1")); - const ref = host.get(Watcher, {}); + const ref = host.hold(Watcher, {}); await ref.ready; emit("a"); await host.reload([facet("v2")]); @@ -201,7 +200,7 @@ describe("application requests", () => { }); const host = createHost({ self: { kind: "host" }, onError: () => {} }); host.install(facet(true)); - await host.get(Watcher, {}).ready; + await host.hold(Watcher, {}).ready; await host.reload([facet(true)]); assert.equal(subscribes, 1); await host.reload([facet(false)]); @@ -226,7 +225,7 @@ describe("application requests", () => { }, }; }); - const ref = host.get(Mirror, {}); + const ref = host.hold(Mirror, {}); await ref.ready; const events: StateEvent[] = []; let replica: unknown; @@ -276,7 +275,7 @@ describe("application requests", () => { // A worker root of code version 1 (an eager object's listener, say) calls the server after connecting. const call = worker.root(async (ctx) => { await go.promise; - return ctx.get(Echo, {}).codeVersion(ctx); + return (await ctx.get(Echo, {})).codeVersion(ctx); }); const [down, up] = memoryLinkPair(); server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" }); @@ -310,7 +309,7 @@ describe("application requests", () => { }); return { db: self.expose(db) }; }); - const ref = host.get(Store, {}); + const ref = host.hold(Store, {}); assert.equal(ref.db, undefined); gate.open(); await ref.ready; @@ -321,7 +320,7 @@ describe("application requests", () => { self.expose({ key: "x", value: 1 }); return {}; }); - await assert.rejects(host.get(Other, {}).ready, /expose takes a resource declared by this object/); + await assert.rejects(host.hold(Other, {}).ready, /expose takes a resource declared by this object/); }); it("#7 a cluster reload reports what each child prepared", async () => { @@ -349,7 +348,7 @@ describe("application requests", () => { }); worker.install(greeter("v1")); await tick(); - const ref = server.get(Greeter, {}); + const ref = server.hold(Greeter, {}); assert.equal(await ref.greet("a"), "v1 a"); modules.set("greeter", greeter("v2")); const report = await server.reload([], { remote: { "session:": ["greeter"] } }); @@ -360,7 +359,7 @@ describe("application requests", () => { assert.equal(child?.codeVersion, report.codeVersion); }); - it("#8 a remote state member forgets its value when nobody listens; the next subscriber sees the fresh value", async () => { + it("#8 a held remote reference keeps its state without listeners; a later subscriber sees the current value", async () => { const Counter = remote("t.count8", { version: 1, input: Type.Object({}), @@ -387,17 +386,18 @@ describe("application requests", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const counter = browser.get(Counter, {}); + const counter = browser.hold(Counter, {}); + await counter.ready; + assert.equal(counter.n.value, 0); const stop = counter.n.subscribe(() => {}); await counter.set(1); await until(() => counter.n.value === 1); stop(); - assert.equal(counter.n.value, undefined); await counter.set(2); + await until(() => counter.n.value === 2); const seen: unknown[] = []; const again = counter.n.subscribe((value) => seen.push(value)); - await until(() => seen.at(-1) === 2); - assert.deepEqual(seen, [undefined, 2]); + assert.deepEqual(seen, [2]); again(); }); @@ -410,7 +410,7 @@ describe("application requests", () => { }); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(Thing, () => ({ variant: "main" })); - const localThing = server.get(Thing, {}, { principal: { user: "u" } }); + const localThing = server.hold(Thing, {}, { principal: { user: "u" } }); const localStatus: unknown[] = []; localThing.onStatus((status, error) => localStatus.push(status === "gone" ? [status, (error as { code: string }).code] : status), @@ -430,7 +430,7 @@ describe("application requests", () => { upstream: { link: up, principal: { kind: "server" } }, onError: () => {}, }); - const remoteThing = browser.get(Thing, {}); + const remoteThing = browser.hold(Thing, {}); const remoteStatus: string[] = []; remoteThing.onStatus((status) => remoteStatus.push(status)); await remoteThing.ready; @@ -502,7 +502,7 @@ describe("application requests", () => { }); return {}; }); - await worker.get(Chat, {}).ready; + await worker.hold(Chat, {}).ready; await worker.close(); assert.deepEqual(log, ["chat closed, proc live, tracker ok", "server: bye", "proc closed"]); }); diff --git a/packages/chord/test/review.test.ts b/packages/chord/test/review.test.ts index ed041d83f..acf6a3746 100644 --- a/packages/chord/test/review.test.ts +++ b/packages/chord/test/review.test.ts @@ -66,7 +66,7 @@ describe("review: reload commit", () => { }), }); host.install(facet("v1", false)); - const refs = ["a", "b", "c"].map((id) => host.get(Item, { id })); + const refs = ["a", "b", "c"].map((id) => host.hold(Item, { id })); await Promise.all(refs.map((ref) => ref.ready)); const codeVersion = host.codeVersion; await assert.rejects(host.reload([facet("v2", true)]), /subscribe fails/); @@ -102,7 +102,7 @@ describe("review: reload commit", () => { { linger: 60_000 }, ), }); - const first = host.get(V1, {}); + const first = host.hold(V1, {}); await first.ready; first.release(); await host.reload([ @@ -124,7 +124,7 @@ describe("review: reload commit", () => { ), }, ]); - const second = host.get(V2, {}); + const second = host.hold(V2, {}); const values: unknown[] = []; second.count.subscribe((value) => values.push(value)); await second.ready; @@ -135,7 +135,7 @@ describe("review: reload commit", () => { it("#1 a commit that still fails midway fails the host terminally (no half-published code version serving work)", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) }); - const ref = host.get(Item, { id: "a" }); + const ref = host.hold(Item, { id: "a" }); await ref.ready; // Everything fallible runs in prepare now, so break an install step directly (white box). const objects = (host as unknown as { objects: Map }).objects; @@ -169,8 +169,8 @@ describe("review: reload commit", () => { }, }); host.install(facet("v1")); - const item = host.get(Item, { id: "a" }); - const other = host.get(Other, {}); + const item = host.hold(Item, { id: "a" }); + const other = host.hold(Other, {}); await Promise.all([item.ready, other.ready]); const seen: Promise[] = []; item.onShape(() => seen.push(other.version())); @@ -203,7 +203,7 @@ describe("review: leases, invocations, activation", () => { return { count }; }), }); - const ref = host.get(Store, {}); + const ref = host.hold(Store, {}); await ref.ready; events.emit(1); await tick(); @@ -259,7 +259,7 @@ describe("review: leases, invocations, activation", () => { }, }; }); - const released = host.get(Worker, { id: "r" }); + const released = host.hold(Worker, { id: "r" }); const call = released.work(); await ticks(); released.release(); @@ -271,7 +271,7 @@ describe("review: leases, invocations, activation", () => { go = latch(); log.length = 0; - const forced = host.get(Worker, { id: "f" }, { principal: { user: "u" } }); + const forced = host.hold(Worker, { id: "f" }, { principal: { user: "u" } }); const forcedCall = forced.work(); await ticks(); const invalidated = host.invalidatePrincipal((principal) => (principal as { user?: string }).user === "u"); @@ -329,11 +329,11 @@ describe("review: leases, invocations, activation", () => { self.on("drop", count, () => ref?.release()); return { count }; }); - ref = host.get(Store, {}); + ref = host.hold(Store, {}); await ref.ready.catch(() => {}); await ticks(20); assert.deepEqual(log, ["open", "close"]); - const again = host.get(Store, {}); + const again = host.hold(Store, {}); ref = undefined; await again.ready; assert.equal(runs, 2); @@ -360,7 +360,7 @@ describe("review: prepare isolation and contracts", () => { }), }); host.install(facet(false)); - const ref = host.get(Db, {}); + const ref = host.hold(Db, {}); await ref.ready; await assert.rejects(host.reload([facet(true)]), (error) => isChordError(error, "not_ready")); assert.equal(native.mutated, false); @@ -372,7 +372,7 @@ describe("review: prepare isolation and contracts", () => { const log: string[] = []; const Fresh = local("t.fresh", { version: 1, input: Type.Object({}) }); host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) }); - await Promise.all(["a", "b"].map((id) => host.get(Item, { id }).ready)); + await Promise.all(["a", "b"].map((id) => host.hold(Item, { id }).ready)); const plugins = (fail: boolean): Facet[] => [ { name: "fresh", @@ -431,7 +431,7 @@ describe("review: prepare isolation and contracts", () => { }); }, }); - const consumer = host.get(Consumer, {}); + const consumer = host.hold(Consumer, {}); assert.equal(await consumer.ask(), "old"); await host.reload([ { @@ -475,7 +475,7 @@ describe("review: prepare isolation and contracts", () => { }), }); host.install(facet(1)); - const ref = host.get(Db, {}); + const ref = host.hold(Db, {}); await ref.ready; // The failing close aborts the reload at the gate; the old resource must not stay closed. await assert.rejects(host.reload([facet(2)]), /close fails/); @@ -517,7 +517,7 @@ describe("review: prepare isolation and contracts", () => { }, }); host.install(facet(true)); - const ref = host.get(Item, { id: "a" }); + const ref = host.hold(Item, { id: "a" }); await ref.ready; const go = latch(); const pinned = host.root(async (ctx) => { @@ -580,7 +580,7 @@ describe("review: prepare isolation and contracts", () => { }, }); host.serve(Strict, () => ({ variant: "main", echo: (args) => args })); - const ref = host.get(Strict, {}); + const ref = host.hold(Strict, {}); assert.deepEqual(await ref.echo({ a: "x", junk: 1 } as never), { a: "x" }); }); }); @@ -611,9 +611,9 @@ describe("review: ctx races", () => { }); return {}; }); - const runner = host.get(Runner, {}); + const runner = host.hold(Runner, {}); await runner.ready; - const target = host.get(Target, {}); + const target = host.hold(Target, {}); await target.ready; runner.release(); await ticks(); @@ -627,7 +627,7 @@ describe("review: ctx races", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const other = createHost({ self: { kind: "host" }, onError: () => {} }); host.serve(Target, () => ({ hit: () => {} })); - const target = host.get(Target, {}); + const target = host.hold(Target, {}); await target.ready; const expired = await host.root((ctx) => ctx); await assert.rejects( diff --git a/packages/chord/test/state-init.test.ts b/packages/chord/test/state-init.test.ts index 9bfd40487..77f101d5e 100644 --- a/packages/chord/test/state-init.test.ts +++ b/packages/chord/test/state-init.test.ts @@ -67,7 +67,7 @@ describe("state init", () => { }, }; }); - const ref = host.get(Notes, {}); + const ref = host.hold(Notes, {}); const seen: unknown[] = []; ref.notes.subscribe((value) => seen.push(value)); // A call made while init runs waits until the object is live, then reads the loaded value. @@ -94,7 +94,7 @@ describe("state init", () => { notes.value; return { notes, count, has: () => false, add: () => {} }; }); - await assert.rejects(host.get(Notes, {}).ready, (error) => isChordError(error) && error.code === "not_ready"); + await assert.rejects(host.hold(Notes, {}).ready, (error) => isChordError(error) && error.code === "not_ready"); }); it("a throwing or invalid init fails the activation and closes its resources; the next get tries again", async () => { @@ -109,13 +109,13 @@ describe("state init", () => { const count = self.derive([notes], (list) => list.length); return { notes, count, has: () => false, add: () => {} }; }); - await assert.rejects(host.get(Notes, {}).ready, /db down/); + await assert.rejects(host.hold(Notes, {}).ready, /db down/); assert.deepEqual(closed, ["db"]); result = () => [1]; - await assert.rejects(host.get(Notes, {}).ready, (error) => isChordError(error) && error.code === "invalid"); + await assert.rejects(host.hold(Notes, {}).ready, (error) => isChordError(error) && error.code === "invalid"); assert.deepEqual(closed, ["db", "db"]); result = () => ["ok"]; - const ref = host.get(Notes, {}); + const ref = host.hold(Notes, {}); await ref.ready; assert.deepEqual(ref.notes.value, ["ok"]); }); @@ -129,11 +129,13 @@ describe("state init", () => { }); host.serve(Source, () => ({ all: () => ["from source"] })); host.serve(Notes, (_input, _principal, self) => { - const notes = self.state("notes", NotesSchema, { init: (ctx: Ctx) => ctx.get(Source, {}).all(ctx) }); + const notes = self.state("notes", NotesSchema, { + init: async (ctx: Ctx) => (await ctx.get(Source, {})).all(ctx), + }); const count = self.derive([notes], (list) => list.length); return { notes, count, has: () => false, add: () => {} }; }); - const ref = host.get(Notes, {}); + const ref = host.hold(Notes, {}); await ref.ready; assert.deepEqual(ref.notes.value, ["from source"]); }); @@ -184,7 +186,7 @@ describe("state init", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const inits: string[] = []; await host.install(facet({ inits })); - const ref = host.get(Notes, {}); + const ref = host.hold(Notes, {}); await ref.ready; await host.root((ctx) => ref.add("written", ctx)); await host.reload([facet({ inits, extra: () => ["x", "y"] })]); @@ -198,7 +200,7 @@ describe("state init", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const inits: string[] = []; await host.install(facet({ inits })); - const ref = host.get(Notes, {}); + const ref = host.hold(Notes, {}); await ref.ready; await assert.rejects( host.reload([ @@ -219,7 +221,7 @@ describe("state init", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const inits: string[] = []; await host.install(facet({ inits })); - const ref = host.get(Notes, {}); + const ref = host.hold(Notes, {}); await ref.ready; const report = await host.reload([facet({ inits, countVersion: 2, extra: () => ["x"] })]); assert.equal(report.gated.length, 1); diff --git a/packages/chord/test/state.test.ts b/packages/chord/test/state.test.ts index a38e139a6..656ac0601 100644 --- a/packages/chord/test/state.test.ts +++ b/packages/chord/test/state.test.ts @@ -60,22 +60,21 @@ describe("state cells", () => { it("publishes snapshots then exact ops; derivations follow", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); serveList(host); - const ref = host.get(List, {}); + const ref = host.hold(List, {}); const events: StateEvent[] = []; const labels: StateEvent[] = []; ref.list.events((event) => events.push(event)); ref.labels.events((event) => labels.push(event)); await ref.push({ id: 1, label: "a" }); - // Subscribed while loading: an empty snapshot, the hydrated value as a reset, then exact ops. - assert.deepEqual(events[0], { type: "snap", value: undefined }); - assert.deepEqual(events[1], { type: "reset", value: { items: [], count: 0 } }); - const ops = events[2]?.type === "ops" ? events[2].ops.map((op) => JSON.stringify(op)).sort() : []; + // Subscribed while loading: the snapshot once live, then exact ops. + assert.deepEqual(events[0], { type: "snap", value: { items: [], count: 0 } }); + const ops = events[1]?.type === "ops" ? events[1].ops.map((op) => JSON.stringify(op)).sort() : []; assert.deepEqual(ops, ['["p",["items"],0,0,[{"id":1,"label":"a"}]]', '["s",["count"],1]']); - assert.deepEqual(events[2]?.value, { items: [{ id: 1, label: "a" }], count: 1 }); + assert.deepEqual(events[1]?.value, { items: [{ id: 1, label: "a" }], count: 1 }); assert.deepEqual(ref.labels.value, ["a"]); assert.deepEqual( labels.map((event) => event.type), - ["snap", "reset", "ops"], + ["snap", "ops"], ); await ref.set({ items: [], count: 0 }); assert.deepEqual(ref.labels.value, []); @@ -98,7 +97,7 @@ describe("state cells", () => { }, }; }); - const ref = host.get(Counter, {}); + const ref = host.hold(Counter, {}); const values: unknown[] = []; await ref.ready; const events: StateEvent[] = []; @@ -130,7 +129,7 @@ describe("state cells", () => { }, }; }); - await assert.rejects(host.get(Box, {}).put(), (error) => isChordError(error, "invalid")); + await assert.rejects(host.hold(Box, {}).put(), (error) => isChordError(error, "invalid")); }); it("validates initial values always and writes only in dev mode", async () => { @@ -140,15 +139,15 @@ describe("state cells", () => { self.state("x", Type.Object({ n: Type.Number() }), { n: "no" } as never); return {}; }); - await assert.rejects(host.get(Bad, {}).ready, /initial value of state x/); + await assert.rejects(host.hold(Bad, {}).ready, /initial value of state x/); const lax = createHost({ self: { kind: "host" }, onError: () => {} }); serveList(lax); - await lax.get(List, {}).set({ items: [], count: "x" } as never); + await lax.hold(List, {}).set({ items: [], count: "x" } as never); const dev = createHost({ self: { kind: "host" }, dev: true, onError: () => {} }); serveList(dev); - const ref = dev.get(List, {}); + const ref = dev.hold(List, {}); await assert.rejects( ref.set({ items: [], count: "x" } as never), (error) => isChordError(error, "invalid") && error.reason === "state", @@ -173,7 +172,7 @@ describe("state cells", () => { x.replace(captured!, {}); return {}; }); - await assert.rejects(host.get(Writer, {}).ready, /read_only/); + await assert.rejects(host.hold(Writer, {}).ready, /read_only/); }); it("listeners run each event in a fresh root; tasks get an abortable ctx", async () => { @@ -206,7 +205,7 @@ describe("state cells", () => { }); return { seen }; }); - const ref = host.get(Watcher, {}); + const ref = host.hold(Watcher, {}); await ref.ready; events.emit(2); events.emit(3); @@ -242,7 +241,7 @@ describe("state cells", () => { }, }; }); - const ref = host.get(Echo, {}); + const ref = host.hold(Echo, {}); await ref.ready; const values: unknown[] = []; let replica: unknown; diff --git a/packages/chord/test/tutorial.test.ts b/packages/chord/test/tutorial.test.ts index 60f4e1e97..55c0dfe17 100644 --- a/packages/chord/test/tutorial.test.ts +++ b/packages/chord/test/tutorial.test.ts @@ -156,7 +156,7 @@ it("02: first contract, factory, reference and root", { timeout: 5000 }, async ( const host = createHost({ self: "server" }); host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` })); const result = await host.root(async (ctx) => { - const todos = ctx.get(TodoLabel, { list: "family" }); + const todos = await ctx.get(TodoLabel, { list: "family" }); return todos.label(ctx); }); assert.equal(result, "Todos for family"); @@ -169,8 +169,7 @@ it("03: cells publish values and derivations", { timeout: 5000 }, async () => { // example:03-call const host = createHost({ self: "server", dev: true }); host.serve(TodoStore, todoFactory); - const todos = host.get(TodoStore, { list: "family" }); - await todos.ready; + const todos = await host.get(TodoStore, { list: "family" }); const counts: (number | undefined)[] = []; const stop = todos.openCount.subscribe((value) => counts.push(value)); await host.root(async (ctx) => { @@ -232,9 +231,8 @@ it("04: holds, asynchronous resources and release", { timeout: 5000 }, async () ); // end-example:04-saving // example:04-call - const saved = host.get(SavedTodos, { list: "family" }); - const database = host.get(Database, {}); - await saved.ready; + const saved = await host.get(SavedTodos, { list: "family" }); + const database = await host.get(Database, {}); await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx)); assert.deepEqual(database.db!.saved, ["Buy milk"]); assert.equal(opens, 1); @@ -255,13 +253,13 @@ it("05: principals select views of one shared store", { timeout: 5000 }, async ( const host = createHost({ name: "server", self: "server", principalKey: (principal) => principal }); host.serve(TodoStore, todoFactory); serveViews(host); - const owner = host.get(Todos, { list: "family" }, { principal: "owner" }); - const guest = host.get(Todos, { list: "family" }, { principal: "guest" }); - await Promise.all([owner.ready, guest.ready]); - assert.equal(owner.variant, "editor"); + const owner = await host.get(Todos, { list: "family" }, { principal: "owner" }); + const guest = await host.get(Todos, { list: "family" }, { principal: "guest" }); + assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant assert.equal(guest.variant, "viewer"); await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" }); assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]); + // @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider. await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant")); await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid")); // end-example:05-call @@ -283,23 +281,21 @@ it("06: remote methods and live state over a memory link", { timeout: 5000 }, as kind: "client", upstream: { link: up, principal: "server" }, }); - const todos = browser.get(Todos, { list: "family" }); - const hydrated = Promise.withResolvers(); + const todos = await browser.get(Todos, { list: "family" }); + assert.ok(todos.variant === "editor"); + assert.deepEqual(todos.rows.value, []); const updated = Promise.withResolvers(); const stop = todos.rows.subscribe((rows) => { - if (rows !== undefined) hydrated.resolve(); - if (rows?.length === 1) updated.resolve(); + if (rows.length === 1) updated.resolve(); }); - await todos.ready; - await hydrated.promise; - assert.deepEqual(todos.rows.value, []); await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx)); await updated.promise; assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]); // Deliberately bypass TypeScript to simulate a malformed caller. await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid")); stop(); - assert.equal(todos.rows.value, undefined); + // A held reference keeps its state without listeners. + assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]); todos.release(); // end-example:06 }); @@ -336,8 +332,7 @@ it("07: listeners react and tasks start fresh calls", { timeout: 5000 }, async ( }); return { seen }; }); - const activity = host.get(Activity, {}); - await activity.ready; + const activity = await host.get(Activity, {}); await Promise.all([seeded.promise, observed.promise]); assert.equal(activity.seen.value, 1); activity.release(); @@ -367,7 +362,7 @@ it("08: plugins contribute registry entries and expose callbacks", { timeout: 50 self.register(Actions, { addSample: async (ctx) => { await todos.add({ text: "Try plugins" }, ctx); - return todos.openCount.value!; + return todos.openCount.value; }, }); return {}; @@ -401,7 +396,7 @@ it("09: compatible reload, migration and an old-contract adapter", { timeout: 50 })), }); await host.load([facet("Original")]); - const todos = host.get(TodoStore, { list: "family" }); + const todos = await host.get(TodoStore, { list: "family" }); await todos.add({ text: "Buy milk" }); const entered = Promise.withResolvers(); const resume = Promise.withResolvers(); @@ -470,7 +465,7 @@ it("09: compatible reload, migration and an old-contract adapter", { timeout: 50 }, ]); assert.equal(await todos.label(), "Todos for family"); - const newer = host.get(RenamedStore, { list: "family" }); + const newer = await host.get(RenamedStore, { list: "family" }); assert.equal(await newer.heading(), "Todos for family"); assert.equal(todos.rows.value?.[0]?.text, "BUY MILK"); // end-example:09-contract @@ -495,7 +490,8 @@ it("10: stub a provider with a fake facet and check error codes", { timeout: 500 }; await host.load([fake]); serveViews(host); - const owner = host.get(Todos, { list: "family" }, { principal: "owner" }); + const owner = await host.get(Todos, { list: "family" }, { principal: "owner" }); + assert.ok(owner.variant === "editor"); await owner.add({ text: "Test delegation" }); assert.deepEqual(received, ["Test delegation"]); await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid")); @@ -510,10 +506,9 @@ it("11: short roots release temporary holds", { timeout: 5000 }, async () => { // example:11 const host = createHost({ self: "server" }); host.serve(TodoStore, todoFactory); - const screen = host.get(TodoStore, { list: "family" }); - await screen.ready; + const screen = await host.get(TodoStore, { list: "family" }); await host.root(async (ctx) => { - const temporary = ctx.get(TodoStore, { list: "family" }); + const temporary = await ctx.get(TodoStore, { list: "family" }); await temporary.add({ text: "Keep roots short" }, ctx); }); // The temporary reference is released here; screen still holds the store. assert.equal(screen.openCount.value, 1); diff --git a/packages/chord/test/types.test.ts b/packages/chord/test/types.test.ts new file mode 100644 index 000000000..f64f0ab98 --- /dev/null +++ b/packages/chord/test/types.test.ts @@ -0,0 +1,41 @@ +// Entry 8: reference types. State members are plain values (a reference you hold has its state); a remote reference is a +// union discriminated by `variant`, so another variant's members are reachable only after narrowing. Checked by tsc. +import { Type } from "typebox"; +import { describe, expectTypeOf, it } from "vitest"; +import { type Ctx, type Host, hostNamed, local, method, type Readable, type Ref, remote } from "../src/index.ts"; + +const Doc = remote("t.e8-types-doc", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { + reader: { state: { text: Type.String() } }, + editor: { state: { text: Type.String() }, methods: { edit: method(Type.String()) } }, + }, +}); +const Single = remote("t.e8-types-single", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { main: { state: { n: Type.Number() }, methods: { bump: method() } } }, +}); +const Local = local("t.e8-types-local", { version: 1, input: Type.Object({}), members: { n: Type.Number() } }); + +describe("reference types", () => { + it("state members are T; other variants' members need narrowing; gets are awaited", () => { + const check = async (host: Host, ctx: Ctx) => { + const doc = await host.get(Doc, {}); + expectTypeOf(doc.text).toEqualTypeOf>(); + // @ts-expect-error `edit` exists only in the editor variant. + void doc.edit; + if (doc.variant === "editor") expectTypeOf(doc.edit).toBeFunction(); + const single = await ctx.get(Single, {}); + expectTypeOf(single.n.value).toEqualTypeOf(); + expectTypeOf(single.bump).toBeFunction(); + const held = host.hold(Local, {}); + expectTypeOf(held.n).toEqualTypeOf>(); + expectTypeOf(host.get(Single, {})).toEqualTypeOf>>(); + }; + expectTypeOf(check).toBeFunction(); + }); +}); diff --git a/packages/chord/test/variants.test.ts b/packages/chord/test/variants.test.ts new file mode 100644 index 000000000..363f50361 --- /dev/null +++ b/packages/chord/test/variants.test.ts @@ -0,0 +1,168 @@ +// Entry 8: a binding applies a shape only once its new state members have snapshots; members outside the variant +// throw `not_in_variant`; a re-hold after a link loss keeps the last values readable and sends one fresh unit. +import assert from "node:assert/strict"; +import { Type } from "typebox"; +import { describe, it } from "vitest"; +import { + type Ctx, + createHost, + type Facet, + hostNamed, + isChordError, + memoryLinkPair, + method, + type Readable, + remote, +} from "../src/index.ts"; +import { CountingLink, stateFrames } from "./fixtures/counting.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(check: () => boolean, attempts = 200): Promise { + for (let i = 0; i < attempts && !check(); i++) await sleep(2); + assert.ok(check(), "condition not reached"); +} + +const Doc = remote("t.e8-doc", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { + a: { state: { x: Type.Number() }, methods: { bump: method() } }, + b: { state: { x: Type.Number(), y: Type.String() }, methods: { bump: method() } }, + c: { state: { y: Type.String() }, methods: { bump: method() } }, + }, +}); + +/** The server's facet: `Doc` in variant `variant` (the cells survive reloads by name). */ +function docFacet(variant: "a" | "b" | "c"): Facet { + return { + name: "doc", + setup: (api) => + api.serve(Doc, (_input, _principal, self) => { + const x = self.state("x", Type.Number(), 1); + const y = self.state("y", Type.String(), "why"); + const bump = (ctx: Ctx) => { + x.replace(ctx, x.value + 1); + }; + if (variant === "a") return { variant, x, bump }; + if (variant === "b") return { variant, x, y, bump }; + return { variant, y, bump }; + }), + }; +} + +/** A reference whose variant changes under the test: its members, typed loosely (TypeScript narrows only once). */ +type View = { readonly variant: string | undefined; readonly x: Readable; readonly y: Readable }; +const view = (ref: object): View => ref as unknown as View; + +function setup() { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { user: "u" } }); + const link = new CountingLink(up); + const browser = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link, principal: { kind: "server" } }, + onError: () => {}, + }); + return { server, browser, link }; +} + +describe("variants", () => { + it("a new variant's members arrive with its shape; a member outside the variant throws not_in_variant", async () => { + const { server, browser, link } = setup(); + await server.install(docFacet("a")); + const doc = browser.hold(Doc, {}); + await doc.ready; + assert.equal(view(doc).variant, "a"); + assert.equal(view(doc).x.value, 1); + assert.throws( + () => view(doc).y.value, + (error) => isChordError(error, "not_in_variant"), + ); + // Every shape the reference shows is readable in full, at the moment it is shown. + const failures: unknown[] = []; + doc.onShape((shape) => { + for (const name of ["x", "y"]) { + const member = Doc.description.variants[shape.variant ?? ""]?.[name]; + if (member?.kind !== "state") continue; + try { + void (view(doc) as unknown as Record>)[name]!.value; + } catch (error) { + failures.push(error); + } + } + }); + const before = stateFrames(link.received).length; + await server.reload([docFacet("b")]); + await until(() => view(doc).variant === "b"); + assert.equal(view(doc).y.value, "why"); + assert.equal(view(doc).x.value, 1); + const unit = stateFrames(link.received).slice(before); + assert.deepEqual( + unit.filter((frame) => frame.as === "snap").map((frame) => frame.m), + ["y"], + "only the new member's snapshot", + ); + await server.reload([docFacet("c")]); + await until(() => view(doc).variant === "c"); + assert.throws( + () => view(doc).x.value, + (error) => isChordError(error, "not_in_variant"), + ); + assert.equal(view(doc).y.value, "why"); + assert.deepEqual(failures, []); + await browser.close(); + await server.close(); + }); +}); + +describe("re-hold after a link loss", () => { + it("keeps the last values readable, sends one fresh unit, and a reference joining meanwhile waits for it", async () => { + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + await server.install(docFacet("b")); + const [down, up] = memoryLinkPair(); + server.accept(down, { principal: { user: "u" } }); + const browser = createHost({ + self: { kind: "host" }, + name: "browser", + kind: "client", + upstream: { link: up, principal: { kind: "server" } }, + onError: () => {}, + }); + const doc = browser.hold(Doc, {}); + await doc.ready; + await doc.bump(); + await until(() => view(doc).x.value === 2); + up.close(); + await until(() => doc.status === "loading"); + // The last values stay readable while the binding re-holds. + assert.equal(view(doc).x.value, 2); + const joining = browser.hold(Doc, {}); + assert.equal(joining.status, "loading"); + let joined = false; + void joining.ready.then(() => { + joined = true; + }); + await sleep(5); + assert.equal(joined, false, "a reference joining during the re-hold waits for the fresh unit"); + + const [down2, up2] = memoryLinkPair(); + server.accept(down2, { principal: { user: "u" } }); + const link = new CountingLink(up2); + browser.connect(link, { principal: { kind: "server" } }); + await joining.ready; + assert.equal(doc.status, "live"); + const hold = link.sent.find((frame) => frame.t === "hold"); + assert.ok(hold?.t === "hold" && hold.again === true, "a re-hold says again"); + assert.equal(link.count("sent", "hold"), 1, "one hold for both references"); + const snaps = stateFrames(link.received).filter((frame) => frame.as === "snap"); + assert.deepEqual(snaps.map((frame) => frame.m).sort(), ["x", "y"], "one snapshot per member"); + // The old hold was released with the link: the server made a fresh object (its cells start over). + assert.equal(view(doc).x.value, 1); + await browser.close(); + await server.close(); + }); +}); diff --git a/packages/chord/test/websocket.test.ts b/packages/chord/test/websocket.test.ts index 9afebcd11..8cb1e95c2 100644 --- a/packages/chord/test/websocket.test.ts +++ b/packages/chord/test/websocket.test.ts @@ -65,7 +65,7 @@ describe("WebSocket links", () => { }); try { const browser = open("ok"); - const board = browser.get(Board, {}); + const board = browser.hold(Board, {}); const seen: unknown[] = []; board.notes.subscribe((value) => seen.push(value)); await board.add("hello"); @@ -86,7 +86,7 @@ describe("WebSocket links", () => { onError: () => {}, }); await assert.rejects( - impostor.root((ctx) => impostor.get(Board, {}).add("x", ctx), { + impostor.root((ctx) => impostor.hold(Board, {}).add("x", ctx), { signal: AbortSignal.timeout(150), }), (error) => isChordError(error, "aborted"), @@ -94,7 +94,7 @@ describe("WebSocket links", () => { await impostor.close(); const refused = open("bad"); - const denied = refused.get(Board, {}); + const denied = refused.hold(Board, {}); // The server closes unauthenticated sockets; calls wait for a reconnect until their ctx gives up. await assert.rejects( refused.root((ctx) => denied.add("x", ctx), { signal: AbortSignal.timeout(100) }), @@ -132,7 +132,7 @@ describe("WebSocket links: packed payloads", () => { upstream: { link: webSocketLink(socket as unknown as WebSocketLike), principal: { kind: "server" } }, onError: () => {}, }); - const board = browser.get(Board, {}); + const board = browser.hold(Board, {}); assert.deepEqual(await board.who(), notes); board.notes.subscribe(() => {}); for (let i = 0; i < 100 && board.notes.value === undefined; i++) await new Promise((r) => setTimeout(r, 5)); @@ -141,7 +141,7 @@ describe("WebSocket links: packed payloads", () => { // A text-only transport (the TUI launcher) turns a packed message back into its text frame. const frames = binary.map((message) => JSON.parse(inflateMessage(message)) as Record); assert(frames.some((frame) => frame.t === "result" && JSON.stringify(frame.value) === JSON.stringify(notes))); - assert(frames.some((frame) => frame.t === "state" && JSON.stringify(frame.snap) === JSON.stringify(notes))); + assert(frames.some((frame) => frame.t === "state" && JSON.stringify(frame.value) === JSON.stringify(notes))); await browser.close(); // A client's binary message is a protocol error: the server closes the socket.