Green-Thread Actor Runtime
Erlang's isolation model. Rust's zero-copy ownership. No function colouring.
smarm is a concurrent runtime for Rust. Each actor is a green thread with its own
mmap'd stack. N OS threads share a run queue backed by a fixed slot slab
(16,384 slots, ~4 MiB, allocated once) with lock-free slot lookup. Actors communicate
exclusively via message passing (owned values over channels); no shared mutable state
without an explicit Arc<Mutex<T>>.
Preemption is allocator-driven: every Nth heap allocation, smarm reads RDTSC and yields
the actor if its timeslice has expired. No OS signals, no separate timer thread for scheduling.
Since v0.3 the monolithic Mutex<SharedState> is gone: scheduling state lives in one
atomic word per slot, the loom-checked transitions of which close every lost-wakeup window by
construction. On top sits an OTP layer: gen_server, supervisors with restart strategies,
monitors, links, and a named-pid registry.
No function colouring. No Box<dyn Future>. No poll state machines. Just plain Rust functions that block.
64 KB stacks instead of 8 MB. Context switch in ~10–20 ns (6 GPR saves + ret) instead of kernel mode.
Zero-copy ownership via Rust's type system. No GC pause. Message passing is a move, not a clone — with gen_server, supervision, links and monitors all the same.
Module Map
22 source modules, four layers. Layer 0 has no smarm dependencies. Layer 1 is new in the
v0.5–v0.8 line and is the heart of the runtime: two small modules whose every transition is
model-checked with loom. Layer 2 is the machinery, with runtime.rs as the hub.
Layer 3 is the public surface plus the OTP layer built on it.
mmap a contiguous region, mprotect the bottom page to PROT_NONE as a guard. Overflow → SIGSEGV. New in v0.8: a stack pool in RuntimeInner recycles stacks across spawns (cap: threads × 4), avoiding mmap/munmap churn.
Two #[naked] assembly functions (switch_to_actor, switch_to_scheduler). Save 6 callee-saved GPRs, swap rsp, restore, ret. Thread-locals hold each side's saved stack pointer. XMM registers intentionally not saved — the compiler spills them at Rust call sites.
Implements GlobalAlloc. Every Nth alloc (or check!()), read RDTSC; timeslice expired → switch_to_scheduler(). Also hosts cooperative cancellation: check_cancelled() reads the on-CPU actor's stop flag and raises the StopSentinel panic so the stack unwinds cleanly.
Pid(u32 index, u32 generation). Index = slot in the slab; generation bumps on reclaim and is never reused. A stale Pid fails the generation check atomically with any transition it attempts — ABA is structurally impossible.
Min-heap by deadline. Reason::Sleep and Reason::WaitTimeout entries now carry the wait's park-epoch: a stale entry (wait already satisfied) fails the epoch match and is a guaranteed no-op rather than a heuristic one.
Three-state futex mutex (Drepper's mutex3) that cannot poison. The guard enters NoPreempt, which both bounds lock hold times and structurally prevents the stop sentinel from unwinding a critical section. Used for slot cold data, free list, stack pool, registry.
std vs loom::sync indirection for the two model-checked modules. Build the models with RUSTFLAGS="--cfg loom" cargo test --lib --release. Everything else uses std directly — context switches and futexes aren't loom-able.
The per-slot state machine as a standalone unit: one AtomicU64 packing (gen | park-epoch | state), every transition a CAS on the whole word. Loom proves lost-wakeup, double-enqueue, stale-epoch and ABA unreachable. See the Slot State section.
The global queue behind a compile-time choice: rq-mutex (default), rq-mpmc (Vyukov ring), rq-striped (M rings). All satisfy the same contract: push is infallible, a pid is in the queue at most once. See Run Queue.
Owns the Stack; defines the trampoline every first ret lands in. Runs the closure inside catch_unwind; outcome is Exit, Panic(payload), or — new — Stopped when the unwind was the StopSentinel from cooperative cancellation.
Owns RuntimeInner: the fixed slot slab, free list, run queue, timers, IO handle, live_actors counter, per-thread stats. Hosts spawn, finalize_actor, the unpark protocol, and schedule_loop. The old Mutex<SharedState> no longer exists.
Unbounded MPSC, plus — new — recv_timeout, select, and select_timeout over any set of Selectable arms with ready-index priority in declaration order. A closed arm counts as ready-forever, which gen_server leans on for shutdown.
Actor-aware mutex with mandatory timeout (default 30s). FIFO waiter queue, WaitTimeout timer entry, park. Timer and grant now race through the epoch-matched unpark — exactly one of them wakes the waiter, the other is a proven no-op.
Two background OS threads: epoll thread (EPOLLONESHOT, pushes FdReady) and pool thread (blocking closures in catch_unwind, pushes Blocking). Completions and waiter registrations now carry epochs; the v0.3 fd-leak-on-death gap is closed by an unwind guard plus a belt-and-braces EPOLL_CTL_DEL.
monitor(pid): unidirectional, one-shot Down on a private channel; demonitor takes it back; dead targets yield an immediate NoProc. link(pid): bidirectional and persistent — abnormal death propagates as a transitive stop cascade, or as an ExitSignal message if the peer called trap_exit.
Erlang-style name registry: a bimap (name ↔ pid) under one RawMutex, O(1) both directions. Cleanup is lazy: liveness is checked against the generation-checked slot word on contact, so dead bindings behave as absent — zero coupling to the actor lifecycle.
Per-event tracing behind --features smarm-trace, zero cost without it. MPSC to a dedicated drain thread that batches to disk; output is Chrome-trace JSON viewable in Perfetto. record() disables preemption (it can allocate).
The public surface: spawn, spawn_under, yield_now, sleep, request_stop, join, IO waits, NoPreempt. lib.rs re-exports everything and installs the PreemptingAllocator as #[global_allocator].
Erlang's two workhorses, built on channels + select + spawn — not on runtime internals. gen_server: call/cast/info/down over one actor. supervisor: OneForOne / OneForAll / RestForOne strategies with a restart-intensity cap (default 3 per 5s).
Who Imports What
The shape survives the v0.8 rewrite: runtime.rs is the hub. What changed is what feeds it —
the scheduling state itself (slot_state) and the queue (run_queue) are now standalone,
loom-checked inputs rather than fields of a big struct. The OTP layer deliberately sits on the
public surface: gen_server and the supervisor use channels, select, monitors and
spawn the same way user code would.
Circular dependency, still intentional: channel and mutex call scheduler::unpark_at(), which calls into runtime — and runtime's schedule_loop resumes actors that run channel/mutex code. It works because an unpark is now a single CAS on the slot word plus an enqueue: it never blocks, and preemption is disabled while any smarm-internal lock (RawMutex guard, queue mutex) is held.
What Happens When You Call run(f)
Starting from user code calling smarm::run(|| { ... }). The single-threaded run() is a wrapper around runtime::init(Config::exact(1)).run(f).
Install panic hook (once)
A OnceLock guard installs a custom panic hook that suppresses output inside actor context. Without this, concurrent actor panics can deadlock Rust's default backtrace printer (non-reentrant internal lock). The previous hook is chained for panics outside actors.
Allocate the slot slab runtime.rs
Box<[Slot]> with max_actors entries (default 16,384, ~4 MiB) is allocated once and never moves — that's what makes lock-free slot lookup sound. Every index goes onto the free list. Exhausting the slab is a loud panic naming the Config::max_actors knob.
Start IoThread io.rs
Creates a wake pipe (O_NONBLOCK), an epollfd, and a shutdown pipe registered in the epollfd. Spawns the epoll thread (epoll_wait loop) and the pool thread (blocking-work mpsc receiver). Both share a completion VecDeque behind a mutex.
Install RUNTIME thread-local runtime.rs
Arc<RuntimeInner> is cloned into the calling thread's RUNTIME thread-local. This makes with_runtime() work on the calling thread immediately — needed for the next step.
Spawn initial actor scheduler.rs
scheduler::spawn(f) pops a slot index from the free list, grabs a stack from the pool (or mmaps one), writes the initial register frame, stores the closure in the slot, bumps live_actors, publishes Queued and enqueues the pid. Details in Spawn.
Spawn N−1 OS scheduler threads, enter schedule_loop on thread 0
Each extra thread clones Arc<RuntimeInner>, sets its thread-locals, and enters schedule_loop. Thread 0 is the calling thread and blocks in the loop until the program is done.
Termination & shutdown — counter-based
No slot-table scan under a big lock anymore. A thread exits when the queue pops empty and live_actors == 0 and no IO is outstanding. live_actors is incremented before each spawn's enqueue and decremented as the very last step of finalize_actor — strictly after every wakeup finalize produces — so live == 0 proves no work can ever appear again, and every thread independently reaches the same verdict. Then OS threads are joined and IoThread::drop() tears down both background threads.
The Scheduler Cycle
This is the heartbeat of the runtime, drawn the way biochemists draw the Krebs cycle — because it
is one: a closed loop of state transformations that regenerates its own starting point, fed
from outside by unparks and drained by finalization. The OS thread's execution context is the
energy currency: at station 4 it is handed to the actor (switch_to_actor) and at station 5
the actor hands it back — the bottom arc is dashed because, for that stretch, the scheduler
doesn't exist on this thread at all.
Two details worth pausing on. First, the drain is try-lock: one winner per revolution
pops due timers and IO completions; losers skip straight to the queue, so the pure-compute hot
path never contends a global lock just to learn there's nothing to drain. Second, the
claim is a CAS, Queued → Running on the slot word. The only way it fails is a stale
generation (the actor died and the slot was recycled while the pid sat in the queue), in which
case the pid is simply skipped — nothing else can move a queued actor's word, because wakes
no-op on Queued.
The Yield Sources
| Source | Intent set | Who re-queues | Notes |
|---|---|---|---|
yield_now() |
Yield | Scheduler immediately | yield_return: Running → Queued, pushed to queue tail |
| Allocator preemption | Yield | Scheduler immediately | RDTSC check in maybe_preempt() (every Nth alloc or check!()) |
channel::recv() (empty) |
Park | send() → unpark_at(pid, e) |
begin_wait opens the epoch, then the receiver registers |
select(arms) |
Park | First ready arm's sender | Loser arms hold a consumed epoch — proven no-ops, not pending wakes |
mutex::lock() (contended) |
Park | Guard drop or timeout timer | Grant and timer race through the epoch — exactly one lands |
sleep(d) / recv_timeout |
Park | Timer heap → drain step | Entry carries the epoch; a satisfied wait's entry is a strict no-op |
wait_readable/writable(fd) |
Park | epoll thread → completions → drain | EPOLLONESHOT; waiter registered as (pid, epoch) |
ServerRef::call() |
Park | Server's reply send (or its death) | Just recv() on a fresh one-shot reply channel |
request_stop(pid) target |
— (unwinds) | n/a — StopSentinel panic |
Raised at the next check_cancelled(): alloc, check!(), or wake from a park |
Slot State & the Park-Epoch
Everything the scheduler needs to know about an actor's schedulability lives in one atomic
u64 per slot: (generation << 32) | (park-epoch << 8) | state. Every transition
is a CAS on the whole packed word, which buys two things at once: the generation check is atomic
with the transition (a stale pid can never act on a recycled slot), and an unpark racing the
prep-to-park window becomes a state — RunningNotified — resolved by the scheduler's
park-return CAS rather than a flag read under a lock.
The park-epoch (middle 24 bits) is the actor's wait identity. A waiting actor calls
begin_wait once per wait — bump, get e — before registering (pid, e) with any
waker. Wakes are epoch-matched: unpark_at(pid, e) lands only if the word still carries
e, and every successful wake consumes the epoch. So at most one wake can ever land per
wait, by construction. This is what lets a select register with several channels and park
once: the winning arm's wake bumps the epoch, and every loser arm's later wake fails the match
and no-ops — instead of leaving a phantom notification that would fault the actor's next
one-shot park in sleep, lock_timeout, or block_on_io.
The only wildcard wake (no epoch) is request_stop, which is terminal — control never
returns to the code that parked. A no-park exit (a select arm ready at registration time)
retires the wait explicitly: bump the epoch, then clear_notify eats anything that already
landed, then re-check the stop flag so a terminal wake can't be swallowed.
slot_state.rs compiles its atomics from sync_shim, so loom model-checks the
production transitions directly (RUSTFLAGS="--cfg loom"). The shipped theorems: no lost
wakeup (park vs unpark), at-most-one enqueue (two racing unparkers), consumed-epoch wakes never
land (select's loser arms), the retire eats late arm notifications, stale-generation unparks
never touch a reused slot, and unpark-vs-claim coalesces. Each is an exhaustive interleaving
proof, not a stress test.
Run Queue: Three Variants, One Contract
The v0.3 doc listed the single global Mutex<SharedState> as the primary scalability
ceiling. v0.8's answer is to make the queue its own module behind a compile-time feature
choice, all three variants loom-checked against the same contract — and to shrink what the
queue mutex covers to only the pop/push itself; the slot's own atomics carry everything
needed to resume.
A VecDeque behind a RawMutex. Innermost lock in the ordering — nothing else is ever acquired under it. Simple, predictable, and fine until queue ops dominate.
A Vyukov-style bounded MPMC ring with per-cell sequence counters. Lock-free push/pop; falls back to an overflow side-list to keep push infallible.
M independent rings; producers and consumers hash/rotate across stripes to spread contention. A stepping stone toward per-thread deques with work stealing.
Push is infallible. A pid is in the queue at most once. The only pushes are paired 1:1 with successful transitions into Queued; only the scheduler transitions Queued → Running, paired 1:1 with pops. Queue ops require preemption disabled (debug-asserted).
Termination doesn't lean on pop-None being a fence (with the rings it's only a snapshot). The argument is counter-first: every queue entry's target stays Queued — hence counted in live_actors — until that very entry is popped, so live == 0 by itself implies the queue is empty forever.
New Actor From First Resume
Spawning is still the trickiest part of the runtime: an actor's first resume is fundamentally
different from subsequent ones because we can't "call" into a new stack — we have to
ret into it.
scheduler::spawn(f) called
Pops a slot index from the free list (RawMutex<Vec<u32>>) — the slab is fixed, so slot exhaustion panics loudly naming Config::max_actors. The slot's current generation becomes the new Pid(index, gen). A Stack comes from the stack pool if one is cached, else a fresh 64 KB mmap + guard page.
Initial stack frame written context::init_actor_stack()
Starting from top & ~15 − 8 (aligned), pushes downward: the trampoline address as the ret target, then 6 zero words for the callee-saved registers. The resulting rsp is stored in the slot's sp atomic. No actual function call has happened yet.
high addr ← top
top-8: &trampoline ← will be popped by 'ret'
top-16: 0 ← rbx
top-24: 0 ← rbp
top-32: 0 ← r12
top-40: 0 ← r13
top-48: 0 ← r14
top-56: 0 ← r15 ← initial rsp stored here
Closure stored in the slot
The Box<dyn FnOnce() + Send> goes into the slot's closure: AtomicPtr — swap-to-take, no map, no lock. (v0.3 kept a pending_closures map inside SharedState; that's gone with the struct.) It can't ride the actor's stack because nothing can pass it via a register during first resume.
Count it live, then publish
live_actors += 1 before the actor becomes poppable — the termination argument needs every enqueue to target a counted actor. Then publish_queued(gen): a plain Release store Vacant → Queued with epoch 0 (the spawner owns the vacant slot exclusively, so no CAS needed). This store is the moment the actor exists to pops, unparks and stops. Finally the pid is enqueued.
Scheduler pops the pid, claims, prepares first resume
try_claim CASes Queued → Running. The scheduler loads sp from the slot, sees take_closure() return Some (it's the first resume) and moves the box into the trampoline's thread-local. Then: bind the stop flag, reset the timeslice, enable preemption, switch_to_actor().
First context switch lands in trampoline()
switch_to_actor() saves the scheduler's GPRs, loads the slot's sp as the new rsp, pops the 6 zero words, then rets — popping the trampoline address and jumping to it. We're now on the actor's stack. The trampoline takes the closure from the thread-local and calls it inside catch_unwind(AssertUnwindSafe(f)).
Actor returns → trampoline classifies the outcome
Ok(()) → Exit. Err(payload) → Panic(payload) — unless the payload is the StopSentinel, in which case the outcome is Stopped: a cooperative cancellation, not a crash, and links/supervisors treat it as such. The done flag is set, one last switch_to_scheduler(), and the scheduler runs finalize_actor: Down to monitors, ExitSignal or stop cascade to links, Signal to the supervisor, epoch-matched joiner wakes, stack back to the pool, slot reclaimed at gen+1, and live_actors −= 1 last of all.
Allocator-Driven Timeslicing
How it works
The PreemptingAllocator is installed as the process's #[global_allocator]. Its alloc(), alloc_zeroed(), and realloc() all call maybe_preempt() before delegating to the system allocator.
maybe_preempt() decrements a thread-local counter. Every 128 allocations (default), it reads RDTSC. If rdtsc() − timeslice_start > 300_000 cycles (~100µs at 3 GHz) and PREEMPTION_ENABLED == true, it calls switch_to_scheduler().
The check!() macro calls the same maybe_preempt() — the explicit preemption point for tight no-alloc loops, since stable Rust offers no transparent way to interrupt them.
Cooperative cancellation rides the same rail
request_stop(pid) sets a flag in the target's Arc<AtomicBool> and fires a wildcard unpark. The flag is observed by check_cancelled() — called from maybe_preempt() and from the wake side of every park — which raises the StopSentinel panic. The trampoline's catch_unwind recognises it and reports Outcome::Stopped, distinct from a user Panic: the stack unwinds, Drops run, links and supervisors see a stop, not a crash.
Invariant: preemption off while holding smarm-internal locks
A timeslice switch while holding an internal lock would suspend the actor with the lock held, stalling every OS thread that touches it. With the big SharedState mutex gone, the rule is enforced structurally:
PREEMPTION_ENABLED = falsein the scheduler loop before/afterswitch_to_actor()- every
RawMutexguard (slot cold data, free list, stack pool, registry) entersNoPreempton construction - run-queue ops require preemption disabled — debug-asserted in
run_queue.rs trace::record()also disables preemption (it can allocate)
Disabling preemption also gates the stop sentinel: with PREEMPTION_ENABLED == false, maybe_preempt neither yields nor raises the sentinel, so no allocation inside a critical section can unwind it. That's why RawMutex can afford to be non-poisoning.
Known gap, unchanged: tight no-alloc loops are invisible without explicit check!() calls — to preemption and to cancellation. Documented and by design; such loops are uncommon in message-passing workloads.
// preempt.rs — simplified
pub fn maybe_preempt() {
ALLOC_COUNT.with(|c| {
let n = c.get();
if n == 0 {
c.set(ACTIVE_ALLOC_INTERVAL.with(|i| i.get())); // reset counter
if PREEMPTION_ENABLED.with(|e| e.get()) {
check_cancelled(); // may raise StopSentinel → Outcome::Stopped
let elapsed = rdtsc() - TIMESLICE_START.with(|s| s.get());
if elapsed > ACTIVE_TIMESLICE_CYCLES.with(|i| i.get()) {
unsafe { switch_to_scheduler() }; // YieldIntent::Yield
}
}
} else {
c.set(n - 1);
}
});
}
Two Background Threads, One Wake Pipe
Both completion kinds now carry the waiter's park-epoch, so the wake side is the same
protocol as everything else. For Blocking, the result is stashed under the slot's cold lock
before the unpark_at — and if the actor died with the op in flight (a stop), the
generation check fails and the result is discarded instead of being written into a recycled slot.
This path closed a latent lost wakeup: the v0.3 code set the result for a still-Running actor
without flagging it; under v0.8 that interleaving lands as RunningNotified and the upcoming
park re-queues.
epoll_ctl ADD/DEL is called by the scheduler thread directly on the epollfd — legal per epoll_ctl(2) even while the epoll thread is inside epoll_wait. Avoids a second command channel. The v0.3 fd-leak on actor-death-during-wait is fixed: an unwind guard in wait_fd deregisters on the stop path, and registration does a belt-and-braces EPOLL_CTL_DEL first (harmless ENOENT if absent).
gen_server: One Object Above, One Actor Below
The mental model you program against is a synchronous object: a state value with handlers,
processing one message at a time — no locks, no races, because there's no concurrency inside
the plane. The implementation is one actor, one inbox channel, and a select loop. The
diagram maps each promise of plane A to the mechanism in plane B that pays for it.
The arm priority is the semantics
Calls and casts travel the same inbox as Envelope variants — that's what makes them
mutually ordered, like Erlang. Everything out-of-band rides separate channels composed at the
wait: select priority is Down arms › Watcher arm › info channels (declaration order) ›
inbox, rebuilt each turn. A hot inbox can't starve a death notice or a system message;
conversely a hot info channel can starve the inbox — deliberately. A closed info arm is
silently dropped from the set; a closed inbox (every ServerRef gone) is graceful
shutdown.
Death needs no monitor
Server death detection falls out of channel closure. Already dead → the inbox is closed and
send fails → ServerDown. Dies mid-call (handler panic or request_stop) → the
reply sender drops as the server's stack unwinds → the parked caller's recv errors →
ServerDown. So call_timeout is exactly recv_timeout on the reply channel —
nothing registered, nothing to leak on the timeout path by construction. One Erlang-faithful
sharp edge: a timed-out request stays in the inbox and will still be handled; only the
reply is discarded. Design idempotent calls accordingly.
terminate() is wired through a drop guard, so it runs on every exit path — clean close, panic, stop — not just the happy one. Keep it cheap and non-panicking: it may run mid-unwind, where a second panic aborts the process. The loop also calls check!() every iteration, so a server whose arms are never empty stays preemptible and cancellable.
Monitors, Links, Supervisors, Registry
All four are built on the same two primitives — the slot's cold lifecycle data, walked by
finalize_actor, and the epoch-matched unpark — and all four follow Erlang's semantics
closely enough that the BEAM chapter of your brain transfers.
monitor(pid) returns a Monitor: a private Receiver<Down> plus the identity for demonitor. Unidirectional, one-shot — exactly one Down ever; the channel closes after. DownReason is deliberately payload-free: a panic payload has one owner, the joiner. Monitoring an already-dead pid yields an immediate NoProc Down rather than silence.
link(pid) is bidirectional and persistent. Abnormal death (panic or stop — never a normal return) propagates: a non-trapping peer is request_stop'd, and because its own links are walked when it finalizes, the stop cascades transitively — "let it crash." A peer that called trap_exit() instead receives an ExitSignal message and keeps running. Linking a dead pid behaves as an immediate NoProc death.
A supervisor is just an actor running OneForOne::new().child(spec)…run(). Strategies: OneForOne (restart the dead child), OneForAll (restart everything), RestForOne (restart it and everyone after it). Restart::{Permanent, Transient, Temporary} per child. The intensity cap (default 3 restarts per 5s, Erlang's default) stops a crash-looping child from burning the tree — tripping it kills the supervisor, escalating upward.
register(name, pid) / whereis(name) / name_of(pid): a bimap under one RawMutex, O(1) both ways. No hook in finalize_actor — every operation checks the bound pid's liveness via the generation-checked slot word, so a stale binding is detectable, never misdirected: dead bindings behave as absent and are pruned on contact. Zero coupling to the actor lifecycle, at the cost of a dead name lingering until touched.
// the whole OTP loop, in user space
spawn(|| {
OneForOne::new()
.strategy(Strategy::RestForOne)
.intensity(3, Duration::from_secs(5))
.child(ChildSpec::new(Restart::Permanent, || db_writer()))
.child(ChildSpec::new(Restart::Transient, || batch_job()))
.run(); // parks on its Signal channel; restarts per policy
});
Things That Would Bite You
The v0.3 doc's headline gotcha is now a theorem instead of a flag. An unpark racing the prep-to-park window lands as the RunningNotified state; the scheduler's park-return CAS re-queues instead of parking. Loom proves parked-forever unreachable across all interleavings — including the Blocking-IO path the old pending_unpark bool silently missed.
The single Mutex<SharedState> that capped scalability no longer exists. Slot lookup is lock-free against a fixed slab; the queue mutex (if rq-mutex) covers only push/pop; rq-mpmc / rq-striped remove even that. Timers and IO sit behind their own try-locked drain.
std::thread::sleep inside an actorStill blocks the entire OS scheduler thread, starving every actor on it. There's no detection. Use smarm::sleep(d). Same family: any blocking syscall outside block_on_io / wait_readable.
terminate()gen_server's terminate() runs from a drop guard, possibly mid-unwind. A panic inside it during an unwind is a double panic → process abort, no supervision tree to save you. Keep it cheap, non-blocking, non-panicking.
Per-slot RawMutex cold data follows a strict leaf rule: never hold two cold locks at once — finalize_actor's link cascade locks peers one at a time. Overall order: io → (cold | free | stack_pool) → run queue innermost; timers independent. Internal code only, but if you're hacking on smarm, this is the map.
call_timeout giving up does not cancel the request — it's already in the server's inbox and will run; only the reply is discarded (the abandoned reply channel makes the server's send fail harmlessly). Erlang behaves identically. Make calls idempotent or live with it.
A granted lock or satisfied wait still leaves its timer entry in the heap (no cancellation). But the entry carries the wait's epoch, and the epoch was consumed by the real wake — so the late fire is a proven no-op, not a "should be fine." Cost unchanged: ~32 bytes per stale entry, bounded.
request_stop is cooperative: the sentinel is raised at the next allocation, check!(), or park wake. An actor spinning in a no-alloc loop without check!() is unstoppable, exactly as it is unpreemptible. Holding a RawMutex guard also defers the sentinel (by design — see Preemption).
Intentional and correct. XMM0–15 are caller-saved in SysV AMD64 ABI. Every yield passes through a Rust call site, so the compiler has already spilled live XMM values to the actor's stack before the naked asm runs; they're restored from its own stack on resume.
panic = unwind is requiredThe trampoline uses catch_unwind to intercept actor panics — and cooperative stops are implemented as an unwind (StopSentinel). With panic = abort, both become process death: no Outcome::Panic, no Stopped, no supervision. Documented; the profile is set in Cargo.toml.