Compare commits

..
26 Commits
Author SHA1 Message Date
smarm-dev 89fb13e29a feat(arch): port context switch + cycle counter to aarch64
Extract the x86-64-specific context-switch shims, initial-stack layout,
and cycle counter out of context.rs into a target_arch-gated src/arch/
module, and add an aarch64 (AAPCS64) backend.

- src/arch/mod.rs: shared SCHEDULER_SP/ACTOR_SP thread-locals + the
  four-item backend contract (init_actor_stack, switch_to_{actor,
  scheduler}, read_cycle_counter); compile_error! on other arches.
- src/arch/x86_64.rs: existing implementation verbatim, plus the rdtsc
  cycle counter relocated here from preempt.rs.
- src/arch/aarch64.rs: x19-x30 stp/ldp context switch, lr-based return,
  CNTVCT_EL0 cycle counter (isb-serialised).
- context.rs: now a thin facade re-exporting crate::arch; public surface
  unchanged for scheduler/preempt/tests.
- preempt.rs: read_cycle_counter delegates to crate::arch; per-arch
  DEFAULT_TIMESLICE_CYCLES (TSC and CNTVCT have different units).
- tests/context.rs: cfg-gate the x86 callee-saved-register probe and add
  the aarch64 x23-x26 sibling.
- .cargo/config.toml: aarch64-unknown-linux-gnu cross linker.

No functional change on x86-64: arch/x86_64.rs is byte-for-byte the old
context.rs body, and tests/preempt.rs (incl. the XMM-not-saved guard from
14dc7a7) is untouched.
2026-06-07 21:54:28 +00:00
smarm-dev d908eb3f95 chore(release): v0.4.0
Mainline release covering roadmap #1-#5 (cancellation, supervisor strategies, links/trap_exit, selective receive, gen_server, demonitor).
2026-06-07 21:54:22 +00:00
smarm-dev 024abc2e73 docs: reflect arm-port carve-out; master is x86-only, aarch64 on branch
Mainline now carries roadmap #1-#5; the aarch64 context-switch port is a single untested commit on the arm-port branch. Update the task.md resume block and the README build section accordingly.
2026-06-07 21:54:04 +00:00
smarm-dev 518103750b feat(monitor): demonitor + per-monitor MonitorId (roadmap #5)
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.
2026-06-07 21:51:56 +00:00
smarm-dev a7832f4a8c docs(roadmap): mark gen_server done; add module to README 2026-06-07 21:51:56 +00:00
smarm-dev 55221e9e98 feat(gen_server): synchronous call / async cast over a single actor (roadmap #5)
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).
2026-06-07 21:51:56 +00:00
smarm-dev 02f55fa0a0 docs(roadmap): mark selective receive (#4) done
Record what landed for recv_match/try_recv_match and update the
no-select gotcha: selective receive stayed per-channel rather than
introducing a cross-channel mailbox.
2026-06-07 21:51:56 +00:00
smarm-dev 2dd61d4a19 feat(channel): selective receive — recv_match + try_recv_match (roadmap #4)
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).
2026-06-07 21:51:56 +00:00
smarm-dev 70bd237277 docs: mark links/trap_exit (#3) done; refresh supervisor + README
- task.md: mark roadmap #3 done (record the resolved dedicated-inbox
  decision + test list), add the feature commit to the resume block, note
  the now-resolved trap_exit channel question, and list spawn_link under #5.
- supervisor.rs: the module comment predated cancellation; rewrite it to say
  sibling/link/shutdown stops are cooperative (request_stop), not that
  one_for_all / rest_for_one / links are impossible.
- README: add monitor + link rows, refresh the supervisor row and src layout,
  and drop restart-intensity caps from "what's not here" (shipped in #2).
2026-06-07 21:51:56 +00:00
smarm-dev 8ac11a57ac feat(link): bidirectional links + trap_exit (roadmap #3)
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.
2026-06-07 21:51:56 +00:00
smarm-dev 59998ce79e docs(roadmap): mark cooperative cancellation (#1) and supervisor strategies (#2) done
Capture the handoff roadmap in-repo, with #1 and #2 marked complete
(commit SHAs, decisions taken, and the orphaned-timer bug fixed in
e80334b). #3 (links + trap_exit) flagged as next.
2026-06-07 21:51:56 +00:00
smarm-dev 68c7c96749 feat(supervisor): one_for_all and rest_for_one strategies + ordered shutdown
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.
2026-06-07 21:51:56 +00:00
smarm-dev c6136b2553 fix(runtime): don't let orphaned timers block shutdown
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.
2026-06-07 21:51:56 +00:00
smarm-dev d9a6520a24 feat(cancel): cooperative cancellation via sentinel unwind
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.
2026-06-07 21:51:56 +00:00
smarm-dev 09236b8bf4 feat(supervisor): one-for-one supervisor with restart policies + intensity cap
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.
2026-06-07 21:51:56 +00:00
smarm-dev 461de2c451 feat(monitor): unidirectional one-shot process monitors
`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.
2026-06-07 21:51:56 +00:00
smarm-dev 14dc7a79cf test(context): guard XMM-not-saved assumption against preemptive switch
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.
2026-05-28 15:09:07 +00:00
smarm-dev 7d44b20baf perf(scheduler): fold timer+IO drain into one lock, skip clock read when no timers
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).
2026-05-28 06:29:58 +00:00
smarm-dev 6d17254ae3 docs(context): fix off-by-one-slot error in init_actor_stack layout comment
The diagram claimed the entry/ret target sits at aligned_top-8 and r15 at
aligned_top-56. The code does (top & ~15) - 8 then a -= 8 before the first
write, so entry actually lands at aligned_top-16 and r15 at aligned_top-64.
Confirmed by single-stepping the shim's ret under llmdbg: the entry slot is
at an address with %16==0, giving the required rsp%16==8 on entry. Code was
correct; only the comment was wrong (and would mislead a maintainer).
2026-05-28 05:50:18 +00:00
smarm-dev 594392a5ae examples: deterministic single-scheduler context-switch probe
A minimal Config::exact(1) program with two actors doing a fixed number of
yield_now() calls. No I/O/timers/channels, so the instruction stream stays
centered on the naked-asm switch. Frame-pointer-friendly. Intended for
single-threaded inspection under a debugger (e.g. llmdbg).
2026-05-28 05:50:18 +00:00
smarm 389ddec56d License: MIT -> Do what you want 2026-05-26 23:14:46 +02:00
smarm 7746dca69b fix(runtime): handle timer firing while actor is still Runnable in sleep()
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.
2026-05-26 21:58:14 +02:00
smarm 72f5d38e5d perf: reduce scheduler mutex contention + stack pool
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.
2026-05-25 23:28:11 +02:00
smarm 64be3f23ac Baseline benchmarks on my machine 2026-05-25 23:23:12 +02:00
smarm d432349f99 Update the documentation 2026-05-25 22:14:07 +02:00
smarm 2b85ef60b2 Make preemption knobs configurable; fix unused-variable warnings
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.
2026-05-25 21:52:16 +02:00
38 changed files with 4983 additions and 357 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
+1
View File
@@ -1,2 +1,3 @@
target target
Cargo.lock Cargo.lock
smarm_trace.json
+14 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "smarm" name = "smarm"
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
@@ -12,7 +12,7 @@ libc = "0.2"
[dev-dependencies] [dev-dependencies]
libc = "0.2" libc = "0.2"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "time"] }
[profile.dev] [profile.dev]
panic = "unwind" panic = "unwind"
@@ -29,3 +29,15 @@ harness = false
[[bench]] [[bench]]
name = "multi_scheduler" name = "multi_scheduler"
harness = false harness = false
[[bench]]
name = "general"
harness = false
[[bench]]
name = "smarm_favored"
harness = false
[[bench]]
name = "tokio_favored"
harness = false
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Tokio Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+32 -10
View File
@@ -1,8 +1,8 @@
# smarm # smarm
> Silly Marks Abstract Rust Machine. A prototype green-thread actor runtime for Rust. > SMARM — Smarm, Marks Actor Runtime Machinery. A proof-of-concept green-thread actor runtime for Rust.
Implements the core ideas in [`LOOM.md`](./LOOM.md): green-thread actors on a Implements the core ideas in [`Achitecture.md`](.docs/Architecture.md): green-thread actors on a
shared heap, scheduled cooperatively, communicating only by `Send` messages. shared heap, scheduled cooperatively, communicating only by `Send` messages.
Erlang's isolation model without Erlang's copying GC, Rust's zero-copy Erlang's isolation model without Erlang's copying GC, Rust's zero-copy
ownership transfers without async's function colouring. ownership transfers without async's function colouring.
@@ -25,7 +25,10 @@ convenience wrapper around `runtime::init(Config::exact(1)).run(f)`.
| `mutex` | `Mutex<T>` with mandatory timeout; FIFO waiters; parks the green thread | | `mutex` | `Mutex<T>` with mandatory timeout; FIFO waiters; parks the green thread |
| `timer` | Min-heap of `(deadline, reason)`; `Sleep` and `WaitTimeout` reasons | | `timer` | Min-heap of `(deadline, reason)`; `Sleep` and `WaitTimeout` reasons |
| `io` | `block_on_io` for blocking work; `wait_readable`/`wait_writable` + `read`/`write` via epoll | | `io` | `block_on_io` for blocking work; `wait_readable`/`wait_writable` + `read`/`write` via epoll |
| `supervisor` | `Signal::Exit` / `Signal::Panic` delivered to a parent actor's mailbox | | `supervisor` | `Signal::Exit`/`Panic`/`Stopped` funnelled to a parent; `OneForOne`/`OneForAll`/`RestForOne` strategies + restart-intensity cap |
| `monitor` | `monitor(pid)``Monitor { id, target, rx }`; one-shot `Down` via `rx`; `demonitor(&m)` tears one registration down; unidirectional death notice |
| `link` | bidirectional `link`/`unlink`; abnormal death propagates (cooperative stop, or an `ExitSignal` message under `trap_exit`) |
| `gen_server` | `call` (sync request-reply) / `cast` (async) over one inbox; `ServerRef` + `init`/`terminate` hooks; server-down via channel closure |
## Quick taste ## Quick taste
@@ -52,20 +55,22 @@ run(|| {
``` ```
src/ src/
stack.rs context.rs preempt.rs pid.rs actor.rs stack.rs context.rs preempt.rs pid.rs actor.rs
scheduler.rs channel.rs mutex.rs timer.rs io.rs supervisor.rs scheduler.rs channel.rs mutex.rs timer.rs io.rs
lib.rs supervisor.rs monitor.rs link.rs runtime.rs
gen_server.rs lib.rs
tests/ tests/
per-module integration tests per-module integration tests
benches/ benches/
primes.rs fan-out/fan-in compute, vs tokio current_thread primes.rs fan-out/fan-in compute, vs tokio current_thread
LOOM.md design intent
``` ```
## Building and running ## Building and running
Standard Cargo. Requires Rust 1.95 or newer (the `#[naked]` attribute went stable Standard Cargo. Requires Rust 1.95 or newer (the `#[naked]` attribute went stable
in 1.88; we use a few unrelated post-1.88 features). x86-64 Linux only — in 1.88; we use a few unrelated post-1.88 features). `master` is x86-64 Linux
ARM64 and macOS are on the deferred list because of the assembly shim and the only. An experimental, **untested** aarch64 context-switch backend lives on the
`arm-port` branch (extracted into a `target_arch`-gated `src/arch/`); it has not
been validated on hardware yet. macOS remains on the deferred list because of the
epoll dependency. epoll dependency.
```sh ```sh
@@ -76,7 +81,24 @@ cargo bench # primes benchmark vs tokio
## What's not here ## What's not here
See the **Defer** section of `LOOM.md`. Notable absences: supervisor See the **Defer** section of `Architecture.md`.
restart-intensity caps, `join!` for handle groups, stack growth via remap, `join!` for handle groups, stack growth via remap,
hierarchical timer wheel, fd-wait timeouts, `Signal::Timeout`. Each is hierarchical timer wheel, fd-wait timeouts, `Signal::Timeout`. Each is
mechanism we know how to add; none belongs in this iteration. mechanism we know how to add; none belongs in this iteration.
## Docs
| Document | What it covers |
|---|---|
| [`Architecture.md`](./docs/Architecture.md) | Design intent, runtime model, and deferred work |
| [`smarm - Deep Dive.html`](./docs/smarm%20-%20Deep%20Dive.html) | Generated walkthrough of the system; good starting point |
| [`BENCHMARKS_AND_TUNING.md`](./docs/BENCHMARKS_AND_TUNING.md) | Where smarm wins and loses vs tokio, preemption knob recommendations |
| [`benchmarks.md`](./docs/benchmarks.md) | Raw benchmark results, methodology, and tuning experiment log |
## Contributing
This is a personal proof-of-concept. There's no PR workflow. If you fork it and do something interesting, just send me an email. If it's nice, I'll upstream the changes.
---
+189 -99
View File
@@ -2,223 +2,313 @@
"chained_spawn": { "chained_spawn": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 1000, "result": 1000,
"median": 8637, "median": 266,
"min": 8553, "min": 242,
"max": 8933 "max": 351
},
"smarm 24-thread": {
"result": 1000,
"median": 742,
"min": 696,
"max": 860
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 1000, "result": 1000,
"median": 124, "median": 62,
"min": 124, "min": 61,
"max": 153 "max": 68
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 1000, "result": 1000,
"median": 188, "median": 190,
"min": 183, "min": 169,
"max": 229 "max": 207
} }
}, },
"yield_many": { "yield_many": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 200000, "result": 200000,
"median": 41622, "median": 19071,
"min": 41063, "min": 18776,
"max": 44973 "max": 19396
},
"smarm 24-thread": {
"result": 200000,
"median": 172454,
"min": 166246,
"max": 174230
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 200000, "result": 200000,
"median": 15085, "median": 4737,
"min": 15013, "min": 4644,
"max": 15274 "max": 5065
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 200000, "result": 200000,
"median": 15964, "median": 8738,
"min": 15880, "min": 7852,
"max": 17959 "max": 9770
} }
}, },
"fan_out_compute": { "fan_out_compute": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 33860, "result": 33860,
"median": 29727, "median": 13234,
"min": 29491, "min": 13196,
"max": 31634 "max": 13390
},
"smarm 24-thread": {
"result": 33860,
"median": 2244,
"min": 2162,
"max": 2380
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 33860, "result": 33860,
"median": 28503, "median": 14049,
"min": 28391, "min": 14035,
"max": 28866 "max": 14300
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 33860, "result": 33860,
"median": 34542, "median": 1474,
"min": 34396, "min": 1285,
"max": 36111 "max": 1823
} }
}, },
"ping_pong_oneshot": { "ping_pong_oneshot": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 1000, "result": 1000,
"median": 16848, "median": 751,
"min": 16633, "min": 727,
"max": 17301 "max": 913
},
"smarm 24-thread": {
"result": 1000,
"median": 1308,
"min": 1227,
"max": 1396
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 1000, "result": 1000,
"median": 879, "median": 407,
"min": 868, "min": 400,
"max": 973 "max": 444
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 1000, "result": 1000,
"median": 4328, "median": 10869,
"min": 4223, "min": 8683,
"max": 4461 "max": 11688
} }
}, },
"spawn_storm_busy": { "spawn_storm_busy": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 10000, "result": 10000,
"median": 130058, "median": 112045,
"min": 126790, "min": 99936,
"max": 134475 "max": 117329
},
"smarm 24-thread": {
"result": 10000,
"median": 137105,
"min": 130852,
"max": 147707
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 10000, "result": 10000,
"median": 2772, "median": 1128,
"min": 2641, "min": 1123,
"max": 4367 "max": 1435
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 10000, "result": 10000,
"median": 7462, "median": 19674,
"min": 4469, "min": 16013,
"max": 12892 "max": 27234
} }
}, },
"mpsc_contention": { "mpsc_contention": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 320000, "result": 320000,
"median": 9260, "median": 3667,
"min": 9095, "min": 3608,
"max": 10081 "max": 4126
},
"smarm 24-thread": {
"result": 320000,
"median": 45681,
"min": 31908,
"max": 51287
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 320000, "result": 320000,
"median": 17570, "median": 6228,
"min": 17213, "min": 6210,
"max": 18276 "max": 6514
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 320000, "result": 320000,
"median": 17593, "median": 66173,
"min": 17452, "min": 42208,
"max": 19564 "max": 83255
} }
}, },
"many_timers": { "many_timers": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 10000, "result": 10000,
"median": 135806, "median": 119988,
"min": 132573, "min": 107308,
"max": 141651 "max": 123557
},
"smarm 24-thread": {
"result": 10000,
"median": 218842,
"min": 182009,
"max": 256988
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 10000, "result": 10000,
"median": 14462, "median": 12432,
"min": 13555, "min": 12308,
"max": 15457 "max": 13468
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 10000, "result": 10000,
"median": 15011, "median": 16311,
"min": 14655, "min": 15026,
"max": 15368 "max": 16897
} }
}, },
"multi_thread_scaling": { "multi_thread_scaling": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 33860, "result": 33860,
"median": 30029, "median": 14908,
"min": 29720, "min": 14857,
"max": 31351 "max": 15218
},
"smarm 2-thread": {
"result": 33860,
"median": 7834,
"min": 7717,
"max": 8033
},
"smarm 4-thread": {
"result": 33860,
"median": 4393,
"min": 4326,
"max": 4435
},
"smarm 24-thread": {
"result": 33860,
"median": 2173,
"min": 2068,
"max": 2405
}, },
"tokio multi 1-thread": { "tokio multi 1-thread": {
"result": 33860, "result": 33860,
"median": 28983, "median": 14432,
"min": 28908, "min": 14219,
"max": 29323 "max": 14763
},
"tokio multi 2-thread": {
"result": 33860,
"median": 7333,
"min": 7222,
"max": 7477
},
"tokio multi 4-thread": {
"result": 33860,
"median": 3741,
"min": 3681,
"max": 3876
},
"tokio multi 24-thread": {
"result": 33860,
"median": 1513,
"min": 1375,
"max": 1979
} }
}, },
"deep_recursion": { "deep_recursion": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 1, "result": 1,
"median": 83, "median": 102,
"min": 78, "min": 96,
"max": 587 "max": 123
},
"smarm 24-thread": {
"result": 1,
"median": 597,
"min": 576,
"max": 682
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 1, "result": 1,
"median": 25, "median": 13,
"min": 25, "min": 11,
"max": 33 "max": 35
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 1, "result": 1,
"median": 59, "median": 56,
"min": 47, "min": 46,
"max": 205 "max": 65
} }
}, },
"yield_in_hot_loop": { "yield_in_hot_loop": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 1000000, "result": 1000000,
"median": 188753, "median": 80680,
"min": 187007, "min": 80308,
"max": 194366 "max": 81845
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 1000000, "result": 1000000,
"median": 153929, "median": 72606,
"min": 152712, "min": 72154,
"max": 158749 "max": 77206
} }
}, },
"uncontended_channel": { "uncontended_channel": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 1000000, "result": 1000000,
"median": 26811, "median": 9257,
"min": 26498, "min": 9223,
"max": 29069 "max": 12049
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 1000000, "result": 1000000,
"median": 51888, "median": 16925,
"min": 51530, "min": 16848,
"max": 52708 "max": 17019
} }
}, },
"catch_unwind_panics": { "catch_unwind_panics": {
"smarm 1-thread": { "smarm 1-thread": {
"result": 10000, "result": 10000,
"median": 142215, "median": 116821,
"min": 140189, "min": 111345,
"max": 143570 "max": 128261
},
"smarm 24-thread": {
"result": 10000,
"median": 117487,
"min": 107011,
"max": 129307
}, },
"tokio current_thread": { "tokio current_thread": {
"result": 10000, "result": 10000,
"median": 682295, "median": 10425,
"min": 670281, "min": 10141,
"max": 700774 "max": 10604
}, },
"tokio multi-thread": { "tokio multi-thread": {
"result": 10000, "result": 10000,
"median": 662688, "median": 6418,
"min": 641453, "min": 3715,
"max": 681868 "max": 7144
} }
} }
} }
+9
View File
@@ -272,6 +272,8 @@ fn bench_panic_smarm(threads: usize) -> (u64, u128) {
let err = Arc::new(AtomicU64::new(0)); let err = Arc::new(AtomicU64::new(0));
let ok2 = ok.clone(); let ok2 = ok.clone();
let err2 = err.clone(); let err2 = err.clone();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let start = Instant::now(); let start = Instant::now();
smarm::runtime::init(bench_cfg(threads)).run(move || { smarm::runtime::init(bench_cfg(threads)).run(move || {
let mut handles = Vec::new(); let mut handles = Vec::new();
@@ -289,6 +291,7 @@ fn bench_panic_smarm(threads: usize) -> (u64, u128) {
} }
} }
}); });
std::panic::set_hook(prev_hook);
let total = ok.load(Ordering::Relaxed) + err.load(Ordering::Relaxed); let total = ok.load(Ordering::Relaxed) + err.load(Ordering::Relaxed);
(total, start.elapsed().as_micros()) (total, start.elapsed().as_micros())
} }
@@ -299,6 +302,8 @@ fn bench_panic_tokio_current() -> (u64, u128) {
let ok2 = ok.clone(); let ok2 = ok.clone();
let err2 = err.clone(); let err2 = err.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let start = Instant::now(); let start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -317,6 +322,7 @@ fn bench_panic_tokio_current() -> (u64, u128) {
} }
} }
}); });
std::panic::set_hook(prev_hook);
let total = ok.load(Ordering::Relaxed) + err.load(Ordering::Relaxed); let total = ok.load(Ordering::Relaxed) + err.load(Ordering::Relaxed);
(total, start.elapsed().as_micros()) (total, start.elapsed().as_micros())
} }
@@ -330,6 +336,8 @@ fn bench_panic_tokio_multi() -> (u64, u128) {
.worker_threads(available_threads()) .worker_threads(available_threads())
.build() .build()
.unwrap(); .unwrap();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let start = Instant::now(); let start = Instant::now();
rt.block_on(async move { rt.block_on(async move {
let mut handles = Vec::new(); let mut handles = Vec::new();
@@ -347,6 +355,7 @@ fn bench_panic_tokio_multi() -> (u64, u128) {
} }
} }
}); });
std::panic::set_hook(prev_hook);
let total = ok.load(Ordering::Relaxed) + err.load(Ordering::Relaxed); let total = ok.load(Ordering::Relaxed) + err.load(Ordering::Relaxed);
(total, start.elapsed().as_micros()) (total, start.elapsed().as_micros())
} }
+28 -21
View File
@@ -1,4 +1,4 @@
# Loom # SMARM Architecture
> Erlang-style actor concurrency for Rust, without the copies, the colors, or the GC pauses. > Erlang-style actor concurrency for Rust, without the copies, the colors, or the GC pauses.
@@ -11,7 +11,7 @@ draws the boundary, the borrow checker already enforces it. What it lacks is an
async/await is IO-centric, colors your functions, and trades stack simplicity for state-machine complexity; async/await is IO-centric, colors your functions, and trades stack simplicity for state-machine complexity;
OS threads are too heavy to spawn per actor. OS threads are too heavy to spawn per actor.
Loom adds a third option: **green-thread actors on a shared heap**, scheduled cooperatively, with SMARM adds a third option: **green-thread actors on a shared heap**, scheduled cooperatively, with
message-passing as the only cross-actor communication primitive. You get Erlang's isolation model without message-passing as the only cross-actor communication primitive. You get Erlang's isolation model without
Erlang's copying GC, and you get Rust's zero-copy ownership transfers without async's cognitive overhead. Erlang's copying GC, and you get Rust's zero-copy ownership transfers without async's cognitive overhead.
No function coloring. No `Box<dyn Future>`. Just actors, messages, and the borrow checker doing what it No function coloring. No `Box<dyn Future>`. Just actors, messages, and the borrow checker doing what it
@@ -24,14 +24,14 @@ already does.
### Actors and scheduling ### Actors and scheduling
Each actor is a lightweight green thread with its own heap-allocated, growable stack. Stacks are Each actor is a lightweight green thread with its own heap-allocated, growable stack. Stacks are
allocated via `mmap` with a guard page below the region; overflow is detected by the OS without Loom allocated via `mmap` with a guard page below the region; overflow is detected by the OS without SMARM
polling for it. Initial stacks are small and grow by remapping on demand. polling for it. Initial stacks are small and grow by remapping on demand.
The scheduler runs one OS thread per CPU. Each scheduler thread loops against a single global The scheduler runs one OS thread per CPU. Each scheduler thread loops against a single global
`Mutex<HashMap>` queue shared across all schedulers. If queue contention becomes a measured bottleneck `Mutex<HashMap>` queue shared across all schedulers. If queue contention becomes a measured bottleneck
this can be revisited; the interface will not change. this can be revisited; the interface will not change.
Loom requires `panic = unwind`. Users who set `panic = abort` accept that supervision and actor SMARM requires `panic = unwind`. Users who set `panic = abort` accept that supervision and actor
isolation are silently degraded to process death. isolation are silently degraded to process death.
### Process descriptor ### Process descriptor
@@ -84,11 +84,11 @@ threshold is exceeded the actor yields. The workloads that starve a scheduler
data transformation — are precisely the ones doing frequent allocations, so this approximation is data transformation — are precisely the ones doing frequent allocations, so this approximation is
correct by construction. correct by construction.
`RDTSC` is not monotonic across core migration; a slightly wrong timeslice is acceptable. Loom is `RDTSC` is not monotonic across core migration; a slightly wrong timeslice is acceptable. SMARM is
not a real-time scheduler. not a real-time scheduler.
Known failure mode: tight no-alloc loops are invisible to this mechanism. Actors doing sustained Known failure mode: tight no-alloc loops are invisible to this mechanism. Actors doing sustained
allocation-free compute must call `loom::yield_now()` explicitly, or offload to a thread pool allocation-free compute must call `smarm::yield_now()` explicitly, or offload to a thread pool
outside the actor scheduler (e.g. rayon). This is documented and acceptable — such loops are rare outside the actor scheduler (e.g. rayon). This is documented and acceptable — such loops are rare
in message-passing workloads. in message-passing workloads.
@@ -99,12 +99,12 @@ An actor yields at:
- **Channel send/recv** — the primary communication primitive - **Channel send/recv** — the primary communication primitive
- **Mutex contention** — attempting to lock a held `Arc<Mutex<>>` parks the actor - **Mutex contention** — attempting to lock a held `Arc<Mutex<>>` parks the actor
- **IO** — blocking on a socket or file descriptor parks the actor until the IO thread signals readiness - **IO** — blocking on a socket or file descriptor parks the actor until the IO thread signals readiness
- **`loom::sleep(duration)`** — parks the actor; the timer wheel re-queues it on expiry - **`smarm::sleep(duration)`** — parks the actor; the timer wheel re-queues it on expiry
- **`loom::yield_now()`** — explicit cooperative yield - **`smarm::yield_now()`** — explicit cooperative yield
- **Allocator preemption** — as above - **Allocator preemption** — as above
- **Spawn** — does not yield by default; the new actor is queued and the spawner continues - **Spawn** — does not yield by default; the new actor is queued and the spawner continues
`std::thread::sleep` inside an actor blocks the entire OS thread and should never be used. Loom `std::thread::sleep` inside an actor blocks the entire OS thread and should never be used. SMARM
may emit a warning if it can detect this. may emit a warning if it can detect this.
### IO thread ### IO thread
@@ -112,7 +112,7 @@ may emit a warning if it can detect this.
A single dedicated IO thread runs an `epoll`/`kqueue` loop. Actors blocking on IO register their A single dedicated IO thread runs an `epoll`/`kqueue` loop. Actors blocking on IO register their
file descriptor and PID; the IO thread moves them back into the global queue when the fd is ready. file descriptor and PID; the IO thread moves them back into the global queue when the fd is ready.
A `HashMap<RawFd, Pid>` maps fds to parked actors. Cancellation (actor dies while waiting on IO) A `HashMap<RawFd, Pid>` maps fds to parked actors. Cancellation (actor dies while waiting on IO)
deregisters the fd. This is intentionally simple and not pluggable; Loom is not a general async deregisters the fd. This is intentionally simple and not pluggable; SMARM is not a general async
executor. executor.
### Communication ### Communication
@@ -155,7 +155,7 @@ sensible global default.
### Mutex timeout ### Mutex timeout
Every `loom::mutex` lock attempt is mediated by the scheduler. If the lock is not acquired within Every `smarm::mutex` lock attempt is mediated by the scheduler. If the lock is not acquired within
a configurable timeout, the actor receives a `LockTimeout` error rather than parking forever. This a configurable timeout, the actor receives a `LockTimeout` error rather than parking forever. This
is a hard runtime guarantee, not a convention. Default timeout is global and configurable; is a hard runtime guarantee, not a convention. Default timeout is global and configurable;
individual locks and individual call sites can override it. individual locks and individual call sites can override it.
@@ -165,9 +165,9 @@ individual locks and individual call sites can override it.
Actors can spawn children and wait on a group of handles: Actors can spawn children and wait on a group of handles:
```rust ```rust
let h1 = loom::spawn(|| compute_a()); let h1 = smarm::spawn(|| compute_a());
let h2 = loom::spawn(|| compute_b()); let h2 = smarm::spawn(|| compute_b());
let (a, b) = loom::join!(h1, h2); let (a, b) = smarm::join!(h1, h2);
``` ```
`join!` parks the calling actor until all handles complete. The last child to finish re-queues the `join!` parks the calling actor until all handles complete. The last child to finish re-queues the
@@ -176,7 +176,7 @@ parent. This is a countdown in the parent's descriptor; no polling, no waker reg
### Timer wheel ### Timer wheel
`loom::sleep` and supervision timeouts are driven by a timer wheel in the scheduler. Sleeping `smarm::sleep` and supervision timeouts are driven by a timer wheel in the scheduler. Sleeping
actors are parked and re-queued by the timer thread on expiry. The timer wheel is internal actors are parked and re-queued by the timer thread on expiry. The timer wheel is internal
infrastructure; its design is an implementation detail. infrastructure; its design is an implementation detail.
@@ -189,22 +189,29 @@ infrastructure; its design is an implementation detail.
- **Queue contention** — if `Mutex<HashMap>` proves to be a bottleneck under profiling, evaluate - **Queue contention** — if `Mutex<HashMap>` proves to be a bottleneck under profiling, evaluate
`DashMap` or a lock-free work-stealing deque (e.g. `crossbeam-deque`). Not before. `DashMap` or a lock-free work-stealing deque (e.g. `crossbeam-deque`). Not before.
- **AVX-512 context save** — extend `ContextSaveArea` when there is a concrete use case. - **AVX-512 context save** — extend `ContextSaveArea` when there is a concrete use case.
- **`loom::sleep` vs raw sleep semantics** — further control knobs deferred until the basic sleep - **`smarm::sleep` vs raw sleep semantics** — further control knobs deferred until the basic sleep
is working and real use cases are understood. is working and real use cases are understood.
- **Supervision tree API** — the contract is defined; the recursive hierarchy, restart strategies, - **Supervision tree API** — the contract is defined; the recursive hierarchy, restart strategies,
and introspection API are implementation work. and introspection API are implementation work.
- **no_std support** — the assembly shim is no_std friendly but the IO thread and allocator require - **no_std support** — the assembly shim is no_std friendly but the IO thread and allocator require
OS primitives. Target is no_std + `alloc` on hosted platforms; bare metal is out of scope. OS primitives. Target is no_std + `alloc` on hosted platforms; bare metal is out of scope.
- **Distribution**Loom is a single-process runtime. No distribution protocol, no BEAM-style - **Distribution**SMARM is a single-process runtime. No distribution protocol, no BEAM-style
clustering. clustering.
--- ---
## What Loom is Not ## What SMARM is Not
- Not a drop-in replacement for Tokio. Loom does not implement `Future` or the async executor interface. - Not a drop-in replacement for Tokio. SMARM does not implement `Future` or the async executor interface.
- Not a general allocator. Loom manages actor stacks; heap allocation for actor data goes through - Not a general allocator. SMARM manages actor stacks; heap allocation for actor data goes through
the system allocator. the system allocator.
- Not Erlang. No hot code reloading, no distribution protocol, no BEAM bytecode. Loom is a - Not Erlang. No hot code reloading, no distribution protocol, no BEAM bytecode. SMARM is a
concurrency runtime, not a platform. concurrency runtime, not a platform.
- Not a real-time scheduler. Timeslice accuracy is best-effort. - Not a real-time scheduler. Timeslice accuracy is best-effort.
---
## On names
<sub>The name is a recursive acronym. The M is for Marks, as in the BEAM — Bogdan/Björn's Erlang Abstract Machine, the virtual machine that runs Erlang and Elixir. smarm is not the BEAM. It just admires it from a safe distance.</sub>
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
//! A deliberately small, deterministic smarm program for inspecting the
//! naked-asm context switch under a debugger (llmdbg).
//!
//! Design goals that make it debugger-friendly:
//! - Exactly one scheduler thread (`Config::exact(1)`) so the whole run is
//! single-OS-threaded — llmdbg is single-threaded-debuggee only today.
//! - A known, small number of voluntary yields, so the number of
//! `switch_to_actor` / `switch_to_scheduler` transitions is predictable.
//! - Frame pointers preserved (see the [profile] note in how we build it),
//! so llmdbg's `finish()` and frame-pointer assumptions hold.
//! - No I/O, no timers, no channels — just the scheduler and the context
//! switch, to keep the instruction stream centered on the asm we care
//! about.
//!
//! Two actors each yield `N_YIELDS` times, printing a counter. Run normally
//! it just prints an interleaving. Run under llmdbg with breakpoints on
//! `smarm::context::switch_to_actor_asm` and
//! `smarm::context::switch_to_scheduler` to watch the stack pointer hand off
//! between the scheduler stack and each actor's mmap'd stack.
use smarm::{init, Config};
const N_YIELDS: usize = 3;
fn main() {
// Single scheduler thread: one OS thread, cooperative only.
let rt = init(Config::exact(1));
rt.run(|| {
let a = smarm::spawn(|| {
for i in 0..N_YIELDS {
println!("actor A tick {i}");
smarm::yield_now();
}
println!("actor A done");
});
let b = smarm::spawn(|| {
for i in 0..N_YIELDS {
println!("actor B tick {i}");
smarm::yield_now();
}
println!("actor B done");
});
a.join().expect("A joined");
b.join().expect("B joined");
println!("root done");
});
}
+36 -2
View File
@@ -24,8 +24,22 @@ use std::panic;
pub enum Outcome { pub enum Outcome {
Exit, Exit,
Panic(Box<dyn Any + Send>), Panic(Box<dyn Any + Send>),
/// The actor was cooperatively cancelled via `request_stop`: a sentinel
/// panic unwound its stack (running Drop) and the trampoline recognised
/// the sentinel. Distinct from a user `Panic` — there is no payload to
/// propagate.
Stopped,
} }
/// The payload of the sentinel panic raised at an observation point when an
/// actor has been flagged for cooperative cancellation. Zero-sized; its only
/// job is to be recognisable in the trampoline's `catch_unwind` so the
/// teardown is reported as `Outcome::Stopped` rather than `Outcome::Panic`.
///
/// User code that wraps its own `catch_unwind` can swallow this (cf. Erlang's
/// `catch`); the stop flag stays set, so the next observation point re-raises.
pub(crate) struct StopSentinel;
// Thread-locals that the scheduler writes immediately before `switch_to_actor`. // Thread-locals that the scheduler writes immediately before `switch_to_actor`.
thread_local! { thread_local! {
/// The closure for the actor we're about to resume *for the first time*. /// The closure for the actor we're about to resume *for the first time*.
@@ -83,8 +97,14 @@ pub extern "C-unwind" fn trampoline() {
.expect("trampoline entered without a closure set"); .expect("trampoline entered without a closure set");
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) { let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
Ok(()) => Outcome::Exit, Ok(()) => Outcome::Exit,
Err(payload) => Outcome::Panic(payload), Err(payload) => {
if payload.is::<StopSentinel>() {
Outcome::Stopped
} else {
Outcome::Panic(payload)
}
}
}; };
LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome)); LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome));
@@ -107,4 +127,18 @@ pub struct Actor {
pub sp: usize, pub sp: usize,
/// The PID of this actor's supervisor. Used to deliver `Signal` on death. /// The PID of this actor's supervisor. Used to deliver `Signal` on death.
pub supervisor: Pid, pub supervisor: Pid,
/// Cooperative-cancellation flag. `request_stop` sets it (and unparks a
/// parked actor); the actor observes it lock-free at its next observation
/// point — see `preempt::check_cancelled`. Shared via `Arc` so the running
/// actor can poll it without taking the shared mutex. Lives on the `Actor`
/// (constructed fresh at spawn), so unlike a `Slot` field it needs no reset
/// in the slot-recycling paths.
pub stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Trap-exit inbox (roadmap #3). `None` until the actor calls
/// `link::trap_exit()`; `Some(tx)` means an abnormal death of a linked
/// peer is delivered here as an `ExitSignal` *message* instead of
/// cooperatively stopping this actor. Lives on the `Actor` (fresh per
/// spawn) for the same reason as `stop`: no slot-recycling reset needed,
/// and a restarted child correctly starts out *not* trapping.
pub trap: Option<crate::channel::Sender<crate::link::ExitSignal>>,
} }
+165
View File
@@ -0,0 +1,165 @@
//! Cooperative context switching and cycle counter, aarch64 (AAPCS64).
//!
//! The aarch64 mirror of the x86-64 backend. The protocol is identical — save
//! callee-saved state, swap stack pointers via the shared `*_SP` thread-locals,
//! restore, return — but the mechanics differ from x86 in two ways worth
//! stating up front, because they drive the stack layout:
//!
//! 1. **Return is via the link register, not the stack.** x86 `ret` pops the
//! return address off the stack; aarch64 `ret` jumps to whatever is in
//! `x30` (lr). So the entry point is parked in the saved-`x30` slot, and
//! the shim restores `x30` and `ret`s to it. There is no "ret target word"
//! sitting on the stack the way there is on x86.
//!
//! 2. **sp must be 16-byte aligned at all times**, not just at call entry.
//! We save 12 registers (x19x28, x29, x30) = 96 bytes, already a multiple
//! of 16, so the frame is aligned by construction.
//!
//! FP/SIMD registers (v8v15, whose low 64 bits are callee-saved under AAPCS64)
//! are NOT saved, for the same reason the x86 backend skips XMM: every yield
//! goes through a Rust call boundary, so the compiler has already spilled any
//! live vector state. If we ever yield from a non-call-boundary, this breaks.
use super::{get_actor_sp, get_scheduler_sp, set_actor_sp, set_scheduler_sp};
// ---------------------------------------------------------------------------
// Initial stack layout
//
// Twelve 8-byte slots, all zero except the x30 slot which holds `entry`. The
// first `switch_to_actor` loads x19x28/x29/x30 back and `ret`s to x30,
// landing at the top of `entry` with sp 16-aligned (the AAPCS64 requirement).
//
// Layout (high → low), relative to aligned_top = top & ~15:
//
// aligned_top - 8 : x30 = entry ← `ret` jumps here.
// aligned_top - 16 : x29 = 0 (fp)
// aligned_top - 24 : x28 = 0
// aligned_top - 32 : x27 = 0
// aligned_top - 40 : x26 = 0
// aligned_top - 48 : x25 = 0
// aligned_top - 56 : x24 = 0
// aligned_top - 64 : x23 = 0
// aligned_top - 72 : x22 = 0
// aligned_top - 80 : x21 = 0
// aligned_top - 88 : x20 = 0
// aligned_top - 96 : x19 = 0 ← initial sp (16-aligned)
//
// The restore order in the shim (ldp pairs, low→high) must match this exactly.
// ---------------------------------------------------------------------------
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
unsafe {
let aligned_top = top as usize & !15;
let mut sp = aligned_top;
sp -= 8; (sp as *mut usize).write(entry as usize); // x30 (lr) → entry
sp -= 8; (sp as *mut usize).write(0); // x29 (fp)
sp -= 8; (sp as *mut usize).write(0); // x28
sp -= 8; (sp as *mut usize).write(0); // x27
sp -= 8; (sp as *mut usize).write(0); // x26
sp -= 8; (sp as *mut usize).write(0); // x25
sp -= 8; (sp as *mut usize).write(0); // x24
sp -= 8; (sp as *mut usize).write(0); // x23
sp -= 8; (sp as *mut usize).write(0); // x22
sp -= 8; (sp as *mut usize).write(0); // x21
sp -= 8; (sp as *mut usize).write(0); // x20
sp -= 8; (sp as *mut usize).write(0); // x19 ← initial sp
sp
}
}
// ---------------------------------------------------------------------------
// Context switch shims
//
// Each shim:
// 1. Pushes x19x28, x29, x30 as six 16-byte stp pairs (sp pre-decrement).
// 2. Moves sp into x0 and calls the Rust helper that stores it.
// 3. Calls the Rust helper that returns the *other* side's saved sp.
// 4. Moves that into sp.
// 5. Restores the twelve registers and rets (to the restored x30).
//
// The helper calls clobber x30 themselves, but that's fine: the outgoing x30
// was already saved to the stack in step 1, and step 5 restores the incoming
// side's x30 before `ret`. The push/pop register order is paired so that the
// `ldp` sequence reverses the `stp` sequence exactly.
// ---------------------------------------------------------------------------
#[unsafe(naked)]
unsafe extern "C" fn switch_to_actor_asm() {
core::arch::naked_asm!(
"stp x19, x20, [sp, #-96]!",
"stp x21, x22, [sp, #16]",
"stp x23, x24, [sp, #32]",
"stp x25, x26, [sp, #48]",
"stp x27, x28, [sp, #64]",
"stp x29, x30, [sp, #80]",
"mov x0, sp",
"bl {set_sched_sp}",
"bl {get_actor_sp}",
"mov sp, x0",
"ldp x21, x22, [sp, #16]",
"ldp x23, x24, [sp, #32]",
"ldp x25, x26, [sp, #48]",
"ldp x27, x28, [sp, #64]",
"ldp x29, x30, [sp, #80]",
"ldp x19, x20, [sp], #96",
"ret",
set_sched_sp = sym set_scheduler_sp,
get_actor_sp = sym get_actor_sp,
);
}
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
pub unsafe fn switch_to_actor() {
unsafe { switch_to_actor_asm() };
}
#[unsafe(naked)]
pub unsafe extern "C" fn switch_to_scheduler() {
core::arch::naked_asm!(
"stp x19, x20, [sp, #-96]!",
"stp x21, x22, [sp, #16]",
"stp x23, x24, [sp, #32]",
"stp x25, x26, [sp, #48]",
"stp x27, x28, [sp, #64]",
"stp x29, x30, [sp, #80]",
"mov x0, sp",
"bl {set_actor_sp}",
"bl {get_sched_sp}",
"mov sp, x0",
"ldp x21, x22, [sp, #16]",
"ldp x23, x24, [sp, #32]",
"ldp x25, x26, [sp, #48]",
"ldp x27, x28, [sp, #64]",
"ldp x29, x30, [sp, #80]",
"ldp x19, x20, [sp], #96",
"ret",
set_actor_sp = sym set_actor_sp,
get_sched_sp = sym get_scheduler_sp,
);
}
// ---------------------------------------------------------------------------
// Cycle counter
//
// CNTVCT_EL0 is the virtual count register, readable from EL0 on Linux. `isb`
// serialises so we don't sample the counter before prior instructions retire
// (the aarch64 analogue of x86's `lfence` before rdtsc).
//
// Unit: CNTVCT ticks, whose frequency is CNTFRQ_EL0 — typically 150 MHz,
// NOT the CPU clock. This is a different and much coarser unit than the x86
// TSC, which is why `TIMESLICE_CYCLES` must be tuned separately for aarch64.
// ---------------------------------------------------------------------------
#[inline(always)]
pub fn read_cycle_counter() -> u64 {
let cnt: u64;
unsafe {
core::arch::asm!(
"isb",
"mrs {cnt}, cntvct_el0",
cnt = out(reg) cnt,
options(nostack, nomem),
);
}
cnt
}
+54
View File
@@ -0,0 +1,54 @@
//! Architecture abstraction layer.
//!
//! Everything that depends on the target ISA lives behind this module: the
//! cooperative context-switch shims, the initial-stack layout, and the
//! cycle counter used by the preemption timeslice. The rest of the runtime
//! talks only to the API re-exported here and never names a register.
//!
//! Each backend (`x86_64`, `aarch64`) exposes the same four items:
//!
//! - `init_actor_stack(top, entry) -> usize`
//! Build a fresh actor stack so the first `switch_to_actor` lands
//! inside `entry` with the ABI-correct stack alignment.
//! - `switch_to_actor()`
//! Resume the actor whose sp is in `ACTOR_SP`; returns when it yields.
//! - `switch_to_scheduler()`
//! Yield from the current actor back to its scheduler thread.
//! - `read_cycle_counter() -> u64`
//! Monotonic per-core cycle counter for the timeslice clock. The unit
//! is ISA-defined (x86 TSC ticks vs. aarch64 virtual-counter ticks),
//! so `TIMESLICE_CYCLES` must be tuned per-arch — see `preempt`.
//!
//! The `SCHEDULER_SP` / `ACTOR_SP` thread-locals are shared across backends
//! and live here, since the saved-stack-pointer protocol is identical
//! regardless of which registers the shim happens to push.
use std::cell::Cell;
thread_local! {
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
}
// Used by the naked shims (via `sym`) and by the scheduler/tests through the
// re-exports below. `pub` because the asm references them as symbols.
pub(crate) fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
pub(crate) fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
#[cfg(target_arch = "x86_64")]
mod x86_64;
#[cfg(target_arch = "x86_64")]
pub use x86_64::{init_actor_stack, read_cycle_counter, switch_to_actor, switch_to_scheduler};
#[cfg(target_arch = "aarch64")]
mod aarch64;
#[cfg(target_arch = "aarch64")]
pub use aarch64::{init_actor_stack, read_cycle_counter, switch_to_actor, switch_to_scheduler};
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
compile_error!(
"smarm's context-switch and cycle-counter layer supports only x86_64 and \
aarch64. Add a backend in src/arch/ for this target."
);
+118
View File
@@ -0,0 +1,118 @@
//! Cooperative context switching and cycle counter, x86-64 (SysV AMD64).
//!
//! Two naked-asm functions move execution between a scheduler thread and an
//! actor running on its own mmap'd stack. The compiler cannot do this; the
//! whole point of `#[unsafe(naked)]` is that we control every instruction.
//!
//! `init_actor_stack` builds the initial stack so that the first
//! `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
//! (the x86-64 ABI requirement at function entry).
use super::{get_actor_sp, get_scheduler_sp, set_actor_sp, set_scheduler_sp};
// ---------------------------------------------------------------------------
// Initial stack layout
//
// We start from aligned_top = top & ~15, then bias by 8 and push seven 8-byte
// slots (downward). The first `switch_to_actor` pops r15..rbx and `ret`s —
// landing in `entry` with rsp % 16 == 8 (the x86-64 ABI state at the point
// just after a `call`, which is what `entry` is compiled to expect).
//
// Layout (high → low), relative to aligned_top = top & ~15. Note the entry
// slot is at aligned_top - 16, NOT aligned_top - 8: the function does
// `(top & ~15) - 8` and *then* a `-= 8` before the first write, so the first
// stored word lands at aligned_top - 16. Verified by single-stepping the
// `ret` under llmdbg: entry sits at an address with %16 == 0, so the post-ret
// rsp is %16 == 8.
//
// aligned_top - 8 : (unused padding; keeps entry's slot %16 == 0)
// aligned_top - 16 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
// aligned_top - 24 : rbx = 0
// aligned_top - 32 : rbp = 0
// aligned_top - 40 : r12 = 0
// aligned_top - 48 : r13 = 0
// aligned_top - 56 : r14 = 0
// aligned_top - 64 : r15 = 0 ← initial rsp
// ---------------------------------------------------------------------------
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
unsafe {
let mut sp = (top as usize & !15) - 8;
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
sp -= 8; (sp as *mut usize).write(0); // rbx
sp -= 8; (sp as *mut usize).write(0); // rbp
sp -= 8; (sp as *mut usize).write(0); // r12
sp -= 8; (sp as *mut usize).write(0); // r13
sp -= 8; (sp as *mut usize).write(0); // r14
sp -= 8; (sp as *mut usize).write(0); // r15
sp
}
}
// ---------------------------------------------------------------------------
// Context switch shims
//
// Each shim:
// 1. Pushes the six callee-saved integer registers.
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
// 4. Moves that into rsp.
// 5. Pops the six registers and rets.
//
// XMM registers are NOT saved here. We rely on every yield happening through
// a Rust call site, which means the compiler has spilled any live XMM state
// to the stack before we get here. (This is the same argument the compiler
// uses internally — callee-saved regs are what survive a `call`, and the
// SysV AMD64 ABI says XMM015 are all caller-saved.) If we ever yield from
// a place that isn't a Rust call boundary, this assumption breaks.
// ---------------------------------------------------------------------------
#[unsafe(naked)]
unsafe extern "C" fn switch_to_actor_asm() {
core::arch::naked_asm!(
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
"mov rdi, rsp",
"call {set_sched_sp}",
"call {get_actor_sp}",
"mov rsp, rax",
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
"ret",
set_sched_sp = sym set_scheduler_sp,
get_actor_sp = sym get_actor_sp,
);
}
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
pub unsafe fn switch_to_actor() {
unsafe { switch_to_actor_asm() };
}
#[unsafe(naked)]
pub unsafe extern "C" fn switch_to_scheduler() {
core::arch::naked_asm!(
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
"mov rdi, rsp",
"call {set_actor_sp}",
"call {get_sched_sp}",
"mov rsp, rax",
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
"ret",
set_actor_sp = sym set_actor_sp,
get_sched_sp = sym get_scheduler_sp,
);
}
// ---------------------------------------------------------------------------
// Cycle counter
//
// `lfence` serialises the instruction stream so we don't measure time before
// prior instructions retire. Unit: TSC ticks (≈ CPU base clock).
// ---------------------------------------------------------------------------
#[inline(always)]
pub fn read_cycle_counter() -> u64 {
unsafe {
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
core::arch::x86_64::_rdtsc()
}
}
+68 -8
View File
@@ -69,7 +69,13 @@ impl<T> Drop for Sender<T> {
let unpark = { let unpark = {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
g.senders -= 1; g.senders -= 1;
if g.senders == 0 && g.queue.is_empty() { // Wake the parked receiver on the last sender drop regardless of
// whether the queue is empty. A plain `recv` only ever parks on an
// empty queue (so this is unchanged for it), but a selective
// `recv_match` may be parked on a *non-empty* queue holding only
// non-matching messages — it must wake to observe closure and
// return Err rather than sleep forever.
if g.senders == 0 {
g.parked_receiver.take() g.parked_receiver.take()
} else { } else {
None None
@@ -98,12 +104,10 @@ impl<T> Sender<T> {
g.parked_receiver.take() g.parked_receiver.take()
}; };
if let Some(pid) = unpark { if let Some(pid) = unpark {
let me = crate::actor::current_pid(); crate::te!(crate::trace::Event::Send { sender: crate::actor::current_pid().unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)), receiver: Some(pid) });
crate::te!(crate::trace::Event::Send { sender: me.unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)), receiver: Some(pid) });
crate::scheduler::unpark(pid); crate::scheduler::unpark(pid);
} else { } else {
let me = crate::actor::current_pid(); crate::te!(crate::trace::Event::Send { sender: crate::actor::current_pid().unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)), receiver: None });
crate::te!(crate::trace::Event::Send { sender: me.unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)), receiver: None });
} }
Ok(()) Ok(())
} }
@@ -132,12 +136,68 @@ impl<T> Receiver<T> {
// Release the lock before parking — the unparker will need it. // Release the lock before parking — the unparker will need it.
crate::scheduler::park_current(); crate::scheduler::park_current();
// Woken up — record it before looping to check the queue. // Woken up — record it before looping to check the queue.
if let Some(me) = crate::actor::current_pid() { crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
crate::te!(crate::trace::Event::RecvWake(me));
}
} }
} }
/// Selective receive: remove and return the first queued message for which
/// `pred` holds, leaving the rest in arrival order. If no queued message
/// matches, parks and re-scans on every send (a selective receiver may park
/// on a *non-empty* queue). Returns `Err(RecvError)` only once the channel
/// is closed and no queued message matches.
///
/// `pred` is run while the channel lock is held: keep it cheap and pure,
/// and do not call back into this channel from inside it. It is modelled as
/// `Fn` (not `FnMut`) deliberately — it is re-run from scratch on every
/// scan, so a stateful predicate would observe surprising re-counting.
pub fn recv_match<F>(&self, pred: F) -> Result<T, RecvError>
where
F: Fn(&T) -> bool,
{
loop {
{
let mut g = self.inner.lock().unwrap();
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
// position() found it, so remove() returns Some.
return Ok(g.queue.remove(i).unwrap());
}
if g.senders == 0 {
// Closed and nothing queued can ever match.
return Err(RecvError);
}
let me = crate::actor::current_pid()
.expect("recv_match() called outside an actor");
debug_assert!(
g.parked_receiver.is_none(),
"channel has more than one receiver"
);
g.parked_receiver = Some(me);
crate::te!(crate::trace::Event::RecvPark(me));
}
// Release the lock before parking — the unparker will need it.
crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
}
}
/// Non-blocking selective receive. `Ok(Some(v))` if a queued message
/// matched `pred` (removed, rest left in order), `Ok(None)` if the channel
/// is open but nothing matched, `Err(RecvError)` if closed and nothing
/// matched. Same predicate contract as [`recv_match`](Self::recv_match).
pub fn try_recv_match<F>(&self, pred: F) -> Result<Option<T>, RecvError>
where
F: Fn(&T) -> bool,
{
let mut g = self.inner.lock().unwrap();
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
return Ok(Some(g.queue.remove(i).unwrap()));
}
if g.senders == 0 {
return Err(RecvError);
}
Ok(None)
}
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if /// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
/// the channel is empty but open, `Err(RecvError)` if closed and drained. /// the channel is empty but open, `Err(RecvError)` if closed and drained.
pub fn try_recv(&self) -> Result<Option<T>, RecvError> { pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
+11 -103
View File
@@ -1,106 +1,14 @@
//! Cooperative context switching, x86-64. //! Cooperative context switching — public façade over the arch backend.
//! //!
//! Two naked-asm functions move execution between a scheduler thread and an //! The real implementation lives in `crate::arch`, selected per `target_arch`.
//! actor running on its own mmap'd stack. The compiler cannot do this; the //! This module re-exports the stable surface (`init_actor_stack`, the two
//! whole point of `#[unsafe(naked)]` is that we control every instruction. //! `switch_to_*` shims, and the `*_actor_sp` accessors) so the scheduler,
//! `preempt`, and the `tests/context.rs` integration tests keep importing
//! `smarm::context::{...}` unchanged regardless of which ISA is built.
//! //!
//! `SCHEDULER_SP` and `ACTOR_SP` are thread-locals holding each side's saved //! See `crate::arch` for the contract every backend implements, and
//! stack pointer. `init_actor_stack` builds the initial stack so that the //! `crate::arch::{x86_64, aarch64}` for the per-ISA register choreography.
//! first `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
//! (the x86-64 ABI requirement at function entry).
use std::cell::Cell; pub use crate::arch::{
get_actor_sp, init_actor_stack, set_actor_sp, switch_to_actor, switch_to_scheduler,
thread_local! { };
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
}
fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
// ---------------------------------------------------------------------------
// Initial stack layout
//
// After alignment, sp = top & ~15 - 8. Then we push (downward) six callee-
// saved register slots and a return address. The first `switch_to_actor`
// pops r15..rbx and `ret`s — landing in `entry` with rsp % 16 == 8.
//
// Layout (high → low), relative to aligned_top = top & ~15:
// aligned_top - 8 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
// aligned_top - 16 : rbx = 0
// aligned_top - 24 : rbp = 0
// aligned_top - 32 : r12 = 0
// aligned_top - 40 : r13 = 0
// aligned_top - 48 : r14 = 0
// aligned_top - 56 : r15 = 0 ← initial rsp
// ---------------------------------------------------------------------------
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
unsafe {
let mut sp = (top as usize & !15) - 8;
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
sp -= 8; (sp as *mut usize).write(0); // rbx
sp -= 8; (sp as *mut usize).write(0); // rbp
sp -= 8; (sp as *mut usize).write(0); // r12
sp -= 8; (sp as *mut usize).write(0); // r13
sp -= 8; (sp as *mut usize).write(0); // r14
sp -= 8; (sp as *mut usize).write(0); // r15
sp
}
}
// ---------------------------------------------------------------------------
// Context switch shims
//
// Each shim:
// 1. Pushes the six callee-saved integer registers.
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
// 4. Moves that into rsp.
// 5. Pops the six registers and rets.
//
// XMM registers are NOT saved here. We rely on every yield happening through
// a Rust call site, which means the compiler has spilled any live XMM state
// to the stack before we get here. (This is the same argument the compiler
// uses internally — callee-saved regs are what survive a `call`, and the
// SysV AMD64 ABI says XMM015 are all caller-saved.) If we ever yield from
// a place that isn't a Rust call boundary, this assumption breaks.
// ---------------------------------------------------------------------------
#[unsafe(naked)]
unsafe extern "C" fn switch_to_actor_asm() {
core::arch::naked_asm!(
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
"mov rdi, rsp",
"call {set_sched_sp}",
"call {get_actor_sp}",
"mov rsp, rax",
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
"ret",
set_sched_sp = sym set_scheduler_sp,
get_actor_sp = sym get_actor_sp,
);
}
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
pub unsafe fn switch_to_actor() {
unsafe { switch_to_actor_asm() };
}
#[unsafe(naked)]
pub unsafe extern "C" fn switch_to_scheduler() {
core::arch::naked_asm!(
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
"mov rdi, rsp",
"call {set_actor_sp}",
"call {get_sched_sp}",
"mov rsp, rax",
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
"ret",
set_actor_sp = sym set_actor_sp,
get_sched_sp = sym get_scheduler_sp,
);
}
+183
View File
@@ -0,0 +1,183 @@
//! gen_server — synchronous call / asynchronous cast over a single actor.
//!
//! A thin request-reply layer on top of [`channel`](crate::channel), modelled
//! on Erlang's `gen_server`. A *server* is an actor owning a state value that
//! implements [`GenServer`]; clients hold a clonable [`ServerRef`] and issue
//! [`call`](ServerRef::call) (synchronous, returns a reply) or
//! [`cast`](ServerRef::cast) (fire-and-forget).
//!
//! ## Why a single inbox
//!
//! smarm has no `select` and no unified per-process mailbox (see the runtime
//! gotchas in `task.md`), so a server cannot wait on a "call channel" and a
//! "cast channel" simultaneously. Instead every message — call or cast —
//! travels the *same* inbox channel as an [`Envelope`], and the server loop
//! dispatches by variant. A `call` carries a freshly made one-shot reply
//! channel; the server sends the reply straight back down it.
//!
//! ## Server death
//!
//! Detection falls out of channel closure, so no monitor is required:
//! - if the server is already gone, its inbox is closed and the `send` in
//! `call`/`cast` fails → [`CallError::ServerDown`] / [`CastError::ServerDown`];
//! - if the server dies *after* a call is enqueued but before it replies
//! (a handler panic, or a cooperative `request_stop`), the reply sender is
//! dropped as the server's stack unwinds, closing the reply channel; the
//! parked caller wakes and its `recv` returns `Err` → `ServerDown`.
//!
//! ## Lifecycle / callbacks
//!
//! [`GenServer::init`] runs once inside the server actor before the first
//! message; [`GenServer::terminate`] runs on the way out. terminate is wired
//! through a drop guard, so it fires on *every* exit path — graceful inbox
//! close, a handler panic, or a cooperative `request_stop` — not only the clean
//! one. Keep it cheap and non-blocking: it may run mid-unwind, and a panic
//! inside it during an unwind aborts the process (a double panic).
//!
//! ## Not here (yet)
//!
//! No `handle_info` and no call timeout. Both need machinery smarm currently
//! defers: a call timeout needs a per-`recv` deadline (cf. the deferred
//! `Signal::Timeout`), and `handle_info` needs the cross-channel mailbox merge
//! that selective receive deliberately left unmade. They are slated to land
//! together with timeouts.
use crate::channel::{channel, Receiver, Sender};
use crate::pid::Pid;
use crate::scheduler::{spawn, spawn_under};
/// Behaviour for a gen_server: a state value plus call/cast handlers.
///
/// `handle_call` and `handle_cast` are required; [`init`](Self::init) and
/// [`terminate`](Self::terminate) are optional lifecycle hooks with no-op
/// defaults.
pub trait GenServer: Send + 'static {
/// Synchronous request type (carried by [`ServerRef::call`]).
type Call: Send + 'static;
/// Reply type returned for a `Call`.
type Reply: Send + 'static;
/// Asynchronous request type (carried by [`ServerRef::cast`]).
type Cast: Send + 'static;
/// Runs once inside the server actor before any message is handled.
fn init(&mut self) {}
/// Handle a synchronous call and produce the reply sent back to the caller.
fn handle_call(&mut self, request: Self::Call) -> Self::Reply;
/// Handle a fire-and-forget cast.
fn handle_cast(&mut self, request: Self::Cast);
/// Runs as the server actor exits, on any exit path (see module docs).
fn terminate(&mut self) {}
}
/// What travels the server's single inbox channel: a synchronous call (with a
/// reply sender) or an asynchronous cast.
enum Envelope<G: GenServer> {
Call(G::Call, Sender<G::Reply>),
Cast(G::Cast),
}
/// A clonable handle to a running server. Cloning yields another sender to the
/// same inbox; the server lives until the last `ServerRef` is dropped, at which
/// point its inbox closes and the loop exits normally.
pub struct ServerRef<G: GenServer> {
tx: Sender<Envelope<G>>,
pid: Pid,
}
impl<G: GenServer> Clone for ServerRef<G> {
fn clone(&self) -> Self {
ServerRef { tx: self.tx.clone(), pid: self.pid }
}
}
/// Returned by [`ServerRef::call`] when the server is no longer reachable.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CallError {
/// The server was already gone, or died before replying.
ServerDown,
}
/// Returned by [`ServerRef::cast`] when the server is no longer reachable.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CastError {
/// The server inbox was closed (the server is gone).
ServerDown,
}
impl<G: GenServer> ServerRef<G> {
/// The server actor's pid — usable with `monitor`, `request_stop`, `link`.
pub fn pid(&self) -> Pid {
self.pid
}
/// Synchronous request-reply. Blocks (parking the calling actor) until the
/// server replies, or returns [`CallError::ServerDown`] if the server is or
/// becomes unreachable before a reply arrives.
pub fn call(&self, request: G::Call) -> Result<G::Reply, CallError> {
let (reply_tx, reply_rx) = channel::<G::Reply>();
self.tx
.send(Envelope::Call(request, reply_tx))
.map_err(|_| CallError::ServerDown)?;
reply_rx.recv().map_err(|_| CallError::ServerDown)
}
/// Fire-and-forget request. Returns once the message is enqueued; does not
/// wait for the server to handle it. [`CastError::ServerDown`] if the inbox
/// is already closed.
pub fn cast(&self, request: G::Cast) -> Result<(), CastError> {
self.tx
.send(Envelope::Cast(request))
.map_err(|_| CastError::ServerDown)
}
}
/// Spawn `state` as a server under the current actor (via [`spawn`]). Returns a
/// [`ServerRef`]; the server's lifetime is governed by its refs, not by
/// joining, so the backing join handle is dropped.
pub fn start<G: GenServer>(state: G) -> ServerRef<G> {
let (tx, rx) = channel::<Envelope<G>>();
let handle = spawn(move || server_loop::<G>(rx, state));
ServerRef { tx, pid: handle.pid() }
}
/// Like [`start`], but spawns the server under an explicit supervisor pid (via
/// [`spawn_under`]) so it slots into the supervision tree.
pub fn start_under<G: GenServer>(supervisor: Pid, state: G) -> ServerRef<G> {
let (tx, rx) = channel::<Envelope<G>>();
let handle = spawn_under(supervisor, move || server_loop::<G>(rx, state));
ServerRef { tx, pid: handle.pid() }
}
fn server_loop<G: GenServer>(rx: Receiver<Envelope<G>>, state: G) {
// terminate() must run on every exit path (clean close, panic, stop), so it
// lives in this guard's Drop rather than after the loop.
struct Terminate<G: GenServer>(G);
impl<G: GenServer> Drop for Terminate<G> {
fn drop(&mut self) {
self.0.terminate();
}
}
let mut guard = Terminate(state);
guard.0.init();
loop {
match rx.recv() {
Ok(Envelope::Call(request, reply_tx)) => {
let reply = guard.0.handle_call(request);
// The caller may have gone away (e.g. cancelled while parked);
// a failed reply send is not the server's problem.
let _ = reply_tx.send(reply);
}
Ok(Envelope::Cast(request)) => guard.0.handle_cast(request),
// All ServerRefs dropped → inbox closed → graceful shutdown.
Err(_) => break,
}
// Observation point so a server whose inbox is never empty stays
// preemptible and cancellable.
crate::check!();
}
}
+10 -3
View File
@@ -11,6 +11,7 @@
//! //!
//! See `LOOM.md` for the design intent and the deferred-for-later list. //! See `LOOM.md` for the design intent and the deferred-for-later list.
pub mod arch;
pub mod stack; pub mod stack;
pub mod context; pub mod context;
pub mod preempt; pub mod preempt;
@@ -22,6 +23,9 @@ pub mod supervisor;
pub mod timer; pub mod timer;
pub mod io; pub mod io;
pub mod mutex; pub mod mutex;
pub mod monitor;
pub mod link;
pub mod gen_server;
pub mod runtime; pub mod runtime;
pub mod trace; pub mod trace;
@@ -37,14 +41,17 @@ static ALLOCATOR: preempt::PreemptingAllocator = preempt::PreemptingAllocator;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub use channel::{channel, Receiver, RecvError, Sender}; pub use channel::{channel, Receiver, RecvError, Sender};
pub use gen_server::{CallError, CastError, GenServer, ServerRef};
pub use link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
pub use mutex::{LockTimeout, Mutex, MutexGuard}; pub use mutex::{LockTimeout, Mutex, MutexGuard};
pub use pid::Pid; pub use pid::Pid;
pub use runtime::{init, Config, Runtime}; pub use runtime::{init, Config, Runtime};
pub use scheduler::{ pub use scheduler::{
block_on_io, run, self_pid, sleep, spawn, spawn_under, wait_readable, wait_writable, block_on_io, request_stop, run, self_pid, sleep, spawn, spawn_under, wait_readable,
yield_now, JoinError, JoinHandle, wait_writable, yield_now, JoinError, JoinHandle,
}; };
pub use supervisor::Signal; pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// check!() // check!()
+164
View File
@@ -0,0 +1,164 @@
//! Process links + `trap_exit`.
//!
//! A *link* is bidirectional and persistent, in contrast to a [`monitor`]
//! (unidirectional, one-shot): once two actors are linked, the *abnormal*
//! death of either propagates to the other. "Abnormal" means a panic or a
//! cooperative [`request_stop`] — a normal return never propagates.
//!
//! What propagation *does* depends on whether the surviving peer traps exits:
//!
//! - **not trapping** (the default): the peer is cooperatively
//! [`request_stop`]'d, so a crash fate-shares across the whole link set —
//! Erlang's "let it crash". Because the peer's own links are walked when
//! *it* finalizes, the stop cascades transitively.
//! - **trapping** (called [`trap_exit`]): the peer instead receives an
//! [`ExitSignal`] *message* on its trap inbox and keeps running, turning
//! peer failures into ordinary inbox events (Erlang's
//! `process_flag(trap_exit, true)`).
//!
//! Linking an already-dead pid is not a silent no-op: it behaves exactly like
//! an immediate abnormal death of the target with reason [`DownReason::NoProc`]
//! — a trapping caller gets a message, a non-trapping caller is stopped.
//!
//! ## Payload
//!
//! [`ExitSignal`] reuses [`DownReason`] and, like a monitor, does **not**
//! carry the panic payload: a panic's payload has a single owner and is
//! delivered to whoever `join()`s the actor. A trap inbox only learns *that*
//! (and *how*) a peer died.
//!
//! ## Inbox
//!
//! The trap inbox is a dedicated channel, distinct from the monitor [`Down`]
//! channel: monitors are documented as one-shot/unidirectional, whereas a trap
//! inbox receives one [`ExitSignal`] per linked peer death over the actor's
//! lifetime. [`trap_exit`] enables trapping and hands back the inbox in one
//! call; calling it again installs a fresh inbox (the previous one closes).
//!
//! ## Races
//!
//! [`link`] registration and `finalize_actor` (in `runtime`) both serialize on
//! the shared mutex, so a target that is live when the link is registered is
//! guaranteed to propagate its eventual death; there is no window in which the
//! death slips between the liveness check and the registration. As elsewhere,
//! we never `send` or `request_stop` while holding the shared lock — those
//! re-enter the runtime — so the act is always performed after the lock is
//! released.
//!
//! [`monitor`]: crate::monitor::monitor
//! [`Down`]: crate::monitor::Down
//! [`request_stop`]: crate::scheduler::request_stop
use crate::channel::{channel, Receiver, Sender};
use crate::monitor::DownReason;
use crate::pid::Pid;
use crate::runtime::State;
use crate::scheduler::{request_stop, self_pid, with_runtime};
/// A linked peer's death, delivered to a trapping actor's inbox.
///
/// `Copy` because it carries no payload — see the module docs for why the
/// panic payload is *not* included.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExitSignal {
/// The linked pid that died (or the dead pid that was linked).
pub from: Pid,
/// How it went down. Only ever `Panic`, `Stopped`, or `NoProc` — a normal
/// `Exit` does not propagate, so it never appears here.
pub reason: DownReason,
}
/// Trap exits on the current actor and return its exit inbox.
///
/// After this call, the abnormal death of a linked peer arrives here as an
/// [`ExitSignal`] message instead of cooperatively stopping this actor.
/// Calling it again installs a fresh inbox; the previously returned receiver
/// then closes.
pub fn trap_exit() -> Receiver<ExitSignal> {
let (tx, rx) = channel::<ExitSignal>();
let me = self_pid();
with_runtime(|inner| {
inner.with_shared(|s| {
if let Some(actor) = s.slot_mut(me).and_then(|slot| slot.actor.as_mut()) {
actor.trap = Some(tx);
}
})
});
rx
}
/// Bidirectionally link the current actor to `target`.
///
/// If `target` is live, the link is recorded on both actors and either's
/// abnormal death will propagate to the other. If `target` is already gone,
/// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller
/// (a message if trapping, otherwise a cooperative stop). Linking yourself, or
/// re-linking an existing peer, is a no-op.
pub fn link(target: Pid) {
let me = self_pid();
if target == me {
return;
}
// Under the lock: if the target is live, record the link both ways and
// return `None`. If it is gone, return the caller's trap sender (if any)
// so we can deliver the NoProc signal after releasing the lock.
let dead_action: Option<Option<Sender<ExitSignal>>> = with_runtime(|inner| {
inner.with_shared(|s| {
let target_live = matches!(
s.slot(target),
Some(slot) if slot.actor.is_some() && !matches!(slot.state, State::Done)
);
if target_live {
if let Some(slot) = s.slot_mut(me) {
if !slot.links.contains(&target) {
slot.links.push(target);
}
}
if let Some(slot) = s.slot_mut(target) {
if !slot.links.contains(&me) {
slot.links.push(me);
}
}
None
} else {
// Grab our own trap sender so the NoProc delivery (below)
// doesn't need a second lock acquisition.
Some(
s.slot(me)
.and_then(|slot| slot.actor.as_ref())
.and_then(|a| a.trap.clone()),
)
}
})
});
match dead_action {
None => {} // linked successfully
Some(Some(tx)) => {
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc });
}
Some(None) => request_stop(me),
}
}
/// Remove the link between the current actor and `target`, in both directions.
///
/// After this, neither actor's death propagates to the other. A no-op if the
/// two were not linked.
pub fn unlink(target: Pid) {
let me = self_pid();
if target == me {
return;
}
with_runtime(|inner| {
inner.with_shared(|s| {
if let Some(slot) = s.slot_mut(me) {
slot.links.retain(|p| *p != target);
}
if let Some(slot) = s.slot_mut(target) {
slot.links.retain(|p| *p != me);
}
})
});
}
+163
View File
@@ -0,0 +1,163 @@
//! Process monitors.
//!
//! `monitor(target)` asks the runtime to deliver a single [`Down`] when
//! `target` terminates, and hands back a [`Monitor`] — the [`Receiver`] to read
//! it from, plus the identity (`id`, `target`) needed to take the registration
//! back down with [`demonitor`]. A monitor is:
//!
//! - **unidirectional** — the watcher learns of the target's death, but the
//! target learns nothing of the watcher, and the watcher is unaffected by
//! the death beyond the notification (contrast a *link*, which propagates
//! failure);
//! - **one-shot** — exactly one `Down` is ever sent for a given monitor.
//! The returned channel closes afterwards, so a second `recv()` yields
//! `Err(RecvError)`.
//!
//! This generalizes the older single-`supervisor_channel` mechanism: a
//! supervisor is just a hard-wired monitor that the parent installs at spawn
//! time. Here any actor may monitor any pid, any number of times.
//!
//! ## Reasons
//!
//! [`DownReason`] is deliberately payload-free. A panicking actor's payload
//! has a single owner and is delivered to whoever `join()`s the actor (as
//! `JoinError`); a monitor only learns *that* it panicked, not the value.
//! Monitoring a pid that is already gone (reclaimed, or never alive) yields
//! [`DownReason::NoProc`] immediately, mirroring Erlang's `noproc`.
//!
//! ## Demonitoring
//!
//! Each `monitor()` registration is tagged with a process-unique [`MonitorId`].
//! [`demonitor`] removes the registration named by a [`Monitor`] from its
//! target's slot, returning `Some(id)` if a live registration was found or
//! `None` if it had already fired (or the target is gone). Dropping the
//! [`Monitor`] afterwards discards any `Down` that the target had *already*
//! queued — the equivalent of Erlang's `demonitor(Ref, [flush])`.
//!
//! ## Races
//!
//! Registration (below) and `finalize_actor` (in `runtime`) both run under the
//! shared-state mutex, so a target that is still alive when its monitor is
//! registered is guaranteed to deliver a real `Down`; there is no window in
//! which the death slips between the liveness check and the registration.
//! `demonitor` is protected by the generation half of the pid: if the target
//! has died and its slot index been recycled, `slot_mut(target)` fails the
//! generation check and `demonitor` is a clean no-op — it can never strip a
//! *different* actor's monitor that happens to share the slot index.
use crate::channel::{channel, Receiver, Sender};
use crate::pid::Pid;
use crate::runtime::State;
use crate::scheduler::with_runtime;
/// Why a monitored actor went down.
///
/// `Copy` because it carries no payload — see the module docs for why the
/// panic payload is *not* included here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownReason {
/// The target returned normally.
Exit,
/// The target panicked. The payload is delivered to the actor's joiner,
/// not to monitors.
Panic,
/// The target was cooperatively cancelled via `request_stop`.
Stopped,
/// The target was already gone (finished and reclaimed, or never alive)
/// at the moment `monitor()` was called.
NoProc,
}
/// A monitored actor's termination notice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Down {
/// The pid that was being monitored.
pub pid: Pid,
/// How it went down.
pub reason: DownReason,
}
/// A process-unique identifier for one `monitor()` registration.
///
/// Opaque and `Copy`. Allocated from a monotonic counter in shared state, so
/// it is never reused for the lifetime of the runtime — distinct `monitor()`
/// calls on the same target get distinct ids, which is what lets [`demonitor`]
/// tear down exactly one of several monitors on a target.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MonitorId(pub(crate) u64);
/// A live monitor: the receiving end of the one-shot [`Down`] channel, plus the
/// identity needed to [`demonitor`] it.
///
/// Read the notification from [`Monitor::rx`]. Not `Clone` (the receiver is a
/// single consumer). Dropping it closes the receiving end; if a `Down` was
/// already queued it is discarded with the channel.
pub struct Monitor {
/// This registration's process-unique id.
pub id: MonitorId,
/// The pid being monitored.
pub target: Pid,
/// The one-shot channel the `Down` arrives on.
pub rx: Receiver<Down>,
}
/// Monitor `target`. Returns a [`Monitor`] whose `rx` receives exactly one
/// [`Down`].
///
/// If `target` is still live, the `Down` arrives when it terminates. If
/// `target` is already gone, a [`DownReason::NoProc`] `Down` is queued
/// immediately so the caller's `rx.recv()` returns without parking.
pub fn monitor(target: Pid) -> Monitor {
let (tx, rx) = channel::<Down>();
// Register under the shared lock. We allocate the id and (if the target is
// live) clone the sender into its monitor list, keeping the original `tx`
// for the NoProc fallback. `tx.clone()` only touches the channel's own
// mutex, never the shared runtime mutex, so it is safe under the lock — but
// we must not *send* here, as `Sender::send` can call back in to unpark a
// parked receiver and the shared mutex is not reentrant.
let (id, registered) = with_runtime(|inner| {
inner.with_shared(|s| {
let id = s.alloc_monitor_id();
let registered = match s.slot_mut(target) {
Some(slot) if !matches!(slot.state, State::Done) => {
slot.monitors.push((id, tx.clone()));
true
}
_ => false,
};
(id, registered)
})
});
if !registered {
let _ = tx.send(Down { pid: target, reason: DownReason::NoProc });
}
Monitor { id, target, rx }
}
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
/// on the target's slot and removed, or `None` if there was nothing to remove
/// — the target already fired its `Down` (the registration is drained on
/// finalize), was never alive (`NoProc`), or has been reclaimed.
///
/// This stops any *future* `Down`. To also discard a `Down` the target may have
/// *already* queued (the finalize-races-demonitor case), drop `m` afterwards;
/// dropping the [`Monitor`] closes its receiver and the queued notice goes with
/// it — the analogue of Erlang's `demonitor(Ref, [flush])`.
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
// Remove the registration under the lock, but move the `Sender` *out* and
// let it drop only after the lock is released: dropping the last sender
// runs `Sender::drop`, which may unpark a parked receiver → `with_shared`,
// and the shared mutex is not reentrant.
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
inner.with_shared(|s| {
let slot = s.slot_mut(m.target)?;
let pos = slot.monitors.iter().position(|(mid, _)| *mid == m.id)?;
Some(slot.monitors.remove(pos))
})
});
// `removed`'s sender drops here, outside the lock.
removed.map(|(id, _sender)| id)
}
+92 -12
View File
@@ -27,35 +27,111 @@
use std::alloc::{GlobalAlloc, Layout, System}; use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell; use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
const ALLOC_INTERVAL: u32 = 128; pub const DEFAULT_ALLOC_INTERVAL: u32 = 128;
const TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
// The timeslice is measured in `rdtsc()` ticks, and the tick *unit* differs by
// ISA (see `crate::arch::read_cycle_counter`). Both constants target ~100µs.
// x86-64 : TSC ≈ CPU base clock; 300_000 ticks ≈ 100µs on a 3 GHz core.
// aarch64: CNTVCT runs at CNTFRQ_EL0, commonly ~24 MHz; 2_400 ticks ≈ 100µs.
// A real implementation would read the frequency at startup and compute this;
// for now the constant is calibrated per-arch. Tune on-device if needed.
#[cfg(target_arch = "x86_64")]
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
#[cfg(target_arch = "aarch64")]
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 2_400; // ≈ 100µs at a 24 MHz CNTVCT
thread_local! { thread_local! {
/// While `false`, the allocator hook is a no-op. /// While `false`, the allocator hook is a no-op.
pub static PREEMPTION_ENABLED: Cell<bool> = const { Cell::new(false) }; pub static PREEMPTION_ENABLED: Cell<bool> = const { Cell::new(false) };
/// Countdown to next RDTSC check. Reset to `ALLOC_INTERVAL` on resume. /// Countdown to next RDTSC check. Reset to `ALLOC_INTERVAL` on resume.
static ALLOC_COUNT: Cell<u32> = const { Cell::new(ALLOC_INTERVAL) }; static ALLOC_COUNT: Cell<u32> = const { Cell::new(DEFAULT_ALLOC_INTERVAL) };
/// RDTSC value written by the scheduler on every actor resume. /// RDTSC value written by the scheduler on every actor resume.
static TIMESLICE_START: Cell<u64> = const { Cell::new(0) }; static TIMESLICE_START: Cell<u64> = const { Cell::new(0) };
/// Per-thread copy of the configured alloc interval, written once at
/// scheduler-thread startup. Kept in a thread-local so the hot path
/// (`maybe_preempt`) pays only a TLS load, with no cache-coherency traffic.
static CONFIGURED_ALLOC_INTERVAL: Cell<u32> = const { Cell::new(DEFAULT_ALLOC_INTERVAL) };
/// Per-thread copy of the configured timeslice, written once at
/// scheduler-thread startup.
static CONFIGURED_TIMESLICE_CYCLES: Cell<u64> = const { Cell::new(DEFAULT_TIMESLICE_CYCLES) };
/// Raw pointer to the on-CPU actor's cancellation flag, set by the
/// scheduler on resume and reset to null when control returns to it.
/// Null while no actor is running, so observation points are inert on the
/// scheduler's own stack. A raw pointer (not a cloned `Arc`) keeps the
/// resume path free of atomic ref-count traffic; see `check_cancelled` for
/// the safety argument.
static CURRENT_STOP: Cell<*const AtomicBool> = const { Cell::new(std::ptr::null()) };
}
// ---------------------------------------------------------------------------
// Cooperative-cancellation observation
// ---------------------------------------------------------------------------
/// Bind the on-CPU actor's stop flag. Called by the scheduler immediately
/// before `switch_to_actor`. `flag` must point into the resuming actor's
/// `Arc<AtomicBool>` heap box.
pub(crate) fn set_current_stop(flag: *const AtomicBool) {
CURRENT_STOP.with(|c| c.set(flag));
}
/// Unbind the stop flag. Called by the scheduler on the return path from an
/// actor, so the pointer never leaks into the scheduler loop or the next actor.
pub(crate) fn clear_current_stop() {
CURRENT_STOP.with(|c| c.set(std::ptr::null()));
}
/// Observation point for cooperative cancellation. If the on-CPU actor has
/// been flagged for stop, raise the sentinel panic so the trampoline's
/// `catch_unwind` tears the stack down (running Drop) and reports
/// `Outcome::Stopped`, distinct from a user `Panic`. A no-op (one TLS load,
/// one relaxed atomic load) on the common path.
///
/// Called from `maybe_preempt` (amortised, the `check!()`/alloc path) and from
/// the wakeup side of every blocking park (`park_current`/`yield_now`), which
/// is past the prep-to-park window — so it can never lose a wakeup.
#[inline]
pub fn check_cancelled() {
let p = CURRENT_STOP.with(|c| c.get());
// SAFETY: `p` is either null (no actor on-CPU — the scheduler clears it on
// every return) or a pointer into the on-CPU actor's `Arc<AtomicBool>`
// heap box. That box outlives the run: an actor's slot is not finalized or
// reclaimed while the actor is on-CPU (finalize runs only after it yields
// back), and the Arc keeps the box alive at a fixed address even if the
// slot table Vec reallocates underneath it.
if !p.is_null() && unsafe { (*p).load(Ordering::Relaxed) } {
std::panic::panic_any(crate::actor::StopSentinel);
}
}
/// Called once per scheduler thread at startup (before any actor runs).
/// Writes the runtime-configured preemption knobs into thread-locals so the
/// hot path reads them without any cross-thread coherency cost.
pub fn configure_preempt(alloc_interval: u32, timeslice_cycles: u64) {
CONFIGURED_ALLOC_INTERVAL.with(|c| c.set(alloc_interval));
CONFIGURED_TIMESLICE_CYCLES.with(|c| c.set(timeslice_cycles));
// Also prime the countdown so the first resume uses the right interval.
ALLOC_COUNT.with(|c| c.set(alloc_interval));
} }
/// Arm the timeslice. Called by the scheduler on every resume. /// Arm the timeslice. Called by the scheduler on every resume.
pub fn reset_timeslice() { pub fn reset_timeslice() {
ALLOC_COUNT.with(|c| c.set(ALLOC_INTERVAL)); ALLOC_COUNT.with(|c| c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get())));
TIMESLICE_START.with(|c| c.set(rdtsc())); TIMESLICE_START.with(|c| c.set(rdtsc()));
} }
/// Per-core cycle counter for the timeslice clock. Delegates to the active
/// arch backend (`rdtsc` on x86-64, `CNTVCT_EL0` on aarch64). The tick unit
/// is ISA-defined, so `DEFAULT_TIMESLICE_CYCLES` is calibrated per-arch below.
#[inline(always)] #[inline(always)]
pub fn rdtsc() -> u64 { pub fn rdtsc() -> u64 {
unsafe { crate::arch::read_cycle_counter()
// SAFETY: x86-64 only. `lfence` serialises the instruction stream so
// we don't measure time before prior instructions retire.
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
core::arch::x86_64::_rdtsc()
}
} }
pub struct PreemptingAllocator; pub struct PreemptingAllocator;
@@ -102,10 +178,14 @@ pub fn maybe_preempt() {
ALLOC_COUNT.with(|c| { ALLOC_COUNT.with(|c| {
let n = c.get(); let n = c.get();
if n == 0 { if n == 0 {
c.set(ALLOC_INTERVAL); c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get()));
// Cooperative cancellation shares the amortised cadence with the
// timeslice check. Observe a pending stop first: if we are being
// cancelled there is no point yielding, we unwind instead.
check_cancelled();
if PREEMPTION_ENABLED.with(|e| e.get()) { if PREEMPTION_ENABLED.with(|e| e.get()) {
let start = TIMESLICE_START.with(|s| s.get()); let start = TIMESLICE_START.with(|s| s.get());
if rdtsc().saturating_sub(start) > TIMESLICE_CYCLES { if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
// SAFETY: reachable only inside an actor (the scheduler // SAFETY: reachable only inside an actor (the scheduler
// sets PREEMPTION_ENABLED on resume and clears it on // sets PREEMPTION_ENABLED on resume and clears it on
// return). The scheduler stack is therefore valid. // return). The scheduler stack is therefore valid.
+294 -84
View File
@@ -31,19 +31,20 @@
//! becomes a measured bottleneck. //! becomes a measured bottleneck.
use crate::actor::{ use crate::actor::{
clear_current_pid, current_pid, is_actor_done, reset_actor_done, clear_current_pid, is_actor_done, reset_actor_done, set_current_actor_box,
set_current_actor_box, set_current_pid, take_last_outcome, Actor, Outcome, set_current_pid, take_last_outcome, Actor, Outcome,
}; };
use crate::channel::Sender; use crate::channel::Sender;
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor}; use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
use crate::io::IoThread; use crate::io::IoThread;
use crate::monitor::{Down, DownReason, MonitorId};
use crate::pid::Pid; use crate::pid::Pid;
use crate::preempt::PREEMPTION_ENABLED; use crate::preempt::PREEMPTION_ENABLED;
use crate::supervisor::Signal; use crate::supervisor::Signal;
use crate::timer::Timers; use crate::timer::Timers;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@@ -70,13 +71,21 @@ pub struct Config {
min: usize, min: usize,
max: usize, max: usize,
exact: Option<usize>, exact: Option<usize>,
alloc_interval: u32,
timeslice_cycles: u64,
stack_pool_cap: usize,
} }
impl Config { impl Config {
/// Exact thread count; takes precedence over min/max. /// Exact thread count; takes precedence over min/max.
pub fn exact(n: usize) -> Self { pub fn exact(n: usize) -> Self {
assert!(n >= 1, "scheduler thread count must be ≥ 1"); assert!(n >= 1, "scheduler thread count must be ≥ 1");
Self { min: n, max: n, exact: Some(n) } Self {
min: n, max: n, exact: Some(n),
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: n * 4,
}
} }
/// Bounded range. Thread count = clamp(available_parallelism, min, max). /// Bounded range. Thread count = clamp(available_parallelism, min, max).
@@ -86,7 +95,37 @@ impl Config {
if let Some(e) = exact { if let Some(e) = exact {
assert!(e >= 1, "exact must be ≥ 1"); assert!(e >= 1, "exact must be ≥ 1");
} }
Self { min, max, exact } Self {
min, max, exact,
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: max * 4,
}
}
/// How many allocations (or `smarm::check!()` calls) between RDTSC checks.
/// Lower = more responsive preemption, higher = less overhead.
/// Default: 128.
pub fn alloc_interval(mut self, n: u32) -> Self {
assert!(n >= 1, "alloc_interval must be ≥ 1");
self.alloc_interval = n;
self
}
/// How many TSC cycles constitute one timeslice.
/// Default: 300_000 (≈ 100µs on a 3 GHz CPU).
pub fn timeslice_cycles(mut self, n: u64) -> Self {
assert!(n >= 1, "timeslice_cycles must be ≥ 1");
self.timeslice_cycles = n;
self
}
/// Maximum number of stacks kept in the pool for reuse across spawns.
/// A larger cap reduces `mmap`/`munmap` syscalls at the cost of idle memory.
/// Default: `thread_count * 4`.
pub fn stack_pool_cap(mut self, n: usize) -> Self {
self.stack_pool_cap = n;
self
} }
/// The number of scheduler threads this config resolves to. /// The number of scheduler threads this config resolves to.
@@ -106,7 +145,12 @@ impl Default for Config {
let avail = thread::available_parallelism() let avail = thread::available_parallelism()
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(1); .unwrap_or(1);
Self { min: 1, max: avail, exact: None } Self {
min: 1, max: avail, exact: None,
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: avail * 4,
}
} }
} }
@@ -180,6 +224,16 @@ pub(crate) struct Slot {
pub(crate) waiters: Vec<Pid>, pub(crate) waiters: Vec<Pid>,
pub(crate) outcome: Option<Outcome>, pub(crate) outcome: Option<Outcome>,
pub(crate) supervisor_channel: Option<Sender<Signal>>, pub(crate) supervisor_channel: Option<Sender<Signal>>,
/// Watchers registered via `monitor()`, each tagged with its
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
/// `Down` when this actor terminates (drained in `finalize_actor`).
/// Distinct from `supervisor_channel`, which is the parent's single funnel.
pub(crate) monitors: Vec<(MonitorId, Sender<Down>)>,
/// Bidirectional links (roadmap #3). Each entry is a peer whose abnormal
/// death propagates to this actor (and vice versa — the relationship is
/// recorded on both slots). Walked + cleared in `finalize_actor`. Reset in
/// all three slot-lifecycle sites, like `monitors`.
pub(crate) links: Vec<Pid>,
pub(crate) outstanding_handles: u32, pub(crate) outstanding_handles: u32,
pub(crate) pending_io_result: Option<crate::io::IoResult>, pub(crate) pending_io_result: Option<crate::io::IoResult>,
/// Set by `unpark()` when the actor is still running (not yet Parked). /// Set by `unpark()` when the actor is still running (not yet Parked).
@@ -197,6 +251,8 @@ impl Slot {
waiters: Vec::new(), waiters: Vec::new(),
outcome: None, outcome: None,
supervisor_channel: None, supervisor_channel: None,
monitors: Vec::new(),
links: Vec::new(),
outstanding_handles: 0, outstanding_handles: 0,
pending_io_result: None, pending_io_result: None,
pending_unpark: false, pending_unpark: false,
@@ -213,8 +269,14 @@ pub(crate) struct SharedState {
pub(crate) root_pid: Option<Pid>, pub(crate) root_pid: Option<Pid>,
pub(crate) timers: Timers, pub(crate) timers: Timers,
pub(crate) io: Option<IoThread>, pub(crate) io: Option<IoThread>,
/// Closures awaiting their first resume, keyed by Pid. /// Closures awaiting their first resume, indexed by slot index.
pub(crate) pending_closures: Vec<(Pid, Closure)>, /// `pending_closures[idx]` is `Some` iff the actor at slot `idx` has not
/// yet been resumed for the first time. Grows on demand; never shrinks.
pub(crate) pending_closures: Vec<Option<Closure>>,
/// Monotonic source of `MonitorId`s, bumped under the shared lock by
/// `alloc_monitor_id`. Never reused, so `demonitor` can name one of several
/// monitors on the same target unambiguously.
pub(crate) next_monitor_id: u64,
} }
impl SharedState { impl SharedState {
@@ -226,10 +288,18 @@ impl SharedState {
root_pid: None, root_pid: None,
timers: Timers::new(), timers: Timers::new(),
io: None, io: None,
pending_closures: Vec::new(), pending_closures: Vec::new(), // indexed by slot index
next_monitor_id: 0,
} }
} }
/// Allocate the next process-unique `MonitorId`. Caller holds the shared
/// lock (this is only reached from `monitor()` under `with_shared`).
pub(crate) fn alloc_monitor_id(&mut self) -> MonitorId {
self.next_monitor_id += 1;
MonitorId(self.next_monitor_id)
}
pub(crate) fn allocate_slot(&mut self) -> (u32, u32) { pub(crate) fn allocate_slot(&mut self) -> (u32, u32) {
if let Some(idx) = self.free_list.pop() { if let Some(idx) = self.free_list.pop() {
let gen = self.slots[idx as usize].generation; let gen = self.slots[idx as usize].generation;
@@ -252,8 +322,9 @@ impl SharedState {
} }
pub(crate) fn pop_pending_closure(&mut self, pid: Pid) -> Option<Closure> { pub(crate) fn pop_pending_closure(&mut self, pid: Pid) -> Option<Closure> {
let pos = self.pending_closures.iter().position(|(p, _)| *p == pid)?; self.pending_closures
Some(self.pending_closures.swap_remove(pos).1) .get_mut(pid.index() as usize)
.and_then(|cell| cell.take())
} }
} }
@@ -270,10 +341,18 @@ pub(crate) struct RuntimeInner {
/// Global counters for RFC 000 primitives. /// Global counters for RFC 000 primitives.
pub(crate) io_parked: AtomicU32, pub(crate) io_parked: AtomicU32,
pub(crate) sleeping: AtomicU32, pub(crate) sleeping: AtomicU32,
/// Preemption knobs, written into each scheduler thread's locals on startup.
pub(crate) alloc_interval: u32,
pub(crate) timeslice_cycles: u64,
/// Recycled stacks waiting to be reused by the next spawn.
/// Grows like a Vec (doubles capacity); shrink policy is TBD.
pub(crate) stack_pool: Mutex<Vec<crate::stack::Stack>>,
/// Maximum number of stacks to retain in the pool.
pub(crate) stack_pool_cap: usize,
} }
impl RuntimeInner { impl RuntimeInner {
fn new(thread_count: usize) -> Arc<Self> { fn new(thread_count: usize, alloc_interval: u32, timeslice_cycles: u64, stack_pool_cap: usize) -> Arc<Self> {
let stats = (0..thread_count).map(|_| SchedulerStats::new()).collect(); let stats = (0..thread_count).map(|_| SchedulerStats::new()).collect();
Arc::new(Self { Arc::new(Self {
shared: Mutex::new(SharedState::new()), shared: Mutex::new(SharedState::new()),
@@ -281,6 +360,10 @@ impl RuntimeInner {
stats, stats,
io_parked: AtomicU32::new(0), io_parked: AtomicU32::new(0),
sleeping: AtomicU32::new(0), sleeping: AtomicU32::new(0),
alloc_interval,
timeslice_cycles,
stack_pool: Mutex::new(Vec::new()),
stack_pool_cap,
}) })
} }
@@ -295,18 +378,6 @@ impl RuntimeInner {
crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(prev)); crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(prev));
result result
} }
/// Returns `None` when the mutex is poisoned.
/// Used in `unpark` / channel Drop which can fire after teardown.
pub(crate) fn try_with_shared<R>(&self, f: impl FnOnce(&mut SharedState) -> R) -> Option<R> {
let prev = crate::preempt::PREEMPTION_ENABLED.with(|c| c.replace(false));
let result = match self.shared.lock() {
Ok(mut g) => Some(f(&mut g)),
Err(p) => Some(f(&mut p.into_inner())),
};
crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(prev));
result
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -322,7 +393,7 @@ pub struct Runtime {
pub fn init(config: Config) -> Runtime { pub fn init(config: Config) -> Runtime {
let n = config.resolved_thread_count(); let n = config.resolved_thread_count();
Runtime { Runtime {
inner: RuntimeInner::new(n), inner: RuntimeInner::new(n, config.alloc_interval, config.timeslice_cycles, config.stack_pool_cap),
thread_count: n, thread_count: n,
} }
} }
@@ -470,6 +541,8 @@ pub(crate) fn reclaim_slot(s: &mut SharedState, pid: Pid) {
slot.outcome = None; slot.outcome = None;
slot.waiters.clear(); slot.waiters.clear();
slot.supervisor_channel = None; slot.supervisor_channel = None;
slot.monitors.clear();
slot.links.clear();
slot.state = State::Done; slot.state = State::Done;
slot.outstanding_handles = 0; slot.outstanding_handles = 0;
slot.pending_unpark = false; slot.pending_unpark = false;
@@ -482,23 +555,53 @@ pub(crate) fn reclaim_slot(s: &mut SharedState, pid: Pid) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) { fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
let (joiner_outcome, sup_signal) = match outcome { let (joiner_outcome, sup_signal, down_reason) = match outcome {
Outcome::Exit => (Outcome::Exit, Signal::Exit(pid)), Outcome::Exit => (Outcome::Exit, Signal::Exit(pid), DownReason::Exit),
Outcome::Panic(payload) => ( Outcome::Panic(payload) => (
Outcome::Panic(payload), Outcome::Panic(payload),
Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>), Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>),
DownReason::Panic,
), ),
// Cooperative cancellation: kept distinct from a normal Exit so a
// supervisor's await logic (roadmap #2) can tell "I stopped it" apart
// from "it finished on its own".
Outcome::Stopped => (Outcome::Stopped, Signal::Stopped(pid), DownReason::Stopped),
}; };
let (waiters, supervisor_pid) = inner.with_shared(|s| { let (waiters, supervisor_pid, monitors, recycled_stack, links) = inner.with_shared(|s| {
let slot = s.slot_mut(pid).expect("finalize_actor: slot vanished"); let slot = s.slot_mut(pid).expect("finalize_actor: slot vanished");
let sup = slot.actor.as_ref().map(|a| a.supervisor); let sup = slot.actor.as_ref().map(|a| a.supervisor);
// Extract the stack before clearing the actor so we can recycle it
// into the pool *outside* the shared lock.
let stack = slot.actor.take().map(|a| a.stack);
slot.outcome = Some(joiner_outcome); slot.outcome = Some(joiner_outcome);
slot.state = State::Done; slot.state = State::Done;
slot.actor = None; let monitors = std::mem::take(&mut slot.monitors);
(std::mem::take(&mut slot.waiters), sup) let waiters = std::mem::take(&mut slot.waiters);
let links = std::mem::take(&mut slot.links);
// Drop this (dying) pid from each linked peer's list now, under the
// same lock. Done regardless of how we died, so a peer that later
// finalizes won't try to propagate back to this about-to-be-reclaimed
// slot — which also makes the abnormal-death cascade acyclic.
for &peer in &links {
if let Some(ps) = s.slot_mut(peer) {
ps.links.retain(|p| *p != pid);
}
}
(waiters, sup, monitors, stack, links)
}); });
// Return the stack to the pool outside the shared lock. The pool grows
// like a Vec (doubles capacity); if we are already at the cap we just
// drop the stack (munmap) instead of retaining it.
if let Some(stack) = recycled_stack {
let mut pool = inner.stack_pool.lock().unwrap();
if pool.len() < inner.stack_pool_cap {
pool.push(stack);
}
// else: drop here → munmap, same as before
}
// Deliver to supervisor. // Deliver to supervisor.
if let Some(sup) = supervisor_pid { if let Some(sup) = supervisor_pid {
let sender = inner.with_shared(|s| { let sender = inner.with_shared(|s| {
@@ -509,6 +612,38 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
} }
} }
// Notify monitors. Sent outside the shared lock for the same reason as the
// supervisor signal: `send` may unpark a parked receiver, which re-takes
// the shared mutex.
for (_, m) in monitors {
let _ = m.send(Down { pid, reason: down_reason });
}
// Propagate to linked peers. A normal exit never propagates; an abnormal
// death (panic or cooperative stop) reaches each linked peer — a trapping
// peer gets an `ExitSignal` message and survives, a non-trapping peer is
// cooperatively stopped (which cascades along *its* links in turn). The
// reverse links were cleared above, so this never ping-pongs back. Decide
// trap-vs-stop under the lock, then act after releasing it: both
// `Sender::send` and `request_stop` re-enter the shared mutex.
if matches!(down_reason, DownReason::Panic | DownReason::Stopped) {
for peer in links {
let trap = inner.with_shared(|s| match s.slot(peer) {
Some(slot) if !matches!(slot.state, State::Done) => {
Some(slot.actor.as_ref().and_then(|a| a.trap.clone()))
}
_ => None, // peer already gone; nothing to do
});
match trap {
Some(Some(tx)) => {
let _ = tx.send(crate::link::ExitSignal { from: pid, reason: down_reason });
}
Some(None) => crate::scheduler::request_stop(peer),
None => {}
}
}
}
// Unpark joiners. // Unpark joiners.
for joiner in waiters { for joiner in waiters {
crate::scheduler::unpark(joiner); crate::scheduler::unpark(joiner);
@@ -526,6 +661,7 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) { fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
crate::preempt::configure_preempt(inner.alloc_interval, inner.timeslice_cycles);
let stats = &inner.stats[slot]; let stats = &inner.stats[slot];
loop { loop {
@@ -534,19 +670,53 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
// losers skip immediately and proceed to step 2. // losers skip immediately and proceed to step 2.
// ---------------------------------------------------------------- // ----------------------------------------------------------------
if let Ok(_drain_guard) = inner.drain_lock.try_lock() { if let Ok(_drain_guard) = inner.drain_lock.try_lock() {
let now = std::time::Instant::now(); // Drain due timers and IO completions in a SINGLE shared-lock
// acquisition. Previously this took two separate `with_shared`
// Drain due timers. // calls (one for timers, one for IO) plus an unconditional
let due = inner.with_shared(|s| s.timers.pop_due(now)); // `Instant::now()` every loop iteration. On the pure-yield /
// pure-compute hot path there are never any timers, so the clock
// read and the extra lock were pure per-yield overhead. We now
// read the clock only when the timer heap is non-empty and fold
// both drains into one lock hold.
//
// IO completions are still drained unconditionally while the IO
// subsystem is present: the IO/pool threads push completions onto
// their own mutex (not `shared`), so the scheduler can't cheaply
// know in advance whether any arrived — it must look. That look is
// a single empty-VecDeque check on the empty path.
let (due, completions) = inner.with_shared(|s| {
let due = if s.timers.is_empty() {
Vec::new()
} else {
s.timers.pop_due(std::time::Instant::now())
};
let completions = s.io.as_mut()
.map(|io| io.drain_completions())
.unwrap_or_default();
(due, completions)
});
for entry in due { for entry in due {
match entry.reason { match entry.reason {
crate::timer::Reason::Sleep => { crate::timer::Reason::Sleep => {
inner.with_shared(|s| { inner.with_shared(|s| {
if let Some(slot) = s.slot_mut(entry.pid) { if let Some(slot) = s.slot_mut(entry.pid) {
if matches!(slot.state, State::Parked) { match slot.state {
slot.state = State::Runnable; State::Parked => {
s.run_queue.push_back(entry.pid); slot.state = State::Runnable;
crate::te!(crate::trace::Event::Enqueue(entry.pid)); s.run_queue.push_back(entry.pid);
crate::te!(crate::trace::Event::Enqueue(entry.pid));
}
// Actor is between `timers.insert_sleep`
// and `park_current` in `sleep()`. Set
// the flag so the upcoming Park yield
// re-queues instead of suspending.
// Mirrors scheduler::unpark() and the
// IO FdReady path below.
State::Runnable => {
slot.pending_unpark = true;
crate::te!(crate::trace::Event::UnparkDeferred(entry.pid));
}
State::Done => {}
} }
} }
}); });
@@ -558,10 +728,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
} }
} }
// Drain IO completions.
let completions = inner.with_shared(|s| {
s.io.as_mut().map(|io| io.drain_completions()).unwrap_or_default()
});
for completion in completions { for completion in completions {
match completion { match completion {
crate::io::Completion::Blocking { pid, result } => { crate::io::Completion::Blocking { pid, result } => {
@@ -615,47 +781,97 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
} // drain_guard drops here } // drain_guard drops here
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// 2. Pop a runnable actor from the shared queue. // 2 + 3. Pop a runnable actor and grab everything we need to resume
// it — all in a single mutex acquisition.
//
// Returns:
// Some((pid, sp, Option<first-resume closure>)) — got work
// None with all_clear=true — done, exit
// None with all_clear=false — idle, wait
// ---------------------------------------------------------------- // ----------------------------------------------------------------
let pid = match inner.with_shared(|s| { enum PopResult {
Work(Pid, usize, *const AtomicBool, Option<Closure>),
Idle {
next_deadline: Option<std::time::Instant>,
io_outstanding: u32,
wake_fd: Option<std::os::fd::RawFd>,
},
AllDone,
}
// SAFETY: the raw pointers are (a) the actor's stack pointer and (b) a
// pointer into the actor's `Arc<AtomicBool>` stop flag, both valid for
// the lifetime of the actor's Slot. They are only used by this same
// thread immediately after this block (switch_to_actor / set_current_stop).
unsafe impl Send for PopResult {} // closure is Send; usize sp + stop ptr are plain data
let pop = inner.with_shared(|s| {
let len = s.run_queue.len() as u64; let len = s.run_queue.len() as u64;
stats.run_queue_len.store(len, Ordering::Relaxed); stats.run_queue_len.store(len, Ordering::Relaxed);
s.run_queue.pop_front()
}) { if let Some(pid) = s.run_queue.pop_front() {
Some(p) => { // Happy path: got a PID, grab SP, the stop-flag pointer, and
crate::te!(crate::trace::Event::Dequeue(p)); // optional first closure. We hand out a raw pointer into the
p // actor's `Arc<AtomicBool>` rather than cloning the Arc: the
} // box stays alive and at a fixed address for as long as the
None => { // actor is on-CPU (it can't be finalized until it yields back),
// Queue was empty when we popped. Re-examine under the lock to // so the scheduler's resume path pays no atomic ref-count cost.
// decide whether to exit or wait. All four conditions must hold let info = s.slot(pid)
// simultaneously before we exit: .and_then(|slot| slot.actor.as_ref()
.map(|a| (a.sp, std::sync::Arc::as_ptr(&a.stop))));
match info {
Some((sp, stop)) => {
let closure = s.pop_pending_closure(pid);
PopResult::Work(pid, sp, stop, closure)
}
None => {
// Stale PID (actor already finalized). Treat as idle
// so the outer loop retries immediately.
PopResult::Idle {
next_deadline: None,
io_outstanding: 0,
wake_fd: None,
}
}
}
} else {
// Queue was empty. Re-examine to decide exit vs wait.
// We exit only when nothing can ever produce more work:
// 1. run queue is still empty // 1. run queue is still empty
// 2. no live actors (nothing parked, nothing mid-finalize) // 2. no live actors (nothing parked, nothing mid-finalize)
// 3. no pending timers // 3. no outstanding IO
// 4. no outstanding IO // Pending timers are deliberately NOT a condition: a timer can
// If any is non-zero we keep spinning — "check the fridge is // only ever do something useful by waking a live actor, so once
// empty before you leave for the airport". // `live == 0` every remaining timer entry is orphaned (e.g. a
let (next_deadline, io_outstanding, wake_fd, all_clear) = // sleeping actor that was cooperatively cancelled out of its
inner.with_shared(|s| { // sleep, leaving its deadline behind). Such entries must not
let next = s.timers.peek_deadline(); // keep the runtime alive until they fire — that could be an
let (out, fd) = match s.io.as_ref() { // arbitrarily long hang. They are dropped here on the way out.
Some(io) => ( let next = s.timers.peek_deadline();
io.outstanding + io.waiters.len() as u32, let (out, fd) = match s.io.as_ref() {
Some(io.wake_fd()), Some(io) => (
), io.outstanding + io.waiters.len() as u32,
None => (0, None), Some(io.wake_fd()),
}; ),
let live = s.slots.iter().filter(|slot| slot.actor.is_some()).count(); None => (0, None),
let queue_empty = s.run_queue.is_empty(); };
let all_clear = queue_empty && live == 0 && next.is_none() && out == 0; let live = s.slots.iter().filter(|slot| slot.actor.is_some()).count();
(next, out, fd, all_clear) let all_clear = s.run_queue.is_empty() && live == 0 && out == 0;
});
if all_clear { if all_clear {
return; s.timers.clear();
PopResult::AllDone
} else {
PopResult::Idle { next_deadline: next, io_outstanding: out, wake_fd: fd }
} }
}
});
let (pid, sp, stop_flag, first_closure) = match pop {
PopResult::Work(pid, sp, stop, closure) => {
crate::te!(crate::trace::Event::Dequeue(pid));
(pid, sp, stop, closure)
}
PopResult::AllDone => return,
PopResult::Idle { next_deadline, io_outstanding, wake_fd } => {
// Something is still in flight. Sleep on the appropriate source // Something is still in flight. Sleep on the appropriate source
// to avoid hammering the mutex; the loop will retry on wake. // to avoid hammering the mutex; the loop will retry on wake.
match (next_deadline, wake_fd) { match (next_deadline, wake_fd) {
@@ -687,17 +903,9 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// 3. Resume the actor. // 3. Resume the actor.
// ---------------------------------------------------------------- // ----------------------------------------------------------------
let sp = match inner.with_shared(|s| {
s.slot(pid).and_then(|slot| slot.actor.as_ref().map(|a| a.sp))
}) {
Some(sp) => sp,
None => {
continue; // stale pid
}
};
// First resume: move the closure into the trampoline's thread-local. // First resume: move the closure into the trampoline's thread-local.
if let Some(b) = inner.with_shared(|s| s.pop_pending_closure(pid)) { if let Some(b) = first_closure {
set_current_actor_box(b); set_current_actor_box(b);
} }
@@ -706,6 +914,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
set_actor_sp(sp); set_actor_sp(sp);
set_current_pid(pid); set_current_pid(pid);
crate::preempt::set_current_stop(stop_flag);
reset_actor_done(); reset_actor_done();
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield)); YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
crate::preempt::reset_timeslice(); crate::preempt::reset_timeslice();
@@ -717,6 +926,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
PREEMPTION_ENABLED.with(|c| c.set(false)); PREEMPTION_ENABLED.with(|c| c.set(false));
stats.current_pid_index.store(u32::MAX, Ordering::Relaxed); stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
clear_current_pid(); clear_current_pid();
crate::preempt::clear_current_stop();
let intent = YIELD_INTENT.with(|c| c.get()); let intent = YIELD_INTENT.with(|c| c.get());
let new_sp = get_actor_sp(); let new_sp = get_actor_sp();
+69 -9
View File
@@ -11,7 +11,7 @@ use crate::actor::current_pid;
use crate::channel::Sender; use crate::channel::Sender;
use crate::pid::Pid; use crate::pid::Pid;
use crate::runtime::{ use crate::runtime::{
self, RuntimeInner, YieldIntent, ROOT_PID, RUNTIME, self, RuntimeInner, YieldIntent, RUNTIME,
}; };
use crate::supervisor::Signal; use crate::supervisor::Signal;
use std::sync::Arc; use std::sync::Arc;
@@ -79,6 +79,10 @@ impl JoinHandle {
return match o { return match o {
Outcome::Exit => Ok(()), Outcome::Exit => Ok(()),
Outcome::Panic(p) => Err(JoinError { payload: p }), Outcome::Panic(p) => Err(JoinError { payload: p }),
// A cooperative stop carries no panic payload to
// propagate; the *reason* is observable via monitors
// (DownReason::Stopped). join() therefore reports Ok.
Outcome::Stopped => Ok(()),
}; };
} }
None => { None => {
@@ -132,24 +136,45 @@ pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle {
} }
pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle { pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle {
// Try to reuse a stack from the pool; fall back to a fresh mmap if empty.
// Allocation happens before taking the shared lock so any syscall doesn't
// stall other scheduler threads.
let stack = with_runtime(|inner| inner.stack_pool.lock().unwrap().pop())
.unwrap_or_else(|| {
crate::stack::Stack::new(crate::runtime::ACTOR_STACK_SIZE)
.expect("stack allocation failed")
});
let sp = init_actor_stack(stack.top(), crate::actor::trampoline);
let pid = with_runtime(|inner| { let pid = with_runtime(|inner| {
inner.with_shared(|s| { inner.with_shared(|s| {
let (idx, gen) = s.allocate_slot(); let (idx, gen) = s.allocate_slot();
let pid = Pid::new(idx, gen); let pid = Pid::new(idx, gen);
let stack = crate::stack::Stack::new(crate::runtime::ACTOR_STACK_SIZE)
.expect("stack allocation failed");
let sp = init_actor_stack(stack.top(), crate::actor::trampoline);
let slot = &mut s.slots[idx as usize]; let slot = &mut s.slots[idx as usize];
slot.actor = Some(crate::actor::Actor { pid, stack, sp, supervisor }); slot.actor = Some(crate::actor::Actor {
pid,
stack,
sp,
supervisor,
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
trap: None,
});
slot.state = crate::runtime::State::Runnable; slot.state = crate::runtime::State::Runnable;
slot.outstanding_handles = 1; slot.outstanding_handles = 1;
slot.outcome = None; slot.outcome = None;
slot.waiters.clear(); slot.waiters.clear();
slot.supervisor_channel = None; slot.supervisor_channel = None;
slot.monitors.clear();
slot.links.clear();
slot.pending_unpark = false; slot.pending_unpark = false;
slot.pending_io_result = None; slot.pending_io_result = None;
s.run_queue.push_back(pid); s.run_queue.push_back(pid);
s.pending_closures.push((pid, Box::new(f) as crate::runtime::Closure)); // Grow the closures vec to cover this slot index, then store.
let idx = idx as usize;
if s.pending_closures.len() <= idx {
s.pending_closures.resize_with(idx + 1, || None);
}
s.pending_closures[idx] = Some(Box::new(f) as crate::runtime::Closure);
crate::te!(crate::trace::Event::Spawn { parent: supervisor, child: pid }); crate::te!(crate::trace::Event::Spawn { parent: supervisor, child: pid });
crate::te!(crate::trace::Event::Enqueue(pid)); crate::te!(crate::trace::Event::Enqueue(pid));
pid pid
@@ -172,11 +197,18 @@ pub fn self_pid() -> Pid {
pub fn yield_now() { pub fn yield_now() {
runtime::set_yield_intent(YieldIntent::Yield); runtime::set_yield_intent(YieldIntent::Yield);
unsafe { crate::context::switch_to_scheduler() }; unsafe { crate::context::switch_to_scheduler() };
// Observation point: we may have been resumed only to be cancelled.
crate::preempt::check_cancelled();
} }
pub fn park_current() { pub fn park_current() {
runtime::set_yield_intent(YieldIntent::Park); runtime::set_yield_intent(YieldIntent::Park);
unsafe { crate::context::switch_to_scheduler() }; unsafe { crate::context::switch_to_scheduler() };
// Observation point on the wakeup side of every blocking primitive
// (recv/sleep/mutex/io/join). Past the prep-to-park window, so this never
// races a wakeup: a stop unparks us, we resume here, and unwind out of
// whatever blocking call parked us — running Drop along the way.
crate::preempt::check_cancelled();
} }
pub fn unpark(pid: Pid) { pub fn unpark(pid: Pid) {
@@ -207,6 +239,37 @@ pub fn unpark(pid: Pid) {
let _ = result; let _ = result;
} }
/// Request cooperative cancellation of `pid`.
///
/// Sets the actor's stop flag and, if it is parked, wakes it so it observes
/// the flag promptly. The actor realises the stop as a controlled unwind at
/// its next observation point (`check!()`/allocation, or the wakeup side of a
/// blocking park), terminating with `Outcome::Stopped`.
///
/// This is best-effort and cooperative: an actor that never reaches an
/// observation point — a tight loop with no `check!()`, no allocation, and no
/// blocking op — cannot be stopped, exactly as it cannot be preempted. A no-op
/// if `pid` is already gone.
pub fn request_stop(pid: Pid) {
let _ = try_with_runtime(|inner| {
inner.with_shared(|s| {
if let Some(slot) = s.slot_mut(pid) {
if let Some(actor) = slot.actor.as_ref() {
actor.stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
// Wake a parked target so it reaches its post-park observation
// point now. A Runnable target will observe on its next yield;
// a Done target has nothing to stop.
if matches!(slot.state, crate::runtime::State::Parked) {
slot.state = crate::runtime::State::Runnable;
s.run_queue.push_back(pid);
crate::te!(crate::trace::Event::Enqueue(pid));
}
}
})
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// NoPreempt // NoPreempt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -344,6 +407,3 @@ pub fn register_supervisor_channel(pid: Pid, sender: Sender<Signal>) {
pub fn run<F: FnOnce() + Send + 'static>(f: F) { pub fn run<F: FnOnce() + Send + 'static>(f: F) {
crate::runtime::init(crate::runtime::Config::exact(1)).run(f); crate::runtime::init(crate::runtime::Config::exact(1)).run(f);
} }
+282
View File
@@ -16,6 +16,10 @@ pub enum Signal {
Exit(Pid), Exit(Pid),
/// The child panicked. Payload is whatever `panic!` was called with. /// The child panicked. Payload is whatever `panic!` was called with.
Panic(Pid, Box<dyn Any + Send>), Panic(Pid, Box<dyn Any + Send>),
/// The child was cooperatively cancelled via `request_stop`. Carries no
/// payload — there is nothing to propagate. Kept distinct from `Exit` so a
/// supervisor can distinguish a stop it requested from a self-termination.
Stopped(Pid),
} }
impl std::fmt::Debug for Signal { impl std::fmt::Debug for Signal {
@@ -23,6 +27,7 @@ impl std::fmt::Debug for Signal {
match self { match self {
Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid), Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid),
Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid), Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid),
Signal::Stopped(pid) => write!(f, "Signal::Stopped({:?})", pid),
} }
} }
} }
@@ -32,6 +37,283 @@ impl Signal {
match self { match self {
Signal::Exit(p) => *p, Signal::Exit(p) => *p,
Signal::Panic(p, _) => *p, Signal::Panic(p, _) => *p,
Signal::Stopped(p) => *p,
} }
} }
} }
// ---------------------------------------------------------------------------
// One-for-one supervisor
//
// A supervisor is itself an actor. It spawns each child under its own pid so
// that every child death funnels into one mailbox (the `supervisor_channel`),
// then loops: receive a Signal, decide per the child's `Restart` policy
// whether to restart, and enforce a restart-intensity cap so a child that
// crashes in a tight loop eventually gives up instead of spinning forever.
//
// What this does NOT do: *forcibly* terminate a running child. smarm actors
// share a heap and rely on Drop/RAII, so tearing down a peer's stack from
// outside is unsound. Stopping a sibling — whether for `one_for_all` /
// `rest_for_one`, for a propagated link death, or for the ordered shutdown
// below — is therefore *cooperative*: `request_stop` flags the child and it
// unwinds at its next observation point. A child with no observation points
// (a tight loop with no `check!()`, allocation, or blocking op) cannot be
// stopped, exactly as it cannot be preempted. When the intensity cap trips,
// this supervisor stops restarting and tears the remaining children down in
// reverse start order before returning.
// ---------------------------------------------------------------------------
use crate::channel::channel;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// When a terminated child should be restarted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Restart {
/// Always restart, on normal exit or panic.
Permanent,
/// Restart only on panic; a normal exit is treated as "done".
Transient,
/// Never restart.
Temporary,
}
/// A child managed by a supervisor: a restart policy plus a *factory* that
/// produces a fresh instance of the child's work on each (re)start.
///
/// The factory is `Fn` (not `FnOnce`) precisely so it can be called again to
/// restart; it is shared via `Arc` so the spec stays cheap to clone.
#[derive(Clone)]
pub struct ChildSpec {
start: Arc<dyn Fn() + Send + Sync + 'static>,
restart: Restart,
}
impl ChildSpec {
pub fn new(restart: Restart, start: impl Fn() + Send + Sync + 'static) -> Self {
Self { start: Arc::new(start), restart }
}
}
/// How a supervisor reacts when one child terminates and a restart is due.
///
/// The *triggering* child's [`Restart`] policy still decides whether a restart
/// happens at all; the strategy only decides *which other children* are cycled
/// along with it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
/// Restart only the child that terminated. Siblings are untouched.
OneForOne,
/// Restart every child: the survivors are cooperatively stopped (in
/// reverse start order) and the whole set is restarted in start order.
OneForAll,
/// Restart the terminated child and every child started *after* it; those
/// started before it are untouched.
RestForOne,
}
/// A supervisor over a fixed set of children.
///
/// Despite the name (kept for backwards compatibility), the restart strategy
/// is selectable via [`OneForOne::strategy`]; the default is
/// [`Strategy::OneForOne`]. Build with `new()`, add children with `child()`,
/// tune the cap with `intensity()`, then drive it with `run()` from inside an
/// actor (typically `spawn(|| OneForOne::new()....run())`).
pub struct OneForOne {
children: Vec<ChildSpec>,
strategy: Strategy,
intensity: u32,
period: Duration,
}
impl Default for OneForOne {
fn default() -> Self {
// Erlang's default supervisor intensity: 1 restart per 5 seconds. We
// start a little more permissive but in the same spirit.
Self {
children: Vec::new(),
strategy: Strategy::OneForOne,
intensity: 3,
period: Duration::from_secs(5),
}
}
}
impl OneForOne {
pub fn new() -> Self {
Self::default()
}
/// Select the restart strategy (default [`Strategy::OneForOne`]).
pub fn strategy(mut self, strategy: Strategy) -> Self {
self.strategy = strategy;
self
}
/// Allow at most `max` restarts within any `period`-long window before the
/// supervisor gives up and `run()` returns.
pub fn intensity(mut self, max: u32, period: Duration) -> Self {
self.intensity = max;
self.period = period;
self
}
pub fn child(mut self, spec: ChildSpec) -> Self {
self.children.push(spec);
self
}
/// Run the supervision loop on the current actor. Returns when every child
/// has reached a terminal, non-restartable state, or when the restart
/// intensity cap is tripped.
pub fn run(self) {
let me = crate::scheduler::self_pid();
let (tx, rx) = channel::<Signal>();
crate::scheduler::register_supervisor_channel(me, tx);
// pid -> index into `self.children`, for the children currently alive.
let mut by_pid: HashMap<Pid, usize> = HashMap::new();
let mut active: usize = 0;
// Sliding window of recent restart instants, for the intensity cap.
let mut restarts: Vec<Instant> = Vec::new();
let start_child = |idx: usize, by_pid: &mut HashMap<Pid, usize>| {
let start = self.children[idx].start.clone();
let h = crate::scheduler::spawn_under(me, move || (start)());
by_pid.insert(h.pid(), idx);
// We supervise via the signal funnel, not by joining; drop the
// handle so the child's slot is reclaimed promptly on death (the
// termination Signal is delivered before reclamation regardless).
drop(h);
};
for idx in 0..self.children.len() {
start_child(idx, &mut by_pid);
active += 1;
}
// A signal that arrives while we are awaiting stop-confirmations (for a
// child we are *not* currently stopping) is stashed here and processed
// by the main loop before it blocks on `recv` again.
let mut pending: VecDeque<Signal> = VecDeque::new();
let next_signal = |pending: &mut VecDeque<Signal>| -> Option<Signal> {
if let Some(s) = pending.pop_front() {
Some(s)
} else {
rx.recv().ok()
}
};
while active > 0 {
let sig = match next_signal(&mut pending) {
Some(s) => s,
None => break, // mailbox closed: nothing left to supervise
};
let idx = match by_pid.remove(&sig.pid()) {
Some(i) => i,
None => continue, // stray/duplicate signal
};
// The triggering child's own policy decides whether *anything*
// restarts. A cooperative stop counts as abnormal alongside a panic.
let abnormal = matches!(sig, Signal::Panic(..) | Signal::Stopped(..));
let should_restart = match self.children[idx].restart {
Restart::Permanent => true,
Restart::Transient => abnormal,
Restart::Temporary => false,
};
if !should_restart {
active -= 1;
continue;
}
// Intensity cap: prune the window, then give up if we are already
// at the limit. One triggering failure counts as one restart event,
// even when the strategy cycles several children. On giving up we
// fall out of the loop and the ordered shutdown below stops any
// survivors.
let now = Instant::now();
restarts.retain(|t| now.duration_since(*t) <= self.period);
if restarts.len() as u32 >= self.intensity {
break;
}
restarts.push(now);
// Which *live* siblings get cycled along with the failed child.
// (The failed child is already gone — removed from `by_pid` above.)
let mut to_stop: Vec<(Pid, usize)> = match self.strategy {
Strategy::OneForOne => Vec::new(),
Strategy::OneForAll => by_pid.iter().map(|(p, i)| (*p, *i)).collect(),
Strategy::RestForOne => by_pid
.iter()
.filter(|(_, i)| **i > idx)
.map(|(p, i)| (*p, *i))
.collect(),
};
// Stop survivors in reverse start order (highest child index first).
to_stop.sort_unstable_by(|a, b| b.1.cmp(&a.1));
// The set we will restart: the failed child plus every sibling we
// are about to stop, restarted in start (ascending index) order.
let mut restart_set: Vec<usize> = Vec::with_capacity(to_stop.len() + 1);
restart_set.push(idx);
// Request stops, then await each survivor's termination signal
// before restarting. `request_stop` on an already-dead pid is a
// no-op; in that case its (already-sent) Exit signal serves as the
// confirmation. Any signal for a pid we are *not* awaiting is
// stashed for the main loop.
let mut awaiting: Vec<Pid> = Vec::with_capacity(to_stop.len());
for (pid, cidx) in &to_stop {
by_pid.remove(pid);
restart_set.push(*cidx);
crate::scheduler::request_stop(*pid);
awaiting.push(*pid);
}
while !awaiting.is_empty() {
let s = match next_signal(&mut pending) {
Some(s) => s,
None => break, // mailbox closed mid-await; stop waiting
};
if let Some(pos) = awaiting.iter().position(|p| *p == s.pid()) {
awaiting.swap_remove(pos);
} else {
pending.push_back(s);
}
}
// Restart the whole set in start order. Net effect on `active`:
// one child died (idx), `to_stop.len()` were stopped, and
// `restart_set.len() == 1 + to_stop.len()` are started — so
// `active` is unchanged and needs no adjustment here.
restart_set.sort_unstable();
for cidx in restart_set {
start_child(cidx, &mut by_pid);
}
}
// Ordered shutdown: stop any survivors in reverse start order and await
// their termination. On the normal `active == 0` exit `by_pid` is empty
// and this is a no-op; on a cap-trip or mailbox-closed break it tears
// the remaining children down deterministically instead of leaking them.
let mut survivors: Vec<(Pid, usize)> = by_pid.iter().map(|(p, i)| (*p, *i)).collect();
survivors.sort_unstable_by(|a, b| b.1.cmp(&a.1));
let mut awaiting: Vec<Pid> = Vec::with_capacity(survivors.len());
for (pid, _) in &survivors {
crate::scheduler::request_stop(*pid);
awaiting.push(*pid);
}
while !awaiting.is_empty() {
let s = match next_signal(&mut pending) {
Some(s) => s,
None => break,
};
if let Some(pos) = awaiting.iter().position(|p| *p == s.pid()) {
awaiting.swap_remove(pos);
}
}
}
}
+7
View File
@@ -119,6 +119,13 @@ impl Timers {
self.heap.is_empty() self.heap.is_empty()
} }
/// Drop all pending entries. Called by the scheduler when it has decided
/// no actor is live: any remaining timer is orphaned and exists only to be
/// discarded so it can't keep the runtime alive.
pub fn clear(&mut self) {
self.heap.clear();
}
/// Soonest pending deadline, or `None` if the heap is empty. /// Soonest pending deadline, or `None` if the heap is empty.
pub fn peek_deadline(&self) -> Option<Instant> { pub fn peek_deadline(&self) -> Option<Instant> {
self.heap.peek().map(|r| r.0.deadline) self.heap.peek().map(|r| r.0.deadline)
+263
View File
@@ -0,0 +1,263 @@
# smarm — task.md (next steps)
Handoff for a future session reusing this sandbox. Read top to bottom once
before starting; the gotchas section is hard-won and will save you a faceplant.
## Resume the environment
- Repo: `smarm`. Two branches (the old single `arm-port` stack was split):
- `master` — the mainline, and HEAD. Carries roadmap #1#5: cooperative
cancellation, supervisor strategies (one_for_one/all, rest_for_one) + the
orphaned-timer shutdown fix, links/trap_exit, selective receive, gen_server,
and demonitor/`MonitorId`. Tagged `v0.4.0`. x86-64 Linux only.
- `arm-port``master` plus a single commit: `feat(arch): aarch64 context
switch + cycle counter`. Extracts the x86-64 context-switch / stack-init /
cycle-counter out of `context.rs` into a `target_arch`-gated `src/arch/`
(x86_64 + aarch64 backends) and adds an AAPCS64 backend. ⚠️ UNTESTED: never
built or run on real ARM hardware. The x86-64 path is unchanged
(`arch/x86_64.rs` is the old `context.rs` body verbatim), so the x86 suite
passing says nothing about the aarch64 backend. Build + test on-device
before trusting it.
- Toolchain is installed but NOT on PATH in a fresh shell. First line of every
session: `. "$HOME/.cargo/env"` (rustc/cargo 1.96).
- Build `cargo build` · all tests `cargo test` · one suite `cargo test --test monitor`.
- Bench probe `cargo bench --bench general` (custom print-only harness; compiles
tokio in release the first time — slow — but `target/` persists across git
checkouts so it's paid once).
- Perf regression check: `git checkout <pre-change-sha>` →
`cargo bench --bench general | tee before.txt` → `git checkout arm-port` →
run again → diff the `smarm 1-thread` medians for `chained_spawn` and
`yield_many` (those exercise spawn/finalize/scheduler). Numbers are noisy on
this shared CPU; treat as "regression beyond noise?" not a precise delta.
## Roadmap (dependency order)
### 1. Cooperative cancellation — the keystone ✅ DONE (`a8ddb4a`)
Everything below (one_for_all/rest_for_one, links) needs a *safe* way to stop a
running peer. Forcible teardown of another green thread's stack is unsound here
(shared heap + Drop). So: cooperative stop the actor observes and unwinds itself.
Shipped as designed (sentinel unwind, not Result-threading). Notes for what
came next / future readers:
- Stop flag lives on `Actor` behind `Arc<AtomicBool>` (fresh per spawn), NOT a
`Slot` field — sidesteps the three-place reset, at the cost of one small
alloc per spawn. The scheduler hands the resume path a raw `*const AtomicBool`
(no per-resume refcount traffic); `yield_many` bench stayed at baseline,
`chained_spawn` ~+6% from that alloc (left as-is; move to a `Slot` field if it
ever matters).
- Observation points: amortised `maybe_preempt`/`check!()` path + the wakeup
side of `park_current`/`yield_now`. Sentinel = `StopSentinel` (zero-size),
recognised in the trampoline → `Outcome::Stopped`. `join()` on a stopped actor
returns `Ok(())` (no payload to propagate; reason is on the monitor channel).
- Documented gaps confirmed by tests: no-observation-point loop can't be stopped
(same as preemption); a user `catch_unwind` can swallow the sentinel but the
flag stays set so the next yield re-raises.
Original plan, for reference:
- Add a per-actor stop flag (Slot field + atomic, or check via shared state).
- `request_stop(pid)`: set the flag, unpark if parked.
- Realize the stop as a **controlled unwind**: when the scheduler resumes a
stop-requested actor, inject a sentinel panic (dedicated payload type) so the
existing `trampoline` `catch_unwind` tears the stack down and runs Drop. The
trampoline recognizes the sentinel and reports a new `Outcome::Stopped`
(distinct from a user `Panic`). This avoids changing every blocking-op
signature.
- Alternative considered: thread `Result<_, Cancelled>` through recv/sleep/
lock/io. Rejected — large API churn. Go with the sentinel unwind.
- Caveat to document: user code with its own `catch_unwind` can swallow the
sentinel (cf. Erlang `catch`); re-check the flag at the next yield and/or
re-raise. And a tight no-alloc loop without `check!()` can't be stopped —
same inherent limitation as preemption.
- Observation points: `maybe_preempt()`/`check!()` (cheap flag check) and the
blocking parks (recv/sleep/mutex/io) on the stop-driven unpark.
- Tests: looping actor on `check!()` gets stopped → `Outcome::Stopped`, Drop
guards ran; parked-on-recv actor gets stopped; no-check loop documents the gap.
### 2. one_for_all / rest_for_one + ordered shutdown ✅ DONE (`351dc9c`)
Shipped. What landed vs the plan:
- `Strategy::{OneForOne,OneForAll,RestForOne}` selected via `.strategy()`,
default `OneForOne`. The struct keeps the `OneForOne` name (compat; existing
tests untouched) — a rename to `Supervisor` is a deferred refactor.
- The *triggering* child's `Restart` policy decides whether anything restarts;
the strategy decides which live siblings are cycled (all / index-> after the
failed one). Survivors are `request_stop`'d in reverse start order, awaited on
the existing `supervisor_channel` funnel (no new channel, no `select`),
restarted in start order. One failure = one intensity tick regardless of group
size. Out-of-band signals during an await are stashed and replayed.
- `Signal::Stopped(pid)` + `DownReason::Stopped` added (kept distinct from Exit,
as planned). A `Stopped` signal counts as abnormal for the restart decision.
- Ordered shutdown: on cap-trip / mailbox-close-with-survivors, stop remaining
children in reverse start order and await them (no-op on the normal exit).
- ⚠️ Surfaced + fixed a latent keystone bug (`e80334b`): a cancelled
sleeping/timeout actor orphans its timer entry, and the scheduler's shutdown
check counted pending timers → `run()` hung until the dead actor's deadline
fired (a `sleep(30s)` sleeper hung shutdown 30s). Fix: timers no longer gate
shutdown (`live == 0` already implies nothing a timer could wake); heap is
cleared on exit. Independent of the supervisor work.
- Tests: all-restart (sibling cycled despite clean exit), suffix-restart
(prefix child left alone), reverse-order teardown.
Original plan, for reference:
- one_for_all: on any child failure, `request_stop` all siblings, await their
termination signals, restart all per spec.
- rest_for_one: stop+restart the failed child and those started after it.
- Supervisor shutdown: stop children in reverse start order.
- Decide signal surface: add `Signal::Stopped(pid)` + `DownReason::Stopped`
rather than folding into Exit (clearer for the supervisor's await logic).
- Tests: all-restart, suffix-restart, reverse-order shutdown.
### 3. Links + trap_exit ✅ DONE (`6581484`)
- `Slot.links: Vec<Pid>` (bidirectional); `link`/`unlink`; `trap_exit()` flag
lives on `Actor` (fresh per spawn → a restarted child starts un-trapped, and
no fourth slot-reset site).
- On finalize, reverse links are cleared under the lock (always — keeps the
cascade acyclic), then for each linked peer: abnormal death (`Panic`/
`Stopped`) → `request_stop(peer)` unless peer traps, in which case deliver an
`ExitSignal` *message* instead. Normal exit does NOT propagate. Linking an
already-dead pid delivers an immediate `NoProc` signal (message if trapping,
else `request_stop(self)` — not a silent no-op).
- Resolved: the trap inbox is a **dedicated** channel (`trap_exit() ->
Receiver<ExitSignal>`), distinct from the monitor `Down` channel; `ExitSignal`
reuses `DownReason` and carries no panic payload (joiner-only, as with
monitors). `spawn_link` deferred to #5.
- Tests (`tests/link.rs`): linked pair one panics → other stopped (+Drop ran);
trap_exit → other gets a message and survives; normal exit doesn't propagate;
dead-pid link stops a non-trapper / messages a trapper; `unlink` prevents
propagation.
### 4. Selective receive (independent track) ✅ DONE (`03f3875`)
Shipped. What landed vs the plan:
- `Receiver::recv_match(pred) -> Result<T, RecvError>` scans the queued
`VecDeque` front-to-back, removes+returns the first match, leaves the rest in
arrival order; parks and re-scans when nothing matches. `try_recv_match` (the
non-blocking variant, mirroring `try_recv`) rolled in same commit.
- Wakeup turned out cheaper than feared: `Sender::send` *already* took
`parked_receiver` on every push, so "wake on ANY send" needed no send-side
change. The one real edit was relaxing `Sender::drop` to unpark the parked
receiver on the last-sender drop regardless of queue emptiness — a selective
receiver can park on a non-empty no-match queue and must wake to observe
closure. No-op for plain `recv` (only ever parks on an empty queue); stress
suite stays green.
- `pred` is `Fn(&T) -> bool` (not `FnMut`) on purpose: it's re-run from scratch
on every scan, so a stateful predicate would re-count surprisingly. It runs
under the channel lock — keep it cheap/pure, don't re-enter the channel.
- Close semantics: `recv_match` returns `Err(RecvError)` only when closed AND no
queued message matches; a match is still returned on a closed channel.
Non-matches are left for a later `recv`.
- Tests (`tests/selective_recv.rs`): out-of-order match pulled first; non-matches
remain in order; park-on-non-empty then wake on a match; closed-with-only-
non-matches → Err; closed-but-match-present → match; `try_recv_match` states.
Original plan, for reference:
- Add `Receiver::recv_match(pred) -> T`: scan the queued `VecDeque`, remove+return
first match, leave the rest in order; park and re-scan on new arrivals.
- This changes channel wakeup: a parked selective receiver must wake on ANY send
(not just empty→nonempty) and re-scan. Touches `channel.rs` carefully — the
stress tests guard lost-wakeup invariants; keep them green.
- Tests: messages arrive out of interest-order; match pulled first; non-matches
remain for a later `recv`.
### 5. Grab-bag (each its own small commit)
- `spawn_link`: spawn-and-link atomically (deferred from #3); thin wrapper over
`spawn_under` + `link`, but do it under one lock so there's no window where
the child dies before the link is recorded.
- `demonitor`: needs a per-monitor id to remove a specific sender. Decide the
monitor API NOW before more code depends on it — likely return a
`Monitor { id, rx }` instead of a bare `Receiver<Down>`.
✅ DONE (this commit). What landed vs the plan:
- `monitor()` now returns `Monitor { id, target, rx }` (added `target` over
the sketched `{id, rx}` so `demonitor` jumps straight to the slot instead of
scanning every slot for the id). `MonitorId(u64)` is opaque, from a
monotonic `next_monitor_id` counter on `SharedState`, bumped under the
shared lock in `monitor()` — no atomics, deterministic, never reused.
- `Slot.monitors: Vec<(MonitorId, Sender<Down>)>`. The three slot-reset sites
were untouched — they `.clear()`/`Vec::new()`, which is element-type-
agnostic, so no new reset obligation. `finalize_actor` just destructures
`(_, m)` and sends as before.
- `demonitor(&Monitor) -> Option<MonitorId>`: `Some(id)` when a live
registration was found+removed, `None` when it had already fired (drained on
finalize), was `NoProc`, or the slot was reclaimed. Chose `Option<MonitorId>`
over a bare bool — names which registration went. Generation half of the pid
makes a stale demonitor a clean no-op: a recycled slot index fails
`slot_mut`'s generation check, so it can never strip a different actor's
monitor.
- ⚠️ Reentrancy: the removed `Sender` is `remove`d out of the Vec under the
lock but **dropped after the lock is released** — `Sender::drop` can unpark a
parked receiver → `with_shared`, and the shared mutex is non-reentrant. Same
discipline as `finalize_actor`.
- "Flush" (discard a `Down` the target already queued) falls out of dropping
the `Monitor`: `demonitor(&m); drop(m)`. That's the cleanup the still-to-come
gen_server **call timeout** wants — monitor the server, wait reply-or-Down-
or-deadline, then demonitor+drop so a timed-out call leaks no registration
and no stale `Down`.
- Perf: touches `Slot` + `finalize_actor`, but `chained_spawn`/`yield_many`
register no monitors, so the Vec stays empty (take-empty is identical cost,
finalize loop runs zero times). before/after `general` probe medians within
noise. Tests (`tests/monitor.rs`): demonitor-stops-delivery, one-of-many
(siblings untouched), after-fire-is-None.
- Named registry: `register(name,pid)`/`whereis`/`send_by_name`; a
`HashMap<String,Pid>` in `SharedState`.
- gen_server-style call/cast: request-reply correlation as a thin layer over
channels (`call` sends `{req, reply_tx}`, awaits `reply_rx`); no runtime change.
✅ DONE (`a4fcf6c`). What landed vs the plan:
- `GenServer` trait on the state value: assoc `Call`/`Reply`/`Cast` types,
required `handle_call`/`handle_cast`, optional `init`/`terminate` hooks.
`ServerRef<G>` is a clonable inbox sender + `pid()`; `start` / `start_under`.
- One inbox, not two: a single `Envelope { Call(req, reply_tx) | Cast }`
channel, dispatched by variant. Forced by no-`select`/no-unified-mailbox —
a server can't wait on a call channel and a cast channel at once.
- Server-down falls out of channel closure (no monitor needed): `send` fails
if the inbox is gone; the reply sender drops on the server's unwind so a
parked caller wakes to `Err`. Both → `Call/CastError::ServerDown`.
- `terminate` runs via a drop guard → fires on *every* exit path (clean inbox
close, handler panic, `request_stop`), not just the clean one. Caveat: it
may run mid-unwind, so keep it non-blocking (a panic inside it during an
unwind double-panics → abort).
- No `handle_info`, no call timeout — both deferred to land with timeouts
(`handle_info` needs the still-unmade cross-channel mailbox merge; a call
timeout needs a per-`recv` deadline / `Signal::Timeout`).
- Pure additive layer (no Slot/scheduler/spawn/finalize change) → no perf
check run. Tests (`tests/gen_server.rs`): cast→call roundtrip, init/terminate
ordering, both server-down paths.
- Docs: README now points at the experimental, untested aarch64 port on the
`arm-port` branch. The module table still calls `context` x86-64-only — true
for `master`, since the `src/arch/` split rides on `arm-port`. Fold the arch/
split and ARM64-supported wording into the README module table + build section
once `arm-port` is validated on hardware and merged.
## Gotchas / invariants (respect these)
- **Shared mutex is non-reentrant.** `Sender::send` can call `unpark` →
`with_shared`. NEVER send on a channel while holding the shared lock. Pattern:
`mem::take` the senders/data under the lock, send after releasing. See
`finalize_actor` (supervisor signal + monitor Downs both sent post-lock).
- **`finalize_actor` order:** take stack/waiters/monitors under lock + set
Done/outcome → recycle stack (post-lock) → deliver supervisor Signal + monitor
Downs (post-lock) → unpark joiners → reclaim slot iff `outstanding_handles==0`.
Death notifications always precede reclamation, so a pid carried in a
Signal/Down is still matchable even as its slot is about to be reused.
- **Slot lifecycle is reset in THREE places** — `Slot::vacant()`,
`reclaim_slot()` (runtime.rs), and the slot-init block in `spawn_under`
(scheduler.rs). Any new Slot field must be reset in all three (monitors was).
- **Pid = (index, generation);** stale handles caught by generation mismatch in
`slot()/slot_mut()`. The monitor `NoProc` path relies on this.
- **No `select`, no unified per-process mailbox.** Why the supervisor uses the
single `supervisor_channel` funnel rather than N monitor channels. trap_exit
resolved this by giving each trapping actor a dedicated `Receiver<ExitSignal>`
inbox (see #3); selective receive (#4) stayed *per-channel* (`recv_match`
scans one channel's queue) rather than introducing a cross-channel mailbox —
if selective receive ever needs to span the monitor/trap inboxes too, that
cross-channel merge is the still-unmade decision.
- **Cooperative-only**: preemption and (future) cancellation both depend on the
actor reaching `check!()`/yield/alloc/blocking points.
- `run()` is single-thread (`Config::exact(1)`); tests rely on deterministic
single-thread ordering (parent runs until it parks). Multi-thread via
`runtime::init(Config…)`.
## Workflow expectations (from the human)
- TDD: write the failing test first, then implement.
- Commit incrementally with conventional-commit messages; keep each commit a
reviewable unit (they diff in their IDE and are the filter to the codebase).
- Run the full suite before each commit; check perf when a change touches
Slot/scheduler/spawn/finalize hot paths.
+132
View File
@@ -0,0 +1,132 @@
//! Cooperative cancellation tests (roadmap item #1, the keystone).
//!
//! `request_stop(pid)` flags an actor for cancellation. The actor realizes the
//! stop as a *controlled unwind*: at the next observation point (a `check!()`
//! / allocation via `maybe_preempt`, or the wakeup side of any blocking park)
//! a dedicated sentinel panic is raised, the trampoline's `catch_unwind` tears
//! the stack down — running Drop guards — and reports `Outcome::Stopped`,
//! distinct from a user `Panic`. Monitors see `DownReason::Stopped`.
//!
//! These cases are deterministic under the single-thread runtime: the parent
//! runs until it parks, so the relative order of `request_stop`, the child
//! reaching its observation point, and the monitor `Down` is fixed.
use smarm::{channel, monitor, request_stop, run, spawn, yield_now, DownReason};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Sets its flag when dropped — used to prove the cancellation unwind runs
/// Drop guards rather than leaking the stack.
struct DropFlag(Arc<AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[test]
fn looping_actor_on_check_is_stopped() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
let h = spawn(move || {
let _g = DropFlag(d);
// Tight loop whose only observation point is check!().
loop {
smarm::check!();
}
});
let pid = h.pid();
let down = monitor(pid);
// Flag the stop before the child is ever resumed; it will observe the
// flag once its check!() loop reaches the amortised preempt check.
request_stop(pid);
let dn = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, pid);
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind");
}
#[test]
fn parked_on_recv_actor_is_stopped() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
let h = spawn(move || {
let _g = DropFlag(d);
let (tx, rx) = channel::<u8>();
// Keep a sender alive so the channel stays open and recv() parks
// indefinitely rather than returning Err.
let _keep = tx;
let _ = rx.recv(); // parks here until the stop unwinds us out
});
let pid = h.pid();
let down = monitor(pid);
// Let the child run and park in recv() before we request the stop.
yield_now();
request_stop(pid);
let dn = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, pid);
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run on cancellation of a parked actor");
}
#[test]
fn no_check_no_alloc_loop_is_not_stopped() {
// Documents the inherent gap: an actor that never reaches an observation
// point (no check!(), no allocation, no blocking op) cannot be stopped —
// the same limitation as preemption. Here the child runs a bounded,
// allocation-free arithmetic loop, so the stop flagged before it runs is
// silently never honored and the actor exits normally.
let saw_exit = Arc::new(AtomicBool::new(false));
let s = saw_exit.clone();
run(move || {
let h = spawn(|| {
let mut x: u64 = 0;
for i in 0..2_000_000u64 {
x = x.wrapping_add(i ^ (x >> 1));
}
std::hint::black_box(x);
});
let pid = h.pid();
let down = monitor(pid);
request_stop(pid); // no observation point => ignored
let dn = down.rx.recv().expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Exit) {
s.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(
saw_exit.load(Ordering::SeqCst),
"a loop with no observation points must exit normally, never Stopped"
);
}
#[test]
fn join_on_stopped_actor_returns_ok() {
// A cooperative stop carries no panic payload to propagate, so join()
// reports Ok(()); the *fact* of the stop is observable via monitors
// (DownReason::Stopped), which is the channel that carries termination
// reason.
run(|| {
let h = spawn(|| loop {
smarm::check!();
});
let pid = h.pid();
request_stop(pid);
assert!(h.join().is_ok(), "join on a stopped actor returns Ok(())");
});
}
+34
View File
@@ -58,6 +58,7 @@ use std::sync::OnceLock;
static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new(); static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new();
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new(); static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
#[cfg(target_arch = "x86_64")]
extern "C-unwind" fn actor_reg_check() { extern "C-unwind" fn actor_reg_check() {
unsafe { unsafe {
let s0: u64 = 0xAAAA_BBBB_0000_0001; let s0: u64 = 0xAAAA_BBBB_0000_0001;
@@ -83,6 +84,39 @@ extern "C-unwind" fn actor_reg_check() {
} }
} }
// aarch64 sibling: same intent, AAPCS64 callee-saved integer registers.
// LLVM reserves x19 (and uses x29/x30 as fp/lr), so those can't be named as
// asm operands. We use x23x26 instead — callee-saved and bindable — moving
// known values into them, yielding, then reading them back, exactly mirroring
// the x86 test's move-into-r12..r15 pattern. The shim saves x23x26 in its
// `stp x23,x24` / `stp x25,x26` pairs, so a clean round-trip proves they
// survive the switch.
#[cfg(target_arch = "aarch64")]
extern "C-unwind" fn actor_reg_check() {
unsafe {
let s0: u64 = 0xAAAA_BBBB_0000_0001;
let s1: u64 = 0xCCCC_DDDD_0000_0002;
let s2: u64 = 0xEEEE_FFFF_0000_0003;
let s3: u64 = 0x1111_2222_0000_0004;
core::arch::asm!(
"mov x23, {s0}", "mov x24, {s1}", "mov x25, {s2}", "mov x26, {s3}",
s0 = in(reg) s0, s1 = in(reg) s1, s2 = in(reg) s2, s3 = in(reg) s3,
out("x23") _, out("x24") _, out("x25") _, out("x26") _,
);
REG_BEFORE.set([s0, s1, s2, s3]).ok();
switch_to_scheduler();
let a0: u64; let a1: u64; let a2: u64; let a3: u64;
core::arch::asm!(
"mov {a0}, x23", "mov {a1}, x24", "mov {a2}, x25", "mov {a3}, x26",
a0 = out(reg) a0, a1 = out(reg) a1, a2 = out(reg) a2, a3 = out(reg) a3,
);
REG_AFTER.set([a0, a1, a2, a3]).ok();
switch_to_scheduler();
}
}
#[test] #[test]
fn callee_saved_registers_survive_yield() { fn callee_saved_registers_survive_yield() {
let stack = Stack::new(64 * 1024).unwrap(); let stack = Stack::new(64 * 1024).unwrap();
+137
View File
@@ -0,0 +1,137 @@
//! gen_server tests: call round-trip, cast, lifecycle callbacks, and the two
//! server-down detection paths (reply-channel close vs. inbox-send failure).
use smarm::gen_server::{start, CallError, GenServer};
use smarm::run;
use std::sync::{Arc, Mutex};
// ---------------------------------------------------------------------------
// A trivial counter server: casts mutate, calls read (or blow up).
// ---------------------------------------------------------------------------
struct Counter {
n: i64,
}
enum Req {
Get,
Boom,
}
enum Op {
Add(i64),
}
impl GenServer for Counter {
type Call = Req;
type Reply = i64;
type Cast = Op;
fn handle_call(&mut self, req: Req) -> i64 {
match req {
Req::Get => self.n,
Req::Boom => panic!("boom"),
}
}
fn handle_cast(&mut self, op: Op) {
match op {
Op::Add(x) => self.n += x,
}
}
}
// Casts are applied in order and a later call observes the accumulated state.
#[test]
fn cast_then_call_roundtrip() {
let got = Arc::new(Mutex::new(0i64));
let got2 = got.clone();
run(move || {
let server = start(Counter { n: 0 });
server.cast(Op::Add(5)).unwrap();
server.cast(Op::Add(3)).unwrap();
let n = server.call(Req::Get).unwrap();
*got2.lock().unwrap() = n;
});
assert_eq!(*got.lock().unwrap(), 8);
}
// ---------------------------------------------------------------------------
// Lifecycle: init runs before the first message, terminate on graceful exit.
// ---------------------------------------------------------------------------
struct Lifecycle {
log: Arc<Mutex<Vec<&'static str>>>,
}
impl GenServer for Lifecycle {
type Call = ();
type Reply = ();
type Cast = ();
fn init(&mut self) {
self.log.lock().unwrap().push("init");
}
fn handle_call(&mut self, _req: ()) {
self.log.lock().unwrap().push("call");
}
fn handle_cast(&mut self, _req: ()) {}
fn terminate(&mut self) {
self.log.lock().unwrap().push("terminate");
}
}
// init -> handle_call -> (drop last ref closes inbox) -> terminate.
#[test]
fn init_and_terminate_run() {
let log = Arc::new(Mutex::new(Vec::new()));
let log2 = log.clone();
run(move || {
let server = start(Lifecycle { log: log2 });
server.call(()).unwrap();
// Dropping the only ref closes the inbox; the server breaks out of its
// recv loop and runs terminate. run() will not return until it has.
drop(server);
});
assert_eq!(*log.lock().unwrap(), vec!["init", "call", "terminate"]);
}
// ---------------------------------------------------------------------------
// Server-down detection.
// ---------------------------------------------------------------------------
// The server dies *while* a call is in flight (handler panics): the reply
// sender drops on unwind, closing the reply channel, so the parked caller's
// recv returns Err -> ServerDown.
#[test]
fn call_to_panicking_handler_is_server_down() {
let got = Arc::new(Mutex::new(None));
let got2 = got.clone();
run(move || {
let server = start(Counter { n: 0 });
let r = server.call(Req::Boom);
*got2.lock().unwrap() = Some(r);
});
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
}
// A call issued *after* the server is already gone: the inbox is closed, so the
// send itself fails -> ServerDown (the other detection path).
#[test]
fn call_after_server_gone_is_server_down() {
let got = Arc::new(Mutex::new(None));
let got2 = got.clone();
run(move || {
let server = start(Counter { n: 0 });
let server2 = server.clone();
// This kills the server (and is itself ServerDown).
assert_eq!(server.call(Req::Boom), Err(CallError::ServerDown));
// Inbox now closed; a fresh call can't even be enqueued.
let r = server2.call(Req::Get);
*got2.lock().unwrap() = Some(r);
});
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
}
+223
View File
@@ -0,0 +1,223 @@
//! Link + trap_exit tests (roadmap item #3).
//!
//! A *link* is bidirectional and persistent (contrast a monitor, which is
//! unidirectional and one-shot). When a linked actor dies *abnormally* — a
//! panic or a cooperative `request_stop` — the death propagates to the peer:
//!
//! - a peer that has NOT trapped exits is cooperatively `request_stop`'d, so
//! a crash fate-shares across the link set (Erlang's "let it crash");
//! - a peer that HAS called `trap_exit()` instead receives an [`ExitSignal`]
//! *message* on its trap inbox and survives.
//!
//! A *normal* exit never propagates — not even to a trapping peer. Linking an
//! already-dead pid delivers an immediate exit signal (`NoProc`): a message to
//! a trapping caller, a `request_stop` to a non-trapping one.
//!
//! These cases are deterministic under the single-thread runtime: an actor
//! runs until it parks/yields, so the relative order of link setup, the
//! triggering death, and the propagated signal is fixed.
use smarm::{
channel, link, monitor, run, self_pid, spawn, trap_exit, unlink, yield_now, DownReason,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Sets its flag when dropped — proves a propagated stop unwound the peer's
/// stack (running Drop) rather than leaking it.
struct DropFlag(Arc<AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
/// Abnormal death of one linked actor cooperatively stops its non-trapping
/// peer, and the peer's Drop guards run during the propagated unwind.
#[test]
fn linked_pair_one_panics_other_is_stopped() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
// B parks on recv (kept open by a retained sender) so it is alive and
// suspended when the propagated stop arrives.
let hb = spawn(move || {
let _g = DropFlag(d);
let (tx, rx) = channel::<u8>();
let _keep = tx;
let _ = rx.recv(); // parks until the link-propagated stop unwinds us
});
let b = hb.pid();
let down_b = monitor(b);
// A links B, then panics. The panic propagates along the link to B.
let ha = spawn(move || {
link(b);
panic!("boom");
});
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, b, "Down reported the wrong pid");
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
drop(ha);
let _ = hb.join();
});
assert!(
saw_stopped.load(Ordering::SeqCst),
"peer should be Stopped by the propagated abnormal exit"
);
assert!(
dropped.load(Ordering::SeqCst),
"peer's Drop guard must run during the propagated cancellation unwind"
);
}
/// A trapping peer receives an abnormal death as an `ExitSignal` message and
/// keeps running instead of being torn down.
#[test]
fn trap_exit_turns_abnormal_death_into_a_message() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let h = spawn(move || {
let inbox = trap_exit();
// Spawn the peer from here so we control ordering: it is enqueued
// behind us and runs once we park on `inbox.recv()`.
let ha = spawn(|| panic!("peer down"));
let a = ha.pid();
link(a);
// Parks here; A then runs, panics, and the trap message wakes us.
let sig = inbox.recv().expect("trap inbox closed without a signal");
if sig.from == a && matches!(sig.reason, DownReason::Panic) {
o.store(true, Ordering::SeqCst);
}
drop(ha);
});
let _ = h.join();
});
assert!(
ok.load(Ordering::SeqCst),
"trapping peer should receive ExitSignal{{from, Panic}} and survive"
);
}
/// A normal exit never propagates — a trapping linked peer sees no message and
/// stays alive.
#[test]
fn normal_exit_does_not_propagate() {
let empty = Arc::new(AtomicBool::new(false));
let e = empty.clone();
run(move || {
let h = spawn(move || {
let inbox = trap_exit();
let ha = spawn(|| { /* exits normally, immediately */ });
let a = ha.pid();
link(a);
yield_now(); // let A run to completion and finalize
// A exited normally: nothing should have landed on the inbox.
if let Ok(None) = inbox.try_recv() {
e.store(true, Ordering::SeqCst);
}
drop(ha);
});
let _ = h.join();
});
assert!(
empty.load(Ordering::SeqCst),
"normal exit must not deliver any ExitSignal to a trapping peer"
);
}
/// Linking an already-dead pid from a non-trapping actor stops the caller
/// (no silent no-op).
#[test]
fn link_to_dead_pid_stops_a_nontrapping_caller() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
let ha = spawn(|| {});
let a = ha.pid();
ha.join().unwrap(); // A finishes and is reclaimed → `a` is now stale
let hb = spawn(move || {
let _g = DropFlag(d);
link(a); // dead target, not trapping → request_stop(self)
loop {
smarm::check!(); // observation point to realize the stop
}
});
let b = hb.pid();
let down_b = monitor(b);
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
let _ = hb.join();
});
assert!(
saw_stopped.load(Ordering::SeqCst),
"linking a dead pid (non-trapping) should stop the caller"
);
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run");
}
/// Linking an already-dead pid from a trapping actor delivers an immediate
/// `NoProc` exit message instead of killing it.
#[test]
fn link_to_dead_pid_messages_a_trapping_caller() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let ha = spawn(|| {});
let a = ha.pid();
ha.join().unwrap(); // `a` is now stale
let hb = spawn(move || {
let inbox = trap_exit();
link(a); // dead target, trapping → immediate NoProc message
let sig = inbox.recv().expect("trap inbox closed without a signal");
if sig.from == a && matches!(sig.reason, DownReason::NoProc) {
o.store(true, Ordering::SeqCst);
}
});
let _ = hb.join();
});
assert!(
ok.load(Ordering::SeqCst),
"linking a dead pid (trapping) should deliver a NoProc ExitSignal"
);
}
/// `unlink` removes the relationship in both directions: an abnormal death no
/// longer reaches the formerly-linked peer.
#[test]
fn unlink_prevents_propagation() {
let survived = Arc::new(AtomicBool::new(false));
let sv = survived.clone();
run(move || {
let h = spawn(move || {
let b = self_pid();
let inbox = trap_exit();
let ha = spawn(move || {
link(b);
unlink(b);
panic!("boom"); // abnormal, but the link is gone
});
yield_now(); // let A link, unlink, and panic
// Unlinked before death → no ExitSignal should have arrived.
if let Ok(None) = inbox.try_recv() {
sv.store(true, Ordering::SeqCst);
}
drop(ha);
});
let _ = h.join();
});
assert!(
survived.load(Ordering::SeqCst),
"unlinked peer must not receive a propagated exit"
);
}
+98
View File
@@ -0,0 +1,98 @@
//! Regression test: multi-thread sleep timer lost-wakeup.
//!
//! Lifted from the `many_timers` workload in `benches/tokio_favored.rs`:
//! N actors each sleep for a short randomised duration and join. On a
//! multi-threaded runtime this hangs because the timer drain path drops
//! wakeups for actors that are still in `State::Runnable` at drain time
//! (i.e. between `timers.insert_sleep` and `park_current` in `sleep()`).
//!
//! The test runs the workload on a background OS thread with a wall-clock
//! watchdog so a deadlock fails the test instead of hanging the suite.
use smarm::runtime::Config;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
// Same shape as benches/tokio_favored.rs::timer_delay_ms — deterministic
// per-actor delay so the test is comparable across runs.
const TIMER_ACTORS: u64 = 10_000;
const TIMER_MIN_MS: u64 = 1;
const TIMER_MAX_MS: u64 = 10;
fn timer_delay_ms(i: u64) -> u64 {
TIMER_MIN_MS + (i.wrapping_mul(2654435761u64) >> 32) % (TIMER_MAX_MS - TIMER_MIN_MS + 1)
}
fn many_timers_workload(threads: usize) {
smarm::runtime::init(Config::exact(threads)).run(|| {
let mut handles = Vec::with_capacity(TIMER_ACTORS as usize);
for i in 0..TIMER_ACTORS {
let ms = timer_delay_ms(i);
handles.push(smarm::spawn(move || {
smarm::sleep(Duration::from_millis(ms));
}));
}
for h in handles {
h.join().unwrap();
}
});
}
/// Wall-clock budget for the workload. The 1-thread version completes in
/// ~130ms on the reporter's machine; the workload's intrinsic minimum is
/// `TIMER_MAX_MS` (10ms). Anything past several seconds is a hang.
const WATCHDOG: Duration = Duration::from_secs(15);
fn run_with_watchdog(threads: usize) {
let (done_tx, done_rx) = mpsc::channel::<()>();
let worker = thread::Builder::new()
.name(format!("many_timers-{}-thread", threads))
.spawn(move || {
many_timers_workload(threads);
let _ = done_tx.send(());
})
.expect("spawn worker thread");
let start = Instant::now();
match done_rx.recv_timeout(WATCHDOG) {
Ok(()) => {
let _ = worker.join();
eprintln!(
"many_timers ({} threads): completed in {:?}",
threads,
start.elapsed()
);
}
Err(_) => {
// The worker is wedged on a smarm scheduler that won't unwind.
// We can't join it; report and abort so the test fails loudly
// instead of the harness hanging on drop.
eprintln!(
"many_timers ({} threads): DEADLOCK — no progress in {:?}",
threads, WATCHDOG
);
std::process::exit(101);
}
}
}
#[test]
fn many_timers_single_thread_completes() {
run_with_watchdog(1);
}
#[test]
fn many_timers_multi_thread_completes() {
// Repro: this is the case that hangs in benches/tokio_favored.rs.
//
// The bug needs ≥2 scheduler threads (one draining timers while
// another is running sleep() between insert and park_current). The
// reporter hit it at 24; we use max(8, available_parallelism()) so
// single-CPU CI sandboxes still trigger it.
let n = thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.max(8);
run_with_watchdog(n);
}
+142
View File
@@ -0,0 +1,142 @@
//! Monitor tests: `monitor(pid)` delivers exactly one `Down` describing how
//! the target terminated, and `demonitor(&m)` tears a registration back down.
//!
//! A monitor is unidirectional and one-shot: it never propagates failure to
//! the watcher (that's a link), it just drops a `Down` into the returned
//! channel. These tests pin the three reasons — `Exit`, `Panic`, `NoProc` —
//! the fan-out case where several monitors watch the same target, and that
//! `demonitor` removes exactly the one registration it names.
//!
//! Scheduling note: under the default single-thread runtime the parent runs
//! until it parks, so every `monitor()` call below registers while the target
//! is still `Runnable` (it hasn't been resumed yet). Registration and
//! `finalize_actor` both serialize on the shared mutex, so a target that is
//! alive at registration time always produces a real `Down`, never a missed
//! one.
use smarm::{demonitor, monitor, run, spawn, DownReason};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
#[test]
fn monitor_sees_normal_exit() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let h = spawn(|| {});
let pid = h.pid();
let down = monitor(pid);
let d = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(d.pid, pid, "Down reported the wrong pid");
if matches!(d.reason, DownReason::Exit) {
o.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(ok.load(Ordering::SeqCst), "expected DownReason::Exit");
}
#[test]
fn monitor_sees_panic() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let h = spawn(|| panic!("boom"));
let pid = h.pid();
let down = monitor(pid);
let d = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(d.pid, pid);
if matches!(d.reason, DownReason::Panic) {
o.store(true, Ordering::SeqCst);
}
// Drain the panic outcome so the joiner path is exercised too; the
// payload goes here, not to the monitor.
let _ = h.join();
});
assert!(ok.load(Ordering::SeqCst), "expected DownReason::Panic");
}
#[test]
fn monitor_already_dead_target_is_noproc() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let h = spawn(|| {});
let pid = h.pid();
// Consume the only handle on a finished child: the slot is reclaimed
// and its generation bumped, so `pid` is now stale.
h.join().unwrap();
let down = monitor(pid);
let d = down.rx.recv().expect("NoProc Down should be delivered immediately");
assert_eq!(d.pid, pid);
if matches!(d.reason, DownReason::NoProc) {
o.store(true, Ordering::SeqCst);
}
});
assert!(ok.load(Ordering::SeqCst), "expected DownReason::NoProc");
}
#[test]
fn multiple_monitors_all_notified() {
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
run(move || {
let h = spawn(|| {});
let pid = h.pid();
let monitors = [monitor(pid), monitor(pid), monitor(pid)];
let _ = h.join();
for m in monitors {
if matches!(m.rx.recv().unwrap().reason, DownReason::Exit) {
c.fetch_add(1, Ordering::SeqCst);
}
}
});
assert_eq!(count.load(Ordering::SeqCst), 3, "every monitor should see the Down");
}
#[test]
fn demonitor_stops_delivery() {
// Demonitoring a live registration removes the slot's only sender for that
// channel; the channel closes, so a later recv() observes Err rather than
// a Down. demonitor reports the id it tore down.
run(|| {
let h = spawn(|| {});
let pid = h.pid();
let m = monitor(pid);
assert_eq!(demonitor(&m), Some(m.id), "live registration should be removed");
assert!(m.rx.recv().is_err(), "no Down should arrive after demonitor");
let _ = h.join();
});
}
#[test]
fn demonitor_one_of_many() {
// Three monitors on one target; demonitor the middle. The other two still
// fire; the demonitored channel is closed with no Down. Distinct ids mean
// tearing one down never disturbs its siblings.
run(|| {
let h = spawn(|| {});
let pid = h.pid();
let ms = [monitor(pid), monitor(pid), monitor(pid)];
assert_eq!(demonitor(&ms[1]), Some(ms[1].id));
let _ = h.join();
assert!(matches!(ms[0].rx.recv().unwrap().reason, DownReason::Exit));
assert!(matches!(ms[2].rx.recv().unwrap().reason, DownReason::Exit));
assert!(ms[1].rx.recv().is_err(), "demonitored channel should be closed");
});
}
#[test]
fn demonitor_after_fire_is_none() {
// Once the Down has fired, the registration is already drained from the
// slot, so demonitor finds nothing to remove and reports None.
run(|| {
let h = spawn(|| {});
let pid = h.pid();
let m = monitor(pid);
let d = m.rx.recv().expect("Down before close");
assert!(matches!(d.reason, DownReason::Exit));
assert_eq!(demonitor(&m), None, "already-fired monitor has nothing to remove");
let _ = h.join();
});
}
+69
View File
@@ -64,3 +64,72 @@ fn check_is_a_noop_when_timeslice_not_expired() {
}); });
assert_eq!(count.load(Ordering::Relaxed), 1_000); assert_eq!(count.load(Ordering::Relaxed), 1_000);
} }
// ---------------------------------------------------------------------------
// XMM-not-saved assumption (context.rs): the context switch saves no SSE
// state. The justification is that every yield crosses a Rust `call` boundary
// (SysV AMD64: XMM0-15 caller-saved), so the compiler spills live XMM before
// the switch and reloads after. The non-obvious path is PREEMPTION: `check!()`
// inlines `maybe_preempt`, so the yield can fire mid-floating-point-loop, not
// at a syntactic call site. This is adversarial because the yield is buried
// inside the actor's own hot loop — but `switch_to_scheduler` is still an
// `extern "C"` call, so the spill still happens. Verified by disasm: the FP
// accumulators are spilled to (%rsp) immediately before the preempt path and
// reloaded after. This test guards against a future change to the yield path
// that bypasses that call boundary (which WOULD require saving XMM).
#[test]
fn live_xmm_survives_preemptive_switch() {
// A floating-point loop with several live f64 accumulators the compiler
// wants resident in XMM across iterations. We force a timeslice expiry on
// a chosen iteration so a preemptive switch_to_scheduler fires while XMM
// is live, then compare against the same workload run with no preemption.
#[inline(never)]
fn fp_workload(preempt_at: u64) -> f64 {
let mut a = 1.0000001_f64;
let mut b = 0.9999999_f64;
let mut acc = 0.0_f64;
for i in 0..50_000u64 {
a = a * 1.0000003 + 0.0000001;
b = b * 0.9999997 + 0.0000002;
acc += a - b + (i as f64) * 1e-9;
if i == preempt_at {
smarm::preempt::expire_timeslice_for_test();
}
smarm::check!();
}
acc
}
// Reference: a preempt_at past the loop end => check!() never yields.
let reference = fp_workload(u64::MAX);
let got = Arc::new(AtomicU64::new(0));
let g = got.clone();
run(move || {
// A second actor so the scheduler has somewhere to switch on yield.
let _other = spawn(|| {
for _ in 0..8 {
smarm::preempt::expire_timeslice_for_test();
smarm::check!();
}
});
let h = spawn(move || {
// Yield from several distinct points so the switch lands on
// different live-XMM states.
let mut total = 0.0_f64;
for k in 0..8 {
total += fp_workload(k * 6_000 + 100);
}
g.store(total.to_bits(), Ordering::SeqCst);
});
h.join().unwrap();
});
let reference_total: f64 = (0..8).map(|_| reference).sum();
let got = f64::from_bits(got.load(Ordering::SeqCst));
assert_eq!(
got.to_bits(),
reference_total.to_bits(),
"XMM state diverged across preemptive switch: got {got:.12}, want {reference_total:.12}"
);
}
+65 -4
View File
@@ -15,10 +15,7 @@
//! - Panic on one scheduler thread doesn't kill others //! - Panic on one scheduler thread doesn't kill others
use smarm::{channel, runtime::{Config, Runtime}, spawn, yield_now, JoinHandle}; use smarm::{channel, runtime::{Config, Runtime}, spawn, yield_now, JoinHandle};
use std::sync::{ use std::sync::{atomic::{AtomicBool, AtomicU64, Ordering}, Arc};
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
Arc, Barrier,
};
use std::time::Duration; use std::time::Duration;
use std::collections::HashSet; use std::collections::HashSet;
@@ -424,3 +421,67 @@ fn ping_pong_completes() {
}); });
assert_eq!(final_val.load(Ordering::SeqCst), ROUNDS); assert_eq!(final_val.load(Ordering::SeqCst), ROUNDS);
} }
/// Regression test for the multi-scheduler timer-only hang.
///
/// Bug: when the run queue is empty and only timers are pending (no IO
/// outstanding), all N scheduler threads called `poll_wake(wake_fd,
/// Some(timeout))` on the *same* pipe fd. When the timer fired, the one
/// thread that won `drain_lock` consumed the single wake byte and re-queued
/// the actors; the other N-1 threads stayed blocked in `poll()` for the full
/// timeout duration. After actors completed and `all_clear` became true, the
/// stuck threads had to wait out the remainder of their poll timeout before
/// noticing — adding up to one full sleep duration of extra latency.
///
/// Fix: use `thread::sleep(timeout)` when `io_outstanding == 0`, so every
/// scheduler thread independently wakes at the deadline without contending
/// on the wake pipe.
///
/// Signal: with SLEEP_MS=300 and ≥2 scheduler threads, the broken impl
/// takes ≥2×SLEEP_MS (actors sleep + stuck threads drain their poll timeout
/// before seeing all_clear). The fixed impl takes ≈SLEEP_MS + epsilon.
#[test]
fn multi_thread_timer_only_no_pipe_contention() {
const SLEEP_MS: u64 = 300;
const ACTORS: usize = 100;
// Need at least 2 scheduler threads: one wins drain_lock and does work,
// the rest pile into poll_wake and get stuck.
let n = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.max(2);
let r = smarm::runtime::init(Config::exact(n.min(4)));
let count = Arc::new(AtomicU64::new(0));
let c = count.clone();
let start = std::time::Instant::now();
r.run(move || {
let mut handles = Vec::new();
for _ in 0..ACTORS {
let cc = c.clone();
handles.push(spawn(move || {
// Pure timer sleep: no channels, no IO.
// All scheduler threads will be idle simultaneously while
// these timers are pending — the condition that triggers the bug.
smarm::sleep(Duration::from_millis(SLEEP_MS));
cc.fetch_add(1, Ordering::SeqCst);
}));
}
for h in handles {
h.join().unwrap();
}
});
assert_eq!(count.load(Ordering::SeqCst), ACTORS as u64, "not all actors completed");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(SLEEP_MS * 2),
"run() took {elapsed:?} — expected <{}ms; likely stuck threads draining \
their poll_wake timeout after all_clear (bug: scheduler threads poll \
the shared wake pipe instead of sleeping independently for timer-only workloads)",
SLEEP_MS * 2,
);
}
+193
View File
@@ -0,0 +1,193 @@
//! Selective-receive tests: `Receiver::recv_match` and `try_recv_match`.
//!
//! `recv_match(pred)` scans the queued messages, removes and returns the first
//! one for which `pred` holds, and leaves the rest in arrival order. With no
//! match it parks and re-scans on every send (a selective receiver may park on
//! a *non-empty* queue), returning `Err` only once the channel is closed and no
//! queued message matches. `try_recv_match` is the non-blocking variant.
use smarm::{channel, run, spawn};
use std::sync::{Arc, Mutex};
// ---------------------------------------------------------------------------
// recv_match — same-actor scan semantics (no parking)
// ---------------------------------------------------------------------------
// First matching message is pulled out of arrival order; non-matches stay
// queued in their original order for later plain recv().
#[test]
fn match_pulled_first_non_matches_remain_in_order() {
let got = Arc::new(Mutex::new(Vec::<i64>::new()));
let got2 = got.clone();
run(move || {
let (tx, rx) = channel::<i64>();
for v in [1, 2, 3] {
tx.send(v).unwrap();
}
// 2 is the only even; it's pulled despite arriving second.
let m = rx.recv_match(|&v| v % 2 == 0).unwrap();
// The non-matches remain, in order: 1 then 3.
let a = rx.recv().unwrap();
let b = rx.recv().unwrap();
let mut g = got2.lock().unwrap();
g.push(m);
g.push(a);
g.push(b);
});
assert_eq!(*got.lock().unwrap(), vec![2, 1, 3]);
}
// A read-only captured value (request-id style) is a plain `Fn` predicate.
#[test]
fn matches_on_captured_value() {
let got = Arc::new(Mutex::new(0i64));
let got2 = got.clone();
run(move || {
let (tx, rx) = channel::<i64>();
for v in [10, 20, 30] {
tx.send(v).unwrap();
}
let target = 20;
let m = rx.recv_match(move |&v| v == target).unwrap();
*got2.lock().unwrap() = m;
});
assert_eq!(*got.lock().unwrap(), 20);
}
// ---------------------------------------------------------------------------
// recv_match — parking on a non-empty, no-match queue
// ---------------------------------------------------------------------------
// The receiver parks while the queue holds only non-matching messages, must
// wake on a send that does NOT empty->fill the queue, re-scan, re-park on a
// still-non-matching send, and finally return when a match arrives.
#[test]
fn parks_on_non_matching_queue_then_wakes_on_match() {
let got = Arc::new(Mutex::new(0i64));
let got2 = got.clone();
run(move || {
let (tx, rx) = channel::<i64>();
tx.send(1).unwrap(); // odd: queued, will not match
let h = spawn(move || {
// Scans [1], no even -> parks on a non-empty queue.
let v = rx.recv_match(|&v| v % 2 == 0).unwrap();
*got2.lock().unwrap() = v;
});
smarm::yield_now(); // let the child park
tx.send(3).unwrap(); // odd: child wakes, re-scans [1,3], re-parks
tx.send(4).unwrap(); // even: child wakes, scans [1,3,4], returns 4
h.join().unwrap();
});
assert_eq!(*got.lock().unwrap(), 4);
}
// ---------------------------------------------------------------------------
// recv_match — close semantics
// ---------------------------------------------------------------------------
// Channel closes (all senders dropped) while only non-matching messages are
// queued: recv_match must return Err rather than block forever.
#[test]
fn closed_with_only_non_matches_returns_err() {
let saw_err = Arc::new(Mutex::new(false));
let saw_err2 = saw_err.clone();
run(move || {
let (tx, rx) = channel::<i64>();
let h = spawn(move || {
// Wants an even; only odds will ever exist.
let r = rx.recv_match(|&v| v % 2 == 0);
*saw_err2.lock().unwrap() = r.is_err();
});
smarm::yield_now(); // child parks on empty queue
tx.send(1).unwrap(); // odd: wakes, re-scans, re-parks (no match)
drop(tx); // last sender gone, still no match -> Err
h.join().unwrap();
});
assert!(*saw_err.lock().unwrap());
}
// A match present on a closed channel is still returned (closure doesn't
// pre-empt an available match).
#[test]
fn closed_but_match_present_returns_match() {
let got = Arc::new(Mutex::new(0i64));
let got2 = got.clone();
run(move || {
let (tx, rx) = channel::<i64>();
tx.send(1).unwrap();
tx.send(2).unwrap();
drop(tx); // closed, but a match (2) is queued
let m = rx.recv_match(|&v| v % 2 == 0).unwrap();
*got2.lock().unwrap() = m;
});
assert_eq!(*got.lock().unwrap(), 2);
}
// ---------------------------------------------------------------------------
// try_recv_match — non-blocking
// ---------------------------------------------------------------------------
#[test]
fn try_recv_match_states() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let out2 = out.clone();
run(move || {
let (tx, rx) = channel::<i64>();
let log = |s: &str| out2.lock().unwrap().push(s.to_string());
// Empty + open -> Ok(None).
log(match rx.try_recv_match(|&v| v % 2 == 0) {
Ok(None) => "open_empty_none",
_ => "WRONG_1",
});
// Non-matching present + open -> Ok(None), message left in place.
tx.send(1).unwrap();
log(match rx.try_recv_match(|&v| v % 2 == 0) {
Ok(None) => "open_nomatch_none",
_ => "WRONG_2",
});
// Match present -> Ok(Some(v)).
tx.send(2).unwrap();
log(match rx.try_recv_match(|&v| v % 2 == 0) {
Ok(Some(2)) => "got_match",
_ => "WRONG_3",
});
// Drain the leftover non-match with a plain recv to confirm it stayed.
log(match rx.recv() {
Ok(1) => "leftover_one",
_ => "WRONG_4",
});
// Closed + no match -> Err.
drop(tx);
log(match rx.try_recv_match(|&v| v % 2 == 0) {
Err(_) => "closed_err",
_ => "WRONG_5",
});
});
assert_eq!(
*out.lock().unwrap(),
vec![
"open_empty_none",
"open_nomatch_none",
"got_match",
"leftover_one",
"closed_err",
]
);
}
+271
View File
@@ -0,0 +1,271 @@
//! One-for-one supervisor tests.
//!
//! A `OneForOne` runs as an ordinary actor: it spawns its children under
//! itself, collects their termination `Signal`s on a single mailbox, and
//! restarts them per policy until either every child is in a terminal,
//! non-restartable state (then `run()` returns) or the restart intensity cap
//! is tripped (then `run()` gives up and returns).
//!
//! Policies:
//! - `Permanent` — restart on any termination (normal or panic).
//! - `Transient` — restart only on panic; a normal exit is "done".
//! - `Temporary` — never restart.
//!
//! All cases below are deterministic under the single-thread runtime: the
//! supervisor parks in `recv()` while a child runs to completion, so signals
//! arrive one at a time in a fixed order.
use smarm::supervisor::{ChildSpec, OneForOne, Restart, Strategy};
use smarm::{run, sleep, spawn};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn counting_child(
counter: &Arc<AtomicUsize>,
behavior: impl Fn(usize) + Send + Sync + 'static,
) -> impl Fn() + Send + Sync + 'static {
let c = counter.clone();
move || {
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
behavior(n);
}
}
#[test]
fn transient_child_is_restarted_on_panic_then_settles() {
let runs = Arc::new(AtomicUsize::new(0));
let r = runs.clone();
run(move || {
let child = counting_child(&r, |n| {
if n < 3 {
panic!("crash {n}");
}
// 3rd run returns normally -> Transient does not restart.
});
let sup = spawn(move || {
OneForOne::new()
.intensity(10, Duration::from_secs(60))
.child(ChildSpec::new(Restart::Transient, child))
.run();
});
sup.join().unwrap();
});
assert_eq!(runs.load(Ordering::SeqCst), 3, "two restarts then a clean exit");
}
#[test]
fn transient_child_normal_exit_is_not_restarted() {
let runs = Arc::new(AtomicUsize::new(0));
let r = runs.clone();
run(move || {
let child = counting_child(&r, |_| { /* return normally */ });
let sup = spawn(move || {
OneForOne::new()
.child(ChildSpec::new(Restart::Transient, child))
.run();
});
sup.join().unwrap();
});
assert_eq!(runs.load(Ordering::SeqCst), 1);
}
#[test]
fn temporary_child_is_not_restarted_on_panic() {
let runs = Arc::new(AtomicUsize::new(0));
let r = runs.clone();
run(move || {
let child = counting_child(&r, |_| panic!("once"));
let sup = spawn(move || {
OneForOne::new()
.child(ChildSpec::new(Restart::Temporary, child))
.run();
});
sup.join().unwrap();
});
assert_eq!(runs.load(Ordering::SeqCst), 1);
}
#[test]
fn intensity_cap_stops_a_permanent_crash_loop() {
let runs = Arc::new(AtomicUsize::new(0));
let r = runs.clone();
run(move || {
let child = counting_child(&r, |n| panic!("always {n}"));
let sup = spawn(move || {
OneForOne::new()
.intensity(3, Duration::from_secs(60))
.child(ChildSpec::new(Restart::Permanent, child))
.run();
});
sup.join().unwrap();
});
// 1 initial run + 3 restarts, then the 4th failure trips the cap.
assert_eq!(runs.load(Ordering::SeqCst), 4);
}
#[test]
fn one_for_one_restarts_only_the_failed_child() {
let a = Arc::new(AtomicUsize::new(0));
let b = Arc::new(AtomicUsize::new(0));
let (ra, rb) = (a.clone(), b.clone());
run(move || {
let child_a = counting_child(&ra, |n| {
if n < 2 {
panic!("a crash {n}");
}
});
let child_b = counting_child(&rb, |_| { /* runs once, normal */ });
let sup = spawn(move || {
OneForOne::new()
.intensity(10, Duration::from_secs(60))
.child(ChildSpec::new(Restart::Transient, child_a))
.child(ChildSpec::new(Restart::Temporary, child_b))
.run();
});
sup.join().unwrap();
});
assert_eq!(a.load(Ordering::SeqCst), 2, "A restarts once then settles");
assert_eq!(b.load(Ordering::SeqCst), 1, "B is untouched by A's restart");
}
// ---------------------------------------------------------------------------
// one_for_all / rest_for_one (roadmap #2)
//
// These exercise the group-restart strategies. The discriminator across all
// three strategies is how a *sibling* of the failed child is treated:
// - one_for_one : sibling untouched.
// - rest_for_one: only siblings started *after* the failed child are cycled.
// - one_for_all : every sibling is cycled, regardless of its own policy.
// ---------------------------------------------------------------------------
#[test]
fn one_for_all_restarts_a_normally_exited_sibling() {
// A panics once then settles; B always exits normally. Under one_for_all,
// A's failure cycles the whole group, so B is *restarted even though it had
// exited normally* and its own Transient policy would never self-restart.
// That second B run is what distinguishes one_for_all from one_for_one
// (where B would run exactly once).
let a = Arc::new(AtomicUsize::new(0));
let b = Arc::new(AtomicUsize::new(0));
let (ra, rb) = (a.clone(), b.clone());
run(move || {
let child_a = counting_child(&ra, |n| {
if n < 2 {
panic!("a crash {n}");
}
});
let child_b = counting_child(&rb, |_| { /* always normal */ });
let sup = spawn(move || {
OneForOne::new()
.strategy(Strategy::OneForAll)
.intensity(10, Duration::from_secs(60))
.child(ChildSpec::new(Restart::Transient, child_a))
.child(ChildSpec::new(Restart::Transient, child_b))
.run();
});
sup.join().unwrap();
});
assert_eq!(a.load(Ordering::SeqCst), 2, "A: crash then clean run");
assert_eq!(b.load(Ordering::SeqCst), 2, "B cycled with the group despite a clean exit");
}
#[test]
fn rest_for_one_cycles_only_the_suffix() {
// Three children A(0) B(1) C(2). B panics once. Under rest_for_one only the
// children started after B (i.e. C) are cycled with it; A — started before
// B — is left running untouched.
//
// A must still be *alive* when B's failure is processed, otherwise "not
// restarted" is indistinguishable from "already gone". So A sleeps on its
// first run (parking across B's failure) and returns immediately on any
// later run. Result:
// one_for_one : A=1 B=2 C=1
// rest_for_one: A=1 B=2 C=2 <- this test
// one_for_all : A=2 B=2 C=2
let a = Arc::new(AtomicUsize::new(0));
let b = Arc::new(AtomicUsize::new(0));
let c = Arc::new(AtomicUsize::new(0));
let (ra, rb, rc) = (a.clone(), b.clone(), c.clone());
run(move || {
let child_a = counting_child(&ra, |n| {
if n == 1 {
// Stay alive (parked) across B's failure, then exit on its own.
sleep(Duration::from_millis(20));
}
});
let child_b = counting_child(&rb, |n| {
if n < 2 {
panic!("b crash {n}");
}
});
let child_c = counting_child(&rc, |_| { /* always normal */ });
let sup = spawn(move || {
OneForOne::new()
.strategy(Strategy::RestForOne)
.intensity(10, Duration::from_secs(60))
.child(ChildSpec::new(Restart::Transient, child_a))
.child(ChildSpec::new(Restart::Transient, child_b))
.child(ChildSpec::new(Restart::Transient, child_c))
.run();
});
sup.join().unwrap();
});
assert_eq!(a.load(Ordering::SeqCst), 1, "A (prefix) is left untouched");
assert_eq!(b.load(Ordering::SeqCst), 2, "B (the failure) is restarted");
assert_eq!(c.load(Ordering::SeqCst), 2, "C (suffix) is cycled with B");
}
#[test]
fn group_restart_and_shutdown_stop_in_reverse_start_order() {
// A(0) and B(1) park (long sleep) and record their teardown order on Drop;
// C(2) panics on every run. Under one_for_all with intensity(1):
// - C's first panic cycles the group: live siblings B,A are stopped in
// reverse start order -> Drop order [B, A].
// - after the restart C panics again, tripping the cap; the supervisor
// shuts down, stopping the survivors B,A in reverse order again.
// We assert the first teardown wave is [B, A] (reverse of start order A,B).
let order = Arc::new(Mutex::new(Vec::<&'static str>::new()));
struct Rec(&'static str, Arc<Mutex<Vec<&'static str>>>);
impl Drop for Rec {
fn drop(&mut self) {
self.1.lock().unwrap().push(self.0);
}
}
let oa = order.clone();
run(move || {
let o_a = oa.clone();
let o_b = oa.clone();
let sup = spawn(move || {
let o_a2 = o_a.clone();
let o_b2 = o_b.clone();
OneForOne::new()
.strategy(Strategy::OneForAll)
.intensity(1, Duration::from_secs(60))
.child(ChildSpec::new(Restart::Permanent, move || {
let _g = Rec("A", o_a2.clone());
sleep(Duration::from_secs(30)); // interrupted by the stop
}))
.child(ChildSpec::new(Restart::Permanent, move || {
let _g = Rec("B", o_b2.clone());
sleep(Duration::from_secs(30));
}))
.child(ChildSpec::new(Restart::Permanent, || panic!("c always")))
.run();
});
sup.join().unwrap();
});
let recorded = order.lock().unwrap().clone();
assert!(
recorded.len() >= 2,
"expected at least one teardown wave, got {recorded:?}"
);
assert_eq!(
&recorded[..2],
&["B", "A"],
"siblings must be torn down in reverse start order (got {recorded:?})"
);
}