The slot table split (ROADMAP_v0.5 phase 2): slot lookup is lock-free, the
run path (yield/park/unpark/pop/resume) takes zero locks beyond the queue
mutex itself, and SharedState shrinks to { run_queue }.
Core pieces:
- src/raw_mutex.rs: hand-rolled 3-state futex mutex (Drepper mutex3),
non-poisoning; the guard enters NoPreempt, which both bounds hold times and
structurally closes the unwind-under-lock hole (the stop sentinel shares
the gate). Used for per-slot cold data, the free list, and the stack pool.
- Fixed slab Box<[Slot]> (default max_actors = 16_384, ~4 MiB, align(128)
against false sharing). Slots never move -> stable addresses, lock-free
index. Exhaustion panics loudly, naming Config::max_actors(n) as the fix.
Unbounded/segmented slab stays deferred (see ROADMAP).
- Per-slot AtomicU64 packing (generation << 32 | state), states
Vacant/Queued/Running/RunningNotified/Parked/Done. Every transition CASes
the packed word, so the generation check is atomic with the transition: no
ABA, no spurious unparks on recycled slots. RunningNotified replaces the
pending_unpark bool (the lost-wakeup window is a state, not a flag) and
uniformly fixes a LATENT LOST WAKEUP in the old Blocking-IO completion
path, which set the result for a still-Running actor without flagging it.
- Invariant: a pid is in the run queue at most once (pushes pair 1:1 with
transitions into Queued; only the scheduler does Queued->Running). This is
what makes phase 3's bounded rings sound.
- sp -> relaxed AtomicUsize; stop flag + first-resume closure as AtomicPtrs
(closure double-boxed for a thin pointer, swap-to-take): the resume path is
fully atomic.
- finalize_actor: Done published under the dying slot's cold lock (join's
check-or-register is linearized by it); link cascade locks peers ONE AT A
TIME (cold locks are leaves) with the acyclicity argument written at the
site; link() registers on the target first, then self (stale self-entries
are benign, every walk re-verifies the peer's word).
- Termination by counters: live_actors incremented in spawn pre-enqueue,
decremented at the very END of finalize after all wakeup enqueues; exit on
io_out == 0 (read before the queue lock, phase-1 ordering) && queue empty
&& live == 0. Soundness note at the site: any enqueue targets a live actor.
- spawn boxes the closure and acquires the stack BEFORE any runtime lock: no
allocation ever happens under a global lock anymore.
- with_runtime/try_with_runtime now enter NoPreempt for their full span.
This fixes a bug the rework exposed: install_actor allocated with
preemption enabled while the RUNTIME RefCell borrow was live; a timeslice
preemption there migrates the actor across OS threads and the borrow guard
increments one thread's RefCell count and decrements another's — underflow
to 'permanently mutably borrowed', cascading panics (caught by stress
suite: deterministic non-unwinding-panic abort in lost_wakeup_many_pairs).
The old code was safe only by accident; now it's structural.
- Behavior note: request_stop on a RUNNING target now marks it
RunningNotified, so its next park returns immediately to an observation
point — faster stop observation; parked/queued/done semantics unchanged.
Validated: 22 suites green in release (1/2/8-thread oversubscribed on the
1-core sandbox) and in debug with all debug_asserts live; stress suite x5.
Monitors could be installed but never taken down. That gap was about to
bite: the gen_server call timeout we want next is the Erlang dance —
monitor the server, wait for the reply or a Down or a deadline, then
demonitor — and without a way to remove a registration, every timed-out
call would leak a monitor on the server's slot and risk a stale Down
arriving later. So this lands the cleanup primitive before anything
depends on the old shape.
The decision flagged in the roadmap ("decide the monitor API NOW") is
resolved by giving each registration a process-unique MonitorId and
returning it to the caller. monitor() now hands back a
Monitor { id, target, rx } rather than a bare Receiver<Down>: read the
notice from rx as before, and pass &Monitor to demonitor to tear exactly
that registration down. The id comes from a monotonic counter in shared
state, bumped under the same lock that does the registration, so it's
deterministic and never reused — which is what lets demonitor name one of
several monitors on the same target unambiguously. target rode along on
the struct (over the sketched {id, rx}) purely so demonitor can go
straight to the slot instead of scanning every slot for the id.
demonitor returns Option<MonitorId> rather than a bool: Some(id) when a
live registration was found and removed, None when there was nothing left
to remove — it already fired (the registration is drained on finalize),
it was a NoProc, or the slot has been reclaimed. The generation half of
the pid quietly protects that last case: a recycled slot index fails
slot_mut's generation check, so a late demonitor is a clean no-op and can
never strip a different actor's monitor that happens to share the index.
The one piece of real care is reentrancy. Removing a registration drops
the slot's Sender, and Sender::drop can unpark a parked receiver, which
re-enters the shared mutex — which is not reentrant. So demonitor moves
the sender out of the Vec under the lock and lets it drop only after the
lock is released, the same discipline finalize_actor already follows for
its monitor and supervisor sends.
Flushing a Down the target already queued isn't a separate flag; it falls
out of dropping the Monitor. demonitor(&m); drop(m) stops future notices
and discards any queued one — the gen_server-call cleanup in one move.
Storage is now Vec<(MonitorId, Sender<Down>)>. The three slot-reset sites
were left alone on purpose: they clear/rebuild the Vec, which doesn't care
about the element type, so there's no fourth reset obligation. finalize
just destructures (_, m). Because chained_spawn and yield_many register no
monitors, that Vec stays empty on the hot path — taking an empty Vec costs
the same and the notify loop runs zero times — and a before/after general
probe confirmed both medians sit within noise.
Tests cover the three behaviours that matter: a demonitored watcher gets
no Down (its channel closes), demonitoring one of several leaves the
siblings firing normally, and demonitoring after the Down has already
fired reports None.
Thin request-reply layer on channels, no runtime change. A server is an
actor owning a GenServer state; clients hold a clonable ServerRef and issue
call (sync, parks for the reply) or cast (fire-and-forget).
- Single inbox carrying an internal Envelope { Call(req, reply_tx) | Cast },
forced by the no-select / no-unified-mailbox invariant; call makes a
one-shot reply channel and parks on it.
- Server-down detection is pure channel closure (no monitor): send fails if
the inbox is gone; the reply sender drops on the server's unwind so a
parked caller wakes to Err. Both collapse to CallError/CastError::ServerDown.
- init/terminate are optional trait hooks; terminate runs via a drop guard so
it fires on every exit path (clean close, panic, request_stop). Must stay
non-blocking — may run mid-unwind.
- ServerRef carries pid() for monitor/request_stop/link; start + start_under.
Tests: cast-then-call roundtrip, init/terminate ordering, both server-down
paths (reply-channel close on handler panic; inbox-send failure when gone).
Add Erlang-style process links so an abnormal death fate-shares across a
link set, with trap_exit to convert that into a message instead.
- Slot.links: Vec<Pid>, bidirectional; reset in all three lifecycle sites.
- Actor.trap: Option<Sender<ExitSignal>>, fresh per spawn (a restarted
child starts un-trapped; no fourth reset site).
- link/unlink free functions on self_pid(); trap_exit() -> Receiver<ExitSignal>
(a dedicated inbox, distinct from the monitor Down channel).
- finalize_actor clears reverse links under the lock (always; keeps the
cascade acyclic), then propagates abnormal deaths post-lock: trapping peer
gets an ExitSignal message and survives, non-trapping peer is request_stop'd.
Normal exit never propagates. Dead-pid link delivers an immediate NoProc
(message if trapping, else request_stop(self) -- never a silent no-op).
- ExitSignal reuses DownReason and carries no panic payload (joiner-only).
Tests in tests/link.rs cover the propagate/trap/normal/dead-pid/unlink cases.
Adds a Strategy enum (OneForOne default, OneForAll, RestForOne) selected
via OneForOne::strategy(). The triggering child's Restart policy still
decides whether anything restarts; the strategy decides which siblings
are cycled with it:
- OneForAll : all live siblings
- RestForOne: siblings started after the failed child
Group restarts stop the affected survivors cooperatively (request_stop)
in reverse start order, await each one's termination signal on the
existing funnel (no new channel, no select), then restart the whole set
in start order. Signals that arrive for a child we aren't currently
stopping are stashed and replayed by the main loop. A Stopped signal now
counts as abnormal for the restart decision.
On giving up (intensity cap) or any exit with survivors, an ordered
shutdown stops the remaining children in reverse start order instead of
leaking them; on the normal all-settled exit it's a no-op.
The struct keeps the OneForOne name for compatibility (existing tests
unchanged); rename is a later refactor.
request_stop(pid) sets a per-actor flag and wakes a parked target. The
actor realizes the stop as a controlled unwind at its next observation
point (check!()/alloc via maybe_preempt, or the wakeup side of any
blocking park): a StopSentinel panic tears the stack down via the
trampoline's existing catch_unwind, running Drop, and is reported as the
new Outcome::Stopped (distinct from a user Panic).
Death surface kept distinct from Exit: Signal::Stopped(pid) +
DownReason::Stopped, so the supervisor await logic to come can tell a
requested stop apart from a self-termination.
Flag lives on Actor behind Arc<AtomicBool>; the scheduler resume path
takes a raw pointer into it (no per-resume refcount traffic), keeping
yield throughput at baseline.
Builds on the existing supervisor_channel funnel (one mailbox per
supervisor; better than N monitor channels given there is no select).
- supervisor.rs: Restart{Permanent,Transient,Temporary}, ChildSpec (an
Fn factory so children can be re-instantiated), and OneForOne with a
builder (child/intensity) and run() supervision loop. Restart decision
keys off whether the Signal was a panic; a sliding-window intensity cap
stops crash loops and returns instead of spinning.
- Does NOT forcibly terminate children: shared-heap + Drop make async
teardown of a peer unsound, so one_for_all/rest_for_one and true links
wait on a cooperative-cancellation primitive. Cap-trip just stops
restarting; live children are left as-is.
tests/supervisor.rs: transient restart-then-settle, transient/temporary
no-restart, permanent crash-loop hitting the cap, and one-for-one
isolation across two children. Full suite green.
`monitor(pid) -> Receiver<Down>` lets any actor watch any other and
receive a single Down{pid, reason} when it terminates. Generalizes the
existing single supervisor_channel (which is just a hard-wired monitor
the parent installs at spawn).
- src/monitor.rs: Down / DownReason{Exit,Panic,NoProc} + monitor().
Payload-free: a panic payload has one owner and goes to the joiner via
JoinError, so monitors learn only that it panicked. Monitoring an
already-gone pid yields NoProc immediately.
- runtime: Slot grows `monitors: Vec<Sender<Down>>`; finalize_actor
drains and notifies them outside the shared lock (send can unpark a
receiver, which re-takes the non-reentrant mutex). Cleared in
reclaim_slot and on slot reuse at spawn.
- Registration and finalize both serialize on the shared mutex, so a
target alive at registration always delivers a real Down (no race
between liveness check and registration).
tests/monitor.rs: Exit, Panic, NoProc-on-dead-target, and fan-out to
multiple monitors. Full suite green.
Stable Rust emits stack probes inline (subq/movq/jne loop) rather than
calling __rust_probestack, so there's no transparent hook for stack-
frame preemption. Override of __rust_probestack links cleanly but never
runs. Falling back to an explicit check!() that users drop into hot
compute loops.
check!() decrements the same ALLOC_COUNT counter as the heap path, so
both event sources fire timeslice checks at the same rate. Documents
the prep-to-park invariant on maybe_preempt — library code that
registers a wakeup and then parks must keep that window alloc-free and
check-free, or a preemption-driven yield in the middle would lose the
wakeup.
Adds a BinaryHeap of timer entries on SchedulerState. sleep() inserts
an entry and parks; schedule_loop pops due entries each iteration and
unparks them. When the run queue is empty but timers are pending, the
OS thread sleeps until the soonest deadline.
Single-threaded only; thread::sleep is fine because no other thread
can wake us. The IO thread coming next will need a Condvar or pipe
wakeup to break this OS-sleep early.
Hand-rolled context switching on mmap'd stacks with guard pages,
allocator-driven RDTSC preemption, unbounded MPSC channels, supervision
via per-slot Signal mailboxes, root supervisor as sentinel PID.
Lib + tests + benches clean check/clippy. All 29 tests pass.
Bench: smarm 3.4% over serial baseline, within 160us of tokio
current-thread on prime-counting fan-out.