pi-mono/packages/chord/docs/slice-10.md
Mario Zechner ed4f488401 feat(chord): remove Chord v1 (/old, /old/node), its tests and docs
p2 runs on Chord v2; apps still on v1 pin @earendil-works/chord@1.5.0.
2026-11-03 12:11:22 +00:00

24 KiB
Raw Blame History

Slice 10: HTTP and workers (spec)

Status: built (2026-10-31; build reviews by Fable, Opus, Astra and Sol, security audits by Fable, Opus and Astra, four fix rounds with rechecks; the shape after the fix rounds is in §2, §3 and design §23). Approved by the owner (2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the decision "Slice 10's points"; spec reviews round 1 by Fable and Opus, /tmp/s10-spec-review-{fable,opus}.md, round 2 by Fable, Opus, Astra and Sol, /tmp/s10-spec-review2-*.md, folded in). Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): chord/http (the server's HTTP and WebSocket entry, routes contributed by plugins through a registry, responses as descriptions) and chord/workers (a worker process per host address, started on demand, stopped when idle, its plugins shipped from the server). Both live in packages/chord/src/node/ (Node-only); the kernel stays portable. The web page host and the TUI are slice 11, telemetry slice 9.

Read in full before writing code: this spec; decisions.md; design.md (§3 hosts, routes, host.activity, §17, §18, §21 "Loading plugins and the reload loop", §23 in full); slices.md; slice-7c.md and src/node/plugins/README.md; the 7b brief slice-7b/book.md (chapters "Assets and Files", "HTTP", "A Mini p2 from Chord"); p2's src/hosts/server.ts, src/runtime/server.ts, src/hosts/worker.ts, src/hosts/socket-limits.ts, src/hosts/principals.ts, plugins/files/server.ts, plugins/terminals/server.ts, plugins/radius/server.ts; Chord v1's src/manager.ts (processLock, forkDriver, createHostManager, childControl; v1 has since been deleted).

1. What p2 has, and what moves into Chord

p2's server host (src/hosts/server.ts, 742 lines; src/runtime/server.ts, 720) does, besides p2's own policy:

p2 today Moves to
a Node http server on loopback, requests parked until load resolved, upgrades captured before listening http
Routes ("METHOD /path", /prefix/*), handlers writing Node's ServerResponse themselves (ranges, download headers, HEAD by hand in files) http: routes returning descriptions
Upgrades (a WebSocket by path for terminals and voice), /chord (acceptWebSockets with per-socket limits) http
Authenticators, RequestResolvers (cookies, devices, the Radius relay), Fetch Metadata, CSP and the loopback cookie the app (p3's base code), through http's authenticate and headers (D3)
loopback and allowed hosts, the Origin check on upgrades http (R4), allowed hosts contributable by plugins
SessionHosts: a worker per session, createHostManager + forkDriver, a kernel lock per session, idle stop from reported counts, crash notices, sticky failed starts, ready timeout workers (failures stay until Retry or a rollout, R2)
relocation and pause of a worker, the catalog, WorkerInitValues the app (built on workers' stop and start data)
hosts/worker.ts: the child's control channel, lock, start data, connect, load, report counts, shutdown workers' child side
the dev reload across server and workers, worker plugin lists plugins (7c) plus workers' rollout step (§4)

Every option that is not bootstrap is changeable by plugins (decision "The app kit is malleable by plugins"): either a contribution to Http or a function the component calls per use, which the app backs with its own registries; nothing is copied out of a contribution or an option at startup. Bootstrap (changed by a restart): the listen address and port, the links path, the worker entry with its execArgv and env, the host kind workers serve.

2. chord/http

As built after the fix rounds (decisions "The HTTP core is a fetch handler", "The HTTP server is a native object", "Settings are arguments, extensions are registries", "The HTTP component's security baseline", "allowed hosts only through contributions", "A CSP nonce per response", "three small points", "a route's overhead accepted"); design §23 states every rule, src/node/http/README.md the API.

const http = createHttp(server, {
	listen: { host: "127.0.0.1", port: 8788 },                        // settings: arguments, a restart to change
	links: { path: "/chord", options: (request, principal) => ({ limits: linkLimits(principal), maxPayload: 64 * MiB }) },
	closeTimeout: 30_000,
	onError: (error) => log(error),                                   // `HttpError`, below
}, ctx);
const app = defineFacet((_host, self) => {
	self.contribute(Authenticators, (request, ctx) => principalFromCookie(readCookie(request, "session", secret)));
	self.contribute(Http, { hosts: ["notes.example.com"], relays: [radiusRelay] });
});
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, { name: "@app/core", facet: app }], modules }, ctx);
…
await http.close(ctx);   // first: no new requests, sockets closed, running requests waited for up to `closeTimeout`
await workers.close(ctx);
await server.close(ctx);
  • The core is http.fetch(request, { localAddress?, localPort?, remoteAddress? }), a Web Request to a Web Response; http.upgrade is Node's upgrade adapter; nodeRequest(message, res) converts a Node request with the listener's checks ({ kind: "request", request, connection } | { kind: "refused", status }, never a throw) for apps mounting on their own Node server. Chord's own listener (with listen) is a host-lifetime object of chord.http whose native is a Node server, on a fast path that skips the Web Request unless a route or authenticator reads it.
  • Registries: Http ("chord.http", version 1: routes, wraps, sockets, maxPayload, hosts, framers, relays, csp; a value or a writable) and Authenticators (in contributor order, the first principal wins). Requests before the publication wait for it (503 past readyTimeout).
  • Admission, foreign requests, origins, default headers, cookies, downloads: the security baseline (design §23). Refusals before any plugin code: a non-loopback socket without an allowed host, forwarded headers without a verified relay, duplicate or missing Host, userinfo in Host, absolute-form targets (403), unparsable targets (400), TRACE/TRACK/CONNECT (501; a route for them refused at compose).
  • A request's scope, HttpRequest, patterns, wraps, responses, fileResponse, sockets, links, revocation, close: design §23. The spec's earlier text (an httpServer that listens first, allowedHosts, one authenticate and a headers option, 64 KiB reads) is superseded by those decisions.
  • Errors (onError, HttpError): route_failed { pattern, plugin, error }, wrap_failed { name, plugin, error }, socket_failed { pattern, plugin, error }, response_failed { pattern, error }, hook_failed { hook, plugin?, error } (authenticate, relay, links.options).
  • ws is an optional peer dependency (D7), imported only here.

3. chord/workers

// server.ts
const workers = createWorkers(server, Session, {
	kind: "worker",                                                                   // the facet kind it ships
	entry: "./worker.ts", execArgv: ["--import", resolver], env: { … },              // bootstrap
	authorize: (principal, address) => app.mayReach(principal, address),              // the route's `authorize` (synchronous)
	principal: (address) => ({ kind: "worker", session: address.params.id }),
	start: (address, ctx) => app.startData(address, ctx),                             // JSON, before each start
	lock: (address) => `app:${realpath(dataDir)}:worker:${address.key}`,              // one live worker per address
	idleMs: () => app.idleMs(),
	plugins,                                                                          // the plugins component
	onOutput: (address, stream, chunk) => logs.append(address.key, stream, chunk),
	onError: (error) => log(error),                                                   // `WorkersError`, below
}, ctx);
workers.state                              // JSON watchable (R5)
await workers.stop(Session({ id }), ctx);  // archive: suspended while held (Q3), then stopped
await workers.retry(Session({ id }), ctx); // the Retry button: a failed or suspended address resolves again
await workers.close(ctx);                  // no new starts, every child stopped
// worker.ts
await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
	const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(join(start.dir, "session.sqlite"))), ctx);
	onClose((ctx) => store.close(ctx));                                               // closers run in reverse order
	const worker = createHost<P>({ address, principal: { kind: "worker", session: start.id }, store, onError });
	onClose((ctx) => worker.close(ctx));
	worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx);   // `started` goes out now: connect
	                                                                                  // before anything slow (a store opens in ms)
	await plugins.load(worker, { kind: "worker", core: [{ name: "@app/core", facet: coreWorker(start) }] }, ctx);
	return { busy: harness.activeTasks };                                             // extra keep-alive counts
}, {
	hostPackages: { "@earendil-works/chord": chord, "@app/core": appCore },  // the child's own instances
	redact: (error) => ({ message: redactSecrets(String(error)), ...codeOf(error) }), // what crosses as `fatal`
	onError: (error) => log(error),
});
  • The route: createWorkers calls host.route(Kind, { authorize, resolve }, ctx) itself and keeps the RouteHandle (retry, suspend: D10b, R1). authorize is synchronous (the kernel's): a plugin backing it keeps an index.
  • Per address, events and operations: a child's events (its exit, its link's close, started, ready, fatal) are observed as they happen and recorded at once on that child (a late event of an old child never changes a new one); the operations (start, stop) run in one queue per address: a start waits for the previous child's exit, a stop during a start waits for it then stops, and a start queued behind an exit checks the address's state after it (a crash recorded meanwhile makes it reject with that failure). Stops record their intent before shutdown, so the exit they cause is no crash.
  • A start (resolve(address)): calls start(address, ctx) (abortable; a throw rejects the start), checks that the component is not closing, forks entry (serialization: "advanced", a detached process group, stdout and stderr piped: the stderr tail kept, mirrored to the server's when there is no onOutput), makes ipcLink(child) at once (so the child's hello and early holds are queued, never lost), and sends the start message { address, start, scripts, lock } (scripts: the worker kind's complete current set, read when the message is sent). The child takes the lock (when an old worker still holds it, it waits by connecting to the lock's socket and trying again when that connection closes: no polling), and sends started when the app's host sets its listener on parent (parent is a thin wrapper of the IPC link, so connect is the signal: the app connects before anything slow). resolve then returns { link, principal: principal(address), trusted: true }, and the handshake and the worker's server traffic run during its load. The start timer (60 s from the fork) covers fork, lock and connect only (Q4): past it the tree is killed and the start fails. ready (after the app's function returned, carrying the idle state and its load report) sets state to running and lets idle stops begin; a load that never ends shows starting until stop.
  • Workers never restart by themselves (R2): a crash (an exit nobody asked for), a fatal, a link lost while its child lives, or a failed start (start threw, the start timer, a lock not taken in time) leaves the address failed with its error (worker_failed reported once). A link lost while its child lives (the heartbeat after a blocked event loop, a protocol error) starts the stop: the grace (5 s), then the tree is killed; its exit is the crash ("link lost"). resolve of a failed address rejects at once with that error; its holders and new holds see failed { waiting } with it as cause (Q1) and stay on the address's path, nothing forks. workers.retry(address) (RouteHandle.retry) or a rollout reaching workers clears it (Q2, §4). An idle stop is no failure: the next hold starts the worker on demand.
  • Keep-alive (D9, R3, Q5): the child's idle state is all of host.activity's references, jobs and lingering and the app's busy (a JSON watchable of numbers) at zero, not calls (a remote call runs on a hold; local calls come from routines, jobs and tasks counted elsewhere). ready { idle } carries the first state, then { idle } on each flip; a rollout applying in the child counts as busy. The server stops a worker idle since idleMs() (5 min by default; checked every second), never before ready. A stop (idle, stop, close): shutdown; the child aborts its ctx, runs the closers registered with onClose in reverse order and exits, forcing its own exit after 3 s; the server kills the tree after 5 s.
  • state (R5, decision "worker errors in public"): per address key { status: "starting" | "running" | "stopping", pid, startedAt } or { status: "failed", error: { message, code?, reason? }, at } with a public error (a fixed message, or the child's redacted fatal); no entry when stopped or never started. The full error and the stderr tail go only to onError (worker_failed); every app hook of a start (start, principal, lock, the fork) is inside that boundary.
  • stop(address, ctx) (R1, Q3): RouteHandle.suspend(address), then the stop. The suspension lasts while the address has holders (they see failed { waiting }, "suspended"); with the last holder it goes, and a later hold starts the worker on demand; retry(address) lifts it at once. Whether an archived session may be opened is the app's (authorize, its views).
  • close(ctx): cancels active and queued starts first (a start(address) waiting on the app's barrier is aborted; nothing forks after it), then stops every child (grace, then kill), clears the timers; new starts reject released/closed; the caller's cancellation ends only its own wait. createWorkers's ctx aborting (the host closing aborts the route's context) closes it too, and from then on every exit is a stop, never a crash (no worker_failed at shutdown).
  • The child (workerMain(fn, { hostPackages, redact, onError })): reads the start message, takes the lock, makes ipcLink(process) and its wrapper parent, creates its plugin runtime with hostPackages (the child's own module instances; functions cannot cross IPC), calls fn({ address, start, parent, plugins, onClose }, ctx). shutdown, the parent's disconnect, SIGTERM and SIGINT alike abort ctx and run the closers registered so far, also while fn still runs (its startup is cancelled through ctx). An uncaught error, or fn throwing, sends fatal with redact(error) (never the original message) and exits; after started that is a crash like any other.
  • Control messages (JSON beside the link's Uint8Array frames; the IPC link skips them): parent to child start, shutdown, reload { scripts }; child to parent started, ready { idle }, idle { idle }, fatal { error }, reloaded { report } | { error }. One reload is outstanding per child at a time: a reload whose caller aborted keeps the child's slot until its reloaded or the child's exit, and the next waits for it. Errors cross as { message, code?, reason? }.
  • Errors (onError, WorkersError, which includes BatchError for a state watch listener that throws): worker_failed { address, error, stderrTail? } (private), authorize_failed { address, error } (a throwing authorize: the hold is refused forbidden), worker_exited { address, code, signal } (only for an exit nobody asked for: not after a stop or a start the component failed).

Plugins in workers (D11)

Workers never build. The plugins component (7c) gets scripts(kind, ctx): the complete current set of one kind (every discovered plugin's facet of it, built on first use, then updated by every rollout that ships that kind), each with its plugin record (name, dir, chord.external). The component's per-host loading (its loaded map, #prepare, adopt, the queue per host) moves into a file without esbuild or the watcher, createPluginRuntime() with load(host, { kind, core, scripts, modules }, ctx) (modules: the host packages' module objects of this process) and reload(host, scripts, ctx) (a complete set: removed = loaded − shipped; same digests unchanged); createPlugins uses it, and the child's plugins is a WorkerPlugins over one (load(host, { kind, core }) loads the start message's scripts; reload messages wait until fn returned); import of chord/plugins/runtime loads no esbuild. Each kind is defined once in createPlugins({ kinds: { <kind>: { platform, hostPackages } } }) (decision "Slice 11's points", Kinds).

4. The rollout across server and workers

const built = await plugins.build(plugins.state.value.dirty, ctx);
const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, ctx);
// result.steps: [{ name: "server", hosts: [{ host: "server", report }] },
//                { name: "workers", hosts: [{ host: "session|S1", report }, { host: "session|S2", stopped: true },
//                                           { host: "session|S3", error }] }]

RolloutStep.reload(built, ctx) returns { hosts: readonly ({ host; report } | { host; stopped: true } | { host; error })[] } (D12; 7c's in-process step returns a list of one, its host the name given to step). workers.step(name):

  1. replaces the workers' current set (plugins.scripts(kind) updated from built);
  2. clears the failures recorded before this rollout and calls RouteHandle.retry for those addresses that have holders (Q2): they start with the new set and are listed; an unheld one starts at its next hold;
  3. ships the complete set to every running or starting worker whose start message or last reload carried an older one (a child queues a reload that arrives before its ready until its load is done); workers reload in parallel.

A worker's entry settles on its reloaded or on its exit or link loss, whichever comes first. A worker stopped cleanly during the step (idle, stop) is listed stopped and dirties nothing (its next start gets the current set); one that crashed or failed to apply is listed with its error and does not stop the rollout, and every plugin of its kind in the build counts as failed there (stays dirty); a worker failing during this rollout stays failed. A plugin's dirty mark clears only when it applied on every host of every step, and a kind built in the rollout that no step shipped keeps its plugins dirty.

5. Not in slice 10

The web page host, the browser's evaluation of scripts and serving them (slice 11); the TUI (slice 11); relocation and pause of workers (the app's: start(address) is where the app awaits its own barrier); request resolvers and authenticators as registries (the app's); chunked transfers as a Chord helper (an owner pattern, design §23); the channel component (D6); telemetry spans for requests (slice 9).

6. Kernel changes

  • host.route(Kind, options, ctx) returns a RouteHandle { retry(address), suspend(address) } (D10b, R1, Q3).
  • A route has no backoff timer (R2): a rejected resolve, or a link that closes before its handshake completed, fails the route's holders failed { waiting } with its error (they stay on the address's path, Q1) and resolves nothing more; the route resolves again only for a new hold, on retry, or once after a link that had completed its handshake dropped while holds exist; a suspended address resolves nothing while it has holders and forgets the suspension with the last one.
  • host.root(fn, ctx, { principal: undefined }) runs anonymous (ctx.principal undefined); only an omitted principal takes the host's (a public-API change of RootOptions: "principal" in options).
  • Origins only from relays (decision, 2026-10-31, security audit): origin is read only on parent and route links; on a child link it is a protocol error; a per-origin record goes once it has no holds, calls or messages in flight.
  • An anonymous host.root scope gets no remote object served on its host (forbidden).
  • Design §3: the paragraph on a resolve waiting for the worker's load is updated (done), and "Chord never closes a route link itself except at host.close" becomes "except at host.close, the heartbeat and protocol errors".

7. Tests, examples, cost

  • One test file per source file. HTTP: listening before the publication (a request waits, then is served; 503 past readyTimeout); the router resolved once; Host (loopback forms, allowed from the option and from a writable contribution changing at runtime) and Origin checks; anonymous requests (ctx.principal undefined on a server with a principal); authentication per request and upgrade; throwing hooks; patterns, decoding, precedence, HEAD, 404; conflicts reported; wraps (phases, order, scopes, a missing route, a throwing wrap); every response kind (ranges 206/416, multi-range, HEAD, download names, chunked without size, a short read, a disconnect stopping read, header merging); json/body limits and their statuses, an unread body; a throwing route; the links endpoint (host.accept, options per socket, a refused socket, the output cap); plugin sockets (an oversized frame closes only that socket; a reload removing the contribution closes its sockets 1012); a removed allowed host closing its connections; a reload swapping a route while a request runs on the old router; fileResponse closing its handle on every path; close (refusals, waiting, then sealing). Workers: a start on the first hold; the link made at the fork (a load longer than the heartbeat's 30 s binds); started on connect; the start timer covering only the start (a slow load is not killed); the lock (an old worker's lock waited for without polling); start throwing; a crash, a fatal, a link lost with a live child (killed after the grace), and a failed start each staying failed (no fork for a new hold; holders failed { waiting }), retry and a rollout clearing them and rebinding the holders; idle stop, a worker idle from its start, a hold keeping it; busy; 1,000 sequential local calls sending no message; stop with holders (suspended, then forgotten with the last holder); stop during start and start during stop; a crash while a resolve is queued; close (cancelling a start waiting in start(address), no worker_failed at shutdown); the rollout step (complete sets, a worker starting during it, a clean stop listed stopped, a crash during it, an aborted reload's late reply, a kind no step shipped staying dirty); the child's shutdown during its startup, disconnect and signals; redact (a secret in the original message reaches neither fatal nor state). Kernel: RouteHandle.retry and suspend; a link closing before its handshake (no re-resolve loop); no re-resolve without a hold, a retry or one drop of a healthy link; host.root with an explicit undefined principal.
  • GC: requests (their scopes, bodies, file handles) and stopped workers' state collected; no listener left on a closed socket; 1,000 addresses started and stopped leave no state entries, no per-address queue records and no suspensions.
  • Examples (each a folder, as 7c's): 40-http (routes, a wrap, a download with ranges, a socket, an allowed host added at runtime), 41-workers (a server routing a session kind to forked workers, idle stop, a crash staying failed until Retry, a rollout reaching the workers and restarting a held failed one).
  • Benchmark: a route's overhead per request against a plain Node handler (bar: within 10% of a keep-alive round trip; Opus measured 2.5 µs of 54 µs); bytes throughput for a 100 MiB file against pipeline; a worker's cold start (fork to started and to ready) with shipped scripts; memory per idle worker on the server.