The phase-1 split filled Idle::next_deadline from the timers lock after the
shared lock was released, which turned the stale-PID branch (formerly a 100µs
poll) into a potential sleep-until-timer-deadline while runnable actors sat in
the queue — a hard stall on exact(1). Stale pops are now PopResult::Retry and
loop without sleeping.
test(poison): strengthen regression coverage. The stop-storm test never fired
the sentinel under a lock (its actors only allocate at lock-free points). Add
self_stop_during_spawn_does_not_poison_shared_mutex: a stop-flagged actor whose
next allocation is the Box::new(closure) inside spawn's with_shared. Validated
both ways: passes with the gate, SIGABRTs (unwind-in-allocator) with the gate
removed. Also alloc_interval(1) so every allocation is an observation point.
- next_monitor_id -> AtomicU64 on RuntimeInner
- timers -> own Mutex<Timers>; io -> own Mutex<Option<IoThread>> (lock order: io-before-shared)
- pending_closures Vec folded into Slot::pending_closure
- termination check reads io liveness before shared; ordering argument documented
- poison fix: check_cancelled() no longer fires while preemption is disabled,
so a cancellation unwind can never poison a runtime/channel mutex
- regression test: tests/poison_stop.rs
- ROADMAP_v0.5.md added
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).
recv_match(pred) scans the queue front-to-back, removes and returns the
first match (rest preserved in arrival order), and parks/re-scans on every
send when nothing matches — a selective receiver may park on a non-empty
queue. Returns Err only once the channel is closed with no queued match.
try_recv_match is the non-blocking variant, mirroring try_recv.
Sender::drop now wakes the parked receiver on the last-sender drop
regardless of queue emptiness, so a selective receiver parked on a
non-empty no-match queue observes closure instead of sleeping forever.
No-op for plain recv (which only ever parks on an empty queue).
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.
The context switch in context.rs saves no SSE/XMM state, justified by
every yield crossing a Rust `call` boundary (SysV: XMM0-15 caller-saved,
so live XMM is spilled before the call). The non-obvious path is
preemption: check!() inlines maybe_preempt(), so a switch can fire
mid-floating-point-loop rather than at a visible call site.
Verified the assumption holds on that path, and *why*: switch_to_scheduler
is extern "C", so the compiler spills live XMM around it even when the
yield is buried inside an inlined preempt check. Disasm of the probe loop
shows the accumulators spilled to (%rsp) immediately before the preempt
path and reloaded after. Behavioural check: an FP loop preempted mid-flight
produces a bit-identical result to the same workload with no preemption
(abs diff = 0, debug and release). Confirmed non-vacuous under llmdbg:
switch_to_scheduler fires repeatedly during the loop.
This is a confirmation, not a fix; context.rs is unchanged. The test exists
to catch a future regression that reaches the switch without crossing the
extern "C" call boundary (e.g. fully-inlined asm with no call, or async
signal-driven preemption) — either WOULD require saving XMM.
While running benchmarks, a hang surfaced in timer-only workloads — actors
sleeping with no IO in flight. Tracing it down, the race lives in sleep():
between the call to `timers.insert_sleep` and the subsequent `park_current`
yield, the actor is still in State::Runnable. If the timer fires in that
window, the old code's `if matches!(slot.state, State::Parked)` guard silently
drops the wakeup. The actor then parks normally and never gets re-queued —
it sleeps forever.
The fix mirrors what scheduler::unpark() and the IO FdReady path already do:
when the timer fires and the slot is still Runnable, set `pending_unpark`
instead of re-queuing immediately. The upcoming Park yield sees the flag and
re-queues the actor rather than suspending it, closing the race without any
new synchronisation.
Adds a regression test: 100 actors doing pure timer sleeps across ≥2
scheduler threads. The test asserts both correctness (all actors complete)
and timeliness (wall time < 2×sleep duration), which is enough signal to
catch a stuck actor even on a single-core CI runner.
Add `Config::alloc_interval()` and `Config::timeslice_cycles()` so
callers can tune preemption sensitivity at runtime. The values flow
through `RuntimeInner` and are written into per-scheduler-thread locals
via a new `configure_preempt()` call at thread startup, keeping the hot
path free of cross-thread coherency traffic.
Fix unused-variable warnings in channel.rs by inlining `current_pid()`
directly into `te!` macro arguments — since the no-op macro arm never
evaluates its argument, no binding is needed at the call site.
Clean up a handful of dead imports exposed by the refactor.
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.