Files
smarm/ROADMAP_v0.5.md
T
Claude 039703dbeb feat(safety): phase 5 — loom model checking + invariant audit/assertion sweep
State machine extracted to src/slot_state.rs as a standalone unit (StateWord:
publish_queued / try_claim / yield_return / park_return / unpark / set_done /
reclaim, plus the Status view for cold paths). runtime.rs keeps the protocol
rationale and consumes the mechanism; every transition self-asserts its
precondition per the assert-the-invariants rule.

src/sync_shim.rs: std vs loom indirection (atomics + UnsafeCell with the
with/with_mut access API) for the two loom-modeled modules. Loom models run
the PRODUCTION code, not a replica:
- slot_state: no-lost-wakeup (park vs unpark), two-unparkers-one-enqueue,
  stale-unpark-never-hits-reused-slot (the ABA theorem), unpark-vs-claim.
- run_queue: mpmc exactly-once through a lap wraparound, push/pop race,
  striped two-producer drain.
RUSTFLAGS="--cfg loom" cargo test --lib --release — 7 models, all pass.
RawMutex deliberately not loom-modeled (futexes can't be; textbook mutex3
with stress + unwind coverage).

Assertion sweep (invariants now checked at the point of reliance):
- enqueue debug-asserts the word reads EXACTLY (gen, Queued) — the
  at-most-once-enqueued invariant the ring capacity proof leans on.
- RawMutex enforces the leaf rule mechanically: debug-build thread-local
  held-count, panics at the acquisition that violates it.
- live_actors underflow (double finalize) asserted.
- StateWord transition preconditions asserted (yield/park/done/reclaim/
  publish/claim).

Audit: with_runtime, RawMutex guards, run-queue ops, and trace::record all
gate preemption (and thereby the stop sentinel) for their span; trace was
already self-gating. Sole remaining exception is the channel std MutexGuard
— the documented first post-v0.5 fast-follow.

Review fixes to the branched-in work:
- slot_state::status_for mapped (matching gen, Vacant) to Stale instead of
  unreachable!: Pid::new is public, so a forged/never-issued pid (e.g.
  monitor(Pid::new(5,0)) on a fresh slab) could reach it — "no such actor"
  is the correct total answer; issued pids still can't get there.
- Tightened enqueue's assert from Status::Live to the exact (gen, Queued)
  word its own comment argues for.

Validated: 22 suites in debug (all asserts + leaf counter live) for
rq-mutex/rq-mpmc/rq-striped, release for rq-mutex, smarm-trace build +
stress, loom 7/7.
2026-06-09 21:27:45 +00:00

158 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# smarm v0.5 — Runtime decomposition & run-path scaling
Goal: dismantle the single `Mutex<SharedState>` 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<Timers>` (only drain-winner + blocking prims touch it).
- [x] `io` → own `Mutex<Option<IoThread>>` (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<Vec<u32>>` (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 ✅ DONE (shootout = phase 4 harness)
- [x] `RunQueue` alias in `src/run_queue.rs`, compile-time selected,
`compile_error!` guards for zero / >1 features. No runtime dispatch.
All variants compile in every build (unit tests always run); the
feature only picks the alias. Non-default variants:
`--no-default-features --features rq-…`.
- `rq-mutex` — `Mutex<VecDeque>`, 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.
- [x] Bounded rings sound via slab cap + at-most-once-enqueued (occupancy ≤
`max_actors`); mpmc capacity = next_pow2(max_actors), striped
Σcapacity ≈ 2×max_actors with probe-from-home-stripe. A full ring
panics as an invariant violation (double enqueue), never spins.
- [x] All hand-rolled, dependency-free.
- [x] Two contracts surfaced by the extraction, documented + debug-asserted
in `run_queue.rs`: queue ops require preemption disabled (a producer
suspended mid-publish stalls every consumer behind its cell — livelock);
and pop-None is a snapshot, not a fence — termination is counter-first
(`live == 0` alone implies the queue holds nothing actionable; argument
rewritten at the `schedule_loop` site).
## Phase 4 — Bench harness ✅ DONE (harness; real numbers from the 20-core box)
- [x] `benches/rq_micro.rs`: raw structures, threads × p:c ratio sweep. One
binary covers all three structures (types compile in every build).
- [x] `benches/rq_runtime.rs`: yield-storm, ping-pong-pairs, spawn-storm,
sweeping scheduler count; variant baked in by feature.
- [x] `scripts/bench_rq.sh`: rebuilds per `rq-*` feature, aggregates RQCSV
lines into `bench_results/summary.csv`. Knobs via SMARM_BENCH_* env;
e.g. `SMARM_BENCH_THREADS="1 2 4 8 16 20" ./scripts/bench_rq.sh`.
- [x] Harness validated end-to-end at smoke scale on the 1-core sandbox;
contention curves and the actual variant decision come from the
20-core box.
## Phase 5 — Safety hardening & model checking ✅ DONE
- [x] State machine extracted to `src/slot_state.rs` (mechanism only; the
protocol rationale stays in `runtime.rs`) so loom checks the PRODUCTION
transitions. Models: lost-wakeup (park vs unpark), at-most-once
(two unparkers), ABA (stale unpark vs reclaim+reuse), claim coalescing.
- [x] Loom on the rings via `src/sync_shim.rs` (std normally, `loom::sync`
under `--cfg loom`): exactly-once through lap wraparound, push/pop
race, striped two-producer drain. `[target.'cfg(loom)'.dependencies]`;
run with `RUSTFLAGS="--cfg loom" cargo test --lib --release`.
RawMutex is deliberately NOT loom-modeled: futexes can't be, and it is
Drepper's textbook mutex3 with stress + unwind tests.
- [x] NoPreempt / no-unwind / TLS-guard audit: with_runtime + RawMutex guards
+ run-queue ops + trace::record all gate preemption (and thereby the
stop sentinel) for their span. Sole remaining exception: channel's std
MutexGuard — the documented first fast-follow.
- [x] Invariant-assertion sweep (the fast-follow, pulled forward): every
StateWord transition self-asserts its precondition; enqueue asserts
exactly (gen, Queued); live_actors underflow asserted; RawMutex
enforces the leaf rule with a debug-build held-count; slab overflow and
ring overflow stay loud panics. House style from here on.
---
## Fast follow (post-v0.5, written down so it isn't lost)
- **Assert the invariants we lean on.** This cycle accumulated load-bearing
invariants: at-most-once-enqueued, queue-ops-under-NoPreempt, never two
cold locks, cold-path generation re-verify under the lock, finalize's
decrement-last, pushes-pair-with-Queued-transitions, thread-local guards
never crossing a switch point. Whenever code RELIES on one and a cheap
check exists, assert it at the point of reliance — `debug_assert!` on hot
paths, full `assert!`/loud panic on cold ones — so a violation fails at
the breakage site, not three modules downstream (the slab-overflow panic
and the queue-op preemption debug_assert are the pattern). Sweep the
existing code for missed spots; new code adopts it as house style. The
phase-5 audit is the natural vehicle for the sweep.
- **Channel mutex migration.** `channel::Inner<T>` is `Arc<Mutex<_>>` 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<Segment>`, 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`).