Files
dev/third_party/pi/packages/chord/CHANGELOG.md
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

18 KiB

Changelog

[1.3.0] - 2026-10-15

Breaking Changes

  • Terminology follows the concepts: a reference (what get returns) is Ref<C> (was Handle<C>), its status RefStatus (was HandleStatus); what a factory returns is ImplementationOf<C> (was BehaviorOf<C>); 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 <n>, 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 gets with other inputs created are gone. Only "host" objects may register (reason invalid/not_host_lifetime, was not_eager). LoadOptions.awaitHostObjects replaces awaitEager
  • Declarations are calls by name: self.state(name, schema, initial, { version?, migrate? }) returns the cell, self.resource(name, { version, deps?, exclusive?, open, close? }) the resource (rev is now version), self.on(name, source, [select,] handler) declares a listener; self.derive(sources, fn) and self.task(fn) take no name. cell(), derivation(), resource(), listener(), self.resources, self.listen, self.tasks and the declaration maps are gone, and so are the types CellDecl, ResourceDecl, DerivationDecl, ListenerDecl. A repeated name of one kind in a run is invalid/duplicate
  • State versions belong to cells: ServeOptions.state is gone; a cell's version (default 1) and migrate(old, from) replace it. A raised version migrates only that cell; a lowered one refuses the reload
  • A derivation is new at every reload (computed at the commit) instead of kept with its function replaced. A listener whose state source a reload replaced subscribes again and gets the current value again: listeners on state must reconcile. Listeners on state subscribe when they activate, so they get the value current then
  • method(args?, result?): without arguments a method is called ref.name(ctx?) and implemented (ctx) => …; without a result it is void (callers get undefined). Descriptions record both by leaving args/result out (fingerprints of such methods change), call frames omit args and result frames value for them. Coverage requires both sides to agree on arguments; a void consumer accepts a result
  • Remote contracts declare where their objects live, and callers no longer name a host: remote(name, { …, host: hostNamed("server") }) or host: hostFamily("session:", "session") (the host session: + the input's required string field session, checked by TypeScript). host is required on remote contracts; GetOptions.at is removed. A get normalizes the input, resolves the host, and acquires locally or over the link to it. serve refuses a type whose placement does not include the host (also at a reload), a host refuses a hold whose input its own contract places elsewhere (invalid/placement), and a relay refuses a hold whose target disagrees with its contract token. The placement is part of the description and fingerprint; coverage requires equal placements, and accepts adapters do not bridge placements. Remote references are addressed by type, normalized input and principal, without a host name. PROTOCOL_VERSION is 3 (remote tokens carry host; a hold's input is normalized). New exports: hostNamed, hostFamily, Placement, PlacementFor, HostNamed, HostFamily
  • 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<P>, Host<P>, defineFacet<P>, FacetApi<P>, Serve<P>, Factory<C, P>, Self<P>, Ctx<P>, GetOptions<P>, RootOptions<P>, Route<P>, AcceptOptions<P> and PrincipalMatch<P> 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: <parent name> }; Route.principal is required, replacing the default { host: <host name> }. 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<C>
  • 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<T> is Readable<T>. 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

  • @earendil-works/chord/output: OutputBuffer, which keeps a head or tail window of a stream at a cost proportional to each chunk, Progress, which paces commits of a changing value by their size, plus boundOutput, sanitizeOutput, characterEnd, utf8ByteLength, and the OutputLimits, BoundedOutput, OutputSlice, and OutputSkip types. Moved from @earendil-works/pi-durable, which now uses them from here.
  • 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
  • maxCalls in AcceptOptions limits the calls a peer may have in progress on a link; further ones are answered at once with unavailable (reason too_many_calls) and the link stays open
  • FacetApi.signal aborts once that run of the facet is no longer installed: a committed reload replaced or removed it, the reload that loaded it aborted, or the host closed. While a reload prepares, the installed and the candidate run are both live
  • HostOptions.taskStopDeadline (default 5000 ms) bounds how long a reload waits for aborted tasks before it starts their replacements
  • Links deflate call arguments, results and state snapshots whose JSON exceeds PACK_THRESHOLD (64K characters): over IPC as a base64 field, over WebSockets as a binary message. Relaying hosts forward the deflated bytes unread; the host that uses the payload inflates it. webSocketLink(socket, { compress, acceptPacked }) (WebSocketLinkOptions) controls sending and accepting packed frames (servers should refuse them from untrusted clients), and inflateMessage turns a packed WebSocket message back into a text frame

Changed

  • Link.send returns the encoded frame's size as sent (deflated payloads count as sent) when the transport knows it (memory and WebSocket links); flow control counts these sizes, so links that return nothing (IPC, custom links) are not windowed
  • A reload starts an object's new tasks only after its aborted ones finished (at most taskStopDeadline, then anyway, reported); before, the old and the new run of a task worked side by side
  • When a reload forces the retirement of the previous code version, it first aborts the roots still running on it (their ctx.signal, reason superseded) and gives them up to 1 s to stop before it closes what only that code used; a call with such a ctx is superseded. Before, resources were closed under running roots, which were never told

Fixed

  • nodeLoader registers every facet's directory before importing any, so a facet that imports a file of a facet listed after it gets the same module copy as that facet; before, the load order could produce duplicates.
  • A link that is already closed when a host accepts or routes to it is no longer kept as a connection: before, a route that returned a dead link (a child process that exited before its link was wrapped) made every later call to that host name fail with unavailable until restart, and leaked the connection's ping timer.
  • Fixed processLock() failing with listen EINVAL on macOS and the BSDs when the temporary directory is too deep for a socket path (sun_path holds 104 bytes); the lock socket then lives in a private per-user directory, /tmp/chord2-<uid>
  • A child host whose hello arrived while a cluster reload ran was marked in step without taking part, so it stayed on the old code version and every call to it failed superseded until it reconnected. It is now synced or reconciled when the reload ends; a child already behind is fenced at once, so its roots' calls up are admitted meanwhile. A reconciliation that is running when a reload starts likewise syncs its child only after that reload, against the committed code version
  • A child host retired its previous code version as soon as a cluster reload's prepare arrived, before its parent did, so the parent's old roots got superseded on it. The coordinator now retires before it sends prepare, a child waits for its parent's retire (at most retireDeadline), and a child coming in step (or about to be reconciled) is told which code versions the parent retired before
  • Disposed objects stayed reachable from the host until an object with the same address was created again (every distinct guest, session or input leaked one object, its references and the code they bind)
  • References kept every code version's services, and with them the factory code of every reload, reachable for as long as they lived (held references, objects of lifetime "host"); old code generations could never be collected
  • Call, task and root contexts were linked to long-lived signals with AbortSignal.any; Node keeps such dependants reachable while they have abort listeners, so a listener on ctx.signal kept its closure (and its code generation) alive for the object's lifetime. Contexts now detach when they close
  • IPC links and the host manager's control channel wrote to a peer process that had exited without a callback, so the EPIPE Node reports for a write in the gap between the exit and disconnect (an ack sent just after a worker shut down) became an unhandled error event that crashed the process. The link now ends on a failed write (reason ipc send failed: EPIPE), and control messages to a gone peer are dropped
  • A relayed call ran under the origin's cancel signal only, so a forced retirement of the relaying host could not cancel it before closing the old code's resources; it now follows its root's signal
  • A resource kept across a reload closed with the close of the factory run that first opened it, and kept that run's code reachable; it now adopts the newest declaration at commit
  • forkDriver passed its own CHORD2_LOCK to forked children, so a child host that forked its own children made those without a lock of their own fail with locked
  • A call through a remote reference accepted a ctx from another host, or an object not made by Chord, and carried its root over the link; it now rejects it with invalid/foreign_ctx like a local reference (#10959)

[1.2.0] - 2026-10-12

Breaking Changes

  • Replaced the facet and service runtime with a typed host runtime: contracts (local, remote, method, opaque) are runtime values described with TypeBox schemas, objects are served by hosts (createHost, defineFacet, defineRegistry) and reached through handles, and hosts exchange JSON frames over memory, Node IPC, or WebSocket links (memoryLinkPair, ipcLink, webSocketLink, acceptWebSockets), with hot reload of plugins. See docs/tutorial.md and docs/guide.md
  • Removed defineService, createFacetHost, createRemoteServiceBinding, createStaticFacetLoader, combineFacetLoaders, the remote-service endpoint, provider, error, and wire helpers, and their types. The old defineFacet, Facet, and FacetLoader are replaced by the new runtime's names of the same spelling
  • Removed the ./bundler export and the facet bundle loaders of ./node; ./node now exports the child host manager (createHostManager, forkDriver, childControl, processLock) and the facet module loader nodeLoader
  • Removed @earendil-works/pi-client, @earendil-works/pi-protocol, and @earendil-works/pi-server, which served only the old runtime; they are no longer released

Changed

  • replicatedState, copyJson, isJsonValue, Context, JsonValue, JsonRepresentation, Draft, and the replicated state types keep their names on the root export; ./context and ./delta are unchanged, and ./context also exports the Context and ContextKey types
  • Depends on typebox instead of esbuild

Fixed

  • Fixed appending to an array costing a walk over the whole array: the delta tracker now plans inserts and removals that keep the remaining elements in order from the changed ranges alone, and applyImmutable builds an appended array in one copy instead of two. 32,000 one-item appends, one commit each, take 1.4 s instead of 11.3 s