writeonce/docs/superpowers/specs/2026-08-20-shard-fiber-arc-design.md
shoney.arickathil c0b0dbb846 docs: audit all markdown against the code, fix findings, flatten status folders
- README: shipped concurrency/HTTP/WebSockets sat in the roadmap as "not yet
  available"; "no package manager" contradicted [deps]; the deps example
  would not have compiled (the key IS the module name)
- runtime/README: leads with wovm, wo-rt.c demoted to a historical section;
  dropped 2 nonexistent recipes, crates/rt, @gc refcounting, 13 suites -> 18
- employee + log-watcher READMEs claimed "does not compile"; both are gates
- error catalog: +10 emitted codes incl WO-E250, the only diagnostic the
  shipped query surface raises; recorded why the sweep rotted
- language-surface: group-by parses, then the typechecker refuses it
- 00-code-review + 00-link-audit re-run; history kept, not rewritten
- 48 dead Rust-era exploration links de-linked rather than re-pointed (their
  prose names the retired plan by number); successor map -> discarded.md
- 08-project-structure: compiler/plan/ never existed; corpus has 9 dirs, 5 empty
- releasing.md: dropped a --draft step the workflow never had
- new docs/00-doc-audit.md: findings + disposition, incl one row where the
  audit was wrong and the doc it accused was right
- status folders removed: 34 stories flat, status only in frontmatter; 252
  links recomputed from resolved paths; board/board-views/structure retaught
- story 24 -> in-progress, since frontmatter is now the only truth
- new iteration 38: fs mutation verbs + net.connect, the two capability
  families no iteration owned
- new iteration 39: gofiber/fiber v3.5.0 parity study. The ledger called
  CSRF/sessions unblocked by iteration 34's HMAC, but the runtime has no
  source of randomness at all
- linkcheck skips .dev/.superpowers: 0 broken paths, 0 bad anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:20:22 +02:00

13 KiB

The 8+11 concurrency arc — shards, fibers, actors: design

Status: spec, awaiting review (2026-08-20). The arc's decisions were settled in iteration 8 (refined) and the brainstorm of 2026-08-20 (this document's Decisions). Covers iterations 8 AND 11 as one arc; iteration 24 (chat) is its acceptance workload and gets its own spec after this one. The plan follows after review. Board: docs/00-status.md.

Per repo convention: concept, reason, and required behavior in words only — no implementation code.

Goal

The runtime scales past one core without ever showing a thread, a lock, or a colored function: pinned thread-per-core shards whose only communication is ownership-moving messages, cooperative fibers above them preempted by reduction budget, blocking builtins that park instead of block, and a database that stays single-writer by being runtime plumbing on its owner shard. One spawn/send surface; app code byte-identical at one shard or sixteen.

Decisions (settled; the spec builds on them)

From the 2026-08-20 refinement: the DB is an actor on an owner shard (never user-visible); 8+11 are one arc; one unified address surface; chat (19) is the driving workload; order 22 → arc → 19 → 23.

From the arc brainstorm (2026-08-20):

  1. All cores by default. WO_SHARDS / [runtime] shards = N override (N=1 is the debug/serial escape hatch); the arc's landing criterion is every standing gate green AT the default. Brave by choice: the gates become the stress test.
  2. Typed receive. An actor is any class with fn receive(msg: M) — structural, the Handler doctrine, no blocking-receive keyword, no new interface to implement. spawn Cls { ... } infers M from the class's receive signature and returns an actor M address (a parametric builtin type exactly like multi T/map<K, V>). send(addr, msg) compile-checks that msg is an M and MOVES it.
  3. Transparent DB RPC. insert/update/delete/query builtins executed on a non-owner shard marshal the statement to the owner shard and PARK the calling fiber until the reply; results come back materialized (they already do). App code never changes; the fourth iteration-8 criterion (byte-identical answers single- or multi-shard) is the proof.
  4. Fibers first, then shards. Stage 1 ships fibers on one shard (framework's fiber-per-connection lands early, no threads); stage 2 ships pinned threads + cross-shard send; stage 3 ships the DB RPC and re-runs 22. Each stage gates green independently.

Part A — the fiber runtime (stage 1, one shard)

The fiber context

A fiber IS the interpreter state wo_vm already isolates: the register window (WO_STACK_SLOTS), the frame stack (WO_MAX_FRAMES), the catch stack, the caught-error slot, and a resume point. Stage 1 extracts those fields into a fiber context; wo_vm keeps the module, the runtime (arena/GC/db — per SHARD, not per fiber), the current-fiber pointer, and a FIFO run queue. Cost, stated: a context is ~42 KiB at today's constants (32 KiB registers + frames + catches), so 1k fibers ≈ 42 MiB — acceptable v1, arena-allocated; segmented/growable windows are recorded future work, not v1.

Scheduling

  • FIFO run queue, no priorities (iteration 11's out-of-scope holds).
  • Preemption by reduction budget: the budget decrements at the EXISTING safepoint sites (NEW, CALL, backward-JMP — the same places 7b's GC polls, one counter check added, no new instrumentation); at zero the fiber re-queues and the next runs. Default budget 4000 reductions, WO_REDUCTIONS overrides. Deterministic: same program, same schedule.
  • main is fiber 0. When it returns, the program's exit value is main's; every other fiber — runnable or parked — is UNWOUND through the drop machinery (the SIGTERM/stop story, reused verbatim): ASan zero leaks is the criterion. A trap that unwinds out of a non-main fiber kills that fiber alone (its drops run, its error goes to stderr the uncaught-trap way); the program does not die.
  • GC: every fiber's registers and frames are roots — vm_gc_roots iterates all contexts, not just the live one. Parked and queued fibers pin their values exactly like the running one.

spawn / send (same-shard in stage 1)

  • spawn Cls { fields } — an expression; Cls must declare fn receive(msg: M) where M is a class, record, or union type (WO-E221 otherwise, naming what's missing); the ctor literal's fields are the actor's state, moved in (the take-push shape). Result type actor M.
  • send(addr, msg): msg must type as M (existing assign/param machinery) and be OWNED (the owner pass's transfer rules; a borrowed or copy-in-hand value is the existing WO-E3xx shape). After the send the sender's binding is dead — compile-time move, the iteration-8 criterion.
  • Delivery: the runtime calls the actee's receive with the message, one message at a time per actor, on the actor's home shard, as its fiber's next work item. An actor is a fiber that sleeps between messages; its mailbox is the FIFO the runtime owns.
  • Same-heap sends hand the pointer over — no copy, no serialization; the move made aliasing impossible at compile time.
  • actor M values are plain copyable words (an address), storable in fields/containers like Int — sending TO a dead actor is a silent drop of the message v1 (Erlang's shape; a delivery-receipt story is future work, recorded).

The I/O plane: io_uring-first (AMENDED 2026-08-20, developer directive)

The original wording made epoll the v1 loop with io_uring as a benchmark-gated swap. That framing is REVERSED by directive, and the linux reference project already pointed here: io_uring is the successor to epoll + libaio for BOTH the storage engine's WAL path and the socket path — one event loop.

  • One io_uring per shard thread is THE event loop, serving three op families as the arc progresses: (1) fiber parking — IORING_OP_POLL_ADD for parked net fds, IORING_OP_TIMEOUT for sleeps, the mailbox eventfd registered the same way (stage 2's wake); (2) WAL durability — 23's WRITE + FSYNC(DATASYNC) chains ride the SAME ring, user_data the commit correlation, the shard executing while the ring drains; (3) the data path — multishot accept/recv/send for iteration 24's connection fan-in, LAST, because it changes resume semantics.
  • The readiness→ops ladder, staged honestly: T4 uses the ring in READINESS mode (POLL_ADD/TIMEOUT — resume re-executes the now-ready builtin, the same retry contract epoll would have had, so the builtin layer barely changes). Submitting the reads/writes THEMSELVES as ring ops (completion carries the result, no retry) is the follow-up step — bigger builtin surgery, staged after T4 proves the ring loop.
  • Kernel floor and ops discipline: io_uring is Linux 5.1+, mature 5.11+; v1 uses only 5.1-safe ops (POLL_ADD, TIMEOUT, WRITE, FSYNC). Multishot accept (5.19) / multishot recv (6.0) are recorded optimizations for 19's spec, gated on the probe, never assumed.
  • The fallback is PORTABILITY, not preference: a startup probe (io_uring_setup, fall back on ENOSYS/EPERM — seccomp'd containers routinely deny io_uring) selects the ring or the epoll+blocking-fsync path; WO_IO=uring|epoll overrides so CI proves BOTH paths on one kernel (this subsumes 23's WO_WAL_MODE). io_uring is the design; epoll exists so the binary runs everywhere — the project's premise.
  • Raw io_uring_setup/io_uring_enter syscalls, libc-only (23's settled fork, now arc-wide); the mmap'd ring layout goes in the binding doc, normative, exactly like the WAL format.
  • net.accept/net.read/net.write and time.sleep PARK the calling fiber against the ring; the shard runs other fibers; completion re-queues the parked one. With exactly one fiber the ring wait IS the blocking call — same code path, program mode is the one-fiber case.
  • fs.* stays genuinely blocking in v1 (local disk, bounded); its reads become ring ops in the ops stage if 22's numbers ask; recorded.
  • The stop story composes: the stop signal wakes the ring (the existing self-pipe/eventfd trick), every parked fiber unwinds — the SIGTERM drain criterion extends to fibers for free.
  • The framework consequence (its own slice, after stage 1): the serve loop spawns a fiber per connection and the close-when-idle policy dies; that lands with iteration 24's spec, not this one.

Fiber context growth (recorded improvement, post-stage-1)

A fiber context is ~42 KiB today (fixed register window + frame array). Unlike native-stack green threads (Go's grow-by-copy must rewrite every pointer into the old stack), this VM's "stack" is register-INDEXED, not address-based — a context is relocatable by construction, so growth is an allocate-larger + memcpy with zero pointer fixups. Start-small (~4 KiB) growable contexts are therefore cheap to add and take fiber counts from thousands toward millions; scheduled after stage 1, before iteration 24's 1k-connection target if measurement asks.

Part B — shards (stage 2)

  • One pinned pthread per shard; shard count = cores by default (WO_SHARDS/manifest override). Each shard owns a full wo_rt: arena, traced list, GC — 7b's collector is per-shard by construction, so no global pause exists to remove.
  • Cross-shard send: an MPSC mailbox ring per shard plus an eventfd to wake an idle shard's epoll loop (the c-runtime exploration's proven pair). The message POINTER crosses; the object's home shard is stamped in its header (there since iteration 2); its eventual free routes back to the allocation-home arena (a small home-free ring per shard, drained at the tick).
  • What may cross: OWNED values whose class transitively contains no TRACED field — the gc_may-shaped fixpoint the runtime already computes, surfaced at compile time: sending a value whose type is or contains an inferred-traced class is WO-E222, naming the traced class and why it is traced. Aliased graphs never cross heaps.
  • spawn placement: round-robin across shards by default; no placement argument in v1 (recorded future work if 22 shows a need). The language never names a shard.
  • Determinism criterion narrows honestly: single-shard runs stay deterministic (stage 1's property); cross-shard interleaving is nondeterministic by nature — the corpus asserts OUTPUT SETS and ownership invariants under TSan, not byte-identical transcripts.

Part C — the DB actor + serving (stage 3)

  • The engine and WAL live on shard 0 (the owner). DB builtins executed on the owner run exactly today's code. On any other shard they marshal the statement into a runtime message, park the fiber, and resume with the materialized reply. transaction { } (iteration 18) marshals as one unit — the staged batch stays owner-side, semantics unchanged.
  • The listener: net.listen + net.accept stay on the shard that calls them v1 (the framework serves from one accept loop; accepted connections' fibers stay on that shard). Distributing accept (SO_REUSEPORT per shard) is 19/23-adjacent future work the benchmark must justify — recorded, not built.
  • Stage 3 closes the arc: just employee/just web-app run multi-shard byte-identical (criterion 4), the actor corpus runs under ASan+TSan, and 22 re-runs with the before/after recorded.

Diagnostics (new)

  • WO-E221 — spawn on a class with no receive(msg: M) (or an M that is not a class/record/union).
  • WO-E222 — cross-shard send of a traced (or traced-containing) type; names the class and the inference reason. Compile-time; stage 1 same-shard sends are exempt (same heap, aliasing is GC's problem and GC handles it).
  • Existing machinery covers the rest: send-of-borrowed is the owner pass's transfer rules; send-type mismatch is the parameter boundary.

Out of scope (arc-wide, restated)

Fiber migration across shards; priorities/timers/structured concurrency (recipe-box, later .wo libraries); async/await (permanently rejected); cross-shard transactions/2PC; SO_REUSEPORT accept distribution; segmented fiber stacks; delivery receipts / actor supervision trees (future story); program-mode API changes (none — one fiber is the degenerate case).

Success criteria

  1. Stage 1: thousands of fibers on one shard, one hot loop among them — everything progresses (starvation-free fixture), output deterministic; a parked-fiber shutdown unwinds ASan-clean; a fiber blocked in net.read resumes while the shard served others, one TID.
  2. Stage 2: the actor corpus under ASan+TSan — zero races, zero leaks; an owned send compiles as a move (sender's later use is a compile error); a traced-containing send is WO-E222; frees route home (asserted in debug builds).
  3. Stage 3: just employee and just web-app green at the all-cores DEFAULT with byte-identical answers; 22's benchmark re-run with the delta recorded; every standing gate green.
  4. The language grew exactly spawn, send, and the actor M type — no async/await, no locks, no thread ever visible.