13 Commits
Author SHA1 Message Date
smarm 8f2d513940 README: rewrite overview, add limitations, roadmap, and contribution notes
Expands the intro into an overview/limitations structure, documents
preemption, tracing/causal profiling, URUS, and adds a 'Coming up' and
'A note on open source' section.
2026-08-18 00:25:25 +02:00
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
78 changed files with 4562 additions and 810 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "smarm"
version = "0.5.0"
version = "0.6.1"
edition = "2021"
rust-version = "1.95"
@@ -39,6 +39,9 @@ rq-mutex = []
rq-mpmc = []
rq-striped = []
[build-dependencies]
cc = "1"
[dependencies]
libc = "0.2"
+46 -43
View File
@@ -1,35 +1,38 @@
# smarm
> SMARM — Smarm, Marks Actor Runtime Machinery. A proof-of-concept green-thread actor runtime for Rust.
> SMARM: Smarm, Marks Actor Runtime Machinery. A proof-of-concept green-thread actor runtime for Rust.
Implements the core ideas in [`Achitecture.md`](.docs/Architecture.md): green-thread actors on a
shared heap, scheduled cooperatively, communicating only by `Send` messages.
Erlang's isolation model without Erlang's copying GC, Rust's zero-copy
ownership transfers without async's function colouring.
SMARM is my attempt to implement the erlang/OTP philosophy in the Rust programming language. This has yielded a fault-tolerant, fast, and scalable runtime. This runtime allows the creation of asynchronous applications in Rust without the function coloring associated with the async/await system. It encourages the writing of simple, synchronous code, and largely elides the need for lifetime annotations.
The scheduler is multi-threaded — one OS thread per available CPU, all drawing
from a shared run queue. The single-threaded `run()` entry point is kept as a
convenience wrapper around `runtime::init(Config::exact(1)).run(f)`.
## What's here
## Overview
SMARM implements green-thread actors on a shared heap, communicating only by `Send` messages. By sharing the heap, SMARM avoids the copying overhead of Erlang, which is safe to do due to Rust's borrow checker.
On top of the core runtime mechanics, SMARM also provides a library of primitives for making applications closely inspired by erlang/OTP. This includes generic servers (gen_servers), generic state machines (gen_statem), and supervision trees.
Supervision trees are the core primitive to allow your application to survive an unexpected panic. Supervisors are processes dedicated to monitoring other processes, which can restart these should they fail. This means that when set up properly an application may 'self-heal' when encountering unforeseen circumstances.
SMARM is not cooperatively scheduled; it uses preemption. This means a heavy task will not starve out other lighter tasks. Everything will make steady progress, which translates to very beneficial behaviour under (over)load: average latency goes up, but tail latency does not blow up.
To help diagnose these unforeseen circumstances, smarm may be compiled with its `tracing` feature, which emits a full trace using [Perfetto](https://perfetto.dev/).
Should you want to optimize your application, SMARM is unusually well poised to help. As the runtime functionally controls time, SMARM comes with a built in causal profiler, under the `causal` feature.
I also built a Phoenix-Framework inspired HTTP 1.1 library on top of SMARM called [URUS](https://git.kalsbeek.dev/Markk116/urus), which implements Pub/Sub, Channels, and basic amenities like Websockets and Server Sent Events.
## Limitations
This runtime requires naked assembly to function, and has thus far only been implemented for x86-64 assembly. It expects an operating system that supports virtual address space, and is therefore not (yet) suited for embedded targets. The IO implementation is currently based around the Linux kernel's `epoll` mechanism, meaning it requires a (GNU+)Linux distribution to run.
The preemption mechanism works by wrapping the memory allocator and checking how many CPU cycles you have used compared to your timeslice budget. This allows preemption to fire in most normal code, but tight zero-allocation loops do not get caught and require manual insertion of `check!()` if you want preemption to function.
This library is still in its early stages, and while I try my best with loom and tests, stable operation cannot be guaranteed. Therefore it is not (yet) recommended for production use.
At this moment, stack memory for each green thread is capped. Uncapping this may lead to performance benefits for deeply recursive algorithms that in a traditional async runtime might require pointer-chases through the heap. This is as yet unrealised.
At this stage, the codebase is largely LLM-generated, which is obvious if you start to read through the internals. While I did the design, and I keep the LLM under tight rein, the codebase is not in a state that I am very happy with. This also goes for the documentation.
| Module | What it does |
|--------------|------------------------------------------------------------------------|
| `stack` | `mmap`'d growable stack with guard page; SIGSEGV on overflow |
| `context` | `#[naked]` x86-64 context-switch shims, callee-saved regs only |
| `preempt` | Allocator-driven preemption; `check!()` macro for no-alloc loops |
| `pid` | `(index, generation)` PIDs; stale handles are detectable, not silent |
| `actor` | Trampoline + `catch_unwind` boundary at the actor entry point |
| `scheduler` | Run queue, slot table, spawn/join, parking, idle path |
| `channel` | Unbounded MPSC channel; `recv` parks the actor; `recv_timeout` bounds it; `select`/`select_timeout` park on many receivers at once (ready-index, priority order) |
| `mutex` | `Mutex<T>` with mandatory timeout; FIFO waiters; parks the green thread |
| `timer` | Min-heap of `(deadline, reason)`; `Sleep` and `WaitTimeout` reasons |
| `io` | `block_on_io` for blocking work; `wait_readable`/`wait_writable` + `read`/`write` via epoll |
| `supervisor` | `Signal::Exit`/`Panic`/`Stopped` funnelled to a parent; `OneForOne`/`OneForAll`/`RestForOne` strategies + restart-intensity cap |
| `monitor` | `monitor(pid)` → `Monitor { id, target, rx }`; one-shot `Down` via `rx`; `demonitor(&m)` tears one registration down; unidirectional death notice |
| `link` | bidirectional `link`/`unlink`; abnormal death propagates (cooperative stop, or an `ExitSignal` message under `trap_exit`) |
| `gen_server` | `call`/`call_timeout` (sync request-reply) / `cast` (async) over one inbox; `handle_info` over static info arms + `handle_down` via `Watcher`-fed monitors, selected ahead of the inbox; `ServerRef`/`ServerBuilder` + `init`/`terminate` hooks; server-down via channel closure |
| `registry` | `register`/`whereis`/`name_of`: name ↔ pid bimap; lazy generation-checked cleanup |
## Quick taste
@@ -67,12 +70,9 @@ benches/
## Building and running
Standard Cargo. Requires Rust 1.95 or newer (the `#[naked]` attribute went stable
in 1.88; we use a few unrelated post-1.88 features). `master` is x86-64 Linux
only. An experimental, **untested** aarch64 context-switch backend lives on the
`arm-port` branch (extracted into a `target_arch`-gated `src/arch/`); it has not
been validated on hardware yet. macOS remains on the deferred list because of the
epoll dependency.
Standard Cargo. Requires Rust 1.95 or newer (the `#[naked]` attribute went stable in 1.88; we use a few unrelated post-1.88 features). I have worked hard to keep this library as dependency-free as possible. `master` is x86-64 Linux only. An experimental, **untested** aarch64 context-switch backend lives on the `arm-port` branch (extracted into a `target_arch`-gated `src/arch/`); it has not been validated on hardware yet. macOS remains on the deferred list because of the epoll dependency.
```sh
cargo test # all tests
@@ -80,26 +80,29 @@ cargo test --test mutex # one module
cargo bench # primes benchmark vs tokio
```
## What's not here
See the **Defer** section of `Architecture.md`.
`join!` for handle groups, stack growth via remap,
hierarchical timer wheel, fd-wait timeouts, `Signal::Timeout`. Each is
mechanism we know how to add; none belongs in this iteration.
## Docs
| Document | What it covers |
|---|---|
| [`Architecture.md`](./docs/Architecture.md) | Design intent, runtime model, and deferred work |
| [`smarm - Deep Dive.html`](./docs/smarm%20-%20Deep%20Dive.html) | Generated walkthrough of the system; good starting point |
| [`smarm - Deep Dive.html`](./docs/smarm%20-%20Deep%20Dive.html) | Generated walkthrough of the system; good starting point if you want to learn about the internals |
| [`BENCHMARKS_AND_TUNING.md`](./docs/BENCHMARKS_AND_TUNING.md) | Where smarm wins and loses vs tokio, preemption knob recommendations |
| [`benchmarks.md`](./docs/benchmarks.md) | Raw benchmark results, methodology, and tuning experiment log |
## Coming up
Clustering: clustering multiple SMARM nodes together is in the pipeline.
SMARM-BEAM Interop: Running SMARM as a supervised node under the BEAM via a Rustler NIF works, including message passing and supervision trees that span the runtimes. However, this library is still too unstable to release.
SMARM is an interesting platform for implementing a 'dataflow' library, but work on this has not yet started.
## Contributing
This is a personal proof-of-concept. There's no PR workflow. If you fork it and do something interesting, just send me an email. If it's nice, I'll upstream the changes.
This started as a personal proof-of-concept, but it is starting to outgrow that name. If you want to contribute, please get in contact to discuss what you want to work on. Code without prior communication is not welcome.
## A note on open source
An open source project is a gift, and by giving it, it is no longer mine. I highly enourage you to fork it, to make it your own. This repository, however, is still mine.
---
+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.
+3 -1
View File
@@ -67,7 +67,9 @@ fn main() {
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);
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)");
+8 -8
View File
@@ -35,8 +35,8 @@
#![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, Step, GenStatemRef};
// === user types ============================================================
@@ -123,7 +123,11 @@ impl DoorSm {
fn start(init: Door) -> GenStatemRef<DoorSm> {
spawn(DoorSm {
state: init,
data: Data { enters: 0, pushes: 0, knocks: 0 },
data: Data {
enters: 0,
pushes: 0,
knocks: 0,
},
})
}
@@ -193,16 +197,12 @@ impl Machine for DoorSm {
(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::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
}
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => Resolution::Unhandled,
// --- state-independent queries (reply, then stay) ---------------
(_, Ev::Call(Call::GetState(r))) => {
+9 -2
View File
@@ -18,8 +18,8 @@
// 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_expanded.rs) =========================
@@ -135,7 +135,14 @@ gen_statem! {
fn main() {
run(|| {
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0, knocks: 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)
+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(",")
},
);
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ pub extern "C-unwind" fn trampoline() {
};
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
Ok(()) => Outcome::Exit,
Ok(()) => Outcome::Exit,
Err(payload) => {
if payload.is::<StopSentinel>() {
Outcome::Stopped
+20 -10
View File
@@ -342,8 +342,7 @@ mod inner {
// 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);
DISCARD_OVERMAX_CYCLES.fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed);
return;
}
let delta = interval.saturating_mul(pct) / 100;
@@ -419,8 +418,7 @@ mod inner {
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_CYCLES.fetch_add(gap.saturating_mul(pct) / 100, Ordering::Relaxed);
OFFCPU_IN_SITE_N.fetch_add(1, Ordering::Relaxed);
}
}
@@ -533,7 +531,9 @@ mod inner {
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_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
@@ -542,12 +542,18 @@ mod inner {
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),
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),
offcpu_in_site_n: self
.offcpu_in_site_n
.saturating_sub(before.offcpu_in_site_n),
}
}
}
@@ -795,7 +801,9 @@ mod inner {
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 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 {
@@ -946,7 +954,9 @@ macro_rules! progress {
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)))
$crate::causal::SiteGuard::enter(
*__SMARM_SITE.get_or_init(|| $crate::causal::site_id($name)),
)
}};
}
+51 -27
View File
@@ -104,7 +104,12 @@ 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> {
@@ -178,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(),
}
}
}
@@ -248,10 +255,18 @@ 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(())
}
@@ -290,10 +305,12 @@ impl<T> Receiver<T> {
// 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(match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}));
crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}
));
}
}
@@ -347,10 +364,12 @@ impl<T> Receiver<T> {
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}));
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();
@@ -412,10 +431,12 @@ impl<T> Receiver<T> {
}
// Release the lock before parking: the unparker will need it.
crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}));
crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
}
));
}
}
@@ -616,7 +637,12 @@ pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
// 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();
@@ -687,11 +713,7 @@ impl Drop for UnregisterGuard<'_> {
// 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>> {
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,
@@ -736,10 +758,7 @@ impl crate::timer::TimerTarget for SelectTimeout {
/// 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> {
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!(
@@ -776,7 +795,12 @@ pub fn try_select_timeout(
// 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();
+26 -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
}
}
+102 -32
View File
@@ -178,11 +178,13 @@
//! 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;
@@ -273,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,
}
}
}
@@ -412,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`.
@@ -426,7 +433,10 @@ 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 a quiet-period window: if the loop goes `after` without dispatching
@@ -518,7 +528,10 @@ 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(),
}
}
}
@@ -569,8 +582,18 @@ 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 });
debug_assert!(reg.rearm_tx.is_some(), "rearm_tx must be Some while periodics is non-empty");
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
}
@@ -617,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(),
}
}
}
@@ -643,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
@@ -665,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.
@@ -677,7 +716,10 @@ 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(),
}
}
/// Private shared body behind [`start`](Self::start) and
@@ -686,12 +728,24 @@ impl<G: GenServer> GenServerBuilder<G> {
/// 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(),
}
}
}
@@ -719,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.
@@ -758,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.
@@ -907,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);
@@ -957,13 +1024,12 @@ fn server_loop<G: GenServer>(
// 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 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);
}
@@ -975,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)),
}
};
@@ -1008,8 +1072,12 @@ fn server_loop<G: GenServer>(
// live set tracks only still-pending timers, then
// dispatch.
match reg.lock() {
Ok(mut g) => { g.oneshots.remove(&id); }
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
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);
@@ -1023,7 +1091,9 @@ fn server_loop<G: GenServer>(
let msg = {
let mut g = match reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
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) {
@@ -1031,9 +1101,9 @@ fn server_loop<G: GenServer>(
let msg = (p.make)();
let tx = match r.rearm_tx.as_ref() {
Some(tx) => tx.clone(),
None => panic!(
"smarm: live periodic without rearm_tx (logic bug)"
),
None => {
panic!("smarm: live periodic without rearm_tx (logic bug)")
}
};
p.live = send_after_to(every, tx, Sys::Tick(id));
Some(msg)
+36 -8
View File
@@ -71,7 +71,7 @@
use crate::channel::{channel, select, Receiver, Sender};
use crate::pid::Pid;
use crate::scheduler::{cancel_timer, send_after_to, 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;
@@ -219,7 +219,11 @@ struct Timers {
impl Timers {
fn new() -> Self {
Timers { next_local: 0, state: None, named: HashMap::new() }
Timers {
next_local: 0,
state: None,
named: HashMap::new(),
}
}
fn mint(&mut self) -> u64 {
@@ -248,7 +252,11 @@ pub struct Cx<Ev> {
impl<Ev> Cx<Ev> {
fn new(sys_tx: Sender<Sys>, reg: Arc<Mutex<Timers>>) -> Self {
Cx { sys_tx, reg, _ev: PhantomData }
Cx {
sys_tx,
reg,
_ev: PhantomData,
}
}
/// Arm the **state timeout**: fire a `state_timeout` event after `after` in
@@ -387,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,
}
}
}
@@ -434,9 +445,22 @@ impl<M: Machine> GenStatemRef<M> {
///
/// Panics if called outside `Runtime::run()`.
pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
spawn_with(crate::scheduler::SpawnOpts::default(), machine)
}
/// [`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 = spawn_actor(move || statem_loop(rx, machine));
GenStatemRef { tx, pid: handle.pid() }
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
@@ -475,7 +499,9 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
Sys::StateTimeout(local) => {
let mut t = match reg.lock() {
Ok(g) => g,
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
Err(e) => panic!(
"smarm: gen_statem reg lock poisoned (core corrupt): {e}"
),
};
match t.state {
Some((live, _)) if live == local => {
@@ -488,7 +514,9 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
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}"),
Err(e) => panic!(
"smarm: gen_statem reg lock poisoned (core corrupt): {e}"
),
};
match t.named.get(name) {
Some(&(live, _)) if live == local => {
+70 -4
View File
@@ -179,6 +179,34 @@ pub struct ActorInfo {
/// `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,
}
/// 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.
@@ -211,7 +239,10 @@ pub fn snapshot() -> RuntimeSnapshot {
actors.push(info);
}
}
RuntimeSnapshot { format_version: SNAPSHOT_FORMAT_VERSION, actors }
RuntimeSnapshot {
format_version: SNAPSHOT_FORMAT_VERSION,
actors,
}
})
}
@@ -220,6 +251,22 @@ pub fn snapshot() -> RuntimeSnapshot {
/// 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())
})
}
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
with_runtime(|inner| {
let slot = inner.slot_at(pid)?;
@@ -265,6 +312,14 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
drop(cold);
// 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();
@@ -290,6 +345,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
overruns,
messages_received,
budget_cycles,
stack,
})
}
@@ -334,7 +390,10 @@ pub fn tree() -> RuntimeTree {
/// 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() {
@@ -365,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(
@@ -383,5 +445,9 @@ fn build_node(
.collect()
})
.unwrap_or_default();
Some(TreeNode { info, orphaned: orphaned[i], children })
Some(TreeNode {
info,
orphaned: orphaned[i],
children,
})
}
+4 -17
View File
@@ -136,7 +136,6 @@ pub struct IoThread {
waiters: Waiters,
// ----- Epoll machinery -----
/// The epollfd, owned by `IoThread`. Callable cross-thread via
/// `epoll_ctl` per the man page.
epollfd: RawFd,
@@ -147,7 +146,6 @@ pub struct IoThread {
shutdown_write: RawFd,
// ----- Threads -----
pool_thread: Option<OsJoinHandle<()>>,
epoll_thread: Option<OsJoinHandle<()>>,
}
@@ -284,9 +282,8 @@ 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());
}
@@ -398,12 +395,7 @@ fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
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 {
@@ -438,12 +430,7 @@ fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
let entry = w.remove(&fd);
if entry.is_some() {
unsafe {
libc::epoll_ctl(
epollfd,
libc::EPOLL_CTL_DEL,
fd,
std::ptr::null_mut(),
);
libc::epoll_ctl(epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
}
}
entry
+37 -30
View File
@@ -11,35 +11,36 @@
//!
//! 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;
pub mod causal;
// ---------------------------------------------------------------------------
// Global allocator
@@ -58,33 +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,
send_after_named_wall, send_after_wall, sleep, sleep_wall,
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable,
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle,
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;
+4 -1
View File
@@ -157,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),
}
+62 -1
View File
@@ -171,12 +171,73 @@ 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 }
}
/// 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.
///
/// 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
+29 -10
View File
@@ -158,7 +158,11 @@ impl TimerTarget for MutexCore {
if st.holder == Some(pid) {
return;
}
match st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch) {
match st
.waiters
.iter()
.position(|w| w.pid == pid && w.epoch == epoch)
{
Some(pos) => {
st.waiters.remove(pos);
true
@@ -246,7 +250,10 @@ impl<T> Mutex<T> {
Some(v) => v,
None => panic!("smarm: Mutex value missing on free fast path (core corrupt)"),
};
return Ok(MutexGuard { mutex: self, value: Some(value) });
return Ok(MutexGuard {
mutex: self,
value: Some(value),
});
}
}
@@ -287,7 +294,10 @@ impl<T> Mutex<T> {
Some(v) => v,
None => panic!("smarm: Mutex value missing after grant (core corrupt)"),
};
Ok(MutexGuard { mutex: self, value: Some(value) })
Ok(MutexGuard {
mutex: self,
value: Some(value),
})
} else {
Err(LockTimeout)
}
@@ -315,7 +325,10 @@ impl<T> Mutex<T> {
Some(v) => v,
None => panic!("smarm: Mutex value missing on try_lock free path (core corrupt)"),
};
Some(MutexGuard { mutex: self, value: Some(value) })
Some(MutexGuard {
mutex: self,
value: Some(value),
})
}
/// Blocking fallback used when called outside the smarm runtime.
@@ -329,10 +342,15 @@ impl<T> Mutex<T> {
Ok(mut g) => g.take(),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
};
if let Some(v) = v { break v; }
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),
})
}
}
@@ -342,7 +360,10 @@ impl<T> Clone for Mutex<T> {
/// 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(),
}
}
}
@@ -388,9 +409,7 @@ impl<T: std::fmt::Debug> std::fmt::Debug for MutexGuard<'_, T> {
Some(v) => v,
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
};
f.debug_tuple("MutexGuard")
.field(value)
.finish()
f.debug_tuple("MutexGuard").field(value).finish()
}
}
+38 -15
View File
@@ -105,7 +105,9 @@ mod parker {
impl Parker {
pub(super) fn new() -> Self {
Self { state: AtomicU32::new(EMPTY) }
Self {
state: AtomicU32::new(EMPTY),
}
}
/// Returns `true` = woken (permit consumed), `false` = timed out.
@@ -211,7 +213,10 @@ mod parker {
impl Parker {
pub(super) fn new() -> Self {
Self { permit: Mutex::new(false), cv: Condvar::new() }
Self {
permit: Mutex::new(false),
cv: Condvar::new(),
}
}
/// Returns `true` = woken (permit consumed), `false` = timed out.
@@ -395,12 +400,10 @@ impl Coordinator {
// The CAS is the exactly-one guarantee: whoever clears the bit
// owns the wake; a racing wake_one retries on the observed value
// (coherence: a failed CAS can never read older than `mask`).
match self.idle.compare_exchange(
mask,
mask & !bit,
Ordering::AcqRel,
Ordering::Acquire,
) {
match self
.idle
.compare_exchange(mask, mask & !bit, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => {
self.parkers[id].unpark();
return true;
@@ -467,7 +470,8 @@ impl Coordinator {
// with the deadline still NO_DEADLINE compares `new < MAX` = true
// and over-wakes — the benign direction. (Under the mandated timer
// serialization this interleaving cannot occur anyway.)
self.tk_armed.store(self.deadline_nanos(deadline), Ordering::SeqCst);
self.tk_armed
.store(self.deadline_nanos(deadline), Ordering::SeqCst);
true
}
@@ -578,7 +582,10 @@ mod tests {
let t0 = Instant::now();
let r = c.park(0, None, || false);
assert_eq!(r, ParkResult::Woken);
assert!(t0.elapsed() < Duration::from_millis(100), "park blocked despite permit");
assert!(
t0.elapsed() < Duration::from_millis(100),
"park blocked despite permit"
);
}
#[test]
@@ -632,14 +639,20 @@ mod tests {
// Wait until all four are published idle.
let t0 = Instant::now();
while c.idle_mask().count_ones() != N as u32 {
assert!(t0.elapsed() < Duration::from_secs(5), "threads never parked");
assert!(
t0.elapsed() < Duration::from_secs(5),
"threads never parked"
);
std::thread::yield_now();
}
assert!(c.wake_one());
// Exactly one wakes; give the others a beat to (incorrectly) wake.
let t0 = Instant::now();
while woken.load(O::SeqCst) == 0 {
assert!(t0.elapsed() < Duration::from_secs(5), "wake_one woke nobody");
assert!(
t0.elapsed() < Duration::from_secs(5),
"wake_one woke nobody"
);
std::thread::yield_now();
}
std::thread::sleep(Duration::from_millis(100));
@@ -706,7 +719,10 @@ mod tests {
assert_eq!(c.armed_deadline_nanos(), c.deadline_nanos(d2));
c.disarm_timer(0);
assert_eq!(c.armed_deadline_nanos(), NO_DEADLINE);
assert!(c.try_arm_timer(1, d1), "role must be re-takeable after disarm");
assert!(
c.try_arm_timer(1, d1),
"role must be re-takeable after disarm"
);
c.disarm_timer(1);
}
@@ -721,7 +737,10 @@ mod tests {
let t0 = Instant::now();
let r = c.park(0, Some(far), || false);
assert_eq!(r, ParkResult::Woken, "re-arm wake lost");
assert!(t0.elapsed() < Duration::from_secs(5), "slept toward the stale deadline");
assert!(
t0.elapsed() < Duration::from_secs(5),
"slept toward the stale deadline"
);
c.disarm_timer(0);
}
@@ -796,7 +815,11 @@ mod tests {
assert!(c.try_arm_timer(0, far));
c.note_deadline(near);
let r = c.park(0, Some(far), || false);
assert_eq!(r, ParkResult::Woken, "re-arm wake lost through note_deadline");
assert_eq!(
r,
ParkResult::Woken,
"re-arm wake lost through note_deadline"
);
c.disarm_timer(0);
}
}
+80 -17
View File
@@ -221,7 +221,9 @@ pub(crate) struct ProcessGroups {
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
@@ -323,20 +325,33 @@ impl ProcessGroups {
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,
@@ -367,7 +382,10 @@ pub fn join<A>(group: impl Into<String>, pid: Pid<A>) -> bool {
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);
@@ -507,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)
}
@@ -518,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)]);
}
@@ -542,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)]);
}
@@ -555,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.
@@ -582,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);
@@ -614,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");
}
@@ -646,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.
+7 -4
View File
@@ -98,10 +98,13 @@ pub(crate) fn clear_current_slot() {
CURRENT_SLOT.with(|c| c.set(std::ptr::null()));
}
/// RFC 007 (`smarm-causal`) — 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.
#[cfg(feature = "smarm-causal")]
/// 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())
+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]
+107 -15
View File
@@ -1,4 +1,3 @@
//! Give an actor a name so other actors can find it and message it.
//!
//! Without the registry, the only way to reach an actor is to already be
@@ -196,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"),
@@ -243,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
@@ -294,7 +298,10 @@ pub(crate) struct Registry {
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 holder's artifacts: every name bound to it, and its
@@ -303,7 +310,11 @@ impl Registry {
/// 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) {
if self
.by_index
.get(&holder.index())
.is_some_and(|mb| mb.pid == holder)
{
self.by_index.remove(&holder.index());
}
}
@@ -351,7 +362,11 @@ impl Registry {
.iter()
.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,
})
}
}
@@ -388,6 +403,16 @@ 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);
@@ -418,13 +443,19 @@ 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>(),
},
);
}
@@ -463,7 +494,10 @@ pub fn install<A: Addressable>(tx: Sender<A::Msg>) -> Pid<A> {
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);
});
}
@@ -486,6 +520,47 @@ pub fn whereis(name: &str) -> Option<Pid> {
})
}
/// What a name is bound to, three-valued (bridge soak signature 4).
///
/// [`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
@@ -522,7 +597,10 @@ pub(crate) fn resolve_named_sender<M: Send + 'static>(name: &str) -> Option<(Pid
}
// 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>)?;
let tx = reg
.by_index
.get(&pid.index())
.and_then(Mailbox::clone_sender::<M>)?;
Some((pid, tx))
})
}
@@ -535,7 +613,11 @@ pub fn unregister(name: &str) -> Option<Pid> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let pid = reg.by_name.remove(name)?;
if live(inner, pid) { Some(pid) } else { None }
if live(inner, pid) {
Some(pid)
} else {
None
}
})
}
@@ -567,12 +649,17 @@ pub fn send<M: Send + 'static>(name: Name<M>, msg: M) -> Result<(), SendError<M>
reg.prune_holder(pid);
return Err(SendError::Unresolved(msg));
}
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)),
}
};
tx.send(msg).map_err(|crate::channel::SendError(m)| SendError::Closed(m))
tx.send(msg)
.map_err(|crate::channel::SendError(m)| SendError::Closed(m))
})
}
@@ -596,7 +683,11 @@ fn send_to_pid<M: Send + 'static>(
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)),
}
@@ -611,7 +702,8 @@ 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` directly to the exact actor identified by `pid`. Unlike
+28 -5
View File
@@ -222,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
@@ -250,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;
@@ -464,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]
+413 -39
View File
@@ -65,6 +65,8 @@
//! word stores are `Release`, loads are `Acquire`. The chain that matters:
//! the park path stores `sp` (Relaxed) *before* its Release transition; any
//! later Acquire transition/load of the word therefore observes that `sp`.
//! RFC 019's `hwm` (and the shrink that reads it) piggybacks this exact
//! pattern in the same pre-Release window and adds no edges.
//! The run-queue mutex independently provides the same edges today; the
//! word's own ordering is what phase 3's lock-free queue will rely on.
//!
@@ -110,10 +112,11 @@
//! deadline, so an expiry wakes one scheduler, not a herd.
use crate::actor::{
clear_current_pid, is_actor_done, reset_actor_done, set_current_actor_box,
set_current_pid, take_last_outcome, Actor, Outcome,
clear_current_pid, is_actor_done, reset_actor_done, set_current_actor_box, set_current_pid,
take_last_outcome, Actor, Outcome,
};
use crate::channel::Sender;
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
use crate::io::IoThread;
use crate::monitor::{Down, DownReason, MonitorId};
use crate::pid::Pid;
@@ -122,11 +125,8 @@ use crate::raw_mutex::RawMutex;
use crate::slot_state::{StateWord, Status, Unpark};
use crate::supervisor::Signal;
use crate::timer::Timers;
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
use std::sync::atomic::{
AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering,
};
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
@@ -160,6 +160,8 @@ pub struct Config {
alloc_interval: u32,
timeslice_cycles: u64,
stack_pool_cap: usize,
stack_reserve: usize,
stack_guard: usize,
max_actors: usize,
wake_slot: bool,
node_id: crate::pg::NodeId,
@@ -171,10 +173,14 @@ impl Config {
pub fn exact(n: usize) -> Self {
assert!(n >= 1, "scheduler thread count must be ≥ 1");
Self {
min: n, max: n, exact: Some(n),
min: n,
max: n,
exact: Some(n),
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: n * 4,
stack_reserve: DEFAULT_STACK_RESERVE,
stack_guard: DEFAULT_STACK_GUARD,
max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false,
node_id: crate::pg::DEFAULT_NODE_ID,
@@ -190,10 +196,14 @@ impl Config {
assert!(e >= 1, "exact must be ≥ 1");
}
Self {
min, max, exact,
min,
max,
exact,
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: max * 4,
stack_reserve: DEFAULT_STACK_RESERVE,
stack_guard: DEFAULT_STACK_GUARD,
max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false,
node_id: crate::pg::DEFAULT_NODE_ID,
@@ -226,6 +236,31 @@ impl Config {
self
}
/// Default per-actor stack reserve (RFC 019). A *virtual* reservation —
/// anonymous mmap is demand-paged, so RSS follows touched pages, not
/// this number — but overflowing it hits the guard and dies. Page-rounded.
/// Per-actor override: `SpawnOpts::stack_reserve`.
/// Default: [`DEFAULT_STACK_RESERVE`] (64 KiB) — the million-cheap-actors
/// story is unchanged; big stacks are opt-in.
pub fn stack_reserve(mut self, n: usize) -> Self {
assert!(n > 0, "stack_reserve must be non-zero");
self.stack_reserve = n;
self
}
/// Default PROT_NONE guard below each stack (RFC 019). Address space
/// only. Page-rounded. Rust overflow is caught by any single page
/// (probestack touches pages in order); the wide default exists for
/// unprobed FFI frames, which can step over a small guard in one
/// `sub rsp`. Per-actor override: `SpawnOpts::guard_size`.
/// Default: [`DEFAULT_STACK_GUARD`] (1 MiB — the kernel's
/// `stack_guard_gap` convention; see its doc for why width is free).
pub fn stack_guard(mut self, n: usize) -> Self {
assert!(n > 0, "stack_guard must be non-zero");
self.stack_guard = n;
self
}
/// Capacity of the actor slot table — the maximum number of
/// **simultaneously live** actors (total spawned over a run is unbounded;
/// slots are recycled). The table is a fixed slab allocated once at
@@ -289,10 +324,14 @@ impl Default for Config {
.map(|n| n.get())
.unwrap_or(1);
Self {
min: 1, max: avail, exact: None,
min: 1,
max: avail,
exact: None,
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: avail * 4,
stack_reserve: DEFAULT_STACK_RESERVE,
stack_guard: DEFAULT_STACK_GUARD,
max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false,
node_id: crate::pg::DEFAULT_NODE_ID,
@@ -341,7 +380,9 @@ pub struct RuntimeStats {
impl RuntimeStats {
/// Sum of run queue lengths across all scheduler threads.
pub fn total_run_queue_len(&self) -> u64 {
self.inner.stats.iter()
self.inner
.stats
.iter()
.map(|s| s.run_queue_len.load(Ordering::Relaxed))
.sum()
}
@@ -365,7 +406,9 @@ impl RuntimeStats {
/// scheduler threads. Counters are reset at the start of each `run()`,
/// so after a run this reads that run's total.
pub fn slot_hits(&self) -> u64 {
self.inner.stats.iter()
self.inner
.stats
.iter()
.map(|s| s.slot_hits.load(Ordering::Relaxed))
.sum()
}
@@ -373,7 +416,9 @@ impl RuntimeStats {
/// RFC 005: total slot occupants displaced to the shared queue, summed
/// across scheduler threads. Reset at the start of each `run()`.
pub fn slot_displacements(&self) -> u64 {
self.inner.stats.iter()
self.inner
.stats
.iter()
.map(|s| s.slot_displacements.load(Ordering::Relaxed))
.sum()
}
@@ -383,7 +428,48 @@ impl RuntimeStats {
// Slot — packed state word + hot atomics + cold lifecycle data
// ---------------------------------------------------------------------------
pub(crate) const ACTOR_STACK_SIZE: usize = 64 * 1024;
/// Default usable stack reserve per actor (RFC 019). See [`Config::stack_reserve`].
pub const DEFAULT_STACK_RESERVE: usize = 64 * 1024;
/// Default PROT_NONE guard below each actor stack (RFC 019). Raised from one
/// page so unprobed C frames cannot leap it. See [`Config::stack_guard`].
///
/// 1 MiB, following the kernel's own answer to the same problem: after Stack
/// Clash (2017) the main-thread guard gap became `stack_guard_gap` = 256
/// pages, because 4 KiB was jumpable by one honest `sub rsp` and no small
/// constant was defensible. Guard pages are PROT_NONE: virtual address space
/// only — zero RSS, zero page-table entries, no overcommit charge — so the
/// wide default is free at any actor count (1 M actors ≈ 1 TiB of VA against
/// a 128 TiB budget). A frame that jumps even this lands in the tier-2
/// overshoot window of the SIGSEGV diagnostic (`signal.rs`) instead of
/// silence.
pub const DEFAULT_STACK_GUARD: usize = 1024 * 1024;
/// RFC 019 §3: minimum releasable span (`sp − hwm` at park) before the
/// park-path shrink spends a syscall. A constant, not a `Config` field
/// (ratified): nobody tunes this well and the measured stakes are low — a
/// threshold-sized `MADV_FREE` costs ~3 µs against a ~100 ns park, paid
/// only on spike-recovery parks, which are rare by construction and *were*
/// the spike. Steady-state actors never reach the syscall: their check is
/// two Relaxed loads and a compare on a line the context-save just wrote.
pub const SHRINK_THRESHOLD: usize = 256 * 1024;
/// RFC 019 §3: parks between shrinks of one actor. Guards a few-µs cost, so
/// it can be coarse (parks, not wall time); the kernel's
/// reclaim-under-pressure-only handling of `MADV_FREE` is the real release
/// hysteresis — re-touched-before-pressure pages cost a 0.24 µs/page
/// cancel-write and no fault. A constant, not `Config` (ratified, same
/// rationale as [`SHRINK_THRESHOLD`]).
pub const SHRINK_COOLDOWN: u32 = 64;
/// RFC 019 §6: the entry-end span (highest addresses — the frames the next
/// actor faults first) a recycled stack keeps resident; everything below it
/// is `MADV_DONTNEED`ed before the stack re-enters the pool. Ratified as a
/// constant, not Config, alongside the shrink knobs; the 64 KiB value was a
/// flagged Claude-solo call at ratification — it equals the default reserve,
/// so with an unraised Config the zap is a no-op and only Configs that raise
/// the default reserve pay it.
pub const RECYCLE_RETAIN: usize = 64 * 1024;
pub(crate) type Closure = Box<dyn FnOnce() + Send>;
@@ -396,6 +482,28 @@ pub(crate) struct SlotCold {
/// epoch-matched unpark.
pub(crate) waiters: Vec<(Pid, u32)>,
pub(crate) outcome: Option<Outcome>,
/// The slot's most recent *watchable-tenancy* death: `(generation,
/// reason)`, stamped by `finalize_actor` — but only for a tenancy whose
/// `watchable` bit was set — and deliberately never cleared: a new
/// tenant's install leaves it standing (it describes the previous
/// tenancy), and only the next *watchable* death overwrites it.
/// Anonymous green-thread churn must not evict it: the free list is
/// LIFO, so the just-freed slot is the first recycled, and an
/// unconditional stamp made a watchable tenancy's record the
/// shortest-lived data in the runtime. Read generation-matched via
/// [`terminal_reason`](crate::monitor::terminal_reason), so a watch that
/// raced its target's death can recover the real down reason instead of
/// a blanket `NoProc` (bridge soak signatures 4 and 5).
pub(crate) terminal: Option<(u32, DownReason)>,
/// Stamp eligibility for `terminal` above: someone could plausibly hold
/// a watch on this tenancy. Two set-sites, both while the tenancy is
/// live: `register_with` *before* the binding lands (no successfully
/// registered actor can die unflagged; a failed register's overshoot is
/// harmless), and [`mark_watchable`](crate::monitor::mark_watchable) —
/// the bridge calls it wherever a pid is encoded across the boundary,
/// because BEAM can only watch pids it holds and can only hold pids
/// that crossed. Reset at reclaim.
pub(crate) watchable: bool,
pub(crate) supervisor_channel: Option<Sender<Signal>>,
/// Watchers registered via `monitor()`, each tagged with its
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
@@ -426,6 +534,35 @@ pub(crate) struct Slot {
/// Release transition out of Running; read after the Acquire transition
/// Queued→Running. Relaxed is sufficient — ordering rides on `word`.
sp: AtomicUsize,
/// RFC 019: sampled stack high-water — the minimum `sp` ever stored above,
/// i.e. the deepest excursion *observed at a switch point*. Advisory:
/// correctness never depends on it; its one job is "is a shrink worth a
/// syscall?". Declared adjacent to `sp` so the min-update dirties the
/// line the context-save just wrote. Same single-writer Relaxed
/// discipline as `sp`; reset to the fresh `sp` at install.
hwm: AtomicUsize,
/// RFC 019: parks since the last shrink (or install). Counted on every
/// pass through the Park arm by the owning scheduler thread; the shrink
/// fires only once this clears [`SHRINK_COOLDOWN`] *and* the releasable
/// span clears [`SHRINK_THRESHOLD`]. Single-writer Relaxed.
parks_since_shrink: AtomicU32,
/// RFC 019: shrinks performed on this incarnation (introspection lands
/// with the RFC's introspect surface; the counter exists from birth so
/// tests can rely on install resetting it). Single-writer Relaxed.
shrink_count: AtomicU32,
/// RFC 019 §7 — stack geometry for the SIGSEGV classifier, readable
/// without the cold lock (the `Stack` itself lives under it). Written in
/// `install_actor` before the Release publish; consulted by the handler
/// only while `preempt::CURRENT_SLOT` points here, i.e. while this actor
/// is on-CPU, so the values are never stale where they are read. 0 =
/// never installed. Usable top of the stack.
pub(crate) diag_stack_top: AtomicUsize,
/// See `diag_stack_top`: the reserve (usable) size.
pub(crate) diag_stack_reserve: AtomicUsize,
/// See `diag_stack_top`: the guard size.
pub(crate) diag_stack_guard: AtomicUsize,
/// See `diag_stack_top`: `(idx << 32) | generation`, for the message.
pub(crate) diag_pid: AtomicU64,
/// Pointer into the actor's `Arc<AtomicBool>` stop flag. Set at spawn,
/// nulled at finalize. The box outlives every read: it is only ever read
/// on the resume path while the actor cannot be finalized (it is on-CPU).
@@ -497,6 +634,13 @@ impl Slot {
Self {
word: StateWord::new(),
sp: AtomicUsize::new(0),
hwm: AtomicUsize::new(0),
parks_since_shrink: AtomicU32::new(0),
shrink_count: AtomicU32::new(0),
diag_stack_top: AtomicUsize::new(0),
diag_stack_reserve: AtomicUsize::new(0),
diag_stack_guard: AtomicUsize::new(0),
diag_pid: AtomicU64::new(0),
stop_ptr: AtomicPtr::new(std::ptr::null_mut()),
closure: AtomicPtr::new(std::ptr::null_mut()),
overruns: AtomicU64::new(0),
@@ -514,6 +658,8 @@ impl Slot {
actor: None,
waiters: Vec::new(),
outcome: None,
terminal: None,
watchable: false,
supervisor_channel: None,
monitors: Vec::new(),
links: Vec::new(),
@@ -550,6 +696,23 @@ impl Slot {
/// Read the overrun tally (Relaxed; the snapshot reads cross-thread).
#[inline]
/// RFC 019 §8 — the stack introspection tuple, all lock-free:
/// `(reserve, guard, top, hwm, parks_since_shrink, shrink_count)`.
/// Geometry from the c6 diag atomics (install-time, gen-coherent under
/// `read_slot`'s gen check exactly like the other counters); `hwm` is the
/// §2 sampled high-water (lowest saved sp). All zeros before first
/// install.
pub(crate) fn stack_introspect(&self) -> (usize, usize, usize, usize, u32, u32) {
(
self.diag_stack_reserve.load(Ordering::Relaxed),
self.diag_stack_guard.load(Ordering::Relaxed),
self.diag_stack_top.load(Ordering::Relaxed),
self.hwm.load(Ordering::Relaxed),
self.parks_since_shrink.load(Ordering::Relaxed),
self.shrink_count.load(Ordering::Relaxed),
)
}
pub(crate) fn overruns(&self) -> u64 {
self.overruns.load(Ordering::Relaxed)
}
@@ -560,7 +723,8 @@ impl Slot {
#[inline]
pub(crate) fn record_message(&self) {
let v = self.messages_received.load(Ordering::Relaxed);
self.messages_received.store(v.wrapping_add(1), Ordering::Relaxed);
self.messages_received
.store(v.wrapping_add(1), Ordering::Relaxed);
}
/// Read the received-message tally (Relaxed; cross-thread snapshot read).
@@ -579,7 +743,8 @@ impl Slot {
#[inline]
pub(crate) fn add_budget(&self, cycles: u64) {
let v = self.budget_cycles.load(Ordering::Relaxed);
self.budget_cycles.store(v.wrapping_add(cycles), Ordering::Relaxed);
self.budget_cycles
.store(v.wrapping_add(cycles), Ordering::Relaxed);
}
/// Read the accumulated budget cycles (Relaxed). Always 0 unless the
@@ -718,7 +883,6 @@ impl Slot {
Some(*unsafe { Box::from_raw(raw) })
}
}
}
// ---------------------------------------------------------------------------
@@ -802,17 +966,23 @@ pub(crate) struct RuntimeInner {
pub(crate) stack_pool: RawMutex<Vec<crate::stack::Stack>>,
/// Maximum number of stacks to retain in the pool.
pub(crate) stack_pool_cap: usize,
/// Default stack shape (RFC 019), pre-page-rounded so it compares exactly
/// against `Stack::shape()`. Only stacks of exactly this shape are pooled.
pub(crate) stack_reserve: usize,
pub(crate) stack_guard: usize,
}
impl RuntimeInner {
// Private constructor taking the parsed Config fields one-for-one; a params
// struct would only move the same 8 values across the call boundary.
// struct would only move the same 10 values across the call boundary.
#[allow(clippy::too_many_arguments)]
fn new(
thread_count: usize,
alloc_interval: u32,
timeslice_cycles: u64,
stack_pool_cap: usize,
stack_reserve: usize,
stack_guard: usize,
max_actors: usize,
wake_slot: bool,
node_id: crate::pg::NodeId,
@@ -854,6 +1024,8 @@ impl RuntimeInner {
process_groups: RawMutex::new(crate::pg::ProcessGroups::new()),
stack_pool: RawMutex::new(Vec::new()),
stack_pool_cap,
stack_reserve: crate::stack::round_to_pages(stack_reserve),
stack_guard: crate::stack::round_to_pages(stack_guard),
})
}
@@ -1016,10 +1188,29 @@ impl RuntimeInner {
MonitorId(self.next_monitor_id.fetch_add(1, Ordering::Relaxed) + 1)
}
/// Pop a vacant slot index, or `None` when the slab is full. The claim
/// is atomic — a single pop under the free-list lock — so callers get
/// claim-or-report semantics with no check-then-spawn TOCTOU: whoever
/// gets `Some` owns that slot, full stop.
pub(crate) fn try_allocate_slot(&self) -> Option<u32> {
self.free.lock().pop()
}
/// Return a slot claimed by [`try_allocate_slot`](Self::try_allocate_slot)
/// that never had an actor installed into it (e.g. stack allocation
/// panicked between claim and install). NOT for dead actors — their
/// slots go back through `reclaim_slot`, which handles generation bump,
/// waiter/monitor/link teardown, and stack recycling.
pub(crate) fn return_vacant_slot(&self, idx: u32) {
self.free.lock().push(idx);
}
/// Pop a vacant slot index, or die loudly. The fixed slab is a deliberate
/// v0.5 simplification (ROADMAP: "Deferred"); the panic names the fix.
/// Callers that can shed load instead use [`try_allocate_slot`]
/// (Self::try_allocate_slot) via `scheduler::try_spawn`.
pub(crate) fn allocate_slot(&self) -> u32 {
match self.free.lock().pop() {
match self.try_allocate_slot() {
Some(idx) => idx,
None => panic!(
"smarm: actor slot table exhausted — {} actors are live \
@@ -1045,6 +1236,9 @@ pub struct Runtime {
/// Initialise the runtime with the given config. Returns a reusable handle.
pub fn init(config: Config) -> Runtime {
// RFC 019 §7: one process-global SIGSEGV handler, installed before any
// scheduler thread (and so before any classifiable fault) can exist.
crate::signal::install_once();
let n = config.resolved_thread_count();
Runtime {
inner: RuntimeInner::new(
@@ -1052,6 +1246,8 @@ pub fn init(config: Config) -> Runtime {
config.alloc_interval,
config.timeslice_cycles,
config.stack_pool_cap,
config.stack_reserve,
config.stack_guard,
config.max_actors,
config.wake_slot,
config.node_id,
@@ -1104,11 +1300,13 @@ impl Runtime {
// Re-initialise shared state for this run.
assert_eq!(
self.inner.run_queue.len(), 0,
self.inner.run_queue.len(),
0,
"run() called while previous run still active"
);
debug_assert_eq!(
self.inner.live_actors.load(Ordering::Acquire), 0,
self.inner.live_actors.load(Ordering::Acquire),
0,
"run() called while previous run still active"
);
// RFC 018: the IO producers reach the runtime (slot table + unpark)
@@ -1255,7 +1453,9 @@ impl Runtime {
/// Snapshot of runtime statistics for introspection / tests.
pub fn stats(&self) -> RuntimeStats {
RuntimeStats { inner: self.inner.clone() }
RuntimeStats {
inner: self.inner.clone(),
}
}
}
@@ -1290,7 +1490,10 @@ thread_local! {
}
#[derive(Copy, Clone)]
pub(crate) enum YieldIntent { Yield, Park }
pub(crate) enum YieldIntent {
Yield,
Park,
}
pub(crate) fn set_yield_intent(i: YieldIntent) {
YIELD_INTENT.with(|c| c.set(i));
@@ -1309,6 +1512,102 @@ pub const ROOT_PID: Pid = Pid::new(u32::MAX, u32::MAX);
// Spawn-side slot installation
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Stack shrink — RFC 019 §3 (park path only)
// ---------------------------------------------------------------------------
/// The per-park shrink check. Called from the `YieldIntent::Park` arm inside
/// the owned window (see the assert-comment at the call site). Fast path —
/// no spike since the last shrink — is two Relaxed loads, a compare, and the
/// park counter bump, all on the slot line the context-save just wrote.
///
/// On a shrink: `MADV_FREE` the whole pages of `[hwm, sp − redzone)` (the
/// inward-rounded range from [`crate::stack::shrink_range`]), then reset
/// `hwm = sp` and the park counter. MADV_FREE only *marks*: the kernel
/// reclaims under pressure, skips re-dirtied pages, and refaults zero pages
/// for writes after reclaim — so an over-eager mark costs a cancel-write,
/// never data.
fn maybe_shrink_stack(slot: &Slot) {
let parks = slot
.parks_since_shrink
.load(Ordering::Relaxed)
.saturating_add(1);
slot.parks_since_shrink.store(parks, Ordering::Relaxed);
let sp = slot.sp.load(Ordering::Relaxed);
let hwm = slot.hwm.load(Ordering::Relaxed);
if sp.wrapping_sub(hwm) < SHRINK_THRESHOLD || sp < hwm {
return; // common case: nothing worth a syscall
}
if parks < SHRINK_COOLDOWN {
return;
}
let page = crate::stack::page_size();
if let Some((addr, len)) = crate::stack::shrink_range(hwm, sp, page) {
// Advisory: on the (kernel-config) chance MADV_FREE is unsupported,
// failing silently degrades to "never shrinks", which is correct.
unsafe {
libc::madvise(addr as *mut libc::c_void, len, libc::MADV_FREE);
}
slot.hwm.store(sp, Ordering::Relaxed);
slot.parks_since_shrink.store(0, Ordering::Relaxed);
slot.shrink_count.store(
slot.shrink_count.load(Ordering::Relaxed).saturating_add(1),
Ordering::Relaxed,
);
}
}
// ---------------------------------------------------------------------------
// Stack acquisition / recycling — RFC 019 pool rule
// ---------------------------------------------------------------------------
/// Get a stack of the shape `opts` requests (`None` fields ⇒ the runtime
/// defaults).
///
/// Pool rule (RFC 019 §1): the pool is a uniform `Vec<Stack>` of
/// default-shaped stacks and stays that way. Default-shaped requests try the
/// pool first; custom shapes always mmap fresh (and `recycle_stack` never
/// admits them, so a pooled stack is default-shaped by induction). The pool
/// lock is dropped before any mmap: no syscall ever stalls another spawner.
pub(crate) fn acquire_stack(
inner: &RuntimeInner,
opts: crate::scheduler::SpawnOpts,
) -> crate::stack::Stack {
let reserve = opts.stack_reserve.unwrap_or(inner.stack_reserve);
let guard = opts.guard_size.unwrap_or(inner.stack_guard);
let default_shaped = crate::stack::round_to_pages(reserve) == inner.stack_reserve
&& crate::stack::round_to_pages(guard) == inner.stack_guard;
if default_shaped {
if let Some(stack) = inner.stack_pool.lock().pop() {
return stack;
}
}
match crate::stack::Stack::new(reserve, guard) {
Ok(stack) => stack,
Err(e) => panic!("stack allocation failed: {e}"),
}
}
/// Return a dead actor's stack: pooled if default-shaped and under cap,
/// otherwise dropped here → munmap (custom shapes and cap overflow alike).
pub(crate) fn recycle_stack(inner: &RuntimeInner, stack: crate::stack::Stack) {
if stack.shape() == (inner.stack_reserve, inner.stack_guard) {
// RFC 019 §6: zap the dead spike before pooling, BEFORE taking the
// pool lock — acquire_stack's invariant is that no syscall ever
// stalls another spawner under it. On the rare cap-overflow the zap
// is wasted work ahead of the munmap; harmless, and cheaper than a
// second lock round-trip to find out.
stack.recycle_zap(RECYCLE_RETAIN);
let mut pool = inner.stack_pool.lock();
if pool.len() < inner.stack_pool_cap {
pool.push(stack);
}
// else: fall through — drop → munmap.
}
// Custom-shaped (or cap overflow): `stack` drops here → munmap.
}
/// Install a freshly spawned actor into the slot `idx` (which must have come
/// from `allocate_slot`) and publish it as Queued. Returns the new `Pid`.
/// Called by `scheduler::spawn_under`; lives here next to its inverse
@@ -1326,17 +1625,40 @@ pub(crate) fn install_actor(
let pid = Pid::new(idx, gen);
let stop = Arc::new(AtomicBool::new(false));
slot.stop_ptr.store(Arc::as_ptr(&stop) as *mut _, Ordering::Release);
// RFC 019 §7: geometry for the SIGSEGV classifier, captured before the
// Stack moves under the cold lock. Ordered before readers by the
// publish below.
let (diag_reserve, diag_guard) = stack.shape();
let diag_top = stack.top() as usize;
slot.stop_ptr
.store(Arc::as_ptr(&stop) as *mut _, Ordering::Release);
{
let mut cold = slot.cold.lock();
debug_assert!(cold.actor.is_none(), "install over live actor");
debug_assert!(cold.waiters.is_empty() && cold.monitors.is_empty() && cold.links.is_empty());
cold.actor = Some(Actor { pid, stack, supervisor, stop, trap: None });
cold.actor = Some(Actor {
pid,
stack,
supervisor,
stop,
trap: None,
});
cold.outstanding_handles = 1;
cold.outcome = None;
cold.pending_io_result = None;
}
slot.sp.store(sp, Ordering::Relaxed);
// RFC 019: a fresh incarnation starts with its high-water at the fresh
// top-of-stack `sp` and its shrink bookkeeping zeroed.
slot.hwm.store(sp, Ordering::Relaxed);
slot.parks_since_shrink.store(0, Ordering::Relaxed);
slot.shrink_count.store(0, Ordering::Relaxed);
slot.diag_stack_top.store(diag_top, Ordering::Relaxed);
slot.diag_stack_reserve
.store(diag_reserve, Ordering::Relaxed);
slot.diag_stack_guard.store(diag_guard, Ordering::Relaxed);
slot.diag_pid
.store(((idx as u64) << 32) | gen as u64, Ordering::Relaxed);
slot.store_closure(closure);
slot.reset_counters();
inner.live_actors.fetch_add(1, Ordering::Relaxed);
@@ -1345,7 +1667,10 @@ pub(crate) fn install_actor(
// Release store orders everything above before any Acquire reader.
slot.word.publish_queued(gen);
inner.enqueue(pid);
crate::te!(crate::trace::Event::Spawn { parent: supervisor, child: pid });
crate::te!(crate::trace::Event::Spawn {
parent: supervisor,
child: pid
});
pid
}
@@ -1362,14 +1687,19 @@ pub(crate) fn install_actor(
/// is released — a last-sender drop can unpark a receiver, which takes the
/// run-queue mutex; legal under a cold lock, but pointless to nest.
pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) {
let Some(slot) = inner.slot_at(pid) else { return };
let Some(slot) = inner.slot_at(pid) else {
return;
};
let dropped_outside;
{
let mut cold = slot.cold.lock();
if slot.status_for(pid) != Status::Done || cold.outstanding_handles != 0 {
return; // already reclaimed, or not yet eligible
}
debug_assert!(cold.actor.is_none(), "reclaiming a slot that still owns an actor");
debug_assert!(
cold.actor.is_none(),
"reclaiming a slot that still owns an actor"
);
dropped_outside = (
cold.outcome.take(),
cold.supervisor_channel.take(),
@@ -1379,6 +1709,7 @@ pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) {
cold.waiters.clear();
cold.monitors.clear();
cold.links.clear();
cold.watchable = false;
slot.reset_counters();
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
// The generation bump IS the reclaim: every stale pid is dead from
@@ -1418,6 +1749,18 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
None => panic!("finalize_actor: actor vanished"),
};
cold.outcome = Some(joiner_outcome);
// Terminal record (soak sig 4): stamped before the generation ever
// bumps, under the cold lock, so a reader that resolved this pid can
// recover the reason after the slot moves on — but only for a
// tenancy that ever held a name. The free list is LIFO, so the slot
// this death frees is the very next one recycled; if every green
// thread's exit stamped too, the churn behind any real workload
// would evict a watchable tenancy's record in well under the race
// window this exists to cover. Overwritten only by the slot's next
// *watchable* death.
if cold.watchable {
cold.terminal = Some((pid.generation(), down_reason));
}
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
// Done is published under the cold lock, so join's
// check-Done-or-register-waiter (also under it) can never miss: it
@@ -1437,13 +1780,7 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
// (the trap sender can unpark its receiver — keep that outside too).
let supervisor_pid = actor.supervisor;
let Actor { stack, .. } = actor;
{
let mut pool = inner.stack_pool.lock();
if pool.len() < inner.stack_pool_cap {
pool.push(stack);
}
// else: drop here → munmap, same as before
}
recycle_stack(inner, stack);
// Deliver to supervisor. ROOT_PID resolves to no slot → silently absorbed.
let sender = inner.slot_at(supervisor_pid).and_then(|sup| {
@@ -1461,7 +1798,10 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
// Notify monitors. Sent outside any slot lock: `send` may unpark a parked
// receiver, which takes the run-queue mutex.
for (_, m) in monitors {
let _ = m.send(Down { pid, reason: down_reason });
let _ = m.send(Down {
pid,
reason: down_reason,
});
}
// Walk linked peers ONE AT A TIME (cold locks are leaves). For every
@@ -1493,7 +1833,10 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
};
match trap {
Some(Some(tx)) => {
let _ = tx.send(crate::link::ExitSignal { from: pid, reason: down_reason });
let _ = tx.send(crate::link::ExitSignal {
from: pid,
reason: down_reason,
});
}
Some(None) => crate::scheduler::request_stop(peer),
None => {}
@@ -1594,6 +1937,8 @@ fn fire_due_timers(inner: &Arc<RuntimeInner>, try_only: bool) {
// ---------------------------------------------------------------------------
fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
// RFC 019 §7: a guard hit leaves no stack to handle the signal on.
crate::signal::register_altstack();
crate::preempt::configure_preempt(inner.alloc_interval, inner.timeslice_cycles);
let stats = &inner.stats[slot_idx];
@@ -1647,7 +1992,9 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
let io_out = inner.io_outstanding.load(Ordering::Acquire)
+ inner.io_fd_waiters.load(Ordering::Acquire);
stats.run_queue_len.store(inner.run_queue.len(), Ordering::Relaxed);
stats
.run_queue_len
.store(inner.run_queue.len(), Ordering::Relaxed);
let pop = match inner.run_queue.pop() {
Some(pid) => Pop::Got(pid),
None => {
@@ -1799,7 +2146,9 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
}
// Update per-thread stats: record who's on-CPU.
stats.current_pid_index.store(pid.index(), Ordering::Relaxed);
stats
.current_pid_index
.store(pid.index(), Ordering::Relaxed);
set_actor_sp(sp);
set_current_pid(pid);
@@ -1841,7 +2190,15 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
crate::preempt::clear_current_slot();
let intent = YIELD_INTENT.with(|c| c.get());
slot.sp.store(get_actor_sp(), Ordering::Relaxed);
let saved_sp = get_actor_sp();
slot.sp.store(saved_sp, Ordering::Relaxed);
// RFC 019 §2: sampled high-water — one branch + at most one store
// into the line the store above just dirtied. Relaxed and advisory;
// it piggybacks the existing Relaxed-store-before-Release pattern
// (mod docs, "Memory ordering") and adds no edges.
if saved_sp < slot.hwm.load(Ordering::Relaxed) {
slot.hwm.store(saved_sp, Ordering::Relaxed);
}
if is_actor_done() {
crate::te!(crate::trace::Event::Done(pid));
@@ -1863,6 +2220,23 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
inner.enqueue(pid);
}
YieldIntent::Park => {
// RFC 019 §3 shrink window (correctness obligation 1):
// this site sits after the `sp` store above and before
// the `park_return` Release transition below publishes
// Parked — the scheduler is on its own stack and the
// actor is saved but not yet stealable, so the madvise
// races nothing (belt). MADV_FREE's cancel-on-write is
// the suspenders: even a racing writer could lose
// nothing written after the mark, and everything below
// live `sp` is dead by definition. Runs on BOTH arms of
// the park_return race — a consumed unpark flag means a
// wasted-but-harmless madvise on a rare window.
//
// This is the ONLY shrink site: the preempt/yield path
// deliberately never checks (§4's bounded leak under
// saturation — syscalls must not fire when scheduler
// cycles are scarcest).
maybe_shrink_stack(slot);
if slot.word.park_return(gen) {
// RFC 007 audit: an in-site park drops its sample
// tail (nothing flushes it; on_resume re-arms).
+233 -84
View File
@@ -68,9 +68,7 @@
use crate::actor::current_pid;
use crate::channel::Sender;
use crate::pid::{Name, Pid};
use crate::runtime::{
self, RuntimeInner, YieldIntent, RUNTIME,
};
use crate::runtime::{self, RuntimeInner, YieldIntent, RUNTIME};
use crate::supervisor::Signal;
use std::sync::atomic::Ordering;
use std::sync::Arc;
@@ -152,7 +150,9 @@ pub struct JoinHandle {
impl JoinHandle {
/// The identity of the actor this handle refers to.
pub fn pid(&self) -> Pid { self.pid }
pub fn pid(&self) -> Pid {
self.pid
}
/// Block the calling actor until the spawned actor finishes, then
/// report how it finished: `Ok(())` if it returned normally or stopped
@@ -182,12 +182,10 @@ impl JoinHandle {
crate::slot_state::Status::Stale => {
panic!("join: target slot has been reused")
}
crate::slot_state::Status::Done => {
Some(match cold.outcome.take() {
Some(outcome) => outcome,
None => panic!("Done slot must have outcome"),
})
}
crate::slot_state::Status::Done => Some(match cold.outcome.take() {
Some(outcome) => outcome,
None => panic!("Done slot must have outcome"),
}),
crate::slot_state::Status::Live => {
// begin_wait is lock-free, legal under the cold lock;
// registering under it makes the epoch atomic with
@@ -227,8 +225,7 @@ impl JoinHandle {
match slot.status_for(self.pid) {
crate::slot_state::Status::Stale => false,
status => {
cold.outstanding_handles =
cold.outstanding_handles.saturating_sub(1);
cold.outstanding_handles = cold.outstanding_handles.saturating_sub(1);
cold.outstanding_handles == 0
&& status == crate::slot_state::Status::Done
}
@@ -259,6 +256,59 @@ impl Drop for JoinHandle {
// spawn / spawn_under / self_pid
// ---------------------------------------------------------------------------
/// Per-spawn stack shape overrides (RFC 019). `None` fields resolve to the
/// runtime's [`Config`](crate::runtime::Config) defaults at spawn time, so
/// struct-update syntax works anywhere without a runtime handle:
///
/// ```
/// use smarm::SpawnOpts;
/// let opts = SpawnOpts { stack_reserve: Some(8 * 1024 * 1024), ..SpawnOpts::default() };
/// ```
///
/// Both sizes are page-rounded. The reserve is *virtual* (demand-paged):
/// an 8 MiB reserve costs address space, not memory — RSS follows touched
/// pages. The guard is PROT_NONE below the stack; raise it for FFI code
/// with unusually large C frames. Custom-shaped stacks bypass the recycle
/// pool: they are mmapped fresh at spawn and munmapped at death.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SpawnOpts {
/// Usable stack reservation. `None` ⇒ [`Config::stack_reserve`](crate::runtime::Config::stack_reserve).
pub stack_reserve: Option<usize>,
/// PROT_NONE guard below the stack. `None` ⇒ [`Config::stack_guard`](crate::runtime::Config::stack_guard).
pub guard_size: Option<usize>,
}
/// Why [`try_spawn`] could not start an actor.
///
/// Marked `non_exhaustive`: today the only refusal is a full slab, but a
/// future variant (say, a shutdown-in-progress refusal) must not be a
/// breaking change for shed-path `match`es.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpawnError {
/// The fixed actor slab ([`Config::max_actors`]
/// (crate::runtime::Config::max_actors)) is full: every slot is claimed
/// by a live actor. This is a routine overload condition, not an
/// invariant violation — shed the unit of work (close the socket,
/// return a 503) and try again once actors have died.
AtCapacity,
}
impl core::fmt::Display for SpawnError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
SpawnError::AtCapacity => {
write!(
f,
"actor slab at capacity (`Config::max_actors` live actors)"
)
}
}
}
}
impl std::error::Error for SpawnError {}
/// Start a new actor running `f`, and return a [`JoinHandle`] for it.
///
/// The new actor runs concurrently with its caller and with every other
@@ -281,22 +331,32 @@ pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle {
spawn_under(parent, f)
}
/// [`spawn`] with per-actor stack shape overrides (RFC 019).
pub fn spawn_with(opts: SpawnOpts, f: impl FnOnce() + Send + 'static) -> JoinHandle {
let parent = current_pid().unwrap_or_else(|| with_runtime(|_| crate::runtime::ROOT_PID));
spawn_under_with(parent, opts, f)
}
/// Like [`spawn`], but explicitly attaches the new actor to `supervisor`
/// instead of the calling actor. Ordinary code should reach for [`spawn`];
/// this exists for supervision trees (see [`supervisor`](crate::supervisor))
/// and other cases that need to place a child under a specific ancestor
/// rather than its true caller.
pub fn spawn_under<A>(supervisor: Pid<A>, f: impl FnOnce() + Send + 'static) -> JoinHandle {
spawn_under_with(supervisor, SpawnOpts::default(), f)
}
/// [`spawn_under`] with per-actor stack shape overrides (RFC 019).
pub fn spawn_under_with<A>(
supervisor: Pid<A>,
opts: SpawnOpts,
f: impl FnOnce() + Send + 'static,
) -> JoinHandle {
let supervisor = supervisor.erase();
// Stack + closure boxing happen before ANY runtime lock is taken: no
// syscall and no allocation ever stalls another scheduler thread.
let stack = with_runtime(|inner| inner.stack_pool.lock().pop())
.unwrap_or_else(|| {
match crate::stack::Stack::new(crate::runtime::ACTOR_STACK_SIZE) {
Ok(stack) => stack,
Err(e) => panic!("stack allocation failed: {e}"),
}
});
// Stack + closure boxing happen before the slot locks are taken; the
// pool lock inside acquire_stack is dropped before any mmap, so no
// syscall ever stalls another scheduler thread.
let stack = with_runtime(|inner| crate::runtime::acquire_stack(inner, opts));
let sp = init_actor_stack(stack.top(), crate::actor::trampoline);
let closure: crate::runtime::Closure = Box::new(f);
@@ -305,7 +365,77 @@ pub fn spawn_under<A>(supervisor: Pid<A>, f: impl FnOnce() + Send + 'static) ->
crate::runtime::install_actor(inner, idx, sp, stack, supervisor, closure)
});
JoinHandle { pid, consumed: false }
JoinHandle {
pid,
consumed: false,
}
}
/// [`spawn`] that reports a full actor slab instead of panicking.
///
/// Behaviour parity with [`spawn`] in every case except one: when the fixed
/// slab ([`Config::max_actors`](crate::runtime::Config::max_actors)) is
/// full, this returns [`Err(SpawnError::AtCapacity)`](SpawnError::AtCapacity)
/// where `spawn` panics the calling actor. Use it at load-shedding call
/// sites — an accept loop spawning one actor per connection, a request
/// admission point — where "at capacity" is a routine overload condition to
/// handle (reject the unit of work), not an invariant violation. Internal
/// and bounded spawn sites should keep [`spawn`]: there, the panic is a
/// correct loud invariant check.
///
/// The claim is atomic (claim-or-report): there is no
/// check-then-spawn race against other spawners for the last slot, so no
/// headroom margin is needed.
pub fn try_spawn(f: impl FnOnce() + Send + 'static) -> Result<JoinHandle, SpawnError> {
let parent = current_pid().unwrap_or_else(|| with_runtime(|_| crate::runtime::ROOT_PID));
try_spawn_under_with(parent, SpawnOpts::default(), f)
}
/// [`try_spawn`] with an explicit supervisor and per-actor stack shape
/// overrides — the full-control core the other `try_` surface is built on
/// (mirrors [`spawn_under_with`]).
pub fn try_spawn_under_with<A>(
supervisor: Pid<A>,
opts: SpawnOpts,
f: impl FnOnce() + Send + 'static,
) -> Result<JoinHandle, SpawnError> {
let supervisor = supervisor.erase();
// Slot FIRST — deliberately the reverse of `spawn`'s stack-first order:
// under overload the Err arm is the HOT path, and a rejection must cost
// one mutex pop, not an mmap/pool-pop + init + recycle per shed unit of
// work. The claim is a single atomic pop (no TOCTOU; see
// `try_allocate_slot`).
let idx = match with_runtime(|inner| inner.try_allocate_slot()) {
Some(idx) => idx,
None => return Err(SpawnError::AtCapacity),
};
// Between claim and install the slot is owned by this frame alone; if
// stack allocation panics in that window the slot must go back or it
// leaks for the life of the runtime (and would trip the run()-teardown
// slot-leak debug_assert).
struct ReturnOnUnwind(Option<u32>);
impl Drop for ReturnOnUnwind {
fn drop(&mut self) {
if let Some(idx) = self.0 {
with_runtime(|inner| inner.return_vacant_slot(idx));
}
}
}
let mut claimed = ReturnOnUnwind(Some(idx));
let stack = with_runtime(|inner| crate::runtime::acquire_stack(inner, opts));
let sp = init_actor_stack(stack.top(), crate::actor::trampoline);
let closure: crate::runtime::Closure = Box::new(f);
claimed.0 = None; // install_actor takes ownership of the slot from here
let pid = with_runtime(|inner| {
crate::runtime::install_actor(inner, idx, sp, stack, supervisor, closure)
});
Ok(JoinHandle {
pid,
consumed: false,
})
}
/// Spawn an actor that other actors can message directly by its [`Pid<A>`],
@@ -336,6 +466,18 @@ pub fn spawn_addr<A: crate::pid::Addressable>(
crate::pid::assert_type::<A>(pid)
}
/// [`spawn_addr`] with per-actor stack shape overrides (RFC 019).
pub fn spawn_addr_with<A: crate::pid::Addressable>(
opts: SpawnOpts,
body: impl FnOnce(crate::channel::Receiver<A::Msg>) + Send + 'static,
) -> Pid<A> {
let (tx, rx) = crate::channel::channel::<A::Msg>();
let handle = spawn_with(opts, move || body(rx));
let pid = handle.pid();
crate::registry::install_for::<A::Msg>(pid, tx);
crate::pid::assert_type::<A>(pid)
}
use crate::context::init_actor_stack;
/// The identity of the actor currently running. Use it to hand your own
@@ -520,11 +662,9 @@ pub fn sleep(duration: std::time::Duration) {
let _np = NoPreempt::enter();
let epoch = begin_wait();
let deadline = crate::timer::deadline_from_now(duration);
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_sleep(deadline, me, epoch),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_sleep(deadline, me, epoch),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
});
park_current();
}
@@ -542,11 +682,9 @@ pub fn sleep_wall(duration: std::time::Duration) {
let _np = NoPreempt::enter();
let epoch = begin_wait();
let deadline = crate::timer::deadline_from_now(duration);
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_sleep_wall(deadline, me, epoch),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_sleep_wall(deadline, me, epoch),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
});
park_current();
}
@@ -562,15 +700,13 @@ pub fn insert_wait_timer(
target: std::sync::Arc<dyn crate::timer::TimerTarget>,
epoch: u32,
) {
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert(
deadline,
pid,
crate::timer::Reason::WaitTimeout { target, epoch },
),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert(
deadline,
pid,
crate::timer::Reason::WaitTimeout { target, epoch },
),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
});
}
@@ -600,11 +736,9 @@ pub fn send_after<A: crate::pid::Addressable>(
let fire = Box::new(move || {
let _ = crate::registry::send_to(dest, msg);
});
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, dest.erase(), fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, dest.erase(), fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
})
}
@@ -624,11 +758,9 @@ pub fn send_after_named<M: Send + 'static>(
let fire = Box::new(move || {
let _ = crate::registry::send(dest, msg);
});
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
})
}
@@ -647,11 +779,9 @@ pub fn send_after_wall<A: crate::pid::Addressable>(
let fire = Box::new(move || {
let _ = crate::registry::send_to(dest, msg);
});
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send_wall(deadline, dest.erase(), fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_send_wall(deadline, dest.erase(), fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
})
}
@@ -668,11 +798,9 @@ pub fn send_after_named_wall<M: Send + 'static>(
let fire = Box::new(move || {
let _ = crate::registry::send(dest, msg);
});
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send_wall(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_send_wall(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
})
}
@@ -692,11 +820,9 @@ pub(crate) fn send_after_to<T: Send + 'static>(
let fire = Box::new(move || {
let _ = tx.send(msg);
});
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
})
}
@@ -704,11 +830,9 @@ pub(crate) fn send_after_to<T: Send + 'static>(
/// it fires. Returns `true` if the timer was still pending and delivery is
/// now prevented, `false` if it had already fired or was already cancelled.
pub fn cancel_timer(id: crate::timer::TimerId) -> bool {
with_runtime(|inner| {
match inner.timers.lock() {
Ok(mut timers) => timers.cancel(id),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
with_runtime(|inner| match inner.timers.lock() {
Ok(mut timers) => timers.cancel(id),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
})
}
@@ -774,7 +898,8 @@ where
};
let mut cold = slot.cold.lock();
debug_assert_eq!(
slot.generation(), me.generation(),
slot.generation(),
me.generation(),
"block_on_io: own slot reused mid-park"
);
match cold.pending_io_result.take() {
@@ -900,12 +1025,20 @@ pub struct FdArm {
impl FdArm {
/// An arm that becomes ready when `fd` is readable.
pub fn readable(fd: std::os::fd::RawFd) -> Self {
FdArm { fd, readable: true, writable: false }
FdArm {
fd,
readable: true,
writable: false,
}
}
/// An arm that becomes ready when `fd` is writable.
pub fn writable(fd: std::os::fd::RawFd) -> Self {
FdArm { fd, readable: false, writable: true }
FdArm {
fd,
readable: false,
writable: true,
}
}
}
@@ -930,9 +1063,7 @@ impl crate::channel::Selectable for FdArm {
match io.as_mut() {
Some(io) => {
inner.io_fd_waiters.fetch_add(1, Ordering::AcqRel);
let r = io.epoll_register(
self.fd, pid, epoch, self.readable, self.writable,
);
let r = io.epoll_register(self.fd, pid, epoch, self.readable, self.writable);
if r.is_err() {
inner.io_fd_waiters.fetch_sub(1, Ordering::AcqRel);
}
@@ -987,7 +1118,11 @@ fn poll_events(fd: std::os::fd::RawFd, readable: bool, writable: bool) -> std::i
if writable {
events |= libc::POLLOUT;
}
let mut pfd = libc::pollfd { fd, events, revents: 0 };
let mut pfd = libc::pollfd {
fd,
events,
revents: 0,
};
loop {
let r = unsafe { libc::poll(&mut pfd, 1, 0) };
if r < 0 {
@@ -1035,7 +1170,11 @@ pub fn wait_writable_timeout(
pub fn read(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result<usize> {
wait_readable(fd)?;
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
/// Convenience wrapper: park until `fd` is writable, then perform the
@@ -1044,7 +1183,11 @@ pub fn read(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result<usize> {
pub fn write(fd: std::os::fd::RawFd, buf: &[u8]) -> std::io::Result<usize> {
wait_writable(fd)?;
let n = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
// ---------------------------------------------------------------------------
@@ -1053,12 +1196,15 @@ pub fn write(fd: std::os::fd::RawFd, buf: &[u8]) -> std::io::Result<usize> {
pub fn register_supervisor_channel(pid: Pid, sender: Sender<Signal>) {
with_runtime(|inner| {
let slot = inner.slot_at(pid)
let slot = inner
.slot_at(pid)
.unwrap_or_else(|| panic!("register_supervisor_channel: pid {:?} not found", pid));
let mut cold = slot.cold.lock();
assert_eq!(
slot.generation(), pid.generation(),
"register_supervisor_channel: pid {:?} not found", pid
slot.generation(),
pid.generation(),
"register_supervisor_channel: pid {:?} not found",
pid
);
cold.supervisor_channel = Some(sender);
});
@@ -1131,6 +1277,9 @@ mod send_after_to_tests {
crate::sleep(Duration::from_millis(30));
r2.store(true, Ordering::SeqCst);
});
assert!(reached.load(Ordering::SeqCst), "runtime survived the dead-channel fire");
assert!(
reached.load(Ordering::SeqCst),
"runtime survived the dead-channel fire"
);
}
}
+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
}
}
+8 -6
View File
@@ -123,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),
}
}
}
@@ -133,9 +133,9 @@ 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,
}
}
}
@@ -169,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,
}
}
}
@@ -392,4 +395,3 @@ impl OneForOne {
}
}
}
+3 -1
View File
@@ -129,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))
}
}
+44 -28
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,7 +99,7 @@ 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,
}
@@ -109,8 +113,8 @@ 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();
@@ -164,8 +168,11 @@ 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();
@@ -182,7 +189,7 @@ mod inner {
}
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 }));
}
});
@@ -197,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\":[");
@@ -210,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\"}}",
@@ -234,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"
);
}
+25 -26
View File
@@ -24,7 +24,11 @@ fn progress_point_counts() {
h.join().unwrap();
let after = smarm::causal::progress_snapshot();
let delta = |name: &str| {
after.iter().find(|(n, _)| n == name).map(|(_, c)| *c).unwrap()
after
.iter()
.find(|(n, _)| n == name)
.map(|(_, c)| *c)
.unwrap()
- before
.iter()
.find(|(n, _)| n == name)
@@ -45,21 +49,12 @@ fn site_guard_nesting_restores() {
assert_eq!(smarm::causal::current_site_name(), None);
{
let _outer = smarm::causal_site!("outer");
assert_eq!(
smarm::causal::current_site_name().as_deref(),
Some("outer")
);
assert_eq!(smarm::causal::current_site_name().as_deref(), Some("outer"));
{
let _inner = smarm::causal_site!("inner");
assert_eq!(
smarm::causal::current_site_name().as_deref(),
Some("inner")
);
assert_eq!(smarm::causal::current_site_name().as_deref(), Some("inner"));
}
assert_eq!(
smarm::causal::current_site_name().as_deref(),
Some("outer")
);
assert_eq!(smarm::causal::current_site_name().as_deref(), Some("outer"));
}
assert_eq!(smarm::causal::current_site_name(), None);
});
@@ -88,10 +83,7 @@ fn virtual_speedup_ledger() {
let bystander = smarm::spawn(move || {
while !stop2.load(Ordering::Relaxed) {
smarm::check!();
out2.store(
smarm::causal::my_absorbed_delay_cycles(),
Ordering::Relaxed,
);
out2.store(smarm::causal::my_absorbed_delay_cycles(), Ordering::Relaxed);
}
});
@@ -166,10 +158,7 @@ fn runnable_bystander_pays_delay() {
while !stop_b.load(Ordering::Relaxed) {
iters2.fetch_add(1, Ordering::Relaxed);
smarm::check!();
absorbed2.store(
smarm::causal::my_absorbed_delay_cycles(),
Ordering::Relaxed,
);
absorbed2.store(smarm::causal::my_absorbed_delay_cycles(), Ordering::Relaxed);
}
});
@@ -177,8 +166,7 @@ fn runnable_bystander_pays_delay() {
let i0 = iters.load(Ordering::Relaxed);
let t = std::time::Instant::now();
smarm::sleep(Duration::from_millis(150));
let rate =
(iters.load(Ordering::Relaxed) - i0) as f64 / t.elapsed().as_secs_f64();
let rate = (iters.load(Ordering::Relaxed) - i0) as f64 / t.elapsed().as_secs_f64();
out.store(rate as u64, Ordering::Relaxed);
};
@@ -448,7 +436,10 @@ fn timer_deadline_shifts_with_injected_delay() {
// Raw deadline passed, effective deadline not: nothing fires, entry kept.
assert!(t.pop_due(now + Duration::from_millis(60)).is_empty());
assert!(!t.is_empty(), "shifted entry must be re-queued, not dropped");
assert!(
!t.is_empty(),
"shifted entry must be re-queued, not dropped"
);
// Past raw + injected (with margin) it must fire. Chase in case a
// parallel test injected more debt meanwhile.
@@ -503,7 +494,11 @@ fn wall_timer_ignores_injected_delay() {
// Just past the raw deadline: the wall entry fires, the virtual one is
// re-queued at its shifted deadline.
let due = t.pop_due(now + Duration::from_millis(60));
assert_eq!(due.len(), 1, "exactly the wall entry must fire at raw deadline");
assert_eq!(
due.len(),
1,
"exactly the wall entry must fire at raw deadline"
);
assert_eq!(due[0].pid, Pid::new(0, 0));
assert!(!t.is_empty(), "virtual sibling must remain queued, shifted");
}
@@ -623,7 +618,11 @@ fn wall_send_after_ignores_injected_delay() {
// Just past the raw deadline: only the wall send pops; run its thunk.
let due = t.pop_due(now + Duration::from_millis(60));
assert_eq!(due.len(), 1, "exactly the wall send must fire at raw deadline");
assert_eq!(
due.len(),
1,
"exactly the wall send must fire at raw deadline"
);
for e in due {
if let smarm::timer::Reason::Send { fire } = e.reason {
fire();
+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"
);
});
}
+91 -15
View File
@@ -100,7 +100,15 @@ 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 });
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();
@@ -116,13 +124,25 @@ fn state_timeout_auto_resets_on_transition() {
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 });
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");
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
@@ -133,14 +153,26 @@ fn named_timeout_survives_transition() {
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 });
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");
assert_eq!(
*got.lock().unwrap(),
1,
"named timeout fired across the transitions"
);
}
// Cancelling a named timeout before its window prevents the fire.
@@ -149,13 +181,25 @@ 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 });
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");
assert_eq!(
*got.lock().unwrap(),
0,
"cancel prevented the named-timeout fire"
);
}
// ===========================================================================
@@ -170,15 +214,27 @@ fn cast_then_call_roundtrip() {
let got2 = got.clone();
run(move || {
// 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)
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::Arm)).unwrap(); // Idle -> Armed (enter)
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
// enters = 1 (start) + 4 transitions = 5.
// enters = 1 (start) + 4 transitions = 5.
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
});
assert_eq!(*got.lock().unwrap(), 5, "one enter on start, one per real transition");
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
@@ -188,7 +244,15 @@ 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 m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); // enter -> 1
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();
@@ -208,7 +272,15 @@ fn call_to_panicking_handler_is_down() {
let got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
let got2 = got.clone();
run(move || {
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
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);
});
@@ -299,7 +371,11 @@ fn postponed_call_answered_after_transition() {
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");
assert_eq!(
*got.lock().unwrap(),
Some(42),
"postponed call answered by the Filled state"
);
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+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();
+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();
});
+110 -25
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!(
@@ -515,5 +559,46 @@ fn runtime_reusable_after_root_panic() {
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");
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 || {
+14 -3
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);
}
@@ -276,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));
});
}
@@ -342,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();
});
}
+8 -6
View File
@@ -18,8 +18,8 @@
//! registry entry guarantees for every named server.
use smarm::{
call, channel, init, request_stop, spawn, Config, GenServer, GenServerBuilder, GenServerName,
CallError, Receiver, RecvTimeoutError,
call, channel, init, request_stop, spawn, CallError, Config, GenServer, GenServerBuilder,
GenServerName, Receiver, RecvTimeoutError,
};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -73,10 +73,12 @@ fn named_server_request_stop_releases_queued_caller_with_server_down() {
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 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.
+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)`
+30 -10
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,9 +421,9 @@ 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; the sink is gone, so its report sender dropped with it —
// closed+empty is Err (documented), which also proves nothing
// further was delivered.
// 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");
});
}
+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;
}