mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
174 lines
12 KiB
Markdown
174 lines
12 KiB
Markdown
# Slice 2: failures and natives (spec, for the owner's approval)
|
|
|
|
Status: **approved (owner, 2026-10-22)**, after Astra's and Fable's spec reviews. The single source for building slice 2, together with the feature map in `slices.md`
|
|
(entries assigned to slice 2), `design.md` read in full (most relevant: §3 timeouts, §6 lifecycle and failures, §7
|
|
references, §13 natives, §21 start barrier), the decision log, the carry-over (L4 section) and the root `AGENTS.md`
|
|
style section. Builds on slice 1 (`src/state`, `documents`, `host`, committed `d8f5e67e6`) and L1.
|
|
|
|
## What an app can do after this slice
|
|
|
|
```ts
|
|
host.serve(Inbox, (_, self) => {
|
|
const settings = self.get(Settings, {});
|
|
const watcher = self.open(
|
|
async (context) => watchDir(settings.dir.value.path, context.abortSignal), // opened during activation
|
|
async (w, reason) => w.close(), // closed in reverse when the object ends
|
|
);
|
|
return { count: …, rescan: async (ctx) => watcher.value.rescan(ctx) };
|
|
});
|
|
|
|
host.serve(Remote, (_, self) => {
|
|
self.open(async (context) => {
|
|
const config = readConfig();
|
|
if (!config) throw broken(new Error("no remote configured")); // retrying is pointless: broken
|
|
return connect(config, context.abortSignal); // a throw here is transient: retried with backoff
|
|
}, (c) => c.close());
|
|
return { … };
|
|
});
|
|
|
|
const host = createHost({ onError, closeTimeout: 30_000, readyTimeout: 60_000 });
|
|
await host.load(facets, ctx); // resolves once every host-lifetime object is live or failed; failures reported
|
|
const inbox = await host.get(Inbox, {}, ctx); // waits through transient retries up to readyTimeout
|
|
await host.close(ctx); // closes natives (reason "stop"); rejects with every close error
|
|
```
|
|
|
|
## 1. Natives (§13)
|
|
|
|
- `self.open(open, close, options?)` in a factory returns a handle (`Native<T>`). Its `value` is readable from the
|
|
moment its own `open` resolved until its `close` starts (so a later opener and handlers during the drain can use
|
|
it); before that it throws `not_ready`, after that `released`.
|
|
- `open(context)` receives a context whose abort signal fires when the attempt is abandoned (the object ends, the
|
|
attempt times out). `close(value, reason, context)` with `reason: { kind: "end" } | { kind: "stop" } | { kind:
|
|
"reload" }` (`end`: last reference gone or a failed attempt; `stop`: `host.close`; `reload` is used from slice 7);
|
|
the close context's signal fires at the native's `closeTimeout`, so a slow close can stop its own work. `options.closeTimeout`
|
|
overrides the host default for this native.
|
|
- **Activation order:** factory; singleton documents hydrated; references live; natives opened **one after another in
|
|
declaration order**; members checked; derives computed; live. An opener may read documents, references and earlier
|
|
natives, not derives (they are computed after the opens).
|
|
- **Closing:** in reverse order. Each close is awaited up to its `closeTimeout`; one that takes longer is reported to
|
|
`onError` as a possible leak and the others continue. A throwing close does not stop the others: `host.close` collects
|
|
every close error and rejects with them (`AggregateError`); an object ending in the background reports each to
|
|
`onError`.
|
|
- **Abandoned opens:** a failed or abandoned attempt closes the natives already open (reason `end`). An `open` that
|
|
resolves after its attempt was abandoned has its value closed at once, exactly once (reason `end`); an abandoned
|
|
attempt never comes back to life. A later attempt opens every native anew.
|
|
- Facets cannot open natives (`FacetSelf` has no `open`). The two restart patterns of R16 (drain in the background,
|
|
swap through a registry) are documentation in the host README; there is nothing to build.
|
|
|
|
## 2. Failures (§6)
|
|
|
|
- Every activation failure becomes a `FailureCause` (L1: `{ kind: "transient" | "broken"; error: ChordError }`,
|
|
foreign errors wrapped by `asChordError`). The kind comes from where it failed, never from the error:
|
|
|
|
| Where | Kind |
|
|
| --- | --- |
|
|
| factory throws; a singleton's `initial()` fails its schema; member check (incl. a native watchable as state, Q14); first derive compute; a dependency cycle (Q5) | broken |
|
|
| an `open` throws | transient, unless it threw `broken(error)` (L1) |
|
|
| the attempt did not become live within `readyTimeout` | transient: `unavailable` "did not become live within …" |
|
|
| a dependency failed (R18) | transient: `unavailable` "A: dependency B failed", with B's error as its `cause` |
|
|
|
|
A keyed document's first `initial()` runs inside a later transaction; a failure there fails that transaction, not the
|
|
object.
|
|
- **Every attempt is bounded** by the host's `readyTimeout`: when it passes, the attempt is abandoned (openers'
|
|
contexts aborted, open natives closed `end`, a late open closed at once) and fails transient. A hanging `open` can
|
|
therefore never block an object or the start barrier.
|
|
- **What a failed object keeps:** only an R18 failure keeps the object's references (so the dependency's recovery can
|
|
retry it); every other failure releases them at once (a cycle, a factory, an open, a derive). A failed object stays
|
|
while anything references it (host-lifetime objects always); it ends with its last reference.
|
|
- **Retries.** One attempt per object at a time; a trigger arriving during an attempt is ignored. Transient: retried
|
|
with a **backoff per identity**, 1 s doubling to 60 s. The backoff lives in a host map `identity → { delay, timer }`
|
|
holding no object (the window is its timer); it is deleted on success, on a broken failure, or when a window has
|
|
passed with no object left to retry, and cleared by `host.close`; while its object stays failed the entry keeps the
|
|
delay for the next doubling. A get inside the
|
|
window waits until `until` before the first attempt; a failed object without references is not retried
|
|
(host-lifetime objects always are). Broken: no automatic retry and no entry; a new object after its end tries at
|
|
once. In this slice a broken host-lifetime object stays failed until the host is recreated (reload, slice 7, and
|
|
`retryOn`, slice 8, add triggers).
|
|
- **Dependency recovery (R18):** when a failed dependency goes live, its R18 dependents are retried at once (their
|
|
backoff timers cancelled). On every retry the new attempt binds its `self.get` references **before** the previous
|
|
attempt's are released, so a dependency held only by this object never ends between attempts.
|
|
- **Reporting:** every failed attempt is reported to `onError` (with the object's name and cause); gets waiting for it
|
|
are rejected in addition (section 3).
|
|
- **The start barrier (§21):** `load` resolves when every host-lifetime object's **first** attempt has an outcome (live
|
|
or failed); later retries never hold `load`. A facet setup that throws or a facet document's `initial()` failing its
|
|
schema still rejects `load` (code errors).
|
|
|
|
## 3. Getting a failing object (§7)
|
|
|
|
- `host.get(Type, params, context, { readyTimeout? })` resolves with a live reference. Each getter has its own deadline
|
|
(`readyTimeout`, host default, overridable per get) for its whole wait, including a wait for `load`; it is separate
|
|
from the attempt's own bound. A broken cause rejects the getter at once with its error; a transient one keeps it
|
|
waiting through retries until the object is live or its deadline passes, then rejects with the last cause's error
|
|
(or `unavailable` if no attempt has failed yet). A getter whose deadline passes or whose context is cancelled
|
|
rejects and releases only its own reference; it never cancels the attempt for other getters.
|
|
|
|
## 4. Ending with natives (§3, §6)
|
|
|
|
- When an object ends: its transactions are sealed at once (`self.tx` throws `released`); callers of its running calls
|
|
are rejected `released` at once and the handlers' contexts aborted (slice 1); Chord keeps each running **handler's**
|
|
own promise and waits for those to settle (the drain), bounded by `closeTimeout`; then it closes its natives in
|
|
reverse order (a handler still running after `closeTimeout` sees its natives close; that is reported); its documents
|
|
stay readable until its closes are done, then are dropped; then the rest as in slice 1.
|
|
- A new attempt or a new object for the same identity starts only after the previous instance's ending has finished;
|
|
gets arriving meanwhile wait.
|
|
- `host.close(context)`: marks every object ended at once (every call on the host fails `released`, every `self.tx`
|
|
throws), aborts pending opens (their late values are closed), joins endings already running in the background, then
|
|
drains and closes object by object in reverse live order (a dependent before its dependencies), with reason `stop`;
|
|
resolves when all are done, or rejects with the collected close errors.
|
|
|
|
## 5. Timeouts (§3, cut C13)
|
|
|
|
Host options with per-thing overrides: `closeTimeout` (default 30 s; per native), `readyTimeout` (default 60 s; per
|
|
get; also bounds each attempt). The call drain uses `closeTimeout` (no separate setting; the design's per-type drain
|
|
override is not built). Park deadline (slice 3), routine handover and `finish` retry (slice 5) and rollout install
|
|
(slice 7) come with their slices.
|
|
|
|
## Cost per hop
|
|
|
|
Commits and derives are unchanged. A call adds one set entry for its handler's promise (the drain needs it).
|
|
Activation adds one awaited open per native and one attempt timer; a failure adds one backoff entry; ending adds the
|
|
drain and the closes.
|
|
|
|
## Carry-over taken by this slice
|
|
|
|
From `carry-over.md` (L4): `broken()`/`openFailure()` used for opens; a native watchable as state fails `broken`
|
|
naming the member; the drain on ending; cuts C5, C6, C7, C13 decided here.
|
|
|
|
## Tests
|
|
|
|
- Natives: open order and access to earlier handles; `not_ready` before live; close in reverse with reasons `end` and
|
|
`stop`; a slow close reported after `closeTimeout` and the others still closed; throwing closes collected by
|
|
`host.close` (AggregateError) and reported to `onError` for a background end; a partly failed attempt closes what it
|
|
opened; the opener's context aborted when the object ends while opening; facets have no `open`.
|
|
- Failures: each row of the table gives the right kind; the cause's error is a ChordError; transient retries follow
|
|
the backoff (fake timers) and reset on success; concurrent gets share one attempt; a broken failure is not retried;
|
|
a failed object ends with its last reference and its backoff entry expires; a get right after a failure waits for
|
|
the scheduled retry.
|
|
- R18: a dependent of a failing dependency fails transient with the dependency's cause, keeps the reference and is
|
|
retried at once when the dependency goes live; releasing the dependent releases the dependency.
|
|
- Start barrier: load resolves with a failed host-lifetime object, reports it to `onError`, and the object goes live
|
|
on a later retry; a throwing facet setup still rejects load.
|
|
- Gets: broken rejects at once; transient waits and succeeds on retry; transient past `readyTimeout` rejects with the
|
|
cause; per-get `readyTimeout`; a cancelled caller releases only its own reference.
|
|
- Ending: the drain waits for a running handler (its own promise, not the rejected caller's) before its native
|
|
closes, bounded by `closeTimeout`; documents readable during closes; transactions sealed at once.
|
|
- Races: an open resolving after its attempt was abandoned is closed once; a get arriving while the previous instance
|
|
closes waits and gets a fresh attempt; simultaneous retry triggers give one attempt; two getters with different
|
|
deadlines; a dependency held only by a retrying dependent is not ended between attempts; `host.close` during a
|
|
pending open, during a background ending, and with several objects in dependency order; a cycle releases its
|
|
references.
|
|
- GC: a failed object (documents, dependencies, natives, callbacks) is collected after its last reference while its
|
|
backoff entry still exists; the entry expires on its own.
|
|
- Examples: `06-natives.ts`, `07-failures.ts` (numbering continues from slice 1).
|
|
|
|
## Decided with the owner (2026-10-22)
|
|
|
|
- `ref.state`, `ref.retry()` and `host.hold` move to slice 3 (no app-held reference can be failed before then).
|
|
- `host.get` on a failing object and the start barrier as in sections 2 and 3; C13 as in section 5; C5: no natives in
|
|
facets; openers take a `Context`; `close` gets a context aborting at `closeTimeout`.
|
|
- C7: `linger` is built in slice 3, with links. The plugin error shape is decided in slice 3, where errors first cross
|
|
a link.
|
|
|
|
## Size
|
|
|
|
About 700 source lines (both reviewers: the attempt states, backoff map, drain and timers).
|