mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Moves chord2 (contracts, objects, cells, roots, plugins, hot reload, memory/IPC/WebSocket links) from p2 into packages/chord, with its tests (now on vitest) and docs. The root export is the new runtime plus the shared base (Context, JsonValue, JsonRepresentation, Draft, copyJson, isJsonValue, replicatedState and its types). ./node now holds the host manager, child control, process locks, and nodeLoader. ./context and ./delta are unchanged; ./bundler is removed. replicatedState stays a standalone module with the same exports. It no longer exposes the exact-op subscription the removed service provider used, so listener failures of mutable states are always reported asynchronously. The package depends on typebox instead of esbuild. The wire format stays JSON.
8.7 KiB
8.7 KiB
Chord requests (from p2)
p2 is the first application built on Chord. These are the bugs, gaps and missing features found while building it. Each has the workaround p2 uses meanwhile.
#1-#7 are fixed in Chord (see notes.md, "p2 requests"); p2 dropped their workarounds.
Fixes live in src; additional resolved requests are marked below.
1. Handle state members cannot be returned as behavior state members (typing)
- Problem: the guide shows
return { kind: "viewer", view: c.view }(a held object's state member exposed as this object's state). The handle member is typedReadable<T | undefined>, the behavior memberReadable<T>, so this does not type-check. - Workaround:
self.derive({ view: derivation([c.view], (v) => v ?? null) }). Costs one more diff per change. - Request: accept handle readables in
BehaviorOf(they areundefinedonly while loading, and the record is live before its behavior is used), or a helper that narrows them.
2. Facets have no host access outside serve
- Problem:
FacetApionly hasserve. Code that is not a Chord method, listener or task needs a host to start a root or read a registry: Durable tools and hooks (rename_sessioncalls the server), a method reading another registry (HarnessConfiginChat.configure). - Workaround: the worker entry stores its host in a runtime module (
workerHost()insrc/runtime/worker.ts). - Request:
FacetApi.root(fn)andFacetApi.registry(r)(orself.root/self.registryon records), so plugins do not reach for a process-global host.
3. A listener's select must return the same object for the same value
- Problem: at reload,
stageListenerskeeps a subscription only whenselect(value)returns the identical subscribable. Aselectthat wraps the value in a new adapter object (natural for Durable watches) resubscribes at every reload. For a source that accepts one subscriber (a DurableWatchHandle.start), the reload aborts:reload: Watch is already started. - Workaround:
watchSource()andsummaries()memoize their adapter per underlying value (WeakMap). - Request: document it in the guide (
listener(source, select, fn)), or compare(resource, select)instead of the selected object (a changedselectfunction would still resubscribe).
4. cell.replace publishes the whole value
- Problem: mirroring an external immutable state (a Durable conversation view, revised on every streamed token)
into a cell with
replacesends a fullrop to every remote subscriber per revision.changeonly diffs what a draft mutates; there is no way to hand over a new immutable revision and get a diff. - Workaround: the cell holds the revision; a derivation
(v) => vexposes it, andDerived.installdiffs revisions withdiffRevisions(cheap, unchanged subtrees are shared). - Request:
cell.replace(ctx, value, { diff: true })(or diff by default when the old and new values share structure).
5. Roots of a child behind the server get superseded
- Problem: a worker spawned after a cluster reload starts on epoch 1 and is reconciled to the server's epoch. Its
eager objects run meanwhile; a listener root of epoch 1 calling the server gets
superseded: epoch 1 is not retained by server. The event is lost unless the caller retries. Notes item 17 lists the general case as open. - Workaround:
retrying(host, ctx, fn)(src/runtime/retry.ts) retries retryable errors in fresh roots; the status reporter re-reads the latest task graph on each attempt. - Request: hold upstream calls of a fenced child until it is reconciled, or have the parent admit such roots in its current epoch (the child had no old roots there).
6. Exposing a native object opened by a resource
- Problem: local members must exist when the factory returns, but a resource's value only exists after activation.
An
opaquemember therefore cannot hand out, say, the Durable Harness opened by a resource. - Workaround: methods returning it (
harness.harness(null)), which makes every use asynchronous. - Request: an
opaquemember backed by a resource (self.expose(res.durable)), readable once the record is live.
7. Cluster reload report does not include children
- Problem:
host.reload([], { remote })on the server returns the server'sReloadReport;changedandpreparedare empty when only worker facets changed, so a caller cannot tell what the workers did. - Request: per-child reports in the coordinator's result.
8. An unsubscribed handle member keeps a stale value
- Problem: a remote handle's state member subscribes upstream only while someone listens. After the last listener
leaves,
valuekeeps the last received value, and the nextsubscribefirst delivers that stale value synchronously, before the fresh snapshot arrives as a reset. Example (p2's sharing test):until(drafts.drafts, (v) => v.length > 0)resolved at once with a draft that had been claimed after the previousuntilunsubscribed;poll(() => sharing.guests.value?.length === 0)never saw the guests leave. - Workaround: tests keep a subscription open while they wait for changes (
until, or an explicitsubscribe); React'suseReadablesubscribes for the component's lifetime. - Request: reset
valuetoundefinedwhen the upstream subscription ends (a new subscriber then seesundefined, then the snapshot), or document it in the guide (State, Handles).
9. No public status event on handles
- Problem: a UI holding a handle must notice
gone(revocation, worker stopped) togetagain.onShapefires on live/variant/reload, not ongone;goneListenersis internal. - Workaround:
useObject(src/runtime/web.tsx) checkshandle.statusevery second afterready. - Request:
handle.onStatus(listener)(oronGone).
10. Host shutdown disposes held objects before their holders
- Problem: when a worker host closes, a
Chatrecord's resourceclosereadundefinedfrom theopaquemember of its heldWorkerProcesshandle (TypeError: Cannot read properties of undefined (reading 'add')). The guide says an object's own holds are released after its resources close, so a dependency should still be live there. - Workaround: the resource captures what it needs at
openand uses that inclose(p2's chat worker). - Request: at host close, dispose holders before what they hold (reverse dependency order), or document that
handles may be gone in
closeduring shutdown.
11. Loading facets does not await eager activation failures
- Problem:
await host.load([facet])can resolve even when an eager factory fails immediately (for example,self.get(ModelConfig)with no models plugin). The failure only reachesonError; startup/tests can report ready while a required object is already unavailable. - Workaround: p2's loader and
testHostcollectserve(..., { eager })declarations and explicitly await their handles'ready. The worker opens its Harness, announces control ready (the parent then accepts its IPC link), and only then awaits upstream eager holds, counting this bootstrap as an active call. Waiting for a status reporter before announcing ready deadlocks: reporter waits for server SessionStatus, server waits for worker ready before accepting that link. - Request: an awaited activation-ready API for loaded eager objects, with startup errors propagated, and a documented separation between local bootstrap readiness and upstream admission. Keep ordinary lazy objects lazy.
12. An eager object's bootstrap hold pins its old contract (resolved)
- Problem: chat-mode changed its store from one boolean to per-conversation state, with a contract-version bump
and state migration. When the store itself was eager, its host-owned hold still required version 1, so reload
failed with
contract_mismatcheven after every real consumer was updated. - Historical workaround: a separate eager router held a non-eager store. This separation is no longer required for contract upgrades (it may still be useful organization).
- Resolved:
src/reload.tsexcludes only the exact eager bootstrap hold from old-consumer compatibility checks, then replaces it with a hold on the new provider contract at commit. The replacement attaches before the old hold releases: the record, migrated cells, unchanged resources and registrations survive. Abort leaves the old bootstrap hold untouched. Other consumers keep their own tokens and still need coverage/acceptsor replacement in the same batch; forgotten version bumps and missing/invalid state migrations remain refused. - Tests:
test/eager-reload.test.tscovers configured input, consecutive contract bumps, migration/rollback, resource lifetime, external and record-owned consumers, adapters, forgotten bumps and cluster vote failure.