# smarm v0.5 — Runtime decomposition & run-path scaling Goal: dismantle the single `Mutex` along the run path so the runtime scales to dozens of cores, with crash isolation hardened so a torn write or a stray cancellation can never poison shared runtime state. Guiding rules established this cycle: - **Lock count that matters is hot-path locks.** Cold lifecycle paths (spawn/join/monitor/link/finalize) may keep a small per-slot lock; the run path (yield/park/unpark/pop/resume) targets zero locks. - **Lock ordering: io-before-shared, and slot locks are leaves** (never hold two slot locks at once). Any new lock states its position in this order. - **No unwind inside a runtime critical section.** The stop sentinel only fires at lock-free observation points. - **Never hold a thread-local guard across a possible switch point.** An actor can be preempted at any allocation and resume on a different OS thread; a live `RefCell` borrow (or std `MutexGuard`) then pairs its acquire/release across two threads' locals — count underflow / UB. `with_runtime`, `with_shared`, and every `RawMutex` guard disable preemption for exactly this reason (phase 2 found this the hard way). --- ## Phase 1 — State decomposition: easy peel-offs ✅ DONE Split independent concerns out of `SharedState` so the global lock guards less. - [x] `next_monitor_id` → `AtomicU64` on `RuntimeInner` (lock-free id minting). - [x] `timers` → own `Mutex` (only drain-winner + blocking prims touch it). - [x] `io` → own `Mutex>` (completion queue already self-locked). - [x] `pending_closures: Vec` → folded into `Slot::pending_closure` (per-actor data). - [x] Termination check split: read io liveness *before* `shared`; ordering argument documented in `schedule_loop`. - [x] **Poison fix:** gate `check_cancelled()` in `maybe_preempt` behind `PREEMPTION_ENABLED`, so a cancellation sentinel can never unwind while a `std::sync::Mutex` (shared/channel) is held. Regression test: `tests/poison_stop.rs`. `SharedState` now holds only: `slots`, `free_list`, `run_queue`, `root_pid`. ## Phase 2 — Slot table split ✅ DONE Make slot lookup lock-free and per-slot state independently mutable. - [x] Fixed slab: `Box<[Slot]>`, slots never move → stable addresses, lock-free index. **Assert on exhaustion** with a panic message naming `Config::max_actors(n)` as the fix. Default `max_actors = 16_384`. (Mental note: must become unbounded or configurable-bounded later — see "Deferred" below. Do not let the fixed cap calcify into an assumption.) - [x] Generation packed INTO the state word (better than the planned separate `AtomicU32`): one `AtomicU64 = (gen << 32) | state`, so the gen check is atomic with every transition — no ABA, no spurious unparks, by CAS. - [x] Per-slot CAS state machine `Vacant/Queued/Running/RunningNotified/ Parked/Done` replacing `state` + `pending_unpark`. Also fixed a latent lost wakeup in the Blocking-IO completion path (result set for a still-Running actor without a flag). - [x] `sp` → relaxed `AtomicUsize`; stop flag + first-resume closure as `AtomicPtr`s — resume path fully lock-free. - [x] Per-slot raw non-poisoning futex mutex (`src/raw_mutex.rs`) for the cold collections. Guard enters `NoPreempt`. - [x] Free list → `RawMutex>` (a leaf lock). Treiber/striped only if the phase-4 spawn-storm bench shows contention — revisit then. - [x] `finalize_actor` link cascade locks peers one at a time; acyclicity argument written at the site. `link()` registers target-first with the race argument at the site. - [x] `live_actors` atomic; termination = io_out == 0 (read pre-queue-lock) && queue empty && live == 0; decrement-last ordering documented as the correctness crux at the site. ## Phase 3 — Pluggable run queue + bench shootout - [ ] Extract `RunQueue` behind a `type RunQueue = ...` alias selected at compile time by mutually-exclusive features, with a `compile_error!` guard if zero or >1 is set. No runtime dispatch. - `rq-mutex` — `Mutex`, the control/baseline. - `rq-mpmc` — single hand-rolled Vyukov bounded MPMC ring (per-cell seq numbers). Strict FIFO; "one hot cache line". - `rq-striped` — M Vyukov rings, fetch-add ticket distribution. Relaxed FIFO, reordering bounded by ~M. Predicted winner @20c. - [ ] Bounded rings are sound because the slab cap + at-most-once-enqueued invariant bound occupancy ≤ `max_actors`; size Σcapacity > `max_actors` so push is infallible (probe next stripe when full; terminates). - [ ] All hand-rolled, dependency-free (portability incl. possible embedded). ## Phase 4 — Bench harness - [ ] Raw-structure microbench: N threads, push/pop throughput vs thread count, sweeping producer:consumer ratios. Isolates the data structure. - [ ] Runtime-level: yield-storm, ping-pong-pairs, spawn-storm, all sweeping scheduler count. Reuses existing `benches/` harness style. - [ ] Driver script rebuilding per `rq-*` feature to compare variants in one go. - [ ] Sandbox validates correctness via oversubscribed `Config::exact(N)` on 1 core; real contention numbers come from the 20-core box. ## Phase 5 — Safety hardening & model checking - [ ] `loom` (dev-dependency only, x86 Linux) model tests for the slot state machine and each ring variant. - [ ] `NoPreempt` / no-unwind-under-lock audit across all internal critical sections; assert the invariant in debug builds where feasible. --- ## Fast follow (post-v0.5, written down so it isn't lost) - **Channel mutex migration.** `channel::Inner` is `Arc>` of the same poison class as the old shared lock; `recv_match` even runs a user predicate under it. The Phase-1 `check_cancelled` gating already removes the unwind *source* globally, so channels are poison-safe today — but phase 2 surfaced a second, sharper reason to migrate: the std `MutexGuard` is held with preemption ENABLED, so a timeslice switch inside a channel critical section migrates the actor and unlocks the pthread mutex from a different OS thread — technically UB (Linux futexes happen to tolerate it). The `RawMutex` guard disables preemption and is cross-thread-release sound by construction. Migrate as the first post-v0.5 change. ## Deferred / explicitly out of scope for v0.5 - **Unbounded / configurable-bounded actor count.** v0.5 ships a fixed slab with a loud assert. Revisit with a segmented slab (array of `AtomicPtr`, doubling segment sizes, append-only) once we actually hit the cap or a workload demands it. - **Idle-wakeup eventcount.** Idle scheduler threads keep the current 100µs poll-sleep. A futex-based eventcount is a later optimization if benches show idle latency matters. - **User-facing safe data structures** (ArcSwap-style cells, structurally- shared persistent maps). Context for the runtime work, not in scope here. - **IO fd hygiene on actor death** (pre-existing v0.2 TODO in `io.rs`).