commit 1aabec0e41a5d47e9a07963d22d05a697ce48919 Author: Zachery Aaron Shores-Chmielewski Date: Mon Feb 23 12:07:59 2026 +0700 init diff --git a/PROCESS_PRIMITIVES.md b/PROCESS_PRIMITIVES.md new file mode 100644 index 0000000..6b5edfa --- /dev/null +++ b/PROCESS_PRIMITIVES.md @@ -0,0 +1,542 @@ +# Process Abstraction for Swactor — Development History + +> Design and implementation record for the "process" abstraction layer built +> on top of swactor's actor primitives. This work ran across items 1–9 and +> added 9 extension traits, 3 registries, and ~70 scenario tests. + +## Context + +Swactor is a distributed actor runtime with local primitives (spawn, send, stop, monitor, +supervise) and distributed primitives (SWIM membership, Kademlia directory, cluster-wide naming, +content-addressed datastore). The goal was to design a "process" abstraction that sits on top of +these primitives, making the experience of running code on a swactor network feel closer to what +an OS process feels like -- with access to an API for requesting resources and querying system +state. + +--- +## Part 1: OS Process Mapping + +### Already strong (direct OS equivalents exist) + +OS Concept: PID +Swactor Equivalent: ActorAddress (32-byte random) +Where: src/actor.rs +──────────────────────────────────────── +OS Concept: fork+exec +Swactor Equivalent: ctx.spawn(), Runtime::spawn() +Where: src/actor.rs, src/runtime.rs +──────────────────────────────────────── +OS Concept: exit(0) +Swactor Equivalent: ctx.stop_self() +Where: src/actor.rs +──────────────────────────────────────── +OS Concept: kill(pid, SIGTERM) +Swactor Equivalent: ctx.stop_actor(addr) +Where: src/actor.rs +──────────────────────────────────────── +OS Concept: SIGCHLD / waitpid +Swactor Equivalent: ctx.monitor() -> Down, ctx.watch() -> ActorExited +Where: crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: IPC (message queues) +Swactor Equivalent: Typed message passing (local + cross-worker + cross-runtime) +Where: src/actor.rs, src/transport.rs +──────────────────────────────────────── +OS Concept: Service names +Swactor Equivalent: NameRegistry (local), ClusterRegistry (cluster CRDT) +Where: crates/std/src/name_registry.rs, crates/distribution/src/registry.rs +──────────────────────────────────────── +OS Concept: Process groups +Swactor Equivalent: GroupRegistry (join/leave/publish/members) +Where: crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: init/systemd +Swactor Equivalent: Supervisor with restart strategies +Where: crates/std/src/supervisor.rs +──────────────────────────────────────── +OS Concept: Scheduler +Swactor Equivalent: Worker pool with load-aware placement + per-actor message budgets +Where: src/worker.rs, src/delivery.rs +──────────────────────────────────────── +OS Concept: Machine identity +Swactor Equivalent: NodeId (ed25519 public key) +Where: crates/distribution/src/types.rs +──────────────────────────────────────── +OS Concept: Cluster membership +Swactor Equivalent: SWIM protocol +Where: crates/distribution/src/swim/ +──────────────────────────────────────── +OS Concept: /proc, top, ps +Swactor Equivalent: RuntimeStats, StatsHook, Dashboard, Investigate protocol +Where: src/stats.rs, crates/dashboard/ + +### Implemented during this work + +OS Concept: System introspection from inside +Swactor Equivalent: CtxSystem (worker_id, num_workers, total_actors, uptime_ms) + SystemInfo +Where: src/actor.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Per-actor introspection +Swactor Equivalent: CtxSelfStats (messages_processed, mailbox_depth, message_type_counts) +Where: src/actor.rs, src/worker.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Process lineage (getppid) +Swactor Equivalent: CtxLineage (ctx.parent(), ctx.supervisor()) +Where: src/actor.rs, src/worker.rs, crates/std/src/ctx_ext.rs, crates/std/src/supervisor_registry.rs +──────────────────────────────────────── +OS Concept: Process environment (environ/getenv) +Swactor Equivalent: CtxEnvironment (ctx.env::(), ctx.environment(), SpawnBuilder for overrides) +Where: src/actor.rs, src/worker.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Well-known environment keys (spawn metadata) +Swactor Equivalent: SpawnTimestamp(u64) injected by StdExtension on_spawn hook; + LogicalName(String) injected by spawn_named (ctx and runtime level) +Where: src/actor.rs, src/extension.rs, src/worker.rs, crates/std/src/extension.rs, + crates/std/src/ctx_ext.rs, crates/std/src/runtime_ext.rs, src/runtime.rs +──────────────────────────────────────── +OS Concept: Service discovery +Swactor Equivalent: ServiceRegistry + CtxResources (ctx.resource::() -> Option) +Where: src/actor.rs, crates/std/src/service_registry.rs, crates/std/src/ctx_ext.rs, + crates/std/src/runtime_ext.rs, crates/std/src/extension.rs +──────────────────────────────────────── +OS Concept: Resource request API (typed handles) +Swactor Equivalent: ResourceHandle trait + CtxHandles (ctx.handle::() -> Option) +Where: crates/std/src/resource_handle.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Exit codes / rich exit values +Swactor Equivalent: ExitValue(Arc), ctx.stop_with(value), + StopReason::Completed, ExitReason::Completed. Exit values propagated via Down/ActorExited. +Where: src/actor.rs, src/worker.rs, crates/std/src/extension.rs, crates/std/src/watch_registry.rs +──────────────────────────────────────── +OS Concept: Parent-child hierarchy + orphan handling +Swactor Equivalent: ChildrenRegistry tracks parent->children. On parent death, unsupervised + children are killed (StopSignal). Supervised children are left to their supervisor. Cascades + naturally across generations via tick-based cleanup. +Where: crates/std/src/children_registry.rs, crates/std/src/extension.rs +──────────────────────────────────────── +OS Concept: Suspend/resume (SIGSTOP/SIGCONT) +Swactor Equivalent: ctx.suspend_self(), ctx.resume(target) with auth (self or supervisor only). + Suspended actors queue messages but don't process them. ResumeSignal via transfer queue for + cross-worker resume. +Where: src/actor.rs, src/worker.rs, src/runtime.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Capability model / sandboxing +Swactor Equivalent: CapabilitySet stored in actor's Environment. Enforced at Ctx level (send, + spawn, stop_actor, monitor, resource). Opt-in: actors without a CapabilitySet are unrestricted. +Where: src/actor.rs, crates/std/src/ctx_ext.rs + +### Still partially there + +OS Concept: Resource limits +What Exists: Mailbox capacity + message budget +What's Missing: No per-actor memory/CPU/fd limits +──────────────────────────────────────── +OS Concept: Auth/permissions +What Exists: Datastore ACL + node-level peer auth + actor-level CapabilitySet +What's Missing: Cluster-level capability propagation (local-only today) + +--- +## Part 2: Design Primitives + +The design followed the existing extension pattern: new capabilities were added as extension traits +on Ctx<'_>, backed by registries in the extension system. This preserved backwards compatibility +and kept the core minimal. + +### 2.1 System Queries (CtxSystem) + +What it enables: An actor can ask about the system it's running in. + +Implemented queries (available via ctx.system_info() or the CtxSystem extension trait): +- ctx.worker_id() -> usize -- which worker thread am I on? +- ctx.num_workers() -> usize -- how many worker threads exist? +- ctx.total_actors() -> usize -- live actors across all workers +- ctx.uptime_ms() -> u64 -- milliseconds since runtime creation + +Implementation: SystemInfo struct in src/actor.rs. ContextInner::system_info() implemented on +both Runtime (for spawn-time context) and WorkerContext (for handler context). Data flows through +TickContext (worker_stats + created_at fields in src/delivery.rs). The CtxSystem extension trait +in crates/std/src/ctx_ext.rs provides ergonomic per-field accessors. + +Future cluster-level queries (not yet implemented): +- What is my node's identity (NodeId)? +- How many cluster nodes are alive? +- Who are the cluster members? + +These require the distribution crate's DistributedNode state to be exposed through the extension +system. The CtxSystem trait can be extended with these when the distribution integration is ready. + +### 2.2 Process Environment (CtxEnvironment) + +What it enables: Typed configuration that flows from parent to child at spawn time. + +Properties: +- Inherited: When actor A spawns actor B via ctx.spawn(), B gets A's environment (Arc clone) +- Overridable: ctx.spawn_builder(actor).env(Key(val)).finish() lazily clones the parent's map + on first override (copy-on-write), leaving the common case (no overrides) allocation-free +- Immutable after spawn: Set at creation, read-only thereafter. Mutable config goes through + messages. +- Typed values: TypeId-keyed (like http::Extensions), not string-to-string +- Runtime-spawned actors start with an empty environment + +Implementation: Environment is Arc>> -- clone is an +Arc bump (zero allocation). EnvironmentBuilder provides from_env() for copy-on-write overrides +(cloning individual entries is cheap since values are also Arc-wrapped). The spawn channel was +replaced with a SpawnRequest struct (addr, actor, parent, env) to avoid further tuple growth. +ActorSlot stores env, and Ctx receives it at both construction sites (tick_all and cleanup_dead). +SpawnBuilder provides the ergonomic override API. The CtxEnvironment extension trait in +crates/std/src/ctx_ext.rs provides the import path, following the same pattern as CtxLineage +(no StdExtension dependency required). Python crate spawns with Environment::new(). 6 scenario +tests in tests/std_extension.rs cover: inheritance, empty for runtime-spawned, grandchild chain, +override-one-inherit-others, readable in on_stop, and sibling independence. + +Well-known keys: +- SpawnTimestamp(u64): Injected by StdExtension's on_spawn hook. Milliseconds since runtime + creation, same time base as SystemInfo::uptime_ms. Opt-in at runtime level (present when + StdExtension is installed). Read via ctx.env::(). +- LogicalName(String): Injected by spawn_named() at both ctx and Runtime levels. Inherited by + children via normal environment inheritance. Read via ctx.env::(). +- ServiceBinding(ActorAddress): Injected by ServiceRegistry's inject_into() hook during + on_spawn. Registered at runtime level via rt.register_service::(addr). Read via + ctx.resource::() (CtxResources trait). Overridable per-subtree via spawn_builder. +- CapabilitySet: Granted at spawn time (via environment or spawn_builder). Inherited by children. + Enforced at Ctx level. See section 2.7. + +Analogy: Unix environ -- inherited by default, augmented at fork/exec time, readable via getenv(). + +### 2.3 Service Discovery (CtxResources) + +What it enables: Actors can discover system services by type, not by knowing raw addresses. + +How it differs from NameRegistry: NameRegistry maps strings to addresses. CtxResources maps +service marker types to addresses. Looking up "datastore" by name gives you a raw ActorAddress and +you must know what messages it accepts. ctx.resource::() gives you the address of the +service registered under that marker type. + +Implementation: Three layers compose the feature: + +1. Core type: ServiceBinding(ActorAddress) in src/actor.rs -- a generic environment key + parameterized by a zero-sized marker type. Any struct satisfying 'static + Send + Sync works + as a marker (no special Service trait required, consistent with Environment's existing API). + +2. Registry + injection: ServiceRegistry in crates/std/src/service_registry.rs stores registered + bindings as RwLock>> (same thread-safety pattern + as SupervisorRegistry). StdExtension's on_spawn hook calls inject_into() before adding + SpawnTimestamp -- this merges all registered bindings into the actor's environment, skipping + keys already present (preserves per-subtree overrides set via spawn_builder). Helper methods + on Environment (contains_type_id) and EnvironmentBuilder (set_raw) support type-erased + injection without knowing concrete types at compile time. + +3. Read API: CtxResources trait in crates/std/src/ctx_ext.rs provides ctx.resource::() -> + Option, a thin wrapper around ctx.env::>().map(|b| b.addr). + Does NOT require StdExtension -- reads from core environment (same pattern as CtxEnvironment). + When a CapabilitySet is present, resource() checks check_service::() and returns None if + denied. RuntimeResources trait in crates/std/src/runtime_ext.rs provides + rt.register_service::(addr) for startup-time registration. + +Key design decisions: +- No Service marker trait: S: 'static + Send + Sync is sufficient. Any zero-size struct works. +- "Skip if present" injection: The registry doesn't overwrite env keys set by spawn_builder, + enabling per-subtree service overrides (e.g., test doubles, staging vs production services). +- No cleanup on service actor death: A dead service's binding stays in the registry (stale + address). Sends to it will fail. Service lifecycle management is a higher-level concern. + +6 scenario tests in tests/std_extension.rs cover: discovery by marker type, child inherits +binding from parent, multiple services each accessible by marker, unregistered returns None, +overridable via spawn_builder, accessible in on_start and on_stop lifecycle hooks. + +Well-known services that could be registered (when swactor-node is updated): +- Storage -- content-addressed datastore (currently wired manually in swactor-node) +- Directory -- actor location resolution (currently locked inside DistributedNode) +- Cluster -- membership/topology info (currently snapshot-only for dashboard) +- Metrics -- runtime stats (currently StatsHook push-only) + +### 2.4 Resource Handles (CtxHandles) + +What it enables: Domain-specific typed proxies that wrap service addresses and provide ergonomic +APIs. + +The pattern: A handle wraps (service_address, self_address) and provides methods that construct +and send the right messages, embedding self_addr as reply_to. Responses arrive as normal messages +in the actor's handle(). + +Implementation: The ResourceHandle trait in crates/std/src/resource_handle.rs defines the contract: +- type Service: 'static + Send + Sync -- the marker type used for service discovery +- from_parts(service_addr, self_addr) -> Self -- construct from addresses +- service_addr() -> ActorAddress -- the underlying service address +- self_addr() -> ActorAddress -- the actor's own address (for reply_to) + +The CtxHandles extension trait in crates/std/src/ctx_ext.rs provides ctx.handle::() -> Option, +which looks up ServiceBinding from the actor's environment and constructs the handle. +Returns None if the service is not registered (consistent with ctx.resource(), ctx.where_is()). + +Handle methods take &self + &Ctx (not stored &Ctx -- avoids lifetime issues with &mut self in +handlers). Example: + impl MyHandle { + pub fn do_work(&self, ctx: &Ctx, data: Vec) -> Result<(), Error> { + ctx.send(self.service_addr(), MyMsg::DoWork { data, reply_to: self.self_addr() }) + } + } + +Key design tension: Handles can't block (no await in swactor). The response arrives asynchronously +as a message. This is inherent to the actor model and not something to "fix" -- the handle just +makes the send side ergonomic. + +5 scenario tests: handle wraps service and sends ergonomically, returns None when service not +registered, inherits service binding from parent, constructible in on_start, two actors with same +handle type each get responses at their own address. + +### 2.5 Process Lineage (CtxLineage) + +What it enables: Actors know their ancestry. + +Implemented queries: +- ctx.parent() -> Option (who spawned me?) + Returns Some(spawner_addr) for actor-spawned children, None for Runtime::spawn(). + Available in handle(), on_start(), and on_stop(). +- ctx.supervisor() -> Option (who supervises me, if anyone?) + Returns Some(supervisor_addr) for supervised children, None for unsupervised actors. + Gracefully returns None when StdExtension is absent (no panic). + +Implementation (parent): The spawn channel uses a SpawnRequest struct (addr, actor, parent, env) -- +the original 3-tuple was replaced when CtxEnvironment was added. When Ctx::spawn is called, the +spawning actor's self_addr is passed as Some(parent). Runtime::spawn passes None. The parent is +stored in ActorSlot::parent_addr and threaded into Ctx::self_parent_addr at both construction sites +(tick_all and cleanup_dead). 4 scenario tests cover: child knows parent, runtime-spawned has no +parent, grandchild sees immediate parent (not grandparent), and parent is visible in on_stop. + +Implementation (supervisor): SupervisorRegistry in crates/std/src/supervisor_registry.rs stores a +child_addr -> supervisor_addr map (RwLock>). Supervisor::start_child calls +register(self_addr, child_addr) after spawning and monitoring. cleanup() removes entries where the +dead address is either child or supervisor (O(n) scan for supervisor death, acceptable since +supervisor death is rare and the map is small). CtxLineage::supervisor() downcasts the extension +gracefully (returns None if StdExtension is absent). 5 scenario tests cover: supervised child knows +supervisor, unsupervised actor returns None, supervisor survives child restart, grandchild not +supervised but parent is, OneForAll restart re-registers all children. + +The CtxLineage extension trait in crates/std/src/ctx_ext.rs provides the ergonomic import path. + +Orphan handling was implemented as part of item 8 (Lifecycle Enrichment) -- see section 2.8. + +### 2.6 Self-Introspection (CtxSelfStats) + +What it enables: Actors can see their own operational metrics. + +Implemented queries (available directly on Ctx or via the CtxSelfStats extension trait): +- ctx.messages_processed() -> u64 -- total successfully processed before current tick +- ctx.mailbox_depth() -> usize -- messages queued at start of current tick (pre-dequeue) +- ctx.message_type_counts() -> &[(&str, u64)] -- per-type counts, sorted descending + +Implementation: Stats are snapshotted from ActorSlot fields into Ctx before each tick_all +iteration (src/worker.rs). The snapshot captures the state before any messages are dequeued +in the current tick, giving actors a consistent view. The same snapshot is provided during +on_stop callbacks in cleanup_dead. The CtxSelfStats extension trait in crates/std/src/ctx_ext.rs +provides the ergonomic import path. + +The Vec allocation for type counts is bounded (max 32 entries from ActorSlot's msg_type_counts +cap) and negligible relative to handle_any cost. + +### 2.7 Capability Model (CapabilitySet + CtxCapabilities) + +What it enables: Controlled access to system resources and other actors. Primarily important for +sandboxing untrusted code (wasm actors in crates/bin-runner/). + +Approach: A single CapabilitySet stored in the actor's Environment. When present, enforcement is +active -- the actor can only perform operations granted by the set. When absent, the actor is +unrestricted (backward compatible). Capabilities inherit from parent to child via normal +environment inheritance. + +Capability grants (all in CapabilitySet): +- with_send(addr) -- send any message type to a specific address +- with_send_typed::(addr) -- send only messages of type M to a specific address +- with_spawn() -- permission to spawn new actors +- with_service::() -- permission to access system service S via ctx.resource::() +- with_monitor(addr) -- permission to monitor a specific actor + +Enforcement points (all in src/actor.rs Ctx methods or crates/std/src/ctx_ext.rs): +- ctx.send::(addr, msg) -- checks check_send::(addr); self-send always allowed +- ctx.spawn() / SpawnBuilder::finish() -- checks check_spawn() +- ctx.stop_actor(addr) -- checks check_send_addr(addr) (stop is a send of StopSignal) +- ctx.monitor(addr) -- checks check_monitor(addr); returns Result +- ctx.resource::() -- checks check_service::(); returns None if denied + +Key design decisions: +- Opt-in: No CapabilitySet in environment means unrestricted. Zero behavioral change for existing + actors. The only cost is an Option check (env.get::()) at each enforcement point. +- Enforcement at Ctx level only: The core ContextInner::send_any is not gated. This means + extension code (supervisors, timers, etc.) that calls send_any directly bypasses capability + checks, which is intentional -- system infrastructure is trusted. +- Dual send granularity: with_send(addr) grants all message types to an address. + with_send_typed::(addr) grants only type M. The check tries address-only first, then typed. + This allows coarse grants for trusted peers and fine-grained grants for untrusted actors. +- Self-send always allowed: A restricted actor can always send to its own address. This prevents + capabilities from breaking actors that use self-messaging patterns (timers, state machines). +- monitor() returns Result: Changed from -> MonitorRef to -> Result. This was + a breaking change to all callers (supervisor.rs, router.rs, test files), fixed mechanically by + adding ? or .unwrap(). + +Builder API: Fluent (CapabilitySet::new().with_send(addr).with_spawn()) and mutable +(caps.grant_send(addr)) variants. Mutable methods return &mut Self for chaining. + +Introspection: CtxCapabilities extension trait in crates/std/src/ctx_ext.rs provides: +- ctx.capabilities() -> Option<&CapabilitySet> -- access the raw set +- ctx.is_restricted() -> bool -- quick check + +Implementation locations: +- src/actor.rs: CapabilitySet struct, builder methods, check methods, Ctx::capabilities() helper, + enforcement in send/spawn/stop_actor/SpawnBuilder::finish +- src/lib.rs: CapabilitySet re-export +- crates/std/src/ctx_ext.rs: CtxCapabilities trait, monitor() enforcement, resource() enforcement +- crates/std/src/lib.rs: CtxCapabilities re-export + +11 scenario tests in tests/std_extension.rs cover: unrestricted actor sends freely (backward +compat), restricted actor denied send, restricted actor allowed send, typed send grant (Ping +allowed / Pong denied), spawn denied, spawn allowed, capability inheritance (child inherits +parent's CapabilitySet), monitor denied, service access denied, self-send always allowed, stop +requires send permission. + +### 2.8 Lifecycle Enrichment + +Rich exit values: ExitValue(Arc) is an opaque typed wrapper. Actors stop +with ctx.stop_with(value) which stores the value and triggers StopReason::Completed. The value +is propagated through Down (monitors) and ActorExited (watchers) via the exit_value: Option +field. Manual PartialEq/Eq on ExitValue (always false -- opaque blob), so Down/ActorExited compare +by addr+reason only. + +Implementation: StopWithSignal(ExitValue) is a sentinel message intercepted in tick_all (like +StopSignal). ActorSlot gains exit_value: Option. cleanup_dead returns +Vec<(ActorAddress, StopReason, Option)> with StopReason::Completed when exit_value is +present. The on_actor_death extension hook receives and propagates exit values to monitors/watchers. + +7 scenario tests: stop_with value received in Down, received in ActorExited, normal stop has None, +panic has None, multiple monitors receive cloned value, stop_with from on_start, supervisor receives +rich exit in handle_down (graceful handoff pattern). + +Orphan handling: ChildrenRegistry tracks parent -> set of children. Populated in on_spawn when a +parent is present. On parent death (on_actor_death), unsupervised children receive StopSignal. +Supervised children are left to their supervisor. Cascades naturally: parent dies -> children killed +next tick -> grandchildren killed the tick after that. StopSignal made pub (was pub(crate)) to +enable this -- it's not Message (not Clone) so can't be sent via ctx.send(). + +4 scenario tests: unsupervised children killed on parent death, supervised children not killed, +cascading cleanup across generations, runtime-spawned actors unaffected. + +Suspend/resume: ActorSlot gains a suspended: bool flag. Suspended actors queue messages but don't +process them (tick_all skips them). ctx.suspend_self() sets the flag via a suspend_requests buffer. +ResumeSignal is intercepted in deliver() to clear the flag. StopSignal/StopWithSignal are also +intercepted for suspended actors (so stop_actor works on them). Cross-worker resume sends +ResumeSignal via the transfer queue. + +Authorization: CtxLifecycle extension trait provides ctx.suspend_self() (always allowed) and +ctx.resume(target) which checks: target == self (self-resume) OR caller is the target's supervisor +via SupervisorRegistry. Returns Err if unauthorized. + +5 scenario tests: suspended actor queues then resume processes, supervisor can resume, non-supervisor +cannot resume, suspended actor can be stopped, cross-worker resume via runtime. + +Graceful handoff: Built on rich exit values. An outgoing actor stops with its state via +ctx.stop_with(state); the supervisor receives it in handle_down's Down message and can pass it +to the replacement's constructor. Enables zero-downtime upgrades. No additional mechanism needed -- +the pattern composes from existing primitives. + +--- +## Part 3: How These Compose + +The primitives form a layered system: + +Layer 3: Integration (swactor-node wires services at startup) +Layer 2: Process (CapabilitySet, ProcessBuilder) +Layer 1: Std (CtxSystem, CtxEnvironment, CtxLineage, CtxSelfStats, Well-known env keys, + SupervisorRegistry, CtxResources, CtxHandles, CtxLifecycle, ChildrenRegistry, + CtxCapabilities) +Layer 0: Core (SystemInfo, Ctx self-stats, parent tracking, Environment + SpawnRequest, + on_spawn hook, spawn_with_env, ServiceBinding, suspend flag, rich exit, orphan + handling, CapabilitySet) + +A "process" in swactor is an actor that has: +1. An identity (ActorAddress) and a name (NameRegistry) +2. A parent and supervisor it can query (CtxLineage) +3. An environment inherited from its spawner, with well-known keys (CtxEnvironment) +4. Access to system services through discovery (CtxResources) +5. The ability to query the system it lives in (CtxSystem) +6. Awareness of its own operational state (CtxSelfStats) +7. Typed resource handles for ergonomic service interaction (CtxHandles) +8. Rich lifecycle support including typed exit values, orphan handling, and suspend/resume +9. Controlled permissions for what it can access (CapabilitySet) + +What stayed the same: The core actor model (message passing, mailboxes, workers, tick-based +execution) was unchanged. ActorInterface, Ctx, Runtime remained the foundation. The process +abstraction was additive -- existing actors continued to work exactly as before. + +--- +## Part 4: Implementation Sequence + +Each item was implemented and merged in dependency order. Earlier items established the +infrastructure (Environment, extension hooks) that later items built on. + +1. **CtxSystem + CtxSelfStats** -- Exposed existing internal data to actors. SystemInfo struct, + ContextInner::system_info(), Ctx self-stats snapshot fields. Extension traits CtxSystem and + CtxSelfStats in swactor-std. Covered by 3 scenario tests. + +2. **CtxLineage (parent tracking)** -- Option threaded through the spawn path. + ContextInner::spawn_any gained a parent parameter. ActorSlot stores parent_addr. Ctx exposes + parent(). CtxLineage extension trait in swactor-std. 4 scenario tests. + Python crate updated to pass parent on spawn. + +3. **CtxEnvironment (process environment)** -- Typed key-value map inherited from parent to + child at spawn time. Environment is Arc>> -- clone + is an Arc bump. EnvironmentBuilder supports copy-on-write overrides via from_env(). The spawn + channel 3-tuple was replaced with a SpawnRequest struct (addr, actor, parent, env) to stop + tuple growth. ActorSlot stores env. Ctx gains env::(), environment(), and spawn_builder(). + SpawnBuilder lazily clones the parent's map on first .env() call. CtxEnvironment extension trait + in swactor-std (no StdExtension dependency). Python crate spawns with Environment::new(). + 6 scenario tests: inheritance, empty for runtime-spawned, grandchild chain, + override-one-inherit-others, readable in on_stop, sibling independence. + +4. **Well-known environment keys** -- SpawnTimestamp(u64) and LogicalName(String) types in + src/actor.rs, exported from src/lib.rs. SpawnTimestamp is opt-in at runtime level: injected by + StdExtension's on_spawn hook (new RuntimeExtension::on_spawn hook with default no-op in + src/extension.rs). Worker::drain_spawns now takes &TickContext and calls on_spawn for each + spawn request, passing uptime_ms to avoid exposing the pub(crate) Instant type. LogicalName is + injected by spawn_named at both ctx level (via spawn_builder + env override) and runtime level + (via new Runtime::spawn_with_env method). LogicalName inherits to children automatically via + normal environment inheritance. 7 scenario tests. + +5. **Supervisor lineage (ctx.supervisor())** -- SupervisorRegistry in + crates/std/src/supervisor_registry.rs stores child_addr -> supervisor_addr as + RwLock>. Supervisor::start_child calls register() after spawning and + monitoring. cleanup() removes entries for dead actors (both as child and as supervisor). + CtxLineage::supervisor() gracefully returns None when StdExtension is absent (downcasts via + as_any, no panic). Distinct from parent() because not every parent is a supervisor. get_ext + made pub(crate) so supervisor.rs can access it. 5 scenario tests. + +6. **Service Registry + CtxResources** -- Actors discover system services by type + (ctx.resource::()) rather than by raw address. ServiceBinding(ActorAddress) + is a generic environment key parameterized by a marker type. ServiceRegistry in StdExtension + stores bindings and injects them into every actor's environment via on_spawn (skipping keys + already present to preserve spawn_builder overrides). CtxResources trait provides + ctx.resource::() sugar. RuntimeResources trait provides rt.register_service::(addr). + 6 scenario tests. + +7. **Resource Handles (CtxHandles)** -- ResourceHandle trait + CtxHandles extension trait. + ctx.handle::() -> Option constructs typed proxies from ServiceBinding in the + actor's environment. Handle methods take &self + &Ctx for ergonomic domain-specific APIs. + 5 scenario tests. + +8. **Lifecycle enrichment** -- Three sub-features: + a) Rich exit values: ExitValue(Arc), ctx.stop_with(value), + StopReason::Completed, ExitReason::Completed. Propagated through Down/ActorExited. + 7 scenario tests. + b) Orphan handling: ChildrenRegistry tracks parent->children. Unsupervised children killed + on parent death. Supervised children left to their supervisor. Natural cascade. + 4 scenario tests. + c) Suspend/resume: ActorSlot::suspended flag, ctx.suspend_self(), ctx.resume(target) with + auth (self or supervisor only). ResumeSignal for cross-worker resume. + 5 scenario tests. + +9. **Capability model (CapabilitySet)** -- Per-actor permission set stored in the Environment. + Grants: with_send(addr), with_send_typed::(addr), with_spawn(), with_service::(), + with_monitor(addr). Enforced at Ctx level in send, spawn, stop_actor, monitor, and resource. + Opt-in: actors without a CapabilitySet are unrestricted (zero behavioral change). Self-send + always allowed. monitor() changed from -> MonitorRef to -> Result (breaking + change, fixed mechanically in supervisor.rs, router.rs, and all test files). CtxCapabilities + extension trait for introspection. 11 scenario tests. diff --git a/PROCESS_RUNNER.md b/PROCESS_RUNNER.md new file mode 100644 index 0000000..2fbfa3b --- /dev/null +++ b/PROCESS_RUNNER.md @@ -0,0 +1,434 @@ +# Process Runner Design: Async Process Management in Swactor + +## Context + +Swactor is a synchronous, tick-based actor framework (Erlang-inspired). Actors must return quickly from `handle()` — blocking stalls the entire worker thread. There is no built-in async I/O. + +The goal: let actors manage long-lived async "processes" — OS subprocesses and SSH shells — with full lifecycle control. Must support both interactive use (live shell, bidirectional real-time I/O) and automated execution (run commands, stream output, report exit). + +Constraints from discussion: +- Backends: SSH + local processes (two backends, not more) +- Scale: Architecture should support thousands; first implementation handles tens +- This is a standalone new feature — not related to or derived from the CI runner system + +--- + +## Architecture: State Machine + Driver + Process-as-Actor + +### Data Flow (full picture) + +``` +OS process stdout/stderr + │ (background thread reads pipe) + ▼ + EventQueue (Arc) — shared lock-free buffer + │ (background thread calls ProcessWaker → ExternalSender → PollTick) + ▼ + Actor handle(PollTick) + │ calls driver.poll() which drains EventQueue + ▼ + Vec + │ + ▼ + session.apply(event) → Vec + │ + ├─ Driver commands → driver.execute(action) → OS I/O + ├─ Notifications → ctx.send(subscriber, ProcessNotification) + └─ SelfTerminate → ctx.stop_self() +``` + +### The Layers + +| Layer | Purpose | Status | +|-------|---------|--------| +| 1 — ProcessSession | Pure-logic state machine | **Implemented** | +| 2 — ProcessDriver trait + MockDriver | Driver abstraction + test double | **Implemented** | +| 3 — Process Actor + ExternalSender | Swactor integration, waker, event queue | **Implemented** | +| 4 — LocalDriver | `std::process::Command` + pipe I/O + signal | **Implemented** | +| 5 — SshDriver | SSH library + channel I/O | Not started | + +--- + +## Implemented: Layers 1 + 2 (Pure Logic) + +Crate: `crates/process/` (`swactor-process`) + +### Layer 1 — ProcessSession (State Machine) + +The core state machine. Pure logic, no I/O, fully deterministic. + +**States:** `Starting` → `Running` → `Stopping` → `Exited` + +State transitions are monotonic — the state never goes backward. `Exited` is terminal. + +**Construction:** + +```rust +let (session, initial_actions) = ProcessSession::new(spec); +// initial_actions == [SpawnProcess { spec }] +// session.state() == Starting +``` + +**Event loop:** + +```rust +let actions = session.apply(event); +for action in actions { + match action { + ProcessAction::SpawnProcess { .. } | + ProcessAction::WriteStdin { .. } | + ProcessAction::SendSignal { .. } | + ProcessAction::ResizePty { .. } | + ProcessAction::CloseStdin | + ProcessAction::ScheduleKillTimeout { .. } => driver.execute(action), + + ProcessAction::NotifyStarted { subscribers } | + ProcessAction::NotifyOutput { subscribers, .. } | + ProcessAction::NotifyExited { subscribers, .. } | + ProcessAction::NotifyError { subscribers, .. } => { /* send to subscribers */ } + + ProcessAction::SelfTerminate => { /* actor stops itself */ } + } +} +``` + +**Key invariants (all verified by property-based tests):** +- Invalid events produce `NotifyError` actions — never panic +- `SelfTerminate` is always the last action when entering `Exited` +- State monotonicity: Starting ≤ Running ≤ Stopping ≤ Exited +- Subscriber count always matches add/remove operations +- No panics for arbitrary event sequences + +**Event handling by state:** + +| Event | Starting | Running | Stopping | Exited | +|-------|----------|---------|----------|--------| +| Started | → Running (+ NotifyStarted) | error | error | error | +| SpawnFailed | → Exited (+ NotifyError + SelfTerminate) | error | error | error | +| OutputReceived | error | NotifyOutput | NotifyOutput | error | +| Exited | error | → Exited (+ NotifyExited + SelfTerminate) | → Exited (+ NotifyExited + SelfTerminate) | error | +| ConnectionLost | error | → Exited (+ NotifyError + SelfTerminate) | → Exited (+ NotifyError + SelfTerminate) | error | +| WriteStdin | error | WriteStdin (or buffer/error) | error | error | +| SendSignal | error | SendSignal | SendSignal (escalation) | error | +| ResizePty | error | ResizePty | error | error | +| CloseStdin | error | CloseStdin (+ clear buffer) | CloseStdin (+ set flag) | error | +| CloseRequested | set deferred flag | → Stopping (+ SendSignal Terminate [+ ScheduleKillTimeout]) | no-op | error | +| KillTimeout | silent | silent | SendSignal Kill | silent | +| Subscribe | add subscriber | add subscriber | add subscriber | add subscriber | +| Unsubscribe | remove subscriber | remove subscriber | remove subscriber | remove subscriber | +| StdinWritten | update flow | update flow + drain buffer | update flow | update flow | +| SignalSent | silent | silent | silent | silent | +| PtyResized | silent | silent | silent | silent | + +**Special behaviors:** +- **Close-before-start:** If `CloseRequested` arrives in `Starting`, a flag is set. When `Started` arrives, the session transitions through Running straight to Stopping and emits `SendSignal(Terminate)` (plus `ScheduleKillTimeout` if configured). +- **Kill timeout:** When `spec.kill_timeout` is `Some(duration)`, entering `Stopping` emits `ScheduleKillTimeout { duration }` alongside `SendSignal(Terminate)`. If the process hasn't exited when the timeout fires, the `KillTimeout` event triggers `SendSignal(Kill)`. `KillTimeout` in non-Stopping states is silently consumed (harmless late arrival after the process already exited). +- **Backpressure:** When `spec.stdin_buffer_limit` is `Some(limit)` and `pending_stdin_bytes >= limit`, `WriteStdin` events are buffered in a `VecDeque` instead of emitting actions. When `StdinWritten` acks reduce `pending_stdin_bytes` below the limit, buffered writes drain in FIFO order. The buffer is cleared on `CloseRequested`, `CloseStdin`, `ConnectionLost`, and `Exited`. When `stdin_buffer_limit` is `None`, all writes pass through immediately (original behavior). +- **FlowControl:** `pending_stdin_bytes` is incremented on `WriteStdin` emission, decremented on `StdinWritten` receipt (saturating). +- **Stdin closed:** Once `CloseStdin` is applied, further `WriteStdin` events produce `InvalidState` errors. Duplicate `CloseStdin` is a no-op. Closing stdin also clears any buffered writes. +- **Late acks in Exited:** `StdinWritten`, `SignalSent`, `PtyResized`, and `KillTimeout` are silently consumed in all states (including Exited) — they never produce errors. + +### Types + +**ProcessSpec** — describes how to spawn a process: +- `command: String`, `args: Vec`, `env: HashMap` +- `working_dir: Option`, `mode: ProcessMode`, `initial_pty_size: Option` +- `kill_timeout: Option` — escalate SIGTERM → SIGKILL after this duration (None = no escalation) +- `stdin_buffer_limit: Option` — buffer stdin writes when pending bytes exceed limit (None = unlimited) + +**ProcessMode** — `Interactive` | `Automated` (Copy) + +**ExitStatus** — `Code(i32)` | `Signal(i32)` | `Unknown` (Copy) + +**Signal** — `Terminate` | `Kill` | `Hangup` | `Interrupt` | `Other(i32)` (Copy) + +**ProcessError** — `SpawnFailed { reason }` | `ConnectionLost { reason }` | `InvalidState { attempted, current_state }` + +**OutputStream** — `Stdout` | `Stderr` (Copy) + +**SubscriberSet** — deduplicated `Vec` with linear-scan dedup. Methods: `add()`, `remove()`, `snapshot()`, `count()`. + +### Layer 2 — ProcessDriver Trait + MockDriver + +```rust +pub trait ProcessDriver: Send { + fn execute(&mut self, action: ProcessAction); + fn poll(&mut self) -> Vec; +} +``` + +**MockDriver** — test-oriented implementation: +- `inject(event)` / `inject_many(events)` — queue events for `poll()` +- `executed_actions()` — view recorded actions +- `take_executed_actions()` — take + clear recorded actions +- `pending_event_count()` — number of queued events +- `poll()` drains all pending events, `execute()` records actions + +--- + +## Implemented: Layers 3 + 4 (Actor Integration + Local OS Processes) + +### ExternalSender (swactor core primitive) + +A `Clone + Send + Sync` handle for injecting messages into actor mailboxes from any thread. Lives in the `swactor` crate (because `Envelope` and `AddressMap` are `pub(crate)`). + +```rust +// Create from a runtime +let sender = runtime.create_sender(); + +// Use from any thread (including I/O background threads) +sender.send_to(actor_addr, MyMessage { ... })?; +``` + +**Implementation:** Clones of the runtime's `Arc`, per-worker `Sender` channels, and `Arc>>` for worker thread unparking. The `send_to` method looks up the actor's worker, pushes an envelope, and unparks the worker thread. + +**Changes to swactor core:** +- `src/channel.rs` — Added `Clone` for `Sender` (clones the inner `Arc`) +- `src/runtime.rs` — Changed `worker_threads` from `Vec>` to `Arc>>`, added `ExternalSender` struct and `Runtime::create_sender()` factory + +### Layer 3 — Process Actor + +**`ProcessActor`** — generic actor implementing `ActorInterface` with `Incoming = ProcessCommand`. + +**Message types:** + +```rust +pub enum ProcessCommand { + WriteStdin { data: Vec }, + SendSignal { signal: Signal }, + ResizePty { size: PtySize }, + CloseStdin, + Close, + Subscribe { address: ActorAddress }, + Unsubscribe { address: ActorAddress }, + PollTick, // internal: sent by waker from I/O threads +} + +pub enum ProcessNotification { + Started { process: ActorAddress }, + Output { process: ActorAddress, data: Vec, stream: OutputStream }, + Exited { process: ActorAddress, status: ExitStatus }, + Error { process: ActorAddress, error: ProcessError }, +} +``` + +**Handle ordering:** Commands are processed first, then I/O events are drained. This ensures `Subscribe` registers the subscriber before `Started` (or other buffered events) get dispatched. `PollTick` has no command effect — it just triggers the drain. + +**Event queue (`EventQueue`):** Thin wrapper around `Arc>`. I/O threads push events; `driver.poll()` drains them. + +**Waker (`ProcessWaker`):** `Arc` — constructed with a closure that sends `PollTick` via `ExternalSender`. I/O threads call `waker.wake()` after pushing events. + +**Factory functions:** + +```rust +// Spawn with real OS subprocess +let addr = spawn_local_process(ctx, &sender, spec)?; + +// Spawn with custom driver (for testing) +let addr = spawn_process(ctx, &sender, spec, driver, waker_slot)?; +``` + +The factory creates the driver, session, and actor, spawns it, then fills the waker slot with a closure that sends `PollTick` to the actor's address. + +### Layer 4 — LocalDriver + +Real OS process management via `std::process::Command` with piped I/O. + +**Components:** + +| File | Purpose | +|------|---------| +| `local/mod.rs` | `LocalDriver` struct, `ProcessDriver` impl, process spawning | +| `local/pipes.rs` | Background thread reading stdout/stderr pipes (8KB buffer) | +| `local/signal.rs` | `Signal` → libc constant mapping, `kill()` wrapper | +| `local/wait.rs` | Background `waitpid()` thread with WIFEXITED/WIFSIGNALED decoding | + +**Thread structure per process:** +- 1 stdout reader thread +- 1 stderr reader thread +- 1 waitpid thread + +Each thread pushes events to the shared `EventQueue` and calls `waker.wake()`. + +**Drop behavior:** Closes stdin, kills the process, waits for exit. + +**PTY support:** Not yet implemented — `ResizePty` is a no-op that returns a `PtyResized` ack. Pipe-based I/O only in this phase. + +--- + +## File Structure + +``` +swactor (root crate): + src/ + channel.rs — + Clone for Sender + runtime.rs — + ExternalSender, create_sender(), Arc + +crates/process/ (swactor-process): + Cargo.toml — + crossbeam-queue, libc deps + src/ + lib.rs — module declarations + re-exports + types.rs — ProcessSpec, ProcessMode, ExitStatus, Signal, PtySize, etc. + event.rs — ProcessEvent enum + action.rs — ProcessAction enum + OutputStream + subscriber.rs — SubscriberSet + session.rs — ProcessSession state machine + driver.rs — ProcessDriver trait + mock.rs — MockDriver + queue.rs — EventQueue (Arc) + waker.rs — ProcessWaker (Arc) + message.rs — ProcessCommand, ProcessNotification + actor.rs — ProcessActor impl ActorInterface + spawn.rs — spawn_local_process(), spawn_process() factory functions + local/ + mod.rs — LocalDriver struct + ProcessDriver impl + pipes.rs — Pipe reader background threads + signal.rs — OS signal delivery + wait.rs — waitpid background thread + tests/ + session_scenarios.rs — 26 session state machine scenario tests + proptest_session.rs — 5 property-based session tests (KillTimeout included in arb_event) + actor_scenarios.rs — 6 actor integration tests (TestDriver) + local_driver.rs — 6 LocalDriver integration tests (real processes) + e2e_process.rs — 2 end-to-end tests (Runtime + LocalDriver + real processes) +``` + +--- + +## Test Coverage + +### Layers 1 + 2 — Session + MockDriver (31 tests) + +**Scenario tests** (26 tests in `tests/session_scenarios.rs`): +1. Happy path automated: new → Started → OutputReceived×N → Exited(0) +2. Interactive session with subscriber lifecycle (add/remove, verify notification membership) +3. Spawn failure → error notification + SelfTerminate +4. Connection loss mid-run → Exited with Unknown status +5. Close before start → deferred SIGTERM on belated start +6. Invalid event in Starting → NotifyError (no panic) +7. Invalid event in Exited → NotifyError (no panic) +8. Stdin closed then write → NotifyError +9. MockDriver round-trip (driver + session in simulated tick loop) +10. Signal escalation in Stopping (Kill after Terminate) +11. Late acks in Exited silently consumed +12. Flow control tracks pending stdin bytes (including saturating subtract) +13. CloseStdin allowed in Stopping +14. Connection loss in Stopping → Exited +15. Duplicate CloseRequested in Stopping → no-op +16. CloseRequested with kill_timeout emits both SendSignal{Terminate} and ScheduleKillTimeout +17. Close-before-start with kill_timeout schedules timer on belated start +18. KillTimeout in Stopping → SendSignal{Kill}, state stays Stopping +19. KillTimeout silently consumed in Starting, Running, Exited +20. CloseRequested without kill_timeout emits no ScheduleKillTimeout +21. Full escalation flow: CloseRequested → KillTimeout → Exited{Signal(9)} +22. Backpressure buffers writes when pending bytes exceed limit +23. StdinWritten ack drains buffered chunks in FIFO order +24. CloseRequested clears stdin buffer +25. No backpressure when limit is None (all writes pass through) +26. Exited clears stdin buffer + +**Property-based tests** (5 tests in `tests/proptest_session.rs`): +1. No panics for arbitrary event sequences (up to 50 events, including KillTimeout) +2. Exited is terminal (state never leaves Exited) +3. SelfTerminate always last action when entering Exited +4. Subscriber count matches add/remove operations +5. State monotonicity (state ordinal never decreases) + +### Layer 3 — Actor Integration (6 tests) + +Tests in `tests/actor_scenarios.rs` using a `TestDriver` (shared `EventQueue` + recorded actions): + +1. **Happy path** — spawn → Started → Output → Exited → subscriber gets all notifications → actor stops +2. **PollTick drains queued events** — three events buffered, single PollTick delivers all three notifications +3. **Close triggers graceful shutdown** — Close command produces SIGTERM via driver +4. **WriteStdin/SendSignal forwarded** — commands reach the driver as actions +5. **Spawn failure** — error notification sent to subscriber, actor self-terminates +6. **Subscribe/Unsubscribe routing** — two subscribers, unsubscribe one, only remaining gets subsequent notifications + +### Layer 4 — LocalDriver Integration (6 tests) + +Tests in `tests/local_driver.rs` using real OS processes, no actor layer: + +1. **`echo hello`** — Started + OutputReceived("hello\n") + Exited(0) +2. **`cat` stdin echo** — write "ping\n" → read "ping\n" back → close stdin → Exited(0) +3. **`sleep 60` + SIGTERM** — Started → send Terminate → Exited(Signal) +4. **Bad command** → SpawnFailed +5. **`seq 1 10000`** — large output integrity (no data loss, correct start/end) +6. **Kill timeout escalation** — spawn SIGTERM-ignoring process, ScheduleKillTimeout fires KillTimeout, SIGKILL terminates it + +### End-to-End (2 tests) + +Tests in `tests/e2e_process.rs` — full stack (Runtime + ExternalSender + ProcessActor + LocalDriver + real process): + +1. **`echo hello` lifecycle** — spawn, subscribe, verify Started → Output("hello") → Exited(0) in order +2. **Bad command** — spawn nonexistent binary, verify Error notification arrives + +--- + +## Design Decisions Made + +1. **ExternalSender over WorkerExtension:** The I/O → actor bridge is a general-purpose swactor core primitive, not process-specific. Any crate can use `ExternalSender` to inject messages from background threads. + +2. **Handle ordering (command first, then drain):** Processing the incoming command before draining I/O events ensures that `Subscribe` registers the subscriber before buffered events (like `Started`) are dispatched. This avoids a race where early lifecycle events are sent to an empty subscriber list. + +3. **ProcessActor is generic over `D: ProcessDriver`:** Enables testing with `TestDriver` while production uses `LocalDriver`. No trait object overhead. + +4. **Thread-per-pipe model:** Each LocalDriver spawns 3 threads (stdout reader, stderr reader, waitpid). Simple, debuggable, correct for Phase 1 (tens of processes). + +5. **EventQueue is lock-free:** Uses `crossbeam_queue::SegQueue` — no contention between I/O writer threads and the actor's poll draining. + +6. **Waker uses OnceLock:** The waker slot (`Arc>`) is filled after the actor address is known. I/O threads that call `waker.get()` before it's set simply skip the wake — events accumulate in the EventQueue and are drained on the next message. + +--- + +## Next Steps + +### Near-term + +1. **PTY support for Interactive mode** — The `LocalDriver` currently uses pipes only. Interactive mode needs PTY allocation (via raw libc: `openpty()` → `fork()` → `setsid()` + `ioctl(TIOCSCTTY)` + `dup2` + `execvp`), `SIGWINCH` for resize, and merged stdout/stderr on a single PTY master FD. The `ResizePty` action is already wired through as a no-op. + +2. **Output buffering policies** — Subscribers currently receive every raw byte chunk. Add optional line-buffering or size-buffering in the session layer for consumers that want complete lines. + +### Layer 5 — SshDriver + +SSH-based process management. Same `ProcessDriver` trait, different backend. + +**Open decisions:** +- **SSH library:** `russh` (pure Rust, async — needs tokio bridge) vs. `ssh2` (libssh2 bindings, synchronous — fits the thread model naturally) +- **Authentication:** Password, key file, agent forwarding, or pluggable credential provider +- **Connection multiplexing:** One SSH connection per process actor, or connection pool with multiple channels +- **Health monitoring:** Heartbeat/keepalive to detect connection drops → `ConnectionLost` events + +### Scaling Path + +The architecture isolates scaling concerns in the driver layer: + +- **Phase 1 (tens):** Each driver spawns OS threads for I/O. Simple, debuggable. ← **current** +- **Phase 2 (hundreds):** Shared thread pool for driver I/O. Replace per-process threads with a pool that multiplexes reads across processes. +- **Phase 3 (thousands):** Async internals (tokio tasks for I/O). State machine and actor layers unchanged — only `ProcessDriver` implementations change. + +--- + +## Alternative Approaches Considered + +### WorkerExtension Approach + +Managing processes as a per-worker extension (like TimerWheel). Rejected because: +- Ties processes to specific workers, complicating supervision +- Processes can't benefit from the actor model's naming, grouping, and monitoring +- The API would be less intuitive than "send a message to the process" +- Tick-bound latency is problematic for interactive use + +### Pure Bridge Actor Approach + +A single centralized bridge actor owning all processes (like IrohDriver). Rejected as the primary design because: +- Doesn't give individual processes actor identity — can't supervise, name, or monitor them independently +- Centralizes failure — the bridge dying kills all processes +- However, this pattern does appear inside the recommended approach: the driver layer within each process actor is essentially a tiny bridge + +### Pure Process-as-Actor (without state machine) + +Just actors with embedded I/O logic, no state machine separation. Rejected because: +- Untestable without real processes or SSH connections +- Can't simulate +- Backend-specific logic (SSH vs. local) interleaved with lifecycle logic diff --git a/STREAMS.md b/STREAMS.md new file mode 100644 index 0000000..1876c0a --- /dev/null +++ b/STREAMS.md @@ -0,0 +1,475 @@ + Swactor Stream Primitive -- Architectural Design + + Context + + Swactor has a distributed actor runtime with SWIM membership, Kademlia routing, and a content-addressed datastore. The current datastore + transfers blobs one chunk at a time via actor message round-trips -- extremely slow for large objects. Beyond the datastore, the system + needs a general-purpose bulk data transfer primitive for ML workloads (training data, weight checkpoints, gradient exchange), real-time + media (video/voice), and future game state replication. + + The stream primitive is a high-performance data channel between nodes that actors negotiate and manage but do not sit on the data path of. + It should achieve top-class throughput by leveraging QUIC's multiplexed streams directly, bypassing the actor mailbox system for data + transfer. + + Decisions made: + - Data path: StreamHandle with try_read/try_write; actors receive lightweight notification messages but data bypasses mailboxes + - Reliability: Reliable-only MVP; abstraction designed so unreliable (QUIC datagrams) can be added later + - Locality: Cross-node only; same-node actors use regular messages + - Crate: New crates/streams/ crate + + --- + 1. Core Concept: Control Plane vs Data Plane + + The fundamental architecture separates stream management (control plane) from data transfer (data plane). + + Control plane -- actor messages through normal mailboxes: + - Stream negotiation (open, accept, reject) + - Parameter configuration (buffer sizes, chunk sizes, parallelism) + - Lifecycle events (established, closed, error) + - Progress/health notifications + + Data plane -- bypasses actors entirely: + - Raw bytes flow through QUIC streams on the iroh transport + - Managed by async tasks on the IrohDriver's tokio runtime + - Actors interact via StreamHandle objects (try_read/try_write), not mailbox messages + - QUIC's built-in flow control handles backpressure + + CONTROL PLANE (actor messages, mailboxes, worker ticks) + +--------+ StreamOpen +-----------+ StreamAccept +--------+ + | Actor | -----------> | Stream | <------------- | Actor | + | (nodeA)| | Manager | |(nodeB) | + +--------+ +-----------+ +--------+ + | | | + | StreamReady(handle) | | StreamReady(handle) + v v v + DATA PLANE (tokio tasks, QUIC streams, pre-allocated buffers) + +----------+ bytes +----------+ bytes +----------+ + | SendHalf | =========> | QUIC | =========> | RecvHalf | + | (writer) | N parallel| streams | N parallel | (reader) | + +----------+ stripes +----------+ stripes +----------+ + + --- + 2. Stream Identity and Addressing + + StreamId: A 16-byte random identifier, generated by the initiator during negotiation. Deliberately not an ActorAddress -- streams are not + actors, are not placed on workers, and are not discoverable via Kademlia. Keeping them out of the AddressMap avoids polluting the actor + routing hot path. + + Full stream address: The tuple (NodeId, StreamId) is globally unique. A node can host many concurrent streams to many peers. + + ALPN separation: Streams use a new protocol identifier swactor/stream/1, separate from the existing swactor/swim/1 used for membership. + This means: + - The iroh accept loop can distinguish stream connections from protocol messages immediately + - Stream data never blocks or interferes with cluster heartbeats + - Stream connections can have different tuning in the future + + --- + 3. QUIC Stream Utilization + + Parallel Stripes for Blob Transfers + + For a single large transfer, multiple QUIC streams are opened in parallel on the same QUIC connection. Each stream carries a disjoint + range of the data. This is the stripe count, negotiated during handshake (default: 4). + + Why multiple streams? A single QUIC stream can be limited by per-stream receive-window backpressure. Multiple streams allow the sender to + push data into QUIC's send buffer more aggressively, keeping the congestion window filled. Measurements from quinn/s2n-quic show 2-8 + parallel streams can improve throughput 2-4x on high-bandwidth-delay-product links. + + Stream layout per transfer: + - Stream 0 (control stream): Bidirectional QUIC stream. Carries the handshake header and out-of-band signals (completion, cancel, errors, + health). Stays open for the transfer's lifetime. + - Streams 1..N (data stripes): Unidirectional QUIC streams, each carrying sequential chunks. Stripe assignment is round-robin by chunk + index. + + Connection Reuse + + Multiple concurrent streams between the same two nodes share one QUIC connection (on the stream ALPN). QUIC multiplexing handles this + natively. The streams crate maintains a connection cache separate from the SWIM connection cache. + + --- + 4. Wire Format + + Two layers of wire format: the stream-level protocol (negotiation + data framing) and the blob transfer application protocol that rides on + top of it. + + Control Stream Header (stream-level) + + [2B magic: 0x53 0x57] -- "SW" + [1B version: 0x01] + [16B StreamId] + [1B mode] -- 0x01=BlobTransfer, 0x02=ContinuousStream (future) + [1B stripe_count] -- parallel data stripes (1-255) + [4B frame_size (BE u32)] -- maximum frame payload size in bytes + [4B metadata_len (BE u32)] + [N bytes metadata] -- negotiation payload (e.g., ContentHash for blob transfer) + + Data Stripe Frame Format (stream-level) + + [4B frame_len (BE u32)] -- 0 = end-of-stripe + [N bytes payload] -- raw data bytes + + Deliberately minimal. No per-frame type tags (QUIC provides ordered reliable delivery), no per-frame checksums on the wire (QUIC provides + TLS integrity for transport), no per-frame metadata. Every byte of overhead on the hot path costs throughput. + + BlobTransfer Application Protocol + + For blob transfers, the `StreamConfig.metadata` carries the 32-byte `ContentHash` of the requested blob (so the serve side knows what to + send). The actual blob data flows over the StreamHandle with this application-level framing: + + [4B manifest_json_length (u32 BE)] + [N bytes manifest JSON] -- serialized ObjectManifest + [chunk_0 raw bytes] -- size from manifest.chunks[0].size + [chunk_1 raw bytes] -- size from manifest.chunks[1].size + ... + + The receiver knows each chunk's expected size and blake3 hash from the manifest. Each chunk is verified individually on arrival: + blake3(chunk_data) == chunk_ref.hash. Corrupted chunks cause immediate transfer failure. This is implemented by the `send_blob` and + `recv_blob` async functions in `crates/datastore/src/blob_transfer.rs`. + + Note: the blob transfer protocol sends chunks sequentially through the StreamHandle, which distributes data frames across stripes via the + data-plane layer's round-robin. Individual chunks are not split across stripes -- the stripe layer is transparent to the application + protocol. + + --- + 5. Buffering Strategy + + Pre-allocated Sliding Window (Zero Allocation on Hot Path) + + The buffer pool is a sliding window, not a store. It never holds the entire blob in memory -- data flows through it like water through a + pipe. A 1TB transfer uses the same ~4MB of buffer memory as a 1MB transfer; only the duration changes. + + All buffers are allocated during stream setup, not per-frame. + + Sender pipeline (per stripe, double-buffered): + Source (disk/memory/computation) + → [Buffer A: being filled from source] + → [Buffer B: being written to QUIC] + → Buffer B recycled → becomes the next Buffer A + → repeat until source exhausted + One buffer is being filled while the other is being sent. When QUIC accepts Buffer B's bytes, it's recycled and refilled from the source. + The source can be disk I/O, a computation producing data, or anything that yields bytes. + + Receiver pipeline (per stripe, double-buffered): + QUIC recv stream + → [Buffer A: being filled from QUIC] + → [Buffer B: being written to disk/consumed] + → Buffer B recycled → becomes the next Buffer A + → repeat until stream ends + The receiver reads from QUIC into one buffer while the previous buffer is being written to disk (for blob transfer) or consumed by the + application. Buffers are recycled, never allocated mid-transfer. + + Backpressure chain (end-to-end): + Source read speed + → fills sender buffer pool (2 per stripe) + → QUIC congestion window + → network bandwidth + → QUIC receive window + → fills receiver buffer pool (2 per stripe) + → sink write speed (disk I/O, consumer processing) + + If ANY link is slow, pressure propagates backward automatically. + No custom flow control needed -- QUIC handles it. + + Sizing: + - Pool: stripe_count * 2 buffers per side = 8 buffers (at 4 stripes) + - Frame size: 256KB per frame (separate from the datastore's 1MB storage chunk size) + - Total memory per stream direction: 8 x 256KB = 2MB + - Total for a bidirectional transfer: ~4MB, regardless of blob size + - At ~1200 bytes per QUIC packet, 256KB = ~213 packets. Smaller blast radius on packet loss than 1MB, better interleaving across stripes, + aligns with OS page sizes. + + TB-Scale Considerations + + For very large transfers (100GB+ ML weights, TB-scale training data), additional design considerations apply: + + Manifest streaming: At 1MB datastore chunks, a 1TB blob has ~1M chunks. Each ChunkRef is ~40 bytes, so the manifest is ~40MB. This is too + large for a single negotiation payload. The current implementation sends the manifest as a JSON preamble on the data stream itself (not in + the negotiation metadata). For TB-scale, the manifest could be streamed progressively instead of loaded all at once. + + Per-chunk verification on arrival: The receiver verifies each chunk individually as it arrives: blake3(chunk_data) == chunk_ref.hash. + This is implemented in `recv_blob`. A corrupted chunk causes immediate transfer failure. This catches problems early rather than waiting + for full reassembly. + + Progressive resume tokens: Resume tokens are emitted periodically (e.g., every 1000 chunks or every 256MB, whichever comes first), not + just on failure. The sender acknowledges receipt of resume tokens. On connection loss, the receiver persists the latest resume token, and + a new stream can resume from that point. For a 1TB transfer, a resume token with a 1M-bit BitVec is ~125KB -- cheap to exchange. + (Not yet implemented -- the ResumeToken type exists but nothing emits or consumes it.) + + Disk I/O as the bottleneck: For TB-scale over fast networks (10Gbps+), disk I/O often becomes the bottleneck rather than the network. The + buffering strategy handles this naturally: when disk writes slow down, the receiver's buffer pool fills, QUIC backpressure kicks in, and + the sender slows to match. No special handling needed -- the pipeline self-regulates. For maximum disk throughput, the receiver can use + O_DIRECT or memory-mapped writes, but this is an implementation optimization, not an architectural decision. + + Stripe count scaling: For very high bandwidth links, the default 4 stripes may not be enough to saturate the connection. The stripe count + should be configurable up to 16, negotiated during handshake based on the expected transfer size and link characteristics. A heuristic: + min(16, max(4, total_chunks / 1000)) -- more stripes for larger transfers. + + --- + 6. StreamHandle -- The Actor-Facing API + + The StreamHandle is a lightweight, Send (but not Clone) object that actors store in their state. It communicates with the data-plane tokio + tasks via channels internally. + + Writer interface: + - try_write(data: &[u8]) -> Result -- Non-blocking. Returns bytes accepted. + - flush() -- Signal that buffered data should be sent. + - close() -- Graceful close. + + Reader interface: + - try_read(buf: &mut [u8]) -> Result -- Non-blocking. Returns bytes read, 0 if none available. + - has_data() -> bool -- Check if data is available without consuming it. + + BlobTransfer Async Functions + + Rather than a wrapper object, blob transfer uses standalone async functions that run inside tokio tasks (spawned after StreamReady). These + functions loop over try_write/try_read with tokio::task::yield_now() for cooperative scheduling: + + - send_blob(send, manifest, read_chunk) -- Writes the manifest preamble, then calls read_chunk(hash) for each chunk on-demand and writes + it. At most one chunk is in memory at a time on the sender side. The read_chunk callback allows any data source (BlobStore via Inbox, + in-memory, etc). + - recv_blob(recv) -- Reads the manifest, then reads and blake3-verifies each chunk. Returns ReceivedBlob { manifest, chunks }. + - poll_inbox(inbox, timeout) -- Async version of the bridge.rs poll_response pattern. Yields instead of thread::sleep. + + These live in crates/datastore/src/blob_transfer.rs. The key insight: since actors can't await futures, the pattern is for the actor to + receive StreamReady, extract the StreamHandle via OneShot::take(), spawn a tokio task for the I/O loop, then stop itself. The tokio task + sends results back to other actors via runtime.send_to(). + + Why non-blocking? Actor handlers are synchronous (fn handle(&mut self, ctx: &Ctx, msg)). They cannot await futures. The try_read/try_write + pattern fits naturally. The tokio task bridge is the mechanism for async I/O. + + --- + 7. Actor Integration: Negotiation Protocol + + Opening a Stream (Initiator) + + 1. Actor sends a StreamOpen control message (through normal actor mailbox routing) to a StreamManager system actor. Contains: target_node: + NodeId, mode, metadata (e.g., ContentHash + manifest for blob transfer), reply_to: ActorAddress. + 2. StreamManager validates the request, allocates a StreamId, and posts an async task to the tokio runtime that: + - Opens a QUIC connection to the target (stream ALPN) + - Opens the control bidirectional stream + - Sends the stream header + - Waits for accept/reject + 3. On accept: StreamManager sends StreamReady { stream_id, handle: StreamHandle } back to the requesting actor. + + Accepting a Stream (Receiver) + + 1. IrohDriver's accept loop receives connection on stream ALPN. + 2. Reads control stream header, extracts StreamId + mode + metadata. + 3. Sends StreamIncoming actor message to local StreamManager. + 4. StreamManager routes to registered stream acceptors (actors that called StreamListen). + 5. Matching actor receives StreamOffer { stream_id, mode, metadata } in its mailbox. + 6. Actor sends StreamAccept or StreamReject back to StreamManager. + 7. On accept: StreamManager allocates buffers, spawns data-plane tasks, sends StreamReady { handle } to the accepting actor. + + Notification Model (Hybrid) + + Stream data bypasses mailboxes, but actors need to know when data is available: + + - The data-plane tasks inject lightweight StreamEvent sentinel messages into the owning actor's mailbox when state changes: DataReady, + WriteReady, Closed, Error. + - Coalescing: An atomic flag prevents duplicate notifications. Set when notification posted, cleared when actor handles it. A + high-throughput stream generates at most one DataReady per actor tick, not one per frame. + - The actor's handle_any dispatches StreamEvent via downcast (same mechanism as Down and ActorExited today -- no core trait changes + needed). + - Actors can also proactively call handle.try_read() from any handler, not just in response to DataReady. + + --- + 8. The StreamManager Actor + + A system actor spawned alongside the IrohDriver, registered under a well-known name. It is the bridge between the actor world and the + stream data plane. + + Responsibilities: + - Registry of active streams: StreamId -> StreamState + - Handle StreamOpen / StreamAccept / StreamReject / StreamListen / StreamClose messages + - Spawn and supervise data-plane tokio tasks + - Monitor stream-holding actors; clean up streams when actors die + - Expose stream metrics (active streams, throughput, errors) for the dashboard + + Communication with tokio runtime: Uses tokio::sync::mpsc and tokio::sync::oneshot channels. Posts commands to async tasks, receives + results as actor messages (via the Inbox pattern already used by DatastoreBridge). + + --- + 9. Flow Control and Backpressure + + Three layers, all leveraging what QUIC already provides: + + 1. QUIC-level: Per-stream and per-connection flow control (receive window, congestion window). This is the primary mechanism. Not + duplicated. + 2. Buffer pool saturation: When receiver's pre-allocated buffer pool is full, the recv-side tokio task stops reading from QUIC. QUIC's + receive window closes, sender stops transmitting. Natural backpressure without custom protocol. + 3. StreamHandle backpressure: try_write() returns 0 bytes accepted when the send buffer is full. The actor knows to back off or buffer + internally. + + No custom flow control protocol. QUIC's congestion control (Cubic/BBR) is battle-tested. Adding application-level flow control would add + complexity and latency without benefit. + + Cancellation + + - Cooperative: StreamCancel signal on the control stream + - Abrupt: reset() on the QUIC streams + - Nuclear: close the QUIC connection (node shutdown only) + + --- + 10. Error Handling and Recovery + + Failure Modes + + ┌──────────────────┬──────────────────────┬────────────────────────────────────────────────┐ + │ Failure │ Detection │ Behavior │ + ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ + │ Frame corruption │ QUIC TLS + checksums │ Automatic retransmit │ + ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ + │ Stream reset │ QUIC RST_STREAM │ StreamEvent::Error to owning actor │ + ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ + │ Connection loss │ QUIC timeout │ StreamEvent::Error on all streams to that node │ + ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ + │ Node death │ SWIM declares Dead │ StreamEvent::Error on all streams to that node │ + ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ + │ Owner actor dies │ Worker cleanup phase │ Stream closed, remote side notified │ + └──────────────────┴──────────────────────┴────────────────────────────────────────────────┘ + + Resume Tokens for Blob Transfers (Not Yet Implemented) + + For large transfers, the receiver periodically emits a ResumeToken on the control channel: + + ResumeToken { + stream_id: StreamId, + manifest_hash: ContentHash, + chunks_received: BitVec, -- which chunks confirmed stored + } + + On failure, the initiator can open a new stream with the ResumeToken. The sender skips confirmed chunks. This avoids retransmitting + terabytes when a checkpoint transfer fails near completion. Leverages the existing ObjectManifest/ChunkRef model. + + The ResumeToken type is defined in crates/streams/src/types.rs but emission/consumption logic is deferred to a future stage. + + --- + 11. Integration with Existing Datastore + + The stream primitive adds a parallel transfer path to the datastore. The existing chunk-at-a-time TransferActor is preserved for + compatibility; the new stream path is used when stream support is configured. + + Architecture: + + DOWNLOAD SIDE: SERVE SIDE: + + DatastoreNode StreamListener + │ DownloadViaStream (Incoming = StreamNotification) + │ ctx.spawn(StreamDownloader) │ on StreamOffer → HandleStreamOffer + ▼ ▼ + StreamDownloader DatastoreNode + (Incoming = StreamNotification) │ HandleStreamOffer + │ on_start: Open → StreamManager │ ctx.spawn(StreamServer) + │ StreamReady → tokio task: ▼ + │ recv_blob → verify → write chunks StreamServer + │ send completion to DatastoreNode (Incoming = StreamNotification) + ▼ │ on_start: Accept → StreamManager + DatastoreNode │ StreamReady → tokio task: + │ StreamDownloadComplete │ read manifest from BlobStore (Inbox) + │ persist metadata, reply to caller │ for each chunk: read from BlobStore, + │ write to stream (one at a time) + │ close stream + + Design principles: + - No bridge/shim actors. Stream-facing actors use Incoming = StreamNotification directly. + - No preloading all chunks into memory. Chunks flow on-demand: storage → network. + - DatastoreNode stays simple (fire-and-forget coordination). The stream actors own the full I/O lifecycle. + - After receiving StreamReady, actors spawn tokio tasks for I/O. Tokio tasks communicate results back via runtime.send_to(). + - StreamServer reads chunks on-demand — at most one chunk in memory at a time. + + Flow: + 1. DatastoreNode receives DownloadViaStream { content_hash, source_node, reply_to }. + 2. Spawns a StreamDownloader actor, which sends Open to StreamManager with metadata = content_hash.0 (32 bytes). + 3. Remote StreamListener receives StreamOffer, extracts ContentHash from metadata, sends HandleStreamOffer to DatastoreNode. + 4. Remote DatastoreNode spawns a StreamServer actor, which sends Accept to StreamManager. + 5. StreamServer receives StreamReady, spawns tokio task: reads manifest from BlobStore, then streams each chunk on-demand via send_blob. + 6. StreamDownloader receives StreamReady, spawns tokio task: calls recv_blob, writes chunks to BlobStore (fire-and-forget), notifies + DatastoreNode of completion. + 7. DatastoreNode creates ObjectEntry and persists via MetadataActor, which sends PutOk to the original caller. + + This eliminates the round-trip-per-chunk bottleneck. A 1GB object with 1MB chunks currently requires 1,024 sequential round-trips. With + streams and 4 parallel stripes, the entire blob flows in a single burst limited only by network bandwidth. + + --- + 12. Growth Path + + Phase 1 (MVP): Reliable Ordered Blob Transfer — IMPLEMENTED + + - StreamConfig with BlobTransfer mode only + - New ALPN swactor/stream/1 handler + - StreamOpen/StreamAccept handshake + - Parallel striped data transfer + - StreamHandle with try_read/try_write + - StreamManager actor + - Datastore integration (StreamListener, StreamDownloader, StreamServer actors) + - BlobTransfer application protocol (send_blob/recv_blob with per-chunk blake3 verification) + + Remaining MVP work: + - Two-node integration test (real QUIC, full download flow) + - Resume tokens (ResumeToken type exists, emission/consumption not yet wired) + + Phase 2: Continuous Streams + + - ContinuousStream mode (no total size known) + - Single bidirectional QUIC stream (no striping) + - Variable-sized message frames + - Bounded ring buffer backpressure + - Enables: federated learning gradient streams, data pipelines + + Phase 3: Unreliable Datagrams + + - UnreliableSequenced reliability mode using QUIC datagrams + - Sequence-based frame dropping (latest-wins) + - Receiver-side jitter buffer + - Advisory StreamThrottle on control channel + - Enables: voice/video, game entity state replication + + Phase 4: Priority and QoS + + - priority: u8 in StreamConfig + - Priority-aware write scheduler across concurrent streams + - QUIC stream priority hints + - Per-stream health reporting and dashboard integration + - Enables: simultaneous video + checkpoint without starvation + + Phase 5: Parallel Unordered Transfer + + - ReliableUnordered mode: parallel QUIC streams per chunk, independent delivery + - Configurable parallelism + - Enables: gradient exchange for distributed ML (any chunk consumable independently) + + --- + 13. Key Design Decisions Summary + + ┌───────────────────┬───────────────────────────────────────────────┬────────────────────────────────────────────────────────────┐ + │ Decision │ Choice │ Rationale │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Data path │ StreamHandle bypass, tokio task bridge │ Max throughput; actors manage, don't bottleneck │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Stream identity │ 16-byte StreamId, not ActorAddress │ Streams are not actors; avoid polluting address space │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ ALPN │ Separate swactor/stream/1 │ Isolate from SWIM; no interference with heartbeats │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Parallel stripes │ 4 QUIC streams per blob transfer │ Saturate congestion window on high-BDP links │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Flow control │ QUIC built-in only │ Don't duplicate what the transport does well │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Wire format │ 4-byte length prefix, no type tags │ Minimal per-frame overhead │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Stream chunk size │ 256KB │ Better packet-loss resilience, page-aligned │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Buffering │ Pre-allocated slab per stream │ Zero allocation on hot path │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Blob protocol │ Async functions, not wrapper object │ Simpler; tokio tasks own the I/O loop after StreamReady │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Actor pattern │ Spawn actor → StreamReady → tokio task → stop │ Clean separation; actor negotiates, task does I/O │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Chunk I/O │ On-demand via Inbox polling (poll_inbox) │ At most 1 chunk in memory; no preloading │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Crate │ New crates/streams/ │ Optional, clean dependency graph │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ MVP scope │ Reliable ordered only │ Covers ML + datastore; unreliable added later │ + ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ + │ Locality │ Cross-node only │ Focused scope; same-node uses regular messages │ + └───────────────────┴───────────────────────────────────────────────┴────────────────────────────────────────────────────────────┘ diff --git a/STREAMS_IMPLEMENTATION.md b/STREAMS_IMPLEMENTATION.md new file mode 100644 index 0000000..14406b3 --- /dev/null +++ b/STREAMS_IMPLEMENTATION.md @@ -0,0 +1,338 @@ +# Swactor Streams -- Implementation Status + +## What Was Built + +Stages 1-4 are implemented. Stages 1-3 built the stream primitive in `crates/streams/` (the `swactor-streams` crate). Stage 4 connected streams to the datastore so blob transfers use QUIC streams instead of sequential actor-message round-trips. All 42 tests pass (37 streams + 5 blob_transfer). + +### Stage 1: Types, Wire Format, and Buffer Pool + +Pure Rust -- no tokio, no iroh, no network. Compiles and tests in isolation. + +#### `src/types.rs` + +Core domain types for the stream system. + +- **`StreamId([u8; 16])`** -- 16-byte random identifier. `Copy`, `Hash`, `Eq`, `Serialize`/`Deserialize`. Custom `Debug` (4-byte hex prefix) and `Display` (8-byte hex prefix) following the codebase's ID conventions. Not an `ActorAddress` -- streams are not actors and don't pollute the address space. +- **`StreamMode`** -- enum with `BlobTransfer` variant. Extensible for future modes (continuous streams, datagrams). +- **`StreamConfig`** -- negotiation parameters: `stripe_count` (default 4), `frame_size` (default 256KB), `metadata` (opaque bytes for application-level negotiation payloads like ContentHash). +- **`StreamError`** -- error enum covering `Closed`, `BrokenPipe`, `Disconnected`, `BufferExhausted`, `InvalidHeader`, and `ChunkVerificationFailed` (with expected/actual hashes for diagnostics). +- **`ResumeToken`** -- checkpoint for resuming interrupted transfers, carrying stream identity and progress counters. + +#### `src/wire.rs` + +Binary wire format for stream headers and data frames. Pure functions, no I/O. + +- **Constants**: `MAGIC: [0x53, 0x57]` ("SW"), `VERSION: 0x01`, `ALPN: b"swactor/stream/1"`. +- **`StreamHeader`** -- the negotiation header sent at connection establishment. Wire layout: `[2B magic][1B version][16B stream_id][1B mode][1B stripe_count][4B frame_size][4B metadata_len][N metadata]`. +- **`encode_header` / `decode_header`** -- round-trippable serialization with validation (magic, version, mode, truncation checks). +- **Data frame format**: `[4B payload_len (big-endian)][payload]`. Deliberately minimal -- no per-frame type tags or checksums (QUIC provides TLS integrity). A zero-length payload signals end-of-stripe. +- **`encode_data_frame` / `decode_data_frame` / `encode_end_of_stripe`** -- frame-level codec. + +#### `src/buffer.rs` + +Pre-allocated buffer pool for zero-allocation data transfer. + +- **`FrameBuf`** -- a `Box<[u8]>` with read/write cursors. `write(&[u8]) -> usize` fills from the write cursor, `read(&mut [u8]) -> usize` drains from the read cursor. `reset()` zeroes only the cursors (not the data) for fast recycling. `load(&[u8])` replaces content directly. +- **`BufferPool`** -- a fixed-size pool backed by `crossbeam::ArrayQueue` (lock-free MPMC). `checkout() -> Option` and `checkin(buf)` enable concurrent use between actor threads and tokio tasks without locks. `Clone` shares the underlying `Arc` so send/recv sides reference the same pool. + +### Stage 2: StreamHandle, Channels, and Data Plane + +Introduces tokio channels and async tasks but NOT iroh. Data-plane tasks are generic over `AsyncRead`/`AsyncWrite`, fully testable with `tokio::io::DuplexStream`. + +#### `src/channel.rs` + +Typed channel messages that move `FrameBuf`s by ownership (zero-copy handoff). + +- **`SendCommand`** -- `Data(FrameBuf)`, `Flush`, `Close`. Actor -> send task. +- **`SendEvent`** -- `WriteReady`, `Error(StreamError)`, `Closed`. Send task -> actor. +- **`RecvCommand`** -- `Consumed(FrameBuf)`, `Close`. Actor -> recv task. +- **`RecvEvent`** -- `Data(FrameBuf)`, `Error(StreamError)`, `Closed`. Recv task -> actor. + +#### `src/notify.rs` + +Notification coalescing to prevent flooding actor mailboxes. + +- **`NotifyFlag`** -- `AtomicU8` bitflags (`DATA_READY`, `WRITE_READY`, `CLOSED`, `ERROR`). `set(kind) -> bool` returns true only if the bit was previously clear, signaling a new notification should be injected. `clear(kind)` is called by the actor after handling. +- **`StreamEvent`** / **`StreamEventKind`** -- the lightweight sentinel message injected into actor mailboxes. Carries `stream_id` and `kind` (DataReady, WriteReady, Closed, Error). +- **`NotifySink`** -- held by data-plane tasks. Combines the shared `NotifyFlag` with an inject closure. Convenience methods: `data_ready()`, `write_ready()`, `closed()`, `error()`. + +#### `src/handle.rs` + +The actor-facing API for reading and writing stream data. + +- **`SendHalf`** -- owns `mpsc::Sender`, `mpsc::Receiver`, a `BufferPool` clone, and an active `FrameBuf`. `try_write(&[u8]) -> Result` fills the active buffer and sends full buffers via `try_send` (non-blocking). Returns 0 on backpressure. `flush()` sends partial buffers. `close()` flushes remaining data and sends the Close command. +- **`RecvHalf`** -- owns `mpsc::Receiver`, `mpsc::Sender`, a `BufferPool` clone, and an active `FrameBuf`. `try_read(&mut [u8]) -> Result` drains the active buffer then pulls new buffers from the channel. Returns 0 when no data is available. `has_data()` peeks without consuming. +- **`StreamHandle`** -- combines `SendHalf` and `RecvHalf`. `Send` but not `Clone` (the mpsc receivers are not cloneable). +- **`create_stream_handle(stream_id, config, pool_size, channel_capacity)`** -- factory that returns `(StreamHandle, DataPlaneEndpoints)`. The handle goes to the actor; the endpoints go to the data-plane tasks. + +#### `src/data_plane.rs` + +Async tasks that bridge `StreamHandle` channels to actual byte streams. + +- **`send_stripe_task`** -- reads `SendCommand`s from the channel, wire-encodes them as data frames, writes to the transport, returns consumed buffers to the pool, and optionally notifies the actor via `NotifySink`. +- **`recv_stripe_task`** -- reads wire-encoded frames from the transport, loads payloads into `FrameBuf`s from the pool, sends `RecvEvent::Data` to the actor channel. Handles end-of-stripe sentinel and connection closure. +- **`spawn_send_stripes` / `spawn_recv_stripes`** -- spawn a set of stripe tasks from a writer/reader factory. The recv spawner merges all stripe outputs into a single `mpsc::Receiver`. + +Generic over `AsyncRead + AsyncWrite + Send + Unpin + 'static`, so tests use `tokio::io::DuplexStream` with no network stack. + +### Stage 3: QUIC Integration and StreamManager Actor + +Connects the data-plane tasks to real QUIC streams via iroh. Introduces the `StreamManager` system actor with full open/accept/reject lifecycle. Modifies `IrohDriver` for generic ALPN routing and bootstraps the StreamManager in `swactor-node`. + +#### `src/messages.rs` + +Protocol types for the stream control plane. + +- **`OneShot`** -- Clone-friendly wrapper for non-Clone data (`StreamHandle`, `Connection`). Uses `Arc>>` internally. First `.take()` extracts the value; subsequent calls (including from clones) return `None`. This allows non-Clone payloads inside Clone message enums required by the actor system's `Message` trait. +- **`StreamManagerMsg`** -- 8-variant enum for messages sent TO the StreamManager actor: + - `Open { target_node, mode, config, reply_to }` -- Request a new stream to a remote node. + - `Accept { stream_id, reply_to }` -- Accept an offered incoming stream. + - `Reject { stream_id }` -- Reject an offered incoming stream. + - `Listen { mode, listener }` -- Register as a stream listener for a given mode. + - `Close { stream_id }` -- Close a stream. + - `IncomingConnection { node_id, stream_id, mode, config, conn }` -- Internal: from accept bridge to StreamManager. + - `OpenCompleted { stream_id, reply_to, result }` -- Internal: async open task completed. + - `AcceptCompleted { stream_id, reply_to, result }` -- Internal: async accept task completed. +- **`StreamNotification`** -- 4-variant enum for notifications sent FROM StreamManager TO user actors: + - `StreamReady { stream_id, handle }` -- Stream is ready for use (open or accept completed). + - `StreamOffer { stream_id, mode, metadata, from_node }` -- A remote node is offering a stream. + - `StreamClosed { stream_id, reason }` -- A stream was closed. + - `StreamFailed { stream_id, error }` -- A stream open/accept failed. + +#### `src/connection.rs` + +Async connection cache for stream QUIC connections, separate from SWIM connections. + +- **`StreamConnectionCache`** -- `HashMap<[u8; 32], Connection>` with health-check-on-access. `get_or_connect()` checks `conn.close_reason().is_none()` before reuse and falls back to connecting via `endpoint.connect(key, ALPN)`. `prune_closed()` for bulk cleanup. Uses the stream ALPN (`swactor/stream/1`). + +#### `src/manager.rs` + +The core StreamManager system actor. + +- **`StreamManager`** -- implements `ActorInterface`. Manages active streams, pending incoming offers, listener registrations, and a connection cache. Holds an `Endpoint`, `tokio::runtime::Handle`, and `Arc` for spawning async tasks and sending messages back to itself. +- **`STREAM_MANAGER_NAME`** -- well-known name `"StreamManager"` for the name registry. +- **Open flow**: Generates `StreamId`, spawns a tokio task that connects, sends header on a control bi-stream, waits for a 1-byte accept/reject response, then creates `StreamHandle` + data-plane tasks, and sends `OpenCompleted` back to the StreamManager. StreamManager then delivers `StreamNotification::StreamReady` to the requesting actor. +- **Incoming flow**: Accept bridge reads header, sends `IncomingConnection` to StreamManager. StreamManager stores as pending, notifies matching listeners with `StreamOffer`. +- **Accept flow**: Takes pending connection, spawns tokio task that sends accept byte, creates `StreamHandle` + data-plane tasks, sends `AcceptCompleted` back. StreamManager delivers `StreamReady` to accepting actor. +- **Reject flow**: Sends reject byte on a uni-stream, drops the connection. +- **Close flow**: Removes stream state; data-plane tasks terminate when channels drop. +- **`handle_down`**: Cleans up streams owned by dead actors and removes dead listeners. +- **Data-plane spawning**: For each stream direction, a single tokio task opens N uni-streams and round-robins data frames across them. Recv tasks accept incoming uni-streams and dispatch each to a `recv_stripe_task`. + +#### `src/accept.rs` + +Bridge between incoming QUIC connections and the StreamManager actor. + +- **`spawn_accept_bridge`** -- spawns a tokio task that reads from a channel of `(node_id, Connection)` pairs, accepting the control bi-stream, reading the stream header via `read_to_end` + `decode_header`, and forwarding `StreamManagerMsg::IncomingConnection` to the StreamManager via `runtime.send_to()`. +- **`handle_incoming`** -- public async function for per-connection header processing. Can also be called directly from the main loop (used by `swactor-node`). + +#### Modified: `crates/distribution/src/iroh_driver.rs` + +Generic ALPN support to route stream connections separately from SWIM. + +- **`IrohDriverConfig`**: Added `additional_alpns: Vec>` field. All existing call sites updated with `additional_alpns: vec![]`. +- **Endpoint creation**: ALPNs now include both SWIM and any additional ALPNs (`vec![ALPN.to_vec()] + additional_alpns`). +- **Accept loop**: After accepting a connection, checks `conn.alpn()`. SWIM ALPN routes to `accepted_conns` (existing behavior). All other ALPNs route to `other_accepted_conns` (new buffer). +- **New field**: `other_accepted_conns: Arc>>`. +- **New methods**: `endpoint() -> &Endpoint` (for outbound stream connections), `drain_other_connections() -> Vec<(NodeId, Connection)>` (polled from main loop). + +#### Modified: `crates/streams/src/types.rs` + +- Added `Hash` derive to `StreamMode` (needed as `HashMap` key in listeners registry). + +#### Modified: `crates/streams/src/lib.rs` + +- Added module declarations and re-exports for `accept`, `connection`, `manager`, `messages`. +- Re-exports: `StreamConnectionCache`, `StreamManager`, `STREAM_MANAGER_NAME`, `OneShot`, `StreamManagerMsg`, `StreamNotification`. + +#### Modified: `crates/streams/Cargo.toml` + +- Added `swactor-std` dependency (for `CtxMonitoring`, `RuntimeNaming`). +- Added `io-util` feature to `tokio` (for `AsyncWriteExt::flush`). + +#### Modified: `crates/swactor-node/src/main.rs` + +Bootstrap integration in `run_iroh()`. + +- Passes `swactor_streams::ALPN.to_vec()` in `IrohDriverConfig::additional_alpns`. +- After driver creation, spawns `StreamManager::new(endpoint, tokio_handle, runtime)` as a named actor under `"StreamManager"`. +- In the main loop, drains `driver.drain_other_connections()` and spawns `handle_incoming` tasks for each, forwarding to the StreamManager. + +#### Modified: `crates/swactor-node/Cargo.toml` + +- Added `swactor-streams` dependency. + +#### Modified: `crates/distribution/tests/common/iroh.rs`, `crates/dashboard/src/bin/swactor-node.rs` + +- Updated all `IrohDriverConfig` construction sites with `additional_alpns: vec![]`. + +### Stage 4: Datastore Stream Integration + +Connects the stream system to the datastore so blob transfers flow over QUIC streams instead of sequential per-chunk actor-message round-trips. A 1GB blob with 1MB chunks that previously required 1,024 round-trips now flows in a single burst. + +#### `crates/datastore/src/blob_transfer.rs` (NEW) + +Async functions for sending/receiving blobs over StreamHandle. Runs inside tokio tasks, NOT actor handlers. + +- **`BlobTransferError`** -- enum: `IncompleteTransfer(String)`, `ChunkVerificationFailed { expected, actual }`, `InvalidManifest(String)`, `Storage(String)`. +- **`ReceivedBlob`** -- `{ manifest: ObjectManifest, chunks: Vec<(ContentHash, Vec)> }`. +- **`send_blob(send, manifest, read_chunk)`** -- generic over an async callback `F: Fn(ContentHash) -> Future>>`. Writes `[4B manifest_json_len][manifest JSON]` preamble, then for each chunk in the manifest calls `read_chunk(hash)` and writes the raw bytes. Chunks are NOT preloaded -- the callback reads one at a time. +- **`recv_blob(recv)`** -- reads manifest preamble, deserializes JSON, then reads + blake3-verifies each chunk against the manifest's `ChunkRef` entries. Returns `ReceivedBlob`. +- **`poll_inbox(inbox, timeout)`** -- async version of `bridge.rs:poll_response`. Yields (`tokio::task::yield_now`) instead of `thread::sleep`, polling the swactor `Inbox` until a message arrives or timeout. +- **Internal helpers**: `write_all` (loops `try_write` + `yield_now`), `read_exact` (loops `try_read` + `yield_now`). + +Wire format: +``` +[4B manifest_json_length (u32 BE)] +[N bytes manifest JSON] +[chunk_0 raw bytes] <- size from manifest.chunks[0].size +[chunk_1 raw bytes] +... +``` + +#### `crates/datastore/src/actors/stream_listener.rs` (NEW) + +Listens for incoming BlobTransfer stream offers and routes them to DatastoreNode. + +- **`StreamListener`** -- `Incoming = StreamNotification`. State: `datastore_node: ActorAddress`, `stream_manager: Option`. +- `on_start`: looks up `"StreamManager"` via `ctx.where_is()`, sends `StreamManagerMsg::Listen { mode: BlobTransfer }`. +- `handle(StreamOffer)`: extracts 32-byte ContentHash from `metadata`, sends `DatastoreNodeMsg::HandleStreamOffer` to DatastoreNode. Rejects if metadata != 32 bytes. + +#### `crates/datastore/src/actors/stream_downloader.rs` (NEW) + +Opens a stream to a remote node and downloads a blob. + +- **`StreamDownloader`** -- `Incoming = StreamNotification`. Constructor takes: `content_hash`, `source_node`, `datastore_node`, `blob_store`, `reply_to`, `stream_manager`, `tokio_handle`, `runtime`. +- `on_start`: sends `StreamManagerMsg::Open { target_node, mode: BlobTransfer, config.metadata: content_hash.0.to_vec() }`. +- `handle(StreamReady)`: takes handle via `OneShot::take()`, spawns tokio task: + - Calls `recv_blob(&mut recv_half)`. + - Writes each chunk to BlobStore via `runtime.send_to(blob_store, WriteChunk)` (fire-and-forget). + - Writes manifest via `runtime.send_to(blob_store, WriteManifest)` (fire-and-forget). + - Sends `DatastoreNodeMsg::StreamDownloadComplete` to DatastoreNode. + - On error: sends `DatastoreNodeMsg::StreamDownloadFailed`. + - Actor calls `ctx.stop_self()` after spawning the task. +- `handle(StreamFailed)`: sends `StreamDownloadFailed`, stops self. + +#### `crates/datastore/src/actors/stream_server.rs` (NEW) + +Serves a blob to a requesting node over a stream, reading chunks on-demand. + +- **`StreamServer`** -- `Incoming = StreamNotification`. Constructor takes: `stream_id`, `content_hash`, `blob_store`, `stream_manager`, `tokio_handle`, `runtime`. +- `on_start`: sends `StreamManagerMsg::Accept { stream_id }`. +- `handle(StreamReady)`: takes handle, spawns tokio task: + - Reads manifest from BlobStore via `runtime.new_inbox()` + `poll_inbox` (async Inbox polling). + - Calls `send_blob(&mut send_half, &manifest, |chunk_hash| { ... })` with a callback that reads each chunk on-demand from BlobStore via a fresh Inbox. + - At most one chunk is in memory at a time. Chunks flow directly from BlobStore to stream. + - Actor calls `ctx.stop_self()`. +- `handle(StreamFailed)`: stops self. + +#### Modified: `crates/datastore/src/messages.rs` + +Added 5 new variants to `DatastoreNodeMsg`: + +- `DownloadViaStream { content_hash, source_node, reply_to }` -- triggers a stream download. +- `HandleStreamOffer { stream_id, content_hash, from_node, stream_manager }` -- routes incoming stream offers. +- `StreamDownloadComplete { content_hash, manifest, reply_to }` -- download succeeded; persist metadata. +- `StreamDownloadFailed { content_hash, reason, reply_to }` -- download failed; notify caller. +- `ConfigureStreams { stream_manager, tokio_handle, runtime }` -- late-binding stream support. + +Changed from `#[derive(Debug, Clone)]` to `#[derive(Clone)]` with manual `Debug` impl (because `Arc` doesn't implement `Debug`). + +#### Modified: `crates/datastore/src/actors/datastore_node.rs` + +Added stream support fields and handlers to the coordinator actor. + +- **New fields**: `runtime: Option>`, `tokio_handle: Option`, `stream_manager: Option` -- all initialized to `None`. +- **`handle_configure_streams`**: stores runtime/tokio_handle/stream_manager. +- **`handle_download_via_stream`**: spawns `StreamDownloader`. Returns `TransferFailed` if streams not configured. +- **`handle_stream_offer`**: spawns `StreamServer`. +- **`handle_stream_download_complete`**: creates `ObjectEntry`, sends `MetadataMsg::PutObject` to metadata actor with the original `reply_to` for direct response routing. +- **`handle_stream_download_failed`**: sends `DatastoreResponse::TransferFailed` to `reply_to`. + +#### Modified: `crates/datastore/src/actors/mod.rs` + +Added module declarations for `stream_downloader`, `stream_listener`, `stream_server`. + +#### Modified: `crates/datastore/src/lib.rs` + +Added `pub mod blob_transfer`. + +#### Modified: `crates/datastore/src/bridge.rs` + +- Added `datastore_addr: ActorAddress` field to `DatastoreGroup` (stored during `spawn()`). +- Added `configure_streams(&self, stream_manager, tokio_handle)` method: sends `ConfigureStreams` to DatastoreNode, spawns and registers `StreamListener` under `"StreamListener"`. + +#### Modified: `crates/datastore/Cargo.toml` + +- Added `swactor-streams = { path = "../streams" }` and `tokio = { version = "1", features = ["sync", "rt", "time"] }` dependencies. +- Added dev-dependencies for testing: `swactor-streams`, `tokio` with `rt-multi-thread`, `macros`, `io-util`. + +#### Modified: `crates/swactor-node/src/main.rs` + +After StreamManager registration, wires stream support into the datastore: +```rust +if let Some(group) = ds_group { + group.configure_streams(stream_mgr_addr, driver.tokio_handle()); +} +``` + +## Test Coverage + +42 tests across all modules: + +| Category | Tests | What they verify | +|----------|-------|------------------| +| `types` | 4 | StreamId uniqueness, Debug/Display formatting, StreamConfig defaults | +| `wire` | 8 | Header round-trip (basic + property-based), bad magic/version/truncation rejection, data frame round-trip (basic + property-based), end-of-stripe sentinel | +| `buffer` | 7 | FrameBuf write/read/reset/load, BufferPool checkout/checkin/exhaustion/recycling/sharing | +| `notify` | 4 | Set returns true first time / false on duplicate, clear re-enables, independent flags, read shows all bits | +| `data_plane` | 9 | Single-stripe end-to-end transfer, multi-chunk ordered delivery (20 chunks), 4-stripe round-robin (100 chunks), graceful close, notification coalescing, backpressure detection | +| `messages` | 5 | OneShot take-once semantics, clone sharing, debug format, StreamManagerMsg is Message, StreamNotification is Message | +| `blob_transfer` | 5 | Small blob round-trip (single chunk), multi-chunk round-trip (4MB / 256KB chunks / 16 chunks), corrupted chunk detection (blake3 verification), truncated stream detection, property-based arbitrary blob round-trips | + +Property-based tests (via `proptest`) cover: +- Arbitrary stream headers (random IDs, stripe counts 1-16, frame sizes 1KB-1MB, metadata 0-256 bytes) +- Arbitrary data frame payloads (0-256KB) +- Arbitrary blob transfers (random data 1-64KB, chunk sizes 256B-8KB) + +## Dependency Footprint + +### `swactor-streams` crate + +- `swactor` (core actor types, with `serde` feature) +- `swactor-std` (for `CtxMonitoring`, `RuntimeNaming`) +- `shared-types` (ContentHash) +- `distribution` (NodeId, iroh re-exports) +- `crossbeam-queue` (lock-free buffer pool -- already a workspace dep) +- `tokio` (mpsc channels, async I/O traits, io-util) +- `iroh` (QUIC transport, connections, endpoints) +- `blake3`, `serde`, `getrandom` + +Dev dependencies: `proptest`, `tokio` (with rt-multi-thread, macros, test-util, io-util). + +### `swactor-datastore` crate (Stage 4 additions) + +- `swactor-streams` (stream primitives, messages, types) +- `tokio` (sync, rt, time -- for spawning async blob transfer tasks and `poll_inbox`) + +Dev dependencies: `swactor-streams`, `tokio` (with rt-multi-thread, macros, io-util). + +## Next Steps + +### Remaining MVP Work + +These items complete the minimum viable stream-based blob transfer: + +1. **Two-node integration test** -- full open/accept/data-transfer/close cycle with real iroh endpoints and two `DatastoreGroup` instances. Verifies StreamListener receives offers, StreamServer serves blobs, StreamDownloader receives and persists them. This is the critical end-to-end validation that all the pieces work together over real QUIC. + +2. **CtxStreams extension trait** (`crates/streams/src/ctx_ext.rs`) -- convenience methods on `Ctx`: `stream_open()`, `stream_listen()`, `stream_accept()`, `stream_reject()`, `stream_close()`. Looks up `"StreamManager"` via `where_is()` and wraps the message construction. Reduces boilerplate for any actor wanting to use streams. + +3. **Resume tokens** -- checkpoint emission every N chunks or N bytes during `send_blob`/`recv_blob`. Stored in `ResumeToken` (already defined in `types.rs`). On reconnect, receiver sends its token in `StreamConfig.metadata` and sender seeks to the right chunk offset. + +### Post-MVP Phases + +- **Dashboard stream metrics** -- expose active streams, bytes transferred, and transfer rates through the existing dashboard infrastructure. +- **Continuous Streams** -- `ContinuousStream` mode for unbounded data (ML gradient streams, data pipelines). Single bidirectional QUIC stream, variable-sized frames, ring buffer backpressure. +- **Unreliable Datagrams** -- QUIC datagram-based mode for latency-sensitive data (voice/video, game state). Sequence-based dropping, jitter buffer. +- **Priority and QoS** -- per-stream priority, write scheduling across concurrent streams, QUIC stream priority hints. +- **Parallel Unordered Transfer** -- independent per-chunk QUIC streams for workloads where any chunk is consumable independently (distributed ML gradient exchange). diff --git a/bin-runner/WASM_ACTOR.md b/bin-runner/WASM_ACTOR.md new file mode 100644 index 0000000..9294b97 --- /dev/null +++ b/bin-runner/WASM_ACTOR.md @@ -0,0 +1,191 @@ +# Wasm Actor Crate — Development History + +> Adds a new crate (`crates/bin-runner/`) that runs WebAssembly guest code +> **inside** a swactor actor. The Wasm instance lives in the actor — not as a +> separate OS process. Messages arrive as bytes, get written into Wasm linear +> memory, and the guest's `handle` export is called. +> +> ~350 lines of Rust (host) · 3 guest modules · 7 tests + +--- + +## Table of Contents + +1. [Overview & Motivation](#1-overview--motivation) +2. [What Was Built](#2-what-was-built) +3. [Guest ↔ Host Contract](#3-guest--host-contract) +4. [Handle Cycle (Hot Path)](#4-handle-cycle-hot-path) +5. [Guest Modules](#5-guest-modules) +6. [Design Decisions & Tradeoffs](#6-design-decisions--tradeoffs) +7. [Known Gaps & Future Improvements](#7-known-gaps--future-improvements) +8. [Test Coverage Summary](#8-test-coverage-summary) + +--- + +## 1. Overview & Motivation + +Swactor already supported running *inside* a browser via `crates/wasm/` +(wasm-bindgen). This crate flips the direction: run untrusted Wasm code +*inside* an actor, sandboxed by wasmtime. Use cases include user-defined +plugins, multi-language actors, and capability-restricted compute. + +The main swactor crate has no wasmtime dependency — all Wasm machinery is +isolated in `crates/bin-runner/`. + +--- + +## 2. What Was Built + +| Component | Location | Purpose | +|-----------|----------|---------| +| `swactor-bin-runner` crate | `crates/bin-runner/` | Host-side: engine, builder, actor impl | +| 3 guest crates | `crates/bin-runner/tests/guests/{echo,double,silent}/` | `#![no_std]` Wasm modules for testing | +| Integration tests | `crates/bin-runner/tests/wasm_actor.rs` | 7 behavioral tests | + +### Crate modules + +``` +crates/bin-runner/src/ + lib.rs — ByteMessage, re-exports + engine.rs — SharedEngine (Arc) + builder.rs — WasmActorBuilder (compile + link + instantiate) + actor.rs — WasmActor implementing ActorInterface + error.rs — WasmActorError enum +``` + +### Public types + +- **`ByteMessage(pub Vec)`** — message type for Wasm actors. Satisfies + `Message` bounds trivially. +- **`SharedEngine`** — wraps `Arc`. Created once, cloned + cheaply across actors. Sandboxed config: no threads, no SIMD, no reference + types. +- **`WasmActorBuilder`** — takes an engine + raw `.wasm` bytes, compiles the + module, links the `swactor.send` host import, extracts typed function handles, + returns a `WasmActor`. +- **`WasmActor`** — implements `ActorInterface`. +- **`WasmActorError`** — `MissingExport(&'static str)` or `Wasmtime(wasmtime::Error)`. + +--- + +## 3. Guest ↔ Host Contract + +**Guest must export:** + +| Export | Signature | Purpose | +|--------|-----------|---------| +| `memory` | WebAssembly linear memory | Host reads/writes message bytes here | +| `alloc` | `(size: i32) -> i32` | Allocate `size` bytes, return pointer | +| `handle` | `(ptr: i32, len: i32)` | Process message at `(ptr, len)` | + +**Guest may import:** + +| Import | Module | Signature | Purpose | +|--------|--------|-----------|---------| +| `send` | `swactor` | `(dest_ptr: i32, payload_ptr: i32, payload_len: i32)` | Send a message to another actor | + +`dest_ptr` points to 32 bytes of `ActorAddress` in guest linear memory. +`payload_ptr` + `payload_len` describe the message bytes. + +--- + +## 4. Handle Cycle (Hot Path) + +``` + ByteMessage arrives + │ + v + 1. host calls guest alloc(msg.len) → ptr + │ + v + 2. host writes msg bytes into guest memory at ptr + │ + v + 3. host calls guest handle(ptr, len) + │ + ├── guest may call swactor.send() N times + │ └── each appends (ActorAddress, Vec) to HostState.outbox + │ + v + 4. host drains outbox → ctx.send(dest, ByteMessage(payload)) for each +``` + +Traps during `alloc` or `handle` will panic. Swactor's existing +`catch_unwind` in `tick_all` poisons the actor — consistent with the +panic-safety model. + +--- + +## 5. Guest Modules + +Three `#![no_std]` Rust crates compiled to `wasm32-unknown-unknown`: + +| Guest | Behavior | Tests it supports | +|-------|----------|-------------------| +| `echo` | Reads 32-byte dest + payload from message; sends payload back to dest | Echo roundtrip, binary preservation | +| `double` | Same framing; sends payload back **twice** | Multi-send verification | +| `silent` | Receives bytes; does nothing | No-output / no-error baseline | + +Each guest uses a simple inline bump allocator (64 KiB heap, 8-byte aligned) +and a `#[panic_handler]` that loops. No external dependencies. + +Message framing convention: the first 32 bytes of the `ByteMessage` payload +are the destination `ActorAddress`, followed by the actual message bytes. +This allows guests to send replies without hardcoding addresses. + +### Building guests + +```bash +rustup target add wasm32-unknown-unknown # one-time + +cd crates/bin-runner/tests/guests/echo && cargo build --target wasm32-unknown-unknown --release +cd crates/bin-runner/tests/guests/double && cargo build --target wasm32-unknown-unknown --release +cd crates/bin-runner/tests/guests/silent && cargo build --target wasm32-unknown-unknown --release +``` + +Each guest crate has its own `[workspace]` marker to stay independent of the +root workspace. + +--- + +## 6. Design Decisions & Tradeoffs + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | **wasmtime, not wasmer/wasm3** | Best-maintained, fuel metering support, cranelift JIT | +| 2 | **Raw bytes, not structured messages** | Keeps the boundary simple; framing/serialization is the guest's concern | +| 3 | **Separate crate, not a feature flag** | wasmtime is ~30 crates; most users don't need it in their dependency tree | +| 4 | **Bump allocator in guests** | Zero-dependency, predictable, sufficient for request/response patterns | +| 5 | **Dest address in message payload** | Avoids hardcoded addresses; guests can send to any actor the host tells them about | +| 6 | **Traps = panics (no Result)** | Matches swactor's existing panic-safety model; `catch_unwind` in `tick_all` poisons the actor | +| 7 | **Engine sharing via Arc** | Module compilation is expensive; `SharedEngine` amortizes it across actors | +| 8 | **Maximum sandboxing defaults** | Disabled: threads, SIMD, relaxed SIMD, reference types, multi-value. Enabled: bulk memory (required by most compilers) | + +--- + +## 7. Known Gaps & Future Improvements + +| # | Gap | Notes | +|---|-----|-------| +| 1 | **No fuel metering** | wasmtime supports fuel; maps naturally to per-tick actor budgets. Deferred to follow-up. | +| 2 | **No WASI** | No filesystem, network, random, or clock access. Intentional for sandboxing, but limits guest capabilities. | +| 3 | **No guest SDK crate** | The test guests serve as examples. A published `swactor-guest` crate with the alloc/handle/send glue would reduce boilerplate. | +| 4 | **Bump allocator never frees** | Fine for short-lived handle calls, but long-running actors would need a real allocator. | +| 5 | **No pre-compilation cache** | `Module::new()` recompiles every time. wasmtime supports serialized modules for faster cold starts. | +| 6 | **`cargo test -p` doesn't resolve** | Must use `--manifest-path`. Workspace resolution quirk. | + +--- + +## 8. Test Coverage Summary + +7 behavioral tests in `crates/bin-runner/tests/wasm_actor.rs`: + +| Test | Scenario | +|------|----------| +| `echo_returns_same_payload` | Send bytes → wasm echoes them back to inbox | +| `echo_preserves_binary_payload` | All 256 byte values survive the roundtrip | +| `silent_produces_no_output` | Guest does nothing; no error, no messages | +| `double_sends_two_copies` | One message in → two messages out | +| `missing_alloc_export_returns_error` | WAT module with no exports → `WasmActorError::MissingExport` | +| `shared_engine_serves_multiple_actors` | Two actors from the same `SharedEngine` work independently | +| `native_actor_communicates_with_wasm_actor` | Native Rust actor → WasmActor → inbox (two-tick delivery) | diff --git a/cfuzz/CFUZZ_OVERVIEW.md b/cfuzz/CFUZZ_OVERVIEW.md new file mode 100644 index 0000000..5139a2e --- /dev/null +++ b/cfuzz/CFUZZ_OVERVIEW.md @@ -0,0 +1,95 @@ +# cfuzz Branch — Development History Overview + +> 19 improvement cycles on the `cfuzz` branch. +> Research-driven methodology: study competitors → identify gap → implement → test → benchmark. +> Grew test suite from 42 → 148 passing tests. + +--- + +## Methodology + +Each cycle followed a consistent pattern: + +1. **Research** — Study how competitors (Erlang/OTP, Tokio, Akka, Ractor, Actix, Kameo) handle the problem +2. **Identify gap** — Find a specific deficiency in swactor +3. **Implement** — Fix the gap with minimal, targeted changes +4. **Test** — Write behavioral tests (Given/When/Then) from the consumer's perspective +5. **Benchmark** — Measure impact where applicable + +### Constraints + +- `src/` structure is frozen — no new files or modules, only modify existing files in-place +- No new dependencies on the root crate +- Behavioral tests only — no white-box/structural tests +- All `cargo test` must pass before each commit +- Never delete tests for active code + +--- + +## Baseline Benchmarks (Pre-Improvement) + +| Benchmark | Time | Throughput | +|-----------|------|-----------| +| spawn | 1.28 µs | — | +| message_roundtrip | 2.24 µs | — | +| send_fire_and_forget | 1.50 µs | — | +| single_actor/1000 | 57.5 µs | 17.4 Melem/s | +| multi_actor/100x100 | 610.6 µs | 16.4 Melem/s | +| ring/100 | 99.9 µs | 1.01 Melem/s | + +--- + +## Cycle Summary + +| Cycle | Commit | Topic | Tests Added | Cumulative Tests | +|-------|--------|-------|-------------|-----------------| +| 1 | `ef87f7e` | [Fairness (message budget)](CYCLE_01_FAIRNESS.md) | 3 | 45 | +| 2 | `10cb078` | [Stress tests + benchmarks](CYCLE_02_STRESS_TESTS.md) | 6 | 51 | +| 3 | `acacc1b` | [Thread parking](CYCLE_03_THREAD_PARKING.md) | 1 | 52 | +| 4 | `cf61619` | [Shutdown fix + bug-inspired tests](CYCLE_04_SHUTDOWN_FIX.md) | 5 | 57 | +| 5 | `7d00e65` | [Load-aware placement](CYCLE_05_LOAD_AWARE_PLACEMENT.md) | 3 | 60 | +| 6 | `265992c` | [Mailbox backpressure](CYCLE_06_BACKPRESSURE.md) | 4 | 64 | +| 7 | `1779ad6` | [Actor recovery](CYCLE_07_ACTOR_RECOVERY.md) | 4 | 68 | +| 8 | `0213938` | [Dead actor cleanup](CYCLE_08_DEAD_ACTOR_CLEANUP.md) | 2 (+2 updated) | 70 | +| 9 | `e28aca0` | [Lifecycle hooks + graceful stop](CYCLE_09_LIFECYCLE_HOOKS.md) | 12 | 82 | +| 10 | `d58a999` | [Actor timers](CYCLE_10_TIMERS.md) | 6 | 88 | +| 11 | `9b1518b` | [Property-based testing](CYCLE_11_PROPERTY_TESTING.md) | 7 | 95 | +| 12 | `66a8523` | [Named actor registry](CYCLE_12_NAMED_REGISTRY.md) | 11 | 106 | +| 13 | `8782638` | [Actor monitoring](CYCLE_13_MONITORING.md) | 7 | 113 | +| 14 | `4d18874` | [Actor groups](CYCLE_14_GROUPS.md) | 9 | 122 | +| 15 | `902471b` | [Ask pattern](CYCLE_15_ASK_PATTERN.md) | 5 | 127 | +| 16 | `0ef6df9` | [Registry benchmarks](CYCLE_16_REGISTRY_BENCHMARKS.md) | 0 | 127 | +| 17 | `a70bd86` | [Supervision trees](CYCLE_17_SUPERVISION.md) | 10 | 138 | +| 18 | `771c38c` | [OneForAll + RestForOne](CYCLE_18_SUPERVISOR_STRATEGIES.md) | 3 | 141 | +| 19 | `c688f0a` | [Router](CYCLE_19_ROUTER.md) | 7 | 148 | + +--- + +## Thematic Groupings + +### Scheduling & Performance (Cycles 1–5) +Foundation work: fairness guarantees, stress testing, thread parking, shutdown reliability, and load-aware actor placement. Research thread: BEAM reductions → tokio coop budget → Kameo/Actix mailboxes → tokio parker → work stealing survey. + +### Resilience & Lifecycle (Cycles 6–10) +Production hardening: backpressure, crash recovery, memory leak fix, lifecycle hooks, and deterministic timers. Narrative arc: from "actors crash permanently" to "actors have a fully managed lifecycle." + +### Testing & Service Discovery (Cycles 11–16) +Property-based testing for invariant verification, plus four registry features (names, monitoring, groups, ask pattern) and benchmarks to validate them. Research shifted from scheduling to service discovery patterns. + +### Supervision (Cycles 17–19) +Capstone features built on everything preceding: supervision trees with configurable restart strategies, and routers for actor pool management. Directly modeled on Erlang/OTP supervision trees. + +--- + +## Frameworks Studied + +| Framework | Language | Key Lessons | +|-----------|----------|-------------| +| Erlang/OTP BEAM | Erlang | 4000-reduction budget, supervision trees, pg groups, gen_server:call | +| Tokio | Rust | 128-op coop budget, work-stealing, parker state machine | +| Akka | Scala/Java | SupervisorStrategy, Router actors, PoisonPill | +| Ractor | Rust | String-based registry, SupervisionEvent, bug history | +| Actix | Rust | Vyukov MPSC queue, 256-message guard, ctx.stop() | +| Kameo | Rust | Dual mailbox (bounded/unbounded), on_panic hook, ActorPool | +| Linux CFS/EEVDF | C | vruntime fairness, NO_HZ adaptive ticks | +| libuv/Node.js | C | Phase-based event loop, round-robin handlers | diff --git a/cfuzz/CYCLE_01_FAIRNESS.md b/cfuzz/CYCLE_01_FAIRNESS.md new file mode 100644 index 0000000..e71e88b --- /dev/null +++ b/cfuzz/CYCLE_01_FAIRNESS.md @@ -0,0 +1,61 @@ +# Cycle 1: Per-Actor Message Budget for Tick Fairness — Development History + +> Commit: `ef87f7e` · 8 files · Priority: P0 (critical bug fix) + +--- + +## Motivation + +The `tick_all` function in `worker.rs` drained the **entire mailbox** for each actor before moving to the next: + +```rust +while let Some(msg) = slot.mailbox.pop_front() { + // processes ALL messages for actor A before moving to actor B +} +``` + +If actor A had 10,000 queued messages, all other actors on the same worker were completely starved until A finished. This is a critical fairness bug — every other runtime studied prevents this. + +## Competitor Analysis + +| Runtime | Fairness Mechanism | Budget | +|---------|-------------------|--------| +| Erlang/OTP BEAM | Reduction counting, preemptive | 4,000 reductions | +| Tokio | Cooperative budgeting | 128–256 operations | +| libuv/Node.js | Round-robin across handlers | No single handler drains completely | +| Linux CFS | vruntime-based fairness | Time slices enforced | +| Ractor | N/A (1 task = 1 actor via tokio) | Inherited from tokio | +| **Swactor (before)** | **None** | **Unlimited drain** | + +The BEAM's reduction budget (4,000 per process before preemption) is the gold standard for actor fairness. Tokio's cooperative budget (128 ops) serves a similar purpose for async tasks. Actix has a 256-message assertion guard that validates the approach. + +## Implementation + +- Added `actor_message_budget: usize` to `RuntimeConfig` (default: 64) +- Modified `tick_all` in `worker.rs` to break after `budget` messages per actor per tick +- `budget=0` means unlimited (100% backward compatible) +- Updated `RuntimeConfig` struct literals across all crates (python, runtime-dashboard, mt_benchmarks) + +**Key files modified:** `src/worker.rs`, `src/config.rs`, `benches/runtime_benchmarks.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Budget of 64 chosen** as default — between BEAM's 4,000 (too generous for swactor's coarser granularity) and tokio's 128 (per-op vs per-message). Benchmarks showed budget=32 was slightly faster for throughput, but 64 provides more fairness headroom. +- **Per-runtime, not per-actor** — simpler configuration, matching the BEAM model where the reduction budget is global. Per-actor budgets could be added later as an extension. +- **budget=0 means unlimited** — backward compatibility for users who want the old behavior. + +## Tests Added + +3 new behavioral tests (42 → 45 total): + +- `hot_actor_does_not_starve_cold_actor` — hot actor with many messages doesn't prevent cold actor from processing +- `unlimited_budget_drains_all` — budget=0 preserves old behavior +- `budget_messages_drain_across_multiple_ticks` — excess messages carry over to next tick + +**Benchmarks added:** `fairness/cold_latency_under_pressure`, `fairness/throughput_by_budget` + +## Result + +- 45 tests pass (42 original + 3 new) +- All workspace crates compile +- Baseline benchmarks established for future comparison diff --git a/cfuzz/CYCLE_02_STRESS_TESTS.md b/cfuzz/CYCLE_02_STRESS_TESTS.md new file mode 100644 index 0000000..b9444ad --- /dev/null +++ b/cfuzz/CYCLE_02_STRESS_TESTS.md @@ -0,0 +1,74 @@ +# Cycle 2: Stress Tests, Expanded Benchmarks, and Research Extension — Development History + +> Commit: `10cb078` · 4 files · 517 insertions + +--- + +## Motivation + +After fixing the fairness bug in Cycle 1, the runtime needed stress testing under adversarial conditions to find edge cases. Additionally, the competitor survey was extended to cover Kameo and Actix — two frameworks with distinct approaches to mailbox management and message dispatch. + +## Competitor Analysis + +### Kameo (v0.19) +- Fully async on tokio, one task per actor +- Dual mailbox: bounded (default 64) or unbounded tokio mpsc channels +- Typed signals via vtable dispatch (no `Box` downcast) +- Erlang-style links for supervision (`on_link_died`) +- `on_panic` hook can restart actor (vs swactor's then-permanent poisoning) +- Known bugs: deadlocks in link establishment, leaked ActorRef preventing stop + +### Actix (v0.13) +- Context-as-Future model — each actor is a single pollable Future on an Arbiter +- **Custom Vyukov lock-free MPSC queue** (not tokio channels) — push is single atomic_swap +- Default mailbox capacity: 16 (tiny) +- `do_send()` bypasses capacity for internal notifications +- **256-message assertion guard** — validates swactor's budget approach +- vtable dispatch via `Box>` — no Any downcast +- WHY FAST: custom MPSC queue, no async overhead, same-thread actors avoid cross-thread coordination + +### Key Insight +Both frameworks use vtable dispatch instead of `Box` downcast. Actix's 256-message assertion guard independently validates the per-actor budget concept from Cycle 1. + +## Implementation + +### Stress Tests (6 new) +- `message_ordering_preserved_under_budget` — FIFO order with budget=8 +- `mt_stress_many_senders_one_receiver` — 50 senders × 100 msgs on 4 threads +- `mt_stress_concurrent_spawn_and_send` — 200 concurrent spawn+send on 4 threads +- `mt_chain_spawning_under_load` — 50-level chain across 2 workers +- `mt_panic_isolation_under_load` — 10 panicking + 10 healthy actors on 4 threads +- `sustained_throughput_does_not_drop_messages` — 10 batches × 100 msgs + +### Benchmarks (2 new groups) +- `msg_size` group: throughput and send_latency by message size (8B, 64B, 256B, 1KB, 4KB) +- `contention` group: fanin (1–100 senders to 1 sink), cross_worker (1–4 threads) + +**Key files modified:** `tests/runtime_api.rs`, `benches/runtime_benchmarks.rs`, `CLAUDE/notes/research_synthesis.md` + +## Design Decisions + +- **Multi-threaded stress tests** included because single-threaded testing can't catch cross-worker races +- **Panic isolation test** inspired by Actix's SyncArbiter model — ensures one panicking actor doesn't take down healthy actors on other workers +- **Message ordering test** validates that the budget mechanism (Cycle 1) doesn't break FIFO guarantees +- **Chain spawning** tests the spawn+send-in-same-handler pattern across worker boundaries + +## Tests Added + +6 new stress tests (45 → 51 total): + +| Test | Pattern | Purpose | +|------|---------|---------| +| `message_ordering_preserved_under_budget` | FIFO verification | Budget doesn't break ordering | +| `mt_stress_many_senders_one_receiver` | Fan-in | 50:1 contention on 4 threads | +| `mt_stress_concurrent_spawn_and_send` | Concurrent spawn | Race condition hunting | +| `mt_chain_spawning_under_load` | Cascading spawn | Cross-worker chain delivery | +| `mt_panic_isolation_under_load` | Fault isolation | Panics don't spread | +| `sustained_throughput_does_not_drop_messages` | Sustained load | No message loss over time | + +## Result + +- 51 tests pass (42 original + 3 fairness + 6 stress) +- All workspace crates compile +- No bugs found — the runtime handles adversarial conditions correctly +- Benchmark data provides baselines for message size sensitivity and contention scaling diff --git a/cfuzz/CYCLE_03_THREAD_PARKING.md b/cfuzz/CYCLE_03_THREAD_PARKING.md new file mode 100644 index 0000000..e8d96f4 --- /dev/null +++ b/cfuzz/CYCLE_03_THREAD_PARKING.md @@ -0,0 +1,51 @@ +# Cycle 3: Thread Parking for Instant Worker Wakeup — Development History + +> Commit: `acacc1b` · 4 files · 59 insertions, 10 deletions + +--- + +## Motivation + +Before this change, idle workers used `thread::sleep` with a fixed timeout to wait for new work. This meant an idle worker wouldn't notice new messages until its sleep timer expired — up to 1ms of unnecessary latency on the idle-to-active transition. Under bursty workloads, this sleep-based backoff wastes both time and power. + +## Competitor Analysis + +| Runtime | Idle Strategy | Wakeup Mechanism | +|---------|--------------|-----------------| +| Tokio | Parker state machine (notified/sleeping/empty) | `unpark()` via atomic CAS | +| Linux | NO_HZ adaptive ticks (stop tick when idle) | Interrupt on new work | +| Go | `notewakeup` / futex | OS-level wake | +| BEAM | Scheduler sleep + signal | Thread signal | +| **Swactor (before)** | **`thread::sleep(1ms)`** | **Timer expiry only** | + +Tokio's parker uses a 3-state machine (notified → sleeping → empty) with atomic transitions. The key insight: `unpark()` is a **no-op** if the thread isn't parked, so callers pay zero cost on the hot path. + +## Implementation + +- Replaced `thread::sleep` with `thread::park_timeout` in worker run loop +- Workers register `thread::current()` via `OnceLock` on startup +- `send_to` and `spawn` call `Thread::unpark()` on target worker after enqueuing work +- Cross-worker sends from `WorkerContext` also unpark the target +- Zero new dependencies — uses only `std::sync::OnceLock` + `std::thread::park_timeout` + +**Key files modified:** `src/worker.rs`, `src/runtime.rs`, `src/delivery.rs` + +## Design Decisions + +- **`OnceLock` for thread handle storage** — set-once semantics match the worker lifecycle (one thread per worker, never changes). Simpler than `Mutex>`. +- **`park_timeout` instead of `park`** — timeout ensures workers periodically wake even without explicit unpark, preventing permanent sleep if an unpark is missed. +- **Unpark on `send_to` and `spawn`** — these are the two operations that create work for a worker. The cost is a single atomic store (no-op if thread is already running). +- **No condvar** — `thread::park/unpark` is simpler and avoids the spurious wakeup complexity of condition variables. Tokio's parker validates this approach. + +## Tests Added + +1 new test (51 → 52 total): + +- `mt_parked_worker_wakes_on_send` — verifies that a parked worker processes a message immediately after send (not after timeout) + +## Result + +- 52 tests pass +- All workspace crates compile +- Idle-to-active latency reduced from up to 1ms to near-zero +- No overhead on hot path — `unpark()` is a no-op when thread isn't parked diff --git a/cfuzz/CYCLE_04_SHUTDOWN_FIX.md b/cfuzz/CYCLE_04_SHUTDOWN_FIX.md new file mode 100644 index 0000000..fc25d5c --- /dev/null +++ b/cfuzz/CYCLE_04_SHUTDOWN_FIX.md @@ -0,0 +1,54 @@ +# Cycle 4: Shutdown Fix + Bug-Inspired Tests — Development History + +> Commit: `cf61619` · 3 files · 161 insertions, 12 deletions + +--- + +## Motivation + +Cycle 3 introduced thread parking, but created a new problem: `shutdown()` didn't unpark workers. Parked workers wouldn't notice the shutdown signal until their `park_timeout` expired, causing delayed shutdown. Additionally, studying bug reports from competitor projects (Ractor, Kameo, Actix) revealed specific failure modes worth testing in swactor. + +## Competitor Bug Analysis + +The 5 new tests were directly inspired by real bug reports from other actor frameworks: + +| Test | Inspired By | Bug | +|------|-------------|-----| +| `stats_snapshot_is_read_only` | Ractor #310 | `get_children()` was destructive — moved children out of supervisor | +| `stats_under_load_do_not_interfere_with_processing` | General | Stats collection shouldn't slow down message processing | +| `shutdown_wakes_parked_workers_immediately` | Cycle 3 regression | Parked workers must notice shutdown promptly | +| `mt_send_after_run_delivers_to_running_actors` | Kameo #185 | Messages sent after `run()` weren't delivered during startup race | +| `budget_respected_even_with_self_sends` | Actix #515 | Self-sends bypassed mailbox capacity, defeating backpressure | + +## Implementation + +### Shutdown Fix +- `shutdown()` now iterates all workers and calls `unpark()` on each thread handle +- Parked workers wake immediately and check the shutdown flag +- Workers that aren't parked are unaffected (unpark is a no-op) + +### Bug-Inspired Tests +Each test encodes a real bug class discovered in competitor frameworks, ensuring swactor doesn't have the same vulnerability. + +**Key files modified:** `src/runtime.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Unpark-all on shutdown** rather than a dedicated shutdown condvar — simpler, reuses existing parking infrastructure from Cycle 3 +- **Bug-inspired testing methodology** — studying competitor bug trackers yields high-value test cases that target real failure modes, not theoretical ones + +## Tests Added + +5 new tests (52 → 57 total): + +- `stats_snapshot_is_read_only` — reading stats doesn't mutate runtime state (from Ractor #310) +- `stats_under_load_do_not_interfere_with_processing` — stats don't affect message processing throughput +- `shutdown_wakes_parked_workers_immediately` — validates fast shutdown with thread parking +- `mt_send_after_run_delivers_to_running_actors` — messages sent after run() are delivered (from Kameo #185) +- `budget_respected_even_with_self_sends` — self-sends don't bypass budget (from Actix #515) + +## Result + +- 57 tests pass +- All workspace crates compile +- Shutdown latency with parked workers reduced from up to 1ms to near-zero diff --git a/cfuzz/CYCLE_05_LOAD_AWARE_PLACEMENT.md b/cfuzz/CYCLE_05_LOAD_AWARE_PLACEMENT.md new file mode 100644 index 0000000..c3eef1b --- /dev/null +++ b/cfuzz/CYCLE_05_LOAD_AWARE_PLACEMENT.md @@ -0,0 +1,66 @@ +# Cycle 5: Load-Aware Actor Placement + Work Stealing Research — Development History + +> Commit: `7d00e65` · 6 files · 184 insertions, 13 deletions + +--- + +## Motivation + +With fairness (Cycle 1), thread parking (Cycle 3), and shutdown (Cycle 4) resolved, the next bottleneck was actor placement. Swactor used blind round-robin to assign actors to workers — ignoring current load. If actors have unequal workloads, round-robin produces persistent imbalance. This cycle also included deep research into work stealing to decide whether full actor migration was worthwhile. + +## Competitor Analysis: Work Stealing Deep Dive + +| Aspect | Tokio | Go | BEAM | ForkJoinPool | +|--------|-------|-----|------|-------------| +| Queue | Fixed 256-slot ring | 256-slot ring + runnext | Per-priority linked | Growable array deque | +| Steal granularity | Half victim's queue | Half victim's runq | Individual processes | One task at a time | +| LIFO fast-path | Dedicated slot (3-use cap) | runnext (stealable 4th try) | None | Owner pops from top | +| Global queue | Mutex intrusive list | Checked 1/61 ticks | Per-priority migration | Even-indexed queues | +| Searcher limit | N/2 workers | GOMAXPROCS/2 | N/A (proactive migration) | Idle stack in ctl | +| Balance strategy | Reactive steal | Reactive steal | **Proactive migration** + reactive | Reactive scan | + +### Key Patterns Discovered + +1. **LIFO slot** — every runtime has one; improves cache locality by running the recipient immediately after the sender. Tokio caps at 3 consecutive uses to prevent starvation. +2. **Steal-half** — Tokio and Go both steal half the victim's queue, amortizing cross-thread coordination overhead. +3. **N/2 searcher limit** — both Tokio and Go cap concurrent searchers to prevent thundering herd (O(N²) cache-line bouncing). +4. **BEAM's migration** — unique dual approach: reactive stealing when idle + proactive migration via periodic `check_balance()`. + +### Feasibility for Swactor + +- **Full actor migration**: Mechanically possible (ActorSlot is `Send`), but has a 1-tick message loss window during migration and requires push-based donation (`ActorPool` is not `Sync` → no pull stealing) +- **Message stealing without actors**: Impossible — the actor IS the state; messages without the actor are meaningless +- **Decision: Load-aware placement over work stealing** — zero correctness risk, handles the primary imbalance source (uneven spawn distribution), full work stealing deferred + +## Implementation + +- `Placement::next_worker()` now reads per-worker stats (`num_actors` + `mailbox_depth`) +- Selects the worker with lowest combined load +- Scan starts from a rotating position → round-robin fallback when all stats are equal (initial burst, before first tick publishes stats) +- O(N) relaxed atomic loads per spawn — trivial for N ≤ 8 workers + +**Key files modified:** `src/delivery.rs`, `tests/runtime_api.rs`, `benches/runtime_benchmarks.rs` + +## Design Decisions + +- **Load-aware placement instead of work stealing** — zero message loss risk, no ordering changes, trivial implementation cost. Handles the #1 source of imbalance: uneven spawn distribution. +- **Combined metric (actors + depth)** — neither actor count alone nor mailbox depth alone captures load accurately. Combined metric approximates total pending work per worker. +- **Relaxed atomics for stat reads** — stats are advisory (best-effort), so relaxed ordering is sufficient. No need for acquire/release which would add synchronization cost. +- **Round-robin fallback** — before the first tick, all workers report zero stats. Falling back to round-robin ensures even initial distribution rather than always picking worker 0. +- **Full work stealing deferred** — would require migration channels, address map coordination, forwarding tombstones, and a message loss window. Benefit uncertain for N ≤ 8 workers. + +## Tests Added + +3 new tests (57 → 60 total): + +- `load_aware_placement_prefers_lighter_worker` — imbalanced load biases spawn toward the lighter worker +- `load_aware_placement_single_worker_degrades_gracefully` — single-thread mode works correctly +- `load_aware_placement_falls_back_to_round_robin_on_fresh_runtime` — even distribution before ticks produce stats + +**Benchmark added:** `placement/spawn_under_load` (2-thread and 4-thread variants) + +## Result + +- 60 tests pass +- All workspace crates compile +- Comprehensive work-stealing research documented for future reference diff --git a/cfuzz/CYCLE_06_BACKPRESSURE.md b/cfuzz/CYCLE_06_BACKPRESSURE.md new file mode 100644 index 0000000..67f6444 --- /dev/null +++ b/cfuzz/CYCLE_06_BACKPRESSURE.md @@ -0,0 +1,56 @@ +# Cycle 6: Per-Actor Mailbox Backpressure — Development History + +> Commit: `265992c` · 6 files · 163 insertions, 8 deletions + +--- + +## Motivation + +Before this change, swactor mailboxes were unbounded — a fast producer could flood a slow consumer's mailbox without limit, eventually exhausting memory. Every production actor framework provides some form of backpressure. This was identified as a key weakness in the competitor analysis. + +## Competitor Analysis + +| Framework | Default Capacity | Overflow Policy | Backpressure Model | +|-----------|-----------------|----------------|-------------------| +| Erlang/OTP | Unbounded | N/A (pobox for opt-in bounding) | Process isolation limits blast radius | +| Actix | 16 | `do_send()` bypasses for internal msgs | Tiny default, force callers to handle | +| Kameo | 64 | Bounded tokio mpsc (sender blocks) | Blocking backpressure | +| Tokio mpsc | User-specified | Bounded (sender blocks or permit pattern) | Blocking or try_send | +| Go channels | User-specified | Blocking send / non-blocking select | Blocking backpressure | +| **Swactor (before)** | **Unbounded** | **None** | **None** | + +Key observation: Actix's default capacity of 16 is aggressive — it forces callers to think about message flow. Kameo's 64 matches swactor's message budget. The consensus across frameworks: bounded by default, with configurable overflow policy. + +## Implementation + +- Added `MailboxOverflow` enum: `DropNewest` (discard incoming when full) and `DropOldest` (evict oldest to make room) +- Added `default_mailbox_capacity` and `mailbox_overflow` to `RuntimeConfig` +- Default: `capacity=0` (unbounded) — 100% backward compatible +- `ActorSlot` stores per-actor capacity and policy (initialized from runtime defaults at spawn time) +- `deliver()` in worker enforces bounds; dropped messages tracked via `drops_this_tick` counter +- `messages_dropped: AtomicU64` added to `WorkerStats` and `WorkerInfo` + +**Key files modified:** `src/config.rs`, `src/worker.rs`, `src/runtime.rs`, `src/stats.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **DropNewest vs DropOldest (not blocking)** — swactor's synchronous tick model can't block the sender (it would deadlock the entire worker). Drop policies are the only viable option for a sync runtime. +- **Default unbounded** — backward compatibility. Users opt into backpressure by setting capacity > 0. +- **Per-runtime defaults, not per-actor** — simpler configuration. Per-actor overrides could be added later via a builder pattern on spawn. +- **Drop counting** — critical for observability. Without it, users can't tell if their system is losing messages. +- **No DropRandom** — the two policies cover the common cases. DropNewest protects against producer floods (newest messages are redundant). DropOldest keeps the freshest state (useful for sensor/status actors). + +## Tests Added + +4 new tests (60 → 64 total): + +- `bounded_mailbox_drop_newest_caps_at_capacity` — 50 msgs sent, capacity 10 → only 10 delivered (oldest 10) +- `bounded_mailbox_drop_oldest_keeps_newest` — 10 msgs sent, capacity 5 → newest 5 kept +- `unbounded_mailbox_delivers_all_messages` — backward compatibility: capacity=0 delivers everything +- `bounded_mailbox_refills_after_processing` — capacity 5, process batch, refill works correctly + +## Result + +- 64 tests pass +- All workspace crates compile +- Swactor weakness "no backpressure" resolved diff --git a/cfuzz/CYCLE_07_ACTOR_RECOVERY.md b/cfuzz/CYCLE_07_ACTOR_RECOVERY.md new file mode 100644 index 0000000..1dd04d9 --- /dev/null +++ b/cfuzz/CYCLE_07_ACTOR_RECOVERY.md @@ -0,0 +1,59 @@ +# Cycle 7: Actor Recovery via Factory-Based Restart — Development History + +> Commit: `1779ad6` · 6 files · 167 insertions, 10 deletions + +--- + +## Motivation + +Before this change, a panicking actor was permanently poisoned — it could never process messages again. Its address remained in the address map but silently discarded all messages. In production, this means a single panic permanently degrades the system. Every mature actor framework provides some form of crash recovery. + +## Competitor Analysis + +| Framework | Recovery Model | State After Restart | Mailbox After Restart | +|-----------|---------------|--------------------|-----------------------| +| Erlang/OTP | Factory (MFA tuple), fresh process | Fresh (new init/1) | Lost (new PID) | +| Akka | Replace internals, keep ActorRef | Fresh (preRestart hook) | Preserved (docs say "usually wrong") | +| Kameo | `on_panic(&mut self)` hook | Potentially corrupt | Preserved | +| Actix | `Supervised` trait, re-create context | Fresh | Lost | +| Ractor | `SupervisionEvent` callback | Up to supervisor | Up to supervisor | +| **Swactor (before)** | **None — permanent poison** | **N/A** | **Silently discarded** | + +### Key Insight +Akka's approach of preserving state by replacing internals is documented as "usually wrong" — the state that caused the panic is likely corrupt. Kameo's `on_panic(&mut self)` is risky for the same reason. Erlang's factory-based restart (fresh process from MFA tuple) is the safest approach: guaranteed clean state. + +## Implementation + +- `Actor` expanded from tuple struct to named fields: `inner`, `restart_factory`, `max_restarts`, `restart_count` +- `AnyActor::try_restart(&self) -> Option>` trait method (default `None`, backward compatible) +- Factory stored as `Arc A + Send + Sync>` — called to produce fresh actor instance on restart +- `spawn_restartable(actor, factory, max_restarts)` added to both `Runtime` and `Ctx` +- `tick_all` panic handler: `try_restart()` before poisoning; on success, replace actor, clear mailbox, reset state +- `restarts` counter added to `WorkerStats` and `WorkerInfo` + +**Key files modified:** `src/actor.rs`, `src/runtime.rs`, `src/worker.rs`, `src/stats.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Factory-based restart (Erlang model)** — safest approach, guaranteed clean state. Factory closure is `Arc A>`, cloned into fresh `Actor` on each restart. +- **max_restarts limit** — prevents infinite restart loops. When exceeded, actor is permanently poisoned. Mirrors Erlang's restart intensity. +- **Mailbox cleared on restart** — messages that triggered the panic are discarded. Fresh actor starts with empty mailbox. (Erlang does this too — new PID means new mailbox.) +- **Same address preserved** — unlike Erlang (new PID), the restarted actor keeps its `ActorAddress`. This is simpler for callers and matches Akka's model. +- **Factory fields are "cold"** — `restart_factory` and `max_restarts` are never touched by `handle_any` (the hot path). After `catch_unwind`, these fields are guaranteed safe to read. +- **Non-restartable actors unchanged** — `try_restart()` returns `None` by default, preserving the existing poison-on-panic behavior. + +## Tests Added + +4 new tests (64 → 68 total): + +- `restartable_actor_recovers_after_panic` — basic restart works: panic, recover, process new messages +- `restartable_actor_resets_state_on_restart` — fresh state confirmed post-restart (counter resets to zero) +- `restartable_actor_respects_max_restarts` — 2 restarts allowed, 3rd panic → permanent poison +- `non_restartable_actor_still_poisons_on_panic` — backward compatibility: default actors still poison + +## Result + +- 68 tests pass +- All workspace crates compile +- Swactor weakness "panicked actors permanently poisoned" resolved +- Foundation laid for supervision trees (Cycle 17) diff --git a/cfuzz/CYCLE_08_DEAD_ACTOR_CLEANUP.md b/cfuzz/CYCLE_08_DEAD_ACTOR_CLEANUP.md new file mode 100644 index 0000000..e697429 --- /dev/null +++ b/cfuzz/CYCLE_08_DEAD_ACTOR_CLEANUP.md @@ -0,0 +1,54 @@ +# Cycle 8: Dead Actor Cleanup (Memory Leak Fix) — Development History + +> Commit: `0213938` · 4 files · 120 insertions, 14 deletions + +--- + +## Motivation + +After Cycles 7 (recovery) and the pre-existing poison-on-panic behavior, dead actors accumulated in both `ActorPool` and `AddressMap` forever. Their slots were never reclaimed, their addresses remained registered, and the system gradually leaked memory. This is a known bug class in actor frameworks. + +## Competitor Analysis + +| Framework | Dead Actor Handling | Known Bugs | +|-----------|-------------------|------------| +| Akka | Automatic cleanup via DeathWatch | #22990 — ActorRef leak in certain paths | +| CAF | Manual cleanup expected | #420 — actor leak in specific failure modes | +| Erlang/OTP | Automatic — process exits free all resources | N/A (VM handles cleanup) | +| Ractor | Supervisor-driven cleanup | Memory bloat per actor at scale | +| **Swactor (before)** | **None — permanent leak** | **Both ActorPool and AddressMap leak** | + +## Implementation + +- Added `AddressMap::remove(addr)` to `delivery.rs` — O(1) removal from address map +- Added `ActorPool::cleanup_dead()` to `worker.rs` — collects and removes poisoned actors, returns their addresses +- Added Phase 7 to `tick_once`: `cleanup_dead` → remove from address_map → re-publish `num_actors` stat +- Stats immediately reflect removal (no stale counts) + +**Key files modified:** `src/delivery.rs`, `src/worker.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Automatic cleanup in tick_once** — no manual API needed. Dead actors are cleaned up every tick, preventing accumulation. +- **Phase 7 (after all message processing)** — cleanup happens after `tick_all` and `pending_local`, so any final messages to dead actors correctly fail. No risk of cleaning up an actor that's about to receive a message. +- **Re-publish `num_actors` after cleanup** — ensures stats are immediately consistent. Without this, stats would show stale actor counts until the next tick. + +### Behavior Change +- **Before**: Sending to a poisoned actor silently discarded the message (address still in map, delivery succeeded, but processing was skipped) +- **After**: Sending to a cleaned-up actor returns `Err` (address removed from map, send fails) +- This is **better** — callers learn the actor is gone instead of silently losing messages. + +## Tests Added + +2 new tests + 2 existing tests updated (68 → 70 total): + +- `dead_actor_cleaned_up_from_stats_and_address_map` — good actor persists, bad actor's address is removed +- `bulk_dead_actor_cleanup` — 20 panicked actors all cleaned up in one tick +- Updated `send_to_poisoned_actor_is_a_silent_black_hole` → now asserts send returns `Err` (behavior change) +- Updated `poisoned_actor_messages_not_counted_as_processed` → sends fail to cleaned-up actor + +## Result + +- 70 tests pass +- All workspace crates compile +- Memory leak closed: dead actors no longer accumulate in ActorPool or AddressMap diff --git a/cfuzz/CYCLE_09_LIFECYCLE_HOOKS.md b/cfuzz/CYCLE_09_LIFECYCLE_HOOKS.md new file mode 100644 index 0000000..ab06da4 --- /dev/null +++ b/cfuzz/CYCLE_09_LIFECYCLE_HOOKS.md @@ -0,0 +1,79 @@ +# Cycle 9: Lifecycle Hooks and Graceful Actor Stop — Development History + +> Commit: `e28aca0` · 8 files · 427 insertions, 27 deletions + +--- + +## Motivation + +Before this change, actors had no initialization or teardown callbacks and no way to stop gracefully. An actor started processing messages immediately (no setup phase) and could only die by panicking. Every mature actor framework provides lifecycle hooks for resource management and graceful shutdown. + +## Competitor Analysis + +| Framework | on_start | on_stop | on_panic | Self-stop | External stop | +|-----------|----------|---------|----------|-----------|---------------| +| Erlang | `init/1` | `terminate/2` (NOT on crash) | N/A | `{stop,Reason,State}` | `gen_server:stop` | +| Akka | `preStart` | `postStop` (always) | `preRestart` | `context.stop(self)` | PoisonPill / stop | +| Actix | `started` | `stopped` | N/A | `ctx.stop()` | `addr.do_send(Stop)` | +| Kameo | `on_start` | `on_stop` | `on_panic` | `Context::stop()` | `stop_gracefully/kill` | +| Ractor | `pre_start` | `post_stop` (NOT on kill/panic) | N/A | `stop()` | `Signal::Kill` | +| **Swactor** | **`on_start`** | **`on_stop` (NOT on panic)** | N/A | **`ctx.stop_self()`** | **`runtime.stop_actor()`** | + +### Key Findings +- Most frameworks do NOT call `on_stop` on panic — state may be corrupt, running teardown on corrupt state is unsafe. Erlang and Ractor agree. Akka is the outlier (always calls `postStop`). +- Self-stop should be immediate (after current message). External stop should be queued (PoisonPill semantics — process pending messages first). +- Restarted actors should get `on_start` called again on the fresh instance. + +## Implementation + +### Lifecycle Hooks +- `ActorInterface::on_start(&mut self, ctx: &Ctx)` — default no-op, called on first tick before any messages +- `ActorInterface::on_stop(&mut self, ctx: &Ctx)` — default no-op, called during cleanup for gracefully-stopped actors +- `AnyActor::on_start()`/`on_stop()` — forwarded from `Actor` implementation +- `ActorSlot` gains `started: bool` flag — tracks whether `on_start` has been called +- `on_start` called in `tick_all` before first message; panic in `on_start` → immediate poison +- `on_stop` called in `cleanup_dead` for stopping (not poisoned) actors, wrapped in `catch_unwind` +- Restarted actors get `started=false` so `on_start` fires again on fresh instance + +### Graceful Stop (Dual Mode) +- `ctx.stop_self()` — **immediate** stop after current message via `request_stop` buffer +- `runtime.stop_actor(addr)` — **external** stop via `StopSignal` message (PoisonPill semantics: queued after existing messages) +- `ActorSlot` gains `stopping: bool` flag +- Phase 7 `cleanup_dead` now handles both poisoned AND stopping actors + +### Stats +- `stops: AtomicU64` added to `WorkerStats` and `WorkerInfo` — tracks graceful stops separately from panics + +**Key files modified:** `src/actor.rs`, `src/worker.rs`, `src/runtime.rs`, `src/delivery.rs`, `src/stats.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **`on_stop` NOT called on panic** — matches Erlang and Ractor. Corrupt state after panic makes teardown unsafe. If you need cleanup, use `spawn_restartable` (Cycle 7) to get a fresh instance. +- **Dual stop modes** — `ctx.stop_self()` is immediate (actor decides "I'm done after this message"). `runtime.stop_actor()` is queued (external signal processed after pending messages). This matches Erlang's `{stop, Reason, State}` vs `gen_server:stop`. +- **StopSignal as a message** — external stop uses the same delivery pipeline as regular messages. No special-case routing needed. The PoisonPill pattern (Akka) is well-proven. +- **`on_start` panic → immediate poison** — initialization failure is fatal. No restart attempted because the factory might produce the same broken actor. Matches Erlang's `{stop, Reason}` from `init/1`. +- **Default no-ops** — both hooks are optional. Existing actors don't need to change. 100% backward compatible. + +## Tests Added + +12 new tests (70 → 82 total): + +- `on_start_called_before_first_message` — on_start fires on first tick, before messages +- `on_start_called_per_actor` — 5 actors each get exactly one on_start call +- `on_start_panic_poisons_actor` — panic in on_start → poisoned, no messages processed +- `actor_can_stop_self` — 5 msgs sent, stops after 3, only 3 processed, on_stop called +- `runtime_can_stop_actor` — external stop via runtime, on_stop called, actor removed +- `send_to_stopped_actor_returns_error` — stopped actor gone from address map +- `stop_vs_panic_tracked_separately_in_stats` — stops and panics counted independently +- `on_stop_can_send_messages` — farewell message sent during on_stop is delivered +- `on_start_called_again_after_restart` — restartable actor gets on_start on fresh instance +- `external_stop_is_queued_after_pending_messages` — PoisonPill semantics verified +- `external_stop_before_new_messages_prevents_processing` — stop before send blocks new msgs +- `stop_nonexistent_actor_returns_error` — stop on bad address returns Err + +## Result + +- 82 tests pass +- All workspace crates compile +- Swactor weaknesses "no lifecycle hooks" and "no graceful stop" both resolved +- Foundation for supervision (Cycle 17) — `on_stop` enables resource cleanup, `stop_actor` enables supervisor-controlled shutdown diff --git a/cfuzz/CYCLE_10_TIMERS.md b/cfuzz/CYCLE_10_TIMERS.md new file mode 100644 index 0000000..98e1d2d --- /dev/null +++ b/cfuzz/CYCLE_10_TIMERS.md @@ -0,0 +1,76 @@ +# Cycle 10: Per-Worker Tick-Counting Timers — Development History + +> Commit: `d58a999` · 5 files · 247 insertions, 5 deletions + +--- + +## Motivation + +Actors often need to schedule delayed or periodic work (timeouts, heartbeats, polling intervals). Before this change, swactor had no timer mechanism — actors had to manually count ticks or rely on external scheduling. The synchronous tick model makes wall-clock timers inappropriate, but tick-counting timers are a natural fit and provide deterministic behavior. + +## Competitor Analysis + +| Framework | Timer Model | Deterministic? | +|-----------|------------|---------------| +| Erlang | `timer:send_after`, `erlang:start_timer` (wall-clock ms) | No | +| Akka | `scheduleOnce`, `scheduler` (wall-clock duration) | No | +| Actix | `ctx.run_later`, `ctx.run_interval` (wall-clock) | No | +| Kameo | `tokio::time::sleep` (wall-clock) | No | +| Tokio | `tokio::time` (wall-clock, pausable for testing) | With `time::pause()` | +| Go | `time.After`, `time.NewTicker` (wall-clock) | No | +| **Swactor** | **Tick-counting** | **Yes — fully deterministic** | + +### Key Insight +Swactor's synchronous tick model makes tick-counting timers uniquely valuable: a timer scheduled for "5 ticks from now" fires at exactly tick N+5, regardless of wall-clock speed. This makes timer behavior reproducible in tests and simulations — something no other framework provides natively. + +Also researched but **rejected**: priority messages (lifecycle hooks from Cycle 9 cover 95% of use cases) and SmallBox optimization (deferred: measure allocation cost first before adding unsafe code). + +## Implementation + +### Timer Types +- `OnceTimer` — fire once at `fire_at` tick, consumed after firing +- `IntervalTimer` — fire every `period` ticks, message cloned via `CloneMsg` trait + +### Timer Infrastructure +- `CloneMsg` trait — type-erased clone for interval timer messages (blanket impl for `Message + Clone`) +- `TimerRequest` enum: `Once { dest, msg, ticks }` | `Interval { dest, msg, period }` +- Per-worker `TimerWheel` — stores pending timers, checked each tick + +### Integration into tick_once +- **Phase 2.5**: Fire due timers, route through full delivery system (pool.deliver for local actors, transfer_txs for cross-worker, inbox_registry for inboxes) +- **Phase 5.5**: Drain timer requests from handler buffer into TimerWheel +- **After cleanup_dead**: GC interval timers for dead actors + +### API +- `ctx.send_after_ticks(addr, msg, ticks)` — one-shot timer +- `ctx.send_interval_ticks(addr, msg, period)` — interval timer +- `Runtime::schedule_timer()` — no-op with warning (timers are per-worker only, must be scheduled from within a handler) + +**Key files modified:** `src/actor.rs`, `src/worker.rs`, `src/runtime.rs`, `src/delivery.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Tick-counting, not wall-clock** — deterministic behavior is a core swactor advantage. Wall-clock timers would break test reproducibility and simulation fidelity. +- **Per-worker timer wheel** — timers are local to the worker that owns the actor. No cross-worker synchronization needed. Timer routing uses the same delivery system as regular messages. +- **CloneMsg trait** — interval timers need to clone the message for each firing. A blanket impl covers all `Message + Clone` types, so users don't need to implement anything extra. +- **Timer GC for dead actors** — interval timers must be cleaned up when their target actor dies, otherwise they fire forever into the void. + +### Bug Fixed +`gc_dead_intervals` was initially over-aggressive — it removed timers for ANY address not in the local pool, including inboxes and cross-worker actors. Fixed to only GC timers for addresses in the `dead` set from `cleanup_dead`. + +## Tests Added + +6 new tests (82 → 88 total): + +- `one_shot_timer_fires_after_n_ticks` — timer with delay=3 fires on tick 4 +- `handler_can_schedule_one_shot_timer` — timer scheduled from within a handler fires correctly +- `one_shot_timer_fires_only_once` — consumed after firing, doesn't repeat +- `interval_timer_fires_repeatedly` — period=2, fires every 2 ticks (3 firings verified) +- `interval_timer_cleaned_up_when_actor_dies` — GC removes orphaned interval timers +- `timer_with_zero_delay_fires_next_tick` — delay=0 fires on next tick (not same tick) + +## Result + +- 88 tests pass +- All workspace crates compile +- Bug found and fixed: over-aggressive timer GC for cross-worker addresses diff --git a/cfuzz/CYCLE_11_PROPERTY_TESTING.md b/cfuzz/CYCLE_11_PROPERTY_TESTING.md new file mode 100644 index 0000000..544628f --- /dev/null +++ b/cfuzz/CYCLE_11_PROPERTY_TESTING.md @@ -0,0 +1,84 @@ +# Cycle 11: Property-Based Testing and Extended Fuzz Targets — Development History + +> Commit: `9b1518b` · 5 files · 534 insertions, 3 deletions + +--- + +## Motivation + +After 10 cycles of behavioral tests, the test suite relied entirely on manually-written scenarios. Property-based testing can explore state spaces that humans wouldn't think to test, automatically finding minimal failing cases. With swactor's deterministic tick model, property-based testing is an especially good fit — no concurrency noise to mask bugs. + +## Competitor Analysis + +| Framework/Tool | Testing Approach | Fit for Swactor | +|----------------|-----------------|-----------------| +| Tokio + Loom | Model-checking for lock-free code | Poor fit — swactor isn't lock-free | +| Erlang + PropEr/QuickCheck | Property-based with shrinking | Good model for swactor | +| Shuttle | Concurrency permutation testing | Moderate — useful for MT tests | +| proptest-state-machine | Stateful property testing for Rust | **Perfect fit** — deterministic ticks | +| cargo-fuzz | Coverage-guided fuzzing | Already in use, extended here | + +### Ranked Approaches +1. **proptest-state-machine** — perfect fit for deterministic ticks, generates random operation sequences, automatic shrinking +2. Extend cargo-fuzz with new action types +3. Simple proptest (stateless properties) +4. Shuttle (concurrency permutations) +5. Loom (lock-free verification) + +### Key Finding: Feature Gap Analysis +While researching testing approaches, also surveyed remaining feature gaps: named actors/registry, actor monitoring/death watch, actor groups/pub-sub, and ask pattern. These became Cycles 12–15. + +## Implementation + +### Property-Based Tests (proptest) +Added `proptest` and `proptest-state-machine` to dev-dependencies. New test file: `tests/proptest_runtime.rs` with 7 tests: + +| Test | Property Verified | +|------|-------------------| +| `fifo_ordering_for_any_message_sequence` | FIFO preserved for 1–100 random messages | +| `budget_limits_per_actor_processing` | Budget caps per-tick processing for 2–10 actors | +| `one_shot_timer_fires_at_correct_tick` | Timer with delay 1–20 fires at exact right tick | +| `interval_timer_fires_at_correct_period` | Period 1–10, verifies 3 consecutive firings | +| `bounded_mailbox_never_exceeds_capacity` | Capacity 1–20, 1–200 messages, never exceeds | +| `spawn_n_actors_all_tracked` | 1–50 actors, all unique, all in stats | +| `swactor_state_machine` | Random Spawn/Send/Tick/Stop/CheckStats sequences | + +### State Machine Test +The `swactor_state_machine` test is the most sophisticated: +- **Reference model**: `HashMap` tracking expected actor lifecycle +- **Operations**: random Spawn, Send, Tick, Stop, CheckStats transitions (up to 40 per test, 128 cases) +- **Invariants checked after every transition**: worker count, actor placement, mailbox safety +- **Automatic shrinking**: finds minimal failing sequences when invariants break + +### Extended Fuzz Targets +Added 4 new `RawAction` variants to `fuzz/fuzz_targets/fuzz_runtime.rs`: +- `StopActor` — graceful stop via `runtime.stop_actor` +- `SpawnRestartable` — `spawn_restartable` with configurable `max_restarts` +- `ScheduleTimer` — one-shot timer via TimerSchedulerActor +- `ScheduleInterval` — interval timer via IntervalSchedulerActor + +3 new actor types added to fuzz: `TimerSchedulerActor`, `IntervalSchedulerActor`, `RestartableEchoActor` + +**Key files modified:** `Cargo.toml`, `tests/proptest_runtime.rs` (new), `fuzz/fuzz_targets/fuzz_runtime.rs` + +## Design Decisions + +- **proptest-state-machine over Loom** — Loom is designed for lock-free concurrent data structures. Swactor's primary correctness properties are sequential (within a tick). The state machine approach tests the actor lifecycle model, which is where bugs are most likely. +- **Reference model pattern** — the state machine test maintains a separate `HashMap` as the "expected" state and compares it against the runtime's actual state after each operation. This catches any divergence between the mental model and reality. +- **Extending existing fuzz targets** — rather than creating new fuzz targets, extended the existing `fuzz_runtime.rs` with new action variants. This means the fuzzer explores interactions between the new features (timers, restart, stop) and existing operations (spawn, send, tick). + +### Bug Found +The state machine test immediately caught an invariant mismatch: `address_map` tracks spawned actors immediately (on spawn), but per-worker `num_actors` lags until the first tick (when the spawn is drained). Fixed the invariant to use `<=` check instead of exact equality. + +## Tests Added + +7 new property tests (88 → 95 total): + +- 6 stateless property tests covering FIFO, budget, timers, mailbox bounds, and spawn tracking +- 1 stateful state machine test covering random operation sequences + +## Result + +- 95 tests pass (88 behavioral + 7 proptest) +- Fuzz targets compile with new action variants +- Bug found: stats lag vs address_map on spawn (invariant relaxed) diff --git a/cfuzz/CYCLE_12_NAMED_REGISTRY.md b/cfuzz/CYCLE_12_NAMED_REGISTRY.md new file mode 100644 index 0000000..2127187 --- /dev/null +++ b/cfuzz/CYCLE_12_NAMED_REGISTRY.md @@ -0,0 +1,83 @@ +# Cycle 12: Named Actor Registry with Auto-Cleanup — Development History + +> Commit: `66a8523` · 6 files · 267 insertions, 7 deletions + +--- + +## Motivation + +Actors in swactor were only addressable by opaque `ActorAddress` values returned from spawn. There was no way to look up an actor by name — callers needed to pass addresses around manually. Named registration is one of the most fundamental actor runtime features, enabling service discovery within a runtime. + +## Competitor Analysis + +| Framework | Key Type | Storage | Scope | Auto-Cleanup | +|-----------|----------|---------|-------|-------------| +| Erlang | Atom | ETS table | Per-node or global | Yes (on process exit) | +| Actix | TypeId | SystemRegistry | Per-Arbiter | Yes (on actor stop) | +| Bastion | Path | Hierarchy | Global | Yes (structural) | +| Ractor | String | DashMap (global static) | Global | Yes (on actor death) | +| xactor | TypeId | Singleton registry | Global | N/A (singletons) | +| Akka | ServiceKey[T] | Receptionist | Cluster-wide | Yes (via DeathWatch) | +| **Swactor** | **String** | **RwLock\** | **Per-runtime** | **Yes (on death)** | + +### Key Findings +- **TypeId keys** (Actix, xactor) don't fit swactor's type-erased model — multiple actors of the same type can't share a TypeId key +- **Global static** (Ractor) breaks multi-runtime scenarios (tests, embedding) +- **Erlang's `register/whereis`** is the gold standard: atom keys, per-node scope, automatic cleanup on process exit + +## Implementation + +### NameRegistry +- `NameRegistry` in `delivery.rs` with forward + reverse maps: + - `names: RwLock>` — name → address lookup + - `addrs: RwLock>` — address → name (for O(1) cleanup) +- Added to `Runtime` as `Arc`, threaded through `TickContext` + +### Runtime API +- `spawn_named(name, actor)` — spawn and register atomically +- `where_is(name)` — look up address by name +- `unregister(name)` — manual unregistration (actor keeps running) +- `registered_names()` — list all registered names + +### Context API +- `ctx.spawn_named(name, actor)` — register from within a handler +- `ctx.where_is(name)` — look up from within a handler + +### Auto-Cleanup +- `cleanup_dead` phase calls `name_registry.unregister_by_addr()` for each dead actor +- Name is freed immediately — can be reused for a replacement actor + +### TOCTOU Prevention +- Name reservation is immediate (before spawn queue push) — prevents race between checking name availability and registering it + +**Key files modified:** `src/delivery.rs`, `src/runtime.rs`, `src/actor.rs`, `src/worker.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **String keys** — most flexible. Atoms (Erlang) aren't idiomatic in Rust. TypeId (Actix) is too restrictive. Strings allow any naming convention. +- **Per-runtime scope** — matches swactor's architecture (one runtime per application). Global registries (Ractor) cause problems in tests and embedded scenarios. +- **RwLock\** — matches the existing `AddressMap` and `InboxRegistry` pattern. RwLock allows concurrent reads (lookups) with exclusive writes (registration). +- **Collision returns error** — `spawn_named` returns `Err` if the name is already taken. The original binding is preserved. This is explicit and predictable, matching Erlang's behavior. +- **Reverse map for O(1) cleanup** — without the reverse map, cleanup would require scanning all entries. The reverse map adds memory proportional to registered actors but makes cleanup constant-time. +- **Immediate reservation** — name is reserved before the spawn is queued, preventing TOCTOU races where two `spawn_named` calls for the same name could both succeed. + +## Tests Added + +11 new tests (95 → 106 total): + +- `named_actor_lookup_returns_spawn_address` — spawn_named → where_is roundtrip +- `named_actor_receives_messages_via_lookup` — send to looked-up address works +- `duplicate_name_returns_error` — collision error, original binding preserved +- `where_is_returns_none_for_unknown_name` — nonexistent name → None +- `name_auto_unregistered_on_actor_death` — stop_actor → name freed +- `name_can_be_reused_after_actor_death` — death → respawn with same name succeeds +- `name_auto_unregistered_on_panic` — panic → name freed +- `registered_names_lists_all` — all registered names returned +- `manual_unregister_frees_name_but_actor_lives` — unregister doesn't kill the actor +- `ctx_where_is_resolves_inside_handler` — where_is works from handler context +- `ctx_spawn_named_registers_from_handler` — spawn_named works from handler context + +## Result + +- 106 tests pass (99 behavioral + 7 proptest) +- All workspace crates compile, zero warnings diff --git a/cfuzz/CYCLE_13_MONITORING.md b/cfuzz/CYCLE_13_MONITORING.md new file mode 100644 index 0000000..59cc323 --- /dev/null +++ b/cfuzz/CYCLE_13_MONITORING.md @@ -0,0 +1,74 @@ +# Cycle 13: Actor Monitoring with Down Message Notifications — Development History + +> Commit: `8782638` · 6 files · 268 insertions, 10 deletions + +--- + +## Motivation + +Actors had no way to know when other actors died. If actor A depended on actor B, and B panicked or was stopped, A would continue sending messages into the void with no notification. Monitoring (also called "death watch") is essential for building fault-tolerant systems — it's the foundation that supervision trees are built on. + +## Competitor Analysis + +| Framework | Mechanism | Direction | Notification | +|-----------|-----------|-----------|-------------| +| Erlang | `monitor/2` | Unidirectional | `DOWN` message | +| Akka | `watch` | Unidirectional | `Terminated` message | +| Ractor | `link` | Bidirectional | `SupervisionEvent` | +| Actix | None built-in | N/A | N/A | +| Kameo | `link` | Bidirectional | `on_link_died` callback | +| **Swactor** | **`ctx.monitor()`** | **Unidirectional** | **`Down` message** | + +### Key Findings +- **Erlang's unidirectional monitor + message delivery** is the best fit for swactor — it reuses the existing type-erased message handler, requires zero trait changes, and is composable +- **Callbacks** (Ractor/Kameo style) rejected — would require adding a new method to `AnyActor`/`ActorInterface` traits, forcing all actors to implement it +- **Bidirectional links** deferred — can be layered on top of monitors later +- **Stacking** (Erlang) — multiple monitors of the same target produce independent notifications + +## Implementation + +### Types (in `actor.rs`) +- `MonitorRef(u64)` — unique token from `AtomicU64` counter, used for demonitor +- `Down { addr: ActorAddress, reason: StopReason }` — delivered as normal mailbox message +- `StopReason` enum: `Normal` (graceful stop) | `Panicked` (panic, not restartable) + +### MonitorRegistry (in `delivery.rs`) +- `watchers: RwLock>>` — watched → list of (ref, watcher) +- `refs: RwLock>` — ref → watched (for O(1) demonitor) + +### API +- `ctx.monitor(target) → MonitorRef` — subscribe to death notifications +- `ctx.demonitor(mref)` — cancel a subscription + +### Integration with cleanup_dead +- `cleanup_dead` now returns `Vec<(ActorAddress, StopReason)>` instead of `Vec` +- After cleanup: iterate dead actors, take monitors from registry, route `Down` through normal delivery (pool.deliver for same-worker, transfer_txs for cross-worker, inbox_registry for inboxes) +- Dead watcher cleanup: `remove_watcher()` strips monitor subscriptions for dead watchers (prevents ghost subscriptions) + +**Key files modified:** `src/actor.rs`, `src/delivery.rs`, `src/runtime.rs`, `src/worker.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Unidirectional monitors (Erlang model)** — simpler than bidirectional links, no cascading death. The watcher is notified but doesn't automatically die. This gives the watcher full control over how to react. +- **Down as a regular message** — delivered through the same mailbox as other messages. Actors with `Incoming = Down` receive it via `handle()`. This reuses the entire existing delivery pipeline with zero special-case code. +- **MonitorRef for demonitor** — each monitor subscription gets a unique ref. This supports stacking (multiple monitors of the same target) and precise cancellation. +- **StopReason distinguishes Normal vs Panicked** — watchers can decide how to react based on whether the death was graceful or a crash. Matches Erlang's `DOWN` message which includes the exit reason. +- **Dead watcher cleanup** — if the watcher dies before the watched actor, its monitor subscriptions are cleaned up. Without this, dead watchers would accumulate as ghost entries in the registry. + +## Tests Added + +7 new tests (106 → 113 total): + +- `monitor_notifies_on_graceful_stop` — Down{reason: Normal} on graceful stop +- `monitor_notifies_on_panic` — Down{reason: Panicked} on panic +- `multiple_watchers_all_notified` — two watchers both receive Down +- `demonitor_cancels_notification` — demonitor → no Down delivered +- `dead_watcher_does_not_receive_down` — dead watcher's monitors cleaned up +- `down_delivered_to_external_inbox` — Down forwarded through inbox +- `stacked_monitors_produce_multiple_notifications` — two monitors on same target → two Downs + +## Result + +- 113 tests pass (106 behavioral + 7 proptest) +- All workspace crates compile, zero warnings +- Foundation for supervision trees (Cycle 17) — monitors provide the death detection mechanism diff --git a/cfuzz/CYCLE_14_GROUPS.md b/cfuzz/CYCLE_14_GROUPS.md new file mode 100644 index 0000000..d8be6d4 --- /dev/null +++ b/cfuzz/CYCLE_14_GROUPS.md @@ -0,0 +1,83 @@ +# Cycle 14: Actor Groups with Pub-Sub Broadcast — Development History + +> Commit: `4d18874` · 5 files · 307 insertions, 8 deletions + +--- + +## Motivation + +Named registry (Cycle 12) provides one-to-one name→actor mapping. Many patterns require one-to-many: broadcasting events to subscribers, load distribution across a pool, or topic-based message routing. Actor groups provide this — a named collection of actors that can receive messages as a group. + +## Competitor Analysis + +| Framework | Mechanism | Key Design | Auto-Cleanup | +|-----------|-----------|------------|-------------| +| Erlang `pg` | Scopes, join/leave/get_members | Flat groups, atom keys | Yes (on process exit) | +| Akka | DistributedPubSub (mediator, topics) | Cluster-wide pub-sub | Yes (via DeathWatch) | +| Ractor | `pg` module (join/leave/broadcast) | Erlang-style, global | Yes | +| Bastion | Dispatcher | Hierarchy-based routing | Structural | +| Redis pub/sub | Channels, patterns | External service | N/A | +| **Swactor** | **GroupRegistry** | **Erlang pg-style, per-runtime** | **Yes (on death)** | + +### Common Patterns Across Frameworks +- Auto-cleanup on death (universal) +- At-most-once delivery (no re-delivery guarantees) +- String-based naming (flat, not hierarchical) +- Lazy group creation/deletion (groups created on first join, deleted when empty) + +## Implementation + +### GroupRegistry (in `delivery.rs`) +- Forward map: `groups: RwLock>>` — group → members +- Reverse map: `memberships: RwLock>>` — actor → groups (for cleanup) +- Groups auto-create on first join, auto-delete when empty + +### Runtime API +- `join_group(addr, name)` — add actor to group +- `leave_group(addr, name)` — remove actor from group +- `publish_to(group, msg)` — broadcast to all group members +- `group_members(group)` — list members +- `groups()` — list all groups + +### Context API (from handler) +- `ctx.join_group(name)` — join from inside handler +- `ctx.leave_group(name)` — leave from inside handler +- `ctx.publish(group, msg)` — broadcast from inside handler +- `ctx.group_members(group)` — query from inside handler + +### Message Delivery +- `publish` clones message at the typed level (`Message: Clone`), sends to each member via normal routing +- Uses the same delivery pipeline as regular messages (pool.deliver, transfer_txs, inbox_registry) + +### Auto-Cleanup +- `group_registry.cleanup(&addr)` called in `cleanup_dead` phase +- Uses reverse map to find all groups the dead actor belonged to, removes from each + +**Key files modified:** `src/delivery.rs`, `src/runtime.rs`, `src/actor.rs`, `src/worker.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Erlang `pg` model** — flat groups with string keys. Simpler than Akka's mediator/topic model, and sufficient for the common use cases (event broadcasting, worker pools). +- **Clone-based broadcast** — message is cloned for each recipient. This is O(N) but straightforward and type-safe. Alternative (shared Arc) would complicate the message pipeline. +- **Reverse map for cleanup** — without it, cleaning up a dead actor would require scanning all groups. O(1) per group membership vs O(groups) scan. +- **Lazy lifecycle** — groups are created implicitly on first join and deleted when the last member leaves. No explicit create/delete API needed. Matches Erlang `pg`. +- **publish requires `Message: Clone`** — enforced at the type level. If a message type isn't Clone, it can't be broadcast. This is a compile-time safety guarantee. + +## Tests Added + +9 new tests (113 → 122 total): + +- `group_members_returns_joined_actors` — join + query returns members +- `empty_group_returns_no_members` — nonexistent group → empty set +- `publish_broadcasts_to_all_members` — 2 members, both receive the message +- `leave_group_stops_receiving_publishes` — leave → excluded from future broadcasts +- `dead_actor_auto_removed_from_group` — stop → removed from group +- `actor_removed_from_all_groups_on_death` — multi-group membership cleanup +- `empty_group_auto_deleted` — last member leaves → group removed from `groups()` +- `ctx_join_group_from_handler` — join via on_start +- `ctx_publish_broadcasts_from_handler` — publish via handler + +## Result + +- 122 tests pass (115 behavioral + 7 proptest) +- All workspace crates compile, zero warnings diff --git a/cfuzz/CYCLE_15_ASK_PATTERN.md b/cfuzz/CYCLE_15_ASK_PATTERN.md new file mode 100644 index 0000000..d40c8f0 --- /dev/null +++ b/cfuzz/CYCLE_15_ASK_PATTERN.md @@ -0,0 +1,67 @@ +# Cycle 15: Ask Pattern for Typed Request-Response — Development History + +> Commit: `902471b` · 3 files · 166 insertions, 1 deletion + +--- + +## Motivation + +Request-response is one of the most common actor communication patterns: "send a question, wait for the answer." Before this change, implementing request-response in swactor required manual inbox creation, message construction with a reply-to address, sending, ticking, and polling — a verbose 5-step process. Every mature actor framework provides a convenience wrapper for this pattern. + +## Competitor Analysis + +| Framework | Pattern | Mechanism | Synchronous? | +|-----------|---------|-----------|-------------| +| Erlang | `gen_server:call` | `From` + `gen_server:reply` | Blocks caller (with timeout) | +| Akka | `ask` | Temporary actor + `Future` | Returns Future | +| Ractor | `call` | `RpcReplyPort` (oneshot channel) | Returns JoinHandle | +| Kameo | `ask` | Async + `Reply` trait | Returns Future | +| xactor | `Handler::handle` | Return value auto-routed | Implicit | +| **Swactor** | **`rt.ask()`** | **Inbox + closure** | **`recv_ticking` (tick-driven)** | + +### Key Findings +- Swactor's synchronous tick model requires explicit `reply_to` — there's no async runtime to suspend the caller +- **Implicit auto-reply rejected** — would add magic to the message pipeline and complicate the actor interface +- **Decision**: convenience wrapper over existing inbox pattern (not a new mechanism) + +## Implementation + +### Ask\ Struct +- Wraps an `Inbox` with convenience methods +- `try_recv()` — poll without ticking (works in both single and multi-threaded modes) +- `recv_ticking(rt, max_ticks)` — tick the runtime until a response arrives or timeout (single-threaded only) +- `reply_addr()` — access the inbox address for manual use + +### Runtime::ask() +- `rt.ask(addr, |reply_to| Msg { reply_to })` — one-line request-response +- Creates inbox, builds message via closure (user provides the reply_to field), sends, returns `Ask` +- Purely sugar over the existing `new_inbox → send_to → tick → try_recv` pattern + +### No Internal Changes +- Zero changes to `ContextInner` or `ActorInterface` +- No implicit auto-reply magic +- Actors reply by explicitly sending to the `reply_to` address (same as before) + +**Key files modified:** `src/runtime.rs`, `tests/runtime_api.rs` + +## Design Decisions + +- **Closure-based message construction** — `rt.ask(addr, |reply_to| Msg { reply_to })` lets the user embed the reply address in any message shape. No trait requirements on the message type (beyond `Message`). +- **`recv_ticking` for single-threaded** — in single-threaded mode, the runtime must be ticked for the target actor to process the request and reply. `recv_ticking` does this automatically. In multi-threaded mode, use `try_recv` with your own tick loop. +- **No implicit reply** — frameworks like xactor auto-route the handler's return value as a reply. This is magical and doesn't fit swactor's explicit model. The ask pattern wraps existing mechanics without adding new ones. +- **max_ticks timeout** — instead of wall-clock timeout, uses tick count for deterministic behavior (consistent with Cycle 10 timers). + +## Tests Added + +5 new tests (122 → 127 total): + +- `ask_recv_ticking_returns_response` — basic PingPong ask roundtrip +- `ask_multiple_times_tracks_state` — 3 sequential asks to CounterActor, state increments +- `ask_timeout_when_no_response` — ask dead actor → timeout error +- `ask_try_recv_returns_none_before_tick` — poll before tick → None, after tick → Some +- `ask_reply_addr_is_accessible` — reply address is valid for manual use + +## Result + +- 127 tests pass (120 behavioral + 7 proptest) +- All workspace crates compile, zero warnings diff --git a/cfuzz/CYCLE_16_REGISTRY_BENCHMARKS.md b/cfuzz/CYCLE_16_REGISTRY_BENCHMARKS.md new file mode 100644 index 0000000..5f0d639 --- /dev/null +++ b/cfuzz/CYCLE_16_REGISTRY_BENCHMARKS.md @@ -0,0 +1,59 @@ +# Cycle 16: Registry Benchmarks for Named Actors, Groups, Monitors, and Ask — Development History + +> Commit: `0ef6df9` · 2 files · 130 insertions, 1 deletion + +--- + +## Motivation + +Cycles 12–15 added four new features (named registry, monitoring, groups, ask pattern) without performance measurement. Before building more features on top of these primitives, it was important to quantify their overhead and ensure they're efficient enough for production use. + +## Benchmark Results + +| Benchmark | Time | Analysis | +|-----------|------|----------| +| `named_spawn_lookup` | ~2.4 µs | vs bare spawn 1.9 µs → **+0.5 µs** overhead for name registration | +| `where_is_100_names` | ~9.0 µs | Includes setup overhead; per-lookup cost is negligible | +| `group_publish/10` | ~4.8 µs | O(N) message cloning | +| `group_publish/50` | ~15.5 µs | Linear scaling confirmed | +| `group_publish/100` | ~60 µs | Linear with O(N) clones | +| `monitor_setup` | ~13.4 µs | monitor + stop + cleanup full cycle | +| `ask_roundtrip` | ~4.5 µs | vs manual roundtrip 3.0 µs → **+1.5 µs** for inbox creation | + +### Analysis + +- **Named lookup**: +0.5 µs over bare spawn — the `RwLock` insert is fast. Acceptable for a feature used at spawn time, not on the hot path. +- **Group publish**: scales linearly with group size, as expected for O(N) message cloning. No optimization needed — the bottleneck is inherent (must clone and deliver N messages). +- **Monitor setup**: 13.4 µs covers the full lifecycle (monitor → stop → cleanup → Down delivery). The monitoring machinery adds minimal per-message overhead. +- **Ask roundtrip**: +1.5 µs over manual inbox pattern (4.5 µs vs 3.0 µs). The overhead is inbox creation. Acceptable for a convenience pattern — users who need maximum throughput can use the manual pattern. + +## Implementation + +5 new criterion benchmark functions added to `benches/runtime_benchmarks.rs` in a `registry` group: + +- `named_spawn_lookup` — spawn_named + where_is roundtrip +- `where_is_100_names` — lookup in 100-name registry +- `group_publish/{10,50,100}` — broadcast to N group members +- `monitor_setup` — monitor + stop + Down delivery cycle +- `ask_roundtrip` — ask + recv_ticking response + +**Key files modified:** `benches/runtime_benchmarks.rs` + +## Design Decisions + +- **Full-cycle benchmarks** — each benchmark measures the complete operation (not just the fast path). For example, `monitor_setup` includes stop and cleanup, not just the monitor call, because that's the real-world cost. +- **Parameterized group publish** — three group sizes (10, 50, 100) to verify linear scaling and catch any unexpected superlinear behavior. +- **No optimization undertaken** — all operations are efficient enough. The benchmark results serve as baselines for future changes. + +## Tests Added + +No new tests (benchmarks only). Test count remains at 127. + +## Result + +- All benchmarks run cleanly +- 127 tests pass, zero warnings +- All registry operations confirmed efficient for production use +- Named lookup: <1 µs overhead over bare spawn +- Ask: ~50% overhead over manual inbox pattern (acceptable for convenience) +- Group publish: linear O(N) as expected diff --git a/cfuzz/CYCLE_17_SUPERVISION.md b/cfuzz/CYCLE_17_SUPERVISION.md new file mode 100644 index 0000000..f7076b6 --- /dev/null +++ b/cfuzz/CYCLE_17_SUPERVISION.md @@ -0,0 +1,89 @@ +# Cycle 17: Supervision Trees with handle_down and Supervisor Actor — Development History + +> Commit: `a70bd86` · 4 files · 754 insertions, 7 deletions + +--- + +## Motivation + +With monitoring (Cycle 13), lifecycle hooks (Cycle 9), and factory-based restart (Cycle 7) in place, swactor had all the building blocks for supervision trees — the signature feature of Erlang/OTP. Supervision trees provide structured fault tolerance: a parent actor (supervisor) monitors children and restarts them according to configurable policies when they fail. + +## Competitor Analysis + +| Framework | Supervisor Model | Strategies | Child Spec | Meltdown Protection | +|-----------|-----------------|------------|------------|---------------------| +| Erlang/OTP | Built-in `supervisor` behaviour | one_for_one, one_for_all, rest_for_one, simple_one_for_one | `{Id, MFA, Restart, Shutdown, Type}` | Intensity/period limits | +| Akka | SupervisorStrategy | Resume, Restart, Stop, Escalate + BackoffSupervisor | N/A (inline) | MaxNrOfRetries/withinTimeRange | +| Ractor | `ractor-supervisor` crate | External crate, event-based | SupervisionEvent callback | N/A | +| Bastion | Built-in hierarchy | Redundancy groups | Structural (parent-child) | N/A | +| CAF | No built-in supervisor | Monitor-based (manual) | N/A | N/A | +| **Swactor** | **User-space `Supervisor` actor** | **OneForOne** (Cycle 17), **OneForAll/RestForOne** (Cycle 18) | **`ChildSpec`** | **max_restarts budget** | + +### Key Findings +- Swactor has all the building blocks: monitor (Cycle 13), `spawn_restartable` (Cycle 7), lifecycle hooks (Cycle 9), `Down` messages (Cycle 13) +- **Decision**: Supervisor as a user-space actor built on existing primitives (like Ractor's `ractor-supervisor` crate), not a special runtime construct +- **`handle_down` callback** enables any actor to react to monitored deaths without requiring `Incoming = Down` — this is the key API gap that needed filling + +## Implementation + +### 1. `handle_down` Callback on ActorInterface + +The core API addition enabling supervision: + +- `fn handle_down(&mut self, ctx: &Ctx, down: Down)` — default no-op, called when a monitored actor dies and the actor's `Incoming` type is NOT `Down` +- Implemented via second downcast attempt in `handle_any`: if the message is `Down` and the actor's `Incoming` type doesn't match, call `handle_down` instead of `handle` +- Fully backward-compatible: actors with `Incoming = Down` still receive via `handle()` as before +- This decouples supervision logic from the actor's primary message type + +### 2. `ctx.stop_actor(addr)` — Stop Another Actor + +- Sends graceful stop to another actor from handler context +- Uses `StopSignal` through normal message routing (PoisonPill semantics) +- Enables supervisor-controlled shutdown of children + +### 3. `Supervisor` Actor + +A user-space actor managing child actors: + +- **`SupervisorStrategy::OneForOne`** — only the failed child is restarted (Cycle 17) +- **`RestartPolicy`**: `Permanent` (always restart), `Transient` (restart only on panic, not normal stop), `Temporary` (never restart) +- **`ChildSpec`** — `{ id: String, restart: RestartPolicy, factory: Fn(&Ctx) -> Result }` +- Children spawned in `on_start`, monitored via `ctx.monitor()` +- Death detected via `handle_down`, restart policy consulted, factory invoked for replacement +- **Meltdown detection**: stops itself when `total_restarts > max_restarts` +- **Cascading shutdown**: `on_stop` sends stop signals to all living children + +### ActiveChild Struct +- Tracks `addr: ActorAddress` and `monitor_ref: MonitorRef` per child +- Reused by Router (Cycle 19) + +**Key files modified:** `src/actor.rs`, `tests/runtime_api.rs`, `docs/runtime.md` + +## Design Decisions + +- **User-space actor (not runtime primitive)** — the Supervisor is just an actor that uses existing APIs (monitor, spawn, stop). No special runtime support needed. This validates the composability of the monitoring and lifecycle systems. +- **`handle_down` as opt-in callback** — adding `handle_down` to `ActorInterface` with a default no-op means existing actors don't need to change. Actors that want to react to deaths override it. The alternative (requiring `Incoming = Down`) would force actors to handle `Down` as their primary message type. +- **Factory takes `&Ctx`** — the factory closure receives the context so it can use `ctx.spawn`, `ctx.monitor`, etc. during child creation. This enables the supervisor to monitor new children immediately. +- **Meltdown protection** — if children keep crashing faster than they can be restarted, the supervisor stops itself rather than looping forever. Matches Erlang's intensity/period limits. +- **Cascading shutdown** — when the supervisor stops, all living children receive stop signals. This prevents orphaned actors. + +## Tests Added + +10 new tests (127 → 138 total, counting 130 behavioral + 7 proptest + 1 doctest): + +- `handle_down_receives_death_notification` — handle_down callback fires on monitored death +- `handle_down_skipped_when_incoming_is_down` — backward compat: Incoming=Down uses handle() +- `ctx_stop_actor_stops_target` — one actor stops another via ctx.stop_actor() +- `supervisor_restarts_permanent_child_on_panic` — panic → restart (OneForOne + Permanent) +- `supervisor_does_not_restart_transient_child_on_normal_stop` — Normal stop → no restart +- `supervisor_restarts_transient_child_on_panic` — Panicked → restart (Transient) +- `supervisor_never_restarts_temporary_child` — Temporary → never restart +- `supervisor_meltdown_after_max_restarts` — exceeding max_restarts stops supervisor +- `supervisor_one_for_one_only_restarts_failed_child` — multi-child, only crashed child restarted +- `supervisor_on_stop_kills_children` — supervisor shutdown cascades to children + +## Result + +- 138 tests pass (130 behavioral + 7 proptest + 1 doctest) +- Zero warnings, full workspace compiles +- Supervisor validates the composability of Cycles 7 (recovery), 9 (lifecycle), and 13 (monitoring) diff --git a/cfuzz/CYCLE_18_SUPERVISOR_STRATEGIES.md b/cfuzz/CYCLE_18_SUPERVISOR_STRATEGIES.md new file mode 100644 index 0000000..e96a888 --- /dev/null +++ b/cfuzz/CYCLE_18_SUPERVISOR_STRATEGIES.md @@ -0,0 +1,86 @@ +# Cycle 18: OneForAll and RestForOne Supervisor Strategies — Development History + +> Commit: `771c38c` · 4 files · 277 insertions, 2 deletions + +--- + +## Motivation + +Cycle 17 introduced supervision with the `OneForOne` strategy (only the failed child is restarted). Erlang/OTP defines two additional coordinated restart strategies that handle interdependent children: + +- **`one_for_all`** — when one child fails, ALL children are restarted (for tightly coupled children that share state assumptions) +- **`rest_for_one`** — when one child fails, it and all children started AFTER it are restarted (for chains where later children depend on earlier ones) + +These strategies require coordinated shutdown: the supervisor must stop living siblings, wait for all of them to die, then restart the affected set in the original spec order. + +### Research Detour: SmallBox/InlineAny Optimization +Before choosing this cycle's topic, investigated SmallBox optimization for message dispatch — a 44% queue throughput improvement was measured. However, it was deferred because: +- Requires `unsafe` code in a core path +- Would touch 32+ call sites across the codebase +- Violates the "src/ structure frozen" constraint + +Extended the Supervisor with coordinated strategies instead — higher value, zero risk. + +## Competitor Analysis + +| Framework | OneForAll | RestForOne | Coordinated Shutdown | +|-----------|-----------|------------|---------------------| +| Erlang/OTP | Yes | Yes | Built into supervisor behaviour | +| Akka | No (different model: Resume/Restart/Stop/Escalate) | No | N/A | +| Ractor | No | No | N/A | +| Bastion | Implicit (redundancy groups) | No | Implicit | +| **Swactor** | **Yes** | **Yes** | **Phase-based state machine** | + +### Erlang's Coordinated Restart +In Erlang, `one_for_all` and `rest_for_one` stop affected children in reverse start order, wait for all to terminate, then restart in start order. This guarantees initialization dependencies are respected. + +## Implementation + +### SupervisorPhase State Machine +- `Normal` — steady state, processing handle_down events normally +- `Stopping { awaiting: HashSet, restart_set: Vec }` — coordinated shutdown in progress + +### SupervisorStrategy Extensions +- `SupervisorStrategy::OneForAll` — all children restarted when one fails +- `SupervisorStrategy::RestForOne` — failed child + all children after it (in spec order) restarted + +### Coordinated Restart Flow +1. Child dies → `handle_down` called +2. Strategy determines affected indices (OneForAll: all, RestForOne: failed + later) +3. `begin_coordinated_restart(ctx, indices)`: + - Sends stop signals to living siblings in the restart set + - Transitions to `Stopping` phase with `awaiting` set + - Already-dead children handled: if all targets are already dead, skip to immediate restart +4. Subsequent `handle_down` calls during `Stopping` phase: + - Remove from `awaiting` set + - When `awaiting` is empty → all stopped +5. `finish_restart(ctx)`: + - Restart all children in the restart set, in spec order + - Transition back to `Normal` phase + +### Refactoring +- `check_intensity()` factored out of `handle_down` for restart budget checking — shared by all strategies + +**Key files modified:** `src/actor.rs`, `tests/runtime_api.rs`, `docs/runtime.md` + +## Design Decisions + +- **Phase-based state machine** — the `Stopping` phase cleanly separates "waiting for siblings to die" from "normal operation." This prevents races where a new death arrives while a coordinated restart is in progress. +- **Stop signals (not kill)** — affected siblings are stopped gracefully (PoisonPill semantics), giving them a chance to run `on_stop` for cleanup. This matches Erlang's `terminate/2` being called during supervised shutdown. +- **Restart in spec order** — children are restarted in the order they appear in the ChildSpec list, regardless of which child triggered the restart. This preserves initialization dependencies. +- **Already-dead optimization** — if all children in the restart set are already dead (e.g., cascading failures), skip the `Stopping` phase entirely and restart immediately. Without this, the supervisor would wait forever for Down messages that already arrived. +- **Meltdown protection shared** — the same `max_restarts` budget applies across all strategies. OneForAll restarts count as one restart event (not N), matching Erlang's behavior. + +## Tests Added + +3 new tests (138 → 141 total): + +- `supervisor_one_for_all_restarts_all_on_single_failure` — one child panics, all 3 get new addresses +- `supervisor_rest_for_one_restarts_rest_after_failed` — child_b panics, child_a unchanged, child_b + child_c restarted +- `supervisor_one_for_all_waits_for_all_downs_before_restart` — verifies coordinated shutdown completes before restart begins + +## Result + +- 141 tests pass (133 behavioral + 7 proptest + 1 doctest) +- Zero warnings, full workspace compiles +- All three Erlang-standard supervision strategies now available: OneForOne, OneForAll, RestForOne diff --git a/cfuzz/CYCLE_19_ROUTER.md b/cfuzz/CYCLE_19_ROUTER.md new file mode 100644 index 0000000..7897eaa --- /dev/null +++ b/cfuzz/CYCLE_19_ROUTER.md @@ -0,0 +1,78 @@ +# Cycle 19: Router Actor for Pooled Message Distribution — Development History + +> Commit: `c688f0a` · 4 files · 528 insertions, 3 deletions + +--- + +## Motivation + +Many workloads benefit from distributing messages across a pool of identical worker actors. Before this change, users had to manually manage actor pools: spawn N workers, track their addresses, implement distribution logic, and handle worker replacement on failure. A Router actor encapsulates this pattern — it receives messages and transparently forwards them to pool members using a configurable strategy. + +## Competitor Analysis + +| Framework | Pool/Router Model | Strategies | Auto-Replace | +|-----------|------------------|------------|-------------| +| Erlang | `poolboy` (checkout/checkin), `wpool` (transparent forwarding, 6 strategies + custom) | RoundRobin, Random, BestWorker, Hash, Available, custom | Manual | +| Akka | Router actors (Pool vs Group), Resizer for dynamic sizing | RoundRobin, Random, SmallestMailbox, Balancing, Broadcast, ScatterGather, TailChopping, ConsistentHashing | Pool auto-creates, Group manual | +| Actix | SyncArbiter (shared queue, implicit work-stealing) | N/A (shared queue) | N/A | +| Kameo | ActorPool (least-connections, auto-replace dead workers) | Least-connections | Yes | +| Ractor | No built-in router (process groups only) | N/A | N/A | +| **Swactor** | **`Router` actor** | **RoundRobin, Random, Broadcast** | **Yes (via monitor + handle_down)** | + +### Key Findings +- **Router-as-actor** with transparent forwarding (wpool/Akka style) is the best fit — the router looks like a regular actor to callers +- **User-space actor** like Supervisor (Cycle 17), reusing monitor + handle_down for worker replacement +- **SmallestMailbox deferred** — requires runtime stats access not available in user-space +- **ConsistentHashing deferred** — requires a hash function parameter, can be added later as a builder method + +## Implementation + +### Router\ Actor +- Generic over `M: Message` — same `Incoming` type as workers, enabling transparent forwarding +- Workers spawned in `on_start`, monitored via `ctx.monitor()`, auto-replaced via `handle_down` +- Reuses `ActiveChild` struct from Supervisor (addr + monitor_ref) + +### Routing Strategies +- `RoutingStrategy::RoundRobin` — sequential circular distribution via counter +- `RoutingStrategy::Random` — random worker selection via `get_random()` helper +- `RoutingStrategy::Broadcast` — clone message to all live workers (`M: Clone` required) + +### Fault Tolerance +- Dead worker detected via `handle_down` → factory invoked → new worker spawned and monitored +- **Meltdown protection**: `total_restarts > max_restarts` → `ctx.stop_self()` +- **Cascading shutdown**: `on_stop` sends stop signals to all workers + +### Configuration +- `Router::new(pool_size, strategy, factory, max_restarts)` — all-in-one constructor +- Factory: `Arc Result + Send + Sync>` + +**Key files modified:** `src/actor.rs`, `tests/runtime_api.rs`, `docs/runtime.md` + +## Design Decisions + +- **Router-as-actor (transparent forwarding)** — callers send messages to the router's address as if it were a regular actor. The router forwards to pool members. This is the cleanest API: no special send function, no pool handle, just an address. +- **User-space actor (not runtime primitive)** — like Supervisor, Router is built entirely on existing APIs (spawn, monitor, handle_down, stop). This validates the actor system's composability. +- **Generic over M** — `Router` has `Incoming = M`, same as the workers. Messages are forwarded with zero transformation. Type safety is enforced at compile time. +- **Broadcast requires Clone** — broadcasting clones the message for each worker. The Clone bound is only required when using the Broadcast strategy, enforced at the type level. +- **SmallestMailbox deferred** — would require reading per-actor mailbox depth from runtime stats, which isn't available from within a handler. Could be added with a stats query API. +- **ConsistentHashing deferred** — requires a hash function parameter (user must define which part of the message determines the routing key). Better to add as a builder method with a closure parameter. +- **Reuses ActiveChild from Supervisor** — the pattern of "track address + monitor ref, replace on death" is identical. Code sharing confirms the design consistency between Supervisor and Router. + +## Tests Added + +7 new tests (141 → 148 total): + +- `router_round_robin_distributes_across_workers` — 6 msgs to 3 workers, each gets 2 +- `router_broadcast_sends_to_all_workers` — 1 msg, all 3 workers receive +- `router_random_delivers_to_some_worker` — 30 msgs across 3 workers, at least 2 workers used +- `router_replaces_dead_worker` — panicked worker auto-replaced, pool size maintained +- `router_meltdown_after_max_restarts` — 3 deaths with max_restarts=2 → router stops +- `router_on_stop_kills_workers` — stopping router cascades to all workers +- `router_broadcast_multiple_messages_all_received` — 5 msgs × 3 workers = 15 received + +## Result + +- 148 tests pass (140 behavioral + 7 proptest + 1 doctest) +- Zero warnings, full workspace compiles +- Router validates the composability of the entire cfuzz feature set: monitoring (Cycle 13), lifecycle hooks (Cycle 9), handle_down (Cycle 17), and the ActiveChild pattern (Cycle 17) +- The cfuzz branch concludes with a comprehensive actor runtime featuring: fairness, backpressure, recovery, lifecycle management, timers, named registry, monitoring, groups, ask pattern, supervision trees, and routers diff --git a/ci/CI_DEPLOYMENT.md b/ci/CI_DEPLOYMENT.md new file mode 100644 index 0000000..82be2ff --- /dev/null +++ b/ci/CI_DEPLOYMENT.md @@ -0,0 +1,140 @@ +# CI Pipeline Deployment — Development History + +> Covers the first real deployment of the CI pipeline: Forgejo (VPS) → ci-relay +> (iroh) → local-runner (Thinkpad). Verified end-to-end with a smoke-test +> pipeline that reports status back to Forgejo. +> +> *Branch: `spot-instance`* + +--- + +## What Was Done + +### Deployed Components + +| Component | Machine | How | +|-----------|---------|-----| +| `.ci.yml` | Repo root | Smoke pipeline: `echo "CI is alive"` on push to `*` | +| `ci-relay` | VPS | Release binary, systemd service | +| `local-runner` | Runner host | Release binary, started via nohup | +| Forgejo webhook | VPS (Docker) | Hook #1, fires on push to relay's HTTP listener | + +### Deployment Steps + +1. **Created `.ci.yml`** — minimal smoke pipeline (`echo "CI is alive"`) +2. **Generated webhook secret** — `openssl rand -hex 32` → `~/.ssh/forgejo.ci-webhook-secret` +3. **Built release binaries** — `cargo build --release -p ci-relay -p local-runner` +4. **Distributed binaries** — `scp` to VPS (`docean:`) and Thinkpad (`thinkpad:`) +5. **Deployed ci-relay as systemd service** on VPS: + - Service file: `/etc/systemd/system/ci-relay.service` + - Iroh Node ID: `` +6. **Started local-runner on Thinkpad** — connects to relay via iroh, confirmed "Connected to relay!" +7. **Configured Forgejo**: + - Added `[webhook] ALLOWED_HOST_LIST = loopback,` to `app.ini` (Forgejo blocks private IPs by default) + - Restarted Forgejo container + - Created webhook via API targeting `http://:8787` + - **Fixed UFW firewall** — Docker bridge traffic to port 8787 was blocked by default DROP policy; added a UFW rule allowing the Docker subnet +8. **Verified end-to-end** — pushed commit, Forgejo shows green check: + - `ci/hello`: success — "Job 'hello' completed" + - `ci/smoke`: success — "Pipeline 'smoke' success" + +### Issue Encountered: UFW Blocking Docker Bridge + +The plan assumed Docker bridge traffic (`172.17.0.1`) would reach the host's port 8787 unimpeded. UFW's default INPUT policy is DROP, which blocks this. The fix was a single firewall rule allowing the Docker subnet. + +### Credentials & Secrets + +| File | Purpose | Location | +|------|---------|----------| +| Forgejo API token | CI status reporting | Spot instance + Thinkpad | +| HMAC webhook secret | Webhook signature verification | Spot instance + Thinkpad | + +Secrets are stored outside the repo. The webhook secret is embedded in the systemd service `ExecStart` line on the VPS. To rotate it: update the service file, restart ci-relay, update Forgejo webhook config. + +### Connection Details + +- **ci-relay** listens on HTTP (webhooks) + iroh (runner connection) +- **local-runner** connects outbound to relay's iroh Node ID (NAT-friendly) +- **Status reports** go directly from runner → Forgejo API over HTTPS (no relay) + +--- + +## Next Step: Real CI Jobs + +The smoke-test pipeline proves the plumbing works. The next step is replacing `echo "CI is alive"` with actual CI jobs in `.ci.yml`. + +Candidates for the first real pipeline: + +1. **`cargo check`** — fast compilation check, catches most errors +2. **`cargo test`** — full test suite (simulation tests can be slow) +3. **`cargo clippy`** — lint pass +4. **Benchmark runs** — the whole reason for running CI on the Thinkpad (consistent hardware) + +Things to consider: + +- **Rust toolchain on Thinkpad**: `local-runner` shells out to run jobs, so the Thinkpad needs `rustup`/`cargo` installed and on PATH +- **Build cache**: consecutive runs in separate `pipeline-N` dirs won't share a target directory. Consider a shared `CARGO_TARGET_DIR` or `sccache` for faster builds +- **Job timeouts**: no timeout mechanism exists yet; a hung `cargo build` would block the single-threaded job queue forever +- **Multiple jobs**: `.ci.yml` supports multiple jobs per pipeline, but they run sequentially. Could add `cargo check` as a fast gate before `cargo test` +- **Branch filtering**: currently triggers on `*` — may want to restrict benchmarks to `master` only + +### Suggested `.ci.yml` Evolution + +```yaml +pipelines: + check: + triggers: + - event: push + branches: ["*"] + jobs: + check: + run: cargo check --workspace + test: + run: cargo test --workspace + clippy: + run: cargo clippy --workspace -- -D warnings + + bench: + triggers: + - event: push + branches: ["master"] + jobs: + bench: + run: cargo bench --workspace +``` + +--- + +## Operational Notes + +### Restarting ci-relay (VPS) + +```bash +ssh +systemctl restart ci-relay +journalctl -u ci-relay -f +``` + +### Restarting local-runner (runner host) + +```bash +ssh +pkill local-runner +nohup ~/local-runner \ + --relay-node-id \ + --forgejo-url https://zachery.lol/code \ + --forgejo-token "$(cat )" \ + --yaml ~/.ci.yml \ + --work-dir ~/ci-work \ + --repo-url https://zachery.lol/code/zacheryasc/swactor.git \ + > ~/local-runner.log 2>&1 & +``` + +### Checking webhook deliveries + +```bash +# Forgejo webhook UI: Settings → Webhooks → Hook #1 → Recent Deliveries +# Or test delivery via API: +curl -X POST "https://zachery.lol/code/api/v1/repos/zacheryasc/swactor/hooks/1/tests" \ + -H "Authorization: token " +``` diff --git a/ci/CI_OUTPUT_IN_FORGEJO.md b/ci/CI_OUTPUT_IN_FORGEJO.md new file mode 100644 index 0000000..c1c24b3 --- /dev/null +++ b/ci/CI_OUTPUT_IN_FORGEJO.md @@ -0,0 +1,139 @@ +# CI Output Visible in Forgejo — Development History + +> Added two mechanisms so CI results are visible directly in the Forgejo web +> UI without SSH-ing into the runner: **enhanced commit status descriptions** +> and **PR comments** with full job output. +> +> *Branch: `spot-instance`* + +--- + +## Problem + +The CI pipeline worked end-to-end but job output was only visible in the +runner's stderr log on the Thinkpad. To see why clippy failed, you had to +`ssh thinkpad 'tail ~/local-runner.log'`. Forgejo's commit status descriptions +just said "Job 'clippy' completed" with no output. + +Forgejo lacks GitHub's Checks API (no annotations, no log viewer), so we use +two complementary approaches. + +## What Was Done + +### 1. Enhanced Commit Status Descriptions + +On job completion, the status description now includes: + +- **On success**: `"Job 'check' passed"` +- **On failure**: `"Job 'clippy' failed: command exited with code 101\n[stderr] error: you should consider..."` — last ~10 lines of output, capped at 250 characters. + +This is visible directly on the PR page and commit page in Forgejo without +clicking anything. + +### 2. PR Comments with Full Output + +When a pipeline reaches terminal state, the StatusReporter: + +1. Queries `GET /repos/{owner}/{repo}/pulls?state=open` to find the PR for the branch +2. Builds a markdown comment with `
` sections per job (up to 100 lines each) +3. Posts it via `POST /repos/{owner}/{repo}/issues/{pr_number}/comments` +4. Re-posts the pipeline commit status with `target_url` pointing to the comment + +Example comment format: + +```markdown +## Pipeline `ci` — failure + +Commit: `5b7ae7b` + +
+clippy — failed: command exited with code 101 + +_Showing last 100 of 523 lines_ + +\``` +error[E0599]: ... +\``` + +
+ +
+check — passed + +\``` +$ cargo check --workspace + Compiling ... +\``` + +
+``` + +### 3. `target_url` on Commit Statuses + +Added `target_url: Option` to `StatusUpdate`. When a PR comment is +successfully posted, the pipeline's commit status badge links directly to that +comment. Clicking the status badge on the PR page jumps to the output. + +## Files Changed + +| File | Change | +|------|--------| +| `crates/ci/src/lib.rs` | Added `target_url: Option` to `StatusUpdate` | +| `crates/ci/src/status_reporter.rs` | Added `JobOutput`, `PostPipelineComment`, `find_pr_for_branch()`, `post_pr_comment()`, `build_pipeline_comment()`, `handle_pipeline_comment()` | +| `crates/ci/src/local_coordinator.rs` | Enhanced `handle_job_complete()` descriptions; added `emit_pipeline_comment()`, called from `try_schedule_next()` | +| `crates/ci/src/coordinator.rs` | Mechanical `target_url: None` at 4 sites | +| `crates/simulation/src/ci/local_sim.rs` | Mechanical `target_url: None` at 3 sites | +| `crates/simulation/src/ci/sim.rs` | Mechanical `target_url: None` at 2 sites | +| `crates/ci/Cargo.toml` | Added `features = ["json"]` to `ureq` for `into_json()` | + +## Deployment & Verification + +Built and deployed updated `local-runner` to the Thinkpad, pushed to the +`spot-instance` branch (which has PR #42 open), and observed: + +**Working:** + +- Commit statuses show descriptive output. The clippy failure status reads: + `Job 'clippy' failed: command exited with code 101` followed by the tail of + the clippy output, truncated at 250 chars. +- Passed jobs show `"Job 'check' passed"` / `"Job 'test' passed"`. +- Pipeline-level status correctly reports `ci/ci → failure`. + +**Blocked on token scope:** + +- PR comment posting returned HTTP 403. The Forgejo API token has + `write:repository` scope (sufficient for commit statuses) but needs + `write:issue` scope to post comments on PRs/issues. +- The code degrades gracefully: logs the error, skips the comment, posts the + pipeline status without `target_url`. + +## TODO + +- [ ] Regenerate Forgejo API token with `write:issue` scope to enable PR comments +- [ ] After token update, re-deploy and verify the comment + `target_url` flow end-to-end + +## Edge Cases Handled + +| Case | Behavior | +|------|----------| +| No open PR for branch | Comment silently skipped, status posted without `target_url` | +| API failures (403, network) | Logged via `eprintln!`, degrades gracefully | +| Long output | Capped at last 100 lines per job in PR comment, with `_Showing last N of M lines_` note | +| Long description | Capped at 250 chars for commit status description field | +| All HTTP code | Gated behind `#[cfg(feature = "local")]` — simulation builds unaffected | + +## Architecture Note + +All new HTTP calls (PR listing, comment posting) happen in the StatusReporter +actor, which is fire-and-forget. The LocalCoordinator never blocks on HTTP. +The flow is: + +``` +LocalCoordinator StatusReporter + | | + |-- emit_status(StatusUpdate) ----->|-- POST /statuses/{sha} + | | + |-- PostPipelineComment ----------->|-- GET /pulls?state=open + | |-- POST /issues/{n}/comments + | |-- POST /statuses/{sha} (with target_url) +``` diff --git a/ci/CI_RELAY.md b/ci/CI_RELAY.md new file mode 100644 index 0000000..d4edd7c --- /dev/null +++ b/ci/CI_RELAY.md @@ -0,0 +1,520 @@ +# CI Webhook Relay via Iroh — Development History + +> Covers the implementation of `ci-relay` and the iroh webhook receiver in +> `local-runner`, enabling Forgejo webhooks to reach a NAT'd CI runner via +> iroh's QUIC transport with automatic NAT traversal. +> +> ~3 files created · ~2 files modified · ~350 insertions +> +> *Branch: `spot-instance`* + +--- + +## Table of Contents + +1. [Problem & Motivation](#1-problem--motivation) +2. [Architecture](#2-architecture) +3. [What Was Built](#3-what-was-built) +4. [ci-relay Binary](#4-ci-relay-binary) +5. [local-runner Iroh Receiver](#5-local-runner-iroh-receiver) +6. [Wire Protocol](#6-wire-protocol) +7. [Connection Flow](#7-connection-flow) +8. [Design Decisions & Tradeoffs](#8-design-decisions--tradeoffs) +9. [Manual Testing Guide](#9-manual-testing-guide) +10. [Known Gaps & Future Improvements](#10-known-gaps--future-improvements) + +--- + +## 1. Problem & Motivation + +The CI runner (`local-runner`) was designed for same-LAN usage: Forgejo sends +webhooks over HTTP to the runner's listen port. In the real deployment: + +- **Forgejo** runs on a VPS (`zachery.lol` / `139.59.195.69`) +- **CI runner** runs on a Thinkpad at home (`192.168.1.102`), behind NAT + +The VPS cannot reach the Thinkpad directly — no inbound port is open, no +static IP, no UPnP. Traditional solutions (SSH reverse tunnel, VPN, port +forwarding on router) all require ongoing configuration and are fragile. + +iroh is already integrated in swactor's distribution layer (`iroh_driver.rs`) +for SWIM protocol traffic. It provides QUIC connections with automatic NAT +traversal via relay servers — exactly what's needed to bridge the webhook gap. + +### Why Not Just SSH Tunnel? + +An SSH tunnel (`ssh -R 8787:localhost:8787 zachery.lol`) would work, but: + +- Tunnels drop on network changes (laptop suspend, WiFi roaming) +- Requires autossh or systemd to keep alive +- Another moving part to debug when CI stops working +- Doesn't reuse any existing infrastructure + +iroh handles reconnection, relay fallback, and NAT traversal automatically. +The implementation reuses the same tagged-message-over-QUIC-stream pattern +already proven in `iroh_driver.rs`. + +--- + +## 2. Architecture + +``` +┌─────────────────────────────┐ ┌──────────────────────────────────┐ +│ VPS (zachery.lol) │ │ Thinkpad (192.168.1.102) │ +│ │ iroh │ │ +│ Forgejo ──webhook──► Relay ├───────►│ local-runner │ +│ :8787 │ QUIC │ (coordinator, runner, reporter) │ +│ │ │ │ +└─────────────────────────────┘ └──────────────────────────────────┘ +``` + +**VPS side** — `ci-relay` binary: +- HTTP listener receives webhook POSTs from Forgejo (localhost only) +- iroh endpoint accepts the runner's inbound connection +- Forwards parsed `WebhookEvent` payloads over iroh uni streams + +**Thinkpad side** — `local-runner` with `--relay-node-id`: +- Connects to the VPS relay's iroh endpoint on startup +- Receives `WebhookEvent` over iroh uni streams +- Feeds events into `LocalCoordinator` via existing `Webhook` message +- Status updates go directly Thinkpad → Forgejo API over HTTPS (no relay needed) + +The relay is intentionally minimal — it's a bridge, not a CI component. All CI +logic stays in `local-runner`. + +--- + +## 3. What Was Built + +| Component | Location | Nature | +|-----------|----------|--------| +| ci-relay binary | `crates/ci-relay/Cargo.toml`, `src/main.rs` | **New** — VPS webhook relay | +| Iroh receiver | `crates/local-runner/src/main.rs` | **Modified** — iroh webhook source | +| Dependencies | `crates/local-runner/Cargo.toml` | **Modified** — added iroh, tokio, serde_json | +| Workspace | `Cargo.toml` | **Modified** — added ci-relay to members | + +--- + +## 4. ci-relay Binary + +### `crates/ci-relay/src/main.rs` + +The relay runs two subsystems on a single process: + +1. **iroh acceptor** (tokio task): accepts inbound connections from the runner, + caches the most recent one in `Arc>>` +2. **HTTP listener** (main thread, blocking `tiny_http`): receives Forgejo + webhook POSTs, verifies HMAC, parses event, forwards over iroh + +### Webhook Handling + +Reuses the same verification and parsing logic as `webhook_server.rs`: + +- HMAC-SHA256 verification via `X-Forgejo-Signature` header (skippable with empty secret) +- Event type from `X-Forgejo-Event` header: `push` → `Push`, `create` → `Tag`, `pull_request` → `Merge` +- JSON parsing via `parse_webhook_json()` (re-exported from `swactor-ci`) + +The relay uses `parse_webhook_json` directly rather than duplicating parsing +logic. This keeps webhook interpretation consistent between HTTP and iroh paths. + +### Forwarding + +On webhook receipt, the relay: +1. Serializes the `WebhookEvent` to JSON +2. Opens a unidirectional QUIC stream on the cached connection +3. Writes the tagged message (`ci::WebhookEvent` tag + JSON payload) +4. Finishes the stream + +If no runner is connected, the relay returns HTTP 502 to Forgejo. Forgejo will +retry the webhook per its configured retry policy. + +### CLI + +``` +ci-relay [OPTIONS] + +Options: + --port HTTP port for Forgejo webhooks [default: 8787] + --secret HMAC-SHA256 secret [default: "" (no verification)] +``` + +On startup, the relay prints its iroh Node ID — this is the value the runner +needs for `--relay-node-id`. + +--- + +## 5. local-runner Iroh Receiver + +### New CLI Flag + +``` +--relay-node-id Iroh Node ID of the VPS ci-relay +``` + +When `--relay-node-id` is provided: +- The HTTP webhook listener is **not started** (no port conflict, no exposure) +- An `iroh-receiver` thread starts instead + +When omitted, behavior is unchanged — the HTTP listener starts on `--port` +as before. + +### `start_iroh_receiver()` + +Spawns a dedicated thread (`iroh-receiver`) with its own single-threaded tokio +runtime: + +1. Creates an iroh `Endpoint` with ALPN `b"swactor/ci/1"` +2. Connects to the relay's `PublicKey` (parsed from the hex flag) +3. Enters a receive loop: + - `conn.accept_uni()` with 1-second timeout + - On stream: reads tagged message, deserializes `WebhookEvent` + - Sends `LocalCoordinatorMsg::Webhook(event)` to the coordinator via the swactor runtime + - On timeout: checks the `stop` flag (for graceful shutdown via Ctrl-C) + - On connection error: breaks and exits + +The thread respects the same `AtomicBool` stop flag as the main loop, so +Ctrl-C cleanly shuts down both the swactor runtime and the iroh connection. + +--- + +## 6. Wire Protocol + +### ALPN + +```rust +const CI_ALPN: &[u8] = b"swactor/ci/1"; +``` + +Distinct from SWIM traffic (`b"swactor/swim/1"`). This allows both protocols +to coexist on the same iroh endpoint in the future if needed. + +### Frame Format + +Same tagged-message format as `iroh_driver.rs`: + +``` +[4 bytes: tag_len (big-endian u32)] +[tag_len bytes: tag string] +[remaining bytes: payload] +``` + +For webhook events: +- Tag: `"ci::WebhookEvent"` (17 bytes) +- Payload: JSON-serialized `WebhookEvent` + +### Transport + +Each webhook is one unidirectional QUIC stream. The relay opens the stream, +writes the tagged message, and finishes. The runner reads the message and the +stream closes. No persistent framing or multiplexing needed — QUIC streams +are lightweight. + +--- + +## 7. Connection Flow + +``` +1. VPS starts ci-relay + → iroh Endpoint binds + → prints Node ID (ed25519 public key, hex) + → HTTP listener starts on --port + → waits for runner connection + +2. Thinkpad starts local-runner --relay-node-id + → iroh Endpoint binds + → connects to relay's PublicKey + → iroh handles NAT traversal (direct or via relay server) + → relay logs "Runner connected: " + +3. Forgejo sends webhook POST to localhost:8787 on VPS + → relay verifies HMAC, parses event + → relay opens uni stream on cached connection + → writes tagged WebhookEvent + → runner receives, deserializes, dispatches to coordinator + +4. Coordinator triggers pipeline + → StatusReporter posts status to Forgejo API directly + (Thinkpad → zachery.lol over HTTPS, no relay involvement) +``` + +The iroh connection is initiated by the runner (outbound from NAT), so no port +forwarding is needed. iroh's relay servers handle the initial rendezvous, then +attempt direct QUIC hole-punching for subsequent traffic. + +--- + +## 8. Design Decisions & Tradeoffs + +### 8.1 Separate Binary vs. Library Module + +**Choice**: `ci-relay` is a standalone binary, not a module in `swactor-ci`. + +**Why**: The relay runs on the VPS, which doesn't need swactor's runtime, +actors, or any CI execution logic. A small binary with minimal dependencies +deploys easily. It only depends on `swactor-ci` for `parse_webhook_json` and +the `WebhookEvent`/`EventType` types. + +**Tradeoff**: Two binaries to build and deploy instead of one. Acceptable +given they run on different machines. + +### 8.2 Runner Connects to Relay (Not Vice Versa) + +**Choice**: The runner initiates the iroh connection to the relay. + +**Why**: The runner is behind NAT. iroh can traverse NAT for established +connections, but the initial rendezvous requires at least one side to be +reachable. The VPS relay has a public IP and gets a stable relay URL from iroh's +infrastructure. The runner connects outbound, which always works regardless of +NAT type. + +### 8.3 Single Cached Connection (Not Connection Pool) + +**Choice**: The relay caches exactly one runner connection in +`Arc>>`. + +**Why**: There's one runner. If a new connection arrives (e.g., runner +restarts), it replaces the old one. No pool management needed. + +**Tradeoff**: If multiple runners were needed, this would need a map. For +single-runner use, the simplicity is worth it. + +### 8.4 Own Tokio Runtime Per Thread + +**Choice**: The iroh-receiver thread creates its own single-threaded tokio +runtime rather than sharing the swactor runtime or the main thread's runtime. + +**Why**: swactor's runtime is not tokio — it's a custom actor scheduler. The +iroh receiver needs async for QUIC operations. A dedicated single-threaded +runtime keeps the iroh I/O isolated from actor scheduling. Same pattern as +`IrohDriver` in the distribution layer (which owns a multi-thread runtime). + +### 8.5 HTTP 502 When No Runner Connected + +**Choice**: If Forgejo sends a webhook but no runner is connected, the relay +returns HTTP 502 (Bad Gateway). + +**Why**: 502 tells Forgejo the upstream is unavailable. Forgejo will retry +the webhook according to its retry policy. This is better than 200 (silently +dropping) or 500 (suggesting a relay bug). When the runner reconnects, the +next webhook will succeed. + +--- + +## 9. Manual Testing Guide + +### Prerequisites + +Build both binaries: + +```bash +cargo build -p ci-relay -p local-runner +``` + +### 9.1 Local Smoke Test (Single Machine) + +This tests the full relay path without needing two machines or Forgejo. + +**Terminal 1 — Start the relay:** + +```bash +./target/debug/ci-relay --port 9787 +``` + +Output: +``` +ci-relay started + Iroh Node ID: + Webhook HTTP: http://0.0.0.0:9787 + +Waiting for runner to connect... +Listening for webhooks... +``` + +Copy the Node ID. + +**Terminal 2 — Start the runner:** + +You need a `.ci.yml` file. Create a minimal one: + +```yaml +# /tmp/test-ci.yml +pipelines: + test: + triggers: + - event: push + branches: ["*"] + jobs: + hello: + run: echo "hello from CI" +``` + +Then start: + +```bash +./target/debug/local-runner \ + --relay-node-id \ + --yaml /tmp/test-ci.yml \ + --work-dir /tmp/ci-work-test +``` + +You should see: +``` + Iroh local ID: + Connecting to relay ... + Connected to relay! +Local CI runner started + Webhook: via iroh relay +``` + +And in Terminal 1: +``` +Runner connected: +``` + +**Terminal 3 — Send a fake webhook:** + +```bash +curl -X POST http://localhost:9787 \ + -H "Content-Type: application/json" \ + -H "X-Forgejo-Event: push" \ + -d '{ + "ref": "refs/heads/main", + "after": "abc123def456789012345678901234567890abcd", + "repository": { + "name": "test-repo", + "owner": { "login": "testuser" } + } + }' +``` + +Expected output: + +- **curl** returns: `ok` +- **Terminal 1** (relay): + ``` + webhook: abc123de main on testuser/test-repo + → forwarded to runner + ``` +- **Terminal 2** (runner): + ``` + iroh: received webhook abc123de on main + ``` + +The runner will also try to post status to Forgejo and log URL errors (since +we didn't pass `--forgejo-url`) — that's expected and confirms the event +reached the coordinator. + +### 9.2 HMAC Verification Test + +Start the relay with a secret: + +```bash +./target/debug/ci-relay --port 9787 --secret mysecret +``` + +**Without signature — should be rejected (401):** + +```bash +curl -v -X POST http://localhost:9787 \ + -H "X-Forgejo-Event: push" \ + -d '{"ref":"refs/heads/main","after":"abc123","repository":{"name":"r","owner":{"login":"u"}}}' +``` + +**With correct signature:** + +```bash +# Compute HMAC-SHA256 +BODY='{"ref":"refs/heads/main","after":"abc123","repository":{"name":"r","owner":{"login":"u"}}}' +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "mysecret" | awk '{print $2}') + +curl -X POST http://localhost:9787 \ + -H "X-Forgejo-Event: push" \ + -H "X-Forgejo-Signature: $SIG" \ + -d "$BODY" +``` + +Should return `ok` and forward to the runner. + +### 9.3 Runner Reconnection Test + +1. Start relay and runner as in 9.1 +2. Kill the runner (Ctrl-C in Terminal 2) +3. Restart the runner with the same `--relay-node-id` +4. The relay should log `Runner connected: ` again +5. Send another webhook — it should flow through + +### 9.4 No Runner Connected Test + +1. Start the relay only (no runner) +2. Send a webhook via curl +3. Should get HTTP 502 and relay logs: `forward failed: no runner connected` + +### 9.5 Full End-to-End with Forgejo + +For a real deployment: + +**On VPS:** + +```bash +./ci-relay --port 8787 --secret +``` + +**On Thinkpad:** + +```bash +./local-runner \ + --relay-node-id \ + --forgejo-url https://zachery.lol \ + --forgejo-token \ + --yaml .ci.yml \ + --work-dir ~/ci-work \ + --repo-url https://zachery.lol//.git +``` + +**In Forgejo (repo settings → Webhooks):** + +- Target URL: `http://localhost:8787` +- Secret: `` +- Events: Push, Create (tags), Pull Request + +Push a commit and watch: +1. Relay logs the webhook and forwards it +2. Runner logs the received event and starts a pipeline +3. Forgejo shows commit status checks (pending → success/failure) + +### 9.6 Inspecting Iroh Connectivity + +Both binaries print their iroh Node ID on startup. To verify they're using +relay servers (expected when both are behind NAT or on different networks), +look for connection timing: + +- **Fast connection (~1-3s)**: direct QUIC hole-punch succeeded +- **Slower connection (~5-10s)**: using iroh relay server fallback + +If connection hangs indefinitely, check that both machines have internet +access and can reach iroh's relay servers (`https://relay.iroh.network`). + +--- + +## 10. Known Gaps & Future Improvements + +| Gap | Effort | Impact | Notes | +|-----|--------|--------|-------| +| Reconnection on runner side | Small | High | If the iroh connection drops mid-operation, the runner currently exits the receive loop. Should retry with backoff. | +| Multiple runner support | Medium | Medium | Relay caches one connection. For running CI on multiple machines, need a connection map keyed by runner identity. | +| Health check / heartbeat | Small | Medium | Neither side detects a silently dead connection until the next webhook. A periodic ping would surface stale connections faster. | +| Relay authentication | Small | Medium | Any iroh endpoint can connect to the relay. Should verify the runner's public key against an allowlist. | +| Binary size | Small | Low | ci-relay pulls in `swactor-ci` (which includes all CI types). A slimmer dependency with just `WebhookEvent` + `parse_webhook_json` would reduce the VPS binary. | +| Logging | Small | Low | Both binaries use `eprintln!`. Structured logging (tracing) would help in production. | + +--- + +## Files Created/Modified + +| Action | File | Purpose | +|--------|------|---------| +| Created | `crates/ci-relay/Cargo.toml` | Relay binary manifest | +| Created | `crates/ci-relay/src/main.rs` | Webhook relay: HTTP → iroh | +| Modified | `crates/local-runner/Cargo.toml` | Added iroh, tokio, serde_json deps | +| Modified | `crates/local-runner/src/main.rs` | Added `--relay-node-id` flag and iroh receiver | +| Modified | `Cargo.toml` (workspace root) | Added ci-relay to workspace members | diff --git a/dashboard-improvements-research.md b/dashboard-improvements-research.md new file mode 100644 index 0000000..d3db015 --- /dev/null +++ b/dashboard-improvements-research.md @@ -0,0 +1,28 @@ +# Dashboard Improvements — Research Phase + +## Summary +Researched 10 comparable monitoring/dashboard systems to inform swactor's dashboard improvement plan. + +## Systems Analyzed +- **Actor runtimes**: Erlang Observer (GUI/CLI/Web), Akka Insights, Ray Dashboard, Orleans Dashboard +- **Async/runtime tools**: tokio-console, Lunatic +- **Message/infrastructure**: RabbitMQ Management, Consul UI, Nomad UI +- **Web frameworks**: Phoenix LiveDashboard + +## Key Findings +1. **Time-series history** is table-stakes — every system provides it +2. **Actor detail drill-down** is universal (Observer has 6-tab process info, Orleans has grain state inspection) +3. **Search/filter** exists in every system +4. **Warning/anomaly detection** (tokio-console's lint system) is a high-value differentiator +5. **Topology visualization** (Consul golden metrics, Observer supervision tree) is rare but powerful + +## Implementation Plan +8 feature stages defined (see `CLAUDE/notes/feature-stages/`): +1. Time-Series History Infrastructure +2. Actor Detail Drill-Down +3. Search and Filter +4. Per-Worker Utilization Visualization +5. Warning/Anomaly Detection +6. Actor-to-Actor Message Flow Topology +7. Per-Actor Logging +8. Per-Message-Type Breakdown diff --git a/datastore/AUTH_SUMMARY.md b/datastore/AUTH_SUMMARY.md new file mode 100644 index 0000000..f09300d --- /dev/null +++ b/datastore/AUTH_SUMMARY.md @@ -0,0 +1,386 @@ +# Datastore Auth: Development History + +**Branch:** `swactor-auth` +**Base commit:** `af15416` (pre-auth baseline) +**5 commits + uncommitted working tree changes** + +--- + +## What Was Built + +A complete ed25519 authorization layer for the distributed datastore, spanning: + +- **Auth engine** — `AuthzEngine` with ACL, signed request verification, replay protection, nonce GC +- **GatewayActor** — actor-level enforcement point with grant/revoke, access requests, key listing +- **Browser auth flow** — WASM Ed25519 crypto, device key generation, access request/grant/deny lifecycle +- **Admin page** — owner key upload, pending request management, manual key grant, authorized key list +- **Expanded CLI** — full CRUD + auth subcommands (`grant`, `revoke`, `requests`, `keys`, `deny`) with name resolution +- **Storage persistence** — entry/manifest persistence to filesystem, startup bulk-load +- **xtask** — `node`, `cli`, `wasm` subcommands with `config.toml` support +- **WASM crypto crate** — `crates/crypto-wasm/`, a `no_std` cdylib exporting `ed25519_sign()`, `get_public_key()`, `buffer_ptr()` + +The auth system enforces binary access control (authorized or not) at the HTTP API boundary. Internal actors remain auth-unaware. Two auth paths: connection-level (iroh QUIC handshake proves NodeId) and per-request signed envelopes (for browser/HTTP API). This branch implements Path 2 end-to-end, including the browser UX. + +--- + +## Architecture + +``` + ┌──────────────────────────────────────────────────────┐ + │ HTTP API (api.rs) │ + │ │ + │ Ungated: │ + │ GET / → browser UI (access page) │ + │ GET /admin → admin page │ + │ GET /crypto.wasm → WASM Ed25519 module │ + │ GET /api/status → node identity │ + │ │ + │ Auth-gated (X-Signed-Request header): │ + │ POST /api/put → check_auth → handle_put │ + │ GET /api/get → check_auth → handle_get │ + │ GET /api/data → check_auth → handle_data │ + │ POST /api/delete → check_auth → handle_delete│ + │ GET /api/list → check_auth → handle_list │ + │ │ + │ Auth management (owner-only): │ + │ POST /api/auth/grant → check_auth_identity │ + │ POST /api/auth/revoke → check_auth_identity │ + │ GET /api/auth/requests→ check_auth_identity │ + │ GET /api/auth/keys → check_auth_identity │ + │ POST /api/auth/deny → check_auth_identity │ + │ │ + │ Signature-only (proves key, no ACL check): │ + │ POST /api/auth/request → check_auth_sig_only │ + └───────────────┬─────────────────────────────────────┘ + │ + GatewayMsg (various) + │ + ┌───────────────▼───────────────┐ + │ GatewayActor │ + │ │ + │ AuthzEngine: │ + │ 1. verify ed25519 signature │ + │ 2. check timestamp ±300s │ + │ 3. check nonce uniqueness │ + │ 4. check ACL │ + │ │ + │ Access request management: │ + │ pending_requests HashMap │ + │ grant resolves label from │ + │ pending request name │ + │ │ + │ ACL persistence: │ + │ persist_acl() on grant/ │ + │ revoke │ + └───────────────┬───────────────┘ + │ + ┌───────────────▼───────────────┐ + │ DatastoreNode │ + │ │ + │ MetadataActor ◄──► BlobStore │ + │ (auth-unaware) │ + └───────────────────────────────┘ + + ┌───────────────────────────────┐ + │ Browser (WASM Ed25519) │ + │ │ + │ /crypto.wasm → initCrypto() │ + │ deviceKeySeed in localStorage │ + │ signBytes() per request │ + │ Access action for all ops │ + │ → X-Signed-Request header │ + └───────────────────────────────┘ + + ┌───────────────────────────────┐ + │ CLI (store_cli) │ + │ │ + │ --key owner.key.json │ + │ Per-action signing: │ + │ Put/Get/Delete/List/Access │ + │ Name resolution for │ + │ grant/revoke/deny │ + └───────────────────────────────┘ +``` + +--- + +## Commit-by-Commit + +### `863185e` — feat: distributed datastore primitives protocol + +Foundation commit establishing the distributed datastore protocol. Defined the protocol messages (`GetChunkRequest`, `FindObjectRequest`, `StoreObjectRequest`, `ListObjectsRequest` and their responses), all implementing `NetworkMessage` with stable `type_tag()` strings. This is the wire protocol for inter-node communication over iroh/QUIC. + +**Key files:** `src/messages.rs` (inter-node message types) + +### `84c4408` — fix: cli for datastore works + +Brought up the `store_node` and `store_cli` binaries as `[[bin]]` targets with feature-gated dependencies (`node` and `cli` features). The node binary spawns the actor runtime, wires up BlobStoreActor/MetadataActor/DatastoreNode, and serves the HTTP API via `tiny_http`. The CLI binary talks to the node over HTTP with `ureq`. Added `clap` for arg parsing, `ctrlc` for graceful shutdown, and `runtime-dashboard` integration. + +**Key files:** `Cargo.toml` (features `node`/`cli`), `src/bin/store_node.rs`, `src/bin/store_cli.rs`, `src/api.rs` + +### `ebee109` — feat: mvp auth protocol + +Core auth implementation: + +- **`src/auth.rs`** — `DatastoreAction` enum, `SignedRequestPayload`, `SignedRequest` envelope, `AccessControlList` (with JSON persistence via `save()`/`load_or_create()`), `AuthzEngine` (4-step verification: signature, timestamp, nonce, ACL), `sign_request()`/`verify_signed_request()` helpers, `AuthzResult`/`DeniedReason` enums. +- **`src/actors/gateway.rs`** — `GatewayActor` wrapping `AuthzEngine`. Handles `Authorize` (pure auth check), `HandleSignedRequest` (auth + dispatch via `action_to_node_msg()`), `CheckConnection` (Path 1), `Grant`/`Revoke` (owner-only ACL mutations), `NonceGcTick`. +- **`src/messages.rs`** — `GatewayMsg` enum, `DatastoreResponse::Denied` variant. +- **`crates/shared-types/`** — Extracted `ContentHash` into its own crate to break dependency cycles between `distribution` and `datastore`. + +Tests added (18 total): +- `auth_scenario_tests.rs` (10 tests) — owner access, stranger denial, grant/revoke lifecycle, signed request happy path, tampered signature, stale timestamp, replayed nonce, nonce GC, non-owner grant/revoke rejection. +- `acl_persistence_tests.rs` (2 tests) — save/load round-trip, create-on-missing. +- `gateway_tests.rs` (4 tests) — connection allow/deny, signed request flow-through, unauthorized signed request denial. + +### `8ac45e5` — fix: adjust auth protocol to datastore protocol + +Aligned the auth types with the content-hash-first datastore protocol: +- `DatastoreAction::Put` carries `content_hash`, `size_bytes`, and `tags` (not raw data). +- `DatastoreAction::Get`/`Delete` use `content_hash`. +- `DatastoreAction::List` uses `name_filter`. +- `GatewayActor::action_to_node_msg()` maps actions to `DatastoreNodeMsg` variants. +- Wired `check_auth()` into the HTTP API handlers (put, get, data, delete, list) — reads `X-Signed-Request` header, sends `GatewayMsg::Authorize` to the gateway actor, denies with 401/403/504 on failure. +- `handle_status` intentionally left ungated. + +### `e549eef` — feat: auth MVP with integrated tests + +Wired auth into both binaries: + +**`store_node.rs`** — `--auth` and `--auth-dir ` flags: +- Loads or generates owner keypair from `/owner.key.json`. +- Owner keypair's public key becomes the `NodeId` (deterministic identity across restarts). +- Loads/creates `/acl.json` with owner as sole authorized key. +- Spawns `GatewayActor` and passes `Some(gateway_addr)` to `start_api_server`. +- Sends `GatewayMsg::NonceGcTick` on the same cadence as the metadata GC tick. + +**`store_cli.rs`** — `--key ` flag: +- Each command builds the appropriate `DatastoreAction`, signs it, sends as `X-Signed-Request` header. +- `status` never signs (always open by design). + +**`http_auth_integration.rs`** — Full-stack integration test: spins up the actor runtime with GatewayActor, starts the HTTP server, proves owner is allowed (PUT/GET/LIST/DELETE), stranger gets 403, missing header gets 401. + +--- + +## Uncommitted Working Tree Changes + +The uncommitted changes represent the bulk of the user-facing work: browser UI, admin page, WASM crypto, expanded CLI, storage persistence, and xtask. + +### Browser UI (`ui_html.rs` — `DATASTORE_UI_HTML`) + +Complete browser access page served at `/`: + +- **Upload panel** — file input + optional name, PUT via `authFetch()` +- **Object table** — list all objects with hash, name, size; download and delete buttons +- **Detail modal** — click a row to see full metadata, chunks, tags +- **Auth detection** — on load, `detectAuth()` fetches `/api/list`; if 401, enables auth mode +- **WASM crypto integration** — `initCrypto()` fetches `/crypto.wasm`, `initKeys()` generates or loads device seed from `localStorage`, derives public key via WASM +- **Auth banner** — shown when user is not authorized, with access request form (name + optional message) +- **Pending state** — after submitting request, shows "waiting for operator approval" with 5-second polling; auto-refreshes when granted +- **Device key display** — shows truncated public key hex in header when auth is active +- **JWK migration** — handles legacy `localStorage.deviceKey` (JWK format) by extracting the `d` parameter as seed + +### Admin Page (`ui_html.rs` — `DATASTORE_ADMIN_HTML`) + +Owner administration page served at `/admin`: + +- **Owner key upload** — file input for `key.json`, loads secret/public key hex, derives via WASM to verify, test call to `/api/auth/requests` to confirm ownership +- **Pending access requests table** — name, message, key (truncated), grant/deny buttons +- **Authorized keys table** — name (label), key (truncated), revoke button +- **Manual grant form** — input for 64-char hex public key + optional name +- **Name disambiguation** — when multiple entries share the same name, appends `(key_prefix)` suffix +- **`ownerAuthFetch()`** — signs all admin API calls with `DatastoreAction::Access` + +### WASM Ed25519 Crypto (`crates/crypto-wasm/`) + +New `no_std` Rust crate compiled to `wasm32-unknown-unknown`: + +- **`Cargo.toml`** — `swactor-crypto-wasm`, `cdylib` crate type, depends on `ed25519-dalek` (no default features) +- **`src/lib.rs`** — Three exported functions: + - `buffer_ptr()` → pointer to 8192-byte shared buffer + - `get_public_key()` — reads 32-byte seed from `BUF[0..32]`, writes public key to `BUF[32..64]` + - `ed25519_sign(msg_len)` — reads seed from `BUF[0..32]`, message from `BUF[128..128+msg_len]`, writes 64-byte signature to `BUF[64..128]` +- **`crypto_wasm.wasm`** — pre-built binary embedded in the datastore via `include_bytes!("crypto_wasm.wasm")` +- Served at `/crypto.wasm` endpoint (ungated) +- Replaces the earlier Web Crypto API approach — Web Crypto's Ed25519 support is inconsistent across browsers; WASM provides deterministic behavior using the same `ed25519-dalek` crate as the Rust backend + +### Expanded GatewayActor (`actors/gateway.rs`) + +New message handlers beyond the original `Authorize`/`HandleSignedRequest`/`CheckConnection`/`Grant`/`Revoke`: + +- **`VerifySignature`** — calls `check_signature_only()` (no ACL check). Used for access request submissions where the caller needs to prove key ownership without being in the ACL. +- **`SubmitAccessRequest`** — stores `AccessRequestInfo { key, name, message, requested_at }` in `pending_requests: HashMap`. +- **`ListAccessRequests`** — owner-only; returns all pending requests. +- **`DenyAccessRequest`** — owner-only; removes a pending request. +- **`ListAuthorizedKeys`** — owner-only; returns `Vec` with labels. + +Grant now resolves labels: when granting a key that has a pending request, the request's `name` field becomes the key's label (unless an explicit label is provided). + +### Expanded Auth Types (`auth.rs`) + +- **`AccessRequestInfo`** — `{ key: NodeId, name: String, message: String, requested_at: u64 }` +- **`AuthorizedKeyInfo`** — `{ key: NodeId, label: String }` +- **`DatastoreAction::Access`** — new variant for browser-originated requests that prove identity without binding to specific content. The browser uses `Access` for all operations (auth is at the HTTP layer). +- **`key_labels: HashMap`** added to `AccessControlList` — maps hex public key to human-readable name. Populated by `grant()`, removed by `revoke()`. +- **`check_signature_only()`** on `AuthzEngine` — verifies signature, timestamp, and nonce but skips ACL check. +- **`authorized_key_list()`** on `AuthzEngine` — returns all authorized keys with their labels. + +### Storage Persistence (`storage/mod.rs`, `storage/in_memory.rs`) + +Extended `StorageBackend` trait with entry persistence: + +- **`write_entry()`** / **`read_entry()`** / **`delete_entry()`** / **`list_entries()`** — persist `ObjectEntry` JSON to disk +- **`FilesystemBackend`** layout extended: + ``` + {root}/ + ├── chunks/{hex[0..2]}/{hex[2..4]}/{full_hex_hash} + ├── manifests/{hex[0..2]}/{hex[2..4]}/{full_hex_hash} + └── entries/{hex[0..2]}/{hex[2..4]}/{full_hex_hash} + ``` +- **`BlobStoreMsg::WriteEntry`** / **`DeleteEntry`** — fire-and-forget messages for entry persistence +- **`BlobStoreMsg::LoadAll`** — startup bulk-load of all entries + their manifests +- **`MetadataMsg::BulkLoad`** — injects loaded entries into MetadataActor's index +- **`store_node.rs` startup sequence** — sends `LoadAll` to BlobStoreActor, polls for `LoadedAll` response, sends `BulkLoad` to MetadataActor + +### Expanded CLI (`store_cli.rs`) + +Full CRUD + auth management subcommands: + +| Subcommand | Auth | Description | +|------------|------|-------------| +| `put [--name]` | `--key` signs `DatastoreAction::Put` | Upload a file | +| `get [--output]` | `--key` signs `DatastoreAction::Get` | Metadata or download | +| `delete ` | `--key` signs `DatastoreAction::Delete` | Delete an object | +| `list [--name] [--all]` | `--key` signs `DatastoreAction::List` | List objects | +| `status` | Never signed | Node identity | +| `grant [--name]` | `--key` signs `Access` | Authorize a key (owner-only) | +| `revoke ` | `--key` signs `Access` | Revoke a key (owner-only) | +| `requests` | `--key` signs `Access` | List pending access requests | +| `keys` | `--key` signs `Access` | List authorized keys | +| `deny ` | `--key` signs `Access` | Deny a pending request | + +**Name resolution:** `grant`, `revoke`, and `deny` accept either a 64-char hex key or a human-readable name. When given a name, the CLI fetches the relevant list from the API and resolves the name to a key. Disambiguated names (`"alice (c9d0e1f2)"`) are supported. + +### xtask (`xtask/src/main.rs`) + +Development task runner with three new subcommands beyond the existing `test`: + +- **`cargo xtask node`** — builds and runs `swactor-store-node`. Flags: `--port`, `--storage-path`, `--auth` (default: true), `--auth-dir`. Builds with `--features node` first, then runs the binary directly (not via `cargo run`) to avoid SIGINT issues. Ignores SIGINT in the xtask process so the child handles Ctrl-C. +- **`cargo xtask cli`** — builds and runs `swactor-store`. Flags: `--url`, `--key`. Auto-detects `./auth/owner.key.json` if present. Passes extra args through. +- **`cargo xtask wasm`** — builds `swactor-crypto-wasm` for `wasm32-unknown-unknown --release`, copies the output to `crates/datastore/src/crypto_wasm.wasm`, optionally runs `wasm-strip`. +- **`config.toml` support** — reads `xtask/config.toml` for default values (node port, storage path, auth settings, CLI url/key). + +**`xtask/Cargo.toml`** — added `toml`, `serde`, `libc` dependencies. + +### HTTP API Expansion (`api.rs`) + +New endpoints: + +| Method | Path | Auth | Handler | +|--------|------|------|---------| +| `POST` | `/api/auth/grant?key=[&name=