51 Commits
Author SHA1 Message Date
Claude (sandbox)andClaude (sandbox) ca1c98336e feat(scheduler,runtime): non-panicking try_spawn for at-capacity load shedding
allocate_slot() panics on a full slab; for a load-shedding caller (an
accept loop spawning one actor per connection) that panic lands in the
spawning actor, which then crash-loops under Restart::Transient into the
still-full slab until its restart budget is spent — and the service stops
accepting entirely. Observed live (urus slowloris scaling, 2026-08-10).
A full slab is a routine overload condition for such callers, not an
invariant violation.

- RuntimeInner::try_allocate_slot() -> Option<u32>: the non-panicking
  core; a single pop under the free-list lock, so the claim is atomic
  (claim-or-report — no check-then-spawn TOCTOU, no headroom margin).
  allocate_slot() is now a thin panicking wrapper over it.
- scheduler::try_spawn / try_spawn_under_with -> Result<JoinHandle,
  SpawnError>: parity with spawn/spawn_under_with except a full slab
  returns Err(SpawnError::AtCapacity) instead of panicking. Minimal
  surface per the agreed strategy; the remaining _with/_addr mirrors are
  trivial wrappers if ever needed.
- Slot-first ordering on the try path (reverse of spawn's stack-first):
  under overload Err is the hot path, and a rejection costs one mutex
  pop — no mmap/pool-pop + init + recycle per shed unit of work. A
  drop-guard returns the claimed slot if stack allocation panics in the
  claim-to-install window (would otherwise leak and trip run()'s
  teardown slot-leak debug_assert).
- SpawnError: non_exhaustive, Display + std::error::Error.
- spawn and every existing call site untouched: the panic remains the
  correct loud invariant check at internal/bounded spawn sites.

tests/try_spawn.rs: parity when slots free; exact slab accounting at
capacity (Err, no panic, repeatable); custom-shape try refuses before
stack allocation; self-heal after slots free; plain spawn still panics
(surfaced via JoinError payload); 4-thread race for the last slots
claims exactly the free count; SpawnError impl checks.

Design doc: smarm-suggestion-try-spawn.md. Downstream consumer change
(canned 503 on AtCapacity in urus's accept loop) is urus scope, not
smarm.

(cherry picked from commit 36de4b36aeaa72b2a5f9f3797b9854652656dcf6)
2026-08-13 15:03:16 +02:00
smarm-agent 95306c7f60 style: cargo fmt sweep under rustc 1.97.1 (toolchain reformat, no semantic change) 2026-08-13 05:56:49 +00:00
smarm-agent 1262cc30e3 monitor: widen stamp eligibility to watchable = named ∪ exported (soak sig 5)
The terminal record existed for watches that raced their target's death, but
e43c673 scoped its stamp to named tenancies — and the pid-identity watch
surface (§4 Slice 3) targets arbitrary actors, including anonymous ones whose
pids cross the boundary in contract replies. The first wild pid-face hit
(width-20 soak, pid_watch_test.exs:47, 1/600 full-suite: a monitor installed
while the child was alive delivered :noproc instead of {:smarm_exit, :panic})
is exactly the residual a0ba9be's commit body deferred.

ever_named becomes `watchable`, with a second set-site: mark_watchable(pid),
which the bridge calls wherever a smarm pid is encoded across the boundary —
BEAM can only watch pids it holds, and can only hold pids that crossed.
Anonymous never-exported churn (holder threads, egress tasks) stays
ineligible, preserving e43c673's LIFO-eviction protection unchanged.

mark_watchable takes the cold lock before the liveness screen: finalize
publishes Done and reads the bit under the same lock, so the mark either
lands before the death stamps or observes the tenancy dead and no-ops —
no lost-stamp window, and marking a corpse cannot invent history (pinned
in the test alongside the mark-while-alive stamp).
2026-08-13 05:56:19 +00:00
smarm-agent 461fe4b768 fix(runtime): only named tenancies stamp the terminal record — anonymous churn must not evict it
Discovered wiring the bridge consult: with an unconditional stamp, the record
for the very death being raced was the shortest-lived data in the runtime.
Every green thread is a slot tenant, the free list is LIFO — so the slot a
named server's death frees is the first one recycled, and the next throwaway
exit (monitor holders, chain-runner work, anything) overwrote the record
before a raced watch could consult it. Deterministic bridge repro: the
corpse resolved fine, terminal_reason read None every time.

register_with now flags the tenancy (ever_named, reset at reclaim) before
the binding lands — set outside the registry lock, so no successfully
registered actor can die unflagged and a failed register's overshoot is
harmless — and finalize stamps only flagged tenancies. Watchable identities
are exactly the named ones (the bridge's pid-identity path deliberately
keeps Erlang's raw :noproc), so nothing consultable is lost.

Contract test updated: the three death modes now self-register; a new
anonymous control pins that unregistered deaths neither stamp nor evict.
2026-08-13 05:56:19 +00:00
smarm-agent b937f1f50f monitor/registry: terminal-outcome record — a raced watch can recover the real down reason (soak sig 4)
A watch installed after its target's death has, until now, only NoProc to
report — but the bridge's proxies install their native watch asynchronously
after acquire returns, so a link established before a crash (from the BEAM's
view) could still lose the panic's translated reason to that blanket NoProc
(width-20 soak signature 4: link_test.exs:26, 1/600 full-suite, 3/2000
link-only, all whereis-miss; deterministic repro in the bridge suite).

Two primitives, no change to monitor()'s own Erlang-faithful stale-pid
semantics — the upgrade is the caller's deliberate act:

- finalize_actor stamps the slot with (generation, DownReason) under the same
  cold-lock block that publishes the outcome. The record survives reclaim,
  registry pruning, and the next tenant's install; only the slot's next death
  overwrites it. terminal_reason(pid) reads it generation-matched.
- resolve_name(name) is whereis with the corpse kept: the dead-holder arm
  returns the stored pid it prunes (NameResolution::Corpse) instead of
  discarding the only evidence of who died — whereis itself prunes on the way
  out, so a whereis-then-lookup consumer would find the evidence already
  destroyed. Live/Unbound match whereis's Some/None; the name heals exactly
  as before.

Contract pinned in tests/terminal_outcome_after_death.rs: one record per way
of dying (Exit/Panic/Stopped), no record while live, corpse capture + heal on
resolve_name, record independence from registry pruning, survival across slot
re-tenancy, overwrite at the next tenancy's death.
2026-08-13 05:56:19 +00:00
Claude (sandbox) 301e3463e3 chore(release): v0.6.0 — RFC 019: actor stack reserve & shrink
Per-actor stack shapes on every spawn surface (SpawnOpts stack_reserve/
guard_size, Config defaults, pool rule: only default-shaped recycle);
sampled stack high-water + MADV_FREE shrink at actor-park (THRESHOLD
256 KiB, COOLDOWN 64 parks, redzone 1 page); pool-recycle MADV_DONTNEED
above the retained 64 KiB entry end; SIGSEGV overflow diagnostics
(two-tier: in-guard definitive / 1 MiB overshoot 'stepped over', prior
handler chained for foreign faults) with per-scheduler sigaltstack; and
the per-actor introspection surface (ActorInfo.stack: reserve, guard,
sampled depth, parks_since_shrink, shrinks).

Amendments ratified during implementation, for the RFC changelog:
- DEFAULT_STACK_GUARD 64 KiB -> 1 MiB, following the kernel's post-Stack-
  Clash stack_guard_gap convention; PROT_NONE width is VA-only and free.
- §7's motivating segfault was a cargo-vendored gz build, not SQLite as
  the RFC text says (cc-built C lacks -fstack-clash-protection; distro
  libraries have it — the risky class is vendored builds).
- §4 hibernate() deferred to the jar (bolt-on: force-flag on the §3
  shrink path, ~10 lines when wanted).

Gates (jobrunner box, 2026-08-08): reclaim gate PASS at c3 and again at
tip (3.0 MiB LazyFree -> kernel reclaim -> Rss to one live page ->
re-spike bit-identical, live data intact; MADV_PAGEOUT stands in for
memcg — cgroup2 is RO in the job container — driving the same reclaim
path). E1 interleaved A/B vs v0.5.0: every ka cell (the E1 subject)
within +0.3..+2.9% at tip; close-mode control cells within noise except
t8-c4 close, which is bistable (~40-44k vs ~46-49k modes for BOTH
variants, base self-disagrees by 11% across rounds); 6 rounds across two
runs are inconclusive there and a 10-round focused run is noted in the
handoff as deferred follow-up, accepted for this release.

No breaking API changes since v0.5.0: SpawnOpts fields and ActorInfo
gained members (exhaustive-construction downstream will need the new
ActorInfo.stack field; urus does not construct it).
2026-08-08 19:44:55 +00:00
Claude (sandbox) 410ba33d82 feat(introspect,runtime): per-actor stack surface on ActorInfo (RFC 019 §8)
- introspect::StackInfo { reserve, guard, depth_high_water,
  parks_since_shrink, shrinks } as ActorInfo.stack; re-exported at crate
  root beside ActorInfo.
- All reads lock-free: geometry from the c6 diag slot atomics, depth =
  top - hwm (the §2 sampled high-water; doc spells out sampled-not-exact
  and that 0 means never-descheduled-at-depth), counters straight off the
  §3 atomics. Coherence for the incarnation rides read_slot's existing
  generation check, same as overruns/messages_received.
- Slot::stack_introspect(): one pub(crate) tuple accessor beside the other
  counter accessors.
- Exact RSS deliberately absent per RFC (mincore = debug tooling only,
  never a runtime path); stack_shape(pid) untouched (cold-lock exact
  variant from c2).
- tests/introspect.rs: defaults surface (64 KiB reserve / 1 MiB guard /
  sampled ~32 KiB depth / gate park counted / zero shrinks) + live shrink
  counters (spike visible pre-shrink; shrinks>=1, cooldown counter reset,
  hwm reset after crossing COOLDOWN) read mid-run -- post-join the slot
  reclaim correctly hides the incarnation, which the first draft of the
  test learned the hard way.

FLAGGED (Claude-solo calls):
- Nested StackInfo struct over five flat ActorInfo fields (grain break;
  the five fields are one concern and ActorInfo is already 12 fields).
- Field names reserve/guard/shrinks (RFC says stack_reserve/stack_guard/
  shrink count; the stack_ prefix is redundant inside StackInfo).
2026-08-08 19:12:46 +00:00
Claude (sandbox) 5fd8aecf55 feat(signal,runtime,stack): SIGSEGV overflow diagnostics + 1 MiB guard default (RFC 019 §7)
- src/signal.rs: process-global SA_SIGINFO|SA_ONSTACK handler installed once
  at runtime::init (before any scheduler thread -> unracing PRIOR save);
  per-scheduler-thread 64 KiB sigaltstack registered at schedule_loop entry
  (a guard hit leaves no stack to handle on). Async-signal-safe throughout:
  classification is plain loads (const-init TLS Cell + slot atomics), print
  is fixed-buffer itoa + one write(2), death is SIG_DFL + refault at the
  same instruction (core-dumpable, correct wait status).
- Two-tier classification (agreed): in-guard = definitive; OVERSHOOT window
  below the guard = 'unprobed (FFI?) frame stepped over it' probable
  attribution -- the RFC's motivating incident (cargo-vendored gz, not
  SQLite as the RFC text says) faults there under a small guard. Pure
  classify() fn, 5 adversarial units incl. saturation at low addresses.
- DEFAULT_STACK_GUARD 64 KiB -> 1 MiB (agreed): kernel stack_guard_gap
  anchor post-Stack-Clash; PROT_NONE is VA-only (no RSS, no page tables,
  no overcommit charge) so width is free at any actor count.
- Unclassified faults reinstate the PRIOR sigaction and refault (agreed):
  std's own OS-thread overflow diagnostics survive our presence.
- Slot: diag_{stack_top,stack_reserve,stack_guard,pid} atomics written in
  install_actor pre-publish; readable without the cold lock (Stack lives
  under it); only consulted while CURRENT_SLOT points at the slot, so
  never stale where read. preempt::current_slot_ptr ungated from
  smarm-causal (now also the classifier's anchor).
- build.rs + cc (agreed Q3): canary/canary.c, 96 KiB local touched low-end
  first, -fno-stack-clash-protection pinned so hardened toolchains don't
  probe the canary into uselessness.
- tests/stack_diag.rs: subprocess x4 -- Rust recursion tier-1; FFI canary
  tier-1 at defaults (1 MiB guard catches the jump); tier-2 at guard=4 KiB
  ('stepped over', reproduces the incident); clean at reserve=256 KiB
  (the §1 knob is the fix, same frame).

FLAGGED (Claude-solo calls):
- OVERSHOOT_SLOP = 1 MiB (matches guard default/kernel gap; beyond it
  attribution would be dishonest).
- Altstack 64 KiB, mmap'd once per OS thread, never freed (bounded by
  thread count; reused across run()s via TLS flag).
- Foreign-fault reinstate permanently deregisters our handler; accepted --
  the process is dying either way.
- Diag geometry as 4 slot atomics (install-time cost only) over a per-switch
  TLS snapshot (hot-path stores).
2026-08-08 18:58:30 +00:00
Claude (sandbox) 7d8b9e0310 feat(stack,runtime): pool-recycle DONTNEED above the retained entry end (RFC 019 §6)
- stack::retain_range: pure checked span fn (retain page-up = zap less;
  None when retain covers the reserve, so the 64 KiB default config never
  pays a syscall) + 6 adversarial units mirroring shrink_range's.
- Stack::recycle_zap: advisory MADV_DONTNEED of [usable_base, top-RETAIN);
  stack is unowned at the call site, synchronous eager zap races nothing.
- recycle_stack: zap OFF-LOCK before pool admission (acquire_stack's
  no-syscall-under-the-pool-lock invariant); rare cap-overflow pays a
  wasted zap ahead of munmap, accepted over a second lock round-trip.
- pub const RECYCLE_RETAIN = 64 KiB beside the shrink knobs, ratified-as-
  constant rationale in doc.
- tests/stack_recycle.rs: mincore-based exact-zero-resident assert over
  the zap span. smaps was tried first and over-counts: a neighboring rw
  anon VMA can merge flush against the stack top (observed once under the
  full-suite run); the PROT_NONE guard pins the usable base exactly.

FLAGGED (Claude-solo calls):
- RFC §6 'above the bottom RETAIN' is direction-ambiguous in address
  terms; implemented as retain the ENTRY end (highest addresses, the
  pages the next actor faults first), zap the cold deep span below.
- Const named RECYCLE_RETAIN (RFC says RETAIN) to sit beside SHRINK_*.
2026-08-08 16:13:53 +00:00
Claude (sandbox) 8225716b11 feat(runtime,stack): sampled stack high-water + MADV_FREE shrink at actor-park (RFC 019 §§2–3)
hwm: AtomicUsize lands beside sp on the slot: the single context-save
site min-updates it (one branch + at most one Relaxed store into the
line the sp store just dirtied), install resets it to the fresh top.
Advisory by construction — correctness never depends on it. The mod-doc
ordering chain gains a line: hwm piggybacks the existing
Relaxed-store-before-Release pattern and adds no edges.

Shrink hook in the YieldIntent::Park arm only, before the park_return
Release transition — the owned window (obligation 1's assert-comment at
the site): after the sp store, before Parked is published, scheduler on
its own stack, actor saved and unstealable. It runs on both arms of the
park_return race (a consumed unpark flag means one wasted-but-harmless
madvise). The preempt/yield path deliberately never checks: §4's
bounded, self-healing leak under saturation, when syscalls are least
affordable.

SHRINK_THRESHOLD = 256 KiB and SHRINK_COOLDOWN = 64 parks are pub
constants with the ratified doc rationale, not Config fields. The freed
span is shrink_range(hwm, sp, page): whole pages of [hwm, sp − 1-page
redzone), rounded inward, checked arithmetic — adversarial inputs
collapse to None (obligation 2). MADV_FREE marks lazily; the kernel's
reclaim-under-pressure IS the hysteresis, cancel-on-write is the safety
net. parks_since_shrink + shrink_count ride the slot for the cooldown
and the future introspect surface.

Tests: 7 adversarial shrink_range units (inverted/empty spans, redzone
underflow, unaligned ends, sp-crossing sweep); integration — 8 MiB
reserve, ~3 MiB spike sampled via yield-at-depth, parks gated on
introspected Parked state past the cooldown, then ≥ 2 MiB LazyFree
asserted inside the stack's smaps range with live data intact; and the
inverse guard — a shallow never-spiking actor ends at exactly 0
LazyFree (also proves the parser isn't vacuously zero via the first
test).
2026-08-08 14:30:32 +00:00
Claude (sandbox) 3cb64eefc2 feat(scheduler,gen_server,gen_statem,introspect): SpawnOpts — per-actor stack shape on every spawn surface (RFC 019 §1)
SpawnOpts { stack_reserve, guard_size } with Option<usize> fields, None
resolving to the Config defaults at spawn time — a deliberate deviation
from the RFC's plain-usize struct so struct-update syntax works without
a runtime handle in scope. Threaded across the five surfaces:
spawn_with, spawn_under_with, spawn_addr_with,
GenServerBuilder::stack_opts (mirrored on NamedGenServerBuilder), and
gen_statem::spawn_with (gen_statem has no builder, so the opts ride a
_with variant — Claude-solo surface call, flagged for review). Existing
spawns forward defaults; no call-site churn.

introspect::stack_shape(pid) pulled forward (agreed) as the first slice
of the RFC 019 introspection surface, giving tests an observable.

Tests (tests/spawn_opts.rs): override/partial-override/rounding on each
surface; obligation 4 from the outside — a dead custom stack is never
handed to the next default spawn (LIFO pool would expose it), and the
reverse (default stacks ARE recycled); 8 MiB reserve behaviorally
permits ~1 MiB recursion. Also: silence unused-Result in the c1
runtime test (join now unwrapped).
2026-08-08 14:22:38 +00:00
Claude (sandbox) 0fe052bc7e feat(stack,runtime): per-shape actor stacks — Stack::new(reserve, guard), Config knobs, pool rule (RFC 019 §1)
Stack takes an explicit (reserve, guard) shape, both page-rounded and
stored; usable_base derives from the stored guard. Guard default raised
4 KiB -> 64 KiB (DEFAULT_STACK_GUARD): probestack makes one page enough
for Rust frames, but an unprobed C frame can leap a page in one sub rsp
— the motivating SQLite segfault. Reserve default stays 64 KiB
(DEFAULT_STACK_RESERVE); ACTOR_STACK_SIZE retired.

Config::{stack_reserve, stack_guard} thread the runtime defaults into
RuntimeInner pre-rounded. All acquisition/recycling now goes through
acquire_stack/recycle_stack carrying the pool rule: only default-shaped
stacks are pooled (pooled ⇒ default-shaped by induction); custom shapes
mmap fresh and munmap at death. Pool lock still dropped before any mmap.

No public spawn API change (SpawnOpts is the next commit).

Tests: shape rounding + accessors, wide-guard faults at both ends
(subprocess), Config::stack_reserve permits >64 KiB recursion that
previously could only segfault.
2026-08-08 14:18:27 +00:00
smarm a03a7ca01e chore(release): v0.5.0
Breaking API rename since v0.4.0: gen_server's ServerRef/ServerBuilder/
ServerCtx -> GenServerRef/GenServerBuilder/GenServerCtx, Watcher<G> is
now generic over its GenServer, and GenServer gained a required
associated Timer type for timer-fire payloads (arm_after/handle_timer).
Downstream consumers (urus) have been ported.
2026-08-08 11:44:35 +02:00
smarm d4839f1d81 feat(runtime,io): driver-enqueues + park/wake idle path — retire the wake pipe
The swap (RFC 018). Schedulers no longer sleep on a shared level-triggered
wake pipe — the herd source that made the default 8-thread config 7x
slower than 2 threads (E1). They park on per-thread futex parkers via the
coordination layer; IO backends become producers behind a two-call
contract (make runnable, then the enqueue tail wakes exactly one parked
scheduler).

Deleted: the drain lock and the one-winner phase-1 drain; the shared
completions VecDeque; the wake pipe fds, poll_wake, drain_wake_pipe,
wake_scheduler, the FdReady/Blocking Completion enum; the 100us idle nap;
the per-pop io.lock liveness read; io.rs's as_millis timeout truncation.

Added:
- enqueue wake tail (fixes the silent enqueue): wake_one_if_idle, a fence
  + one Relaxed mask load when everyone is busy — the pure-compute hot
  path pays almost nothing.
- driver-enqueues: the pool thread stashes its result in the slot,
  decrements io_outstanding, unparks; the epoll thread removes+DELs the
  waiter under the waiters lock and unparks. Both reach the runtime via a
  Weak (no Arc cycle). The waiters map moves behind its own Arc<Mutex> so
  the epoll thread never takes the runtime io lock (teardown holds it
  while joining that thread).
- io_outstanding / io_fd_waiters atomics: the termination verdict reads
  two atomics instead of taking io.lock on every pop.
- timekeeper idle path: at most one parked scheduler holds the timer
  deadline (an expiry wakes one, not a herd); everyone else parks
  indefinitely and is woken by the enqueue tail.
- busy-path timer due-check (ratified design point (a)): under saturation
  nobody parks and no timekeeper exists, yet due timers must still fire —
  one Relaxed load of the earliest-deadline snapshot per loop, clock read
  only when a timer is armed. Maintained under the timers mutex.
- chain rule: a scheduler that pops with more work queued and a sibling
  parked wakes one, so surplus runs in parallel rather than behind it.

tests/park_wake.rs pins the two new observable properties: timers fire
under full scheduler saturation, and sub-ms sleeps are prompt (the
as_millis truncation regression). Full suite + all loom models green;
clippy --lib clean.
2026-07-24 09:12:12 +02:00
smarm 2854b560d6 feat(park): fenced producer fast path + earliest-deadline snapshot
Two integration-driven amendments ahead of the runtime swap:

wake_one_if_idle() realizes RFC 018's "empty-mask fast path is one
relaxed load" soundly: a bare relaxed load is a lost-wake in the Dekker
shape for the lock-free ring queues, so the producer publishes work,
fences (SeqCst), then reads the mask Relaxed — paired with a matching
fence between the consumer's bit-publish and its re-check in park().
The pure-compute hot path (mask 0) never takes the shared mask line
exclusive; the RMW read stays on the rare chain-rule path only. Loom
models 1/2 now drive the fenced pattern end to end.

next_deadline is the earliest KNOWN timer deadline, independent of
whether anyone is parked — which tk_armed cannot give: under saturation
nobody parks, nobody arms, yet due timers must still fire (ratified
design point (a): the busy-path due-check). Maintained under the timers
mutex (note_deadline on insert — which also carries the timekeeper
re-arm wake — refresh_deadline after pop/clear); read lock-free.
deadline_due() costs one Relaxed load and a branch when no timer exists;
the clock is read only when one does.
2026-07-24 09:12:12 +02:00
smarm 7b026cfe56 feat(park): scheduler coordination layer — parkers, idle mask, wake protocol (RFC 018)
Schedulers get an IO-agnostic sleep/wake primitive of their own: one
futex Parker per scheduler thread (permit semantics, std::thread::park
shaped — closes the check-then-park race), an AtomicU64 idle mask with a
set-bit → re-check → wait park protocol, wake_one (highest-bit LIFO,
CAS-clear before unpark: exactly one wakeup per call by construction),
wake_all for the terminal path, and the timekeeper role — at most one
parked scheduler holds the timer deadline, with an atomic armed-deadline
snapshot for the busy-path due-check and an insert-side re-arm wake.

Deadlines travel as nanosecond timespecs end to end; the wake pipe's
as_millis truncation is unrepresentable here. The Dekker publish/re-check
shape is resolved by the same-location-RMW handshake (AcqRel), not SeqCst
loads; loom verifies exactly this in four models (no-lost-wake, chain
propagation, timekeeper handoff, termination), run with
LOOM_MAX_PREEMPTIONS=3 — unbounded exploration is impractical for the
looped models. Loom/non-Linux builds park on a Mutex+Condvar via
sync_shim.

Standalone until the runtime swap (next commit): nothing outside tests
constructs a Coordinator yet, hence the temporary dead_code allow in
lib.rs.
2026-07-24 09:12:12 +02:00
smarm 006a3283e7 chore(hooks): clippy gate falls back to a nix-shell toolchain
Desktop migration: the home-manager rust here ships without the clippy
component. Prefer an installed cargo-clippy; otherwise run clippy from an
ephemeral nix-shell with a separate target dir (mixed-compiler artifacts
are an E0514 hard error). MSRV keeps the shell's older toolchain a
legitimate gate.
2026-07-24 09:12:12 +02:00
Markk116 8c764e9169 docs(monitor): user-facing rewrite of process monitors
Lead with the user's problem (learn when another actor dies without
it knowing you're watching), explain one-directional/one-shot
semantics and contrast briefly with link without assuming link.rs has
been read. Add a compiling doctest. Drop em-dashes. Correctness facts
about registration/death races and demonitor-after-fire safety kept,
reworded in plain terms and separated from the public item docs.
2026-07-24 08:44:56 +02:00
Markk116 41b9d6d056 docs(introspect): user-facing rewrite of runtime introspection
Was the worst offender for external-context references (RFC 016
Chunk 1/4, DECISION D1/D2, RFC 003/011), all removed. Lead with the
practical use cases (debugging, health checks, test assertions,
dashboards) for snapshot()/actor_info()/tree(), and explain the
per-actor-reads-not-a-world-freeze consistency model in plain terms
instead of citing a decision log. Add a compiling doctest.
2026-07-24 08:44:56 +02:00
Markk116 dd845f22fe docs(mutex): user-facing rewrite of the actor-blocking mutex
Every public item was previously undocumented. Lead with why Mutex<T>
exists (a channel/gen_server is overkill for plain shared state) and
how it differs from std::sync::Mutex (parks the actor not the OS
thread, every lock is timeout-bounded by default). Add a compiling
doctest. Document new/lock/lock_timeout/try_lock/set_default_timeout/
MutexGuard/LockTimeout/DEFAULT_TIMEOUT. Drop em-dashes; keep wake
protocol mechanics as contributor-facing comments on private internals.
2026-07-24 08:44:56 +02:00
Markk116 36a0a9832d docs(registry): user-facing rewrite of the name registry
Replace the 'what changed' diff-against-a-prior-design framing with a
plain explanation of what the registry is for (naming an actor so
others can find and message it by name) and a compiling doctest
(register/whereis/send/unregister). Cut all RFC/decision-number/bug-id
references and em-dashes; move type-erasure and locking-discipline
detail into an Implementation notes section for contributors.
2026-07-24 08:40:26 +02:00
Markk116 feda6517e5 docs(channel): user-facing rewrite of the MPSC channel primitive
Lead with what a channel is and how to use it (compiling doctest for
channel()/send/recv/close), before any internal rationale. Document
every previously-undocumented public item (channel(), Sender, Receiver,
SendError, RecvError). Move the RawMutex-vs-std::sync::Mutex rationale
and lock-class discipline into an Implementation notes section. Drop
em-dashes throughout.
2026-07-24 08:40:26 +02:00
Markk116 8625ae4c35 docs(scheduler): user-facing rewrite of the actor/spawn/run entry point
Lead with what an actor is and how to start one with run()/spawn(),
following gen_server.rs's example-first style. Add a compiling module
doctest. Drop RFC references and em-dashes; keep internal mechanics
(preemption gating, thread-local borrow rules) as plain contributor
comments rather than public-facing doc prose.
2026-07-24 08:40:26 +02:00
Claude (sandbox) d9addeba5e test(causal): controller test no longer races the sweep's site snapshot
run_experiments snapshots the site registry once at entry. stage-x is
registered lazily (first causal_site! execution in the worker), so on a
1-core box the snapshot deterministically wins whenever a sibling test
has already paid the tsc_hz calibration — stage-x missed the sweep and
the summary assert tripped (silently, pre-propagation; the earlier
cascade attribution was incomplete). The worker now signals after its
first site entry and the test waits on it before starting the sweep.
Jarred separately: the lazy-registration trap is a lib UX hazard worth a
doc note or warm-up guidance.
2026-07-18 21:59:21 +00:00
Claude (sandbox) d5a3ba1934 fix(causal): born current — slot reuse booked phantom park forgiveness
A fresh/reused slot started with causal_delay=0 and causal_parked=true:
the first resume then 'forgave' the entire monotone global backlog, once
per spawn, booked as park forgiveness. Under close-mode conn churn (~95k
spawns/s) that is millions of phantom forgiven ms per 700ms window, even
in 0% cells — the books could not balance under actor churn while
ka-mode stayed plausible (few spawns). Impacts were unaffected: the
first resume always precedes the first check, so nobody ever spun.

reset_counters now installs Coz's new-thread rule: causal_delay starts
at the current global ledger and causal_parked starts false — a newborn
neither owes nor is forgiven the process's history, and delay injected
while it sits spawn-queued (runnable, not blocked) is owed and paid at
its first check, the semantics audit_zero_pct_window_absorbs_leftover_
debt pins. That test (born failing, unmasked by the run() propagation
fix) and the new actor_churn_between_experiments_forgives_nothing
regression test both go green.
2026-07-18 21:55:56 +00:00
Claude (sandbox) 7eae56a296 fix(runtime): a root actor panic escapes run()
The trampoline caught the root's panic, recorded it as Outcome::Panic on
the slot, and run() dropped the initial handle without reading it — every
assert inside run(), the standard test-suite pattern, was silently
vacuous (found live: a failing-first test passed). run() now reads the
root outcome before the handle drop and resume_unwinds the payload after
full teardown, so a caller's catch_unwind leaves the Runtime reusable;
Exit and Stopped return normally. The payload message is printed before
re-raising (the throw-site hook output was suppressed in-actor).

Correct the two tests this unmasked, both born failing and never run:
the select loser-arm test kept a closed arm in the set (the documented
closed-arm rule: a closed arm reports ready forever — observe the
disconnect and drop it); the send_after-to-dead test expected Ok(None)
from a closed+empty channel (documented: Err(RecvError), which proves
nothing-delivered even more strongly).
2026-07-18 21:52:50 +00:00
Claude (sandbox) 527f045e17 feat(causal): offcpu column + closed-books eff in the attrib probe
The probe now prints eff+offcpu next to eff — attributed plus the
counted runnable off-CPU gaps over ground-truth in-site time — the
per-window check that the located mechanism accounts for the whole
residual (~1.00 = books closed, no remaining silent loss). Audit line
gains the offcpu column, same delta-terms convention as the lib
renderer. Header doc rewritten from hypothesis to resolution.
2026-07-13 12:46:58 +00:00
Claude (sandbox) 0ee3fe7330 feat(causal): offcpu audit bucket — the @50 deficit located (RFC 007)
GPU sweep decomposed the ~23ms/700ms @50 injection deficit: every
ledger bucket is ~zero (drop park 0, discards 0, drop yield ~0.3ms),
books balance at absorbed+forgiven = 4x injected in all 48 cells, and
the 0%-cell contamination signature is absent. The probe pins the
residual: eff 0.933-0.943, a constant 22-27µs missing per site entry
= ~4.9 slice-expiry yields/entry x ~5.6µs runqueue wait. The
"deficit" is runnable off-CPU time inside the site — wall time the
probe's ground truth counts but on-CPU attribution correctly skips
(Coz model: speeding the site's code does not shrink queue-wait).

Measure-only reclassification, no behaviour change: a yield in the
target site stashes (tsc, experiment epoch) on the slot; the next
on_resume counts the gap into OFFCPU_IN_SITE_{CYCLES,N} (would-be
delta terms, MAX_SAMPLE_CYCLES-capped) iff the epoch still matches
and the word is live — a gap straddling end()/a same-word begin()
(live in the probe's 50,50 schedule) is dropped, never a leaked
cooldown. Parks excluded: blocked time is forgiveness territory.
New offcpu column in render_ledger_audit; LedgerCounters and
ExperimentResult grow the two fields. Fidelity footer now states
the on-CPU basis (deliberate wording change to the pinned summary;
the substring pin test still holds). +2 tests (counted gap; epoch
straddle) + render assert.
2026-07-13 12:46:15 +00:00
Claude (sandbox) 9bfeb2c6a2 feat(causal): ledger-audit output in the pipeline demo and attrib probe
- causal_pipeline: SMARM_CAUSAL_AUDIT=1 appends render_ledger_audit()
  after the summary; pinned summary format untouched.
- causal_attrib_probe: per-pct audit line (absorbed/forgiven/drops/
  discards) under the existing eff line, so in-site-vs-attributed and
  the loss buckets land in one place for the sweep.

1-core smoke (work + wide): books balance — absorbed = 2x injected and
forgiven = 2x injected, i.e. owed = injected x (N-1) with N=5 actors,
zero outstanding. drop park = 0 even in wide mode (the bottleneck's
queue is never empty, so its in-guard recv never parks); drop yield is
~1500 events but ~0.1ms per window, confirming slice-expiry yields
sample at their own checkpoint. Deficit decomposition needs the
parallel box.
2026-07-13 12:11:24 +00:00
Claude (sandbox) a3be8f0977 feat(causal): ledger audit — decompose the @50 injection deficit (RFC 007)
Measure-only counters for the deficit hunt (~23ms short per 700ms window
at 50% on the bottleneck site; superlinear vs 25%). Nothing here changes
injection or absorption; the sweep decides the fix.

Buckets, windowed per cell into new ExperimentResult fields (audit
snapshot taken at end() — spin/attribution freeze there, forgiveness
does not):
- spin_absorbed / park_forgiven: where owed delay actually went. Spin
  during a 0% cell is the baseline-contamination signature — leftover
  debt from a prior window being paid in a later one (checks gate on
  the experiment word, so cooldowns pay nothing and debt carries over).
- drop_park / drop_yield (+counts): the deschedule path flushes no
  sample tail — an in-target-site park or yield silently loses
  [last sample -> now]; on_resume re-arms before the actor runs again.
  New on_deschedule hook in all three intent arms (real park; explicit/
  slice-expiry yield; requeued park counts as yield — it never blocked).
  Slice-expiry yields sample at the descheduling checkpoint, so a fat
  yield bucket points at explicit yield_now or requeued parks.
- discard_overmax (+count, in would-be delta terms so columns compare
  against injected_cycles) / discard_unarmed: the attribute() clamps,
  previously silent.

LedgerCounters + ledger_counters() expose cumulative totals (tests,
run-level prints); render_ledger_audit() is the per-cell companion to
render_summary, which stays byte-identical (pinned). ExperimentResult
now derives Default so literals survive future audit-field growth.

Tests: +7 (spin counted, forgiveness counted, in-site park drop, in-site
yield drop, overmax discard, 0%-window leftover absorption — synthesized
deterministically via inject_delay_cycles_for_test with no experiment
active — and the audit render). 22/22 causal.
2026-07-13 12:07:19 +00:00
Claude (sandbox) a2d0b7af18 feat(causal): fidelity footer in render_summary
One unconditional footer line whenever there are results:
"note: impacts are lower bounds — undershoot grows with speedup pct;
rankings unaffected" — surfacing the RFC 007 Validation fidelity statement
where users actually look, instead of only in the RFC. Wording pinned by
the summary test.
2026-07-13 11:11:20 +00:00
Claude (sandbox) a4647f368a feat(causal): wall-anchored send_after — user-facing timer opt-out (RFC 007)
send_after_wall / send_after_named_wall (+ Timers::insert_send_wall) arm a
message-delivery timer that opts out of the RFC 007 virtual-time shift and
fires at its raw deadline regardless of injected delay — the Send-reason
sibling of sleep_wall, closing the jar item whose substrate efbc254 landed.
For deadlines that reflect the outside world (protocol timeouts, wall-clock
schedules) rather than workload pacing. cancel_timer is anchor-agnostic and
unchanged; without the feature the API exists and is identical to send_after.

The gen_server timer layer (send_after_to, RFC 015 §5) deliberately stays
virtual-only — an opt-out there means new options on the gen_server/statem
timeout API, out of scope for now.

Tests: wall send fires at raw deadline while a virtual sibling shifts;
cancel on a wall send with debt outstanding; featureless delivery/cancel
smokes through the public named API. 15/15 causal, 34/34 binaries both
feature configs, lib clippy clean both.
2026-07-13 11:10:11 +00:00
Claude (sandbox) fec760a3c0 docs(causal): record the reserve-shortfall verdict in the demo header
Occupancy probe on 24 cores: δ = 0.3µs/item (0.1% of the serialized
path); wide guard leaves the @50% cell unchanged. The +84-vs-+100
shortfall is controller-side (injected 327ms of the ideal 350ms over
the 700ms window, plus ~3% real-rate dip during experiments), not
unguarded stage time. urus's ~70µs/request remainder remains the
guard-placement case; the occupancy probe discriminates the two.
2026-07-13 09:48:24 +00:00
Claude (sandbox) d5b6a8f66f feat(causal): pipeline demo modes for the reserve-shortfall experiment
SMARM_CAUSAL_MODE selects the reserve stage's guard placement:
work (default, unchanged) | wide (guard over recv+work+send, the whole
serialized per-item path) | occupancy (no experiments; per-segment
timing of reserve's loop at baseline, reporting the unguarded
remainder δ and the impact ceiling it implies).

Discriminates the two candidate explanations for the demo's +84-vs-+100
@50% shortfall: physical recv/send time outside the guard (occupancy
sees δ≈30µs, wide recovers ~2x) vs. injection-side credit loss
(occupancy sees δ≈0, wide caps at ~+84 too — guard cadence identical).
2026-07-13 09:40:27 +00:00
Claude (sandbox) efbc254634 feat(causal): wall-anchored timers — controller windows keep fixed wall length (RFC 007)
New timer anchor: insert_sleep_wall / scheduler::sleep_wall (exported) opt a
Sleep entry out of the RFC 007 virtual-time shift, so it fires at its raw
deadline regardless of injected delay. Featureless config is unchanged (the
API exists but is identical to sleep).

The causal controller's window/cooldown sleeps and the tsc_hz calibration
sleep use it on the actor path (the OS-thread path was already wall). This
fixes the controller's own sleeps dilating under its own injection —
experiment windows stretched ~2x at 50% speedup (337ms -> 646ms injected/
window). Deltas were rate-normalized so results were unbiased; this fixes
sweep cost, not bias. The general wall-anchored-timer-semantics jar item
(user-facing opt-out) remains open; this lands the substrate.

Test: wall_timer_ignores_injected_delay — wall entry fires at raw deadline
while a virtual sibling in the same heap shifts. 13/13 causal, 34/34
binaries both feature configs.
2026-07-13 07:44:51 +00:00
Claude (sandbox) 04dbac1f4b feat(causal): timer-heap virtual time — deadlines chase injected delay (RFC 007)
Injected delays dilate virtual time for the workload, but timer deadlines
stayed wall-anchored: a sleep or receive-timeout fired early in virtual
terms, so timeout/retry behaviour sped up relative to the dilated world
(v1 known gap #1).

Every heap Entry now carries delay_stamp — the global delay ledger at
(re-)queue time, cfg-gated on smarm-causal. pop_due converts any debt
accrued since the stamp to wall time (tsc_hz) and shifts the effective
deadline; a not-yet-due entry is re-queued at the shifted deadline with a
fresh stamp, so it keeps chasing delay injected while it waits. seq is
preserved across re-queues, keeping send_after cancellation identity
intact (cancelled entries are discarded before any shift). Zero debt is
byte-identical to the old path; peek_deadline may under-report, costing
one spurious scheduler wake per injected chunk (documented).

This also makes the park-gated resume credit *correct* rather than
forgiving for sleepers: a sleeping actor now physically pays its debt by
sleeping longer, so the on_resume fast-forward reflects real payment
(sleeping_actor_pays_injected_delay pins this end-to-end through the
runtime).

New test hooks: inject_delay_cycles_for_test (deterministic ledger
driver, eagerly TSC-calibrating so conversion never stalls a scheduler
loop) and cycles_to_duration. The ledger is process-global, so the
delta-sensitive causal tests now serialize on a shared test mutex — they
were racy under the parallel test harness before this, in principle.
2026-07-13 07:31:08 +00:00
Claude (sandbox) d496914d40 fix(causal): flush target-site samples at guard boundaries (RFC 007)
Samples were taken only when maybe_preempt's cold block happened to fire
in-site, so the interval between the last check and SiteGuard drop was
discarded on every site entry. Measured live on the 24-core box:
22-29us lost per entry, a constant attribution efficiency of ~0.93-0.94,
which under-reported every impact (+83.5% where theory says +100%; the
observed shortfall fits 1/(1-pct*eff)-1 at both 25% and 50%).

SiteGuard enter/drop now call site_transition(): leaving the target site
flushes the pending interval into the ledger (sample-only, never spins,
so safe under no-preempt regions); entering the target site re-arms the
sample clock so pre-site time is never attributed (the symmetric
over-attribution). Winner attribution is factored into attribute(),
shared by the cold check and the flush, with the same interval clamps.

Adds examples/causal_attrib_probe.rs (ground-truth in-site time vs
ledger attribution, the probe that confirmed the leak) and the
site_boundaries_flush_tail regression test (a site entry that never
hits a cold check must still be attributed). Also gates causal_probe
on smarm-causal in Cargo.toml - it never was, so featureless builds
of the examples were broken.
2026-07-12 19:35:06 +00:00
Claude (sandbox) 2668f4018f feat(causal): native causal profiling behind smarm-causal (RFC 007 v1)
causal_site! scoped site guards per actor slot, progress! throughput
points, and a Coz-style virtual-speedup engine hooked into
maybe_preempt's amortized cold block: target-site samples grow a global
delay ledger; bystanders spin-absorb their debt at the next causal
check, with timeslice extension so injected delay is not charged
against the slice.

Resume credit (Coz's blocked-thread rule) is gated on a causal_parked
slot bit set only by a real park: crediting on every resume made any
yield-cadence actor delay-immune and every experiment inert (found
live on a 24-core run — dead-flat deltas across all sites).

Report normalization uses a measured TSC frequency (~50ms calibration
on first use) instead of the crate-wide 3 GHz assumption, which
uniformly inflated impact numbers on a 3.7 GHz box. impact_pct() is
the machine-readable form of the summary for programmatic checks.

examples/causal_pipeline.rs burns fixed *work* (calibrated LCG loop),
not fixed wall time — a timed busy-wait absorbs injected delay into
its own budget and reads as a no-op. Self-checking: exits nonzero if
causal separation fails; skips the verdict below 4 cores. Validated
on a 24-core box: reserve (true bottleneck) +29.3%@25/+83.5%@50;
serialize and background-compaction ~0%.

Known v1 gaps (jar): timer-heap deadlines unshifted, no-check!/no-alloc
actors undelayable, multi-scheduler coherence best-effort Relaxed,
off-CPU blame punted, Instant::now() uncorrected.

Zero-cost with the feature off; clippy -D warnings clean both ways;
full suite green with and without smarm-causal.
2026-07-12 19:10:04 +00:00
smarm-agent 1c90a4ef5e fix(registry): by_name stores the full holder Pid — a dead name heals under slot reuse
Root cause of soak20 signature 2 (refcount_test.exs 'watcher crash',
110235x fast {:error, :server_down} probes over the full await window):
by_name mapped name -> slot *index*, so a name whose holder died (no stop
path unregisters; prune is lazy) and whose slot was then re-tenanted read
as live-held: register failed NameTaken{holder: <unrelated tenant>} (which
the bridge macro's generated start() swallows -> start_server/1 reports :ok
for a server that never came up), while name resolution reached the
tenant's mailbox, missed on the message TypeId and failed fast WITHOUT
pruning — the wedge self-sustained for the tenant's lifetime. Name-
addressed send additionally judged liveness on the slot's *current*
mailbox pid, so a same-typed tenant would have received the message
(misdelivery) and a differently typed one a misleading NoChannel.

Fix: by_name: HashMap<&'static str, Pid> — every reader judges the
*stored* holder with the generation-checked live(), so a recycled slot's
tenant no longer impersonates a dead holder, and every touch (register /
whereis / resolve / send) prunes and heals a stale name. prune(index)
becomes prune_holder(pid): names bound to the holder go; the mailbox goes
only while still the holder's own (a tenant's replacement mailbox is left
untouched). Introspection matches names to mailboxes by full pid, so a
stale name never annotates a slot's new tenant.

Deterministic regression test added first and shown to fail pre-fix
(tests/stale_name_slot_reuse.rs: tiny slab forces re-tenanting; old slot
(1,0) died, tenant (1,1) took the index; register -> NameTaken pre-fix).
Post-fix it asserts the healed contract: whereis -> None (pruned), call ->
ServerDown, re-register -> Ok. Suite 33 ok-binaries, clippy gate clean.

In the wild the window opened at every splice_test teardown:
Splice.terminate -> exit_server('subtree') left the name bound; width 20
raised the re-tenant probability. Downstream (smarm_beam): install_child's
unregister-before-register workaround becomes dead code (removed there);
the #[smarm_server] macro's swallowed register error becomes truthful
idempotency (a NameTaken now really is a live holder).
2026-07-12 07:23:30 +00:00
smarm-agent f6641cd266 runtime: name scheduler threads smarm-sched-{slot}
Extra scheduler threads (slots 1..N-1) are now spawned via thread::Builder
with the name smarm-sched-{slot}, so they are identifiable in
/proc/<pid>/task/*/comm, stack dumps and debuggers. Thread 0 keeps its
caller-given name (an embedder names the thread that calls run — smarm_beam
names it smarm-runtime). A refused spawn still panics, matching the previous
thread::spawn semantics.

Motivation: the §17 scheduler-width knob in smarm_beam asserts the *live*
width by counting these named threads, and a 20-scheduler soak needs the
threads tellable apart in wedge captures.
2026-07-11 19:17:52 +00:00
smarm-agent 0017c5b9a1 fix(runtime): consume wake-pipe bytes only under the drain lock
Lost-wakeup: schedule_loop's phase-1 drain uses drain_lock.try_lock(), and
try_lock losers skip the completion drain entirely. Both schedulers park on
one shared wake pipe and, until now, drained ALL its bytes right after their
idle poll_wake returned — outside the drain lock. A loser could therefore
eat the byte announcing a completion the winner had not seen (the winner was
already past drain_completions when the epoll thread pushed it), and both
threads would park with the completion stranded. Because the bridge eventfd
is registered EPOLLONESHOT, the kernel had already disarmed it at
epoll_wait, so no later write could re-fire it: the runtime slept until an
unrelated timer deadline forced another phase-1 pass.

Fix: drain_wake_pipe() moves inside the drain guard, immediately before
drain_completions(); the two post-poll drains in the Pop::Idle arms are
removed. Producers push their completion before writing the byte, so a byte
consumed under the guard always has its completion visible to the drain that
follows. An unconsumed byte keeps the (level-triggered) idle poll returning
instantly, so a try_lock loser spins briefly until the winner releases —
it can no longer sleep through stranded work.

Found via smarm_beam's ingress-cap drain barrier flaking under CPU load
(5/25 loaded suite runs wedged; mid-wedge stacks showed both schedulers in
poll_wake with an FdReady stranded and the eventfd disarmed). Post-fix:
60/60 loaded runs green, tight 5.8-6.8s timing band, no stall tail.
Root-cause notes: smarm_beam outputs/flake-rootcause-egress-overload.md.
2026-07-11 16:13:46 +00:00
smarm-agent 6c2b7e91cf channel: drop queued messages when the Receiver drops
A queued Envelope::Call was stranded until the last Sender dropped, so a
caller parked in gen_server::call was never released with ServerDown when a
*named* server was request_stop'd — the registry's inbox Sender clone (lazy
prune) kept the channel Arc, and the queued reply_tx, alive indefinitely.

Receiver::Drop now drains the queue (items dropped after releasing the lock,
since a reply_tx drop reaches a different channel's lock + the scheduler),
restoring the documented ServerDown guarantee on every teardown path.

Adds tests/stop_with_queued_call.rs: deterministic pure-smarm reproducer.
2026-06-24 20:53:27 +00:00
smarm-agent 3e9c33377c gen_statem: disambiguate module/macro intra-doc links 2026-06-20 19:51:33 +00:00
smarm-agent e54c67c431 supervisor: rewrite docs for users; relocate internals to items 2026-06-20 19:51:32 +00:00
smarm-agent 3e321eaaf3 pg: rewrite docs for users; relocate internals to items
Reframe the module doc in the gen_server house style: lead with what a
process group is and when to reach for one, the auto-eviction-on-death
behaviour, groups-vs-registry, and a running-context note. Keep the
runnable example; add an ignored dispatch/worker-pool example.

Move implementation reasoning to where a maintainer stands: lock
discipline onto the ProcessGroups store, the eager-cleanup rationale onto
reap_group, the join finalize-race detail into join's body comment.

Drop all RFC references and the stray phase marker, and lead the public
read/select fn docs with what the caller gets. Demote the pub(crate)
assert_type intra-doc link in pick_as to plain code, clearing a
pre-existing broken-link warning.
2026-06-20 18:51:00 +00:00
smarm-agent c415f14dd0 ci: deny unwrap_used/expect_used on the library target
Add [lints.clippy] unwrap_used = "deny", expect_used = "deny" plus a tracked
pre-commit hook running `cargo clippy --lib -- -D warnings`. Library code may
not hide a panic behind unwrap/expect; panic!/unreachable! stay un-linted as
the explicit sanctioned form. Gate is the library target only — tests and
examples are not gated. A fresh clone must run
`git config core.hooksPath .githooks` to enable the hook.
2026-06-20 17:47:44 +00:00
smarm-agent 33177a0c48 library + trace: rewrite panic sites as explicit match+panic
Apply the same explicit match+panic shape to the library layer (channel,
gen_server, gen_statem). Extend it to the smarm-trace-gated code that the
default `cargo clippy --lib` does not see: the GLOBAL lock-poison sites in
trace.rs and the current_pid sites inside te!() in channel.rs. Keep the
current_pid match inside the te!() argument so non-trace builds evaluate
nothing extra on the recv-wake hot path. const-init the trace thread-local.
2026-06-20 17:47:39 +00:00
smarm-agent a875fa8285 core: rewrite panic sites as explicit match+panic
Replace implicit unwrap()/expect() in the lock-ordered core with explicit
match arms. Lock-poison sites use one uniform message
("smarm: <lock> lock poisoned (core corrupt): {e}"); invariant sites panic
with a descriptive message naming the violated invariant. No behaviour
change: each rewrite preserves the prior panic-on-bad-arm semantics. Also
clears the accompanying clippy hygiene in these files (redundant_closure,
len_without_is_empty, too_many_arguments, unnecessary_sort_by,
missing_safety_doc, nonminimal_bool/unnecessary_unwrap).
2026-06-20 17:47:33 +00:00
smarm-agent 531571bfa5 gen_statem: postpone events for replay after a transition
A `=> postpone` row (cast/call/info) defers the current event untouched,
to be replayed after the next real transition. `handle` is now two-phase:
a borrow-only postpone pre-pass that hands the event back as
`Step::Postponed(ev)`, then the existing consuming `match (state, event)`.
The loop owns a FIFO queue, drained in the new state ahead of further
intake; a replayed event may postpone again. A postponed `call` keeps its
Reply, so a later state answers it.

`handle` returns `Step` (Postponed / Transitioned / Stayed) so the loop
can see both deferral and transition without reading the state cell.
`Resolution::Postpone` is removed: postpone is a pre-dispatch routing
decision, not a consuming-dispatch outcome.
2026-06-20 13:02:15 +00:00
smarm-agent acf67fef06 gen_statem: state and named timeouts, info events
Add the two timeout flavours, both surfacing as ordinary events matched
in on-state arms:

- cx.state_timeout(d): fires a state_timeout event after d in the current
  state, auto-reset by the loop on every real transition.
- cx.timeout(name, d): fires a timeout(name) event after d, surviving state
  changes, keyed by name, with cx.cancel_timeout(name).

Both ride the existing timer min-heap via send_after_to onto a new per-loop
system channel, selected above the inbox so a fire can't be starved by inbox
traffic. A local-id stamp on each fire lets a reset/cancel that loses the race
discard a stale fire. The macro grows an info: clause and folds three internal
Ev variants (Info, StateTimeout, Timeout) alongside cast/call, with new row
keywords info / state_timeout / timeout. Unmatched info silently drops (the
gen_server default); state/named timeouts have no default, so a state that can
see one must handle it or the match is non-exhaustive.

Rename the hand-written expansion-target example fused -> expanded, retire the
deprecated Switch demo machine (its round-trip / enter / panic-down coverage
moves onto the timer machine), and refresh the macro docs to the door machine.

cargo build --all-targets warning-free; cargo test green.
2026-06-20 12:22:45 +00:00
smarm bfa513cd6d doc: rework gen_server module docs completely 2026-06-20 13:41:27 +02:00
84 changed files with 12665 additions and 2339 deletions
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# smarm pre-commit gate: clippy the library (src/) with warnings as errors.
# unwrap_used / expect_used are denied (Cargo.toml [lints.clippy]): library
# code must not hide a panic behind unwrap/expect. Tests/examples are not gated.
#
# Toolchain resolution: prefer an installed cargo-clippy; on machines whose
# rust comes without the clippy component (e.g. NixOS home-manager), fall
# back to an ephemeral nix-shell toolchain. The fallback uses its own target
# dir (target/clippy) because the shell's rustc version may differ from the
# default toolchain's — mixed-compiler artifacts in one target dir are an
# E0514 hard error. MSRV (Cargo.toml rust-version) keeps the older shell
# toolchain a legitimate gate.
set -eu
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
cd "$(git rev-parse --show-toplevel)"
if cargo clippy --version >/dev/null 2>&1; then
cargo clippy --lib -- -D warnings
elif command -v nix-shell >/dev/null 2>&1; then
nix-shell -p clippy -p cargo -p rustc \
--run 'CARGO_TARGET_DIR=target/clippy cargo clippy --lib -- -D warnings'
else
echo "pre-commit: cargo clippy unavailable and no nix-shell fallback" >&2
exit 1
fi
+1
View File
@@ -4,3 +4,4 @@ smarm_trace.json
/bench_results/
__pycache__/
*.pyc
profile.coz
+29 -1
View File
@@ -1,15 +1,28 @@
[package]
name = "smarm"
version = "0.4.0"
version = "0.6.1"
edition = "2021"
rust-version = "1.95"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
[lints.clippy]
# Library code must never hide a panic behind unwrap/expect. Both are denied; an
# intentional panic is written explicitly as `match { Err(e) => panic!(..) }`.
# panic!/unreachable! are deliberately left un-linted as the blessed explicit
# form. Enforced on the library target only (`cargo clippy --lib`); tests and
# examples unwrap freely and are not gated.
unwrap_used = "deny"
expect_used = "deny"
[features]
default = ["rq-mutex"]
smarm-trace = []
# RFC 007: native causal profiling. Zero cost when off (cf. smarm-trace): the
# hook in `maybe_preempt` and the resume-path fast-forward compile away; the
# two Slot ledger fields exist regardless and stay 0 (budget_cycles precedent).
smarm-causal = []
# RFC 016 Chunk 2: cycle-accurate per-actor time-budget accounting. Off by
# default — it costs two extra RDTSC reads per actor resume on the hot path
# (D6). The `ActorInfo.budget_cycles` field exists regardless; it just stays 0
@@ -26,6 +39,9 @@ rq-mutex = []
rq-mpmc = []
rq-striped = []
[build-dependencies]
cc = "1"
[dependencies]
libc = "0.2"
@@ -80,3 +96,15 @@ harness = false
[[example]]
name = "observer"
required-features = ["observer"]
[[example]]
name = "causal_pipeline"
required-features = ["smarm-causal"]
[[example]]
name = "causal_attrib_probe"
required-features = ["smarm-causal"]
[[example]]
name = "causal_probe"
required-features = ["smarm-causal"]
+67 -26
View File
@@ -26,7 +26,9 @@ use std::time::Instant;
const ITERS: u32 = 15;
fn available_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
fn env_sets() -> u32 {
@@ -108,17 +110,15 @@ fn bench_chained_smarm(threads: usize) -> (u64, u128) {
fn bench_chained_tokio_current() -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0));
let c2 = counter.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
// Use a oneshot done channel like tokio's own chained_spawn bench.
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
fn iter(
c: Arc<AtomicU64>,
done: tokio::sync::oneshot::Sender<()>,
n: u64,
) {
fn iter(c: Arc<AtomicU64>, done: tokio::sync::oneshot::Sender<()>, n: u64) {
if n == 0 {
let _ = done.send(());
} else {
@@ -186,7 +186,9 @@ fn bench_yield_smarm(threads: usize) -> (u64, u128) {
}
fn bench_yield_tokio_current() -> (u64, u128) {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -235,11 +237,22 @@ const PRIME_N: u64 = 400_000;
const PRIME_WORKERS: u64 = 64;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 { return false; }
if n < 2 {
return false;
}
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64;
while i * i <= n { if n % i == 0 { return false; } i += 2; }
while i * i <= n {
if n % i == 0 {
return false;
}
i += 2;
}
true
}
@@ -250,7 +263,11 @@ fn count_primes(lo: u64, hi: u64) -> u64 {
fn primes_slice(w: u64) -> (u64, u64) {
let per = PRIME_N / PRIME_WORKERS;
let lo = w * per;
let hi = if w + 1 == PRIME_WORKERS { PRIME_N } else { lo + per };
let hi = if w + 1 == PRIME_WORKERS {
PRIME_N
} else {
lo + per
};
(lo, hi)
}
@@ -267,7 +284,9 @@ fn bench_primes_smarm(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -275,7 +294,9 @@ fn bench_primes_smarm(threads: usize) -> (u64, u128) {
fn bench_primes_tokio_current() -> (u64, u128) {
let total = Arc::new(AtomicU64::new(0));
let t2 = total.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -287,7 +308,9 @@ fn bench_primes_tokio_current() -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -309,7 +332,9 @@ fn bench_primes_tokio_multi() -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -344,7 +369,9 @@ fn bench_pp_smarm(threads: usize) -> (u64, u128) {
}
fn bench_pp_tokio_current() -> (u64, u128) {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -395,7 +422,6 @@ fn bench_pp_tokio_multi() -> (u64, u128) {
// main
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars
// so the sweep script can override the preemption knobs without recompiling.
@@ -404,10 +430,14 @@ fn bench_pp_tokio_multi() -> (u64, u128) {
fn bench_cfg(threads: usize) -> smarm::runtime::Config {
let mut cfg = smarm::runtime::Config::exact(threads);
if let Ok(v) = std::env::var("SMARM_ALLOC_INTERVAL") {
if let Ok(n) = v.parse::<u32>() { cfg = cfg.alloc_interval(n); }
if let Ok(n) = v.parse::<u32>() {
cfg = cfg.alloc_interval(n);
}
}
if let Ok(v) = std::env::var("SMARM_TIMESLICE_CYCLES") {
if let Ok(n) = v.parse::<u64>() { cfg = cfg.timeslice_cycles(n); }
if let Ok(n) = v.parse::<u64>() {
cfg = cfg.timeslice_cycles(n);
}
}
cfg
}
@@ -417,7 +447,10 @@ fn main() {
println!("smarm general benchmarks");
println!("available parallelism: {n} threads");
let sets = env_sets();
println!("ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)", ITERS * sets);
println!(
"ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)",
ITERS * sets
);
println!(
"CHAIN_DEPTH={CHAIN_DEPTH}, YIELD_TASKS={YIELD_TASKS}×{YIELD_ROUNDS}, \
PRIME_N={PRIME_N}/{PRIME_WORKERS} workers, PP_ROUNDS={PP_ROUNDS}"
@@ -426,21 +459,29 @@ fn main() {
// ---- 1. chained_spawn ----
print_header(&format!("chained_spawn: depth {CHAIN_DEPTH}"));
run_n("smarm 1-thread", ITERS, || bench_chained_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_chained_smarm(n));
run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_chained_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_chained_tokio_current);
run_n("tokio multi-thread", ITERS, bench_chained_tokio_multi);
// ---- 2. yield_many ----
print_header(&format!("yield_many: {YIELD_TASKS} tasks × {YIELD_ROUNDS} yields"));
print_header(&format!(
"yield_many: {YIELD_TASKS} tasks × {YIELD_ROUNDS} yields"
));
run_n("smarm 1-thread", ITERS, || bench_yield_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_yield_smarm(n));
run_n("tokio current_thread", ITERS, bench_yield_tokio_current);
run_n("tokio multi-thread", ITERS, bench_yield_tokio_multi);
// ---- 3. fan_out_compute ----
print_header(&format!("fan_out_compute: primes in [2, {PRIME_N}) across {PRIME_WORKERS}"));
print_header(&format!(
"fan_out_compute: primes in [2, {PRIME_N}) across {PRIME_WORKERS}"
));
run_n("smarm 1-thread", ITERS, || bench_primes_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_primes_smarm(n));
run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_primes_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_primes_tokio_current);
run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi);
+90 -41
View File
@@ -64,11 +64,22 @@ const PRIME_N: u64 = 400_000;
const WORKERS: u64 = 64;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 { return false; }
if n < 2 {
return false;
}
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64;
while i * i <= n { if n % i == 0 { return false; } i += 2; }
while i * i <= n {
if n % i == 0 {
return false;
}
i += 2;
}
true
}
@@ -96,7 +107,9 @@ fn bench_primes_smarm(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -104,7 +117,9 @@ fn bench_primes_smarm(threads: usize) -> (u64, u128) {
fn bench_primes_tokio_current() -> (u64, u128) {
let total = Arc::new(AtomicU64::new(0));
let t2 = total.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -116,7 +131,9 @@ fn bench_primes_tokio_current() -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -138,17 +155,21 @@ fn bench_primes_tokio_multi() -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
fn bench_primes_baseline() -> (u64, u128) {
let start = Instant::now();
let total: u64 = (0..WORKERS).map(|w| {
let (lo, hi) = primes_slice(w);
count_primes(lo, hi)
}).sum();
let total: u64 = (0..WORKERS)
.map(|w| {
let (lo, hi) = primes_slice(w);
count_primes(lo, hi)
})
.sum();
(total, start.elapsed().as_micros())
}
@@ -167,15 +188,17 @@ fn bench_pingpong_smarm(threads: usize) -> (u64, u128) {
tx_a.send(0).unwrap();
loop {
let v = rx_b.recv().unwrap();
if v >= PING_ROUNDS { break; }
if v >= PING_ROUNDS {
break;
}
tx_a.send(v + 1).unwrap();
}
});
let hb = smarm::spawn(move || {
loop {
let v = rx_a.recv().unwrap();
tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS { break; }
let hb = smarm::spawn(move || loop {
let v = rx_a.recv().unwrap();
tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS {
break;
}
});
ha.join().unwrap();
@@ -198,7 +221,9 @@ fn bench_pingpong_tokio_current() -> (u64, u128) {
tx_a.send(0).unwrap();
loop {
let v = rx_b.recv().await.unwrap();
if v >= PING_ROUNDS { break; }
if v >= PING_ROUNDS {
break;
}
tx_a.send(v + 1).unwrap();
}
});
@@ -206,7 +231,9 @@ fn bench_pingpong_tokio_current() -> (u64, u128) {
loop {
let v = rx_a.recv().await.unwrap();
tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS { break; }
if v + 1 >= PING_ROUNDS {
break;
}
}
});
let _ = ha.await;
@@ -229,7 +256,9 @@ fn bench_pingpong_tokio_multi() -> (u64, u128) {
tx_a.send(0).unwrap();
loop {
let v = rx_b.recv().await.unwrap();
if v >= PING_ROUNDS { break; }
if v >= PING_ROUNDS {
break;
}
tx_a.send(v + 1).unwrap();
}
});
@@ -237,7 +266,9 @@ fn bench_pingpong_tokio_multi() -> (u64, u128) {
loop {
let v = rx_a.recv().await.unwrap();
tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS { break; }
if v + 1 >= PING_ROUNDS {
break;
}
}
});
let _ = ha.await;
@@ -264,7 +295,9 @@ fn bench_spawn_smarm(threads: usize) -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
(counter.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -272,7 +305,9 @@ fn bench_spawn_smarm(threads: usize) -> (u64, u128) {
fn bench_spawn_tokio_current() -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0));
let c = counter.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -283,7 +318,9 @@ fn bench_spawn_tokio_current() -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(counter.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -304,7 +341,9 @@ fn bench_spawn_tokio_multi() -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(counter.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -320,24 +359,34 @@ fn main() {
println!("PRIME_N={PRIME_N}, WORKERS={WORKERS}, PING_ROUNDS={PING_ROUNDS}, SPAWN_COUNT={SPAWN_COUNT}");
// ---- Primes ----
print_header(&format!("Fan-out/fan-in: count primes in [2, {PRIME_N}) across {WORKERS} workers"));
run_n("baseline (serial)", ITERS, bench_primes_baseline);
run_n("smarm single-thread", ITERS, || bench_primes_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_primes_smarm(n));
run_n("tokio current_thread", ITERS, bench_primes_tokio_current);
run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi);
print_header(&format!(
"Fan-out/fan-in: count primes in [2, {PRIME_N}) across {WORKERS} workers"
));
run_n("baseline (serial)", ITERS, bench_primes_baseline);
run_n("smarm single-thread", ITERS, || bench_primes_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_primes_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_primes_tokio_current);
run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi);
// ---- Ping-pong ----
print_header(&format!("Ping-pong: {PING_ROUNDS} round-trips between two actors"));
run_n("smarm single-thread", ITERS, || bench_pingpong_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_pingpong_smarm(n));
run_n("tokio current_thread", ITERS, bench_pingpong_tokio_current);
run_n("tokio multi-thread", ITERS, bench_pingpong_tokio_multi);
print_header(&format!(
"Ping-pong: {PING_ROUNDS} round-trips between two actors"
));
run_n("smarm single-thread", ITERS, || bench_pingpong_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_pingpong_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_pingpong_tokio_current);
run_n("tokio multi-thread", ITERS, bench_pingpong_tokio_multi);
// ---- Spawn throughput ----
print_header(&format!("Spawn throughput: {SPAWN_COUNT} actors spawned and joined"));
run_n("smarm single-thread", ITERS, || bench_spawn_smarm(1));
print_header(&format!(
"Spawn throughput: {SPAWN_COUNT} actors spawned and joined"
));
run_n("smarm single-thread", ITERS, || bench_spawn_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_spawn_smarm(n));
run_n("tokio current_thread", ITERS, bench_spawn_tokio_current);
run_n("tokio multi-thread", ITERS, bench_spawn_tokio_multi);
run_n("tokio current_thread", ITERS, bench_spawn_tokio_current);
run_n("tokio multi-thread", ITERS, bench_spawn_tokio_multi);
}
+24 -7
View File
@@ -16,12 +16,20 @@ const WORKERS: u64 = 16;
const ITERATIONS: u32 = 5;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 { return false; }
if n < 2 {
return false;
}
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64;
while i * i <= n {
if n % i == 0 { return false; }
if n % i == 0 {
return false;
}
i += 2;
}
true
@@ -30,7 +38,9 @@ fn is_prime(n: u64) -> bool {
fn count_primes_in(lo: u64, hi: u64) -> u64 {
let mut count = 0u64;
for n in lo..hi {
if is_prime(n) { count += 1; }
if is_prime(n) {
count += 1;
}
}
count
}
@@ -38,7 +48,11 @@ fn count_primes_in(lo: u64, hi: u64) -> u64 {
fn slice(worker: u64) -> (u64, u64) {
let per = N / WORKERS;
let lo = worker * per;
let hi = if worker + 1 == WORKERS { N } else { (worker + 1) * per };
let hi = if worker + 1 == WORKERS {
N
} else {
(worker + 1) * per
};
(lo, hi)
}
@@ -125,7 +139,10 @@ fn main() {
"Counting primes in [2, {}) across {} workers, {} iterations each\n",
N, WORKERS, ITERATIONS
);
println!("{:>12} | {:>15} | {:>16} | {:>15} | {:>15}", "runtime", "primes found", "median", "min", "max");
println!(
"{:>12} | {:>15} | {:>16} | {:>15} | {:>15}",
"runtime", "primes found", "median", "min", "max"
);
println!("{}", "-".repeat(80));
run_n("baseline", ITERATIONS, bench_baseline);
+44 -7
View File
@@ -27,12 +27,19 @@ use std::sync::Arc;
use std::time::Instant;
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn env_threads() -> Vec<usize> {
std::env::var("SMARM_BENCH_THREADS")
.map(|v| v.split_whitespace().filter_map(|t| t.parse().ok()).collect())
.map(|v| {
v.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect()
})
.unwrap_or_else(|_| vec![1, 2, 4])
}
@@ -53,7 +60,11 @@ fn drive<Q: Send + Sync + 'static>(
for p in 0..producers {
let q = q.clone();
// Give the last producer the remainder.
let n = if p == producers - 1 { items - per * (producers - 1) } else { per };
let n = if p == producers - 1 {
items - per * (producers - 1)
} else {
per
};
hs.push(std::thread::spawn(move || {
let pid = Pid::new(p as u32, 0);
for _ in 0..n {
@@ -132,7 +143,12 @@ fn main() {
for &t in &threads_sweep {
for (p, c) in ratios_for(t) {
for s in ["mutex", "mpmc", "striped"] {
cases.push(Case { structure: s, threads: t, producers: p, consumers: c });
cases.push(Case {
structure: s,
threads: t,
producers: p,
consumers: c,
});
}
}
}
@@ -147,7 +163,14 @@ fn main() {
if case.threads < 2 {
drive_single(&*q, MutexQueue::push, MutexQueue::pop, items)
} else {
drive(q, MutexQueue::push, MutexQueue::pop, case.producers, case.consumers, items)
drive(
q,
MutexQueue::push,
MutexQueue::pop,
case.producers,
case.consumers,
items,
)
}
}
"mpmc" => {
@@ -155,7 +178,14 @@ fn main() {
if case.threads < 2 {
drive_single(&*q, MpmcRing::push, MpmcRing::pop, items)
} else {
drive(q, MpmcRing::push, MpmcRing::pop, case.producers, case.consumers, items)
drive(
q,
MpmcRing::push,
MpmcRing::pop,
case.producers,
case.consumers,
items,
)
}
}
"striped" => {
@@ -163,7 +193,14 @@ fn main() {
if case.threads < 2 {
drive_single(&*q, StripedRing::push, StripedRing::pop, items)
} else {
drive(q, StripedRing::push, StripedRing::pop, case.producers, case.consumers, items)
drive(
q,
StripedRing::push,
StripedRing::pop,
case.producers,
case.consumers,
items,
)
}
}
_ => unreachable!(),
+21 -4
View File
@@ -54,12 +54,19 @@ fn variant() -> &'static str {
}
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn env_threads() -> Vec<usize> {
std::env::var("SMARM_BENCH_THREADS")
.map(|v| v.split_whitespace().filter_map(|t| t.parse().ok()).collect())
.map(|v| {
v.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect()
})
.unwrap_or_else(|_| vec![1, 2, 4])
}
@@ -238,12 +245,22 @@ fn main() {
);
println!(
"RQCSV,runtime,{},{},{},{},{},{},{}",
variant(), slot_str, name, t, work, mid.us, per_s
variant(),
slot_str,
name,
t,
work,
mid.us,
per_s
);
if slot {
println!(
"RQSLOT,{},{},{},{},{}",
variant(), name, t, mid.hits, mid.displacements
variant(),
name,
t,
mid.hits,
mid.displacements
);
}
}
+55 -19
View File
@@ -37,7 +37,9 @@ use std::time::Instant;
const ITERS: u32 = 15;
fn available_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
fn env_sets() -> u32 {
@@ -116,7 +118,9 @@ fn bench_recurse_smarm(threads: usize) -> (u64, u128) {
fn bench_recurse_tokio_current() -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0));
let c2 = counter.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -199,7 +203,9 @@ fn bench_hot_smarm() -> (u64, u128) {
}
fn bench_hot_tokio_current() -> (u64, u128) {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -249,7 +255,9 @@ fn bench_unc_smarm() -> (u64, u128) {
}
fn bench_unc_tokio_current() -> (u64, u128) {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -297,8 +305,12 @@ fn bench_panic_smarm(threads: usize) -> (u64, u128) {
}
for h in handles {
match h.join() {
Ok(()) => { ok2.fetch_add(1, Ordering::Relaxed); }
Err(_) => { err2.fetch_add(1, Ordering::Relaxed); }
Ok(()) => {
ok2.fetch_add(1, Ordering::Relaxed);
}
Err(_) => {
err2.fetch_add(1, Ordering::Relaxed);
}
}
}
});
@@ -312,7 +324,9 @@ fn bench_panic_tokio_current() -> (u64, u128) {
let err = Arc::new(AtomicU64::new(0));
let ok2 = ok.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();
@@ -328,8 +342,12 @@ fn bench_panic_tokio_current() -> (u64, u128) {
}
for h in handles {
match h.await {
Ok(()) => { ok2.fetch_add(1, Ordering::Relaxed); }
Err(_) => { err2.fetch_add(1, Ordering::Relaxed); }
Ok(()) => {
ok2.fetch_add(1, Ordering::Relaxed);
}
Err(_) => {
err2.fetch_add(1, Ordering::Relaxed);
}
}
}
});
@@ -361,8 +379,12 @@ fn bench_panic_tokio_multi() -> (u64, u128) {
}
for h in handles {
match h.await {
Ok(()) => { ok2.fetch_add(1, Ordering::Relaxed); }
Err(_) => { err2.fetch_add(1, Ordering::Relaxed); }
Ok(()) => {
ok2.fetch_add(1, Ordering::Relaxed);
}
Err(_) => {
err2.fetch_add(1, Ordering::Relaxed);
}
}
}
});
@@ -375,7 +397,6 @@ fn bench_panic_tokio_multi() -> (u64, u128) {
// main
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars
// so the sweep script can override the preemption knobs without recompiling.
@@ -384,10 +405,14 @@ fn bench_panic_tokio_multi() -> (u64, u128) {
fn bench_cfg(threads: usize) -> smarm::runtime::Config {
let mut cfg = smarm::runtime::Config::exact(threads);
if let Ok(v) = std::env::var("SMARM_ALLOC_INTERVAL") {
if let Ok(n) = v.parse::<u32>() { cfg = cfg.alloc_interval(n); }
if let Ok(n) = v.parse::<u32>() {
cfg = cfg.alloc_interval(n);
}
}
if let Ok(v) = std::env::var("SMARM_TIMESLICE_CYCLES") {
if let Ok(n) = v.parse::<u64>() { cfg = cfg.timeslice_cycles(n); }
if let Ok(n) = v.parse::<u64>() {
cfg = cfg.timeslice_cycles(n);
}
}
cfg
}
@@ -397,7 +422,10 @@ fn main() {
println!("smarm smarm-favored benchmarks");
println!("available parallelism: {n} threads");
let sets = env_sets();
println!("ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)", ITERS * sets);
println!(
"ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)",
ITERS * sets
);
println!(
"RECURSE_DEPTH={RECURSE_DEPTH}, HOT_YIELDS={HOT_YIELDS}×2, \
UNCONT_MSGS={UNCONT_MSGS}, PANIC_TASKS={PANIC_TASKS}"
@@ -406,22 +434,30 @@ fn main() {
// ---- 9. deep_recursion ----
print_header(&format!("deep_recursion: depth {RECURSE_DEPTH}"));
run_n("smarm 1-thread", ITERS, || bench_recurse_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_recurse_smarm(n));
run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_recurse_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_recurse_tokio_current);
run_n("tokio multi-thread", ITERS, bench_recurse_tokio_multi);
// ---- 10. yield_in_hot_loop ----
print_header(&format!("yield_in_hot_loop: 2 actors × {HOT_YIELDS} yields (single thread)"));
print_header(&format!(
"yield_in_hot_loop: 2 actors × {HOT_YIELDS} yields (single thread)"
));
run_n("smarm 1-thread", ITERS, bench_hot_smarm);
run_n("tokio current_thread", ITERS, bench_hot_tokio_current);
// ---- 11. uncontended_channel ----
print_header(&format!("uncontended_channel: 1→1, {UNCONT_MSGS} msgs (single thread)"));
print_header(&format!(
"uncontended_channel: 1→1, {UNCONT_MSGS} msgs (single thread)"
));
run_n("smarm 1-thread", ITERS, bench_unc_smarm);
run_n("tokio current_thread", ITERS, bench_unc_tokio_current);
// ---- 12. catch_unwind_panics ----
print_header(&format!("catch_unwind_panics: {PANIC_TASKS} tasks, 50% panic"));
print_header(&format!(
"catch_unwind_panics: {PANIC_TASKS} tasks, 50% panic"
));
run_n("smarm 1-thread", ITERS, || bench_panic_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_panic_smarm(n));
run_n("tokio current_thread", ITERS, bench_panic_tokio_current);
+30 -5
View File
@@ -73,7 +73,10 @@ fn variant() -> &'static str {
}
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
// --------------------------------------------------------------------------
@@ -226,7 +229,11 @@ fn main() {
let mean_cyc = pooled_cyc.iter().map(|&v| v as f64).sum::<f64>() / n.max(1) as f64;
// Derived effective frequency: cycles per ns = GHz. Cross-checks the two
// lenses against the box's known base clock.
let derived_ghz = if mean_ns > 0.0 { mean_cyc / mean_ns } else { 0.0 };
let derived_ghz = if mean_ns > 0.0 {
mean_cyc / mean_ns
} else {
0.0
};
let p50 = pct(&pooled_ns, 50.0);
let p90 = pct(&pooled_ns, 90.0);
@@ -241,8 +248,14 @@ fn main() {
" rounds={} warmup={} runs={} (instrumentation floor: {} ns / {} cyc, subtracted)",
rounds, warmup, runs, floor_ns, floor_cyc
);
println!(" {:<10} {:<10} {:<10} {:<10} {:<10}", "p50 ns", "p90 ns", "p99 ns", "min ns", "max ns");
println!(" {:<10} {:<10} {:<10} {:<10} {:<10}", p50, p90, p99, lo, hi);
println!(
" {:<10} {:<10} {:<10} {:<10} {:<10}",
"p50 ns", "p90 ns", "p99 ns", "min ns", "max ns"
);
println!(
" {:<10} {:<10} {:<10} {:<10} {:<10}",
p50, p90, p99, lo, hi
);
println!(
" mean {:.1} ns | mean {:.0} cyc | derived {:.3} GHz",
mean_ns, mean_cyc, derived_ghz
@@ -251,6 +264,18 @@ fn main() {
// Greppable line — same spirit as SPINCSV.
println!(
"SWITCHCSV,{},{},{},{},{},{},{},{},{},{},{:.1},{:.0},{:.3}",
variant(), mode, rounds, runs, n, p50, p90, p99, lo, hi, mean_ns, mean_cyc, derived_ghz
variant(),
mode,
rounds,
runs,
n,
p50,
p90,
p99,
lo,
hi,
mean_ns,
mean_cyc,
derived_ghz
);
}
+107 -35
View File
@@ -36,7 +36,9 @@ use std::time::{Duration, Instant};
const ITERS: u32 = 15;
fn available_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
fn env_sets() -> u32 {
@@ -84,8 +86,8 @@ fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
// 5. spawn_storm_busy — workers loaded, then storm of zero-work spawns
// ---------------------------------------------------------------------------
const STORM_BACKGROUND: u64 = 8; // number of background "busy" actors
const STORM_SPAWN: u64 = 10_000; // zero-work spawns to time
const STORM_BACKGROUND: u64 = 8; // number of background "busy" actors
const STORM_SPAWN: u64 = 10_000; // zero-work spawns to time
fn bench_storm_smarm(threads: usize) -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0));
@@ -114,11 +116,15 @@ fn bench_storm_smarm(threads: usize) -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
// Tear down background.
s2.store(true, Ordering::Relaxed);
for h in bg_handles { h.join().unwrap(); }
for h in bg_handles {
h.join().unwrap();
}
});
(counter.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -129,7 +135,9 @@ fn bench_storm_tokio_current() -> (u64, u128) {
let c2 = counter.clone();
let s2 = stop.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -149,9 +157,13 @@ fn bench_storm_tokio_current() -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
s2.store(true, Ordering::Relaxed);
for h in bg_handles { let _ = h.await; }
for h in bg_handles {
let _ = h.await;
}
});
(counter.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -184,9 +196,13 @@ fn bench_storm_tokio_multi() -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
s2.store(true, Ordering::Relaxed);
for h in bg_handles { let _ = h.await; }
for h in bg_handles {
let _ = h.await;
}
});
(counter.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -219,14 +235,21 @@ fn bench_mpsc_smarm(threads: usize) -> (u64, u128) {
}
let _ = count; // discard; run() closure must return ()
});
for h in prod_handles { h.join().unwrap(); }
for h in prod_handles {
h.join().unwrap();
}
let _ = consumer.join().unwrap();
});
(MPSC_PRODUCERS * MPSC_PER_PRODUCER, start.elapsed().as_micros())
(
MPSC_PRODUCERS * MPSC_PER_PRODUCER,
start.elapsed().as_micros(),
)
}
fn bench_mpsc_tokio_current() -> (u64, u128) {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
@@ -248,10 +271,15 @@ fn bench_mpsc_tokio_current() -> (u64, u128) {
}
count
});
for h in prod_handles { let _ = h.await; }
for h in prod_handles {
let _ = h.await;
}
let _ = consumer.await;
});
(MPSC_PRODUCERS * MPSC_PER_PRODUCER, start.elapsed().as_micros())
(
MPSC_PRODUCERS * MPSC_PER_PRODUCER,
start.elapsed().as_micros(),
)
}
fn bench_mpsc_tokio_multi() -> (u64, u128) {
@@ -279,10 +307,15 @@ fn bench_mpsc_tokio_multi() -> (u64, u128) {
}
count
});
for h in prod_handles { let _ = h.await; }
for h in prod_handles {
let _ = h.await;
}
let _ = consumer.await;
});
(MPSC_PRODUCERS * MPSC_PER_PRODUCER, start.elapsed().as_micros())
(
MPSC_PRODUCERS * MPSC_PER_PRODUCER,
start.elapsed().as_micros(),
)
}
// ---------------------------------------------------------------------------
@@ -308,7 +341,9 @@ fn bench_timers_smarm(threads: usize) -> (u64, u128) {
smarm::sleep(Duration::from_millis(ms));
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
(TIMER_ACTORS, start.elapsed().as_micros())
}
@@ -328,7 +363,9 @@ fn bench_timers_tokio_current() -> (u64, u128) {
tokio::time::sleep(Duration::from_millis(ms)).await;
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(TIMER_ACTORS, start.elapsed().as_micros())
}
@@ -348,7 +385,9 @@ fn bench_timers_tokio_multi() -> (u64, u128) {
tokio::time::sleep(Duration::from_millis(ms)).await;
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(TIMER_ACTORS, start.elapsed().as_micros())
}
@@ -361,11 +400,22 @@ const SCALING_N: u64 = 400_000;
const SCALING_WORKERS: u64 = 64;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 { return false; }
if n < 2 {
return false;
}
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64;
while i * i <= n { if n % i == 0 { return false; } i += 2; }
while i * i <= n {
if n % i == 0 {
return false;
}
i += 2;
}
true
}
@@ -376,7 +426,11 @@ fn count_primes(lo: u64, hi: u64) -> u64 {
fn scaling_slice(w: u64) -> (u64, u64) {
let per = SCALING_N / SCALING_WORKERS;
let lo = w * per;
let hi = if w + 1 == SCALING_WORKERS { SCALING_N } else { lo + per };
let hi = if w + 1 == SCALING_WORKERS {
SCALING_N
} else {
lo + per
};
(lo, hi)
}
@@ -393,7 +447,9 @@ fn bench_scaling_smarm(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -415,7 +471,9 @@ fn bench_scaling_tokio_multi(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed);
}));
}
for h in handles { let _ = h.await; }
for h in handles {
let _ = h.await;
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
@@ -424,7 +482,6 @@ fn bench_scaling_tokio_multi(threads: usize) -> (u64, u128) {
// main
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars
// so the sweep script can override the preemption knobs without recompiling.
@@ -433,10 +490,14 @@ fn bench_scaling_tokio_multi(threads: usize) -> (u64, u128) {
fn bench_cfg(threads: usize) -> smarm::runtime::Config {
let mut cfg = smarm::runtime::Config::exact(threads);
if let Ok(v) = std::env::var("SMARM_ALLOC_INTERVAL") {
if let Ok(n) = v.parse::<u32>() { cfg = cfg.alloc_interval(n); }
if let Ok(n) = v.parse::<u32>() {
cfg = cfg.alloc_interval(n);
}
}
if let Ok(v) = std::env::var("SMARM_TIMESLICE_CYCLES") {
if let Ok(n) = v.parse::<u64>() { cfg = cfg.timeslice_cycles(n); }
if let Ok(n) = v.parse::<u64>() {
cfg = cfg.timeslice_cycles(n);
}
}
cfg
}
@@ -446,7 +507,10 @@ fn main() {
println!("smarm tokio-favored benchmarks");
println!("available parallelism: {n} threads");
let sets = env_sets();
println!("ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)", ITERS * sets);
println!(
"ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)",
ITERS * sets
);
println!(
"STORM_BACKGROUND={STORM_BACKGROUND}, STORM_SPAWN={STORM_SPAWN}, \
MPSC={MPSC_PRODUCERS}×{MPSC_PER_PRODUCER}, \
@@ -477,7 +541,9 @@ fn main() {
"many_timers: {TIMER_ACTORS} actors sleeping {TIMER_MIN_MS}–{TIMER_MAX_MS} ms"
));
run_n("smarm 1-thread", ITERS, || bench_timers_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_timers_smarm(n));
run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_timers_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_timers_tokio_current);
run_n("tokio multi-thread", ITERS, bench_timers_tokio_multi);
@@ -487,13 +553,19 @@ fn main() {
));
let sweep: Vec<usize> = {
let mut v = vec![1usize, 2, 4];
if n > 4 && !v.contains(&n) { v.push(n); }
if n > 4 && !v.contains(&n) {
v.push(n);
}
v.into_iter().filter(|t| *t <= n).collect()
};
for t in &sweep {
run_n(&format!("smarm {t}-thread"), ITERS, || bench_scaling_smarm(*t));
run_n(&format!("smarm {t}-thread"), ITERS, || {
bench_scaling_smarm(*t)
});
}
for t in &sweep {
run_n(&format!("tokio multi {t}-thread"), ITERS, || bench_scaling_tokio_multi(*t));
run_n(&format!("tokio multi {t}-thread"), ITERS, || {
bench_scaling_tokio_multi(*t)
});
}
}
+11
View File
@@ -0,0 +1,11 @@
fn main() {
// RFC 019 §7 test canary (agreed Q3): compiled without stack-clash
// protection so its 96 KiB local is a genuine one-displacement guard
// jumper; distro-hardened compilers would otherwise probe it page-wise
// and defeat the test's purpose.
cc::Build::new()
.file("canary/canary.c")
.flag_if_supported("-fno-stack-clash-protection")
.compile("smarm_canary");
println!("cargo:rerun-if-changed=canary/canary.c");
}
+14
View File
@@ -0,0 +1,14 @@
/* RFC 019 §7 FFI canary: an honest unprobed C frame with a 96 KiB local,
* touched from its LOW end first — the exact "one sub rsp steps over a small
* guard" pattern the RFC's motivating incident hit (a cargo-vendored gz
* build; cc-invoked builds do not enable -fstack-clash-protection, and this
* file pins that off explicitly so the canary stays a canary even on
* hardened-default toolchains). */
void smarm_canary_burn(void) {
volatile char buf[96 * 1024];
buf[0] = 1; /* deepest address first */
for (unsigned i = 0; i < sizeof buf; i += 4096) {
buf[i] = (char)i;
}
buf[sizeof buf - 1] = 1;
}
+1 -1
View File
@@ -75,7 +75,7 @@ genuine advantage over tokio's task abort model.
### Spawn-heavy workloads (19–70×)
Every smarm actor `mmap`s a 64 KiB stack with a guard page. This is
Every smarm actor `mmap`s a 64 KiB stack reserve with a 64 KiB PROT_NONE guard below (both per-actor configurable since RFC 019; the reserve is demand-paged). This is
a syscall. Tokio tasks are heap-allocated state machines — no stack,
no syscall, ~100 bytes each. For workloads that spawn thousands of
short-lived actors per second, this is a structural disadvantage.
+200
View File
@@ -0,0 +1,200 @@
//! Attribution-efficiency probe (RFC 007 follow-up).
//!
//! Original hypothesis: the ~4pt impact shortfall on the 24-core
//! validation (+29.3/+83.5 vs theoretical +33/+100) is a constant
//! attribution efficiency eff ≈ 0.91 from site-exit tail truncation.
//! The guard-drop flush closed that leak, yet eff held at ~0.93 —
//! RESOLVED (2026-07-13 sweep): the residual is runnable off-CPU time
//! inside the site (~4.9 slice-expiry yields/entry x ~5.6µs runqueue
//! wait), wall time the ground truth below counts but on-CPU
//! attribution correctly skips. The offcpu audit bucket now counts it;
//! `eff+offcpu` printed per window should sit at ~1.00 — the
//! closed-books check.
//!
//! Measurement: same pipeline as `causal_pipeline`, but the `reserve`
//! actor also measures its raw in-site time directly (rdtsc at guard
//! enter/exit) and counts site entries. For each experiment window at
//! pct%:
//!
//! eff = (Δglobal_delay / (pct/100)) / Δin_site_cycles
//!
//! and the missing time per site entry localizes the leak:
//!
//! tail_us/entry = (Δin_site − Δglobal_delay/(pct/100)) / Δentries
//!
//! A constant eff across 25/50% with tail/entry in the tens of µs
//! localizes a per-entry mechanism; `eff+offcpu` ≈ 1.00 confirms the
//! runnable-gap account and rules out any remaining silent loss.
//!
//! Run: cargo run --release --example causal_attrib_probe --features smarm-causal
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn rdtsc() -> u64 {
// x86_64 only — same clock the ledger uses.
unsafe { core::arch::x86_64::_rdtsc() }
}
/// Same fixed-work loop as causal_pipeline (dependent LCG, preemptible).
fn work_iters(iters: u64) {
let mut acc = 0x2545_f491_4f6c_dd1du64;
let mut i = 0u64;
while i < iters {
let chunk_end = (i + 256).min(iters);
while i < chunk_end {
acc = acc.wrapping_mul(6364136223846793005).wrapping_add(i);
i += 1;
}
std::hint::black_box(acc);
smarm::check!();
}
}
fn calibrate_iters_per_us() -> u64 {
let n = 8_000_000u64;
let t = Instant::now();
work_iters(n);
(n / (t.elapsed().as_micros().max(1) as u64)).max(1)
}
static IN_SITE_CYCLES: AtomicU64 = AtomicU64::new(0);
static SITE_ENTRIES: AtomicU64 = AtomicU64::new(0);
fn main() {
let per_us = calibrate_iters_per_us();
println!("calibration: {per_us} work iters/µs");
let work_us = move |us: u64| work_iters(us * per_us);
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
println!("cores: {cores}");
if cores < 4 {
println!("probe: SKIPPED (needs the stages in parallel)");
return;
}
smarm::init(smarm::Config::default()).run(move || {
let stop = Arc::new(AtomicBool::new(false));
let (tx_ab, rx_ab) = smarm::channel::<u64>();
let (tx_bc, rx_bc) = smarm::channel::<u64>();
let stop_p = stop.clone();
let producer = smarm::spawn(move || {
let mut i = 0u64;
while !stop_p.load(Ordering::Relaxed) {
{
let _g = smarm::causal_site!("serialize");
work_us(200);
}
if tx_ab.send(i).is_err() {
break;
}
i += 1;
}
});
// Reserve: the target — instrumented with ground-truth in-site time.
let reserve = smarm::spawn(move || {
while let Ok(item) = rx_ab.recv() {
{
let t0 = rdtsc();
let _g = smarm::causal_site!("reserve");
work_us(400);
// Measured before guard drop: exactly the span the
// ledger should be attributing.
IN_SITE_CYCLES.fetch_add(rdtsc().saturating_sub(t0), Ordering::Relaxed);
SITE_ENTRIES.fetch_add(1, Ordering::Relaxed);
}
if tx_bc.send(item).is_err() {
break;
}
}
});
let notify = smarm::spawn(move || {
while rx_bc.recv().is_ok() {
{
let _g = smarm::causal_site!("notify");
work_us(50);
}
smarm::progress!("orders-processed");
}
});
let stop_bg = stop.clone();
let background = smarm::spawn(move || {
while !stop_bg.load(Ordering::Relaxed) {
let _g = smarm::causal_site!("background-compaction");
work_us(500);
}
});
smarm::sleep(Duration::from_millis(300));
let hz = smarm::causal::tsc_hz();
println!("tsc_hz: {:.3} GHz", hz / 1e9);
// Manual windows so ledger/ground-truth snapshots align exactly.
for &pct in &[25u32, 50, 50, 25] {
let g0 = smarm::causal::global_delay_cycles();
let s0 = IN_SITE_CYCLES.load(Ordering::Relaxed);
let e0 = SITE_ENTRIES.load(Ordering::Relaxed);
let a0 = smarm::causal::ledger_counters();
smarm::causal::begin_experiment_for_test("reserve", pct);
smarm::sleep(Duration::from_millis(1000));
smarm::causal::end_experiment_for_test();
let audit = smarm::causal::ledger_counters().delta_since(&a0);
let injected = smarm::causal::global_delay_cycles() - g0;
let in_site = IN_SITE_CYCLES.load(Ordering::Relaxed) - s0;
let entries = SITE_ENTRIES.load(Ordering::Relaxed) - e0;
let attributed = injected as f64 / (pct as f64 / 100.0);
let eff = attributed / in_site as f64;
// Books-closure check: add back the runnable off-CPU gaps the
// audit counted (delta terms -> raw via /pct) — should be ~1.00.
let eff_closed = (injected as f64 + audit.offcpu_in_site_cycles as f64)
/ (pct as f64 / 100.0)
/ in_site as f64;
let missing = in_site as f64 - attributed;
let tail_us = if entries > 0 {
missing / entries as f64 / hz * 1e6
} else {
f64::NAN
};
println!(
"pct {pct:>2}% in_site {:>8.1}ms attributed {:>8.1}ms eff {eff:.3} eff+offcpu {eff_closed:.3} entries {entries} missing/entry {tail_us:.1}µs",
in_site as f64 / hz * 1e3,
attributed / hz * 1e3,
);
// RFC 007 deficit hunt: name the losses. Drop/discard columns are
// in would-be delta terms — divide by pct/100 to compare with the
// missing attribution above.
let ms = |c: u64| c as f64 / hz * 1e3;
println!(
" audit: absorbed {:>7.1}ms forgiven {:>6.1}ms drop park {:>5.2}ms/{:<5} yield {:>5.2}ms/{:<5} offcpu {:>6.2}ms/{:<5} discard >max {:>5.2}ms/{:<3} unarmed {}",
ms(audit.spin_absorbed_cycles),
ms(audit.park_forgiven_cycles),
ms(audit.drop_park_cycles),
audit.drop_park_n,
ms(audit.drop_yield_cycles),
audit.drop_yield_n,
ms(audit.offcpu_in_site_cycles),
audit.offcpu_in_site_n,
ms(audit.discard_overmax_cycles),
audit.discard_overmax_n,
audit.discard_unarmed_n
);
smarm::sleep(Duration::from_millis(150));
}
stop.store(true, Ordering::Relaxed);
producer.join().unwrap();
reserve.join().unwrap();
notify.join().unwrap();
background.join().unwrap();
println!("probe: DONE");
});
}
+296
View File
@@ -0,0 +1,296 @@
//! Causal-profiling demo (RFC 007): a pipeline where conventional profiling
//! lies and causal profiling doesn't.
//!
//! producer --(serialize ~200µs/item)--> reserve --(~400µs/item)--> notify
//! background: an actor burning CPU constantly, fully off the critical path
//!
//! `reserve` is the true bottleneck. `serialize` is hot but overlapped with
//! `reserve`'s backlog, and `background` is the hottest code in the process
//! while contributing nothing to throughput. A cycle profiler ranks them
//! background > reserve ≈ 2×serialize; the causal report instead shows
//! throughput responding to virtual speedups of `reserve` and (near-)ignoring
//! `serialize` and `background`.
//!
//! Stage cost is fixed *work* (a calibrated arithmetic loop), not fixed wall
//! time. This matters: a timed busy-wait absorbs injected causal delay into
//! its own budget and finishes on schedule regardless, making every
//! experiment read as a no-op (found live on a 24-core run: dead-flat
//! deltas). Real workloads are work-shaped, so the demo must be too.
//!
//! Run:
//! cargo run --release --example causal_pipeline --features smarm-causal
//!
//! Modes (`SMARM_CAUSAL_MODE`), for probing what the guard placement leaves
//! out of the measurement (a site speeds up only what it wraps; `recv`/`send`
//! on the serialized stage sit outside the canonical guard):
//! work (default) — guard wraps only the 400µs of work.
//! wide — guard widened over recv + work + send, the whole
//! serialized per-item path.
//! occupancy — no experiments; times each segment of reserve's loop
//! at baseline and reports the unguarded per-item
//! overhead δ plus the impact ceiling it implies.
//!
//! Result (24-core run, 2026-07-13, job f9305cbb): δ measured 0.3µs/item —
//! 0.1% of the serialized path — and `wide` does not move the @50% cell
//! (+83.5/+86.3 vs work's +81.5/+86.7). This demo's +84-vs-+100 @50%
//! shortfall is therefore NOT unguarded stage time; it is controller-side:
//! injected delay reaches ~327ms of the ideal 350ms over the 700ms window,
//! plus a ~3% real-throughput dip while experiments run. Contrast urus's
//! causal_bench, where the same arithmetic identified a real ~70µs/request
//! unguarded remainder (recv/reply outside the store guard). Sites measure
//! what they wrap — and the occupancy probe tells you which case you're in.
//!
//! Prints a summary, writes `profile.coz` (Coz plot-compatible), and — given
//! enough cores for the pipeline to actually run in parallel — checks the
//! expected separation and exits nonzero if it doesn't hold, so a CI box can
//! run this as a smoke test.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// LCG-mix `iters` times in dependent sequence (unvectorizable, un-elidable),
/// staying preemptible — and causal-sampleable/delayable — via `check!()`.
fn work_iters(iters: u64) {
let mut acc = 0x2545_f491_4f6c_dd1du64;
let mut i = 0u64;
while i < iters {
let chunk_end = (i + 256).min(iters);
while i < chunk_end {
acc = acc.wrapping_mul(6364136223846793005).wrapping_add(i);
i += 1;
}
std::hint::black_box(acc);
smarm::check!();
}
}
/// Measure how many `work_iters` iterations fit in a microsecond on this
/// machine, so stage costs below are meaningful in time while staying
/// work-shaped.
fn calibrate_iters_per_us() -> u64 {
let n = 8_000_000u64;
let t = Instant::now();
work_iters(n);
(n / (t.elapsed().as_micros().max(1) as u64)).max(1)
}
/// Guard placement for the `reserve` stage — see module doc.
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Work,
Wide,
Occupancy,
}
fn main() {
let mode = match std::env::var("SMARM_CAUSAL_MODE").as_deref() {
Err(_) | Ok("") | Ok("work") => Mode::Work,
Ok("wide") => Mode::Wide,
Ok("occupancy") => Mode::Occupancy,
Ok(other) => {
eprintln!("unknown SMARM_CAUSAL_MODE {other:?} (work|wide|occupancy)");
std::process::exit(2);
}
};
println!(
"mode: {}",
match mode {
Mode::Work => "work",
Mode::Wide => "wide",
Mode::Occupancy => "occupancy",
}
);
let per_us = calibrate_iters_per_us();
println!("calibration: {per_us} work iters/µs");
let work_us = move |us: u64| work_iters(us * per_us);
let mut failures: Vec<String> = Vec::new();
smarm::init(smarm::Config::default()).run(move || {
let stop = Arc::new(AtomicBool::new(false));
let (tx_ab, rx_ab) = smarm::channel::<u64>();
let (tx_bc, rx_bc) = smarm::channel::<u64>();
// Producer: hot serialization, but upstream of the bottleneck.
let stop_p = stop.clone();
let producer = smarm::spawn(move || {
let mut i = 0u64;
while !stop_p.load(Ordering::Relaxed) {
{
let _g = smarm::causal_site!("serialize");
work_us(200);
}
if tx_ab.send(i).is_err() {
break;
}
i += 1;
}
// tx_ab drops here; downstream drains and exits.
});
// Reserve: the true bottleneck (~400µs of work per item).
let reserve = smarm::spawn(move || match mode {
Mode::Work => {
while let Ok(item) = rx_ab.recv() {
{
let _g = smarm::causal_site!("reserve");
work_us(400);
}
if tx_bc.send(item).is_err() {
break;
}
}
}
// Whole serialized per-item path under the guard: a virtual
// speedup now also compresses recv/send, so the @50% cell should
// recover the theoretical 2× that `work` mode's placement caps.
Mode::Wide => loop {
let _g = smarm::causal_site!("reserve");
let Ok(item) = rx_ab.recv() else { break };
work_us(400);
if tx_bc.send(item).is_err() {
break;
}
},
// Time each segment at baseline; the recv+send remainder δ is
// the serialized time a `work`-placed guard cannot speed up.
Mode::Occupancy => {
let (mut recv_ns, mut work_ns, mut send_ns, mut n) = (0u64, 0u64, 0u64, 0u64);
loop {
let t0 = Instant::now();
let Ok(item) = rx_ab.recv() else { break };
let t1 = Instant::now();
{
let _g = smarm::causal_site!("reserve");
work_us(400);
}
let t2 = Instant::now();
if tx_bc.send(item).is_err() {
break;
}
recv_ns += (t1 - t0).as_nanos() as u64;
work_ns += (t2 - t1).as_nanos() as u64;
send_ns += t2.elapsed().as_nanos() as u64;
n += 1;
}
let items = n.max(1) as f64;
let (r, w, s) = (
recv_ns as f64 / items / 1e3,
work_ns as f64 / items / 1e3,
send_ns as f64 / items / 1e3,
);
let delta = r + s;
let total = w + delta;
println!("occupancy: {n} items; per item recv {r:.1}µs + work(guarded) {w:.1}µs + send {s:.1}µs");
println!(
"occupancy: unguarded δ = {delta:.1}µs/item = {:.1}% of the serialized path",
100.0 * delta / total
);
for pct in [25u32, 50] {
let f = 1.0 - f64::from(pct) / 100.0;
println!(
"occupancy: predicted reserve impact @{pct}% -> {:+.1}% (ceiling if δ were guarded: {:+.1}%)",
100.0 * (total / (f * w + delta) - 1.0),
100.0 * (1.0 / f - 1.0)
);
}
}
});
// Notify: light tail stage; marks the unit of useful work.
let notify = smarm::spawn(move || {
while rx_bc.recv().is_ok() {
{
let _g = smarm::causal_site!("notify");
work_us(50);
}
smarm::progress!("orders-processed");
}
});
// Background: hottest code in the process, zero throughput relevance.
let stop_bg = stop.clone();
let background = smarm::spawn(move || {
while !stop_bg.load(Ordering::Relaxed) {
let _g = smarm::causal_site!("background-compaction");
work_us(500);
}
});
// Warm up so queues reach steady state before measuring.
smarm::sleep(Duration::from_millis(300));
if mode == Mode::Occupancy {
// No experiments: hold steady state for a window, then drain and
// let the reserve actor print its segment report.
smarm::sleep(Duration::from_millis(1500));
stop.store(true, Ordering::Relaxed);
producer.join().unwrap();
reserve.join().unwrap();
notify.join().unwrap();
background.join().unwrap();
return;
}
let results = smarm::causal::run_experiments(&smarm::causal::ExperimentPlan {
speedups_pct: vec![0, 25, 50],
experiment: Duration::from_millis(700),
cooldown: Duration::from_millis(150),
});
stop.store(true, Ordering::Relaxed);
producer.join().unwrap();
reserve.join().unwrap();
notify.join().unwrap();
background.join().unwrap();
print!("{}", smarm::causal::render_summary(&results));
// RFC 007 deficit hunt: SMARM_CAUSAL_AUDIT=1 appends the per-cell
// ledger audit (injected/absorbed/forgiven + drop and discard
// buckets) without touching the pinned summary format.
if std::env::var_os("SMARM_CAUSAL_AUDIT").is_some() {
print!("{}", smarm::causal::render_ledger_audit(&results));
}
let coz = smarm::causal::render_coz(&results);
match std::fs::write("profile.coz", coz) {
Ok(()) => println!("\nwrote profile.coz"),
Err(e) => eprintln!("\nfailed to write profile.coz: {e}"),
}
// Verdict. The separation only exists when the four pipeline actors
// actually run in parallel; on a small box, report and skip.
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
if cores < 4 {
println!("verdict: SKIPPED ({cores} cores; separation needs the stages in parallel)");
return;
}
let impact = |site: &str| {
smarm::causal::impact_pct(&results, site, 25, "orders-processed")
};
let mut expect = |site: &str, ok: &dyn Fn(f64) -> bool, want: &str| match impact(site) {
Some(p) => {
let verdict = if ok(p) { "ok" } else { "FAIL" };
println!("verdict: {site} @25% -> {p:+.1}% (want {want}) {verdict}");
if !ok(p) {
failures.push(format!("{site}: {p:+.1}% (want {want})"));
}
}
None => {
println!("verdict: {site} @25% -> missing cell FAIL");
failures.push(format!("{site}: missing cell"));
}
};
expect("reserve", &|p| p > 15.0, "> +15%");
expect("serialize", &|p| p < 10.0, "< +10%");
expect("background-compaction", &|p| p < 10.0, "< +10%");
if failures.is_empty() {
println!("verdict: PASS — causal separation holds");
} else {
println!("verdict: FAIL — {}", failures.join("; "));
std::process::exit(1);
}
});
}
+145
View File
@@ -0,0 +1,145 @@
//! Diagnostic probe for RFC 007 on a target box. Measures, in order:
//! 1. TSC frequency against `Instant` (the crate assumes 3 GHz).
//! 2. TSC sanity under actor migration: distribution of wall time actually
//! spent in `burn_us(400)` across many runs — a bimodal/short tail means
//! cross-core TSC offsets are cutting burns short.
//! 3. Pipeline stage rates with no experiment running (who is the real
//! bottleneck?).
//! 4. The same rates during a 50% experiment on `background-compaction`
//! (a correct implementation must slow every stage; an off-critical-path
//! target must reduce end-to-end throughput proportionally).
//!
//! Run: cargo run --release --example causal_probe --features smarm-causal
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn burn_us(us: u64) {
let cycles = us * 3_000;
let start = smarm::preempt::rdtsc();
while smarm::preempt::rdtsc().saturating_sub(start) < cycles {
smarm::check!();
}
}
fn main() {
// 1. TSC calibration (plain OS thread, before the runtime starts).
let c0 = smarm::preempt::rdtsc();
let t0 = Instant::now();
std::thread::sleep(Duration::from_millis(200));
let hz = (smarm::preempt::rdtsc() - c0) as f64 / t0.elapsed().as_secs_f64();
println!("tsc_hz: {:.3e} (crate assumes 3.0e9)", hz);
smarm::init(smarm::Config::default()).run(move || {
// 2. burn_us(400) wall-time distribution inside a migrating actor.
let h = smarm::spawn(|| {
let mut samples: Vec<u64> = (0..500)
.map(|_| {
let t = Instant::now();
burn_us(400);
t.elapsed().as_micros() as u64
})
.collect();
samples.sort_unstable();
println!(
"burn_us(400) wall us: min {} p10 {} p50 {} p90 {} max {}",
samples[0], samples[50], samples[250], samples[450], samples[499]
);
});
h.join().unwrap();
// 3+4. Pipeline with per-stage counters.
let stop = Arc::new(AtomicBool::new(false));
let produced = Arc::new(AtomicU64::new(0));
let reserved = Arc::new(AtomicU64::new(0));
let notified = Arc::new(AtomicU64::new(0));
let (tx_ab, rx_ab) = smarm::channel::<u64>();
let (tx_bc, rx_bc) = smarm::channel::<u64>();
let stop_p = stop.clone();
let produced2 = produced.clone();
let producer = smarm::spawn(move || {
let mut i = 0u64;
while !stop_p.load(Ordering::Relaxed) {
{
let _g = smarm::causal_site!("serialize");
burn_us(200);
}
if tx_ab.send(i).is_err() {
break;
}
produced2.fetch_add(1, Ordering::Relaxed);
i += 1;
}
});
let reserved2 = reserved.clone();
let reserve = smarm::spawn(move || {
while let Ok(item) = rx_ab.recv() {
{
let _g = smarm::causal_site!("reserve");
burn_us(400);
}
reserved2.fetch_add(1, Ordering::Relaxed);
if tx_bc.send(item).is_err() {
break;
}
}
});
let notified2 = notified.clone();
let notify = smarm::spawn(move || {
while rx_bc.recv().is_ok() {
{
let _g = smarm::causal_site!("notify");
burn_us(50);
}
notified2.fetch_add(1, Ordering::Relaxed);
smarm::progress!("orders-processed");
}
});
let stop_bg = stop.clone();
let background = smarm::spawn(move || {
while !stop_bg.load(Ordering::Relaxed) {
let _g = smarm::causal_site!("background-compaction");
burn_us(500);
}
});
smarm::sleep(Duration::from_millis(300));
let window = |label: &str| {
let (p0, r0, n0) = (
produced.load(Ordering::Relaxed),
reserved.load(Ordering::Relaxed),
notified.load(Ordering::Relaxed),
);
let d0 = smarm::causal::global_delay_cycles();
let t = Instant::now();
smarm::sleep(Duration::from_millis(700));
let secs = t.elapsed().as_secs_f64();
println!(
"{label}: produced {:.0}/s reserved {:.0}/s notified {:.0}/s injected {:.0}ms(assumed-3GHz)",
(produced.load(Ordering::Relaxed) - p0) as f64 / secs,
(reserved.load(Ordering::Relaxed) - r0) as f64 / secs,
(notified.load(Ordering::Relaxed) - n0) as f64 / secs,
(smarm::causal::global_delay_cycles() - d0) as f64 / 3.0e9 * 1e3,
);
};
window("no-experiment ");
smarm::causal::begin_experiment_for_test("background-compaction", 50);
window("bg-comp @ 50% ");
smarm::causal::end_experiment_for_test();
window("post-experiment");
stop.store(true, Ordering::Relaxed);
producer.join().unwrap();
reserve.join().unwrap();
notify.join().unwrap();
background.join().unwrap();
});
}
@@ -31,12 +31,12 @@
//! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents
//! how to make each of the four guarantees fire.
//!
//! Run: `cargo run --example gen_statem_fused`
//! Run: `cargo run --example gen_statem_expanded`
#![deny(dead_code, unreachable_patterns)]
use smarm::gen_statem::{spawn, Cx, GenStatemRef, Machine, Reply, Resolution, Step};
use smarm::run;
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, GenStatemRef};
// === user types ============================================================
@@ -50,6 +50,7 @@ enum Door {
struct Data {
enters: u32, // total state entries (incl. initial)
pushes: u32, // times a push closed the door
knocks: u32, // knocks answered (a Locked knock is postponed, then counted)
}
enum Cast {
@@ -57,17 +58,24 @@ enum Cast {
Pull,
Lock,
Unlock(u32), // carries a key
Knock, // counted when the door is reachable; postponed while Locked
}
enum Call {
GetState(Reply<Door>),
GetEnters(Reply<u32>),
GetPushes(Reply<u32>),
GetKnocks(Reply<u32>),
}
enum Ev {
Cast(Cast),
Call(Call),
// The runtime's internal events. `Info` is out-of-band (here unused, so
// `()`); `StateTimeout` / `Timeout` are timer fires the loop feeds back in.
Info(()),
StateTimeout,
Timeout(&'static str),
}
const CODE: u32 = 1234;
@@ -115,34 +123,66 @@ impl DoorSm {
fn start(init: Door) -> GenStatemRef<DoorSm> {
spawn(DoorSm {
state: init,
data: Data { enters: 0, pushes: 0 },
data: Data {
enters: 0,
pushes: 0,
knocks: 0,
},
})
}
fn enter(&mut self, _cx: &mut Cx<Ev>) {
fn enter(&mut self, cx: &mut Cx<Ev>) {
self.data.enters += 1;
// A state-timeout: an Open door auto-closes after a quiet window. The
// loop auto-resets it on any transition, so it fires only if the door is
// still Open when it elapses.
if self.state == Door::Open {
cx.state_timeout(std::time::Duration::from_millis(5));
}
}
}
impl Machine for DoorSm {
type Ev = Ev;
fn state_timeout_ev() -> Ev {
Ev::StateTimeout
}
fn timeout_ev(name: &'static str) -> Ev {
Ev::Timeout(name)
}
fn on_start(&mut self, cx: &mut Cx<Ev>) {
self.enter(cx);
}
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) -> Step<Ev> {
let prev = self.state;
// ---- the transition table -----------------------------------------
// This match is total over (Door, Ev). Read it as the declared graph:
// each `=> To(x)` is an edge, each `=> Unhandled` an explicit refusal.
// ---- phase 1: postpone routing (borrow-only) ----------------------
// A deferred event is handed back untouched for the loop's postpone
// queue; no handler code runs on it. Here: a knock at a locked door.
// (The macro emits this as a `match (state, &ev)` yielding a bool; the
// hand-written form can just test directly.)
if let (Door::Locked, Ev::Cast(Cast::Knock)) = (prev, &ev) {
return Step::Postponed(ev);
}
// ---- phase 2: the consuming transition table ----------------------
// Total over (Door, Ev). Read it as the declared graph: each `=> To(x)`
// is an edge, each `=> Unhandled` an explicit refusal. The postponed
// pair above reappears as `unreachable!` so the match stays total.
let res: Resolution<Door> = match (self.state, ev) {
// --- Open -------------------------------------------------------
(Door::Open, Ev::Cast(Cast::Push)) => {
on_push(&mut self.data);
Resolution::To(Door::Closed)
}
(Door::Open, Ev::Cast(Cast::Knock)) => {
self.data.knocks += 1;
Resolution::To(prev)
}
(Door::Open, Ev::Cast(Cast::Pull | Cast::Lock | Cast::Unlock(_))) => {
Resolution::Unhandled
}
@@ -150,15 +190,19 @@ impl Machine for DoorSm {
// --- Closed -----------------------------------------------------
(Door::Closed, Ev::Cast(Cast::Pull)) => Resolution::To(Door::Open),
(Door::Closed, Ev::Cast(Cast::Lock)) => Resolution::To(Door::Locked),
(Door::Closed, Ev::Cast(Cast::Knock)) => {
self.data.knocks += 1;
Resolution::To(prev)
}
(Door::Closed, Ev::Cast(Cast::Push | Cast::Unlock(_))) => Resolution::Unhandled,
// --- Locked (branching row: handler picks within UnlockOutcome) -
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => {
Resolution::To(on_unlock(key).into())
}
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => {
Resolution::Unhandled
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => Resolution::To(on_unlock(key).into()),
// Routed out in phase 1; listed only to keep this match total.
(Door::Locked, Ev::Cast(Cast::Knock)) => {
unreachable!("postponed event is replayed, not dispatched here")
}
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => Resolution::Unhandled,
// --- state-independent queries (reply, then stay) ---------------
(_, Ev::Call(Call::GetState(r))) => {
@@ -173,17 +217,38 @@ impl Machine for DoorSm {
r.reply(self.data.pushes);
Resolution::To(prev)
}
(_, Ev::Call(Call::GetKnocks(r))) => {
r.reply(self.data.knocks);
Resolution::To(prev)
}
// --- timeouts: an Open door auto-closes; others have none armed --
(Door::Open, Ev::StateTimeout) => Resolution::To(Door::Closed),
(_, Ev::StateTimeout) => Resolution::Unhandled,
(_, Ev::Timeout(name)) => {
// No named timeout is armed in this run; a real handler would
// dispatch on `name`. Acknowledge it to exercise the field.
let _ = name;
Resolution::Unhandled
}
// --- out-of-band info: silent drop (the gen_server default) ------
(_, Ev::Info(_)) => Resolution::Unhandled,
};
// ---- apply the resolution -----------------------------------------
match res {
Resolution::To(s) if s == prev => {} // stay: no enter
Resolution::To(s) if s == prev => Step::Stayed, // stay: no enter
Resolution::To(s) => {
self.state = s; // sole writer of the state cell
cx.__reset_state_timeout(); // auto-reset across transitions
self.enter(cx);
Step::Transitioned
}
Resolution::Unhandled => {
cx.on_unhandled();
Step::Stayed
}
Resolution::Postpone => unreachable!("postpone is not generated yet"),
Resolution::Unhandled => cx.on_unhandled(),
}
}
}
@@ -193,20 +258,29 @@ fn main() {
let door = DoorSm::start(Door::Closed);
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted)
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay (knock still deferred)
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed; deferred Knock replays here
door.send(Ev::Cast(Cast::Push)).unwrap(); // Closed: Push invalid -> Unhandled
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open (arms 5ms auto-close)
door.send(Ev::Info(())).unwrap(); // out-of-band: silently dropped
// Wait past the auto-close window: the state-timeout fires and the door
// closes itself, with no further input. (`smarm::sleep` parks the actor
// without blocking a worker thread, so the timer wheel keeps turning.)
smarm::sleep(std::time::Duration::from_millis(40));
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
let knocks = door.call(|r| Ev::Call(Call::GetKnocks(r))).unwrap();
println!("state={st:?} enters={enters} pushes={pushes}");
assert_eq!(st, Door::Closed);
println!("state={st:?} enters={enters} pushes={pushes} knocks={knocks}");
assert_eq!(st, Door::Closed); // auto-closed by the state-timeout
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
assert_eq!(pushes, 1);
assert_eq!(pushes, 0); // no push ever closed it this run
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
println!("ok");
});
}
+32 -9
View File
@@ -1,4 +1,4 @@
//! The **same** machine as `examples/gen_statem_fused.rs`, written through the
//! The **same** machine as `examples/gen_statem_expanded.rs`, written through the
//! `gen_statem!` macro. Diff this file against that one to see exactly what the
//! macro buys: every `// ===` section there that was boilerplate (the `Ev`
//! enum, the `DoorSm` struct, `start`, the whole `Machine` impl, the `enter`
@@ -18,10 +18,10 @@
// dispatch's own unreachable_patterns internally.
use smarm::gen_statem;
use smarm::run;
use smarm::gen_statem::Reply;
use smarm::run;
// === user types (identical to gen_statem_fused.rs) =========================
// === user types (identical to gen_statem_expanded.rs) =========================
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Door {
@@ -33,6 +33,7 @@ enum Door {
struct Data {
enters: u32, // total state entries (incl. initial)
pushes: u32, // times a push closed the door
knocks: u32, // knocks answered (a Locked knock is postponed, then counted)
}
enum Cast {
@@ -40,12 +41,14 @@ enum Cast {
Pull,
Lock,
Unlock(u32), // carries a key
Knock, // counted when the door is reachable; postponed while Locked
}
enum Call {
GetState(Reply<Door>),
GetEnters(Reply<u32>),
GetPushes(Reply<u32>),
GetKnocks(Reply<u32>),
}
const CODE: u32 = 1234;
@@ -87,7 +90,7 @@ fn on_unlock(key: u32) -> UnlockOutcome {
gen_statem! {
machine: DoorSm { state: Door, data: Data };
event: Ev { cast: Cast, call: Call };
event: Ev { cast: Cast, call: Call, info: () };
// You name the bindings the bodies use; the macro can't lend you its own
// `self`/`cx` across macro hygiene. `data` = &mut Data, `prev` = current
@@ -100,15 +103,20 @@ gen_statem! {
on Door::Open => {
cast Cast::Push => { on_push(data); Door::Closed },
cast Cast::Knock => { data.knocks += 1; prev },
cast Cast::Pull | Cast::Lock | Cast::Unlock(_) => unhandled,
}
on Door::Closed => {
cast Cast::Pull => Door::Open,
cast Cast::Lock => Door::Locked,
cast Cast::Knock => { data.knocks += 1; prev },
cast Cast::Push | Cast::Unlock(_) => unhandled,
}
on Door::Locked => {
cast Cast::Unlock(key) => on_unlock(key), // branch -> UnlockOutcome
// A knock at a locked door waits: defer it until the door is reachable,
// where the replay counts it.
cast Cast::Knock => postpone,
cast Cast::Push | Cast::Pull | Cast::Lock => unhandled,
}
@@ -117,35 +125,50 @@ gen_statem! {
call Call::GetState(r) => { r.reply(prev); prev },
call Call::GetEnters(r) => { r.reply(data.enters); prev },
call Call::GetPushes(r) => { r.reply(data.pushes); prev },
call Call::GetKnocks(r) => { r.reply(data.knocks); prev },
// This machine arms no timeouts, so refuse them everywhere. (Info has a
// built-in silent-drop default, so it needs no row.)
state_timeout => unhandled,
timeout _ => unhandled,
}
}
fn main() {
run(|| {
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0 });
let door = DoorSm::start(
Door::Closed,
Data {
enters: 0,
pushes: 0,
knocks: 0,
},
);
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted)
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay (knock still deferred)
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed; deferred Knock replays here
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
let knocks = door.call(|r| Ev::Call(Call::GetKnocks(r))).unwrap();
println!("state={st:?} enters={enters} pushes={pushes}");
println!("state={st:?} enters={enters} pushes={pushes} knocks={knocks}");
assert_eq!(st, Door::Closed);
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
assert_eq!(pushes, 1);
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
println!("ok");
});
}
// ===========================================================================
// BREAK-CASE MENU — the four guarantees, through the macro. Each fires exactly
// as it does in the hand-written gen_statem_fused.rs.
// as it does in the hand-written gen_statem_expanded.rs.
//
// 1. ORPHAN HANDLER (dead_code -> error):
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
+3 -1
View File
@@ -6,7 +6,9 @@
//! every use — so the address keeps working across a supervised restart, with
//! no stale [`GenServerRef`] to refresh.
use smarm::{call, cast, run, whereis_server, GenServer, GenServerBuilder, GenServerName, GenServerRef};
use smarm::{
call, cast, run, whereis_server, GenServer, GenServerBuilder, GenServerName, GenServerRef,
};
/// A counter server: synchronous `Get`, asynchronous `Inc` / `Add`.
struct Counter {
+13 -3
View File
@@ -15,7 +15,9 @@
//! `call`, nothing more.
use smarm::observer::{self, ObserverReply, ObserverRequest};
use smarm::{channel, register, run, spawn, ActorState, Name, RuntimeSnapshot, RuntimeTree, TreeNode};
use smarm::{
channel, register, run, spawn, ActorState, Name, RuntimeSnapshot, RuntimeTree, TreeNode,
};
const ECHO: Name<u64> = Name::new("echo");
@@ -31,7 +33,11 @@ fn state_glyph(s: ActorState) -> &'static str {
/// A `ps`-style table over the flat snapshot.
fn print_snapshot(snap: &RuntimeSnapshot) {
println!("snapshot (format v{}, {} actors)", snap.format_version, snap.actors.len());
println!(
"snapshot (format v{}, {} actors)",
snap.format_version,
snap.actors.len()
);
println!(
" {:<10} {:<9} {:<10} {:>4} {:>4} {:>4} {:>4} {:>5} {}",
"pid", "state", "parent", "mon", "lnk", "joi", "mbox", "msgs", "names"
@@ -52,7 +58,11 @@ fn print_snapshot(snap: &RuntimeSnapshot) {
a.joiners,
a.mailbox_depth,
a.messages_received,
if a.names.is_empty() { "-".to_string() } else { a.names.join(",") },
if a.names.is_empty() {
"-".to_string()
} else {
a.names.join(",")
},
);
}
}
+5 -3
View File
@@ -93,11 +93,13 @@ pub fn take_last_outcome() -> Option<Outcome> {
/// unwinding to cross the boundary, but `catch_unwind` here means unwinding
/// never actually does.
pub extern "C-unwind" fn trampoline() {
let b = CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take())
.expect("trampoline entered without a closure set");
let b = match CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take()) {
Some(b) => b,
None => panic!("smarm: trampoline entered without a closure set (core corrupt)"),
};
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
Ok(()) => Outcome::Exit,
Ok(()) => Outcome::Exit,
Err(payload) => {
if payload.is::<StopSentinel>() {
Outcome::Stopped
+969
View File
@@ -0,0 +1,969 @@
//! Native causal profiling (RFC 007). Enabled by `--features smarm-causal`;
//! zero cost without it (same discipline as `smarm-trace`).
//!
//! The Coz algorithm, transposed onto actors: to estimate what speeding up
//! code site S by p% would do to throughput, we instead *slow everything
//! else down* by p% of the time spent in S, and watch the progress-point
//! rates respond. Where Coz must inject real `usleep`s into OS threads from
//! the outside, smarm owns every clock that matters:
//!
//! - Sampling and delay injection happen at `maybe_preempt`'s amortised
//! cadence — an existing, safe hook (never inside a prep-to-park region).
//! - Injected delay is subtracted from the actor's timeslice
//! (`preempt::extend_timeslice`), so experiments don't perturb scheduling.
//! - Delay bookkeeping is *actor*-granular: each `Slot` carries an absorbed-
//! delay ledger, compared against a global ledger. Parked actors absorb
//! accrued delay for free on resume (Coz's blocked-thread rule) — waiting
//! is never penalised.
//!
//! v1 scope (per RFC discussion): explicit scoped sites (`causal_site!`)
//! rather than PC sampling (jar Q1 stays open); throughput progress points
//! only; timer-heap deadlines are *not* shifted (documented gap — long
//! experiments can make real-time timeouts fire early in virtual terms);
//! multi-scheduler coherence is best-effort via global atomics.
//!
//! Usage:
//! ```ignore
//! let _g = smarm::causal_site!("inventory-reserve"); // in suspect code
//! smarm::progress!("orders-processed"); // per unit of work
//! let results = smarm::causal::run_experiments(&Default::default());
//! print!("{}", smarm::causal::render_summary(&results));
//! std::fs::write("profile.coz", smarm::causal::render_coz(&results))?;
//! ```
#[cfg(feature = "smarm-causal")]
mod inner {
use crate::preempt;
use std::cell::Cell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
// -----------------------------------------------------------------------
// Global state
// -----------------------------------------------------------------------
/// Active experiment, packed `(site_id << 32) | speedup_pct`. 0 = idle.
/// A single word so the hot path reads one atomic; experiments are global
/// across scheduler threads (jar Q7, v1: plain Relaxed atomics).
static EXPERIMENT: AtomicU64 = AtomicU64::new(0);
/// Monotone experiment-window counter, bumped by every `begin()`. Lets
/// the offcpu-gap stash (RFC 007) tell apart two windows with an
/// identical site+pct word — live in the attrib probe's 50,50 schedule
/// — so a gap straddling `end()`/`begin()` never counts a cooldown.
static EXPERIMENT_EPOCH: AtomicU64 = AtomicU64::new(0);
/// Global virtual-delay ledger, in TSC cycles: the total delay every
/// actor *should* have experienced since startup. Grows while a sample
/// lands in the experiment's target site; each actor's `Slot` ledger
/// chases it by spin-absorbing at preemption checks.
static GLOBAL_DELAY: AtomicU64 = AtomicU64::new(0);
/// Registered site names; site id = index + 1 (0 = "no site").
static SITES: OnceLock<Mutex<Vec<&'static str>>> = OnceLock::new();
/// Registered progress points (leaked for `'static`, like trace's drain
/// state — the set is small and lives for the process).
static PROGRESS: OnceLock<Mutex<Vec<&'static ProgressPoint>>> = OnceLock::new();
thread_local! {
/// TSC at this thread's previous causal check, the sample "period"
/// denominator. Re-armed on every actor resume so scheduler time and
/// a previous actor's tail never count toward a sample. 0 = unarmed.
static LAST_SAMPLE_TSC: Cell<u64> = const { Cell::new(0) };
}
/// Guard against TSC weirdness (migration between unsynced sockets,
/// virtualisation steps): a single sample interval larger than this is
/// discarded rather than believed. ~33ms at 3 GHz — far beyond any real
/// gap between preemption checks inside a slice.
const MAX_SAMPLE_CYCLES: u64 = 100_000_000;
/// Cap on delay spun in one visit, so one check can never wedge an actor
/// for a human-visible pause; the remainder is absorbed on later visits.
/// ~3ms at 3 GHz.
const MAX_SPIN_PER_VISIT: u64 = 10_000_000;
// -----------------------------------------------------------------------
// Ledger audit (RFC 007 deficit hunt): where injected delay is born,
// paid, and forgiven — and where would-be attribution is silently lost
// (deschedule tails, clamp discards). Monotone Relaxed totals, read via
// `ledger_counters()`; `run_experiments` windows them into
// `ExperimentResult`. Measure-only: nothing here changes injection or
// absorption behaviour.
// -----------------------------------------------------------------------
/// Cycles bystanders actually spun to pay down the global ledger.
static SPIN_ABSORBED_CYCLES: AtomicU64 = AtomicU64::new(0);
/// Cycles waived at wake after a real park (the blocked-thread rule).
static PARK_FORGIVEN_CYCLES: AtomicU64 = AtomicU64::new(0);
/// Would-be attribution lost when the target-site actor parks mid-site.
static DROP_PARK_CYCLES: AtomicU64 = AtomicU64::new(0);
static DROP_PARK_N: AtomicU64 = AtomicU64::new(0);
/// Same loss at yields (explicit, slice-expiry, or a park that requeued).
static DROP_YIELD_CYCLES: AtomicU64 = AtomicU64::new(0);
static DROP_YIELD_N: AtomicU64 = AtomicU64::new(0);
/// Samples discarded by the TSC-weirdness clamp, in would-be delta terms.
static DISCARD_OVERMAX_CYCLES: AtomicU64 = AtomicU64::new(0);
static DISCARD_OVERMAX_N: AtomicU64 = AtomicU64::new(0);
/// In-site samples dropped because the thread's clock was unarmed.
static DISCARD_UNARMED_N: AtomicU64 = AtomicU64::new(0);
/// Would-be attribution over runnable off-CPU gaps inside the target
/// site (yield-descheduled -> resumed within the same window). Not a
/// loss: on-CPU-only attribution is the Coz model — queue-wait is not
/// shrunk by speeding the site's code — but counted so the audit books
/// close against wall in-site time (the located @50 "deficit").
static OFFCPU_IN_SITE_CYCLES: AtomicU64 = AtomicU64::new(0);
static OFFCPU_IN_SITE_N: AtomicU64 = AtomicU64::new(0);
fn sites() -> &'static Mutex<Vec<&'static str>> {
SITES.get_or_init(|| Mutex::new(Vec::new()))
}
fn progress_points() -> &'static Mutex<Vec<&'static ProgressPoint>> {
PROGRESS.get_or_init(|| Mutex::new(Vec::new()))
}
/// Recover from lock poisoning: all these registries hold plain data that
/// is valid at every instruction boundary, so a panicked registrant can't
/// leave them torn.
fn lock_unpoisoned<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match m.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
// -----------------------------------------------------------------------
// Sites
// -----------------------------------------------------------------------
/// Register (or look up) a causal site by name; returns its nonzero id.
/// Called once per `causal_site!` expansion via a `OnceLock`, so the
/// mutex is off every hot path.
pub fn site_id(name: &'static str) -> u32 {
let mut v = lock_unpoisoned(sites());
if let Some(pos) = v.iter().position(|n| *n == name) {
return (pos + 1) as u32;
}
v.push(name);
v.len() as u32
}
fn site_name(id: u32) -> Option<String> {
if id == 0 {
return None;
}
let v = lock_unpoisoned(sites());
v.get((id - 1) as usize).map(|s| (*s).to_string())
}
/// RAII marker: while alive, the current *actor* (not thread — the id
/// lives in its `Slot` and survives preemption/migration) is "inside"
/// the site. Nesting restores the outer site on drop. Inert outside an
/// actor (scheduler/OS-thread stacks).
pub struct SiteGuard {
/// Slot of the actor that entered, null if entered outside an actor.
/// Valid for the guard's whole life: the guard lives on the actor's
/// stack, and a slot is never reclaimed while its actor is alive —
/// the same argument as `preempt::check_cancelled`.
slot: *const crate::runtime::Slot,
prev: u32,
}
impl SiteGuard {
/// Enter `site` for the on-CPU actor.
pub fn enter(site: u32) -> Self {
let slot = preempt::current_slot_ptr();
if slot.is_null() {
return SiteGuard { slot, prev: 0 };
}
// SAFETY: non-null ⇒ points at the on-CPU actor's slot; see the
// field docs for the lifetime argument.
let prev = unsafe { (*slot).causal_site() };
unsafe { (*slot).set_causal_site(site) };
site_transition(slot, prev, site);
SiteGuard { slot, prev }
}
}
impl Drop for SiteGuard {
fn drop(&mut self) {
if !self.slot.is_null() {
// SAFETY (both): as in `enter` — the actor (and thus its
// slot) is alive for as long as this guard is on its stack.
let site = unsafe { (*self.slot).causal_site() };
unsafe { (*self.slot).set_causal_site(self.prev) };
site_transition(self.slot, site, self.prev);
}
}
}
/// Name of the site the on-CPU actor is currently inside, if any.
/// (Introspection/testing; not a hot path.)
pub fn current_site_name() -> Option<String> {
let slot = preempt::current_slot_ptr();
if slot.is_null() {
return None;
}
// SAFETY: on-CPU actor's slot, valid for the whole resume.
site_name(unsafe { (*slot).causal_site() })
}
// -----------------------------------------------------------------------
// Progress points
// -----------------------------------------------------------------------
/// A named throughput counter. One per distinct name; `progress!` call
/// sites sharing a name share the counter.
pub struct ProgressPoint {
name: &'static str,
count: AtomicU64,
}
impl ProgressPoint {
/// The hot path: one Relaxed RMW. (Contended across actors by design
/// — a progress point is a global rate meter.)
#[inline]
pub fn bump(&self) {
self.count.fetch_add(1, Ordering::Relaxed);
}
}
/// Register (or look up) a progress point. Called once per `progress!`
/// expansion via a `OnceLock`; the mutex is off the hot path.
pub fn register_progress(name: &'static str) -> &'static ProgressPoint {
let mut v = lock_unpoisoned(progress_points());
if let Some(p) = v.iter().find(|p| p.name == name) {
return p;
}
let p: &'static ProgressPoint = Box::leak(Box::new(ProgressPoint {
name,
count: AtomicU64::new(0),
}));
v.push(p);
p
}
/// Snapshot of all progress points as `(name, count)`.
pub fn progress_snapshot() -> Vec<(String, u64)> {
lock_unpoisoned(progress_points())
.iter()
.map(|p| (p.name.to_string(), p.count.load(Ordering::Relaxed)))
.collect()
}
// -----------------------------------------------------------------------
// The hot hook: sample + absorb
// -----------------------------------------------------------------------
/// Called from `maybe_preempt` at the amortised timeslice-check cadence,
/// under the `PREEMPTION_ENABLED` gate (so never in a prep-to-park or
/// no-preempt region — spinning here is as safe as yielding is).
///
/// One Relaxed load and out when no experiment is running.
#[inline]
pub(crate) fn check() {
let exp = EXPERIMENT.load(Ordering::Relaxed);
if exp == 0 {
return;
}
cold_check(exp);
}
/// The experiment-active path, kept out of the inlined fast path.
#[cold]
fn cold_check(exp: u64) {
let slot = preempt::current_slot_ptr();
if slot.is_null() {
return;
}
let now = preempt::rdtsc();
let last = LAST_SAMPLE_TSC.with(|c| c.replace(now));
let target_site = (exp >> 32) as u32;
let pct = exp & 0xffff_ffff;
// SAFETY (both derefs below): non-null ⇒ the on-CPU actor's slot,
// never reclaimed while the actor runs — see `check_cancelled`.
let my_site = unsafe { (*slot).causal_site() };
if my_site == target_site && pct > 0 {
// A sample landed in the target site: everyone else must fall
// behind by pct% of the sampled interval. Grow the global ledger
// and credit ourselves the same amount — the credited gap *is*
// the virtual speedup.
if last == 0 {
// Unarmed clock: no interval to attribute — count the loss.
DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed);
return;
}
// SAFETY: `slot` is the on-CPU actor's slot (checked non-null
// above); see `check_cancelled` for the lifetime argument.
unsafe { attribute(slot, now.saturating_sub(last), pct) };
} else {
// Not the winner: chase the global ledger by spinning off the
// difference, then push the slice start forward so injected
// delay never counts as compute (the clock correction that Coz
// cannot do from outside).
let global = GLOBAL_DELAY.load(Ordering::Relaxed);
let mine = unsafe { (*slot).causal_delay() };
if mine >= global {
return;
}
let spin = (global - mine).min(MAX_SPIN_PER_VISIT);
let start = preempt::rdtsc();
while preempt::rdtsc().saturating_sub(start) < spin {
core::hint::spin_loop();
}
SPIN_ABSORBED_CYCLES.fetch_add(spin, Ordering::Relaxed);
unsafe { (*slot).set_causal_delay(mine.wrapping_add(spin)) };
preempt::extend_timeslice(spin);
// The spin is not part of the next sample interval either.
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
}
}
/// Attribute one target-site sample of `interval` cycles at `pct`%:
/// grow the global ledger and credit the sampling actor's own ledger by
/// the same amount — the credited gap *is* the virtual speedup. Shared
/// by the cold check and the guard-boundary flush. Applies the same
/// clamps as sampling always has: zero intervals and clock hiccups are
/// discarded, not the run.
///
/// SAFETY: `slot` must point at the on-CPU actor's slot (the
/// `check_cancelled` lifetime argument).
unsafe fn attribute(slot: *const crate::runtime::Slot, interval: u64, pct: u64) {
if interval == 0 {
return; // now == last: nothing to attribute, nothing lost
}
if interval > MAX_SAMPLE_CYCLES {
// TSC-weirdness clamp: the sample is discarded, not the run.
// Count the loss in would-be delta terms so the audit's columns
// compare directly against `injected_cycles`.
DISCARD_OVERMAX_N.fetch_add(1, Ordering::Relaxed);
DISCARD_OVERMAX_CYCLES.fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed);
return;
}
let delta = interval.saturating_mul(pct) / 100;
GLOBAL_DELAY.fetch_add(delta, Ordering::Relaxed);
let mine = (*slot).causal_delay();
(*slot).set_causal_delay(mine.wrapping_add(delta));
}
/// Site-boundary hook, called by `SiteGuard` enter/drop when the
/// actor's current site changes from `old` to `new`. Sample-only —
/// never spins — so it is safe anywhere, including no-preempt regions
/// where `check()` cannot run.
///
/// - Leaving the experiment's target site: flush the pending interval.
/// Cold checks only sample when they happen to fire in-site, so the
/// tail between the last check and the guard drop was otherwise
/// discarded on every site entry — measured live at ~22-29µs/entry,
/// ~6-7% of all target time (eff 0.93), which under-reported every
/// impact (+83.5% where theory says +100%).
/// - Entering the target site: re-arm the sample clock, so time spent
/// *before* the site can never be attributed to it by the first
/// in-site check (the symmetric over-attribution).
#[inline]
fn site_transition(slot: *const crate::runtime::Slot, old: u32, new: u32) {
let exp = EXPERIMENT.load(Ordering::Relaxed);
if exp == 0 || old == new {
return;
}
let target = (exp >> 32) as u32;
let pct = exp & 0xffff_ffff;
if old == target && new != target {
let now = preempt::rdtsc();
let last = LAST_SAMPLE_TSC.with(|c| c.replace(now));
if pct > 0 {
if last != 0 {
// SAFETY: forwarded from the guard, which holds the on-CPU
// actor's slot for its whole life (see `SiteGuard::slot`).
unsafe { attribute(slot, now.saturating_sub(last), pct) };
} else {
DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed);
}
}
} else if new == target && old != target {
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
}
}
/// Resume-path hook (scheduler thread, actor off-CPU). Two duties:
///
/// - If the last deschedule was a *real park*, time blocked absorbs any
/// delay accrued meanwhile for free — Coz's blocked-thread rule, which
/// keeps experiments from punishing actors for waiting. An actor that
/// merely yielded (slice expiry) was runnable the whole time and keeps
/// its debt: it must pay by spinning at its next check. Forgiving on
/// every resume would make any yield-cadence actor delay-immune and
/// experiments inert (found live on a 24-core run: nothing slowed).
/// - If the deschedule was a *yield* in the live experiment's target
/// site, count the off-CPU gap it opened into the offcpu audit bucket
/// (RFC 007: the located @50 deficit — runnable queue-wait is wall
/// time in-site that on-CPU attribution correctly skips). Same-window
/// only, enforced by the experiment epoch; measure-only.
/// - Arm this thread's sample clock so the first interval of the resume
/// excludes scheduler time.
#[inline]
pub(crate) fn on_resume(slot: &crate::runtime::Slot) {
let (desched_tsc, desched_epoch) = slot.take_causal_desched();
if desched_tsc != 0 && desched_epoch == EXPERIMENT_EPOCH.load(Ordering::Relaxed) {
// Same epoch ⇒ no `begin()` since the stash; a nonzero word ⇒
// no `end()` either — the gap closed inside its own window.
let exp = EXPERIMENT.load(Ordering::Relaxed);
if exp != 0 {
let pct = exp & 0xffff_ffff;
let gap = preempt::rdtsc()
.saturating_sub(desched_tsc)
.min(MAX_SAMPLE_CYCLES);
OFFCPU_IN_SITE_CYCLES.fetch_add(gap.saturating_mul(pct) / 100, Ordering::Relaxed);
OFFCPU_IN_SITE_N.fetch_add(1, Ordering::Relaxed);
}
}
if slot.take_causal_parked() {
let global = GLOBAL_DELAY.load(Ordering::Relaxed);
let mine = slot.causal_delay();
if mine < global {
PARK_FORGIVEN_CYCLES.fetch_add(global - mine, Ordering::Relaxed);
slot.set_causal_delay(global);
}
}
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
}
/// Deschedule-path hook (scheduler side, same OS thread the actor just
/// ran on). If an experiment is live and the departing actor sits in the
/// target site, the sample tail `[last sample -> now]` is about to be
/// lost: nothing flushes it here, and `on_resume` re-arms the clock
/// before the actor runs again. Measure-only (RFC 007 deficit hunt) —
/// tally the would-be attribution into the park/yield drop buckets and
/// leave behaviour untouched. The interval is capped at
/// MAX_SAMPLE_CYCLES: past that the flush would have discarded it anyway
/// (counted separately). `now` includes the few hundred ns of scheduler
/// bookkeeping since the actor actually stopped — an acceptable
/// overcount for a diagnostic.
///
/// Slice-expiry yields sample at the same checkpoint that deschedules
/// them, so their tails are ~zero by construction; a fat yield bucket
/// therefore points at explicit `yield_now` calls or requeued parks.
///
/// Yields additionally stash the deschedule instant on the slot so
/// `on_resume` can count the runnable off-CPU gap (offcpu bucket).
pub(crate) fn on_deschedule(slot: &crate::runtime::Slot, real_park: bool) {
let exp = EXPERIMENT.load(Ordering::Relaxed);
if exp == 0 {
return;
}
let target = (exp >> 32) as u32;
let pct = exp & 0xffff_ffff;
if pct == 0 || slot.causal_site() != target {
return;
}
let now = preempt::rdtsc();
if !real_park {
// Runnable gap opens here; `on_resume` closes and counts it
// (offcpu bucket). Parks are excluded: blocked time is already
// represented by forgiveness, and blocked wall time is not
// queue-wait.
slot.set_causal_desched(now, EXPERIMENT_EPOCH.load(Ordering::Relaxed));
}
let last = LAST_SAMPLE_TSC.with(|c| c.get());
if last == 0 {
return;
}
let interval = now.saturating_sub(last).min(MAX_SAMPLE_CYCLES);
let would_be = interval.saturating_mul(pct) / 100;
if real_park {
DROP_PARK_N.fetch_add(1, Ordering::Relaxed);
DROP_PARK_CYCLES.fetch_add(would_be, Ordering::Relaxed);
} else {
DROP_YIELD_N.fetch_add(1, Ordering::Relaxed);
DROP_YIELD_CYCLES.fetch_add(would_be, Ordering::Relaxed);
}
}
// -----------------------------------------------------------------------
// Experiments
// -----------------------------------------------------------------------
fn begin(site: u32, pct: u32) {
EXPERIMENT_EPOCH.fetch_add(1, Ordering::Relaxed);
EXPERIMENT.store(((site as u64) << 32) | pct as u64, Ordering::Relaxed);
}
fn end() {
EXPERIMENT.store(0, Ordering::Relaxed);
}
/// Total virtual delay injected so far, in TSC cycles.
pub fn global_delay_cycles() -> u64 {
GLOBAL_DELAY.load(Ordering::Relaxed)
}
/// Cumulative ledger-audit totals since startup (RFC 007 deficit hunt).
/// All monotone; window a span by snapshotting before/after and taking
/// `delta_since`. Cycle fields are in would-be-injected delta terms so
/// they compare directly against `injected_cycles`.
#[derive(Clone, Copy, Debug, Default)]
pub struct LedgerCounters {
pub spin_absorbed_cycles: u64,
pub park_forgiven_cycles: u64,
pub drop_park_cycles: u64,
pub drop_park_n: u64,
pub drop_yield_cycles: u64,
pub drop_yield_n: u64,
pub discard_overmax_cycles: u64,
pub discard_overmax_n: u64,
pub discard_unarmed_n: u64,
pub offcpu_in_site_cycles: u64,
pub offcpu_in_site_n: u64,
}
impl LedgerCounters {
/// Field-wise difference against an earlier snapshot.
pub fn delta_since(&self, before: &LedgerCounters) -> LedgerCounters {
LedgerCounters {
spin_absorbed_cycles: self
.spin_absorbed_cycles
.saturating_sub(before.spin_absorbed_cycles),
park_forgiven_cycles: self
.park_forgiven_cycles
.saturating_sub(before.park_forgiven_cycles),
drop_park_cycles: self
.drop_park_cycles
.saturating_sub(before.drop_park_cycles),
drop_park_n: self.drop_park_n.saturating_sub(before.drop_park_n),
drop_yield_cycles: self
.drop_yield_cycles
.saturating_sub(before.drop_yield_cycles),
drop_yield_n: self.drop_yield_n.saturating_sub(before.drop_yield_n),
discard_overmax_cycles: self
.discard_overmax_cycles
.saturating_sub(before.discard_overmax_cycles),
discard_overmax_n: self
.discard_overmax_n
.saturating_sub(before.discard_overmax_n),
discard_unarmed_n: self
.discard_unarmed_n
.saturating_sub(before.discard_unarmed_n),
offcpu_in_site_cycles: self
.offcpu_in_site_cycles
.saturating_sub(before.offcpu_in_site_cycles),
offcpu_in_site_n: self
.offcpu_in_site_n
.saturating_sub(before.offcpu_in_site_n),
}
}
}
/// Snapshot the cumulative audit counters.
pub fn ledger_counters() -> LedgerCounters {
LedgerCounters {
spin_absorbed_cycles: SPIN_ABSORBED_CYCLES.load(Ordering::Relaxed),
park_forgiven_cycles: PARK_FORGIVEN_CYCLES.load(Ordering::Relaxed),
drop_park_cycles: DROP_PARK_CYCLES.load(Ordering::Relaxed),
drop_park_n: DROP_PARK_N.load(Ordering::Relaxed),
drop_yield_cycles: DROP_YIELD_CYCLES.load(Ordering::Relaxed),
drop_yield_n: DROP_YIELD_N.load(Ordering::Relaxed),
discard_overmax_cycles: DISCARD_OVERMAX_CYCLES.load(Ordering::Relaxed),
discard_overmax_n: DISCARD_OVERMAX_N.load(Ordering::Relaxed),
discard_unarmed_n: DISCARD_UNARMED_N.load(Ordering::Relaxed),
offcpu_in_site_cycles: OFFCPU_IN_SITE_CYCLES.load(Ordering::Relaxed),
offcpu_in_site_n: OFFCPU_IN_SITE_N.load(Ordering::Relaxed),
}
}
/// Absorbed-delay ledger of the on-CPU actor (testing/introspection).
pub fn my_absorbed_delay_cycles() -> u64 {
let slot = preempt::current_slot_ptr();
if slot.is_null() {
return 0;
}
// SAFETY: on-CPU actor's slot; see `check_cancelled`.
unsafe { (*slot).causal_delay() }
}
/// Test support: start an experiment targeting `site_name` at `pct`%
/// virtual speedup. Registers the site if needed.
pub fn begin_experiment_for_test(name: &'static str, pct: u32) {
begin(site_id(name), pct);
}
/// Test support: stop the running experiment.
pub fn end_experiment_for_test() {
end();
}
/// Test support: grow the global delay ledger directly, as if target-site
/// samples had attributed `cycles` — deterministic driver for the timer
/// virtual-time tests. Calibrates the TSC eagerly so conversion later
/// never stalls a scheduler loop.
pub fn inject_delay_cycles_for_test(cycles: u64) {
let _ = tsc_hz();
GLOBAL_DELAY.fetch_add(cycles, Ordering::Relaxed);
}
/// Convert ledger cycles to wall time at the measured TSC rate.
pub fn cycles_to_duration(cycles: u64) -> Duration {
Duration::from_secs_f64(cycles as f64 / tsc_hz())
}
/// Controller parameters: which speedups to try per site, and the
/// experiment/cooldown windows.
pub struct ExperimentPlan {
pub speedups_pct: Vec<u32>,
pub experiment: Duration,
pub cooldown: Duration,
}
impl Default for ExperimentPlan {
fn default() -> Self {
ExperimentPlan {
speedups_pct: vec![0, 25, 50],
experiment: Duration::from_millis(500),
cooldown: Duration::from_millis(100),
}
}
}
/// One completed experiment cell.
#[derive(Default)]
pub struct ExperimentResult {
pub site: String,
pub speedup_pct: u32,
pub duration: Duration,
/// Progress-point deltas over the window, `(name, count)`.
pub deltas: Vec<(String, u64)>,
/// Virtual delay injected during the window (cycles).
pub injected_cycles: u64,
// Ledger-audit deltas over the window (RFC 007 deficit hunt); see
// `LedgerCounters` for field semantics. `spin_absorbed_cycles > 0`
// in a 0% cell means the window paid debt left over from an earlier
// one — the baseline-contamination signature.
pub spin_absorbed_cycles: u64,
pub park_forgiven_cycles: u64,
pub drop_park_cycles: u64,
pub drop_park_n: u64,
pub drop_yield_cycles: u64,
pub drop_yield_n: u64,
pub discard_overmax_cycles: u64,
pub discard_overmax_n: u64,
pub discard_unarmed_n: u64,
pub offcpu_in_site_cycles: u64,
pub offcpu_in_site_n: u64,
}
/// Run the plan synchronously on the calling (OS) thread: for every
/// registered site × speedup, run one experiment window and record
/// progress-point deltas, with a cooldown between cells. Sites and
/// progress points must already be registered (the workload has to be
/// running); the caller owns workload start/stop.
///
/// v1 controller: exhaustive sweep, fixed windows, no adaptive site
/// selection or confidence stopping (jar Q5).
///
/// Callable from a plain OS thread *or* from inside an actor: sleeping
/// parks the green thread when we're on one (so no scheduler thread is
/// blocked), and falls back to `thread::sleep` otherwise.
pub fn run_experiments(plan: &ExperimentPlan) -> Vec<ExperimentResult> {
fn controller_sleep(d: Duration) {
if preempt::current_slot_ptr().is_null() {
std::thread::sleep(d);
} else {
// Wall-anchored: the controller's window/cooldown sleeps
// *define* the experiment's wall length; letting them chase
// the delay it is itself injecting would stretch every window
// (observed ~2x at 50% speedup). Deltas are rate-normalized
// either way — this fixes cost, not bias.
crate::scheduler::sleep_wall(d);
}
}
// Calibrate before any window so report rendering never has to sleep.
let _ = tsc_hz();
let site_list: Vec<(u32, String)> = {
let v = lock_unpoisoned(sites());
v.iter()
.enumerate()
.map(|(i, n)| ((i + 1) as u32, (*n).to_string()))
.collect()
};
let mut out = Vec::new();
for (sid, sname) in &site_list {
for &pct in &plan.speedups_pct {
let before = progress_snapshot();
let injected_before = global_delay_cycles();
let audit_before = ledger_counters();
let t0 = Instant::now();
begin(*sid, pct);
controller_sleep(plan.experiment);
end();
// Snapshot immediately: injection and spin freeze at `end()`
// (checks gate on the experiment word), but forgiveness does
// not — a later snapshot would leak cooldown wakes into the
// window.
let audit = ledger_counters().delta_since(&audit_before);
let elapsed = t0.elapsed();
let after = progress_snapshot();
let deltas = after
.iter()
.map(|(n, c)| {
let b = before
.iter()
.find(|(bn, _)| bn == n)
.map(|(_, bc)| *bc)
.unwrap_or(0);
(n.clone(), c.saturating_sub(b))
})
.collect();
out.push(ExperimentResult {
site: sname.clone(),
speedup_pct: pct,
duration: elapsed,
deltas,
injected_cycles: global_delay_cycles() - injected_before,
spin_absorbed_cycles: audit.spin_absorbed_cycles,
park_forgiven_cycles: audit.park_forgiven_cycles,
drop_park_cycles: audit.drop_park_cycles,
drop_park_n: audit.drop_park_n,
drop_yield_cycles: audit.drop_yield_cycles,
drop_yield_n: audit.drop_yield_n,
discard_overmax_cycles: audit.discard_overmax_cycles,
discard_overmax_n: audit.discard_overmax_n,
discard_unarmed_n: audit.discard_unarmed_n,
offcpu_in_site_cycles: audit.offcpu_in_site_cycles,
offcpu_in_site_n: audit.offcpu_in_site_n,
});
controller_sleep(plan.cooldown);
}
}
out
}
// -----------------------------------------------------------------------
// Reports
// -----------------------------------------------------------------------
/// Measured TSC frequency (Hz), calibrated once. The crate-wide 3 GHz
/// constant is fine for the *relative* timeslice check, but report
/// normalisation divides wall time by injected time, so a 20% Hz error
/// skews every impact number — measured live: a 3.7 GHz box inflated all
/// baselines uniformly. Calibrated against `Instant` over ~50ms on first
/// use; `run_experiments` triggers it before its first window (using the
/// park-aware sleep, so no scheduler thread is blocked when called from
/// an actor).
static TSC_HZ_MEASURED: OnceLock<f64> = OnceLock::new();
/// Measured TSC frequency in Hz. Calibrates on first call (~50ms).
pub fn tsc_hz() -> f64 {
*TSC_HZ_MEASURED.get_or_init(|| {
let c0 = preempt::rdtsc();
let t0 = Instant::now();
let d = Duration::from_millis(50);
if preempt::current_slot_ptr().is_null() {
std::thread::sleep(d);
} else {
// Wall-anchored: calibration divides TSC delta by *wall*
// elapsed; a virtual sleep dilated by concurrent injection
// would still measure correctly (elapsed() is wall) but
// waste window time — and must never depend on the ledger
// it exists to convert.
crate::scheduler::sleep_wall(d);
}
(preempt::rdtsc().wrapping_sub(c0)) as f64 / t0.elapsed().as_secs_f64()
})
}
/// Normalized rate for one cell: count over the *virtual* window
/// (wall − injected) — Coz's normalization: injected delay does not
/// exist in the virtual timeline. A bottleneck site keeps its raw count
/// while shrinking the divisor → positive impact; a fully overlapped
/// site loses count proportionally → ~zero.
fn normalized_rate(r: &ExperimentResult, point: &str) -> Option<f64> {
let count = r.deltas.iter().find(|(n, _)| n == point).map(|(_, c)| *c)?;
let injected_secs = r.injected_cycles as f64 / tsc_hz();
let virtual_secs = (r.duration.as_secs_f64() - injected_secs).max(1e-9);
Some(count as f64 / virtual_secs)
}
/// Impact of virtually speeding up `site` by `speedup_pct` on progress
/// point `point`, in percent relative to that site's own 0% baseline
/// cell. `None` if either cell or the point is missing, or the baseline
/// rate is zero. This is the machine-readable form of the summary's
/// "vs baseline" column, for programmatic checks (CI, examples).
pub fn impact_pct(
results: &[ExperimentResult],
site: &str,
speedup_pct: u32,
point: &str,
) -> Option<f64> {
let cell = results
.iter()
.find(|r| r.site == site && r.speedup_pct == speedup_pct)?;
let base = results
.iter()
.find(|r| r.site == site && r.speedup_pct == 0)?;
let rate = normalized_rate(cell, point)?;
let b = normalized_rate(base, point)?;
if b <= 0.0 {
return None;
}
Some((rate / b - 1.0) * 100.0)
}
/// Human-readable summary: per (site, progress point), the throughput at
/// each virtual speedup and the change relative to that site's own 0%
/// baseline. A near-zero column across speedups means: optimising this
/// site buys you nothing — the RFC's headline answer.
///
/// Ends with a one-line fidelity note (RFC 007 Validation): reported
/// impacts are conservative — attribution counts on-CPU site time only,
/// so runnable queue-wait inside the site (the located @50 "deficit",
/// eff ≈ 0.93 live) is never injected and gains are lower bounds; site
/// *rankings* are unaffected.
pub fn render_summary(results: &[ExperimentResult]) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "== smarm causal profile ==");
let mut sites_seen: Vec<&str> = Vec::new();
for r in results {
if !sites_seen.contains(&r.site.as_str()) {
sites_seen.push(&r.site);
}
}
for site in sites_seen {
let _ = writeln!(s, "site {site}");
for r in results.iter().filter(|r| r.site == site) {
for (name, _) in &r.deltas {
let rate = match normalized_rate(r, name) {
Some(x) => x,
None => continue,
};
let rel = impact_pct(results, site, r.speedup_pct, name)
.map(|p| format!("{p:+.1}%"))
.unwrap_or_else(|| "n/a".to_string());
let _ = writeln!(
s,
" speedup {:>3}% {name:<24} {rate:>12.1}/s vs baseline {rel} (injected {:.1}ms)",
r.speedup_pct,
r.injected_cycles as f64 / tsc_hz() * 1e3
);
}
}
}
if !results.is_empty() {
let _ = writeln!(
s,
"note: impacts are lower bounds — site time counts on-CPU only (runnable queue-wait is not attributed); rankings unaffected"
);
}
s
}
/// Ledger-audit companion to `render_summary` (RFC 007 deficit hunt):
/// per cell, where the window's virtual delay went — born (injected),
/// paid (absorbed), waived (forgiven at wake) — and the attribution the
/// sampler lost: tails dropped at parks/yields inside the target site,
/// plus clamp discards. Cycle columns in ms at the calibrated TSC rate.
/// `absorbed` above `injected` in a cell (0% especially) means it paid
/// debt left over from earlier windows.
pub fn render_ledger_audit(results: &[ExperimentResult]) -> String {
use std::fmt::Write;
let hz = tsc_hz();
let ms = |c: u64| c as f64 / hz * 1e3;
let mut s = String::new();
let _ = writeln!(s, "== smarm causal ledger audit ==");
for r in results {
let _ = writeln!(
s,
"site {:<22} @{:>2}% injected {:>7.1}ms absorbed {:>7.1}ms forgiven {:>7.1}ms \
drop park {:>6.2}ms/{:<4} yield {:>6.2}ms/{:<4} offcpu {:>6.2}ms/{:<5} \
discard >max {:>6.2}ms/{:<3} unarmed {}",
r.site,
r.speedup_pct,
ms(r.injected_cycles),
ms(r.spin_absorbed_cycles),
ms(r.park_forgiven_cycles),
ms(r.drop_park_cycles),
r.drop_park_n,
ms(r.drop_yield_cycles),
r.drop_yield_n,
ms(r.offcpu_in_site_cycles),
r.offcpu_in_site_n,
ms(r.discard_overmax_cycles),
r.discard_overmax_n,
r.discard_unarmed_n
);
}
s
}
/// Coz-compatible profile text (`profile.coz`), so Coz's existing plot
/// tooling renders our experiments — the RFC's "don't build a UI" call.
pub fn render_coz(results: &[ExperimentResult]) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "startup\ttime=0");
for r in results {
let _ = writeln!(
s,
"experiment\tselected={}\tspeedup={:.2}\tduration={}\tselected-samples=1",
r.site,
r.speedup_pct as f64 / 100.0,
r.duration.as_nanos()
);
for (name, count) in &r.deltas {
let _ = writeln!(s, "throughput-point\tname={name}\tdelta={count}");
}
}
s
}
}
#[cfg(feature = "smarm-causal")]
pub use inner::*;
/// Mark one unit of useful work complete at a named throughput progress
/// point (RFC 007). One Relaxed increment when `smarm-causal` is on; nothing
/// at all when it's off.
#[cfg(feature = "smarm-causal")]
#[macro_export]
macro_rules! progress {
($name:literal) => {{
static __SMARM_PP: ::std::sync::OnceLock<&'static $crate::causal::ProgressPoint> =
::std::sync::OnceLock::new();
__SMARM_PP
.get_or_init(|| $crate::causal::register_progress($name))
.bump();
}};
}
#[cfg(not(feature = "smarm-causal"))]
#[macro_export]
macro_rules! progress {
($name:literal) => {{}};
}
/// Enter a named causal-profiling site for the current actor; the returned
/// guard exits it (restoring any enclosing site) on drop. Site identity is
/// stored in the actor's slot, so it survives preemption and migration.
/// Expands to a unit no-op without `smarm-causal`.
#[cfg(feature = "smarm-causal")]
#[macro_export]
macro_rules! causal_site {
($name:literal) => {{
static __SMARM_SITE: ::std::sync::OnceLock<u32> = ::std::sync::OnceLock::new();
$crate::causal::SiteGuard::enter(
*__SMARM_SITE.get_or_init(|| $crate::causal::site_id($name)),
)
}};
}
#[cfg(not(feature = "smarm-causal"))]
#[macro_export]
macro_rules! causal_site {
($name:literal) => {
()
};
}
+375 -231
View File
@@ -1,38 +1,102 @@
//! Unbounded MPSC channels.
//! Unbounded multi-producer, single-consumer channels: how actors talk to
//! each other.
//!
//! Inner state is `Arc<RawMutex<Inner<T>>>` so channels can be sent across OS
//! threads (required for the multi-scheduler runtime where a sender and
//! receiver may run on different scheduler threads simultaneously).
//! A channel is a queue with a typed [`Sender`] on one end and a typed
//! [`Receiver`] on the other. Any number of actors can hold a clone of the
//! `Sender` and push messages onto the same queue; exactly one [`Receiver`]
//! reads them back out, in the order they arrived. This is the basic wiring
//! smarm's other actor primitives (`gen_server`, `pg`, the registry) are all
//! built out of, and it is directly usable on its own for a worker that just
//! needs an inbox.
//!
//! ## Why `RawMutex` (Channel class), not `std::sync::Mutex`
//! ## A first channel
//!
//! An actor holding a guard with preemption *enabled* can be timesliced
//! inside the critical section and resume on a different OS thread — the
//! pthread mutex would then be released from a thread that didn't lock it,
//! which is UB (Linux futexes happen to tolerate it, but it's not
//! guaranteed). `RawMutex` disables preemption for the guard's span and is
//! cross-thread-release sound by construction, closing the hole. It also
//! cannot poison. Channel locks form their own [`LockClass::Channel`]
//! (raw_mutex.rs): they may be taken under a cold (Leaf) lock — finalize and
//! `monitor()` clone senders that live in slots — but nothing may be locked
//! under them, which the debug build enforces. `recv_match` runs its user
//! predicate under this lock: keep it cheap, pure, and channel-free.
//! ```
//! use smarm::{channel, run, spawn};
//!
//! Semantics:
//! - Senders are clonable; the last sender drop closes the channel.
//! - `Receiver::recv` on an empty open channel parks the receiver.
//! - `Receiver::recv` on an empty closed channel returns `Err(RecvError)`.
//! - `Sender::send` on an open channel always succeeds.
//! - `Sender::send` on a closed channel (receiver dropped) returns
//! `Err(SendError(value))`.
//! - When a send pushes to a previously empty queue and a receiver is
//! parked, the receiver is unparked.
//! run(|| {
//! let (tx, rx) = channel::<u64>();
//!
//! let worker = spawn(move || {
//! // Blocks until a message arrives.
//! let n = rx.recv().unwrap();
//! assert_eq!(n, 42);
//!
//! // Once every Sender is dropped, recv() reports the channel closed
//! // instead of blocking forever.
//! assert!(rx.recv().is_err());
//! });
//!
//! tx.send(42).unwrap();
//! drop(tx); // last sender gone: the channel is now closed
//! worker.join().unwrap();
//! });
//! ```
//!
//! ## Sending
//!
//! [`Sender`] is cheaply clonable: hand a clone to every actor that needs to
//! push messages into this queue. The channel stays open as long as at least
//! one clone exists; [`Sender::send`] never blocks and always succeeds while
//! the channel is open, since the queue is unbounded. Once the [`Receiver`]
//! has been dropped, `send` returns the message back to you in
//! [`SendError`] instead of delivering it.
//!
//! ## Receiving
//!
//! There is exactly one [`Receiver`] per channel (it is not clonable).
//! [`Receiver::recv`] returns the next message in arrival order, parking the
//! calling actor if the queue is currently empty. Once every `Sender` has
//! been dropped and the queue has been drained, `recv` stops parking and
//! returns [`RecvError`] instead, so a receiver never blocks forever waiting
//! on senders that are never coming back.
//!
//! Beyond plain `recv`, three variants cover the common needs:
//!
//! - [`Receiver::try_recv`]: never parks: reports an empty-but-open channel
//! as `Ok(None)` instead of waiting.
//! - [`Receiver::recv_timeout`]: parks, but gives up and returns
//! [`RecvTimeoutError::Timeout`] if no message arrives before a deadline.
//! - [`Receiver::recv_match`] / [`Receiver::try_recv_match`]: selective
//! receive. Instead of taking whatever is at the front of the queue, pick
//! out the first message matching a predicate, leaving the rest queued in
//! order. Handy for an actor that wants to prioritise one kind of message
//! over others already waiting.
//!
//! ## Waiting on several channels: `select`
//!
//! [`select`] parks an actor across several receivers at once and reports
//! the index of the first one that is ready (has a message queued, or has
//! been closed). [`select_timeout`] adds a deadline, the way `recv_timeout`
//! does for a single channel. See their docs for the full contract,
//! including the priority-order and no-fairness guarantee.
//!
//! ## Implementation notes
//!
//! The queue and its bookkeeping live behind `Arc<RawMutex<Inner<T>>>`
//! rather than a `std::sync::Mutex`, so that a channel can be freely shared
//! and sent across the OS threads backing the multi-scheduler runtime.
//! `RawMutex` matters here for a subtler reason too: an ordinary pthread
//! mutex can be released from a different OS thread than the one that took
//! it (smarm's preemption can migrate a timesliced actor between scheduler
//! threads mid-critical-section), and doing that to a `std::sync::Mutex` is
//! undefined behavior. `RawMutex` disables preemption for the guard's short
//! lifetime instead, so the release always happens on the thread that
//! acquired it, and it has no poisoning to worry about besides. Channel
//! locks are cheap and are never held across another lock acquisition or a
//! blocking call; the predicate passed to `recv_match` runs under this lock,
//! which is why it needs to stay cheap, pure, and must not call back into
//! the same channel.
use crate::pid::Pid;
use crate::raw_mutex::RawMutex;
use std::collections::VecDeque;
use std::sync::Arc;
/// Create a new channel and return its `(Sender, Receiver)` halves.
///
/// The channel is unbounded (no capacity limit) and open until every
/// `Sender` has been dropped.
pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
let inner = Arc::new(RawMutex::new_channel(Inner {
queue: VecDeque::new(),
@@ -40,34 +104,50 @@ pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
senders: 1,
receiver_alive: true,
}));
(Sender { inner: inner.clone() }, Receiver { inner })
(
Sender {
inner: inner.clone(),
},
Receiver { inner },
)
}
struct Inner<T> {
queue: VecDeque<T>,
/// The parked receiver's `(pid, park-epoch)`. The epoch is the slot
/// word's runtime-wide wait identity (see slot_state.rs): wakers call
/// `unpark_at(pid, epoch)`, so an entry left over from an already-woken
/// wait — a `select` loser arm, a satisfied `recv_timeout`'s timer — is
/// inert: the wake fails the word's epoch CAS and no-ops. This replaces
/// the old per-channel `cur_wait`/`next_wait_seq`/`timed_out` trio: wait
/// identity now exists exactly once, in the slot word.
/// The parked receiver's `(pid, park-epoch)`, if one is currently
/// waiting. The epoch identifies exactly which wait this is, so a waker
/// left over from a wait that already ended (a losing `select` arm, a
/// `recv_timeout` whose timer fired after it was already satisfied) is
/// inert and does nothing when it fires.
parked_receiver: Option<(Pid, u32)>,
senders: usize,
receiver_alive: bool,
}
/// The sending half of a channel, created by [`channel`]. Clonable: every
/// clone pushes onto the same queue, and the channel stays open as long as
/// any clone is alive. Dropping the last `Sender` closes the channel, which
/// wakes a parked [`Receiver`] so it can observe the closure.
pub struct Sender<T> {
inner: Arc<RawMutex<Inner<T>>>,
}
/// The receiving half of a channel, created by [`channel`]. Not clonable:
/// a channel has exactly one receiver. Reads messages in the order they
/// were sent, via [`recv`](Receiver::recv) and its variants.
pub struct Receiver<T> {
inner: Arc<RawMutex<Inner<T>>>,
}
/// Returned by [`Sender::send`] when the channel's [`Receiver`] has already
/// been dropped. Carries the message back so it is never silently lost;
/// recover it with `.0` or by matching.
#[derive(Debug, PartialEq, Eq)]
pub struct SendError<T>(pub T);
/// Returned by [`Receiver::recv`] (and the other receive methods, in their
/// own error types) when the channel is closed: every `Sender` has been
/// dropped and no message is left queued.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct RecvError;
@@ -84,8 +164,8 @@ impl std::error::Error for RecvError {}
pub enum RecvTimeoutError {
/// The deadline passed with no message available.
Timeout,
/// All senders dropped with no message available — the bounded analogue
/// of [`RecvError`].
/// Every sender was dropped with no message available. The
/// timeout-aware counterpart of plain [`RecvError`].
Disconnected,
}
@@ -103,7 +183,9 @@ impl std::error::Error for RecvTimeoutError {}
impl<T> Clone for Sender<T> {
fn clone(&self) -> Self {
self.inner.lock().senders += 1;
Sender { inner: self.inner.clone() }
Sender {
inner: self.inner.clone(),
}
}
}
@@ -115,8 +197,8 @@ impl<T> Drop for Sender<T> {
// 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
// `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()
@@ -132,19 +214,37 @@ impl<T> Drop for Sender<T> {
impl<T> Drop for Receiver<T> {
fn drop(&mut self) {
self.inner.lock().receiver_alive = false;
// The only consumer is gone: queued messages can never be delivered.
// Drop them now instead of leaving them queued until the last Sender
// happens to go away, which can be long after this receiver's owner
// has exited if some other part of the runtime is still holding a
// clone of the Sender. Draining runs each queued message's own drop
// glue, which matters for a gen_server call: dropping a queued call
// envelope drops its reply channel too, which wakes the caller with
// an error instead of leaving it parked forever. Drain under the
// lock, then run the drops after releasing it, since a message's
// drop glue may itself touch a different channel or the scheduler.
let drained = {
let mut g = self.inner.lock();
g.receiver_alive = false;
std::mem::take(&mut g.queue)
};
drop(drained);
}
}
impl<T> Sender<T> {
/// Number of messages currently queued behind this channel. Introspection
/// only (RFC 016 mailbox depth); takes the channel lock, so callers reach
/// it under the registry Leaf (Leaf → Channel) via the erased probe in
/// `registry.rs`, never on a hot path.
/// Number of messages currently queued and not yet received. For
/// introspection and monitoring; takes the channel's internal lock, so
/// avoid calling it from a hot path.
pub(crate) fn queued_len(&self) -> usize {
self.inner.lock().queue.len()
}
/// Push `value` onto the channel. Succeeds unconditionally as long as
/// the [`Receiver`] is still alive: the queue has no capacity limit, so
/// this never blocks and never fails except when the channel is closed,
/// in which case `value` comes back in [`SendError`].
pub fn send(&self, value: T) -> Result<(), SendError<T>> {
let unpark = {
let mut g = self.inner.lock();
@@ -155,16 +255,28 @@ impl<T> Sender<T> {
g.parked_receiver.take()
};
if let Some((pid, epoch)) = unpark {
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: crate::actor::current_pid()
.unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)),
receiver: Some(pid)
});
crate::scheduler::unpark_at(pid, epoch);
} else {
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: crate::actor::current_pid()
.unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)),
receiver: None
});
}
Ok(())
}
}
impl<T> Receiver<T> {
/// Block until a message is available and return it. Messages come back
/// in the order they were sent. If the queue is empty and every
/// [`Sender`] has already been dropped, returns [`RecvError`] instead of
/// blocking forever.
pub fn recv(&self) -> Result<T, RecvError> {
loop {
{
@@ -176,54 +288,52 @@ impl<T> Receiver<T> {
if g.senders == 0 {
return Err(RecvError);
}
let me = crate::actor::current_pid()
.expect("recv() called outside an actor");
let me = match crate::actor::current_pid() {
Some(me) => me,
None => panic!("smarm: recv() called outside an actor"),
};
debug_assert!(
g.parked_receiver.is_none_or(|(p, _)| p == me),
"channel has more than one receiver"
);
// begin_wait is lock-free — legal under the Channel lock;
// begin_wait is lock-free, so it's legal under the Channel lock;
// registering in the same critical section makes the epoch
// atomic with the senders' view of the registration.
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
crate::te!(crate::trace::Event::RecvPark(me));
}
// Release the lock before parking — the unparker will need it.
// Release the lock before parking: the unparker will need it.
crate::scheduler::park_current();
// Woken up — record it before looping to check the queue.
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
// Woken up. Record it before looping to check the queue.
crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}
));
}
}
/// Bounded receive: like [`recv`](Self::recv), but gives up once
/// `timeout` has elapsed, returning [`RecvTimeoutError::Timeout`].
/// Like [`recv`](Self::recv), but gives up and returns
/// [`RecvTimeoutError::Timeout`] if no message has arrived by the time
/// `timeout` elapses.
///
/// Built on the same timer machinery as `Mutex::lock_timeout`: the wait
/// registers a `WaitTimeout` entry stamped with the wait's park-epoch;
/// on expiry the channel (as the
/// [`TimerTarget`](crate::timer::TimerTarget)) checks whether *this*
/// wait is still parked and, only then, cancels it. A wake that races
/// the deadline resolves message-first: if a message is available when
/// the receiver runs, it is delivered even if the timer had already
/// fired. A satisfied or abandoned wait leaves its timer entry to expire
/// as a no-op (registration gone; epoch consumed), per the
/// no-cancellation convention in `timer.rs`.
/// If a message arrives at essentially the same moment the deadline
/// passes, the message wins: you get `Ok` rather than `Timeout`. If
/// every sender is dropped before a message arrives or the deadline
/// passes, you get [`RecvTimeoutError::Disconnected`].
///
/// The wake is classified from state alone — wakes are precise (the only
/// stamped wakers of this wait are a send, the last-sender drop, and the
/// timer; a stop wake unwinds out of `park_current` and never reaches
/// the classification), so: message queued → `Ok`; `senders == 0` →
/// `Disconnected`; neither → it was the timer → `Timeout`.
///
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-
/// due timer is drained, then reports `Timeout` unless a message was
/// already queued.
/// `Duration::ZERO` is a valid timeout: it still gives any
/// already-queued message a chance to be returned, and only then
/// reports `Timeout`.
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Result<T, RecvTimeoutError>
where
T: Send + 'static,
{
let me = crate::actor::current_pid()
.expect("recv_timeout() called outside an actor");
let me = match crate::actor::current_pid() {
Some(me) => me,
None => panic!("smarm: recv_timeout() called outside an actor"),
};
// Fast path + wait registration, one critical section.
let epoch;
@@ -247,14 +357,19 @@ impl<T> Receiver<T> {
// Arm the timer after releasing the channel lock (insert takes the
// timers lock; never nest under a Channel lock). A send or even the
// timer itself may unpark us before we park — the RunningNotified
// timer itself may unpark us before we park; the runtime's wake
// protocol makes the park below return immediately in that case.
let deadline = crate::timer::deadline_from_now(timeout);
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = self.inner.clone();
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}
));
let mut g = self.inner.lock();
if let Some(v) = g.queue.pop_front() {
crate::preempt::note_message_received();
@@ -266,16 +381,23 @@ impl<T> Receiver<T> {
Err(RecvTimeoutError::Timeout)
}
/// 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.
/// Selective receive: find and return the first queued message for
/// which `pred` returns `true`, leaving every other message in the
/// queue untouched and in order. Useful when an actor's inbox mixes
/// message kinds and it wants to handle one kind out of turn, without
/// discarding the rest.
///
/// `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.
/// If nothing queued matches, this blocks and re-checks every time a new
/// message arrives, the same way [`recv`](Self::recv) blocks on an empty
/// queue: a selective receiver can be waiting even while the queue holds
/// messages, just none that match yet. Returns [`RecvError`] only once
/// the channel is closed and still nothing matches.
///
/// `pred` runs while the channel is locked, so keep it cheap, side
/// effect free, and make sure it never calls back into this same
/// channel. It takes `&T` and is called fresh on every scan (not `FnMut`
/// with running state), so it should judge each message purely on its
/// own content.
pub fn recv_match<F>(&self, pred: F) -> Result<T, RecvError>
where
F: Fn(&T) -> bool,
@@ -283,17 +405,23 @@ impl<T> Receiver<T> {
loop {
{
let mut g = self.inner.lock();
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
if let Some(i) = g.queue.iter().position(&pred) {
// position() found it, so remove() returns Some.
crate::preempt::note_message_received();
return Ok(g.queue.remove(i).unwrap());
let v = match g.queue.remove(i) {
Some(v) => v,
None => panic!("smarm: channel queue.remove after position (logic bug)"),
};
return Ok(v);
}
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");
let me = match crate::actor::current_pid() {
Some(me) => me,
None => panic!("smarm: recv_match() called outside an actor"),
};
debug_assert!(
g.parked_receiver.is_none_or(|(p, _)| p == me),
"channel has more than one receiver"
@@ -301,24 +429,35 @@ impl<T> Receiver<T> {
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
crate::te!(crate::trace::Event::RecvPark(me));
}
// 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::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}
));
}
}
/// 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).
/// The non-blocking counterpart of [`recv_match`](Self::recv_match):
/// returns immediately either way. `Ok(Some(v))` if a queued message
/// matched `pred` (removed; the rest stay queued in order), `Ok(None)`
/// if the channel is open but nothing currently matches, `Err(RecvError)`
/// if the channel is closed and nothing matches. Same predicate contract
/// as `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();
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
if let Some(i) = g.queue.iter().position(&pred) {
crate::preempt::note_message_received();
return Ok(Some(g.queue.remove(i).unwrap()));
let v = match g.queue.remove(i) {
Some(v) => v,
None => panic!("smarm: channel queue.remove after position (logic bug)"),
};
return Ok(Some(v));
}
if g.senders == 0 {
return Err(RecvError);
@@ -326,8 +465,10 @@ impl<T> Receiver<T> {
Ok(None)
}
/// 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 non-blocking counterpart of [`recv`](Self::recv): returns
/// immediately either way. `Ok(Some(v))` if a message was queued,
/// `Ok(None)` if the channel is open but currently empty, `Err(RecvError)`
/// if the channel is closed and the queue is drained.
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
let mut g = self.inner.lock();
if let Some(v) = g.queue.pop_front() {
@@ -342,18 +483,18 @@ impl<T> Receiver<T> {
}
// ---------------------------------------------------------------------------
// TimerTarget — the expiry half of recv_timeout
// TimerTarget: the expiry half of recv_timeout
// ---------------------------------------------------------------------------
impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
fn on_timeout(&self, pid: Pid, epoch: u32) {
// Cancel the wait only if THIS wait (epoch match) is still
// registered. If a sender already took `parked_receiver`, the
// receiver is waking with a message — message wins, the timer
// receiver is waking with a message: message wins, the timer
// no-ops. If a later wait by the same receiver is registered, the
// epoch mismatches — stale entry, no-op. (The unpark_at would fail
// its word CAS in either case anyway; checking under the lock keeps
// the registration bookkeeping exact.)
// epoch mismatches: stale entry, no-op. (unpark_at would fail its
// internal check in either case anyway; checking under the lock
// keeps the registration bookkeeping exact.)
let unpark = {
let mut g = self.lock();
if g.parked_receiver == Some((pid, epoch)) {
@@ -363,7 +504,7 @@ impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
false
}
};
// Unpark outside the channel lock — it may take the run-queue lock;
// Unpark outside the channel lock: it may take the run-queue lock;
// legal under a Channel lock, but pointless to nest.
if unpark {
crate::scheduler::unpark_at(pid, epoch);
@@ -372,7 +513,7 @@ impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
}
// ---------------------------------------------------------------------------
// select — ready-index wait over multiple receivers
// select: ready-index wait over multiple receivers
// ---------------------------------------------------------------------------
pub(crate) mod sealed {
@@ -380,33 +521,34 @@ pub(crate) mod sealed {
}
impl<T> sealed::Sealed for Receiver<T> {}
/// An arm of a [`select`]. Implemented by [`Receiver`]; sealed, because the
/// registration contract below is part of the runtime's wake protocol.
/// An arm of a [`select`]: something you can wait on alongside other arms
/// and be told when it becomes ready. Implemented by [`Receiver`]; sealed
/// (cannot be implemented outside this crate), since the registration
/// contract below is part of the runtime's internal wake protocol.
///
/// Contract (all under the arm's own lock): `sel_register` checks-or-
/// registers atomically — if the arm is ready it does NOT register and
/// registers atomically. If the arm is ready it does not register and
/// returns `Ok(false)`; otherwise it publishes `(pid, epoch)` where its
/// wakers will find it and returns `Ok(true)`. "Ready" means a receive
/// would not park: a message is queued, or the arm is closed. `Err` means
/// would not block: a message is queued, or the arm is closed. `Err` means
/// the arm could not register at all (only fd arms can fail; channel
/// registration is infallible) — the wait must be retired and earlier
/// registration always succeeds), and the wait must be retired and earlier
/// eager-cleanup arms unregistered.
pub trait Selectable: sealed::Sealed {
#[doc(hidden)]
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool>;
#[doc(hidden)]
fn sel_ready(&self) -> bool;
/// Remove this arm's `(pid, epoch)` registration if — and only if — it
/// is still in place. Default no-op: a losing channel arm's stale
/// registration is inert (its wakers die at the epoch CAS; the next
/// wait overwrites the slot). Fd arms override this: their staleness
/// poisons the fd (waiters entry + kernel-side ONESHOT registration)
/// and needs an eager cleanup pass.
/// Remove this arm's `(pid, epoch)` registration if, and only if, it is
/// still in place. Default no-op: a losing channel arm's stale
/// registration is harmless and self-cleans. Fd arms override this:
/// their staleness would otherwise leave the fd unusable for future
/// selects, so they need an eager cleanup pass.
#[doc(hidden)]
fn sel_unregister(&self, _pid: Pid, _epoch: u32) {}
/// Whether this arm requires the eager cleanup pass at all. Gates the
/// post-wake `sel_unregister` sweep so channel-only selects keep
/// today's zero-cancellation hot path.
/// post-wake `sel_unregister` sweep so channel-only selects keep their
/// cheap, cleanup-free path.
#[doc(hidden)]
fn sel_eager_cleanup(&self) -> bool {
false
@@ -433,50 +575,54 @@ impl<T> Selectable for Receiver<T> {
}
}
/// Park on every arm at once; return the index of the first ready one.
/// Wait on several channels at once and return the index of the first one
/// that is ready, instead of blocking on just one with [`Receiver::recv`].
///
/// "Ready" means a receive on that arm would not park: a message is queued,
/// or the arm is **closed** (so the caller's `try_recv` observes the
/// disconnect — a dead arm is an event, not a hang). The caller consumes the
/// arm itself, typically via [`Receiver::try_recv`]; single-receiver
/// channels guarantee nothing can steal the message in between.
/// "Ready" means a receive on that arm would not block: a message is
/// queued, or the arm is closed (so the caller's own `try_recv` observes
/// the disconnect: a dead arm is something to react to, not something to
/// hang on). `select` only tells you which arm is ready; read the actual
/// message yourself, typically with [`Receiver::try_recv`] on that arm.
///
/// A closed arm stays ready *forever*: once its disconnect has been
/// observed, drop it from the arm set — under priority order it would
/// otherwise win every subsequent call and starve every higher-indexed arm.
/// A closed arm stays ready forever. Once you have observed its disconnect,
/// drop it from the arm set you pass in next time: otherwise, under the
/// priority order below, it would win every subsequent call and starve
/// every arm listed after it.
///
/// Arms are scanned **in order**: index 0 is the highest priority, both on
/// the immediate-ready path and after a wake. This is a documented
/// guarantee (compose like BEAM receive clauses: put control channels
/// first), not an accident — and therefore there is NO fairness promise; a
/// saturated arm 0 starves arm 1 by design.
/// Arms are checked **in order**: index 0 is the highest priority, both
/// when checking immediately and after being woken. This is a deliberate,
/// documented guarantee, not an accident of implementation: put a control
/// or shutdown channel first so it is always noticed promptly. The
/// flip side is that there is **no fairness guarantee**: a busy arm 0 can
/// starve arm 1 indefinitely by design.
///
/// One actor may select on a channel and later `recv` on it (or select on
/// overlapping sets) freely. What stays illegal is what was always illegal:
/// two *different* actors receiving on one channel.
/// One actor can `select` on a channel and later plain `recv` on it (or
/// `select` again on an overlapping set of arms) with no restriction. What
/// stays illegal is what was always illegal for a channel: two *different*
/// actors receiving on the same one.
///
/// Built on the consuming-wake protocol (see slot_state.rs): all arms are
/// registered under one wait epoch; the winning wake consumes it, so losing
/// arms' registrations are inert and need no cancellation pass — they
/// self-clean at their wakers' failed CAS, or get overwritten by this
/// receiver's next wait on that channel.
///
/// Panics if `arms` is empty, when called outside an actor, or if an fd
/// arm fails to register (EBADF, EMFILE, a second waiter on one fd —
/// see [`try_select`] for the fallible form; channel-only selects cannot
/// fail).
/// Panics if `arms` is empty, if called outside an actor, or if an fd arm
/// fails to register (see [`try_select`] for the fallible form; a
/// channel-only `select` can never fail).
pub fn select(arms: &[&dyn Selectable]) -> usize {
try_select(arms).expect("select(): fd arm failed to register (use try_select)")
match try_select(arms) {
Ok(i) => i,
Err(e) => panic!("smarm: select() fd arm failed to register (use try_select): {e}"),
}
}
/// [`select`], fallible: `Err` when an arm fails to register (only fd
/// arms can — EBADF, EMFILE on the epoll set, or a second waiter on an
/// fd that already has one). On `Err` the wait is fully retired and no
/// The fallible form of [`select`]: `Err` when an arm fails to register.
/// Only fd arms can fail this way (for example, the file descriptor is
/// invalid, or something else is already waiting on it); a channel-only
/// select can never fail. On `Err` the wait is fully retired and no
/// registration is left behind: every arm registered before the failing
/// one has been unregistered.
pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
assert!(!arms.is_empty(), "select() on an empty arm list");
let me = crate::actor::current_pid().expect("select() called outside an actor");
let me = match crate::actor::current_pid() {
Some(me) => me,
None => panic!("smarm: select() called outside an actor"),
};
loop {
let epoch = crate::scheduler::begin_wait();
if let Some(i) = register_arms(me, epoch, arms)? {
@@ -484,15 +630,19 @@ pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
}
// Stale fd registrations are not harmless (a losing fd arm's
// waiters entry poisons the fd with AlreadyExists and its
// kernel-side ONESHOT registration can fire arbitrarily late), so
// selects containing fd arms run an eager cleanup pass after the
// park — including when a terminal stop unwinds out of it, via
// the guard. Channel-only selects skip all of it: `eager` is
// false, the guard is disarmed, and the loser-arm self-cleaning
// story is unchanged.
// leftover registration can make the fd unusable for the next
// select until a kernel event happens to clear it), so selects
// containing fd arms run an eager cleanup pass after the park,
// including when a terminal stop unwinds out of it, via the guard.
// Channel-only selects skip all of it: `eager` is false, the guard
// is disarmed, and the loser-arm self-cleaning story is unchanged.
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
let mut guard = UnregisterGuard {
arms,
me,
epoch,
armed: eager,
};
crate::scheduler::park_current();
@@ -503,22 +653,22 @@ pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
drop(guard);
// Woken precisely: an arm's send (message) or last-sender drop
// (closure) consumed our epoch, and both leave their arm ready —
// return the first one, in priority order (which may be a
// (closure) is what woke us, and both leave their arm ready.
// Return the first ready one, in priority order (which may be a
// different, higher-priority arm than the one that woke us; its
// message stays queued and re-reports ready on the next call).
// Fd arms classify by a fresh zero-timeout poll, so they too are
// a pure function of state — independent of the registration the
// cleanup pass just removed.
// a pure function of current state, independent of the
// registration the cleanup pass just removed.
for (i, arm) in arms.iter().enumerate() {
if arm.sel_ready() {
return Ok(i);
}
}
// Unreachable by protocol (a stop wake unwinds out of
// park_current). Defensive: re-open the wait and re-register —
// stale own-registrations are overwritten (channels) or were
// removed by the cleanup pass above (fds).
// Unreachable in practice (a stop wake unwinds out of
// park_current before we get here). Defensive: re-open the wait
// and re-register; stale own-registrations are overwritten
// (channels) or were removed by the cleanup pass above (fds).
}
}
@@ -533,11 +683,11 @@ fn unregister_arms(arms: &[&dyn Selectable], me: Pid, epoch: u32) {
}
}
/// Stop-unwind twin of the explicit cleanup pass: a terminal stop unwinds
/// out of `park_current`, and a registered fd arm must not outlive its
/// actor (the generalization of `wait_fd`'s `Dereg`). Disarmed on the
/// normal path after the explicit pass runs; never armed when no fd arm
/// registered, keeping the channel-only path guard-free in effect.
// Stop-unwind twin of the explicit cleanup pass: a terminal stop unwinds
// out of `park_current`, and a registered fd arm must not outlive its
// actor. Disarmed on the normal path after the explicit pass runs; never
// armed when no fd arm is registered, keeping the channel-only path
// guard-free in effect.
struct UnregisterGuard<'a> {
arms: &'a [&'a dyn Selectable],
me: Pid,
@@ -553,25 +703,17 @@ impl Drop for UnregisterGuard<'_> {
}
}
/// The registration pass shared by [`select`] and [`select_timeout`]:
/// check-or-register each arm, in priority order, each atomically under its
/// own lock. Cross-arm atomicity is unnecessary: an arm becoming ready
/// right after its registration wakes the caller through the protocol (the
/// prep-to-park window is closed by RunningNotified).
///
/// `Ok(Some(i))` = arm `i` was ready, the pass stopped, and the wait has
/// been RETIRED (no park may follow): earlier arms hold live-epoch
/// registrations, so earlier *fd* arms are unregistered eagerly, then the
/// epoch is bumped, a landed notification eaten, and a pending stop
/// re-observed — without which a stale arm wake could fault a later
/// one-shot park. `Err` = an arm failed to register; identical unwind
/// (earlier fd arms unregistered, wait retired). `Ok(None)` = every arm
/// registered; the caller parks.
fn register_arms(
me: Pid,
epoch: u32,
arms: &[&dyn Selectable],
) -> std::io::Result<Option<usize>> {
// The registration pass shared by `select` and `select_timeout`: check-or-
// register each arm, in priority order, each atomically under its own lock.
// Cross-arm atomicity is unnecessary: an arm becoming ready right after its
// registration still wakes the caller through the normal wake path.
//
// `Ok(Some(i))` = arm `i` was already ready, the pass stopped, and the wait
// has been fully retired (no park may follow): earlier fd arms are
// unregistered eagerly so none are left dangling. `Err` = an arm failed to
// register; same unwind (earlier fd arms unregistered, wait retired).
// `Ok(None)` = every arm registered successfully; the caller parks.
fn register_arms(me: Pid, epoch: u32, arms: &[&dyn Selectable]) -> std::io::Result<Option<usize>> {
for (i, arm) in arms.iter().enumerate() {
let registered = match arm.sel_register(me, epoch) {
Ok(r) => r,
@@ -590,10 +732,10 @@ fn register_arms(
Ok(None)
}
/// The [`select_timeout`] timer target: stateless, because precise wakes
/// make classification a pure function of channel state. The entry is
/// stamped with the select's epoch; if an arm already won, this unpark dies
/// at the word's epoch CAS (the no-cancellation convention in `timer.rs`).
// The `select_timeout` timer target: stateless, because a wake's cause can
// always be read back off plain channel state (an arm ready, or not). If
// an arm already won before the deadline, this timer's fire is simply
// ignored, the way any other stale wakeup is.
struct SelectTimeout;
impl crate::timer::TimerTarget for SelectTimeout {
fn on_timeout(&self, pid: Pid, epoch: u32) {
@@ -601,62 +743,64 @@ impl crate::timer::TimerTarget for SelectTimeout {
}
}
/// [`select`] with a deadline: returns `Some(index)` like `select`, or
/// `None` once `timeout` elapses with no arm ready.
/// Like [`select`], but gives up and returns `None` if no arm becomes
/// ready before `timeout` elapses.
///
/// All of `select`'s semantics carry over (priority order, closed arms
/// permanently ready, no fairness promise). The timeout is one more stamped
/// waker on the same wait epoch — nothing is registered in any arm for it,
/// so there is nothing to cancel or leak: an arm winning leaves the timer
/// entry to expire as a stale-epoch no-op; the timer winning leaves the
/// arms' registrations to self-clean exactly as a `select` loser's would.
/// All of `select`'s semantics carry over: arms are still checked in
/// priority order, a closed arm is still permanently ready, and there is
/// still no fairness guarantee across arms. A message that arrives at
/// essentially the same moment the deadline passes still wins, the same
/// way [`Receiver::recv_timeout`] resolves that race.
///
/// The wake is classified from state alone (wakes are precise): some arm
/// ready → `Some` of the first, in priority order; none ready → the timer
/// was the only remaining stamped waker → `None`. A message that races the
/// deadline resolves message-first, as `recv_timeout` does.
/// `Duration::ZERO` is a valid timeout: it still gives an already-ready arm
/// a chance to be reported before falling through to `None`.
///
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-due
/// timer is drained, then reports `None` unless an arm was already ready.
///
/// Panics if `arms` is empty, when called outside an actor, or if an fd
/// arm fails to register (see [`try_select_timeout`] for the fallible
/// form; channel-only selects cannot fail).
pub fn select_timeout(
arms: &[&dyn Selectable],
timeout: std::time::Duration,
) -> Option<usize> {
try_select_timeout(arms, timeout)
.expect("select_timeout(): fd arm failed to register (use try_select_timeout)")
/// Panics if `arms` is empty, if called outside an actor, or if an fd arm
/// fails to register (see [`try_select_timeout`] for the fallible form; a
/// channel-only select can never fail).
pub fn select_timeout(arms: &[&dyn Selectable], timeout: std::time::Duration) -> Option<usize> {
match try_select_timeout(arms, timeout) {
Ok(r) => r,
Err(e) => panic!(
"smarm: select_timeout() fd arm failed to register (use try_select_timeout): {e}"
),
}
}
/// [`select_timeout`], fallible: `Err` when an arm fails to register
/// (only fd arms can). On `Err` the wait is fully retired and no
/// registration — arm-side or kernel-side — is left behind.
/// The fallible form of [`select_timeout`]: `Err` when an arm fails to
/// register (only fd arms can). On `Err` the wait is fully retired and no
/// registration is left behind on any arm.
pub fn try_select_timeout(
arms: &[&dyn Selectable],
timeout: std::time::Duration,
) -> std::io::Result<Option<usize>> {
assert!(!arms.is_empty(), "select_timeout() on an empty arm list");
let me = crate::actor::current_pid().expect("select_timeout() called outside an actor");
let me = match crate::actor::current_pid() {
Some(me) => me,
None => panic!("smarm: select_timeout() called outside an actor"),
};
let epoch = crate::scheduler::begin_wait();
if let Some(i) = register_arms(me, epoch, arms)? {
return Ok(Some(i)); // ready now: the timer was never armed
}
// Arm the timer after the registration pass, outside every Channel
// lock (insert takes the timers lock).
// Arm the timer after the registration pass, outside every channel
// lock (inserting a timer takes the timers lock).
let deadline = crate::timer::deadline_from_now(timeout);
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = std::sync::Arc::new(SelectTimeout);
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
// Same eager-cleanup story as `try_select`: the timer arm needs none
// (stateless, stale entries die at the epoch CAS), channel arms need
// none, fd arms do — and a timer win in particular leaves every fd
// arm's registration behind, which without this pass would poison
// those fds until a kernel event happened to fire.
// Same eager-cleanup story as `try_select`: a timer win in particular
// leaves every fd arm's registration behind, which without this pass
// would leave those fds unusable until a kernel event happened to
// clear them.
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
let mut guard = UnregisterGuard {
arms,
me,
epoch,
armed: eager,
};
crate::scheduler::park_current();
+44 -11
View File
@@ -16,10 +16,18 @@ thread_local! {
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)) }
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
@@ -49,13 +57,20 @@ pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
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 -= 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
}
}
@@ -94,10 +109,28 @@ unsafe extern "C" fn switch_to_actor_asm() {
}
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
///
/// # Safety
///
/// The caller must be running on a scheduler thread with a valid actor stack
/// pointer installed in `ACTOR_SP` — either by `init_actor_stack` (first
/// resume) or by a prior `switch_to_scheduler` (subsequent resumes). Resuming
/// with an unset or stale `ACTOR_SP` transfers control to an arbitrary address.
/// Must not be called from within an actor (only the scheduler side may resume).
pub unsafe fn switch_to_actor() {
unsafe { switch_to_actor_asm() };
}
/// Yield from the running actor back to its scheduler thread. Returns when the
/// actor is next resumed via [`switch_to_actor`].
///
/// # Safety
///
/// The caller must be running on an actor stack that was entered through
/// [`switch_to_actor`], so that `SCHEDULER_SP` holds the live saved stack
/// pointer of the scheduler side. Calling this from the scheduler thread, or
/// before any actor has been resumed, transfers control to an arbitrary
/// address.
#[unsafe(naked)]
pub unsafe extern "C" fn switch_to_scheduler() {
core::arch::naked_asm!(
+438 -236
View File
@@ -1,77 +1,190 @@
//! gen_server — synchronous call / asynchronous cast over a single actor.
//! A stateful actor with typed request-reply messaging.
//!
//! 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 [`GenServerRef`] and issue
//! [`call`](GenServerRef::call) (synchronous, returns a reply) or
//! [`cast`](GenServerRef::cast) (fire-and-forget).
//! ## What is a gen_server?
//!
//! ## One inbox, many arms
//! Whenever you need mutable state shared between threads, the obvious tool is
//! `Arc<Mutex<State>>`. That works, but it scatters lock guards and error
//! handling everywhere, and offers no natural place to put the logic that
//! operates on the state.
//!
//! Every call and cast travels the *same* inbox channel as an [`Envelope`]
//! (calls and casts are ordered relative to each other, like Erlang); 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.
//! A gen_server is a cleaner alternative: a dedicated thread that *owns* the
//! state and handles one message at a time. Other threads talk to it by sending
//! typed messages and optionally waiting for a typed reply. Because messages are
//! serialised through a single inbox. The server is the only thing that ever
//! touches the state, so there are no lock guards at all.
//!
//! Out-of-band messages ride *separate* channels composed at the wait via
//! [`select`](channel::select): info channels handed over at start
//! ([`GenServerBuilder::with_info`]) are dispatched to
//! [`handle_info`](GenServer::handle_info). Arm priority is
//! **infos before inbox**, in declaration order — a hot inbox cannot starve
//! an out-of-band message; conversely a hot info channel CAN starve the
//! inbox, deliberately (system-message semantics). An info channel whose
//! senders are all gone is silently dropped from the arm set (per the
//! closed-arm-is-ready-forever rule on `select`); a closed *inbox* still
//! means graceful shutdown.
//! You write the logic by implementing [`GenServer`]. smarm runs the receive
//! loop, the reply plumbing, and the lifecycle.
//!
//! ## Server death
//! ## A first server
//!
//! 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`.
//! Let's build a counter that can be incremented from any thread and queried for
//! its current value.
//!
//! ## Lifecycle / callbacks
//! ```ignore
//! use smarm::gen_server::{self, GenServer, GenServerRef};
//!
//! [`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).
//! // State
//!
//! ## Time (timers and idle)
//! struct Counter {
//! count: u64,
//! }
//!
//! A server arms timers through a [`TimerHandle`] cloned from the [`GenServerCtx`]
//! in `init` and stored on the state — the same shape as [`Watcher`] for
//! monitors. [`arm_after`](TimerHandle::arm_after) is a one-shot,
//! [`tick_every`](TimerHandle::tick_every) a periodic; both fire into
//! [`handle_timer`](GenServer::handle_timer) at *system priority* (above infos
//! and the inbox, by arm position), and [`cancel`](TimerHandle::cancel) carries
//! the substrate's race signal. Separately, [`GenServerCtx::idle_after`] sets a
//! receive-timeout window: quiet for the whole window fires
//! [`handle_idle`](GenServer::handle_idle).
//! // Message types
//!
//! Two unrelated things share the word *timeout*: the server-side **idle /
//! receive timeout** above, and the client-side **call deadline**
//! ([`GenServerRef::call_timeout`]) — how long a caller waits for a reply. They sit
//! on different axes and never interact (RFC 015 §7).
//! // Calls expect a reply; casts do not.
//! enum CounterCall { Get }
//! enum CounterCast { Increment }
//!
//! ## Not here (yet)
//! // Behaviour
//!
//! No dynamic info subscription: the info-channel set is fixed at start.
//! Revisit against a real consumer. (Monitor `Down` forwarding is dynamic —
//! see `handle_down` — because monitors are inherently created at runtime.)
//! The idle window is likewise set once, in `init` (RFC 015 §4.4).
//! impl GenServer for Counter {
//! type Call = CounterCall;
//! type Reply = u64;
//! type Cast = CounterCast;
//! type Info = (); // no out-of-band messages
//! type Timer = (); // no timers
//!
//! fn handle_call(&mut self, request: CounterCall) -> u64 {
//! match request {
//! CounterCall::Get => self.count,
//! }
//! }
//!
//! fn handle_cast(&mut self, request: CounterCast) {
//! match request {
//! CounterCast::Increment => self.count += 1,
//! }
//! }
//! }
//! ```
//!
//! Start the server and talk to it:
//!
//! ```ignore
//! let server: GenServerRef<Counter> = gen_server::start(Counter { count: 0 });
//!
//! server.cast(CounterCast::Increment).unwrap();
//! server.cast(CounterCast::Increment).unwrap();
//!
//! let n = server.call(CounterCall::Get).unwrap();
//! // n == 2
//! ```
//!
//! [`GenServerRef`] is cheaply clonable; hand copies to as many threads as you
//! like. They all share the same inbox; the server handles messages one at a
//! time, in arrival order.
//!
//! ## Client / server APIs
//!
//! In practice, callers should not have to know about `CounterCall` or
//! `CounterCast`. The recommended pattern is to wrap the message types in plain
//! functions on the same module:
//!
//! ```ignore
//! // Client API
//!
//! impl Counter {
//! pub fn start() -> GenServerRef<Counter> {
//! gen_server::start(Counter { count: 0 })
//! }
//!
//! pub fn increment(server: &GenServerRef<Counter>) {
//! server.cast(CounterCast::Increment).unwrap();
//! }
//!
//! pub fn get(server: &GenServerRef<Counter>) -> u64 {
//! server.call(CounterCall::Get).unwrap()
//! }
//! }
//!
//! // Callers now just do:
//! let counter = Counter::start();
//! Counter::increment(&counter);
//! Counter::increment(&counter);
//! let n = Counter::get(&counter); // n == 2
//! ```
//!
//! ## call vs cast
//!
//! `call` sends a request and blocks the calling thread until the server
//! replies. Use it when you need a return value, or when you need to know that
//! the server has processed the message before continuing.
//!
//! `cast` enqueues a message and returns immediately, without waiting for the
//! server to handle it. Use it for fire-and-forget updates where you don't need
//! confirmation.
//!
//! Both return `Err(ServerDown)` if the inbox is closed (the server is gone).
//! If you need to cap how long you wait for a reply, use
//! [`GenServerRef::call_timeout`], which additionally returns `Err(Timeout)` if
//! the deadline passes before a reply arrives.
//!
//! ## Lifecycle
//!
//! Two optional callbacks bracket the server's life:
//!
//! - [`GenServer::init`] runs once before the first message. Use it to start
//! timers or set up monitors; see the [`GenServerCtx`] it receives.
//! - [`GenServer::terminate`] runs when the server is about to exit. It fires
//! on every exit path (all `GenServerRef`s dropped, a handler panic, or an
//! explicit [`GenServerRef::shutdown`]), not only on clean shutdown. Keep it
//! short and non-blocking: if `terminate` panics while the server is already
//! unwinding from a handler panic, the process aborts.
//!
//! ## When the server stops
//!
//! The server runs as long as at least one [`GenServerRef`] exists. When the last
//! one is dropped, the inbox closes and the loop exits gracefully. To stop a
//! server explicitly and wait for it to finish, call [`GenServerRef::shutdown`].
//!
//! If the server panics inside a handler, the panic unwinds the server thread.
//! Any caller currently waiting in `call` sees `Err(ServerDown)`: the reply
//! channel closes as the server unwinds, which wakes the caller.
//!
//! ## Going further
//!
//! Out-of-band messages: a server can receive messages from sources other
//! than `call`/`cast`, for example notifications from a background task. Pass
//! extra [`Receiver`] channels to [`GenServerBuilder::with_info`]; they are
//! dispatched to [`GenServer::handle_info`], always before inbox messages.
//!
//! Monitors: to watch another actor and be notified when it exits, clone a
//! [`Watcher`] from [`GenServerCtx::watcher`] during `init` and call
//! [`Watcher::watch`] with a [`Monitor`] from any handler. The loop dispatches
//! the resulting [`Down`] to [`GenServer::handle_down`].
//!
//! Timers: clone a [`TimerHandle`] from [`GenServerCtx::timer`] during `init`.
//! Use [`TimerHandle::arm_after`] for a one-shot and [`TimerHandle::tick_every`]
//! for a periodic; both fire into [`GenServer::handle_timer`].
//!
//! Idle detection: call [`GenServerCtx::idle_after`] during `init` to set a
//! quiet window: if no message of any kind is dispatched for that duration,
//! the loop calls [`GenServer::handle_idle`] and resets the window. This differs
//! from the client-side `call_timeout`. Idle measures silence on the *server*,
//! while `call_timeout` caps how long *one caller* waits.
//!
//! Names and registration: a server can be given a static name so other
//! actors can reach it without holding a `GenServerRef`. Use
//! [`GenServerBuilder::named`] to register on start, and the free functions
//! [`call`], [`cast`], and [`whereis_server`] to address it by name. Registered
//! servers are a natural fit for supervision; see `supervisor` for how to
//! build a tree that restarts servers on failure.
//!
//! ## Limitations
//!
//! The info-channel set is fixed at start; channels cannot be added or removed
//! while the server is running. Monitors are dynamic (they can be registered
//! from any handler via [`Watcher::watch`]) because monitors are inherently
//! created at runtime. The idle window is set once, in `init`.
use crate::channel::{channel, select, select_timeout, Receiver, RecvTimeoutError, Selectable, Sender};
use crate::channel::{
channel, select, select_timeout, Receiver, RecvTimeoutError, Selectable, Sender,
};
use crate::monitor::{demonitor, monitor, Down, Monitor};
use crate::pid::Pid;
use crate::registry::{register_with, resolve_named_sender, RegisterError};
use crate::scheduler::{cancel_timer, request_stop, send_after_to, spawn, spawn_under};
use crate::scheduler::{cancel_timer, request_stop, send_after_to};
use crate::timer::TimerId;
use std::cell::Cell;
use std::collections::HashMap;
@@ -79,37 +192,33 @@ use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Behaviour for a gen_server: a state value plus call/cast handlers.
/// The behaviour you implement to make a type into a gen_server.
///
/// `handle_call` and `handle_cast` are required; [`init`](Self::init) and
/// [`terminate`](Self::terminate) are optional lifecycle hooks with no-op
/// defaults.
/// Implement this on your state struct. `handle_call` and `handle_cast` are
/// required; all other methods have no-op defaults and can be added as needed.
pub trait GenServer: Send + 'static {
/// Synchronous request type (carried by [`GenServerRef::call`]).
/// The request type sent by [`GenServerRef::call`]. Must produce a [`Reply`](Self::Reply).
type Call: Send + 'static;
/// Reply type returned for a `Call`.
/// The value returned to the caller by [`handle_call`](Self::handle_call).
type Reply: Send + 'static;
/// Asynchronous request type (carried by [`GenServerRef::cast`]).
/// The request type sent by [`GenServerRef::cast`]. No reply is produced.
type Cast: Send + 'static;
/// Out-of-band message type, delivered to [`handle_info`](Self::handle_info)
/// from the info channels registered at start
/// ([`GenServerBuilder::with_info`]). Servers with several out-of-band
/// sources enum them up into one `Info`. Use `()` if unused.
/// Out-of-band message type delivered to [`handle_info`](Self::handle_info)
/// from channels registered via [`GenServerBuilder::with_info`]. If you don't
/// use info channels, set this to `()`.
type Info: Send + 'static;
/// The server's own scheduled-timer payload, delivered to
/// [`handle_timer`](Self::handle_timer) when a timer armed via the loop's
/// [`TimerHandle`] fires (RFC 015). Kept distinct from
/// [`Info`](Self::Info) — `Info` is *external* out-of-band traffic, `Timer`
/// is the server's *own* fires — so the system/userspace split stays
/// legible and a tagged timer round-trips
/// (`arm_after(d, Tk::Retry(n))` → `handle_timer(Tk::Retry(n))`). Use `()`
/// if unused, exactly as `Info` does.
/// Payload type for timers armed via [`TimerHandle`], delivered to
/// [`handle_timer`](Self::handle_timer). Kept separate from [`Info`](Self::Info)
/// so that timer fires (which your server schedules itself) stay distinct
/// from external messages (which arrive from outside). Use `()` if unused.
type Timer: Send + 'static;
/// Runs once inside the server actor before any message is handled. The
/// [`GenServerCtx`] is the loop's one runtime hook: clone its [`Watcher`]
/// into the state here to be able to [`watch`](Watcher::watch) monitors
/// from any later handler.
/// [`GenServerCtx`] is the loop's one runtime hook: clone whichever of
/// [`ctx.watcher()`](GenServerCtx::watcher) (monitors) and
/// [`ctx.timer()`](GenServerCtx::timer) (timers) you will need from later
/// handlers into the state here. If you need neither, the unused ctx drops
/// and the loop's system arm auto-closes (see module docs).
fn init(&mut self, _ctx: &GenServerCtx<Self>)
where
Self: Sized,
@@ -130,19 +239,19 @@ pub trait GenServer: Send + 'static {
/// [`Watcher::watch`]. Default: drop it.
fn handle_down(&mut self, _down: Down) {}
/// Handle a fired timer armed through the loop's [`TimerHandle`]
/// Handle a fired timer armed through [`TimerHandle`]
/// ([`arm_after`](TimerHandle::arm_after) /
/// [`tick_every`](TimerHandle::tick_every)). Timer fires re-enter at system
/// priority — above infos and the inbox — so a heartbeat cannot be starved
/// by userspace traffic. Default: drop it (RFC 015 §6).
/// [`tick_every`](TimerHandle::tick_every)). Timer fires are delivered
/// before info and inbox messages, so a heartbeat cannot be starved by
/// application traffic. Default: drop the message.
fn handle_timer(&mut self, _msg: Self::Timer) {}
/// Handle a receive/idle timeout: fired when the loop has waited a full
/// idle window (set once via [`GenServerCtx::idle_after`]) with no message of
/// any kind dispatched. The window resets on every dispatched message and
/// re-arms after this fires (a steady idle detector); a server wanting
/// one-shot idle-shutdown simply requests its own stop here. Default: no-op
/// (RFC 015 §4.4, §6).
/// Handle a quiet-period notification: called when the server has gone the
/// full idle window (set via [`GenServerCtx::idle_after`] in `init`) without
/// dispatching any message. The window resets automatically after this
/// fires, so it acts as a steady idle detector. To shut down after one idle
/// period, call [`request_stop`](crate::scheduler::request_stop) here.
/// Default: no-op.
fn handle_idle(&mut self) {}
/// Runs as the server actor exits, on any exit path (see module docs).
@@ -150,7 +259,7 @@ pub trait GenServer: Send + 'static {
}
/// What travels the server's single inbox channel: a synchronous call (with a
/// reply sender) or an asynchronous cast.
/// reply sender) or an asynchronous cast. Private — callers use [`GenServerRef`].
enum Envelope<G: GenServer> {
Call(G::Call, Sender<G::Reply>),
Cast(G::Cast),
@@ -166,7 +275,10 @@ pub struct GenServerRef<G: GenServer> {
impl<G: GenServer> Clone for GenServerRef<G> {
fn clone(&self) -> Self {
GenServerRef { tx: self.tx.clone(), pid: self.pid }
GenServerRef {
tx: self.tx.clone(),
pid: self.pid,
}
}
}
@@ -182,11 +294,10 @@ pub enum CallError {
pub enum CallTimeoutError {
/// The server was already gone, or died before replying.
ServerDown,
/// The deadline passed before a reply arrived. The request stays in the
/// server's inbox: it will still be *handled*, but the reply is discarded
/// (the abandoned reply channel's receiver is dropped, so the server's
/// reply send fails harmlessly). Erlang behaves the same way; design
/// idempotent calls accordingly.
/// The deadline passed before a reply arrived. The request is still in the
/// server's inbox and will be handled — the reply is simply discarded
/// because the reply channel was dropped when the timeout fired. Design
/// calls that may time out to be idempotent, so a late reply causes no harm.
Timeout,
}
@@ -214,25 +325,17 @@ impl<G: GenServer> GenServerRef<G> {
reply_rx.recv().map_err(|_| CallError::ServerDown)
}
/// Bounded synchronous request-reply: like [`call`](Self::call), but
/// gives up after `timeout`, returning [`CallTimeoutError::Timeout`].
/// Like [`call`](Self::call), but gives up after `timeout` and returns
/// [`CallTimeoutError::Timeout`] if no reply arrives in time.
///
/// This `timeout` is a **client-side call deadline** — how long *this caller*
/// waits for a reply — and is wholly separate from the server-side idle /
/// receive timeout ([`GenServerCtx::idle_after`] →
/// [`GenServer::handle_idle`]), which measures quiet on the *server's* inbox.
/// Same word ("timeout"), two different axes (RFC 015 §7); neither touches
/// the other.
/// Note that "timeout" means two different things here and in
/// [`GenServerCtx::idle_after`]: this one is a *client-side call deadline* —
/// how long this particular caller waits. The idle timeout measures silence
/// on the *server's* inbox. They are completely independent.
///
/// The roadmap sketched this as monitor + wait-reply-or-Down + demonitor;
/// that machinery is unnecessary here because server death is already
/// observable on the reply channel itself — the reply sender is dropped
/// as the server's stack unwinds, closing the channel and waking the
/// parked caller (see the module docs). So a bounded call is exactly
/// [`Receiver::recv_timeout`] on the reply channel: `Ok` on a reply,
/// `Disconnected` → [`CallTimeoutError::ServerDown`], `Timeout` →
/// [`CallTimeoutError::Timeout`]. Nothing is registered, so nothing can
/// leak on the timeout path by construction.
/// On timeout, the server still handles the request; only the reply is
/// discarded (the reply channel is dropped, so the server's send fails
/// silently). Design timed-out calls to be idempotent.
pub fn call_timeout(
&self,
request: G::Call,
@@ -257,18 +360,17 @@ impl<G: GenServer> GenServerRef<G> {
.map_err(|_| CastError::ServerDown)
}
/// Ask the server to terminate and block until it has — the `sys`-style
/// stop (cf. Erlang's `gen_server:stop/1`). Sends a cooperative stop to the
/// server actor and waits for its `Down`, so [`GenServer::terminate`] has
/// run by the time this returns (it fires from the loop's drop guard on the
/// stop unwind). Returns immediately if the server was already gone.
/// Stop the server and block until it has fully exited.
///
/// This is the explicit teardown for a server pinned alive by a registered
/// [`GenServerName`] (whose stored sender means dropping every external
/// [`GenServerRef`] no longer closes the inbox). Best-effort like all
/// cooperative cancellation: a server wedged in a tight loop with no
/// observation point cannot be stopped. Panics if called outside
/// `Runtime::run()`.
/// Sends a cooperative stop signal to the server actor and waits for it to
/// exit, so [`GenServer::terminate`] has run by the time this returns.
/// Returns immediately if the server is already gone.
///
/// This is the right teardown for a server kept alive by a registered
/// [`GenServerName`], where dropping every external `GenServerRef` is not enough
/// to close the inbox. Like all cooperative cancellation, it is best-effort:
/// a server wedged in a tight loop with no observation point cannot be
/// stopped this way. Panics if called outside `Runtime::run()`.
pub fn shutdown(&self) {
let mon = monitor(self.pid);
request_stop(self.pid);
@@ -279,29 +381,28 @@ impl<G: GenServer> GenServerRef<G> {
}
}
/// The server loop's internal *system intake* channel (RFC 015 §4.5). Control
/// (monitor handoff) and armed-timer fires are structurally the same — both are
/// loop-internal sources selected above the inbox — so they fold into one
/// channel, dispatched by variant. The arm stays open while any [`Watcher`] or
/// [`TimerHandle`] clone (or an in-flight fire thunk) still holds a sender.
/// Internal channel carrying control messages to the server loop. Monitors
/// and timer fires both need to land above the inbox in priority, so they
/// share one channel dispatched by variant. The arm stays open while any
/// [`Watcher`] or [`TimerHandle`] clone (or an in-flight timer fire) holds a sender.
enum Sys<G: GenServer> {
/// A monitor handed in via [`Watcher::watch`]; pushed onto the loop's
/// monitor set.
Watch(Monitor),
/// A fired one-shot timer carrying its loop-local id (so the loop can retire
/// it from the live set) and the server's payload, dispatched to
/// [`GenServer::handle_timer`].
/// A fired one-shot timer: loop-local id (so the loop can retire the
/// registry entry) plus the server's payload.
Timer(crate::timer::TimerId, G::Timer),
/// A fired periodic tick carrying its stable local id. The loop resolves the
/// payload from the registry's factory, dispatches it to
/// [`GenServer::handle_timer`], and re-arms the next tick (RFC 015 §4.3).
/// A fired periodic tick carrying its stable local id. The loop looks up
/// the payload factory, dispatches it to [`GenServer::handle_timer`], and
/// re-arms the next tick before returning.
Tick(crate::timer::TimerId),
}
/// The server loop's runtime hook, passed to [`GenServer::init`]. Hands out the
/// loop's two clonable intake handles — the [`Watcher`] (monitors) and the
/// [`TimerHandle`] (timers) — plus the one-shot idle-window setter. Opaque, so
/// fields can grow without breaking.
/// [`TimerHandle`] (timers) — plus the one-shot idle-window setter. All fields
/// are private so the struct can gain new hooks in future without breaking
/// existing implementations.
pub struct GenServerCtx<G: GenServer> {
sys_tx: Sender<Sys<G>>,
reg: Arc<Mutex<TimerReg<G>>>,
@@ -316,7 +417,9 @@ impl<G: GenServer> GenServerCtx<G> {
/// A clonable handle to the loop's monitor intake. Store it in the state
/// during `init` to watch monitors from later handlers.
pub fn watcher(&self) -> Watcher<G> {
Watcher { tx: self.sys_tx.clone() }
Watcher {
tx: self.sys_tx.clone(),
}
}
/// Shorthand for `ctx.watcher().watch(m)` when watching during `init`.
@@ -330,30 +433,31 @@ impl<G: GenServer> GenServerCtx<G> {
/// [`tick_every`](TimerHandle::tick_every) /
/// [`cancel`](TimerHandle::cancel) from any later handler.
pub fn timer(&self) -> TimerHandle<G> {
TimerHandle { sys_tx: self.sys_tx.clone(), reg: self.reg.clone() }
TimerHandle {
sys_tx: self.sys_tx.clone(),
reg: self.reg.clone(),
}
}
/// Set the idle / receive-timeout window: if no message of any kind is
/// dispatched for `after`, the loop fires [`GenServer::handle_idle`]
/// (RFC 015 §4.4). Set once, in `init`; the window is loop-owned and fixed
/// (dynamic per-message reconfiguration is a non-goal). The window resets on
/// every dispatched message and re-arms after `handle_idle` (a steady idle
/// detector); a server wanting one-shot idle-shutdown requests its own stop
/// in `handle_idle`.
/// Set a quiet-period window: if the loop goes `after` without dispatching
/// any message, it calls [`GenServer::handle_idle`] and resets the window.
/// Call this once during `init`; the window is fixed for the server's
/// lifetime.
///
/// Distinct from [`GenServerRef::call_timeout`], which is a *client-side* call
/// deadline — same word, different axis (§7).
/// This is distinct from [`GenServerRef::call_timeout`], which caps how long a
/// single caller waits for a reply. This one measures silence on the whole
/// inbox.
pub fn idle_after(&self, after: Duration) {
self.idle.set(Some(after));
}
}
/// Per-server timer bookkeeping, shared between the loop and every
/// [`TimerHandle`] clone. A gen_server actor is single-threaded — handle calls
/// (made from inside handlers) and the loop never run concurrently — so this
/// `Mutex` is always uncontended; it is `Arc<Mutex>` rather than `Rc<RefCell>`
/// only because the handle is stored on `self` and `GenServer: Send` forces the
/// handle (hence its shared state) to be `Send`.
/// [`TimerHandle`] clone. A gen_server actor is single-threaded — handlers
/// and the loop never run concurrently — so this `Mutex` is always
/// uncontended at runtime; `Arc<Mutex>` (rather than `Rc<RefCell>`) is used
/// only because `GenServer: Send` forces the handle (hence this shared state)
/// to be `Send`.
struct TimerReg<G: GenServer> {
/// Monotonic minter for loop-local [`TimerId`]s — the ids handed to users,
/// kept distinct from the substrate `seq` (which changes on every periodic
@@ -379,15 +483,14 @@ struct TimerReg<G: GenServer> {
/// One periodic timer's loop-side bookkeeping.
struct Periodic<G: GenServer> {
/// Re-arm interval; each fire schedules the next at `now + every` (§4.3).
/// Re-arm interval; each fire schedules the next at `now + every`.
every: Duration,
/// The currently-armed substrate timer for this periodic (changes on every
/// The currently-armed underlying timer for this periodic (changes on every
/// re-arm); cancelled by [`cancel`](TimerHandle::cancel).
live: crate::timer::TimerId,
/// Produces a fresh payload for each tick. Built in `tick_every` as
/// `move || msg.clone()` — so the `Clone` bound lives there, on the one
/// method that needs it, and never leaks onto `type Timer` or the loop
/// (which is monomorphised per server and only *calls* this box).
/// `move || msg.clone()` — so the `Clone` bound lives on that one method
/// and never appears on `type Timer` or anywhere else in the loop.
make: Box<dyn FnMut() -> G::Timer + Send>,
}
@@ -413,11 +516,10 @@ impl<G: GenServer> TimerReg<G> {
}
}
/// Arms, cancels, and (chunk 4) periodically ticks server timers, the time-side
/// twin of [`Watcher`] (RFC 015 §4.3). Handed out by [`GenServerCtx::timer`] in
/// `init` and stored on the state; later handlers arm timers without any change
/// to their signatures. Clonable; the arm stays open while any clone (or an
/// in-flight fire) lives.
/// Arms and cancels timers for a server loop, the time-side twin of [`Watcher`].
/// Handed out by [`GenServerCtx::timer`] in `init`; store it on the state so
/// handlers can arm timers without changing their signatures. Clonable; the
/// loop's timer arm stays open while any clone (or an in-flight fire) lives.
pub struct TimerHandle<G: GenServer> {
sys_tx: Sender<Sys<G>>,
reg: Arc<Mutex<TimerReg<G>>>,
@@ -426,18 +528,23 @@ pub struct TimerHandle<G: GenServer> {
// Manual Clone for the same reason as `Watcher`: no `G: Clone` needed.
impl<G: GenServer> Clone for TimerHandle<G> {
fn clone(&self) -> Self {
TimerHandle { sys_tx: self.sys_tx.clone(), reg: self.reg.clone() }
TimerHandle {
sys_tx: self.sys_tx.clone(),
reg: self.reg.clone(),
}
}
}
impl<G: GenServer> TimerHandle<G> {
/// Arm a one-shot timer: deliver `msg` to [`GenServer::handle_timer`] after
/// `after`, unless [`cancel`](Self::cancel)led first. Returns a loop-local
/// [`TimerId`] for cancellation. A thin wrapper over the `send_after`
/// substrate that lands the fire on the loop's own system arm (above infos
/// and the inbox), not in the inbox.
/// Arm a one-shot timer: deliver `msg` to [`GenServer::handle_timer`] once,
/// after `after`, unless [`cancel`](Self::cancel)led first. Returns a
/// [`TimerId`] you can pass to `cancel`. Timer fires arrive before info and
/// inbox messages.
pub fn arm_after(&self, after: Duration, msg: G::Timer) -> TimerId {
let mut reg = self.reg.lock().unwrap();
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
};
let local = reg.mint();
// The fire thunk carries the local id so the loop can retire the entry
// on dispatch; `msg` is moved in (no `Clone` needed for one-shots).
@@ -448,20 +555,23 @@ impl<G: GenServer> TimerHandle<G> {
/// Arm a periodic timer: deliver a fresh `msg` to
/// [`GenServer::handle_timer`] every `every`, until
/// [`cancel`](Self::cancel)led. Loop-managed sugar over the one-shot
/// substrate (RFC 015 §4.3): the substrate stays one-shot; the loop re-arms
/// at `now + every` after each fire and exposes one stable [`TimerId`], so a
/// single `cancel` stops the re-arm *and* the pending instance.
/// [`cancel`](Self::cancel)led. The underlying timer is one-shot; the loop
/// re-arms it after each fire to produce the repeating cadence, and exposes
/// a single stable [`TimerId`] so one `cancel` stops both the pending
/// instance and all future ones.
///
/// Requires `Self::Timer: Clone` — a periodic re-delivers the same logical
/// message each tick, so the loop needs a fresh copy per period. The bound
/// sits on this method alone; one-shot [`arm_after`](Self::arm_after) and
/// the `type Timer` declaration stay unconstrained.
/// Requires `G::Timer: Clone` because the same logical message is cloned
/// fresh for each tick. This bound is on this method only; one-shot
/// [`arm_after`](Self::arm_after) and the `type Timer` declaration are
/// unconstrained.
pub fn tick_every(&self, every: Duration, msg: G::Timer) -> TimerId
where
G::Timer: Clone,
{
let mut reg = self.reg.lock().unwrap();
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
};
let local = reg.mint();
// A live periodic must keep the system arm open so the next tick can be
// re-armed; the first periodic installs the loop's re-arm sender.
@@ -472,18 +582,34 @@ impl<G: GenServer> TimerHandle<G> {
// First instance fires after `every`; the payload is produced loop-side
// from `make` on fire, so the tick carries only the stable id.
let sub = send_after_to(every, self.sys_tx.clone(), Sys::Tick(local));
reg.periodics.insert(local, Periodic { every, live: sub, make });
reg.periodics.insert(
local,
Periodic {
every,
live: sub,
make,
},
);
debug_assert!(
reg.rearm_tx.is_some(),
"rearm_tx must be Some while periodics is non-empty"
);
local
}
/// Cancel an armed timer (one-shot or periodic). Returns the substrate's
/// race signal — `true` if the cancel beat the (pending) fire, `false` if
/// it had already fired / been cancelled / is unknown. For a periodic this
/// also stops the re-arm: the entry is dropped, so the loop will not
/// schedule another tick even if the pending one already escaped onto the
/// channel (that in-flight tick is discarded on dispatch).
/// race signal — `true` if the cancel beat the fire, `false` if the timer
/// had already fired, been cancelled, or is otherwise unknown. Most callers
/// can ignore the return value: a fired-but-not-yet-dispatched one-shot is
/// discarded by the loop on delivery (see the `Tick` arm in `server_loop`),
/// and a cancelled periodic will not re-arm. For a periodic, `cancel` also
/// stops re-arming: the entry is removed so the loop will not schedule
/// another tick even if the pending one already escaped onto the channel.
pub fn cancel(&self, id: TimerId) -> bool {
let mut reg = self.reg.lock().unwrap();
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
};
if let Some(sub) = reg.oneshots.remove(&id) {
return cancel_timer(sub);
}
@@ -492,6 +618,10 @@ impl<G: GenServer> TimerHandle<G> {
if reg.periodics.is_empty() {
reg.rearm_tx = None;
}
debug_assert!(
reg.rearm_tx.is_some() != reg.periodics.is_empty(),
"rearm_tx must be Some iff periodics is non-empty"
);
return beat;
}
false
@@ -510,7 +640,9 @@ pub struct Watcher<G: GenServer> {
// regardless of the server type (it clones only the inner sender).
impl<G: GenServer> Clone for Watcher<G> {
fn clone(&self) -> Self {
Watcher { tx: self.tx.clone() }
Watcher {
tx: self.tx.clone(),
}
}
}
@@ -536,11 +668,17 @@ pub struct GenServerBuilder<G: GenServer> {
state: G,
infos: Vec<Receiver<G::Info>>,
supervisor: Option<Pid>,
stack_opts: crate::scheduler::SpawnOpts,
}
impl<G: GenServer> GenServerBuilder<G> {
pub fn new(state: G) -> Self {
GenServerBuilder { state, infos: Vec::new(), supervisor: None }
GenServerBuilder {
state,
infos: Vec::new(),
supervisor: None,
stack_opts: crate::scheduler::SpawnOpts::default(),
}
}
/// Add an out-of-band channel; messages arriving on it are dispatched to
@@ -558,6 +696,14 @@ impl<G: GenServer> GenServerBuilder<G> {
self
}
/// Stack shape for the server actor (RFC 019) — see
/// [`SpawnOpts`](crate::SpawnOpts). Useful for servers that recurse
/// deeply or call into FFI with large C frames.
pub fn stack_opts(mut self, opts: crate::scheduler::SpawnOpts) -> Self {
self.stack_opts = opts;
self
}
/// Spawn the server actor and hand back its [`GenServerRef`]. The server's
/// lifetime is governed by its refs, not by joining, so the backing join
/// handle is dropped.
@@ -570,37 +716,53 @@ impl<G: GenServer> GenServerBuilder<G> {
/// live server). Consumes the builder, carrying its `with_info` / `under`
/// configuration through.
pub fn named(self, name: GenServerName<G>) -> NamedGenServerBuilder<G> {
NamedGenServerBuilder { builder: self, name: name.as_str() }
NamedGenServerBuilder {
builder: self,
name: name.as_str(),
}
}
/// The shared spawn body behind [`start`](Self::start) and
/// [`NamedGenServerBuilder::start`]: make the inbox, spawn the loop, return the
/// ref. (The named path additionally publishes the inbox sender under the
/// name before returning.)
/// Private shared body behind [`start`](Self::start) and
/// [`NamedGenServerBuilder::start`]: allocate the inbox, spawn the loop,
/// return the ref. The named path additionally publishes the inbox sender
/// under the name before returning.
fn spawn_server(self) -> GenServerRef<G> {
let (tx, rx) = channel::<Envelope<G>>();
let GenServerBuilder { state, infos, supervisor } = self;
let GenServerBuilder {
state,
infos,
supervisor,
stack_opts,
} = self;
let handle = match supervisor {
Some(sup) => spawn_under(sup, move || server_loop::<G>(rx, state, infos)),
None => spawn(move || server_loop::<G>(rx, state, infos)),
Some(sup) => crate::scheduler::spawn_under_with(sup, stack_opts, move || {
server_loop::<G>(rx, state, infos)
}),
None => {
crate::scheduler::spawn_with(stack_opts, move || server_loop::<G>(rx, state, infos))
}
};
GenServerRef { tx, pid: handle.pid() }
GenServerRef {
tx,
pid: handle.pid(),
}
}
}
/// A durable name typed by the *server* (RFC 014): a gen_server is multi-message
/// (call / cast over one inbox), so it is addressed by `G` rather than by a
/// single message type. By-name [`call`] / [`cast`] check the request against
/// `G`'s `Call` / `Cast` / `Reply`. Declared as a constant and shared freely:
/// A typed, static name for a gen_server, used to address it through the
/// registry without holding a [`GenServerRef`]. Because a gen_server handles
/// multiple message types, the name is typed by the whole server (`G`) rather
/// than by a single message type. Declare it as a constant:
///
/// ```ignore
/// const COUNTER: GenServerName<Counter> = GenServerName::new("counter");
/// ```
///
/// Under the hood the server's inbox is published into the registry as a
/// `Sender<Envelope<G>>` keyed by its message `TypeId` — the same channel store
/// every other name uses — so naming needs no separate directory. `Envelope`
/// stays private: only `GenServerName<G>` opens that door.
/// Use [`GenServerBuilder::named`] to bind the name on start, and the free
/// functions [`call`], [`cast`], and [`whereis_server`] to address the server
/// by name. Under the hood the server's inbox sender is stored in the registry
/// keyed by name and message `TypeId`; `Envelope` is private, so only
/// `GenServerName<G>` can open that slot.
pub struct GenServerName<G> {
name: &'static str,
_marker: PhantomData<fn() -> G>,
@@ -611,7 +773,10 @@ impl<G> GenServerName<G> {
/// associated constants at call sites.
#[inline]
pub const fn new(name: &'static str) -> Self {
Self { name, _marker: PhantomData }
Self {
name,
_marker: PhantomData,
}
}
/// The underlying registry key.
@@ -650,6 +815,12 @@ impl<G: GenServer> NamedGenServerBuilder<G> {
self
}
/// Stack shape for the server actor (see [`GenServerBuilder::stack_opts`]).
pub fn stack_opts(mut self, opts: crate::scheduler::SpawnOpts) -> Self {
self.builder = self.builder.stack_opts(opts);
self
}
/// Spawn the server and bind its name in one step. Fallible: returns
/// [`RegisterError::NameTaken`] if the name is already held by a different
/// live server.
@@ -732,17 +903,25 @@ fn server_loop<G: GenServer>(
state: G,
mut infos: Vec<Receiver<G::Info>>,
) {
// terminate() must run on every exit path (clean close, panic, stop), so it
// lives in this guard's Drop rather than after the loop. The guard also owns
// a registry handle so the *same* drop drains every live timer (RFC 015
// §4.7): once the loop is gone no periodic can re-arm anyway, but cancelling
// here keeps no armed substrate entry lingering past the server, and the
// assert pins the invariant.
// Drop guard — owns the server state and the timer registry.
//
// Why a guard rather than code after the loop:
// - `terminate()` must fire on *every* exit path: clean inbox close,
// a handler panic, and cooperative `request_stop`. A guard's `Drop`
// covers all three; code after the loop only covers the clean path.
// - The timer drain must run *before* `terminate()`: terminate may
// inspect state but must not arm new timers into a dead loop. By
// owning the registry here the drain and the terminate call are
// sequenced correctly and can never be reordered.
// - A debug_assert pins the post-drain invariant cheaply.
struct Terminate<G: GenServer>(G, Arc<Mutex<TimerReg<G>>>);
impl<G: GenServer> Drop for Terminate<G> {
fn drop(&mut self) {
{
let mut reg = self.1.lock().unwrap();
let mut reg = match self.1.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
};
for (_, sub) in reg.oneshots.drain() {
cancel_timer(sub);
}
@@ -761,6 +940,9 @@ fn server_loop<G: GenServer>(
}
}
// Unwrap an envelope and hand it to the right handler. For calls, a failed
// reply send means the caller went away (e.g. cancelled while parked) —
// that is the caller's problem, not the server's.
fn dispatch<G: GenServer>(state: &mut G, env: Envelope<G>) {
match env {
Envelope::Call(request, reply_tx) => {
@@ -788,7 +970,11 @@ fn server_loop<G: GenServer>(
// Bind the ctx so the idle window set during init can be read back, then
// drop it — that drops the loop's own Sys sender, so a state that cloned no
// Watcher/TimerHandle lets the arm auto-close (the unused-ctx behaviour).
let ctx = GenServerCtx { sys_tx, reg: reg.clone(), idle: Cell::new(None) };
let ctx = GenServerCtx {
sys_tx,
reg: reg.clone(),
idle: Cell::new(None),
};
guard.0.init(&ctx);
let idle = ctx.idle.get();
drop(ctx);
@@ -807,8 +993,9 @@ fn server_loop<G: GenServer>(
loop {
if monitors.is_empty() && !sys_open && infos.is_empty() {
// Nothing to select over: park on the inbox alone (the pre-v0.8
// loop), with the idle window as the recv timeout when one is set.
// Fast path: no extra arms, no select overhead — park directly on
// the inbox. Mirrors the inbox arm of the select path below; any
// change there must be applied here too.
match idle_deadline {
Some(dl) => {
let wait = dl.saturating_duration_since(Instant::now());
@@ -832,15 +1019,17 @@ fn server_loop<G: GenServer>(
},
}
} else {
// Arm priority: downs, then the system arm, then infos (each in
// declaration order), then the inbox. The arm slice is rebuilt
// per iteration because every set but the inbox shrinks or grows.
// The whole wait carries the idle window as its timeout.
let nd = monitors.len();
let nw = sys_open as usize;
// Slow path: one or more extra arms live — build the arm slice and
// select. Arm order encodes priority: downs → system → infos →
// inbox. The slice is rebuilt each iteration because the monitor
// and info sets shrink/grow. Mirrors the fast-path inbox park
// above; keep them in sync.
let nd = monitors.len(); // monitor band: [0, nd)
let nw = sys_open as usize; // system arm: [nd, nd+nw)
// info band: [nd+nw, nd+nw+ni)
// inbox arm: [nd+nw+ni]
let sel = {
let mut arms: Vec<&dyn Selectable> =
Vec::with_capacity(nd + nw + infos.len() + 1);
let mut arms: Vec<&dyn Selectable> = Vec::with_capacity(nd + nw + infos.len() + 1);
for m in &monitors {
arms.push(&m.rx);
}
@@ -852,9 +1041,7 @@ fn server_loop<G: GenServer>(
}
arms.push(&rx);
match idle_deadline {
Some(dl) => {
select_timeout(&arms, dl.saturating_duration_since(Instant::now()))
}
Some(dl) => select_timeout(&arms, dl.saturating_duration_since(Instant::now())),
None => Some(select(&arms)),
}
};
@@ -869,8 +1056,8 @@ fn server_loop<G: GenServer>(
}
};
if i < nd {
// A Down retires its arm either way: delivered (one-shot) or
// closed without delivering (defensive; shouldn't happen).
// Monitor band: a Down retires its arm either way (one-shot)
// or closes without delivering (defensive; shouldn't happen).
let m = monitors.remove(i);
if let Ok(Some(down)) = m.rx.try_recv() {
guard.0.handle_down(down);
@@ -884,7 +1071,14 @@ fn server_loop<G: GenServer>(
// The one-shot fired: retire its registry entry so the
// live set tracks only still-pending timers, then
// dispatch.
reg.lock().unwrap().oneshots.remove(&id);
match reg.lock() {
Ok(mut g) => {
g.oneshots.remove(&id);
}
Err(e) => {
panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}")
}
}
guard.0.handle_timer(msg);
reset_idle(&mut idle_deadline);
}
@@ -895,16 +1089,22 @@ fn server_loop<G: GenServer>(
// so the period is measured from fire-handling and a
// handler that cancels stops the just-armed instance.
let msg = {
let mut g = reg.lock().unwrap();
let mut g = match reg.lock() {
Ok(g) => g,
Err(e) => panic!(
"smarm: gen_server reg lock poisoned (core corrupt): {e}"
),
};
let r = &mut *g;
if let Some(p) = r.periodics.get_mut(&id) {
let every = p.every;
let msg = (p.make)();
let tx = r
.rearm_tx
.as_ref()
.expect("a live periodic keeps rearm_tx Some")
.clone();
let tx = match r.rearm_tx.as_ref() {
Some(tx) => tx.clone(),
None => {
panic!("smarm: live periodic without rearm_tx (logic bug)")
}
};
p.live = send_after_to(every, tx, Sys::Tick(id));
Some(msg)
} else {
@@ -924,6 +1124,7 @@ fn server_loop<G: GenServer>(
Err(_) => sys_open = false,
}
} else if i < nd + nw + infos.len() {
// Info band.
let j = i - nd - nw;
match infos[j].try_recv() {
Ok(Some(info)) => {
@@ -938,6 +1139,7 @@ fn server_loop<G: GenServer>(
}
}
} else {
// Inbox arm (mirrors the fast-path park above).
match rx.try_recv() {
Ok(Some(env)) => {
dispatch(&mut guard.0, env);
+639 -111
View File
@@ -14,13 +14,13 @@
//! [`on_start`](Machine::on_start), then one [`handle`](Machine::handle) per
//! inbox event — mirroring `gen_server`'s spawn/teardown idioms.
//!
//! The [`gen_statem!`](crate::gen_statem) macro is the authoring surface: it
//! The [`gen_statem!`](crate::gen_statem!) macro is the authoring surface: it
//! *generates* a `Machine` impl from per-state handler blocks. Edge-validity is
//! not a separate check — it falls out of the generated total `match (state,
//! event)` under denied lints (a forgotten or duplicated pair is a compile
//! error), so no proc-macro is needed. See `examples/gen_statem_macro.rs` for
//! the macro form and `examples/gen_statem_fused.rs` for the hand-written shape
//! it expands to.
//! the macro form and `examples/gen_statem_expanded.rs` for the hand-written
//! shape it expands to.
//!
//! ## The unified event
//!
@@ -38,19 +38,51 @@
//! `gen_server` call round-trip, but with the reply handle riding *inside* the
//! user's own event so a handler can answer it.
//!
//! [`Resolution::Postpone`] and the timeout arming on [`Cx`] are part of the
//! type surface but are not yet wired up.
//! ## Timeouts
//!
//! Two timeout flavours, both armed from a handler through [`Cx`] and both
//! surfacing back as ordinary **events** the machine matches in its `on State`
//! arms — unlike `gen_server`, which routes fires to a separate handler:
//!
//! - [`cx.state_timeout(d)`](Cx::state_timeout) fires a `state_timeout` event
//! after `d` *in the current state*, and is auto-reset on any state change.
//! Only one is ever pending; arming again replaces it.
//! - [`cx.timeout(name, d)`](Cx::timeout) fires a `timeout(name)` event after
//! `d`, **survives** state changes, and is keyed by `name` so several can be
//! in flight and each is independently [`cancel_timeout`](Cx::cancel_timeout)led.
//!
//! Both ride the same timer min-heap as `gen_server` (no separate timer
//! mechanism): a fire lands on the loop's own system channel — above the inbox,
//! so a timeout can't be starved by inbox traffic — and the loop turns it into
//! the corresponding internal event and runs it through the normal `handle`
//! dispatch.
//!
//! ## Postpone
//!
//! A `=> postpone` row defers the current event untouched, to be replayed after
//! the next **real transition**. The deferred event — a `cast`, `call`, or
//! `info` — moves whole onto a loop-side FIFO queue; a postponed `call` keeps
//! its [`Reply`] handle and is answered by whichever later state handles the
//! replay. On a transition the queue drains in order through the normal
//! [`handle`](Machine::handle) dispatch in the new state, ahead of any further
//! inbox or timer event; a replayed event may postpone again (it re-queues for
//! the next transition). See the macro docs for the row surface and [`Step`]
//! for how a postpone surfaces to the loop.
use crate::channel::{channel, Receiver, Sender};
use crate::channel::{channel, select, Receiver, Sender};
use crate::pid::Pid;
use crate::scheduler::spawn as spawn_actor;
use crate::scheduler::{cancel_timer, send_after_to};
use crate::timer::TimerId;
use std::collections::{HashMap, VecDeque};
use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
use std::time::Duration;
// ---------------------------------------------------------------------------
// Machine
// ---------------------------------------------------------------------------
/// A finite state machine driven by the [`statem`](crate::gen_statem) loop.
/// A finite state machine driven by the [`statem`](mod@crate::gen_statem) loop.
///
/// The implementor owns its current state tag and its persistent data. It is
/// the **sole writer** of the state cell (the loop never touches it): both
@@ -59,28 +91,45 @@ use std::marker::PhantomData;
/// [`handle`](Self::handle) per event.
pub trait Machine: Send + 'static {
/// The single payload this machine's inbox carries — the user's `cast` and
/// `call` enums folded together with the runtime's internal events. See the
/// module docs.
/// `call` enums folded together with the runtime's internal events
/// (`state_timeout`, `timeout(name)`, and an `Info` wrapper). See the module
/// docs.
type Ev: Send + 'static;
/// Wrap a fired state-timeout into this machine's event. The loop calls this
/// when the pending state-timeout fires, then runs the result through
/// [`handle`](Self::handle) like any other event. The macro generates it as
/// `Ev::StateTimeout`.
fn state_timeout_ev() -> Self::Ev;
/// Wrap a fired named timeout into this machine's event, carrying the name
/// it was armed under. The macro generates it as `Ev::Timeout(name)`.
fn timeout_ev(name: &'static str) -> Self::Ev;
/// Runs once inside the actor before the first event. The canonical body
/// runs the `enter` arm for the initial state.
fn on_start(&mut self, cx: &mut Cx<Self::Ev>);
/// React to one event. The body matches `(state, event)`, performs side
/// effects / replies, and ends each arm in a [`Resolution`] — typically a
/// state tag via `Tag.into()` (transition, or "stay" when it equals the
/// current tag). On a transition the body sets the state cell and runs the
/// new state's `enter`.
fn handle(&mut self, ev: Self::Ev, cx: &mut Cx<Self::Ev>);
/// React to one event, returning a [`Step`] the loop acts on. The body
/// first routes a `postpone` row (handing the event back untouched as
/// [`Step::Postponed`]); otherwise it matches `(state, event)`, performs
/// side effects / replies, and ends each arm in a [`Resolution`] — typically
/// a state tag via `Tag.into()` (transition, or "stay" when it equals the
/// current tag). On a transition the body sets the state cell, runs the new
/// state's `enter`, and returns [`Step::Transitioned`]; a stay or unmatched
/// event returns [`Step::Stayed`].
fn handle(&mut self, ev: Self::Ev, cx: &mut Cx<Self::Ev>) -> Step<Self::Ev>;
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/// The outcome of handling one event, before the loop/handler acts on it:
/// dispatch reduces to `(state, event) -> Resolution<State>`.
/// The outcome of the **consuming** dispatch — the by-value `match (state,
/// event)` — before the apply-tail acts on it: `(state, event) ->
/// Resolution<State>`. Postpone is *not* here: a deferred event never reaches
/// this match (it is routed out first; see [`Step`] and the macro's two-phase
/// `handle`), so the only outcomes are a target state or "unhandled".
///
/// [`From<S>`](From) is why a bare state tag works as an arm tail and why
/// "stay" needs no keyword — `Tag.into()` is `To(Tag)`, and the handler treats
@@ -89,10 +138,6 @@ pub enum Resolution<S> {
/// End in state `s`. A **transition** when `s != current` (set the cell,
/// run `enter`); a **stay** when `s == current` (no `enter`).
To(S),
/// Defer the current event onto the postpone queue, to be replayed after
/// the next real transition. Produced by `cx.postpone()`; present
/// here for forward-compatibility but not yet generated.
Postpone,
/// No arm matched `(state, event)`: log-and-drop via
/// [`Cx::on_unhandled`], mirroring `gen_server`'s handling of unexpected
/// messages.
@@ -105,31 +150,195 @@ impl<S> From<S> for Resolution<S> {
}
}
/// What one [`handle`](Machine::handle) call did, as the loop needs to see it.
/// Unlike [`Resolution`] (internal to the consuming match), this is the
/// loop-visible result, because the loop must know two things the match alone
/// doesn't surface: whether an event was **deferred** (so the loop owns it for
/// the postpone queue) and whether a **real transition** happened (so the loop
/// replays that queue). The three cases are mutually exclusive.
pub enum Step<Ev> {
/// The event was deferred by a `postpone` row and handed back untouched.
/// The loop pushes it onto the postpone queue; no state change occurred.
Postponed(Ev),
/// A real transition happened (`enter` for the new state has already run).
/// The loop replays the postpone queue in the new state.
Transitioned,
/// Handled with no transition — a stay or an unmatched event. Nothing is
/// deferred and the postpone queue is left as-is.
Stayed,
}
// ---------------------------------------------------------------------------
// Cx — the per-handler context
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Sys — the loop's internal timer-fire channel
// ---------------------------------------------------------------------------
/// A timer fire landing on the loop's own system channel, selected above the
/// inbox so a timeout cannot be starved by inbox traffic. Each fire carries the
/// local id it was armed under so the loop can confirm it is still the live one
/// (a fire that a reset/cancel beat onto the channel is discarded).
enum Sys {
/// The state-timeout fired, carrying the local id it was armed under so the
/// loop can drop a stale fire (one a reset/cancel beat onto the channel).
StateTimeout(u64),
/// A named timeout fired, carrying its name and the local id it was armed
/// under.
Timeout(&'static str, u64),
}
// ---------------------------------------------------------------------------
// Timers — shared timer bookkeeping
// ---------------------------------------------------------------------------
/// Per-machine timer bookkeeping, shared between the loop and every [`Cx`]
/// borrow (a machine is single-threaded — handler calls and the loop never run
/// concurrently — so this `Mutex` is always uncontended; it is `Arc<Mutex>`
/// rather than `Rc<RefCell>` only because `Machine: Send` forces it).
///
/// Each arming mints a fresh **local id** carried in the fire payload and
/// recorded here alongside the substrate id used to cancel. On fire the loop
/// compares the payload's local id against the recorded one: a fire whose id no
/// longer matches — a reset/cancel armed a newer one or removed the entry after
/// this fire already escaped onto the channel — is stale and dropped. The local
/// id is minted up front (unlike the substrate id, which only exists once armed),
/// so it can ride the payload with no chicken-and-egg.
struct Timers {
/// Monotonic minter for local ids, kept distinct from substrate ids (the
/// latter are what we hand to [`cancel_timer`]).
next_local: u64,
/// The currently-armed state-timeout's `(local id, substrate id)`. Cancelled
/// and cleared on every real state change (auto-reset), replaced on re-arm.
state: Option<(u64, TimerId)>,
/// Live named timeouts: name → `(local id, substrate id)`. Survives state
/// changes; an entry lives until it fires or is cancelled.
named: HashMap<&'static str, (u64, TimerId)>,
}
impl Timers {
fn new() -> Self {
Timers {
next_local: 0,
state: None,
named: HashMap::new(),
}
}
fn mint(&mut self) -> u64 {
let id = self.next_local;
self.next_local = self.next_local.wrapping_add(1);
id
}
}
// ---------------------------------------------------------------------------
// Cx — the per-handler context
// ---------------------------------------------------------------------------
/// The context handle injected into [`Machine::on_start`] and
/// [`Machine::handle`]. Non-state outcomes (postpone, timeout arming) live here
/// as method calls rather than keywords.
/// [`Machine::handle`]. Non-state outcomes — timeout arming, and later
/// postpone — live here as method calls rather than keywords. It lives only on
/// the actor's own stack and is never sent.
///
/// For now it carries only the [`on_unhandled`](Self::on_unhandled) hook;
/// `cx.state_timeout(d)` / `cx.timeout(name, d)` and `cx.postpone()` will attach
/// here when implemented. It lives only on the actor's own
/// stack and is never sent.
/// Timeouts armed here land on the loop's system channel through `sys_tx` and
/// are tracked in the shared `reg` so reset/cancel can find the pending one.
pub struct Cx<Ev> {
sys_tx: Sender<Sys>,
reg: Arc<Mutex<Timers>>,
_ev: PhantomData<fn() -> Ev>,
}
impl<Ev> Cx<Ev> {
fn new() -> Self {
Cx { _ev: PhantomData }
fn new(sys_tx: Sender<Sys>, reg: Arc<Mutex<Timers>>) -> Self {
Cx {
sys_tx,
reg,
_ev: PhantomData,
}
}
/// The default for an event no arm matched: **log-and-drop**. Overridable
/// hook wiring is a follow-on; for now an unmatched event is
/// silently dropped, as `gen_server` does with unexpected messages.
/// Arm the **state timeout**: fire a `state_timeout` event after `after` in
/// the current state. Auto-reset on any state change (the loop cancels and
/// clears it on every real transition), so it measures quiet time *within* a
/// state. Only one is ever pending — arming again cancels and replaces the
/// previous one.
pub fn state_timeout(&mut self, after: Duration) {
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
};
if let Some((_, old_sub)) = reg.state.take() {
cancel_timer(old_sub);
}
let local = reg.mint();
let sub = send_after_to(after, self.sys_tx.clone(), Sys::StateTimeout(local));
reg.state = Some((local, sub));
}
/// Arm a **named generic timeout**: fire a `timeout(name)` event after
/// `after`. Survives state changes; several may be in flight keyed by
/// `name`. Arming the same `name` again cancels and replaces the pending one
/// for that name.
pub fn timeout(&mut self, name: &'static str, after: Duration) {
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
};
if let Some((_, old_sub)) = reg.named.remove(name) {
cancel_timer(old_sub);
}
let local = reg.mint();
let sub = send_after_to(after, self.sys_tx.clone(), Sys::Timeout(name, local));
reg.named.insert(name, (local, sub));
}
/// Cancel the named timeout `name` if pending. Returns `true` if the cancel
/// beat the fire, `false` if it had already fired / was never armed.
pub fn cancel_timeout(&mut self, name: &'static str) -> bool {
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
};
match reg.named.remove(name) {
Some((_, sub)) => cancel_timer(sub),
None => false,
}
}
/// Cancel the pending state-timeout if any. Returns `true` if the cancel
/// beat the fire. Rarely needed by hand (the loop auto-resets on
/// transition); exposed for a handler that wants to disarm within a state.
pub fn cancel_state_timeout(&mut self) -> bool {
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
};
match reg.state.take() {
Some((_, sub)) => cancel_timer(sub),
None => false,
}
}
/// The default for an event no arm matched: **log-and-drop**, as
/// `gen_server` does with unexpected messages.
pub fn on_unhandled(&mut self) {}
/// Cancel and clear the pending state-timeout. Called by the macro on every
/// real transition (the state-timeout is auto-reset across state changes);
/// not part of the authoring surface. A handler re-arms in the new state's
/// `enter` if it wants one.
#[doc(hidden)]
pub fn __reset_state_timeout(&mut self) {
let mut reg = match self.reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
};
if let Some((_, sub)) = reg.state.take() {
cancel_timer(sub);
}
}
}
// ---------------------------------------------------------------------------
@@ -143,9 +352,8 @@ impl<Ev> Cx<Ev> {
/// machine dies, closing the channel).
///
/// Carrying the handle in the event — rather than the loop owning a reply slot
/// — is what lets a later chunk **postpone a call**: the whole event, reply
/// handle included, moves onto the postpone queue and is answered by a later
/// state.
/// — is what will let a **postponed call** work: the whole event, reply handle
/// included, moves onto the postpone queue and is answered by a later state.
pub struct Reply<T> {
tx: Sender<T>,
}
@@ -187,7 +395,10 @@ pub struct GenStatemRef<M: Machine> {
impl<M: Machine> Clone for GenStatemRef<M> {
fn clone(&self) -> Self {
GenStatemRef { tx: self.tx.clone(), pid: self.pid }
GenStatemRef {
tx: self.tx.clone(),
pid: self.pid,
}
}
}
@@ -211,9 +422,7 @@ impl<M: Machine> GenStatemRef<M> {
/// stack unwinds, closing the channel and waking the caller).
///
/// `make` wraps the [`Reply`] all the way to `M::Ev` — typically
/// `|r| Ev::Call(MyCall::GetCount(r))`. (The deferred macro would generate a
/// thinner `call` that hides the `Ev::Call` wrap and takes the bare variant
/// constructor.)
/// `|r| Ev::Call(MyCall::GetCount(r))`.
pub fn call<T, F>(&self, make: F) -> Result<T, CallError>
where
T: Send + 'static,
@@ -236,23 +445,106 @@ impl<M: Machine> GenStatemRef<M> {
///
/// Panics if called outside `Runtime::run()`.
pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
let (tx, rx) = channel::<M::Ev>();
let handle = spawn_actor(move || statem_loop(rx, machine));
GenStatemRef { tx, pid: handle.pid() }
spawn_with(crate::scheduler::SpawnOpts::default(), machine)
}
/// The machine actor body: `on_start`, then one `handle` per inbox event until
/// the inbox closes (all refs dropped → graceful shutdown). Chunk 1 parks on
/// the inbox alone — no system arm, no timers — the analogue of `gen_server`'s
/// plain-inbox park.
/// [`spawn`] with per-actor stack shape overrides (RFC 019) for the machine's
/// actor — see [`SpawnOpts`](crate::SpawnOpts). gen_statem has no builder
/// (its one-shot `spawn(machine)` shape predates RFC 019), so the opts ride
/// a `_with` variant like the scheduler's own spawns.
///
/// Panics if called outside `Runtime::run()`.
pub fn spawn_with<M: Machine>(opts: crate::scheduler::SpawnOpts, machine: M) -> GenStatemRef<M> {
let (tx, rx) = channel::<M::Ev>();
let handle = crate::scheduler::spawn_with(opts, move || statem_loop(rx, machine));
GenStatemRef {
tx,
pid: handle.pid(),
}
}
/// The machine actor body: `on_start`, then one `handle` per event until the
/// inbox closes (all refs dropped → graceful shutdown).
///
/// Two intake sources are selected each iteration with the **timer arm above
/// the inbox**, so a timeout fire is never starved by inbox traffic: `sys_rx`
/// carries timer fires armed through `cx`, `rx` is the user inbox. A fire is
/// turned into the matching internal event (`state_timeout` / `timeout(name)`)
/// and run through the same `handle` dispatch as an inbox event — the
/// gen_statem model, where timeouts surface as ordinary events.
///
/// The loop owns the **postpone queue**: a `handle` that defers its event hands
/// it back ([`Step::Postponed`]) for the queue; a `handle` that transitions
/// ([`Step::Transitioned`]) triggers a [`replay`] of the queue in the new
/// state, ahead of the next intake.
fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
let mut cx = Cx::new();
let (sys_tx, sys_rx) = channel::<Sys>();
let reg = Arc::new(Mutex::new(Timers::new()));
// The loop owns `cx` (and through it a `sys_tx` clone) for its whole life,
// so the sys arm never closes from under us — no auto-close dance needed.
let mut cx = Cx::new(sys_tx, reg.clone());
// Events deferred by `postpone` rows, replayed FIFO on the next transition.
let mut postpone: VecDeque<M::Ev> = VecDeque::new();
machine.on_start(&mut cx);
loop {
match rx.recv() {
Ok(ev) => machine.handle(ev, &mut cx),
// All StatemRefs dropped → inbox closed → shutdown.
Err(_) => break,
// Timer arm first: a ready fire is taken in preference to the inbox.
let i = select(&[&sys_rx, &rx]);
if i == 0 {
match sys_rx.try_recv() {
Ok(Some(fire)) => {
// Confirm the fire is still the live one before dispatching:
// a reset/cancel may have replaced/removed it after it
// escaped onto the channel. A stale fire is dropped.
let ev = match fire {
Sys::StateTimeout(local) => {
let mut t = match reg.lock() {
Ok(g) => g,
Err(e) => panic!(
"smarm: gen_statem reg lock poisoned (core corrupt): {e}"
),
};
match t.state {
Some((live, _)) if live == local => {
t.state = None; // retire: it has now fired
Some(M::state_timeout_ev())
}
_ => None,
}
}
Sys::Timeout(name, local) => {
let mut t = match reg.lock() {
Ok(g) => g,
Err(e) => panic!(
"smarm: gen_statem reg lock poisoned (core corrupt): {e}"
),
};
match t.named.get(name) {
Some(&(live, _)) if live == local => {
t.named.remove(name); // retire on fire
Some(M::timeout_ev(name))
}
_ => None,
}
}
};
if let Some(ev) = ev {
dispatch(&mut machine, &mut cx, &mut postpone, ev);
}
}
// Single-receiver: nothing can drain the arm between select's
// ready and our try_recv.
Ok(None) => debug_assert!(false, "ready system arm was empty"),
// The loop holds a sys_tx for its whole life, so this is
// unreachable; fall through defensively.
Err(_) => {}
}
} else {
match rx.try_recv() {
Ok(Some(ev)) => dispatch(&mut machine, &mut cx, &mut postpone, ev),
Ok(None) => debug_assert!(false, "ready inbox was empty"),
// All GenStatemRefs dropped → inbox closed → shutdown.
Err(_) => break,
}
}
// Observation point so a machine fed a hot inbox stays preemptible and
// cancellable.
@@ -260,15 +552,60 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
}
}
/// Run one event through `handle` and act on its [`Step`]: stash a deferred
/// event on the postpone queue, or — on a real transition — [`replay`] the
/// queue in the new state. A stay/unmatched event needs nothing further.
fn dispatch<M: Machine>(
machine: &mut M,
cx: &mut Cx<M::Ev>,
postpone: &mut VecDeque<M::Ev>,
ev: M::Ev,
) {
match machine.handle(ev, cx) {
Step::Postponed(ev) => postpone.push_back(ev),
Step::Stayed => {}
Step::Transitioned => replay(machine, cx, postpone),
}
}
/// Replay deferred events after a real transition: each goes back through
/// `handle` in FIFO order, in the now-current state. An event that postpones
/// again re-queues (to wait for the *next* transition); one that transitions
/// re-arms the replay, so a later state can in turn drain what is still pending.
/// Subsequent events in a batch already see the post-transition state, since
/// `handle` reads the live state cell — the outer loop only re-runs to give
/// re-queued events another pass once a transition has occurred within a batch.
fn replay<M: Machine>(machine: &mut M, cx: &mut Cx<M::Ev>, postpone: &mut VecDeque<M::Ev>) {
loop {
if postpone.is_empty() {
return;
}
// Take the current backlog; anything deferred during this pass lands in
// the now-empty queue and is only retried if this pass also transitioned.
let batch = std::mem::take(postpone);
let mut transitioned = false;
for ev in batch {
match machine.handle(ev, cx) {
Step::Postponed(ev) => postpone.push_back(ev),
Step::Stayed => {}
Step::Transitioned => transitioned = true,
}
}
if !transitioned {
return;
}
}
}
// ---------------------------------------------------------------------------
// gen_statem! — the authoring macro (fused total-match variant)
// gen_statem! — the authoring macro (total-match dispatch)
// ---------------------------------------------------------------------------
/// Assemble a complete [`Machine`] from hand-written types, a glanceable
/// transition table, and free handler functions.
///
/// This is the **fused** authoring surface (see `examples/statem_fused.rs` for
/// the same machine written out by hand). You keep ownership of every type that
/// This is the authoring surface (see `examples/gen_statem_expanded.rs` for the
/// same machine written out by hand). You keep ownership of every type that
/// carries meaning — the state enum, the data struct, the `cast`/`call` enums,
/// and any per-row successor enums — and the macro emits only the mechanical
/// scaffolding: the unified event enum, the machine struct and its private
@@ -308,12 +645,12 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// machine lives in the same crate as this macro, and is **silently dropped
/// when you call `gen_statem!` from a downstream crate** — neither a crate
/// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one
/// guarantee a `macro_rules!` cannot carry across the crate boundary; the
/// RFC's proc-macro could (it would stamp the arms with call-site spans).
/// guarantee a `macro_rules!` cannot carry across the crate boundary; a
/// proc-macro could (it would stamp the arms with call-site spans).
/// Guard against it in review, or with an in-crate test of the machine.
///
/// There is deliberately **no separate `transitions { … }` adjacency block**:
/// in this variant the `match` *is* the table and the successor enums *are* the
/// the `match` *is* the table and the successor enums *are* the
/// per-state target sets, so a second listing would be redundant and
/// un-cross-checkable without a proc-macro. The graph is read off the `on`
/// blocks and the successor enums at the top of the file.
@@ -323,14 +660,16 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// ```ignore
/// // ── your types (the macro never generates or inspects these) ───────────
/// #[derive(Clone, Copy, PartialEq, Eq, Debug)]
/// enum Switch { Off, On }
/// struct Counts { flips: u32, enters: u32 }
/// enum SwitchCast { Flip }
/// enum SwitchCall { GetCount(Reply<u32>) }
/// enum Door { Open, Closed, Locked }
/// struct Data { enters: u32 }
/// enum DoorCast { Push, Pull, Lock, Unlock(u32) }
/// enum DoorCall { GetState(Reply<Door>) }
///
/// gen_statem! {
/// machine: SwitchSm { state: Switch, data: Counts };
/// event: Ev { cast: SwitchCast, call: SwitchCall };
/// machine: DoorSm { state: Door, data: Data };
/// // The event clause folds three user enums together. `info` is for
/// // out-of-band messages; use `()` if you have none.
/// event: Ev { cast: DoorCast, call: DoorCall, info: () };
///
/// // Name the bindings your handler bodies use. A declarative macro can't
/// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers
@@ -339,26 +678,51 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// context(data, prev, cx);
///
/// // `enter` runs on entry to a state; side effects only, returns ().
/// // Match the state tag (or `_`); the arm body is statements.
/// // Match the state tag (or `_`); the arm body is statements. Arm any
/// // state-timeout here (it is auto-reset on the way into a new state).
/// enter {
/// Door::Open => {
/// data.enters += 1;
/// cx.state_timeout(std::time::Duration::from_secs(30)); // auto-close
/// }
/// _ => data.enters += 1,
/// }
///
/// // The transition table. Group rows by current state with `on <pat>`.
/// // A row is: cast|call <event-pattern> [if <guard>] => <tail> ,
/// // A row is: <kind> <event-pattern> [if <guard>] => <tail> ,
/// // where <kind> is one of `cast`, `call`, `info`, `state_timeout`
/// // (no pattern — it is a unit event), or `timeout <name-pattern>`,
/// // and the tail is one of:
/// // * a state tag `Switch::On` (transition, or "stay"
/// // if it equals current)
/// // * a block ending in one `{ data.flips += 1; Switch::On }`
/// // * a successor-enum value `unlock(key)` (branching row)
/// // * the keyword `unhandled` (explicit refusal)
/// on Switch::Off => {
/// cast SwitchCast::Flip => { data.flips += 1; Switch::On },
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
/// // * a state tag `Door::Closed` (transition, or "stay"
/// // if it equals current)
/// // * a block ending in one `{ data.enters += 1; Door::Closed }`
/// // * a successor-enum value `on_unlock(key)` (branching row)
/// // * the keyword `unhandled` (explicit refusal)
/// // * the keyword `postpone` (defer until next
/// // transition; cast/call/
/// // info only)
/// on Door::Open => {
/// cast DoorCast::Push => Door::Closed,
/// // An armed state-timeout surfaces as an ordinary event:
/// state_timeout => Door::Closed,
/// cast DoorCast::Pull | DoorCast::Lock | DoorCast::Unlock(_) => unhandled,
/// }
/// on Switch::On => {
/// cast SwitchCast::Flip => Switch::Off,
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
/// on Door::Closed => {
/// cast DoorCast::Pull => Door::Open,
/// cast DoorCast::Lock => Door::Locked,
/// cast DoorCast::Push | DoorCast::Unlock(_) => unhandled,
/// // No state-timeout armed here, so refuse it.
/// state_timeout => unhandled,
/// }
/// on Door::Locked => {
/// cast DoorCast::Unlock(key) => on_unlock(key), // -> successor enum
/// cast DoorCast::Push | DoorCast::Pull | DoorCast::Lock => unhandled,
/// state_timeout => unhandled,
/// }
///
/// // state-independent queries (reply, then stay via `prev`)
/// on _ => {
/// call DoorCall::GetState(r) => { r.reply(prev); prev },
/// }
/// }
/// ```
@@ -368,30 +732,53 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// * **Every row ends in a comma** (block-bodied ones too) and every `on` block
/// is `on <state-pat> => { … }`. These two are macro-grammar requirements, not
/// style: a declarative matcher can't otherwise tell where a row's tail ends.
/// * **Qualify event patterns** (`SwitchCast::Flip`) and **state tags**
/// (`Switch::On`). A declarative macro can't prepend the enum name inside an
/// opaque pattern fragment, so the `cast`/`call` keyword only selects the
/// event wrapper — it does not qualify for you. The keyword also reads as a
/// sync/async marker at a glance.
/// * **Qualify event patterns** (`DoorCast::Push`) and **state tags**
/// (`Door::Closed`). A declarative macro can't prepend the enum name inside an
/// opaque pattern fragment, so the row keyword only selects the event wrapper
/// — it does not qualify for you. `cast`/`call` also read as a sync/async
/// marker at a glance.
/// * **Timeouts surface as events.** `state_timeout` (a unit event) and
/// `timeout <name>` (matching the `&'static str` a generic timeout was armed
/// under) are matched in `on` rows just like casts and calls — arm them via
/// `cx`, handle the fire here. Because a `timeout` name is an `&str`, a row
/// that matches specific names needs a `timeout _ => …` fallback to stay
/// exhaustive.
/// * **`info` defaults to a silent drop.** An out-of-band `info` you do not
/// match anywhere is dropped (the `gen_server` default), so a machine that
/// ignores info writes no `info` rows at all. State-timeouts and named
/// timeouts have **no** such default: a state that can see one must handle it
/// (or `unhandled` it) or the match is non-exhaustive.
/// * **Stay** = return the current tag. The `prev` you named in `context` is
/// bound to the pre-handler state for exactly this — handy in any-state
/// (`on _`) rows where there is no single literal tag to write.
/// * **`data` and `cx`** (the names you chose) are in scope in every body:
/// mutate `data`, reply through a `Reply` bound in the pattern, and (chunks
/// 2–3) arm timeouts or postpone via `cx`. You never write `self`.
/// mutate `data`, reply through a `Reply` bound in the pattern, and arm
/// timeouts via `cx`. You never write `self`.
/// * **Guards** are plain match guards. A guarded arm does not count toward
/// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly)
/// trip `E0004`.
/// * **Branching rows** return a successor enum that `impl`s `From<_>` for the
/// state type; the macro supplies the outer `.into()`.
/// * **`postpone`** defers the current event untouched, to be replayed after the
/// next *real transition* (a stay does not trigger replay). It is available
/// for `cast`, `call`, and `info` rows — not the timeout events. The deferred
/// event keeps any `Reply` it carries, so a postponed `call` is answered by
/// whichever later state handles the replay. The body runs *no* code (the
/// event is untouched), so a `postpone` row is just `cast Foo(_) => postpone,`;
/// prefer a non-binding pattern, and if you guard it, the guard must not depend
/// on the event's payload (it is evaluated in a borrow pre-pass). On a
/// transition the queue drains FIFO, ahead of further inbox/timer events; a
/// replayed event may postpone again (it re-queues for the next transition).
///
/// # What it emits
///
/// `enum $Ev { Cast($Cast), Call($Call) }`, `struct $Sm { state, data }`,
/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, the `Machine` impl (`on_start`
/// running the initial `enter`; `handle` = the dispatch match + the
/// stay/transition/unhandled apply-tail, the cell's sole writer), and the
/// `enter` dispatch. Chunk 1: real time, no timers, no postpone.
/// The unified `enum $Ev` (the `Cast`/`Call`/`Info` wrappers plus the internal
/// `StateTimeout` / `Timeout` events), `struct $Sm { state, data }`,
/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, and the `Machine` impl:
/// `on_start` runs the initial `enter`; `handle` is the dispatch match plus the
/// stay/transition/unhandled apply-tail (the cell's sole writer, which also
/// auto-resets the state-timeout on every real transition); and the `enter`
/// dispatch.
///
/// # Limitation
///
@@ -403,16 +790,23 @@ macro_rules! gen_statem {
// ===== public entry =====================================================
(
machine: $sm:ident { state: $State:ty, data: $Data:ty } ;
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty } ;
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty, info: $Info:ty } ;
context ( $data:ident , $cur:ident , $cx:ident ) ;
enter { $( $est:pat => $ebody:expr ),+ $(,)? }
$( on $st:pat => { $($rows:tt)* } )+
) => {
/// Unified inbox payload: the user's `cast`/`call` enums folded together
/// (chunks 2–3 add the runtime's internal timeout variants here).
/// Unified inbox payload: the user's `cast`/`call`/`info` enums folded
/// together with the runtime's internal timeout events.
enum $Ev {
Cast($Cast),
Call($Call),
/// Out-of-band message, matched in `info` rows. An unmatched info is
/// silently dropped (the `gen_server` default).
Info($Info),
/// The state-timeout fired (matched in `state_timeout` rows).
StateTimeout,
/// A named timeout fired (matched in `timeout <pat>` rows).
Timeout(&'static str),
}
struct $sm {
@@ -438,6 +832,14 @@ macro_rules! gen_statem {
impl $crate::gen_statem::Machine for $sm {
type Ev = $Ev;
fn state_timeout_ev() -> $Ev {
$Ev::StateTimeout
}
fn timeout_ev(name: &'static str) -> $Ev {
$Ev::Timeout(name)
}
fn on_start(&mut self, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
let s = self.state;
self.enter(s, $cx);
@@ -446,79 +848,205 @@ macro_rules! gen_statem {
#[allow(unused_variables)]
#[deny(unreachable_patterns)] // conflicting rows must fail even though
// this match is external-macro-expanded
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::gen_statem::Cx<$Ev>)
-> $crate::gen_statem::Step<$Ev>
{
// Caller-named bindings (shared call-site hygiene, so row bodies
// can see them): `$cur` = current state tag, `$data` = &mut Data.
let $cur = self.state;
let $data = &mut self.data;
// @arms emits a block: a postpone pre-pass that `return`s
// `Step::Postponed(ev)` for a deferred event (handing it back
// untouched), then the consuming `match (state, event)` whose
// value is this `Resolution`.
let next: $crate::gen_statem::Resolution<$State> =
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ]
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ] [ ]
$( on $st => { $($rows)* } )+);
match next {
$crate::gen_statem::Resolution::To(s) if s == $cur => {}
$crate::gen_statem::Resolution::To(s) if s == $cur => {
$crate::gen_statem::Step::Stayed
}
$crate::gen_statem::Resolution::To(s) => {
self.state = s; // <- sole writer of the state cell
// A real transition auto-resets the state-timeout: the
// new state re-arms in its `enter` if it wants one.
$cx.__reset_state_timeout();
self.enter(s, $cx);
$crate::gen_statem::Step::Transitioned
}
$crate::gen_statem::Resolution::Postpone => {
unreachable!("postpone is not generated yet")
$crate::gen_statem::Resolution::Unhandled => {
$cx.on_unhandled();
$crate::gen_statem::Step::Stayed
}
$crate::gen_statem::Resolution::Unhandled => $cx.on_unhandled(),
}
}
}
};
// ===== @arms: build the dispatch match ==================================
// No more on-blocks: emit the (total, catch-all-free) match.
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => {
match ($ss, $se) { $($arms)* }
// ===== @arms / @rows: build the two-phase dispatch ======================
// Two accumulators are threaded: `[ $($arms)* ]` is the phase-2 consuming
// match (by value); `[ $($post)* ]` is the phase-1 postpone router (by ref).
// A normal row feeds only phase-2; a `postpone` row feeds both — a `=> true`
// router and a `=> unreachable!` phase-2 filler that keeps the consuming
// match total.
//
// Terminal (no on-blocks left): emit the block the `handle` body assigns to
// `next`. Phase 1 routes a deferred event back out as `Step::Postponed`;
// phase 2 is the catch-all-free consuming match (the one macro-injected arm
// is the `Info` silent-drop — last and broadest, so per-state `info` rows
// stay reachable; cast/call/timeouts get no fallback, so a forgotten pair is
// still E0004).
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ]) => {
{
// Phase 1 — postpone routing (borrow-only). A guard on a postpone
// row runs here, against by-ref bindings, so it must not depend on
// the event's payload.
let __defer = match ($ss, &$se) {
$($post)*
_ => false,
};
if __defer {
return $crate::gen_statem::Step::Postponed($se);
}
// Phase 2 — the consuming dispatch. Each postpone pair reappears here
// as `unreachable!` so the match stays total; phase 1 already
// returned for it.
match ($ss, $se) {
$($arms)*
(_, $Ev::Info(_)) => $crate::gen_statem::Resolution::Unhandled,
}
}
};
// Open an on-block: remember its state pat, drain its rows, then continue.
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ]
on $st:pat => { $($rows:tt)* } $($more:tt)*
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] ($st)
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] [ $($post)* ] ($st)
{ $($rows)* } { $($more)* })
};
// ===== @rows: drain one on-block's rows, threading the global acc ========
// ===== @rows: drain one on-block's rows, threading both accs =============
// cast, explicit refusal
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ cast $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// cast, postpone (defer the event; the replay in a later state handles it)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ cast $ev:pat $(if $g:expr)? => postpone , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => unreachable!("postponed event is replayed, not dispatched here"), ]
[ $($post)* ($st, $Ev::Cast($ev)) $(if $g)? => true, ]
($st) { $($rows)* } { $($more)* })
};
// cast, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ cast $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// call, explicit refusal
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ call $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// call, postpone (the Reply rides inside the event onto the queue)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ call $ev:pat $(if $g:expr)? => postpone , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => unreachable!("postponed event is replayed, not dispatched here"), ]
[ $($post)* ($st, $Ev::Call($ev)) $(if $g)? => true, ]
($st) { $($rows)* } { $($more)* })
};
// call, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ call $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// info, explicit refusal
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ info $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// info, postpone
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ info $ev:pat $(if $g:expr)? => postpone , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => unreachable!("postponed event is replayed, not dispatched here"), ]
[ $($post)* ($st, $Ev::Info($ev)) $(if $g)? => true, ]
($st) { $($rows)* } { $($more)* })
};
// info, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ info $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// state_timeout, explicit refusal (unit event — no pattern; not postponable)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ state_timeout $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::StateTimeout) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// state_timeout, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ state_timeout $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::StateTimeout) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// timeout, explicit refusal (pattern matches the name; not postponable)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ timeout $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Timeout($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// timeout, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ timeout $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Timeout($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
[ $($post)* ]
($st) { $($rows)* } { $($more)* })
};
// this block is drained: hand the remaining on-blocks back to @arms
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
{ } { $($more:tt)* }
) => {
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] $($more)*)
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] [ $($post)* ] $($more)*)
};
}
+251 -87
View File
@@ -1,26 +1,85 @@
//! RFC 016 — runtime introspection (Chunk 1: the read primitive).
//! Inspect what is running right now: which actors exist, what state each one
//! is in, and how they are related.
//!
//! A synchronous, internal read of the slab that returns *owned* data. This is
//! the mechanism the whole RFC hangs off: tests, the future observer
//! gen_server (Chunk 4), and a later control plane (RFC 003) are all consumers
//! of [`snapshot`] / [`actor_info`], never of the runtime internals directly.
//! This is the tool for questions like "is my server still alive", "how many
//! actors are currently parked waiting on something", or "what does the spawn
//! tree look like". It is meant for debugging, test assertions, a health check
//! endpoint, or a monitoring dashboard: anywhere you want to look at the
//! runtime from the outside without stopping it or coupling your code to its
//! internals.
//!
//! ## Consistency (DECISION D2 — per-slot tearing, `ps` semantics)
//! Three entry points, in order of scope:
//!
//! [`snapshot`] is point-in-time and mildly racy *across* actors: each slot's
//! scheduling state is a lock-free word load, so an actor reported `Running`
//! may already be `Parked`, and an actor can die mid-scan. This is the cheap,
//! useful model (a coherent stop-the-world cut is expensive and rarely wanted).
//! [`actor_info`] is coherent for the single actor it names.
//! - [`snapshot`] returns every actor that currently exists, as a plain
//! owned `Vec`, so you can filter, count, or search it however you like.
//! - [`actor_info`] returns a coherent view of exactly one actor, by pid.
//! Cheaper than filtering a whole snapshot down to one entry, and more
//! precise (see "Consistency" below).
//! - [`tree`] returns the same actors as [`snapshot`], folded into a
//! parent/child forest that mirrors who spawned whom.
//!
//! ## Locking
//! ```
//! use smarm::{actor_info, channel, run, snapshot, spawn, ActorState};
//!
//! The lock order is **Leaf → Channel, at most one of each** (`raw_mutex.rs`);
//! cold locks, the registry, and the free list are all Leaves, so we may never
//! hold two at once. The read is therefore phased: first a single registry-leaf
//! pass for names and mailbox depth (the per-channel length read is a Channel
//! lock taken under that Leaf — legal), released before the slab scan takes any
//! per-slot cold Leaf.
//! run(|| {
//! let (ready_tx, ready_rx) = channel::<()>();
//! let (gate_tx, gate_rx) = channel::<()>();
//!
//! let worker = spawn(move || {
//! ready_tx.send(()).unwrap();
//! gate_rx.recv().unwrap(); // blocks here until released
//! });
//! ready_rx.recv().unwrap();
//!
//! // `snapshot` sees every actor, including this one and the worker.
//! let snap = snapshot();
//! assert!(snap.actors.len() >= 2);
//!
//! // `actor_info` gives a coherent view of just the worker. It is
//! // blocked on the gate channel, so it must be Parked.
//! let pid = worker.pid();
//! let info = actor_info(pid).expect("worker is still alive");
//! assert_eq!(info.state, ActorState::Parked);
//!
//! gate_tx.send(()).unwrap();
//! worker.join().unwrap();
//!
//! // Once joined, the pid no longer names a live actor.
//! assert!(actor_info(pid).is_none());
//! });
//! ```
//!
//! ## Consistency
//!
//! [`snapshot`] is not a single atomic pause-the-world freeze: it walks every
//! actor's state one after another, so it is a series of independent,
//! cheap, lock-free reads rather than one coherent moment in time. Between
//! reading actor A and actor B, either one can change state, and an actor can
//! even finish and disappear mid-scan. In practice this is exactly what you
//! want: a coherent stop-the-world snapshot would mean pausing every actor in
//! the runtime just to look at it, which is expensive and rarely necessary
//! for a dashboard, a test assertion, or a debugging session.
//!
//! [`actor_info`], in contrast, is coherent for the one actor it names: all of
//! its fields describe the same instant for that actor, because a single
//! actor's data cannot tear the way a scan across many actors can.
//!
//! ## Implementation notes
//!
//! These details matter if you are working on smarm itself; they are not part
//! of the public contract.
//!
//! The read never stops the scheduler and never holds a lock across the whole
//! scan. Each actor's scheduling state is a single lock-free word load
//! (hence the possible tearing described above). Reading the rest of an
//! actor's cold data (its supervisor, monitors, links, and so on) takes a
//! brief per-actor lock, just long enough to copy those fields out; nothing
//! is held across actors. Locking follows the crate-wide rule that at most
//! one "leaf" lock (a per-actor lock, the registry lock, or the free list
//! lock) is held at a time, with no leaf lock held while acquiring another.
//! The read is phased accordingly: first one pass over the registry to
//! collect every actor's registered names and mailbox depth, released before
//! the per-actor scan begins.
use crate::pid::Pid;
use crate::registry::MailboxInfo;
@@ -31,15 +90,28 @@ use crate::slot_state::{
};
use std::collections::HashMap;
/// Snapshot wire-format version (DECISION D1). [`RuntimeSnapshot`] is treated as
/// a stable type from day one: it becomes the observer protocol (Chunk 4) and
/// crosses a version boundary the moment a remote observer attaches (RFC 011),
/// so the version travels with the data from the start.
/// The format version carried by every [`RuntimeSnapshot`] and
/// [`RuntimeTree`], as [`RuntimeSnapshot::format_version`] /
/// [`RuntimeTree::format_version`]. If you serialize a snapshot (for example
/// to send it somewhere else, or to compare snapshots taken with different
/// versions of smarm) check this field: a change in its value means the shape
/// of [`ActorInfo`] or its neighbors has changed and old and new snapshots
/// should not be assumed compatible. If you only ever read a snapshot
/// in-process in the same version of smarm that produced it, you can ignore
/// this field.
pub const SNAPSHOT_FORMAT_VERSION: u16 = 1;
/// Fine-grained scheduling state, mapped from the packed slot word with no new
/// storage. `RunningNotified` collapses into `Notified` — a wake landed while
/// the actor was on-CPU and it will re-queue when it yields.
/// What an actor is doing right now, from the scheduler's point of view.
///
/// - `Queued`: runnable, waiting for a scheduler thread to pick it up.
/// - `Running`: currently executing on a scheduler thread.
/// - `Notified`: was running and got woken up (for example, a message
/// arrived) before it had a chance to yield or park; it will be re-queued
/// as soon as it does.
/// - `Parked`: blocked, waiting on something such as a channel receive, a
/// mutex, a timer, or an IO event.
/// - `Done`: has finished (returned or panicked) but its slot has not been
/// reclaimed for reuse yet, so it is still visible to introspection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorState {
Queued,
@@ -49,8 +121,8 @@ pub enum ActorState {
Done,
}
/// Classify a packed state word. `None` for a Vacant slot (skipped by the scan)
/// — the only state that is not an actor.
/// Classify a packed state word. `None` for a Vacant slot (skipped by the
/// scan): a vacant slot holds no actor at all, live or done.
fn classify(w: u64) -> Option<ActorState> {
Some(match word_state(w) {
ST_QUEUED => ActorState::Queued,
@@ -62,61 +134,104 @@ fn classify(w: u64) -> Option<ActorState> {
})
}
/// Owned, point-in-time view of one actor — no borrows of runtime internals, so
/// it is safe to hand to any consumer.
/// An owned, self-contained view of one actor at (approximately) one moment.
/// It borrows nothing from the runtime, so you can keep it, send it
/// elsewhere, or print it long after the actor it describes has changed
/// state or even exited.
#[derive(Debug, Clone)]
pub struct ActorInfo {
pub pid: Pid,
/// Registered names, inverted from the registry (usually 0 or 1).
/// Names this actor is currently registered under (see the
/// [`registry`](crate::registry) module). Usually empty or one name;
/// an actor can have more if it registered several.
pub names: Vec<&'static str>,
pub state: ActorState,
/// Spawn-time parent edge (DECISION D9): `spawn_under` sets it to the
/// supervisor, plain `spawn` to the spawning actor — so it is parentage,
/// not necessarily a supervision relationship. `ROOT_PID` for the run's
/// root actor and for `Done` tombstones (whose `Actor` is already gone).
/// The actor that spawned this one: whoever called `spawn` or
/// `spawn_under` to create it. This is a parentage record, not
/// necessarily a supervision relationship: `spawn_under` records the
/// supervisor you asked for, while plain `spawn` records the spawning
/// actor itself, whether or not it supervises anything. It is the
/// runtime's root pid for the run's own root actor, and for a `Done`
/// actor whose bookkeeping has already been cleared.
pub supervisor: Pid,
pub trap_exit: bool,
pub monitors: u32,
pub links: u32,
pub joiners: u32,
/// Queued messages summed over the actor's *published* channels (register /
/// install / spawn_addr / gen_server). 0 for an actor that holds only a
/// private `channel()` receiver — those are invisible to the registry.
/// Messages currently queued and not yet delivered, summed across every
/// channel this actor has published (via `register`, `install`,
/// `spawn_addr`, or starting a gen_server). This is 0 for an actor that
/// only holds a private, unpublished `channel()` receiver, since nothing
/// outside the actor can see that channel exists.
pub mailbox_depth: u32,
/// Timeslice overruns tallied for this incarnation (RFC 016 Chunk 2): how
/// many times the actor was preempted for exceeding its slice. Resets on
/// restart (per-incarnation, D7).
/// How many times this actor has been preempted for running past its
/// scheduling timeslice. Counts only since the actor's current start (a
/// supervisor restart begins a fresh count).
pub overruns: u64,
/// Messages this actor has received (dequeued) this incarnation (RFC 016
/// Chunk 2) — answers "is this actor a hotspot / draining slower than its
/// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7).
/// How many messages this actor has received (taken off its inbox), since
/// its current start. Useful for spotting an actor whose mailbox is
/// filling up faster than it can drain it: compare this against
/// `mailbox_depth` over time.
pub messages_received: u64,
/// Approximate on-CPU cycles this incarnation has consumed (RFC 016 Chunk 2)
/// — a reductions-like work metric for relative comparison. Always 0 unless
/// the `budget-accounting` feature is enabled (it costs an RDTSC per resume,
/// D6). Per-incarnation (D7).
/// Approximate CPU cycles this actor has spent running, since its current
/// start. A relative measure for comparing actors against each other, not
/// an absolute or wall-clock figure. Always 0 unless the crate's
/// `budget-accounting` feature is enabled, since measuring it costs a
/// timestamp read on every resume.
pub budget_cycles: u64,
/// RFC 019 §8 — this actor's stack, as the runtime sees it. All fields
/// are lock-free atomic reads, coherent for this incarnation via the
/// same generation check as the counters above. Exact RSS is
/// deliberately absent: `mincore` is debug tooling, never a runtime
/// path.
pub stack: StackInfo,
}
/// A whole-runtime snapshot. See the module docs for the D2 tearing model.
/// RFC 019 §8 — per-actor stack introspection. Sizes are page-rounded, as
/// [`Stack::new`](crate::stack::Stack::new) rounds them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StackInfo {
/// Usable stack size ([`SpawnOpts::stack_reserve`]
/// (crate::SpawnOpts::stack_reserve) or the Config/default).
pub reserve: usize,
/// PROT_NONE guard below the usable region.
pub guard: usize,
/// Sampled high-water depth in bytes: `top − lowest saved sp`. Sampled,
/// not exact — the context save at yields/parks/preemptions is the
/// sampler (RFC 019 §2), so a spike the actor never yielded inside is
/// invisible. 0 depth means "never descheduled at any depth", not
/// "never ran".
pub depth_high_water: usize,
/// Parks on this incarnation since its last shrink (or since install if
/// it has never shrunk) — the §3 cooldown counter, live.
pub parks_since_shrink: u32,
/// §3 shrinks performed on this incarnation.
pub shrinks: u32,
}
/// A snapshot of every actor in the runtime at (approximately) one moment.
/// See the module docs' "Consistency" section for what "approximately" means
/// here.
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
pub format_version: u16,
pub actors: Vec<ActorInfo>,
}
/// Snapshot every live (and `Done`-but-not-yet-reclaimed) actor on the slab.
/// O(n) over the slot table, running with preemption disabled (like every
/// runtime primitive) but holding no lock across the scan. Panics outside
/// `Runtime::run()`; callable from actor code and the run thread.
/// Every actor that currently exists: running, queued, parked, or finished
/// but not yet cleaned up. Cheap and lock-free per actor; see the module
/// docs for what "approximately one moment" means for the result as a whole.
/// Panics if called outside [`run`](crate::run).
pub fn snapshot() -> RuntimeSnapshot {
with_runtime(|inner| {
// Phase A: one registry-leaf pass for names + mailbox depth, released
// before any cold leaf (no two Leaves at once).
// First pass: one registry lock to collect names + mailbox depth for
// every actor, released before touching any per-actor lock below.
let mail = inner.registry.lock().introspect_map();
// Phase B: lock-free slab scan; per-slot cold leaf only to copy cold
// fields. Tearing across slots is intentional (D2).
// Second pass: walk the actor table. Each actor's scheduling state is
// a lock-free word load; only copying its other fields takes a brief
// per-actor lock. Tearing across actors is expected here (see the
// module docs' "Consistency" section).
let mut actors = Vec::new();
for (idx, slot) in inner.slots.iter().enumerate() {
let idx = idx as u32;
@@ -124,12 +239,34 @@ pub fn snapshot() -> RuntimeSnapshot {
actors.push(info);
}
}
RuntimeSnapshot { format_version: SNAPSHOT_FORMAT_VERSION, actors }
RuntimeSnapshot {
format_version: SNAPSHOT_FORMAT_VERSION,
actors,
}
})
}
/// A coherent view of exactly one actor, or `None` if `pid` does not name a
/// currently-live entry: it is stale (that actor has already exited and its
/// slot was reused by another), out of range, or was never a real pid at
/// all. Unlike [`snapshot`], every field of the result describes the same
/// instant, since there is only one actor to read.
/// The stack shape `(reserve, guard)` of a live actor, page-rounded — the
/// RFC 019 introspection surface's first field (depth sampling and shrink
/// counters land with the shrink machinery). `None` if `pid` no longer names
/// a live actor. Takes the actor's cold lock briefly; debugging/assertion
/// use, not a hot-path call.
pub fn stack_shape(pid: Pid) -> Option<(usize, usize)> {
with_runtime(|inner| {
let slot = inner.slot_at(pid)?;
let cold = slot.cold.lock();
if slot.generation() != pid.generation() {
return None;
}
cold.actor.as_ref().map(|a| a.stack.shape())
})
}
/// Coherent view of a single actor, or `None` if the pid is stale, out of
/// range, or names a Vacant slot.
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
with_runtime(|inner| {
let slot = inner.slot_at(pid)?;
@@ -141,11 +278,12 @@ pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
})
}
/// Build one `ActorInfo` for slot `idx`, or `None` if Vacant or
/// racing-reclaimed. State is classified from a lock-free word load (the torn
/// read); the cold lock then pins the generation (reclaim bumps it under that
/// same lock) so the cold fields are coherent for this incarnation. `mail` is
/// this slot's registry entry, if any.
/// Build one `ActorInfo` for slot `idx`, or `None` if the slot is empty or
/// was reclaimed while this read was in progress. The scheduling state comes
/// from a lock-free word load (the source of the tearing described in the
/// module docs); the per-actor lock then confirms the actor has not since
/// exited and been replaced, so the rest of the fields are coherent for this
/// exact actor. `mail` is this slot's registry entry, if any.
fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorInfo> {
let w = slot.state_word();
let state = classify(w)?;
@@ -153,10 +291,11 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
let pid = Pid::new(idx, gen);
let cold = slot.cold.lock();
// If the generation moved between the lock-free load and acquiring the cold
// lock, the slot was reclaimed (and maybe reused) — drop it rather than mix
// one incarnation's state with another's cold data. (ps semantics: a racing
// actor may simply be missed mid-scan.)
// If the generation moved between the lock-free load and acquiring the
// per-actor lock, this actor exited (and the slot may already hold a new
// one). Drop it rather than mix one actor's state with another's data; a
// racing actor may simply be missed by this scan, which is expected (see
// the module docs' "Consistency" section).
if word_gen(slot.state_word()) != gen {
return None;
}
@@ -172,7 +311,15 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
let joiners = cold.waiters.len() as u32;
drop(cold);
// Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2).
// Counters are plain atomics, read lock-free.
let (reserve, guard, top, hwm, parks_since_shrink, shrinks) = slot.stack_introspect();
let stack = StackInfo {
reserve,
guard,
depth_high_water: top.saturating_sub(hwm),
parks_since_shrink,
shrinks,
};
let overruns = slot.overruns();
let messages_received = slot.messages_received();
let budget_cycles = slot.budget_cycles();
@@ -198,45 +345,55 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
overruns,
messages_received,
budget_cycles,
stack,
})
}
// ---------------------------------------------------------------------------
// Chunk 3 — tree view (pure derivation over a Chunk-1 snapshot)
// Tree view: a pure derivation over a snapshot
// ---------------------------------------------------------------------------
/// One node in the parentage forest. `children` are the actors whose recorded
/// parent edge points at this node's pid.
/// One node in the parentage forest returned by [`tree`]. `children` are the
/// actors whose recorded parent (see [`ActorInfo::supervisor`]) points at
/// this node's actor.
#[derive(Debug, Clone)]
pub struct TreeNode {
pub info: ActorInfo,
/// The actor's recorded parent was absent from the snapshot (already
/// Done/Vacant, or itself a tombstone), so it was re-rooted under the forest
/// sentinel rather than dropped — the tree stays total (DECISION D8).
/// True if this actor's recorded parent was not found in the snapshot
/// (it had already exited, or was itself missing), so this node was
/// placed at the top of the forest instead of being dropped. This keeps
/// every actor in the snapshot visible somewhere in the tree, even one
/// whose parent is gone.
pub orphaned: bool,
pub children: Vec<TreeNode>,
}
/// The parentage forest. Roots are actors parented at `ROOT_PID` (genuine
/// roots) plus re-rooted orphans. The edge is *spawned-by / parent*, not
/// necessarily supervision (DECISION D9) — see [`ActorInfo::supervisor`].
/// The parentage forest: every actor from a snapshot, arranged by who spawned
/// whom. Roots are actors with no parent in the snapshot (including the
/// run's own root actor) plus any orphaned actors (see [`TreeNode::orphaned`]).
/// This mirrors spawn parentage, not necessarily a supervision tree; see
/// [`ActorInfo::supervisor`].
#[derive(Debug, Clone)]
pub struct RuntimeTree {
pub format_version: u16,
pub roots: Vec<TreeNode>,
}
/// Take a live [`snapshot`] and fold it into the parentage forest.
/// Take a fresh [`snapshot`] and fold it into the parentage forest.
pub fn tree() -> RuntimeTree {
tree_from(snapshot())
}
/// Fold an existing snapshot into a forest by grouping each actor under its
/// parent pid — a single O(n) pass, no new reads. Exposed separately so a
/// consumer that already holds a snapshot (or a synthetic one, in tests) can
/// derive the tree without a second scan.
/// Fold an existing snapshot into a parentage forest by grouping each actor
/// under its parent, without taking a new snapshot. Useful if you already
/// have one (for example, one built in a test, or one you took earlier and
/// want to inspect again) and want the tree view of it without re-reading
/// the runtime.
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
let RuntimeSnapshot { format_version, actors } = snap;
let RuntimeSnapshot {
format_version,
actors,
} = snap;
let mut index_of: HashMap<Pid, usize> = HashMap::with_capacity(actors.len());
for (i, a) in actors.iter().enumerate() {
@@ -254,7 +411,7 @@ pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
children_of.entry(parent).or_default().push(i);
} else {
// Parent is the forest sentinel (genuine root) or absent from the
// snapshot (orphan, D8) — either way a root of the forest.
// snapshot (orphan): either way, a root of the forest.
orphaned[i] = parent != ROOT_PID;
roots.push(i);
}
@@ -267,7 +424,10 @@ pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
.into_iter()
.filter_map(|i| build_node(i, &children_of, &orphaned, &mut slots))
.collect();
RuntimeTree { format_version, roots: root_nodes }
RuntimeTree {
format_version,
roots: root_nodes,
}
}
fn build_node(
@@ -285,5 +445,9 @@ fn build_node(
.collect()
})
.unwrap_or_default();
Some(TreeNode { info, orphaned: orphaned[i], children })
Some(TreeNode {
info,
orphaned: orphaned[i],
children,
})
}
+167 -242
View File
@@ -13,44 +13,68 @@
//! leaves the actor, no copying through an intermediary thread. Built on
//! these are the conveniences `read(fd, &mut buf)` and `write(fd, &buf)`.
//!
//! Architecture
//! ============
//! Per `run()`, two OS threads:
//! - **epoll thread**: owns the epollfd. Loops in `epoll_wait`. On a
//! ready fd, pushes `Completion::FdReady { pid, fd, events }` to the
//! shared completion queue and writes the scheduler-wake pipe. On the
//! shutdown pipe (also registered in epollfd), exits.
//! - **pool thread**: blocks on the request mpsc. Runs the closure
//! inside `catch_unwind`, pushes `Completion::Blocking { pid, result }`,
//! writes the scheduler-wake pipe.
//! Architecture (RFC 018: driver-enqueues)
//! =======================================
//! Per `run()`, two OS threads, each a *producer* behind the runtime's
//! two-call contract — make the actor runnable (`unpark_at`, whose enqueue
//! tail wakes a parked scheduler), nothing else:
//!
//! Both threads share a single `completions: Arc<Mutex<VecDeque<Completion>>>`
//! and the same scheduler-wake pipe.
//! - **epoll thread**: owns `epoll_wait` on the epollfd. On a ready fd it
//! removes the parked waiter from the shared `waiters` map and DELs the
//! fd (both under the waiters lock — see below), then unparks the
//! actor directly. On the shutdown pipe (also registered in the
//! epollfd), exits.
//! - **pool thread**: blocks on the request mpsc. Runs the closure inside
//! `catch_unwind`, stashes the result in the actor's slot
//! (`pending_io_result`, under the cold lock, generation-checked),
//! decrements the runtime's `io_outstanding`, and unparks the actor.
//!
//! `epoll_ctl` (register/unregister fd interest) is called by the
//! scheduler thread *directly* on the epollfd. That's well-defined per
//! `epoll_ctl(2)`: a thread may be calling `epoll_wait` on the epollfd
//! while another thread calls `epoll_ctl`. Avoids needing a second mpsc
//! and a second wake mechanism.
//! There is no shared completion queue and no wake pipe: each producer
//! routes its own completion, so the whole byte-vs-completion visibility
//! discipline of the drain era — and the stranded-completion hazards it
//! defended against — is unrepresentable. Producers reach the runtime
//! through a `Weak<RuntimeInner>`: upgraded per completion (the path is
//! syscall-bound; the refcount op is noise) and avoiding an Arc cycle
//! through `RuntimeInner::io`.
//!
//! `epoll_ctl` (register fd interest) is called by the scheduler thread
//! directly on the epollfd. That's well-defined per `epoll_ctl(2)`: a
//! thread may be calling `epoll_wait` on the epollfd while another thread
//! calls `epoll_ctl`.
//!
//! Epoll mode
//! ==========
//! Level-triggered with EPOLLONESHOT. After a wakeup the kernel
//! auto-disarms the fd, so we never get two wakeups for one
//! `wait_readable` call. The scheduler explicitly `EPOLL_CTL_DEL`s the fd
//! on completion to free the slot for re-registration. Net effect: each
//! `wait_readable` call. The epoll thread explicitly `EPOLL_CTL_DEL`s the
//! fd on readiness to free the slot for re-registration. Net effect: each
//! `wait_readable(fd)` is one ADD, one wakeup, one DEL — symmetric and
//! stateless between calls.
//!
//! ## The waiters lock is the ADD/DEL serialization
//!
//! Registration (scheduler thread: check-vacant, defensive DEL, ADD,
//! insert) and readiness consumption (epoll thread: remove, DEL) each run
//! entirely under the `waiters` mutex. This is what makes the
//! oneshot-rearm race unrepresentable: a woken actor re-registering the
//! same fd cannot interleave with the epoll thread's DEL for the *previous*
//! registration — whichever takes the lock second sees a consistent
//! kernel-side state. Lock order: `io` (the runtime's outer mutex, held by
//! scheduler-side callers) → `waiters` → slot/queue leaves via `unpark_at`.
//! The epoll thread takes `waiters` without `io` — it must never take
//! `io`, both for lock-order hygiene and because teardown holds `io` while
//! joining it.
//!
//! Fd hygiene
//! ==========
//! An actor stopped while waiting on an fd unwinds out of `wait_fd`'s park;
//! a drop guard there (armed after a successful register, forgotten on a
//! normal wake) removes the `waiters` entry iff it is still that wait's
//! `(pid, epoch)` and only then `EPOLL_CTL_DEL`s the fd — an entry already
//! consumed by a racing `FdReady` means the fd may carry someone else's
//! fresh registration, which must be left alone. `epoll_register` keeps a
//! defensive bare DEL before ADD as belt-and-braces.
//! normal wake) calls [`IoThread::cancel_waiter`], which removes the
//! `waiters` entry iff it is still that wait's `(pid, epoch)` and only then
//! `EPOLL_CTL_DEL`s the fd — an entry already consumed by the epoll thread
//! means the fd may carry someone else's fresh registration, which must be
//! left alone. `epoll_register` keeps a defensive bare DEL before ADD as
//! belt-and-braces.
//!
//! Buffers used with `read`/`write` should be on fds opened with
//! `O_NONBLOCK`. If they aren't, the syscall may block the scheduler
@@ -68,13 +92,14 @@
//! they have no equivalent panic-propagation path.
use crate::pid::Pid;
use crate::runtime::RuntimeInner;
use std::any::Any;
use std::collections::{HashMap, VecDeque};
use std::collections::HashMap;
use std::io;
use std::os::fd::RawFd;
use std::panic;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::sync::atomic::Ordering;
use std::sync::{mpsc, Arc, Mutex, Weak};
use std::thread::JoinHandle as OsJoinHandle;
// ---------------------------------------------------------------------------
@@ -86,45 +111,31 @@ use std::thread::JoinHandle as OsJoinHandle;
pub type IoResult = Result<Box<dyn Any + Send>, Box<dyn Any + Send>>;
struct Request {
/// The submitter's park-epoch — carried through to the `Blocking`
/// completion so the wake is epoch-matched.
/// The submitter's park-epoch — the eventual wake is epoch-matched.
epoch: u32,
pid: Pid,
/// The work to perform. Returns the wire-form result directly.
work: Box<dyn FnOnce() -> IoResult + Send>,
}
/// Completion message from either IO thread back to the scheduler.
pub enum Completion {
/// A `block_on_io` closure has finished (Ok = return value, Err = panic
/// payload).
Blocking { pid: Pid, epoch: u32, result: IoResult },
/// An fd registered via `wait_readable`/`wait_writable` is ready. The
/// scheduler looks up the parked pid in `waiters`, unparks it, and
/// removes the entry. `pid` isn't in this variant because the epoll
/// thread doesn't have access to the `waiters` map; the scheduler
/// thread owns that.
FdReady { fd: RawFd, events: u32 },
}
/// The parked-waiter map, shared between scheduler-side registration and
/// the epoll thread's readiness consumption. See the module docs on why
/// this single lock is the ADD/DEL serialization.
type Waiters = Arc<Mutex<HashMap<RawFd, (Pid, u32)>>>;
// ---------------------------------------------------------------------------
// IoThread — created per `run()`, owned by `SchedulerState`.
// IoThread — created per `run()`, owned by `RuntimeInner::io`.
// ---------------------------------------------------------------------------
pub struct IoThread {
// ----- Channels & queues -----
/// Submission queue into the blocking-work pool.
tx: mpsc::Sender<Request>,
/// Shared completion queue, fed by both the pool and the epoll thread.
completions: Arc<Mutex<VecDeque<Completion>>>,
/// Pipe the scheduler polls in its idle path. Both IO threads write to
/// `wake_write` after pushing a completion.
wake_read: RawFd,
wake_write: RawFd,
/// One parked actor per registered fd. Populated by `epoll_register`,
/// consumed by the epoll thread on readiness or `cancel_waiter` on an
/// unwound wait.
waiters: Waiters,
// ----- Epoll machinery -----
/// The epollfd, owned by `IoThread`. Callable cross-thread via
/// `epoll_ctl` per the man page.
epollfd: RawFd,
@@ -133,39 +144,24 @@ pub struct IoThread {
/// shutdown.
shutdown_read: RawFd,
shutdown_write: RawFd,
/// One parked actor per registered fd. Populated by `wait_readable` /
/// `wait_writable` and drained by the scheduler when a `FdReady`
/// completion is processed.
pub waiters: HashMap<RawFd, (Pid, u32)>,
// ----- Threads -----
pool_thread: Option<OsJoinHandle<()>>,
epoll_thread: Option<OsJoinHandle<()>>,
/// Number of `block_on_io` requests in-flight. Used by the scheduler's
/// idle path to decide whether to wait on the pipe or exit. Fd waits
/// are not counted here; they're counted by `waiters.len()`.
pub outstanding: u32,
}
impl IoThread {
pub fn start() -> io::Result<Self> {
// Scheduler-facing wake pipe.
let (wake_read, wake_write) = make_pipe()?;
// Pool submission channel + shared completion queue.
/// Start the pool and epoll threads. `rt` is the producers' route back
/// into the runtime (slot table + unpark protocol); a `Weak` so the
/// `RuntimeInner → IoThread → RuntimeInner` cycle never forms.
pub(crate) fn start(rt: Weak<RuntimeInner>) -> io::Result<Self> {
// Pool submission channel.
let (tx, rx) = mpsc::channel::<Request>();
let completions: Arc<Mutex<VecDeque<Completion>>> =
Arc::new(Mutex::new(VecDeque::new()));
let waiters: Waiters = Arc::new(Mutex::new(HashMap::new()));
// Epoll machinery.
let epollfd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
if epollfd < 0 {
// Best-effort fd cleanup before bailing.
unsafe {
libc::close(wake_read);
libc::close(wake_write);
}
return Err(io::Error::last_os_error());
}
@@ -174,8 +170,6 @@ impl IoThread {
Err(e) => {
unsafe {
libc::close(epollfd);
libc::close(wake_read);
libc::close(wake_write);
}
return Err(e);
}
@@ -202,79 +196,51 @@ impl IoThread {
libc::close(epollfd);
libc::close(shutdown_read);
libc::close(shutdown_write);
libc::close(wake_read);
libc::close(wake_write);
}
return Err(e);
}
// Spawn pool thread.
let pool_comps = completions.clone();
let pool_rt = rt.clone();
let pool_thread = std::thread::Builder::new()
.name("smarm-io-pool".into())
.spawn(move || pool_loop(rx, pool_comps, wake_write))?;
.spawn(move || pool_loop(rx, pool_rt))?;
// Spawn epoll thread.
let epoll_comps = completions.clone();
let epoll_waiters = waiters.clone();
let epoll_thread = std::thread::Builder::new()
.name("smarm-io-epoll".into())
.spawn(move || epoll_loop(epollfd, epoll_comps, wake_write))?;
.spawn(move || epoll_loop(epollfd, epoll_waiters, rt))?;
Ok(Self {
tx,
completions,
wake_read,
wake_write,
waiters,
epollfd,
shutdown_read,
shutdown_write,
waiters: HashMap::new(),
pool_thread: Some(pool_thread),
epoll_thread: Some(epoll_thread),
outstanding: 0,
})
}
/// Hand a request to the pool. Increments `outstanding`.
/// Hand a request to the pool. The caller (scheduler.rs) increments
/// `io_outstanding` BEFORE calling — the pool decrements on completion,
/// and an increment that trailed the completion would underflow.
pub fn submit(&mut self, pid: Pid, epoch: u32, work: Box<dyn FnOnce() -> IoResult + Send>) {
self.outstanding += 1;
// Send can only fail if the pool has hung up, which only happens
// on shutdown. submit during shutdown is a bug.
self.tx
.send(Request { pid, epoch, work })
.expect("io pool hung up unexpectedly");
}
/// Drain every available completion. Caller (the scheduler) routes the
/// results and updates `outstanding` / `waiters` accordingly.
pub fn drain_completions(&mut self) -> Vec<Completion> {
let mut q = self.completions.lock().unwrap();
let mut out = Vec::with_capacity(q.len());
while let Some(c) = q.pop_front() {
out.push(c);
if self.tx.send(Request { pid, epoch, work }).is_err() {
panic!("smarm: io pool hung up unexpectedly (submit during shutdown)");
}
out
}
pub fn wake_fd(&self) -> RawFd {
self.wake_read
}
/// Write the wake pipe directly: rouse every scheduler thread blocked in
/// its idle `poll_wake`. Used by the terminal (AllDone) path — an idle
/// sibling may be blocked on a snapshot that nothing will ever refresh
/// (an orphaned timer deadline, or `io_outstanding` from a waiter that
/// was stop-cancelled and so never produces a completion).
pub fn wake(&self) {
wake_scheduler(self.wake_write);
}
/// Register interest in `fd` becoming readable/writable; record `pid`
/// as the parked waiter. The epoll thread will push a `FdReady`
/// completion when the kernel signals.
/// as the parked waiter. The epoll thread unparks it on readiness.
/// The caller increments `io_fd_waiters` BEFORE calling (mirror of
/// `submit`'s contract) and decrements it again if this errors.
///
/// EPOLLONESHOT: one wakeup per registration. The scheduler must
/// `epoll_del` on completion to free the slot for re-registration.
/// EPOLLONESHOT: one wakeup per registration; the epoll thread DELs on
/// readiness, `cancel_waiter` DELs on an unwound wait.
pub fn epoll_register(
&mut self,
fd: RawFd,
@@ -283,20 +249,24 @@ impl IoThread {
readable: bool,
writable: bool,
) -> io::Result<()> {
let mut waiters = match self.waiters.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: io waiters lock poisoned (core corrupt): {e}"),
};
// Two actors waiting on the same fd would be a misuse: the kernel
// delivers exactly one EPOLLONESHOT wakeup, so the second waiter
// would hang. Reject up front.
if self.waiters.contains_key(&fd) {
if waiters.contains_key(&fd) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"fd already has a parked waiter",
));
}
// Belt-and-braces: the unwind guard in `wait_fd` is responsible for
// cleaning up a stopped waiter's registration, but a bare DEL is
// harmless if the fd isn't registered (ENOENT) and removes any leak
// a path we haven't thought of might leave behind.
// Belt-and-braces: `cancel_waiter` is responsible for cleaning up a
// stopped waiter's registration, but a bare DEL is harmless if the
// fd isn't registered (ENOENT) and removes any leak a path we
// haven't thought of might leave behind.
unsafe {
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
}
@@ -312,25 +282,34 @@ impl IoThread {
events,
u64: fd as u64,
};
let r = unsafe {
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_ADD, fd, &mut ev as *mut _)
};
let r =
unsafe { libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_ADD, fd, &mut ev as *mut _) };
if r < 0 {
return Err(io::Error::last_os_error());
}
self.waiters.insert(fd, (pid, epoch));
waiters.insert(fd, (pid, epoch));
Ok(())
}
/// Remove `fd` from the epollfd. Called by the scheduler after a
/// `FdReady` completion, so the next `wait_readable(fd)` can ADD again.
///
/// Does NOT touch `waiters` — that's the scheduler's bookkeeping; this
/// is purely the kernel-side cleanup.
pub fn epoll_deregister(&mut self, fd: RawFd) {
// EPOLL_CTL_DEL of an already-removed fd returns ENOENT; ignore.
unsafe {
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
/// Remove `fd`'s waiter iff it is still `(pid, epoch)`, DELing the fd
/// from the epollfd in the same critical section. Returns whether the
/// entry was removed (the caller then decrements `io_fd_waiters`).
/// `false` means the epoll thread consumed the registration first —
/// the fd may already carry someone else's fresh ADD; hands off.
pub fn cancel_waiter(&mut self, fd: RawFd, pid: Pid, epoch: u32) -> bool {
let mut waiters = match self.waiters.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: io waiters lock poisoned (core corrupt): {e}"),
};
if waiters.get(&fd) == Some(&(pid, epoch)) {
waiters.remove(&fd);
// EPOLL_CTL_DEL of an already-removed fd returns ENOENT; ignore.
unsafe {
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
}
true
} else {
false
}
}
}
@@ -351,7 +330,10 @@ impl Drop for IoThread {
let real_tx = std::mem::replace(&mut self.tx, dead_tx);
drop(real_tx);
// 3. Join both threads.
// 3. Join both threads. Safe even while the caller holds the
// runtime's `io` mutex: neither thread ever takes it (they reach
// the runtime through a Weak they upgrade per completion, and
// the epoll thread's only lock is `waiters`).
if let Some(h) = self.epoll_thread.take() {
let _ = h.join();
}
@@ -364,8 +346,6 @@ impl Drop for IoThread {
libc::close(self.epollfd);
libc::close(self.shutdown_read);
libc::close(self.shutdown_write);
libc::close(self.wake_read);
libc::close(self.wake_write);
}
}
}
@@ -376,36 +356,38 @@ impl Drop for IoThread {
const SHUTDOWN_EPOLL_TOKEN: u64 = u64::MAX;
// ---------------------------------------------------------------------------
// Pool loop
// Pool loop (producer: Blocking completions)
// ---------------------------------------------------------------------------
fn pool_loop(
rx: mpsc::Receiver<Request>,
completions: Arc<Mutex<VecDeque<Completion>>>,
wake_write: RawFd,
) {
fn pool_loop(rx: mpsc::Receiver<Request>, rt: Weak<RuntimeInner>) {
while let Ok(Request { pid, epoch, work }) = rx.recv() {
let result: IoResult = match panic::catch_unwind(panic::AssertUnwindSafe(work)) {
Ok(r) => r,
Err(payload) => Err(payload),
};
completions
.lock()
.unwrap()
.push_back(Completion::Blocking { pid, epoch, result });
wake_scheduler(wake_write);
let Some(inner) = rt.upgrade() else { return };
// Stash the result under the cold lock (generation-checked: an
// actor stopped with the op in flight discards it), decrement the
// in-flight count, then wake through the epoch-matched unpark. The
// unpark's enqueue tail wakes a parked scheduler; the actor stays
// `live` until it resumes and finalizes, so the decrement's
// ordering against the termination verdict is not load-bearing.
if let Some(slot) = inner.slot_at(pid) {
let mut cold = slot.cold.lock();
if slot.generation() == pid.generation() {
cold.pending_io_result = Some(result);
}
}
inner.io_outstanding.fetch_sub(1, Ordering::AcqRel);
inner.unpark_at(pid, epoch);
}
}
// ---------------------------------------------------------------------------
// Epoll loop
// Epoll loop (producer: FdReady completions)
// ---------------------------------------------------------------------------
fn epoll_loop(
epollfd: RawFd,
completions: Arc<Mutex<VecDeque<Completion>>>,
wake_write: RawFd,
) {
fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
// Buffer for epoll_wait. 64 is plenty for our scale; if a real load
// appears that needs more, this is a one-line change.
const MAX_EVENTS: usize = 64;
@@ -413,12 +395,7 @@ fn epoll_loop(
loop {
let n = unsafe {
libc::epoll_wait(
epollfd,
events.as_mut_ptr(),
MAX_EVENTS as libc::c_int,
-1,
)
libc::epoll_wait(epollfd, events.as_mut_ptr(), MAX_EVENTS as libc::c_int, -1)
};
if n < 0 {
@@ -433,26 +410,36 @@ fn epoll_loop(
}
let mut shutdown_requested = false;
let mut pushed_any = false;
{
let mut q = completions.lock().unwrap();
for ev in events.iter().take(n as usize) {
if ev.u64 == SHUTDOWN_EPOLL_TOKEN {
shutdown_requested = true;
continue;
for ev in events.iter().take(n as usize) {
if ev.u64 == SHUTDOWN_EPOLL_TOKEN {
shutdown_requested = true;
continue;
}
let fd = ev.u64 as RawFd;
// Consume the registration: remove + DEL under the waiters
// lock (the ADD/DEL serialization — see module docs). A
// vanished entry means `cancel_waiter` beat us: the wake is
// already moot.
let entry = {
let mut w = match waiters.lock() {
Ok(g) => g,
Err(e) => {
panic!("smarm: io waiters lock poisoned (core corrupt): {e}")
}
};
let entry = w.remove(&fd);
if entry.is_some() {
unsafe {
libc::epoll_ctl(epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
}
}
let fd = ev.u64 as RawFd;
let evs = ev.events;
q.push_back(Completion::FdReady {
fd,
events: evs,
});
pushed_any = true;
entry
};
if let Some((pid, epoch)) = entry {
let Some(inner) = rt.upgrade() else { return };
inner.io_fd_waiters.fetch_sub(1, Ordering::AcqRel);
inner.unpark_at(pid, epoch);
}
}
if pushed_any {
wake_scheduler(wake_write);
}
if shutdown_requested {
return;
@@ -460,27 +447,8 @@ fn epoll_loop(
}
}
/// Write one byte to the scheduler's wake pipe. Retries on EINTR; ignores
/// EAGAIN (pipe full means there's already an outstanding wake we haven't
/// consumed yet, which is sufficient).
fn wake_scheduler(wake_write: RawFd) {
let buf: [u8; 1] = [0];
unsafe {
loop {
let n = libc::write(wake_write, buf.as_ptr() as *const _, 1);
if n < 0 {
let e = *libc::__errno_location();
if e == libc::EINTR {
continue;
}
}
break;
}
}
}
// ---------------------------------------------------------------------------
// Pipe helpers (unchanged from v0.2)
// Pipe helper
// ---------------------------------------------------------------------------
fn make_pipe() -> io::Result<(RawFd, RawFd)> {
@@ -491,46 +459,3 @@ fn make_pipe() -> io::Result<(RawFd, RawFd)> {
}
Ok((fds[0], fds[1]))
}
/// Drain pending bytes from the wake pipe. The scheduler calls this after
/// a `poll` wakeup so the next idle call sees an empty pipe.
pub fn drain_wake_pipe(fd: RawFd) {
let mut buf = [0u8; 64];
loop {
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
if n <= 0 {
break;
}
}
}
/// Block on `fd` for up to `timeout`, returning when either there's data
/// to read or the timeout elapses. `None` for `timeout` means wait forever.
pub fn poll_wake(fd: RawFd, timeout: Option<std::time::Duration>) {
let timeout_ms: libc::c_int = match timeout {
None => -1,
Some(d) => {
let ms = d.as_millis();
if ms > i32::MAX as u128 {
i32::MAX
} else {
ms as i32
}
}
};
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
loop {
let r = unsafe { libc::poll(&mut pfd as *mut _, 1, timeout_ms) };
if r < 0 {
let e = unsafe { *libc::__errno_location() };
if e == libc::EINTR {
continue;
}
}
break;
}
}
+39 -29
View File
@@ -11,32 +11,35 @@
//!
//! See `LOOM.md` for the design intent and the deferred-for-later list.
pub mod stack;
pub mod context;
pub mod preempt;
pub mod pid;
pub mod actor;
pub mod causal;
pub mod channel;
pub mod scheduler;
pub mod supervisor;
pub mod timer;
pub mod io;
pub mod mutex;
pub mod monitor;
pub mod registry;
pub mod pg;
pub mod link;
pub mod context;
pub mod gen_server;
pub mod gen_statem;
pub mod introspect;
pub mod io;
pub mod link;
pub mod monitor;
pub mod mutex;
#[cfg(feature = "observer")]
pub mod observer;
pub mod runtime;
pub(crate) mod park;
pub mod pg;
pub mod pid;
pub mod preempt;
pub(crate) mod raw_mutex;
pub(crate) mod slot_state;
pub(crate) mod sync_shim;
pub mod registry;
#[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures
pub mod run_queue;
pub mod runtime;
pub mod scheduler;
pub(crate) mod signal;
pub(crate) mod slot_state;
pub mod stack;
pub mod supervisor;
pub(crate) mod sync_shim;
pub mod timer;
pub mod trace;
// ---------------------------------------------------------------------------
@@ -56,32 +59,39 @@ pub use channel::{
};
pub use gen_server::{
call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer,
NamedGenServerBuilder, GenServerBuilder, GenServerCtx, GenServerName, GenServerRef, TimerHandle, Watcher,
GenServerBuilder, GenServerCtx, GenServerName, GenServerRef, NamedGenServerBuilder,
TimerHandle, Watcher,
};
pub use gen_statem::{
CallError as GenStatemCallError, Cx, Machine, Reply, Resolution, SendError as GenStatemSendError,
GenStatemRef,
CallError as GenStatemCallError, Cx, GenStatemRef, Machine, Reply, Resolution,
SendError as GenStatemSendError,
};
pub use introspect::{
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
TreeNode, SNAPSHOT_FORMAT_VERSION,
StackInfo, TreeNode, SNAPSHOT_FORMAT_VERSION,
};
pub use link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{
demonitor, mark_watchable, monitor, terminal_reason, Down, DownReason, Monitor, MonitorId,
};
pub use mutex::{LockTimeout, Mutex, MutexGuard};
#[cfg(feature = "observer")]
pub use observer::{ObserverReply, ObserverRequest};
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 pg::{
dispatch, join, leave, members, members_as, pick, pick_as, Incarnation, Member, NodeId,
};
pub use pid::{Addressable, Erased, Name, Pid, RawPid};
pub use pg::{dispatch, join, leave, members, members_as, pick, pick_as, Incarnation, Member, NodeId};
pub use registry::{
install, lookup_as, register, send, send_dyn, send_to, unregister, whereis, RegisterError,
SendError,
install, lookup_as, register, resolve_name, send, send_dyn, send_to, unregister, whereis,
NameResolution, RegisterError, SendError,
};
pub use runtime::{init, Config, Runtime};
pub use scheduler::{
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named, sleep,
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable,
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle,
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named,
send_after_named_wall, send_after_wall, sleep, sleep_wall, spawn, spawn_addr, spawn_addr_with,
spawn_under, spawn_under_with, spawn_with, try_spawn, try_spawn_under_with, wait_readable,
wait_readable_timeout, wait_writable, wait_writable_timeout, yield_now, FdArm, JoinError,
JoinHandle, SpawnError, SpawnOpts,
};
pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
pub use timer::TimerId;
+8 -2
View File
@@ -135,7 +135,10 @@ pub fn link<A>(target: Pid<A>) {
if registered_on_target {
with_runtime(|inner| {
let slot = inner.slot_at(me).expect("link: own slot vanished");
let slot = match inner.slot_at(me) {
Some(s) => s,
None => panic!("smarm: link own slot vanished (core corrupt)"),
};
let mut cold = slot.cold.lock();
if !cold.links.contains(&target) {
cold.links.push(target);
@@ -154,7 +157,10 @@ pub fn link<A>(target: Pid<A>) {
});
match my_trap {
Some(tx) => {
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc });
let _ = tx.send(ExitSignal {
from: target,
reason: DownReason::NoProc,
});
}
None => request_stop(me),
}
+165 -64
View File
@@ -1,49 +1,85 @@
//! Process monitors.
//! Find out when another actor dies, without it knowing or caring that you're
//! watching.
//!
//! `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:
//! Say one actor manages a pool of workers and needs to know when a worker
//! exits, so it can replace it. The worker does not need to know it is being
//! watched, and nothing about the worker's own behavior should change because
//! someone is watching it. That is what [`monitor`] is for: call
//! `monitor(target)` to get a [`Monitor`], and read exactly one [`Down`]
//! message off `monitor.rx` whenever `target` terminates, however it
//! terminates.
//!
//! - **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)`.
//! ```
//! use smarm::{monitor, run, spawn, DownReason};
//!
//! 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.
//! run(|| {
//! let worker = spawn(|| {
//! // does some work, then returns
//! });
//! let pid = worker.pid();
//!
//! ## Reasons
//! let m = monitor(pid);
//! let _ = worker.join();
//!
//! [`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`.
//! let down = m.rx.recv().expect("monitor channel closed before Down");
//! assert_eq!(down.pid, pid);
//! assert_eq!(down.reason, DownReason::Exit);
//! });
//! ```
//!
//! ## Demonitoring
//! A monitor is one-directional and one-shot:
//!
//! 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])`.
//! - **One-directional**: the watcher learns that the target died, but the
//! target is completely unaffected. It never learns it was being watched,
//! and its own behavior and lifetime do not change because of the monitor.
//! This is the opposite of a [`link`](mod@crate::link), which is bidirectional:
//! linking two actors means an abnormal death on either side can bring the
//! other down too. Reach for a monitor when you just want to *know*; reach
//! for a link when a peer's crash should actually stop you.
//! - **One-shot**: you get exactly one [`Down`] per `monitor()` call, then the
//! channel closes. Calling `monitor` again on the same target (or a
//! different one) gives you an independent registration with its own
//! [`Monitor`] and its own one-shot channel; nothing stops you from
//! monitoring the same actor many times over; each call is watched and
//! fires on its own.
//!
//! ## Races
//! ## Why a monitor never hands you the panic value
//!
//! 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.
//! If the target panicked, [`Down`] tells you *that* it panicked
//! ([`DownReason::Panic`]), but not the panic's payload. The payload has a
//! single owner: it is handed to whichever caller `join()`s the actor's
//! [`JoinHandle`](crate::JoinHandle), as a `JoinError`. A monitor only needs
//! to know that something went wrong, not reproduce the exact value that
//! caused it, so it gets the reason and nothing else.
//!
//! Monitoring a target that is already gone (it finished and was cleaned up,
//! or the pid never pointed at a real actor) is not an error: you get a
//! [`Down`] with [`DownReason::NoProc`] right away, instead of waiting
//! forever for something that already happened.
//!
//! ## Stopping a monitor early
//!
//! [`demonitor`] cancels a monitor before it fires. If the registration was
//! still live, it removes it and returns `Some` of the monitor's id: no
//! `Down` will arrive on that channel from here on. If the target had already
//! died and its `Down` already sent, there is nothing left to cancel and
//! `demonitor` returns `None`; the `Down` you already have (or that is
//! already sitting in the channel) is unaffected.
//!
//! If you want to cancel *and* make sure a `Down` that already arrived is
//! discarded without reading it, just drop the [`Monitor`]: dropping it closes
//! its receiver, and any queued `Down` is dropped along with it.
//!
//! ## Correctness notes for implementers
//!
//! A target that is still alive at the moment `monitor()` registers is
//! guaranteed to eventually produce a real `Down`: registration and the
//! target's own termination bookkeeping run under the same lock, so there is
//! no window in which the target could die without the just-added
//! registration seeing it. `demonitor` is similarly race-free against a target
//! that has since died and had its slot reused by a new, unrelated actor: it
//! is checked against the exact monitored incarnation, so it can never remove
//! a different actor's registration by accident, it simply reports `None`.
use crate::channel::{channel, Receiver, Sender};
use crate::pid::Pid;
@@ -51,8 +87,8 @@ 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.
/// Carries no payload: see the module docs for why a monitor never receives
/// the panic value itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownReason {
/// The target returned normally.
@@ -76,21 +112,22 @@ pub struct Down {
pub reason: DownReason,
}
/// A process-unique identifier for one `monitor()` registration.
/// A 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.
/// Opaque and `Copy`. Never reused for the life of the runtime, so if you
/// monitor the same target more than once, each call's id is distinct. This
/// is what lets [`demonitor`] tear down exactly one of several monitors on
/// the same target without disturbing the others.
#[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.
/// Read the notification from [`Monitor::rx`]. Not `Clone`, since only one
/// side is meant to consume it. Dropping a `Monitor` closes the receiving
/// end; if a `Down` had already arrived but was never read, it is discarded
/// along with it.
pub struct Monitor {
/// This registration's process-unique id.
pub id: MonitorId,
@@ -110,10 +147,11 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
let target = target.erase();
let (tx, rx) = channel::<Down>();
// Register under the target's cold lock. `tx.clone()` takes the channel's
// own lock — a Channel-class RawMutex, explicitly permitted *under* a Leaf
// (cold) lock by the lock order (see raw_mutex.rs). We must still not
// *send* under the lock, as `Sender::send` can unpark a parked receiver,
// Implementation note: registration happens under the target's cold
// lock. `tx.clone()` takes the channel's own lock, a Channel-class
// RawMutex, which is explicitly permitted under a Leaf (cold) lock by
// the lock order documented in raw_mutex.rs. We must still not *send*
// under the lock, since `Sender::send` can unpark a parked receiver,
// and there's no reason to nest that.
let (id, registered) = with_runtime(|inner| {
let id = inner.alloc_monitor_id();
@@ -133,26 +171,89 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
});
if !registered {
let _ = tx.send(Down { pid: target, reason: DownReason::NoProc });
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.
/// Flag `target`'s tenancy as watchable: its death will stamp the slot's
/// terminal record (see [`terminal_reason`]), exactly as registering a name
/// does. The bridge calls this wherever a smarm pid is *encoded across the
/// boundary* — a contract reply, an introspection listing — because BEAM can
/// only watch pids it holds, and can only hold pids that crossed. Keeping the
/// bit rare is what keeps the record alive: anonymous never-exported churn
/// (holder threads, egress tasks) stays ineligible and cannot evict a
/// watchable tenancy's record from a LIFO-recycled slot.
///
/// 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])`.
/// Generation-checked and live-screened: marking a pid whose tenancy already
/// ended is a no-op — its record either exists (it was flagged before dying)
/// or is honestly unknowable. Same `Runtime::run()` context contract as
/// [`monitor`].
pub fn mark_watchable<A>(target: Pid<A>) {
let target = target.erase();
with_runtime(|inner| {
if let Some(slot) = inner.slot_at(target) {
// Cold lock FIRST: finalize publishes Done and checks the
// watchable bit under this same lock, so the mark either lands
// before finalize reads it (the death stamps) or observes the
// tenancy already dead (no-op). No lost-stamp window between an
// unlocked liveness read and the flag set.
let mut cold = slot.cold.lock();
if slot.is_live_for(target) {
cold.watchable = true;
}
}
});
}
/// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy
/// ever registered a name and is the *most recent named* death of its slot:
/// finalize stamps the slot with `(generation, reason)` for once-registered
/// tenancies (anonymous green-thread churn does not stamp — nor evict), and
/// the record survives reclaim and the next tenant's install, until the next
/// *named* tenant of the slot itself dies. `None` means the pid never lived,
/// is still alive, never held a name, or its record was overwritten by a
/// later named tenancy's death — callers fall back to `NoProc` semantics.
///
/// This exists for watch-installers that raced their target's death (bridge
/// soak signature 4): a `NoProc` observed at install time can be upgraded to
/// the real reason while the record still matches, which is exactly what an
/// install that had won the race would have delivered. It does NOT change
/// [`monitor`]'s own semantics — monitoring a stale pid still queues `NoProc`,
/// the same shape Erlang gives — the upgrade is the caller's deliberate act.
/// Same context contract as [`monitor`]: must run inside `Runtime::run()`.
pub fn terminal_reason<A>(target: Pid<A>) -> Option<DownReason> {
let target = target.erase();
with_runtime(|inner| {
let slot = inner.slot_at(target)?;
let cold = slot.cold.lock();
match cold.terminal {
Some((generation, reason)) if generation == target.generation() => Some(reason),
_ => None,
}
})
}
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
/// and removed, so no `Down` will arrive on `m.rx` from here on. Returns
/// `None` if there was nothing left to remove: the target had already gone
/// down and its `Down` was already sent (or is already sitting in the
/// channel, unread).
///
/// This only stops a *future* `Down`. If you also want to discard a `Down`
/// that already arrived (or is about to, in a race with this call), drop `m`
/// instead of, or in addition to, calling this: dropping the [`Monitor`]
/// closes its receiver and any queued notice is discarded with it.
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
// Remove the registration under the target's cold 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 — legal under a cold lock, but pointless to nest.
// Implementation note: the registration is removed under the target's
// cold lock, but the `Sender` is moved *out* and dropped only after the
// lock is released. Dropping the last sender runs `Sender::drop`, which
// may unpark a parked receiver; legal under a cold lock, but pointless
// to nest.
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
let slot = inner.slot_at(m.target)?;
let mut cold = slot.cold.lock();
+236 -43
View File
@@ -1,12 +1,89 @@
//! Actor-aware mutex with mandatory timeout.
//! Shared mutable state across actors, when a channel is overkill.
//!
//! `Mutex<T>` parks the calling *green* thread on contention rather than
//! blocking the OS thread. Every lock attempt is bounded by a timeout.
//! smarm actors normally coordinate by sending messages, and for a piece of
//! owned state the right tool is usually a `gen_server`: one actor holds the
//! data and everyone else talks to it. Sometimes that is more machinery than
//! you need, and plain shared, lockable state is simpler: [`Mutex<T>`] is
//! that escape hatch. It behaves like `std::sync::Mutex<T>`, guarding a value
//! of type `T` behind a guard that gives you `&mut T` while held, but it is
//! built for smarm's actors rather than OS threads.
//!
//! Internals use `Arc<std::sync::Mutex<...>>` so the type is genuinely
//! `Send + Sync` and can be shared across scheduler threads.
//! The key difference from `std::sync::Mutex` is what happens on contention.
//! [`Mutex::lock`] parks the calling actor (a cooperatively scheduled green
//! thread) rather than blocking the underlying OS thread, so other actors on
//! the same OS thread keep running while it waits. And every lock attempt is
//! bounded by a timeout: an actor that hangs on to the lock forever (stuck in
//! a bug, or just slow) would otherwise wedge every other actor waiting on
//! it, so smarm makes the wait bounded by default instead of leaving it up
//! to you to remember.
//!
//! Fairness: FIFO. Poisoning: none. Reentrance: deadlock (caller bug).
//! ## A first lock
//!
//! ```
//! use smarm::{run, spawn, Mutex};
//!
//! run(|| {
//! let counter = Mutex::new(0u32);
//!
//! // Mutex::clone() is cheap and hands out another handle to the SAME
//! // underlying value, much like Arc::clone: every clone shares one lock
//! // and one value, so mutations through one are visible through all.
//! let a = counter.clone();
//! let b = counter.clone();
//!
//! let h1 = spawn(move || {
//! let mut guard = a.lock().unwrap();
//! *guard += 1;
//! });
//! let h2 = spawn(move || {
//! let mut guard = b.lock().unwrap();
//! *guard += 1;
//! });
//! h1.join().unwrap();
//! h2.join().unwrap();
//!
//! assert_eq!(*counter.lock().unwrap(), 2);
//! });
//! ```
//!
//! ## Choosing a timeout
//!
//! [`Mutex::lock`] waits up to [`DEFAULT_TIMEOUT`] (30 seconds) before giving
//! up with [`LockTimeout`]. To use a different bound for one call, use
//! [`Mutex::lock_timeout`] instead; to change the default for every future
//! `lock()` call on this mutex (including through its clones), use
//! [`Mutex::set_default_timeout`]. If you never want to wait at all, use
//! [`Mutex::try_lock`], which returns immediately whether or not the lock was
//! free.
//!
//! ## Fairness and panics
//!
//! Waiters are granted the lock in the order they started waiting (FIFO), so
//! no actor can be starved by later arrivals repeatedly cutting in line.
//!
//! This mutex never poisons. `std::sync::Mutex` marks itself poisoned if a
//! thread panics while holding the lock, because a partly mutated value might
//! be left behind for the next lock holder to see. smarm's actors already
//! rely on `Drop` running during unwinding to release the lock, so if a
//! holder panics, [`MutexGuard::drop`] still runs and the next waiter is
//! granted the lock normally. It is the same tradeoff `std::sync::Mutex`
//! offers you if you choose to ignore poisoning: you may see a value left
//! mid-update by the panicking actor, so a panic inside a critical section is
//! still a bug worth fixing, just not one that also wedges every future lock
//! attempt.
//!
//! Locking a mutex you already hold (on the same actor) does not queue
//! behind yourself: it deadlocks, the same way relocking a non-reentrant
//! `std::sync::Mutex` does. Don't call `lock` while already holding a guard
//! from the same `Mutex`.
//!
//! ## Outside the runtime
//!
//! `Mutex<T>` also works when called from plain code that is not running as
//! a smarm actor (for example, in a test's setup code before calling
//! [`run`](crate::run)). There, an actor's cooperative park has no meaning,
//! so a lock attempt instead blocks the calling OS thread directly until the
//! mutex is free; there is no timeout on this path.
use crate::pid::Pid;
use crate::scheduler;
@@ -15,8 +92,14 @@ use std::collections::VecDeque;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
/// How long [`Mutex::lock`] waits for the lock before giving up, unless
/// overridden per-mutex with [`Mutex::set_default_timeout`] or per-call with
/// [`Mutex::lock_timeout`].
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// Returned by [`Mutex::lock`] / [`Mutex::lock_timeout`] when the timeout
/// elapses before the lock became available. The lock attempt is abandoned;
/// nothing was acquired, and the mutex's value is unaffected.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct LockTimeout;
@@ -64,20 +147,27 @@ impl MutexCore {
impl TimerTarget for MutexCore {
fn on_timeout(&self, pid: Pid, epoch: u32) {
let unpark = {
let mut st = self.state.lock().unwrap();
let mut st = match self.state.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
// Remove from waiters only if still there with matching epoch.
// If the lock was already granted (holder == Some(pid)), the
// timer fired after the grant — treat as no-op; the actor
// timer fired after the grant: treat as no-op; the actor
// will see `is_holder == true` and return Ok.
if st.holder == Some(pid) {
return;
}
let pos = st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch);
if pos.is_some() {
st.waiters.remove(pos.unwrap());
true
} else {
false
match st
.waiters
.iter()
.position(|w| w.pid == pid && w.epoch == epoch)
{
Some(pos) => {
st.waiters.remove(pos);
true
}
None => false,
}
};
if unpark {
@@ -97,6 +187,8 @@ pub struct Mutex<T> {
}
impl<T> Mutex<T> {
/// Wrap `value` in a new mutex, initially unlocked, with the default
/// lock timeout ([`DEFAULT_TIMEOUT`]).
pub fn new(value: T) -> Self {
Self {
core: Arc::new(MutexCore::new(DEFAULT_TIMEOUT)),
@@ -104,15 +196,36 @@ impl<T> Mutex<T> {
}
}
/// Change how long future [`lock`](Self::lock) calls on this mutex wait
/// before giving up. Applies to every clone of this `Mutex` (they share
/// one underlying lock), and to `lock` calls already in progress that
/// have not yet started waiting. Does not affect [`lock_timeout`](Self::lock_timeout)
/// calls, which always use the timeout passed in.
pub fn set_default_timeout(&self, timeout: Duration) {
self.core.state.lock().unwrap().default_timeout = timeout;
match self.core.state.lock() {
Ok(mut st) => st.default_timeout = timeout,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
}
}
/// Acquire the lock, waiting up to this mutex's default timeout
/// ([`DEFAULT_TIMEOUT`], or whatever [`set_default_timeout`](Self::set_default_timeout)
/// last set) if it is currently held elsewhere. Returns a [`MutexGuard`]
/// that releases the lock when dropped, or [`LockTimeout`] if the
/// deadline passes first. To use a one-off timeout instead of the
/// mutex's default, call [`lock_timeout`](Self::lock_timeout) directly.
pub fn lock(&self) -> Result<MutexGuard<'_, T>, LockTimeout> {
let timeout = self.core.state.lock().unwrap().default_timeout;
let timeout = match self.core.state.lock() {
Ok(st) => st.default_timeout,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
self.lock_timeout(timeout)
}
/// Acquire the lock, waiting up to `timeout` (ignoring this mutex's
/// default) if it is currently held elsewhere. Returns a [`MutexGuard`]
/// that releases the lock when dropped, or [`LockTimeout`] if `timeout`
/// elapses first with the lock still unavailable.
pub fn lock_timeout(&self, timeout: Duration) -> Result<MutexGuard<'_, T>, LockTimeout> {
// Outside the runtime (e.g. in tests, after run() returns) there is no
// current actor PID. Fall back to a blocking std::sync::Mutex acquire.
@@ -122,21 +235,36 @@ impl<T> Mutex<T> {
// Fast path: nobody holds it.
{
let mut st = self.core.state.lock().unwrap();
let mut st = match self.core.state.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
if st.holder.is_none() {
st.holder = Some(me);
drop(st);
let value = self.value.lock().unwrap().take()
.expect("Mutex: value missing on free fast path");
return Ok(MutexGuard { mutex: self, value: Some(value) });
let taken = match self.value.lock() {
Ok(mut g) => g.take(),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
};
let value = match taken {
Some(v) => v,
None => panic!("smarm: Mutex value missing on free fast path (core corrupt)"),
};
return Ok(MutexGuard {
mutex: self,
value: Some(value),
});
}
}
// Slow path: register as a waiter, set timeout, park.
let _np = scheduler::NoPreempt::enter();
let epoch = {
let mut st = self.core.state.lock().unwrap();
// begin_wait is lock-free — legal under the state lock; this
let mut st = match self.core.state.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
// begin_wait is lock-free (legal under the state lock); this
// makes the epoch atomic with the registration's visibility to
// grants and timeouts.
let epoch = scheduler::begin_wait();
@@ -149,31 +277,58 @@ impl<T> Mutex<T> {
scheduler::insert_wait_timer(deadline, me, target, epoch);
scheduler::park_current();
// Resumed — precisely: only our grant or our timer can wake this
// Resumed, precisely: only our grant or our timer can wake this
// wait (both epoch-stamped; a stop wake unwinds out of
// park_current). The one-shot interpretation below is therefore
// exhaustive. Are we the holder?
let is_holder = self.core.state.lock().unwrap().holder == Some(me);
let is_holder = match self.core.state.lock() {
Ok(st) => st.holder == Some(me),
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
if is_holder {
let value = self.value.lock().unwrap().take()
.expect("Mutex: value missing after grant");
Ok(MutexGuard { mutex: self, value: Some(value) })
let taken = match self.value.lock() {
Ok(mut g) => g.take(),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
};
let value = match taken {
Some(v) => v,
None => panic!("smarm: Mutex value missing after grant (core corrupt)"),
};
Ok(MutexGuard {
mutex: self,
value: Some(value),
})
} else {
Err(LockTimeout)
}
}
/// Acquire the lock only if it is immediately available: never parks and
/// never waits. Returns `Some` with a [`MutexGuard`] if the lock was
/// free, `None` if it is currently held elsewhere.
pub fn try_lock(&self) -> Option<MutexGuard<'_, T>> {
let me = crate::actor::current_pid()?;
let mut st = self.core.state.lock().unwrap();
let mut st = match self.core.state.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
if st.holder.is_some() {
return None;
}
st.holder = Some(me);
drop(st);
let value = self.value.lock().unwrap().take()
.expect("Mutex: value missing on try_lock free path");
Some(MutexGuard { mutex: self, value: Some(value) })
let taken = match self.value.lock() {
Ok(mut g) => g.take(),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
};
let value = match taken {
Some(v) => v,
None => panic!("smarm: Mutex value missing on try_lock free path (core corrupt)"),
};
Some(MutexGuard {
mutex: self,
value: Some(value),
})
}
/// Blocking fallback used when called outside the smarm runtime.
@@ -183,17 +338,32 @@ impl<T> Mutex<T> {
// tracking and just grab the value mutex directly. This is safe because
// outside the runtime there are no green threads competing.
let value = loop {
let v = self.value.lock().unwrap().take();
if let Some(v) = v { break v; }
let v = match self.value.lock() {
Ok(mut g) => g.take(),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
};
if let Some(v) = v {
break v;
}
std::thread::yield_now();
};
Ok(MutexGuard { mutex: self, value: Some(value) })
Ok(MutexGuard {
mutex: self,
value: Some(value),
})
}
}
impl<T> Clone for Mutex<T> {
/// Cheap: hands back another handle to the same underlying lock and
/// value, the way `Arc::clone` does. All clones of a `Mutex` share one
/// lock and one protected value; locking through any clone excludes
/// every other clone.
fn clone(&self) -> Self {
Self { core: self.core.clone(), value: self.value.clone() }
Self {
core: self.core.clone(),
value: self.value.clone(),
}
}
}
@@ -205,6 +375,10 @@ unsafe impl<T: Send> Sync for Mutex<T> {}
// Guard
// ---------------------------------------------------------------------------
/// Grants access to the value inside a [`Mutex`] while the lock is held.
/// Dereferences to `&T` and `&mut T`. Dropping the guard releases the lock
/// and, if another actor is waiting, wakes the next one in arrival order.
/// Returned by [`Mutex::lock`], [`Mutex::lock_timeout`], and [`Mutex::try_lock`].
pub struct MutexGuard<'a, T> {
mutex: &'a Mutex<T>,
value: Option<T>,
@@ -212,30 +386,49 @@ pub struct MutexGuard<'a, T> {
impl<T> std::ops::Deref for MutexGuard<'_, T> {
type Target = T;
fn deref(&self) -> &T { self.value.as_ref().expect("MutexGuard: value missing") }
fn deref(&self) -> &T {
match self.value.as_ref() {
Some(v) => v,
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
}
}
}
impl<T> std::ops::DerefMut for MutexGuard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
self.value.as_mut().expect("MutexGuard: value missing")
match self.value.as_mut() {
Some(v) => v,
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
}
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for MutexGuard<'_, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("MutexGuard")
.field(self.value.as_ref().expect("MutexGuard: value missing"))
.finish()
let value = match self.value.as_ref() {
Some(v) => v,
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
};
f.debug_tuple("MutexGuard").field(value).finish()
}
}
impl<T> Drop for MutexGuard<'_, T> {
fn drop(&mut self) {
let v = self.value.take().expect("MutexGuard: double drop");
*self.mutex.value.lock().unwrap() = Some(v);
let v = match self.value.take() {
Some(v) => v,
None => panic!("smarm: MutexGuard double drop (core corrupt)"),
};
match self.mutex.value.lock() {
Ok(mut g) => *g = Some(v),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
}
let next = {
let mut st = self.mutex.core.state.lock().unwrap();
let mut st = match self.mutex.core.state.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
match st.waiters.pop_front() {
Some(w) => {
st.holder = Some(w.pid);
+1017
View File
File diff suppressed because it is too large Load Diff
+208 -107
View File
@@ -1,15 +1,17 @@
//! Process groups — a `name → multiset<Member>` map (RFC 012).
//! Process groups: one name, many actors.
//!
//! Sits parallel to [`registry`](crate::registry), not on top of it. The
//! registry is a *bimap*: at most one pid per name. A group is the opposite —
//! many pids per name, the same pid in many groups — so it cannot be a
//! generalization of the bimap; it is its own keyspace sharing only the
//! liveness signal source (monitors) and the lock *class*.
//! A process group is a named set of actors that you can look up, fan out to,
//! or pick a worker from. It is the natural home for a *worker pool* (several
//! interchangeable actors doing the same job), for *service discovery* (find
//! everyone currently offering some capability), and for *broadcast* (reach
//! every member of a group at once).
//!
//! ## Example
//! The set is *live*: members [`join`] it, and a member that dies is removed
//! automatically. You never deregister a dead actor — there is no bookkeeping
//! to get wrong, and [`members`] / [`pick`] never hand you an actor that has
//! already gone.
//!
//! A group is a live membership view: members join, and a member that dies is
//! evicted automatically — no deregistration call, no bookkeeping.
//! ## Joining and reading a group
//!
//! ```
//! use smarm::{channel, join, leave, members, pick, run, spawn};
@@ -25,8 +27,8 @@
//! join("pool", w2.pid());
//! assert_eq!(members("pool").len(), 2);
//!
//! // One worker dies. Nobody told the group — the death hook evicts it,
//! // so it is gone from `members` and never returned by `pick`.
//! // One worker dies. Nothing tells the group — smarm evicts it
//! // automatically, so it is gone from `members` and never picked.
//! tx1.send(()).unwrap();
//! w1.join().unwrap();
//! assert_eq!(members("pool"), vec![w2.pid()]);
@@ -41,60 +43,72 @@
//! });
//! ```
//!
//! ## Cleanup is eager and monitor-driven (unlike the registry)
//! [`members`] returns every live member in the order they joined; [`pick`]
//! returns one of them, or `None` when the group is empty. The same actor can
//! belong to any number of groups at once, and joining a group it is already in
//! is a harmless no-op.
//!
//! The registry prunes a stale binding lazily, on contact, because it only
//! ever resolves one binding at a time. A group is *iterated* — `members` fans
//! out to every member — so it must not carry dead members across a broadcast.
//! Each [`join`] installs a `monitor(pid)` and keeps the resulting [`Monitor`]
//! *alongside the group entry*; the actor's one-shot `Down` lands in that
//! monitor's channel when it dies. Every group operation [`reap`s][reap] the
//! group it touches first — draining each membership's monitor with a
//! non-blocking `try_recv` — and on the first sign of death sweeps that pid out
//! of *every* group via the predicate primitive. So the set is self-pruning on
//! contact rather than filtered on read; the read path additionally applies a
//! generation-checked liveness backstop so a member already dead but not yet
//! reaped is never *returned*, even though eviction remains the monitor's job.
//! ## Membership ends on its own
//!
//! [reap]: ProcessGroups::reap_group
//! You do not have to clean up after a member that dies. When an actor exits —
//! for any reason — it is removed from every group it had joined, before any
//! later read or send can observe it. [`leave`] is only for *voluntary*
//! departure, when a still-living actor wants out of a group.
//!
//! ## Eviction is one dumb primitive
//! This is the main difference from keeping your own `Vec<Pid>`: a plain list
//! goes stale the instant a member dies, and you would have to notice and prune
//! it yourself. A group prunes itself.
//!
//! [`ProcessGroups::remove_where`] removes every member matching a predicate
//! from every group. The primitive never knows *why* a member leaves — that is
//! the caller's concern. Its first caller is the monitor death hook (this RFC,
//! via `reap_group`); the later `evict_incarnation(node, inc)` sweep (RFC 010)
//! reuses the same predicate path, which is the whole reason to shape it as a
//! predicate.
//! ## Sending to a group
//!
//! ## Identity is cluster-shaped from the first commit
//! For a worker pool you usually want to hand a job to *one* available member.
//! [`dispatch`] picks a live member and sends it a message in a single step,
//! returning the member it reached:
//!
//! A [`Member`] is not a bare [`Pid`]: it is `(NodeId, Incarnation, Pid)`, a
//! field-for-field image of a modern BEAM pid (`NEW_PID_EXT`) so the eventual
//! ETF codec (RFC 011) is a near-identity mapping. In this RFC `NodeId` and
//! `Incarnation` are runtime-init constants — one node, one fixed incarnation
//! — threaded through storage and eviction anyway so the public surface never
//! has to change to acquire them once clustering (RFC 010) supplies real
//! values. No cluster types leak out of the public functions: callers pass and
//! receive [`Pid`]; the node/incarnation are filled from runtime identity.
//! ```ignore
//! use smarm::{dispatch, join, Addressable};
//!
//! ## Locking
//! struct Job(String);
//! struct Worker;
//! impl Addressable for Worker { type Msg = Job; }
//!
//! One `RawMutex` (Leaf class) on `RuntimeInner`, mirroring `registry`. The
//! group lock is never held with another Leaf (it never touches the registry
//! or a slot's cold lock). The two places that *do* need another lock are kept
//! off the group-lock path:
//! // Each worker has published a `Pid<Worker>` inbox and joined the pool.
//! join("workers", worker_a);
//! join("workers", worker_b);
//!
//! - `monitor()` / `demonitor()` acquire the target's cold lock (Leaf) and
//! so run *before* / *after* the group lock, never under it.
//! - draining a monitor with `try_recv` takes the channel's Channel-class
//! lock — permitted *under* a Leaf by the lock order (`raw_mutex.rs`), and
//! a channel critical section only does the lock-free unpark protocol, so
//! no Leaf is ever nested under it.
//! // Route one job to whichever live worker `pick` lands on.
//! match dispatch::<Worker>("workers", Job("resize image".into())) {
//! Ok(who) => println!("sent to {who:?}"),
//! Err(returned) => println!("no worker took it: {returned:?}"),
//! }
//! ```
//!
//! Evicted and rejected [`Monitor`]s are dropped only *after* the group lock is
//! released, so a receiver-drop never runs a wakeup under the lock (same
//! discipline as `demonitor`).
//! When you want the pids themselves rather than to send right away, [`pick_as`]
//! and [`members_as`] return typed [`Pid<A>`](Pid)s for a homogeneous group, so
//! the follow-up send stays compile-checked. The untyped [`pick`] and
//! [`members`] are for mixed groups, where all you can rely on is identity.
//!
//! ## Groups vs. the registry
//!
//! A group is the many-actors counterpart to the [`registry`](crate::registry).
//! The registry binds a name to *at most one* actor and re-resolves it on every
//! send — what you want for a single well-known service. A group binds a name to
//! *many* actors, and one actor may sit in many groups. Reach for the registry
//! when there is exactly one of something; reach for a group when there is a set.
//!
//! ## Identity and clustering
//!
//! A group member is described by a [`Member`] — a [`Pid`] plus a [`NodeId`] and
//! an [`Incarnation`]. Today everything is single-node, those two fields are
//! fixed defaults, and you only ever pass and receive a plain [`Pid`]: the extra
//! identity is carried so this API will not have to change when groups learn to
//! span a cluster.
//!
//! ## Running context
//!
//! Every function here addresses the current runtime, so each must be called
//! from inside [`run`](crate::run) (that is, on an actor thread). Calling one
//! from outside a running runtime panics.
use crate::monitor::{demonitor, monitor, Monitor};
use crate::pid::{assert_type, Addressable, Pid};
@@ -103,7 +117,7 @@ use crate::scheduler::with_runtime;
use std::collections::HashMap;
/// A cluster node handle. A `u32` integer handle, *not* an interned atom — the
/// single deliberate divergence from the BEAM wire shape (RFC 011 names it).
/// single deliberate divergence from the BEAM wire shape.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct NodeId(u32);
@@ -149,9 +163,8 @@ impl From<u32> for Incarnation {
}
}
/// The fixed single-node identity used until clustering (RFC 010) supplies
/// real values. Carried like `wake_slot` so the public API never has to change
/// to acquire it.
/// The fixed single-node identity used until clustering supplies real values.
/// Carried like `wake_slot` so the public API never has to change to acquire it.
pub const DEFAULT_NODE_ID: NodeId = NodeId(0);
/// The fixed incarnation for the single-node default. Non-zero so it never
/// collides with a BEAM "any creation" wildcard at interop time.
@@ -161,7 +174,7 @@ pub const DEFAULT_INCARNATION: Incarnation = Incarnation(1);
///
/// Deliberately a field-for-field image of a modern BEAM pid (`NEW_PID_EXT`).
/// In memory it is a plain struct — no wire packing; the packed representation
/// belongs to the `RemoteRef` boundary (RFC 010/011), not here.
/// belongs to the remote-reference boundary, not here.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct Member {
/// Which node the pid lives on. `DEFAULT_NODE_ID` while single-node.
@@ -174,7 +187,7 @@ pub struct Member {
}
/// One membership: a [`Member`] and the [`Monitor`] that watches its liveness.
/// The monitor lives *alongside* the group entry (RFC 012) so a group is
/// The monitor lives *alongside* the group entry so a group is
/// self-contained: draining the membership tells us whether the member is
/// still alive, and dropping the membership drops its monitor.
struct Membership {
@@ -185,14 +198,32 @@ struct Membership {
/// The store: `name → multiset<Member>`. Within a single group a `Member`
/// appears at most once (`join` is idempotent); the *multiset* framing is for
/// cluster-readiness — the same pid is freely a member of many groups, and the
/// width admits multiples in general. Held under one Leaf-class `RawMutex`.
/// width admits multiples in general.
///
/// Locking discipline. Held under one Leaf-class `RawMutex` on `RuntimeInner`,
/// mirroring the registry, and never held together with another Leaf lock (it
/// never touches the registry or a slot's cold lock). The two operations that
/// do need another lock are kept off the group-lock path:
///
/// - `monitor()` / `demonitor()` take the target's cold lock (also Leaf), so
/// they run *before* / *after* the group lock, never under it.
/// - draining a monitor with `try_recv` takes the channel's Channel-class
/// lock, which the lock order permits *under* a Leaf; a channel critical
/// section only does the lock-free unpark protocol, so no Leaf ever nests
/// under it.
///
/// Evicted and rejected [`Monitor`]s are therefore dropped only *after* the
/// group lock is released, so a receiver-drop never runs a wakeup under the
/// lock — the same discipline as `demonitor`.
pub(crate) struct ProcessGroups {
groups: HashMap<String, Vec<Membership>>,
}
impl ProcessGroups {
pub(crate) fn new() -> Self {
Self { groups: HashMap::new() }
Self {
groups: HashMap::new(),
}
}
/// Insert `ms` into `group`. Idempotent on the *member*: if the member is
@@ -223,9 +254,11 @@ impl ProcessGroups {
/// The one dumb eviction primitive: drop every member matching `pred` from
/// every group, pruning emptied groups, and return the evicted memberships'
/// monitors for the caller to drop outside the lock. The primitive does not
/// know *why* a member leaves. Callers: the death hook (`reap_group`) and,
/// later, `evict_incarnation` (RFC 010), over the same path. Insertion
/// order within a group is preserved (`members` / `pick` are order-stable).
/// know *why* a member leaves; that is the caller's concern. Its callers are
/// the death hook (`reap_group`) and, once clustering lands, an
/// incarnation-eviction sweep — both over this same predicate path, which is
/// the whole reason to shape eviction as a predicate. Insertion order within
/// a group is preserved (`members` / `pick` are order-stable).
fn remove_where(&mut self, mut pred: impl FnMut(&Member) -> bool) -> Vec<Monitor> {
let mut evicted = Vec::new();
self.groups.retain(|_, v| {
@@ -242,12 +275,18 @@ impl ProcessGroups {
evicted
}
/// Drain-on-contact death hook. Drains every membership monitor in `group`
/// with a non-blocking `try_recv`: a delivered `Down` (any reason) or a
/// closed channel means that member is dead. On the first death detected,
/// sweep *all* of the dead pids out of *every* group via [`remove_where`]
/// — a death is removed from each group it joined, not just the one being
/// touched. Returns the evicted monitors to drop outside the lock.
/// Drain-on-contact death hook. The registry can prune a stale binding
/// lazily, on contact, because it only ever resolves one binding at a time;
/// a group is *iterated* — `members` fans out to everyone — so it must not
/// carry a dead member across a broadcast. Every group operation reaps the
/// group it touches first.
///
/// Drains every membership monitor in `group` with a non-blocking
/// `try_recv`: a delivered `Down` (any reason) or a closed channel means
/// that member is dead. On the first death detected, sweep *all* of the
/// dead pids out of *every* group via [`remove_where`] — a death is removed
/// from each group it joined, not just the one being touched. Returns the
/// evicted monitors to drop outside the lock.
fn reap_group(&mut self, group: &str) -> Vec<Monitor> {
let dead: Vec<Pid> = {
let Some(v) = self.groups.get(group) else {
@@ -279,27 +318,40 @@ impl ProcessGroups {
}
/// Live members of `group`, in insertion order. The `is_live` oracle is the
/// read-path backstop (Phase 3): a member whose slot is already dead is
/// read-path backstop: a member whose slot is already dead is
/// dropped from the *result* even if its `Down` has not been drained yet.
/// Backstop only — the entry stays in storage; eviction is the monitor's
/// job (`reap_group`).
fn members_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Vec<Pid> {
self.groups
.get(group)
.map(|v| v.iter().map(|e| e.member.pid).filter(|&p| is_live(p)).collect())
.map(|v| {
v.iter()
.map(|e| e.member.pid)
.filter(|&p| is_live(p))
.collect()
})
.unwrap_or_default()
}
/// The first live member of `group` in insertion order — stateless
/// first-live `pick`, with the same read-path backstop as `members_where`.
fn first_member_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Option<Pid> {
self.groups.get(group)?.iter().map(|e| e.member.pid).find(|&p| is_live(p))
self.groups
.get(group)?
.iter()
.map(|e| e.member.pid)
.find(|&p| is_live(p))
}
}
/// Build the full member identity for `pid` from runtime identity.
fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member {
Member { node: inner.node_id, incarnation: inner.incarnation, pid }
Member {
node: inner.node_id,
incarnation: inner.incarnation,
pid,
}
}
/// Is `pid` a live actor right now? Generation-checked atomic slot-word read,
@@ -314,22 +366,26 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
/// pid is a member at most once (idempotent). Returns `true` if this call newly
/// added the membership, `false` if it was already a member.
///
/// Installs a `monitor(pid)` whose one-shot `Down` drives eviction: the
/// registration races `finalize_actor` under the slot's cold lock exactly as
/// every other monitor does, so no death slips between the join and the
/// registration. A redundant (idempotent) join tears its extra monitor back
/// down.
/// Installs a monitor on `pid` so the actor's death evicts it from the group
/// automatically — you never have to remove a dead member yourself. A redundant
/// (idempotent) join tears its extra monitor back down.
///
/// Panics if called outside `Runtime::run()`.
pub fn join<A>(group: impl Into<String>, pid: Pid<A>) -> bool {
let group = group.into();
let pid = pid.erase();
// Install the monitor BEFORE taking the group lock: monitor() acquires the
// target's cold lock (Leaf), and two Leaf locks are never held at once.
// target's cold lock (Leaf), and two Leaf locks are never held at once. The
// registration races `finalize_actor` under that cold lock exactly as every
// other monitor does, so no death can slip between the join and the monitor
// being in place.
let mon = monitor(pid);
let (rejected, reaped) = with_runtime(|inner| {
let ms = Membership { member: member_for(inner, pid), monitor: mon };
let ms = Membership {
member: member_for(inner, pid),
monitor: mon,
};
let mut pg = inner.process_groups.lock();
let reaped = pg.reap_group(&group);
let rejected = pg.join(&group, ms);
@@ -372,12 +428,13 @@ pub fn leave<A>(group: &str, pid: Pid<A>) -> bool {
}
}
/// Fan-out read: every live member of `group`.
/// Every live member of `group`, in the order they joined. Returns an empty
/// vector if the group does not exist or has no live members.
///
/// The touched group is reaped first, so dead members are evicted before the
/// read. The generation-checked liveness read is a belt-and-braces backstop:
/// even in the finalize window where a member is already dead but its `Down`
/// has not yet landed, it is dropped from the result.
/// Dead members are never returned: the group is pruned of anything that has
/// died before the read, and as a backstop a member whose slot is already dead
/// is dropped from the result even in the brief window before its death has
/// been fully processed.
///
/// Panics if called outside `Runtime::run()`.
pub fn members(group: &str) -> Vec<Pid> {
@@ -391,11 +448,10 @@ pub fn members(group: &str) -> Vec<Pid> {
pids
}
/// Pool/discovery read: one live member of `group`, or `None` if empty.
/// Stateless first-live selection: the touched group is reaped first, and the
/// generation-checked liveness backstop skips any member already dead but not
/// yet reaped. Smarter routing is an explicitly later, clustered concern per
/// RFC 010.
/// One live member of `group`, or `None` if the group is empty (or every
/// member has died). Selection is a stateless first-live scan in join order,
/// with the same dead-member backstop as [`members`]; smarter, load-aware
/// routing is a later, clustered concern.
///
/// Panics if called outside `Runtime::run()`.
pub fn pick(group: &str) -> Option<Pid> {
@@ -409,11 +465,11 @@ pub fn pick(group: &str) -> Option<Pid> {
picked
}
/// Typed `pick`: one live member of `group` as a [`Pid<A>`] (RFC 014 §4.4).
/// Typed [`pick`]: one live member of `group` as a [`Pid<A>`](Pid).
/// For a homogeneous pool every member is an `A`, so the picked member comes
/// back typed and dispatch is an ordinary compile-checked [`send_to`] rather
/// than the [`send_dyn`](crate::send_dyn) escape hatch. Re-types via the
/// unchecked [`assert_type`] primitive — a wrong `A` degrades to
/// unchecked `assert_type` primitive — a wrong `A` degrades to
/// [`SendError::NoChannel`] on the next send, never a misdelivery.
///
/// Panics if called outside `Runtime::run()`.
@@ -469,7 +525,11 @@ mod tests {
let (tx, rx) = channel::<Down>();
let ms = Membership {
member: member(index, generation),
monitor: Monitor { id: MonitorId(0), target: pid, rx },
monitor: Monitor {
id: MonitorId(0),
target: pid,
rx,
},
};
(ms, tx)
}
@@ -480,7 +540,10 @@ mod tests {
let (a, _ta) = synth(1, 0);
let (b, _tb) = synth(1, 0);
assert!(pg.join("workers", a).is_none(), "first join inserts");
assert!(pg.join("workers", b).is_some(), "second identical join is handed back");
assert!(
pg.join("workers", b).is_some(),
"second identical join is handed back"
);
assert_eq!(pg.members_of("workers"), vec![member(1, 0)]);
}
@@ -504,7 +567,10 @@ mod tests {
let (a, _ta) = synth(1, 0);
let (b, _tb) = synth(1, 1);
assert!(pg.join("g", a).is_none());
assert!(pg.join("g", b).is_none(), "different generation is a distinct member");
assert!(
pg.join("g", b).is_none(),
"different generation is a distinct member"
);
assert_eq!(pg.members_of("g"), vec![member(1, 0), member(1, 1)]);
}
@@ -517,16 +583,27 @@ mod tests {
pg.join("g", b);
assert!(pg.leave("g", member(1, 0)).is_some());
assert_eq!(pg.members_of("g"), vec![member(2, 0)]);
assert!(pg.leave("g", member(1, 0)).is_none(), "second leave finds nothing");
assert!(
pg.leave("g", member(1, 0)).is_none(),
"second leave finds nothing"
);
assert!(pg.leave("g", member(2, 0)).is_some());
assert!(pg.members_of("g").is_empty(), "group is now empty");
assert!(pg.leave("never", member(9, 0)).is_none(), "leaving an unknown group is a no-op");
assert!(
pg.leave("never", member(9, 0)).is_none(),
"leaving an unknown group is a no-op"
);
}
#[test]
fn remove_where_sweeps_every_group() {
let mut pg = ProcessGroups::new();
for (g, (m, _t)) in [("a", synth(1, 0)), ("a", synth(2, 0)), ("b", synth(1, 0)), ("c", synth(3, 0))] {
for (g, (m, _t)) in [
("a", synth(1, 0)),
("a", synth(2, 0)),
("b", synth(1, 0)),
("c", synth(3, 0)),
] {
pg.join(g, m);
}
// Death of pid index 1 (any generation) evicts it everywhere.
@@ -544,8 +621,16 @@ mod tests {
let pid = Pid::new(1, 0);
let (tx, rx) = channel::<Down>();
let dead = Membership {
member: Member { node: DEFAULT_NODE_ID, incarnation: Incarnation::new(7), pid },
monitor: Monitor { id: MonitorId(0), target: pid, rx },
member: Member {
node: DEFAULT_NODE_ID,
incarnation: Incarnation::new(7),
pid,
},
monitor: Monitor {
id: MonitorId(0),
target: pid,
rx,
},
};
let _keep = tx;
let (live, _tl) = synth(2, 0);
@@ -576,9 +661,17 @@ mod tests {
pg.join("b", b1);
// pid 1 dies: its group-a monitor receives a Down. Its group-b monitor
// has not — reap must still sweep pid 1 out of b by the pid predicate.
ta1.send(Down { pid: Pid::new(1, 0), reason: DownReason::Exit }).unwrap();
ta1.send(Down {
pid: Pid::new(1, 0),
reason: DownReason::Exit,
})
.unwrap();
let evicted = pg.reap_group("a");
assert_eq!(evicted.len(), 2, "pid 1's memberships in both a and b are evicted");
assert_eq!(
evicted.len(),
2,
"pid 1's memberships in both a and b are evicted"
);
assert_eq!(pg.members_of("a"), vec![member(2, 0)]);
assert!(pg.members_of("b").is_empty(), "swept from b too; pruned");
}
@@ -608,8 +701,16 @@ mod tests {
let dead = Pid::new(1, 0);
let oracle = |pid: Pid| pid != dead;
assert_eq!(pg.members_where("g", oracle), vec![Pid::new(2, 0)], "dead pid filtered from read");
assert_eq!(pg.first_member_where("g", oracle), Some(Pid::new(2, 0)), "pick skips the dead first member");
assert_eq!(
pg.members_where("g", oracle),
vec![Pid::new(2, 0)],
"dead pid filtered from read"
);
assert_eq!(
pg.first_member_where("g", oracle),
Some(Pid::new(2, 0)),
"pick skips the dead first member"
);
// Backstop does not evict — that stays the monitor's job; raw storage
// still holds both until reap runs.
+12 -3
View File
@@ -79,7 +79,10 @@ impl Pid<Erased> {
/// here; typing happens at typed-actor boundaries via [`Pid::from_raw`].
#[inline]
pub const fn new(index: u32, generation: u32) -> Self {
Self { raw: RawPid::new(index, generation), _marker: PhantomData }
Self {
raw: RawPid::new(index, generation),
_marker: PhantomData,
}
}
}
@@ -90,7 +93,10 @@ impl<A> Pid<A> {
/// resolution paths.
#[inline]
pub(crate) const fn from_raw(raw: RawPid) -> Self {
Self { raw, _marker: PhantomData }
Self {
raw,
_marker: PhantomData,
}
}
/// The raw identity, dropping the actor type — the key for identity-only
@@ -192,7 +198,10 @@ impl<M> Name<M> {
/// associated constants at call sites.
#[inline]
pub const fn new(name: &'static str) -> Self {
Self { name, _marker: PhantomData }
Self {
name,
_marker: PhantomData,
}
}
/// The underlying registry key.
+27
View File
@@ -98,6 +98,28 @@ pub(crate) fn clear_current_slot() {
CURRENT_SLOT.with(|c| c.set(std::ptr::null()));
}
/// Raw pointer to the on-CPU actor's slot, null on the scheduler's own
/// stack. Same lifetime argument as `note_overrun`: the slot is never
/// reclaimed while its actor is on-CPU. Consumers: the `smarm-causal`
/// profiler (RFC 007) and — unconditionally — the SIGSEGV classifier
/// (RFC 019 §7), which additionally relies on this being a plain load of a
/// const-initialized TLS Cell (no lazy init, no allocation, no dtor): safe
/// from a signal handler.
#[inline]
pub(crate) fn current_slot_ptr() -> *const crate::runtime::Slot {
CURRENT_SLOT.with(|c| c.get())
}
/// RFC 007 (`smarm-causal`) — push the slice start forward by `cycles`, so
/// virtually-injected delay spun inside `maybe_preempt` does not count against
/// the actor's timeslice (the clock-correction half of the RFC: the runtime
/// owns this clock, so it can subtract its own perturbation).
#[cfg(feature = "smarm-causal")]
#[inline]
pub(crate) fn extend_timeslice(cycles: u64) {
TIMESLICE_START.with(|c| c.set(c.get().wrapping_add(cycles)));
}
/// Tally a timeslice overrun against the on-CPU actor (RFC 016 Chunk 2). A
/// no-op if no actor is bound (the scheduler's own stack). Reached only from
/// the slice-expiry branch, which is already the yield path, so its cost is
@@ -247,6 +269,11 @@ pub fn maybe_preempt() {
// Observe a pending stop first: if we are being cancelled
// there is no point yielding, we unwind instead.
check_cancelled();
// RFC 007: causal-profiling sample/absorb point. Shares the
// amortised cadence, and the PREEMPTION_ENABLED gate — so it
// can never spin inside a prep-to-park or no-preempt region.
#[cfg(feature = "smarm-causal")]
crate::causal::check();
let start = TIMESLICE_START.with(|s| s.get());
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
// Tally the overrun (RFC 016 Chunk 2) before handing back —
+4 -1
View File
@@ -166,7 +166,10 @@ impl<T> RawMutex<T> {
{
self.lock_slow();
}
RawMutexGuard { m: self, prev_preempt }
RawMutexGuard {
m: self,
prev_preempt,
}
}
#[cold]
+399 -225
View File
@@ -1,55 +1,107 @@
//! Named mailbox registry — resolve a name (or pid) to a *messageable* actor.
//! Give an actor a name so other actors can find it and message it.
//!
//! ## What changed (RFC 014)
//! Without the registry, the only way to reach an actor is to already be
//! holding its [`Pid`], usually because you spawned it yourself or someone
//! passed it to you. That is fine for a worker you just created, but it does
//! not work for a well-known service that arbitrary parts of your program
//! need to find independently, like a logger, a config store, or a
//! connection pool. The registry solves this: an actor claims a name once,
//! and from then on any other actor can look that name up, or send to it
//! directly, without ever having been handed a `Pid`.
//!
//! The old registry was a `name <-> pid` bimap: `whereis` handed back a `Pid`
//! you could not send to, because a pid is just `(index, generation)` with no
//! delivery endpoint. This rework makes resolution yield something messageable.
//! ```
//! use smarm::{channel, register, run, send, spawn, unregister, whereis, Name};
//!
//! Two facts shape the structure:
//! const COUNTER: Name<u64> = Name::new("counter");
//!
//! 1. **A name resolves to a single actor.** Many actors under one label is
//! what *process groups* (`pg`) are for; the registry is one-name-one-actor
//! (several names *may* point at the same actor).
//! 2. **Channels are typed**, so an actor has no single untyped mailbox. An
//! actor instead owns a *set* of typed channels — one [`Sender`] per message
//! type it accepts. So the registry maps name/pid to a [`Mailbox`]: a small
//! structure holding that actor's pid plus all of its typed channels, keyed
//! by message [`TypeId`].
//! run(|| {
//! let (ready_tx, ready_rx) = channel::<()>();
//! let (tx, rx) = channel::<u64>();
//!
//! Resolution is therefore: `name -> pid` (single actor) `-> Mailbox -> the
//! channel for message type M`. A `Name<Cmd>` and a `Name<Admin>` on the *same*
//! actor select *different* channels purely by their type parameter, so
//! capability separation (RFC 014 §4.7) needs no extra machinery.
//! let worker = spawn(move || {
//! // Claim the name for this actor's inbox. Any actor holding
//! // `COUNTER` can now reach this one by name.
//! register(COUNTER, tx).unwrap();
//! ready_tx.send(()).unwrap();
//! assert_eq!(rx.recv().unwrap(), 42);
//! });
//!
//! ## Type erasure is contained
//! ready_rx.recv().unwrap(); // wait for the worker to register
//!
//! Each stored channel is a `Box<dyn Any + Send>` that is concretely a
//! `Sender<M>`, filed under `TypeId::of::<M>()`. A resolve for `M` looks up
//! that exact `TypeId` and downcasts to `Sender<M>` — keyed by the very type we
//! downcast to, so the downcast cannot fail on correct data; a failure is a
//! smarm bug, asserted in debug. The phantom `M` on [`Name`] re-imposes the
//! type at the call site, so callers never touch the erasure.
//! // Look the name up, or just send to it directly.
//! assert_eq!(whereis("counter"), Some(worker.pid()));
//! send(COUNTER, 42).unwrap();
//!
//! ## Cleanup is lazy (prune-on-contact)
//! worker.join().unwrap();
//!
//! As before, there is no `finalize` hook and no name field on the slot. Every
//! operation that touches a binding checks the target pid's liveness via the
//! generation-checked slot word; a binding to a dead actor behaves as absent
//! and is pruned on contact (its [`Mailbox`] and every name pointing at it are
//! dropped). The cost is a dead binding lingering until something looks at it;
//! the payoff is zero coupling to the actor lifecycle.
//! // The name dies with the actor: nobody holds it anymore.
//! assert_eq!(whereis("counter"), None);
//! });
//! ```
//!
//! ## Locking
//! ## Names carry a message type
//!
//! One `RawMutex` (Leaf class) in `RuntimeInner`, exactly like the old
//! registry. The fold (name index *and* handles under the one lock) is what
//! keeps a name-addressed `send` on a single Leaf — `raw_mutex` panics on a
//! second Leaf acquired while one is held. The send path clones the `Sender`
//! **under** the Leaf lock (a `Sender::clone` takes a Channel lock, permitted
//! under a Leaf), then **releases** the Leaf and only *then* sends — a send can
//! unpark a receiver, and wakeup-bearing work runs outside the Leaf. Order is
//! **Leaf -> Channel**, as `pg`/`finalize`.
//! A [`Name<M>`] is a plain string plus a type parameter `M`: the message
//! type that name expects to receive. [`Name::new`] is `const`, so the usual
//! pattern is a module-level constant like `COUNTER` above, shared by every
//! caller. The type parameter means a name is only ever sent the kind of
//! message it was declared for. If two different constants share the same
//! string but have different message types, they still address two
//! independent channels on the same actor: registering both just gives that
//! actor two ways to be reached, one per message type. This is how you give
//! one actor a "public" channel and a separate, differently-typed "admin"
//! channel under related names, without inventing an enum to merge them.
//!
//! ## One actor per name, looked up fresh every time
//!
//! A name always points at exactly one actor at a time (contrast a *process
//! group*, from the [`pg`](crate::pg) module, which is one name mapping to
//! many actors). Unlike a plain [`Pid`], which names one specific actor
//! forever and stops working the moment that actor dies, a name is
//! re-resolved on every [`send`]: if the actor holding it dies and a new one
//! registers under the same name, the next `send` reaches the new holder
//! automatically. Use a name for a long-lived service whose exact identity
//! you do not want to track by hand; use a `Pid` when you already have one
//! and want to talk to that exact actor.
//!
//! ## Registration ends when the actor does
//!
//! There is no separate step to clean up a name when its actor exits: dying
//! is enough. The next operation that touches a dead binding (a [`whereis`],
//! a [`send`], or another actor's [`register`] of the same name) notices the
//! actor is gone and clears the stale entry as a side effect, so the name
//! becomes free again. [`unregister`] is only for a live actor voluntarily
//! giving up a name it no longer wants; nothing has to call it on the way
//! out.
//!
//! ## Implementation notes
//!
//! These details matter if you are working on smarm itself; they are not
//! part of the public contract.
//!
//! Internally, each live actor that has published at least one channel owns
//! a `Mailbox`: its pid plus a set of typed channels, keyed by the message
//! type's `TypeId`. A stored channel is a `Box<dyn Any + Send>` that
//! is concretely a `Sender<M>`; resolving for `M` looks up that exact
//! `TypeId` and downcasts, so the downcast cannot fail on correct data (a
//! failure would be a bug in the registry itself, checked in debug builds).
//! Registering a name therefore means: find or create the actor's mailbox,
//! insert the channel under its type, and point the name at the actor's pid.
//!
//! There is no callback when an actor exits. Every operation that touches a
//! binding checks the target pid's liveness directly against the scheduler's
//! slot table (which also tracks a generation counter, so a dead actor's
//! reused slot index is never mistaken for the same actor). A binding to a
//! dead actor is treated as absent and dropped right there. This keeps the
//! registry decoupled from actor teardown, at the cost of a dead binding
//! lingering until something happens to look at it.
//!
//! The whole registry (both the name index and the per-actor mailboxes) sits
//! behind one lock, which is what lets a name-addressed [`send`] resolve and
//! clone the target's sender in a single critical section. The sender is
//! cloned while that lock is held, then the lock is released before the
//! actual send, since delivering a message can wake a parked receiver and
//! that wakeup work should not run while the registry is locked.
use crate::channel::Sender;
use crate::pid::{Addressable, Name, Pid};
@@ -80,28 +132,33 @@ impl std::fmt::Display for RegisterError {
impl std::error::Error for RegisterError {}
/// Why a name-addressed [`send`] did not deliver. Carries the message back so
/// the caller never loses it (mirrors [`crate::channel::SendError`]).
/// Why a send did not deliver. Every variant carries the undelivered message
/// back, mirroring [`crate::channel::SendError`], so a failed send never
/// silently drops what you tried to send.
///
/// `Debug`/`Display` are hand-written so neither demands `M: Debug` — the
/// payload is returned, not printed.
/// `Debug` and `Display` are hand-written so neither requires `M: Debug`,
/// since the payload is handed back to you, not printed.
pub enum SendError<M> {
/// No live actor is currently registered under this name. Name-addressed
/// [`send`] only; the pid-addressed counterpart is [`SendError::Dead`].
/// No live actor is currently registered under this name. Returned only
/// by name-addressed [`send`]; the pid-addressed counterpart of "nothing
/// there" is [`SendError::Dead`].
Unresolved(M),
/// The pid-addressed actor is no longer the live incarnation this pid names
/// — it has died, even if its slot now holds a *different* actor (a direct
/// `Pid<A>` send never redirects; contrast name-addressed [`send`]). Pid
/// paths ([`send_to`] / [`send_dyn`]) only.
/// The actor this pid identifies has died, even if its slot has since
/// been taken over by a different, live actor. A direct `Pid<A>` send
/// never redirects to that new occupant; contrast name-addressed
/// [`send`], which would reach it. Returned by the pid-addressed sends,
/// [`send_to`] and [`send_dyn`].
Dead(M),
/// The actor is live but exposes no channel for this message type.
/// The actor is live but has not published a channel for this message
/// type.
NoChannel(M),
/// The actor's channel for this message type is closed (its receiver is gone).
/// The actor's channel for this message type is closed (its receiver has
/// been dropped).
Closed(M),
/// No live member to deliver to — a [`dispatch`](crate::dispatch) over an
/// empty (or all-dead) process group. Group-addressed dispatch only; the
/// name-addressed counterpart is [`SendError::Unresolved`]. The message is
/// handed back undelivered.
/// No live member was available to deliver to: returned by
/// [`dispatch`](crate::dispatch) when the target process group is empty
/// or every member in it has died. The name-addressed counterpart of
/// this case is [`SendError::Unresolved`].
NoMember(M),
}
@@ -138,7 +195,9 @@ impl<M> std::fmt::Display for SendError<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SendError::Unresolved(_) => write!(f, "no live actor registered under that name"),
SendError::Dead(_) => write!(f, "the addressed actor is no longer the live incarnation"),
SendError::Dead(_) => {
write!(f, "the addressed actor is no longer the live incarnation")
}
SendError::NoChannel(_) => write!(f, "actor has no channel for this message type"),
SendError::Closed(_) => write!(f, "the actor's channel for this type is closed"),
SendError::NoMember(_) => write!(f, "no live member in the process group"),
@@ -150,9 +209,9 @@ impl<M> std::error::Error for SendError<M> {}
/// A registry-stored channel, type-erased over its message type. The stored
/// object must serve two readers: `clone_sender` (downcast back to the concrete
/// `Sender<M>`) and the RFC 016 snapshot (queued length without knowing `M`).
/// A bare `Box<dyn Any>` gives the first but not the second, so we erase behind
/// this small trait instead.
/// `Sender<M>`) and the runtime introspection snapshot (queued length without
/// knowing `M`). A bare `Box<dyn Any>` gives the first but not the second, so
/// we erase behind this small trait instead.
trait ErasedSender: Send {
fn as_any(&self) -> &dyn Any;
fn queued_len(&self) -> usize;
@@ -169,7 +228,7 @@ impl<M: Send + 'static> ErasedSender for Sender<M> {
/// One typed channel of an actor, type-erased. Concretely a `Sender<M>` filed
/// under `TypeId::of::<M>()`; `msg_type` is `type_name::<M>()`, kept for
/// observers (RFC 014 §4.5) and as the debug cross-check on the downcast.
/// observability tooling and as the debug cross-check on the downcast.
struct Channel {
sender: Box<dyn ErasedSender>,
msg_type: &'static str,
@@ -185,7 +244,10 @@ struct Mailbox {
impl Mailbox {
fn new(pid: Pid) -> Self {
Self { pid, channels: HashMap::new() }
Self {
pid,
channels: HashMap::new(),
}
}
/// Clone the `Sender<M>` for this actor, if it has one. Called **under the
@@ -193,17 +255,18 @@ impl Mailbox {
/// legal under a Leaf (Leaf -> Channel).
fn clone_sender<M: Send + 'static>(&self) -> Option<Sender<M>> {
let ch = self.channels.get(&TypeId::of::<M>())?;
let tx = ch
.sender
.as_any()
.downcast_ref::<Sender<M>>()
.expect("channel keyed by TypeId but downcast to its own type failed — smarm bug");
let tx = match ch.sender.as_any().downcast_ref::<Sender<M>>() {
Some(tx) => tx,
None => panic!(
"smarm: channel keyed by TypeId but downcast to its own type failed (core corrupt)"
),
};
debug_assert_eq!(ch.msg_type, type_name::<M>(), "msg_type / TypeId disagree");
Some(tx.clone())
}
}
/// Per-actor registry view handed to RFC 016 introspection: registered names
/// Per-actor registry view handed to runtime introspection: registered names
/// and summed mailbox depth, tagged with the mailbox's `pid` so a stale
/// incarnation can be filtered against the slab. Covers only *published*
/// channels (`register` / `install` / `spawn_addr` / gen_server start); an
@@ -216,39 +279,60 @@ pub(crate) struct MailboxInfo {
}
/// The directory. Invariant (held under the registry lock): every value in
/// `by_name` is the index of a [`Mailbox`] present in `by_index`, and that
/// mailbox's `pid.index()` equals the key. Stale entries (dead actors) violate
/// nothing — they are simply pruned on contact.
/// `by_name` is the full [`Pid`] (index *and* generation) of an actor that
/// published a [`Mailbox`] into `by_index` at registration time. Stale entries
/// (dead holders, including holders whose slot has since been re-tenanted by
/// a different actor) violate nothing: they are pruned on contact, and the
/// generation makes "dead" decidable even after slot reuse.
pub(crate) struct Registry {
/// `pid.index() -> the actor's mailbox`. The handle store.
by_index: HashMap<u32, Mailbox>,
/// `name -> pid.index()`. Several names may map to one actor.
by_name: HashMap<&'static str, u32>,
/// `name -> holder pid`. Several names may map to one actor. The full pid
/// (not just the index) is load-bearing: an index alone cannot tell a dead
/// holder from the live actor now tenanting its recycled slot. Comparing
/// only the index would make such a name read as live-held (unresolvable
/// and unregisterable at once) and could misdeliver to whatever new,
/// same-typed actor now sits in that slot.
by_name: HashMap<&'static str, Pid>,
}
impl Registry {
pub(crate) fn new() -> Self {
Self { by_index: HashMap::new(), by_name: HashMap::new() }
Self {
by_index: HashMap::new(),
by_name: HashMap::new(),
}
}
/// Drop a dead actor's mailbox and every name that pointed at it.
fn prune(&mut self, index: u32) {
self.by_index.remove(&index);
self.by_name.retain(|_, idx| *idx != index);
/// Drop a dead holder's artifacts: every name bound to it, and its
/// mailbox, but only while the mailbox is still *its own*. A recycled
/// slot's mailbox belongs to the live tenant (publish replaces it
/// wholesale on pid mismatch) and is left untouched.
fn prune_holder(&mut self, holder: Pid) {
self.by_name.retain(|_, p| *p != holder);
if self
.by_index
.get(&holder.index())
.is_some_and(|mb| mb.pid == holder)
{
self.by_index.remove(&holder.index());
}
}
/// RFC 016 snapshot input: per-slot-index registry view — the actor's
/// registered names (inverted from `by_name`) and its mailbox depth (queued
/// messages summed across every published typed channel). Built in one pass
/// under the registry Leaf; the per-channel `queued_len` takes a Channel
/// lock, legal under the Leaf (Leaf → Channel). Carries each mailbox's full
/// `pid` so the caller can discard a stale incarnation's entry against the
/// slab's live generation. Names that dangle (point at no mailbox) are
/// dropped — they violate no invariant and get pruned on next contact.
/// Runtime introspection input: per-slot-index registry view, giving the
/// actor's registered names (inverted from `by_name`) and its mailbox
/// depth (queued messages summed across every published typed channel).
/// Carries each mailbox's full `pid` so the caller can discard a stale
/// incarnation's entry against the slab's live generation. Names are
/// matched to mailboxes by *full pid*, so a stale name (dead holder)
/// still annotates the corpse's own mailbox if that survives, but never a
/// recycled slot's new tenant; names that attach to no mailbox are
/// dropped, since that violates no invariant and they get pruned on next
/// contact.
pub(crate) fn introspect_map(&self) -> HashMap<u32, MailboxInfo> {
let mut names: HashMap<u32, Vec<&'static str>> = HashMap::new();
for (&name, &idx) in &self.by_name {
names.entry(idx).or_default().push(name);
let mut names: HashMap<Pid, Vec<&'static str>> = HashMap::new();
for (&name, &pid) in &self.by_name {
names.entry(pid).or_default().push(name);
}
let mut out: HashMap<u32, MailboxInfo> = HashMap::with_capacity(self.by_index.len());
for (&idx, mb) in &self.by_index {
@@ -257,7 +341,7 @@ impl Registry {
idx,
MailboxInfo {
pid: mb.pid,
names: names.remove(&idx).unwrap_or_default(),
names: names.remove(&mb.pid).unwrap_or_default(),
depth: depth.min(u32::MAX as usize) as u32,
},
);
@@ -267,17 +351,22 @@ impl Registry {
/// Single-actor form of [`introspect_map`](Self::introspect_map): the
/// registry view for one slot index, or `None` if no mailbox is published
/// there. Used by `actor_info` so its cost stays proportional to the one
/// actor rather than locking every channel in the runtime.
/// there. Used by the runtime's per-actor introspection so its cost stays
/// proportional to the one actor rather than locking every channel in the
/// runtime.
pub(crate) fn introspect_one(&self, idx: u32) -> Option<MailboxInfo> {
let mb = self.by_index.get(&idx)?;
let depth: usize = mb.channels.values().map(|c| c.sender.queued_len()).sum();
let names = self
.by_name
.iter()
.filter_map(|(&n, &i)| (i == idx).then_some(n))
.filter_map(|(&n, &p)| (p == mb.pid).then_some(n))
.collect();
Some(MailboxInfo { pid: mb.pid, names, depth: depth.min(u32::MAX as usize) as u32 })
Some(MailboxInfo {
pid: mb.pid,
names,
depth: depth.min(u32::MAX as usize) as u32,
})
}
}
@@ -286,14 +375,18 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
}
/// Publish the current actor's `Sender<M>` under `name`, capturing the channel
/// so the name becomes messageable. Idempotent for the same `(name, type)`;
/// registering a *second* type under the same (or another) name on the same
/// actor just adds another channel to the actor's mailbox.
/// Give the current actor's channel a name, so other actors can find and
/// message it by that name instead of needing its [`Pid`].
///
/// Fails with [`RegisterError::NameTaken`] if the name is held by a *different*
/// live actor (a binding to a dead actor is pruned and the name treated as
/// free). Panics if called outside `Runtime::run()`.
/// Calling this again with the same `(name, type)` from the same actor is
/// harmless. Registering a *second* message type under the same (or a
/// different) name from the same actor just adds another typed channel to
/// that actor's mailbox; it does not replace the first.
///
/// Fails with [`RegisterError::NameTaken`] if the name is currently held by a
/// *different* live actor. A name held by an actor that has since died is not
/// considered taken: it is quietly reclaimed and handed to you. Panics if
/// called outside [`run`](crate::run).
pub fn register<M: Send + 'static>(name: Name<M>, tx: Sender<M>) -> Result<(), RegisterError> {
register_with(self_pid(), name.as_str(), tx)
}
@@ -301,8 +394,8 @@ pub fn register<M: Send + 'static>(name: Name<M>, tx: Sender<M>) -> Result<(), R
/// Bind `name` to `pid`'s mailbox and publish `tx` under `M`'s [`TypeId`], for
/// an explicit (already-live) actor rather than `self`. The shared core of
/// [`register`] (which passes `self_pid()`) and the parent-side server-name
/// bind in `gen_server` (which names a freshly spawned server before its body
/// has run, so the name resolves the instant `start()` returns). Same collision
/// bind in `gen_server`, which names a freshly spawned server before its body
/// has run, so the name resolves the instant `start()` returns. Same collision
/// rules and lock discipline as `register`.
pub(crate) fn register_with<M: Send + 'static>(
me: Pid,
@@ -310,25 +403,36 @@ pub(crate) fn register_with<M: Send + 'static>(
tx: Sender<M>,
) -> Result<(), RegisterError> {
with_runtime(|inner| {
// Stamp-eligibility for the terminal record (soak sig 4): flag the
// tenancy BEFORE the binding lands and outside the registry lock (no
// nesting), so no successfully-registered actor can die unflagged.
// A register that then fails leaves a harmless overshoot; a stale
// `me` is screened by the same live() the binding requires below.
if live(inner, me) {
if let Some(slot) = inner.slot_at(me) {
slot.cold.lock().watchable = true;
}
}
let mut reg = inner.registry.lock();
if !live(inner, me) {
return Err(RegisterError::NoProc);
}
if let Some(&holder_idx) = reg.by_name.get(key) {
match reg.by_index.get(&holder_idx).map(|m| m.pid) {
Some(holder) if holder == me => {} // same actor: just add the channel below
Some(holder) if live(inner, holder) => {
return Err(RegisterError::NameTaken { holder });
}
Some(_) => reg.prune(holder_idx), // dead holder: free the name
None => {
reg.by_name.remove(key); // dangling name: free it
}
if let Some(&holder) = reg.by_name.get(key) {
if holder == me {
// Same actor: just add the channel below.
} else if live(inner, holder) {
return Err(RegisterError::NameTaken { holder });
} else {
// Dead holder: free the name (and its other stale artifacts).
// Liveness is judged against the *stored* pid, generation
// included, so a recycled slot's live tenant no longer makes a
// dead name read as taken.
reg.prune_holder(holder);
}
}
// Publish (or extend) the mailbox with this channel, then bind the name.
publish_channel::<M>(&mut reg, me, tx);
reg.by_name.insert(key, me.index());
reg.by_name.insert(key, me);
Ok(())
})
}
@@ -339,26 +443,32 @@ pub(crate) fn register_with<M: Send + 'static>(
/// index from a dead prior incarnation (pid mismatch) is replaced wholesale.
/// Caller holds the registry lock and has established that `me` is live.
fn publish_channel<M: Send + 'static>(reg: &mut Registry, me: Pid, tx: Sender<M>) {
let mb = reg.by_index.entry(me.index()).or_insert_with(|| Mailbox::new(me));
let mb = reg
.by_index
.entry(me.index())
.or_insert_with(|| Mailbox::new(me));
if mb.pid != me {
*mb = Mailbox::new(me);
}
mb.channels.insert(
TypeId::of::<M>(),
Channel { sender: Box::new(tx), msg_type: type_name::<M>() },
Channel {
sender: Box::new(tx),
msg_type: type_name::<M>(),
},
);
}
/// Publish the current actor's `Sender<A::Msg>` into its mailbox **without**
/// binding a name, and hand back the typed [`Pid<A>`] that addresses this
/// actor directly. This is the opt-in, lazy install of RFC 014 §5: an actor
/// that wants to be reachable by a direct, identity-bound [`Pid<A>`] (rather
/// than only via a re-resolving [`Name`]) calls this once with its inbox
/// sender, then hands the returned pid out.
/// actor directly.
///
/// Unlike [`register`] there is no name to collide on, and `self` is always a
/// live actor inside `run()`, so this is infallible. Panics if called outside
/// `Runtime::run()`.
/// This is for an actor that wants to be reachable directly by its pid,
/// rather than only through a re-resolving [`Name`]: call this once with your
/// inbox sender, then hand the returned `Pid<A>` to whoever should be able to
/// message you. Unlike [`register`] there is no name to collide on, and the
/// current actor is always live while inside `run()`, so this cannot fail.
/// Panics if called outside [`run`](crate::run).
pub fn install<A: Addressable>(tx: Sender<A::Msg>) -> Pid<A> {
let me = self_pid();
with_runtime(|inner| {
@@ -374,158 +484,217 @@ pub fn install<A: Addressable>(tx: Sender<A::Msg>) -> Pid<A> {
/// Publish `tx` into `pid`'s mailbox under `M`'s [`TypeId`], for an explicit
/// (freshly minted, already-live) actor rather than `self`. The parent-side
/// half of [`spawn_addr`](crate::spawn_addr): the spawner makes the inbox and
/// publishes the sender here *before* handing back the `Pid<A>`, so an immediate
/// `send_to` on the returned pid always resolves — the address is live the
/// instant the caller holds it, with no dependence on the body having run yet.
/// publishes the sender here *before* handing back the `Pid<A>`, so an
/// immediate `send_to` on the returned pid always resolves. The address is
/// live the instant the caller holds it, with no dependence on the spawned
/// actor's body having run yet.
///
/// Caller guarantees `pid` is the just-installed actor (Queued, this exact
/// Caller guarantees `pid` is the just-installed actor (queued, this exact
/// incarnation); `publish_channel` replaces any stale leftover at the slot.
pub(crate) fn install_for<M: Send + 'static>(pid: Pid, tx: Sender<M>) {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
debug_assert!(live(inner, pid), "install_for: pid must be a freshly spawned, live actor");
debug_assert!(
live(inner, pid),
"install_for: pid must be a freshly spawned, live actor"
);
publish_channel::<M>(&mut reg, pid, tx);
});
}
/// The single actor currently registered under `name`, or `None` if unbound or
/// no longer live (the stale binding is pruned on the way out).
/// Look up which actor currently holds `name`, if any. Returns `None` if the
/// name is unbound, or if it was bound to an actor that has since died (the
/// stale binding is cleared as a side effect of this call).
pub fn whereis(name: &str) -> Option<Pid> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let idx = *reg.by_name.get(name)?;
match reg.by_index.get(&idx).map(|m| m.pid) {
Some(pid) if live(inner, pid) => Some(pid),
Some(_) => {
reg.prune(idx);
None
}
None => {
reg.by_name.remove(name);
None
}
let pid = *reg.by_name.get(name)?;
if live(inner, pid) {
Some(pid)
} else {
// Generation-checked against the stored holder: a recycled slot's
// live tenant reads dead here, and the stale name heals.
reg.prune_holder(pid);
None
}
})
}
/// Resolve `name` to a *typed* [`Pid<A>`] — the identity-bound counterpart of
/// [`whereis`] (RFC 014 §4.4). Recovers the compile-checked
/// [`send_to`] path from a durable name: looks the name up,
/// then re-types the erased pid as `Pid<A>` via the unchecked
/// [`assert_type`](crate::pid::assert_type) primitive. A wrong `A` is not
/// unsound — it degrades to [`SendError::NoChannel`] on the next send (routing
/// is by message `TypeId`), never a misdelivery. `None` if unbound or dead.
/// What a name is bound to, three-valued (bridge soak signature 4).
///
/// Panics if called outside `Runtime::run()`.
/// [`Live`](NameResolution::Live) is [`whereis`]'s `Some`.
/// [`Corpse`](NameResolution::Corpse) carries the *stored* holder pid of a
/// dead-but-unpruned binding — a state Erlang cannot represent (its name
/// death unregisters atomically; smarm's prune is lazy), captured here before
/// the prune that `whereis` performs discards it, so the caller can consult
/// [`terminal_reason`](crate::monitor::terminal_reason) for the tenancy's
/// real down reason. [`Unbound`](NameResolution::Unbound) matches Erlang's
/// unregistered name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameResolution {
/// The stored holder is live (generation-checked); the binding stands.
Live(Pid),
/// The stored holder is dead. The binding was pruned on the way out —
/// the name heals exactly as `whereis` heals it; only the evidence is
/// returned instead of discarded. A second resolve is `Unbound`.
Corpse(Pid),
/// No binding stored (never registered, or already pruned by any reader).
Unbound,
}
/// Resolve `name` like [`whereis`], but keep the corpse: the dead-holder arm
/// returns the stored pid it pruned instead of a bare `None`. Same lock
/// discipline and pruning behavior as `whereis`; same `Runtime::run()`
/// context contract.
pub fn resolve_name(name: &str) -> NameResolution {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let Some(&pid) = reg.by_name.get(name) else {
return NameResolution::Unbound;
};
if live(inner, pid) {
NameResolution::Live(pid)
} else {
reg.prune_holder(pid);
NameResolution::Corpse(pid)
}
})
}
/// Like [`whereis`], but returns a *typed* [`Pid<A>`] instead of a bare
/// [`Pid`], so a follow-up [`send_to`] is compile-checked instead of needing
/// the untyped [`send_dyn`] escape hatch. `None` if the name is unbound or its
/// holder has died.
///
/// The type `A` is not checked against what the name's holder actually
/// published: if you pick the wrong `A`, this still succeeds, but the next
/// send against the returned pid degrades to [`SendError::NoChannel`] rather
/// than reaching the wrong actor or the wrong channel.
///
/// Panics if called outside [`run`](crate::run).
pub fn lookup_as<A: Addressable>(name: &str) -> Option<Pid<A>> {
whereis(name).map(crate::pid::assert_type::<A>)
}
/// Resolve `name` to its actor's pid and a cloned `Sender<M>`, under the Leaf
/// lock (clone-under-lock, then release). The crate-internal building block for
/// `gen_server`'s by-name addressing: a named server publishes its inbox as a
/// `Sender<Envelope<G>>` (via [`register_with`]), and `whereis_server` / `call`
/// / `cast` recover that exact typed sender here to rebuild a `GenServerRef<G>`.
/// `None` if unbound, dead (pruned on the way out), or holding no `M` channel.
/// Resolve `name` to its actor's pid and a cloned `Sender<M>`, all under one
/// lock acquisition. The crate-internal building block for `gen_server`'s
/// by-name addressing: a named server publishes its inbox as a
/// `Sender<Envelope<G>>` (via [`register_with`]), and the server's `call` /
/// `cast` / `whereis_server` recover that exact typed sender here to rebuild a
/// `GenServerRef<G>`. `None` if unbound, dead (pruned on the way out), or
/// holding no `M` channel.
pub(crate) fn resolve_named_sender<M: Send + 'static>(name: &str) -> Option<(Pid, Sender<M>)> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let idx = *reg.by_name.get(name)?;
let pid = match reg.by_index.get(&idx).map(|m| m.pid) {
Some(pid) if live(inner, pid) => pid,
Some(_) => {
reg.prune(idx);
return None;
}
None => {
reg.by_name.remove(name);
return None;
}
};
let tx = reg.by_index.get(&idx).and_then(Mailbox::clone_sender::<M>)?;
let pid = *reg.by_name.get(name)?;
if !live(inner, pid) {
// Stored-pid liveness, generation included: a name whose holder
// died is pruned (heals) even if the slot has a new tenant.
// Otherwise the tenant's mailbox would make the name unresolvable
// without pruning, wedging it for the tenant's lifetime.
reg.prune_holder(pid);
return None;
}
// A live holder's mailbox is its own (publish replaces wholesale on
// pid mismatch, and one live actor per slot), so index lookup is safe.
let tx = reg
.by_index
.get(&pid.index())
.and_then(Mailbox::clone_sender::<M>)?;
Some((pid, tx))
})
}
/// Remove the binding for `name`, returning the actor it pointed at if still
/// live. Only the *name* is freed; the actor's mailbox (and any other names for
/// it) remain. A binding to a dead actor is reported as `None`.
/// Give up a name. Returns the actor it pointed at, if that actor was still
/// live. Only the *name* is freed; the actor's mailbox (and any other names
/// bound to it) are unaffected. A binding to an already-dead actor reports
/// `None`, since there was nothing live to release.
pub fn unregister(name: &str) -> Option<Pid> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let idx = reg.by_name.remove(name)?;
match reg.by_index.get(&idx).map(|m| m.pid) {
Some(pid) if live(inner, pid) => Some(pid),
_ => None,
let pid = reg.by_name.remove(name)?;
if live(inner, pid) {
Some(pid)
} else {
None
}
})
}
/// Resolve `name` to its actor's `Sender<M>` and deliver `msg`. The whole point
/// of the rework: a name you can *send* to.
/// Look `name` up and deliver `msg` to whichever actor currently holds it.
/// This is the point of naming an actor: a name you can send a message to
/// directly, without a separate lookup step.
///
/// Errors (message returned in every case): [`SendError::Unresolved`] if no
/// live actor holds the name, [`SendError::NoChannel`] if that actor has no
/// channel for `M`, [`SendError::Closed`] if its `M` channel's receiver is
/// gone. Panics if called outside `Runtime::run()`.
/// On failure the message comes back to you, wrapped in the [`SendError`]
/// variant that explains why: [`SendError::Unresolved`] if no live actor
/// currently holds the name, [`SendError::NoChannel`] if the actor that holds
/// it never published a channel for `M`, or [`SendError::Closed`] if it
/// published one but has since dropped the receiving end. Panics if called
/// outside [`run`](crate::run).
pub fn send<M: Send + 'static>(name: Name<M>, msg: M) -> Result<(), SendError<M>> {
let key = name.as_str();
with_runtime(|inner| {
// Resolve + clone the sender under the Leaf lock, then drop the lock
// before sending (a send can unpark a receiver).
// Resolve + clone the sender under the registry lock, then drop the
// lock before sending (a send can unpark a receiver).
let tx = {
let mut reg = inner.registry.lock();
let idx = match reg.by_name.get(key) {
Some(&i) => i,
let pid = match reg.by_name.get(key) {
Some(&p) => p,
None => return Err(SendError::Unresolved(msg)),
};
let pid = match reg.by_index.get(&idx).map(|m| m.pid) {
Some(pid) => pid,
None => {
reg.by_name.remove(key);
return Err(SendError::Unresolved(msg));
}
};
if !live(inner, pid) {
reg.prune(idx);
// Stored-pid liveness (generation included), so a recycled
// slot's new live tenant is never mistaken for the name's
// original (now-dead) holder.
reg.prune_holder(pid);
return Err(SendError::Unresolved(msg));
}
match reg.by_index.get(&idx).and_then(Mailbox::clone_sender::<M>) {
match reg
.by_index
.get(&pid.index())
.and_then(Mailbox::clone_sender::<M>)
{
Some(tx) => tx,
None => return Err(SendError::NoChannel(msg)),
}
};
tx.send(msg).map_err(|crate::channel::SendError(m)| SendError::Closed(m))
tx.send(msg)
.map_err(|crate::channel::SendError(m)| SendError::Closed(m))
})
}
/// Resolve a *raw* pid to its mailbox and deliver `msg` on the channel for `M`,
/// with **no redirect**. The stored mailbox must be this exact incarnation
/// (generation included) and still live; otherwise the actor this pid named is
/// gone and the result is [`SendError::Dead`] — even when the slot now holds a
/// different, live actor (which we leave untouched). Shared by [`send_to`]
/// (typed, `M = A::Msg`, channel guaranteed on an installed actor) and
/// [`send_dyn`] (explicit `M`, where `NoChannel` is a real outcome).
/// (generation included) and still live; otherwise the actor this pid named
/// is gone and the result is [`SendError::Dead`], even when the slot now
/// holds a different, live actor (which is left untouched). Shared by
/// [`send_to`] (typed, `M = A::Msg`, channel guaranteed on an installed
/// actor) and [`send_dyn`] (explicit `M`, where `NoChannel` is a real
/// outcome).
fn send_to_pid<M: Send + 'static>(
inner: &crate::runtime::RuntimeInner,
pid: Pid,
msg: M,
) -> Result<(), SendError<M>> {
// Resolve + clone the sender under the Leaf lock, then drop the lock before
// sending (a send can unpark a receiver) — Leaf -> Channel, as name `send`.
// Resolve + clone the sender under the registry lock, then drop the lock
// before sending (a send can unpark a receiver), same order as `send`.
let tx = {
let mut reg = inner.registry.lock();
match reg.by_index.get(&pid.index()).map(|m| m.pid) {
// Exact incarnation, still alive: its `M` channel, or NoChannel.
Some(stored) if stored == pid && live(inner, pid) => {
match reg.by_index.get(&pid.index()).and_then(Mailbox::clone_sender::<M>) {
match reg
.by_index
.get(&pid.index())
.and_then(Mailbox::clone_sender::<M>)
{
Some(tx) => tx,
None => return Err(SendError::NoChannel(msg)),
}
}
// Our incarnation's mailbox, but the actor has died: prune + Dead.
Some(stored) if stored == pid => {
reg.prune(pid.index());
reg.prune_holder(pid);
return Err(SendError::Dead(msg));
}
// A different incarnation (or nothing) occupies the slot: the actor
@@ -533,35 +702,40 @@ fn send_to_pid<M: Send + 'static>(
_ => return Err(SendError::Dead(msg)),
}
};
tx.send(msg).map_err(|crate::channel::SendError(m)| SendError::Closed(m))
tx.send(msg)
.map_err(|crate::channel::SendError(m)| SendError::Closed(m))
}
/// Deliver `msg` to the exact actor named by `pid` — RFC 014 §4.2's direct,
/// identity-bound addressing mode. Unlike name-addressed [`send`] there is **no
/// redirect**: if that incarnation has died the message comes back as
/// [`SendError::Dead`], even if its slot now holds a different actor.
/// Deliver `msg` directly to the exact actor identified by `pid`. Unlike
/// name-addressed [`send`], there is **no redirect**: if that specific actor
/// has died, the message comes back as [`SendError::Dead`], even if its slot
/// has since been taken over by a different, live actor. Use this when you
/// already hold a `Pid<A>` and want to talk to that one actor specifically;
/// use [`send`] with a [`Name`] when you want whichever actor currently holds
/// a name.
///
/// The message type is the actor's `A::Msg`, so on a live actor that has
/// installed its inbox (via [`install`] or [`register`]) the channel is always
/// present; [`SendError::NoChannel`] therefore means the actor is live but
/// never published a `Pid<A>`-reachable inbox. Panics if called outside
/// `Runtime::run()`.
/// installed its inbox (via [`install`] or [`register`]) the channel is
/// always present; [`SendError::NoChannel`] therefore means the actor is live
/// but never published a `Pid<A>`-reachable inbox. Panics if called outside
/// [`run`](crate::run).
pub fn send_to<A: Addressable>(pid: Pid<A>, msg: A::Msg) -> Result<(), SendError<A::Msg>> {
with_runtime(|inner| send_to_pid::<A::Msg>(inner, pid.erase(), msg))
}
/// The explicit bare-pid escape hatch (RFC 014 §4.6): deliver `msg` of type `M`
/// to `pid` when all you hold is an untyped [`Pid`] — a pid off a [`Down`], or
/// out of a future `members()` — so the typed [`send_to`] is unavailable.
/// The escape hatch for sending to a bare, untyped [`Pid`] when the typed
/// [`send_to`] is unavailable, for example a pid recovered from a [`Down`]
/// notification or a group's `members()` list, where you no longer know the
/// actor's message type at compile time.
///
/// This is the one send whose message type can genuinely be wrong: the actor
/// may be live yet expose no channel for `M`, returning [`SendError::NoChannel`]
/// (on the typed paths that downcast collapses to a `debug_assert`). It is
/// named and documented as the fallible fallback so the typed `Pid<A>` /
/// `Name<M>` paths stay the obvious default and an agentic caller reaches for a
/// present primitive instead of inventing a workaround. Liveness is identical
/// to [`send_to`]: identity-bound, no redirect, [`SendError::Dead`] once the
/// addressed incarnation is gone. Panics if called outside `Runtime::run()`.
/// Because the message type is not checked at compile time here, this is the
/// one send that can genuinely be live-but-wrong: the actor may be alive yet
/// expose no channel for `M`, in which case you get [`SendError::NoChannel`]
/// back instead of a misdelivery. Liveness and redirect behavior are
/// otherwise identical to [`send_to`]: identity-bound, no redirect,
/// [`SendError::Dead`] once the addressed incarnation is gone. Prefer
/// `send_to` with a typed `Pid<A>` whenever you have one; reach for this only
/// when you don't. Panics if called outside [`run`](crate::run).
///
/// [`Down`]: crate::Down
pub fn send_dyn<M: Send + 'static>(pid: Pid, msg: M) -> Result<(), SendError<M>> {
+55 -8
View File
@@ -113,16 +113,32 @@ impl MutexQueue {
pub fn push(&self, pid: Pid) {
assert_no_preempt();
self.q.lock().unwrap().push_back(pid);
match self.q.lock() {
Ok(mut g) => g.push_back(pid),
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
}
}
pub fn pop(&self) -> Option<Pid> {
assert_no_preempt();
self.q.lock().unwrap().pop_front()
match self.q.lock() {
Ok(mut g) => g.pop_front(),
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
}
}
pub fn len(&self) -> u64 {
self.q.lock().unwrap().len() as u64
match self.q.lock() {
Ok(g) => g.len() as u64,
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
}
}
pub fn is_empty(&self) -> bool {
match self.q.lock() {
Ok(g) => g.is_empty(),
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
}
}
}
@@ -206,7 +222,10 @@ impl MpmcRing {
if diff == 0 {
// Our turn: claim the position.
match self.enqueue_pos.0.compare_exchange_weak(
pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed,
pos,
pos + 1,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => {
// SAFETY: the claim gives us exclusive write access
@@ -234,7 +253,10 @@ impl MpmcRing {
let diff = seq as isize - (pos + 1) as isize;
if diff == 0 {
match self.dequeue_pos.0.compare_exchange_weak(
pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed,
pos,
pos + 1,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => {
// SAFETY: the claim gives us exclusive read access;
@@ -262,6 +284,10 @@ impl MpmcRing {
let d = self.dequeue_pos.0.load(Ordering::Relaxed);
e.saturating_sub(d) as u64
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ---------------------------------------------------------------------------
@@ -341,6 +367,10 @@ impl StripedRing {
pub fn len(&self) -> u64 {
self.stripes.iter().map(|s| s.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.stripes.iter().all(|s| s.is_empty())
}
}
// ---------------------------------------------------------------------------
@@ -440,19 +470,36 @@ mod tests {
let popped = popped.lock().unwrap();
assert_eq!(popped.len(), total, "count mismatch");
let set: HashSet<u64> = popped.iter().map(|p| ((p.index() as u64) << 32) | p.generation() as u64).collect();
let set: HashSet<u64> = popped
.iter()
.map(|p| ((p.index() as u64) << 32) | p.generation() as u64)
.collect();
assert_eq!(set.len(), total, "duplicate or lost element");
assert_eq!(pop(&q), None);
}
#[test]
fn mpmc_exactly_once_contended() {
exactly_once(MpmcRing::new(8, 4096), |q, p| q.push(p), |q| q.pop(), 4, 4, 1000);
exactly_once(
MpmcRing::new(8, 4096),
|q, p| q.push(p),
|q| q.pop(),
4,
4,
1000,
);
}
#[test]
fn striped_exactly_once_contended() {
exactly_once(StripedRing::new(8, 4096), |q, p| q.push(p), |q| q.pop(), 4, 4, 1000);
exactly_once(
StripedRing::new(8, 4096),
|q, p| q.push(p),
|q| q.pop(),
4,
4,
1000,
);
}
#[test]
+860 -196
View File
File diff suppressed because it is too large Load Diff
+738 -231
View File
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
//! RFC 019 §7 — overflow diagnostics.
//!
//! One process-global SIGSEGV handler, installed once at [`crate::runtime::init`]
//! (before any scheduler thread exists, so the PRIOR save is unracing), plus a
//! per-scheduler-thread `sigaltstack` registered at `schedule_loop` entry — a
//! guard hit means the faulting stack has no room to run anything, so the
//! altstack is not optional.
//!
//! The handler classifies `si_addr` against the *current* actor only, reached
//! through `preempt::CURRENT_SLOT` — a const-initialized `Cell<*const Slot>`
//! whose access is a plain TLS load (no lazy init, no allocation, no dtor
//! registration), and which every scheduler thread has materialized before an
//! actor can run on it. The slot's diag atomics (`diag_stack_top` & co) are
//! written in `install_actor` before the Release publish and are only consulted
//! here while the actor is on-CPU, so they cannot be stale.
//!
//! Two classification tiers:
//! - **In-guard**: definitive. Rust frames probe pages in order
//! (`__rust_probestack`), so Rust overflow always lands here; so does any C
//! built with `-fstack-clash-protection` (distro-packaged libraries), and —
//! with the 1 MiB default guard — nearly every unprobed frame too.
//! - **Overshoot**: within [`OVERSHOOT_SLOP`] *below* the guard. An unprobed
//! frame (cargo-built C via `cc` almost never enables clash protection)
//! large enough to step over the guard in one `sub rsp`. Attribution is
//! "probable": the address is in unmapped VA that nothing else owns, an
//! actor was on-CPU, and the distance fits a frame — the diagnostic says so.
//!
//! Classified faults print one line (async-signal-safe: stack buffer +
//! `write(2)`, no fmt, no alloc, no locks) and re-raise with default
//! disposition — no unwind, no resume, no fail-soft (jarred; UB-adjacent from
//! a handler). Unclassified faults reinstate the PRIOR handler and refault, so
//! std's own "thread ... has overflowed its stack" diagnostics for OS-thread
//! stacks survive our presence. Reinstating deregisters us for good, which is
//! fine: the process is dying either way.
use std::cell::Cell;
use std::mem::MaybeUninit;
use std::sync::atomic::Ordering;
use std::sync::Once;
/// Tier-2 window below the guard. Matches the guard default (and the kernel's
/// `stack_guard_gap`): a frame that out-jumps both the guard and this window
/// in one displacement is past what a diagnostic can honestly attribute.
pub(crate) const OVERSHOOT_SLOP: usize = 1024 * 1024;
/// Per-scheduler-thread signal stack. MINSIGSTKSZ is ~11 KiB on AVX-512
/// hardware; 64 KiB leaves the formatter room without mattering to anyone.
/// One per OS thread, never freed: scheduler threads live for the process in
/// practice, and repeated `run()`s on reused threads re-use the registration
/// (the TLS flag), so the leak is bounded by the OS thread count.
const ALTSTACK_SIZE: usize = 64 * 1024;
static INSTALL: Once = Once::new();
/// The handler that was installed before ours (std's, typically). Written
/// exactly once inside INSTALL — which completes in `runtime::init` before
/// any scheduler thread (and thus any classifiable fault) can exist — and
/// only read from the handler afterwards.
static mut PRIOR: MaybeUninit<libc::sigaction> = MaybeUninit::uninit();
thread_local! {
/// Whether this OS thread has registered its altstack.
static ALTSTACK_SET: Cell<bool> = const { Cell::new(false) };
}
/// Where a fault landed relative to the current actor's stack.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum FaultClass {
/// Inside `[top − reserve − guard, top − reserve)`: the guard region.
Guard,
/// Within `OVERSHOOT_SLOP` below the guard: stepped over it. Payload is
/// the distance below `guard_lo`.
Overshoot(usize),
/// Not ours to explain.
Foreign,
}
/// Pure classifier — all edges unit-tested below. `top` is the stack's usable
/// top, `reserve`/`guard` its shape; both page-rounded by `Stack::new`.
pub(crate) fn classify(addr: usize, top: usize, reserve: usize, guard: usize) -> FaultClass {
let guard_hi = top.wrapping_sub(reserve);
let guard_lo = guard_hi.wrapping_sub(guard);
if addr >= guard_lo && addr < guard_hi {
FaultClass::Guard
} else if addr < guard_lo && addr >= guard_lo.saturating_sub(OVERSHOOT_SLOP) {
FaultClass::Overshoot(guard_lo - addr)
} else {
FaultClass::Foreign
}
}
/// Install the process-global handler. Idempotent; called from
/// `runtime::init`.
pub(crate) fn install_once() {
INSTALL.call_once(|| unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handler as *const () as usize;
sa.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK;
libc::sigemptyset(&mut sa.sa_mask);
let prior = &mut *std::ptr::addr_of_mut!(PRIOR);
libc::sigaction(libc::SIGSEGV, &sa, prior.as_mut_ptr());
});
}
/// Register this OS thread's altstack (idempotent per thread). Called at
/// `schedule_loop` entry, so every thread that can run an actor has one.
pub(crate) fn register_altstack() {
ALTSTACK_SET.with(|set| {
if set.get() {
return;
}
unsafe {
let sp = libc::mmap(
std::ptr::null_mut(),
ALTSTACK_SIZE,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
-1,
0,
);
if sp == libc::MAP_FAILED {
// Degrade: no altstack means a guard hit dies without the
// message (handler can't run) — the pre-RFC behavior, never
// incorrectness.
return;
}
let ss = libc::stack_t {
ss_sp: sp,
ss_flags: 0,
ss_size: ALTSTACK_SIZE,
};
libc::sigaltstack(&ss, std::ptr::null_mut());
}
set.set(true);
});
}
// ---------------------------------------------------------------------------
// The handler
// ---------------------------------------------------------------------------
unsafe extern "C" fn handler(
_sig: libc::c_int,
info: *mut libc::siginfo_t,
_ctx: *mut libc::c_void,
) {
let slot_ptr = crate::preempt::current_slot_ptr();
if !slot_ptr.is_null() {
let slot = &*slot_ptr;
let top = slot.diag_stack_top.load(Ordering::Relaxed);
if top != 0 {
let reserve = slot.diag_stack_reserve.load(Ordering::Relaxed);
let guard = slot.diag_stack_guard.load(Ordering::Relaxed);
let pid = slot.diag_pid.load(Ordering::Relaxed);
let addr = (*info).si_addr() as usize;
match classify(addr, top, reserve, guard) {
FaultClass::Guard => {
let mut b = Buf::new();
b.s("smarm: actor ");
b.pid(pid);
b.s(" overflowed its stack: fault in the guard region, depth-at-fault=");
b.u(top - addr);
b.s(" bytes (reserve=");
b.u(reserve);
b.s(", guard=");
b.u(guard);
b.s("). Raise stack_reserve (SpawnOpts or Config).\n");
b.emit();
die_by_default();
return;
}
FaultClass::Overshoot(below) => {
let mut b = Buf::new();
b.s("smarm: actor ");
b.pid(pid);
b.s(" probably overflowed its stack: fault ");
b.u(below);
b.s(" bytes below the guard - an unprobed (FFI?) frame stepped over it (reserve=");
b.u(reserve);
b.s(", guard=");
b.u(guard);
b.s("). Raise stack_guard or stack_reserve.\n");
b.emit();
die_by_default();
return;
}
FaultClass::Foreign => {}
}
}
}
// Not ours: put back whoever was there before us and refault into them.
let prior = &*std::ptr::addr_of!(PRIOR);
libc::sigaction(libc::SIGSEGV, prior.as_ptr(), std::ptr::null_mut());
}
/// Reset SIGSEGV to default disposition; returning from the handler then
/// refaults at the same instruction and the process dies the normal death
/// (core-dumpable, correct wait status), exactly as if we were never here —
/// but with the message already on stderr.
unsafe fn die_by_default() {
let mut dfl: libc::sigaction = std::mem::zeroed();
dfl.sa_sigaction = libc::SIG_DFL;
libc::sigemptyset(&mut dfl.sa_mask);
libc::sigaction(libc::SIGSEGV, &dfl, std::ptr::null_mut());
}
// ---------------------------------------------------------------------------
// Async-signal-safe formatting: fixed buffer, decimal itoa, one write(2).
// ---------------------------------------------------------------------------
struct Buf {
b: [u8; 320],
len: usize,
}
impl Buf {
fn new() -> Self {
Buf {
b: [0; 320],
len: 0,
}
}
fn s(&mut self, s: &str) {
for &c in s.as_bytes() {
if self.len < self.b.len() {
self.b[self.len] = c;
self.len += 1;
}
}
}
fn u(&mut self, mut n: usize) {
let mut tmp = [0u8; 20];
let mut i = tmp.len();
loop {
i -= 1;
tmp[i] = b'0' + (n % 10) as u8;
n /= 10;
if n == 0 {
break;
}
}
for &c in &tmp[i..] {
if self.len < self.b.len() {
self.b[self.len] = c;
self.len += 1;
}
}
}
/// `idx.gen`, unpacked from the install-time packing.
fn pid(&mut self, packed: u64) {
self.u((packed >> 32) as usize);
self.s(".");
self.u((packed & 0xffff_ffff) as usize);
}
fn emit(&self) {
unsafe {
libc::write(2, self.b.as_ptr() as *const libc::c_void, self.len);
}
}
}
// ---------------------------------------------------------------------------
// Classifier units — the arithmetic edges, before anything integrates.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::{classify, FaultClass, OVERSHOOT_SLOP};
const PG: usize = 4096;
// A synthetic stack far from address-space edges: top at 1 GiB.
const TOP: usize = 1 << 30;
const RESERVE: usize = 16 * PG;
const GUARD: usize = 4 * PG;
const GUARD_HI: usize = TOP - RESERVE;
const GUARD_LO: usize = GUARD_HI - GUARD;
#[test]
fn inside_guard_both_edges() {
assert_eq!(classify(GUARD_LO, TOP, RESERVE, GUARD), FaultClass::Guard);
assert_eq!(
classify(GUARD_HI - 1, TOP, RESERVE, GUARD),
FaultClass::Guard
);
assert_eq!(
classify(GUARD_LO + GUARD / 2, TOP, RESERVE, GUARD),
FaultClass::Guard
);
}
#[test]
fn usable_region_is_foreign() {
// A fault inside the RW stack itself isn't a guard hit and must not
// be explained as one.
assert_eq!(classify(GUARD_HI, TOP, RESERVE, GUARD), FaultClass::Foreign);
assert_eq!(classify(TOP - 1, TOP, RESERVE, GUARD), FaultClass::Foreign);
}
#[test]
fn above_top_is_foreign() {
assert_eq!(classify(TOP, TOP, RESERVE, GUARD), FaultClass::Foreign);
assert_eq!(classify(TOP + PG, TOP, RESERVE, GUARD), FaultClass::Foreign);
}
#[test]
fn overshoot_window_edges() {
assert_eq!(
classify(GUARD_LO - 1, TOP, RESERVE, GUARD),
FaultClass::Overshoot(1)
);
assert_eq!(
classify(GUARD_LO - OVERSHOOT_SLOP, TOP, RESERVE, GUARD),
FaultClass::Overshoot(OVERSHOOT_SLOP)
);
assert_eq!(
classify(GUARD_LO - OVERSHOOT_SLOP - 1, TOP, RESERVE, GUARD),
FaultClass::Foreign
);
}
#[test]
fn low_address_stack_saturates_not_wraps() {
// A stack mapped so low that the slop window would underflow: the
// window clips to 0 instead of wrapping around the address space.
let top = RESERVE + GUARD + PG; // guard_lo == PG
assert_eq!(classify(0, top, RESERVE, GUARD), FaultClass::Overshoot(PG));
// Null-page fault still classified only because it IS within slop
// here; with a normal-height stack it is Foreign (covered above by
// the window-edge test at realistic addresses).
}
}
+9 -9
View File
@@ -188,8 +188,7 @@ impl StateWord {
loop {
let w = self.load();
debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(w) == gen,
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(w) == gen,
"yield return from invalid word {w:#x}"
);
if self
@@ -247,8 +246,7 @@ impl StateWord {
loop {
let w = self.load();
debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(w) == gen,
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(w) == gen,
"begin_wait from invalid word {w:#x}"
);
let next = word_epoch(w).wrapping_add(1) & EPOCH_MASK;
@@ -342,8 +340,7 @@ impl StateWord {
loop {
let w = self.load();
debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(w) == gen,
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(w) == gen,
"clear_notify from invalid word {w:#x}"
);
if word_state(w) != ST_RUNNING_NOTIFIED {
@@ -372,8 +369,7 @@ impl StateWord {
pub(crate) fn set_done(&self, gen: u32) {
let prev = self.0.swap(pack(gen, 0, ST_DONE), Ordering::AcqRel);
debug_assert!(
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(prev) == gen,
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(prev) == gen,
"finalize from invalid word {prev:#x}"
);
}
@@ -538,7 +534,11 @@ mod loom_tests {
// not a pending notification.
assert!(word.try_claim(0));
assert_eq!(word.unpark(0, Some(epoch)), Unpark::Noop);
assert_eq!(word_state(word.load()), ST_RUNNING, "stale epoch notified a live run");
assert_eq!(
word_state(word.load()),
ST_RUNNING,
"stale epoch notified a live run"
);
});
}
+237 -17
View File
@@ -1,32 +1,45 @@
//! mmap-based growable stack with a guard page below.
//! mmap-based actor stack with a PROT_NONE guard region below (RFC 019).
//!
//! Layout (low → high address):
//! [ guard page (PROT_NONE) | stack region ]
//! ^ top() — initial stack pointer
//! [ guard region (PROT_NONE) | stack region ]
//! ^ top() — initial stack pointer
//!
//! Stacks grow downward. Overflow lands in the guard page → SIGSEGV.
//! Stacks grow downward. Overflow lands in the guard region → SIGSEGV.
//!
//! Both the usable reserve and the guard are caller-chosen (page-rounded).
//! The reserve is a *virtual* reservation: anonymous mmap is demand-paged,
//! so RSS is touched-pages, not reserve × actors. The guard costs address
//! space only. A wide guard (the runtime defaults to 64 KiB) exists for
//! unprobed FFI frames: Rust frames touch pages in order (probestack), so
//! one page catches Rust overflow, but a C frame with a large local can
//! step over a single page in one `sub rsp`.
use std::io;
pub struct Stack {
/// Bottom of the entire mmap'd region (start of guard page).
/// Bottom of the entire mmap'd region (start of the guard).
base: *mut u8,
/// Total mmap'd size: guard_size + stack_size.
total_size: usize,
/// Usable stack size (excluding guard page).
/// Usable stack size (excluding the guard).
stack_size: usize,
/// PROT_NONE region below the usable stack.
guard_size: usize,
}
// Stack owns its memory; safe to send across threads.
unsafe impl Send for Stack {}
impl Stack {
/// Allocate a new stack. `stack_size` is the usable region; one page is
/// added below as a guard page. Both are rounded up to the page size.
pub fn new(stack_size: usize) -> io::Result<Self> {
/// Allocate a new stack. `stack_size` is the usable region; `guard_size`
/// is mapped PROT_NONE below it. Both are rounded up to the page size
/// and must be non-zero.
pub fn new(stack_size: usize, guard_size: usize) -> io::Result<Self> {
assert!(stack_size > 0, "stack_size must be non-zero");
assert!(guard_size > 0, "guard_size must be non-zero");
let page = page_size();
let stack_size = round_up(stack_size, page);
let guard_size = page;
let guard_size = round_up(guard_size, page);
let total_size = guard_size + stack_size;
let base = unsafe {
@@ -44,16 +57,19 @@ impl Stack {
}
let base = base as *mut u8;
let ret = unsafe {
libc::mprotect(base as *mut libc::c_void, guard_size, libc::PROT_NONE)
};
let ret = unsafe { libc::mprotect(base as *mut libc::c_void, guard_size, libc::PROT_NONE) };
if ret != 0 {
let err = io::Error::last_os_error();
unsafe { libc::munmap(base as *mut libc::c_void, total_size) };
return Err(err);
}
Ok(Self { base, total_size, stack_size })
Ok(Self {
base,
total_size,
stack_size,
guard_size,
})
}
/// 16-byte-aligned top of the usable region.
@@ -62,14 +78,54 @@ impl Stack {
(raw_top & !15) as *mut u8
}
/// Pointer to the bottom of the usable region (just above the guard page).
/// Pointer to the bottom of the usable region (just above the guard).
pub fn usable_base(&self) -> *mut u8 {
unsafe { self.base.add(page_size()) }
unsafe { self.base.add(self.guard_size) }
}
pub fn stack_size(&self) -> usize {
self.stack_size
}
pub fn guard_size(&self) -> usize {
self.guard_size
}
/// `(stack_size, guard_size)` after page rounding. The pool rule
/// (RFC 019 §1) compares this against the runtime defaults: only
/// default-shaped stacks are pooled.
pub fn shape(&self) -> (usize, usize) {
(self.stack_size, self.guard_size)
}
/// Pool-recycle zap (RFC 019 §6): `MADV_DONTNEED` everything below the
/// retained entry end `[top − retain, top)` — the span the next actor's
/// shallow frames land in stays resident, the dead spike below it is
/// released. The stack is unowned at the call site (its actor is dead),
/// so a synchronous eager zap races nothing and the RSS drop is
/// immediate — a museum of worst-case spikes is exactly what a pool must
/// not be; DONTNEED's ~8× per-page cost vs FREE is irrelevant off the
/// hot path. Advisory like the park-path shrink: a failure degrades to
/// "the pool keeps RSS", never to incorrectness. No-op (no syscall) when
/// `retain` covers the whole usable region — i.e. always, at the 64 KiB
/// default reserve.
pub(crate) fn recycle_zap(&self, retain: usize) {
if let Some((off, len)) = retain_range(self.stack_size, retain, page_size()) {
unsafe {
libc::madvise(
self.usable_base().add(off) as *mut libc::c_void,
len,
libc::MADV_DONTNEED,
);
}
}
}
}
/// Round `n` up to whole pages — the same rounding `Stack::new` applies, so
/// runtime defaults stored pre-rounded compare exactly against [`Stack::shape`].
pub(crate) fn round_to_pages(n: usize) -> usize {
round_up(n, page_size())
}
impl Drop for Stack {
@@ -80,10 +136,174 @@ impl Drop for Stack {
}
}
fn page_size() -> usize {
pub(crate) fn page_size() -> usize {
unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }
}
fn round_up(n: usize, align: usize) -> usize {
(n + align - 1) & !(align - 1)
}
/// The whole-page span the park-path shrink may `MADV_FREE` (RFC 019 §3):
/// `[page_up(hwm), page_down(sp − redzone))`, or `None` if no full page fits.
///
/// `hwm` is the sampled high-water (deepest observed `sp`); everything in
/// `[hwm, sp)` is below the live frame and dead by definition. One page of
/// redzone stays resident under live `sp` — it covers the SysV 128-byte red
/// zone plus spill margin with room to spare. Rounding is inward on both
/// ends so the result can never touch the redzone, cross `sp`, or dip below
/// `hwm`; all arithmetic is checked so adversarial inputs (`sp < redzone`,
/// `hwm ≥ sp`, values near the address-space edges) collapse to `None`
/// rather than a wild or negative-length range.
pub(crate) fn shrink_range(hwm: usize, sp: usize, page: usize) -> Option<(usize, usize)> {
debug_assert!(page.is_power_of_two());
if hwm >= sp {
return None;
}
let redzone = page;
let end = sp.checked_sub(redzone)? & !(page - 1); // page_down(sp − redzone)
let start = hwm.checked_add(page - 1)? & !(page - 1); // page_up(hwm)
if end > start {
Some((start, end - start))
} else {
None
}
}
/// The `(offset_from_usable_base, len)` span the pool recycle DONTNEEDs
/// (RFC 019 §6): everything below the retained entry end. "Bottom RETAIN of
/// the stack" is read stack-wise (entry frames = highest addresses of a
/// downward stack): the retained span is `[top − page_up(retain), top)`, the
/// zapped span is the rest — retaining the low-address deep end instead
/// would keep the coldest pages and release the ones the next actor faults
/// first. `retain` rounds *up* to whole pages (retain more, zap less), so
/// with `stack_size` page-rounded by `Stack::new` the result is always
/// page-aligned. Checked math: `retain ≥ stack_size` (notably the default
/// 64 KiB reserve with the 64 KiB RETAIN) and overflow collapse to `None`.
pub(crate) fn retain_range(
stack_size: usize,
retain: usize,
page: usize,
) -> Option<(usize, usize)> {
debug_assert!(page.is_power_of_two());
let retain = retain.checked_add(page - 1)? & !(page - 1); // page_up(retain)
let len = stack_size.checked_sub(retain)?;
if len == 0 {
return None;
}
Some((0, len))
}
#[cfg(test)]
mod tests {
use super::{retain_range, shrink_range};
const PG: usize = 4096;
#[test]
fn retain_covers_whole_stack_is_a_noop() {
// The default config: reserve == RETAIN == 64 KiB. No zap, no syscall.
assert_eq!(retain_range(16 * PG, 16 * PG, PG), None);
assert_eq!(retain_range(PG, PG, PG), None);
}
#[test]
fn retain_larger_than_stack_is_a_noop() {
assert_eq!(retain_range(16 * PG, 17 * PG, PG), None);
assert_eq!(retain_range(PG, usize::MAX, PG), None); // page_up overflows
}
#[test]
fn retain_zero_zaps_everything() {
assert_eq!(retain_range(16 * PG, 0, PG), Some((0, 16 * PG)));
}
#[test]
fn retain_rounds_up_zapping_less() {
// 1 byte of retain keeps a whole page.
assert_eq!(retain_range(16 * PG, 1, PG), Some((0, 15 * PG)));
assert_eq!(retain_range(16 * PG, PG + 1, PG), Some((0, 14 * PG)));
}
#[test]
fn retain_one_page_short_of_stack() {
assert_eq!(retain_range(2 * PG, PG, PG), Some((0, PG)));
}
#[test]
fn retain_range_is_page_aligned() {
for size_pg in [1usize, 2, 3, 16, 1024] {
for retain in [0usize, 1, PG - 1, PG, PG + 1, 4 * PG, size_pg * PG] {
if let Some((off, len)) = retain_range(size_pg * PG, retain, PG) {
assert_eq!(off, 0);
assert_eq!(len % PG, 0);
assert!(len <= size_pg * PG);
assert!(len > 0);
}
}
}
}
#[test]
fn empty_and_inverted_spans_are_none() {
assert_eq!(shrink_range(0x8000_0000, 0x8000_0000, PG), None); // hwm == sp
assert_eq!(shrink_range(0x8000_1000, 0x8000_0000, PG), None); // hwm > sp
}
#[test]
fn span_smaller_than_redzone_plus_page_is_none() {
let sp = 0x8000_0000;
// Everything within redzone+1 page of sp: no full page clears both
// the redzone and the page_up(hwm) rounding.
assert_eq!(shrink_range(sp - PG, sp, PG), None);
assert_eq!(shrink_range(sp - 2 * PG + 1, sp, PG), None);
}
#[test]
fn exact_two_pages_frees_one() {
let sp = 0x8000_0000;
let hwm = sp - 2 * PG;
// [hwm, hwm+PG) frees; [sp−PG, sp) is redzone.
assert_eq!(shrink_range(hwm, sp, PG), Some((hwm, PG)));
}
#[test]
fn unaligned_ends_round_inward() {
let sp = 0x8000_0123; // live sp mid-page
let hwm = 0x7f00_0abc; // high-water mid-page
let (start, len) = shrink_range(hwm, sp, PG).unwrap();
assert_eq!(start % PG, 0);
assert_eq!(len % PG, 0);
assert!(start >= hwm); // never below the sampled high-water
assert!(start + len <= (sp - PG) & !(PG - 1)); // never into the redzone
}
#[test]
fn result_never_crosses_sp() {
// Sweep hwm across every offset of the page straddling the boundary.
let sp = 0x8000_0000 + 137;
for hwm in (sp - 4 * PG)..(sp) {
if let Some((start, len)) = shrink_range(hwm, sp, PG) {
assert!(start >= hwm);
assert!(start + len + PG <= sp + PG); // end ≤ page_down(sp − PG) < sp
assert!(len > 0);
}
}
}
#[test]
fn underflow_near_zero_is_none() {
assert_eq!(shrink_range(0, PG - 1, PG), None); // sp < redzone
assert_eq!(shrink_range(0, 0, PG), None);
}
#[test]
fn big_span_frees_interior() {
let sp = 0x8000_0000;
let spike = 4 * 1024 * 1024;
let hwm = sp - spike;
let (start, len) = shrink_range(hwm, sp, PG).unwrap();
assert_eq!(start, hwm); // aligned input: starts exactly at hwm
assert_eq!(len, spike - PG); // everything but the redzone page
}
}
+119 -41
View File
@@ -1,16 +1,114 @@
//! Supervision signals.
//! Supervision: keep a set of actors alive.
//!
//! Every actor has a supervisor, which is itself just an actor with a
//! `Receiver<Signal>`. When a child actor terminates, the scheduler sends
//! a `Signal` on the supervisor's channel. The supervisor decides what to
//! do — restart, escalate, ignore.
//! A *supervisor* is an actor whose only job is to start a fixed set of child
//! actors and react when one of them terminates — restarting it (and, depending
//! on the strategy, some of its siblings) according to a policy, or giving up
//! when failures arrive too fast. It is how you turn "an actor that might crash"
//! into "a service that stays up": a crash becomes a restart instead of a hole
//! in the process tree.
//!
//! For v0.1 there is no built-in restart-intensity cap. That's policy and
//! lives in user code; library is mechanism only.
//! The supervisor type is [`OneForOne`]. The name is historical — the restart
//! *strategy* is selectable via [`OneForOne::strategy`], and
//! [`Strategy::OneForOne`] is merely the default. You declare the children up
//! front as [`ChildSpec`]s, each carrying a [`Restart`] policy, then hand the
//! supervision loop an actor of its own with [`OneForOne::run`].
//!
//! ## A child that crashes and recovers
//!
//! ```
//! use smarm::{run, spawn, ChildSpec, OneForOne, Restart};
//! use std::sync::Arc;
//! use std::sync::atomic::{AtomicUsize, Ordering};
//! use std::time::Duration;
//!
//! run(|| {
//! // A flaky child: it panics on its first two starts, then settles.
//! let starts = Arc::new(AtomicUsize::new(0));
//! let s = starts.clone();
//! let child = move || {
//! let n = s.fetch_add(1, Ordering::SeqCst) + 1;
//! if n < 3 {
//! panic!("boom {n}");
//! }
//! // The third start returns normally.
//! };
//!
//! // The supervisor runs on its own actor. `Transient` restarts a child
//! // that panics but treats a clean return as "done", so once the child
//! // finally succeeds the supervisor has nothing left to do and `run()`
//! // returns. smarm catches the child's panic and turns it into a restart;
//! // it never reaches the process as a real crash.
//! let sup = spawn(move || {
//! OneForOne::new()
//! .intensity(5, Duration::from_secs(60))
//! .child(ChildSpec::new(Restart::Transient, child))
//! .run();
//! });
//! sup.join().unwrap();
//!
//! assert_eq!(starts.load(Ordering::SeqCst), 3); // one start, two restarts
//! });
//! ```
//!
//! ## Restart policies
//!
//! Each child carries a [`Restart`] policy that decides whether *that child*
//! comes back when it terminates:
//!
//! - [`Restart::Permanent`] restarts on any termination, normal or panic —
//! for a service that should never be down.
//! - [`Restart::Transient`] restarts only on an abnormal exit (a panic or a
//! cooperative stop); a clean return means "done" — for work that runs to
//! completion but should be retried if it crashes.
//! - [`Restart::Temporary`] never restarts; the death is simply noted.
//!
//! ## Strategies: which siblings get cycled
//!
//! When a restart is due, the [`Strategy`] decides which *other* children are
//! cycled along with the one that died. The triggering child's own policy still
//! decides whether anything restarts at all.
//!
//! - [`Strategy::OneForOne`] restarts only the child that died — the default.
//! - [`Strategy::OneForAll`] restarts every child: the survivors are stopped,
//! then the whole set is restarted.
//! - [`Strategy::RestForOne`] restarts the dead child and every child started
//! after it, leaving earlier children untouched.
//!
//! ## Stopping a sibling is cooperative
//!
//! Cycling a sibling means stopping it first, and a supervisor never tears a
//! running actor down from outside: smarm actors share a heap and rely on
//! Drop/RAII, so unwinding a peer's stack from elsewhere would be unsound.
//! Instead the supervisor *requests* the stop and the child unwinds at its next
//! observation point — a `check!()`, an allocation, or a blocking call. A child
//! wedged in a tight loop with no observation point cannot be stopped, for the
//! same reason it cannot be preempted.
//!
//! ## Giving up: the restart-intensity cap
//!
//! A child that crashes the instant it starts would otherwise restart forever.
//! [`OneForOne::intensity`] bounds that: at most `max` restarts within any
//! `period`-long sliding window. One terminating child counts as a single
//! restart event even when the strategy cycles several siblings. When the cap
//! trips, the supervisor stops restarting, cooperatively stops any survivors in
//! reverse start order, and `run()` returns.
//!
//! ## Running context
//!
//! [`OneForOne::run`] takes over the calling actor as the supervision loop, so
//! a supervisor gets an actor of its own — typically
//! `spawn(|| OneForOne::new()/* … */.run())`, all from inside
//! [`run`](crate::run). Each child is spawned beneath the supervisor's pid, so
//! every child termination funnels back to it as a [`Signal`].
use crate::pid::Pid;
use std::any::Any;
/// A child-termination notice delivered to its supervisor.
///
/// Every child a supervisor starts is spawned beneath the supervisor's pid, so
/// each child's termination funnels back to it as one of these. The variant
/// records *how* the child went — which is what its [`Restart`] policy keys off.
pub enum Signal {
/// The child exited normally.
Exit(Pid),
@@ -25,9 +123,9 @@ pub enum Signal {
impl std::fmt::Debug for Signal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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::Stopped(pid) => write!(f, "Signal::Stopped({:?})", pid),
Signal::Stopped(pid) => write!(f, "Signal::Stopped({:?})", pid),
}
}
}
@@ -35,34 +133,13 @@ impl std::fmt::Debug for Signal {
impl Signal {
pub fn pid(&self) -> Pid {
match self {
Signal::Exit(p) => *p,
Signal::Exit(p) => *p,
Signal::Panic(p, _) => *p,
Signal::Stopped(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;
@@ -92,7 +169,10 @@ pub struct ChildSpec {
impl ChildSpec {
pub fn new(restart: Restart, start: impl Fn() + Send + Sync + 'static) -> Self {
Self { start: Arc::new(start), restart }
Self {
start: Arc::new(start),
restart,
}
}
}
@@ -115,11 +195,10 @@ pub enum Strategy {
/// 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())`).
/// Build it with [`new`](Self::new), add children with [`child`](Self::child),
/// pick a [`strategy`](Self::strategy) and an [`intensity`](Self::intensity)
/// cap, then drive the loop with [`run`](Self::run) on an actor of its own. See
/// the [module docs](self) for the full picture.
pub struct OneForOne {
children: Vec<ChildSpec>,
strategy: Strategy,
@@ -253,7 +332,7 @@ impl OneForOne {
.collect(),
};
// Stop survivors in reverse start order (highest child index first).
to_stop.sort_unstable_by(|a, b| b.1.cmp(&a.1));
to_stop.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
// The set we will restart: the failed child plus every sibling we
// are about to stop, restarted in start (ascending index) order.
@@ -299,7 +378,7 @@ impl OneForOne {
// 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));
survivors.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
let mut awaiting: Vec<Pid> = Vec::with_capacity(survivors.len());
for (pid, _) in &survivors {
crate::scheduler::request_stop(*pid);
@@ -316,4 +395,3 @@ impl OneForOne {
}
}
}
+11 -2
View File
@@ -6,10 +6,19 @@
//! Build the loom models with: `RUSTFLAGS="--cfg loom" cargo test --lib --release`
#[cfg(loom)]
pub(crate) use loom::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
pub(crate) use loom::sync::atomic::{fence, AtomicU64, AtomicUsize, Ordering};
#[cfg(not(loom))]
pub(crate) use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
pub(crate) use std::sync::atomic::{fence, AtomicU64, AtomicUsize, Ordering};
// park.rs condvar-parker (loom + non-Linux builds only; the Linux non-loom
// build parks on a futex and never touches these — gating them identically
// keeps the default build free of unused imports).
#[cfg(loom)]
pub(crate) use loom::sync::{Condvar, Mutex};
#[cfg(all(not(loom), not(target_os = "linux")))]
pub(crate) use std::sync::{Condvar, Mutex};
/// `UnsafeCell` with loom's `with`/`with_mut` access API; pass-through cost
/// is zero in normal builds (`#[inline]`, newtype over std's cell).
+155 -17
View File
@@ -102,6 +102,19 @@ pub struct Entry {
seq: u64,
pub pid: Pid,
pub reason: Reason,
/// RFC 007 virtual time: the global delay ledger reading when this entry
/// was (re-)queued. `pop_due` shifts the effective deadline by any delay
/// injected since, so timers dilate together with the causally-delayed
/// workload instead of firing early in virtual terms.
#[cfg(feature = "smarm-causal")]
delay_stamp: u64,
/// RFC 007: a wall-anchored entry opts out of the virtual-time shift —
/// its deadline is honoured in wall time regardless of injected delay.
/// Used by the causal controller's own measurement/cooldown sleeps so
/// experiment windows keep a fixed wall length; ordinary workload timers
/// stay virtual (`false`).
#[cfg(feature = "smarm-causal")]
wall: bool,
}
impl PartialEq for Entry {
@@ -116,7 +129,9 @@ impl Ord for Entry {
// Earlier deadline first; ties broken by insertion order so the
// ordering is total. `Reason` and `Pid` deliberately don't
// participate.
self.deadline.cmp(&other.deadline).then_with(|| self.seq.cmp(&other.seq))
self.deadline
.cmp(&other.deadline)
.then_with(|| self.seq.cmp(&other.seq))
}
}
@@ -128,6 +143,14 @@ impl PartialOrd for Entry {
#[derive(Default)]
pub struct Timers {
/// RFC 018: the scheduler coordination layer. Attached once at
/// `RuntimeInner::new`; every insert notes its deadline (min-maintained
/// snapshot for the busy-path due-check + the timekeeper re-arm wake)
/// and every pop/clear re-anchors the snapshot to the heap minimum.
/// All calls happen under the timers mutex — the serialization the
/// coordinator's timer protocol mandates. `None` only in unit tests
/// that construct a bare `Timers`.
coord: Option<std::sync::Arc<crate::park::Coordinator>>,
/// Reverse-wrapped so the smallest deadline is at the top.
heap: BinaryHeap<Reverse<Entry>>,
/// Monotonic counter for the tiebreaker `seq` field (and the `TimerId` of a
@@ -144,7 +167,18 @@ pub struct Timers {
impl Timers {
pub fn new() -> Self {
Self { heap: BinaryHeap::new(), next_seq: 0, armed: std::collections::HashSet::new() }
Self {
coord: None,
heap: BinaryHeap::new(),
next_seq: 0,
armed: std::collections::HashSet::new(),
}
}
/// Attach the scheduler coordination layer (RFC 018). Called once, at
/// runtime construction, before any scheduler thread exists.
pub(crate) fn attach_coordinator(&mut self, c: std::sync::Arc<crate::park::Coordinator>) {
self.coord = Some(c);
}
/// Insert a `Sleep` timer. Convenience for the common case.
@@ -152,6 +186,19 @@ impl Timers {
self.insert(deadline, pid, Reason::Sleep { epoch });
}
/// Insert a *wall-anchored* `Sleep` timer: fires at `deadline` in wall
/// time even while causal profiling (feature `smarm-causal`) is injecting
/// virtual delay — it never chases the delay ledger. Without the feature
/// this is identical to [`insert_sleep`](Self::insert_sleep).
///
/// Intended for measurement machinery (the causal controller's window and
/// cooldown sleeps, TSC calibration) whose durations *define* wall time
/// rather than participate in the workload. Workload code should use the
/// ordinary virtual-anchored timers.
pub fn insert_sleep_wall(&mut self, deadline: Instant, pid: Pid, epoch: u32) {
self.push(deadline, pid, Reason::Sleep { epoch }, true);
}
/// Arm a cancellable `send_after` timer: run `fire` at `deadline` unless
/// [`cancel`](Self::cancel)led first. `pid` is informational only (the
/// destination, or who armed it — useful for introspection); it is *not*
@@ -163,11 +210,24 @@ impl Timers {
pid: Pid,
fire: Box<dyn FnOnce() + Send>,
) -> TimerId {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.armed.insert(seq);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason: Reason::Send { fire } }));
TimerId(seq)
self.armed.insert(self.next_seq);
TimerId(self.push(deadline, pid, Reason::Send { fire }, false))
}
/// Arm a *wall-anchored* cancellable `send_after` timer (RFC 007): the
/// same contract as [`insert_send`](Self::insert_send), but the entry
/// opts out of the virtual-time shift and fires at its raw deadline
/// regardless of injected delay — the `Send`-reason sibling of
/// [`insert_sleep_wall`](Self::insert_sleep_wall). Without the
/// `smarm-causal` feature this is identical to `insert_send`.
pub fn insert_send_wall(
&mut self,
deadline: Instant,
pid: Pid,
fire: Box<dyn FnOnce() + Send>,
) -> TimerId {
self.armed.insert(self.next_seq);
TimerId(self.push(deadline, pid, Reason::Send { fire }, true))
}
/// Cancel an armed `send_after` timer. Returns `true` if the timer was
@@ -179,11 +239,38 @@ impl Timers {
self.armed.remove(&id.0)
}
/// Insert an arbitrary timer entry.
/// Insert an arbitrary (virtual-anchored) timer entry.
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
self.push(deadline, pid, reason, false);
}
/// Common insertion path. `wall` selects the RFC 007 anchor (see
/// [`insert_sleep_wall`](Self::insert_sleep_wall)); it is accepted — and
/// ignored — without the `smarm-causal` feature so callers don't fork.
/// Returns the entry's `seq`.
fn push(&mut self, deadline: Instant, pid: Pid, reason: Reason, wall: bool) -> u64 {
#[cfg(not(feature = "smarm-causal"))]
let _ = wall;
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason }));
self.heap.push(Reverse(Entry {
deadline,
seq,
pid,
reason,
#[cfg(feature = "smarm-causal")]
delay_stamp: crate::causal::global_delay_cycles(),
#[cfg(feature = "smarm-causal")]
wall,
}));
// RFC 018: publish the (possibly new-minimum) deadline to the
// busy-path snapshot and wake the timekeeper if it is parked
// toward a later one. We hold the timers mutex — the mandated
// serialization for both.
if let Some(c) = &self.coord {
c.note_deadline(deadline);
}
seq
}
pub fn is_empty(&self) -> bool {
@@ -196,6 +283,9 @@ impl Timers {
pub fn clear(&mut self) {
self.heap.clear();
self.armed.clear();
if let Some(c) = &self.coord {
c.refresh_deadline(None);
}
}
/// Soonest pending deadline, or `None` if the heap is empty.
@@ -210,19 +300,67 @@ impl Timers {
/// one is silently dropped here (its `seq` was already removed from
/// `armed` by [`cancel`](Self::cancel)). Returning it removes it from
/// `armed`, so a later `cancel` of a fired timer reports `false`.
///
/// RFC 007 virtual time (feature `smarm-causal`): before an entry fires,
/// any global delay injected since it was (re-)queued is added to its
/// deadline; an entry whose *effective* deadline hasn't passed is pushed
/// back with the shifted deadline and a fresh stamp, so it keeps chasing
/// delay injected while it waits. Consequences, both benign:
/// [`peek_deadline`](Self::peek_deadline) may under-report (raw deadline
/// earlier than effective), costing at most one spurious scheduler wake
/// per injected chunk; and a shift never converts wall time — with zero
/// debt the path is byte-identical to the featureless one. Wall-anchored
/// entries ([`insert_sleep_wall`](Self::insert_sleep_wall)) are exempt
/// from the shift and always fire at their raw deadline.
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
let mut out = Vec::new();
#[cfg(feature = "smarm-causal")]
let global = crate::causal::global_delay_cycles();
while let Some(r) = self.heap.peek() {
if r.0.deadline <= now {
let entry = self.heap.pop().unwrap().0;
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.remove(&entry.seq) {
// Cancelled before it came due: discard, do not deliver.
continue;
}
out.push(entry);
} else {
if r.0.deadline > now {
break;
}
#[allow(unused_mut)]
let mut entry = match self.heap.pop() {
Some(e) => e.0,
None => panic!("smarm: timer heap pop after peek returned None (core corrupt)"),
};
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.contains(&entry.seq) {
// Cancelled before it came due: discard, do not deliver.
// (Checked before any shift so a cancelled entry is never
// re-queued just to be discarded later.)
continue;
}
#[cfg(feature = "smarm-causal")]
if !entry.wall {
let debt = global.saturating_sub(entry.delay_stamp);
if debt > 0 {
let shifted = entry
.deadline
.checked_add(crate::causal::cycles_to_duration(debt))
.unwrap_or(entry.deadline);
if shifted > now {
// Not due in virtual time: re-queue at the shifted
// deadline, stamped, keeping `seq` (and thus `Send`
// cancellation identity) intact.
entry.deadline = shifted;
entry.delay_stamp = global;
self.heap.push(Reverse(entry));
continue;
}
}
}
if matches!(entry.reason, Reason::Send { .. }) {
self.armed.remove(&entry.seq);
}
out.push(entry);
}
// RFC 018: re-anchor the busy-path snapshot to the new heap minimum
// (still under the timers mutex). A causal-shift re-queue above went
// through `heap.push` directly, so this peek is the one place the
// snapshot is guaranteed to catch up.
if let Some(c) = &self.coord {
c.refresh_deadline(self.peek_deadline());
}
out
}
+63 -34
View File
@@ -16,13 +16,17 @@
#[cfg(feature = "smarm-trace")]
#[macro_export]
macro_rules! te {
($kind:expr) => { $crate::trace::record($kind) };
($kind:expr) => {
$crate::trace::record($kind)
};
}
#[cfg(not(feature = "smarm-trace"))]
#[macro_export]
macro_rules! te {
($kind:expr) => { () };
($kind:expr) => {
()
};
}
#[cfg(feature = "smarm-trace")]
@@ -68,8 +72,8 @@ mod inner {
// -----------------------------------------------------------------------
struct Record {
nanos: u64, // ns since open()
tid: u64, // OS thread id
nanos: u64, // ns since open()
tid: u64, // OS thread id
event: Event,
}
@@ -84,8 +88,8 @@ mod inner {
// -----------------------------------------------------------------------
struct Global {
sender: mpsc::Sender<Msg>,
start: Instant,
sender: mpsc::Sender<Msg>,
start: Instant,
}
static GLOBAL: Mutex<Option<Global>> = Mutex::new(None);
@@ -95,13 +99,13 @@ mod inner {
// The start Instant is copied alongside it — also one mutex hit per thread.
// record() never touches GLOBAL after that.
struct LocalState {
tx: mpsc::Sender<Msg>,
tx: mpsc::Sender<Msg>,
start: Instant,
}
thread_local! {
static LOCAL_STATE: std::cell::RefCell<Option<LocalState>> =
std::cell::RefCell::new(None);
const { std::cell::RefCell::new(None) };
}
// -----------------------------------------------------------------------
@@ -109,20 +113,26 @@ mod inner {
// -----------------------------------------------------------------------
pub fn open() {
let path = std::env::var("SMARM_TRACE_FILE")
.unwrap_or_else(|_| "smarm_trace.json".to_owned());
let path =
std::env::var("SMARM_TRACE_FILE").unwrap_or_else(|_| "smarm_trace.json".to_owned());
let (tx, rx) = mpsc::channel::<Msg>();
let start = Instant::now();
*GLOBAL.lock().unwrap() = Some(Global { sender: tx, start });
match GLOBAL.lock() {
Ok(mut g) => *g = Some(Global { sender: tx, start }),
Err(e) => panic!("smarm: trace lock poisoned (core corrupt): {e}"),
}
// Drain thread: owns the Receiver, writes to disk.
let path_for_thread = path.clone();
std::thread::Builder::new()
match std::thread::Builder::new()
.name("smarm-trace-drain".into())
.spawn(move || drain_thread(rx, &path_for_thread))
.expect("failed to spawn trace drain thread");
{
Ok(_) => {}
Err(e) => panic!("smarm: failed to spawn trace drain thread: {e}"),
}
eprintln!("[smarm-trace] writing to {}", path);
}
@@ -133,7 +143,10 @@ mod inner {
// Drop the global sender so the drain thread's recv() returns Err
// after the Flush sentinel, signalling clean shutdown.
let sender = {
let mut g = GLOBAL.lock().unwrap();
let mut g = match GLOBAL.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: trace lock poisoned (core corrupt): {e}"),
};
g.take().map(|g| g.sender)
};
if let Some(tx) = sender {
@@ -155,21 +168,28 @@ mod inner {
// which would try to re-acquire inner.shared (already held at many
// te!() call sites) -> deadlock. Guard at the very top, before any
// allocation-capable call.
let was_enabled = crate::preempt::PREEMPTION_ENABLED
.with(|e| { let v = e.get(); e.set(false); v });
let was_enabled = crate::preempt::PREEMPTION_ENABLED.with(|e| {
let v = e.get();
e.set(false);
v
});
LOCAL_STATE.with(|cell| {
let mut opt = cell.borrow_mut();
// Lazily initialise: one mutex hit per thread, ever.
if opt.is_none() {
if let Some(g) = GLOBAL.lock().unwrap().as_ref() {
let guard = match GLOBAL.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: trace lock poisoned (core corrupt): {e}"),
};
if let Some(g) = guard.as_ref() {
let tx = g.sender.clone();
*opt = Some(LocalState { tx, start: g.start });
}
}
if let Some(ls) = opt.as_ref() {
let nanos = ls.start.elapsed().as_nanos() as u64;
let tid = os_tid();
let tid = os_tid();
let _ = ls.tx.send(Msg::Event(Record { nanos, tid, event }));
}
});
@@ -184,7 +204,10 @@ mod inner {
fn drain_thread(rx: mpsc::Receiver<Msg>, path: &str) {
let f = match std::fs::File::create(path) {
Ok(f) => f,
Err(e) => { eprintln!("[smarm-trace] create failed: {}", e); return; }
Err(e) => {
eprintln!("[smarm-trace] create failed: {}", e);
return;
}
};
let mut w = std::io::BufWriter::new(f);
let _ = writeln!(w, "{{\"traceEvents\":[");
@@ -197,7 +220,9 @@ mod inner {
Ok(Msg::Event(r)) => {
let (name, actor_idx) = chrome_fields(&r.event);
let ts_us = r.nanos as f64 / 1000.0;
if !first { let _ = w.write_all(b",\n"); }
if !first {
let _ = w.write_all(b",\n");
}
first = false;
let _ = write!(w,
"{{\"ph\":\"i\",\"ts\":{:.3},\"pid\":{},\"tid\":{},\"name\":{:?},\"s\":\"g\"}}",
@@ -221,27 +246,31 @@ mod inner {
fn chrome_fields(ev: &Event) -> (String, u32) {
match ev {
Event::Spawn { parent, child } =>
(format!("spawn c={}", child.index()), parent.index()),
Event::Resume(p) => ("resume".into(), p.index()),
Event::Yield(p) => ("yield".into(), p.index()),
Event::Park(p) => ("park".into(), p.index()),
Event::Done(p) => ("done".into(), p.index()),
Event::UnparkDirect(p) => ("unpark_direct".into(), p.index()),
Event::UnparkDeferred(p) => ("unpark_deferred".into(), p.index()),
Event::Spawn { parent, child } => {
(format!("spawn c={}", child.index()), parent.index())
}
Event::Resume(p) => ("resume".into(), p.index()),
Event::Yield(p) => ("yield".into(), p.index()),
Event::Park(p) => ("park".into(), p.index()),
Event::Done(p) => ("done".into(), p.index()),
Event::UnparkDirect(p) => ("unpark_direct".into(), p.index()),
Event::UnparkDeferred(p) => ("unpark_deferred".into(), p.index()),
Event::UnparkFlagConsumed(p) => ("unpark_flag_consumed".into(), p.index()),
Event::Send { sender, receiver } => (
format!("send rx={}", receiver
.map(|p| p.index().to_string())
.unwrap_or_else(|| "none".into())),
format!(
"send rx={}",
receiver
.map(|p| p.index().to_string())
.unwrap_or_else(|| "none".into())
),
sender.index(),
),
Event::RecvPark(p) => ("recv_park".into(), p.index()),
Event::RecvWake(p) => ("recv_wake".into(), p.index()),
Event::Enqueue(p) => ("enqueue".into(), p.index()),
Event::Dequeue(p) => ("dequeue".into(), p.index()),
Event::Enqueue(p) => ("enqueue".into(), p.index()),
Event::Dequeue(p) => ("dequeue".into(), p.index()),
Event::SlotPush(p) => ("slot_push".into(), p.index()),
Event::SlotPop(p) => ("slot_pop".into(), p.index()),
Event::SlotPop(p) => ("slot_pop".into(), p.index()),
}
}
+24 -6
View File
@@ -49,8 +49,14 @@ fn looping_actor_on_check_is_stopped() {
}
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");
assert!(
saw_stopped.load(Ordering::SeqCst),
"expected DownReason::Stopped"
);
assert!(
dropped.load(Ordering::SeqCst),
"Drop guard must run during the cancellation unwind"
);
}
#[test]
@@ -79,8 +85,14 @@ fn parked_on_recv_actor_is_stopped() {
}
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");
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]
@@ -185,6 +197,12 @@ fn stop_flagged_while_queued_lands_at_first_park() {
.recv_timeout(Duration::from_secs(10))
.expect("runtime deadlocked: stop against a QUEUED actor was lost at its first park");
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind");
assert!(
saw_stopped.load(Ordering::SeqCst),
"expected DownReason::Stopped"
);
assert!(
dropped.load(Ordering::SeqCst),
"Drop guard must run during the cancellation unwind"
);
}
+1039
View File
File diff suppressed because it is too large Load Diff
+18 -10
View File
@@ -154,7 +154,10 @@ fn channel_ops_interleaved_with_monitor_churn_multi_thread() {
}
consumer.join().unwrap();
});
assert_eq!(total.load(std::sync::atomic::Ordering::Relaxed), (0..32).sum::<i64>());
assert_eq!(
total.load(std::sync::atomic::Ordering::Relaxed),
(0..32).sum::<i64>()
);
}
// ---------------------------------------------------------------------------
@@ -220,7 +223,10 @@ fn recv_timeout_reports_disconnected_on_close() {
fn recv_timeout_zero_duration_is_a_bounded_poll() {
run(|| {
let (_tx, rx) = channel::<i64>();
assert_eq!(rx.recv_timeout(Duration::ZERO), Err(RecvTimeoutError::Timeout));
assert_eq!(
rx.recv_timeout(Duration::ZERO),
Err(RecvTimeoutError::Timeout)
);
});
}
@@ -262,15 +268,17 @@ fn recv_timeout_many_waiters_multi_thread() {
let (tx, rx) = channel::<i64>();
let got = got2.clone();
let timed_out = timed_out2.clone();
handles.push(spawn(move || match rx.recv_timeout(Duration::from_millis(100)) {
Ok(v) => {
assert_eq!(v, i);
got.fetch_add(1, Ordering::Relaxed);
handles.push(spawn(move || {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(v) => {
assert_eq!(v, i);
got.fetch_add(1, Ordering::Relaxed);
}
Err(RecvTimeoutError::Timeout) => {
timed_out.fetch_add(1, Ordering::Relaxed);
}
Err(e) => panic!("unexpected: {e}"),
}
Err(RecvTimeoutError::Timeout) => {
timed_out.fetch_add(1, Ordering::Relaxed);
}
Err(e) => panic!("unexpected: {e}"),
}));
if i % 2 == 0 {
handles.push(spawn(move || {
+35 -16
View File
@@ -11,9 +11,15 @@ thread_local! {
static LOG: Cell<u64> = const { Cell::new(0) };
}
fn log(v: u64) { LOG.with(|c| c.set(c.get() | v)); }
fn get_log() -> u64 { LOG.with(|c| c.get()) }
fn reset_log() { LOG.with(|c| c.set(0)); }
fn log(v: u64) {
LOG.with(|c| c.set(c.get() | v));
}
fn get_log() -> u64 {
LOG.with(|c| c.get())
}
fn reset_log() {
LOG.with(|c| c.set(0));
}
extern "C-unwind" fn actor_simple() {
log(0x1);
@@ -23,7 +29,7 @@ extern "C-unwind" fn actor_simple() {
#[test]
fn actor_runs_and_returns_to_scheduler() {
reset_log();
let stack = Stack::new(64 * 1024).unwrap();
let stack = Stack::new(64 * 1024, 4096).unwrap();
let sp = init_actor_stack(stack.top(), actor_simple);
set_actor_sp(sp);
unsafe { switch_to_actor() };
@@ -40,7 +46,7 @@ extern "C-unwind" fn actor_two_steps() {
#[test]
fn actor_yields_and_resumes() {
reset_log();
let stack = Stack::new(64 * 1024).unwrap();
let stack = Stack::new(64 * 1024, 4096).unwrap();
let sp = init_actor_stack(stack.top(), actor_two_steps);
set_actor_sp(sp);
@@ -56,7 +62,7 @@ fn actor_yields_and_resumes() {
use std::sync::OnceLock;
static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new();
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
extern "C-unwind" fn actor_reg_check() {
unsafe {
@@ -73,7 +79,10 @@ extern "C-unwind" fn actor_reg_check() {
REG_BEFORE.set([s0, s1, s2, s3]).ok();
switch_to_scheduler();
let a0: u64; let a1: u64; let a2: u64; let a3: u64;
let a0: u64;
let a1: u64;
let a2: u64;
let a3: u64;
core::arch::asm!(
"mov {a0}, r12", "mov {a1}, r13", "mov {a2}, r14", "mov {a3}, r15",
a0 = out(reg) a0, a1 = out(reg) a1, a2 = out(reg) a2, a3 = out(reg) a3,
@@ -85,11 +94,17 @@ extern "C-unwind" fn actor_reg_check() {
#[test]
fn callee_saved_registers_survive_yield() {
let stack = Stack::new(64 * 1024).unwrap();
let stack = Stack::new(64 * 1024, 4096).unwrap();
let sp = init_actor_stack(stack.top(), actor_reg_check);
set_actor_sp(sp);
unsafe { switch_to_actor(); switch_to_actor(); }
assert_eq!(REG_BEFORE.get().copied().unwrap(), REG_AFTER.get().copied().unwrap());
unsafe {
switch_to_actor();
switch_to_actor();
}
assert_eq!(
REG_BEFORE.get().copied().unwrap(),
REG_AFTER.get().copied().unwrap()
);
}
// Two actors, independent stacks.
@@ -117,20 +132,24 @@ extern "C-unwind" fn actor_b() {
#[test]
fn two_actors_dont_corrupt_each_other() {
let stack_a = Stack::new(64 * 1024).unwrap();
let stack_b = Stack::new(64 * 1024).unwrap();
let stack_a = Stack::new(64 * 1024, 4096).unwrap();
let stack_b = Stack::new(64 * 1024, 4096).unwrap();
let sp_a = init_actor_stack(stack_a.top(), actor_a);
let sp_b = init_actor_stack(stack_b.top(), actor_b);
set_actor_sp(sp_a); unsafe { switch_to_actor() };
set_actor_sp(sp_a);
unsafe { switch_to_actor() };
let sp_a = get_actor_sp();
set_actor_sp(sp_b); unsafe { switch_to_actor() };
set_actor_sp(sp_b);
unsafe { switch_to_actor() };
let sp_b = get_actor_sp();
set_actor_sp(sp_a); unsafe { switch_to_actor() };
set_actor_sp(sp_b); unsafe { switch_to_actor() };
set_actor_sp(sp_a);
unsafe { switch_to_actor() };
set_actor_sp(sp_b);
unsafe { switch_to_actor() };
assert_eq!(A_VAL.with(|c| c.get()), 0xA00D);
assert_eq!(B_VAL.with(|c| c.get()), 0xB00D);
+22 -7
View File
@@ -11,8 +11,8 @@
//! OUTSIDE `run` — an in-actor assertion alone passes vacuously.
use smarm::{
channel, run, select, select_timeout, spawn, try_select, wait_readable,
wait_readable_timeout, wait_writable_timeout, yield_now, FdArm,
channel, run, select, select_timeout, spawn, try_select, wait_readable, wait_readable_timeout,
wait_writable_timeout, yield_now, FdArm,
};
use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -33,7 +33,10 @@ impl Pipe {
let mut fds: [libc::c_int; 2] = [0; 2];
let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
assert_eq!(r, 0, "pipe2 failed");
Pipe { read: fds[0], write: fds[1] }
Pipe {
read: fds[0],
write: fds[1],
}
}
}
@@ -253,12 +256,18 @@ fn wait_readable_timeout_times_out_then_succeeds_with_data() {
let (rfd, wfd) = (p.read, p.write);
let start = Instant::now();
assert_eq!(wait_readable_timeout(rfd, Duration::from_millis(30)).unwrap(), false);
assert_eq!(
wait_readable_timeout(rfd, Duration::from_millis(30)).unwrap(),
false
);
assert!(start.elapsed() >= Duration::from_millis(30));
// Timed-out wait must leave the fd clean; ready path returns true.
assert_eq!(raw_write(wfd, b"d"), 1);
assert_eq!(wait_readable_timeout(rfd, Duration::from_secs(5)).unwrap(), true);
assert_eq!(
wait_readable_timeout(rfd, Duration::from_secs(5)).unwrap(),
true
);
let mut buf = [0u8; 1];
assert_eq!(raw_read(rfd, &mut buf), 1);
ok2.store(true, Ordering::SeqCst);
@@ -274,7 +283,10 @@ fn wait_readable_timeout_wakes_on_late_data() {
let p = Pipe::new();
let (rfd, wfd) = (p.read, p.write);
let h = spawn(move || {
assert_eq!(wait_readable_timeout(rfd, Duration::from_secs(5)).unwrap(), true);
assert_eq!(
wait_readable_timeout(rfd, Duration::from_secs(5)).unwrap(),
true
);
let mut buf = [0u8; 1];
assert_eq!(raw_read(rfd, &mut buf), 1);
got2.store(buf[0] as u32, Ordering::SeqCst);
@@ -292,7 +304,10 @@ fn wait_writable_timeout_ready_now_on_empty_pipe() {
run(move || {
let p = Pipe::new();
// An empty pipe's write end is writable: ready-now path, no park.
assert_eq!(wait_writable_timeout(p.write, Duration::from_secs(5)).unwrap(), true);
assert_eq!(
wait_writable_timeout(p.write, Duration::from_secs(5)).unwrap(),
true
);
ok2.store(true, Ordering::SeqCst);
});
assert!(ok.load(Ordering::SeqCst));
+59 -15
View File
@@ -403,7 +403,10 @@ fn worker_pool_down_reaches_handle_down() {
let got = Arc::new(Mutex::new(Vec::new()));
let got2 = got.clone();
run(move || {
let server = start(Pool { watcher: None, log: Vec::new() });
let server = start(Pool {
watcher: None,
log: Vec::new(),
});
server.cast(PoolCast::SpawnDoomedWorker).unwrap();
let _ = server.call(()).unwrap(); // sync point: cast handled, worker live
*got2.lock().unwrap() = server.call(()).unwrap();
@@ -421,7 +424,10 @@ fn watch_dead_pid_is_noproc_down() {
let h = spawn(|| {});
let dead = h.pid();
h.join().unwrap();
let server = start(Pool { watcher: None, log: Vec::new() });
let server = start(Pool {
watcher: None,
log: Vec::new(),
});
server.cast(PoolCast::Watch(dead)).unwrap();
*got2.lock().unwrap() = server.call(()).unwrap();
});
@@ -497,7 +503,12 @@ impl GenServer for Timed {
}
fn timed(fired: Arc<Mutex<Vec<u32>>>, cancel_won: Arc<Mutex<Option<bool>>>) -> Timed {
Timed { timer: None, fired, cancel_won, last: None }
Timed {
timer: None,
fired,
cancel_won,
last: None,
}
}
// A one-shot armed from a handler fires into handle_timer with its payload.
@@ -534,7 +545,11 @@ fn cancel_before_fire_suppresses_it() {
let count = server.call(()).unwrap();
assert_eq!(count, 0, "cancelled timer must not fire");
});
assert_eq!(*cancel_won.lock().unwrap(), Some(true), "cancel beat the fire");
assert_eq!(
*cancel_won.lock().unwrap(),
Some(true),
"cancel beat the fire"
);
assert!(fired.lock().unwrap().is_empty());
}
@@ -549,11 +564,16 @@ fn tick_every_rearms_repeatedly() {
run(move || {
let cw = Arc::new(Mutex::new(None));
let server = start(timed(f2, cw));
server.cast(TkCast::Tick(Duration::from_millis(20))).unwrap();
server
.cast(TkCast::Tick(Duration::from_millis(20)))
.unwrap();
let _ = server.call(()).unwrap(); // sync: periodic armed
smarm::sleep(Duration::from_millis(130)); // ~6 periods
let count = server.call(()).unwrap();
assert!(count >= 3, "periodic should have re-armed several times, got {count}");
assert!(
count >= 3,
"periodic should have re-armed several times, got {count}"
);
});
// Every tick delivered the same payload.
assert!(fired.lock().unwrap().iter().all(|&v| v == 9));
@@ -568,7 +588,9 @@ fn cancel_stops_a_periodic() {
let c2 = cancel_won.clone();
run(move || {
let server = start(timed(f2, c2));
server.cast(TkCast::Tick(Duration::from_millis(20))).unwrap();
server
.cast(TkCast::Tick(Duration::from_millis(20)))
.unwrap();
let _ = server.call(()).unwrap();
smarm::sleep(Duration::from_millis(70)); // a few ticks
server.cast(TkCast::CancelLast).unwrap();
@@ -616,11 +638,17 @@ fn idle_fires_repeatedly_on_quiet() {
let idles = Arc::new(Mutex::new(0));
let i2 = idles.clone();
run(move || {
let server = start(Idler { window: Duration::from_millis(25), idles: i2 });
let server = start(Idler {
window: Duration::from_millis(25),
idles: i2,
});
smarm::sleep(Duration::from_millis(130)); // quiet ⇒ ~5 windows
drop(server); // keep the server alive across the quiet span
});
assert!(*idles.lock().unwrap() >= 2, "idle should re-arm and fire several times");
assert!(
*idles.lock().unwrap() >= 2,
"idle should re-arm and fire several times"
);
}
// Traffic within the window keeps idle from firing; only once the inbox goes
@@ -632,7 +660,10 @@ fn traffic_resets_the_idle_window() {
let before_quiet = Arc::new(Mutex::new(u32::MAX));
let bq = before_quiet.clone();
run(move || {
let server = start(Idler { window: Duration::from_millis(60), idles: i2 });
let server = start(Idler {
window: Duration::from_millis(60),
idles: i2,
});
// Poke every 25ms (< 60ms window) for ~100ms: each cast resets the
// window before it can elapse.
for _ in 0..4 {
@@ -643,8 +674,15 @@ fn traffic_resets_the_idle_window() {
smarm::sleep(Duration::from_millis(140)); // now genuinely quiet
drop(server);
});
assert_eq!(*before_quiet.lock().unwrap(), 0, "steady traffic must suppress idle");
assert!(*idles.lock().unwrap() >= 1, "idle fires once the inbox falls quiet");
assert_eq!(
*before_quiet.lock().unwrap(),
0,
"steady traffic must suppress idle"
);
assert!(
*idles.lock().unwrap() >= 1,
"idle fires once the inbox falls quiet"
);
}
// RFC 015 §4.7 — no armed timer survives loop exit. A server with a live
@@ -658,15 +696,21 @@ fn no_timer_survives_exit() {
let f_read = fired.clone();
run(move || {
let server = start(timed(f_server, Arc::new(Mutex::new(None))));
server.cast(TkCast::Tick(Duration::from_millis(15))).unwrap();
server
.cast(TkCast::Tick(Duration::from_millis(15)))
.unwrap();
let _ = server.call(()).unwrap(); // sync: periodic armed
smarm::sleep(Duration::from_millis(45)); // a couple of ticks
let mon = smarm::monitor(server.pid());
drop(server); // inbox closes → loop exits → guard drains timers
// Clean Down ⇒ the loop returned without the no-leak assert aborting.
// Clean Down ⇒ the loop returned without the no-leak assert aborting.
assert!(mon.rx.recv().is_ok());
let at_exit = f_read.lock().unwrap().len();
smarm::sleep(Duration::from_millis(90)); // would be several more ticks
assert_eq!(f_read.lock().unwrap().len(), at_exit, "no tick may fire after exit");
assert_eq!(
f_read.lock().unwrap().len(),
at_exit,
"no tick may fire after exit"
);
});
}
+393 -45
View File
@@ -1,76 +1,240 @@
//! gen_statem behaviour tests, driven through the `gen_statem!` macro: cast/call
//! round-trip, `enter` firing on start and on every real transition (but not on
//! a stay), and the machine-down path when a handler panics.
//! a stay), the machine-down path when a handler panics, and the timeout
//! surface (state-timeout firing + auto-reset across transitions; named
//! timeouts surviving transitions; cancellation).
use smarm::gen_statem;
use smarm::gen_statem::{CallError, Reply};
use smarm::run;
use std::sync::{Arc, Mutex};
use std::time::Duration;
// A two-state machine: Flip toggles, calls read counters, Boom panics.
// ===========================================================================
// Timeouts
// ===========================================================================
//
// A small timer machine. `Idle` is quiet; `Armed` arms a state-timeout on entry
// that — when it fires — bumps `st_fires` and returns to `Idle`. A named
// timeout ("ping") is armed independently, survives the Idle/Armed transition,
// and bumps `named_fires` when it fires. Counters are read back over calls.
//
// Durations are tiny and waits use `smarm::sleep` (parks the actor, leaves the
// timer wheel turning). These tests run against real time, so they are a touch
// slow; a controllable clock would tighten them.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Switch {
Off,
On,
enum T {
Idle,
Armed,
}
struct Counts {
flips: u32,
enters: u32,
struct TData {
enters: u32, // total state entries (incl. initial)
st_fires: u32, // state-timeout fires
named_fires: u32, // named-timeout fires
st_window: u64, // ms for the Armed state-timeout (set per test intent)
}
enum Cast {
Flip,
enum TCast {
Arm, // Idle -> Armed
Disarm, // Armed -> Idle (a transition that should auto-reset the state-timeout)
Ping(u64), // arm a named "ping" timeout after the given ms
CancelPing, // cancel the named "ping"
}
enum Call {
GetFlips(Reply<u32>),
GetEnters(Reply<u32>),
Boom(Reply<u32>),
enum TCall {
Enters(Reply<u32>),
StFires(Reply<u32>),
NamedFires(Reply<u32>),
Boom(Reply<u32>), // panics, to exercise the call-to-dead-machine path
}
gen_statem! {
machine: Sm { state: Switch, data: Counts };
event: Ev { cast: Cast, call: Call };
machine: TimerSm { state: T, data: TData };
event: Ev2 { cast: TCast, call: TCall, info: () };
context(data, prev, cx);
enter {
_ => data.enters += 1,
// On entering Armed, arm the state-timeout. On Idle, nothing — and the
// loop has already auto-reset any pending state-timeout on the way in.
T::Armed => { data.enters += 1; cx.state_timeout(Duration::from_millis(data.st_window)); },
T::Idle => data.enters += 1,
}
on Switch::Off => {
cast Cast::Flip => { data.flips += 1; Switch::On },
}
on Switch::On => {
cast Cast::Flip => Switch::Off,
on T::Idle => {
cast TCast::Arm => T::Armed,
cast TCast::Disarm => unhandled,
state_timeout => unhandled,
}
// State-independent queries: reply, then stay via `prev`. Boom panics
// (`boom()` is typed as a state tag so the arm stays well-formed).
on T::Armed => {
// The state-timeout elapsed while still Armed: count it and go Idle.
state_timeout => { data.st_fires += 1; T::Idle },
cast TCast::Disarm => T::Idle,
cast TCast::Arm => unhandled,
}
// State-independent rows: the named-timeout (survives transitions), the
// arm/cancel casts, the counter reads, and the panicking call.
on _ => {
call Call::GetFlips(r) => { r.reply(data.flips); prev },
call Call::GetEnters(r) => { r.reply(data.enters); prev },
call Call::Boom(_r) => boom(),
cast TCast::Ping(ms) => { cx.timeout("ping", Duration::from_millis(ms)); prev },
cast TCast::CancelPing => { cx.cancel_timeout("ping"); prev },
timeout "ping" => { data.named_fires += 1; prev },
timeout _ => unhandled,
call TCall::Enters(r) => { r.reply(data.enters); prev },
call TCall::StFires(r) => { r.reply(data.st_fires); prev },
call TCall::NamedFires(r) => { r.reply(data.named_fires); prev },
call TCall::Boom(_r) => boom(),
}
}
fn boom() -> Switch {
fn boom() -> T {
panic!("boom")
}
// Casts are applied in order and a later call observes the accumulated data.
// A state-timeout armed on entry to Armed fires after its window, bumping the
// counter and returning the machine to Idle on its own.
#[test]
fn state_timeout_fires() {
let got = Arc::new(Mutex::new(0u32));
let got2 = got.clone();
run(move || {
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 5,
},
);
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 5ms state-timeout
smarm::sleep(Duration::from_millis(40)); // let it fire
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
});
assert_eq!(*got.lock().unwrap(), 1, "state-timeout fired exactly once");
}
// Leaving Armed before the window elapses auto-resets the state-timeout: it
// must not fire afterward, even though we wait well past its original window.
#[test]
fn state_timeout_auto_resets_on_transition() {
let got = Arc::new(Mutex::new(99u32));
let got2 = got.clone();
run(move || {
// Long window so the explicit Disarm beats it comfortably.
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 50,
},
);
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 50ms state-timeout
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle, auto-resets it
smarm::sleep(Duration::from_millis(80)); // past the original window
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
});
assert_eq!(
*got.lock().unwrap(),
0,
"auto-reset cancelled the pending state-timeout"
);
}
// A named timeout survives a state change: armed in Idle, it still fires after
// the machine has moved to Armed and back.
#[test]
fn named_timeout_survives_transition() {
let got = Arc::new(Mutex::new(0u32));
let got2 = got.clone();
run(move || {
// Armed's own state-timeout is long so it doesn't interfere.
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 200,
},
);
m.send(Ev2::Cast(TCast::Ping(20))).unwrap(); // arm "ping" for 20ms (in Idle)
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed (ping must survive this)
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle (and this)
smarm::sleep(Duration::from_millis(60)); // let "ping" fire
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap();
});
assert_eq!(
*got.lock().unwrap(),
1,
"named timeout fired across the transitions"
);
}
// Cancelling a named timeout before its window prevents the fire.
#[test]
fn named_timeout_cancel() {
let got = Arc::new(Mutex::new(99u32));
let got2 = got.clone();
run(move || {
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 200,
},
);
m.send(Ev2::Cast(TCast::Ping(30))).unwrap(); // arm "ping" for 30ms
m.send(Ev2::Cast(TCast::CancelPing)).unwrap(); // cancel before it fires
smarm::sleep(Duration::from_millis(60)); // past the original window
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap();
});
assert_eq!(
*got.lock().unwrap(),
0,
"cancel prevented the named-timeout fire"
);
}
// ===========================================================================
// Core behaviour (cast/call round-trip, enter semantics, panic -> Down)
// ===========================================================================
// Casts are applied in order and a later call observes the resulting state via
// its counters: two Arm/Disarm round-trips leave the machine back in Idle.
#[test]
fn cast_then_call_roundtrip() {
let got = Arc::new(Mutex::new(0u32));
let got2 = got.clone();
run(move || {
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flips = 1)
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (no flip count)
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
*got2.lock().unwrap() = flips;
// Long state-timeout window so it never fires during the test.
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 10_000,
},
);
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
// enters = 1 (start) + 4 transitions = 5.
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
});
assert_eq!(*got.lock().unwrap(), 1, "turned On once across the two flips");
assert_eq!(
*got.lock().unwrap(),
5,
"one enter on start, one per real transition"
);
}
// `enter` fires once on start and once per *real* transition; a stay (a call
@@ -80,14 +244,22 @@ fn enter_on_start_and_each_transition_but_not_stay() {
let got = Arc::new(Mutex::new((0u32, 0u32, 0u32)));
let got2 = got.clone();
run(move || {
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); // enter -> 1
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
// Two stays (the reads) must not bump enters.
let _ = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
let still = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On -> enter -> 2
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
*got2.lock().unwrap() = (after_start, still, after_flip);
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 10_000,
},
); // enter -> 1
let after_start = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
// A stay (a counter read returns `prev`) must not bump enters.
let _ = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
let still = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed -> enter -> 2
let after_arm = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
*got2.lock().unwrap() = (after_start, still, after_arm);
});
assert_eq!(*got.lock().unwrap(), (1, 1, 2));
}
@@ -100,9 +272,185 @@ fn call_to_panicking_handler_is_down() {
let got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
let got2 = got.clone();
run(move || {
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
let r = sw.call(|rep| Ev::Call(Call::Boom(rep)));
let m = TimerSm::start(
T::Idle,
TData {
enters: 0,
st_fires: 0,
named_fires: 0,
st_window: 10_000,
},
);
let r = m.call(|rep| Ev2::Call(TCall::Boom(rep)));
*got2.lock().unwrap() = Some(r);
});
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down)));
}
// ===========================================================================
// Postpone
// ===========================================================================
//
// Two machines. `LatchSm` defers a `Take` *call* while `Empty` and answers it
// from `Filled` — the Reply rides inside the postponed event onto the queue and
// is honoured by whichever later state handles the replay. `RelaySm` defers a
// `Mark` cast through two states so a replayed event can postpone *again*,
// landing only once the handling state is reached.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum L {
Empty,
Filled,
}
struct LData {
value: u32, // the value a Fill stored, handed back by a Take
takes: u32, // completed takes
}
enum LCast {
Fill(u32), // Empty -> Filled, storing the value
}
enum LCall {
Take(Reply<u32>), // Filled: reply value & empty; Empty: postpone until filled
Takes(Reply<u32>), // completed-take count (stay)
Status(Reply<L>), // current state tag (stay)
}
gen_statem! {
machine: LatchSm { state: L, data: LData };
event: LEv { cast: LCast, call: LCall, info: () };
context(data, prev, cx);
enter { _ => {} }
on L::Empty => {
cast LCast::Fill(v) => { data.value = v; L::Filled },
// No value yet: defer the take (with its Reply) until a Fill arrives.
call LCall::Take(r) => postpone,
}
on L::Filled => {
cast LCast::Fill(_) => unhandled, // already full
call LCall::Take(r) => { r.reply(data.value); data.takes += 1; L::Empty },
}
on _ => {
call LCall::Takes(r) => { r.reply(data.takes); prev },
call LCall::Status(r) => { r.reply(prev); prev },
state_timeout => unhandled,
timeout _ => unhandled,
}
}
// A `Take` issued while the latch is Empty parks the caller and is postponed
// (Reply included). A later Fill transitions Empty -> Filled, whose replay
// answers the deferred call — the parked caller wakes with the filled value.
#[test]
fn postponed_call_answered_after_transition() {
let got = Arc::new(Mutex::new(None::<u32>));
let g2 = got.clone();
run(move || {
let m = LatchSm::start(L::Empty, LData { value: 0, takes: 0 });
// Child actor issues the Take while Empty; its call parks on the reply.
let m2 = m.clone();
let taken = Arc::new(Mutex::new(None::<u32>));
let t2 = taken.clone();
smarm::scheduler::spawn(move || {
let v = m2.call(|r| LEv::Call(LCall::Take(r))).unwrap();
*t2.lock().unwrap() = Some(v);
});
smarm::sleep(Duration::from_millis(20)); // let the Take land and be deferred
// While deferred, the latch is untouched: still Empty, no take completed.
// (These reads are stays — they don't disturb the postponed event.)
assert_eq!(m.call(|r| LEv::Call(LCall::Status(r))).unwrap(), L::Empty);
assert_eq!(m.call(|r| LEv::Call(LCall::Takes(r))).unwrap(), 0);
m.send(LEv::Cast(LCast::Fill(42))).unwrap(); // Empty -> Filled: replays the Take
smarm::sleep(Duration::from_millis(20)); // let the child wake with its reply
*g2.lock().unwrap() = *taken.lock().unwrap();
});
assert_eq!(
*got.lock().unwrap(),
Some(42),
"postponed call answered by the Filled state"
);
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum R {
S0,
S1,
S2,
}
struct RData {
marks: u32, // Marks handled (only S2 handles one)
}
enum RCast {
Go, // S0 -> S1 -> S2 -> S0
Mark, // postponed in S0/S1, handled in S2
}
enum RCall {
Marks(Reply<u32>),
State(Reply<R>),
}
gen_statem! {
machine: RelaySm { state: R, data: RData };
event: REv { cast: RCast, call: RCall, info: () };
context(data, prev, cx);
enter { _ => {} }
on R::S0 => {
cast RCast::Go => R::S1,
cast RCast::Mark => postpone,
}
on R::S1 => {
cast RCast::Go => R::S2,
cast RCast::Mark => postpone, // a replay here defers again
}
on R::S2 => {
cast RCast::Go => R::S0,
cast RCast::Mark => { data.marks += 1; prev }, // finally handled
}
on _ => {
call RCall::Marks(r) => { r.reply(data.marks); prev },
call RCall::State(r) => { r.reply(prev); prev },
state_timeout => unhandled,
timeout _ => unhandled,
}
}
// A postponed event that is replayed into a state which *also* postpones it
// re-queues, and is handled only once a state that accepts it is reached. Each
// `Marks` call is a stay that acts as a sync barrier after the preceding casts.
#[test]
fn replayed_event_can_postpone_again() {
let got = Arc::new(Mutex::new((9u32, 9u32, 9u32, R::S0)));
let g2 = got.clone();
run(move || {
let m = RelaySm::start(R::S0, RData { marks: 0 });
m.send(REv::Cast(RCast::Mark)).unwrap(); // S0: postponed
let a = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // deferred -> 0
m.send(REv::Cast(RCast::Go)).unwrap(); // S0 -> S1: replay Mark -> postponed again
let b = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // re-deferred -> 0
m.send(REv::Cast(RCast::Go)).unwrap(); // S1 -> S2: replay Mark -> handled
let c = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // -> 1
let s = m.call(|r| REv::Call(RCall::State(r))).unwrap(); // Mark was a stay in S2
*g2.lock().unwrap() = (a, b, c, s);
});
let (a, b, c, s) = *got.lock().unwrap();
assert_eq!(a, 0, "deferred while S0");
assert_eq!(b, 0, "re-deferred while S1");
assert_eq!(c, 1, "handled once S2 is reached");
assert_eq!(s, R::S2, "Mark handled as a stay in S2");
}
+162 -5
View File
@@ -56,7 +56,11 @@ fn snapshot_lists_actors_with_parent_edge() {
// The root itself is on-CPU (it's running this code) and rooted under
// the forest sentinel.
let root = snap.actors.iter().find(|a| a.pid == me).expect("root present");
let root = snap
.actors
.iter()
.find(|a| a.pid == me)
.expect("root present");
assert_eq!(root.state, ActorState::Running);
assert_eq!(root.supervisor, smarm::Pid::new(u32::MAX, u32::MAX));
@@ -200,7 +204,11 @@ fn tree_places_child_under_its_spawner() {
// The root is parented at the forest sentinel, so it's a genuine root,
// and the worker it spawned hangs beneath it.
let root = t.roots.iter().find(|n| n.info.pid == me).expect("root in forest");
let root = t
.roots
.iter()
.find(|n| n.info.pid == me)
.expect("root in forest");
assert!(!root.orphaned);
assert!(
root.children.iter().any(|c| c.info.pid == h.pid()),
@@ -237,6 +245,13 @@ fn tree_from_nests_children_and_reroots_orphans() {
overruns: 0,
messages_received: 0,
budget_cycles: 0,
stack: smarm::StackInfo {
reserve: 0,
guard: 0,
depth_high_water: 0,
parks_since_shrink: 0,
shrinks: 0,
},
};
let snap = RuntimeSnapshot {
@@ -251,14 +266,25 @@ fn tree_from_nests_children_and_reroots_orphans() {
let t = tree_from(snap);
assert_eq!(t.roots.len(), 2);
let root = t.roots.iter().find(|n| n.info.pid == root_pid).expect("root present");
let root = t
.roots
.iter()
.find(|n| n.info.pid == root_pid)
.expect("root present");
assert!(!root.orphaned);
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].info.pid, child);
assert!(!root.children[0].orphaned);
let o = t.roots.iter().find(|n| n.info.pid == orphan).expect("orphan re-rooted");
assert!(o.orphaned, "an actor whose parent is absent must be flagged orphaned");
let o = t
.roots
.iter()
.find(|n| n.info.pid == orphan)
.expect("orphan re-rooted");
assert!(
o.orphaned,
"an actor whose parent is absent must be flagged orphaned"
);
assert!(o.children.is_empty());
}
@@ -352,3 +378,134 @@ fn budget_cycles_accumulate_when_enabled() {
h.join().unwrap();
});
}
// ---------------------------------------------------------------------------
// RFC 019 §8 — the stack introspection surface.
// ---------------------------------------------------------------------------
/// Burn ~`frames` × 4 KiB of stack with a yield at max depth, so the context
/// save samples the high-water there (RFC 019 §2: hwm is SAMPLED at
/// deschedule, not tracked continuously).
#[inline(never)]
fn burn_stack_yielding(frames: usize) -> u64 {
let mut local = [0u8; 4096];
local[0] = frames as u8;
let below = if frames == 0 {
smarm::yield_now();
0
} else {
burn_stack_yielding(frames - 1)
};
std::hint::black_box(&mut local);
below.wrapping_add(local[0] as u64)
}
#[test]
fn stack_info_reports_defaults_and_sampled_depth() {
run(|| {
let (ready_tx, ready_rx) = channel::<()>();
let (gate_tx, gate_rx) = channel::<()>();
let h = spawn(move || {
// ~32 KiB deep with a yield at the bottom: the sample point.
std::hint::black_box(burn_stack_yielding(8));
ready_tx.send(()).unwrap();
gate_rx.recv().unwrap();
});
ready_rx.recv().unwrap();
let info = spin_until(h.pid(), |a| a.state == ActorState::Parked);
let s = info.stack;
assert_eq!(s.reserve, 64 * 1024, "default reserve");
assert_eq!(
s.guard,
1024 * 1024,
"default guard (kernel stack_guard_gap convention)"
);
assert!(
s.depth_high_water >= 8 * 4096,
"hwm sampled at the deep yield: expected ≥ 32 KiB, got {}",
s.depth_high_water
);
assert!(
s.depth_high_water < s.reserve,
"depth {} cannot exceed the reserve {}",
s.depth_high_water,
s.reserve
);
// Parked at the gate right now, never shrunk (64 KiB reserve cannot
// cross the shrink threshold).
assert!(s.parks_since_shrink >= 1, "the gate park must be counted");
assert_eq!(s.shrinks, 0);
gate_tx.send(()).unwrap();
h.join().unwrap();
});
}
#[test]
fn stack_info_shrink_counters_are_live() {
use smarm::runtime::{Config, SHRINK_COOLDOWN, SHRINK_THRESHOLD};
use smarm::{spawn_with, SpawnOpts};
let rt = smarm::runtime::init(Config::exact(1));
rt.run(|| {
let (park_tx, park_rx) = channel::<()>();
let spike = 768 * 4096;
assert!(spike > SHRINK_THRESHOLD);
let worker = spawn_with(
SpawnOpts {
stack_reserve: Some(8 * 1024 * 1024),
..SpawnOpts::default()
},
move || {
std::hint::black_box(burn_stack_yielding(768));
for _ in 0..(SHRINK_COOLDOWN + 8) {
park_rx.recv().unwrap();
}
},
);
let wpid = worker.pid();
// Before any parks complete: the spike depth is visible.
let info = spin_until(wpid, |a| a.state == ActorState::Parked);
assert!(
info.stack.depth_high_water >= spike,
"spike should be sampled: {} < {spike}",
info.stack.depth_high_water
);
// Cross the cooldown, then read the counters live while the worker
// is parked waiting for the remaining rounds (post-join the slot is
// reclaimed and the generation check correctly hides it).
for _ in 0..(SHRINK_COOLDOWN + 2) {
spin_until(wpid, |a| a.state == ActorState::Parked);
park_tx.send(()).unwrap();
}
let info = spin_until(wpid, |a| {
a.state == ActorState::Parked && a.stack.shrinks >= 1
});
let s = info.stack;
assert!(
s.shrinks >= 1,
"cooldown was crossed with a spike above threshold"
);
assert!(
s.parks_since_shrink < SHRINK_COOLDOWN,
"counter must reset at shrink: {}",
s.parks_since_shrink
);
assert!(
s.depth_high_water < spike,
"hwm resets to the shallow park sp at shrink; got {}",
s.depth_high_water
);
for _ in 0..6 {
spin_until(wpid, |a| a.state == ActorState::Parked);
park_tx.send(()).unwrap();
}
worker.join().unwrap();
});
}
+13 -3
View File
@@ -56,8 +56,16 @@ fn other_actors_run_while_block_on_io_is_in_flight() {
let pos_2 = v.iter().position(|&x| x == 2).unwrap();
let pos_3 = v.iter().position(|&x| x == 3).unwrap();
let pos_4 = v.iter().position(|&x| x == 4).unwrap();
assert!(pos_2 < pos_4, "B's first step ran after A resumed: {:?}", *v);
assert!(pos_3 < pos_4, "B's second step ran after A resumed: {:?}", *v);
assert!(
pos_2 < pos_4,
"B's first step ran after A resumed: {:?}",
*v
);
assert!(
pos_3 < pos_4,
"B's second step ran after A resumed: {:?}",
*v
);
}
#[test]
@@ -76,7 +84,9 @@ fn many_concurrent_block_on_io_calls_all_complete() {
cc.fetch_add(n, Ordering::SeqCst);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
assert_eq!(counter.load(Ordering::SeqCst), 10);
}
+11 -4
View File
@@ -144,8 +144,7 @@ fn write_sugar_sends_bytes_to_pipe() {
// Pipe is empty + has buffer space, so this returns immediately
// after wait_writable wakes (which happens fast because the
// kernel marks an empty pipe as immediately writable).
let n = smarm::scheduler::write(p_writer.write, b"smarm")
.expect("write failed");
let n = smarm::scheduler::write(p_writer.write, b"smarm").expect("write failed");
assert_eq!(n, 5);
c.fetch_add(1, Ordering::SeqCst);
});
@@ -209,10 +208,18 @@ fn other_actors_run_while_one_is_parked_on_wait_readable() {
let pos_lit_a = v.iter().position(|&c| c == b'a').unwrap();
let big_b_count = v.iter().filter(|&&c| c == b'B').count();
assert_eq!(big_b_count, 3, "B should have made 3 steps: {:?}", *v);
assert!(pos_big_a < pos_lit_a, "A pre-park before A post-park: {:?}", *v);
assert!(
pos_big_a < pos_lit_a,
"A pre-park before A post-park: {:?}",
*v
);
// At least the last B step should be before A resumes.
let last_big_b = v.iter().rposition(|&c| c == b'B').unwrap();
assert!(last_big_b < pos_lit_a, "B should finish before A resumes: {:?}", *v);
assert!(
last_big_b < pos_lit_a,
"B should finish before A resumes: {:?}",
*v
);
}
// ---------------------------------------------------------------------------
+10 -4
View File
@@ -57,7 +57,10 @@ fn linked_pair_one_panics_other_is_stopped() {
panic!("boom");
});
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
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);
@@ -117,7 +120,7 @@ fn normal_exit_does_not_propagate() {
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.
// A exited normally: nothing should have landed on the inbox.
if let Ok(None) = inbox.try_recv() {
e.store(true, Ordering::SeqCst);
}
@@ -152,7 +155,10 @@ fn link_to_dead_pid_stops_a_nontrapping_caller() {
});
let b = hb.pid();
let down_b = monitor(b);
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
let dn = down_b
.rx
.recv()
.expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
@@ -208,7 +214,7 @@ fn unlink_prevents_propagation() {
panic!("boom"); // abnormal, but the link is gone
});
yield_now(); // let A link, unlink, and panic
// Unlinked before death → no ExitSignal should have arrived.
// Unlinked before death → no ExitSignal should have arrived.
if let Ok(None) = inbox.try_recv() {
sv.store(true, Ordering::SeqCst);
}
+27 -6
View File
@@ -67,7 +67,10 @@ fn monitor_already_dead_target_is_noproc() {
// 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");
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);
@@ -91,7 +94,11 @@ fn multiple_monitors_all_notified() {
}
}
});
assert_eq!(count.load(Ordering::SeqCst), 3, "every monitor should see the Down");
assert_eq!(
count.load(Ordering::SeqCst),
3,
"every monitor should see the Down"
);
}
#[test]
@@ -103,8 +110,15 @@ fn demonitor_stops_delivery() {
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");
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();
});
}
@@ -122,7 +136,10 @@ fn demonitor_one_of_many() {
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");
assert!(
ms[1].rx.recv().is_err(),
"demonitored channel should be closed"
);
});
}
@@ -136,7 +153,11 @@ fn demonitor_after_fire_is_none() {
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");
assert_eq!(
demonitor(&m),
None,
"already-fired monitor has nothing to remove"
);
let _ = h.join();
});
}
+16 -4
View File
@@ -3,9 +3,9 @@
//! needs to be able to park.
use smarm::{run, spawn, yield_now, LockTimeout, Mutex};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
@@ -111,8 +111,16 @@ fn contended_lock_parks_until_holder_releases() {
let pos_b_locked = v.iter().position(|s| *s == "B_locked").unwrap();
assert!(pos_a_locked < pos_b_try, "log: {:?}", *v);
assert!(pos_b_try < pos_a_dropped, "B should attempt before A drops: {:?}", *v);
assert!(pos_a_dropped < pos_b_locked, "B should lock only after A drops: {:?}", *v);
assert!(
pos_b_try < pos_a_dropped,
"B should attempt before A drops: {:?}",
*v
);
assert!(
pos_a_dropped < pos_b_locked,
"B should lock only after A drops: {:?}",
*v
);
}
// ---------------------------------------------------------------------------
@@ -209,7 +217,11 @@ fn waiters_are_granted_the_lock_in_fifo_order() {
});
let v = order.lock().unwrap().clone();
assert_eq!(v, vec![1, 2, 3, 4], "waiters should acquire in arrival order");
assert_eq!(
v,
vec![1, 2, 3, 4],
"waiters should acquire in arrival order"
);
}
// ---------------------------------------------------------------------------
+5 -3
View File
@@ -77,8 +77,7 @@ fn observer_reports_none_for_a_forged_pid() {
// An index that is not in the slab at all — the verb relays the
// primitive's `None` faithfully.
let forged = smarm::Pid::new(u32::MAX - 1, 0);
let ObserverReply::ActorInfo(none) =
obs.call(ObserverRequest::ActorInfo(forged)).unwrap()
let ObserverReply::ActorInfo(none) = obs.call(ObserverRequest::ActorInfo(forged)).unwrap()
else {
panic!("ActorInfo verb must reply ActorInfo");
};
@@ -113,7 +112,10 @@ fn observer_sees_a_parked_actor_as_parked() {
}
smarm::yield_now();
}
assert!(parked, "observer should eventually report the worker as Parked");
assert!(
parked,
"observer should eventually report the worker as Parked"
);
gate_tx.send(()).unwrap();
worker.join().unwrap();
+69
View File
@@ -0,0 +1,69 @@
//! RFC 018 scheduler park/wake — observable-behavior guards.
//!
//! These pin the two timer-latency properties the park/wake swap must
//! preserve or introduce:
//!
//! - `sleep_fires_under_saturation`: due timers fire even when every
//! scheduler is busy (nobody parked ⇒ no timekeeper) — the busy-path
//! due-check, ratified design point (a). The old drain phase gave this
//! for free (timers drained every loop iteration); the new design must
//! not lose it.
//! - `submillisecond_sleep_is_prompt`: a sub-ms sleep completes promptly.
//! Under the old wake pipe, `poll_wake`'s `as_millis` truncation turned
//! sub-ms deadlines into 0ms busy-polls (correct wall time, pathological
//! CPU); under park/wake the futex timespec carries full nanosecond
//! precision.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[test]
fn sleep_fires_under_saturation() {
let rt = smarm::runtime::init(smarm::runtime::Config::exact(4));
rt.run(|| {
let stop = Arc::new(AtomicBool::new(false));
let mut spinners = Vec::new();
// 8 spinners over 4 schedulers: the run queue never empties, so no
// scheduler ever parks and no timekeeper exists. Only the busy-path
// due-check can fire the sleeper's timer before the spinners quit.
for _ in 0..8 {
let stop = stop.clone();
spinners.push(smarm::spawn(move || {
let t0 = Instant::now();
while !stop.load(Ordering::Relaxed) && t0.elapsed() < Duration::from_secs(5) {
smarm::yield_now();
}
}));
}
let t0 = Instant::now();
smarm::sleep(Duration::from_millis(10));
let dt = t0.elapsed();
stop.store(true, Ordering::Relaxed);
for s in spinners {
let _ = s.join();
}
assert!(
dt < Duration::from_millis(500),
"10ms sleep took {dt:?} under scheduler saturation — busy-path \
timer firing is broken (timekeeper-only firing stalls under load)"
);
});
}
#[test]
fn submillisecond_sleep_is_prompt() {
let rt = smarm::runtime::init(smarm::runtime::Config::exact(2));
rt.run(|| {
// Warm one iteration, then measure.
smarm::sleep(Duration::from_micros(500));
let t0 = Instant::now();
smarm::sleep(Duration::from_micros(500));
let dt = t0.elapsed();
assert!(dt >= Duration::from_micros(400), "woke early: {dt:?}");
assert!(
dt < Duration::from_millis(100),
"500µs sleep took {dt:?} — sub-ms deadline handling is broken"
);
});
}
+13 -3
View File
@@ -44,7 +44,10 @@ fn a_dead_actor_vanishes_from_every_group_it_joined() {
// Drain-on-contact: touching g1 detects the death and sweeps the pid
// out of every group (g2 included), not just g1.
assert!(members("g1").is_empty(), "evicted from the touched group");
assert!(members("g2").is_empty(), "and swept from the untouched group");
assert!(
members("g2").is_empty(),
"and swept from the untouched group"
);
assert_eq!(pick("g1"), None);
});
}
@@ -83,7 +86,11 @@ fn live_members_survive_a_peers_death() {
tx_a.send(()).unwrap();
a.join().unwrap();
assert_eq!(members("svc"), vec![b.pid()], "only the dead peer is reaped");
assert_eq!(
members("svc"),
vec![b.pid()],
"only the dead peer is reaped"
);
assert_eq!(pick("svc"), Some(b.pid()));
tx_b.send(()).unwrap();
@@ -125,7 +132,10 @@ fn joining_an_already_dead_pid_is_evicted_on_next_contact() {
// monitor() on a gone pid queues a NoProc Down immediately, so the
// membership is reaped the next time the group is touched.
join("late", pid);
assert!(members("late").is_empty(), "dead-at-join member is reaped on read");
assert!(
members("late").is_empty(),
"dead-at-join member is reaped on read"
);
assert_eq!(pick("late"), None);
});
}
+10 -2
View File
@@ -41,7 +41,11 @@ fn stop_storm_does_not_poison_runtime() {
}
c.fetch_add(1, Ordering::SeqCst);
});
assert_eq!(completed.load(Ordering::SeqCst), 1, "root completed cleanly");
assert_eq!(
completed.load(Ordering::SeqCst),
1,
"root completed cleanly"
);
}
/// The sharper repro: a stop-flagged actor whose *next allocation* is the
@@ -85,5 +89,9 @@ fn self_stop_during_spawn_does_not_poison_shared_mutex() {
}
c.fetch_add(1, Ordering::SeqCst);
});
assert_eq!(completed.load(Ordering::SeqCst), 1, "root completed cleanly");
assert_eq!(
completed.load(Ordering::SeqCst),
1,
"root completed cleanly"
);
}
+15 -4
View File
@@ -43,10 +43,21 @@ fn check_yields_when_timeslice_expired() {
let pos_big_b = v.iter().position(|&c| c == b'B').unwrap();
let pos_lit_a = v.iter().position(|&c| c == b'a').unwrap();
let pos_lit_b = v.iter().position(|&c| c == b'b').unwrap();
assert!(pos_big_a < pos_lit_a, "A's tail ran before B's head: {:?}", *v);
assert!(pos_big_b < pos_lit_b, "B's tail ran before A's head: {:?}", *v);
assert!(pos_big_a.max(pos_big_b) < pos_lit_a.min(pos_lit_b),
"preemption didn't interleave: {:?}", *v);
assert!(
pos_big_a < pos_lit_a,
"A's tail ran before B's head: {:?}",
*v
);
assert!(
pos_big_b < pos_lit_b,
"B's tail ran before A's head: {:?}",
*v
);
assert!(
pos_big_a.max(pos_big_b) < pos_lit_a.min(pos_lit_b),
"preemption didn't interleave: {:?}",
*v
);
}
#[test]
+13 -4
View File
@@ -65,7 +65,10 @@ fn name_held_by_live_actor_is_taken() {
ready_rx.recv().unwrap();
// Root tries to claim a live actor's name for itself -> NameTaken.
let (tx_b, _rx_b) = channel::<u64>();
assert_eq!(register(SVC, tx_b), Err(RegisterError::NameTaken { holder: a.pid() }));
assert_eq!(
register(SVC, tx_b),
Err(RegisterError::NameTaken { holder: a.pid() })
);
send(SVC, 0).unwrap(); // release a (delivers to the holder, a)
a.join().unwrap();
});
@@ -105,7 +108,10 @@ fn dead_holder_is_pruned_and_name_taken_over() {
fn send_errors_unresolved_and_no_channel() {
run(|| {
// No actor at all.
assert!(matches!(send(Name::<u64>::new("ghost"), 1u64), Err(SendError::Unresolved(_))));
assert!(matches!(
send(Name::<u64>::new("ghost"), 1u64),
Err(SendError::Unresolved(_))
));
let (ready_tx, ready_rx) = channel::<()>();
let (tx, rx) = channel::<u64>();
@@ -227,8 +233,11 @@ fn send_dyn_delivers_and_reports_wrong_type() {
ready_rx.recv().unwrap();
let p = h.pid(); // a bare Pid<Erased>, as if recovered off a Down
send_dyn::<u64>(p, 3u64).unwrap(); // right type: delivered
// Live actor, but it has no channel for &str — the genuinely-fallible case.
assert!(matches!(send_dyn::<&'static str>(p, "nope"), Err(SendError::NoChannel(_))));
// Live actor, but it has no channel for &str — the genuinely-fallible case.
assert!(matches!(
send_dyn::<&'static str>(p, "nope"),
Err(SendError::NoChannel(_))
));
done_tx.send(()).unwrap();
h.join().unwrap();
});
+141 -24
View File
@@ -14,10 +14,17 @@
//! - No slot leaks under high spawn/join churn
//! - Panic on one scheduler thread doesn't kill others
use smarm::{channel, runtime::{Config, Runtime}, spawn, yield_now, JoinHandle};
use std::sync::{atomic::{AtomicBool, AtomicU64, Ordering}, Arc};
use std::time::Duration;
use smarm::{
channel,
runtime::{Config, Runtime},
spawn, yield_now, JoinHandle,
};
use std::collections::HashSet;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
use std::time::Duration;
// ---------------------------------------------------------------------------
// Helpers
@@ -29,7 +36,9 @@ fn rt(n: usize) -> Runtime {
}
/// Convenient single-threaded runtime (regression guard).
fn rt1() -> Runtime { rt(1) }
fn rt1() -> Runtime {
rt(1)
}
/// Multi-threaded runtime using all available parallelism.
fn rt_par() -> Runtime {
@@ -79,7 +88,9 @@ fn config_min_1_max_1_is_single_threaded() {
fn runtime_run_executes_closure() {
let flag = Arc::new(AtomicBool::new(false));
let f = flag.clone();
rt(1).run(move || { f.store(true, Ordering::SeqCst); });
rt(1).run(move || {
f.store(true, Ordering::SeqCst);
});
assert!(flag.load(Ordering::SeqCst));
}
@@ -111,8 +122,12 @@ fn runtime_can_be_used_multiple_times_sequentially() {
let b = Arc::new(AtomicU64::new(0));
let ac = a.clone();
let bc = b.clone();
r.run(move || { ac.fetch_add(1, Ordering::SeqCst); });
r.run(move || { bc.fetch_add(1, Ordering::SeqCst); });
r.run(move || {
ac.fetch_add(1, Ordering::SeqCst);
});
r.run(move || {
bc.fetch_add(1, Ordering::SeqCst);
});
assert_eq!(a.load(Ordering::SeqCst), 1);
assert_eq!(b.load(Ordering::SeqCst), 1);
}
@@ -126,7 +141,9 @@ fn exact_1_spawn_join_works() {
let v = Arc::new(AtomicU64::new(0));
let vc = v.clone();
rt1().run(move || {
let h = spawn(move || { vc.store(42, Ordering::SeqCst); });
let h = spawn(move || {
vc.store(42, Ordering::SeqCst);
});
h.join().unwrap();
});
assert_eq!(v.load(Ordering::SeqCst), 42);
@@ -155,7 +172,9 @@ fn exact_1_panic_captured() {
let s = saw_err.clone();
rt1().run(move || {
let h = spawn(|| panic!("oops"));
if h.join().is_err() { s.store(true, Ordering::SeqCst); }
if h.join().is_err() {
s.store(true, Ordering::SeqCst);
}
});
assert!(saw_err.load(Ordering::SeqCst));
}
@@ -176,7 +195,9 @@ fn multi_thread_all_actors_complete() {
cc.fetch_add(1, Ordering::SeqCst);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
assert_eq!(counter.load(Ordering::SeqCst), 100);
}
@@ -221,7 +242,9 @@ fn multi_thread_many_channels_no_lost_wakeups() {
tx.send(1).unwrap();
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
});
assert_eq!(count.load(Ordering::SeqCst), PAIRS as u64);
}
@@ -247,7 +270,9 @@ fn multi_thread_mutex_contention_no_deadlock() {
}
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
let g = m.lock_timeout(Duration::from_secs(1)).unwrap();
t.store(*g, Ordering::SeqCst);
});
@@ -262,7 +287,9 @@ fn multi_thread_join_across_threads() {
rt_par().run(move || {
let h = spawn(move || {
// Do some work to make scheduling interesting.
for _ in 0..10 { yield_now(); }
for _ in 0..10 {
yield_now();
}
vc.store(1, Ordering::SeqCst);
});
h.join().unwrap();
@@ -279,8 +306,7 @@ fn multi_thread_join_across_threads() {
#[test]
fn actors_run_on_multiple_os_threads() {
let thread_ids: Arc<smarm::Mutex<HashSet<u64>>> =
Arc::new(smarm::Mutex::new(HashSet::new()));
let thread_ids: Arc<smarm::Mutex<HashSet<u64>>> = Arc::new(smarm::Mutex::new(HashSet::new()));
rt_par().run({
let ids = thread_ids.clone();
@@ -294,11 +320,15 @@ fn actors_run_on_multiple_os_threads() {
g.insert(tid);
}));
}
for h in handles { h.join().unwrap(); }
for h in handles {
h.join().unwrap();
}
}
});
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let n = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let ids = thread_ids.lock_timeout(Duration::from_secs(1)).unwrap();
// If we have >1 scheduler threads, we expect >1 OS thread IDs.
@@ -326,11 +356,17 @@ fn scheduler_stats_run_queue_len_is_observable() {
// run() completes (queue len == 0 at quiescence).
let r = rt_par();
r.run(|| {
for _ in 0..10 { spawn(|| {}); }
for _ in 0..10 {
spawn(|| {});
}
// Don't join — let them drain naturally.
});
let stats = r.stats();
assert_eq!(stats.total_run_queue_len(), 0, "queue should be empty after run()");
assert_eq!(
stats.total_run_queue_len(),
0,
"queue should be empty after run()"
);
}
#[test]
@@ -359,7 +395,9 @@ fn panic_in_actor_does_not_kill_runtime() {
}));
}
let _ = bad.join(); // expect Err
for h in good_handles { h.join().unwrap(); }
for h in good_handles {
h.join().unwrap();
}
});
assert_eq!(completed.load(Ordering::SeqCst), 10);
}
@@ -379,9 +417,11 @@ fn no_slot_leak_under_churn() {
rt_par().run(move || {
for _ in 0..500 {
let cc = c.clone();
spawn(move || { cc.fetch_add(1, Ordering::SeqCst); })
.join()
.unwrap();
spawn(move || {
cc.fetch_add(1, Ordering::SeqCst);
})
.join()
.unwrap();
}
});
assert_eq!(counter.load(Ordering::SeqCst), 500);
@@ -474,7 +514,11 @@ fn multi_thread_timer_only_no_pipe_contention() {
}
});
assert_eq!(count.load(Ordering::SeqCst), ACTORS as u64, "not all actors completed");
assert_eq!(
count.load(Ordering::SeqCst),
ACTORS as u64,
"not all actors completed"
);
let elapsed = start.elapsed();
assert!(
@@ -485,3 +529,76 @@ fn multi_thread_timer_only_no_pipe_contention() {
SLEEP_MS * 2,
);
}
// ---------------------------------------------------------------------------
// Root panic propagation
/// A panic in the root actor escapes `run()` to the caller. Anything else
/// makes every assert inside `run` silently vacuous — found live when a
/// failing-first test passed: the tripped assert was caught by the
/// trampoline, recorded as `Outcome::Panic` on the root slot, and dropped
/// unread with the initial handle.
#[test]
#[should_panic(expected = "root actor panic escapes")]
fn root_panic_escapes_run() {
rt1().run(|| {
panic!("root actor panic escapes");
});
}
/// Teardown completes before the root panic propagates: a caller that
/// catches it can immediately `run()` again on the same `Runtime` (the
/// documented sequential-reuse contract).
#[test]
fn runtime_reusable_after_root_panic() {
let r = rt1();
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
r.run(|| panic!("boom"));
}));
assert!(caught.is_err(), "root panic must escape run()");
let ran = Arc::new(AtomicBool::new(false));
let ran_t = ran.clone();
r.run(move || ran_t.store(true, Ordering::Relaxed));
assert!(
ran.load(Ordering::Relaxed),
"runtime unusable after root panic"
);
}
// ---------------------------------------------------------------------------
// RFC 019 — Config stack knobs
// ---------------------------------------------------------------------------
/// Burn ~`frames` × 4 KiB of stack; probestack touches pages in order so
/// exceeding the reserve would hit the guard and SIGSEGV the process.
#[inline(never)]
fn burn_stack(frames: usize) -> u64 {
let mut local = [0u8; 4096];
local[0] = frames as u8;
let below = if frames == 0 {
0
} else {
burn_stack(frames - 1)
};
std::hint::black_box(&mut local);
below.wrapping_add(local[0] as u64)
}
#[test]
fn config_stack_reserve_permits_deep_recursion() {
// ~256 KiB of frames: four times the old fixed 64 KiB reserve. With
// Config::stack_reserve raised this must complete; before RFC 019 it
// could only segfault.
let rt = smarm::runtime::init(Config::exact(1).stack_reserve(1024 * 1024));
let done = Arc::new(AtomicBool::new(false));
let done2 = done.clone();
rt.run(move || {
spawn(move || {
std::hint::black_box(burn_stack(64));
done2.store(true, Ordering::SeqCst);
})
.join()
.unwrap();
});
assert!(done.load(Ordering::SeqCst));
}
+7 -4
View File
@@ -14,7 +14,9 @@ use std::sync::Arc;
fn root_actor_runs() {
let captured = Arc::new(AtomicI64::new(0));
let c = captured.clone();
run(move || { c.store(99, Ordering::SeqCst); });
run(move || {
c.store(99, Ordering::SeqCst);
});
assert_eq!(captured.load(Ordering::SeqCst), 99);
}
@@ -27,7 +29,9 @@ fn spawn_and_join_returns_exit() {
let captured = Arc::new(AtomicI64::new(0));
let c = captured.clone();
run(move || {
let h = spawn(move || { c.store(7, Ordering::SeqCst); });
let h = spawn(move || {
c.store(7, Ordering::SeqCst);
});
let res = h.join();
assert!(res.is_ok(), "join returned {:?}", res);
});
@@ -68,8 +72,7 @@ fn yield_now_interleaves_actors() {
#[test]
fn self_pid_is_stable_within_an_actor() {
let pid_cell: Arc<std::sync::Mutex<Option<smarm::Pid>>> =
Arc::new(std::sync::Mutex::new(None));
let pid_cell: Arc<std::sync::Mutex<Option<smarm::Pid>>> = Arc::new(std::sync::Mutex::new(None));
let p2 = pid_cell.clone();
run(move || {
let h = spawn(move || {
+21 -4
View File
@@ -19,7 +19,12 @@ fn ready_arm_returns_immediately_without_parking() {
txa.send(42).unwrap();
let i = select(&[&rxb, &rxa]);
assert_eq!(i, 1);
out2.store(rxa.try_recv().unwrap().expect("ready arm must hold a message"), Ordering::SeqCst);
out2.store(
rxa.try_recv()
.unwrap()
.expect("ready arm must hold a message"),
Ordering::SeqCst,
);
});
assert_eq!(out.load(Ordering::SeqCst), 42);
}
@@ -108,8 +113,14 @@ fn loser_arm_wake_after_parked_select_stays_precise() {
t0.elapsed() >= Duration::from_millis(40),
"one-shot park returned early: a stale loser-arm wake landed"
);
// Arm 0 is now closed (the sender actor exited after its sends) and
// a closed arm reports ready forever under priority order — observe
// the disconnect and drop it from the set, per the documented
// closed-arm rule.
assert_eq!(select(&[&rxa, &rxb]), 0);
assert!(rxa.try_recv().is_err(), "arm 0 must report disconnect");
// The loser's message was never lost.
assert_eq!(select(&[&rxa, &rxb]), 1);
assert_eq!(select(&[&rxb]), 0);
assert_eq!(rxb.try_recv().unwrap(), Some(2));
h.join().unwrap();
});
@@ -270,7 +281,10 @@ fn select_timeout_ready_arm_wins_without_arming_a_timer() {
let (txa, rxa) = channel::<i64>();
let (_keep_b, rxb) = channel::<i64>();
txa.send(5).unwrap();
assert_eq!(select_timeout(&[&rxb, &rxa], Duration::from_millis(500)), Some(1));
assert_eq!(
select_timeout(&[&rxb, &rxa], Duration::from_millis(500)),
Some(1)
);
assert_eq!(rxa.try_recv().unwrap(), Some(5));
});
}
@@ -336,7 +350,10 @@ fn select_timeout_closed_arm_is_ready_not_a_timeout() {
let (_keep_a, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
drop(txb);
assert_eq!(select_timeout(&[&rxa, &rxb], Duration::from_millis(200)), Some(1));
assert_eq!(
select_timeout(&[&rxa, &rxb], Duration::from_millis(200)),
Some(1)
);
assert!(rxb.try_recv().is_err());
});
}
+235
View File
@@ -0,0 +1,235 @@
//! RFC 019 commit 2 — the `SpawnOpts` surface.
//!
//! Covers: per-spawn stack shape overrides on every spawn surface, the
//! `None ⇒ Config default` resolution, the pool rule from the outside
//! (obligation 4: a custom-shaped stack never enters the pool), and that a
//! big reserve behaviorally takes effect (deep recursion completes).
use smarm::runtime::{Config, DEFAULT_STACK_GUARD, DEFAULT_STACK_RESERVE};
use smarm::{self_pid, spawn, spawn_under_with, spawn_with, GenServerBuilder, SpawnOpts};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
fn rt1() -> smarm::runtime::Runtime {
smarm::runtime::init(Config::exact(1))
}
#[test]
fn default_spawn_has_default_shape() {
rt1().run(|| {
let h = spawn(|| {
let shape = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(shape, (DEFAULT_STACK_RESERVE, DEFAULT_STACK_GUARD));
});
h.join().unwrap();
});
}
#[test]
fn spawn_with_overrides_reserve_and_guard() {
rt1().run(|| {
let opts = SpawnOpts {
stack_reserve: Some(1024 * 1024),
guard_size: Some(256 * 1024),
};
let h = spawn_with(opts, || {
let shape = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(shape, (1024 * 1024, 256 * 1024));
});
h.join().unwrap();
});
}
#[test]
fn spawn_with_partial_override_keeps_config_default_for_the_rest() {
rt1().run(|| {
let opts = SpawnOpts {
stack_reserve: Some(1024 * 1024),
..SpawnOpts::default()
};
let h = spawn_with(opts, || {
let shape = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(shape, (1024 * 1024, DEFAULT_STACK_GUARD));
});
h.join().unwrap();
});
}
#[test]
fn spawn_with_rounds_to_pages() {
rt1().run(|| {
let opts = SpawnOpts {
stack_reserve: Some(64 * 1024 + 1),
guard_size: Some(4097),
};
let h = spawn_with(opts, || {
let (reserve, guard) = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(reserve % 4096, 0);
assert_eq!(guard % 4096, 0);
assert!(reserve >= 64 * 1024 + 1);
assert!(guard >= 4097);
});
h.join().unwrap();
});
}
#[test]
fn spawn_under_with_takes_opts() {
rt1().run(|| {
let me = self_pid();
let opts = SpawnOpts {
stack_reserve: Some(128 * 1024),
..SpawnOpts::default()
};
let h = spawn_under_with(me, opts, || {
let (reserve, _) = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(reserve, 128 * 1024);
});
h.join().unwrap();
});
}
/// Obligation 4, from the outside: a dead custom stack must not be handed to
/// the next default spawn. The pool is LIFO, so if the custom stack had been
/// (wrongly) pushed at death, the very next default-shaped spawn on this
/// single-threaded runtime would pop it and report a custom shape.
#[test]
fn custom_stack_never_enters_the_pool() {
rt1().run(|| {
spawn_with(
SpawnOpts {
stack_reserve: Some(512 * 1024),
guard_size: Some(128 * 1024),
},
|| {},
)
.join()
.unwrap();
let h = spawn(|| {
let shape = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(shape, (DEFAULT_STACK_RESERVE, DEFAULT_STACK_GUARD));
});
h.join().unwrap();
});
}
/// The reverse direction of the pool rule: a default-shaped stack IS pooled
/// and reused (cap = threads × 4 ≥ 1 here, pool empty at start).
#[test]
fn default_stack_is_recycled() {
rt1().run(|| {
spawn(|| {}).join().unwrap();
let h = spawn(|| {
let shape = smarm::introspect::stack_shape(self_pid()).unwrap();
assert_eq!(shape, (DEFAULT_STACK_RESERVE, DEFAULT_STACK_GUARD));
});
h.join().unwrap();
});
}
/// Burn ~`frames` × 4 KiB of stack (see tests/runtime.rs twin).
#[inline(never)]
fn burn_stack(frames: usize) -> u64 {
let mut local = [0u8; 4096];
local[0] = frames as u8;
let below = if frames == 0 {
0
} else {
burn_stack(frames - 1)
};
std::hint::black_box(&mut local);
below.wrapping_add(local[0] as u64)
}
#[test]
fn big_reserve_behaviorally_takes_effect() {
// ~1 MiB deep on an 8 MiB per-spawn reserve, runtime default untouched.
rt1().run(|| {
let done = Arc::new(AtomicBool::new(false));
let done2 = done.clone();
spawn_with(
SpawnOpts {
stack_reserve: Some(8 * 1024 * 1024),
..SpawnOpts::default()
},
move || {
std::hint::black_box(burn_stack(256));
done2.store(true, Ordering::SeqCst);
},
)
.join()
.unwrap();
assert!(done.load(Ordering::SeqCst));
});
}
// ---------------------------------------------------------------------------
// Builder surfaces
// ---------------------------------------------------------------------------
struct Echo;
impl smarm::GenServer for Echo {
type Call = ();
type Reply = (usize, usize);
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _c: ()) -> (usize, usize) {
smarm::introspect::stack_shape(self_pid()).unwrap()
}
fn handle_cast(&mut self, _c: ()) {}
}
#[test]
fn gen_server_builder_stack_opts() {
rt1().run(|| {
let server = GenServerBuilder::new(Echo)
.stack_opts(SpawnOpts {
stack_reserve: Some(256 * 1024),
..SpawnOpts::default()
})
.start();
let (reserve, guard) = server.call(()).unwrap();
assert_eq!(reserve, 256 * 1024);
assert_eq!(guard, DEFAULT_STACK_GUARD);
server.shutdown();
});
}
struct Probe;
impl smarm::Machine for Probe {
type Ev = smarm::channel::Sender<(usize, usize)>;
fn state_timeout_ev() -> Self::Ev {
unreachable!("no timers in this test")
}
fn timeout_ev(_name: &'static str) -> Self::Ev {
unreachable!("no timers in this test")
}
fn on_start(&mut self, _cx: &mut smarm::Cx<Self::Ev>) {}
fn handle(
&mut self,
ev: Self::Ev,
_cx: &mut smarm::Cx<Self::Ev>,
) -> smarm::gen_statem::Step<Self::Ev> {
let _ = ev.send(smarm::introspect::stack_shape(self_pid()).unwrap());
smarm::gen_statem::Step::Stayed
}
}
#[test]
fn gen_statem_spawn_with_stack_opts() {
rt1().run(|| {
let m = smarm::gen_statem::spawn_with(
SpawnOpts {
stack_reserve: Some(256 * 1024),
..SpawnOpts::default()
},
Probe,
);
let (tx, rx) = smarm::channel::channel();
m.send(tx).unwrap();
let (reserve, guard) = rx.recv().unwrap();
assert_eq!(reserve, 256 * 1024);
assert_eq!(guard, DEFAULT_STACK_GUARD);
});
}
+103 -11
View File
@@ -7,13 +7,13 @@ use smarm::stack::Stack;
#[test]
fn top_is_16_byte_aligned() {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
assert_eq!(s.top() as usize % 16, 0);
}
#[test]
fn top_is_within_allocation() {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
let top = s.top() as usize;
let base = s.usable_base() as usize;
assert!(top > base);
@@ -22,7 +22,7 @@ fn top_is_within_allocation() {
#[test]
fn write_and_read_top_of_stack() {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
let sentinel: u64 = 0xDEAD_BEEF_CAFE_1234;
unsafe {
let ptr = s.top().sub(8) as *mut u64;
@@ -33,7 +33,7 @@ fn write_and_read_top_of_stack() {
#[test]
fn write_and_read_bottom_of_usable_region() {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
let sentinel: u64 = 0x0102_0304_0506_0708;
unsafe {
let ptr = s.usable_base() as *mut u64;
@@ -44,17 +44,17 @@ fn write_and_read_bottom_of_usable_region() {
#[test]
fn small_stack_allocates() {
assert!(Stack::new(4096).is_ok());
assert!(Stack::new(4096, 4096).is_ok());
}
#[test]
fn large_stack_allocates() {
assert!(Stack::new(8 * 1024 * 1024).is_ok());
assert!(Stack::new(8 * 1024 * 1024, 4096).is_ok());
}
#[test]
fn stack_size_at_least_requested() {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
assert!(s.stack_size() >= 64 * 1024);
}
@@ -68,15 +68,32 @@ use std::process::Command;
fn run_as_child_if_requested() {
match env::var("SMARM_SUBTEST").as_deref() {
Ok("guard_page_direct") => {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
unsafe {
let guard_ptr = s.usable_base().sub(1);
guard_ptr.write_volatile(0xAB);
}
std::process::exit(0);
}
Ok("wide_guard_top") => {
// One byte below the usable region, 64 KiB guard: must fault.
let s = Stack::new(64 * 1024, 64 * 1024).unwrap();
unsafe {
s.usable_base().sub(1).write_volatile(0xAB);
}
std::process::exit(0);
}
Ok("wide_guard_bottom") => {
// The very bottom page of a 64 KiB guard: an unprobed C-style
// leap over a small guard lands here — must still fault.
let s = Stack::new(64 * 1024, 64 * 1024).unwrap();
unsafe {
s.usable_base().sub(64 * 1024).write_volatile(0xAB);
}
std::process::exit(0);
}
Ok("stack_overflow") => {
let s = Stack::new(64 * 1024).unwrap();
let s = Stack::new(64 * 1024, 4096).unwrap();
unsafe {
let mut ptr = s.top().sub(1);
let stop = s.usable_base().sub(1);
@@ -107,7 +124,12 @@ fn guard_page_causes_sigsegv() {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(status.signal(), Some(11), "expected SIGSEGV, got: {:?}", status);
assert_eq!(
status.signal(),
Some(11),
"expected SIGSEGV, got: {:?}",
status
);
}
}
@@ -118,6 +140,76 @@ fn stack_overflow_causes_sigsegv() {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(status.signal(), Some(11), "expected SIGSEGV, got: {:?}", status);
assert_eq!(
status.signal(),
Some(11),
"expected SIGSEGV, got: {:?}",
status
);
}
}
// ---------------------------------------------------------------------------
// RFC 019 — explicit shape: rounding, guard accessor, wide-guard coverage.
// ---------------------------------------------------------------------------
#[test]
fn sizes_round_up_to_page() {
let s = Stack::new(64 * 1024 + 1, 4096 + 1).unwrap();
assert_eq!(s.stack_size() % 4096, 0);
assert_eq!(s.guard_size() % 4096, 0);
assert!(s.stack_size() >= 64 * 1024 + 1);
assert!(s.guard_size() >= 4096 + 1);
}
#[test]
fn shape_reports_rounded_sizes() {
let s = Stack::new(64 * 1024, 64 * 1024).unwrap();
assert_eq!(s.shape(), (64 * 1024, 64 * 1024));
}
#[test]
fn usable_base_sits_above_guard() {
let s = Stack::new(64 * 1024, 64 * 1024).unwrap();
// The usable region must start exactly guard_size above the mapping
// base: a write at usable_base is legal, one byte below is not (the
// subprocess tests below prove the "not").
let sentinel: u64 = 0x1111_2222_3333_4444;
unsafe {
let ptr = s.usable_base() as *mut u64;
ptr.write_volatile(sentinel);
assert_eq!(ptr.read_volatile(), sentinel);
}
}
#[test]
fn wide_guard_faults_at_top() {
run_as_child_if_requested();
let status = spawn_subtest("wide_guard_top");
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(
status.signal(),
Some(11),
"expected SIGSEGV, got: {:?}",
status
);
}
}
#[test]
fn wide_guard_faults_at_bottom() {
run_as_child_if_requested();
let status = spawn_subtest("wide_guard_bottom");
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(
status.signal(),
Some(11),
"expected SIGSEGV, got: {:?}",
status
);
}
}
+153
View File
@@ -0,0 +1,153 @@
//! RFC 019 §7 — overflow diagnostics, observed from outside via subprocess
//! (mirrors tests/stack.rs's harness, plus stderr capture).
//!
//! Four cases:
//! - Rust recursion at defaults: probed frames walk into the guard →
//! tier-1 definitive message, death by SIGSEGV.
//! - FFI canary (96 KiB unprobed C local) at defaults: first touch lands
//! inside the 1 MiB guard → tier-1 message.
//! - FFI canary with the guard shrunk to 4 KiB: the frame steps over it
//! into unmapped VA below → tier-2 "stepped over" message. This is the
//! RFC's motivating incident (cargo-vendored gz build) reproduced.
//! - FFI canary with reserve raised to 256 KiB: fits, runs clean, exits 0 —
//! the §1 knob is the fix, proven by the same frame.
use std::env;
use std::process::Command;
unsafe extern "C" {
fn smarm_canary_burn();
}
/// Unbounded probed recursion; each frame dirties 4 KiB. black_box defeats
/// tail-call elision so the walk is real.
#[inline(never)]
#[allow(unconditional_recursion)]
fn recurse_forever(depth: u64) -> u64 {
let mut local = [0u8; 4096];
local[0] = depth as u8;
std::hint::black_box(&mut local);
recurse_forever(depth + 1).wrapping_add(local[0] as u64)
}
fn run_as_child_if_requested() {
let mode = match env::var("SMARM_DIAG_SUBTEST") {
Ok(m) => m,
Err(_) => return,
};
use smarm::runtime::Config;
use smarm::{spawn_with, SpawnOpts};
let rt = smarm::runtime::init(Config::exact(1));
rt.run(move || {
let opts = match mode.as_str() {
"rust_overflow" | "ffi_tier1" => SpawnOpts::default(),
// Small guard: the canary's 96 KiB displacement clears it.
"ffi_tier2" => SpawnOpts {
guard_size: Some(4096),
..SpawnOpts::default()
},
// Enough reserve: the same frame simply fits.
"ffi_clean" => SpawnOpts {
stack_reserve: Some(256 * 1024),
..SpawnOpts::default()
},
other => panic!("unknown subtest {other}"),
};
let is_rust = mode == "rust_overflow";
spawn_with(opts, move || {
if is_rust {
std::hint::black_box(recurse_forever(0));
} else {
unsafe { smarm_canary_burn() };
}
})
.join()
.unwrap();
});
std::process::exit(0);
}
fn spawn_subtest(name: &str) -> std::process::Output {
let exe = env::current_exe().unwrap();
Command::new(exe)
.env("SMARM_DIAG_SUBTEST", name)
.args(["--test-threads=1", "--quiet"])
.output()
.expect("failed to spawn subprocess")
}
#[cfg(unix)]
fn assert_died_sigsegv(out: &std::process::Output) {
use std::os::unix::process::ExitStatusExt;
assert_eq!(
out.status.signal(),
Some(11),
"expected death by SIGSEGV, got {:?}; stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn rust_overflow_dies_with_tier1_message() {
run_as_child_if_requested();
let out = spawn_subtest("rust_overflow");
assert_died_sigsegv(&out);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("overflowed its stack") && err.contains("in the guard region"),
"missing tier-1 diagnostic; stderr:\n{err}"
);
assert!(
err.contains("reserve=65536"),
"wrong reserve in message:\n{err}"
);
assert!(
err.contains("guard=1048576"),
"wrong guard in message:\n{err}"
);
}
#[test]
fn ffi_canary_at_defaults_dies_with_tier1_message() {
run_as_child_if_requested();
let out = spawn_subtest("ffi_tier1");
assert_died_sigsegv(&out);
let err = String::from_utf8_lossy(&out.stderr);
// 96 KiB displacement from a 64 KiB reserve lands ~32 KiB into the
// 1 MiB guard: definitively classified.
assert!(
err.contains("in the guard region"),
"wide guard should catch the unprobed frame in tier 1; stderr:\n{err}"
);
}
#[test]
fn ffi_canary_over_small_guard_dies_with_tier2_message() {
run_as_child_if_requested();
let out = spawn_subtest("ffi_tier2");
assert_died_sigsegv(&out);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("stepped over it") && err.contains("below the guard"),
"expected tier-2 overshoot attribution; stderr:\n{err}"
);
assert!(err.contains("guard=4096"), "wrong guard in message:\n{err}");
}
#[test]
fn ffi_canary_with_enough_reserve_runs_clean() {
run_as_child_if_requested();
let out = spawn_subtest("ffi_clean");
assert!(
out.status.success(),
"canary should fit in 256 KiB reserve, got {:?}; stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("smarm: actor"),
"no diagnostic expected on the clean path; stderr:\n{err}"
);
}
+133
View File
@@ -0,0 +1,133 @@
//! RFC 019 commit 5 — pool recycle zaps a dead stack down to its retained
//! entry end, observed from the outside.
//!
//! A default-shaped stack that spiked deep and then died must not carry its
//! spike into the pool as resident RSS: `recycle_stack` DONTNEEDs everything
//! below the top `RECYCLE_RETAIN` bytes before pushing. The zap is
//! synchronous on the death path, so the drop is immediate — but the death
//! path itself races the observer's `join` return, hence the brief poll.
//!
//! Residency is measured with `mincore`, not smaps: a neighboring rw anon
//! mapping can land flush against the stack top and the kernel merges the
//! VMAs (observed under the full test run), so per-mapping smaps fields
//! over-count. The PROT_NONE guard below can never merge, so the usable
//! base is exactly the anchor VMA's start, and `mincore` counts pages
//! within [usable_base, usable_base + reserve) regardless of merging.
use smarm::runtime::{Config, RECYCLE_RETAIN};
use smarm::{channel, spawn, yield_now};
const RESERVE: usize = 4 * 1024 * 1024;
/// Burn ~`frames` × 4 KiB of stack, dirtying every frame.
#[inline(never)]
fn burn_stack(frames: usize) -> u64 {
let mut local = [0u8; 4096];
local[0] = frames as u8;
let below = if frames == 0 {
0
} else {
burn_stack(frames - 1)
};
std::hint::black_box(&mut local);
below.wrapping_add(local[0] as u64)
}
/// Resident-page count over [lo, lo + len) via mincore (len page-aligned).
fn resident_pages(lo: usize, len: usize) -> usize {
let page = 4096;
let mut vec = vec![0u8; len / page];
let ret = unsafe { libc::mincore(lo as *mut libc::c_void, len, vec.as_mut_ptr()) };
assert_eq!(
ret,
0,
"mincore failed: {}",
std::io::Error::last_os_error()
);
vec.iter().filter(|&&b| b & 1 != 0).count()
}
/// The [start, end) of the VMA containing `addr`.
fn vma_containing(addr: usize) -> (usize, usize) {
let maps = std::fs::read_to_string("/proc/self/maps").unwrap();
for line in maps.lines() {
if let Some((range, _)) = line.split_once(' ') {
if let Some((a, b)) = range.split_once('-') {
if let (Ok(start), Ok(end)) =
(usize::from_str_radix(a, 16), usize::from_str_radix(b, 16))
{
if start <= addr && addr < end {
return (start, end);
}
}
}
}
}
panic!("no VMA contains {addr:#x}");
}
fn vma_exists(addr: usize) -> bool {
let maps = std::fs::read_to_string("/proc/self/maps").unwrap();
for line in maps.lines() {
if let Some((range, _)) = line.split_once(' ') {
if let Some((a, b)) = range.split_once('-') {
if let (Ok(start), Ok(end)) =
(usize::from_str_radix(a, 16), usize::from_str_radix(b, 16))
{
if start <= addr && addr < end {
return true;
}
}
}
}
}
false
}
#[test]
fn recycle_zaps_dead_stack_down_to_retain() {
// Default reserve raised so the pool holds big stacks (default-shaped ⇒
// pooled) and the zap has something to bite; single scheduler.
let rt = smarm::runtime::init(Config::exact(1).stack_reserve(RESERVE));
rt.run(|| {
let (tx, rx) = channel::<usize>();
let h = spawn(move || {
let probe = 0u8;
let anchor = &probe as *const u8 as usize;
// The guard below is PROT_NONE and can never merge with the
// usable region, so the anchor VMA's start IS the usable base.
let (vlo, _) = vma_containing(anchor);
// Dirty ~3 MiB of the 4 MiB reserve, then die.
std::hint::black_box(burn_stack(768));
tx.send(vlo).unwrap();
});
let usable_base = rx.recv().unwrap();
h.join().unwrap();
// The zap span is everything below the retained entry end. DONTNEED
// on private anon discards synchronously and unconditionally, so
// this must go to exactly zero resident pages; the poll only covers
// the death path racing join's return.
let zap_len = RESERVE - RECYCLE_RETAIN;
let mut resident = usize::MAX;
for _ in 0..10_000 {
resident = resident_pages(usable_base, zap_len);
if resident == 0 {
break;
}
yield_now();
}
assert_eq!(
resident, 0,
"recycled stack's zap span still resident: {resident} pages in \
[{usable_base:#x}, +{zap_len:#x})"
);
// Pooled, not munmapped: the mapping must still be there.
assert!(
vma_exists(usable_base),
"default-shaped stack was unmapped instead of pooled"
);
});
}
+161
View File
@@ -0,0 +1,161 @@
//! RFC 019 commit 3 — park-path stack shrink, observed from the outside.
//!
//! The one integration-level claim of the shrink machinery: an actor that
//! spikes deep, returns shallow, and then parks past the cooldown gets its
//! dead span MADV_FREE'd — visible as `LazyFree` in `/proc/self/smaps`
//! within the stack's address range — while everything live survives.
//!
//! The high-water mark is *sampled* at context-save, so the spike yields
//! once at max depth to guarantee a sample there (in production, preemption
//! provides the quasi-random samples; a test must not rely on luck).
use smarm::runtime::{Config, SHRINK_COOLDOWN, SHRINK_THRESHOLD};
use smarm::{actor_info, channel, spawn, spawn_with, yield_now, ActorState, SpawnOpts};
/// Burn ~`frames` × 4 KiB of stack, yielding once at the bottom so the
/// context-save samples `sp` at max depth.
#[inline(never)]
fn burn_stack_yielding(frames: usize) -> u64 {
let mut local = [0u8; 4096];
local[0] = frames as u8;
let below = if frames == 0 {
yield_now();
0
} else {
burn_stack_yielding(frames - 1)
};
std::hint::black_box(&mut local);
below.wrapping_add(local[0] as u64)
}
/// Sum the `LazyFree:` kB of every smaps mapping intersecting [lo, hi).
fn lazy_free_bytes_in(lo: usize, hi: usize) -> usize {
let smaps = std::fs::read_to_string("/proc/self/smaps").unwrap();
let mut total_kb = 0usize;
let mut in_range = false;
for line in smaps.lines() {
if let Some((range, _)) = line.split_once(' ') {
if let Some((a, b)) = range.split_once('-') {
if let (Ok(start), Ok(end)) =
(usize::from_str_radix(a, 16), usize::from_str_radix(b, 16))
{
in_range = start < hi && end > lo;
continue;
}
}
}
if in_range {
if let Some(rest) = line.strip_prefix("LazyFree:") {
let kb: usize = rest.trim().trim_end_matches(" kB").trim().parse().unwrap();
total_kb += kb;
}
}
}
total_kb * 1024
}
#[test]
fn spike_then_parks_marks_lazyfree_and_keeps_live_data() {
// Single scheduler: the controller can gate on the worker being Parked.
let rt = smarm::runtime::init(Config::exact(1));
rt.run(|| {
let (park_tx, park_rx) = channel::<()>();
let (done_tx, done_rx) = channel::<(usize, u64)>();
let spike = 768 * 4096; // ~3 MiB, well past SHRINK_THRESHOLD
assert!(spike > SHRINK_THRESHOLD);
let worker = spawn_with(
SpawnOpts {
stack_reserve: Some(8 * 1024 * 1024),
..SpawnOpts::default()
},
move || {
// Live data that must survive the shrink, and an anchor
// address inside the stack for the smaps scan.
let live = [0xA5u8; 64];
let anchor = live.as_ptr() as usize;
// Spike: ~3 MiB deep, sampled at the bottom, unwound.
std::hint::black_box(burn_stack_yielding(768));
// Park past the cooldown. Each recv on the drained inbox is
// one park; the controller sends only when it sees us Parked.
for _ in 0..(SHRINK_COOLDOWN + 8) {
park_rx.recv().unwrap();
}
// Measure from inside: the stack spans ≤ 8 MiB below anchor.
let lazy = lazy_free_bytes_in(anchor - 8 * 1024 * 1024, anchor + 4096);
let checksum = live.iter().map(|&b| b as u64).sum();
done_tx.send((lazy, checksum)).unwrap();
},
);
let wpid = worker.pid();
for _ in 0..(SHRINK_COOLDOWN + 8) {
// Gate: send only once the worker is genuinely parked so every
// round is a real park-on-empty-mailbox.
loop {
match actor_info(wpid) {
Some(info) if info.state == ActorState::Parked => break,
Some(_) => yield_now(),
None => panic!("worker died early"),
}
}
park_tx.send(()).unwrap();
}
let (lazy, checksum) = done_rx.recv().unwrap();
// The spike was ~3 MiB; demand at least 2 MiB marked to leave slack
// for the redzone, rounding, and pages the unwind re-dirtied.
assert!(
lazy >= 2 * 1024 * 1024,
"expected ≥ 2 MiB LazyFree in the stack range, got {} bytes",
lazy
);
assert_eq!(
checksum,
64 * 0xA5u64,
"live stack data corrupted by shrink"
);
worker.join().unwrap();
});
}
/// Steady-state actors must never pay the syscall: an actor that parks a lot
/// but never spikes past the threshold ends with zero LazyFree in its stack.
#[test]
fn shallow_actor_never_shrinks() {
let rt = smarm::runtime::init(Config::exact(1));
rt.run(|| {
let (park_tx, park_rx) = channel::<()>();
let (done_tx, done_rx) = channel::<usize>();
let worker = spawn(move || {
let probe = 0u8;
let anchor = &probe as *const u8 as usize;
for _ in 0..(SHRINK_COOLDOWN + 8) {
park_rx.recv().unwrap();
}
done_tx
.send(lazy_free_bytes_in(anchor - 64 * 1024, anchor + 4096))
.unwrap();
});
let wpid = worker.pid();
for _ in 0..(SHRINK_COOLDOWN + 8) {
loop {
match actor_info(wpid) {
Some(info) if info.state == ActorState::Parked => break,
Some(_) => yield_now(),
None => panic!("worker died early"),
}
}
park_tx.send(()).unwrap();
}
assert_eq!(done_rx.recv().unwrap(), 0, "steady-state actor was shrunk");
worker.join().unwrap();
});
}
+169
View File
@@ -0,0 +1,169 @@
//! Reproducer (soak20 signature 2, refcount_test.exs "watcher crash"):
//! `by_name` stores only the slot *index*, so a name whose holder died — never
//! unregistered, since no smarm stop path unregisters (prune is lazy) — and
//! whose slot was then re-tenanted by an unrelated actor reads as *live-held*:
//!
//! - `register` of the name fails `NameTaken { holder: <unrelated tenant> }`,
//! so the bridge's generated `start()` (a `let _ =`) silently no-ops and
//! `start_server/1` reports `:ok` for a server that never came up;
//! - a by-name `call` resolves the tenant's mailbox, misses on the message
//! `TypeId`, and fails `ServerDown` fast — and does NOT prune (only the
//! dead-holder and dangling-name arms prune), so the name never heals
//! while the tenant lives. The wedge is self-sustaining.
//!
//! Wild signature: 110235x fast `{:error, :server_down}` probes over the full
//! 5 s await window after a swallowed restart (200-run width-20 soak, run 59).
//!
//! The test asserts the *contract*: after its holder dies, a name must be
//! re-registrable regardless of what happened to the slot. Red pre-fix.
use smarm::{
call, init, request_stop, whereis, CallError, Config, GenServer, GenServerBuilder,
GenServerName, RegisterError,
};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const TARGET: GenServerName<Target> = GenServerName::new("stale_reuse_target");
/// The named server whose death opens the window. Trivial on purpose.
struct Target;
impl GenServer for Target {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _req: ()) {}
fn handle_cast(&mut self, _op: ()) {}
}
/// The unrelated tenant. A *different* server type, so its mailbox holds a
/// different `Envelope` `TypeId` — a same-typed tenant would make the by-name
/// `call` *deliver to the wrong server* instead of failing, which is the same
/// root hole wearing a worse hat.
struct Filler;
impl GenServer for Filler {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _req: ()) {}
fn handle_cast(&mut self, _op: ()) {}
}
#[derive(Debug)]
struct Observed {
old_slot: (u32, u32),
tenant_slot: (u32, u32),
/// `whereis` of the dead name after re-tenanting — `Some` is the misread.
whereis_after_reuse: Option<(u32, u32)>,
/// By-name call after re-tenanting — the wild `server_down` fast-fail.
call_after_reuse: Result<(), CallError>,
/// The contract under test: re-registering the dead name.
restart: Result<(), RegisterError>,
}
#[test]
fn dead_name_with_reused_slot_must_be_re_registrable() {
let out: Arc<Mutex<Option<Observed>>> = Arc::new(Mutex::new(None));
let out_w = out.clone();
// A deliberately tiny slab forces prompt slot recycling: with every filler
// held alive, the freed slot is the only *recycled* one, so a filler lands
// on it deterministically well before the slab (a loud panic) runs out.
init(Config::exact(2).max_actors(32)).run(move || {
// 1. Named server up; record its slot.
let target = GenServerBuilder::new(Target)
.named(TARGET)
.start()
.expect("name should be free at test start");
let old_pid = target.pid();
// 2. Kill it WITHOUT unregistering (no stop path does). Death is
// confirmed via the *ref*, never the name — a by-name resolve of a
// dead-but-not-yet-reused holder takes the prune arm and heals the
// name, destroying the precondition.
request_stop(old_pid);
loop {
match target.call(()) {
Err(CallError::ServerDown) => break,
Ok(()) => smarm::sleep(Duration::from_millis(5)),
}
}
drop(target);
// 3. Re-tenant the slot: spawn fillers (all kept alive) until one
// lands on the old index.
let mut fillers = Vec::new();
let mut tenant = None;
for i in 0..24 {
let name: &'static str = Box::leak(format!("stale_filler_{i}").into_boxed_str());
let f = GenServerBuilder::new(Filler)
.named(GenServerName::<Filler>::new(name))
.start()
.expect("filler names are fresh");
let fp = f.pid();
fillers.push(f);
if fp.index() == old_pid.index() {
tenant = Some(fp);
break;
}
}
let tenant = tenant.expect(
"precondition: the freed slot must be re-tenanted within the tiny slab \
(slots are recycled; every filler is held alive)",
);
// 4. Observe the poisoned state through the same paths the bridge uses.
let whereis_after_reuse = whereis(TARGET.as_str()).map(|p| (p.index(), p.generation()));
let call_after_reuse = call(TARGET, ());
let restart = GenServerBuilder::new(Target)
.named(TARGET)
.start()
.map(|_fresh_ref| ());
*out_w.lock().unwrap() = Some(Observed {
old_slot: (old_pid.index(), old_pid.generation()),
tenant_slot: (tenant.index(), tenant.generation()),
whereis_after_reuse,
call_after_reuse,
restart,
});
drop(fillers);
});
let o = out.lock().unwrap().take().expect("run body completed");
eprintln!("observed: {o:?}");
assert!(
o.restart.is_ok(),
"re-registering '{}' after its holder died failed with {:?}: the dead name \
reads as held by the live, unrelated tenant {:?} because by_name kept only \
the slot index (old slot {:?}). This is the silent-no-op start_server path \
of soak20 signature 2.",
TARGET.as_str(),
o.restart,
o.tenant_slot,
o.old_slot,
);
// The healed semantics around the re-register: the stale name reads
// *unbound* (never the tenant), and a by-name call fails ServerDown rather
// than resolving anything of the tenant's.
assert_eq!(
o.whereis_after_reuse, None,
"whereis of a dead name must prune and report unbound, not the slot's new tenant",
);
assert_eq!(
o.call_after_reuse,
Err(CallError::ServerDown),
"a by-name call to a dead name must fail ServerDown",
);
}
+122
View File
@@ -0,0 +1,122 @@
//! Reproducer: a *named* gen_server stopped with `request_stop` while a `call`
//! sits **un-dequeued** in its inbox does NOT release the parked caller with
//! `CallError::ServerDown`. The caller parks forever, contradicting the
//! documented gen_server guarantee ("Any caller currently waiting in `call`
//! sees `Err(ServerDown)`").
//!
//! Root cause (channel.rs): `Receiver::Drop` only flips `receiver_alive = false`
//! and never drains `queue`. The queued `Envelope::Call(_, reply_tx)` therefore
//! survives as long as the channel `Arc<Inner>` does — and for a *named* server
//! the registry holds a `Sender` clone (lazy prune) that keeps the `Arc` alive
//! after the server is gone. So the queued `reply_tx` is never dropped, the
//! caller's `reply_rx` never closes, and `reply_rx.recv()` parks forever.
//!
//! Anonymous servers happen to dodge this: when their last `GenServerRef`
//! drops, every `Sender` drops, the `Arc` refcount hits zero, `Inner` (and its
//! queue) is dropped, and the queued `reply_tx` goes with it — waking the
//! caller. The bug is specific to "a `Sender` outlives the `Receiver`", which a
//! registry entry guarantees for every named server.
use smarm::{
call, channel, init, request_stop, spawn, CallError, Config, GenServer, GenServerBuilder,
GenServerName, Receiver, RecvTimeoutError,
};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const BLOCKER: GenServerName<Blocker> = GenServerName::new("repro_blocker");
/// A server that, on its single cast, parks forever on a gate channel the test
/// never feeds. This deterministically holds the server *inside a handler* (not
/// at the inbox recv), so any subsequent `call` queues behind it and stays
/// un-dequeued — exactly the state `request_stop` then has to clean up.
struct Blocker {
gate: Option<Receiver<()>>,
}
impl GenServer for Blocker {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
// Trivial + instant: if this ever ran for the queued call, the caller would
// get Ok(()) immediately. It must NOT run — the server is parked on the gate
// when the stop arrives.
fn handle_call(&mut self, _req: ()) {}
// Park forever (until cancelled). recv() on an open channel with no message
// parks the actor; the gate sender is held by the test and never fires.
fn handle_cast(&mut self, _op: ()) {
if let Some(gate) = self.gate.take() {
let _ = gate.recv();
}
}
}
#[test]
fn named_server_request_stop_releases_queued_caller_with_server_down() {
// Final observation, asserted after the run.
// Some(Err(ServerDown)) -> contract honored (fixed)
// None -> caller never released; parked past the 3s
// bound (bug reproduced)
let outcome: Arc<Mutex<Option<Result<(), CallError>>>> = Arc::new(Mutex::new(None));
let outcome_w = outcome.clone();
init(Config::exact(2)).run(move || {
// Gate the server will park on. Held for the whole run so the server's
// gate.recv() parks (rather than seeing Disconnected and returning).
let (gate_tx, gate_rx) = channel::<()>();
// Channel the queued caller reports its result back on.
let (res_tx, res_rx) = channel::<Result<(), CallError>>();
// 1. Start the named server and keep its ref alive.
let server = GenServerBuilder::new(Blocker {
gate: Some(gate_rx),
})
.named(BLOCKER)
.start()
.expect("name should be free");
let spid = server.pid();
// 2. Send the cast and let the server dequeue it and park on the gate.
server.cast(()).expect("server is live");
smarm::sleep(Duration::from_millis(100));
// 3. A separate caller issues a by-name `call`. The server is parked on
// the gate, so this Call envelope queues un-dequeued; the caller then
// parks on its reply channel.
spawn(move || {
let r = call(BLOCKER, ());
let _ = res_tx.send(r);
});
smarm::sleep(Duration::from_millis(100));
// 4. Stop the server. Its loop unwinds out of the gate.recv() and drops
// the inbox Receiver — at which point the queued caller is *supposed*
// to be released with ServerDown.
request_stop(spid);
// 5. Bounded wait. A correct runtime releases the caller in well under
// 3s; the bug leaves it parked, so we time out.
let observed = match res_rx.recv_timeout(Duration::from_secs(3)) {
Ok(r) => Some(r),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => None,
};
*outcome_w.lock().unwrap() = observed;
// Keep the gate sender alive until the very end.
drop(gate_tx);
});
let observed = outcome.lock().unwrap().take();
assert_eq!(
observed,
Some(Err(CallError::ServerDown)),
"queued caller was not released with ServerDown after the named server \
was request_stop'd (None = parked forever => bug reproduced)"
);
}
+16 -8
View File
@@ -10,7 +10,11 @@
//! out rather than produce a false pass — run with `cargo test -- --timeout`
//! or under a CI timeout.
use smarm::{channel, runtime::{Config, Runtime}, spawn, yield_now, JoinHandle};
use smarm::{
channel,
runtime::{Config, Runtime},
spawn, yield_now, JoinHandle,
};
use std::sync::{
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc,
@@ -199,7 +203,9 @@ fn thundering_herd_all_wake() {
}
// Let all receivers park before we send.
for _ in 0..4 { yield_now(); }
for _ in 0..4 {
yield_now();
}
// Coordinator blasts all channels.
handles.push(spawn(move || {
@@ -240,8 +246,7 @@ fn concurrent_spawn_join_churn() {
for _ in 0..PARENTS {
let tc = t.clone();
parent_handles.push(spawn(move || {
let mut child_handles: Vec<JoinHandle> =
Vec::with_capacity(CHILDREN_PER_PARENT);
let mut child_handles: Vec<JoinHandle> = Vec::with_capacity(CHILDREN_PER_PARENT);
for _ in 0..CHILDREN_PER_PARENT {
let tcc = tc.clone();
@@ -292,7 +297,9 @@ fn join_race_child_finishes_first() {
}
// Yield enough to let children run to completion before we join.
for _ in 0..8 { yield_now(); }
for _ in 0..8 {
yield_now();
}
for h in handles {
// If child already finished, join must return immediately with Ok.
@@ -374,8 +381,7 @@ fn panic_storm_does_not_corrupt_scheduler() {
fn pid_generation_increments_on_reuse() {
use smarm::self_pid;
let pids: Arc<smarm::Mutex<Vec<smarm::Pid>>> =
Arc::new(smarm::Mutex::new(Vec::new()));
let pids: Arc<smarm::Mutex<Vec<smarm::Pid>>> = Arc::new(smarm::Mutex::new(Vec::new()));
let p = pids.clone();
rt(1).run(move || {
@@ -392,7 +398,9 @@ fn pid_generation_increments_on_reuse() {
}
});
let g = pids.lock_timeout(std::time::Duration::from_secs(1)).unwrap();
let g = pids
.lock_timeout(std::time::Duration::from_secs(1))
.unwrap();
// Any two PIDs that share an index must have different generations.
for i in 0..g.len() {
for j in (i + 1)..g.len() {
+10 -2
View File
@@ -51,7 +51,11 @@ fn transient_child_is_restarted_on_panic_then_settles() {
});
sup.join().unwrap();
});
assert_eq!(runs.load(Ordering::SeqCst), 3, "two restarts then a clean exit");
assert_eq!(
runs.load(Ordering::SeqCst),
3,
"two restarts then a clean exit"
);
}
#[test]
@@ -167,7 +171,11 @@ fn one_for_all_restarts_a_normally_exited_sibling() {
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");
assert_eq!(
b.load(Ordering::SeqCst),
2,
"B cycled with the group despite a clean exit"
);
}
#[test]
+276
View File
@@ -0,0 +1,276 @@
//! The terminal-record contract (bridge soak signature 4): a watch installed
//! *after* its target's death — the async-install race the bridge's proxies
//! live with — must be able to recover the real down reason instead of a
//! blanket `NoProc`. Two primitives carry it:
//!
//! - `finalize_actor` stamps the slot with `(generation, DownReason)`; the
//! record survives reclaim, registry pruning, and the next tenant's
//! install, and is overwritten only by the slot's next death.
//! [`terminal_reason`] reads it generation-matched.
//! - [`resolve_name`] is `whereis` with the corpse kept: the dead-holder arm
//! returns the stored pid it prunes ([`NameResolution::Corpse`]) instead
//! of discarding the only evidence of *who* died. `Unbound` stays the
//! Erlang-shaped `noproc` for names that were never (or are no longer)
//! bound.
//!
//! `monitor()` of a stale pid still queues plain `NoProc` — the upgrade is a
//! caller's deliberate act, not a semantics change.
use smarm::{
init, mark_watchable, request_stop, resolve_name, terminal_reason, CallError, Config,
DownReason, GenServer, GenServerBuilder, GenServerName, NameResolution,
};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const TARGET: GenServerName<Target> = GenServerName::new("terminal_target");
/// Named server that panics on cast — the sig-4 death.
struct Target;
impl GenServer for Target {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _req: ()) {}
fn handle_cast(&mut self, _op: ()) {
panic!("terminal_target: induced panic");
}
}
/// Slot filler for the re-tenancy phase (distinct type, held alive).
struct Filler;
impl GenServer for Filler {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _req: ()) {}
fn handle_cast(&mut self, _op: ()) {}
}
#[derive(Debug)]
struct Observed {
exit_reason: Option<DownReason>,
anon_reason: Option<DownReason>,
/// Anonymous but export-marked while alive — must stamp (sig 5).
marked_reason: Option<DownReason>,
/// Marked only after death — must remain unknowable.
marked_late_reason: Option<DownReason>,
panic_reason: Option<DownReason>,
stopped_reason: Option<DownReason>,
live_reason: Option<DownReason>,
live_resolution_is_live: bool,
unknown_resolution: NameResolution,
/// First resolve after the named target's panic — must be Corpse(old pid).
corpse_resolution_matches: bool,
/// Second resolve — the Corpse arm pruned, so the name has healed.
resolution_after_prune: NameResolution,
/// Read AFTER the prune above: the record is slot-side, not registry-side.
corpse_reason_after_prune: Option<DownReason>,
/// Record survives the slot being re-tenanted (new tenant still alive).
corpse_reason_after_reuse: Option<DownReason>,
/// ... and dies with the next tenancy's death (overwritten).
corpse_reason_after_tenant_death: Option<DownReason>,
tenant_reason: Option<DownReason>,
}
#[test]
fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
let out: Arc<Mutex<Option<Observed>>> = Arc::new(Mutex::new(None));
let out_w = out.clone();
// Tiny slab: prompt slot recycling for the re-tenancy phase.
init(Config::exact(2).max_actors(32)).run(move || {
// --- Registered plain actors: one record per way of dying. The
// record is named-tenancy-only, so each actor self-registers a
// throwaway channel before dying; the anonymous control below pins
// the complement.
let h = smarm::spawn(|| {
let (tx, _rx) = smarm::channel::<()>();
let _ = smarm::register(smarm::Name::<()>::new("terminal_probe_exit"), tx);
});
let pid_exit = h.pid();
let _ = h.join();
let exit_reason = terminal_reason(pid_exit);
let h = smarm::spawn(|| {
let (tx, _rx) = smarm::channel::<()>();
let _ = smarm::register(smarm::Name::<()>::new("terminal_probe_panic"), tx);
panic!("induced");
});
let pid_panic = h.pid();
let _ = h.join();
let panic_reason = terminal_reason(pid_panic);
let h = smarm::spawn(|| {
let (tx, _rx) = smarm::channel::<()>();
let _ = smarm::register(smarm::Name::<()>::new("terminal_probe_stop"), tx);
loop {
smarm::sleep(Duration::from_millis(2));
}
});
let pid_stop = h.pid();
request_stop(pid_stop);
let _ = h.join();
let stopped_reason = terminal_reason(pid_stop);
// --- Anonymous control: an unregistered death must NOT stamp (nor
// evict) — the free list is LIFO, so green-thread churn would
// otherwise overwrite a watchable record faster than any race
// window this exists to cover.
let h = smarm::spawn(|| panic!("anonymous"));
let pid_anon = h.pid();
let _ = h.join();
let anon_reason = terminal_reason(pid_anon);
// --- mark_watchable: the bridge's export-seam eligibility (sig 5).
// An anonymous actor marked while alive stamps like a named one ...
let h = smarm::spawn(|| loop {
smarm::sleep(Duration::from_millis(2));
});
let pid_marked = h.pid();
mark_watchable(pid_marked);
request_stop(pid_marked);
let _ = h.join();
let marked_reason = terminal_reason(pid_marked);
// ... while marking a pid whose tenancy already ended is a no-op:
// the history is honestly unknowable, not retroactively invented.
mark_watchable(pid_anon);
let marked_late_reason = terminal_reason(pid_anon);
// --- The named target: live readings first. -----------------------
let target = GenServerBuilder::new(Target)
.named(TARGET)
.start()
.expect("name free at test start");
let old_pid = target.pid();
let live_reason = terminal_reason(old_pid);
let live_resolution_is_live =
resolve_name(TARGET.as_str()) == NameResolution::Live(old_pid.erase());
let unknown_resolution = resolve_name("terminal_never_bound");
// --- Kill it by panic; confirm death via the ref, NEVER the name
// (any name reader would take the prune arm and destroy the corpse
// precondition — the same trap stale_name_slot_reuse.rs documents).
let _ = target.cast(());
loop {
match target.call(()) {
Err(CallError::ServerDown) => break,
Ok(()) => smarm::sleep(Duration::from_millis(2)),
}
}
let corpse_resolution_matches =
resolve_name(TARGET.as_str()) == NameResolution::Corpse(old_pid.erase());
let resolution_after_prune = resolve_name(TARGET.as_str());
let corpse_reason_after_prune = terminal_reason(old_pid);
// --- Re-tenant the freed slot; the record must outlive the install
// and die only with the next tenancy's death.
let mut fillers = Vec::new();
let mut tenant = None;
for i in 0..24 {
let name: &'static str = Box::leak(format!("terminal_filler_{i}").into_boxed_str());
let f = GenServerBuilder::new(Filler)
.named(GenServerName::<Filler>::new(name))
.start()
.expect("filler names are fresh");
let fp = f.pid();
let landed = fp.index() == old_pid.index();
fillers.push(f);
if landed {
tenant = Some((fillers.len() - 1, fp));
break;
}
}
let (tenant_at, tenant_pid) = tenant.expect(
"precondition: the freed slot must be re-tenanted within the tiny slab \
(slots are recycled; every filler is held alive)",
);
let corpse_reason_after_reuse = terminal_reason(old_pid);
request_stop(tenant_pid);
loop {
match fillers[tenant_at].call(()) {
Err(CallError::ServerDown) => break,
Ok(()) => smarm::sleep(Duration::from_millis(2)),
}
}
let corpse_reason_after_tenant_death = terminal_reason(old_pid);
let tenant_reason = terminal_reason(tenant_pid);
*out_w.lock().unwrap() = Some(Observed {
exit_reason,
anon_reason,
panic_reason,
stopped_reason,
live_reason,
live_resolution_is_live,
unknown_resolution,
corpse_resolution_matches,
resolution_after_prune,
marked_reason,
marked_late_reason,
corpse_reason_after_prune,
corpse_reason_after_reuse,
corpse_reason_after_tenant_death,
tenant_reason,
});
});
let o = out.lock().unwrap().take().expect("runtime body completed");
assert_eq!(o.exit_reason, Some(DownReason::Exit), "{o:?}");
assert_eq!(
o.anon_reason, None,
"anonymous deaths must not stamp: {o:?}"
);
assert_eq!(o.panic_reason, Some(DownReason::Panic), "{o:?}");
assert_eq!(
o.marked_reason,
Some(DownReason::Stopped),
"mark_watchable while alive must make the death stamp: {o:?}"
);
assert_eq!(
o.marked_late_reason, None,
"marking a dead tenancy must not invent history: {o:?}"
);
assert_eq!(o.stopped_reason, Some(DownReason::Stopped), "{o:?}");
assert_eq!(
o.live_reason, None,
"live tenancy must have no record: {o:?}"
);
assert!(o.live_resolution_is_live, "{o:?}");
assert_eq!(o.unknown_resolution, NameResolution::Unbound, "{o:?}");
assert!(
o.corpse_resolution_matches,
"first post-death resolve must carry the corpse: {o:?}"
);
assert_eq!(
o.resolution_after_prune,
NameResolution::Unbound,
"the Corpse arm prunes — the name heals: {o:?}"
);
assert_eq!(
o.corpse_reason_after_prune,
Some(DownReason::Panic),
"the record is slot-side; registry pruning must not touch it: {o:?}"
);
assert_eq!(
o.corpse_reason_after_reuse,
Some(DownReason::Panic),
"a new tenant's install must leave the previous tenancy's record: {o:?}"
);
assert_eq!(
o.corpse_reason_after_tenant_death, None,
"the next death overwrites — the old generation no longer matches: {o:?}"
);
assert_eq!(o.tenant_reason, Some(DownReason::Stopped), "{o:?}");
}
+7 -4
View File
@@ -35,7 +35,10 @@ impl PipePair {
let mut fds: [libc::c_int; 2] = [0; 2];
let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
assert_eq!(r, 0, "pipe2 failed");
PipePair { read: fds[0], write: fds[1] }
PipePair {
read: fds[0],
write: fds[1],
}
}
}
@@ -67,9 +70,9 @@ fn run_with_watchdog(limit: Duration, body: impl FnOnce() + Send + 'static) {
rt.run(body);
let _ = done_tx.send(());
});
done_rx
.recv_timeout(limit)
.expect("Runtime::run did not return: idle scheduler thread was never woken at termination");
done_rx.recv_timeout(limit).expect(
"Runtime::run did not return: idle scheduler thread was never woken at termination",
);
}
/// Permanent-hang variant: sibling blocked in `poll_wake(wake_fd, None)`
+88 -9
View File
@@ -166,14 +166,19 @@ fn timers_only_pop_entries_whose_deadline_has_passed() {
#[test]
fn timers_mix_sleep_and_wait_timeout_reasons() {
let mut t = Timers::new();
let target = Arc::new(RecordingTarget { calls: Mutex::new(Vec::new()) });
let target = Arc::new(RecordingTarget {
calls: Mutex::new(Vec::new()),
});
let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0), 1);
t.insert(
now + Duration::from_millis(10),
Pid::new(1, 0),
Reason::WaitTimeout { target: target.clone(), epoch: 42 },
Reason::WaitTimeout {
target: target.clone(),
epoch: 42,
},
);
let due = t.pop_due(now + Duration::from_millis(20));
@@ -238,7 +243,10 @@ fn armed_send_timer_is_returned_and_fires() {
let mut due = t.pop_due(now + Duration::from_millis(20));
assert_eq!(due.len(), 1, "an armed send timer should pop when due");
assert!(!fired.load(Ordering::SeqCst), "pop must not fire on its own");
assert!(
!fired.load(Ordering::SeqCst),
"pop must not fire on its own"
);
run_fire(due.pop().unwrap());
assert!(fired.load(Ordering::SeqCst), "running the thunk delivers");
assert!(t.is_empty());
@@ -282,7 +290,11 @@ fn cancel_after_fire_returns_false() {
fn cancel_unknown_id_returns_false() {
let mut t = Timers::new();
let now = Instant::now();
let id = t.insert_send(now + Duration::from_millis(5), Pid::new(0, 0), Box::new(|| {}));
let id = t.insert_send(
now + Duration::from_millis(5),
Pid::new(0, 0),
Box::new(|| {}),
);
assert!(t.cancel(id));
// Second cancel of the same id: already gone.
assert!(!t.cancel(id));
@@ -293,7 +305,11 @@ fn send_timers_interleave_with_sleep_in_deadline_order() {
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(30), Pid::new(0, 0), 1);
let _id = t.insert_send(now + Duration::from_millis(10), Pid::new(1, 0), Box::new(|| {}));
let _id = t.insert_send(
now + Duration::from_millis(10),
Pid::new(1, 0),
Box::new(|| {}),
);
t.insert_sleep(now + Duration::from_millis(20), Pid::new(2, 0), 1);
let due = t.pop_due(now + Duration::from_millis(50));
@@ -308,7 +324,11 @@ fn send_timers_interleave_with_sleep_in_deadline_order() {
fn clear_drops_armed_send_timers() {
let mut t = Timers::new();
let now = Instant::now();
let id = t.insert_send(now + Duration::from_millis(10), Pid::new(0, 0), Box::new(|| {}));
let id = t.insert_send(
now + Duration::from_millis(10),
Pid::new(0, 0),
Box::new(|| {}),
);
t.clear();
assert!(t.is_empty());
// The arm record is gone too: cancelling reports nothing to cancel.
@@ -356,7 +376,7 @@ fn send_after_to_unresolved_name_is_silent() {
// Nobody registered NOPE; firing resolves to nothing and is dropped.
let _id = send_after_named(Duration::from_millis(10), NOPE, 1);
sleep(Duration::from_millis(40)); // let it fire and no-op
// Reaching here without a panic is the assertion.
// Reaching here without a panic is the assertion.
});
}
@@ -401,7 +421,66 @@ fn send_after_to_dead_typed_pid_is_silent() {
assert_eq!(report_rx.recv().unwrap(), 1); // sink has now exited
let _id = send_after(Duration::from_millis(15), sink, 2);
sleep(Duration::from_millis(45)); // let it fire against the dead pid
// No panic, and nothing further delivered.
assert_eq!(report_rx.try_recv(), Ok(None));
// No panic; the sink is gone, so its report sender dropped with it —
// closed+empty is Err (documented), which also proves nothing
// further was delivered.
assert!(report_rx.try_recv().is_err(), "nothing further delivered");
});
}
// ---------------------------------------------------------------------------
// Wall-anchored send_after (RFC 007 user-facing opt-out). The API exists in
// both feature configs; featureless it is behaviourally identical to
// `send_after` — these tests pin exactly that.
// ---------------------------------------------------------------------------
#[test]
fn armed_wall_send_timer_is_returned_and_fires() {
let mut t = Timers::new();
let now = Instant::now();
let fired = Arc::new(AtomicBool::new(false));
let f = fired.clone();
let _id = t.insert_send_wall(
now + Duration::from_millis(10),
Pid::new(0, 0),
Box::new(move || f.store(true, Ordering::SeqCst)),
);
let mut due = t.pop_due(now + Duration::from_millis(20));
assert_eq!(due.len(), 1, "an armed wall send timer should pop when due");
run_fire(due.pop().unwrap());
assert!(fired.load(Ordering::SeqCst), "running the thunk delivers");
assert!(t.is_empty());
}
use smarm::send_after_named_wall;
#[test]
fn send_after_named_wall_delivers_after_the_delay() {
const WPING: Name<u64> = Name::new("send_after_wall_ping");
run(|| {
let (tx, rx) = channel::<u64>();
register(WPING, tx).unwrap();
let t0 = Instant::now();
let _id = send_after_named_wall(Duration::from_millis(30), WPING, 99);
assert_eq!(rx.recv().unwrap(), 99);
assert!(
t0.elapsed() >= Duration::from_millis(25),
"delivered too early: {:?}",
t0.elapsed()
);
});
}
#[test]
fn send_after_named_wall_cancels() {
const WC: Name<u64> = Name::new("send_after_wall_cancel");
run(|| {
let (tx, rx) = channel::<u64>();
register(WC, tx).unwrap();
let id = send_after_named_wall(Duration::from_millis(50), WC, 7);
assert!(cancel_timer(id), "cancel before fire returns true");
sleep(Duration::from_millis(90));
assert_eq!(rx.try_recv(), Ok(None), "cancelled wall timer delivered");
});
}
+185
View File
@@ -0,0 +1,185 @@
//! Non-panicking spawn at slab capacity (`try_spawn`).
//!
//! Covers: parity with `spawn` when slots are free; `Err(AtCapacity)` instead
//! of a panic on a full slab (the spawning actor survives — the crash-loop
//! from the motivating slowloris incident cannot start); self-heal (a freed
//! slot makes the next `try_spawn` succeed); and exact claim-or-report
//! accounting under a multi-thread race for the last slots (no TOCTOU
//! overshoot, no panic).
use smarm::runtime::Config;
use smarm::{spawn, try_spawn, try_spawn_under_with, yield_now, SpawnError, SpawnOpts};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
/// A child that holds its slot until `release` flips, without parking
/// machinery: busy-yield keeps the scheduler moving and the slot occupied.
fn holder(release: Arc<AtomicBool>) -> impl FnOnce() + Send + 'static {
move || {
while !release.load(Ordering::Acquire) {
yield_now();
}
}
}
#[test]
fn try_spawn_is_spawn_when_slots_free() {
smarm::runtime::init(Config::exact(1)).run(|| {
let ran = Arc::new(AtomicBool::new(false));
let flag = ran.clone();
let h = try_spawn(move || flag.store(true, Ordering::Release))
.expect("slots free — must behave exactly like spawn");
h.join().unwrap();
assert!(ran.load(Ordering::Acquire));
});
}
#[test]
fn at_capacity_is_err_not_panic_and_accounting_is_exact() {
const MAX: usize = 8;
smarm::runtime::init(Config::exact(1).max_actors(MAX)).run(|| {
let release = Arc::new(AtomicBool::new(false));
// Fill the slab from the initial actor: slots are claimed at spawn
// time, so children need not have run yet. Count until refusal.
let mut held = Vec::new();
loop {
match try_spawn(holder(release.clone())) {
Ok(h) => held.push(h),
Err(e) => {
assert_eq!(e, SpawnError::AtCapacity);
break;
}
}
}
// Initial actor occupies one slot; the rest were spawnable.
assert_eq!(held.len(), MAX - 1, "slab accounting must be exact");
// Still refusing (and still not panicking) on repeat.
assert!(matches!(try_spawn(|| ()), Err(SpawnError::AtCapacity)));
// The `_with` surface refuses identically — a custom shape must not
// reach stack allocation when there is no slot for it.
let opts = SpawnOpts {
stack_reserve: Some(1024 * 1024),
..SpawnOpts::default()
};
assert!(matches!(
try_spawn_under_with(smarm::self_pid(), opts, || ()),
Err(SpawnError::AtCapacity)
));
// Self-heal: free the slots, join, and the next try_spawn succeeds.
release.store(true, Ordering::Release);
for h in held {
h.join().unwrap();
}
let h = try_spawn(|| ()).expect("slots freed — must succeed again");
h.join().unwrap();
});
}
#[test]
fn plain_spawn_still_panics_at_capacity() {
// The existing invariant-check semantics of `spawn` are untouched: at a
// full slab it panics, the panic is caught at the actor isolation
// boundary, and it surfaces as a join error — exactly as before. The
// bomb actor is spawned into the LAST slot (so the slab is full only
// once the bomb itself is live) and the panic lands inside the bomb,
// not the initial actor.
const MAX: usize = 6;
smarm::runtime::init(Config::exact(1).max_actors(MAX)).run(|| {
let release = Arc::new(AtomicBool::new(false));
let mut held = Vec::new();
for _ in 0..MAX - 2 {
held.push(spawn(holder(release.clone())));
}
let armed = Arc::new(AtomicBool::new(false));
let armed2 = armed.clone();
let bomb = spawn(move || {
armed2.store(true, Ordering::Release);
// Slab is now full (initial + MAX−2 holders + this actor); the
// plain spawn must panic this actor.
let _ = spawn(|| ());
unreachable!("allocate_slot must have panicked");
});
let err = bomb
.join()
.expect_err("bomb must die by panic, not run through");
assert!(armed.load(Ordering::Acquire), "bomb must have actually run");
// The panic message is a formatted String (panic! with args).
let msg = err
.payload
.downcast_ref::<String>()
.cloned()
.unwrap_or_else(|| "<non-string payload>".into());
assert!(
msg.contains("slot table exhausted"),
"panic must be the slab-exhaustion invariant message, got: {msg}"
);
release.store(true, Ordering::Release);
for h in held {
h.join().unwrap();
}
});
}
#[test]
fn racing_try_spawns_claim_exactly_the_free_slots() {
// 4 scheduler threads, 4 spawner actors hammering try_spawn for a small
// pool of remaining slots. Claim-or-report must hand out exactly the
// free slots across all racers — no overshoot (TOCTOU), no panic.
const MAX: usize = 32;
const SPAWNERS: usize = 4;
smarm::runtime::init(Config::exact(4).max_actors(MAX)).run(|| {
let release = Arc::new(AtomicBool::new(false));
let won = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicUsize::new(0));
// Occupy some slots up front so the racers fight over a remainder.
let mut pre = Vec::new();
for _ in 0..8 {
pre.push(spawn(holder(release.clone())));
}
// Free slots now: MAX − 1 (initial) − 8 (pre) − SPAWNERS.
let up_for_grabs = MAX - 1 - 8 - SPAWNERS;
let mut spawners = Vec::new();
for _ in 0..SPAWNERS {
let release = release.clone();
let won = won.clone();
let done = done.clone();
spawners.push(spawn(move || {
loop {
match try_spawn(holder(release.clone())) {
Ok(h) => {
won.fetch_add(1, Ordering::AcqRel);
drop(h); // detached; slot held by the holder
}
Err(SpawnError::AtCapacity) => break,
Err(_) => unreachable!("non_exhaustive future-proofing"),
}
}
done.fetch_add(1, Ordering::AcqRel);
}));
}
// Wait for every racer to hit AtCapacity.
while done.load(Ordering::Acquire) < SPAWNERS {
yield_now();
}
assert_eq!(won.load(Ordering::Acquire), up_for_grabs);
release.store(true, Ordering::Release);
for h in pre.into_iter().chain(spawners) {
h.join().unwrap();
}
});
}
#[test]
fn spawn_error_is_a_real_error() {
let e = SpawnError::AtCapacity;
let msg = format!("{e}");
assert!(
msg.contains("capacity"),
"Display should name the condition: {msg}"
);
let _: &dyn std::error::Error = &e;
}