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.
A cooperatively-cancelled actor that was sleeping (or in a bounded wait)
leaves its timer entry behind in the heap. The scheduler's shutdown
check required "no pending timers", so a single orphaned far-future
deadline kept the whole runtime alive until it fired — e.g. cancelling
an actor mid sleep(30s) hung run() for 30s.
A timer can only ever do useful work by waking a live actor, so once no
actors are live every remaining entry is orphaned by definition. Drop
the pending-timers condition from the shutdown check (live == 0 already
implies nothing a timer could wake) and clear the heap on the way out.
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.
`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 single-scheduler hot path acquired the shared mutex three times per
yield: once to drain timers, once to drain IO completions, and once to pop
the next runnable actor. It also called Instant::now() every loop iteration
to feed timers.pop_due(), even though the pure-yield / pure-compute workloads
never arm a timer.
Confirmed with llmdbg before touching anything: breakpointing
switch_to_scheduler and instruction-counting one scheduler-side yield cycle on
the ctx_switch_probe showed ~6950 instructions / 68 calls of bookkeeping
between consecutive actor resumes, dominated by lock acquisition and the
per-iteration clock read — not by the naked-asm switch itself (~16 insns).
The te!() trace macro compiles to () without the smarm-trace feature, so it
was ruled out as a contributor.
This collapses the two drain `with_shared` calls into one lock hold and reads
the clock only when the timer heap is non-empty. IO completions are still
drained unconditionally while the IO subsystem is live, because the IO/pool
threads push completions onto their own mutex (not `shared`), so the scheduler
cannot know in advance whether any arrived — it must look; that look is a
single empty-VecDeque check on the hot path. No change to timer or IO
semantics; the asm switch is untouched.
Measured on this box (single core, nproc==1), medians over the bench harness,
re-baselined locally first:
yield_many smarm 1-thread 38459 -> 29943 us (-22.1%)
yield_in_hot_loop smarm 1-thread 166220 -> 122629 us (-26.2%)
ping_pong_oneshot smarm 1-thread 1619 -> 1367 us (-15.6%)
chained_spawn smarm 1-thread 481 -> 399 us (-17.0%)
Run-to-run spread on yield_many is ~3-4% (29565-30634), so the 22% move is
well outside the noise. Gap to tokio current_thread closes from ~2.4x to ~1.95x.
All tests green: cargo test --test context (4) plus the full suite (93 total).
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.
Three changes, each independently measured, landed together.
Fuse pre-resume mutex acquisitions
-----------------------------------
The schedule loop previously took the shared lock three separate times
per actor resume: once to pop the run queue, once to read the stack
pointer, and once to pop the first-resume closure. These are now a
single acquisition that returns everything needed to resume the actor.
As a side effect, pending_closures was changed from a Vec<(Pid, Closure)>
with O(n) linear scan to a Vec<Option<Closure>> indexed by slot index,
making first-closure lookup O(1).
Move stack allocation outside the shared lock
----------------------------------------------
Stack::new() (mmap + mprotect) was previously called inside with_shared,
stalling every other scheduler thread for the duration of two syscalls on
every spawn. It now runs before the lock is acquired.
Stack pool
----------
Rather than munmap-ing a stack when an actor finishes and mmap-ing a fresh
one on the next spawn, stacks are now recycled through a per-Runtime pool
(Mutex<Vec<Stack>>). finalize_actor extracts the stack from the Actor
before clearing the slot and pushes it to the pool outside the shared lock.
spawn_under pops from the pool before falling back to Stack::new().
The pool is unbounded for now (shrink policy TBD) but capped at
stack_pool_cap stacks on return, defaulting to thread_count * 4.
The cap is configurable via Config::stack_pool_cap(n).
Results (24-thread, against stored baseline)
--------------------------------------------
chained_spawn smarm 1-thread: 9763 → 261 µs (-97%)
chained_spawn smarm 24-thread: 23562 → 838 µs (-96%)
ping_pong_oneshot smarm 1-thread: 18409 → 742 µs (-96%)
ping_pong_oneshot smarm 24-thread: 44596 → 1425 µs (-97%)
catch_unwind_panics smarm 24-thread: 267812 → 124094 µs (-54%)
fan_out_compute smarm 24-thread: 2839 → 2226 µs (-22%)
Tokio regressions in the checker output are baseline measurement drift;
no tokio code was changed.
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.