fix(chord): admit a routed host even when the relay that spawned it is gone

A relayed hold released (or its principal refused or revoked) while the route resolved made #resolve throw before adding the connection, so the spawned host's link was never read and its own upstream references failed unavailable/ready_timeout after readyTimeout. The connection is now added right after resolve; the checks only fail the relay.
This commit is contained in:
Armin Ronacher 2026-11-01 14:47:36 +01:00
parent 0f21a3319d
commit c09c98d7d2
4 changed files with 31 additions and 5 deletions

View file

@ -37,6 +37,7 @@
### Fixed
- A host a route resolves is admitted even when the relayed reference that asked for it is released, its client disconnects, or its principal is refused or revoked while the route resolves. Before, the parent never read the new host's link: a spawned worker's own references to its parent (its `lifetime: "host"` objects' upstream references) waited until they failed `unavailable/ready_timeout`, reported as a possible reference cycle across hosts.
- The Node SQLite driver keeps the 256 most recently used prepared statements per connection instead of every statement it ever prepared, so callers generating SQL of unbounded shapes (a query builder) no longer grow memory without bound.
- Fixed append ops from the delta tracker and `diffRevisions()` keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit ([#9265](https://github.com/earendil-works/pi/issues/9265))

View file

@ -279,7 +279,7 @@ The tests cover at least: lifetime/references/linger/forced disposal/gone; activ
- **Two-version rule.** An object of a changed type created after a commit has only new code; a previous-code-version root calling it gets `superseded`.
- **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; use `opt(str({ default }))` (pi-schema) 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.
- **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. The host `resolve` returns is admitted before that second check, so a refused, revoked or released relay never leaves a spawned host's link unread. `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 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.

View file

@ -1136,7 +1136,7 @@ export class HostImpl implements Host, HostCore {
return best?.[1];
}
/** Authorize, resolve (maybe spawn), authorize again; returns the synced connection to the host. */
/** Authorize, resolve (maybe spawn) and admit the host, authorize again; returns the synced connection to the host. */
async #resolve(
route: Route,
hostName: string,
@ -1148,15 +1148,17 @@ export class HostImpl implements Host, HostCore {
if (!(await route.authorize(principal, hostName))) throw refuse();
if (!still()) throw revoked();
const link = await route.resolve(hostName);
if (!still()) throw revoked();
if (!(await route.authorize(principal, hostName))) throw refuse();
if (!still()) throw revoked();
// Admitted before the checks: a host the route spawned for a request that is gone or refused meanwhile still runs,
// and its own references to this host would otherwise wait on a link nobody reads until they time out.
let connection = [...this.connections].find((candidate) => candidate.link === link);
connection ??= this.#addConnection(link, {
role: "downstream",
principal: route.principal(hostName),
peer: { kind: "host", name: hostName },
});
if (!still()) throw revoked();
if (!(await route.authorize(principal, hostName))) throw refuse();
if (!still()) throw revoked();
await connection.whenSynced();
if (!still()) throw revoked();
return connection;

View file

@ -311,6 +311,29 @@ describe("links review: relay ownership", () => {
assert.ok(!(c.workers.get("session:S1")?.link.sent ?? []).some((frame) => frame.t === "hold"));
});
it("a release while the route resolves still admits the resolved worker: its references upstream go live", async () => {
const Feed = remote("t.feed", {
version: 1,
input: obj({}, { open: true }),
host: hostNamed("server"),
variants: { main: { methods: { ping: method(undefined, str()) } } },
});
const c = cluster();
c.server.serve(Feed, () => ({ variant: "main", ping: () => "pong" }));
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" } });
await sleep(5);
// The client goes away while the worker starts: the route still hands over the worker it spawned.
raw.send({ t: "release", id: 1 });
gate.open();
await until(() => c.workers.has("session:S1"));
const worker = c.workers.get("session:S1")!.host;
const feed = await worker.get(Feed, {}, { signal: AbortSignal.timeout(500) });
assert.equal(await feed.ping(), "pong");
});
it("#9 an origin disconnect removes its relayed holds and subscriptions downstream", async () => {
const c = cluster();
const browser = c.browser({ guest: "g" });