swactor/docs/specs/drafts/ENGINE_SPEC.md
Zachery Aaron Shores-Chmielewski f8fc594b95 chore(docs): adopt repo-local spec workflow
Specs gain a monotonic Id and relocate by their true status: drafts
(WIP/aspirational) to docs/specs/drafts/, and accurate code-behavior
references stay in their crate dirs. docs/specs/archive/ is reserved for
superseded docs (currently empty).

Dispositions were cross-referenced against code, not the specs' own headers.
IROH_DRIVER claimed "current-state" but ~30% is unbuilt redesign, so it moves
to drafts. DATA_PLANE_ACTOR's central integration claim is unrealized (myelin
bypasses its node actor), and ACTOR_PANEL is not a reference; both are dropped
rather than reviewed or archived. DATASTREAM and MANAGED_PROCESS stay as
references; MYELIN stays in place (stale, flagged for review).

From here, commit titles reference a spec by [N] when one applies. This
bootstrap commit does not carry one.


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-08-09 15:19:39 +04:00

11 KiB

swactor engine — specification

Id: 1 Last modified: Last reviewed:

Scope: the execution substrate that drives swactor workers and hosts their async side-work, defined as an interface implemented per environment.

1. Purpose

swactor actors are synchronous, single-writer message handlers. Real systems need work actors cannot do inline: draining byte streams, running retry backoffs, polling on an interval, blocking GPU calls. That work lives in tasks on an execution substrate. Today that substrate is Tokio — hardcoded and reinvented per crate (ambient Handle::try_current(), silently-owned runtimes, ad-hoc block_on sync facades, a mix of tokio tasks and std threads).

This spec defines the engine: a single execution substrate, expressed as an interface, that (a) drives swactor workers and (b) runs the async tasks that back them. Tokio is one implementation; a minimal std-thread engine, a Go engine, a JS-worker engine, and a deterministic test engine are others. Authoring the interface from swactor's needs lets core and each engine implementation be optimized independently on either side of the seam.

2. Scope

In scope

  • The engine interface: what swactor requires of an engine, and what an engine provides.
  • The responsibility split between actor-workers and the engine.
  • How the engine drives workers (hosting the worker loop, the inbox as the wait seam).
  • The capability surface: tasks, timers, async I/O, blocking, time.
  • The bridge contract: how a task delivers into an actor mailbox, and the sync/async boundary rules.
  • The invariants an engine must uphold.
  • Reference instantiations (non-normative).

Out of scope

  • Actor execution semantics — single-writer, per-(sender,target) FIFO, fairness, panic isolation. Those belong to the actor-worker / core.
  • Backpressure policy. Producers and consumers share one engine; pressure handling is the application's decision, not swactor's.
  • Cancellation and shutdown lifecycle (deferred; nice-to-have).
  • Failure / observability propagation, except where it falls out of the bridge contract.
  • Cross-process / cross-isolation delivery and serialization.
  • Specific protocols and codecs (iroh/QUIC, datastream framing). Those are crate logic built on the engine.

3. Model

  • An actor-worker owns a disjoint set of actors, processes them one at a time, and is the unit of actor execution. It holds the pool, mailboxes, routing, and a single synchronous entry point: run one pass (tick_once), which drains its inbox into mailboxes and processes non-empty mailboxes up to a fairness budget.
  • The engine is the execution substrate. It does exactly two things:
    1. Drives workers — hosts each worker's loop: wait until the worker has work, run a pass, repeat.
    2. Runs tasks — schedules the async side-work (timers, I/O pumps, blocking calls) that backs the actors.
  • The engine owns all progression. Actor handlers never .await. Every handler is a synchronous transition that returns control immediately. The engine runs the loop that drives them and holds every long-lived flow (a worker idle on its inbox, a task doing I/O, a timer). The actor world is pure transition. Only the engine carries control flow across time.
  • Workers and tasks share one substrate and one scheduler. There is no separate "I/O runtime" beside the actor runtime.

4. The engine interface

The interface is authored from swactor's needs. It is a contract — operations plus their semantics and invariants. A Rust trait is its canonical Rust binding; Go, JS, and other hosts implement the same contract natively. This spec defines the contract, not the Rust signature.

The engine provides: // USER: The host_worker(id, pass) fn needs more explanation and justification // USER: Why are we including a timer as a core function necessary to the engine. Can it not go somewhere else?

operation meaning
host_worker(id, pass) → deposit Create the worker's inbox, start its reactive loop (wait on the inbox, call pass), and return the deposit handle core uses to route messages into it.
spawn(task) Schedule an async unit of work on the substrate.
spawn_blocking(work) Schedule blocking CPU / syscall work off the async path.
timer(delay) / interval(period) Schedule future or recurring work.
now() The engine's monotonic clock.

Core provides back to the engine and to tasks: // USER: Core is fine as it is. We are not modifying core, it was carefully designed and is very pure. The engine is to be abstracted in such a way as to complement the abstractions core gives us. I think we can satisfy these fns through existing core, but we don't say that core provides xyz, as that is not the framing of this spec.

surface meaning
pass (per worker) The synchronous entry point tick_once(&tc) → did_work, run once per pass.
deliver A handle to deposit a message into an actor mailbox by address — the bridge (§7). Cloneable; captured by tasks.

The split is deliberate. Core owns actor logic, routing, and the deposit side of every inbox. The engine owns the idle side and all scheduling. Core only transitions. The engine drives. The deposit handle returned by host_worker is engine-agnostic (loss-free, non-blocking push) so core's routing can deposit without knowing which engine is in use.

5. Driving workers

// USER: pass is stupid when we already have a tick() built in.

  • The engine hosts N workers. For each, it runs: call pass; if it did work, call it again (a productive pass may have buffered same-worker sends that need draining); if it did no work, idle on the inbox until a deposit makes a pass runnable. There is no separate wake primitive. A deposit into the inbox is what makes the next transition runnable, so the engine drives it.
  • The inbox is the wait seam. The engine creates each inbox and holds its consumer side, choosing how to wait (a blocking recv under std threads; an async recv().await under tokio; an event under JS). Core holds the deposit side for routing.
  • Non-reentrancy. The engine must never run two passes of the same worker concurrently. A worker's &mut self is live only for the duration of a synchronous pass call — never held across a wait.
  • Scheduling strategy is the engine's choice. Whether a pass runs inline on the executor (cooperative) or on a blocking thread is an implementation tradeoff the engine owns; core is agnostic to it.

6. Capability surface

// USER: Maybe just I/O instead of explicitly async? So we can have a blocking I/O if our engine only supports that // USER: Not sure I want to put time inside the engine. I am open to being convinced, but the added complexity and tying it // USER: to what I wanted to be a simple task/execution api is worrying me about future compatability.

The primitives an engine may provide. Capabilities are per-implementation and discoverable: each engine reports which it supports, and binding an engine that lacks a required capability fails at construction, never at runtime.

  • Tasks — spawn of an async unit of work; the substrate's unit of concurrency.
  • Timers — one-shot delay and recurring interval.
  • Async I/O — streams, sockets, files. This is where implementations diverge most: a tokio engine offers sockets / QUIC / streams; a JS engine offers fetch / WebSocket; a std-thread engine offers none (only blocking I/O via spawn_blocking).
  • Blocking — spawn_blocking for CPU-bound or syscall work that must not stall the executor.
  • Time — now(). In a test engine this is virtual, advanced by the test; this is what makes deterministic testing possible.

An engine that provides only tasks + blocking + time is still a valid (if unperformant) engine. Crates that need async I/O bind to an engine that provides it.

7. The bridge contract

// USER: Why this contract, why are tasks delivering directly to actors?

How an engine task gets a result into an actor mailbox.

  • A task captures a deliver handle (obtained from core, not from the engine) bound to a destination address, or a runtime-wide send_to(addr, msg). Delivering deposits the message into the owning worker's inbox — a loss-free, non-blocking pointer-move along the same path any sender uses. No serialization, no copy, within one address space.
  • Deliver is fire-and-forget from the task's view: it returns immediately; the actor handles the message on a later pass of its worker.
  • Boundary rules:
    • Actor handlers are synchronous and single-writer. They never .await.
    • &mut Worker and any actor state is live only during a synchronous pass; it is never held across a wait and never sent into a task.
    • All .await lives in tasks. Tasks never touch actor state directly; they communicate only via the deliver handle and the inbox.
  • The inbox a task delivers into is the same FIFO, loss-free, unbounded queue the worker waits on. Mailbox ordering semantics (per-(sender,target) FIFO) are the actor-worker's concern; the engine's only obligation is that the inbox itself is FIFO and loss-free.

8. Invariants

An engine must uphold:

  • Non-reentrant passes. At most one pass per worker at any instant.
  • Loss-free, non-blocking delivery. The inbox never drops and never blocks the sender (unbounded).
  • FIFO inbox. Messages depart an inbox in deposit order.
  • Progress independence. A long-running or blocked task must not stall worker passes, and vice versa. The engine provides enough concurrency that workers and tasks progress independently (on a cooperative single-thread host like JS, this is a discipline the engine enforces: no blocking calls in tasks or passes).
  • Actors never await. No .await reaches actor code; the engine owns every wait.

9. Reference instantiations (non-normative)

Illustrations of how each environment satisfies the contract — not prescription.

  • tokio. Workers and tasks are tokio tasks; a worker loop is loop { inbox.recv().await; pass(); } with &mut Worker live only across the synchronous pass (long passes may be moved to spawn_blocking; that scheduling choice is the engine's, per §5). Async I/O, spawn_blocking, and now() are tokio's. This is today's de-facto engine, made explicit.
  • std-thread. Each worker is an OS thread blocking on its inbox; tasks are OS threads or a small pool; spawn_blocking is a thread; there is no async I/O, only blocking I/O. Simple, unperformant, dependency-free — and a valid engine.
  • deterministic test engine. A single-threaded stepping scheduler: workers and tasks are entries the test advances manually; now() is virtual time advanced by the test; async I/O is faked or mocked. It implements the same contract, so crates test against the interface with no real network and no threads, fully deterministic. It falls out of the contract; it is not specified separately.
  • Go / JS-worker (illustrative). Workers and tasks map to goroutines + channels, or to the JS event loop + postMessage / callbacks. Each provides the capability subset its runtime supports.

10. What this spec does not define

The boundary, stated plainly:

  • Actor execution semantics (single-writer, FIFO, fairness, panic isolation).
  • Backpressure.
  • Cancellation and shutdown.
  • Failure / observability propagation beyond the bridge.
  • Cross-process / cross-isolation delivery and serialization.
  • Specific protocols and codecs.