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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: shape rounding + accessors, wide-guard faults at both ends
(subprocess), Config::stack_reserve permits >64 KiB recursion that
previously could only segfault.
2026-08-08 14:18:27 +00:00
77 changed files with 4516 additions and 767 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "smarm" name = "smarm"
version = "0.5.0" version = "0.6.1"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
@@ -39,6 +39,9 @@ rq-mutex = []
rq-mpmc = [] rq-mpmc = []
rq-striped = [] rq-striped = []
[build-dependencies]
cc = "1"
[dependencies] [dependencies]
libc = "0.2" libc = "0.2"
+67 -26
View File
@@ -26,7 +26,9 @@ use std::time::Instant;
const ITERS: u32 = 15; const ITERS: u32 = 15;
fn available_threads() -> usize { 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 { fn env_sets() -> u32 {
@@ -108,17 +110,15 @@ fn bench_chained_smarm(threads: usize) -> (u64, u128) {
fn bench_chained_tokio_current() -> (u64, u128) { fn bench_chained_tokio_current() -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0)); let counter = Arc::new(AtomicU64::new(0));
let c2 = counter.clone(); 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
// Use a oneshot done channel like tokio's own chained_spawn bench. // Use a oneshot done channel like tokio's own chained_spawn bench.
let (done_tx, done_rx) = tokio::sync::oneshot::channel(); let (done_tx, done_rx) = tokio::sync::oneshot::channel();
fn iter( fn iter(c: Arc<AtomicU64>, done: tokio::sync::oneshot::Sender<()>, n: u64) {
c: Arc<AtomicU64>,
done: tokio::sync::oneshot::Sender<()>,
n: u64,
) {
if n == 0 { if n == 0 {
let _ = done.send(()); let _ = done.send(());
} else { } else {
@@ -186,7 +186,9 @@ fn bench_yield_smarm(threads: usize) -> (u64, u128) {
} }
fn bench_yield_tokio_current() -> (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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -235,11 +237,22 @@ const PRIME_N: u64 = 400_000;
const PRIME_WORKERS: u64 = 64; const PRIME_WORKERS: u64 = 64;
fn is_prime(n: u64) -> bool { fn is_prime(n: u64) -> bool {
if n < 2 { return false; } if n < 2 {
if n < 4 { return true; } return false;
if n % 2 == 0 { return false; } }
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64; 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 true
} }
@@ -250,7 +263,11 @@ fn count_primes(lo: u64, hi: u64) -> u64 {
fn primes_slice(w: u64) -> (u64, u64) { fn primes_slice(w: u64) -> (u64, u64) {
let per = PRIME_N / PRIME_WORKERS; let per = PRIME_N / PRIME_WORKERS;
let lo = w * per; 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) (lo, hi)
} }
@@ -267,7 +284,9 @@ fn bench_primes_smarm(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed); 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()) (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) { fn bench_primes_tokio_current() -> (u64, u128) {
let total = Arc::new(AtomicU64::new(0)); let total = Arc::new(AtomicU64::new(0));
let t2 = total.clone(); 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -287,7 +308,9 @@ fn bench_primes_tokio_current() -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed); 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()) (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); 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()) (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) { 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -395,7 +422,6 @@ fn bench_pp_tokio_multi() -> (u64, u128) {
// main // main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars // Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars
// so the sweep script can override the preemption knobs without recompiling. // 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 { fn bench_cfg(threads: usize) -> smarm::runtime::Config {
let mut cfg = smarm::runtime::Config::exact(threads); let mut cfg = smarm::runtime::Config::exact(threads);
if let Ok(v) = std::env::var("SMARM_ALLOC_INTERVAL") { 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(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 cfg
} }
@@ -417,7 +447,10 @@ fn main() {
println!("smarm general benchmarks"); println!("smarm general benchmarks");
println!("available parallelism: {n} threads"); println!("available parallelism: {n} threads");
let sets = env_sets(); 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!( println!(
"CHAIN_DEPTH={CHAIN_DEPTH}, YIELD_TASKS={YIELD_TASKS}×{YIELD_ROUNDS}, \ "CHAIN_DEPTH={CHAIN_DEPTH}, YIELD_TASKS={YIELD_TASKS}×{YIELD_ROUNDS}, \
PRIME_N={PRIME_N}/{PRIME_WORKERS} workers, PP_ROUNDS={PP_ROUNDS}" PRIME_N={PRIME_N}/{PRIME_WORKERS} workers, PP_ROUNDS={PP_ROUNDS}"
@@ -426,21 +459,29 @@ fn main() {
// ---- 1. chained_spawn ---- // ---- 1. chained_spawn ----
print_header(&format!("chained_spawn: depth {CHAIN_DEPTH}")); print_header(&format!("chained_spawn: depth {CHAIN_DEPTH}"));
run_n("smarm 1-thread", ITERS, || bench_chained_smarm(1)); 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 current_thread", ITERS, bench_chained_tokio_current);
run_n("tokio multi-thread", ITERS, bench_chained_tokio_multi); run_n("tokio multi-thread", ITERS, bench_chained_tokio_multi);
// ---- 2. yield_many ---- // ---- 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("smarm 1-thread", ITERS, || bench_yield_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_yield_smarm(n)); run_n(&format!("smarm {n}-thread"), ITERS, || bench_yield_smarm(n));
run_n("tokio current_thread", ITERS, bench_yield_tokio_current); run_n("tokio current_thread", ITERS, bench_yield_tokio_current);
run_n("tokio multi-thread", ITERS, bench_yield_tokio_multi); run_n("tokio multi-thread", ITERS, bench_yield_tokio_multi);
// ---- 3. fan_out_compute ---- // ---- 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("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 current_thread", ITERS, bench_primes_tokio_current);
run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi); run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi);
+76 -27
View File
@@ -64,11 +64,22 @@ const PRIME_N: u64 = 400_000;
const WORKERS: u64 = 64; const WORKERS: u64 = 64;
fn is_prime(n: u64) -> bool { fn is_prime(n: u64) -> bool {
if n < 2 { return false; } if n < 2 {
if n < 4 { return true; } return false;
if n % 2 == 0 { return false; } }
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64; 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 true
} }
@@ -96,7 +107,9 @@ fn bench_primes_smarm(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed); 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()) (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) { fn bench_primes_tokio_current() -> (u64, u128) {
let total = Arc::new(AtomicU64::new(0)); let total = Arc::new(AtomicU64::new(0));
let t2 = total.clone(); 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -116,7 +131,9 @@ fn bench_primes_tokio_current() -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed); 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()) (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); 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()) (total.load(Ordering::Relaxed), start.elapsed().as_micros())
} }
fn bench_primes_baseline() -> (u64, u128) { fn bench_primes_baseline() -> (u64, u128) {
let start = Instant::now(); let start = Instant::now();
let total: u64 = (0..WORKERS).map(|w| { let total: u64 = (0..WORKERS)
.map(|w| {
let (lo, hi) = primes_slice(w); let (lo, hi) = primes_slice(w);
count_primes(lo, hi) count_primes(lo, hi)
}).sum(); })
.sum();
(total, start.elapsed().as_micros()) (total, start.elapsed().as_micros())
} }
@@ -167,15 +188,17 @@ fn bench_pingpong_smarm(threads: usize) -> (u64, u128) {
tx_a.send(0).unwrap(); tx_a.send(0).unwrap();
loop { loop {
let v = rx_b.recv().unwrap(); let v = rx_b.recv().unwrap();
if v >= PING_ROUNDS { break; } if v >= PING_ROUNDS {
break;
}
tx_a.send(v + 1).unwrap(); tx_a.send(v + 1).unwrap();
} }
}); });
let hb = smarm::spawn(move || { let hb = smarm::spawn(move || loop {
loop {
let v = rx_a.recv().unwrap(); let v = rx_a.recv().unwrap();
tx_b.send(v + 1).unwrap(); tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS { break; } if v + 1 >= PING_ROUNDS {
break;
} }
}); });
ha.join().unwrap(); ha.join().unwrap();
@@ -198,7 +221,9 @@ fn bench_pingpong_tokio_current() -> (u64, u128) {
tx_a.send(0).unwrap(); tx_a.send(0).unwrap();
loop { loop {
let v = rx_b.recv().await.unwrap(); let v = rx_b.recv().await.unwrap();
if v >= PING_ROUNDS { break; } if v >= PING_ROUNDS {
break;
}
tx_a.send(v + 1).unwrap(); tx_a.send(v + 1).unwrap();
} }
}); });
@@ -206,7 +231,9 @@ fn bench_pingpong_tokio_current() -> (u64, u128) {
loop { loop {
let v = rx_a.recv().await.unwrap(); let v = rx_a.recv().await.unwrap();
tx_b.send(v + 1).unwrap(); tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS { break; } if v + 1 >= PING_ROUNDS {
break;
}
} }
}); });
let _ = ha.await; let _ = ha.await;
@@ -229,7 +256,9 @@ fn bench_pingpong_tokio_multi() -> (u64, u128) {
tx_a.send(0).unwrap(); tx_a.send(0).unwrap();
loop { loop {
let v = rx_b.recv().await.unwrap(); let v = rx_b.recv().await.unwrap();
if v >= PING_ROUNDS { break; } if v >= PING_ROUNDS {
break;
}
tx_a.send(v + 1).unwrap(); tx_a.send(v + 1).unwrap();
} }
}); });
@@ -237,7 +266,9 @@ fn bench_pingpong_tokio_multi() -> (u64, u128) {
loop { loop {
let v = rx_a.recv().await.unwrap(); let v = rx_a.recv().await.unwrap();
tx_b.send(v + 1).unwrap(); tx_b.send(v + 1).unwrap();
if v + 1 >= PING_ROUNDS { break; } if v + 1 >= PING_ROUNDS {
break;
}
} }
}); });
let _ = ha.await; let _ = ha.await;
@@ -264,7 +295,9 @@ fn bench_spawn_smarm(threads: usize) -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed); 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()) (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) { fn bench_spawn_tokio_current() -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0)); let counter = Arc::new(AtomicU64::new(0));
let c = counter.clone(); 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -283,7 +318,9 @@ fn bench_spawn_tokio_current() -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed); 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()) (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); 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()) (counter.load(Ordering::Relaxed), start.elapsed().as_micros())
} }
@@ -320,22 +359,32 @@ fn main() {
println!("PRIME_N={PRIME_N}, WORKERS={WORKERS}, PING_ROUNDS={PING_ROUNDS}, SPAWN_COUNT={SPAWN_COUNT}"); println!("PRIME_N={PRIME_N}, WORKERS={WORKERS}, PING_ROUNDS={PING_ROUNDS}, SPAWN_COUNT={SPAWN_COUNT}");
// ---- Primes ---- // ---- Primes ----
print_header(&format!("Fan-out/fan-in: count primes in [2, {PRIME_N}) across {WORKERS} workers")); 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("baseline (serial)", ITERS, bench_primes_baseline);
run_n("smarm single-thread", ITERS, || bench_primes_smarm(1)); run_n("smarm single-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 current_thread", ITERS, bench_primes_tokio_current);
run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi); run_n("tokio multi-thread", ITERS, bench_primes_tokio_multi);
// ---- Ping-pong ---- // ---- Ping-pong ----
print_header(&format!("Ping-pong: {PING_ROUNDS} round-trips between two actors")); print_header(&format!(
"Ping-pong: {PING_ROUNDS} round-trips between two actors"
));
run_n("smarm single-thread", ITERS, || bench_pingpong_smarm(1)); run_n("smarm single-thread", ITERS, || bench_pingpong_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_pingpong_smarm(n)); run_n(&format!("smarm {n}-thread"), ITERS, || {
bench_pingpong_smarm(n)
});
run_n("tokio current_thread", ITERS, bench_pingpong_tokio_current); run_n("tokio current_thread", ITERS, bench_pingpong_tokio_current);
run_n("tokio multi-thread", ITERS, bench_pingpong_tokio_multi); run_n("tokio multi-thread", ITERS, bench_pingpong_tokio_multi);
// ---- Spawn throughput ---- // ---- Spawn throughput ----
print_header(&format!("Spawn throughput: {SPAWN_COUNT} actors spawned and joined")); print_header(&format!(
"Spawn throughput: {SPAWN_COUNT} actors spawned and joined"
));
run_n("smarm single-thread", ITERS, || bench_spawn_smarm(1)); run_n("smarm single-thread", ITERS, || bench_spawn_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_spawn_smarm(n)); run_n(&format!("smarm {n}-thread"), ITERS, || bench_spawn_smarm(n));
run_n("tokio current_thread", ITERS, bench_spawn_tokio_current); run_n("tokio current_thread", ITERS, bench_spawn_tokio_current);
+24 -7
View File
@@ -16,12 +16,20 @@ const WORKERS: u64 = 16;
const ITERATIONS: u32 = 5; const ITERATIONS: u32 = 5;
fn is_prime(n: u64) -> bool { fn is_prime(n: u64) -> bool {
if n < 2 { return false; } if n < 2 {
if n < 4 { return true; } return false;
if n % 2 == 0 { return false; } }
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64; let mut i = 3u64;
while i * i <= n { while i * i <= n {
if n % i == 0 { return false; } if n % i == 0 {
return false;
}
i += 2; i += 2;
} }
true true
@@ -30,7 +38,9 @@ fn is_prime(n: u64) -> bool {
fn count_primes_in(lo: u64, hi: u64) -> u64 { fn count_primes_in(lo: u64, hi: u64) -> u64 {
let mut count = 0u64; let mut count = 0u64;
for n in lo..hi { for n in lo..hi {
if is_prime(n) { count += 1; } if is_prime(n) {
count += 1;
}
} }
count count
} }
@@ -38,7 +48,11 @@ fn count_primes_in(lo: u64, hi: u64) -> u64 {
fn slice(worker: u64) -> (u64, u64) { fn slice(worker: u64) -> (u64, u64) {
let per = N / WORKERS; let per = N / WORKERS;
let lo = worker * per; 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) (lo, hi)
} }
@@ -125,7 +139,10 @@ fn main() {
"Counting primes in [2, {}) across {} workers, {} iterations each\n", "Counting primes in [2, {}) across {} workers, {} iterations each\n",
N, WORKERS, ITERATIONS 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)); println!("{}", "-".repeat(80));
run_n("baseline", ITERATIONS, bench_baseline); run_n("baseline", ITERATIONS, bench_baseline);
+44 -7
View File
@@ -27,12 +27,19 @@ use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
fn env_usize(key: &str, default: usize) -> usize { 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> { fn env_threads() -> Vec<usize> {
std::env::var("SMARM_BENCH_THREADS") 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]) .unwrap_or_else(|_| vec![1, 2, 4])
} }
@@ -53,7 +60,11 @@ fn drive<Q: Send + Sync + 'static>(
for p in 0..producers { for p in 0..producers {
let q = q.clone(); let q = q.clone();
// Give the last producer the remainder. // 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 || { hs.push(std::thread::spawn(move || {
let pid = Pid::new(p as u32, 0); let pid = Pid::new(p as u32, 0);
for _ in 0..n { for _ in 0..n {
@@ -132,7 +143,12 @@ fn main() {
for &t in &threads_sweep { for &t in &threads_sweep {
for (p, c) in ratios_for(t) { for (p, c) in ratios_for(t) {
for s in ["mutex", "mpmc", "striped"] { 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 { if case.threads < 2 {
drive_single(&*q, MutexQueue::push, MutexQueue::pop, items) drive_single(&*q, MutexQueue::push, MutexQueue::pop, items)
} else { } else {
drive(q, MutexQueue::push, MutexQueue::pop, case.producers, case.consumers, items) drive(
q,
MutexQueue::push,
MutexQueue::pop,
case.producers,
case.consumers,
items,
)
} }
} }
"mpmc" => { "mpmc" => {
@@ -155,7 +178,14 @@ fn main() {
if case.threads < 2 { if case.threads < 2 {
drive_single(&*q, MpmcRing::push, MpmcRing::pop, items) drive_single(&*q, MpmcRing::push, MpmcRing::pop, items)
} else { } else {
drive(q, MpmcRing::push, MpmcRing::pop, case.producers, case.consumers, items) drive(
q,
MpmcRing::push,
MpmcRing::pop,
case.producers,
case.consumers,
items,
)
} }
} }
"striped" => { "striped" => {
@@ -163,7 +193,14 @@ fn main() {
if case.threads < 2 { if case.threads < 2 {
drive_single(&*q, StripedRing::push, StripedRing::pop, items) drive_single(&*q, StripedRing::push, StripedRing::pop, items)
} else { } else {
drive(q, StripedRing::push, StripedRing::pop, case.producers, case.consumers, items) drive(
q,
StripedRing::push,
StripedRing::pop,
case.producers,
case.consumers,
items,
)
} }
} }
_ => unreachable!(), _ => unreachable!(),
+21 -4
View File
@@ -54,12 +54,19 @@ fn variant() -> &'static str {
} }
fn env_usize(key: &str, default: usize) -> usize { 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> { fn env_threads() -> Vec<usize> {
std::env::var("SMARM_BENCH_THREADS") 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]) .unwrap_or_else(|_| vec![1, 2, 4])
} }
@@ -238,12 +245,22 @@ fn main() {
); );
println!( println!(
"RQCSV,runtime,{},{},{},{},{},{},{}", "RQCSV,runtime,{},{},{},{},{},{},{}",
variant(), slot_str, name, t, work, mid.us, per_s variant(),
slot_str,
name,
t,
work,
mid.us,
per_s
); );
if slot { if slot {
println!( println!(
"RQSLOT,{},{},{},{},{}", "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; const ITERS: u32 = 15;
fn available_threads() -> usize { 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 { fn env_sets() -> u32 {
@@ -116,7 +118,9 @@ fn bench_recurse_smarm(threads: usize) -> (u64, u128) {
fn bench_recurse_tokio_current() -> (u64, u128) { fn bench_recurse_tokio_current() -> (u64, u128) {
let counter = Arc::new(AtomicU64::new(0)); let counter = Arc::new(AtomicU64::new(0));
let c2 = counter.clone(); 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -199,7 +203,9 @@ fn bench_hot_smarm() -> (u64, u128) {
} }
fn bench_hot_tokio_current() -> (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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -249,7 +255,9 @@ fn bench_unc_smarm() -> (u64, u128) {
} }
fn bench_unc_tokio_current() -> (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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -297,8 +305,12 @@ fn bench_panic_smarm(threads: usize) -> (u64, u128) {
} }
for h in handles { for h in handles {
match h.join() { match h.join() {
Ok(()) => { ok2.fetch_add(1, Ordering::Relaxed); } Ok(()) => {
Err(_) => { err2.fetch_add(1, Ordering::Relaxed); } 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 err = Arc::new(AtomicU64::new(0));
let ok2 = ok.clone(); let ok2 = ok.clone();
let err2 = err.clone(); let err2 = err.clone();
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let prev_hook = std::panic::take_hook(); let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); std::panic::set_hook(Box::new(|_| {}));
let start = Instant::now(); let start = Instant::now();
@@ -328,8 +342,12 @@ fn bench_panic_tokio_current() -> (u64, u128) {
} }
for h in handles { for h in handles {
match h.await { match h.await {
Ok(()) => { ok2.fetch_add(1, Ordering::Relaxed); } Ok(()) => {
Err(_) => { err2.fetch_add(1, Ordering::Relaxed); } 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 { for h in handles {
match h.await { match h.await {
Ok(()) => { ok2.fetch_add(1, Ordering::Relaxed); } Ok(()) => {
Err(_) => { err2.fetch_add(1, Ordering::Relaxed); } 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 // main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars // Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars
// so the sweep script can override the preemption knobs without recompiling. // 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 { fn bench_cfg(threads: usize) -> smarm::runtime::Config {
let mut cfg = smarm::runtime::Config::exact(threads); let mut cfg = smarm::runtime::Config::exact(threads);
if let Ok(v) = std::env::var("SMARM_ALLOC_INTERVAL") { 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(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 cfg
} }
@@ -397,7 +422,10 @@ fn main() {
println!("smarm smarm-favored benchmarks"); println!("smarm smarm-favored benchmarks");
println!("available parallelism: {n} threads"); println!("available parallelism: {n} threads");
let sets = env_sets(); 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!( println!(
"RECURSE_DEPTH={RECURSE_DEPTH}, HOT_YIELDS={HOT_YIELDS}×2, \ "RECURSE_DEPTH={RECURSE_DEPTH}, HOT_YIELDS={HOT_YIELDS}×2, \
UNCONT_MSGS={UNCONT_MSGS}, PANIC_TASKS={PANIC_TASKS}" UNCONT_MSGS={UNCONT_MSGS}, PANIC_TASKS={PANIC_TASKS}"
@@ -406,22 +434,30 @@ fn main() {
// ---- 9. deep_recursion ---- // ---- 9. deep_recursion ----
print_header(&format!("deep_recursion: depth {RECURSE_DEPTH}")); print_header(&format!("deep_recursion: depth {RECURSE_DEPTH}"));
run_n("smarm 1-thread", ITERS, || bench_recurse_smarm(1)); 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 current_thread", ITERS, bench_recurse_tokio_current);
run_n("tokio multi-thread", ITERS, bench_recurse_tokio_multi); run_n("tokio multi-thread", ITERS, bench_recurse_tokio_multi);
// ---- 10. yield_in_hot_loop ---- // ---- 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("smarm 1-thread", ITERS, bench_hot_smarm);
run_n("tokio current_thread", ITERS, bench_hot_tokio_current); run_n("tokio current_thread", ITERS, bench_hot_tokio_current);
// ---- 11. uncontended_channel ---- // ---- 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("smarm 1-thread", ITERS, bench_unc_smarm);
run_n("tokio current_thread", ITERS, bench_unc_tokio_current); run_n("tokio current_thread", ITERS, bench_unc_tokio_current);
// ---- 12. catch_unwind_panics ---- // ---- 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("smarm 1-thread", ITERS, || bench_panic_smarm(1));
run_n(&format!("smarm {n}-thread"), ITERS, || bench_panic_smarm(n)); run_n(&format!("smarm {n}-thread"), ITERS, || bench_panic_smarm(n));
run_n("tokio current_thread", ITERS, bench_panic_tokio_current); 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 { 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; 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 // Derived effective frequency: cycles per ns = GHz. Cross-checks the two
// lenses against the box's known base clock. // 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 p50 = pct(&pooled_ns, 50.0);
let p90 = pct(&pooled_ns, 90.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={} (instrumentation floor: {} ns / {} cyc, subtracted)",
rounds, warmup, runs, floor_ns, floor_cyc rounds, warmup, runs, floor_ns, floor_cyc
); );
println!(" {:<10} {:<10} {:<10} {:<10} {:<10}", "p50 ns", "p90 ns", "p99 ns", "min ns", "max ns"); println!(
println!(" {:<10} {:<10} {:<10} {:<10} {:<10}", p50, p90, p99, lo, hi); " {:<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!( println!(
" mean {:.1} ns | mean {:.0} cyc | derived {:.3} GHz", " mean {:.1} ns | mean {:.0} cyc | derived {:.3} GHz",
mean_ns, mean_cyc, derived_ghz mean_ns, mean_cyc, derived_ghz
@@ -251,6 +264,18 @@ fn main() {
// Greppable line — same spirit as SPINCSV. // Greppable line — same spirit as SPINCSV.
println!( println!(
"SWITCHCSV,{},{},{},{},{},{},{},{},{},{},{:.1},{:.0},{:.3}", "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
); );
} }
+105 -33
View File
@@ -36,7 +36,9 @@ use std::time::{Duration, Instant};
const ITERS: u32 = 15; const ITERS: u32 = 15;
fn available_threads() -> usize { 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 { fn env_sets() -> u32 {
@@ -114,11 +116,15 @@ fn bench_storm_smarm(threads: usize) -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed); cc.fetch_add(1, Ordering::Relaxed);
})); }));
} }
for h in handles { h.join().unwrap(); } for h in handles {
h.join().unwrap();
}
// Tear down background. // Tear down background.
s2.store(true, Ordering::Relaxed); 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()) (counter.load(Ordering::Relaxed), start.elapsed().as_micros())
} }
@@ -129,7 +135,9 @@ fn bench_storm_tokio_current() -> (u64, u128) {
let c2 = counter.clone(); let c2 = counter.clone();
let s2 = stop.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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -149,9 +157,13 @@ fn bench_storm_tokio_current() -> (u64, u128) {
cc.fetch_add(1, Ordering::Relaxed); 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); 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()) (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); 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); 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()) (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 () 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(); 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) { 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 start = Instant::now();
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move { local.block_on(&rt, async move {
@@ -248,10 +271,15 @@ fn bench_mpsc_tokio_current() -> (u64, u128) {
} }
count count
}); });
for h in prod_handles { let _ = h.await; } for h in prod_handles {
let _ = h.await;
}
let _ = consumer.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) { fn bench_mpsc_tokio_multi() -> (u64, u128) {
@@ -279,10 +307,15 @@ fn bench_mpsc_tokio_multi() -> (u64, u128) {
} }
count count
}); });
for h in prod_handles { let _ = h.await; } for h in prod_handles {
let _ = h.await;
}
let _ = consumer.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)); 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()) (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; 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()) (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; 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()) (TIMER_ACTORS, start.elapsed().as_micros())
} }
@@ -361,11 +400,22 @@ const SCALING_N: u64 = 400_000;
const SCALING_WORKERS: u64 = 64; const SCALING_WORKERS: u64 = 64;
fn is_prime(n: u64) -> bool { fn is_prime(n: u64) -> bool {
if n < 2 { return false; } if n < 2 {
if n < 4 { return true; } return false;
if n % 2 == 0 { return false; } }
if n < 4 {
return true;
}
if n % 2 == 0 {
return false;
}
let mut i = 3u64; 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 true
} }
@@ -376,7 +426,11 @@ fn count_primes(lo: u64, hi: u64) -> u64 {
fn scaling_slice(w: u64) -> (u64, u64) { fn scaling_slice(w: u64) -> (u64, u64) {
let per = SCALING_N / SCALING_WORKERS; let per = SCALING_N / SCALING_WORKERS;
let lo = w * per; 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) (lo, hi)
} }
@@ -393,7 +447,9 @@ fn bench_scaling_smarm(threads: usize) -> (u64, u128) {
tc.fetch_add(count_primes(lo, hi), Ordering::Relaxed); 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()) (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); 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()) (total.load(Ordering::Relaxed), start.elapsed().as_micros())
} }
@@ -424,7 +482,6 @@ fn bench_scaling_tokio_multi(threads: usize) -> (u64, u128) {
// main // main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars // Knob helper — reads SMARM_ALLOC_INTERVAL / SMARM_TIMESLICE_CYCLES env vars
// so the sweep script can override the preemption knobs without recompiling. // 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 { fn bench_cfg(threads: usize) -> smarm::runtime::Config {
let mut cfg = smarm::runtime::Config::exact(threads); let mut cfg = smarm::runtime::Config::exact(threads);
if let Ok(v) = std::env::var("SMARM_ALLOC_INTERVAL") { 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(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 cfg
} }
@@ -446,7 +507,10 @@ fn main() {
println!("smarm tokio-favored benchmarks"); println!("smarm tokio-favored benchmarks");
println!("available parallelism: {n} threads"); println!("available parallelism: {n} threads");
let sets = env_sets(); 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!( println!(
"STORM_BACKGROUND={STORM_BACKGROUND}, STORM_SPAWN={STORM_SPAWN}, \ "STORM_BACKGROUND={STORM_BACKGROUND}, STORM_SPAWN={STORM_SPAWN}, \
MPSC={MPSC_PRODUCERS}×{MPSC_PER_PRODUCER}, \ 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" "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("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 current_thread", ITERS, bench_timers_tokio_current);
run_n("tokio multi-thread", ITERS, bench_timers_tokio_multi); run_n("tokio multi-thread", ITERS, bench_timers_tokio_multi);
@@ -487,13 +553,19 @@ fn main() {
)); ));
let sweep: Vec<usize> = { let sweep: Vec<usize> = {
let mut v = vec![1usize, 2, 4]; 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() v.into_iter().filter(|t| *t <= n).collect()
}; };
for t in &sweep { 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 { 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×) ### 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, a syscall. Tokio tasks are heap-allocated state machines — no stack,
no syscall, ~100 bytes each. For workloads that spawn thousands of no syscall, ~100 bytes each. For workloads that spawn thousands of
short-lived actors per second, this is a structural disadvantage. 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"); println!("calibration: {per_us} work iters/µs");
let work_us = move |us: u64| work_iters(us * per_us); 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}"); println!("cores: {cores}");
if cores < 4 { if cores < 4 {
println!("probe: SKIPPED (needs the stages in parallel)"); println!("probe: SKIPPED (needs the stages in parallel)");
+8 -8
View File
@@ -35,8 +35,8 @@
#![deny(dead_code, unreachable_patterns)] #![deny(dead_code, unreachable_patterns)]
use smarm::gen_statem::{spawn, Cx, GenStatemRef, Machine, Reply, Resolution, Step};
use smarm::run; use smarm::run;
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, Step, GenStatemRef};
// === user types ============================================================ // === user types ============================================================
@@ -123,7 +123,11 @@ impl DoorSm {
fn start(init: Door) -> GenStatemRef<DoorSm> { fn start(init: Door) -> GenStatemRef<DoorSm> {
spawn(DoorSm { spawn(DoorSm {
state: init, 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, (Door::Closed, Ev::Cast(Cast::Push | Cast::Unlock(_))) => Resolution::Unhandled,
// --- Locked (branching row: handler picks within UnlockOutcome) - // --- Locked (branching row: handler picks within UnlockOutcome) -
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => { (Door::Locked, Ev::Cast(Cast::Unlock(key))) => Resolution::To(on_unlock(key).into()),
Resolution::To(on_unlock(key).into())
}
// Routed out in phase 1; listed only to keep this match total. // Routed out in phase 1; listed only to keep this match total.
(Door::Locked, Ev::Cast(Cast::Knock)) => { (Door::Locked, Ev::Cast(Cast::Knock)) => {
unreachable!("postponed event is replayed, not dispatched here") unreachable!("postponed event is replayed, not dispatched here")
} }
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => { (Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => Resolution::Unhandled,
Resolution::Unhandled
}
// --- state-independent queries (reply, then stay) --------------- // --- state-independent queries (reply, then stay) ---------------
(_, Ev::Call(Call::GetState(r))) => { (_, Ev::Call(Call::GetState(r))) => {
+9 -2
View File
@@ -18,8 +18,8 @@
// dispatch's own unreachable_patterns internally. // dispatch's own unreachable_patterns internally.
use smarm::gen_statem; use smarm::gen_statem;
use smarm::run;
use smarm::gen_statem::Reply; use smarm::gen_statem::Reply;
use smarm::run;
// === user types (identical to gen_statem_expanded.rs) ========================= // === user types (identical to gen_statem_expanded.rs) =========================
@@ -135,7 +135,14 @@ gen_statem! {
fn main() { fn main() {
run(|| { 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::Lock)).unwrap(); // Closed -> Locked
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted) 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 //! every use — so the address keeps working across a supervised restart, with
//! no stale [`GenServerRef`] to refresh. //! 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`. /// A counter server: synchronous `Get`, asynchronous `Inc` / `Add`.
struct Counter { struct Counter {
+13 -3
View File
@@ -15,7 +15,9 @@
//! `call`, nothing more. //! `call`, nothing more.
use smarm::observer::{self, ObserverReply, ObserverRequest}; 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"); 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. /// A `ps`-style table over the flat snapshot.
fn print_snapshot(snap: &RuntimeSnapshot) { 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!( println!(
" {:<10} {:<9} {:<10} {:>4} {:>4} {:>4} {:>4} {:>5} {}", " {:<10} {:<9} {:<10} {:>4} {:>4} {:>4} {:>4} {:>5} {}",
"pid", "state", "parent", "mon", "lnk", "joi", "mbox", "msgs", "names" "pid", "state", "parent", "mon", "lnk", "joi", "mbox", "msgs", "names"
@@ -52,7 +58,11 @@ fn print_snapshot(snap: &RuntimeSnapshot) {
a.joiners, a.joiners,
a.mailbox_depth, a.mailbox_depth,
a.messages_received, 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(",")
},
); );
} }
} }
+20 -10
View File
@@ -342,8 +342,7 @@ mod inner {
// Count the loss in would-be delta terms so the audit's columns // Count the loss in would-be delta terms so the audit's columns
// compare directly against `injected_cycles`. // compare directly against `injected_cycles`.
DISCARD_OVERMAX_N.fetch_add(1, Ordering::Relaxed); DISCARD_OVERMAX_N.fetch_add(1, Ordering::Relaxed);
DISCARD_OVERMAX_CYCLES DISCARD_OVERMAX_CYCLES.fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed);
.fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed);
return; return;
} }
let delta = interval.saturating_mul(pct) / 100; let delta = interval.saturating_mul(pct) / 100;
@@ -419,8 +418,7 @@ mod inner {
let gap = preempt::rdtsc() let gap = preempt::rdtsc()
.saturating_sub(desched_tsc) .saturating_sub(desched_tsc)
.min(MAX_SAMPLE_CYCLES); .min(MAX_SAMPLE_CYCLES);
OFFCPU_IN_SITE_CYCLES OFFCPU_IN_SITE_CYCLES.fetch_add(gap.saturating_mul(pct) / 100, Ordering::Relaxed);
.fetch_add(gap.saturating_mul(pct) / 100, Ordering::Relaxed);
OFFCPU_IN_SITE_N.fetch_add(1, Ordering::Relaxed); OFFCPU_IN_SITE_N.fetch_add(1, Ordering::Relaxed);
} }
} }
@@ -533,7 +531,9 @@ mod inner {
park_forgiven_cycles: self park_forgiven_cycles: self
.park_forgiven_cycles .park_forgiven_cycles
.saturating_sub(before.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_park_n: self.drop_park_n.saturating_sub(before.drop_park_n),
drop_yield_cycles: self drop_yield_cycles: self
.drop_yield_cycles .drop_yield_cycles
@@ -542,12 +542,18 @@ mod inner {
discard_overmax_cycles: self discard_overmax_cycles: self
.discard_overmax_cycles .discard_overmax_cycles
.saturating_sub(before.discard_overmax_cycles), .saturating_sub(before.discard_overmax_cycles),
discard_overmax_n: self.discard_overmax_n.saturating_sub(before.discard_overmax_n), discard_overmax_n: self
discard_unarmed_n: self.discard_unarmed_n.saturating_sub(before.discard_unarmed_n), .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: self
.offcpu_in_site_cycles .offcpu_in_site_cycles
.saturating_sub(before.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 let cell = results
.iter() .iter()
.find(|r| r.site == site && r.speedup_pct == speedup_pct)?; .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 rate = normalized_rate(cell, point)?;
let b = normalized_rate(base, point)?; let b = normalized_rate(base, point)?;
if b <= 0.0 { if b <= 0.0 {
@@ -946,7 +954,9 @@ macro_rules! progress {
macro_rules! causal_site { macro_rules! causal_site {
($name:literal) => {{ ($name:literal) => {{
static __SMARM_SITE: ::std::sync::OnceLock<u32> = ::std::sync::OnceLock::new(); 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)),
)
}}; }};
} }
+45 -21
View File
@@ -104,7 +104,12 @@ pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
senders: 1, senders: 1,
receiver_alive: true, receiver_alive: true,
})); }));
(Sender { inner: inner.clone() }, Receiver { inner }) (
Sender {
inner: inner.clone(),
},
Receiver { inner },
)
} }
struct Inner<T> { struct Inner<T> {
@@ -178,7 +183,9 @@ impl std::error::Error for RecvTimeoutError {}
impl<T> Clone for Sender<T> { impl<T> Clone for Sender<T> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
self.inner.lock().senders += 1; 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() g.parked_receiver.take()
}; };
if let Some((pid, epoch)) = unpark { 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); crate::scheduler::unpark_at(pid, epoch);
} else { } 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(()) Ok(())
} }
@@ -290,10 +305,12 @@ impl<T> Receiver<T> {
// Release the lock before parking: the unparker will need it. // Release the lock before parking: the unparker will need it.
crate::scheduler::park_current(); crate::scheduler::park_current();
// Woken up. Record it before looping to check the queue. // Woken up. Record it before looping to check the queue.
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() { crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p, Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"), 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::insert_wait_timer(deadline, me, target, epoch);
crate::scheduler::park_current(); crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() { crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p, Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"), None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
})); }
));
let mut g = self.inner.lock(); let mut g = self.inner.lock();
if let Some(v) = g.queue.pop_front() { if let Some(v) = g.queue.pop_front() {
crate::preempt::note_message_received(); crate::preempt::note_message_received();
@@ -412,10 +431,12 @@ impl<T> Receiver<T> {
} }
// Release the lock before parking: the unparker will need it. // Release the lock before parking: the unparker will need it.
crate::scheduler::park_current(); crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() { crate::te!(crate::trace::Event::RecvWake(
match crate::actor::current_pid() {
Some(p) => p, Some(p) => p,
None => panic!("smarm: RecvWake outside an actor (core corrupt)"), 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 // Channel-only selects skip all of it: `eager` is false, the guard
// is disarmed, and the loser-arm self-cleaning story is unchanged. // is disarmed, and the loser-arm self-cleaning story is unchanged.
let eager = arms.iter().any(|a| a.sel_eager_cleanup()); 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(); crate::scheduler::park_current();
@@ -687,11 +713,7 @@ impl Drop for UnregisterGuard<'_> {
// unregistered eagerly so none are left dangling. `Err` = an arm failed to // unregistered eagerly so none are left dangling. `Err` = an arm failed to
// register; same unwind (earlier fd arms unregistered, wait retired). // register; same unwind (earlier fd arms unregistered, wait retired).
// `Ok(None)` = every arm registered successfully; the caller parks. // `Ok(None)` = every arm registered successfully; the caller parks.
fn register_arms( fn register_arms(me: Pid, epoch: u32, arms: &[&dyn Selectable]) -> std::io::Result<Option<usize>> {
me: Pid,
epoch: u32,
arms: &[&dyn Selectable],
) -> std::io::Result<Option<usize>> {
for (i, arm) in arms.iter().enumerate() { for (i, arm) in arms.iter().enumerate() {
let registered = match arm.sel_register(me, epoch) { let registered = match arm.sel_register(me, epoch) {
Ok(r) => r, 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 /// 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 /// fails to register (see [`try_select_timeout`] for the fallible form; a
/// channel-only select can never fail). /// channel-only select can never fail).
pub fn select_timeout( pub fn select_timeout(arms: &[&dyn Selectable], timeout: std::time::Duration) -> Option<usize> {
arms: &[&dyn Selectable],
timeout: std::time::Duration,
) -> Option<usize> {
match try_select_timeout(arms, timeout) { match try_select_timeout(arms, timeout) {
Ok(r) => r, Ok(r) => r,
Err(e) => panic!( Err(e) => panic!(
@@ -776,7 +795,12 @@ pub fn try_select_timeout(
// would leave those fds unusable until a kernel event happened to // would leave those fds unusable until a kernel event happened to
// clear them. // clear them.
let eager = arms.iter().any(|a| a.sel_eager_cleanup()); 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(); crate::scheduler::park_current();
+26 -11
View File
@@ -16,10 +16,18 @@ thread_local! {
static ACTOR_SP: Cell<usize> = const { Cell::new(0) }; static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
} }
fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) } fn get_scheduler_sp() -> usize {
fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) } SCHEDULER_SP.with(|c| c.get())
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 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 // 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 { pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
unsafe { unsafe {
let mut sp = (top as usize & !15) - 8; let mut sp = (top as usize & !15) - 8;
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target sp -= 8;
sp -= 8; (sp as *mut usize).write(0); // rbx (sp as *mut usize).write(entry as usize); // ret target
sp -= 8; (sp as *mut usize).write(0); // rbp sp -= 8;
sp -= 8; (sp as *mut usize).write(0); // r12 (sp as *mut usize).write(0); // rbx
sp -= 8; (sp as *mut usize).write(0); // r13 sp -= 8;
sp -= 8; (sp as *mut usize).write(0); // r14 (sp as *mut usize).write(0); // rbp
sp -= 8; (sp as *mut usize).write(0); // r15 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 sp
} }
} }
+98 -28
View File
@@ -178,11 +178,13 @@
//! from any handler via [`Watcher::watch`]) because monitors are inherently //! from any handler via [`Watcher::watch`]) because monitors are inherently
//! created at runtime. The idle window is set once, in `init`. //! 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::monitor::{demonitor, monitor, Down, Monitor};
use crate::pid::Pid; use crate::pid::Pid;
use crate::registry::{register_with, resolve_named_sender, RegisterError}; 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 crate::timer::TimerId;
use std::cell::Cell; use std::cell::Cell;
use std::collections::HashMap; use std::collections::HashMap;
@@ -273,7 +275,10 @@ pub struct GenServerRef<G: GenServer> {
impl<G: GenServer> Clone for GenServerRef<G> { impl<G: GenServer> Clone for GenServerRef<G> {
fn clone(&self) -> Self { 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 /// A clonable handle to the loop's monitor intake. Store it in the state
/// during `init` to watch monitors from later handlers. /// during `init` to watch monitors from later handlers.
pub fn watcher(&self) -> Watcher<G> { 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`. /// Shorthand for `ctx.watcher().watch(m)` when watching during `init`.
@@ -426,7 +433,10 @@ impl<G: GenServer> GenServerCtx<G> {
/// [`tick_every`](TimerHandle::tick_every) / /// [`tick_every`](TimerHandle::tick_every) /
/// [`cancel`](TimerHandle::cancel) from any later handler. /// [`cancel`](TimerHandle::cancel) from any later handler.
pub fn timer(&self) -> TimerHandle<G> { 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 /// 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. // Manual Clone for the same reason as `Watcher`: no `G: Clone` needed.
impl<G: GenServer> Clone for TimerHandle<G> { impl<G: GenServer> Clone for TimerHandle<G> {
fn clone(&self) -> Self { 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 // First instance fires after `every`; the payload is produced loop-side
// from `make` on fire, so the tick carries only the stable id. // 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)); let sub = send_after_to(every, self.sys_tx.clone(), Sys::Tick(local));
reg.periodics.insert(local, Periodic { every, live: sub, make }); reg.periodics.insert(
debug_assert!(reg.rearm_tx.is_some(), "rearm_tx must be Some while periodics is non-empty"); local,
Periodic {
every,
live: sub,
make,
},
);
debug_assert!(
reg.rearm_tx.is_some(),
"rearm_tx must be Some while periodics is non-empty"
);
local local
} }
@@ -617,7 +640,9 @@ pub struct Watcher<G: GenServer> {
// regardless of the server type (it clones only the inner sender). // regardless of the server type (it clones only the inner sender).
impl<G: GenServer> Clone for Watcher<G> { impl<G: GenServer> Clone for Watcher<G> {
fn clone(&self) -> Self { 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, state: G,
infos: Vec<Receiver<G::Info>>, infos: Vec<Receiver<G::Info>>,
supervisor: Option<Pid>, supervisor: Option<Pid>,
stack_opts: crate::scheduler::SpawnOpts,
} }
impl<G: GenServer> GenServerBuilder<G> { impl<G: GenServer> GenServerBuilder<G> {
pub fn new(state: G) -> Self { 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 /// Add an out-of-band channel; messages arriving on it are dispatched to
@@ -665,6 +696,14 @@ impl<G: GenServer> GenServerBuilder<G> {
self 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 /// 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 /// lifetime is governed by its refs, not by joining, so the backing join
/// handle is dropped. /// handle is dropped.
@@ -677,7 +716,10 @@ impl<G: GenServer> GenServerBuilder<G> {
/// live server). Consumes the builder, carrying its `with_info` / `under` /// live server). Consumes the builder, carrying its `with_info` / `under`
/// configuration through. /// configuration through.
pub fn named(self, name: GenServerName<G>) -> NamedGenServerBuilder<G> { 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 /// Private shared body behind [`start`](Self::start) and
@@ -686,12 +728,24 @@ impl<G: GenServer> GenServerBuilder<G> {
/// under the name before returning. /// under the name before returning.
fn spawn_server(self) -> GenServerRef<G> { fn spawn_server(self) -> GenServerRef<G> {
let (tx, rx) = channel::<Envelope<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 { let handle = match supervisor {
Some(sup) => spawn_under(sup, move || server_loop::<G>(rx, state, infos)), Some(sup) => crate::scheduler::spawn_under_with(sup, stack_opts, move || {
None => spawn(move || server_loop::<G>(rx, state, infos)), 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. /// associated constants at call sites.
#[inline] #[inline]
pub const fn new(name: &'static str) -> Self { pub const fn new(name: &'static str) -> Self {
Self { name, _marker: PhantomData } Self {
name,
_marker: PhantomData,
}
} }
/// The underlying registry key. /// The underlying registry key.
@@ -758,6 +815,12 @@ impl<G: GenServer> NamedGenServerBuilder<G> {
self 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 /// Spawn the server and bind its name in one step. Fallible: returns
/// [`RegisterError::NameTaken`] if the name is already held by a different /// [`RegisterError::NameTaken`] if the name is already held by a different
/// live server. /// 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 // 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 // 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). // 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); guard.0.init(&ctx);
let idle = ctx.idle.get(); let idle = ctx.idle.get();
drop(ctx); drop(ctx);
@@ -962,8 +1029,7 @@ fn server_loop<G: GenServer>(
// info band: [nd+nw, nd+nw+ni) // info band: [nd+nw, nd+nw+ni)
// inbox arm: [nd+nw+ni] // inbox arm: [nd+nw+ni]
let sel = { let sel = {
let mut arms: Vec<&dyn Selectable> = let mut arms: Vec<&dyn Selectable> = Vec::with_capacity(nd + nw + infos.len() + 1);
Vec::with_capacity(nd + nw + infos.len() + 1);
for m in &monitors { for m in &monitors {
arms.push(&m.rx); arms.push(&m.rx);
} }
@@ -975,9 +1041,7 @@ fn server_loop<G: GenServer>(
} }
arms.push(&rx); arms.push(&rx);
match idle_deadline { match idle_deadline {
Some(dl) => { Some(dl) => select_timeout(&arms, dl.saturating_duration_since(Instant::now())),
select_timeout(&arms, dl.saturating_duration_since(Instant::now()))
}
None => Some(select(&arms)), None => Some(select(&arms)),
} }
}; };
@@ -1008,8 +1072,12 @@ fn server_loop<G: GenServer>(
// live set tracks only still-pending timers, then // live set tracks only still-pending timers, then
// dispatch. // dispatch.
match reg.lock() { match reg.lock() {
Ok(mut g) => { g.oneshots.remove(&id); } Ok(mut g) => {
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"), g.oneshots.remove(&id);
}
Err(e) => {
panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}")
}
} }
guard.0.handle_timer(msg); guard.0.handle_timer(msg);
reset_idle(&mut idle_deadline); reset_idle(&mut idle_deadline);
@@ -1023,7 +1091,9 @@ fn server_loop<G: GenServer>(
let msg = { let msg = {
let mut g = match reg.lock() { let mut g = match reg.lock() {
Ok(g) => g, 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; let r = &mut *g;
if let Some(p) = r.periodics.get_mut(&id) { if let Some(p) = r.periodics.get_mut(&id) {
@@ -1031,9 +1101,9 @@ fn server_loop<G: GenServer>(
let msg = (p.make)(); let msg = (p.make)();
let tx = match r.rearm_tx.as_ref() { let tx = match r.rearm_tx.as_ref() {
Some(tx) => tx.clone(), Some(tx) => tx.clone(),
None => panic!( None => {
"smarm: live periodic without rearm_tx (logic bug)" panic!("smarm: live periodic without rearm_tx (logic bug)")
), }
}; };
p.live = send_after_to(every, tx, Sys::Tick(id)); p.live = send_after_to(every, tx, Sys::Tick(id));
Some(msg) Some(msg)
+36 -8
View File
@@ -71,7 +71,7 @@
use crate::channel::{channel, select, Receiver, Sender}; use crate::channel::{channel, select, Receiver, Sender};
use crate::pid::Pid; 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 crate::timer::TimerId;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::marker::PhantomData; use std::marker::PhantomData;
@@ -219,7 +219,11 @@ struct Timers {
impl Timers { impl Timers {
fn new() -> Self { 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 { fn mint(&mut self) -> u64 {
@@ -248,7 +252,11 @@ pub struct Cx<Ev> {
impl<Ev> Cx<Ev> { impl<Ev> Cx<Ev> {
fn new(sys_tx: Sender<Sys>, reg: Arc<Mutex<Timers>>) -> Self { 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 /// 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> { impl<M: Machine> Clone for GenStatemRef<M> {
fn clone(&self) -> Self { 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()`. /// Panics if called outside `Runtime::run()`.
pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> { 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 (tx, rx) = channel::<M::Ev>();
let handle = spawn_actor(move || statem_loop(rx, machine)); let handle = crate::scheduler::spawn_with(opts, move || statem_loop(rx, machine));
GenStatemRef { tx, pid: handle.pid() } GenStatemRef {
tx,
pid: handle.pid(),
}
} }
/// The machine actor body: `on_start`, then one `handle` per event until the /// 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) => { Sys::StateTimeout(local) => {
let mut t = match reg.lock() { let mut t = match reg.lock() {
Ok(g) => g, 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 { match t.state {
Some((live, _)) if live == local => { 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) => { Sys::Timeout(name, local) => {
let mut t = match reg.lock() { let mut t = match reg.lock() {
Ok(g) => g, 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) { match t.named.get(name) {
Some(&(live, _)) if live == local => { 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 /// `budget-accounting` feature is enabled, since measuring it costs a
/// timestamp read on every resume. /// timestamp read on every resume.
pub budget_cycles: u64, 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. /// A snapshot of every actor in the runtime at (approximately) one moment.
@@ -211,7 +239,10 @@ pub fn snapshot() -> RuntimeSnapshot {
actors.push(info); 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 /// 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 /// all. Unlike [`snapshot`], every field of the result describes the same
/// instant, since there is only one actor to read. /// 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> { pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
with_runtime(|inner| { with_runtime(|inner| {
let slot = inner.slot_at(pid)?; let slot = inner.slot_at(pid)?;
@@ -265,6 +312,14 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
drop(cold); drop(cold);
// Counters are plain atomics, read lock-free. // 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 overruns = slot.overruns();
let messages_received = slot.messages_received(); let messages_received = slot.messages_received();
let budget_cycles = slot.budget_cycles(); let budget_cycles = slot.budget_cycles();
@@ -290,6 +345,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
overruns, overruns,
messages_received, messages_received,
budget_cycles, 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 /// want to inspect again) and want the tree view of it without re-reading
/// the runtime. /// the runtime.
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree { 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()); let mut index_of: HashMap<Pid, usize> = HashMap::with_capacity(actors.len());
for (i, a) in actors.iter().enumerate() { for (i, a) in actors.iter().enumerate() {
@@ -365,7 +424,10 @@ pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
.into_iter() .into_iter()
.filter_map(|i| build_node(i, &children_of, &orphaned, &mut slots)) .filter_map(|i| build_node(i, &children_of, &orphaned, &mut slots))
.collect(); .collect();
RuntimeTree { format_version, roots: root_nodes } RuntimeTree {
format_version,
roots: root_nodes,
}
} }
fn build_node( fn build_node(
@@ -383,5 +445,9 @@ fn build_node(
.collect() .collect()
}) })
.unwrap_or_default(); .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, waiters: Waiters,
// ----- Epoll machinery ----- // ----- Epoll machinery -----
/// The epollfd, owned by `IoThread`. Callable cross-thread via /// The epollfd, owned by `IoThread`. Callable cross-thread via
/// `epoll_ctl` per the man page. /// `epoll_ctl` per the man page.
epollfd: RawFd, epollfd: RawFd,
@@ -147,7 +146,6 @@ pub struct IoThread {
shutdown_write: RawFd, shutdown_write: RawFd,
// ----- Threads ----- // ----- Threads -----
pool_thread: Option<OsJoinHandle<()>>, pool_thread: Option<OsJoinHandle<()>>,
epoll_thread: Option<OsJoinHandle<()>>, epoll_thread: Option<OsJoinHandle<()>>,
} }
@@ -284,9 +282,8 @@ impl IoThread {
events, events,
u64: fd as u64, u64: fd as u64,
}; };
let r = unsafe { let r =
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_ADD, fd, &mut ev as *mut _) unsafe { libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_ADD, fd, &mut ev as *mut _) };
};
if r < 0 { if r < 0 {
return Err(io::Error::last_os_error()); return Err(io::Error::last_os_error());
} }
@@ -398,12 +395,7 @@ fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
loop { loop {
let n = unsafe { let n = unsafe {
libc::epoll_wait( libc::epoll_wait(epollfd, events.as_mut_ptr(), MAX_EVENTS as libc::c_int, -1)
epollfd,
events.as_mut_ptr(),
MAX_EVENTS as libc::c_int,
-1,
)
}; };
if n < 0 { if n < 0 {
@@ -438,12 +430,7 @@ fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
let entry = w.remove(&fd); let entry = w.remove(&fd);
if entry.is_some() { if entry.is_some() {
unsafe { unsafe {
libc::epoll_ctl( libc::epoll_ctl(epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
epollfd,
libc::EPOLL_CTL_DEL,
fd,
std::ptr::null_mut(),
);
} }
} }
entry entry
+37 -30
View File
@@ -11,35 +11,36 @@
//! //!
//! See `LOOM.md` for the design intent and the deferred-for-later list. //! See `LOOM.md` for the design intent and the deferred-for-later list.
pub mod stack;
pub mod context;
pub mod preempt;
pub mod pid;
pub mod actor; pub mod actor;
pub mod causal;
pub mod channel; pub mod channel;
pub mod scheduler; pub mod context;
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 gen_server; pub mod gen_server;
pub mod gen_statem; pub mod gen_statem;
pub mod introspect; pub mod introspect;
pub mod io;
pub mod link;
pub mod monitor;
pub mod mutex;
#[cfg(feature = "observer")] #[cfg(feature = "observer")]
pub mod observer; pub mod observer;
pub mod runtime;
pub(crate) mod park; pub(crate) mod park;
pub mod pg;
pub mod pid;
pub mod preempt;
pub(crate) mod raw_mutex; pub(crate) mod raw_mutex;
pub(crate) mod slot_state; pub mod registry;
pub(crate) mod sync_shim;
#[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures #[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures
pub mod run_queue; 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 trace;
pub mod causal;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Global allocator // Global allocator
@@ -58,33 +59,39 @@ pub use channel::{
}; };
pub use gen_server::{ pub use gen_server::{
call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer, 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::{ pub use gen_statem::{
CallError as GenStatemCallError, Cx, Machine, Reply, Resolution, SendError as GenStatemSendError, CallError as GenStatemCallError, Cx, GenStatemRef, Machine, Reply, Resolution,
GenStatemRef, SendError as GenStatemSendError,
}; };
pub use introspect::{ pub use introspect::{
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree, 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")] #[cfg(feature = "observer")]
pub use observer::{ObserverReply, ObserverRequest}; pub use observer::{ObserverReply, ObserverRequest};
pub use link::{link, trap_exit, unlink, ExitSignal}; pub use pg::{
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId}; dispatch, join, leave, members, members_as, pick, pick_as, Incarnation, Member, NodeId,
pub use mutex::{LockTimeout, Mutex, MutexGuard}; };
pub use pid::{Addressable, Erased, Name, Pid, RawPid}; 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::{ pub use registry::{
install, lookup_as, register, send, send_dyn, send_to, unregister, whereis, RegisterError, install, lookup_as, register, resolve_name, send, send_dyn, send_to, unregister, whereis,
SendError, NameResolution, RegisterError, SendError,
}; };
pub use runtime::{init, Config, Runtime}; pub use runtime::{init, Config, Runtime};
pub use scheduler::{ pub use scheduler::{
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named, 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, send_after_named_wall, send_after_wall, sleep, sleep_wall, spawn, spawn_addr, spawn_addr_with,
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable, spawn_under, spawn_under_with, spawn_with, try_spawn, try_spawn_under_with, wait_readable,
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle, 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 supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
pub use timer::TimerId; pub use timer::TimerId;
+4 -1
View File
@@ -157,7 +157,10 @@ pub fn link<A>(target: Pid<A>) {
}); });
match my_trap { match my_trap {
Some(tx) => { Some(tx) => {
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc }); let _ = tx.send(ExitSignal {
from: target,
reason: DownReason::NoProc,
});
} }
None => request_stop(me), None => request_stop(me),
} }
+62 -1
View File
@@ -171,12 +171,73 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
}); });
if !registered { if !registered {
let _ = tx.send(Down { pid: target, reason: DownReason::NoProc }); let _ = tx.send(Down {
pid: target,
reason: DownReason::NoProc,
});
} }
Monitor { id, target, rx } 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 /// 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 /// 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 /// `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) { if st.holder == Some(pid) {
return; 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) => { Some(pos) => {
st.waiters.remove(pos); st.waiters.remove(pos);
true true
@@ -246,7 +250,10 @@ impl<T> Mutex<T> {
Some(v) => v, Some(v) => v,
None => panic!("smarm: Mutex value missing on free fast path (core corrupt)"), 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, Some(v) => v,
None => panic!("smarm: Mutex value missing after grant (core corrupt)"), 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 { } else {
Err(LockTimeout) Err(LockTimeout)
} }
@@ -315,7 +325,10 @@ impl<T> Mutex<T> {
Some(v) => v, Some(v) => v,
None => panic!("smarm: Mutex value missing on try_lock free path (core corrupt)"), 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. /// Blocking fallback used when called outside the smarm runtime.
@@ -329,10 +342,15 @@ impl<T> Mutex<T> {
Ok(mut g) => g.take(), Ok(mut g) => g.take(),
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"), 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(); 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 /// lock and one protected value; locking through any clone excludes
/// every other clone. /// every other clone.
fn clone(&self) -> Self { 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, Some(v) => v,
None => panic!("smarm: MutexGuard value missing (core corrupt)"), None => panic!("smarm: MutexGuard value missing (core corrupt)"),
}; };
f.debug_tuple("MutexGuard") f.debug_tuple("MutexGuard").field(value).finish()
.field(value)
.finish()
} }
} }
+38 -15
View File
@@ -105,7 +105,9 @@ mod parker {
impl Parker { impl Parker {
pub(super) fn new() -> Self { pub(super) fn new() -> Self {
Self { state: AtomicU32::new(EMPTY) } Self {
state: AtomicU32::new(EMPTY),
}
} }
/// Returns `true` = woken (permit consumed), `false` = timed out. /// Returns `true` = woken (permit consumed), `false` = timed out.
@@ -211,7 +213,10 @@ mod parker {
impl Parker { impl Parker {
pub(super) fn new() -> Self { 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. /// 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 // The CAS is the exactly-one guarantee: whoever clears the bit
// owns the wake; a racing wake_one retries on the observed value // owns the wake; a racing wake_one retries on the observed value
// (coherence: a failed CAS can never read older than `mask`). // (coherence: a failed CAS can never read older than `mask`).
match self.idle.compare_exchange( match self
mask, .idle
mask & !bit, .compare_exchange(mask, mask & !bit, Ordering::AcqRel, Ordering::Acquire)
Ordering::AcqRel, {
Ordering::Acquire,
) {
Ok(_) => { Ok(_) => {
self.parkers[id].unpark(); self.parkers[id].unpark();
return true; return true;
@@ -467,7 +470,8 @@ impl Coordinator {
// with the deadline still NO_DEADLINE compares `new < MAX` = true // with the deadline still NO_DEADLINE compares `new < MAX` = true
// and over-wakes — the benign direction. (Under the mandated timer // and over-wakes — the benign direction. (Under the mandated timer
// serialization this interleaving cannot occur anyway.) // 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 true
} }
@@ -578,7 +582,10 @@ mod tests {
let t0 = Instant::now(); let t0 = Instant::now();
let r = c.park(0, None, || false); let r = c.park(0, None, || false);
assert_eq!(r, ParkResult::Woken); 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] #[test]
@@ -632,14 +639,20 @@ mod tests {
// Wait until all four are published idle. // Wait until all four are published idle.
let t0 = Instant::now(); let t0 = Instant::now();
while c.idle_mask().count_ones() != N as u32 { 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(); std::thread::yield_now();
} }
assert!(c.wake_one()); assert!(c.wake_one());
// Exactly one wakes; give the others a beat to (incorrectly) wake. // Exactly one wakes; give the others a beat to (incorrectly) wake.
let t0 = Instant::now(); let t0 = Instant::now();
while woken.load(O::SeqCst) == 0 { 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::yield_now();
} }
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(100));
@@ -706,7 +719,10 @@ mod tests {
assert_eq!(c.armed_deadline_nanos(), c.deadline_nanos(d2)); assert_eq!(c.armed_deadline_nanos(), c.deadline_nanos(d2));
c.disarm_timer(0); c.disarm_timer(0);
assert_eq!(c.armed_deadline_nanos(), NO_DEADLINE); 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); c.disarm_timer(1);
} }
@@ -721,7 +737,10 @@ mod tests {
let t0 = Instant::now(); let t0 = Instant::now();
let r = c.park(0, Some(far), || false); let r = c.park(0, Some(far), || false);
assert_eq!(r, ParkResult::Woken, "re-arm wake lost"); 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); c.disarm_timer(0);
} }
@@ -796,7 +815,11 @@ mod tests {
assert!(c.try_arm_timer(0, far)); assert!(c.try_arm_timer(0, far));
c.note_deadline(near); c.note_deadline(near);
let r = c.park(0, Some(far), || false); 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); c.disarm_timer(0);
} }
} }
+80 -17
View File
@@ -221,7 +221,9 @@ pub(crate) struct ProcessGroups {
impl ProcessGroups { impl ProcessGroups {
pub(crate) fn new() -> Self { 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 /// 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> { fn members_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Vec<Pid> {
self.groups self.groups
.get(group) .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() .unwrap_or_default()
} }
/// The first live member of `group` in insertion order — stateless /// The first live member of `group` in insertion order — stateless
/// first-live `pick`, with the same read-path backstop as `members_where`. /// 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> { 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. /// Build the full member identity for `pid` from runtime identity.
fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member { 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, /// 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 mon = monitor(pid);
let (rejected, reaped) = with_runtime(|inner| { 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 mut pg = inner.process_groups.lock();
let reaped = pg.reap_group(&group); let reaped = pg.reap_group(&group);
let rejected = pg.join(&group, ms); let rejected = pg.join(&group, ms);
@@ -507,7 +525,11 @@ mod tests {
let (tx, rx) = channel::<Down>(); let (tx, rx) = channel::<Down>();
let ms = Membership { let ms = Membership {
member: member(index, generation), member: member(index, generation),
monitor: Monitor { id: MonitorId(0), target: pid, rx }, monitor: Monitor {
id: MonitorId(0),
target: pid,
rx,
},
}; };
(ms, tx) (ms, tx)
} }
@@ -518,7 +540,10 @@ mod tests {
let (a, _ta) = synth(1, 0); let (a, _ta) = synth(1, 0);
let (b, _tb) = synth(1, 0); let (b, _tb) = synth(1, 0);
assert!(pg.join("workers", a).is_none(), "first join inserts"); 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)]); assert_eq!(pg.members_of("workers"), vec![member(1, 0)]);
} }
@@ -542,7 +567,10 @@ mod tests {
let (a, _ta) = synth(1, 0); let (a, _ta) = synth(1, 0);
let (b, _tb) = synth(1, 1); let (b, _tb) = synth(1, 1);
assert!(pg.join("g", a).is_none()); 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)]); assert_eq!(pg.members_of("g"), vec![member(1, 0), member(1, 1)]);
} }
@@ -555,16 +583,27 @@ mod tests {
pg.join("g", b); pg.join("g", b);
assert!(pg.leave("g", member(1, 0)).is_some()); assert!(pg.leave("g", member(1, 0)).is_some());
assert_eq!(pg.members_of("g"), vec![member(2, 0)]); 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.leave("g", member(2, 0)).is_some());
assert!(pg.members_of("g").is_empty(), "group is now empty"); 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] #[test]
fn remove_where_sweeps_every_group() { fn remove_where_sweeps_every_group() {
let mut pg = ProcessGroups::new(); 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); pg.join(g, m);
} }
// Death of pid index 1 (any generation) evicts it everywhere. // Death of pid index 1 (any generation) evicts it everywhere.
@@ -582,8 +621,16 @@ mod tests {
let pid = Pid::new(1, 0); let pid = Pid::new(1, 0);
let (tx, rx) = channel::<Down>(); let (tx, rx) = channel::<Down>();
let dead = Membership { let dead = Membership {
member: Member { node: DEFAULT_NODE_ID, incarnation: Incarnation::new(7), pid }, member: Member {
monitor: Monitor { id: MonitorId(0), target: pid, rx }, node: DEFAULT_NODE_ID,
incarnation: Incarnation::new(7),
pid,
},
monitor: Monitor {
id: MonitorId(0),
target: pid,
rx,
},
}; };
let _keep = tx; let _keep = tx;
let (live, _tl) = synth(2, 0); let (live, _tl) = synth(2, 0);
@@ -614,9 +661,17 @@ mod tests {
pg.join("b", b1); pg.join("b", b1);
// pid 1 dies: its group-a monitor receives a Down. Its group-b monitor // 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. // 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"); 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_eq!(pg.members_of("a"), vec![member(2, 0)]);
assert!(pg.members_of("b").is_empty(), "swept from b too; pruned"); 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 dead = Pid::new(1, 0);
let oracle = |pid: Pid| pid != dead; 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!(
assert_eq!(pg.first_member_where("g", oracle), Some(Pid::new(2, 0)), "pick skips the dead first member"); 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 // Backstop does not evict — that stays the monitor's job; raw storage
// still holds both until reap runs. // 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`]. /// here; typing happens at typed-actor boundaries via [`Pid::from_raw`].
#[inline] #[inline]
pub const fn new(index: u32, generation: u32) -> Self { 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. /// resolution paths.
#[inline] #[inline]
pub(crate) const fn from_raw(raw: RawPid) -> Self { 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 /// 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. /// associated constants at call sites.
#[inline] #[inline]
pub const fn new(name: &'static str) -> Self { pub const fn new(name: &'static str) -> Self {
Self { name, _marker: PhantomData } Self {
name,
_marker: PhantomData,
}
} }
/// The underlying registry key. /// 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())); CURRENT_SLOT.with(|c| c.set(std::ptr::null()));
} }
/// RFC 007 (`smarm-causal`) — raw pointer to the on-CPU actor's slot, null on /// Raw pointer to the on-CPU actor's slot, null on the scheduler's own
/// the scheduler's own stack. Same lifetime argument as `note_overrun`: the /// stack. Same lifetime argument as `note_overrun`: the slot is never
/// slot is never reclaimed while its actor is on-CPU. /// reclaimed while its actor is on-CPU. Consumers: the `smarm-causal`
#[cfg(feature = "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] #[inline]
pub(crate) fn current_slot_ptr() -> *const crate::runtime::Slot { pub(crate) fn current_slot_ptr() -> *const crate::runtime::Slot {
CURRENT_SLOT.with(|c| c.get()) CURRENT_SLOT.with(|c| c.get())
+4 -1
View File
@@ -166,7 +166,10 @@ impl<T> RawMutex<T> {
{ {
self.lock_slow(); self.lock_slow();
} }
RawMutexGuard { m: self, prev_preempt } RawMutexGuard {
m: self,
prev_preempt,
}
} }
#[cold] #[cold]
+107 -15
View File
@@ -1,4 +1,3 @@
//! Give an actor a name so other actors can find it and message it. //! 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 //! 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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
SendError::Unresolved(_) => write!(f, "no live actor registered under that name"), 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::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::Closed(_) => write!(f, "the actor's channel for this type is closed"),
SendError::NoMember(_) => write!(f, "no live member in the process group"), SendError::NoMember(_) => write!(f, "no live member in the process group"),
@@ -243,7 +244,10 @@ struct Mailbox {
impl Mailbox { impl Mailbox {
fn new(pid: Pid) -> Self { 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 /// Clone the `Sender<M>` for this actor, if it has one. Called **under the
@@ -294,7 +298,10 @@ pub(crate) struct Registry {
impl Registry { impl Registry {
pub(crate) fn new() -> Self { 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 /// 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. /// wholesale on pid mismatch) and is left untouched.
fn prune_holder(&mut self, holder: Pid) { fn prune_holder(&mut self, holder: Pid) {
self.by_name.retain(|_, p| *p != holder); 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()); self.by_index.remove(&holder.index());
} }
} }
@@ -351,7 +362,11 @@ impl Registry {
.iter() .iter()
.filter_map(|(&n, &p)| (p == mb.pid).then_some(n)) .filter_map(|(&n, &p)| (p == mb.pid).then_some(n))
.collect(); .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>, tx: Sender<M>,
) -> Result<(), RegisterError> { ) -> Result<(), RegisterError> {
with_runtime(|inner| { 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(); let mut reg = inner.registry.lock();
if !live(inner, me) { if !live(inner, me) {
return Err(RegisterError::NoProc); 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. /// index from a dead prior incarnation (pid mismatch) is replaced wholesale.
/// Caller holds the registry lock and has established that `me` is live. /// 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>) { 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 { if mb.pid != me {
*mb = Mailbox::new(me); *mb = Mailbox::new(me);
} }
mb.channels.insert( mb.channels.insert(
TypeId::of::<M>(), 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>) { pub(crate) fn install_for<M: Send + 'static>(pid: Pid, tx: Sender<M>) {
with_runtime(|inner| { with_runtime(|inner| {
let mut reg = inner.registry.lock(); 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); 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 /// 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 /// [`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 /// 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 // 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. // 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)) Some((pid, tx))
}) })
} }
@@ -535,7 +613,11 @@ pub fn unregister(name: &str) -> Option<Pid> {
with_runtime(|inner| { with_runtime(|inner| {
let mut reg = inner.registry.lock(); let mut reg = inner.registry.lock();
let pid = reg.by_name.remove(name)?; 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); reg.prune_holder(pid);
return Err(SendError::Unresolved(msg)); 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, Some(tx) => tx,
None => return Err(SendError::NoChannel(msg)), 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) { match reg.by_index.get(&pid.index()).map(|m| m.pid) {
// Exact incarnation, still alive: its `M` channel, or NoChannel. // Exact incarnation, still alive: its `M` channel, or NoChannel.
Some(stored) if stored == pid && live(inner, pid) => { 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, Some(tx) => tx,
None => return Err(SendError::NoChannel(msg)), None => return Err(SendError::NoChannel(msg)),
} }
@@ -611,7 +702,8 @@ fn send_to_pid<M: Send + 'static>(
_ => return Err(SendError::Dead(msg)), _ => 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 /// Deliver `msg` directly to the exact actor identified by `pid`. Unlike
+28 -5
View File
@@ -222,7 +222,10 @@ impl MpmcRing {
if diff == 0 { if diff == 0 {
// Our turn: claim the position. // Our turn: claim the position.
match self.enqueue_pos.0.compare_exchange_weak( match self.enqueue_pos.0.compare_exchange_weak(
pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed, pos,
pos + 1,
Ordering::Relaxed,
Ordering::Relaxed,
) { ) {
Ok(_) => { Ok(_) => {
// SAFETY: the claim gives us exclusive write access // SAFETY: the claim gives us exclusive write access
@@ -250,7 +253,10 @@ impl MpmcRing {
let diff = seq as isize - (pos + 1) as isize; let diff = seq as isize - (pos + 1) as isize;
if diff == 0 { if diff == 0 {
match self.dequeue_pos.0.compare_exchange_weak( match self.dequeue_pos.0.compare_exchange_weak(
pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed, pos,
pos + 1,
Ordering::Relaxed,
Ordering::Relaxed,
) { ) {
Ok(_) => { Ok(_) => {
// SAFETY: the claim gives us exclusive read access; // SAFETY: the claim gives us exclusive read access;
@@ -464,19 +470,36 @@ mod tests {
let popped = popped.lock().unwrap(); let popped = popped.lock().unwrap();
assert_eq!(popped.len(), total, "count mismatch"); 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!(set.len(), total, "duplicate or lost element");
assert_eq!(pop(&q), None); assert_eq!(pop(&q), None);
} }
#[test] #[test]
fn mpmc_exactly_once_contended() { 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] #[test]
fn striped_exactly_once_contended() { 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] #[test]
+413 -39
View File
@@ -65,6 +65,8 @@
//! word stores are `Release`, loads are `Acquire`. The chain that matters: //! word stores are `Release`, loads are `Acquire`. The chain that matters:
//! the park path stores `sp` (Relaxed) *before* its Release transition; any //! the park path stores `sp` (Relaxed) *before* its Release transition; any
//! later Acquire transition/load of the word therefore observes that `sp`. //! 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 //! 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. //! 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. //! deadline, so an expiry wakes one scheduler, not a herd.
use crate::actor::{ use crate::actor::{
clear_current_pid, is_actor_done, reset_actor_done, set_current_actor_box, clear_current_pid, is_actor_done, reset_actor_done, set_current_actor_box, set_current_pid,
set_current_pid, take_last_outcome, Actor, Outcome, take_last_outcome, Actor, Outcome,
}; };
use crate::channel::Sender; use crate::channel::Sender;
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
use crate::io::IoThread; use crate::io::IoThread;
use crate::monitor::{Down, DownReason, MonitorId}; use crate::monitor::{Down, DownReason, MonitorId};
use crate::pid::Pid; use crate::pid::Pid;
@@ -122,11 +125,8 @@ use crate::raw_mutex::RawMutex;
use crate::slot_state::{StateWord, Status, Unpark}; use crate::slot_state::{StateWord, Status, Unpark};
use crate::supervisor::Signal; use crate::supervisor::Signal;
use crate::timer::Timers; use crate::timer::Timers;
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
use std::sync::atomic::{ use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering};
AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering,
};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@@ -160,6 +160,8 @@ pub struct Config {
alloc_interval: u32, alloc_interval: u32,
timeslice_cycles: u64, timeslice_cycles: u64,
stack_pool_cap: usize, stack_pool_cap: usize,
stack_reserve: usize,
stack_guard: usize,
max_actors: usize, max_actors: usize,
wake_slot: bool, wake_slot: bool,
node_id: crate::pg::NodeId, node_id: crate::pg::NodeId,
@@ -171,10 +173,14 @@ impl Config {
pub fn exact(n: usize) -> Self { pub fn exact(n: usize) -> Self {
assert!(n >= 1, "scheduler thread count must be ≥ 1"); assert!(n >= 1, "scheduler thread count must be ≥ 1");
Self { Self {
min: n, max: n, exact: Some(n), min: n,
max: n,
exact: Some(n),
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL, alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES, timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: n * 4, stack_pool_cap: n * 4,
stack_reserve: DEFAULT_STACK_RESERVE,
stack_guard: DEFAULT_STACK_GUARD,
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false, wake_slot: false,
node_id: crate::pg::DEFAULT_NODE_ID, node_id: crate::pg::DEFAULT_NODE_ID,
@@ -190,10 +196,14 @@ impl Config {
assert!(e >= 1, "exact must be ≥ 1"); assert!(e >= 1, "exact must be ≥ 1");
} }
Self { Self {
min, max, exact, min,
max,
exact,
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL, alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES, timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: max * 4, stack_pool_cap: max * 4,
stack_reserve: DEFAULT_STACK_RESERVE,
stack_guard: DEFAULT_STACK_GUARD,
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false, wake_slot: false,
node_id: crate::pg::DEFAULT_NODE_ID, node_id: crate::pg::DEFAULT_NODE_ID,
@@ -226,6 +236,31 @@ impl Config {
self 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 /// Capacity of the actor slot table — the maximum number of
/// **simultaneously live** actors (total spawned over a run is unbounded; /// **simultaneously live** actors (total spawned over a run is unbounded;
/// slots are recycled). The table is a fixed slab allocated once at /// slots are recycled). The table is a fixed slab allocated once at
@@ -289,10 +324,14 @@ impl Default for Config {
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(1); .unwrap_or(1);
Self { Self {
min: 1, max: avail, exact: None, min: 1,
max: avail,
exact: None,
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL, alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES, timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
stack_pool_cap: avail * 4, stack_pool_cap: avail * 4,
stack_reserve: DEFAULT_STACK_RESERVE,
stack_guard: DEFAULT_STACK_GUARD,
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false, wake_slot: false,
node_id: crate::pg::DEFAULT_NODE_ID, node_id: crate::pg::DEFAULT_NODE_ID,
@@ -341,7 +380,9 @@ pub struct RuntimeStats {
impl RuntimeStats { impl RuntimeStats {
/// Sum of run queue lengths across all scheduler threads. /// Sum of run queue lengths across all scheduler threads.
pub fn total_run_queue_len(&self) -> u64 { 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)) .map(|s| s.run_queue_len.load(Ordering::Relaxed))
.sum() .sum()
} }
@@ -365,7 +406,9 @@ impl RuntimeStats {
/// scheduler threads. Counters are reset at the start of each `run()`, /// scheduler threads. Counters are reset at the start of each `run()`,
/// so after a run this reads that run's total. /// so after a run this reads that run's total.
pub fn slot_hits(&self) -> u64 { pub fn slot_hits(&self) -> u64 {
self.inner.stats.iter() self.inner
.stats
.iter()
.map(|s| s.slot_hits.load(Ordering::Relaxed)) .map(|s| s.slot_hits.load(Ordering::Relaxed))
.sum() .sum()
} }
@@ -373,7 +416,9 @@ impl RuntimeStats {
/// RFC 005: total slot occupants displaced to the shared queue, summed /// RFC 005: total slot occupants displaced to the shared queue, summed
/// across scheduler threads. Reset at the start of each `run()`. /// across scheduler threads. Reset at the start of each `run()`.
pub fn slot_displacements(&self) -> u64 { pub fn slot_displacements(&self) -> u64 {
self.inner.stats.iter() self.inner
.stats
.iter()
.map(|s| s.slot_displacements.load(Ordering::Relaxed)) .map(|s| s.slot_displacements.load(Ordering::Relaxed))
.sum() .sum()
} }
@@ -383,7 +428,48 @@ impl RuntimeStats {
// Slot — packed state word + hot atomics + cold lifecycle data // 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>; pub(crate) type Closure = Box<dyn FnOnce() + Send>;
@@ -396,6 +482,28 @@ pub(crate) struct SlotCold {
/// epoch-matched unpark. /// epoch-matched unpark.
pub(crate) waiters: Vec<(Pid, u32)>, pub(crate) waiters: Vec<(Pid, u32)>,
pub(crate) outcome: Option<Outcome>, 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>>, pub(crate) supervisor_channel: Option<Sender<Signal>>,
/// Watchers registered via `monitor()`, each tagged with its /// Watchers registered via `monitor()`, each tagged with its
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one /// `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 /// Release transition out of Running; read after the Acquire transition
/// Queued→Running. Relaxed is sufficient — ordering rides on `word`. /// Queued→Running. Relaxed is sufficient — ordering rides on `word`.
sp: AtomicUsize, 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, /// 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 /// 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). /// on the resume path while the actor cannot be finalized (it is on-CPU).
@@ -497,6 +634,13 @@ impl Slot {
Self { Self {
word: StateWord::new(), word: StateWord::new(),
sp: AtomicUsize::new(0), 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()), stop_ptr: AtomicPtr::new(std::ptr::null_mut()),
closure: AtomicPtr::new(std::ptr::null_mut()), closure: AtomicPtr::new(std::ptr::null_mut()),
overruns: AtomicU64::new(0), overruns: AtomicU64::new(0),
@@ -514,6 +658,8 @@ impl Slot {
actor: None, actor: None,
waiters: Vec::new(), waiters: Vec::new(),
outcome: None, outcome: None,
terminal: None,
watchable: false,
supervisor_channel: None, supervisor_channel: None,
monitors: Vec::new(), monitors: Vec::new(),
links: Vec::new(), links: Vec::new(),
@@ -550,6 +696,23 @@ impl Slot {
/// Read the overrun tally (Relaxed; the snapshot reads cross-thread). /// Read the overrun tally (Relaxed; the snapshot reads cross-thread).
#[inline] #[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 { pub(crate) fn overruns(&self) -> u64 {
self.overruns.load(Ordering::Relaxed) self.overruns.load(Ordering::Relaxed)
} }
@@ -560,7 +723,8 @@ impl Slot {
#[inline] #[inline]
pub(crate) fn record_message(&self) { pub(crate) fn record_message(&self) {
let v = self.messages_received.load(Ordering::Relaxed); 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). /// Read the received-message tally (Relaxed; cross-thread snapshot read).
@@ -579,7 +743,8 @@ impl Slot {
#[inline] #[inline]
pub(crate) fn add_budget(&self, cycles: u64) { pub(crate) fn add_budget(&self, cycles: u64) {
let v = self.budget_cycles.load(Ordering::Relaxed); 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 /// Read the accumulated budget cycles (Relaxed). Always 0 unless the
@@ -718,7 +883,6 @@ impl Slot {
Some(*unsafe { Box::from_raw(raw) }) Some(*unsafe { Box::from_raw(raw) })
} }
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -802,17 +966,23 @@ pub(crate) struct RuntimeInner {
pub(crate) stack_pool: RawMutex<Vec<crate::stack::Stack>>, pub(crate) stack_pool: RawMutex<Vec<crate::stack::Stack>>,
/// Maximum number of stacks to retain in the pool. /// Maximum number of stacks to retain in the pool.
pub(crate) stack_pool_cap: usize, 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 { impl RuntimeInner {
// Private constructor taking the parsed Config fields one-for-one; a params // 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)] #[allow(clippy::too_many_arguments)]
fn new( fn new(
thread_count: usize, thread_count: usize,
alloc_interval: u32, alloc_interval: u32,
timeslice_cycles: u64, timeslice_cycles: u64,
stack_pool_cap: usize, stack_pool_cap: usize,
stack_reserve: usize,
stack_guard: usize,
max_actors: usize, max_actors: usize,
wake_slot: bool, wake_slot: bool,
node_id: crate::pg::NodeId, node_id: crate::pg::NodeId,
@@ -854,6 +1024,8 @@ impl RuntimeInner {
process_groups: RawMutex::new(crate::pg::ProcessGroups::new()), process_groups: RawMutex::new(crate::pg::ProcessGroups::new()),
stack_pool: RawMutex::new(Vec::new()), stack_pool: RawMutex::new(Vec::new()),
stack_pool_cap, 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) 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 /// Pop a vacant slot index, or die loudly. The fixed slab is a deliberate
/// v0.5 simplification (ROADMAP: "Deferred"); the panic names the fix. /// 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 { pub(crate) fn allocate_slot(&self) -> u32 {
match self.free.lock().pop() { match self.try_allocate_slot() {
Some(idx) => idx, Some(idx) => idx,
None => panic!( None => panic!(
"smarm: actor slot table exhausted — {} actors are live \ "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. /// Initialise the runtime with the given config. Returns a reusable handle.
pub fn init(config: Config) -> Runtime { 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(); let n = config.resolved_thread_count();
Runtime { Runtime {
inner: RuntimeInner::new( inner: RuntimeInner::new(
@@ -1052,6 +1246,8 @@ pub fn init(config: Config) -> Runtime {
config.alloc_interval, config.alloc_interval,
config.timeslice_cycles, config.timeslice_cycles,
config.stack_pool_cap, config.stack_pool_cap,
config.stack_reserve,
config.stack_guard,
config.max_actors, config.max_actors,
config.wake_slot, config.wake_slot,
config.node_id, config.node_id,
@@ -1104,11 +1300,13 @@ impl Runtime {
// Re-initialise shared state for this run. // Re-initialise shared state for this run.
assert_eq!( assert_eq!(
self.inner.run_queue.len(), 0, self.inner.run_queue.len(),
0,
"run() called while previous run still active" "run() called while previous run still active"
); );
debug_assert_eq!( 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" "run() called while previous run still active"
); );
// RFC 018: the IO producers reach the runtime (slot table + unpark) // RFC 018: the IO producers reach the runtime (slot table + unpark)
@@ -1255,7 +1453,9 @@ impl Runtime {
/// Snapshot of runtime statistics for introspection / tests. /// Snapshot of runtime statistics for introspection / tests.
pub fn stats(&self) -> RuntimeStats { pub fn stats(&self) -> RuntimeStats {
RuntimeStats { inner: self.inner.clone() } RuntimeStats {
inner: self.inner.clone(),
}
} }
} }
@@ -1290,7 +1490,10 @@ thread_local! {
} }
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub(crate) enum YieldIntent { Yield, Park } pub(crate) enum YieldIntent {
Yield,
Park,
}
pub(crate) fn set_yield_intent(i: YieldIntent) { pub(crate) fn set_yield_intent(i: YieldIntent) {
YIELD_INTENT.with(|c| c.set(i)); 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 // 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 /// 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`. /// from `allocate_slot`) and publish it as Queued. Returns the new `Pid`.
/// Called by `scheduler::spawn_under`; lives here next to its inverse /// 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 pid = Pid::new(idx, gen);
let stop = Arc::new(AtomicBool::new(false)); 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(); let mut cold = slot.cold.lock();
debug_assert!(cold.actor.is_none(), "install over live actor"); debug_assert!(cold.actor.is_none(), "install over live actor");
debug_assert!(cold.waiters.is_empty() && cold.monitors.is_empty() && cold.links.is_empty()); 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.outstanding_handles = 1;
cold.outcome = None; cold.outcome = None;
cold.pending_io_result = None; cold.pending_io_result = None;
} }
slot.sp.store(sp, Ordering::Relaxed); 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.store_closure(closure);
slot.reset_counters(); slot.reset_counters();
inner.live_actors.fetch_add(1, Ordering::Relaxed); 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. // Release store orders everything above before any Acquire reader.
slot.word.publish_queued(gen); slot.word.publish_queued(gen);
inner.enqueue(pid); inner.enqueue(pid);
crate::te!(crate::trace::Event::Spawn { parent: supervisor, child: pid }); crate::te!(crate::trace::Event::Spawn {
parent: supervisor,
child: pid
});
pid pid
} }
@@ -1362,14 +1687,19 @@ pub(crate) fn install_actor(
/// is released — a last-sender drop can unpark a receiver, which takes the /// 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. /// run-queue mutex; legal under a cold lock, but pointless to nest.
pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) { 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 dropped_outside;
{ {
let mut cold = slot.cold.lock(); let mut cold = slot.cold.lock();
if slot.status_for(pid) != Status::Done || cold.outstanding_handles != 0 { if slot.status_for(pid) != Status::Done || cold.outstanding_handles != 0 {
return; // already reclaimed, or not yet eligible 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 = ( dropped_outside = (
cold.outcome.take(), cold.outcome.take(),
cold.supervisor_channel.take(), cold.supervisor_channel.take(),
@@ -1379,6 +1709,7 @@ pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) {
cold.waiters.clear(); cold.waiters.clear();
cold.monitors.clear(); cold.monitors.clear();
cold.links.clear(); cold.links.clear();
cold.watchable = false;
slot.reset_counters(); slot.reset_counters();
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release); slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
// The generation bump IS the reclaim: every stale pid is dead from // 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"), None => panic!("finalize_actor: actor vanished"),
}; };
cold.outcome = Some(joiner_outcome); 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); slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
// Done is published under the cold lock, so join's // Done is published under the cold lock, so join's
// check-Done-or-register-waiter (also under it) can never miss: it // 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). // (the trap sender can unpark its receiver — keep that outside too).
let supervisor_pid = actor.supervisor; let supervisor_pid = actor.supervisor;
let Actor { stack, .. } = actor; let Actor { stack, .. } = actor;
{ recycle_stack(inner, stack);
let mut pool = inner.stack_pool.lock();
if pool.len() < inner.stack_pool_cap {
pool.push(stack);
}
// else: drop here → munmap, same as before
}
// Deliver to supervisor. ROOT_PID resolves to no slot → silently absorbed. // Deliver to supervisor. ROOT_PID resolves to no slot → silently absorbed.
let sender = inner.slot_at(supervisor_pid).and_then(|sup| { 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 // Notify monitors. Sent outside any slot lock: `send` may unpark a parked
// receiver, which takes the run-queue mutex. // receiver, which takes the run-queue mutex.
for (_, m) in monitors { 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 // 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 { match trap {
Some(Some(tx)) => { 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), Some(None) => crate::scheduler::request_stop(peer),
None => {} None => {}
@@ -1594,6 +1937,8 @@ fn fire_due_timers(inner: &Arc<RuntimeInner>, try_only: bool) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) { 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); crate::preempt::configure_preempt(inner.alloc_interval, inner.timeslice_cycles);
let stats = &inner.stats[slot_idx]; 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) let io_out = inner.io_outstanding.load(Ordering::Acquire)
+ inner.io_fd_waiters.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() { let pop = match inner.run_queue.pop() {
Some(pid) => Pop::Got(pid), Some(pid) => Pop::Got(pid),
None => { None => {
@@ -1799,7 +2146,9 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
} }
// Update per-thread stats: record who's on-CPU. // 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_actor_sp(sp);
set_current_pid(pid); set_current_pid(pid);
@@ -1841,7 +2190,15 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
crate::preempt::clear_current_slot(); crate::preempt::clear_current_slot();
let intent = YIELD_INTENT.with(|c| c.get()); 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() { if is_actor_done() {
crate::te!(crate::trace::Event::Done(pid)); crate::te!(crate::trace::Event::Done(pid));
@@ -1863,6 +2220,23 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
inner.enqueue(pid); inner.enqueue(pid);
} }
YieldIntent::Park => { 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) { if slot.word.park_return(gen) {
// RFC 007 audit: an in-site park drops its sample // RFC 007 audit: an in-site park drops its sample
// tail (nothing flushes it; on_resume re-arms). // tail (nothing flushes it; on_resume re-arms).
+209 -60
View File
@@ -68,9 +68,7 @@
use crate::actor::current_pid; use crate::actor::current_pid;
use crate::channel::Sender; use crate::channel::Sender;
use crate::pid::{Name, Pid}; use crate::pid::{Name, Pid};
use crate::runtime::{ use crate::runtime::{self, RuntimeInner, YieldIntent, RUNTIME};
self, RuntimeInner, YieldIntent, RUNTIME,
};
use crate::supervisor::Signal; use crate::supervisor::Signal;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
@@ -152,7 +150,9 @@ pub struct JoinHandle {
impl JoinHandle { impl JoinHandle {
/// The identity of the actor this handle refers to. /// 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 /// Block the calling actor until the spawned actor finishes, then
/// report how it finished: `Ok(())` if it returned normally or stopped /// report how it finished: `Ok(())` if it returned normally or stopped
@@ -182,12 +182,10 @@ impl JoinHandle {
crate::slot_state::Status::Stale => { crate::slot_state::Status::Stale => {
panic!("join: target slot has been reused") panic!("join: target slot has been reused")
} }
crate::slot_state::Status::Done => { crate::slot_state::Status::Done => Some(match cold.outcome.take() {
Some(match cold.outcome.take() {
Some(outcome) => outcome, Some(outcome) => outcome,
None => panic!("Done slot must have outcome"), None => panic!("Done slot must have outcome"),
}) }),
}
crate::slot_state::Status::Live => { crate::slot_state::Status::Live => {
// begin_wait is lock-free, legal under the cold lock; // begin_wait is lock-free, legal under the cold lock;
// registering under it makes the epoch atomic with // registering under it makes the epoch atomic with
@@ -227,8 +225,7 @@ impl JoinHandle {
match slot.status_for(self.pid) { match slot.status_for(self.pid) {
crate::slot_state::Status::Stale => false, crate::slot_state::Status::Stale => false,
status => { status => {
cold.outstanding_handles = cold.outstanding_handles = cold.outstanding_handles.saturating_sub(1);
cold.outstanding_handles.saturating_sub(1);
cold.outstanding_handles == 0 cold.outstanding_handles == 0
&& status == crate::slot_state::Status::Done && status == crate::slot_state::Status::Done
} }
@@ -259,6 +256,59 @@ impl Drop for JoinHandle {
// spawn / spawn_under / self_pid // 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. /// Start a new actor running `f`, and return a [`JoinHandle`] for it.
/// ///
/// The new actor runs concurrently with its caller and with every other /// 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_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` /// Like [`spawn`], but explicitly attaches the new actor to `supervisor`
/// instead of the calling actor. Ordinary code should reach for [`spawn`]; /// instead of the calling actor. Ordinary code should reach for [`spawn`];
/// this exists for supervision trees (see [`supervisor`](crate::supervisor)) /// this exists for supervision trees (see [`supervisor`](crate::supervisor))
/// and other cases that need to place a child under a specific ancestor /// and other cases that need to place a child under a specific ancestor
/// rather than its true caller. /// rather than its true caller.
pub fn spawn_under<A>(supervisor: Pid<A>, f: impl FnOnce() + Send + 'static) -> JoinHandle { 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(); let supervisor = supervisor.erase();
// Stack + closure boxing happen before ANY runtime lock is taken: no // Stack + closure boxing happen before the slot locks are taken; the
// syscall and no allocation ever stalls another scheduler thread. // pool lock inside acquire_stack is dropped before any mmap, so no
let stack = with_runtime(|inner| inner.stack_pool.lock().pop()) // syscall ever stalls another scheduler thread.
.unwrap_or_else(|| { let stack = with_runtime(|inner| crate::runtime::acquire_stack(inner, opts));
match crate::stack::Stack::new(crate::runtime::ACTOR_STACK_SIZE) {
Ok(stack) => stack,
Err(e) => panic!("stack allocation failed: {e}"),
}
});
let sp = init_actor_stack(stack.top(), crate::actor::trampoline); let sp = init_actor_stack(stack.top(), crate::actor::trampoline);
let closure: crate::runtime::Closure = Box::new(f); 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) 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>`], /// 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) 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; use crate::context::init_actor_stack;
/// The identity of the actor currently running. Use it to hand your own /// 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 _np = NoPreempt::enter();
let epoch = begin_wait(); let epoch = begin_wait();
let deadline = crate::timer::deadline_from_now(duration); let deadline = crate::timer::deadline_from_now(duration);
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_sleep(deadline, me, epoch), Ok(mut timers) => timers.insert_sleep(deadline, me, epoch),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
}); });
park_current(); park_current();
} }
@@ -542,11 +682,9 @@ pub fn sleep_wall(duration: std::time::Duration) {
let _np = NoPreempt::enter(); let _np = NoPreempt::enter();
let epoch = begin_wait(); let epoch = begin_wait();
let deadline = crate::timer::deadline_from_now(duration); let deadline = crate::timer::deadline_from_now(duration);
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_sleep_wall(deadline, me, epoch), Ok(mut timers) => timers.insert_sleep_wall(deadline, me, epoch),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
}); });
park_current(); park_current();
} }
@@ -562,15 +700,13 @@ pub fn insert_wait_timer(
target: std::sync::Arc<dyn crate::timer::TimerTarget>, target: std::sync::Arc<dyn crate::timer::TimerTarget>,
epoch: u32, epoch: u32,
) { ) {
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert( Ok(mut timers) => timers.insert(
deadline, deadline,
pid, pid,
crate::timer::Reason::WaitTimeout { target, epoch }, crate::timer::Reason::WaitTimeout { target, epoch },
), ),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), 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 fire = Box::new(move || {
let _ = crate::registry::send_to(dest, msg); let _ = crate::registry::send_to(dest, msg);
}); });
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, dest.erase(), fire), Ok(mut timers) => timers.insert_send(deadline, dest.erase(), fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), 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 fire = Box::new(move || {
let _ = crate::registry::send(dest, msg); let _ = crate::registry::send(dest, msg);
}); });
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, armed_by, fire), Ok(mut timers) => timers.insert_send(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), 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 fire = Box::new(move || {
let _ = crate::registry::send_to(dest, msg); let _ = crate::registry::send_to(dest, msg);
}); });
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send_wall(deadline, dest.erase(), fire), Ok(mut timers) => timers.insert_send_wall(deadline, dest.erase(), fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), 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 fire = Box::new(move || {
let _ = crate::registry::send(dest, msg); let _ = crate::registry::send(dest, msg);
}); });
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send_wall(deadline, armed_by, fire), Ok(mut timers) => timers.insert_send_wall(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), 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 fire = Box::new(move || {
let _ = tx.send(msg); let _ = tx.send(msg);
}); });
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.insert_send(deadline, armed_by, fire), Ok(mut timers) => timers.insert_send(deadline, armed_by, fire),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), 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 /// 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. /// now prevented, `false` if it had already fired or was already cancelled.
pub fn cancel_timer(id: crate::timer::TimerId) -> bool { pub fn cancel_timer(id: crate::timer::TimerId) -> bool {
with_runtime(|inner| { with_runtime(|inner| match inner.timers.lock() {
match inner.timers.lock() {
Ok(mut timers) => timers.cancel(id), Ok(mut timers) => timers.cancel(id),
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"), Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
}
}) })
} }
@@ -774,7 +898,8 @@ where
}; };
let mut cold = slot.cold.lock(); let mut cold = slot.cold.lock();
debug_assert_eq!( debug_assert_eq!(
slot.generation(), me.generation(), slot.generation(),
me.generation(),
"block_on_io: own slot reused mid-park" "block_on_io: own slot reused mid-park"
); );
match cold.pending_io_result.take() { match cold.pending_io_result.take() {
@@ -900,12 +1025,20 @@ pub struct FdArm {
impl FdArm { impl FdArm {
/// An arm that becomes ready when `fd` is readable. /// An arm that becomes ready when `fd` is readable.
pub fn readable(fd: std::os::fd::RawFd) -> Self { 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. /// An arm that becomes ready when `fd` is writable.
pub fn writable(fd: std::os::fd::RawFd) -> Self { 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() { match io.as_mut() {
Some(io) => { Some(io) => {
inner.io_fd_waiters.fetch_add(1, Ordering::AcqRel); inner.io_fd_waiters.fetch_add(1, Ordering::AcqRel);
let r = io.epoll_register( let r = io.epoll_register(self.fd, pid, epoch, self.readable, self.writable);
self.fd, pid, epoch, self.readable, self.writable,
);
if r.is_err() { if r.is_err() {
inner.io_fd_waiters.fetch_sub(1, Ordering::AcqRel); 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 { if writable {
events |= libc::POLLOUT; events |= libc::POLLOUT;
} }
let mut pfd = libc::pollfd { fd, events, revents: 0 }; let mut pfd = libc::pollfd {
fd,
events,
revents: 0,
};
loop { loop {
let r = unsafe { libc::poll(&mut pfd, 1, 0) }; let r = unsafe { libc::poll(&mut pfd, 1, 0) };
if r < 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> { pub fn read(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result<usize> {
wait_readable(fd)?; wait_readable(fd)?;
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }; 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 /// 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> { pub fn write(fd: std::os::fd::RawFd, buf: &[u8]) -> std::io::Result<usize> {
wait_writable(fd)?; wait_writable(fd)?;
let n = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) }; 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>) { pub fn register_supervisor_channel(pid: Pid, sender: Sender<Signal>) {
with_runtime(|inner| { 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)); .unwrap_or_else(|| panic!("register_supervisor_channel: pid {:?} not found", pid));
let mut cold = slot.cold.lock(); let mut cold = slot.cold.lock();
assert_eq!( assert_eq!(
slot.generation(), pid.generation(), slot.generation(),
"register_supervisor_channel: pid {:?} not found", pid pid.generation(),
"register_supervisor_channel: pid {:?} not found",
pid
); );
cold.supervisor_channel = Some(sender); cold.supervisor_channel = Some(sender);
}); });
@@ -1131,6 +1277,9 @@ mod send_after_to_tests {
crate::sleep(Duration::from_millis(30)); crate::sleep(Duration::from_millis(30));
r2.store(true, Ordering::SeqCst); 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 { loop {
let w = self.load(); let w = self.load();
debug_assert!( debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(w) == gen,
&& word_gen(w) == gen,
"yield return from invalid word {w:#x}" "yield return from invalid word {w:#x}"
); );
if self if self
@@ -247,8 +246,7 @@ impl StateWord {
loop { loop {
let w = self.load(); let w = self.load();
debug_assert!( debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(w) == gen,
&& word_gen(w) == gen,
"begin_wait from invalid word {w:#x}" "begin_wait from invalid word {w:#x}"
); );
let next = word_epoch(w).wrapping_add(1) & EPOCH_MASK; let next = word_epoch(w).wrapping_add(1) & EPOCH_MASK;
@@ -342,8 +340,7 @@ impl StateWord {
loop { loop {
let w = self.load(); let w = self.load();
debug_assert!( debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(w) == gen,
&& word_gen(w) == gen,
"clear_notify from invalid word {w:#x}" "clear_notify from invalid word {w:#x}"
); );
if word_state(w) != ST_RUNNING_NOTIFIED { if word_state(w) != ST_RUNNING_NOTIFIED {
@@ -372,8 +369,7 @@ impl StateWord {
pub(crate) fn set_done(&self, gen: u32) { pub(crate) fn set_done(&self, gen: u32) {
let prev = self.0.swap(pack(gen, 0, ST_DONE), Ordering::AcqRel); let prev = self.0.swap(pack(gen, 0, ST_DONE), Ordering::AcqRel);
debug_assert!( debug_assert!(
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED) matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED) && word_gen(prev) == gen,
&& word_gen(prev) == gen,
"finalize from invalid word {prev:#x}" "finalize from invalid word {prev:#x}"
); );
} }
@@ -538,7 +534,11 @@ mod loom_tests {
// not a pending notification. // not a pending notification.
assert!(word.try_claim(0)); assert!(word.try_claim(0));
assert_eq!(word.unpark(0, Some(epoch)), Unpark::Noop); 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"
);
}); });
} }
+236 -16
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): //! Layout (low → high address):
//! [ guard page (PROT_NONE) | stack region ] //! [ guard region (PROT_NONE) | stack region ]
//! ^ top() — initial stack pointer //! ^ 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; use std::io;
pub struct Stack { 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, base: *mut u8,
/// Total mmap'd size: guard_size + stack_size. /// Total mmap'd size: guard_size + stack_size.
total_size: usize, total_size: usize,
/// Usable stack size (excluding guard page). /// Usable stack size (excluding the guard).
stack_size: usize, stack_size: usize,
/// PROT_NONE region below the usable stack.
guard_size: usize,
} }
// Stack owns its memory; safe to send across threads. // Stack owns its memory; safe to send across threads.
unsafe impl Send for Stack {} unsafe impl Send for Stack {}
impl Stack { impl Stack {
/// Allocate a new stack. `stack_size` is the usable region; one page is /// Allocate a new stack. `stack_size` is the usable region; `guard_size`
/// added below as a guard page. Both are rounded up to the page size. /// is mapped PROT_NONE below it. Both are rounded up to the page size
pub fn new(stack_size: usize) -> io::Result<Self> { /// 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 page = page_size();
let stack_size = round_up(stack_size, page); 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 total_size = guard_size + stack_size;
let base = unsafe { let base = unsafe {
@@ -44,16 +57,19 @@ impl Stack {
} }
let base = base as *mut u8; let base = base as *mut u8;
let ret = unsafe { let ret = unsafe { libc::mprotect(base as *mut libc::c_void, guard_size, libc::PROT_NONE) };
libc::mprotect(base as *mut libc::c_void, guard_size, libc::PROT_NONE)
};
if ret != 0 { if ret != 0 {
let err = io::Error::last_os_error(); let err = io::Error::last_os_error();
unsafe { libc::munmap(base as *mut libc::c_void, total_size) }; unsafe { libc::munmap(base as *mut libc::c_void, total_size) };
return Err(err); 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. /// 16-byte-aligned top of the usable region.
@@ -62,14 +78,54 @@ impl Stack {
(raw_top & !15) as *mut u8 (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 { 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 { pub fn stack_size(&self) -> usize {
self.stack_size 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 { 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 } unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }
} }
fn round_up(n: usize, align: usize) -> usize { fn round_up(n: usize, align: usize) -> usize {
(n + align - 1) & !(align - 1) (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
}
}
+4 -2
View File
@@ -169,7 +169,10 @@ pub struct ChildSpec {
impl ChildSpec { impl ChildSpec {
pub fn new(restart: Restart, start: impl Fn() + Send + Sync + 'static) -> Self { 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 // Earlier deadline first; ties broken by insertion order so the
// ordering is total. `Reason` and `Pid` deliberately don't // ordering is total. `Reason` and `Pid` deliberately don't
// participate. // 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))
} }
} }
+28 -12
View File
@@ -16,13 +16,17 @@
#[cfg(feature = "smarm-trace")] #[cfg(feature = "smarm-trace")]
#[macro_export] #[macro_export]
macro_rules! te { macro_rules! te {
($kind:expr) => { $crate::trace::record($kind) }; ($kind:expr) => {
$crate::trace::record($kind)
};
} }
#[cfg(not(feature = "smarm-trace"))] #[cfg(not(feature = "smarm-trace"))]
#[macro_export] #[macro_export]
macro_rules! te { macro_rules! te {
($kind:expr) => { () }; ($kind:expr) => {
()
};
} }
#[cfg(feature = "smarm-trace")] #[cfg(feature = "smarm-trace")]
@@ -109,8 +113,8 @@ mod inner {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
pub fn open() { pub fn open() {
let path = std::env::var("SMARM_TRACE_FILE") let path =
.unwrap_or_else(|_| "smarm_trace.json".to_owned()); std::env::var("SMARM_TRACE_FILE").unwrap_or_else(|_| "smarm_trace.json".to_owned());
let (tx, rx) = mpsc::channel::<Msg>(); let (tx, rx) = mpsc::channel::<Msg>();
let start = Instant::now(); let start = Instant::now();
@@ -164,8 +168,11 @@ mod inner {
// which would try to re-acquire inner.shared (already held at many // which would try to re-acquire inner.shared (already held at many
// te!() call sites) -> deadlock. Guard at the very top, before any // te!() call sites) -> deadlock. Guard at the very top, before any
// allocation-capable call. // allocation-capable call.
let was_enabled = crate::preempt::PREEMPTION_ENABLED let was_enabled = crate::preempt::PREEMPTION_ENABLED.with(|e| {
.with(|e| { let v = e.get(); e.set(false); v }); let v = e.get();
e.set(false);
v
});
LOCAL_STATE.with(|cell| { LOCAL_STATE.with(|cell| {
let mut opt = cell.borrow_mut(); let mut opt = cell.borrow_mut();
@@ -197,7 +204,10 @@ mod inner {
fn drain_thread(rx: mpsc::Receiver<Msg>, path: &str) { fn drain_thread(rx: mpsc::Receiver<Msg>, path: &str) {
let f = match std::fs::File::create(path) { let f = match std::fs::File::create(path) {
Ok(f) => f, 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 mut w = std::io::BufWriter::new(f);
let _ = writeln!(w, "{{\"traceEvents\":["); let _ = writeln!(w, "{{\"traceEvents\":[");
@@ -210,7 +220,9 @@ mod inner {
Ok(Msg::Event(r)) => { Ok(Msg::Event(r)) => {
let (name, actor_idx) = chrome_fields(&r.event); let (name, actor_idx) = chrome_fields(&r.event);
let ts_us = r.nanos as f64 / 1000.0; 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; first = false;
let _ = write!(w, let _ = write!(w,
"{{\"ph\":\"i\",\"ts\":{:.3},\"pid\":{},\"tid\":{},\"name\":{:?},\"s\":\"g\"}}", "{{\"ph\":\"i\",\"ts\":{:.3},\"pid\":{},\"tid\":{},\"name\":{:?},\"s\":\"g\"}}",
@@ -234,8 +246,9 @@ mod inner {
fn chrome_fields(ev: &Event) -> (String, u32) { fn chrome_fields(ev: &Event) -> (String, u32) {
match ev { match ev {
Event::Spawn { parent, child } => Event::Spawn { parent, child } => {
(format!("spawn c={}", child.index()), parent.index()), (format!("spawn c={}", child.index()), parent.index())
}
Event::Resume(p) => ("resume".into(), p.index()), Event::Resume(p) => ("resume".into(), p.index()),
Event::Yield(p) => ("yield".into(), p.index()), Event::Yield(p) => ("yield".into(), p.index()),
Event::Park(p) => ("park".into(), p.index()), Event::Park(p) => ("park".into(), p.index()),
@@ -244,9 +257,12 @@ mod inner {
Event::UnparkDeferred(p) => ("unpark_deferred".into(), p.index()), Event::UnparkDeferred(p) => ("unpark_deferred".into(), p.index()),
Event::UnparkFlagConsumed(p) => ("unpark_flag_consumed".into(), p.index()), Event::UnparkFlagConsumed(p) => ("unpark_flag_consumed".into(), p.index()),
Event::Send { sender, receiver } => ( Event::Send { sender, receiver } => (
format!("send rx={}", receiver format!(
"send rx={}",
receiver
.map(|p| p.index().to_string()) .map(|p| p.index().to_string())
.unwrap_or_else(|| "none".into())), .unwrap_or_else(|| "none".into())
),
sender.index(), sender.index(),
), ),
Event::RecvPark(p) => ("recv_park".into(), p.index()), Event::RecvPark(p) => ("recv_park".into(), p.index()),
+24 -6
View File
@@ -49,8 +49,14 @@ fn looping_actor_on_check_is_stopped() {
} }
let _ = h.join(); let _ = h.join();
}); });
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped"); assert!(
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind"); saw_stopped.load(Ordering::SeqCst),
"expected DownReason::Stopped"
);
assert!(
dropped.load(Ordering::SeqCst),
"Drop guard must run during the cancellation unwind"
);
} }
#[test] #[test]
@@ -79,8 +85,14 @@ fn parked_on_recv_actor_is_stopped() {
} }
let _ = h.join(); let _ = h.join();
}); });
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped"); assert!(
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run on cancellation of a parked actor"); saw_stopped.load(Ordering::SeqCst),
"expected DownReason::Stopped"
);
assert!(
dropped.load(Ordering::SeqCst),
"Drop guard must run on cancellation of a parked actor"
);
} }
#[test] #[test]
@@ -185,6 +197,12 @@ fn stop_flagged_while_queued_lands_at_first_park() {
.recv_timeout(Duration::from_secs(10)) .recv_timeout(Duration::from_secs(10))
.expect("runtime deadlocked: stop against a QUEUED actor was lost at its first park"); .expect("runtime deadlocked: stop against a QUEUED actor was lost at its first park");
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped"); assert!(
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind"); 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(); h.join().unwrap();
let after = smarm::causal::progress_snapshot(); let after = smarm::causal::progress_snapshot();
let delta = |name: &str| { 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 - before
.iter() .iter()
.find(|(n, _)| n == name) .find(|(n, _)| n == name)
@@ -45,21 +49,12 @@ fn site_guard_nesting_restores() {
assert_eq!(smarm::causal::current_site_name(), None); assert_eq!(smarm::causal::current_site_name(), None);
{ {
let _outer = smarm::causal_site!("outer"); let _outer = smarm::causal_site!("outer");
assert_eq!( assert_eq!(smarm::causal::current_site_name().as_deref(), Some("outer"));
smarm::causal::current_site_name().as_deref(),
Some("outer")
);
{ {
let _inner = smarm::causal_site!("inner"); let _inner = smarm::causal_site!("inner");
assert_eq!( assert_eq!(smarm::causal::current_site_name().as_deref(), Some("inner"));
smarm::causal::current_site_name().as_deref(),
Some("inner")
);
} }
assert_eq!( assert_eq!(smarm::causal::current_site_name().as_deref(), Some("outer"));
smarm::causal::current_site_name().as_deref(),
Some("outer")
);
} }
assert_eq!(smarm::causal::current_site_name(), None); assert_eq!(smarm::causal::current_site_name(), None);
}); });
@@ -88,10 +83,7 @@ fn virtual_speedup_ledger() {
let bystander = smarm::spawn(move || { let bystander = smarm::spawn(move || {
while !stop2.load(Ordering::Relaxed) { while !stop2.load(Ordering::Relaxed) {
smarm::check!(); smarm::check!();
out2.store( out2.store(smarm::causal::my_absorbed_delay_cycles(), Ordering::Relaxed);
smarm::causal::my_absorbed_delay_cycles(),
Ordering::Relaxed,
);
} }
}); });
@@ -166,10 +158,7 @@ fn runnable_bystander_pays_delay() {
while !stop_b.load(Ordering::Relaxed) { while !stop_b.load(Ordering::Relaxed) {
iters2.fetch_add(1, Ordering::Relaxed); iters2.fetch_add(1, Ordering::Relaxed);
smarm::check!(); smarm::check!();
absorbed2.store( absorbed2.store(smarm::causal::my_absorbed_delay_cycles(), Ordering::Relaxed);
smarm::causal::my_absorbed_delay_cycles(),
Ordering::Relaxed,
);
} }
}); });
@@ -177,8 +166,7 @@ fn runnable_bystander_pays_delay() {
let i0 = iters.load(Ordering::Relaxed); let i0 = iters.load(Ordering::Relaxed);
let t = std::time::Instant::now(); let t = std::time::Instant::now();
smarm::sleep(Duration::from_millis(150)); smarm::sleep(Duration::from_millis(150));
let rate = let rate = (iters.load(Ordering::Relaxed) - i0) as f64 / t.elapsed().as_secs_f64();
(iters.load(Ordering::Relaxed) - i0) as f64 / t.elapsed().as_secs_f64();
out.store(rate as u64, Ordering::Relaxed); 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. // Raw deadline passed, effective deadline not: nothing fires, entry kept.
assert!(t.pop_due(now + Duration::from_millis(60)).is_empty()); 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 // Past raw + injected (with margin) it must fire. Chase in case a
// parallel test injected more debt meanwhile. // 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 // Just past the raw deadline: the wall entry fires, the virtual one is
// re-queued at its shifted deadline. // re-queued at its shifted deadline.
let due = t.pop_due(now + Duration::from_millis(60)); 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_eq!(due[0].pid, Pid::new(0, 0));
assert!(!t.is_empty(), "virtual sibling must remain queued, shifted"); 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. // Just past the raw deadline: only the wall send pops; run its thunk.
let due = t.pop_due(now + Duration::from_millis(60)); 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 { for e in due {
if let smarm::timer::Reason::Send { fire } = e.reason { if let smarm::timer::Reason::Send { fire } = e.reason {
fire(); fire();
+11 -3
View File
@@ -154,7 +154,10 @@ fn channel_ops_interleaved_with_monitor_churn_multi_thread() {
} }
consumer.join().unwrap(); 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() { fn recv_timeout_zero_duration_is_a_bounded_poll() {
run(|| { run(|| {
let (_tx, rx) = channel::<i64>(); 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,7 +268,8 @@ fn recv_timeout_many_waiters_multi_thread() {
let (tx, rx) = channel::<i64>(); let (tx, rx) = channel::<i64>();
let got = got2.clone(); let got = got2.clone();
let timed_out = timed_out2.clone(); let timed_out = timed_out2.clone();
handles.push(spawn(move || match rx.recv_timeout(Duration::from_millis(100)) { handles.push(spawn(move || {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(v) => { Ok(v) => {
assert_eq!(v, i); assert_eq!(v, i);
got.fetch_add(1, Ordering::Relaxed); got.fetch_add(1, Ordering::Relaxed);
@@ -271,6 +278,7 @@ fn recv_timeout_many_waiters_multi_thread() {
timed_out.fetch_add(1, Ordering::Relaxed); timed_out.fetch_add(1, Ordering::Relaxed);
} }
Err(e) => panic!("unexpected: {e}"), Err(e) => panic!("unexpected: {e}"),
}
})); }));
if i % 2 == 0 { if i % 2 == 0 {
handles.push(spawn(move || { handles.push(spawn(move || {
+34 -15
View File
@@ -11,9 +11,15 @@ thread_local! {
static LOG: Cell<u64> = const { Cell::new(0) }; static LOG: Cell<u64> = const { Cell::new(0) };
} }
fn log(v: u64) { LOG.with(|c| c.set(c.get() | v)); } fn log(v: u64) {
fn get_log() -> u64 { LOG.with(|c| c.get()) } LOG.with(|c| c.set(c.get() | v));
fn reset_log() { LOG.with(|c| c.set(0)); } }
fn get_log() -> u64 {
LOG.with(|c| c.get())
}
fn reset_log() {
LOG.with(|c| c.set(0));
}
extern "C-unwind" fn actor_simple() { extern "C-unwind" fn actor_simple() {
log(0x1); log(0x1);
@@ -23,7 +29,7 @@ extern "C-unwind" fn actor_simple() {
#[test] #[test]
fn actor_runs_and_returns_to_scheduler() { fn actor_runs_and_returns_to_scheduler() {
reset_log(); 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); let sp = init_actor_stack(stack.top(), actor_simple);
set_actor_sp(sp); set_actor_sp(sp);
unsafe { switch_to_actor() }; unsafe { switch_to_actor() };
@@ -40,7 +46,7 @@ extern "C-unwind" fn actor_two_steps() {
#[test] #[test]
fn actor_yields_and_resumes() { fn actor_yields_and_resumes() {
reset_log(); 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); let sp = init_actor_stack(stack.top(), actor_two_steps);
set_actor_sp(sp); set_actor_sp(sp);
@@ -73,7 +79,10 @@ extern "C-unwind" fn actor_reg_check() {
REG_BEFORE.set([s0, s1, s2, s3]).ok(); REG_BEFORE.set([s0, s1, s2, s3]).ok();
switch_to_scheduler(); 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!( core::arch::asm!(
"mov {a0}, r12", "mov {a1}, r13", "mov {a2}, r14", "mov {a3}, r15", "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, 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] #[test]
fn callee_saved_registers_survive_yield() { fn callee_saved_registers_survive_yield() {
let stack = Stack::new(64 * 1024).unwrap(); let stack = Stack::new(64 * 1024, 4096).unwrap();
let sp = init_actor_stack(stack.top(), actor_reg_check); let sp = init_actor_stack(stack.top(), actor_reg_check);
set_actor_sp(sp); set_actor_sp(sp);
unsafe { switch_to_actor(); switch_to_actor(); } unsafe {
assert_eq!(REG_BEFORE.get().copied().unwrap(), REG_AFTER.get().copied().unwrap()); switch_to_actor();
switch_to_actor();
}
assert_eq!(
REG_BEFORE.get().copied().unwrap(),
REG_AFTER.get().copied().unwrap()
);
} }
// Two actors, independent stacks. // Two actors, independent stacks.
@@ -117,20 +132,24 @@ extern "C-unwind" fn actor_b() {
#[test] #[test]
fn two_actors_dont_corrupt_each_other() { fn two_actors_dont_corrupt_each_other() {
let stack_a = Stack::new(64 * 1024).unwrap(); let stack_a = Stack::new(64 * 1024, 4096).unwrap();
let stack_b = Stack::new(64 * 1024).unwrap(); let stack_b = Stack::new(64 * 1024, 4096).unwrap();
let sp_a = init_actor_stack(stack_a.top(), actor_a); let sp_a = init_actor_stack(stack_a.top(), actor_a);
let sp_b = init_actor_stack(stack_b.top(), actor_b); 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(); 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(); let sp_b = get_actor_sp();
set_actor_sp(sp_a); unsafe { switch_to_actor() }; set_actor_sp(sp_a);
set_actor_sp(sp_b); unsafe { switch_to_actor() }; unsafe { switch_to_actor() };
set_actor_sp(sp_b);
unsafe { switch_to_actor() };
assert_eq!(A_VAL.with(|c| c.get()), 0xA00D); assert_eq!(A_VAL.with(|c| c.get()), 0xA00D);
assert_eq!(B_VAL.with(|c| c.get()), 0xB00D); 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. //! OUTSIDE `run` — an in-actor assertion alone passes vacuously.
use smarm::{ use smarm::{
channel, run, select, select_timeout, spawn, try_select, wait_readable, channel, run, select, select_timeout, spawn, try_select, wait_readable, wait_readable_timeout,
wait_readable_timeout, wait_writable_timeout, yield_now, FdArm, wait_writable_timeout, yield_now, FdArm,
}; };
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -33,7 +33,10 @@ impl Pipe {
let mut fds: [libc::c_int; 2] = [0; 2]; let mut fds: [libc::c_int; 2] = [0; 2];
let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) }; let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
assert_eq!(r, 0, "pipe2 failed"); 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 (rfd, wfd) = (p.read, p.write);
let start = Instant::now(); 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)); assert!(start.elapsed() >= Duration::from_millis(30));
// Timed-out wait must leave the fd clean; ready path returns true. // Timed-out wait must leave the fd clean; ready path returns true.
assert_eq!(raw_write(wfd, b"d"), 1); 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]; let mut buf = [0u8; 1];
assert_eq!(raw_read(rfd, &mut buf), 1); assert_eq!(raw_read(rfd, &mut buf), 1);
ok2.store(true, Ordering::SeqCst); ok2.store(true, Ordering::SeqCst);
@@ -274,7 +283,10 @@ fn wait_readable_timeout_wakes_on_late_data() {
let p = Pipe::new(); let p = Pipe::new();
let (rfd, wfd) = (p.read, p.write); let (rfd, wfd) = (p.read, p.write);
let h = spawn(move || { 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]; let mut buf = [0u8; 1];
assert_eq!(raw_read(rfd, &mut buf), 1); assert_eq!(raw_read(rfd, &mut buf), 1);
got2.store(buf[0] as u32, Ordering::SeqCst); got2.store(buf[0] as u32, Ordering::SeqCst);
@@ -292,7 +304,10 @@ fn wait_writable_timeout_ready_now_on_empty_pipe() {
run(move || { run(move || {
let p = Pipe::new(); let p = Pipe::new();
// An empty pipe's write end is writable: ready-now path, no park. // 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); ok2.store(true, Ordering::SeqCst);
}); });
assert!(ok.load(Ordering::SeqCst)); assert!(ok.load(Ordering::SeqCst));
+58 -14
View File
@@ -403,7 +403,10 @@ fn worker_pool_down_reaches_handle_down() {
let got = Arc::new(Mutex::new(Vec::new())); let got = Arc::new(Mutex::new(Vec::new()));
let got2 = got.clone(); let got2 = got.clone();
run(move || { 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(); server.cast(PoolCast::SpawnDoomedWorker).unwrap();
let _ = server.call(()).unwrap(); // sync point: cast handled, worker live let _ = server.call(()).unwrap(); // sync point: cast handled, worker live
*got2.lock().unwrap() = server.call(()).unwrap(); *got2.lock().unwrap() = server.call(()).unwrap();
@@ -421,7 +424,10 @@ fn watch_dead_pid_is_noproc_down() {
let h = spawn(|| {}); let h = spawn(|| {});
let dead = h.pid(); let dead = h.pid();
h.join().unwrap(); 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(); server.cast(PoolCast::Watch(dead)).unwrap();
*got2.lock().unwrap() = server.call(()).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 { 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. // 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(); let count = server.call(()).unwrap();
assert_eq!(count, 0, "cancelled timer must not fire"); 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()); assert!(fired.lock().unwrap().is_empty());
} }
@@ -549,11 +564,16 @@ fn tick_every_rearms_repeatedly() {
run(move || { run(move || {
let cw = Arc::new(Mutex::new(None)); let cw = Arc::new(Mutex::new(None));
let server = start(timed(f2, cw)); 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 let _ = server.call(()).unwrap(); // sync: periodic armed
smarm::sleep(Duration::from_millis(130)); // ~6 periods smarm::sleep(Duration::from_millis(130)); // ~6 periods
let count = server.call(()).unwrap(); 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. // Every tick delivered the same payload.
assert!(fired.lock().unwrap().iter().all(|&v| v == 9)); assert!(fired.lock().unwrap().iter().all(|&v| v == 9));
@@ -568,7 +588,9 @@ fn cancel_stops_a_periodic() {
let c2 = cancel_won.clone(); let c2 = cancel_won.clone();
run(move || { run(move || {
let server = start(timed(f2, c2)); 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(); let _ = server.call(()).unwrap();
smarm::sleep(Duration::from_millis(70)); // a few ticks smarm::sleep(Duration::from_millis(70)); // a few ticks
server.cast(TkCast::CancelLast).unwrap(); server.cast(TkCast::CancelLast).unwrap();
@@ -616,11 +638,17 @@ fn idle_fires_repeatedly_on_quiet() {
let idles = Arc::new(Mutex::new(0)); let idles = Arc::new(Mutex::new(0));
let i2 = idles.clone(); let i2 = idles.clone();
run(move || { 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 smarm::sleep(Duration::from_millis(130)); // quiet ⇒ ~5 windows
drop(server); // keep the server alive across the quiet span 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 // 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 before_quiet = Arc::new(Mutex::new(u32::MAX));
let bq = before_quiet.clone(); let bq = before_quiet.clone();
run(move || { 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 // Poke every 25ms (< 60ms window) for ~100ms: each cast resets the
// window before it can elapse. // window before it can elapse.
for _ in 0..4 { for _ in 0..4 {
@@ -643,8 +674,15 @@ fn traffic_resets_the_idle_window() {
smarm::sleep(Duration::from_millis(140)); // now genuinely quiet smarm::sleep(Duration::from_millis(140)); // now genuinely quiet
drop(server); drop(server);
}); });
assert_eq!(*before_quiet.lock().unwrap(), 0, "steady traffic must suppress idle"); assert_eq!(
assert!(*idles.lock().unwrap() >= 1, "idle fires once the inbox falls quiet"); *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 // RFC 015 §4.7 — no armed timer survives loop exit. A server with a live
@@ -658,7 +696,9 @@ fn no_timer_survives_exit() {
let f_read = fired.clone(); let f_read = fired.clone();
run(move || { run(move || {
let server = start(timed(f_server, Arc::new(Mutex::new(None)))); 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 let _ = server.call(()).unwrap(); // sync: periodic armed
smarm::sleep(Duration::from_millis(45)); // a couple of ticks smarm::sleep(Duration::from_millis(45)); // a couple of ticks
let mon = smarm::monitor(server.pid()); let mon = smarm::monitor(server.pid());
@@ -667,6 +707,10 @@ fn no_timer_survives_exit() {
assert!(mon.rx.recv().is_ok()); assert!(mon.rx.recv().is_ok());
let at_exit = f_read.lock().unwrap().len(); let at_exit = f_read.lock().unwrap().len();
smarm::sleep(Duration::from_millis(90)); // would be several more ticks 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"
);
}); });
} }
+88 -12
View File
@@ -100,7 +100,15 @@ fn state_timeout_fires() {
let got = Arc::new(Mutex::new(0u32)); let got = Arc::new(Mutex::new(0u32));
let got2 = got.clone(); let got2 = got.clone();
run(move || { 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 m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 5ms state-timeout
smarm::sleep(Duration::from_millis(40)); // let it fire smarm::sleep(Duration::from_millis(40)); // let it fire
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap(); *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(); let got2 = got.clone();
run(move || { run(move || {
// Long window so the explicit Disarm beats it comfortably. // 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::Arm)).unwrap(); // -> Armed, arms 50ms state-timeout
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle, auto-resets it m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle, auto-resets it
smarm::sleep(Duration::from_millis(80)); // past the original window smarm::sleep(Duration::from_millis(80)); // past the original window
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap(); *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 // 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(); let got2 = got.clone();
run(move || { run(move || {
// Armed's own state-timeout is long so it doesn't interfere. // 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::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::Arm)).unwrap(); // -> Armed (ping must survive this)
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle (and this) m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle (and this)
smarm::sleep(Duration::from_millis(60)); // let "ping" fire smarm::sleep(Duration::from_millis(60)); // let "ping" fire
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap(); *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. // 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 got = Arc::new(Mutex::new(99u32));
let got2 = got.clone(); let got2 = got.clone();
run(move || { 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::Ping(30))).unwrap(); // arm "ping" for 30ms
m.send(Ev2::Cast(TCast::CancelPing)).unwrap(); // cancel before it fires m.send(Ev2::Cast(TCast::CancelPing)).unwrap(); // cancel before it fires
smarm::sleep(Duration::from_millis(60)); // past the original window smarm::sleep(Duration::from_millis(60)); // past the original window
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap(); *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,7 +214,15 @@ fn cast_then_call_roundtrip() {
let got2 = got.clone(); let got2 = got.clone();
run(move || { run(move || {
// Long state-timeout window so it never fires during the test. // 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 }); 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::Arm)).unwrap(); // Idle -> Armed (enter)
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (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)
@@ -178,7 +230,11 @@ fn cast_then_call_roundtrip() {
// enters = 1 (start) + 4 transitions = 5. // enters = 1 (start) + 4 transitions = 5.
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap(); *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 // `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 got = Arc::new(Mutex::new((0u32, 0u32, 0u32)));
let got2 = got.clone(); let got2 = got.clone();
run(move || { 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(); let after_start = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
// A stay (a counter read returns `prev`) must not bump enters. // A stay (a counter read returns `prev`) must not bump enters.
let _ = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap(); 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 got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
let got2 = got.clone(); let got2 = got.clone();
run(move || { 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))); let r = m.call(|rep| Ev2::Call(TCall::Boom(rep)));
*got2.lock().unwrap() = Some(r); *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 smarm::sleep(Duration::from_millis(20)); // let the child wake with its reply
*g2.lock().unwrap() = *taken.lock().unwrap(); *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)] #[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 root itself is on-CPU (it's running this code) and rooted under
// the forest sentinel. // 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.state, ActorState::Running);
assert_eq!(root.supervisor, smarm::Pid::new(u32::MAX, u32::MAX)); 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, // The root is parented at the forest sentinel, so it's a genuine root,
// and the worker it spawned hangs beneath it. // 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.orphaned);
assert!( assert!(
root.children.iter().any(|c| c.info.pid == h.pid()), root.children.iter().any(|c| c.info.pid == h.pid()),
@@ -237,6 +245,13 @@ fn tree_from_nests_children_and_reroots_orphans() {
overruns: 0, overruns: 0,
messages_received: 0, messages_received: 0,
budget_cycles: 0, budget_cycles: 0,
stack: smarm::StackInfo {
reserve: 0,
guard: 0,
depth_high_water: 0,
parks_since_shrink: 0,
shrinks: 0,
},
}; };
let snap = RuntimeSnapshot { let snap = RuntimeSnapshot {
@@ -251,14 +266,25 @@ fn tree_from_nests_children_and_reroots_orphans() {
let t = tree_from(snap); let t = tree_from(snap);
assert_eq!(t.roots.len(), 2); 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!(!root.orphaned);
assert_eq!(root.children.len(), 1); assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].info.pid, child); assert_eq!(root.children[0].info.pid, child);
assert!(!root.children[0].orphaned); assert!(!root.children[0].orphaned);
let o = t.roots.iter().find(|n| n.info.pid == orphan).expect("orphan re-rooted"); let o = t
assert!(o.orphaned, "an actor whose parent is absent must be flagged orphaned"); .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()); assert!(o.children.is_empty());
} }
@@ -352,3 +378,134 @@ fn budget_cycles_accumulate_when_enabled() {
h.join().unwrap(); 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_2 = v.iter().position(|&x| x == 2).unwrap();
let pos_3 = v.iter().position(|&x| x == 3).unwrap(); let pos_3 = v.iter().position(|&x| x == 3).unwrap();
let pos_4 = v.iter().position(|&x| x == 4).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!(
assert!(pos_3 < pos_4, "B's second step ran after A resumed: {:?}", *v); 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] #[test]
@@ -76,7 +84,9 @@ fn many_concurrent_block_on_io_calls_all_complete() {
cc.fetch_add(n, Ordering::SeqCst); 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); 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 // Pipe is empty + has buffer space, so this returns immediately
// after wait_writable wakes (which happens fast because the // after wait_writable wakes (which happens fast because the
// kernel marks an empty pipe as immediately writable). // kernel marks an empty pipe as immediately writable).
let n = smarm::scheduler::write(p_writer.write, b"smarm") let n = smarm::scheduler::write(p_writer.write, b"smarm").expect("write failed");
.expect("write failed");
assert_eq!(n, 5); assert_eq!(n, 5);
c.fetch_add(1, Ordering::SeqCst); 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 pos_lit_a = v.iter().position(|&c| c == b'a').unwrap();
let big_b_count = v.iter().filter(|&&c| c == b'B').count(); 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_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. // At least the last B step should be before A resumes.
let last_big_b = v.iter().rposition(|&c| c == b'B').unwrap(); 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
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+8 -2
View File
@@ -57,7 +57,10 @@ fn linked_pair_one_panics_other_is_stopped() {
panic!("boom"); 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"); assert_eq!(dn.pid, b, "Down reported the wrong pid");
if matches!(dn.reason, DownReason::Stopped) { if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst); s.store(true, Ordering::SeqCst);
@@ -152,7 +155,10 @@ fn link_to_dead_pid_stops_a_nontrapping_caller() {
}); });
let b = hb.pid(); let b = hb.pid();
let down_b = monitor(b); 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) { if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst); s.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. // and its generation bumped, so `pid` is now stale.
h.join().unwrap(); h.join().unwrap();
let down = monitor(pid); 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); assert_eq!(d.pid, pid);
if matches!(d.reason, DownReason::NoProc) { if matches!(d.reason, DownReason::NoProc) {
o.store(true, Ordering::SeqCst); 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] #[test]
@@ -103,8 +110,15 @@ fn demonitor_stops_delivery() {
let h = spawn(|| {}); let h = spawn(|| {});
let pid = h.pid(); let pid = h.pid();
let m = monitor(pid); let m = monitor(pid);
assert_eq!(demonitor(&m), Some(m.id), "live registration should be removed"); assert_eq!(
assert!(m.rx.recv().is_err(), "no Down should arrive after demonitor"); 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(); let _ = h.join();
}); });
} }
@@ -122,7 +136,10 @@ fn demonitor_one_of_many() {
let _ = h.join(); let _ = h.join();
assert!(matches!(ms[0].rx.recv().unwrap().reason, DownReason::Exit)); assert!(matches!(ms[0].rx.recv().unwrap().reason, DownReason::Exit));
assert!(matches!(ms[2].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 m = monitor(pid);
let d = m.rx.recv().expect("Down before close"); let d = m.rx.recv().expect("Down before close");
assert!(matches!(d.reason, DownReason::Exit)); 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(); let _ = h.join();
}); });
} }
+16 -4
View File
@@ -3,9 +3,9 @@
//! needs to be able to park. //! needs to be able to park.
use smarm::{run, spawn, yield_now, LockTimeout, Mutex}; use smarm::{run, spawn, yield_now, LockTimeout, Mutex};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant}; 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(); let pos_b_locked = v.iter().position(|s| *s == "B_locked").unwrap();
assert!(pos_a_locked < pos_b_try, "log: {:?}", *v); assert!(pos_a_locked < pos_b_try, "log: {:?}", *v);
assert!(pos_b_try < pos_a_dropped, "B should attempt before A drops: {:?}", *v); assert!(
assert!(pos_a_dropped < pos_b_locked, "B should lock only after A drops: {:?}", *v); 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(); 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 // An index that is not in the slab at all — the verb relays the
// primitive's `None` faithfully. // primitive's `None` faithfully.
let forged = smarm::Pid::new(u32::MAX - 1, 0); let forged = smarm::Pid::new(u32::MAX - 1, 0);
let ObserverReply::ActorInfo(none) = let ObserverReply::ActorInfo(none) = obs.call(ObserverRequest::ActorInfo(forged)).unwrap()
obs.call(ObserverRequest::ActorInfo(forged)).unwrap()
else { else {
panic!("ActorInfo verb must reply ActorInfo"); panic!("ActorInfo verb must reply ActorInfo");
}; };
@@ -113,7 +112,10 @@ fn observer_sees_a_parked_actor_as_parked() {
} }
smarm::yield_now(); 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(); gate_tx.send(()).unwrap();
worker.join().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 // Drain-on-contact: touching g1 detects the death and sweeps the pid
// out of every group (g2 included), not just g1. // out of every group (g2 included), not just g1.
assert!(members("g1").is_empty(), "evicted from the touched group"); 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); assert_eq!(pick("g1"), None);
}); });
} }
@@ -83,7 +86,11 @@ fn live_members_survive_a_peers_death() {
tx_a.send(()).unwrap(); tx_a.send(()).unwrap();
a.join().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())); assert_eq!(pick("svc"), Some(b.pid()));
tx_b.send(()).unwrap(); 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 // monitor() on a gone pid queues a NoProc Down immediately, so the
// membership is reaped the next time the group is touched. // membership is reaped the next time the group is touched.
join("late", pid); 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); 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); 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 /// 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); 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_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_a = v.iter().position(|&c| c == b'a').unwrap();
let pos_lit_b = v.iter().position(|&c| c == b'b').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!(
assert!(pos_big_b < pos_lit_b, "B's tail ran before A's head: {:?}", *v); pos_big_a < pos_lit_a,
assert!(pos_big_a.max(pos_big_b) < pos_lit_a.min(pos_lit_b), "A's tail ran before B's head: {:?}",
"preemption didn't interleave: {:?}", *v); *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] #[test]
+12 -3
View File
@@ -65,7 +65,10 @@ fn name_held_by_live_actor_is_taken() {
ready_rx.recv().unwrap(); ready_rx.recv().unwrap();
// Root tries to claim a live actor's name for itself -> NameTaken. // Root tries to claim a live actor's name for itself -> NameTaken.
let (tx_b, _rx_b) = channel::<u64>(); 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) send(SVC, 0).unwrap(); // release a (delivers to the holder, a)
a.join().unwrap(); a.join().unwrap();
}); });
@@ -105,7 +108,10 @@ fn dead_holder_is_pruned_and_name_taken_over() {
fn send_errors_unresolved_and_no_channel() { fn send_errors_unresolved_and_no_channel() {
run(|| { run(|| {
// No actor at all. // 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 (ready_tx, ready_rx) = channel::<()>();
let (tx, rx) = channel::<u64>(); let (tx, rx) = channel::<u64>();
@@ -228,7 +234,10 @@ fn send_dyn_delivers_and_reports_wrong_type() {
let p = h.pid(); // a bare Pid<Erased>, as if recovered off a Down let p = h.pid(); // a bare Pid<Erased>, as if recovered off a Down
send_dyn::<u64>(p, 3u64).unwrap(); // right type: delivered send_dyn::<u64>(p, 3u64).unwrap(); // right type: delivered
// Live actor, but it has no channel for &str — the genuinely-fallible case. // Live actor, but it has no channel for &str — the genuinely-fallible case.
assert!(matches!(send_dyn::<&'static str>(p, "nope"), Err(SendError::NoChannel(_)))); assert!(matches!(
send_dyn::<&'static str>(p, "nope"),
Err(SendError::NoChannel(_))
));
done_tx.send(()).unwrap(); done_tx.send(()).unwrap();
h.join().unwrap(); h.join().unwrap();
}); });
+108 -23
View File
@@ -14,10 +14,17 @@
//! - No slot leaks under high spawn/join churn //! - No slot leaks under high spawn/join churn
//! - Panic on one scheduler thread doesn't kill others //! - Panic on one scheduler thread doesn't kill others
use smarm::{channel, runtime::{Config, Runtime}, spawn, yield_now, JoinHandle}; use smarm::{
use std::sync::{atomic::{AtomicBool, AtomicU64, Ordering}, Arc}; channel,
use std::time::Duration; runtime::{Config, Runtime},
spawn, yield_now, JoinHandle,
};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
use std::time::Duration;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -29,7 +36,9 @@ fn rt(n: usize) -> Runtime {
} }
/// Convenient single-threaded runtime (regression guard). /// Convenient single-threaded runtime (regression guard).
fn rt1() -> Runtime { rt(1) } fn rt1() -> Runtime {
rt(1)
}
/// Multi-threaded runtime using all available parallelism. /// Multi-threaded runtime using all available parallelism.
fn rt_par() -> Runtime { fn rt_par() -> Runtime {
@@ -79,7 +88,9 @@ fn config_min_1_max_1_is_single_threaded() {
fn runtime_run_executes_closure() { fn runtime_run_executes_closure() {
let flag = Arc::new(AtomicBool::new(false)); let flag = Arc::new(AtomicBool::new(false));
let f = flag.clone(); 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)); 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 b = Arc::new(AtomicU64::new(0));
let ac = a.clone(); let ac = a.clone();
let bc = b.clone(); let bc = b.clone();
r.run(move || { ac.fetch_add(1, Ordering::SeqCst); }); r.run(move || {
r.run(move || { bc.fetch_add(1, Ordering::SeqCst); }); ac.fetch_add(1, Ordering::SeqCst);
});
r.run(move || {
bc.fetch_add(1, Ordering::SeqCst);
});
assert_eq!(a.load(Ordering::SeqCst), 1); assert_eq!(a.load(Ordering::SeqCst), 1);
assert_eq!(b.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 v = Arc::new(AtomicU64::new(0));
let vc = v.clone(); let vc = v.clone();
rt1().run(move || { rt1().run(move || {
let h = spawn(move || { vc.store(42, Ordering::SeqCst); }); let h = spawn(move || {
vc.store(42, Ordering::SeqCst);
});
h.join().unwrap(); h.join().unwrap();
}); });
assert_eq!(v.load(Ordering::SeqCst), 42); assert_eq!(v.load(Ordering::SeqCst), 42);
@@ -155,7 +172,9 @@ fn exact_1_panic_captured() {
let s = saw_err.clone(); let s = saw_err.clone();
rt1().run(move || { rt1().run(move || {
let h = spawn(|| panic!("oops")); 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)); assert!(saw_err.load(Ordering::SeqCst));
} }
@@ -176,7 +195,9 @@ fn multi_thread_all_actors_complete() {
cc.fetch_add(1, Ordering::SeqCst); 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); assert_eq!(counter.load(Ordering::SeqCst), 100);
} }
@@ -221,7 +242,9 @@ fn multi_thread_many_channels_no_lost_wakeups() {
tx.send(1).unwrap(); 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); 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(); let g = m.lock_timeout(Duration::from_secs(1)).unwrap();
t.store(*g, Ordering::SeqCst); t.store(*g, Ordering::SeqCst);
}); });
@@ -262,7 +287,9 @@ fn multi_thread_join_across_threads() {
rt_par().run(move || { rt_par().run(move || {
let h = spawn(move || { let h = spawn(move || {
// Do some work to make scheduling interesting. // Do some work to make scheduling interesting.
for _ in 0..10 { yield_now(); } for _ in 0..10 {
yield_now();
}
vc.store(1, Ordering::SeqCst); vc.store(1, Ordering::SeqCst);
}); });
h.join().unwrap(); h.join().unwrap();
@@ -279,8 +306,7 @@ fn multi_thread_join_across_threads() {
#[test] #[test]
fn actors_run_on_multiple_os_threads() { fn actors_run_on_multiple_os_threads() {
let thread_ids: Arc<smarm::Mutex<HashSet<u64>>> = let thread_ids: Arc<smarm::Mutex<HashSet<u64>>> = Arc::new(smarm::Mutex::new(HashSet::new()));
Arc::new(smarm::Mutex::new(HashSet::new()));
rt_par().run({ rt_par().run({
let ids = thread_ids.clone(); let ids = thread_ids.clone();
@@ -294,11 +320,15 @@ fn actors_run_on_multiple_os_threads() {
g.insert(tid); 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(); let ids = thread_ids.lock_timeout(Duration::from_secs(1)).unwrap();
// If we have >1 scheduler threads, we expect >1 OS thread IDs. // 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). // run() completes (queue len == 0 at quiescence).
let r = rt_par(); let r = rt_par();
r.run(|| { r.run(|| {
for _ in 0..10 { spawn(|| {}); } for _ in 0..10 {
spawn(|| {});
}
// Don't join — let them drain naturally. // Don't join — let them drain naturally.
}); });
let stats = r.stats(); 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] #[test]
@@ -359,7 +395,9 @@ fn panic_in_actor_does_not_kill_runtime() {
})); }));
} }
let _ = bad.join(); // expect Err 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); assert_eq!(completed.load(Ordering::SeqCst), 10);
} }
@@ -379,7 +417,9 @@ fn no_slot_leak_under_churn() {
rt_par().run(move || { rt_par().run(move || {
for _ in 0..500 { for _ in 0..500 {
let cc = c.clone(); let cc = c.clone();
spawn(move || { cc.fetch_add(1, Ordering::SeqCst); }) spawn(move || {
cc.fetch_add(1, Ordering::SeqCst);
})
.join() .join()
.unwrap(); .unwrap();
} }
@@ -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(); let elapsed = start.elapsed();
assert!( assert!(
@@ -515,5 +559,46 @@ fn runtime_reusable_after_root_panic() {
let ran = Arc::new(AtomicBool::new(false)); let ran = Arc::new(AtomicBool::new(false));
let ran_t = ran.clone(); let ran_t = ran.clone();
r.run(move || ran_t.store(true, Ordering::Relaxed)); 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() { fn root_actor_runs() {
let captured = Arc::new(AtomicI64::new(0)); let captured = Arc::new(AtomicI64::new(0));
let c = captured.clone(); 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); 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 captured = Arc::new(AtomicI64::new(0));
let c = captured.clone(); let c = captured.clone();
run(move || { run(move || {
let h = spawn(move || { c.store(7, Ordering::SeqCst); }); let h = spawn(move || {
c.store(7, Ordering::SeqCst);
});
let res = h.join(); let res = h.join();
assert!(res.is_ok(), "join returned {:?}", res); assert!(res.is_ok(), "join returned {:?}", res);
}); });
@@ -68,8 +72,7 @@ fn yield_now_interleaves_actors() {
#[test] #[test]
fn self_pid_is_stable_within_an_actor() { fn self_pid_is_stable_within_an_actor() {
let pid_cell: Arc<std::sync::Mutex<Option<smarm::Pid>>> = let pid_cell: Arc<std::sync::Mutex<Option<smarm::Pid>>> = Arc::new(std::sync::Mutex::new(None));
Arc::new(std::sync::Mutex::new(None));
let p2 = pid_cell.clone(); let p2 = pid_cell.clone();
run(move || { run(move || {
let h = spawn(move || { let h = spawn(move || {
+14 -3
View File
@@ -19,7 +19,12 @@ fn ready_arm_returns_immediately_without_parking() {
txa.send(42).unwrap(); txa.send(42).unwrap();
let i = select(&[&rxb, &rxa]); let i = select(&[&rxb, &rxa]);
assert_eq!(i, 1); 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); 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 (txa, rxa) = channel::<i64>();
let (_keep_b, rxb) = channel::<i64>(); let (_keep_b, rxb) = channel::<i64>();
txa.send(5).unwrap(); 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)); 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 (_keep_a, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>(); let (txb, rxb) = channel::<i64>();
drop(txb); 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()); 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] #[test]
fn top_is_16_byte_aligned() { 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); assert_eq!(s.top() as usize % 16, 0);
} }
#[test] #[test]
fn top_is_within_allocation() { 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 top = s.top() as usize;
let base = s.usable_base() as usize; let base = s.usable_base() as usize;
assert!(top > base); assert!(top > base);
@@ -22,7 +22,7 @@ fn top_is_within_allocation() {
#[test] #[test]
fn write_and_read_top_of_stack() { 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; let sentinel: u64 = 0xDEAD_BEEF_CAFE_1234;
unsafe { unsafe {
let ptr = s.top().sub(8) as *mut u64; let ptr = s.top().sub(8) as *mut u64;
@@ -33,7 +33,7 @@ fn write_and_read_top_of_stack() {
#[test] #[test]
fn write_and_read_bottom_of_usable_region() { 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; let sentinel: u64 = 0x0102_0304_0506_0708;
unsafe { unsafe {
let ptr = s.usable_base() as *mut u64; let ptr = s.usable_base() as *mut u64;
@@ -44,17 +44,17 @@ fn write_and_read_bottom_of_usable_region() {
#[test] #[test]
fn small_stack_allocates() { fn small_stack_allocates() {
assert!(Stack::new(4096).is_ok()); assert!(Stack::new(4096, 4096).is_ok());
} }
#[test] #[test]
fn large_stack_allocates() { fn large_stack_allocates() {
assert!(Stack::new(8 * 1024 * 1024).is_ok()); assert!(Stack::new(8 * 1024 * 1024, 4096).is_ok());
} }
#[test] #[test]
fn stack_size_at_least_requested() { 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); assert!(s.stack_size() >= 64 * 1024);
} }
@@ -68,15 +68,32 @@ use std::process::Command;
fn run_as_child_if_requested() { fn run_as_child_if_requested() {
match env::var("SMARM_SUBTEST").as_deref() { match env::var("SMARM_SUBTEST").as_deref() {
Ok("guard_page_direct") => { Ok("guard_page_direct") => {
let s = Stack::new(64 * 1024).unwrap(); let s = Stack::new(64 * 1024, 4096).unwrap();
unsafe { unsafe {
let guard_ptr = s.usable_base().sub(1); let guard_ptr = s.usable_base().sub(1);
guard_ptr.write_volatile(0xAB); guard_ptr.write_volatile(0xAB);
} }
std::process::exit(0); 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") => { Ok("stack_overflow") => {
let s = Stack::new(64 * 1024).unwrap(); let s = Stack::new(64 * 1024, 4096).unwrap();
unsafe { unsafe {
let mut ptr = s.top().sub(1); let mut ptr = s.top().sub(1);
let stop = s.usable_base().sub(1); let stop = s.usable_base().sub(1);
@@ -107,7 +124,12 @@ fn guard_page_causes_sigsegv() {
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::process::ExitStatusExt; 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)] #[cfg(unix)]
{ {
use std::os::unix::process::ExitStatusExt; 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();
});
}
+5 -3
View File
@@ -18,8 +18,8 @@
//! registry entry guarantees for every named server. //! registry entry guarantees for every named server.
use smarm::{ use smarm::{
call, channel, init, request_stop, spawn, Config, GenServer, GenServerBuilder, GenServerName, call, channel, init, request_stop, spawn, CallError, Config, GenServer, GenServerBuilder,
CallError, Receiver, RecvTimeoutError, GenServerName, Receiver, RecvTimeoutError,
}; };
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@@ -73,7 +73,9 @@ fn named_server_request_stop_releases_queued_caller_with_server_down() {
let (res_tx, res_rx) = channel::<Result<(), CallError>>(); let (res_tx, res_rx) = channel::<Result<(), CallError>>();
// 1. Start the named server and keep its ref alive. // 1. Start the named server and keep its ref alive.
let server = GenServerBuilder::new(Blocker { gate: Some(gate_rx) }) let server = GenServerBuilder::new(Blocker {
gate: Some(gate_rx),
})
.named(BLOCKER) .named(BLOCKER)
.start() .start()
.expect("name should be free"); .expect("name should be free");
+16 -8
View File
@@ -10,7 +10,11 @@
//! out rather than produce a false pass — run with `cargo test -- --timeout` //! out rather than produce a false pass — run with `cargo test -- --timeout`
//! or under a CI 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::{ use std::sync::{
atomic::{AtomicU64, AtomicUsize, Ordering}, atomic::{AtomicU64, AtomicUsize, Ordering},
Arc, Arc,
@@ -199,7 +203,9 @@ fn thundering_herd_all_wake() {
} }
// Let all receivers park before we send. // Let all receivers park before we send.
for _ in 0..4 { yield_now(); } for _ in 0..4 {
yield_now();
}
// Coordinator blasts all channels. // Coordinator blasts all channels.
handles.push(spawn(move || { handles.push(spawn(move || {
@@ -240,8 +246,7 @@ fn concurrent_spawn_join_churn() {
for _ in 0..PARENTS { for _ in 0..PARENTS {
let tc = t.clone(); let tc = t.clone();
parent_handles.push(spawn(move || { parent_handles.push(spawn(move || {
let mut child_handles: Vec<JoinHandle> = let mut child_handles: Vec<JoinHandle> = Vec::with_capacity(CHILDREN_PER_PARENT);
Vec::with_capacity(CHILDREN_PER_PARENT);
for _ in 0..CHILDREN_PER_PARENT { for _ in 0..CHILDREN_PER_PARENT {
let tcc = tc.clone(); 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. // 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 { for h in handles {
// If child already finished, join must return immediately with Ok. // 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() { fn pid_generation_increments_on_reuse() {
use smarm::self_pid; use smarm::self_pid;
let pids: Arc<smarm::Mutex<Vec<smarm::Pid>>> = let pids: Arc<smarm::Mutex<Vec<smarm::Pid>>> = Arc::new(smarm::Mutex::new(Vec::new()));
Arc::new(smarm::Mutex::new(Vec::new()));
let p = pids.clone(); let p = pids.clone();
rt(1).run(move || { 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. // Any two PIDs that share an index must have different generations.
for i in 0..g.len() { for i in 0..g.len() {
for j in (i + 1)..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(); 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] #[test]
@@ -167,7 +171,11 @@ fn one_for_all_restarts_a_normally_exited_sibling() {
sup.join().unwrap(); sup.join().unwrap();
}); });
assert_eq!(a.load(Ordering::SeqCst), 2, "A: crash then clean run"); 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] #[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 mut fds: [libc::c_int; 2] = [0; 2];
let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) }; let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
assert_eq!(r, 0, "pipe2 failed"); 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); rt.run(body);
let _ = done_tx.send(()); let _ = done_tx.send(());
}); });
done_rx done_rx.recv_timeout(limit).expect(
.recv_timeout(limit) "Runtime::run did not return: idle scheduler thread was never woken at termination",
.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)` /// Permanent-hang variant: sibling blocked in `poll_wake(wake_fd, None)`
+26 -6
View File
@@ -166,14 +166,19 @@ fn timers_only_pop_entries_whose_deadline_has_passed() {
#[test] #[test]
fn timers_mix_sleep_and_wait_timeout_reasons() { fn timers_mix_sleep_and_wait_timeout_reasons() {
let mut t = Timers::new(); 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(); let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0), 1); t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0), 1);
t.insert( t.insert(
now + Duration::from_millis(10), now + Duration::from_millis(10),
Pid::new(1, 0), 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)); 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)); 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_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()); run_fire(due.pop().unwrap());
assert!(fired.load(Ordering::SeqCst), "running the thunk delivers"); assert!(fired.load(Ordering::SeqCst), "running the thunk delivers");
assert!(t.is_empty()); assert!(t.is_empty());
@@ -282,7 +290,11 @@ fn cancel_after_fire_returns_false() {
fn cancel_unknown_id_returns_false() { fn cancel_unknown_id_returns_false() {
let mut t = Timers::new(); let mut t = Timers::new();
let now = Instant::now(); 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)); assert!(t.cancel(id));
// Second cancel of the same id: already gone. // Second cancel of the same id: already gone.
assert!(!t.cancel(id)); assert!(!t.cancel(id));
@@ -293,7 +305,11 @@ fn send_timers_interleave_with_sleep_in_deadline_order() {
let mut t = Timers::new(); let mut t = Timers::new();
let now = Instant::now(); let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(30), Pid::new(0, 0), 1); 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); t.insert_sleep(now + Duration::from_millis(20), Pid::new(2, 0), 1);
let due = t.pop_due(now + Duration::from_millis(50)); 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() { fn clear_drops_armed_send_timers() {
let mut t = Timers::new(); let mut t = Timers::new();
let now = Instant::now(); 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(); t.clear();
assert!(t.is_empty()); assert!(t.is_empty());
// The arm record is gone too: cancelling reports nothing to cancel. // The arm record is gone too: cancelling reports nothing to cancel.
+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;
}