Compare commits
38
Commits
0cf6b80396
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4839f1d81 | ||
|
|
2854b560d6 | ||
|
|
7b026cfe56 | ||
|
|
006a3283e7 | ||
|
|
8c764e9169 | ||
|
|
41b9d6d056 | ||
|
|
dd845f22fe | ||
|
|
36a0a9832d | ||
|
|
feda6517e5 | ||
|
|
8625ae4c35 | ||
|
|
d9addeba5e | ||
|
|
d5a3ba1934 | ||
|
|
7eae56a296 | ||
|
|
527f045e17 | ||
|
|
0ee3fe7330 | ||
|
|
9bfeb2c6a2 | ||
|
|
a3be8f0977 | ||
|
|
a2d0b7af18 | ||
|
|
a4647f368a | ||
|
|
fec760a3c0 | ||
|
|
d5b6a8f66f | ||
|
|
efbc254634 | ||
|
|
04dbac1f4b | ||
|
|
d496914d40 | ||
|
|
2668f4018f | ||
|
|
1c90a4ef5e | ||
|
|
f6641cd266 | ||
|
|
0017c5b9a1 | ||
|
|
6c2b7e91cf | ||
|
|
3e9c33377c | ||
|
|
e54c67c431 | ||
|
|
3e321eaaf3 | ||
|
|
c415f14dd0 | ||
|
|
33177a0c48 | ||
|
|
a875fa8285 | ||
|
|
531571bfa5 | ||
|
|
acf67fef06 | ||
|
|
bfa513cd6d |
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# smarm pre-commit gate: clippy the library (src/) with warnings as errors.
|
||||
# unwrap_used / expect_used are denied (Cargo.toml [lints.clippy]): library
|
||||
# code must not hide a panic behind unwrap/expect. Tests/examples are not gated.
|
||||
#
|
||||
# Toolchain resolution: prefer an installed cargo-clippy; on machines whose
|
||||
# rust comes without the clippy component (e.g. NixOS home-manager), fall
|
||||
# back to an ephemeral nix-shell toolchain. The fallback uses its own target
|
||||
# dir (target/clippy) because the shell's rustc version may differ from the
|
||||
# default toolchain's — mixed-compiler artifacts in one target dir are an
|
||||
# E0514 hard error. MSRV (Cargo.toml rust-version) keeps the older shell
|
||||
# toolchain a legitimate gate.
|
||||
set -eu
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
if cargo clippy --version >/dev/null 2>&1; then
|
||||
cargo clippy --lib -- -D warnings
|
||||
elif command -v nix-shell >/dev/null 2>&1; then
|
||||
nix-shell -p clippy -p cargo -p rustc \
|
||||
--run 'CARGO_TARGET_DIR=target/clippy cargo clippy --lib -- -D warnings'
|
||||
else
|
||||
echo "pre-commit: cargo clippy unavailable and no nix-shell fallback" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -4,3 +4,4 @@ smarm_trace.json
|
||||
/bench_results/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
profile.coz
|
||||
|
||||
+25
@@ -7,9 +7,22 @@ rust-version = "1.95"
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
|
||||
|
||||
[lints.clippy]
|
||||
# Library code must never hide a panic behind unwrap/expect. Both are denied; an
|
||||
# intentional panic is written explicitly as `match { Err(e) => panic!(..) }`.
|
||||
# panic!/unreachable! are deliberately left un-linted as the blessed explicit
|
||||
# form. Enforced on the library target only (`cargo clippy --lib`); tests and
|
||||
# examples unwrap freely and are not gated.
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
|
||||
[features]
|
||||
default = ["rq-mutex"]
|
||||
smarm-trace = []
|
||||
# RFC 007: native causal profiling. Zero cost when off (cf. smarm-trace): the
|
||||
# hook in `maybe_preempt` and the resume-path fast-forward compile away; the
|
||||
# two Slot ledger fields exist regardless and stay 0 (budget_cycles precedent).
|
||||
smarm-causal = []
|
||||
# RFC 016 Chunk 2: cycle-accurate per-actor time-budget accounting. Off by
|
||||
# default — it costs two extra RDTSC reads per actor resume on the hot path
|
||||
# (D6). The `ActorInfo.budget_cycles` field exists regardless; it just stays 0
|
||||
@@ -80,3 +93,15 @@ harness = false
|
||||
[[example]]
|
||||
name = "observer"
|
||||
required-features = ["observer"]
|
||||
|
||||
[[example]]
|
||||
name = "causal_pipeline"
|
||||
required-features = ["smarm-causal"]
|
||||
|
||||
[[example]]
|
||||
name = "causal_attrib_probe"
|
||||
required-features = ["smarm-causal"]
|
||||
|
||||
[[example]]
|
||||
name = "causal_probe"
|
||||
required-features = ["smarm-causal"]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Attribution-efficiency probe (RFC 007 follow-up).
|
||||
//!
|
||||
//! Original hypothesis: the ~4pt impact shortfall on the 24-core
|
||||
//! validation (+29.3/+83.5 vs theoretical +33/+100) is a constant
|
||||
//! attribution efficiency eff ≈ 0.91 from site-exit tail truncation.
|
||||
//! The guard-drop flush closed that leak, yet eff held at ~0.93 —
|
||||
//! RESOLVED (2026-07-13 sweep): the residual is runnable off-CPU time
|
||||
//! inside the site (~4.9 slice-expiry yields/entry x ~5.6µs runqueue
|
||||
//! wait), wall time the ground truth below counts but on-CPU
|
||||
//! attribution correctly skips. The offcpu audit bucket now counts it;
|
||||
//! `eff+offcpu` printed per window should sit at ~1.00 — the
|
||||
//! closed-books check.
|
||||
//!
|
||||
//! Measurement: same pipeline as `causal_pipeline`, but the `reserve`
|
||||
//! actor also measures its raw in-site time directly (rdtsc at guard
|
||||
//! enter/exit) and counts site entries. For each experiment window at
|
||||
//! pct%:
|
||||
//!
|
||||
//! eff = (Δglobal_delay / (pct/100)) / Δin_site_cycles
|
||||
//!
|
||||
//! and the missing time per site entry localizes the leak:
|
||||
//!
|
||||
//! tail_us/entry = (Δin_site − Δglobal_delay/(pct/100)) / Δentries
|
||||
//!
|
||||
//! A constant eff across 25/50% with tail/entry in the tens of µs
|
||||
//! localizes a per-entry mechanism; `eff+offcpu` ≈ 1.00 confirms the
|
||||
//! runnable-gap account and rules out any remaining silent loss.
|
||||
//!
|
||||
//! Run: cargo run --release --example causal_attrib_probe --features smarm-causal
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn rdtsc() -> u64 {
|
||||
// x86_64 only — same clock the ledger uses.
|
||||
unsafe { core::arch::x86_64::_rdtsc() }
|
||||
}
|
||||
|
||||
/// Same fixed-work loop as causal_pipeline (dependent LCG, preemptible).
|
||||
fn work_iters(iters: u64) {
|
||||
let mut acc = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut i = 0u64;
|
||||
while i < iters {
|
||||
let chunk_end = (i + 256).min(iters);
|
||||
while i < chunk_end {
|
||||
acc = acc.wrapping_mul(6364136223846793005).wrapping_add(i);
|
||||
i += 1;
|
||||
}
|
||||
std::hint::black_box(acc);
|
||||
smarm::check!();
|
||||
}
|
||||
}
|
||||
|
||||
fn calibrate_iters_per_us() -> u64 {
|
||||
let n = 8_000_000u64;
|
||||
let t = Instant::now();
|
||||
work_iters(n);
|
||||
(n / (t.elapsed().as_micros().max(1) as u64)).max(1)
|
||||
}
|
||||
|
||||
static IN_SITE_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static SITE_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn main() {
|
||||
let per_us = calibrate_iters_per_us();
|
||||
println!("calibration: {per_us} work iters/µs");
|
||||
let work_us = move |us: u64| work_iters(us * per_us);
|
||||
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
println!("cores: {cores}");
|
||||
if cores < 4 {
|
||||
println!("probe: SKIPPED (needs the stages in parallel)");
|
||||
return;
|
||||
}
|
||||
|
||||
smarm::init(smarm::Config::default()).run(move || {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx_ab, rx_ab) = smarm::channel::<u64>();
|
||||
let (tx_bc, rx_bc) = smarm::channel::<u64>();
|
||||
|
||||
let stop_p = stop.clone();
|
||||
let producer = smarm::spawn(move || {
|
||||
let mut i = 0u64;
|
||||
while !stop_p.load(Ordering::Relaxed) {
|
||||
{
|
||||
let _g = smarm::causal_site!("serialize");
|
||||
work_us(200);
|
||||
}
|
||||
if tx_ab.send(i).is_err() {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Reserve: the target — instrumented with ground-truth in-site time.
|
||||
let reserve = smarm::spawn(move || {
|
||||
while let Ok(item) = rx_ab.recv() {
|
||||
{
|
||||
let t0 = rdtsc();
|
||||
let _g = smarm::causal_site!("reserve");
|
||||
work_us(400);
|
||||
// Measured before guard drop: exactly the span the
|
||||
// ledger should be attributing.
|
||||
IN_SITE_CYCLES.fetch_add(rdtsc().saturating_sub(t0), Ordering::Relaxed);
|
||||
SITE_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if tx_bc.send(item).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let notify = smarm::spawn(move || {
|
||||
while rx_bc.recv().is_ok() {
|
||||
{
|
||||
let _g = smarm::causal_site!("notify");
|
||||
work_us(50);
|
||||
}
|
||||
smarm::progress!("orders-processed");
|
||||
}
|
||||
});
|
||||
|
||||
let stop_bg = stop.clone();
|
||||
let background = smarm::spawn(move || {
|
||||
while !stop_bg.load(Ordering::Relaxed) {
|
||||
let _g = smarm::causal_site!("background-compaction");
|
||||
work_us(500);
|
||||
}
|
||||
});
|
||||
|
||||
smarm::sleep(Duration::from_millis(300));
|
||||
|
||||
let hz = smarm::causal::tsc_hz();
|
||||
println!("tsc_hz: {:.3} GHz", hz / 1e9);
|
||||
|
||||
// Manual windows so ledger/ground-truth snapshots align exactly.
|
||||
for &pct in &[25u32, 50, 50, 25] {
|
||||
let g0 = smarm::causal::global_delay_cycles();
|
||||
let s0 = IN_SITE_CYCLES.load(Ordering::Relaxed);
|
||||
let e0 = SITE_ENTRIES.load(Ordering::Relaxed);
|
||||
let a0 = smarm::causal::ledger_counters();
|
||||
smarm::causal::begin_experiment_for_test("reserve", pct);
|
||||
smarm::sleep(Duration::from_millis(1000));
|
||||
smarm::causal::end_experiment_for_test();
|
||||
let audit = smarm::causal::ledger_counters().delta_since(&a0);
|
||||
let injected = smarm::causal::global_delay_cycles() - g0;
|
||||
let in_site = IN_SITE_CYCLES.load(Ordering::Relaxed) - s0;
|
||||
let entries = SITE_ENTRIES.load(Ordering::Relaxed) - e0;
|
||||
|
||||
let attributed = injected as f64 / (pct as f64 / 100.0);
|
||||
let eff = attributed / in_site as f64;
|
||||
// Books-closure check: add back the runnable off-CPU gaps the
|
||||
// audit counted (delta terms -> raw via /pct) — should be ~1.00.
|
||||
let eff_closed = (injected as f64 + audit.offcpu_in_site_cycles as f64)
|
||||
/ (pct as f64 / 100.0)
|
||||
/ in_site as f64;
|
||||
let missing = in_site as f64 - attributed;
|
||||
let tail_us = if entries > 0 {
|
||||
missing / entries as f64 / hz * 1e6
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
println!(
|
||||
"pct {pct:>2}% in_site {:>8.1}ms attributed {:>8.1}ms eff {eff:.3} eff+offcpu {eff_closed:.3} entries {entries} missing/entry {tail_us:.1}µs",
|
||||
in_site as f64 / hz * 1e3,
|
||||
attributed / hz * 1e3,
|
||||
);
|
||||
// RFC 007 deficit hunt: name the losses. Drop/discard columns are
|
||||
// in would-be delta terms — divide by pct/100 to compare with the
|
||||
// missing attribution above.
|
||||
let ms = |c: u64| c as f64 / hz * 1e3;
|
||||
println!(
|
||||
" audit: absorbed {:>7.1}ms forgiven {:>6.1}ms drop park {:>5.2}ms/{:<5} yield {:>5.2}ms/{:<5} offcpu {:>6.2}ms/{:<5} discard >max {:>5.2}ms/{:<3} unarmed {}",
|
||||
ms(audit.spin_absorbed_cycles),
|
||||
ms(audit.park_forgiven_cycles),
|
||||
ms(audit.drop_park_cycles),
|
||||
audit.drop_park_n,
|
||||
ms(audit.drop_yield_cycles),
|
||||
audit.drop_yield_n,
|
||||
ms(audit.offcpu_in_site_cycles),
|
||||
audit.offcpu_in_site_n,
|
||||
ms(audit.discard_overmax_cycles),
|
||||
audit.discard_overmax_n,
|
||||
audit.discard_unarmed_n
|
||||
);
|
||||
smarm::sleep(Duration::from_millis(150));
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
producer.join().unwrap();
|
||||
reserve.join().unwrap();
|
||||
notify.join().unwrap();
|
||||
background.join().unwrap();
|
||||
println!("probe: DONE");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Causal-profiling demo (RFC 007): a pipeline where conventional profiling
|
||||
//! lies and causal profiling doesn't.
|
||||
//!
|
||||
//! producer --(serialize ~200µs/item)--> reserve --(~400µs/item)--> notify
|
||||
//! background: an actor burning CPU constantly, fully off the critical path
|
||||
//!
|
||||
//! `reserve` is the true bottleneck. `serialize` is hot but overlapped with
|
||||
//! `reserve`'s backlog, and `background` is the hottest code in the process
|
||||
//! while contributing nothing to throughput. A cycle profiler ranks them
|
||||
//! background > reserve ≈ 2×serialize; the causal report instead shows
|
||||
//! throughput responding to virtual speedups of `reserve` and (near-)ignoring
|
||||
//! `serialize` and `background`.
|
||||
//!
|
||||
//! Stage cost is fixed *work* (a calibrated arithmetic loop), not fixed wall
|
||||
//! time. This matters: a timed busy-wait absorbs injected causal delay into
|
||||
//! its own budget and finishes on schedule regardless, making every
|
||||
//! experiment read as a no-op (found live on a 24-core run: dead-flat
|
||||
//! deltas). Real workloads are work-shaped, so the demo must be too.
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo run --release --example causal_pipeline --features smarm-causal
|
||||
//!
|
||||
//! Modes (`SMARM_CAUSAL_MODE`), for probing what the guard placement leaves
|
||||
//! out of the measurement (a site speeds up only what it wraps; `recv`/`send`
|
||||
//! on the serialized stage sit outside the canonical guard):
|
||||
//! work (default) — guard wraps only the 400µs of work.
|
||||
//! wide — guard widened over recv + work + send, the whole
|
||||
//! serialized per-item path.
|
||||
//! occupancy — no experiments; times each segment of reserve's loop
|
||||
//! at baseline and reports the unguarded per-item
|
||||
//! overhead δ plus the impact ceiling it implies.
|
||||
//!
|
||||
//! Result (24-core run, 2026-07-13, job f9305cbb): δ measured 0.3µs/item —
|
||||
//! 0.1% of the serialized path — and `wide` does not move the @50% cell
|
||||
//! (+83.5/+86.3 vs work's +81.5/+86.7). This demo's +84-vs-+100 @50%
|
||||
//! shortfall is therefore NOT unguarded stage time; it is controller-side:
|
||||
//! injected delay reaches ~327ms of the ideal 350ms over the 700ms window,
|
||||
//! plus a ~3% real-throughput dip while experiments run. Contrast urus's
|
||||
//! causal_bench, where the same arithmetic identified a real ~70µs/request
|
||||
//! unguarded remainder (recv/reply outside the store guard). Sites measure
|
||||
//! what they wrap — and the occupancy probe tells you which case you're in.
|
||||
//!
|
||||
//! Prints a summary, writes `profile.coz` (Coz plot-compatible), and — given
|
||||
//! enough cores for the pipeline to actually run in parallel — checks the
|
||||
//! expected separation and exits nonzero if it doesn't hold, so a CI box can
|
||||
//! run this as a smoke test.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// LCG-mix `iters` times in dependent sequence (unvectorizable, un-elidable),
|
||||
/// staying preemptible — and causal-sampleable/delayable — via `check!()`.
|
||||
fn work_iters(iters: u64) {
|
||||
let mut acc = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut i = 0u64;
|
||||
while i < iters {
|
||||
let chunk_end = (i + 256).min(iters);
|
||||
while i < chunk_end {
|
||||
acc = acc.wrapping_mul(6364136223846793005).wrapping_add(i);
|
||||
i += 1;
|
||||
}
|
||||
std::hint::black_box(acc);
|
||||
smarm::check!();
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure how many `work_iters` iterations fit in a microsecond on this
|
||||
/// machine, so stage costs below are meaningful in time while staying
|
||||
/// work-shaped.
|
||||
fn calibrate_iters_per_us() -> u64 {
|
||||
let n = 8_000_000u64;
|
||||
let t = Instant::now();
|
||||
work_iters(n);
|
||||
(n / (t.elapsed().as_micros().max(1) as u64)).max(1)
|
||||
}
|
||||
|
||||
/// Guard placement for the `reserve` stage — see module doc.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Mode {
|
||||
Work,
|
||||
Wide,
|
||||
Occupancy,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mode = match std::env::var("SMARM_CAUSAL_MODE").as_deref() {
|
||||
Err(_) | Ok("") | Ok("work") => Mode::Work,
|
||||
Ok("wide") => Mode::Wide,
|
||||
Ok("occupancy") => Mode::Occupancy,
|
||||
Ok(other) => {
|
||||
eprintln!("unknown SMARM_CAUSAL_MODE {other:?} (work|wide|occupancy)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"mode: {}",
|
||||
match mode {
|
||||
Mode::Work => "work",
|
||||
Mode::Wide => "wide",
|
||||
Mode::Occupancy => "occupancy",
|
||||
}
|
||||
);
|
||||
let per_us = calibrate_iters_per_us();
|
||||
println!("calibration: {per_us} work iters/µs");
|
||||
let work_us = move |us: u64| work_iters(us * per_us);
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
smarm::init(smarm::Config::default()).run(move || {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let (tx_ab, rx_ab) = smarm::channel::<u64>();
|
||||
let (tx_bc, rx_bc) = smarm::channel::<u64>();
|
||||
|
||||
// Producer: hot serialization, but upstream of the bottleneck.
|
||||
let stop_p = stop.clone();
|
||||
let producer = smarm::spawn(move || {
|
||||
let mut i = 0u64;
|
||||
while !stop_p.load(Ordering::Relaxed) {
|
||||
{
|
||||
let _g = smarm::causal_site!("serialize");
|
||||
work_us(200);
|
||||
}
|
||||
if tx_ab.send(i).is_err() {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// tx_ab drops here; downstream drains and exits.
|
||||
});
|
||||
|
||||
// Reserve: the true bottleneck (~400µs of work per item).
|
||||
let reserve = smarm::spawn(move || match mode {
|
||||
Mode::Work => {
|
||||
while let Ok(item) = rx_ab.recv() {
|
||||
{
|
||||
let _g = smarm::causal_site!("reserve");
|
||||
work_us(400);
|
||||
}
|
||||
if tx_bc.send(item).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whole serialized per-item path under the guard: a virtual
|
||||
// speedup now also compresses recv/send, so the @50% cell should
|
||||
// recover the theoretical 2× that `work` mode's placement caps.
|
||||
Mode::Wide => loop {
|
||||
let _g = smarm::causal_site!("reserve");
|
||||
let Ok(item) = rx_ab.recv() else { break };
|
||||
work_us(400);
|
||||
if tx_bc.send(item).is_err() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
// Time each segment at baseline; the recv+send remainder δ is
|
||||
// the serialized time a `work`-placed guard cannot speed up.
|
||||
Mode::Occupancy => {
|
||||
let (mut recv_ns, mut work_ns, mut send_ns, mut n) = (0u64, 0u64, 0u64, 0u64);
|
||||
loop {
|
||||
let t0 = Instant::now();
|
||||
let Ok(item) = rx_ab.recv() else { break };
|
||||
let t1 = Instant::now();
|
||||
{
|
||||
let _g = smarm::causal_site!("reserve");
|
||||
work_us(400);
|
||||
}
|
||||
let t2 = Instant::now();
|
||||
if tx_bc.send(item).is_err() {
|
||||
break;
|
||||
}
|
||||
recv_ns += (t1 - t0).as_nanos() as u64;
|
||||
work_ns += (t2 - t1).as_nanos() as u64;
|
||||
send_ns += t2.elapsed().as_nanos() as u64;
|
||||
n += 1;
|
||||
}
|
||||
let items = n.max(1) as f64;
|
||||
let (r, w, s) = (
|
||||
recv_ns as f64 / items / 1e3,
|
||||
work_ns as f64 / items / 1e3,
|
||||
send_ns as f64 / items / 1e3,
|
||||
);
|
||||
let delta = r + s;
|
||||
let total = w + delta;
|
||||
println!("occupancy: {n} items; per item recv {r:.1}µs + work(guarded) {w:.1}µs + send {s:.1}µs");
|
||||
println!(
|
||||
"occupancy: unguarded δ = {delta:.1}µs/item = {:.1}% of the serialized path",
|
||||
100.0 * delta / total
|
||||
);
|
||||
for pct in [25u32, 50] {
|
||||
let f = 1.0 - f64::from(pct) / 100.0;
|
||||
println!(
|
||||
"occupancy: predicted reserve impact @{pct}% -> {:+.1}% (ceiling if δ were guarded: {:+.1}%)",
|
||||
100.0 * (total / (f * w + delta) - 1.0),
|
||||
100.0 * (1.0 / f - 1.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notify: light tail stage; marks the unit of useful work.
|
||||
let notify = smarm::spawn(move || {
|
||||
while rx_bc.recv().is_ok() {
|
||||
{
|
||||
let _g = smarm::causal_site!("notify");
|
||||
work_us(50);
|
||||
}
|
||||
smarm::progress!("orders-processed");
|
||||
}
|
||||
});
|
||||
|
||||
// Background: hottest code in the process, zero throughput relevance.
|
||||
let stop_bg = stop.clone();
|
||||
let background = smarm::spawn(move || {
|
||||
while !stop_bg.load(Ordering::Relaxed) {
|
||||
let _g = smarm::causal_site!("background-compaction");
|
||||
work_us(500);
|
||||
}
|
||||
});
|
||||
|
||||
// Warm up so queues reach steady state before measuring.
|
||||
smarm::sleep(Duration::from_millis(300));
|
||||
|
||||
if mode == Mode::Occupancy {
|
||||
// No experiments: hold steady state for a window, then drain and
|
||||
// let the reserve actor print its segment report.
|
||||
smarm::sleep(Duration::from_millis(1500));
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
producer.join().unwrap();
|
||||
reserve.join().unwrap();
|
||||
notify.join().unwrap();
|
||||
background.join().unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let results = smarm::causal::run_experiments(&smarm::causal::ExperimentPlan {
|
||||
speedups_pct: vec![0, 25, 50],
|
||||
experiment: Duration::from_millis(700),
|
||||
cooldown: Duration::from_millis(150),
|
||||
});
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
producer.join().unwrap();
|
||||
reserve.join().unwrap();
|
||||
notify.join().unwrap();
|
||||
background.join().unwrap();
|
||||
|
||||
print!("{}", smarm::causal::render_summary(&results));
|
||||
// RFC 007 deficit hunt: SMARM_CAUSAL_AUDIT=1 appends the per-cell
|
||||
// ledger audit (injected/absorbed/forgiven + drop and discard
|
||||
// buckets) without touching the pinned summary format.
|
||||
if std::env::var_os("SMARM_CAUSAL_AUDIT").is_some() {
|
||||
print!("{}", smarm::causal::render_ledger_audit(&results));
|
||||
}
|
||||
let coz = smarm::causal::render_coz(&results);
|
||||
match std::fs::write("profile.coz", coz) {
|
||||
Ok(()) => println!("\nwrote profile.coz"),
|
||||
Err(e) => eprintln!("\nfailed to write profile.coz: {e}"),
|
||||
}
|
||||
|
||||
// Verdict. The separation only exists when the four pipeline actors
|
||||
// actually run in parallel; on a small box, report and skip.
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
if cores < 4 {
|
||||
println!("verdict: SKIPPED ({cores} cores; separation needs the stages in parallel)");
|
||||
return;
|
||||
}
|
||||
let impact = |site: &str| {
|
||||
smarm::causal::impact_pct(&results, site, 25, "orders-processed")
|
||||
};
|
||||
let mut expect = |site: &str, ok: &dyn Fn(f64) -> bool, want: &str| match impact(site) {
|
||||
Some(p) => {
|
||||
let verdict = if ok(p) { "ok" } else { "FAIL" };
|
||||
println!("verdict: {site} @25% -> {p:+.1}% (want {want}) {verdict}");
|
||||
if !ok(p) {
|
||||
failures.push(format!("{site}: {p:+.1}% (want {want})"));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("verdict: {site} @25% -> missing cell FAIL");
|
||||
failures.push(format!("{site}: missing cell"));
|
||||
}
|
||||
};
|
||||
expect("reserve", &|p| p > 15.0, "> +15%");
|
||||
expect("serialize", &|p| p < 10.0, "< +10%");
|
||||
expect("background-compaction", &|p| p < 10.0, "< +10%");
|
||||
|
||||
if failures.is_empty() {
|
||||
println!("verdict: PASS — causal separation holds");
|
||||
} else {
|
||||
println!("verdict: FAIL — {}", failures.join("; "));
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Diagnostic probe for RFC 007 on a target box. Measures, in order:
|
||||
//! 1. TSC frequency against `Instant` (the crate assumes 3 GHz).
|
||||
//! 2. TSC sanity under actor migration: distribution of wall time actually
|
||||
//! spent in `burn_us(400)` across many runs — a bimodal/short tail means
|
||||
//! cross-core TSC offsets are cutting burns short.
|
||||
//! 3. Pipeline stage rates with no experiment running (who is the real
|
||||
//! bottleneck?).
|
||||
//! 4. The same rates during a 50% experiment on `background-compaction`
|
||||
//! (a correct implementation must slow every stage; an off-critical-path
|
||||
//! target must reduce end-to-end throughput proportionally).
|
||||
//!
|
||||
//! Run: cargo run --release --example causal_probe --features smarm-causal
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn burn_us(us: u64) {
|
||||
let cycles = us * 3_000;
|
||||
let start = smarm::preempt::rdtsc();
|
||||
while smarm::preempt::rdtsc().saturating_sub(start) < cycles {
|
||||
smarm::check!();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// 1. TSC calibration (plain OS thread, before the runtime starts).
|
||||
let c0 = smarm::preempt::rdtsc();
|
||||
let t0 = Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let hz = (smarm::preempt::rdtsc() - c0) as f64 / t0.elapsed().as_secs_f64();
|
||||
println!("tsc_hz: {:.3e} (crate assumes 3.0e9)", hz);
|
||||
|
||||
smarm::init(smarm::Config::default()).run(move || {
|
||||
// 2. burn_us(400) wall-time distribution inside a migrating actor.
|
||||
let h = smarm::spawn(|| {
|
||||
let mut samples: Vec<u64> = (0..500)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
burn_us(400);
|
||||
t.elapsed().as_micros() as u64
|
||||
})
|
||||
.collect();
|
||||
samples.sort_unstable();
|
||||
println!(
|
||||
"burn_us(400) wall us: min {} p10 {} p50 {} p90 {} max {}",
|
||||
samples[0], samples[50], samples[250], samples[450], samples[499]
|
||||
);
|
||||
});
|
||||
h.join().unwrap();
|
||||
|
||||
// 3+4. Pipeline with per-stage counters.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let produced = Arc::new(AtomicU64::new(0));
|
||||
let reserved = Arc::new(AtomicU64::new(0));
|
||||
let notified = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let (tx_ab, rx_ab) = smarm::channel::<u64>();
|
||||
let (tx_bc, rx_bc) = smarm::channel::<u64>();
|
||||
|
||||
let stop_p = stop.clone();
|
||||
let produced2 = produced.clone();
|
||||
let producer = smarm::spawn(move || {
|
||||
let mut i = 0u64;
|
||||
while !stop_p.load(Ordering::Relaxed) {
|
||||
{
|
||||
let _g = smarm::causal_site!("serialize");
|
||||
burn_us(200);
|
||||
}
|
||||
if tx_ab.send(i).is_err() {
|
||||
break;
|
||||
}
|
||||
produced2.fetch_add(1, Ordering::Relaxed);
|
||||
i += 1;
|
||||
}
|
||||
});
|
||||
|
||||
let reserved2 = reserved.clone();
|
||||
let reserve = smarm::spawn(move || {
|
||||
while let Ok(item) = rx_ab.recv() {
|
||||
{
|
||||
let _g = smarm::causal_site!("reserve");
|
||||
burn_us(400);
|
||||
}
|
||||
reserved2.fetch_add(1, Ordering::Relaxed);
|
||||
if tx_bc.send(item).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let notified2 = notified.clone();
|
||||
let notify = smarm::spawn(move || {
|
||||
while rx_bc.recv().is_ok() {
|
||||
{
|
||||
let _g = smarm::causal_site!("notify");
|
||||
burn_us(50);
|
||||
}
|
||||
notified2.fetch_add(1, Ordering::Relaxed);
|
||||
smarm::progress!("orders-processed");
|
||||
}
|
||||
});
|
||||
|
||||
let stop_bg = stop.clone();
|
||||
let background = smarm::spawn(move || {
|
||||
while !stop_bg.load(Ordering::Relaxed) {
|
||||
let _g = smarm::causal_site!("background-compaction");
|
||||
burn_us(500);
|
||||
}
|
||||
});
|
||||
|
||||
smarm::sleep(Duration::from_millis(300));
|
||||
|
||||
let window = |label: &str| {
|
||||
let (p0, r0, n0) = (
|
||||
produced.load(Ordering::Relaxed),
|
||||
reserved.load(Ordering::Relaxed),
|
||||
notified.load(Ordering::Relaxed),
|
||||
);
|
||||
let d0 = smarm::causal::global_delay_cycles();
|
||||
let t = Instant::now();
|
||||
smarm::sleep(Duration::from_millis(700));
|
||||
let secs = t.elapsed().as_secs_f64();
|
||||
println!(
|
||||
"{label}: produced {:.0}/s reserved {:.0}/s notified {:.0}/s injected {:.0}ms(assumed-3GHz)",
|
||||
(produced.load(Ordering::Relaxed) - p0) as f64 / secs,
|
||||
(reserved.load(Ordering::Relaxed) - r0) as f64 / secs,
|
||||
(notified.load(Ordering::Relaxed) - n0) as f64 / secs,
|
||||
(smarm::causal::global_delay_cycles() - d0) as f64 / 3.0e9 * 1e3,
|
||||
);
|
||||
};
|
||||
|
||||
window("no-experiment ");
|
||||
smarm::causal::begin_experiment_for_test("background-compaction", 50);
|
||||
window("bg-comp @ 50% ");
|
||||
smarm::causal::end_experiment_for_test();
|
||||
window("post-experiment");
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
producer.join().unwrap();
|
||||
reserve.join().unwrap();
|
||||
notify.join().unwrap();
|
||||
background.join().unwrap();
|
||||
});
|
||||
}
|
||||
@@ -31,12 +31,12 @@
|
||||
//! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents
|
||||
//! how to make each of the four guarantees fire.
|
||||
//!
|
||||
//! Run: `cargo run --example gen_statem_fused`
|
||||
//! Run: `cargo run --example gen_statem_expanded`
|
||||
|
||||
#![deny(dead_code, unreachable_patterns)]
|
||||
|
||||
use smarm::run;
|
||||
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, GenStatemRef};
|
||||
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, Step, GenStatemRef};
|
||||
|
||||
// === user types ============================================================
|
||||
|
||||
@@ -50,6 +50,7 @@ enum Door {
|
||||
struct Data {
|
||||
enters: u32, // total state entries (incl. initial)
|
||||
pushes: u32, // times a push closed the door
|
||||
knocks: u32, // knocks answered (a Locked knock is postponed, then counted)
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
@@ -57,17 +58,24 @@ enum Cast {
|
||||
Pull,
|
||||
Lock,
|
||||
Unlock(u32), // carries a key
|
||||
Knock, // counted when the door is reachable; postponed while Locked
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetState(Reply<Door>),
|
||||
GetEnters(Reply<u32>),
|
||||
GetPushes(Reply<u32>),
|
||||
GetKnocks(Reply<u32>),
|
||||
}
|
||||
|
||||
enum Ev {
|
||||
Cast(Cast),
|
||||
Call(Call),
|
||||
// The runtime's internal events. `Info` is out-of-band (here unused, so
|
||||
// `()`); `StateTimeout` / `Timeout` are timer fires the loop feeds back in.
|
||||
Info(()),
|
||||
StateTimeout,
|
||||
Timeout(&'static str),
|
||||
}
|
||||
|
||||
const CODE: u32 = 1234;
|
||||
@@ -115,34 +123,62 @@ impl DoorSm {
|
||||
fn start(init: Door) -> GenStatemRef<DoorSm> {
|
||||
spawn(DoorSm {
|
||||
state: init,
|
||||
data: Data { enters: 0, pushes: 0 },
|
||||
data: Data { enters: 0, pushes: 0, knocks: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
fn enter(&mut self, _cx: &mut Cx<Ev>) {
|
||||
fn enter(&mut self, cx: &mut Cx<Ev>) {
|
||||
self.data.enters += 1;
|
||||
// A state-timeout: an Open door auto-closes after a quiet window. The
|
||||
// loop auto-resets it on any transition, so it fires only if the door is
|
||||
// still Open when it elapses.
|
||||
if self.state == Door::Open {
|
||||
cx.state_timeout(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine for DoorSm {
|
||||
type Ev = Ev;
|
||||
|
||||
fn state_timeout_ev() -> Ev {
|
||||
Ev::StateTimeout
|
||||
}
|
||||
|
||||
fn timeout_ev(name: &'static str) -> Ev {
|
||||
Ev::Timeout(name)
|
||||
}
|
||||
|
||||
fn on_start(&mut self, cx: &mut Cx<Ev>) {
|
||||
self.enter(cx);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
|
||||
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) -> Step<Ev> {
|
||||
let prev = self.state;
|
||||
|
||||
// ---- the transition table -----------------------------------------
|
||||
// This match is total over (Door, Ev). Read it as the declared graph:
|
||||
// each `=> To(x)` is an edge, each `=> Unhandled` an explicit refusal.
|
||||
// ---- phase 1: postpone routing (borrow-only) ----------------------
|
||||
// A deferred event is handed back untouched for the loop's postpone
|
||||
// queue; no handler code runs on it. Here: a knock at a locked door.
|
||||
// (The macro emits this as a `match (state, &ev)` yielding a bool; the
|
||||
// hand-written form can just test directly.)
|
||||
if let (Door::Locked, Ev::Cast(Cast::Knock)) = (prev, &ev) {
|
||||
return Step::Postponed(ev);
|
||||
}
|
||||
|
||||
// ---- phase 2: the consuming transition table ----------------------
|
||||
// Total over (Door, Ev). Read it as the declared graph: each `=> To(x)`
|
||||
// is an edge, each `=> Unhandled` an explicit refusal. The postponed
|
||||
// pair above reappears as `unreachable!` so the match stays total.
|
||||
let res: Resolution<Door> = match (self.state, ev) {
|
||||
// --- Open -------------------------------------------------------
|
||||
(Door::Open, Ev::Cast(Cast::Push)) => {
|
||||
on_push(&mut self.data);
|
||||
Resolution::To(Door::Closed)
|
||||
}
|
||||
(Door::Open, Ev::Cast(Cast::Knock)) => {
|
||||
self.data.knocks += 1;
|
||||
Resolution::To(prev)
|
||||
}
|
||||
(Door::Open, Ev::Cast(Cast::Pull | Cast::Lock | Cast::Unlock(_))) => {
|
||||
Resolution::Unhandled
|
||||
}
|
||||
@@ -150,12 +186,20 @@ impl Machine for DoorSm {
|
||||
// --- Closed -----------------------------------------------------
|
||||
(Door::Closed, Ev::Cast(Cast::Pull)) => Resolution::To(Door::Open),
|
||||
(Door::Closed, Ev::Cast(Cast::Lock)) => Resolution::To(Door::Locked),
|
||||
(Door::Closed, Ev::Cast(Cast::Knock)) => {
|
||||
self.data.knocks += 1;
|
||||
Resolution::To(prev)
|
||||
}
|
||||
(Door::Closed, Ev::Cast(Cast::Push | Cast::Unlock(_))) => Resolution::Unhandled,
|
||||
|
||||
// --- Locked (branching row: handler picks within UnlockOutcome) -
|
||||
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => {
|
||||
Resolution::To(on_unlock(key).into())
|
||||
}
|
||||
// Routed out in phase 1; listed only to keep this match total.
|
||||
(Door::Locked, Ev::Cast(Cast::Knock)) => {
|
||||
unreachable!("postponed event is replayed, not dispatched here")
|
||||
}
|
||||
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => {
|
||||
Resolution::Unhandled
|
||||
}
|
||||
@@ -173,17 +217,38 @@ impl Machine for DoorSm {
|
||||
r.reply(self.data.pushes);
|
||||
Resolution::To(prev)
|
||||
}
|
||||
(_, Ev::Call(Call::GetKnocks(r))) => {
|
||||
r.reply(self.data.knocks);
|
||||
Resolution::To(prev)
|
||||
}
|
||||
|
||||
// --- timeouts: an Open door auto-closes; others have none armed --
|
||||
(Door::Open, Ev::StateTimeout) => Resolution::To(Door::Closed),
|
||||
(_, Ev::StateTimeout) => Resolution::Unhandled,
|
||||
(_, Ev::Timeout(name)) => {
|
||||
// No named timeout is armed in this run; a real handler would
|
||||
// dispatch on `name`. Acknowledge it to exercise the field.
|
||||
let _ = name;
|
||||
Resolution::Unhandled
|
||||
}
|
||||
|
||||
// --- out-of-band info: silent drop (the gen_server default) ------
|
||||
(_, Ev::Info(_)) => Resolution::Unhandled,
|
||||
};
|
||||
|
||||
// ---- apply the resolution -----------------------------------------
|
||||
match res {
|
||||
Resolution::To(s) if s == prev => {} // stay: no enter
|
||||
Resolution::To(s) if s == prev => Step::Stayed, // stay: no enter
|
||||
Resolution::To(s) => {
|
||||
self.state = s; // sole writer of the state cell
|
||||
cx.__reset_state_timeout(); // auto-reset across transitions
|
||||
self.enter(cx);
|
||||
Step::Transitioned
|
||||
}
|
||||
Resolution::Unhandled => {
|
||||
cx.on_unhandled();
|
||||
Step::Stayed
|
||||
}
|
||||
Resolution::Postpone => unreachable!("postpone is not generated yet"),
|
||||
Resolution::Unhandled => cx.on_unhandled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,20 +258,29 @@ fn main() {
|
||||
let door = DoorSm::start(Door::Closed);
|
||||
|
||||
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
||||
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted)
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
||||
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
||||
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
||||
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
|
||||
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay (knock still deferred)
|
||||
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed; deferred Knock replays here
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Closed: Push invalid -> Unhandled
|
||||
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open (arms 5ms auto-close)
|
||||
door.send(Ev::Info(())).unwrap(); // out-of-band: silently dropped
|
||||
|
||||
// Wait past the auto-close window: the state-timeout fires and the door
|
||||
// closes itself, with no further input. (`smarm::sleep` parks the actor
|
||||
// without blocking a worker thread, so the timer wheel keeps turning.)
|
||||
smarm::sleep(std::time::Duration::from_millis(40));
|
||||
|
||||
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
|
||||
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
|
||||
let knocks = door.call(|r| Ev::Call(Call::GetKnocks(r))).unwrap();
|
||||
|
||||
println!("state={st:?} enters={enters} pushes={pushes}");
|
||||
assert_eq!(st, Door::Closed);
|
||||
println!("state={st:?} enters={enters} pushes={pushes} knocks={knocks}");
|
||||
assert_eq!(st, Door::Closed); // auto-closed by the state-timeout
|
||||
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
||||
assert_eq!(pushes, 1);
|
||||
assert_eq!(pushes, 0); // no push ever closed it this run
|
||||
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
|
||||
println!("ok");
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The **same** machine as `examples/gen_statem_fused.rs`, written through the
|
||||
//! The **same** machine as `examples/gen_statem_expanded.rs`, written through the
|
||||
//! `gen_statem!` macro. Diff this file against that one to see exactly what the
|
||||
//! macro buys: every `// ===` section there that was boilerplate (the `Ev`
|
||||
//! enum, the `DoorSm` struct, `start`, the whole `Machine` impl, the `enter`
|
||||
@@ -21,7 +21,7 @@ use smarm::gen_statem;
|
||||
use smarm::run;
|
||||
use smarm::gen_statem::Reply;
|
||||
|
||||
// === user types (identical to gen_statem_fused.rs) =========================
|
||||
// === user types (identical to gen_statem_expanded.rs) =========================
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Door {
|
||||
@@ -33,6 +33,7 @@ enum Door {
|
||||
struct Data {
|
||||
enters: u32, // total state entries (incl. initial)
|
||||
pushes: u32, // times a push closed the door
|
||||
knocks: u32, // knocks answered (a Locked knock is postponed, then counted)
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
@@ -40,12 +41,14 @@ enum Cast {
|
||||
Pull,
|
||||
Lock,
|
||||
Unlock(u32), // carries a key
|
||||
Knock, // counted when the door is reachable; postponed while Locked
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetState(Reply<Door>),
|
||||
GetEnters(Reply<u32>),
|
||||
GetPushes(Reply<u32>),
|
||||
GetKnocks(Reply<u32>),
|
||||
}
|
||||
|
||||
const CODE: u32 = 1234;
|
||||
@@ -87,7 +90,7 @@ fn on_unlock(key: u32) -> UnlockOutcome {
|
||||
|
||||
gen_statem! {
|
||||
machine: DoorSm { state: Door, data: Data };
|
||||
event: Ev { cast: Cast, call: Call };
|
||||
event: Ev { cast: Cast, call: Call, info: () };
|
||||
|
||||
// You name the bindings the bodies use; the macro can't lend you its own
|
||||
// `self`/`cx` across macro hygiene. `data` = &mut Data, `prev` = current
|
||||
@@ -100,15 +103,20 @@ gen_statem! {
|
||||
|
||||
on Door::Open => {
|
||||
cast Cast::Push => { on_push(data); Door::Closed },
|
||||
cast Cast::Knock => { data.knocks += 1; prev },
|
||||
cast Cast::Pull | Cast::Lock | Cast::Unlock(_) => unhandled,
|
||||
}
|
||||
on Door::Closed => {
|
||||
cast Cast::Pull => Door::Open,
|
||||
cast Cast::Lock => Door::Locked,
|
||||
cast Cast::Knock => { data.knocks += 1; prev },
|
||||
cast Cast::Push | Cast::Unlock(_) => unhandled,
|
||||
}
|
||||
on Door::Locked => {
|
||||
cast Cast::Unlock(key) => on_unlock(key), // branch -> UnlockOutcome
|
||||
// A knock at a locked door waits: defer it until the door is reachable,
|
||||
// where the replay counts it.
|
||||
cast Cast::Knock => postpone,
|
||||
cast Cast::Push | Cast::Pull | Cast::Lock => unhandled,
|
||||
}
|
||||
|
||||
@@ -117,35 +125,43 @@ gen_statem! {
|
||||
call Call::GetState(r) => { r.reply(prev); prev },
|
||||
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||
call Call::GetPushes(r) => { r.reply(data.pushes); prev },
|
||||
call Call::GetKnocks(r) => { r.reply(data.knocks); prev },
|
||||
// This machine arms no timeouts, so refuse them everywhere. (Info has a
|
||||
// built-in silent-drop default, so it needs no row.)
|
||||
state_timeout => unhandled,
|
||||
timeout _ => unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
run(|| {
|
||||
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0 });
|
||||
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0, knocks: 0 });
|
||||
|
||||
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
||||
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted)
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
||||
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
||||
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
||||
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay (knock still deferred)
|
||||
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed; deferred Knock replays here
|
||||
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
|
||||
|
||||
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
|
||||
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
|
||||
let knocks = door.call(|r| Ev::Call(Call::GetKnocks(r))).unwrap();
|
||||
|
||||
println!("state={st:?} enters={enters} pushes={pushes}");
|
||||
println!("state={st:?} enters={enters} pushes={pushes} knocks={knocks}");
|
||||
assert_eq!(st, Door::Closed);
|
||||
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
||||
assert_eq!(pushes, 1);
|
||||
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
|
||||
println!("ok");
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// BREAK-CASE MENU — the four guarantees, through the macro. Each fires exactly
|
||||
// as it does in the hand-written gen_statem_fused.rs.
|
||||
// as it does in the hand-written gen_statem_expanded.rs.
|
||||
//
|
||||
// 1. ORPHAN HANDLER (dead_code -> error):
|
||||
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
|
||||
|
||||
+4
-2
@@ -93,8 +93,10 @@ pub fn take_last_outcome() -> Option<Outcome> {
|
||||
/// unwinding to cross the boundary, but `catch_unwind` here means unwinding
|
||||
/// never actually does.
|
||||
pub extern "C-unwind" fn trampoline() {
|
||||
let b = CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take())
|
||||
.expect("trampoline entered without a closure set");
|
||||
let b = match CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take()) {
|
||||
Some(b) => b,
|
||||
None => panic!("smarm: trampoline entered without a closure set (core corrupt)"),
|
||||
};
|
||||
|
||||
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
|
||||
Ok(()) => Outcome::Exit,
|
||||
|
||||
+959
@@ -0,0 +1,959 @@
|
||||
//! Native causal profiling (RFC 007). Enabled by `--features smarm-causal`;
|
||||
//! zero cost without it (same discipline as `smarm-trace`).
|
||||
//!
|
||||
//! The Coz algorithm, transposed onto actors: to estimate what speeding up
|
||||
//! code site S by p% would do to throughput, we instead *slow everything
|
||||
//! else down* by p% of the time spent in S, and watch the progress-point
|
||||
//! rates respond. Where Coz must inject real `usleep`s into OS threads from
|
||||
//! the outside, smarm owns every clock that matters:
|
||||
//!
|
||||
//! - Sampling and delay injection happen at `maybe_preempt`'s amortised
|
||||
//! cadence — an existing, safe hook (never inside a prep-to-park region).
|
||||
//! - Injected delay is subtracted from the actor's timeslice
|
||||
//! (`preempt::extend_timeslice`), so experiments don't perturb scheduling.
|
||||
//! - Delay bookkeeping is *actor*-granular: each `Slot` carries an absorbed-
|
||||
//! delay ledger, compared against a global ledger. Parked actors absorb
|
||||
//! accrued delay for free on resume (Coz's blocked-thread rule) — waiting
|
||||
//! is never penalised.
|
||||
//!
|
||||
//! v1 scope (per RFC discussion): explicit scoped sites (`causal_site!`)
|
||||
//! rather than PC sampling (jar Q1 stays open); throughput progress points
|
||||
//! only; timer-heap deadlines are *not* shifted (documented gap — long
|
||||
//! experiments can make real-time timeouts fire early in virtual terms);
|
||||
//! multi-scheduler coherence is best-effort via global atomics.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```ignore
|
||||
//! let _g = smarm::causal_site!("inventory-reserve"); // in suspect code
|
||||
//! smarm::progress!("orders-processed"); // per unit of work
|
||||
//! let results = smarm::causal::run_experiments(&Default::default());
|
||||
//! print!("{}", smarm::causal::render_summary(&results));
|
||||
//! std::fs::write("profile.coz", smarm::causal::render_coz(&results))?;
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
mod inner {
|
||||
use crate::preempt;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Global state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Active experiment, packed `(site_id << 32) | speedup_pct`. 0 = idle.
|
||||
/// A single word so the hot path reads one atomic; experiments are global
|
||||
/// across scheduler threads (jar Q7, v1: plain Relaxed atomics).
|
||||
static EXPERIMENT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Monotone experiment-window counter, bumped by every `begin()`. Lets
|
||||
/// the offcpu-gap stash (RFC 007) tell apart two windows with an
|
||||
/// identical site+pct word — live in the attrib probe's 50,50 schedule
|
||||
/// — so a gap straddling `end()`/`begin()` never counts a cooldown.
|
||||
static EXPERIMENT_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Global virtual-delay ledger, in TSC cycles: the total delay every
|
||||
/// actor *should* have experienced since startup. Grows while a sample
|
||||
/// lands in the experiment's target site; each actor's `Slot` ledger
|
||||
/// chases it by spin-absorbing at preemption checks.
|
||||
static GLOBAL_DELAY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Registered site names; site id = index + 1 (0 = "no site").
|
||||
static SITES: OnceLock<Mutex<Vec<&'static str>>> = OnceLock::new();
|
||||
|
||||
/// Registered progress points (leaked for `'static`, like trace's drain
|
||||
/// state — the set is small and lives for the process).
|
||||
static PROGRESS: OnceLock<Mutex<Vec<&'static ProgressPoint>>> = OnceLock::new();
|
||||
|
||||
thread_local! {
|
||||
/// TSC at this thread's previous causal check, the sample "period"
|
||||
/// denominator. Re-armed on every actor resume so scheduler time and
|
||||
/// a previous actor's tail never count toward a sample. 0 = unarmed.
|
||||
static LAST_SAMPLE_TSC: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Guard against TSC weirdness (migration between unsynced sockets,
|
||||
/// virtualisation steps): a single sample interval larger than this is
|
||||
/// discarded rather than believed. ~33ms at 3 GHz — far beyond any real
|
||||
/// gap between preemption checks inside a slice.
|
||||
const MAX_SAMPLE_CYCLES: u64 = 100_000_000;
|
||||
|
||||
/// Cap on delay spun in one visit, so one check can never wedge an actor
|
||||
/// for a human-visible pause; the remainder is absorbed on later visits.
|
||||
/// ~3ms at 3 GHz.
|
||||
const MAX_SPIN_PER_VISIT: u64 = 10_000_000;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ledger audit (RFC 007 deficit hunt): where injected delay is born,
|
||||
// paid, and forgiven — and where would-be attribution is silently lost
|
||||
// (deschedule tails, clamp discards). Monotone Relaxed totals, read via
|
||||
// `ledger_counters()`; `run_experiments` windows them into
|
||||
// `ExperimentResult`. Measure-only: nothing here changes injection or
|
||||
// absorption behaviour.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Cycles bystanders actually spun to pay down the global ledger.
|
||||
static SPIN_ABSORBED_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
/// Cycles waived at wake after a real park (the blocked-thread rule).
|
||||
static PARK_FORGIVEN_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
/// Would-be attribution lost when the target-site actor parks mid-site.
|
||||
static DROP_PARK_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static DROP_PARK_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// Same loss at yields (explicit, slice-expiry, or a park that requeued).
|
||||
static DROP_YIELD_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static DROP_YIELD_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// Samples discarded by the TSC-weirdness clamp, in would-be delta terms.
|
||||
static DISCARD_OVERMAX_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static DISCARD_OVERMAX_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// In-site samples dropped because the thread's clock was unarmed.
|
||||
static DISCARD_UNARMED_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// Would-be attribution over runnable off-CPU gaps inside the target
|
||||
/// site (yield-descheduled -> resumed within the same window). Not a
|
||||
/// loss: on-CPU-only attribution is the Coz model — queue-wait is not
|
||||
/// shrunk by speeding the site's code — but counted so the audit books
|
||||
/// close against wall in-site time (the located @50 "deficit").
|
||||
static OFFCPU_IN_SITE_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static OFFCPU_IN_SITE_N: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn sites() -> &'static Mutex<Vec<&'static str>> {
|
||||
SITES.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn progress_points() -> &'static Mutex<Vec<&'static ProgressPoint>> {
|
||||
PROGRESS.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Recover from lock poisoning: all these registries hold plain data that
|
||||
/// is valid at every instruction boundary, so a panicked registrant can't
|
||||
/// leave them torn.
|
||||
fn lock_unpoisoned<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match m.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sites
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Register (or look up) a causal site by name; returns its nonzero id.
|
||||
/// Called once per `causal_site!` expansion via a `OnceLock`, so the
|
||||
/// mutex is off every hot path.
|
||||
pub fn site_id(name: &'static str) -> u32 {
|
||||
let mut v = lock_unpoisoned(sites());
|
||||
if let Some(pos) = v.iter().position(|n| *n == name) {
|
||||
return (pos + 1) as u32;
|
||||
}
|
||||
v.push(name);
|
||||
v.len() as u32
|
||||
}
|
||||
|
||||
fn site_name(id: u32) -> Option<String> {
|
||||
if id == 0 {
|
||||
return None;
|
||||
}
|
||||
let v = lock_unpoisoned(sites());
|
||||
v.get((id - 1) as usize).map(|s| (*s).to_string())
|
||||
}
|
||||
|
||||
/// RAII marker: while alive, the current *actor* (not thread — the id
|
||||
/// lives in its `Slot` and survives preemption/migration) is "inside"
|
||||
/// the site. Nesting restores the outer site on drop. Inert outside an
|
||||
/// actor (scheduler/OS-thread stacks).
|
||||
pub struct SiteGuard {
|
||||
/// Slot of the actor that entered, null if entered outside an actor.
|
||||
/// Valid for the guard's whole life: the guard lives on the actor's
|
||||
/// stack, and a slot is never reclaimed while its actor is alive —
|
||||
/// the same argument as `preempt::check_cancelled`.
|
||||
slot: *const crate::runtime::Slot,
|
||||
prev: u32,
|
||||
}
|
||||
|
||||
impl SiteGuard {
|
||||
/// Enter `site` for the on-CPU actor.
|
||||
pub fn enter(site: u32) -> Self {
|
||||
let slot = preempt::current_slot_ptr();
|
||||
if slot.is_null() {
|
||||
return SiteGuard { slot, prev: 0 };
|
||||
}
|
||||
// SAFETY: non-null ⇒ points at the on-CPU actor's slot; see the
|
||||
// field docs for the lifetime argument.
|
||||
let prev = unsafe { (*slot).causal_site() };
|
||||
unsafe { (*slot).set_causal_site(site) };
|
||||
site_transition(slot, prev, site);
|
||||
SiteGuard { slot, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SiteGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.slot.is_null() {
|
||||
// SAFETY (both): as in `enter` — the actor (and thus its
|
||||
// slot) is alive for as long as this guard is on its stack.
|
||||
let site = unsafe { (*self.slot).causal_site() };
|
||||
unsafe { (*self.slot).set_causal_site(self.prev) };
|
||||
site_transition(self.slot, site, self.prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name of the site the on-CPU actor is currently inside, if any.
|
||||
/// (Introspection/testing; not a hot path.)
|
||||
pub fn current_site_name() -> Option<String> {
|
||||
let slot = preempt::current_slot_ptr();
|
||||
if slot.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: on-CPU actor's slot, valid for the whole resume.
|
||||
site_name(unsafe { (*slot).causal_site() })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Progress points
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A named throughput counter. One per distinct name; `progress!` call
|
||||
/// sites sharing a name share the counter.
|
||||
pub struct ProgressPoint {
|
||||
name: &'static str,
|
||||
count: AtomicU64,
|
||||
}
|
||||
|
||||
impl ProgressPoint {
|
||||
/// The hot path: one Relaxed RMW. (Contended across actors by design
|
||||
/// — a progress point is a global rate meter.)
|
||||
#[inline]
|
||||
pub fn bump(&self) {
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register (or look up) a progress point. Called once per `progress!`
|
||||
/// expansion via a `OnceLock`; the mutex is off the hot path.
|
||||
pub fn register_progress(name: &'static str) -> &'static ProgressPoint {
|
||||
let mut v = lock_unpoisoned(progress_points());
|
||||
if let Some(p) = v.iter().find(|p| p.name == name) {
|
||||
return p;
|
||||
}
|
||||
let p: &'static ProgressPoint = Box::leak(Box::new(ProgressPoint {
|
||||
name,
|
||||
count: AtomicU64::new(0),
|
||||
}));
|
||||
v.push(p);
|
||||
p
|
||||
}
|
||||
|
||||
/// Snapshot of all progress points as `(name, count)`.
|
||||
pub fn progress_snapshot() -> Vec<(String, u64)> {
|
||||
lock_unpoisoned(progress_points())
|
||||
.iter()
|
||||
.map(|p| (p.name.to_string(), p.count.load(Ordering::Relaxed)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The hot hook: sample + absorb
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Called from `maybe_preempt` at the amortised timeslice-check cadence,
|
||||
/// under the `PREEMPTION_ENABLED` gate (so never in a prep-to-park or
|
||||
/// no-preempt region — spinning here is as safe as yielding is).
|
||||
///
|
||||
/// One Relaxed load and out when no experiment is running.
|
||||
#[inline]
|
||||
pub(crate) fn check() {
|
||||
let exp = EXPERIMENT.load(Ordering::Relaxed);
|
||||
if exp == 0 {
|
||||
return;
|
||||
}
|
||||
cold_check(exp);
|
||||
}
|
||||
|
||||
/// The experiment-active path, kept out of the inlined fast path.
|
||||
#[cold]
|
||||
fn cold_check(exp: u64) {
|
||||
let slot = preempt::current_slot_ptr();
|
||||
if slot.is_null() {
|
||||
return;
|
||||
}
|
||||
let now = preempt::rdtsc();
|
||||
let last = LAST_SAMPLE_TSC.with(|c| c.replace(now));
|
||||
let target_site = (exp >> 32) as u32;
|
||||
let pct = exp & 0xffff_ffff;
|
||||
|
||||
// SAFETY (both derefs below): non-null ⇒ the on-CPU actor's slot,
|
||||
// never reclaimed while the actor runs — see `check_cancelled`.
|
||||
let my_site = unsafe { (*slot).causal_site() };
|
||||
|
||||
if my_site == target_site && pct > 0 {
|
||||
// A sample landed in the target site: everyone else must fall
|
||||
// behind by pct% of the sampled interval. Grow the global ledger
|
||||
// and credit ourselves the same amount — the credited gap *is*
|
||||
// the virtual speedup.
|
||||
if last == 0 {
|
||||
// Unarmed clock: no interval to attribute — count the loss.
|
||||
DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
// SAFETY: `slot` is the on-CPU actor's slot (checked non-null
|
||||
// above); see `check_cancelled` for the lifetime argument.
|
||||
unsafe { attribute(slot, now.saturating_sub(last), pct) };
|
||||
} else {
|
||||
// Not the winner: chase the global ledger by spinning off the
|
||||
// difference, then push the slice start forward so injected
|
||||
// delay never counts as compute (the clock correction that Coz
|
||||
// cannot do from outside).
|
||||
let global = GLOBAL_DELAY.load(Ordering::Relaxed);
|
||||
let mine = unsafe { (*slot).causal_delay() };
|
||||
if mine >= global {
|
||||
return;
|
||||
}
|
||||
let spin = (global - mine).min(MAX_SPIN_PER_VISIT);
|
||||
let start = preempt::rdtsc();
|
||||
while preempt::rdtsc().saturating_sub(start) < spin {
|
||||
core::hint::spin_loop();
|
||||
}
|
||||
SPIN_ABSORBED_CYCLES.fetch_add(spin, Ordering::Relaxed);
|
||||
unsafe { (*slot).set_causal_delay(mine.wrapping_add(spin)) };
|
||||
preempt::extend_timeslice(spin);
|
||||
// The spin is not part of the next sample interval either.
|
||||
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute one target-site sample of `interval` cycles at `pct`%:
|
||||
/// grow the global ledger and credit the sampling actor's own ledger by
|
||||
/// the same amount — the credited gap *is* the virtual speedup. Shared
|
||||
/// by the cold check and the guard-boundary flush. Applies the same
|
||||
/// clamps as sampling always has: zero intervals and clock hiccups are
|
||||
/// discarded, not the run.
|
||||
///
|
||||
/// SAFETY: `slot` must point at the on-CPU actor's slot (the
|
||||
/// `check_cancelled` lifetime argument).
|
||||
unsafe fn attribute(slot: *const crate::runtime::Slot, interval: u64, pct: u64) {
|
||||
if interval == 0 {
|
||||
return; // now == last: nothing to attribute, nothing lost
|
||||
}
|
||||
if interval > MAX_SAMPLE_CYCLES {
|
||||
// TSC-weirdness clamp: the sample is discarded, not the run.
|
||||
// Count the loss in would-be delta terms so the audit's columns
|
||||
// compare directly against `injected_cycles`.
|
||||
DISCARD_OVERMAX_N.fetch_add(1, Ordering::Relaxed);
|
||||
DISCARD_OVERMAX_CYCLES
|
||||
.fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let delta = interval.saturating_mul(pct) / 100;
|
||||
GLOBAL_DELAY.fetch_add(delta, Ordering::Relaxed);
|
||||
let mine = (*slot).causal_delay();
|
||||
(*slot).set_causal_delay(mine.wrapping_add(delta));
|
||||
}
|
||||
|
||||
/// Site-boundary hook, called by `SiteGuard` enter/drop when the
|
||||
/// actor's current site changes from `old` to `new`. Sample-only —
|
||||
/// never spins — so it is safe anywhere, including no-preempt regions
|
||||
/// where `check()` cannot run.
|
||||
///
|
||||
/// - Leaving the experiment's target site: flush the pending interval.
|
||||
/// Cold checks only sample when they happen to fire in-site, so the
|
||||
/// tail between the last check and the guard drop was otherwise
|
||||
/// discarded on every site entry — measured live at ~22-29µs/entry,
|
||||
/// ~6-7% of all target time (eff 0.93), which under-reported every
|
||||
/// impact (+83.5% where theory says +100%).
|
||||
/// - Entering the target site: re-arm the sample clock, so time spent
|
||||
/// *before* the site can never be attributed to it by the first
|
||||
/// in-site check (the symmetric over-attribution).
|
||||
#[inline]
|
||||
fn site_transition(slot: *const crate::runtime::Slot, old: u32, new: u32) {
|
||||
let exp = EXPERIMENT.load(Ordering::Relaxed);
|
||||
if exp == 0 || old == new {
|
||||
return;
|
||||
}
|
||||
let target = (exp >> 32) as u32;
|
||||
let pct = exp & 0xffff_ffff;
|
||||
if old == target && new != target {
|
||||
let now = preempt::rdtsc();
|
||||
let last = LAST_SAMPLE_TSC.with(|c| c.replace(now));
|
||||
if pct > 0 {
|
||||
if last != 0 {
|
||||
// SAFETY: forwarded from the guard, which holds the on-CPU
|
||||
// actor's slot for its whole life (see `SiteGuard::slot`).
|
||||
unsafe { attribute(slot, now.saturating_sub(last), pct) };
|
||||
} else {
|
||||
DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
} else if new == target && old != target {
|
||||
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume-path hook (scheduler thread, actor off-CPU). Two duties:
|
||||
///
|
||||
/// - If the last deschedule was a *real park*, time blocked absorbs any
|
||||
/// delay accrued meanwhile for free — Coz's blocked-thread rule, which
|
||||
/// keeps experiments from punishing actors for waiting. An actor that
|
||||
/// merely yielded (slice expiry) was runnable the whole time and keeps
|
||||
/// its debt: it must pay by spinning at its next check. Forgiving on
|
||||
/// every resume would make any yield-cadence actor delay-immune and
|
||||
/// experiments inert (found live on a 24-core run: nothing slowed).
|
||||
/// - If the deschedule was a *yield* in the live experiment's target
|
||||
/// site, count the off-CPU gap it opened into the offcpu audit bucket
|
||||
/// (RFC 007: the located @50 deficit — runnable queue-wait is wall
|
||||
/// time in-site that on-CPU attribution correctly skips). Same-window
|
||||
/// only, enforced by the experiment epoch; measure-only.
|
||||
/// - Arm this thread's sample clock so the first interval of the resume
|
||||
/// excludes scheduler time.
|
||||
#[inline]
|
||||
pub(crate) fn on_resume(slot: &crate::runtime::Slot) {
|
||||
let (desched_tsc, desched_epoch) = slot.take_causal_desched();
|
||||
if desched_tsc != 0 && desched_epoch == EXPERIMENT_EPOCH.load(Ordering::Relaxed) {
|
||||
// Same epoch ⇒ no `begin()` since the stash; a nonzero word ⇒
|
||||
// no `end()` either — the gap closed inside its own window.
|
||||
let exp = EXPERIMENT.load(Ordering::Relaxed);
|
||||
if exp != 0 {
|
||||
let pct = exp & 0xffff_ffff;
|
||||
let gap = preempt::rdtsc()
|
||||
.saturating_sub(desched_tsc)
|
||||
.min(MAX_SAMPLE_CYCLES);
|
||||
OFFCPU_IN_SITE_CYCLES
|
||||
.fetch_add(gap.saturating_mul(pct) / 100, Ordering::Relaxed);
|
||||
OFFCPU_IN_SITE_N.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if slot.take_causal_parked() {
|
||||
let global = GLOBAL_DELAY.load(Ordering::Relaxed);
|
||||
let mine = slot.causal_delay();
|
||||
if mine < global {
|
||||
PARK_FORGIVEN_CYCLES.fetch_add(global - mine, Ordering::Relaxed);
|
||||
slot.set_causal_delay(global);
|
||||
}
|
||||
}
|
||||
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
|
||||
}
|
||||
|
||||
/// Deschedule-path hook (scheduler side, same OS thread the actor just
|
||||
/// ran on). If an experiment is live and the departing actor sits in the
|
||||
/// target site, the sample tail `[last sample -> now]` is about to be
|
||||
/// lost: nothing flushes it here, and `on_resume` re-arms the clock
|
||||
/// before the actor runs again. Measure-only (RFC 007 deficit hunt) —
|
||||
/// tally the would-be attribution into the park/yield drop buckets and
|
||||
/// leave behaviour untouched. The interval is capped at
|
||||
/// MAX_SAMPLE_CYCLES: past that the flush would have discarded it anyway
|
||||
/// (counted separately). `now` includes the few hundred ns of scheduler
|
||||
/// bookkeeping since the actor actually stopped — an acceptable
|
||||
/// overcount for a diagnostic.
|
||||
///
|
||||
/// Slice-expiry yields sample at the same checkpoint that deschedules
|
||||
/// them, so their tails are ~zero by construction; a fat yield bucket
|
||||
/// therefore points at explicit `yield_now` calls or requeued parks.
|
||||
///
|
||||
/// Yields additionally stash the deschedule instant on the slot so
|
||||
/// `on_resume` can count the runnable off-CPU gap (offcpu bucket).
|
||||
pub(crate) fn on_deschedule(slot: &crate::runtime::Slot, real_park: bool) {
|
||||
let exp = EXPERIMENT.load(Ordering::Relaxed);
|
||||
if exp == 0 {
|
||||
return;
|
||||
}
|
||||
let target = (exp >> 32) as u32;
|
||||
let pct = exp & 0xffff_ffff;
|
||||
if pct == 0 || slot.causal_site() != target {
|
||||
return;
|
||||
}
|
||||
let now = preempt::rdtsc();
|
||||
if !real_park {
|
||||
// Runnable gap opens here; `on_resume` closes and counts it
|
||||
// (offcpu bucket). Parks are excluded: blocked time is already
|
||||
// represented by forgiveness, and blocked wall time is not
|
||||
// queue-wait.
|
||||
slot.set_causal_desched(now, EXPERIMENT_EPOCH.load(Ordering::Relaxed));
|
||||
}
|
||||
let last = LAST_SAMPLE_TSC.with(|c| c.get());
|
||||
if last == 0 {
|
||||
return;
|
||||
}
|
||||
let interval = now.saturating_sub(last).min(MAX_SAMPLE_CYCLES);
|
||||
let would_be = interval.saturating_mul(pct) / 100;
|
||||
if real_park {
|
||||
DROP_PARK_N.fetch_add(1, Ordering::Relaxed);
|
||||
DROP_PARK_CYCLES.fetch_add(would_be, Ordering::Relaxed);
|
||||
} else {
|
||||
DROP_YIELD_N.fetch_add(1, Ordering::Relaxed);
|
||||
DROP_YIELD_CYCLES.fetch_add(would_be, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Experiments
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn begin(site: u32, pct: u32) {
|
||||
EXPERIMENT_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
EXPERIMENT.store(((site as u64) << 32) | pct as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn end() {
|
||||
EXPERIMENT.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Total virtual delay injected so far, in TSC cycles.
|
||||
pub fn global_delay_cycles() -> u64 {
|
||||
GLOBAL_DELAY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Cumulative ledger-audit totals since startup (RFC 007 deficit hunt).
|
||||
/// All monotone; window a span by snapshotting before/after and taking
|
||||
/// `delta_since`. Cycle fields are in would-be-injected delta terms so
|
||||
/// they compare directly against `injected_cycles`.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct LedgerCounters {
|
||||
pub spin_absorbed_cycles: u64,
|
||||
pub park_forgiven_cycles: u64,
|
||||
pub drop_park_cycles: u64,
|
||||
pub drop_park_n: u64,
|
||||
pub drop_yield_cycles: u64,
|
||||
pub drop_yield_n: u64,
|
||||
pub discard_overmax_cycles: u64,
|
||||
pub discard_overmax_n: u64,
|
||||
pub discard_unarmed_n: u64,
|
||||
pub offcpu_in_site_cycles: u64,
|
||||
pub offcpu_in_site_n: u64,
|
||||
}
|
||||
|
||||
impl LedgerCounters {
|
||||
/// Field-wise difference against an earlier snapshot.
|
||||
pub fn delta_since(&self, before: &LedgerCounters) -> LedgerCounters {
|
||||
LedgerCounters {
|
||||
spin_absorbed_cycles: self
|
||||
.spin_absorbed_cycles
|
||||
.saturating_sub(before.spin_absorbed_cycles),
|
||||
park_forgiven_cycles: self
|
||||
.park_forgiven_cycles
|
||||
.saturating_sub(before.park_forgiven_cycles),
|
||||
drop_park_cycles: self.drop_park_cycles.saturating_sub(before.drop_park_cycles),
|
||||
drop_park_n: self.drop_park_n.saturating_sub(before.drop_park_n),
|
||||
drop_yield_cycles: self
|
||||
.drop_yield_cycles
|
||||
.saturating_sub(before.drop_yield_cycles),
|
||||
drop_yield_n: self.drop_yield_n.saturating_sub(before.drop_yield_n),
|
||||
discard_overmax_cycles: self
|
||||
.discard_overmax_cycles
|
||||
.saturating_sub(before.discard_overmax_cycles),
|
||||
discard_overmax_n: self.discard_overmax_n.saturating_sub(before.discard_overmax_n),
|
||||
discard_unarmed_n: self.discard_unarmed_n.saturating_sub(before.discard_unarmed_n),
|
||||
offcpu_in_site_cycles: self
|
||||
.offcpu_in_site_cycles
|
||||
.saturating_sub(before.offcpu_in_site_cycles),
|
||||
offcpu_in_site_n: self.offcpu_in_site_n.saturating_sub(before.offcpu_in_site_n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the cumulative audit counters.
|
||||
pub fn ledger_counters() -> LedgerCounters {
|
||||
LedgerCounters {
|
||||
spin_absorbed_cycles: SPIN_ABSORBED_CYCLES.load(Ordering::Relaxed),
|
||||
park_forgiven_cycles: PARK_FORGIVEN_CYCLES.load(Ordering::Relaxed),
|
||||
drop_park_cycles: DROP_PARK_CYCLES.load(Ordering::Relaxed),
|
||||
drop_park_n: DROP_PARK_N.load(Ordering::Relaxed),
|
||||
drop_yield_cycles: DROP_YIELD_CYCLES.load(Ordering::Relaxed),
|
||||
drop_yield_n: DROP_YIELD_N.load(Ordering::Relaxed),
|
||||
discard_overmax_cycles: DISCARD_OVERMAX_CYCLES.load(Ordering::Relaxed),
|
||||
discard_overmax_n: DISCARD_OVERMAX_N.load(Ordering::Relaxed),
|
||||
discard_unarmed_n: DISCARD_UNARMED_N.load(Ordering::Relaxed),
|
||||
offcpu_in_site_cycles: OFFCPU_IN_SITE_CYCLES.load(Ordering::Relaxed),
|
||||
offcpu_in_site_n: OFFCPU_IN_SITE_N.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbed-delay ledger of the on-CPU actor (testing/introspection).
|
||||
pub fn my_absorbed_delay_cycles() -> u64 {
|
||||
let slot = preempt::current_slot_ptr();
|
||||
if slot.is_null() {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: on-CPU actor's slot; see `check_cancelled`.
|
||||
unsafe { (*slot).causal_delay() }
|
||||
}
|
||||
|
||||
/// Test support: start an experiment targeting `site_name` at `pct`%
|
||||
/// virtual speedup. Registers the site if needed.
|
||||
pub fn begin_experiment_for_test(name: &'static str, pct: u32) {
|
||||
begin(site_id(name), pct);
|
||||
}
|
||||
|
||||
/// Test support: stop the running experiment.
|
||||
pub fn end_experiment_for_test() {
|
||||
end();
|
||||
}
|
||||
|
||||
/// Test support: grow the global delay ledger directly, as if target-site
|
||||
/// samples had attributed `cycles` — deterministic driver for the timer
|
||||
/// virtual-time tests. Calibrates the TSC eagerly so conversion later
|
||||
/// never stalls a scheduler loop.
|
||||
pub fn inject_delay_cycles_for_test(cycles: u64) {
|
||||
let _ = tsc_hz();
|
||||
GLOBAL_DELAY.fetch_add(cycles, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Convert ledger cycles to wall time at the measured TSC rate.
|
||||
pub fn cycles_to_duration(cycles: u64) -> Duration {
|
||||
Duration::from_secs_f64(cycles as f64 / tsc_hz())
|
||||
}
|
||||
|
||||
/// Controller parameters: which speedups to try per site, and the
|
||||
/// experiment/cooldown windows.
|
||||
pub struct ExperimentPlan {
|
||||
pub speedups_pct: Vec<u32>,
|
||||
pub experiment: Duration,
|
||||
pub cooldown: Duration,
|
||||
}
|
||||
|
||||
impl Default for ExperimentPlan {
|
||||
fn default() -> Self {
|
||||
ExperimentPlan {
|
||||
speedups_pct: vec![0, 25, 50],
|
||||
experiment: Duration::from_millis(500),
|
||||
cooldown: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One completed experiment cell.
|
||||
#[derive(Default)]
|
||||
pub struct ExperimentResult {
|
||||
pub site: String,
|
||||
pub speedup_pct: u32,
|
||||
pub duration: Duration,
|
||||
/// Progress-point deltas over the window, `(name, count)`.
|
||||
pub deltas: Vec<(String, u64)>,
|
||||
/// Virtual delay injected during the window (cycles).
|
||||
pub injected_cycles: u64,
|
||||
// Ledger-audit deltas over the window (RFC 007 deficit hunt); see
|
||||
// `LedgerCounters` for field semantics. `spin_absorbed_cycles > 0`
|
||||
// in a 0% cell means the window paid debt left over from an earlier
|
||||
// one — the baseline-contamination signature.
|
||||
pub spin_absorbed_cycles: u64,
|
||||
pub park_forgiven_cycles: u64,
|
||||
pub drop_park_cycles: u64,
|
||||
pub drop_park_n: u64,
|
||||
pub drop_yield_cycles: u64,
|
||||
pub drop_yield_n: u64,
|
||||
pub discard_overmax_cycles: u64,
|
||||
pub discard_overmax_n: u64,
|
||||
pub discard_unarmed_n: u64,
|
||||
pub offcpu_in_site_cycles: u64,
|
||||
pub offcpu_in_site_n: u64,
|
||||
}
|
||||
|
||||
/// Run the plan synchronously on the calling (OS) thread: for every
|
||||
/// registered site × speedup, run one experiment window and record
|
||||
/// progress-point deltas, with a cooldown between cells. Sites and
|
||||
/// progress points must already be registered (the workload has to be
|
||||
/// running); the caller owns workload start/stop.
|
||||
///
|
||||
/// v1 controller: exhaustive sweep, fixed windows, no adaptive site
|
||||
/// selection or confidence stopping (jar Q5).
|
||||
///
|
||||
/// Callable from a plain OS thread *or* from inside an actor: sleeping
|
||||
/// parks the green thread when we're on one (so no scheduler thread is
|
||||
/// blocked), and falls back to `thread::sleep` otherwise.
|
||||
pub fn run_experiments(plan: &ExperimentPlan) -> Vec<ExperimentResult> {
|
||||
fn controller_sleep(d: Duration) {
|
||||
if preempt::current_slot_ptr().is_null() {
|
||||
std::thread::sleep(d);
|
||||
} else {
|
||||
// Wall-anchored: the controller's window/cooldown sleeps
|
||||
// *define* the experiment's wall length; letting them chase
|
||||
// the delay it is itself injecting would stretch every window
|
||||
// (observed ~2x at 50% speedup). Deltas are rate-normalized
|
||||
// either way — this fixes cost, not bias.
|
||||
crate::scheduler::sleep_wall(d);
|
||||
}
|
||||
}
|
||||
// Calibrate before any window so report rendering never has to sleep.
|
||||
let _ = tsc_hz();
|
||||
let site_list: Vec<(u32, String)> = {
|
||||
let v = lock_unpoisoned(sites());
|
||||
v.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| ((i + 1) as u32, (*n).to_string()))
|
||||
.collect()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (sid, sname) in &site_list {
|
||||
for &pct in &plan.speedups_pct {
|
||||
let before = progress_snapshot();
|
||||
let injected_before = global_delay_cycles();
|
||||
let audit_before = ledger_counters();
|
||||
let t0 = Instant::now();
|
||||
begin(*sid, pct);
|
||||
controller_sleep(plan.experiment);
|
||||
end();
|
||||
// Snapshot immediately: injection and spin freeze at `end()`
|
||||
// (checks gate on the experiment word), but forgiveness does
|
||||
// not — a later snapshot would leak cooldown wakes into the
|
||||
// window.
|
||||
let audit = ledger_counters().delta_since(&audit_before);
|
||||
let elapsed = t0.elapsed();
|
||||
let after = progress_snapshot();
|
||||
let deltas = after
|
||||
.iter()
|
||||
.map(|(n, c)| {
|
||||
let b = before
|
||||
.iter()
|
||||
.find(|(bn, _)| bn == n)
|
||||
.map(|(_, bc)| *bc)
|
||||
.unwrap_or(0);
|
||||
(n.clone(), c.saturating_sub(b))
|
||||
})
|
||||
.collect();
|
||||
out.push(ExperimentResult {
|
||||
site: sname.clone(),
|
||||
speedup_pct: pct,
|
||||
duration: elapsed,
|
||||
deltas,
|
||||
injected_cycles: global_delay_cycles() - injected_before,
|
||||
spin_absorbed_cycles: audit.spin_absorbed_cycles,
|
||||
park_forgiven_cycles: audit.park_forgiven_cycles,
|
||||
drop_park_cycles: audit.drop_park_cycles,
|
||||
drop_park_n: audit.drop_park_n,
|
||||
drop_yield_cycles: audit.drop_yield_cycles,
|
||||
drop_yield_n: audit.drop_yield_n,
|
||||
discard_overmax_cycles: audit.discard_overmax_cycles,
|
||||
discard_overmax_n: audit.discard_overmax_n,
|
||||
discard_unarmed_n: audit.discard_unarmed_n,
|
||||
offcpu_in_site_cycles: audit.offcpu_in_site_cycles,
|
||||
offcpu_in_site_n: audit.offcpu_in_site_n,
|
||||
});
|
||||
controller_sleep(plan.cooldown);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reports
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Measured TSC frequency (Hz), calibrated once. The crate-wide 3 GHz
|
||||
/// constant is fine for the *relative* timeslice check, but report
|
||||
/// normalisation divides wall time by injected time, so a 20% Hz error
|
||||
/// skews every impact number — measured live: a 3.7 GHz box inflated all
|
||||
/// baselines uniformly. Calibrated against `Instant` over ~50ms on first
|
||||
/// use; `run_experiments` triggers it before its first window (using the
|
||||
/// park-aware sleep, so no scheduler thread is blocked when called from
|
||||
/// an actor).
|
||||
static TSC_HZ_MEASURED: OnceLock<f64> = OnceLock::new();
|
||||
|
||||
/// Measured TSC frequency in Hz. Calibrates on first call (~50ms).
|
||||
pub fn tsc_hz() -> f64 {
|
||||
*TSC_HZ_MEASURED.get_or_init(|| {
|
||||
let c0 = preempt::rdtsc();
|
||||
let t0 = Instant::now();
|
||||
let d = Duration::from_millis(50);
|
||||
if preempt::current_slot_ptr().is_null() {
|
||||
std::thread::sleep(d);
|
||||
} else {
|
||||
// Wall-anchored: calibration divides TSC delta by *wall*
|
||||
// elapsed; a virtual sleep dilated by concurrent injection
|
||||
// would still measure correctly (elapsed() is wall) but
|
||||
// waste window time — and must never depend on the ledger
|
||||
// it exists to convert.
|
||||
crate::scheduler::sleep_wall(d);
|
||||
}
|
||||
(preempt::rdtsc().wrapping_sub(c0)) as f64 / t0.elapsed().as_secs_f64()
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalized rate for one cell: count over the *virtual* window
|
||||
/// (wall − injected) — Coz's normalization: injected delay does not
|
||||
/// exist in the virtual timeline. A bottleneck site keeps its raw count
|
||||
/// while shrinking the divisor → positive impact; a fully overlapped
|
||||
/// site loses count proportionally → ~zero.
|
||||
fn normalized_rate(r: &ExperimentResult, point: &str) -> Option<f64> {
|
||||
let count = r.deltas.iter().find(|(n, _)| n == point).map(|(_, c)| *c)?;
|
||||
let injected_secs = r.injected_cycles as f64 / tsc_hz();
|
||||
let virtual_secs = (r.duration.as_secs_f64() - injected_secs).max(1e-9);
|
||||
Some(count as f64 / virtual_secs)
|
||||
}
|
||||
|
||||
/// Impact of virtually speeding up `site` by `speedup_pct` on progress
|
||||
/// point `point`, in percent relative to that site's own 0% baseline
|
||||
/// cell. `None` if either cell or the point is missing, or the baseline
|
||||
/// rate is zero. This is the machine-readable form of the summary's
|
||||
/// "vs baseline" column, for programmatic checks (CI, examples).
|
||||
pub fn impact_pct(
|
||||
results: &[ExperimentResult],
|
||||
site: &str,
|
||||
speedup_pct: u32,
|
||||
point: &str,
|
||||
) -> Option<f64> {
|
||||
let cell = results
|
||||
.iter()
|
||||
.find(|r| r.site == site && r.speedup_pct == speedup_pct)?;
|
||||
let base = results.iter().find(|r| r.site == site && r.speedup_pct == 0)?;
|
||||
let rate = normalized_rate(cell, point)?;
|
||||
let b = normalized_rate(base, point)?;
|
||||
if b <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((rate / b - 1.0) * 100.0)
|
||||
}
|
||||
|
||||
/// Human-readable summary: per (site, progress point), the throughput at
|
||||
/// each virtual speedup and the change relative to that site's own 0%
|
||||
/// baseline. A near-zero column across speedups means: optimising this
|
||||
/// site buys you nothing — the RFC's headline answer.
|
||||
///
|
||||
/// Ends with a one-line fidelity note (RFC 007 Validation): reported
|
||||
/// impacts are conservative — attribution counts on-CPU site time only,
|
||||
/// so runnable queue-wait inside the site (the located @50 "deficit",
|
||||
/// eff ≈ 0.93 live) is never injected and gains are lower bounds; site
|
||||
/// *rankings* are unaffected.
|
||||
pub fn render_summary(results: &[ExperimentResult]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "== smarm causal profile ==");
|
||||
let mut sites_seen: Vec<&str> = Vec::new();
|
||||
for r in results {
|
||||
if !sites_seen.contains(&r.site.as_str()) {
|
||||
sites_seen.push(&r.site);
|
||||
}
|
||||
}
|
||||
for site in sites_seen {
|
||||
let _ = writeln!(s, "site {site}");
|
||||
for r in results.iter().filter(|r| r.site == site) {
|
||||
for (name, _) in &r.deltas {
|
||||
let rate = match normalized_rate(r, name) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
let rel = impact_pct(results, site, r.speedup_pct, name)
|
||||
.map(|p| format!("{p:+.1}%"))
|
||||
.unwrap_or_else(|| "n/a".to_string());
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" speedup {:>3}% {name:<24} {rate:>12.1}/s vs baseline {rel} (injected {:.1}ms)",
|
||||
r.speedup_pct,
|
||||
r.injected_cycles as f64 / tsc_hz() * 1e3
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !results.is_empty() {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"note: impacts are lower bounds — site time counts on-CPU only (runnable queue-wait is not attributed); rankings unaffected"
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Ledger-audit companion to `render_summary` (RFC 007 deficit hunt):
|
||||
/// per cell, where the window's virtual delay went — born (injected),
|
||||
/// paid (absorbed), waived (forgiven at wake) — and the attribution the
|
||||
/// sampler lost: tails dropped at parks/yields inside the target site,
|
||||
/// plus clamp discards. Cycle columns in ms at the calibrated TSC rate.
|
||||
/// `absorbed` above `injected` in a cell (0% especially) means it paid
|
||||
/// debt left over from earlier windows.
|
||||
pub fn render_ledger_audit(results: &[ExperimentResult]) -> String {
|
||||
use std::fmt::Write;
|
||||
let hz = tsc_hz();
|
||||
let ms = |c: u64| c as f64 / hz * 1e3;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "== smarm causal ledger audit ==");
|
||||
for r in results {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"site {:<22} @{:>2}% injected {:>7.1}ms absorbed {:>7.1}ms forgiven {:>7.1}ms \
|
||||
drop park {:>6.2}ms/{:<4} yield {:>6.2}ms/{:<4} offcpu {:>6.2}ms/{:<5} \
|
||||
discard >max {:>6.2}ms/{:<3} unarmed {}",
|
||||
r.site,
|
||||
r.speedup_pct,
|
||||
ms(r.injected_cycles),
|
||||
ms(r.spin_absorbed_cycles),
|
||||
ms(r.park_forgiven_cycles),
|
||||
ms(r.drop_park_cycles),
|
||||
r.drop_park_n,
|
||||
ms(r.drop_yield_cycles),
|
||||
r.drop_yield_n,
|
||||
ms(r.offcpu_in_site_cycles),
|
||||
r.offcpu_in_site_n,
|
||||
ms(r.discard_overmax_cycles),
|
||||
r.discard_overmax_n,
|
||||
r.discard_unarmed_n
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Coz-compatible profile text (`profile.coz`), so Coz's existing plot
|
||||
/// tooling renders our experiments — the RFC's "don't build a UI" call.
|
||||
pub fn render_coz(results: &[ExperimentResult]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "startup\ttime=0");
|
||||
for r in results {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"experiment\tselected={}\tspeedup={:.2}\tduration={}\tselected-samples=1",
|
||||
r.site,
|
||||
r.speedup_pct as f64 / 100.0,
|
||||
r.duration.as_nanos()
|
||||
);
|
||||
for (name, count) in &r.deltas {
|
||||
let _ = writeln!(s, "throughput-point\tname={name}\tdelta={count}");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
pub use inner::*;
|
||||
|
||||
/// Mark one unit of useful work complete at a named throughput progress
|
||||
/// point (RFC 007). One Relaxed increment when `smarm-causal` is on; nothing
|
||||
/// at all when it's off.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
#[macro_export]
|
||||
macro_rules! progress {
|
||||
($name:literal) => {{
|
||||
static __SMARM_PP: ::std::sync::OnceLock<&'static $crate::causal::ProgressPoint> =
|
||||
::std::sync::OnceLock::new();
|
||||
__SMARM_PP
|
||||
.get_or_init(|| $crate::causal::register_progress($name))
|
||||
.bump();
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "smarm-causal"))]
|
||||
#[macro_export]
|
||||
macro_rules! progress {
|
||||
($name:literal) => {{}};
|
||||
}
|
||||
|
||||
/// Enter a named causal-profiling site for the current actor; the returned
|
||||
/// guard exits it (restoring any enclosing site) on drop. Site identity is
|
||||
/// stored in the actor's slot, so it survives preemption and migration.
|
||||
/// Expands to a unit no-op without `smarm-causal`.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
#[macro_export]
|
||||
macro_rules! causal_site {
|
||||
($name:literal) => {{
|
||||
static __SMARM_SITE: ::std::sync::OnceLock<u32> = ::std::sync::OnceLock::new();
|
||||
$crate::causal::SiteGuard::enter(*__SMARM_SITE.get_or_init(|| $crate::causal::site_id($name)))
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "smarm-causal"))]
|
||||
#[macro_export]
|
||||
macro_rules! causal_site {
|
||||
($name:literal) => {
|
||||
()
|
||||
};
|
||||
}
|
||||
+336
-216
@@ -1,38 +1,102 @@
|
||||
//! Unbounded MPSC channels.
|
||||
//! Unbounded multi-producer, single-consumer channels: how actors talk to
|
||||
//! each other.
|
||||
//!
|
||||
//! Inner state is `Arc<RawMutex<Inner<T>>>` so channels can be sent across OS
|
||||
//! threads (required for the multi-scheduler runtime where a sender and
|
||||
//! receiver may run on different scheduler threads simultaneously).
|
||||
//! A channel is a queue with a typed [`Sender`] on one end and a typed
|
||||
//! [`Receiver`] on the other. Any number of actors can hold a clone of the
|
||||
//! `Sender` and push messages onto the same queue; exactly one [`Receiver`]
|
||||
//! reads them back out, in the order they arrived. This is the basic wiring
|
||||
//! smarm's other actor primitives (`gen_server`, `pg`, the registry) are all
|
||||
//! built out of, and it is directly usable on its own for a worker that just
|
||||
//! needs an inbox.
|
||||
//!
|
||||
//! ## Why `RawMutex` (Channel class), not `std::sync::Mutex`
|
||||
//! ## A first channel
|
||||
//!
|
||||
//! An actor holding a guard with preemption *enabled* can be timesliced
|
||||
//! inside the critical section and resume on a different OS thread — the
|
||||
//! pthread mutex would then be released from a thread that didn't lock it,
|
||||
//! which is UB (Linux futexes happen to tolerate it, but it's not
|
||||
//! guaranteed). `RawMutex` disables preemption for the guard's span and is
|
||||
//! cross-thread-release sound by construction, closing the hole. It also
|
||||
//! cannot poison. Channel locks form their own [`LockClass::Channel`]
|
||||
//! (raw_mutex.rs): they may be taken under a cold (Leaf) lock — finalize and
|
||||
//! `monitor()` clone senders that live in slots — but nothing may be locked
|
||||
//! under them, which the debug build enforces. `recv_match` runs its user
|
||||
//! predicate under this lock: keep it cheap, pure, and channel-free.
|
||||
//! ```
|
||||
//! use smarm::{channel, run, spawn};
|
||||
//!
|
||||
//! Semantics:
|
||||
//! - Senders are clonable; the last sender drop closes the channel.
|
||||
//! - `Receiver::recv` on an empty open channel parks the receiver.
|
||||
//! - `Receiver::recv` on an empty closed channel returns `Err(RecvError)`.
|
||||
//! - `Sender::send` on an open channel always succeeds.
|
||||
//! - `Sender::send` on a closed channel (receiver dropped) returns
|
||||
//! `Err(SendError(value))`.
|
||||
//! - When a send pushes to a previously empty queue and a receiver is
|
||||
//! parked, the receiver is unparked.
|
||||
//! run(|| {
|
||||
//! let (tx, rx) = channel::<u64>();
|
||||
//!
|
||||
//! let worker = spawn(move || {
|
||||
//! // Blocks until a message arrives.
|
||||
//! let n = rx.recv().unwrap();
|
||||
//! assert_eq!(n, 42);
|
||||
//!
|
||||
//! // Once every Sender is dropped, recv() reports the channel closed
|
||||
//! // instead of blocking forever.
|
||||
//! assert!(rx.recv().is_err());
|
||||
//! });
|
||||
//!
|
||||
//! tx.send(42).unwrap();
|
||||
//! drop(tx); // last sender gone: the channel is now closed
|
||||
//! worker.join().unwrap();
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Sending
|
||||
//!
|
||||
//! [`Sender`] is cheaply clonable: hand a clone to every actor that needs to
|
||||
//! push messages into this queue. The channel stays open as long as at least
|
||||
//! one clone exists; [`Sender::send`] never blocks and always succeeds while
|
||||
//! the channel is open, since the queue is unbounded. Once the [`Receiver`]
|
||||
//! has been dropped, `send` returns the message back to you in
|
||||
//! [`SendError`] instead of delivering it.
|
||||
//!
|
||||
//! ## Receiving
|
||||
//!
|
||||
//! There is exactly one [`Receiver`] per channel (it is not clonable).
|
||||
//! [`Receiver::recv`] returns the next message in arrival order, parking the
|
||||
//! calling actor if the queue is currently empty. Once every `Sender` has
|
||||
//! been dropped and the queue has been drained, `recv` stops parking and
|
||||
//! returns [`RecvError`] instead, so a receiver never blocks forever waiting
|
||||
//! on senders that are never coming back.
|
||||
//!
|
||||
//! Beyond plain `recv`, three variants cover the common needs:
|
||||
//!
|
||||
//! - [`Receiver::try_recv`]: never parks: reports an empty-but-open channel
|
||||
//! as `Ok(None)` instead of waiting.
|
||||
//! - [`Receiver::recv_timeout`]: parks, but gives up and returns
|
||||
//! [`RecvTimeoutError::Timeout`] if no message arrives before a deadline.
|
||||
//! - [`Receiver::recv_match`] / [`Receiver::try_recv_match`]: selective
|
||||
//! receive. Instead of taking whatever is at the front of the queue, pick
|
||||
//! out the first message matching a predicate, leaving the rest queued in
|
||||
//! order. Handy for an actor that wants to prioritise one kind of message
|
||||
//! over others already waiting.
|
||||
//!
|
||||
//! ## Waiting on several channels: `select`
|
||||
//!
|
||||
//! [`select`] parks an actor across several receivers at once and reports
|
||||
//! the index of the first one that is ready (has a message queued, or has
|
||||
//! been closed). [`select_timeout`] adds a deadline, the way `recv_timeout`
|
||||
//! does for a single channel. See their docs for the full contract,
|
||||
//! including the priority-order and no-fairness guarantee.
|
||||
//!
|
||||
//! ## Implementation notes
|
||||
//!
|
||||
//! The queue and its bookkeeping live behind `Arc<RawMutex<Inner<T>>>`
|
||||
//! rather than a `std::sync::Mutex`, so that a channel can be freely shared
|
||||
//! and sent across the OS threads backing the multi-scheduler runtime.
|
||||
//! `RawMutex` matters here for a subtler reason too: an ordinary pthread
|
||||
//! mutex can be released from a different OS thread than the one that took
|
||||
//! it (smarm's preemption can migrate a timesliced actor between scheduler
|
||||
//! threads mid-critical-section), and doing that to a `std::sync::Mutex` is
|
||||
//! undefined behavior. `RawMutex` disables preemption for the guard's short
|
||||
//! lifetime instead, so the release always happens on the thread that
|
||||
//! acquired it, and it has no poisoning to worry about besides. Channel
|
||||
//! locks are cheap and are never held across another lock acquisition or a
|
||||
//! blocking call; the predicate passed to `recv_match` runs under this lock,
|
||||
//! which is why it needs to stay cheap, pure, and must not call back into
|
||||
//! the same channel.
|
||||
|
||||
use crate::pid::Pid;
|
||||
use crate::raw_mutex::RawMutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Create a new channel and return its `(Sender, Receiver)` halves.
|
||||
///
|
||||
/// The channel is unbounded (no capacity limit) and open until every
|
||||
/// `Sender` has been dropped.
|
||||
pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
|
||||
let inner = Arc::new(RawMutex::new_channel(Inner {
|
||||
queue: VecDeque::new(),
|
||||
@@ -45,29 +109,40 @@ pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
|
||||
|
||||
struct Inner<T> {
|
||||
queue: VecDeque<T>,
|
||||
/// The parked receiver's `(pid, park-epoch)`. The epoch is the slot
|
||||
/// word's runtime-wide wait identity (see slot_state.rs): wakers call
|
||||
/// `unpark_at(pid, epoch)`, so an entry left over from an already-woken
|
||||
/// wait — a `select` loser arm, a satisfied `recv_timeout`'s timer — is
|
||||
/// inert: the wake fails the word's epoch CAS and no-ops. This replaces
|
||||
/// the old per-channel `cur_wait`/`next_wait_seq`/`timed_out` trio: wait
|
||||
/// identity now exists exactly once, in the slot word.
|
||||
/// The parked receiver's `(pid, park-epoch)`, if one is currently
|
||||
/// waiting. The epoch identifies exactly which wait this is, so a waker
|
||||
/// left over from a wait that already ended (a losing `select` arm, a
|
||||
/// `recv_timeout` whose timer fired after it was already satisfied) is
|
||||
/// inert and does nothing when it fires.
|
||||
parked_receiver: Option<(Pid, u32)>,
|
||||
senders: usize,
|
||||
receiver_alive: bool,
|
||||
}
|
||||
|
||||
/// The sending half of a channel, created by [`channel`]. Clonable: every
|
||||
/// clone pushes onto the same queue, and the channel stays open as long as
|
||||
/// any clone is alive. Dropping the last `Sender` closes the channel, which
|
||||
/// wakes a parked [`Receiver`] so it can observe the closure.
|
||||
pub struct Sender<T> {
|
||||
inner: Arc<RawMutex<Inner<T>>>,
|
||||
}
|
||||
|
||||
/// The receiving half of a channel, created by [`channel`]. Not clonable:
|
||||
/// a channel has exactly one receiver. Reads messages in the order they
|
||||
/// were sent, via [`recv`](Receiver::recv) and its variants.
|
||||
pub struct Receiver<T> {
|
||||
inner: Arc<RawMutex<Inner<T>>>,
|
||||
}
|
||||
|
||||
/// Returned by [`Sender::send`] when the channel's [`Receiver`] has already
|
||||
/// been dropped. Carries the message back so it is never silently lost;
|
||||
/// recover it with `.0` or by matching.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct SendError<T>(pub T);
|
||||
|
||||
/// Returned by [`Receiver::recv`] (and the other receive methods, in their
|
||||
/// own error types) when the channel is closed: every `Sender` has been
|
||||
/// dropped and no message is left queued.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct RecvError;
|
||||
|
||||
@@ -84,8 +159,8 @@ impl std::error::Error for RecvError {}
|
||||
pub enum RecvTimeoutError {
|
||||
/// The deadline passed with no message available.
|
||||
Timeout,
|
||||
/// All senders dropped with no message available — the bounded analogue
|
||||
/// of [`RecvError`].
|
||||
/// Every sender was dropped with no message available. The
|
||||
/// timeout-aware counterpart of plain [`RecvError`].
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
@@ -115,8 +190,8 @@ impl<T> Drop for Sender<T> {
|
||||
// Wake the parked receiver on the last sender drop regardless of
|
||||
// whether the queue is empty. A plain `recv` only ever parks on an
|
||||
// empty queue (so this is unchanged for it), but a selective
|
||||
// `recv_match` may be parked on a *non-empty* queue holding only
|
||||
// non-matching messages — it must wake to observe closure and
|
||||
// `recv_match` may be parked on a non-empty queue holding only
|
||||
// non-matching messages. It must wake to observe closure and
|
||||
// return Err rather than sleep forever.
|
||||
if g.senders == 0 {
|
||||
g.parked_receiver.take()
|
||||
@@ -132,19 +207,37 @@ impl<T> Drop for Sender<T> {
|
||||
|
||||
impl<T> Drop for Receiver<T> {
|
||||
fn drop(&mut self) {
|
||||
self.inner.lock().receiver_alive = false;
|
||||
// The only consumer is gone: queued messages can never be delivered.
|
||||
// Drop them now instead of leaving them queued until the last Sender
|
||||
// happens to go away, which can be long after this receiver's owner
|
||||
// has exited if some other part of the runtime is still holding a
|
||||
// clone of the Sender. Draining runs each queued message's own drop
|
||||
// glue, which matters for a gen_server call: dropping a queued call
|
||||
// envelope drops its reply channel too, which wakes the caller with
|
||||
// an error instead of leaving it parked forever. Drain under the
|
||||
// lock, then run the drops after releasing it, since a message's
|
||||
// drop glue may itself touch a different channel or the scheduler.
|
||||
let drained = {
|
||||
let mut g = self.inner.lock();
|
||||
g.receiver_alive = false;
|
||||
std::mem::take(&mut g.queue)
|
||||
};
|
||||
drop(drained);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Sender<T> {
|
||||
/// Number of messages currently queued behind this channel. Introspection
|
||||
/// only (RFC 016 mailbox depth); takes the channel lock, so callers reach
|
||||
/// it under the registry Leaf (Leaf → Channel) via the erased probe in
|
||||
/// `registry.rs`, never on a hot path.
|
||||
/// Number of messages currently queued and not yet received. For
|
||||
/// introspection and monitoring; takes the channel's internal lock, so
|
||||
/// avoid calling it from a hot path.
|
||||
pub(crate) fn queued_len(&self) -> usize {
|
||||
self.inner.lock().queue.len()
|
||||
}
|
||||
|
||||
/// Push `value` onto the channel. Succeeds unconditionally as long as
|
||||
/// the [`Receiver`] is still alive: the queue has no capacity limit, so
|
||||
/// this never blocks and never fails except when the channel is closed,
|
||||
/// in which case `value` comes back in [`SendError`].
|
||||
pub fn send(&self, value: T) -> Result<(), SendError<T>> {
|
||||
let unpark = {
|
||||
let mut g = self.inner.lock();
|
||||
@@ -165,6 +258,10 @@ impl<T> Sender<T> {
|
||||
}
|
||||
|
||||
impl<T> Receiver<T> {
|
||||
/// Block until a message is available and return it. Messages come back
|
||||
/// in the order they were sent. If the queue is empty and every
|
||||
/// [`Sender`] has already been dropped, returns [`RecvError`] instead of
|
||||
/// blocking forever.
|
||||
pub fn recv(&self) -> Result<T, RecvError> {
|
||||
loop {
|
||||
{
|
||||
@@ -176,54 +273,50 @@ impl<T> Receiver<T> {
|
||||
if g.senders == 0 {
|
||||
return Err(RecvError);
|
||||
}
|
||||
let me = crate::actor::current_pid()
|
||||
.expect("recv() called outside an actor");
|
||||
let me = match crate::actor::current_pid() {
|
||||
Some(me) => me,
|
||||
None => panic!("smarm: recv() called outside an actor"),
|
||||
};
|
||||
debug_assert!(
|
||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||
"channel has more than one receiver"
|
||||
);
|
||||
// begin_wait is lock-free — legal under the Channel lock;
|
||||
// begin_wait is lock-free, so it's legal under the Channel lock;
|
||||
// registering in the same critical section makes the epoch
|
||||
// atomic with the senders' view of the registration.
|
||||
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
||||
crate::te!(crate::trace::Event::RecvPark(me));
|
||||
}
|
||||
// Release the lock before parking — the unparker will need it.
|
||||
// Release the lock before parking: the unparker will need it.
|
||||
crate::scheduler::park_current();
|
||||
// Woken up — record it before looping to check the queue.
|
||||
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
|
||||
// Woken up. Record it before looping to check the queue.
|
||||
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() {
|
||||
Some(p) => p,
|
||||
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded receive: like [`recv`](Self::recv), but gives up once
|
||||
/// `timeout` has elapsed, returning [`RecvTimeoutError::Timeout`].
|
||||
/// Like [`recv`](Self::recv), but gives up and returns
|
||||
/// [`RecvTimeoutError::Timeout`] if no message has arrived by the time
|
||||
/// `timeout` elapses.
|
||||
///
|
||||
/// Built on the same timer machinery as `Mutex::lock_timeout`: the wait
|
||||
/// registers a `WaitTimeout` entry stamped with the wait's park-epoch;
|
||||
/// on expiry the channel (as the
|
||||
/// [`TimerTarget`](crate::timer::TimerTarget)) checks whether *this*
|
||||
/// wait is still parked and, only then, cancels it. A wake that races
|
||||
/// the deadline resolves message-first: if a message is available when
|
||||
/// the receiver runs, it is delivered even if the timer had already
|
||||
/// fired. A satisfied or abandoned wait leaves its timer entry to expire
|
||||
/// as a no-op (registration gone; epoch consumed), per the
|
||||
/// no-cancellation convention in `timer.rs`.
|
||||
/// If a message arrives at essentially the same moment the deadline
|
||||
/// passes, the message wins: you get `Ok` rather than `Timeout`. If
|
||||
/// every sender is dropped before a message arrives or the deadline
|
||||
/// passes, you get [`RecvTimeoutError::Disconnected`].
|
||||
///
|
||||
/// The wake is classified from state alone — wakes are precise (the only
|
||||
/// stamped wakers of this wait are a send, the last-sender drop, and the
|
||||
/// timer; a stop wake unwinds out of `park_current` and never reaches
|
||||
/// the classification), so: message queued → `Ok`; `senders == 0` →
|
||||
/// `Disconnected`; neither → it was the timer → `Timeout`.
|
||||
///
|
||||
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-
|
||||
/// due timer is drained, then reports `Timeout` unless a message was
|
||||
/// already queued.
|
||||
/// `Duration::ZERO` is a valid timeout: it still gives any
|
||||
/// already-queued message a chance to be returned, and only then
|
||||
/// reports `Timeout`.
|
||||
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Result<T, RecvTimeoutError>
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let me = crate::actor::current_pid()
|
||||
.expect("recv_timeout() called outside an actor");
|
||||
let me = match crate::actor::current_pid() {
|
||||
Some(me) => me,
|
||||
None => panic!("smarm: recv_timeout() called outside an actor"),
|
||||
};
|
||||
|
||||
// Fast path + wait registration, one critical section.
|
||||
let epoch;
|
||||
@@ -247,14 +340,17 @@ impl<T> Receiver<T> {
|
||||
|
||||
// Arm the timer after releasing the channel lock (insert takes the
|
||||
// timers lock; never nest under a Channel lock). A send or even the
|
||||
// timer itself may unpark us before we park — the RunningNotified
|
||||
// timer itself may unpark us before we park; the runtime's wake
|
||||
// protocol makes the park below return immediately in that case.
|
||||
let deadline = crate::timer::deadline_from_now(timeout);
|
||||
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = self.inner.clone();
|
||||
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||
|
||||
crate::scheduler::park_current();
|
||||
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
|
||||
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() {
|
||||
Some(p) => p,
|
||||
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
|
||||
}));
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(v) = g.queue.pop_front() {
|
||||
crate::preempt::note_message_received();
|
||||
@@ -266,16 +362,23 @@ impl<T> Receiver<T> {
|
||||
Err(RecvTimeoutError::Timeout)
|
||||
}
|
||||
|
||||
/// Selective receive: remove and return the first queued message for which
|
||||
/// `pred` holds, leaving the rest in arrival order. If no queued message
|
||||
/// matches, parks and re-scans on every send (a selective receiver may park
|
||||
/// on a *non-empty* queue). Returns `Err(RecvError)` only once the channel
|
||||
/// is closed and no queued message matches.
|
||||
/// Selective receive: find and return the first queued message for
|
||||
/// which `pred` returns `true`, leaving every other message in the
|
||||
/// queue untouched and in order. Useful when an actor's inbox mixes
|
||||
/// message kinds and it wants to handle one kind out of turn, without
|
||||
/// discarding the rest.
|
||||
///
|
||||
/// `pred` is run while the channel lock is held: keep it cheap and pure,
|
||||
/// and do not call back into this channel from inside it. It is modelled as
|
||||
/// `Fn` (not `FnMut`) deliberately — it is re-run from scratch on every
|
||||
/// scan, so a stateful predicate would observe surprising re-counting.
|
||||
/// If nothing queued matches, this blocks and re-checks every time a new
|
||||
/// message arrives, the same way [`recv`](Self::recv) blocks on an empty
|
||||
/// queue: a selective receiver can be waiting even while the queue holds
|
||||
/// messages, just none that match yet. Returns [`RecvError`] only once
|
||||
/// the channel is closed and still nothing matches.
|
||||
///
|
||||
/// `pred` runs while the channel is locked, so keep it cheap, side
|
||||
/// effect free, and make sure it never calls back into this same
|
||||
/// channel. It takes `&T` and is called fresh on every scan (not `FnMut`
|
||||
/// with running state), so it should judge each message purely on its
|
||||
/// own content.
|
||||
pub fn recv_match<F>(&self, pred: F) -> Result<T, RecvError>
|
||||
where
|
||||
F: Fn(&T) -> bool,
|
||||
@@ -283,17 +386,23 @@ impl<T> Receiver<T> {
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
|
||||
if let Some(i) = g.queue.iter().position(&pred) {
|
||||
// position() found it, so remove() returns Some.
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(g.queue.remove(i).unwrap());
|
||||
let v = match g.queue.remove(i) {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: channel queue.remove after position (logic bug)"),
|
||||
};
|
||||
return Ok(v);
|
||||
}
|
||||
if g.senders == 0 {
|
||||
// Closed and nothing queued can ever match.
|
||||
return Err(RecvError);
|
||||
}
|
||||
let me = crate::actor::current_pid()
|
||||
.expect("recv_match() called outside an actor");
|
||||
let me = match crate::actor::current_pid() {
|
||||
Some(me) => me,
|
||||
None => panic!("smarm: recv_match() called outside an actor"),
|
||||
};
|
||||
debug_assert!(
|
||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||
"channel has more than one receiver"
|
||||
@@ -301,24 +410,33 @@ impl<T> Receiver<T> {
|
||||
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
||||
crate::te!(crate::trace::Event::RecvPark(me));
|
||||
}
|
||||
// Release the lock before parking — the unparker will need it.
|
||||
// Release the lock before parking: the unparker will need it.
|
||||
crate::scheduler::park_current();
|
||||
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
|
||||
crate::te!(crate::trace::Event::RecvWake(match crate::actor::current_pid() {
|
||||
Some(p) => p,
|
||||
None => panic!("smarm: RecvWake outside an actor (core corrupt)"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking selective receive. `Ok(Some(v))` if a queued message
|
||||
/// matched `pred` (removed, rest left in order), `Ok(None)` if the channel
|
||||
/// is open but nothing matched, `Err(RecvError)` if closed and nothing
|
||||
/// matched. Same predicate contract as [`recv_match`](Self::recv_match).
|
||||
/// The non-blocking counterpart of [`recv_match`](Self::recv_match):
|
||||
/// returns immediately either way. `Ok(Some(v))` if a queued message
|
||||
/// matched `pred` (removed; the rest stay queued in order), `Ok(None)`
|
||||
/// if the channel is open but nothing currently matches, `Err(RecvError)`
|
||||
/// if the channel is closed and nothing matches. Same predicate contract
|
||||
/// as `recv_match`.
|
||||
pub fn try_recv_match<F>(&self, pred: F) -> Result<Option<T>, RecvError>
|
||||
where
|
||||
F: Fn(&T) -> bool,
|
||||
{
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
|
||||
if let Some(i) = g.queue.iter().position(&pred) {
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(Some(g.queue.remove(i).unwrap()));
|
||||
let v = match g.queue.remove(i) {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: channel queue.remove after position (logic bug)"),
|
||||
};
|
||||
return Ok(Some(v));
|
||||
}
|
||||
if g.senders == 0 {
|
||||
return Err(RecvError);
|
||||
@@ -326,8 +444,10 @@ impl<T> Receiver<T> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
|
||||
/// the channel is empty but open, `Err(RecvError)` if closed and drained.
|
||||
/// The non-blocking counterpart of [`recv`](Self::recv): returns
|
||||
/// immediately either way. `Ok(Some(v))` if a message was queued,
|
||||
/// `Ok(None)` if the channel is open but currently empty, `Err(RecvError)`
|
||||
/// if the channel is closed and the queue is drained.
|
||||
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(v) = g.queue.pop_front() {
|
||||
@@ -342,18 +462,18 @@ impl<T> Receiver<T> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimerTarget — the expiry half of recv_timeout
|
||||
// TimerTarget: the expiry half of recv_timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
// Cancel the wait only if THIS wait (epoch match) is still
|
||||
// registered. If a sender already took `parked_receiver`, the
|
||||
// receiver is waking with a message — message wins, the timer
|
||||
// receiver is waking with a message: message wins, the timer
|
||||
// no-ops. If a later wait by the same receiver is registered, the
|
||||
// epoch mismatches — stale entry, no-op. (The unpark_at would fail
|
||||
// its word CAS in either case anyway; checking under the lock keeps
|
||||
// the registration bookkeeping exact.)
|
||||
// epoch mismatches: stale entry, no-op. (unpark_at would fail its
|
||||
// internal check in either case anyway; checking under the lock
|
||||
// keeps the registration bookkeeping exact.)
|
||||
let unpark = {
|
||||
let mut g = self.lock();
|
||||
if g.parked_receiver == Some((pid, epoch)) {
|
||||
@@ -363,7 +483,7 @@ impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
|
||||
false
|
||||
}
|
||||
};
|
||||
// Unpark outside the channel lock — it may take the run-queue lock;
|
||||
// Unpark outside the channel lock: it may take the run-queue lock;
|
||||
// legal under a Channel lock, but pointless to nest.
|
||||
if unpark {
|
||||
crate::scheduler::unpark_at(pid, epoch);
|
||||
@@ -372,7 +492,7 @@ impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// select — ready-index wait over multiple receivers
|
||||
// select: ready-index wait over multiple receivers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) mod sealed {
|
||||
@@ -380,33 +500,34 @@ pub(crate) mod sealed {
|
||||
}
|
||||
impl<T> sealed::Sealed for Receiver<T> {}
|
||||
|
||||
/// An arm of a [`select`]. Implemented by [`Receiver`]; sealed, because the
|
||||
/// registration contract below is part of the runtime's wake protocol.
|
||||
/// An arm of a [`select`]: something you can wait on alongside other arms
|
||||
/// and be told when it becomes ready. Implemented by [`Receiver`]; sealed
|
||||
/// (cannot be implemented outside this crate), since the registration
|
||||
/// contract below is part of the runtime's internal wake protocol.
|
||||
///
|
||||
/// Contract (all under the arm's own lock): `sel_register` checks-or-
|
||||
/// registers atomically — if the arm is ready it does NOT register and
|
||||
/// registers atomically. If the arm is ready it does not register and
|
||||
/// returns `Ok(false)`; otherwise it publishes `(pid, epoch)` where its
|
||||
/// wakers will find it and returns `Ok(true)`. "Ready" means a receive
|
||||
/// would not park: a message is queued, or the arm is closed. `Err` means
|
||||
/// would not block: a message is queued, or the arm is closed. `Err` means
|
||||
/// the arm could not register at all (only fd arms can fail; channel
|
||||
/// registration is infallible) — the wait must be retired and earlier
|
||||
/// registration always succeeds), and the wait must be retired and earlier
|
||||
/// eager-cleanup arms unregistered.
|
||||
pub trait Selectable: sealed::Sealed {
|
||||
#[doc(hidden)]
|
||||
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool>;
|
||||
#[doc(hidden)]
|
||||
fn sel_ready(&self) -> bool;
|
||||
/// Remove this arm's `(pid, epoch)` registration if — and only if — it
|
||||
/// is still in place. Default no-op: a losing channel arm's stale
|
||||
/// registration is inert (its wakers die at the epoch CAS; the next
|
||||
/// wait overwrites the slot). Fd arms override this: their staleness
|
||||
/// poisons the fd (waiters entry + kernel-side ONESHOT registration)
|
||||
/// and needs an eager cleanup pass.
|
||||
/// Remove this arm's `(pid, epoch)` registration if, and only if, it is
|
||||
/// still in place. Default no-op: a losing channel arm's stale
|
||||
/// registration is harmless and self-cleans. Fd arms override this:
|
||||
/// their staleness would otherwise leave the fd unusable for future
|
||||
/// selects, so they need an eager cleanup pass.
|
||||
#[doc(hidden)]
|
||||
fn sel_unregister(&self, _pid: Pid, _epoch: u32) {}
|
||||
/// Whether this arm requires the eager cleanup pass at all. Gates the
|
||||
/// post-wake `sel_unregister` sweep so channel-only selects keep
|
||||
/// today's zero-cancellation hot path.
|
||||
/// post-wake `sel_unregister` sweep so channel-only selects keep their
|
||||
/// cheap, cleanup-free path.
|
||||
#[doc(hidden)]
|
||||
fn sel_eager_cleanup(&self) -> bool {
|
||||
false
|
||||
@@ -433,50 +554,54 @@ impl<T> Selectable for Receiver<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Park on every arm at once; return the index of the first ready one.
|
||||
/// Wait on several channels at once and return the index of the first one
|
||||
/// that is ready, instead of blocking on just one with [`Receiver::recv`].
|
||||
///
|
||||
/// "Ready" means a receive on that arm would not park: a message is queued,
|
||||
/// or the arm is **closed** (so the caller's `try_recv` observes the
|
||||
/// disconnect — a dead arm is an event, not a hang). The caller consumes the
|
||||
/// arm itself, typically via [`Receiver::try_recv`]; single-receiver
|
||||
/// channels guarantee nothing can steal the message in between.
|
||||
/// "Ready" means a receive on that arm would not block: a message is
|
||||
/// queued, or the arm is closed (so the caller's own `try_recv` observes
|
||||
/// the disconnect: a dead arm is something to react to, not something to
|
||||
/// hang on). `select` only tells you which arm is ready; read the actual
|
||||
/// message yourself, typically with [`Receiver::try_recv`] on that arm.
|
||||
///
|
||||
/// A closed arm stays ready *forever*: once its disconnect has been
|
||||
/// observed, drop it from the arm set — under priority order it would
|
||||
/// otherwise win every subsequent call and starve every higher-indexed arm.
|
||||
/// A closed arm stays ready forever. Once you have observed its disconnect,
|
||||
/// drop it from the arm set you pass in next time: otherwise, under the
|
||||
/// priority order below, it would win every subsequent call and starve
|
||||
/// every arm listed after it.
|
||||
///
|
||||
/// Arms are scanned **in order**: index 0 is the highest priority, both on
|
||||
/// the immediate-ready path and after a wake. This is a documented
|
||||
/// guarantee (compose like BEAM receive clauses: put control channels
|
||||
/// first), not an accident — and therefore there is NO fairness promise; a
|
||||
/// saturated arm 0 starves arm 1 by design.
|
||||
/// Arms are checked **in order**: index 0 is the highest priority, both
|
||||
/// when checking immediately and after being woken. This is a deliberate,
|
||||
/// documented guarantee, not an accident of implementation: put a control
|
||||
/// or shutdown channel first so it is always noticed promptly. The
|
||||
/// flip side is that there is **no fairness guarantee**: a busy arm 0 can
|
||||
/// starve arm 1 indefinitely by design.
|
||||
///
|
||||
/// One actor may select on a channel and later `recv` on it (or select on
|
||||
/// overlapping sets) freely. What stays illegal is what was always illegal:
|
||||
/// two *different* actors receiving on one channel.
|
||||
/// One actor can `select` on a channel and later plain `recv` on it (or
|
||||
/// `select` again on an overlapping set of arms) with no restriction. What
|
||||
/// stays illegal is what was always illegal for a channel: two *different*
|
||||
/// actors receiving on the same one.
|
||||
///
|
||||
/// Built on the consuming-wake protocol (see slot_state.rs): all arms are
|
||||
/// registered under one wait epoch; the winning wake consumes it, so losing
|
||||
/// arms' registrations are inert and need no cancellation pass — they
|
||||
/// self-clean at their wakers' failed CAS, or get overwritten by this
|
||||
/// receiver's next wait on that channel.
|
||||
///
|
||||
/// Panics if `arms` is empty, when called outside an actor, or if an fd
|
||||
/// arm fails to register (EBADF, EMFILE, a second waiter on one fd —
|
||||
/// see [`try_select`] for the fallible form; channel-only selects cannot
|
||||
/// fail).
|
||||
/// Panics if `arms` is empty, if called outside an actor, or if an fd arm
|
||||
/// fails to register (see [`try_select`] for the fallible form; a
|
||||
/// channel-only `select` can never fail).
|
||||
pub fn select(arms: &[&dyn Selectable]) -> usize {
|
||||
try_select(arms).expect("select(): fd arm failed to register (use try_select)")
|
||||
match try_select(arms) {
|
||||
Ok(i) => i,
|
||||
Err(e) => panic!("smarm: select() fd arm failed to register (use try_select): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`select`], fallible: `Err` when an arm fails to register (only fd
|
||||
/// arms can — EBADF, EMFILE on the epoll set, or a second waiter on an
|
||||
/// fd that already has one). On `Err` the wait is fully retired and no
|
||||
/// The fallible form of [`select`]: `Err` when an arm fails to register.
|
||||
/// Only fd arms can fail this way (for example, the file descriptor is
|
||||
/// invalid, or something else is already waiting on it); a channel-only
|
||||
/// select can never fail. On `Err` the wait is fully retired and no
|
||||
/// registration is left behind: every arm registered before the failing
|
||||
/// one has been unregistered.
|
||||
pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
||||
assert!(!arms.is_empty(), "select() on an empty arm list");
|
||||
let me = crate::actor::current_pid().expect("select() called outside an actor");
|
||||
let me = match crate::actor::current_pid() {
|
||||
Some(me) => me,
|
||||
None => panic!("smarm: select() called outside an actor"),
|
||||
};
|
||||
loop {
|
||||
let epoch = crate::scheduler::begin_wait();
|
||||
if let Some(i) = register_arms(me, epoch, arms)? {
|
||||
@@ -484,13 +609,12 @@ pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
||||
}
|
||||
|
||||
// Stale fd registrations are not harmless (a losing fd arm's
|
||||
// waiters entry poisons the fd with AlreadyExists and its
|
||||
// kernel-side ONESHOT registration can fire arbitrarily late), so
|
||||
// selects containing fd arms run an eager cleanup pass after the
|
||||
// park — including when a terminal stop unwinds out of it, via
|
||||
// the guard. Channel-only selects skip all of it: `eager` is
|
||||
// false, the guard is disarmed, and the loser-arm self-cleaning
|
||||
// story is unchanged.
|
||||
// leftover registration can make the fd unusable for the next
|
||||
// select until a kernel event happens to clear it), so selects
|
||||
// containing fd arms run an eager cleanup pass after the park,
|
||||
// including when a terminal stop unwinds out of it, via the guard.
|
||||
// Channel-only selects skip all of it: `eager` is false, the guard
|
||||
// is disarmed, and the loser-arm self-cleaning story is unchanged.
|
||||
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
||||
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
||||
|
||||
@@ -503,22 +627,22 @@ pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
||||
drop(guard);
|
||||
|
||||
// Woken precisely: an arm's send (message) or last-sender drop
|
||||
// (closure) consumed our epoch, and both leave their arm ready —
|
||||
// return the first one, in priority order (which may be a
|
||||
// (closure) is what woke us, and both leave their arm ready.
|
||||
// Return the first ready one, in priority order (which may be a
|
||||
// different, higher-priority arm than the one that woke us; its
|
||||
// message stays queued and re-reports ready on the next call).
|
||||
// Fd arms classify by a fresh zero-timeout poll, so they too are
|
||||
// a pure function of state — independent of the registration the
|
||||
// cleanup pass just removed.
|
||||
// a pure function of current state, independent of the
|
||||
// registration the cleanup pass just removed.
|
||||
for (i, arm) in arms.iter().enumerate() {
|
||||
if arm.sel_ready() {
|
||||
return Ok(i);
|
||||
}
|
||||
}
|
||||
// Unreachable by protocol (a stop wake unwinds out of
|
||||
// park_current). Defensive: re-open the wait and re-register —
|
||||
// stale own-registrations are overwritten (channels) or were
|
||||
// removed by the cleanup pass above (fds).
|
||||
// Unreachable in practice (a stop wake unwinds out of
|
||||
// park_current before we get here). Defensive: re-open the wait
|
||||
// and re-register; stale own-registrations are overwritten
|
||||
// (channels) or were removed by the cleanup pass above (fds).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,11 +657,11 @@ fn unregister_arms(arms: &[&dyn Selectable], me: Pid, epoch: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop-unwind twin of the explicit cleanup pass: a terminal stop unwinds
|
||||
/// out of `park_current`, and a registered fd arm must not outlive its
|
||||
/// actor (the generalization of `wait_fd`'s `Dereg`). Disarmed on the
|
||||
/// normal path after the explicit pass runs; never armed when no fd arm
|
||||
/// registered, keeping the channel-only path guard-free in effect.
|
||||
// Stop-unwind twin of the explicit cleanup pass: a terminal stop unwinds
|
||||
// out of `park_current`, and a registered fd arm must not outlive its
|
||||
// actor. Disarmed on the normal path after the explicit pass runs; never
|
||||
// armed when no fd arm is registered, keeping the channel-only path
|
||||
// guard-free in effect.
|
||||
struct UnregisterGuard<'a> {
|
||||
arms: &'a [&'a dyn Selectable],
|
||||
me: Pid,
|
||||
@@ -553,20 +677,16 @@ impl Drop for UnregisterGuard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The registration pass shared by [`select`] and [`select_timeout`]:
|
||||
/// check-or-register each arm, in priority order, each atomically under its
|
||||
/// own lock. Cross-arm atomicity is unnecessary: an arm becoming ready
|
||||
/// right after its registration wakes the caller through the protocol (the
|
||||
/// prep-to-park window is closed by RunningNotified).
|
||||
///
|
||||
/// `Ok(Some(i))` = arm `i` was ready, the pass stopped, and the wait has
|
||||
/// been RETIRED (no park may follow): earlier arms hold live-epoch
|
||||
/// registrations, so earlier *fd* arms are unregistered eagerly, then the
|
||||
/// epoch is bumped, a landed notification eaten, and a pending stop
|
||||
/// re-observed — without which a stale arm wake could fault a later
|
||||
/// one-shot park. `Err` = an arm failed to register; identical unwind
|
||||
/// (earlier fd arms unregistered, wait retired). `Ok(None)` = every arm
|
||||
/// registered; the caller parks.
|
||||
// The registration pass shared by `select` and `select_timeout`: check-or-
|
||||
// register each arm, in priority order, each atomically under its own lock.
|
||||
// Cross-arm atomicity is unnecessary: an arm becoming ready right after its
|
||||
// registration still wakes the caller through the normal wake path.
|
||||
//
|
||||
// `Ok(Some(i))` = arm `i` was already ready, the pass stopped, and the wait
|
||||
// has been fully retired (no park may follow): earlier fd arms are
|
||||
// unregistered eagerly so none are left dangling. `Err` = an arm failed to
|
||||
// register; same unwind (earlier fd arms unregistered, wait retired).
|
||||
// `Ok(None)` = every arm registered successfully; the caller parks.
|
||||
fn register_arms(
|
||||
me: Pid,
|
||||
epoch: u32,
|
||||
@@ -590,10 +710,10 @@ fn register_arms(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The [`select_timeout`] timer target: stateless, because precise wakes
|
||||
/// make classification a pure function of channel state. The entry is
|
||||
/// stamped with the select's epoch; if an arm already won, this unpark dies
|
||||
/// at the word's epoch CAS (the no-cancellation convention in `timer.rs`).
|
||||
// The `select_timeout` timer target: stateless, because a wake's cause can
|
||||
// always be read back off plain channel state (an arm ready, or not). If
|
||||
// an arm already won before the deadline, this timer's fire is simply
|
||||
// ignored, the way any other stale wakeup is.
|
||||
struct SelectTimeout;
|
||||
impl crate::timer::TimerTarget for SelectTimeout {
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
@@ -601,60 +721,60 @@ impl crate::timer::TimerTarget for SelectTimeout {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`select`] with a deadline: returns `Some(index)` like `select`, or
|
||||
/// `None` once `timeout` elapses with no arm ready.
|
||||
/// Like [`select`], but gives up and returns `None` if no arm becomes
|
||||
/// ready before `timeout` elapses.
|
||||
///
|
||||
/// All of `select`'s semantics carry over (priority order, closed arms
|
||||
/// permanently ready, no fairness promise). The timeout is one more stamped
|
||||
/// waker on the same wait epoch — nothing is registered in any arm for it,
|
||||
/// so there is nothing to cancel or leak: an arm winning leaves the timer
|
||||
/// entry to expire as a stale-epoch no-op; the timer winning leaves the
|
||||
/// arms' registrations to self-clean exactly as a `select` loser's would.
|
||||
/// All of `select`'s semantics carry over: arms are still checked in
|
||||
/// priority order, a closed arm is still permanently ready, and there is
|
||||
/// still no fairness guarantee across arms. A message that arrives at
|
||||
/// essentially the same moment the deadline passes still wins, the same
|
||||
/// way [`Receiver::recv_timeout`] resolves that race.
|
||||
///
|
||||
/// The wake is classified from state alone (wakes are precise): some arm
|
||||
/// ready → `Some` of the first, in priority order; none ready → the timer
|
||||
/// was the only remaining stamped waker → `None`. A message that races the
|
||||
/// deadline resolves message-first, as `recv_timeout` does.
|
||||
/// `Duration::ZERO` is a valid timeout: it still gives an already-ready arm
|
||||
/// a chance to be reported before falling through to `None`.
|
||||
///
|
||||
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-due
|
||||
/// timer is drained, then reports `None` unless an arm was already ready.
|
||||
///
|
||||
/// Panics if `arms` is empty, when called outside an actor, or if an fd
|
||||
/// arm fails to register (see [`try_select_timeout`] for the fallible
|
||||
/// form; channel-only selects cannot fail).
|
||||
/// Panics if `arms` is empty, if called outside an actor, or if an fd arm
|
||||
/// fails to register (see [`try_select_timeout`] for the fallible form; a
|
||||
/// channel-only select can never fail).
|
||||
pub fn select_timeout(
|
||||
arms: &[&dyn Selectable],
|
||||
timeout: std::time::Duration,
|
||||
) -> Option<usize> {
|
||||
try_select_timeout(arms, timeout)
|
||||
.expect("select_timeout(): fd arm failed to register (use try_select_timeout)")
|
||||
match try_select_timeout(arms, timeout) {
|
||||
Ok(r) => r,
|
||||
Err(e) => panic!(
|
||||
"smarm: select_timeout() fd arm failed to register (use try_select_timeout): {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`select_timeout`], fallible: `Err` when an arm fails to register
|
||||
/// (only fd arms can). On `Err` the wait is fully retired and no
|
||||
/// registration — arm-side or kernel-side — is left behind.
|
||||
/// The fallible form of [`select_timeout`]: `Err` when an arm fails to
|
||||
/// register (only fd arms can). On `Err` the wait is fully retired and no
|
||||
/// registration is left behind on any arm.
|
||||
pub fn try_select_timeout(
|
||||
arms: &[&dyn Selectable],
|
||||
timeout: std::time::Duration,
|
||||
) -> std::io::Result<Option<usize>> {
|
||||
assert!(!arms.is_empty(), "select_timeout() on an empty arm list");
|
||||
let me = crate::actor::current_pid().expect("select_timeout() called outside an actor");
|
||||
let me = match crate::actor::current_pid() {
|
||||
Some(me) => me,
|
||||
None => panic!("smarm: select_timeout() called outside an actor"),
|
||||
};
|
||||
let epoch = crate::scheduler::begin_wait();
|
||||
if let Some(i) = register_arms(me, epoch, arms)? {
|
||||
return Ok(Some(i)); // ready now: the timer was never armed
|
||||
}
|
||||
|
||||
// Arm the timer after the registration pass, outside every Channel
|
||||
// lock (insert takes the timers lock).
|
||||
// Arm the timer after the registration pass, outside every channel
|
||||
// lock (inserting a timer takes the timers lock).
|
||||
let deadline = crate::timer::deadline_from_now(timeout);
|
||||
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = std::sync::Arc::new(SelectTimeout);
|
||||
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||
|
||||
// Same eager-cleanup story as `try_select`: the timer arm needs none
|
||||
// (stateless, stale entries die at the epoch CAS), channel arms need
|
||||
// none, fd arms do — and a timer win in particular leaves every fd
|
||||
// arm's registration behind, which without this pass would poison
|
||||
// those fds until a kernel event happened to fire.
|
||||
// Same eager-cleanup story as `try_select`: a timer win in particular
|
||||
// leaves every fd arm's registration behind, which without this pass
|
||||
// would leave those fds unusable until a kernel event happened to
|
||||
// clear them.
|
||||
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
||||
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
||||
|
||||
|
||||
@@ -94,10 +94,28 @@ unsafe extern "C" fn switch_to_actor_asm() {
|
||||
}
|
||||
|
||||
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The caller must be running on a scheduler thread with a valid actor stack
|
||||
/// pointer installed in `ACTOR_SP` — either by `init_actor_stack` (first
|
||||
/// resume) or by a prior `switch_to_scheduler` (subsequent resumes). Resuming
|
||||
/// with an unset or stale `ACTOR_SP` transfers control to an arbitrary address.
|
||||
/// Must not be called from within an actor (only the scheduler side may resume).
|
||||
pub unsafe fn switch_to_actor() {
|
||||
unsafe { switch_to_actor_asm() };
|
||||
}
|
||||
|
||||
/// Yield from the running actor back to its scheduler thread. Returns when the
|
||||
/// actor is next resumed via [`switch_to_actor`].
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The caller must be running on an actor stack that was entered through
|
||||
/// [`switch_to_actor`], so that `SCHEDULER_SP` holds the live saved stack
|
||||
/// pointer of the scheduler side. Calling this from the scheduler thread, or
|
||||
/// before any actor has been resumed, transfers control to an arbitrary
|
||||
/// address.
|
||||
#[unsafe(naked)]
|
||||
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||
core::arch::naked_asm!(
|
||||
|
||||
+347
-215
@@ -1,71 +1,182 @@
|
||||
//! gen_server — synchronous call / asynchronous cast over a single actor.
|
||||
//! A stateful actor with typed request-reply messaging.
|
||||
//!
|
||||
//! A thin request-reply layer on top of [`channel`](crate::channel()), modelled
|
||||
//! on Erlang's `gen_server`. A *server* is an actor owning a state value that
|
||||
//! implements [`GenServer`]; clients hold a clonable [`GenServerRef`] and issue
|
||||
//! [`call`](GenServerRef::call) (synchronous, returns a reply) or
|
||||
//! [`cast`](GenServerRef::cast) (fire-and-forget).
|
||||
//! ## What is a gen_server?
|
||||
//!
|
||||
//! ## One inbox, many arms
|
||||
//! Whenever you need mutable state shared between threads, the obvious tool is
|
||||
//! `Arc<Mutex<State>>`. That works, but it scatters lock guards and error
|
||||
//! handling everywhere, and offers no natural place to put the logic that
|
||||
//! operates on the state.
|
||||
//!
|
||||
//! Every call and cast travels the *same* inbox channel as an [`Envelope`]
|
||||
//! (calls and casts are ordered relative to each other, like Erlang); the
|
||||
//! server loop dispatches by variant. A `call` carries a freshly made
|
||||
//! one-shot reply channel; the server sends the reply straight back down it.
|
||||
//! A gen_server is a cleaner alternative: a dedicated thread that *owns* the
|
||||
//! state and handles one message at a time. Other threads talk to it by sending
|
||||
//! typed messages and optionally waiting for a typed reply. Because messages are
|
||||
//! serialised through a single inbox. The server is the only thing that ever
|
||||
//! touches the state, so there are no lock guards at all.
|
||||
//!
|
||||
//! Out-of-band messages ride *separate* channels composed at the wait via
|
||||
//! [`select`](channel::select): info channels handed over at start
|
||||
//! ([`GenServerBuilder::with_info`]) are dispatched to
|
||||
//! [`handle_info`](GenServer::handle_info). Arm priority is
|
||||
//! **infos before inbox**, in declaration order — a hot inbox cannot starve
|
||||
//! an out-of-band message; conversely a hot info channel CAN starve the
|
||||
//! inbox, deliberately (system-message semantics). An info channel whose
|
||||
//! senders are all gone is silently dropped from the arm set (per the
|
||||
//! closed-arm-is-ready-forever rule on `select`); a closed *inbox* still
|
||||
//! means graceful shutdown.
|
||||
//! You write the logic by implementing [`GenServer`]. smarm runs the receive
|
||||
//! loop, the reply plumbing, and the lifecycle.
|
||||
//!
|
||||
//! ## Server death
|
||||
//! ## A first server
|
||||
//!
|
||||
//! Detection falls out of channel closure, so no monitor is required:
|
||||
//! - if the server is already gone, its inbox is closed and the `send` in
|
||||
//! `call`/`cast` fails → [`CallError::ServerDown`] / [`CastError::ServerDown`];
|
||||
//! - if the server dies *after* a call is enqueued but before it replies
|
||||
//! (a handler panic, or a cooperative `request_stop`), the reply sender is
|
||||
//! dropped as the server's stack unwinds, closing the reply channel; the
|
||||
//! parked caller wakes and its `recv` returns `Err` → `ServerDown`.
|
||||
//! Let's build a counter that can be incremented from any thread and queried for
|
||||
//! its current value.
|
||||
//!
|
||||
//! ## Lifecycle / callbacks
|
||||
//! ```ignore
|
||||
//! use smarm::gen_server::{self, GenServer, GenServerRef};
|
||||
//!
|
||||
//! [`GenServer::init`] runs once inside the server actor before the first
|
||||
//! message; [`GenServer::terminate`] runs on the way out. terminate is wired
|
||||
//! through a drop guard, so it fires on *every* exit path — graceful inbox
|
||||
//! close, a handler panic, or a cooperative `request_stop` — not only the clean
|
||||
//! one. Keep it cheap and non-blocking: it may run mid-unwind, and a panic
|
||||
//! inside it during an unwind aborts the process (a double panic).
|
||||
//! // State
|
||||
//!
|
||||
//! ## Time (timers and idle)
|
||||
//! struct Counter {
|
||||
//! count: u64,
|
||||
//! }
|
||||
//!
|
||||
//! A server arms timers through a [`TimerHandle`] cloned from the [`GenServerCtx`]
|
||||
//! in `init` and stored on the state — the same shape as [`Watcher`] for
|
||||
//! monitors. [`arm_after`](TimerHandle::arm_after) is a one-shot,
|
||||
//! [`tick_every`](TimerHandle::tick_every) a periodic; both fire into
|
||||
//! [`handle_timer`](GenServer::handle_timer) at *system priority* (above infos
|
||||
//! and the inbox, by arm position), and [`cancel`](TimerHandle::cancel) carries
|
||||
//! the substrate's race signal. Separately, [`GenServerCtx::idle_after`] sets a
|
||||
//! receive-timeout window: quiet for the whole window fires
|
||||
//! [`handle_idle`](GenServer::handle_idle).
|
||||
//! // Message types
|
||||
//!
|
||||
//! Two unrelated things share the word *timeout*: the server-side **idle /
|
||||
//! receive timeout** above, and the client-side **call deadline**
|
||||
//! ([`GenServerRef::call_timeout`]) — how long a caller waits for a reply. They sit
|
||||
//! on different axes and never interact (RFC 015 §7).
|
||||
//! // Calls expect a reply; casts do not.
|
||||
//! enum CounterCall { Get }
|
||||
//! enum CounterCast { Increment }
|
||||
//!
|
||||
//! ## Not here (yet)
|
||||
//! // Behaviour
|
||||
//!
|
||||
//! No dynamic info subscription: the info-channel set is fixed at start.
|
||||
//! Revisit against a real consumer. (Monitor `Down` forwarding is dynamic —
|
||||
//! see `handle_down` — because monitors are inherently created at runtime.)
|
||||
//! The idle window is likewise set once, in `init` (RFC 015 §4.4).
|
||||
//! impl GenServer for Counter {
|
||||
//! type Call = CounterCall;
|
||||
//! type Reply = u64;
|
||||
//! type Cast = CounterCast;
|
||||
//! type Info = (); // no out-of-band messages
|
||||
//! type Timer = (); // no timers
|
||||
//!
|
||||
//! fn handle_call(&mut self, request: CounterCall) -> u64 {
|
||||
//! match request {
|
||||
//! CounterCall::Get => self.count,
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! fn handle_cast(&mut self, request: CounterCast) {
|
||||
//! match request {
|
||||
//! CounterCast::Increment => self.count += 1,
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Start the server and talk to it:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let server: GenServerRef<Counter> = gen_server::start(Counter { count: 0 });
|
||||
//!
|
||||
//! server.cast(CounterCast::Increment).unwrap();
|
||||
//! server.cast(CounterCast::Increment).unwrap();
|
||||
//!
|
||||
//! let n = server.call(CounterCall::Get).unwrap();
|
||||
//! // n == 2
|
||||
//! ```
|
||||
//!
|
||||
//! [`GenServerRef`] is cheaply clonable; hand copies to as many threads as you
|
||||
//! like. They all share the same inbox; the server handles messages one at a
|
||||
//! time, in arrival order.
|
||||
//!
|
||||
//! ## Client / server APIs
|
||||
//!
|
||||
//! In practice, callers should not have to know about `CounterCall` or
|
||||
//! `CounterCast`. The recommended pattern is to wrap the message types in plain
|
||||
//! functions on the same module:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Client API
|
||||
//!
|
||||
//! impl Counter {
|
||||
//! pub fn start() -> GenServerRef<Counter> {
|
||||
//! gen_server::start(Counter { count: 0 })
|
||||
//! }
|
||||
//!
|
||||
//! pub fn increment(server: &GenServerRef<Counter>) {
|
||||
//! server.cast(CounterCast::Increment).unwrap();
|
||||
//! }
|
||||
//!
|
||||
//! pub fn get(server: &GenServerRef<Counter>) -> u64 {
|
||||
//! server.call(CounterCall::Get).unwrap()
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Callers now just do:
|
||||
//! let counter = Counter::start();
|
||||
//! Counter::increment(&counter);
|
||||
//! Counter::increment(&counter);
|
||||
//! let n = Counter::get(&counter); // n == 2
|
||||
//! ```
|
||||
//!
|
||||
//! ## call vs cast
|
||||
//!
|
||||
//! `call` sends a request and blocks the calling thread until the server
|
||||
//! replies. Use it when you need a return value, or when you need to know that
|
||||
//! the server has processed the message before continuing.
|
||||
//!
|
||||
//! `cast` enqueues a message and returns immediately, without waiting for the
|
||||
//! server to handle it. Use it for fire-and-forget updates where you don't need
|
||||
//! confirmation.
|
||||
//!
|
||||
//! Both return `Err(ServerDown)` if the inbox is closed (the server is gone).
|
||||
//! If you need to cap how long you wait for a reply, use
|
||||
//! [`GenServerRef::call_timeout`], which additionally returns `Err(Timeout)` if
|
||||
//! the deadline passes before a reply arrives.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! Two optional callbacks bracket the server's life:
|
||||
//!
|
||||
//! - [`GenServer::init`] runs once before the first message. Use it to start
|
||||
//! timers or set up monitors; see the [`GenServerCtx`] it receives.
|
||||
//! - [`GenServer::terminate`] runs when the server is about to exit. It fires
|
||||
//! on every exit path (all `GenServerRef`s dropped, a handler panic, or an
|
||||
//! explicit [`GenServerRef::shutdown`]), not only on clean shutdown. Keep it
|
||||
//! short and non-blocking: if `terminate` panics while the server is already
|
||||
//! unwinding from a handler panic, the process aborts.
|
||||
//!
|
||||
//! ## When the server stops
|
||||
//!
|
||||
//! The server runs as long as at least one [`GenServerRef`] exists. When the last
|
||||
//! one is dropped, the inbox closes and the loop exits gracefully. To stop a
|
||||
//! server explicitly and wait for it to finish, call [`GenServerRef::shutdown`].
|
||||
//!
|
||||
//! If the server panics inside a handler, the panic unwinds the server thread.
|
||||
//! Any caller currently waiting in `call` sees `Err(ServerDown)`: the reply
|
||||
//! channel closes as the server unwinds, which wakes the caller.
|
||||
//!
|
||||
//! ## Going further
|
||||
//!
|
||||
//! Out-of-band messages: a server can receive messages from sources other
|
||||
//! than `call`/`cast`, for example notifications from a background task. Pass
|
||||
//! extra [`Receiver`] channels to [`GenServerBuilder::with_info`]; they are
|
||||
//! dispatched to [`GenServer::handle_info`], always before inbox messages.
|
||||
//!
|
||||
//! Monitors: to watch another actor and be notified when it exits, clone a
|
||||
//! [`Watcher`] from [`GenServerCtx::watcher`] during `init` and call
|
||||
//! [`Watcher::watch`] with a [`Monitor`] from any handler. The loop dispatches
|
||||
//! the resulting [`Down`] to [`GenServer::handle_down`].
|
||||
//!
|
||||
//! Timers: clone a [`TimerHandle`] from [`GenServerCtx::timer`] during `init`.
|
||||
//! Use [`TimerHandle::arm_after`] for a one-shot and [`TimerHandle::tick_every`]
|
||||
//! for a periodic; both fire into [`GenServer::handle_timer`].
|
||||
//!
|
||||
//! Idle detection: call [`GenServerCtx::idle_after`] during `init` to set a
|
||||
//! quiet window: if no message of any kind is dispatched for that duration,
|
||||
//! the loop calls [`GenServer::handle_idle`] and resets the window. This differs
|
||||
//! from the client-side `call_timeout`. Idle measures silence on the *server*,
|
||||
//! while `call_timeout` caps how long *one caller* waits.
|
||||
//!
|
||||
//! Names and registration: a server can be given a static name so other
|
||||
//! actors can reach it without holding a `GenServerRef`. Use
|
||||
//! [`GenServerBuilder::named`] to register on start, and the free functions
|
||||
//! [`call`], [`cast`], and [`whereis_server`] to address it by name. Registered
|
||||
//! servers are a natural fit for supervision; see `supervisor` for how to
|
||||
//! build a tree that restarts servers on failure.
|
||||
//!
|
||||
//! ## Limitations
|
||||
//!
|
||||
//! The info-channel set is fixed at start; channels cannot be added or removed
|
||||
//! while the server is running. Monitors are dynamic (they can be registered
|
||||
//! from any handler via [`Watcher::watch`]) because monitors are inherently
|
||||
//! created at runtime. The idle window is set once, in `init`.
|
||||
|
||||
use crate::channel::{channel, select, select_timeout, Receiver, RecvTimeoutError, Selectable, Sender};
|
||||
use crate::monitor::{demonitor, monitor, Down, Monitor};
|
||||
@@ -79,37 +190,33 @@ use std::marker::PhantomData;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Behaviour for a gen_server: a state value plus call/cast handlers.
|
||||
/// The behaviour you implement to make a type into a gen_server.
|
||||
///
|
||||
/// `handle_call` and `handle_cast` are required; [`init`](Self::init) and
|
||||
/// [`terminate`](Self::terminate) are optional lifecycle hooks with no-op
|
||||
/// defaults.
|
||||
/// Implement this on your state struct. `handle_call` and `handle_cast` are
|
||||
/// required; all other methods have no-op defaults and can be added as needed.
|
||||
pub trait GenServer: Send + 'static {
|
||||
/// Synchronous request type (carried by [`GenServerRef::call`]).
|
||||
/// The request type sent by [`GenServerRef::call`]. Must produce a [`Reply`](Self::Reply).
|
||||
type Call: Send + 'static;
|
||||
/// Reply type returned for a `Call`.
|
||||
/// The value returned to the caller by [`handle_call`](Self::handle_call).
|
||||
type Reply: Send + 'static;
|
||||
/// Asynchronous request type (carried by [`GenServerRef::cast`]).
|
||||
/// The request type sent by [`GenServerRef::cast`]. No reply is produced.
|
||||
type Cast: Send + 'static;
|
||||
/// Out-of-band message type, delivered to [`handle_info`](Self::handle_info)
|
||||
/// from the info channels registered at start
|
||||
/// ([`GenServerBuilder::with_info`]). Servers with several out-of-band
|
||||
/// sources enum them up into one `Info`. Use `()` if unused.
|
||||
/// Out-of-band message type delivered to [`handle_info`](Self::handle_info)
|
||||
/// from channels registered via [`GenServerBuilder::with_info`]. If you don't
|
||||
/// use info channels, set this to `()`.
|
||||
type Info: Send + 'static;
|
||||
/// The server's own scheduled-timer payload, delivered to
|
||||
/// [`handle_timer`](Self::handle_timer) when a timer armed via the loop's
|
||||
/// [`TimerHandle`] fires (RFC 015). Kept distinct from
|
||||
/// [`Info`](Self::Info) — `Info` is *external* out-of-band traffic, `Timer`
|
||||
/// is the server's *own* fires — so the system/userspace split stays
|
||||
/// legible and a tagged timer round-trips
|
||||
/// (`arm_after(d, Tk::Retry(n))` → `handle_timer(Tk::Retry(n))`). Use `()`
|
||||
/// if unused, exactly as `Info` does.
|
||||
/// Payload type for timers armed via [`TimerHandle`], delivered to
|
||||
/// [`handle_timer`](Self::handle_timer). Kept separate from [`Info`](Self::Info)
|
||||
/// so that timer fires (which your server schedules itself) stay distinct
|
||||
/// from external messages (which arrive from outside). Use `()` if unused.
|
||||
type Timer: Send + 'static;
|
||||
|
||||
/// Runs once inside the server actor before any message is handled. The
|
||||
/// [`GenServerCtx`] is the loop's one runtime hook: clone its [`Watcher`]
|
||||
/// into the state here to be able to [`watch`](Watcher::watch) monitors
|
||||
/// from any later handler.
|
||||
/// [`GenServerCtx`] is the loop's one runtime hook: clone whichever of
|
||||
/// [`ctx.watcher()`](GenServerCtx::watcher) (monitors) and
|
||||
/// [`ctx.timer()`](GenServerCtx::timer) (timers) you will need from later
|
||||
/// handlers into the state here. If you need neither, the unused ctx drops
|
||||
/// and the loop's system arm auto-closes (see module docs).
|
||||
fn init(&mut self, _ctx: &GenServerCtx<Self>)
|
||||
where
|
||||
Self: Sized,
|
||||
@@ -130,19 +237,19 @@ pub trait GenServer: Send + 'static {
|
||||
/// [`Watcher::watch`]. Default: drop it.
|
||||
fn handle_down(&mut self, _down: Down) {}
|
||||
|
||||
/// Handle a fired timer armed through the loop's [`TimerHandle`]
|
||||
/// Handle a fired timer armed through [`TimerHandle`]
|
||||
/// ([`arm_after`](TimerHandle::arm_after) /
|
||||
/// [`tick_every`](TimerHandle::tick_every)). Timer fires re-enter at system
|
||||
/// priority — above infos and the inbox — so a heartbeat cannot be starved
|
||||
/// by userspace traffic. Default: drop it (RFC 015 §6).
|
||||
/// [`tick_every`](TimerHandle::tick_every)). Timer fires are delivered
|
||||
/// before info and inbox messages, so a heartbeat cannot be starved by
|
||||
/// application traffic. Default: drop the message.
|
||||
fn handle_timer(&mut self, _msg: Self::Timer) {}
|
||||
|
||||
/// Handle a receive/idle timeout: fired when the loop has waited a full
|
||||
/// idle window (set once via [`GenServerCtx::idle_after`]) with no message of
|
||||
/// any kind dispatched. The window resets on every dispatched message and
|
||||
/// re-arms after this fires (a steady idle detector); a server wanting
|
||||
/// one-shot idle-shutdown simply requests its own stop here. Default: no-op
|
||||
/// (RFC 015 §4.4, §6).
|
||||
/// Handle a quiet-period notification: called when the server has gone the
|
||||
/// full idle window (set via [`GenServerCtx::idle_after`] in `init`) without
|
||||
/// dispatching any message. The window resets automatically after this
|
||||
/// fires, so it acts as a steady idle detector. To shut down after one idle
|
||||
/// period, call [`request_stop`](crate::scheduler::request_stop) here.
|
||||
/// Default: no-op.
|
||||
fn handle_idle(&mut self) {}
|
||||
|
||||
/// Runs as the server actor exits, on any exit path (see module docs).
|
||||
@@ -150,7 +257,7 @@ pub trait GenServer: Send + 'static {
|
||||
}
|
||||
|
||||
/// What travels the server's single inbox channel: a synchronous call (with a
|
||||
/// reply sender) or an asynchronous cast.
|
||||
/// reply sender) or an asynchronous cast. Private — callers use [`GenServerRef`].
|
||||
enum Envelope<G: GenServer> {
|
||||
Call(G::Call, Sender<G::Reply>),
|
||||
Cast(G::Cast),
|
||||
@@ -182,11 +289,10 @@ pub enum CallError {
|
||||
pub enum CallTimeoutError {
|
||||
/// The server was already gone, or died before replying.
|
||||
ServerDown,
|
||||
/// The deadline passed before a reply arrived. The request stays in the
|
||||
/// server's inbox: it will still be *handled*, but the reply is discarded
|
||||
/// (the abandoned reply channel's receiver is dropped, so the server's
|
||||
/// reply send fails harmlessly). Erlang behaves the same way; design
|
||||
/// idempotent calls accordingly.
|
||||
/// The deadline passed before a reply arrived. The request is still in the
|
||||
/// server's inbox and will be handled — the reply is simply discarded
|
||||
/// because the reply channel was dropped when the timeout fired. Design
|
||||
/// calls that may time out to be idempotent, so a late reply causes no harm.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
@@ -214,25 +320,17 @@ impl<G: GenServer> GenServerRef<G> {
|
||||
reply_rx.recv().map_err(|_| CallError::ServerDown)
|
||||
}
|
||||
|
||||
/// Bounded synchronous request-reply: like [`call`](Self::call), but
|
||||
/// gives up after `timeout`, returning [`CallTimeoutError::Timeout`].
|
||||
/// Like [`call`](Self::call), but gives up after `timeout` and returns
|
||||
/// [`CallTimeoutError::Timeout`] if no reply arrives in time.
|
||||
///
|
||||
/// This `timeout` is a **client-side call deadline** — how long *this caller*
|
||||
/// waits for a reply — and is wholly separate from the server-side idle /
|
||||
/// receive timeout ([`GenServerCtx::idle_after`] →
|
||||
/// [`GenServer::handle_idle`]), which measures quiet on the *server's* inbox.
|
||||
/// Same word ("timeout"), two different axes (RFC 015 §7); neither touches
|
||||
/// the other.
|
||||
/// Note that "timeout" means two different things here and in
|
||||
/// [`GenServerCtx::idle_after`]: this one is a *client-side call deadline* —
|
||||
/// how long this particular caller waits. The idle timeout measures silence
|
||||
/// on the *server's* inbox. They are completely independent.
|
||||
///
|
||||
/// The roadmap sketched this as monitor + wait-reply-or-Down + demonitor;
|
||||
/// that machinery is unnecessary here because server death is already
|
||||
/// observable on the reply channel itself — the reply sender is dropped
|
||||
/// as the server's stack unwinds, closing the channel and waking the
|
||||
/// parked caller (see the module docs). So a bounded call is exactly
|
||||
/// [`Receiver::recv_timeout`] on the reply channel: `Ok` on a reply,
|
||||
/// `Disconnected` → [`CallTimeoutError::ServerDown`], `Timeout` →
|
||||
/// [`CallTimeoutError::Timeout`]. Nothing is registered, so nothing can
|
||||
/// leak on the timeout path by construction.
|
||||
/// On timeout, the server still handles the request; only the reply is
|
||||
/// discarded (the reply channel is dropped, so the server's send fails
|
||||
/// silently). Design timed-out calls to be idempotent.
|
||||
pub fn call_timeout(
|
||||
&self,
|
||||
request: G::Call,
|
||||
@@ -257,18 +355,17 @@ impl<G: GenServer> GenServerRef<G> {
|
||||
.map_err(|_| CastError::ServerDown)
|
||||
}
|
||||
|
||||
/// Ask the server to terminate and block until it has — the `sys`-style
|
||||
/// stop (cf. Erlang's `gen_server:stop/1`). Sends a cooperative stop to the
|
||||
/// server actor and waits for its `Down`, so [`GenServer::terminate`] has
|
||||
/// run by the time this returns (it fires from the loop's drop guard on the
|
||||
/// stop unwind). Returns immediately if the server was already gone.
|
||||
/// Stop the server and block until it has fully exited.
|
||||
///
|
||||
/// This is the explicit teardown for a server pinned alive by a registered
|
||||
/// [`GenServerName`] (whose stored sender means dropping every external
|
||||
/// [`GenServerRef`] no longer closes the inbox). Best-effort like all
|
||||
/// cooperative cancellation: a server wedged in a tight loop with no
|
||||
/// observation point cannot be stopped. Panics if called outside
|
||||
/// `Runtime::run()`.
|
||||
/// Sends a cooperative stop signal to the server actor and waits for it to
|
||||
/// exit, so [`GenServer::terminate`] has run by the time this returns.
|
||||
/// Returns immediately if the server is already gone.
|
||||
///
|
||||
/// This is the right teardown for a server kept alive by a registered
|
||||
/// [`GenServerName`], where dropping every external `GenServerRef` is not enough
|
||||
/// to close the inbox. Like all cooperative cancellation, it is best-effort:
|
||||
/// a server wedged in a tight loop with no observation point cannot be
|
||||
/// stopped this way. Panics if called outside `Runtime::run()`.
|
||||
pub fn shutdown(&self) {
|
||||
let mon = monitor(self.pid);
|
||||
request_stop(self.pid);
|
||||
@@ -279,29 +376,28 @@ impl<G: GenServer> GenServerRef<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server loop's internal *system intake* channel (RFC 015 §4.5). Control
|
||||
/// (monitor handoff) and armed-timer fires are structurally the same — both are
|
||||
/// loop-internal sources selected above the inbox — so they fold into one
|
||||
/// channel, dispatched by variant. The arm stays open while any [`Watcher`] or
|
||||
/// [`TimerHandle`] clone (or an in-flight fire thunk) still holds a sender.
|
||||
/// Internal channel carrying control messages to the server loop. Monitors
|
||||
/// and timer fires both need to land above the inbox in priority, so they
|
||||
/// share one channel dispatched by variant. The arm stays open while any
|
||||
/// [`Watcher`] or [`TimerHandle`] clone (or an in-flight timer fire) holds a sender.
|
||||
enum Sys<G: GenServer> {
|
||||
/// A monitor handed in via [`Watcher::watch`]; pushed onto the loop's
|
||||
/// monitor set.
|
||||
Watch(Monitor),
|
||||
/// A fired one-shot timer carrying its loop-local id (so the loop can retire
|
||||
/// it from the live set) and the server's payload, dispatched to
|
||||
/// [`GenServer::handle_timer`].
|
||||
/// A fired one-shot timer: loop-local id (so the loop can retire the
|
||||
/// registry entry) plus the server's payload.
|
||||
Timer(crate::timer::TimerId, G::Timer),
|
||||
/// A fired periodic tick carrying its stable local id. The loop resolves the
|
||||
/// payload from the registry's factory, dispatches it to
|
||||
/// [`GenServer::handle_timer`], and re-arms the next tick (RFC 015 §4.3).
|
||||
/// A fired periodic tick carrying its stable local id. The loop looks up
|
||||
/// the payload factory, dispatches it to [`GenServer::handle_timer`], and
|
||||
/// re-arms the next tick before returning.
|
||||
Tick(crate::timer::TimerId),
|
||||
}
|
||||
|
||||
/// The server loop's runtime hook, passed to [`GenServer::init`]. Hands out the
|
||||
/// loop's two clonable intake handles — the [`Watcher`] (monitors) and the
|
||||
/// [`TimerHandle`] (timers) — plus the one-shot idle-window setter. Opaque, so
|
||||
/// fields can grow without breaking.
|
||||
/// [`TimerHandle`] (timers) — plus the one-shot idle-window setter. All fields
|
||||
/// are private so the struct can gain new hooks in future without breaking
|
||||
/// existing implementations.
|
||||
pub struct GenServerCtx<G: GenServer> {
|
||||
sys_tx: Sender<Sys<G>>,
|
||||
reg: Arc<Mutex<TimerReg<G>>>,
|
||||
@@ -333,27 +429,25 @@ impl<G: GenServer> GenServerCtx<G> {
|
||||
TimerHandle { sys_tx: self.sys_tx.clone(), reg: self.reg.clone() }
|
||||
}
|
||||
|
||||
/// Set the idle / receive-timeout window: if no message of any kind is
|
||||
/// dispatched for `after`, the loop fires [`GenServer::handle_idle`]
|
||||
/// (RFC 015 §4.4). Set once, in `init`; the window is loop-owned and fixed
|
||||
/// (dynamic per-message reconfiguration is a non-goal). The window resets on
|
||||
/// every dispatched message and re-arms after `handle_idle` (a steady idle
|
||||
/// detector); a server wanting one-shot idle-shutdown requests its own stop
|
||||
/// in `handle_idle`.
|
||||
/// Set a quiet-period window: if the loop goes `after` without dispatching
|
||||
/// any message, it calls [`GenServer::handle_idle`] and resets the window.
|
||||
/// Call this once during `init`; the window is fixed for the server's
|
||||
/// lifetime.
|
||||
///
|
||||
/// Distinct from [`GenServerRef::call_timeout`], which is a *client-side* call
|
||||
/// deadline — same word, different axis (§7).
|
||||
/// This is distinct from [`GenServerRef::call_timeout`], which caps how long a
|
||||
/// single caller waits for a reply. This one measures silence on the whole
|
||||
/// inbox.
|
||||
pub fn idle_after(&self, after: Duration) {
|
||||
self.idle.set(Some(after));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-server timer bookkeeping, shared between the loop and every
|
||||
/// [`TimerHandle`] clone. A gen_server actor is single-threaded — handle calls
|
||||
/// (made from inside handlers) and the loop never run concurrently — so this
|
||||
/// `Mutex` is always uncontended; it is `Arc<Mutex>` rather than `Rc<RefCell>`
|
||||
/// only because the handle is stored on `self` and `GenServer: Send` forces the
|
||||
/// handle (hence its shared state) to be `Send`.
|
||||
/// [`TimerHandle`] clone. A gen_server actor is single-threaded — handlers
|
||||
/// and the loop never run concurrently — so this `Mutex` is always
|
||||
/// uncontended at runtime; `Arc<Mutex>` (rather than `Rc<RefCell>`) is used
|
||||
/// only because `GenServer: Send` forces the handle (hence this shared state)
|
||||
/// to be `Send`.
|
||||
struct TimerReg<G: GenServer> {
|
||||
/// Monotonic minter for loop-local [`TimerId`]s — the ids handed to users,
|
||||
/// kept distinct from the substrate `seq` (which changes on every periodic
|
||||
@@ -379,15 +473,14 @@ struct TimerReg<G: GenServer> {
|
||||
|
||||
/// One periodic timer's loop-side bookkeeping.
|
||||
struct Periodic<G: GenServer> {
|
||||
/// Re-arm interval; each fire schedules the next at `now + every` (§4.3).
|
||||
/// Re-arm interval; each fire schedules the next at `now + every`.
|
||||
every: Duration,
|
||||
/// The currently-armed substrate timer for this periodic (changes on every
|
||||
/// The currently-armed underlying timer for this periodic (changes on every
|
||||
/// re-arm); cancelled by [`cancel`](TimerHandle::cancel).
|
||||
live: crate::timer::TimerId,
|
||||
/// Produces a fresh payload for each tick. Built in `tick_every` as
|
||||
/// `move || msg.clone()` — so the `Clone` bound lives there, on the one
|
||||
/// method that needs it, and never leaks onto `type Timer` or the loop
|
||||
/// (which is monomorphised per server and only *calls* this box).
|
||||
/// `move || msg.clone()` — so the `Clone` bound lives on that one method
|
||||
/// and never appears on `type Timer` or anywhere else in the loop.
|
||||
make: Box<dyn FnMut() -> G::Timer + Send>,
|
||||
}
|
||||
|
||||
@@ -413,11 +506,10 @@ impl<G: GenServer> TimerReg<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms, cancels, and (chunk 4) periodically ticks server timers, the time-side
|
||||
/// twin of [`Watcher`] (RFC 015 §4.3). Handed out by [`GenServerCtx::timer`] in
|
||||
/// `init` and stored on the state; later handlers arm timers without any change
|
||||
/// to their signatures. Clonable; the arm stays open while any clone (or an
|
||||
/// in-flight fire) lives.
|
||||
/// Arms and cancels timers for a server loop, the time-side twin of [`Watcher`].
|
||||
/// Handed out by [`GenServerCtx::timer`] in `init`; store it on the state so
|
||||
/// handlers can arm timers without changing their signatures. Clonable; the
|
||||
/// loop's timer arm stays open while any clone (or an in-flight fire) lives.
|
||||
pub struct TimerHandle<G: GenServer> {
|
||||
sys_tx: Sender<Sys<G>>,
|
||||
reg: Arc<Mutex<TimerReg<G>>>,
|
||||
@@ -431,13 +523,15 @@ impl<G: GenServer> Clone for TimerHandle<G> {
|
||||
}
|
||||
|
||||
impl<G: GenServer> TimerHandle<G> {
|
||||
/// Arm a one-shot timer: deliver `msg` to [`GenServer::handle_timer`] after
|
||||
/// `after`, unless [`cancel`](Self::cancel)led first. Returns a loop-local
|
||||
/// [`TimerId`] for cancellation. A thin wrapper over the `send_after`
|
||||
/// substrate that lands the fire on the loop's own system arm (above infos
|
||||
/// and the inbox), not in the inbox.
|
||||
/// Arm a one-shot timer: deliver `msg` to [`GenServer::handle_timer`] once,
|
||||
/// after `after`, unless [`cancel`](Self::cancel)led first. Returns a
|
||||
/// [`TimerId`] you can pass to `cancel`. Timer fires arrive before info and
|
||||
/// inbox messages.
|
||||
pub fn arm_after(&self, after: Duration, msg: G::Timer) -> TimerId {
|
||||
let mut reg = self.reg.lock().unwrap();
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
let local = reg.mint();
|
||||
// The fire thunk carries the local id so the loop can retire the entry
|
||||
// on dispatch; `msg` is moved in (no `Clone` needed for one-shots).
|
||||
@@ -448,20 +542,23 @@ impl<G: GenServer> TimerHandle<G> {
|
||||
|
||||
/// Arm a periodic timer: deliver a fresh `msg` to
|
||||
/// [`GenServer::handle_timer`] every `every`, until
|
||||
/// [`cancel`](Self::cancel)led. Loop-managed sugar over the one-shot
|
||||
/// substrate (RFC 015 §4.3): the substrate stays one-shot; the loop re-arms
|
||||
/// at `now + every` after each fire and exposes one stable [`TimerId`], so a
|
||||
/// single `cancel` stops the re-arm *and* the pending instance.
|
||||
/// [`cancel`](Self::cancel)led. The underlying timer is one-shot; the loop
|
||||
/// re-arms it after each fire to produce the repeating cadence, and exposes
|
||||
/// a single stable [`TimerId`] so one `cancel` stops both the pending
|
||||
/// instance and all future ones.
|
||||
///
|
||||
/// Requires `Self::Timer: Clone` — a periodic re-delivers the same logical
|
||||
/// message each tick, so the loop needs a fresh copy per period. The bound
|
||||
/// sits on this method alone; one-shot [`arm_after`](Self::arm_after) and
|
||||
/// the `type Timer` declaration stay unconstrained.
|
||||
/// Requires `G::Timer: Clone` because the same logical message is cloned
|
||||
/// fresh for each tick. This bound is on this method only; one-shot
|
||||
/// [`arm_after`](Self::arm_after) and the `type Timer` declaration are
|
||||
/// unconstrained.
|
||||
pub fn tick_every(&self, every: Duration, msg: G::Timer) -> TimerId
|
||||
where
|
||||
G::Timer: Clone,
|
||||
{
|
||||
let mut reg = self.reg.lock().unwrap();
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
let local = reg.mint();
|
||||
// A live periodic must keep the system arm open so the next tick can be
|
||||
// re-armed; the first periodic installs the loop's re-arm sender.
|
||||
@@ -473,17 +570,23 @@ impl<G: GenServer> TimerHandle<G> {
|
||||
// from `make` on fire, so the tick carries only the stable id.
|
||||
let sub = send_after_to(every, self.sys_tx.clone(), Sys::Tick(local));
|
||||
reg.periodics.insert(local, Periodic { every, live: sub, make });
|
||||
debug_assert!(reg.rearm_tx.is_some(), "rearm_tx must be Some while periodics is non-empty");
|
||||
local
|
||||
}
|
||||
|
||||
/// Cancel an armed timer (one-shot or periodic). Returns the substrate's
|
||||
/// race signal — `true` if the cancel beat the (pending) fire, `false` if
|
||||
/// it had already fired / been cancelled / is unknown. For a periodic this
|
||||
/// also stops the re-arm: the entry is dropped, so the loop will not
|
||||
/// schedule another tick even if the pending one already escaped onto the
|
||||
/// channel (that in-flight tick is discarded on dispatch).
|
||||
/// race signal — `true` if the cancel beat the fire, `false` if the timer
|
||||
/// had already fired, been cancelled, or is otherwise unknown. Most callers
|
||||
/// can ignore the return value: a fired-but-not-yet-dispatched one-shot is
|
||||
/// discarded by the loop on delivery (see the `Tick` arm in `server_loop`),
|
||||
/// and a cancelled periodic will not re-arm. For a periodic, `cancel` also
|
||||
/// stops re-arming: the entry is removed so the loop will not schedule
|
||||
/// another tick even if the pending one already escaped onto the channel.
|
||||
pub fn cancel(&self, id: TimerId) -> bool {
|
||||
let mut reg = self.reg.lock().unwrap();
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if let Some(sub) = reg.oneshots.remove(&id) {
|
||||
return cancel_timer(sub);
|
||||
}
|
||||
@@ -492,6 +595,10 @@ impl<G: GenServer> TimerHandle<G> {
|
||||
if reg.periodics.is_empty() {
|
||||
reg.rearm_tx = None;
|
||||
}
|
||||
debug_assert!(
|
||||
reg.rearm_tx.is_some() != reg.periodics.is_empty(),
|
||||
"rearm_tx must be Some iff periodics is non-empty"
|
||||
);
|
||||
return beat;
|
||||
}
|
||||
false
|
||||
@@ -573,10 +680,10 @@ impl<G: GenServer> GenServerBuilder<G> {
|
||||
NamedGenServerBuilder { builder: self, name: name.as_str() }
|
||||
}
|
||||
|
||||
/// The shared spawn body behind [`start`](Self::start) and
|
||||
/// [`NamedGenServerBuilder::start`]: make the inbox, spawn the loop, return the
|
||||
/// ref. (The named path additionally publishes the inbox sender under the
|
||||
/// name before returning.)
|
||||
/// Private shared body behind [`start`](Self::start) and
|
||||
/// [`NamedGenServerBuilder::start`]: allocate the inbox, spawn the loop,
|
||||
/// return the ref. The named path additionally publishes the inbox sender
|
||||
/// under the name before returning.
|
||||
fn spawn_server(self) -> GenServerRef<G> {
|
||||
let (tx, rx) = channel::<Envelope<G>>();
|
||||
let GenServerBuilder { state, infos, supervisor } = self;
|
||||
@@ -588,19 +695,20 @@ impl<G: GenServer> GenServerBuilder<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable name typed by the *server* (RFC 014): a gen_server is multi-message
|
||||
/// (call / cast over one inbox), so it is addressed by `G` rather than by a
|
||||
/// single message type. By-name [`call`] / [`cast`] check the request against
|
||||
/// `G`'s `Call` / `Cast` / `Reply`. Declared as a constant and shared freely:
|
||||
/// A typed, static name for a gen_server, used to address it through the
|
||||
/// registry without holding a [`GenServerRef`]. Because a gen_server handles
|
||||
/// multiple message types, the name is typed by the whole server (`G`) rather
|
||||
/// than by a single message type. Declare it as a constant:
|
||||
///
|
||||
/// ```ignore
|
||||
/// const COUNTER: GenServerName<Counter> = GenServerName::new("counter");
|
||||
/// ```
|
||||
///
|
||||
/// Under the hood the server's inbox is published into the registry as a
|
||||
/// `Sender<Envelope<G>>` keyed by its message `TypeId` — the same channel store
|
||||
/// every other name uses — so naming needs no separate directory. `Envelope`
|
||||
/// stays private: only `GenServerName<G>` opens that door.
|
||||
/// Use [`GenServerBuilder::named`] to bind the name on start, and the free
|
||||
/// functions [`call`], [`cast`], and [`whereis_server`] to address the server
|
||||
/// by name. Under the hood the server's inbox sender is stored in the registry
|
||||
/// keyed by name and message `TypeId`; `Envelope` is private, so only
|
||||
/// `GenServerName<G>` can open that slot.
|
||||
pub struct GenServerName<G> {
|
||||
name: &'static str,
|
||||
_marker: PhantomData<fn() -> G>,
|
||||
@@ -732,17 +840,25 @@ fn server_loop<G: GenServer>(
|
||||
state: G,
|
||||
mut infos: Vec<Receiver<G::Info>>,
|
||||
) {
|
||||
// terminate() must run on every exit path (clean close, panic, stop), so it
|
||||
// lives in this guard's Drop rather than after the loop. The guard also owns
|
||||
// a registry handle so the *same* drop drains every live timer (RFC 015
|
||||
// §4.7): once the loop is gone no periodic can re-arm anyway, but cancelling
|
||||
// here keeps no armed substrate entry lingering past the server, and the
|
||||
// assert pins the invariant.
|
||||
// Drop guard — owns the server state and the timer registry.
|
||||
//
|
||||
// Why a guard rather than code after the loop:
|
||||
// - `terminate()` must fire on *every* exit path: clean inbox close,
|
||||
// a handler panic, and cooperative `request_stop`. A guard's `Drop`
|
||||
// covers all three; code after the loop only covers the clean path.
|
||||
// - The timer drain must run *before* `terminate()`: terminate may
|
||||
// inspect state but must not arm new timers into a dead loop. By
|
||||
// owning the registry here the drain and the terminate call are
|
||||
// sequenced correctly and can never be reordered.
|
||||
// - A debug_assert pins the post-drain invariant cheaply.
|
||||
struct Terminate<G: GenServer>(G, Arc<Mutex<TimerReg<G>>>);
|
||||
impl<G: GenServer> Drop for Terminate<G> {
|
||||
fn drop(&mut self) {
|
||||
{
|
||||
let mut reg = self.1.lock().unwrap();
|
||||
let mut reg = match self.1.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
for (_, sub) in reg.oneshots.drain() {
|
||||
cancel_timer(sub);
|
||||
}
|
||||
@@ -761,6 +877,9 @@ fn server_loop<G: GenServer>(
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap an envelope and hand it to the right handler. For calls, a failed
|
||||
// reply send means the caller went away (e.g. cancelled while parked) —
|
||||
// that is the caller's problem, not the server's.
|
||||
fn dispatch<G: GenServer>(state: &mut G, env: Envelope<G>) {
|
||||
match env {
|
||||
Envelope::Call(request, reply_tx) => {
|
||||
@@ -807,8 +926,9 @@ fn server_loop<G: GenServer>(
|
||||
|
||||
loop {
|
||||
if monitors.is_empty() && !sys_open && infos.is_empty() {
|
||||
// Nothing to select over: park on the inbox alone (the pre-v0.8
|
||||
// loop), with the idle window as the recv timeout when one is set.
|
||||
// Fast path: no extra arms, no select overhead — park directly on
|
||||
// the inbox. Mirrors the inbox arm of the select path below; any
|
||||
// change there must be applied here too.
|
||||
match idle_deadline {
|
||||
Some(dl) => {
|
||||
let wait = dl.saturating_duration_since(Instant::now());
|
||||
@@ -832,12 +952,15 @@ fn server_loop<G: GenServer>(
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Arm priority: downs, then the system arm, then infos (each in
|
||||
// declaration order), then the inbox. The arm slice is rebuilt
|
||||
// per iteration because every set but the inbox shrinks or grows.
|
||||
// The whole wait carries the idle window as its timeout.
|
||||
let nd = monitors.len();
|
||||
let nw = sys_open as usize;
|
||||
// Slow path: one or more extra arms live — build the arm slice and
|
||||
// select. Arm order encodes priority: downs → system → infos →
|
||||
// inbox. The slice is rebuilt each iteration because the monitor
|
||||
// and info sets shrink/grow. Mirrors the fast-path inbox park
|
||||
// above; keep them in sync.
|
||||
let nd = monitors.len(); // monitor band: [0, nd)
|
||||
let nw = sys_open as usize; // system arm: [nd, nd+nw)
|
||||
// info band: [nd+nw, nd+nw+ni)
|
||||
// inbox arm: [nd+nw+ni]
|
||||
let sel = {
|
||||
let mut arms: Vec<&dyn Selectable> =
|
||||
Vec::with_capacity(nd + nw + infos.len() + 1);
|
||||
@@ -869,8 +992,8 @@ fn server_loop<G: GenServer>(
|
||||
}
|
||||
};
|
||||
if i < nd {
|
||||
// A Down retires its arm either way: delivered (one-shot) or
|
||||
// closed without delivering (defensive; shouldn't happen).
|
||||
// Monitor band: a Down retires its arm either way (one-shot)
|
||||
// or closes without delivering (defensive; shouldn't happen).
|
||||
let m = monitors.remove(i);
|
||||
if let Ok(Some(down)) = m.rx.try_recv() {
|
||||
guard.0.handle_down(down);
|
||||
@@ -884,7 +1007,10 @@ fn server_loop<G: GenServer>(
|
||||
// The one-shot fired: retire its registry entry so the
|
||||
// live set tracks only still-pending timers, then
|
||||
// dispatch.
|
||||
reg.lock().unwrap().oneshots.remove(&id);
|
||||
match reg.lock() {
|
||||
Ok(mut g) => { g.oneshots.remove(&id); }
|
||||
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
guard.0.handle_timer(msg);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
@@ -895,16 +1021,20 @@ fn server_loop<G: GenServer>(
|
||||
// so the period is measured from fire-handling and a
|
||||
// handler that cancels stops the just-armed instance.
|
||||
let msg = {
|
||||
let mut g = reg.lock().unwrap();
|
||||
let mut g = match reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_server reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
let r = &mut *g;
|
||||
if let Some(p) = r.periodics.get_mut(&id) {
|
||||
let every = p.every;
|
||||
let msg = (p.make)();
|
||||
let tx = r
|
||||
.rearm_tx
|
||||
.as_ref()
|
||||
.expect("a live periodic keeps rearm_tx Some")
|
||||
.clone();
|
||||
let tx = match r.rearm_tx.as_ref() {
|
||||
Some(tx) => tx.clone(),
|
||||
None => panic!(
|
||||
"smarm: live periodic without rearm_tx (logic bug)"
|
||||
),
|
||||
};
|
||||
p.live = send_after_to(every, tx, Sys::Tick(id));
|
||||
Some(msg)
|
||||
} else {
|
||||
@@ -924,6 +1054,7 @@ fn server_loop<G: GenServer>(
|
||||
Err(_) => sys_open = false,
|
||||
}
|
||||
} else if i < nd + nw + infos.len() {
|
||||
// Info band.
|
||||
let j = i - nd - nw;
|
||||
match infos[j].try_recv() {
|
||||
Ok(Some(info)) => {
|
||||
@@ -938,6 +1069,7 @@ fn server_loop<G: GenServer>(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Inbox arm (mirrors the fast-path park above).
|
||||
match rx.try_recv() {
|
||||
Ok(Some(env)) => {
|
||||
dispatch(&mut guard.0, env);
|
||||
|
||||
+607
-107
@@ -14,13 +14,13 @@
|
||||
//! [`on_start`](Machine::on_start), then one [`handle`](Machine::handle) per
|
||||
//! inbox event — mirroring `gen_server`'s spawn/teardown idioms.
|
||||
//!
|
||||
//! The [`gen_statem!`](crate::gen_statem) macro is the authoring surface: it
|
||||
//! The [`gen_statem!`](crate::gen_statem!) macro is the authoring surface: it
|
||||
//! *generates* a `Machine` impl from per-state handler blocks. Edge-validity is
|
||||
//! not a separate check — it falls out of the generated total `match (state,
|
||||
//! event)` under denied lints (a forgotten or duplicated pair is a compile
|
||||
//! error), so no proc-macro is needed. See `examples/gen_statem_macro.rs` for
|
||||
//! the macro form and `examples/gen_statem_fused.rs` for the hand-written shape
|
||||
//! it expands to.
|
||||
//! the macro form and `examples/gen_statem_expanded.rs` for the hand-written
|
||||
//! shape it expands to.
|
||||
//!
|
||||
//! ## The unified event
|
||||
//!
|
||||
@@ -38,19 +38,51 @@
|
||||
//! `gen_server` call round-trip, but with the reply handle riding *inside* the
|
||||
//! user's own event so a handler can answer it.
|
||||
//!
|
||||
//! [`Resolution::Postpone`] and the timeout arming on [`Cx`] are part of the
|
||||
//! type surface but are not yet wired up.
|
||||
//! ## Timeouts
|
||||
//!
|
||||
//! Two timeout flavours, both armed from a handler through [`Cx`] and both
|
||||
//! surfacing back as ordinary **events** the machine matches in its `on State`
|
||||
//! arms — unlike `gen_server`, which routes fires to a separate handler:
|
||||
//!
|
||||
//! - [`cx.state_timeout(d)`](Cx::state_timeout) fires a `state_timeout` event
|
||||
//! after `d` *in the current state*, and is auto-reset on any state change.
|
||||
//! Only one is ever pending; arming again replaces it.
|
||||
//! - [`cx.timeout(name, d)`](Cx::timeout) fires a `timeout(name)` event after
|
||||
//! `d`, **survives** state changes, and is keyed by `name` so several can be
|
||||
//! in flight and each is independently [`cancel_timeout`](Cx::cancel_timeout)led.
|
||||
//!
|
||||
//! Both ride the same timer min-heap as `gen_server` (no separate timer
|
||||
//! mechanism): a fire lands on the loop's own system channel — above the inbox,
|
||||
//! so a timeout can't be starved by inbox traffic — and the loop turns it into
|
||||
//! the corresponding internal event and runs it through the normal `handle`
|
||||
//! dispatch.
|
||||
//!
|
||||
//! ## Postpone
|
||||
//!
|
||||
//! A `=> postpone` row defers the current event untouched, to be replayed after
|
||||
//! the next **real transition**. The deferred event — a `cast`, `call`, or
|
||||
//! `info` — moves whole onto a loop-side FIFO queue; a postponed `call` keeps
|
||||
//! its [`Reply`] handle and is answered by whichever later state handles the
|
||||
//! replay. On a transition the queue drains in order through the normal
|
||||
//! [`handle`](Machine::handle) dispatch in the new state, ahead of any further
|
||||
//! inbox or timer event; a replayed event may postpone again (it re-queues for
|
||||
//! the next transition). See the macro docs for the row surface and [`Step`]
|
||||
//! for how a postpone surfaces to the loop.
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::channel::{channel, select, Receiver, Sender};
|
||||
use crate::pid::Pid;
|
||||
use crate::scheduler::spawn as spawn_actor;
|
||||
use crate::scheduler::{cancel_timer, send_after_to, spawn as spawn_actor};
|
||||
use crate::timer::TimerId;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A finite state machine driven by the [`statem`](crate::gen_statem) loop.
|
||||
/// A finite state machine driven by the [`statem`](mod@crate::gen_statem) loop.
|
||||
///
|
||||
/// The implementor owns its current state tag and its persistent data. It is
|
||||
/// the **sole writer** of the state cell (the loop never touches it): both
|
||||
@@ -59,28 +91,45 @@ use std::marker::PhantomData;
|
||||
/// [`handle`](Self::handle) per event.
|
||||
pub trait Machine: Send + 'static {
|
||||
/// The single payload this machine's inbox carries — the user's `cast` and
|
||||
/// `call` enums folded together with the runtime's internal events. See the
|
||||
/// module docs.
|
||||
/// `call` enums folded together with the runtime's internal events
|
||||
/// (`state_timeout`, `timeout(name)`, and an `Info` wrapper). See the module
|
||||
/// docs.
|
||||
type Ev: Send + 'static;
|
||||
|
||||
/// Wrap a fired state-timeout into this machine's event. The loop calls this
|
||||
/// when the pending state-timeout fires, then runs the result through
|
||||
/// [`handle`](Self::handle) like any other event. The macro generates it as
|
||||
/// `Ev::StateTimeout`.
|
||||
fn state_timeout_ev() -> Self::Ev;
|
||||
|
||||
/// Wrap a fired named timeout into this machine's event, carrying the name
|
||||
/// it was armed under. The macro generates it as `Ev::Timeout(name)`.
|
||||
fn timeout_ev(name: &'static str) -> Self::Ev;
|
||||
|
||||
/// Runs once inside the actor before the first event. The canonical body
|
||||
/// runs the `enter` arm for the initial state.
|
||||
fn on_start(&mut self, cx: &mut Cx<Self::Ev>);
|
||||
|
||||
/// React to one event. The body matches `(state, event)`, performs side
|
||||
/// effects / replies, and ends each arm in a [`Resolution`] — typically a
|
||||
/// state tag via `Tag.into()` (transition, or "stay" when it equals the
|
||||
/// current tag). On a transition the body sets the state cell and runs the
|
||||
/// new state's `enter`.
|
||||
fn handle(&mut self, ev: Self::Ev, cx: &mut Cx<Self::Ev>);
|
||||
/// React to one event, returning a [`Step`] the loop acts on. The body
|
||||
/// first routes a `postpone` row (handing the event back untouched as
|
||||
/// [`Step::Postponed`]); otherwise it matches `(state, event)`, performs
|
||||
/// side effects / replies, and ends each arm in a [`Resolution`] — typically
|
||||
/// a state tag via `Tag.into()` (transition, or "stay" when it equals the
|
||||
/// current tag). On a transition the body sets the state cell, runs the new
|
||||
/// state's `enter`, and returns [`Step::Transitioned`]; a stay or unmatched
|
||||
/// event returns [`Step::Stayed`].
|
||||
fn handle(&mut self, ev: Self::Ev, cx: &mut Cx<Self::Ev>) -> Step<Self::Ev>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The outcome of handling one event, before the loop/handler acts on it:
|
||||
/// dispatch reduces to `(state, event) -> Resolution<State>`.
|
||||
/// The outcome of the **consuming** dispatch — the by-value `match (state,
|
||||
/// event)` — before the apply-tail acts on it: `(state, event) ->
|
||||
/// Resolution<State>`. Postpone is *not* here: a deferred event never reaches
|
||||
/// this match (it is routed out first; see [`Step`] and the macro's two-phase
|
||||
/// `handle`), so the only outcomes are a target state or "unhandled".
|
||||
///
|
||||
/// [`From<S>`](From) is why a bare state tag works as an arm tail and why
|
||||
/// "stay" needs no keyword — `Tag.into()` is `To(Tag)`, and the handler treats
|
||||
@@ -89,10 +138,6 @@ pub enum Resolution<S> {
|
||||
/// End in state `s`. A **transition** when `s != current` (set the cell,
|
||||
/// run `enter`); a **stay** when `s == current` (no `enter`).
|
||||
To(S),
|
||||
/// Defer the current event onto the postpone queue, to be replayed after
|
||||
/// the next real transition. Produced by `cx.postpone()`; present
|
||||
/// here for forward-compatibility but not yet generated.
|
||||
Postpone,
|
||||
/// No arm matched `(state, event)`: log-and-drop via
|
||||
/// [`Cx::on_unhandled`], mirroring `gen_server`'s handling of unexpected
|
||||
/// messages.
|
||||
@@ -105,31 +150,187 @@ impl<S> From<S> for Resolution<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one [`handle`](Machine::handle) call did, as the loop needs to see it.
|
||||
/// Unlike [`Resolution`] (internal to the consuming match), this is the
|
||||
/// loop-visible result, because the loop must know two things the match alone
|
||||
/// doesn't surface: whether an event was **deferred** (so the loop owns it for
|
||||
/// the postpone queue) and whether a **real transition** happened (so the loop
|
||||
/// replays that queue). The three cases are mutually exclusive.
|
||||
pub enum Step<Ev> {
|
||||
/// The event was deferred by a `postpone` row and handed back untouched.
|
||||
/// The loop pushes it onto the postpone queue; no state change occurred.
|
||||
Postponed(Ev),
|
||||
/// A real transition happened (`enter` for the new state has already run).
|
||||
/// The loop replays the postpone queue in the new state.
|
||||
Transitioned,
|
||||
/// Handled with no transition — a stay or an unmatched event. Nothing is
|
||||
/// deferred and the postpone queue is left as-is.
|
||||
Stayed,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cx — the per-handler context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sys — the loop's internal timer-fire channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A timer fire landing on the loop's own system channel, selected above the
|
||||
/// inbox so a timeout cannot be starved by inbox traffic. Each fire carries the
|
||||
/// local id it was armed under so the loop can confirm it is still the live one
|
||||
/// (a fire that a reset/cancel beat onto the channel is discarded).
|
||||
enum Sys {
|
||||
/// The state-timeout fired, carrying the local id it was armed under so the
|
||||
/// loop can drop a stale fire (one a reset/cancel beat onto the channel).
|
||||
StateTimeout(u64),
|
||||
/// A named timeout fired, carrying its name and the local id it was armed
|
||||
/// under.
|
||||
Timeout(&'static str, u64),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timers — shared timer bookkeeping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-machine timer bookkeeping, shared between the loop and every [`Cx`]
|
||||
/// borrow (a machine is single-threaded — handler calls and the loop never run
|
||||
/// concurrently — so this `Mutex` is always uncontended; it is `Arc<Mutex>`
|
||||
/// rather than `Rc<RefCell>` only because `Machine: Send` forces it).
|
||||
///
|
||||
/// Each arming mints a fresh **local id** carried in the fire payload and
|
||||
/// recorded here alongside the substrate id used to cancel. On fire the loop
|
||||
/// compares the payload's local id against the recorded one: a fire whose id no
|
||||
/// longer matches — a reset/cancel armed a newer one or removed the entry after
|
||||
/// this fire already escaped onto the channel — is stale and dropped. The local
|
||||
/// id is minted up front (unlike the substrate id, which only exists once armed),
|
||||
/// so it can ride the payload with no chicken-and-egg.
|
||||
struct Timers {
|
||||
/// Monotonic minter for local ids, kept distinct from substrate ids (the
|
||||
/// latter are what we hand to [`cancel_timer`]).
|
||||
next_local: u64,
|
||||
/// The currently-armed state-timeout's `(local id, substrate id)`. Cancelled
|
||||
/// and cleared on every real state change (auto-reset), replaced on re-arm.
|
||||
state: Option<(u64, TimerId)>,
|
||||
/// Live named timeouts: name → `(local id, substrate id)`. Survives state
|
||||
/// changes; an entry lives until it fires or is cancelled.
|
||||
named: HashMap<&'static str, (u64, TimerId)>,
|
||||
}
|
||||
|
||||
impl Timers {
|
||||
fn new() -> Self {
|
||||
Timers { next_local: 0, state: None, named: HashMap::new() }
|
||||
}
|
||||
|
||||
fn mint(&mut self) -> u64 {
|
||||
let id = self.next_local;
|
||||
self.next_local = self.next_local.wrapping_add(1);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cx — the per-handler context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The context handle injected into [`Machine::on_start`] and
|
||||
/// [`Machine::handle`]. Non-state outcomes (postpone, timeout arming) live here
|
||||
/// as method calls rather than keywords.
|
||||
/// [`Machine::handle`]. Non-state outcomes — timeout arming, and later
|
||||
/// postpone — live here as method calls rather than keywords. It lives only on
|
||||
/// the actor's own stack and is never sent.
|
||||
///
|
||||
/// For now it carries only the [`on_unhandled`](Self::on_unhandled) hook;
|
||||
/// `cx.state_timeout(d)` / `cx.timeout(name, d)` and `cx.postpone()` will attach
|
||||
/// here when implemented. It lives only on the actor's own
|
||||
/// stack and is never sent.
|
||||
/// Timeouts armed here land on the loop's system channel through `sys_tx` and
|
||||
/// are tracked in the shared `reg` so reset/cancel can find the pending one.
|
||||
pub struct Cx<Ev> {
|
||||
sys_tx: Sender<Sys>,
|
||||
reg: Arc<Mutex<Timers>>,
|
||||
_ev: PhantomData<fn() -> Ev>,
|
||||
}
|
||||
|
||||
impl<Ev> Cx<Ev> {
|
||||
fn new() -> Self {
|
||||
Cx { _ev: PhantomData }
|
||||
fn new(sys_tx: Sender<Sys>, reg: Arc<Mutex<Timers>>) -> Self {
|
||||
Cx { sys_tx, reg, _ev: PhantomData }
|
||||
}
|
||||
|
||||
/// The default for an event no arm matched: **log-and-drop**. Overridable
|
||||
/// hook wiring is a follow-on; for now an unmatched event is
|
||||
/// silently dropped, as `gen_server` does with unexpected messages.
|
||||
/// Arm the **state timeout**: fire a `state_timeout` event after `after` in
|
||||
/// the current state. Auto-reset on any state change (the loop cancels and
|
||||
/// clears it on every real transition), so it measures quiet time *within* a
|
||||
/// state. Only one is ever pending — arming again cancels and replaces the
|
||||
/// previous one.
|
||||
pub fn state_timeout(&mut self, after: Duration) {
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if let Some((_, old_sub)) = reg.state.take() {
|
||||
cancel_timer(old_sub);
|
||||
}
|
||||
let local = reg.mint();
|
||||
let sub = send_after_to(after, self.sys_tx.clone(), Sys::StateTimeout(local));
|
||||
reg.state = Some((local, sub));
|
||||
}
|
||||
|
||||
/// Arm a **named generic timeout**: fire a `timeout(name)` event after
|
||||
/// `after`. Survives state changes; several may be in flight keyed by
|
||||
/// `name`. Arming the same `name` again cancels and replaces the pending one
|
||||
/// for that name.
|
||||
pub fn timeout(&mut self, name: &'static str, after: Duration) {
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if let Some((_, old_sub)) = reg.named.remove(name) {
|
||||
cancel_timer(old_sub);
|
||||
}
|
||||
let local = reg.mint();
|
||||
let sub = send_after_to(after, self.sys_tx.clone(), Sys::Timeout(name, local));
|
||||
reg.named.insert(name, (local, sub));
|
||||
}
|
||||
|
||||
/// Cancel the named timeout `name` if pending. Returns `true` if the cancel
|
||||
/// beat the fire, `false` if it had already fired / was never armed.
|
||||
pub fn cancel_timeout(&mut self, name: &'static str) -> bool {
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
match reg.named.remove(name) {
|
||||
Some((_, sub)) => cancel_timer(sub),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the pending state-timeout if any. Returns `true` if the cancel
|
||||
/// beat the fire. Rarely needed by hand (the loop auto-resets on
|
||||
/// transition); exposed for a handler that wants to disarm within a state.
|
||||
pub fn cancel_state_timeout(&mut self) -> bool {
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
match reg.state.take() {
|
||||
Some((_, sub)) => cancel_timer(sub),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The default for an event no arm matched: **log-and-drop**, as
|
||||
/// `gen_server` does with unexpected messages.
|
||||
pub fn on_unhandled(&mut self) {}
|
||||
|
||||
/// Cancel and clear the pending state-timeout. Called by the macro on every
|
||||
/// real transition (the state-timeout is auto-reset across state changes);
|
||||
/// not part of the authoring surface. A handler re-arms in the new state's
|
||||
/// `enter` if it wants one.
|
||||
#[doc(hidden)]
|
||||
pub fn __reset_state_timeout(&mut self) {
|
||||
let mut reg = match self.reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if let Some((_, sub)) = reg.state.take() {
|
||||
cancel_timer(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,9 +344,8 @@ impl<Ev> Cx<Ev> {
|
||||
/// machine dies, closing the channel).
|
||||
///
|
||||
/// Carrying the handle in the event — rather than the loop owning a reply slot
|
||||
/// — is what lets a later chunk **postpone a call**: the whole event, reply
|
||||
/// handle included, moves onto the postpone queue and is answered by a later
|
||||
/// state.
|
||||
/// — is what will let a **postponed call** work: the whole event, reply handle
|
||||
/// included, moves onto the postpone queue and is answered by a later state.
|
||||
pub struct Reply<T> {
|
||||
tx: Sender<T>,
|
||||
}
|
||||
@@ -211,9 +411,7 @@ impl<M: Machine> GenStatemRef<M> {
|
||||
/// stack unwinds, closing the channel and waking the caller).
|
||||
///
|
||||
/// `make` wraps the [`Reply`] all the way to `M::Ev` — typically
|
||||
/// `|r| Ev::Call(MyCall::GetCount(r))`. (The deferred macro would generate a
|
||||
/// thinner `call` that hides the `Ev::Call` wrap and takes the bare variant
|
||||
/// constructor.)
|
||||
/// `|r| Ev::Call(MyCall::GetCount(r))`.
|
||||
pub fn call<T, F>(&self, make: F) -> Result<T, CallError>
|
||||
where
|
||||
T: Send + 'static,
|
||||
@@ -241,18 +439,84 @@ pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
|
||||
GenStatemRef { tx, pid: handle.pid() }
|
||||
}
|
||||
|
||||
/// The machine actor body: `on_start`, then one `handle` per inbox event until
|
||||
/// the inbox closes (all refs dropped → graceful shutdown). Chunk 1 parks on
|
||||
/// the inbox alone — no system arm, no timers — the analogue of `gen_server`'s
|
||||
/// plain-inbox park.
|
||||
/// The machine actor body: `on_start`, then one `handle` per event until the
|
||||
/// inbox closes (all refs dropped → graceful shutdown).
|
||||
///
|
||||
/// Two intake sources are selected each iteration with the **timer arm above
|
||||
/// the inbox**, so a timeout fire is never starved by inbox traffic: `sys_rx`
|
||||
/// carries timer fires armed through `cx`, `rx` is the user inbox. A fire is
|
||||
/// turned into the matching internal event (`state_timeout` / `timeout(name)`)
|
||||
/// and run through the same `handle` dispatch as an inbox event — the
|
||||
/// gen_statem model, where timeouts surface as ordinary events.
|
||||
///
|
||||
/// The loop owns the **postpone queue**: a `handle` that defers its event hands
|
||||
/// it back ([`Step::Postponed`]) for the queue; a `handle` that transitions
|
||||
/// ([`Step::Transitioned`]) triggers a [`replay`] of the queue in the new
|
||||
/// state, ahead of the next intake.
|
||||
fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
let mut cx = Cx::new();
|
||||
let (sys_tx, sys_rx) = channel::<Sys>();
|
||||
let reg = Arc::new(Mutex::new(Timers::new()));
|
||||
// The loop owns `cx` (and through it a `sys_tx` clone) for its whole life,
|
||||
// so the sys arm never closes from under us — no auto-close dance needed.
|
||||
let mut cx = Cx::new(sys_tx, reg.clone());
|
||||
// Events deferred by `postpone` rows, replayed FIFO on the next transition.
|
||||
let mut postpone: VecDeque<M::Ev> = VecDeque::new();
|
||||
machine.on_start(&mut cx);
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(ev) => machine.handle(ev, &mut cx),
|
||||
// All StatemRefs dropped → inbox closed → shutdown.
|
||||
Err(_) => break,
|
||||
// Timer arm first: a ready fire is taken in preference to the inbox.
|
||||
let i = select(&[&sys_rx, &rx]);
|
||||
if i == 0 {
|
||||
match sys_rx.try_recv() {
|
||||
Ok(Some(fire)) => {
|
||||
// Confirm the fire is still the live one before dispatching:
|
||||
// a reset/cancel may have replaced/removed it after it
|
||||
// escaped onto the channel. A stale fire is dropped.
|
||||
let ev = match fire {
|
||||
Sys::StateTimeout(local) => {
|
||||
let mut t = match reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
match t.state {
|
||||
Some((live, _)) if live == local => {
|
||||
t.state = None; // retire: it has now fired
|
||||
Some(M::state_timeout_ev())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Sys::Timeout(name, local) => {
|
||||
let mut t = match reg.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: gen_statem reg lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
match t.named.get(name) {
|
||||
Some(&(live, _)) if live == local => {
|
||||
t.named.remove(name); // retire on fire
|
||||
Some(M::timeout_ev(name))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
dispatch(&mut machine, &mut cx, &mut postpone, ev);
|
||||
}
|
||||
}
|
||||
// Single-receiver: nothing can drain the arm between select's
|
||||
// ready and our try_recv.
|
||||
Ok(None) => debug_assert!(false, "ready system arm was empty"),
|
||||
// The loop holds a sys_tx for its whole life, so this is
|
||||
// unreachable; fall through defensively.
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
match rx.try_recv() {
|
||||
Ok(Some(ev)) => dispatch(&mut machine, &mut cx, &mut postpone, ev),
|
||||
Ok(None) => debug_assert!(false, "ready inbox was empty"),
|
||||
// All GenStatemRefs dropped → inbox closed → shutdown.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
// Observation point so a machine fed a hot inbox stays preemptible and
|
||||
// cancellable.
|
||||
@@ -260,15 +524,60 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one event through `handle` and act on its [`Step`]: stash a deferred
|
||||
/// event on the postpone queue, or — on a real transition — [`replay`] the
|
||||
/// queue in the new state. A stay/unmatched event needs nothing further.
|
||||
fn dispatch<M: Machine>(
|
||||
machine: &mut M,
|
||||
cx: &mut Cx<M::Ev>,
|
||||
postpone: &mut VecDeque<M::Ev>,
|
||||
ev: M::Ev,
|
||||
) {
|
||||
match machine.handle(ev, cx) {
|
||||
Step::Postponed(ev) => postpone.push_back(ev),
|
||||
Step::Stayed => {}
|
||||
Step::Transitioned => replay(machine, cx, postpone),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay deferred events after a real transition: each goes back through
|
||||
/// `handle` in FIFO order, in the now-current state. An event that postpones
|
||||
/// again re-queues (to wait for the *next* transition); one that transitions
|
||||
/// re-arms the replay, so a later state can in turn drain what is still pending.
|
||||
/// Subsequent events in a batch already see the post-transition state, since
|
||||
/// `handle` reads the live state cell — the outer loop only re-runs to give
|
||||
/// re-queued events another pass once a transition has occurred within a batch.
|
||||
fn replay<M: Machine>(machine: &mut M, cx: &mut Cx<M::Ev>, postpone: &mut VecDeque<M::Ev>) {
|
||||
loop {
|
||||
if postpone.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Take the current backlog; anything deferred during this pass lands in
|
||||
// the now-empty queue and is only retried if this pass also transitioned.
|
||||
let batch = std::mem::take(postpone);
|
||||
let mut transitioned = false;
|
||||
for ev in batch {
|
||||
match machine.handle(ev, cx) {
|
||||
Step::Postponed(ev) => postpone.push_back(ev),
|
||||
Step::Stayed => {}
|
||||
Step::Transitioned => transitioned = true,
|
||||
}
|
||||
}
|
||||
if !transitioned {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// gen_statem! — the authoring macro (fused total-match variant)
|
||||
// gen_statem! — the authoring macro (total-match dispatch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Assemble a complete [`Machine`] from hand-written types, a glanceable
|
||||
/// transition table, and free handler functions.
|
||||
///
|
||||
/// This is the **fused** authoring surface (see `examples/statem_fused.rs` for
|
||||
/// the same machine written out by hand). You keep ownership of every type that
|
||||
/// This is the authoring surface (see `examples/gen_statem_expanded.rs` for the
|
||||
/// same machine written out by hand). You keep ownership of every type that
|
||||
/// carries meaning — the state enum, the data struct, the `cast`/`call` enums,
|
||||
/// and any per-row successor enums — and the macro emits only the mechanical
|
||||
/// scaffolding: the unified event enum, the machine struct and its private
|
||||
@@ -308,12 +617,12 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
/// machine lives in the same crate as this macro, and is **silently dropped
|
||||
/// when you call `gen_statem!` from a downstream crate** — neither a crate
|
||||
/// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one
|
||||
/// guarantee a `macro_rules!` cannot carry across the crate boundary; the
|
||||
/// RFC's proc-macro could (it would stamp the arms with call-site spans).
|
||||
/// guarantee a `macro_rules!` cannot carry across the crate boundary; a
|
||||
/// proc-macro could (it would stamp the arms with call-site spans).
|
||||
/// Guard against it in review, or with an in-crate test of the machine.
|
||||
///
|
||||
/// There is deliberately **no separate `transitions { … }` adjacency block**:
|
||||
/// in this variant the `match` *is* the table and the successor enums *are* the
|
||||
/// the `match` *is* the table and the successor enums *are* the
|
||||
/// per-state target sets, so a second listing would be redundant and
|
||||
/// un-cross-checkable without a proc-macro. The graph is read off the `on`
|
||||
/// blocks and the successor enums at the top of the file.
|
||||
@@ -323,14 +632,16 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
/// ```ignore
|
||||
/// // ── your types (the macro never generates or inspects these) ───────────
|
||||
/// #[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
/// enum Switch { Off, On }
|
||||
/// struct Counts { flips: u32, enters: u32 }
|
||||
/// enum SwitchCast { Flip }
|
||||
/// enum SwitchCall { GetCount(Reply<u32>) }
|
||||
/// enum Door { Open, Closed, Locked }
|
||||
/// struct Data { enters: u32 }
|
||||
/// enum DoorCast { Push, Pull, Lock, Unlock(u32) }
|
||||
/// enum DoorCall { GetState(Reply<Door>) }
|
||||
///
|
||||
/// gen_statem! {
|
||||
/// machine: SwitchSm { state: Switch, data: Counts };
|
||||
/// event: Ev { cast: SwitchCast, call: SwitchCall };
|
||||
/// machine: DoorSm { state: Door, data: Data };
|
||||
/// // The event clause folds three user enums together. `info` is for
|
||||
/// // out-of-band messages; use `()` if you have none.
|
||||
/// event: Ev { cast: DoorCast, call: DoorCall, info: () };
|
||||
///
|
||||
/// // Name the bindings your handler bodies use. A declarative macro can't
|
||||
/// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers
|
||||
@@ -339,26 +650,51 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
/// context(data, prev, cx);
|
||||
///
|
||||
/// // `enter` runs on entry to a state; side effects only, returns ().
|
||||
/// // Match the state tag (or `_`); the arm body is statements.
|
||||
/// // Match the state tag (or `_`); the arm body is statements. Arm any
|
||||
/// // state-timeout here (it is auto-reset on the way into a new state).
|
||||
/// enter {
|
||||
/// Door::Open => {
|
||||
/// data.enters += 1;
|
||||
/// cx.state_timeout(std::time::Duration::from_secs(30)); // auto-close
|
||||
/// }
|
||||
/// _ => data.enters += 1,
|
||||
/// }
|
||||
///
|
||||
/// // The transition table. Group rows by current state with `on <pat>`.
|
||||
/// // A row is: cast|call <event-pattern> [if <guard>] => <tail> ,
|
||||
/// // A row is: <kind> <event-pattern> [if <guard>] => <tail> ,
|
||||
/// // where <kind> is one of `cast`, `call`, `info`, `state_timeout`
|
||||
/// // (no pattern — it is a unit event), or `timeout <name-pattern>`,
|
||||
/// // and the tail is one of:
|
||||
/// // * a state tag `Switch::On` (transition, or "stay"
|
||||
/// // if it equals current)
|
||||
/// // * a block ending in one `{ data.flips += 1; Switch::On }`
|
||||
/// // * a successor-enum value `unlock(key)` (branching row)
|
||||
/// // * the keyword `unhandled` (explicit refusal)
|
||||
/// on Switch::Off => {
|
||||
/// cast SwitchCast::Flip => { data.flips += 1; Switch::On },
|
||||
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
|
||||
/// // * a state tag `Door::Closed` (transition, or "stay"
|
||||
/// // if it equals current)
|
||||
/// // * a block ending in one `{ data.enters += 1; Door::Closed }`
|
||||
/// // * a successor-enum value `on_unlock(key)` (branching row)
|
||||
/// // * the keyword `unhandled` (explicit refusal)
|
||||
/// // * the keyword `postpone` (defer until next
|
||||
/// // transition; cast/call/
|
||||
/// // info only)
|
||||
/// on Door::Open => {
|
||||
/// cast DoorCast::Push => Door::Closed,
|
||||
/// // An armed state-timeout surfaces as an ordinary event:
|
||||
/// state_timeout => Door::Closed,
|
||||
/// cast DoorCast::Pull | DoorCast::Lock | DoorCast::Unlock(_) => unhandled,
|
||||
/// }
|
||||
/// on Switch::On => {
|
||||
/// cast SwitchCast::Flip => Switch::Off,
|
||||
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
|
||||
/// on Door::Closed => {
|
||||
/// cast DoorCast::Pull => Door::Open,
|
||||
/// cast DoorCast::Lock => Door::Locked,
|
||||
/// cast DoorCast::Push | DoorCast::Unlock(_) => unhandled,
|
||||
/// // No state-timeout armed here, so refuse it.
|
||||
/// state_timeout => unhandled,
|
||||
/// }
|
||||
/// on Door::Locked => {
|
||||
/// cast DoorCast::Unlock(key) => on_unlock(key), // -> successor enum
|
||||
/// cast DoorCast::Push | DoorCast::Pull | DoorCast::Lock => unhandled,
|
||||
/// state_timeout => unhandled,
|
||||
/// }
|
||||
///
|
||||
/// // state-independent queries (reply, then stay via `prev`)
|
||||
/// on _ => {
|
||||
/// call DoorCall::GetState(r) => { r.reply(prev); prev },
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@@ -368,30 +704,53 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
/// * **Every row ends in a comma** (block-bodied ones too) and every `on` block
|
||||
/// is `on <state-pat> => { … }`. These two are macro-grammar requirements, not
|
||||
/// style: a declarative matcher can't otherwise tell where a row's tail ends.
|
||||
/// * **Qualify event patterns** (`SwitchCast::Flip`) and **state tags**
|
||||
/// (`Switch::On`). A declarative macro can't prepend the enum name inside an
|
||||
/// opaque pattern fragment, so the `cast`/`call` keyword only selects the
|
||||
/// event wrapper — it does not qualify for you. The keyword also reads as a
|
||||
/// sync/async marker at a glance.
|
||||
/// * **Qualify event patterns** (`DoorCast::Push`) and **state tags**
|
||||
/// (`Door::Closed`). A declarative macro can't prepend the enum name inside an
|
||||
/// opaque pattern fragment, so the row keyword only selects the event wrapper
|
||||
/// — it does not qualify for you. `cast`/`call` also read as a sync/async
|
||||
/// marker at a glance.
|
||||
/// * **Timeouts surface as events.** `state_timeout` (a unit event) and
|
||||
/// `timeout <name>` (matching the `&'static str` a generic timeout was armed
|
||||
/// under) are matched in `on` rows just like casts and calls — arm them via
|
||||
/// `cx`, handle the fire here. Because a `timeout` name is an `&str`, a row
|
||||
/// that matches specific names needs a `timeout _ => …` fallback to stay
|
||||
/// exhaustive.
|
||||
/// * **`info` defaults to a silent drop.** An out-of-band `info` you do not
|
||||
/// match anywhere is dropped (the `gen_server` default), so a machine that
|
||||
/// ignores info writes no `info` rows at all. State-timeouts and named
|
||||
/// timeouts have **no** such default: a state that can see one must handle it
|
||||
/// (or `unhandled` it) or the match is non-exhaustive.
|
||||
/// * **Stay** = return the current tag. The `prev` you named in `context` is
|
||||
/// bound to the pre-handler state for exactly this — handy in any-state
|
||||
/// (`on _`) rows where there is no single literal tag to write.
|
||||
/// * **`data` and `cx`** (the names you chose) are in scope in every body:
|
||||
/// mutate `data`, reply through a `Reply` bound in the pattern, and (chunks
|
||||
/// 2–3) arm timeouts or postpone via `cx`. You never write `self`.
|
||||
/// mutate `data`, reply through a `Reply` bound in the pattern, and arm
|
||||
/// timeouts via `cx`. You never write `self`.
|
||||
/// * **Guards** are plain match guards. A guarded arm does not count toward
|
||||
/// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly)
|
||||
/// trip `E0004`.
|
||||
/// * **Branching rows** return a successor enum that `impl`s `From<_>` for the
|
||||
/// state type; the macro supplies the outer `.into()`.
|
||||
/// * **`postpone`** defers the current event untouched, to be replayed after the
|
||||
/// next *real transition* (a stay does not trigger replay). It is available
|
||||
/// for `cast`, `call`, and `info` rows — not the timeout events. The deferred
|
||||
/// event keeps any `Reply` it carries, so a postponed `call` is answered by
|
||||
/// whichever later state handles the replay. The body runs *no* code (the
|
||||
/// event is untouched), so a `postpone` row is just `cast Foo(_) => postpone,`;
|
||||
/// prefer a non-binding pattern, and if you guard it, the guard must not depend
|
||||
/// on the event's payload (it is evaluated in a borrow pre-pass). On a
|
||||
/// transition the queue drains FIFO, ahead of further inbox/timer events; a
|
||||
/// replayed event may postpone again (it re-queues for the next transition).
|
||||
///
|
||||
/// # What it emits
|
||||
///
|
||||
/// `enum $Ev { Cast($Cast), Call($Call) }`, `struct $Sm { state, data }`,
|
||||
/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, the `Machine` impl (`on_start`
|
||||
/// running the initial `enter`; `handle` = the dispatch match + the
|
||||
/// stay/transition/unhandled apply-tail, the cell's sole writer), and the
|
||||
/// `enter` dispatch. Chunk 1: real time, no timers, no postpone.
|
||||
/// The unified `enum $Ev` (the `Cast`/`Call`/`Info` wrappers plus the internal
|
||||
/// `StateTimeout` / `Timeout` events), `struct $Sm { state, data }`,
|
||||
/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, and the `Machine` impl:
|
||||
/// `on_start` runs the initial `enter`; `handle` is the dispatch match plus the
|
||||
/// stay/transition/unhandled apply-tail (the cell's sole writer, which also
|
||||
/// auto-resets the state-timeout on every real transition); and the `enter`
|
||||
/// dispatch.
|
||||
///
|
||||
/// # Limitation
|
||||
///
|
||||
@@ -403,16 +762,23 @@ macro_rules! gen_statem {
|
||||
// ===== public entry =====================================================
|
||||
(
|
||||
machine: $sm:ident { state: $State:ty, data: $Data:ty } ;
|
||||
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty } ;
|
||||
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty, info: $Info:ty } ;
|
||||
context ( $data:ident , $cur:ident , $cx:ident ) ;
|
||||
enter { $( $est:pat => $ebody:expr ),+ $(,)? }
|
||||
$( on $st:pat => { $($rows:tt)* } )+
|
||||
) => {
|
||||
/// Unified inbox payload: the user's `cast`/`call` enums folded together
|
||||
/// (chunks 2–3 add the runtime's internal timeout variants here).
|
||||
/// Unified inbox payload: the user's `cast`/`call`/`info` enums folded
|
||||
/// together with the runtime's internal timeout events.
|
||||
enum $Ev {
|
||||
Cast($Cast),
|
||||
Call($Call),
|
||||
/// Out-of-band message, matched in `info` rows. An unmatched info is
|
||||
/// silently dropped (the `gen_server` default).
|
||||
Info($Info),
|
||||
/// The state-timeout fired (matched in `state_timeout` rows).
|
||||
StateTimeout,
|
||||
/// A named timeout fired (matched in `timeout <pat>` rows).
|
||||
Timeout(&'static str),
|
||||
}
|
||||
|
||||
struct $sm {
|
||||
@@ -438,6 +804,14 @@ macro_rules! gen_statem {
|
||||
impl $crate::gen_statem::Machine for $sm {
|
||||
type Ev = $Ev;
|
||||
|
||||
fn state_timeout_ev() -> $Ev {
|
||||
$Ev::StateTimeout
|
||||
}
|
||||
|
||||
fn timeout_ev(name: &'static str) -> $Ev {
|
||||
$Ev::Timeout(name)
|
||||
}
|
||||
|
||||
fn on_start(&mut self, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
|
||||
let s = self.state;
|
||||
self.enter(s, $cx);
|
||||
@@ -446,79 +820,205 @@ macro_rules! gen_statem {
|
||||
#[allow(unused_variables)]
|
||||
#[deny(unreachable_patterns)] // conflicting rows must fail even though
|
||||
// this match is external-macro-expanded
|
||||
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
|
||||
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::gen_statem::Cx<$Ev>)
|
||||
-> $crate::gen_statem::Step<$Ev>
|
||||
{
|
||||
// Caller-named bindings (shared call-site hygiene, so row bodies
|
||||
// can see them): `$cur` = current state tag, `$data` = &mut Data.
|
||||
let $cur = self.state;
|
||||
let $data = &mut self.data;
|
||||
// @arms emits a block: a postpone pre-pass that `return`s
|
||||
// `Step::Postponed(ev)` for a deferred event (handing it back
|
||||
// untouched), then the consuming `match (state, event)` whose
|
||||
// value is this `Resolution`.
|
||||
let next: $crate::gen_statem::Resolution<$State> =
|
||||
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ]
|
||||
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ] [ ]
|
||||
$( on $st => { $($rows)* } )+);
|
||||
match next {
|
||||
$crate::gen_statem::Resolution::To(s) if s == $cur => {}
|
||||
$crate::gen_statem::Resolution::To(s) if s == $cur => {
|
||||
$crate::gen_statem::Step::Stayed
|
||||
}
|
||||
$crate::gen_statem::Resolution::To(s) => {
|
||||
self.state = s; // <- sole writer of the state cell
|
||||
// A real transition auto-resets the state-timeout: the
|
||||
// new state re-arms in its `enter` if it wants one.
|
||||
$cx.__reset_state_timeout();
|
||||
self.enter(s, $cx);
|
||||
$crate::gen_statem::Step::Transitioned
|
||||
}
|
||||
$crate::gen_statem::Resolution::Postpone => {
|
||||
unreachable!("postpone is not generated yet")
|
||||
$crate::gen_statem::Resolution::Unhandled => {
|
||||
$cx.on_unhandled();
|
||||
$crate::gen_statem::Step::Stayed
|
||||
}
|
||||
$crate::gen_statem::Resolution::Unhandled => $cx.on_unhandled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ===== @arms: build the dispatch match ==================================
|
||||
// No more on-blocks: emit the (total, catch-all-free) match.
|
||||
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => {
|
||||
match ($ss, $se) { $($arms)* }
|
||||
// ===== @arms / @rows: build the two-phase dispatch ======================
|
||||
// Two accumulators are threaded: `[ $($arms)* ]` is the phase-2 consuming
|
||||
// match (by value); `[ $($post)* ]` is the phase-1 postpone router (by ref).
|
||||
// A normal row feeds only phase-2; a `postpone` row feeds both — a `=> true`
|
||||
// router and a `=> unreachable!` phase-2 filler that keeps the consuming
|
||||
// match total.
|
||||
//
|
||||
// Terminal (no on-blocks left): emit the block the `handle` body assigns to
|
||||
// `next`. Phase 1 routes a deferred event back out as `Step::Postponed`;
|
||||
// phase 2 is the catch-all-free consuming match (the one macro-injected arm
|
||||
// is the `Info` silent-drop — last and broadest, so per-state `info` rows
|
||||
// stay reachable; cast/call/timeouts get no fallback, so a forgotten pair is
|
||||
// still E0004).
|
||||
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ]) => {
|
||||
{
|
||||
// Phase 1 — postpone routing (borrow-only). A guard on a postpone
|
||||
// row runs here, against by-ref bindings, so it must not depend on
|
||||
// the event's payload.
|
||||
let __defer = match ($ss, &$se) {
|
||||
$($post)*
|
||||
_ => false,
|
||||
};
|
||||
if __defer {
|
||||
return $crate::gen_statem::Step::Postponed($se);
|
||||
}
|
||||
// Phase 2 — the consuming dispatch. Each postpone pair reappears here
|
||||
// as `unreachable!` so the match stays total; phase 1 already
|
||||
// returned for it.
|
||||
match ($ss, $se) {
|
||||
$($arms)*
|
||||
(_, $Ev::Info(_)) => $crate::gen_statem::Resolution::Unhandled,
|
||||
}
|
||||
}
|
||||
};
|
||||
// Open an on-block: remember its state pat, drain its rows, then continue.
|
||||
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]
|
||||
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ]
|
||||
on $st:pat => { $($rows:tt)* } $($more:tt)*
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] ($st)
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] [ $($post)* ] ($st)
|
||||
{ $($rows)* } { $($more)* })
|
||||
};
|
||||
|
||||
// ===== @rows: drain one on-block's rows, threading the global acc ========
|
||||
// ===== @rows: drain one on-block's rows, threading both accs =============
|
||||
// cast, explicit refusal
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ cast $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// cast, postpone (defer the event; the replay in a later state handles it)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ cast $ev:pat $(if $g:expr)? => postpone , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => unreachable!("postponed event is replayed, not dispatched here"), ]
|
||||
[ $($post)* ($st, $Ev::Cast($ev)) $(if $g)? => true, ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// cast, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ cast $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// call, explicit refusal
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ call $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// call, postpone (the Reply rides inside the event onto the queue)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ call $ev:pat $(if $g:expr)? => postpone , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => unreachable!("postponed event is replayed, not dispatched here"), ]
|
||||
[ $($post)* ($st, $Ev::Call($ev)) $(if $g)? => true, ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// call, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ call $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// info, explicit refusal
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ info $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// info, postpone
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ info $ev:pat $(if $g:expr)? => postpone , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => unreachable!("postponed event is replayed, not dispatched here"), ]
|
||||
[ $($post)* ($st, $Ev::Info($ev)) $(if $g)? => true, ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// info, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ info $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// state_timeout, explicit refusal (unit event — no pattern; not postponable)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ state_timeout $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::StateTimeout) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// state_timeout, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ state_timeout $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::StateTimeout) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// timeout, explicit refusal (pattern matches the name; not postponable)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ timeout $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Timeout($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// timeout, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ timeout $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Timeout($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
|
||||
[ $($post)* ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// this block is drained: hand the remaining on-blocks back to @arms
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] [ $($post:tt)* ] ($st:pat)
|
||||
{ } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] $($more)*)
|
||||
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] [ $($post)* ] $($more)*)
|
||||
};
|
||||
}
|
||||
|
||||
+181
-83
@@ -1,26 +1,85 @@
|
||||
//! RFC 016 — runtime introspection (Chunk 1: the read primitive).
|
||||
//! Inspect what is running right now: which actors exist, what state each one
|
||||
//! is in, and how they are related.
|
||||
//!
|
||||
//! A synchronous, internal read of the slab that returns *owned* data. This is
|
||||
//! the mechanism the whole RFC hangs off: tests, the future observer
|
||||
//! gen_server (Chunk 4), and a later control plane (RFC 003) are all consumers
|
||||
//! of [`snapshot`] / [`actor_info`], never of the runtime internals directly.
|
||||
//! This is the tool for questions like "is my server still alive", "how many
|
||||
//! actors are currently parked waiting on something", or "what does the spawn
|
||||
//! tree look like". It is meant for debugging, test assertions, a health check
|
||||
//! endpoint, or a monitoring dashboard: anywhere you want to look at the
|
||||
//! runtime from the outside without stopping it or coupling your code to its
|
||||
//! internals.
|
||||
//!
|
||||
//! ## Consistency (DECISION D2 — per-slot tearing, `ps` semantics)
|
||||
//! Three entry points, in order of scope:
|
||||
//!
|
||||
//! [`snapshot`] is point-in-time and mildly racy *across* actors: each slot's
|
||||
//! scheduling state is a lock-free word load, so an actor reported `Running`
|
||||
//! may already be `Parked`, and an actor can die mid-scan. This is the cheap,
|
||||
//! useful model (a coherent stop-the-world cut is expensive and rarely wanted).
|
||||
//! [`actor_info`] is coherent for the single actor it names.
|
||||
//! - [`snapshot`] returns every actor that currently exists, as a plain
|
||||
//! owned `Vec`, so you can filter, count, or search it however you like.
|
||||
//! - [`actor_info`] returns a coherent view of exactly one actor, by pid.
|
||||
//! Cheaper than filtering a whole snapshot down to one entry, and more
|
||||
//! precise (see "Consistency" below).
|
||||
//! - [`tree`] returns the same actors as [`snapshot`], folded into a
|
||||
//! parent/child forest that mirrors who spawned whom.
|
||||
//!
|
||||
//! ## Locking
|
||||
//! ```
|
||||
//! use smarm::{actor_info, channel, run, snapshot, spawn, ActorState};
|
||||
//!
|
||||
//! The lock order is **Leaf → Channel, at most one of each** (`raw_mutex.rs`);
|
||||
//! cold locks, the registry, and the free list are all Leaves, so we may never
|
||||
//! hold two at once. The read is therefore phased: first a single registry-leaf
|
||||
//! pass for names and mailbox depth (the per-channel length read is a Channel
|
||||
//! lock taken under that Leaf — legal), released before the slab scan takes any
|
||||
//! per-slot cold Leaf.
|
||||
//! run(|| {
|
||||
//! let (ready_tx, ready_rx) = channel::<()>();
|
||||
//! let (gate_tx, gate_rx) = channel::<()>();
|
||||
//!
|
||||
//! let worker = spawn(move || {
|
||||
//! ready_tx.send(()).unwrap();
|
||||
//! gate_rx.recv().unwrap(); // blocks here until released
|
||||
//! });
|
||||
//! ready_rx.recv().unwrap();
|
||||
//!
|
||||
//! // `snapshot` sees every actor, including this one and the worker.
|
||||
//! let snap = snapshot();
|
||||
//! assert!(snap.actors.len() >= 2);
|
||||
//!
|
||||
//! // `actor_info` gives a coherent view of just the worker. It is
|
||||
//! // blocked on the gate channel, so it must be Parked.
|
||||
//! let pid = worker.pid();
|
||||
//! let info = actor_info(pid).expect("worker is still alive");
|
||||
//! assert_eq!(info.state, ActorState::Parked);
|
||||
//!
|
||||
//! gate_tx.send(()).unwrap();
|
||||
//! worker.join().unwrap();
|
||||
//!
|
||||
//! // Once joined, the pid no longer names a live actor.
|
||||
//! assert!(actor_info(pid).is_none());
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Consistency
|
||||
//!
|
||||
//! [`snapshot`] is not a single atomic pause-the-world freeze: it walks every
|
||||
//! actor's state one after another, so it is a series of independent,
|
||||
//! cheap, lock-free reads rather than one coherent moment in time. Between
|
||||
//! reading actor A and actor B, either one can change state, and an actor can
|
||||
//! even finish and disappear mid-scan. In practice this is exactly what you
|
||||
//! want: a coherent stop-the-world snapshot would mean pausing every actor in
|
||||
//! the runtime just to look at it, which is expensive and rarely necessary
|
||||
//! for a dashboard, a test assertion, or a debugging session.
|
||||
//!
|
||||
//! [`actor_info`], in contrast, is coherent for the one actor it names: all of
|
||||
//! its fields describe the same instant for that actor, because a single
|
||||
//! actor's data cannot tear the way a scan across many actors can.
|
||||
//!
|
||||
//! ## Implementation notes
|
||||
//!
|
||||
//! These details matter if you are working on smarm itself; they are not part
|
||||
//! of the public contract.
|
||||
//!
|
||||
//! The read never stops the scheduler and never holds a lock across the whole
|
||||
//! scan. Each actor's scheduling state is a single lock-free word load
|
||||
//! (hence the possible tearing described above). Reading the rest of an
|
||||
//! actor's cold data (its supervisor, monitors, links, and so on) takes a
|
||||
//! brief per-actor lock, just long enough to copy those fields out; nothing
|
||||
//! is held across actors. Locking follows the crate-wide rule that at most
|
||||
//! one "leaf" lock (a per-actor lock, the registry lock, or the free list
|
||||
//! lock) is held at a time, with no leaf lock held while acquiring another.
|
||||
//! The read is phased accordingly: first one pass over the registry to
|
||||
//! collect every actor's registered names and mailbox depth, released before
|
||||
//! the per-actor scan begins.
|
||||
|
||||
use crate::pid::Pid;
|
||||
use crate::registry::MailboxInfo;
|
||||
@@ -31,15 +90,28 @@ use crate::slot_state::{
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Snapshot wire-format version (DECISION D1). [`RuntimeSnapshot`] is treated as
|
||||
/// a stable type from day one: it becomes the observer protocol (Chunk 4) and
|
||||
/// crosses a version boundary the moment a remote observer attaches (RFC 011),
|
||||
/// so the version travels with the data from the start.
|
||||
/// The format version carried by every [`RuntimeSnapshot`] and
|
||||
/// [`RuntimeTree`], as [`RuntimeSnapshot::format_version`] /
|
||||
/// [`RuntimeTree::format_version`]. If you serialize a snapshot (for example
|
||||
/// to send it somewhere else, or to compare snapshots taken with different
|
||||
/// versions of smarm) check this field: a change in its value means the shape
|
||||
/// of [`ActorInfo`] or its neighbors has changed and old and new snapshots
|
||||
/// should not be assumed compatible. If you only ever read a snapshot
|
||||
/// in-process in the same version of smarm that produced it, you can ignore
|
||||
/// this field.
|
||||
pub const SNAPSHOT_FORMAT_VERSION: u16 = 1;
|
||||
|
||||
/// Fine-grained scheduling state, mapped from the packed slot word with no new
|
||||
/// storage. `RunningNotified` collapses into `Notified` — a wake landed while
|
||||
/// the actor was on-CPU and it will re-queue when it yields.
|
||||
/// What an actor is doing right now, from the scheduler's point of view.
|
||||
///
|
||||
/// - `Queued`: runnable, waiting for a scheduler thread to pick it up.
|
||||
/// - `Running`: currently executing on a scheduler thread.
|
||||
/// - `Notified`: was running and got woken up (for example, a message
|
||||
/// arrived) before it had a chance to yield or park; it will be re-queued
|
||||
/// as soon as it does.
|
||||
/// - `Parked`: blocked, waiting on something such as a channel receive, a
|
||||
/// mutex, a timer, or an IO event.
|
||||
/// - `Done`: has finished (returned or panicked) but its slot has not been
|
||||
/// reclaimed for reuse yet, so it is still visible to introspection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ActorState {
|
||||
Queued,
|
||||
@@ -49,8 +121,8 @@ pub enum ActorState {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Classify a packed state word. `None` for a Vacant slot (skipped by the scan)
|
||||
/// — the only state that is not an actor.
|
||||
/// Classify a packed state word. `None` for a Vacant slot (skipped by the
|
||||
/// scan): a vacant slot holds no actor at all, live or done.
|
||||
fn classify(w: u64) -> Option<ActorState> {
|
||||
Some(match word_state(w) {
|
||||
ST_QUEUED => ActorState::Queued,
|
||||
@@ -62,61 +134,76 @@ fn classify(w: u64) -> Option<ActorState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Owned, point-in-time view of one actor — no borrows of runtime internals, so
|
||||
/// it is safe to hand to any consumer.
|
||||
/// An owned, self-contained view of one actor at (approximately) one moment.
|
||||
/// It borrows nothing from the runtime, so you can keep it, send it
|
||||
/// elsewhere, or print it long after the actor it describes has changed
|
||||
/// state or even exited.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorInfo {
|
||||
pub pid: Pid,
|
||||
/// Registered names, inverted from the registry (usually 0 or 1).
|
||||
/// Names this actor is currently registered under (see the
|
||||
/// [`registry`](crate::registry) module). Usually empty or one name;
|
||||
/// an actor can have more if it registered several.
|
||||
pub names: Vec<&'static str>,
|
||||
pub state: ActorState,
|
||||
/// Spawn-time parent edge (DECISION D9): `spawn_under` sets it to the
|
||||
/// supervisor, plain `spawn` to the spawning actor — so it is parentage,
|
||||
/// not necessarily a supervision relationship. `ROOT_PID` for the run's
|
||||
/// root actor and for `Done` tombstones (whose `Actor` is already gone).
|
||||
/// The actor that spawned this one: whoever called `spawn` or
|
||||
/// `spawn_under` to create it. This is a parentage record, not
|
||||
/// necessarily a supervision relationship: `spawn_under` records the
|
||||
/// supervisor you asked for, while plain `spawn` records the spawning
|
||||
/// actor itself, whether or not it supervises anything. It is the
|
||||
/// runtime's root pid for the run's own root actor, and for a `Done`
|
||||
/// actor whose bookkeeping has already been cleared.
|
||||
pub supervisor: Pid,
|
||||
pub trap_exit: bool,
|
||||
pub monitors: u32,
|
||||
pub links: u32,
|
||||
pub joiners: u32,
|
||||
/// Queued messages summed over the actor's *published* channels (register /
|
||||
/// install / spawn_addr / gen_server). 0 for an actor that holds only a
|
||||
/// private `channel()` receiver — those are invisible to the registry.
|
||||
/// Messages currently queued and not yet delivered, summed across every
|
||||
/// channel this actor has published (via `register`, `install`,
|
||||
/// `spawn_addr`, or starting a gen_server). This is 0 for an actor that
|
||||
/// only holds a private, unpublished `channel()` receiver, since nothing
|
||||
/// outside the actor can see that channel exists.
|
||||
pub mailbox_depth: u32,
|
||||
/// Timeslice overruns tallied for this incarnation (RFC 016 Chunk 2): how
|
||||
/// many times the actor was preempted for exceeding its slice. Resets on
|
||||
/// restart (per-incarnation, D7).
|
||||
/// How many times this actor has been preempted for running past its
|
||||
/// scheduling timeslice. Counts only since the actor's current start (a
|
||||
/// supervisor restart begins a fresh count).
|
||||
pub overruns: u64,
|
||||
/// Messages this actor has received (dequeued) this incarnation (RFC 016
|
||||
/// Chunk 2) — answers "is this actor a hotspot / draining slower than its
|
||||
/// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7).
|
||||
/// How many messages this actor has received (taken off its inbox), since
|
||||
/// its current start. Useful for spotting an actor whose mailbox is
|
||||
/// filling up faster than it can drain it: compare this against
|
||||
/// `mailbox_depth` over time.
|
||||
pub messages_received: u64,
|
||||
/// Approximate on-CPU cycles this incarnation has consumed (RFC 016 Chunk 2)
|
||||
/// — a reductions-like work metric for relative comparison. Always 0 unless
|
||||
/// the `budget-accounting` feature is enabled (it costs an RDTSC per resume,
|
||||
/// D6). Per-incarnation (D7).
|
||||
/// Approximate CPU cycles this actor has spent running, since its current
|
||||
/// start. A relative measure for comparing actors against each other, not
|
||||
/// an absolute or wall-clock figure. Always 0 unless the crate's
|
||||
/// `budget-accounting` feature is enabled, since measuring it costs a
|
||||
/// timestamp read on every resume.
|
||||
pub budget_cycles: u64,
|
||||
}
|
||||
|
||||
/// A whole-runtime snapshot. See the module docs for the D2 tearing model.
|
||||
/// A snapshot of every actor in the runtime at (approximately) one moment.
|
||||
/// See the module docs' "Consistency" section for what "approximately" means
|
||||
/// here.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeSnapshot {
|
||||
pub format_version: u16,
|
||||
pub actors: Vec<ActorInfo>,
|
||||
}
|
||||
|
||||
/// Snapshot every live (and `Done`-but-not-yet-reclaimed) actor on the slab.
|
||||
/// O(n) over the slot table, running with preemption disabled (like every
|
||||
/// runtime primitive) but holding no lock across the scan. Panics outside
|
||||
/// `Runtime::run()`; callable from actor code and the run thread.
|
||||
/// Every actor that currently exists: running, queued, parked, or finished
|
||||
/// but not yet cleaned up. Cheap and lock-free per actor; see the module
|
||||
/// docs for what "approximately one moment" means for the result as a whole.
|
||||
/// Panics if called outside [`run`](crate::run).
|
||||
pub fn snapshot() -> RuntimeSnapshot {
|
||||
with_runtime(|inner| {
|
||||
// Phase A: one registry-leaf pass for names + mailbox depth, released
|
||||
// before any cold leaf (no two Leaves at once).
|
||||
// First pass: one registry lock to collect names + mailbox depth for
|
||||
// every actor, released before touching any per-actor lock below.
|
||||
let mail = inner.registry.lock().introspect_map();
|
||||
|
||||
// Phase B: lock-free slab scan; per-slot cold leaf only to copy cold
|
||||
// fields. Tearing across slots is intentional (D2).
|
||||
// Second pass: walk the actor table. Each actor's scheduling state is
|
||||
// a lock-free word load; only copying its other fields takes a brief
|
||||
// per-actor lock. Tearing across actors is expected here (see the
|
||||
// module docs' "Consistency" section).
|
||||
let mut actors = Vec::new();
|
||||
for (idx, slot) in inner.slots.iter().enumerate() {
|
||||
let idx = idx as u32;
|
||||
@@ -128,8 +215,11 @@ pub fn snapshot() -> RuntimeSnapshot {
|
||||
})
|
||||
}
|
||||
|
||||
/// Coherent view of a single actor, or `None` if the pid is stale, out of
|
||||
/// range, or names a Vacant slot.
|
||||
/// A coherent view of exactly one actor, or `None` if `pid` does not name a
|
||||
/// currently-live entry: it is stale (that actor has already exited and its
|
||||
/// slot was reused by another), out of range, or was never a real pid at
|
||||
/// all. Unlike [`snapshot`], every field of the result describes the same
|
||||
/// instant, since there is only one actor to read.
|
||||
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
|
||||
with_runtime(|inner| {
|
||||
let slot = inner.slot_at(pid)?;
|
||||
@@ -141,11 +231,12 @@ pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build one `ActorInfo` for slot `idx`, or `None` if Vacant or
|
||||
/// racing-reclaimed. State is classified from a lock-free word load (the torn
|
||||
/// read); the cold lock then pins the generation (reclaim bumps it under that
|
||||
/// same lock) so the cold fields are coherent for this incarnation. `mail` is
|
||||
/// this slot's registry entry, if any.
|
||||
/// Build one `ActorInfo` for slot `idx`, or `None` if the slot is empty or
|
||||
/// was reclaimed while this read was in progress. The scheduling state comes
|
||||
/// from a lock-free word load (the source of the tearing described in the
|
||||
/// module docs); the per-actor lock then confirms the actor has not since
|
||||
/// exited and been replaced, so the rest of the fields are coherent for this
|
||||
/// exact actor. `mail` is this slot's registry entry, if any.
|
||||
fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorInfo> {
|
||||
let w = slot.state_word();
|
||||
let state = classify(w)?;
|
||||
@@ -153,10 +244,11 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
|
||||
let pid = Pid::new(idx, gen);
|
||||
|
||||
let cold = slot.cold.lock();
|
||||
// If the generation moved between the lock-free load and acquiring the cold
|
||||
// lock, the slot was reclaimed (and maybe reused) — drop it rather than mix
|
||||
// one incarnation's state with another's cold data. (ps semantics: a racing
|
||||
// actor may simply be missed mid-scan.)
|
||||
// If the generation moved between the lock-free load and acquiring the
|
||||
// per-actor lock, this actor exited (and the slot may already hold a new
|
||||
// one). Drop it rather than mix one actor's state with another's data; a
|
||||
// racing actor may simply be missed by this scan, which is expected (see
|
||||
// the module docs' "Consistency" section).
|
||||
if word_gen(slot.state_word()) != gen {
|
||||
return None;
|
||||
}
|
||||
@@ -172,7 +264,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
|
||||
let joiners = cold.waiters.len() as u32;
|
||||
drop(cold);
|
||||
|
||||
// Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2).
|
||||
// Counters are plain atomics, read lock-free.
|
||||
let overruns = slot.overruns();
|
||||
let messages_received = slot.messages_received();
|
||||
let budget_cycles = slot.budget_cycles();
|
||||
@@ -202,39 +294,45 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunk 3 — tree view (pure derivation over a Chunk-1 snapshot)
|
||||
// Tree view: a pure derivation over a snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One node in the parentage forest. `children` are the actors whose recorded
|
||||
/// parent edge points at this node's pid.
|
||||
/// One node in the parentage forest returned by [`tree`]. `children` are the
|
||||
/// actors whose recorded parent (see [`ActorInfo::supervisor`]) points at
|
||||
/// this node's actor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeNode {
|
||||
pub info: ActorInfo,
|
||||
/// The actor's recorded parent was absent from the snapshot (already
|
||||
/// Done/Vacant, or itself a tombstone), so it was re-rooted under the forest
|
||||
/// sentinel rather than dropped — the tree stays total (DECISION D8).
|
||||
/// True if this actor's recorded parent was not found in the snapshot
|
||||
/// (it had already exited, or was itself missing), so this node was
|
||||
/// placed at the top of the forest instead of being dropped. This keeps
|
||||
/// every actor in the snapshot visible somewhere in the tree, even one
|
||||
/// whose parent is gone.
|
||||
pub orphaned: bool,
|
||||
pub children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// The parentage forest. Roots are actors parented at `ROOT_PID` (genuine
|
||||
/// roots) plus re-rooted orphans. The edge is *spawned-by / parent*, not
|
||||
/// necessarily supervision (DECISION D9) — see [`ActorInfo::supervisor`].
|
||||
/// The parentage forest: every actor from a snapshot, arranged by who spawned
|
||||
/// whom. Roots are actors with no parent in the snapshot (including the
|
||||
/// run's own root actor) plus any orphaned actors (see [`TreeNode::orphaned`]).
|
||||
/// This mirrors spawn parentage, not necessarily a supervision tree; see
|
||||
/// [`ActorInfo::supervisor`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeTree {
|
||||
pub format_version: u16,
|
||||
pub roots: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// Take a live [`snapshot`] and fold it into the parentage forest.
|
||||
/// Take a fresh [`snapshot`] and fold it into the parentage forest.
|
||||
pub fn tree() -> RuntimeTree {
|
||||
tree_from(snapshot())
|
||||
}
|
||||
|
||||
/// Fold an existing snapshot into a forest by grouping each actor under its
|
||||
/// parent pid — a single O(n) pass, no new reads. Exposed separately so a
|
||||
/// consumer that already holds a snapshot (or a synthetic one, in tests) can
|
||||
/// derive the tree without a second scan.
|
||||
/// Fold an existing snapshot into a parentage forest by grouping each actor
|
||||
/// under its parent, without taking a new snapshot. Useful if you already
|
||||
/// have one (for example, one built in a test, or one you took earlier and
|
||||
/// want to inspect again) and want the tree view of it without re-reading
|
||||
/// the runtime.
|
||||
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
|
||||
let RuntimeSnapshot { format_version, actors } = snap;
|
||||
|
||||
@@ -254,7 +352,7 @@ pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
|
||||
children_of.entry(parent).or_default().push(i);
|
||||
} else {
|
||||
// Parent is the forest sentinel (genuine root) or absent from the
|
||||
// snapshot (orphan, D8) — either way a root of the forest.
|
||||
// snapshot (orphan): either way, a root of the forest.
|
||||
orphaned[i] = parent != ROOT_PID;
|
||||
roots.push(i);
|
||||
}
|
||||
|
||||
@@ -13,44 +13,68 @@
|
||||
//! leaves the actor, no copying through an intermediary thread. Built on
|
||||
//! these are the conveniences `read(fd, &mut buf)` and `write(fd, &buf)`.
|
||||
//!
|
||||
//! Architecture
|
||||
//! ============
|
||||
//! Per `run()`, two OS threads:
|
||||
//! - **epoll thread**: owns the epollfd. Loops in `epoll_wait`. On a
|
||||
//! ready fd, pushes `Completion::FdReady { pid, fd, events }` to the
|
||||
//! shared completion queue and writes the scheduler-wake pipe. On the
|
||||
//! shutdown pipe (also registered in epollfd), exits.
|
||||
//! - **pool thread**: blocks on the request mpsc. Runs the closure
|
||||
//! inside `catch_unwind`, pushes `Completion::Blocking { pid, result }`,
|
||||
//! writes the scheduler-wake pipe.
|
||||
//! Architecture (RFC 018: driver-enqueues)
|
||||
//! =======================================
|
||||
//! Per `run()`, two OS threads, each a *producer* behind the runtime's
|
||||
//! two-call contract — make the actor runnable (`unpark_at`, whose enqueue
|
||||
//! tail wakes a parked scheduler), nothing else:
|
||||
//!
|
||||
//! Both threads share a single `completions: Arc<Mutex<VecDeque<Completion>>>`
|
||||
//! and the same scheduler-wake pipe.
|
||||
//! - **epoll thread**: owns `epoll_wait` on the epollfd. On a ready fd it
|
||||
//! removes the parked waiter from the shared `waiters` map and DELs the
|
||||
//! fd (both under the waiters lock — see below), then unparks the
|
||||
//! actor directly. On the shutdown pipe (also registered in the
|
||||
//! epollfd), exits.
|
||||
//! - **pool thread**: blocks on the request mpsc. Runs the closure inside
|
||||
//! `catch_unwind`, stashes the result in the actor's slot
|
||||
//! (`pending_io_result`, under the cold lock, generation-checked),
|
||||
//! decrements the runtime's `io_outstanding`, and unparks the actor.
|
||||
//!
|
||||
//! `epoll_ctl` (register/unregister fd interest) is called by the
|
||||
//! scheduler thread *directly* on the epollfd. That's well-defined per
|
||||
//! `epoll_ctl(2)`: a thread may be calling `epoll_wait` on the epollfd
|
||||
//! while another thread calls `epoll_ctl`. Avoids needing a second mpsc
|
||||
//! and a second wake mechanism.
|
||||
//! There is no shared completion queue and no wake pipe: each producer
|
||||
//! routes its own completion, so the whole byte-vs-completion visibility
|
||||
//! discipline of the drain era — and the stranded-completion hazards it
|
||||
//! defended against — is unrepresentable. Producers reach the runtime
|
||||
//! through a `Weak<RuntimeInner>`: upgraded per completion (the path is
|
||||
//! syscall-bound; the refcount op is noise) and avoiding an Arc cycle
|
||||
//! through `RuntimeInner::io`.
|
||||
//!
|
||||
//! `epoll_ctl` (register fd interest) is called by the scheduler thread
|
||||
//! directly on the epollfd. That's well-defined per `epoll_ctl(2)`: a
|
||||
//! thread may be calling `epoll_wait` on the epollfd while another thread
|
||||
//! calls `epoll_ctl`.
|
||||
//!
|
||||
//! Epoll mode
|
||||
//! ==========
|
||||
//! Level-triggered with EPOLLONESHOT. After a wakeup the kernel
|
||||
//! auto-disarms the fd, so we never get two wakeups for one
|
||||
//! `wait_readable` call. The scheduler explicitly `EPOLL_CTL_DEL`s the fd
|
||||
//! on completion to free the slot for re-registration. Net effect: each
|
||||
//! `wait_readable` call. The epoll thread explicitly `EPOLL_CTL_DEL`s the
|
||||
//! fd on readiness to free the slot for re-registration. Net effect: each
|
||||
//! `wait_readable(fd)` is one ADD, one wakeup, one DEL — symmetric and
|
||||
//! stateless between calls.
|
||||
//!
|
||||
//! ## The waiters lock is the ADD/DEL serialization
|
||||
//!
|
||||
//! Registration (scheduler thread: check-vacant, defensive DEL, ADD,
|
||||
//! insert) and readiness consumption (epoll thread: remove, DEL) each run
|
||||
//! entirely under the `waiters` mutex. This is what makes the
|
||||
//! oneshot-rearm race unrepresentable: a woken actor re-registering the
|
||||
//! same fd cannot interleave with the epoll thread's DEL for the *previous*
|
||||
//! registration — whichever takes the lock second sees a consistent
|
||||
//! kernel-side state. Lock order: `io` (the runtime's outer mutex, held by
|
||||
//! scheduler-side callers) → `waiters` → slot/queue leaves via `unpark_at`.
|
||||
//! The epoll thread takes `waiters` without `io` — it must never take
|
||||
//! `io`, both for lock-order hygiene and because teardown holds `io` while
|
||||
//! joining it.
|
||||
//!
|
||||
//! Fd hygiene
|
||||
//! ==========
|
||||
//! An actor stopped while waiting on an fd unwinds out of `wait_fd`'s park;
|
||||
//! a drop guard there (armed after a successful register, forgotten on a
|
||||
//! normal wake) removes the `waiters` entry iff it is still that wait's
|
||||
//! `(pid, epoch)` and only then `EPOLL_CTL_DEL`s the fd — an entry already
|
||||
//! consumed by a racing `FdReady` means the fd may carry someone else's
|
||||
//! fresh registration, which must be left alone. `epoll_register` keeps a
|
||||
//! defensive bare DEL before ADD as belt-and-braces.
|
||||
//! normal wake) calls [`IoThread::cancel_waiter`], which removes the
|
||||
//! `waiters` entry iff it is still that wait's `(pid, epoch)` and only then
|
||||
//! `EPOLL_CTL_DEL`s the fd — an entry already consumed by the epoll thread
|
||||
//! means the fd may carry someone else's fresh registration, which must be
|
||||
//! left alone. `epoll_register` keeps a defensive bare DEL before ADD as
|
||||
//! belt-and-braces.
|
||||
//!
|
||||
//! Buffers used with `read`/`write` should be on fds opened with
|
||||
//! `O_NONBLOCK`. If they aren't, the syscall may block the scheduler
|
||||
@@ -68,13 +92,14 @@
|
||||
//! they have no equivalent panic-propagation path.
|
||||
|
||||
use crate::pid::Pid;
|
||||
use crate::runtime::RuntimeInner;
|
||||
use std::any::Any;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::os::fd::RawFd;
|
||||
use std::panic;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{mpsc, Arc, Mutex, Weak};
|
||||
use std::thread::JoinHandle as OsJoinHandle;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,42 +111,29 @@ use std::thread::JoinHandle as OsJoinHandle;
|
||||
pub type IoResult = Result<Box<dyn Any + Send>, Box<dyn Any + Send>>;
|
||||
|
||||
struct Request {
|
||||
/// The submitter's park-epoch — carried through to the `Blocking`
|
||||
/// completion so the wake is epoch-matched.
|
||||
/// The submitter's park-epoch — the eventual wake is epoch-matched.
|
||||
epoch: u32,
|
||||
pid: Pid,
|
||||
/// The work to perform. Returns the wire-form result directly.
|
||||
work: Box<dyn FnOnce() -> IoResult + Send>,
|
||||
}
|
||||
|
||||
/// Completion message from either IO thread back to the scheduler.
|
||||
pub enum Completion {
|
||||
/// A `block_on_io` closure has finished (Ok = return value, Err = panic
|
||||
/// payload).
|
||||
Blocking { pid: Pid, epoch: u32, result: IoResult },
|
||||
/// An fd registered via `wait_readable`/`wait_writable` is ready. The
|
||||
/// scheduler looks up the parked pid in `waiters`, unparks it, and
|
||||
/// removes the entry. `pid` isn't in this variant because the epoll
|
||||
/// thread doesn't have access to the `waiters` map; the scheduler
|
||||
/// thread owns that.
|
||||
FdReady { fd: RawFd, events: u32 },
|
||||
}
|
||||
/// The parked-waiter map, shared between scheduler-side registration and
|
||||
/// the epoll thread's readiness consumption. See the module docs on why
|
||||
/// this single lock is the ADD/DEL serialization.
|
||||
type Waiters = Arc<Mutex<HashMap<RawFd, (Pid, u32)>>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IoThread — created per `run()`, owned by `SchedulerState`.
|
||||
// IoThread — created per `run()`, owned by `RuntimeInner::io`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct IoThread {
|
||||
// ----- Channels & queues -----
|
||||
|
||||
/// Submission queue into the blocking-work pool.
|
||||
tx: mpsc::Sender<Request>,
|
||||
/// Shared completion queue, fed by both the pool and the epoll thread.
|
||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
||||
/// Pipe the scheduler polls in its idle path. Both IO threads write to
|
||||
/// `wake_write` after pushing a completion.
|
||||
wake_read: RawFd,
|
||||
wake_write: RawFd,
|
||||
/// One parked actor per registered fd. Populated by `epoll_register`,
|
||||
/// consumed by the epoll thread on readiness or `cancel_waiter` on an
|
||||
/// unwound wait.
|
||||
waiters: Waiters,
|
||||
|
||||
// ----- Epoll machinery -----
|
||||
|
||||
@@ -133,39 +145,25 @@ pub struct IoThread {
|
||||
/// shutdown.
|
||||
shutdown_read: RawFd,
|
||||
shutdown_write: RawFd,
|
||||
/// One parked actor per registered fd. Populated by `wait_readable` /
|
||||
/// `wait_writable` and drained by the scheduler when a `FdReady`
|
||||
/// completion is processed.
|
||||
pub waiters: HashMap<RawFd, (Pid, u32)>,
|
||||
|
||||
// ----- Threads -----
|
||||
|
||||
pool_thread: Option<OsJoinHandle<()>>,
|
||||
epoll_thread: Option<OsJoinHandle<()>>,
|
||||
|
||||
/// Number of `block_on_io` requests in-flight. Used by the scheduler's
|
||||
/// idle path to decide whether to wait on the pipe or exit. Fd waits
|
||||
/// are not counted here; they're counted by `waiters.len()`.
|
||||
pub outstanding: u32,
|
||||
}
|
||||
|
||||
impl IoThread {
|
||||
pub fn start() -> io::Result<Self> {
|
||||
// Scheduler-facing wake pipe.
|
||||
let (wake_read, wake_write) = make_pipe()?;
|
||||
// Pool submission channel + shared completion queue.
|
||||
/// Start the pool and epoll threads. `rt` is the producers' route back
|
||||
/// into the runtime (slot table + unpark protocol); a `Weak` so the
|
||||
/// `RuntimeInner → IoThread → RuntimeInner` cycle never forms.
|
||||
pub(crate) fn start(rt: Weak<RuntimeInner>) -> io::Result<Self> {
|
||||
// Pool submission channel.
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let completions: Arc<Mutex<VecDeque<Completion>>> =
|
||||
Arc::new(Mutex::new(VecDeque::new()));
|
||||
let waiters: Waiters = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Epoll machinery.
|
||||
let epollfd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
|
||||
if epollfd < 0 {
|
||||
// Best-effort fd cleanup before bailing.
|
||||
unsafe {
|
||||
libc::close(wake_read);
|
||||
libc::close(wake_write);
|
||||
}
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
@@ -174,8 +172,6 @@ impl IoThread {
|
||||
Err(e) => {
|
||||
unsafe {
|
||||
libc::close(epollfd);
|
||||
libc::close(wake_read);
|
||||
libc::close(wake_write);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
@@ -202,79 +198,51 @@ impl IoThread {
|
||||
libc::close(epollfd);
|
||||
libc::close(shutdown_read);
|
||||
libc::close(shutdown_write);
|
||||
libc::close(wake_read);
|
||||
libc::close(wake_write);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Spawn pool thread.
|
||||
let pool_comps = completions.clone();
|
||||
let pool_rt = rt.clone();
|
||||
let pool_thread = std::thread::Builder::new()
|
||||
.name("smarm-io-pool".into())
|
||||
.spawn(move || pool_loop(rx, pool_comps, wake_write))?;
|
||||
.spawn(move || pool_loop(rx, pool_rt))?;
|
||||
|
||||
// Spawn epoll thread.
|
||||
let epoll_comps = completions.clone();
|
||||
let epoll_waiters = waiters.clone();
|
||||
let epoll_thread = std::thread::Builder::new()
|
||||
.name("smarm-io-epoll".into())
|
||||
.spawn(move || epoll_loop(epollfd, epoll_comps, wake_write))?;
|
||||
.spawn(move || epoll_loop(epollfd, epoll_waiters, rt))?;
|
||||
|
||||
Ok(Self {
|
||||
tx,
|
||||
completions,
|
||||
wake_read,
|
||||
wake_write,
|
||||
waiters,
|
||||
epollfd,
|
||||
shutdown_read,
|
||||
shutdown_write,
|
||||
waiters: HashMap::new(),
|
||||
pool_thread: Some(pool_thread),
|
||||
epoll_thread: Some(epoll_thread),
|
||||
outstanding: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hand a request to the pool. Increments `outstanding`.
|
||||
/// Hand a request to the pool. The caller (scheduler.rs) increments
|
||||
/// `io_outstanding` BEFORE calling — the pool decrements on completion,
|
||||
/// and an increment that trailed the completion would underflow.
|
||||
pub fn submit(&mut self, pid: Pid, epoch: u32, work: Box<dyn FnOnce() -> IoResult + Send>) {
|
||||
self.outstanding += 1;
|
||||
// Send can only fail if the pool has hung up, which only happens
|
||||
// on shutdown. submit during shutdown is a bug.
|
||||
self.tx
|
||||
.send(Request { pid, epoch, work })
|
||||
.expect("io pool hung up unexpectedly");
|
||||
}
|
||||
|
||||
/// Drain every available completion. Caller (the scheduler) routes the
|
||||
/// results and updates `outstanding` / `waiters` accordingly.
|
||||
pub fn drain_completions(&mut self) -> Vec<Completion> {
|
||||
let mut q = self.completions.lock().unwrap();
|
||||
let mut out = Vec::with_capacity(q.len());
|
||||
while let Some(c) = q.pop_front() {
|
||||
out.push(c);
|
||||
if self.tx.send(Request { pid, epoch, work }).is_err() {
|
||||
panic!("smarm: io pool hung up unexpectedly (submit during shutdown)");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn wake_fd(&self) -> RawFd {
|
||||
self.wake_read
|
||||
}
|
||||
|
||||
/// Write the wake pipe directly: rouse every scheduler thread blocked in
|
||||
/// its idle `poll_wake`. Used by the terminal (AllDone) path — an idle
|
||||
/// sibling may be blocked on a snapshot that nothing will ever refresh
|
||||
/// (an orphaned timer deadline, or `io_outstanding` from a waiter that
|
||||
/// was stop-cancelled and so never produces a completion).
|
||||
pub fn wake(&self) {
|
||||
wake_scheduler(self.wake_write);
|
||||
}
|
||||
|
||||
/// Register interest in `fd` becoming readable/writable; record `pid`
|
||||
/// as the parked waiter. The epoll thread will push a `FdReady`
|
||||
/// completion when the kernel signals.
|
||||
/// as the parked waiter. The epoll thread unparks it on readiness.
|
||||
/// The caller increments `io_fd_waiters` BEFORE calling (mirror of
|
||||
/// `submit`'s contract) and decrements it again if this errors.
|
||||
///
|
||||
/// EPOLLONESHOT: one wakeup per registration. The scheduler must
|
||||
/// `epoll_del` on completion to free the slot for re-registration.
|
||||
/// EPOLLONESHOT: one wakeup per registration; the epoll thread DELs on
|
||||
/// readiness, `cancel_waiter` DELs on an unwound wait.
|
||||
pub fn epoll_register(
|
||||
&mut self,
|
||||
fd: RawFd,
|
||||
@@ -283,20 +251,24 @@ impl IoThread {
|
||||
readable: bool,
|
||||
writable: bool,
|
||||
) -> io::Result<()> {
|
||||
let mut waiters = match self.waiters.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: io waiters lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
// Two actors waiting on the same fd would be a misuse: the kernel
|
||||
// delivers exactly one EPOLLONESHOT wakeup, so the second waiter
|
||||
// would hang. Reject up front.
|
||||
if self.waiters.contains_key(&fd) {
|
||||
if waiters.contains_key(&fd) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"fd already has a parked waiter",
|
||||
));
|
||||
}
|
||||
|
||||
// Belt-and-braces: the unwind guard in `wait_fd` is responsible for
|
||||
// cleaning up a stopped waiter's registration, but a bare DEL is
|
||||
// harmless if the fd isn't registered (ENOENT) and removes any leak
|
||||
// a path we haven't thought of might leave behind.
|
||||
// Belt-and-braces: `cancel_waiter` is responsible for cleaning up a
|
||||
// stopped waiter's registration, but a bare DEL is harmless if the
|
||||
// fd isn't registered (ENOENT) and removes any leak a path we
|
||||
// haven't thought of might leave behind.
|
||||
unsafe {
|
||||
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
||||
}
|
||||
@@ -318,19 +290,29 @@ impl IoThread {
|
||||
if r < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
self.waiters.insert(fd, (pid, epoch));
|
||||
waiters.insert(fd, (pid, epoch));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove `fd` from the epollfd. Called by the scheduler after a
|
||||
/// `FdReady` completion, so the next `wait_readable(fd)` can ADD again.
|
||||
///
|
||||
/// Does NOT touch `waiters` — that's the scheduler's bookkeeping; this
|
||||
/// is purely the kernel-side cleanup.
|
||||
pub fn epoll_deregister(&mut self, fd: RawFd) {
|
||||
// EPOLL_CTL_DEL of an already-removed fd returns ENOENT; ignore.
|
||||
unsafe {
|
||||
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
||||
/// Remove `fd`'s waiter iff it is still `(pid, epoch)`, DELing the fd
|
||||
/// from the epollfd in the same critical section. Returns whether the
|
||||
/// entry was removed (the caller then decrements `io_fd_waiters`).
|
||||
/// `false` means the epoll thread consumed the registration first —
|
||||
/// the fd may already carry someone else's fresh ADD; hands off.
|
||||
pub fn cancel_waiter(&mut self, fd: RawFd, pid: Pid, epoch: u32) -> bool {
|
||||
let mut waiters = match self.waiters.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: io waiters lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if waiters.get(&fd) == Some(&(pid, epoch)) {
|
||||
waiters.remove(&fd);
|
||||
// EPOLL_CTL_DEL of an already-removed fd returns ENOENT; ignore.
|
||||
unsafe {
|
||||
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +333,10 @@ impl Drop for IoThread {
|
||||
let real_tx = std::mem::replace(&mut self.tx, dead_tx);
|
||||
drop(real_tx);
|
||||
|
||||
// 3. Join both threads.
|
||||
// 3. Join both threads. Safe even while the caller holds the
|
||||
// runtime's `io` mutex: neither thread ever takes it (they reach
|
||||
// the runtime through a Weak they upgrade per completion, and
|
||||
// the epoll thread's only lock is `waiters`).
|
||||
if let Some(h) = self.epoll_thread.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
@@ -364,8 +349,6 @@ impl Drop for IoThread {
|
||||
libc::close(self.epollfd);
|
||||
libc::close(self.shutdown_read);
|
||||
libc::close(self.shutdown_write);
|
||||
libc::close(self.wake_read);
|
||||
libc::close(self.wake_write);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,36 +359,38 @@ impl Drop for IoThread {
|
||||
const SHUTDOWN_EPOLL_TOKEN: u64 = u64::MAX;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pool loop
|
||||
// Pool loop (producer: Blocking completions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pool_loop(
|
||||
rx: mpsc::Receiver<Request>,
|
||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
||||
wake_write: RawFd,
|
||||
) {
|
||||
fn pool_loop(rx: mpsc::Receiver<Request>, rt: Weak<RuntimeInner>) {
|
||||
while let Ok(Request { pid, epoch, work }) = rx.recv() {
|
||||
let result: IoResult = match panic::catch_unwind(panic::AssertUnwindSafe(work)) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => Err(payload),
|
||||
};
|
||||
completions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(Completion::Blocking { pid, epoch, result });
|
||||
wake_scheduler(wake_write);
|
||||
let Some(inner) = rt.upgrade() else { return };
|
||||
// Stash the result under the cold lock (generation-checked: an
|
||||
// actor stopped with the op in flight discards it), decrement the
|
||||
// in-flight count, then wake through the epoch-matched unpark. The
|
||||
// unpark's enqueue tail wakes a parked scheduler; the actor stays
|
||||
// `live` until it resumes and finalizes, so the decrement's
|
||||
// ordering against the termination verdict is not load-bearing.
|
||||
if let Some(slot) = inner.slot_at(pid) {
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.generation() == pid.generation() {
|
||||
cold.pending_io_result = Some(result);
|
||||
}
|
||||
}
|
||||
inner.io_outstanding.fetch_sub(1, Ordering::AcqRel);
|
||||
inner.unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Epoll loop
|
||||
// Epoll loop (producer: FdReady completions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn epoll_loop(
|
||||
epollfd: RawFd,
|
||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
||||
wake_write: RawFd,
|
||||
) {
|
||||
fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
|
||||
// Buffer for epoll_wait. 64 is plenty for our scale; if a real load
|
||||
// appears that needs more, this is a one-line change.
|
||||
const MAX_EVENTS: usize = 64;
|
||||
@@ -433,26 +418,41 @@ fn epoll_loop(
|
||||
}
|
||||
|
||||
let mut shutdown_requested = false;
|
||||
let mut pushed_any = false;
|
||||
{
|
||||
let mut q = completions.lock().unwrap();
|
||||
for ev in events.iter().take(n as usize) {
|
||||
if ev.u64 == SHUTDOWN_EPOLL_TOKEN {
|
||||
shutdown_requested = true;
|
||||
continue;
|
||||
for ev in events.iter().take(n as usize) {
|
||||
if ev.u64 == SHUTDOWN_EPOLL_TOKEN {
|
||||
shutdown_requested = true;
|
||||
continue;
|
||||
}
|
||||
let fd = ev.u64 as RawFd;
|
||||
// Consume the registration: remove + DEL under the waiters
|
||||
// lock (the ADD/DEL serialization — see module docs). A
|
||||
// vanished entry means `cancel_waiter` beat us: the wake is
|
||||
// already moot.
|
||||
let entry = {
|
||||
let mut w = match waiters.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
panic!("smarm: io waiters lock poisoned (core corrupt): {e}")
|
||||
}
|
||||
};
|
||||
let entry = w.remove(&fd);
|
||||
if entry.is_some() {
|
||||
unsafe {
|
||||
libc::epoll_ctl(
|
||||
epollfd,
|
||||
libc::EPOLL_CTL_DEL,
|
||||
fd,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let fd = ev.u64 as RawFd;
|
||||
let evs = ev.events;
|
||||
q.push_back(Completion::FdReady {
|
||||
fd,
|
||||
events: evs,
|
||||
});
|
||||
pushed_any = true;
|
||||
entry
|
||||
};
|
||||
if let Some((pid, epoch)) = entry {
|
||||
let Some(inner) = rt.upgrade() else { return };
|
||||
inner.io_fd_waiters.fetch_sub(1, Ordering::AcqRel);
|
||||
inner.unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
if pushed_any {
|
||||
wake_scheduler(wake_write);
|
||||
}
|
||||
if shutdown_requested {
|
||||
return;
|
||||
@@ -460,27 +460,8 @@ fn epoll_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one byte to the scheduler's wake pipe. Retries on EINTR; ignores
|
||||
/// EAGAIN (pipe full means there's already an outstanding wake we haven't
|
||||
/// consumed yet, which is sufficient).
|
||||
fn wake_scheduler(wake_write: RawFd) {
|
||||
let buf: [u8; 1] = [0];
|
||||
unsafe {
|
||||
loop {
|
||||
let n = libc::write(wake_write, buf.as_ptr() as *const _, 1);
|
||||
if n < 0 {
|
||||
let e = *libc::__errno_location();
|
||||
if e == libc::EINTR {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipe helpers (unchanged from v0.2)
|
||||
// Pipe helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_pipe() -> io::Result<(RawFd, RawFd)> {
|
||||
@@ -491,46 +472,3 @@ fn make_pipe() -> io::Result<(RawFd, RawFd)> {
|
||||
}
|
||||
Ok((fds[0], fds[1]))
|
||||
}
|
||||
|
||||
/// Drain pending bytes from the wake pipe. The scheduler calls this after
|
||||
/// a `poll` wakeup so the next idle call sees an empty pipe.
|
||||
pub fn drain_wake_pipe(fd: RawFd) {
|
||||
let mut buf = [0u8; 64];
|
||||
loop {
|
||||
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
if n <= 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block on `fd` for up to `timeout`, returning when either there's data
|
||||
/// to read or the timeout elapses. `None` for `timeout` means wait forever.
|
||||
pub fn poll_wake(fd: RawFd, timeout: Option<std::time::Duration>) {
|
||||
let timeout_ms: libc::c_int = match timeout {
|
||||
None => -1,
|
||||
Some(d) => {
|
||||
let ms = d.as_millis();
|
||||
if ms > i32::MAX as u128 {
|
||||
i32::MAX
|
||||
} else {
|
||||
ms as i32
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
loop {
|
||||
let r = unsafe { libc::poll(&mut pfd as *mut _, 1, timeout_ms) };
|
||||
if r < 0 {
|
||||
let e = unsafe { *libc::__errno_location() };
|
||||
if e == libc::EINTR {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -32,12 +32,14 @@ pub mod introspect;
|
||||
#[cfg(feature = "observer")]
|
||||
pub mod observer;
|
||||
pub mod runtime;
|
||||
pub(crate) mod park;
|
||||
pub(crate) mod raw_mutex;
|
||||
pub(crate) mod slot_state;
|
||||
pub(crate) mod sync_shim;
|
||||
#[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures
|
||||
pub mod run_queue;
|
||||
pub mod trace;
|
||||
pub mod causal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global allocator
|
||||
@@ -79,7 +81,8 @@ pub use registry::{
|
||||
};
|
||||
pub use runtime::{init, Config, Runtime};
|
||||
pub use scheduler::{
|
||||
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named, sleep,
|
||||
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named,
|
||||
send_after_named_wall, send_after_wall, sleep, sleep_wall,
|
||||
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable,
|
||||
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle,
|
||||
};
|
||||
|
||||
+4
-1
@@ -135,7 +135,10 @@ pub fn link<A>(target: Pid<A>) {
|
||||
|
||||
if registered_on_target {
|
||||
with_runtime(|inner| {
|
||||
let slot = inner.slot_at(me).expect("link: own slot vanished");
|
||||
let slot = match inner.slot_at(me) {
|
||||
Some(s) => s,
|
||||
None => panic!("smarm: link own slot vanished (core corrupt)"),
|
||||
};
|
||||
let mut cold = slot.cold.lock();
|
||||
if !cold.links.contains(&target) {
|
||||
cold.links.push(target);
|
||||
|
||||
+102
-62
@@ -1,49 +1,85 @@
|
||||
//! Process monitors.
|
||||
//! Find out when another actor dies, without it knowing or caring that you're
|
||||
//! watching.
|
||||
//!
|
||||
//! `monitor(target)` asks the runtime to deliver a single [`Down`] when
|
||||
//! `target` terminates, and hands back a [`Monitor`] — the [`Receiver`] to read
|
||||
//! it from, plus the identity (`id`, `target`) needed to take the registration
|
||||
//! back down with [`demonitor`]. A monitor is:
|
||||
//! Say one actor manages a pool of workers and needs to know when a worker
|
||||
//! exits, so it can replace it. The worker does not need to know it is being
|
||||
//! watched, and nothing about the worker's own behavior should change because
|
||||
//! someone is watching it. That is what [`monitor`] is for: call
|
||||
//! `monitor(target)` to get a [`Monitor`], and read exactly one [`Down`]
|
||||
//! message off `monitor.rx` whenever `target` terminates, however it
|
||||
//! terminates.
|
||||
//!
|
||||
//! - **unidirectional** — the watcher learns of the target's death, but the
|
||||
//! target learns nothing of the watcher, and the watcher is unaffected by
|
||||
//! the death beyond the notification (contrast a *link*, which propagates
|
||||
//! failure);
|
||||
//! - **one-shot** — exactly one `Down` is ever sent for a given monitor.
|
||||
//! The returned channel closes afterwards, so a second `recv()` yields
|
||||
//! `Err(RecvError)`.
|
||||
//! ```
|
||||
//! use smarm::{monitor, run, spawn, DownReason};
|
||||
//!
|
||||
//! This generalizes the older single-`supervisor_channel` mechanism: a
|
||||
//! supervisor is just a hard-wired monitor that the parent installs at spawn
|
||||
//! time. Here any actor may monitor any pid, any number of times.
|
||||
//! run(|| {
|
||||
//! let worker = spawn(|| {
|
||||
//! // does some work, then returns
|
||||
//! });
|
||||
//! let pid = worker.pid();
|
||||
//!
|
||||
//! ## Reasons
|
||||
//! let m = monitor(pid);
|
||||
//! let _ = worker.join();
|
||||
//!
|
||||
//! [`DownReason`] is deliberately payload-free. A panicking actor's payload
|
||||
//! has a single owner and is delivered to whoever `join()`s the actor (as
|
||||
//! `JoinError`); a monitor only learns *that* it panicked, not the value.
|
||||
//! Monitoring a pid that is already gone (reclaimed, or never alive) yields
|
||||
//! [`DownReason::NoProc`] immediately, mirroring Erlang's `noproc`.
|
||||
//! let down = m.rx.recv().expect("monitor channel closed before Down");
|
||||
//! assert_eq!(down.pid, pid);
|
||||
//! assert_eq!(down.reason, DownReason::Exit);
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Demonitoring
|
||||
//! A monitor is one-directional and one-shot:
|
||||
//!
|
||||
//! Each `monitor()` registration is tagged with a process-unique [`MonitorId`].
|
||||
//! [`demonitor`] removes the registration named by a [`Monitor`] from its
|
||||
//! target's slot, returning `Some(id)` if a live registration was found or
|
||||
//! `None` if it had already fired (or the target is gone). Dropping the
|
||||
//! [`Monitor`] afterwards discards any `Down` that the target had *already*
|
||||
//! queued — the equivalent of Erlang's `demonitor(Ref, [flush])`.
|
||||
//! - **One-directional**: the watcher learns that the target died, but the
|
||||
//! target is completely unaffected. It never learns it was being watched,
|
||||
//! and its own behavior and lifetime do not change because of the monitor.
|
||||
//! This is the opposite of a [`link`](mod@crate::link), which is bidirectional:
|
||||
//! linking two actors means an abnormal death on either side can bring the
|
||||
//! other down too. Reach for a monitor when you just want to *know*; reach
|
||||
//! for a link when a peer's crash should actually stop you.
|
||||
//! - **One-shot**: you get exactly one [`Down`] per `monitor()` call, then the
|
||||
//! channel closes. Calling `monitor` again on the same target (or a
|
||||
//! different one) gives you an independent registration with its own
|
||||
//! [`Monitor`] and its own one-shot channel; nothing stops you from
|
||||
//! monitoring the same actor many times over; each call is watched and
|
||||
//! fires on its own.
|
||||
//!
|
||||
//! ## Races
|
||||
//! ## Why a monitor never hands you the panic value
|
||||
//!
|
||||
//! Registration (below) and `finalize_actor` (in `runtime`) both run under the
|
||||
//! shared-state mutex, so a target that is still alive when its monitor is
|
||||
//! registered is guaranteed to deliver a real `Down`; there is no window in
|
||||
//! which the death slips between the liveness check and the registration.
|
||||
//! `demonitor` is protected by the generation half of the pid: if the target
|
||||
//! has died and its slot index been recycled, `slot_mut(target)` fails the
|
||||
//! generation check and `demonitor` is a clean no-op — it can never strip a
|
||||
//! *different* actor's monitor that happens to share the slot index.
|
||||
//! If the target panicked, [`Down`] tells you *that* it panicked
|
||||
//! ([`DownReason::Panic`]), but not the panic's payload. The payload has a
|
||||
//! single owner: it is handed to whichever caller `join()`s the actor's
|
||||
//! [`JoinHandle`](crate::JoinHandle), as a `JoinError`. A monitor only needs
|
||||
//! to know that something went wrong, not reproduce the exact value that
|
||||
//! caused it, so it gets the reason and nothing else.
|
||||
//!
|
||||
//! Monitoring a target that is already gone (it finished and was cleaned up,
|
||||
//! or the pid never pointed at a real actor) is not an error: you get a
|
||||
//! [`Down`] with [`DownReason::NoProc`] right away, instead of waiting
|
||||
//! forever for something that already happened.
|
||||
//!
|
||||
//! ## Stopping a monitor early
|
||||
//!
|
||||
//! [`demonitor`] cancels a monitor before it fires. If the registration was
|
||||
//! still live, it removes it and returns `Some` of the monitor's id: no
|
||||
//! `Down` will arrive on that channel from here on. If the target had already
|
||||
//! died and its `Down` already sent, there is nothing left to cancel and
|
||||
//! `demonitor` returns `None`; the `Down` you already have (or that is
|
||||
//! already sitting in the channel) is unaffected.
|
||||
//!
|
||||
//! If you want to cancel *and* make sure a `Down` that already arrived is
|
||||
//! discarded without reading it, just drop the [`Monitor`]: dropping it closes
|
||||
//! its receiver, and any queued `Down` is dropped along with it.
|
||||
//!
|
||||
//! ## Correctness notes for implementers
|
||||
//!
|
||||
//! A target that is still alive at the moment `monitor()` registers is
|
||||
//! guaranteed to eventually produce a real `Down`: registration and the
|
||||
//! target's own termination bookkeeping run under the same lock, so there is
|
||||
//! no window in which the target could die without the just-added
|
||||
//! registration seeing it. `demonitor` is similarly race-free against a target
|
||||
//! that has since died and had its slot reused by a new, unrelated actor: it
|
||||
//! is checked against the exact monitored incarnation, so it can never remove
|
||||
//! a different actor's registration by accident, it simply reports `None`.
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::pid::Pid;
|
||||
@@ -51,8 +87,8 @@ use crate::scheduler::with_runtime;
|
||||
|
||||
/// Why a monitored actor went down.
|
||||
///
|
||||
/// `Copy` because it carries no payload — see the module docs for why the
|
||||
/// panic payload is *not* included here.
|
||||
/// Carries no payload: see the module docs for why a monitor never receives
|
||||
/// the panic value itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DownReason {
|
||||
/// The target returned normally.
|
||||
@@ -76,21 +112,22 @@ pub struct Down {
|
||||
pub reason: DownReason,
|
||||
}
|
||||
|
||||
/// A process-unique identifier for one `monitor()` registration.
|
||||
/// A unique identifier for one [`monitor`] registration.
|
||||
///
|
||||
/// Opaque and `Copy`. Allocated from a monotonic counter in shared state, so
|
||||
/// it is never reused for the lifetime of the runtime — distinct `monitor()`
|
||||
/// calls on the same target get distinct ids, which is what lets [`demonitor`]
|
||||
/// tear down exactly one of several monitors on a target.
|
||||
/// Opaque and `Copy`. Never reused for the life of the runtime, so if you
|
||||
/// monitor the same target more than once, each call's id is distinct. This
|
||||
/// is what lets [`demonitor`] tear down exactly one of several monitors on
|
||||
/// the same target without disturbing the others.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MonitorId(pub(crate) u64);
|
||||
|
||||
/// A live monitor: the receiving end of the one-shot [`Down`] channel, plus the
|
||||
/// identity needed to [`demonitor`] it.
|
||||
///
|
||||
/// Read the notification from [`Monitor::rx`]. Not `Clone` (the receiver is a
|
||||
/// single consumer). Dropping it closes the receiving end; if a `Down` was
|
||||
/// already queued it is discarded with the channel.
|
||||
/// Read the notification from [`Monitor::rx`]. Not `Clone`, since only one
|
||||
/// side is meant to consume it. Dropping a `Monitor` closes the receiving
|
||||
/// end; if a `Down` had already arrived but was never read, it is discarded
|
||||
/// along with it.
|
||||
pub struct Monitor {
|
||||
/// This registration's process-unique id.
|
||||
pub id: MonitorId,
|
||||
@@ -110,10 +147,11 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
|
||||
let target = target.erase();
|
||||
let (tx, rx) = channel::<Down>();
|
||||
|
||||
// Register under the target's cold lock. `tx.clone()` takes the channel's
|
||||
// own lock — a Channel-class RawMutex, explicitly permitted *under* a Leaf
|
||||
// (cold) lock by the lock order (see raw_mutex.rs). We must still not
|
||||
// *send* under the lock, as `Sender::send` can unpark a parked receiver,
|
||||
// Implementation note: registration happens under the target's cold
|
||||
// lock. `tx.clone()` takes the channel's own lock, a Channel-class
|
||||
// RawMutex, which is explicitly permitted under a Leaf (cold) lock by
|
||||
// the lock order documented in raw_mutex.rs. We must still not *send*
|
||||
// under the lock, since `Sender::send` can unpark a parked receiver,
|
||||
// and there's no reason to nest that.
|
||||
let (id, registered) = with_runtime(|inner| {
|
||||
let id = inner.alloc_monitor_id();
|
||||
@@ -140,19 +178,21 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
|
||||
}
|
||||
|
||||
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
|
||||
/// on the target's slot and removed, or `None` if there was nothing to remove
|
||||
/// — the target already fired its `Down` (the registration is drained on
|
||||
/// finalize), was never alive (`NoProc`), or has been reclaimed.
|
||||
/// and removed, so no `Down` will arrive on `m.rx` from here on. Returns
|
||||
/// `None` if there was nothing left to remove: the target had already gone
|
||||
/// down and its `Down` was already sent (or is already sitting in the
|
||||
/// channel, unread).
|
||||
///
|
||||
/// This stops any *future* `Down`. To also discard a `Down` the target may have
|
||||
/// *already* queued (the finalize-races-demonitor case), drop `m` afterwards;
|
||||
/// dropping the [`Monitor`] closes its receiver and the queued notice goes with
|
||||
/// it — the analogue of Erlang's `demonitor(Ref, [flush])`.
|
||||
/// This only stops a *future* `Down`. If you also want to discard a `Down`
|
||||
/// that already arrived (or is about to, in a race with this call), drop `m`
|
||||
/// instead of, or in addition to, calling this: dropping the [`Monitor`]
|
||||
/// closes its receiver and any queued notice is discarded with it.
|
||||
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
|
||||
// Remove the registration under the target's cold lock, but move the
|
||||
// `Sender` *out* and let it drop only after the lock is released:
|
||||
// dropping the last sender runs `Sender::drop`, which may unpark a parked
|
||||
// receiver — legal under a cold lock, but pointless to nest.
|
||||
// Implementation note: the registration is removed under the target's
|
||||
// cold lock, but the `Sender` is moved *out* and dropped only after the
|
||||
// lock is released. Dropping the last sender runs `Sender::drop`, which
|
||||
// may unpark a parked receiver; legal under a cold lock, but pointless
|
||||
// to nest.
|
||||
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
|
||||
let slot = inner.slot_at(m.target)?;
|
||||
let mut cold = slot.cold.lock();
|
||||
|
||||
+209
-35
@@ -1,12 +1,89 @@
|
||||
//! Actor-aware mutex with mandatory timeout.
|
||||
//! Shared mutable state across actors, when a channel is overkill.
|
||||
//!
|
||||
//! `Mutex<T>` parks the calling *green* thread on contention rather than
|
||||
//! blocking the OS thread. Every lock attempt is bounded by a timeout.
|
||||
//! smarm actors normally coordinate by sending messages, and for a piece of
|
||||
//! owned state the right tool is usually a `gen_server`: one actor holds the
|
||||
//! data and everyone else talks to it. Sometimes that is more machinery than
|
||||
//! you need, and plain shared, lockable state is simpler: [`Mutex<T>`] is
|
||||
//! that escape hatch. It behaves like `std::sync::Mutex<T>`, guarding a value
|
||||
//! of type `T` behind a guard that gives you `&mut T` while held, but it is
|
||||
//! built for smarm's actors rather than OS threads.
|
||||
//!
|
||||
//! Internals use `Arc<std::sync::Mutex<...>>` so the type is genuinely
|
||||
//! `Send + Sync` and can be shared across scheduler threads.
|
||||
//! The key difference from `std::sync::Mutex` is what happens on contention.
|
||||
//! [`Mutex::lock`] parks the calling actor (a cooperatively scheduled green
|
||||
//! thread) rather than blocking the underlying OS thread, so other actors on
|
||||
//! the same OS thread keep running while it waits. And every lock attempt is
|
||||
//! bounded by a timeout: an actor that hangs on to the lock forever (stuck in
|
||||
//! a bug, or just slow) would otherwise wedge every other actor waiting on
|
||||
//! it, so smarm makes the wait bounded by default instead of leaving it up
|
||||
//! to you to remember.
|
||||
//!
|
||||
//! Fairness: FIFO. Poisoning: none. Reentrance: deadlock (caller bug).
|
||||
//! ## A first lock
|
||||
//!
|
||||
//! ```
|
||||
//! use smarm::{run, spawn, Mutex};
|
||||
//!
|
||||
//! run(|| {
|
||||
//! let counter = Mutex::new(0u32);
|
||||
//!
|
||||
//! // Mutex::clone() is cheap and hands out another handle to the SAME
|
||||
//! // underlying value, much like Arc::clone: every clone shares one lock
|
||||
//! // and one value, so mutations through one are visible through all.
|
||||
//! let a = counter.clone();
|
||||
//! let b = counter.clone();
|
||||
//!
|
||||
//! let h1 = spawn(move || {
|
||||
//! let mut guard = a.lock().unwrap();
|
||||
//! *guard += 1;
|
||||
//! });
|
||||
//! let h2 = spawn(move || {
|
||||
//! let mut guard = b.lock().unwrap();
|
||||
//! *guard += 1;
|
||||
//! });
|
||||
//! h1.join().unwrap();
|
||||
//! h2.join().unwrap();
|
||||
//!
|
||||
//! assert_eq!(*counter.lock().unwrap(), 2);
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Choosing a timeout
|
||||
//!
|
||||
//! [`Mutex::lock`] waits up to [`DEFAULT_TIMEOUT`] (30 seconds) before giving
|
||||
//! up with [`LockTimeout`]. To use a different bound for one call, use
|
||||
//! [`Mutex::lock_timeout`] instead; to change the default for every future
|
||||
//! `lock()` call on this mutex (including through its clones), use
|
||||
//! [`Mutex::set_default_timeout`]. If you never want to wait at all, use
|
||||
//! [`Mutex::try_lock`], which returns immediately whether or not the lock was
|
||||
//! free.
|
||||
//!
|
||||
//! ## Fairness and panics
|
||||
//!
|
||||
//! Waiters are granted the lock in the order they started waiting (FIFO), so
|
||||
//! no actor can be starved by later arrivals repeatedly cutting in line.
|
||||
//!
|
||||
//! This mutex never poisons. `std::sync::Mutex` marks itself poisoned if a
|
||||
//! thread panics while holding the lock, because a partly mutated value might
|
||||
//! be left behind for the next lock holder to see. smarm's actors already
|
||||
//! rely on `Drop` running during unwinding to release the lock, so if a
|
||||
//! holder panics, [`MutexGuard::drop`] still runs and the next waiter is
|
||||
//! granted the lock normally. It is the same tradeoff `std::sync::Mutex`
|
||||
//! offers you if you choose to ignore poisoning: you may see a value left
|
||||
//! mid-update by the panicking actor, so a panic inside a critical section is
|
||||
//! still a bug worth fixing, just not one that also wedges every future lock
|
||||
//! attempt.
|
||||
//!
|
||||
//! Locking a mutex you already hold (on the same actor) does not queue
|
||||
//! behind yourself: it deadlocks, the same way relocking a non-reentrant
|
||||
//! `std::sync::Mutex` does. Don't call `lock` while already holding a guard
|
||||
//! from the same `Mutex`.
|
||||
//!
|
||||
//! ## Outside the runtime
|
||||
//!
|
||||
//! `Mutex<T>` also works when called from plain code that is not running as
|
||||
//! a smarm actor (for example, in a test's setup code before calling
|
||||
//! [`run`](crate::run)). There, an actor's cooperative park has no meaning,
|
||||
//! so a lock attempt instead blocks the calling OS thread directly until the
|
||||
//! mutex is free; there is no timeout on this path.
|
||||
|
||||
use crate::pid::Pid;
|
||||
use crate::scheduler;
|
||||
@@ -15,8 +92,14 @@ use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long [`Mutex::lock`] waits for the lock before giving up, unless
|
||||
/// overridden per-mutex with [`Mutex::set_default_timeout`] or per-call with
|
||||
/// [`Mutex::lock_timeout`].
|
||||
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Returned by [`Mutex::lock`] / [`Mutex::lock_timeout`] when the timeout
|
||||
/// elapses before the lock became available. The lock attempt is abandoned;
|
||||
/// nothing was acquired, and the mutex's value is unaffected.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct LockTimeout;
|
||||
|
||||
@@ -64,20 +147,23 @@ impl MutexCore {
|
||||
impl TimerTarget for MutexCore {
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
let unpark = {
|
||||
let mut st = self.state.lock().unwrap();
|
||||
let mut st = match self.state.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
// Remove from waiters only if still there with matching epoch.
|
||||
// If the lock was already granted (holder == Some(pid)), the
|
||||
// timer fired after the grant — treat as no-op; the actor
|
||||
// timer fired after the grant: treat as no-op; the actor
|
||||
// will see `is_holder == true` and return Ok.
|
||||
if st.holder == Some(pid) {
|
||||
return;
|
||||
}
|
||||
let pos = st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch);
|
||||
if pos.is_some() {
|
||||
st.waiters.remove(pos.unwrap());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
match st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch) {
|
||||
Some(pos) => {
|
||||
st.waiters.remove(pos);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
if unpark {
|
||||
@@ -97,6 +183,8 @@ pub struct Mutex<T> {
|
||||
}
|
||||
|
||||
impl<T> Mutex<T> {
|
||||
/// Wrap `value` in a new mutex, initially unlocked, with the default
|
||||
/// lock timeout ([`DEFAULT_TIMEOUT`]).
|
||||
pub fn new(value: T) -> Self {
|
||||
Self {
|
||||
core: Arc::new(MutexCore::new(DEFAULT_TIMEOUT)),
|
||||
@@ -104,15 +192,36 @@ impl<T> Mutex<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Change how long future [`lock`](Self::lock) calls on this mutex wait
|
||||
/// before giving up. Applies to every clone of this `Mutex` (they share
|
||||
/// one underlying lock), and to `lock` calls already in progress that
|
||||
/// have not yet started waiting. Does not affect [`lock_timeout`](Self::lock_timeout)
|
||||
/// calls, which always use the timeout passed in.
|
||||
pub fn set_default_timeout(&self, timeout: Duration) {
|
||||
self.core.state.lock().unwrap().default_timeout = timeout;
|
||||
match self.core.state.lock() {
|
||||
Ok(mut st) => st.default_timeout = timeout,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the lock, waiting up to this mutex's default timeout
|
||||
/// ([`DEFAULT_TIMEOUT`], or whatever [`set_default_timeout`](Self::set_default_timeout)
|
||||
/// last set) if it is currently held elsewhere. Returns a [`MutexGuard`]
|
||||
/// that releases the lock when dropped, or [`LockTimeout`] if the
|
||||
/// deadline passes first. To use a one-off timeout instead of the
|
||||
/// mutex's default, call [`lock_timeout`](Self::lock_timeout) directly.
|
||||
pub fn lock(&self) -> Result<MutexGuard<'_, T>, LockTimeout> {
|
||||
let timeout = self.core.state.lock().unwrap().default_timeout;
|
||||
let timeout = match self.core.state.lock() {
|
||||
Ok(st) => st.default_timeout,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
self.lock_timeout(timeout)
|
||||
}
|
||||
|
||||
/// Acquire the lock, waiting up to `timeout` (ignoring this mutex's
|
||||
/// default) if it is currently held elsewhere. Returns a [`MutexGuard`]
|
||||
/// that releases the lock when dropped, or [`LockTimeout`] if `timeout`
|
||||
/// elapses first with the lock still unavailable.
|
||||
pub fn lock_timeout(&self, timeout: Duration) -> Result<MutexGuard<'_, T>, LockTimeout> {
|
||||
// Outside the runtime (e.g. in tests, after run() returns) there is no
|
||||
// current actor PID. Fall back to a blocking std::sync::Mutex acquire.
|
||||
@@ -122,12 +231,21 @@ impl<T> Mutex<T> {
|
||||
|
||||
// Fast path: nobody holds it.
|
||||
{
|
||||
let mut st = self.core.state.lock().unwrap();
|
||||
let mut st = match self.core.state.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if st.holder.is_none() {
|
||||
st.holder = Some(me);
|
||||
drop(st);
|
||||
let value = self.value.lock().unwrap().take()
|
||||
.expect("Mutex: value missing on free fast path");
|
||||
let taken = match self.value.lock() {
|
||||
Ok(mut g) => g.take(),
|
||||
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
let value = match taken {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: Mutex value missing on free fast path (core corrupt)"),
|
||||
};
|
||||
return Ok(MutexGuard { mutex: self, value: Some(value) });
|
||||
}
|
||||
}
|
||||
@@ -135,8 +253,11 @@ impl<T> Mutex<T> {
|
||||
// Slow path: register as a waiter, set timeout, park.
|
||||
let _np = scheduler::NoPreempt::enter();
|
||||
let epoch = {
|
||||
let mut st = self.core.state.lock().unwrap();
|
||||
// begin_wait is lock-free — legal under the state lock; this
|
||||
let mut st = match self.core.state.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
// begin_wait is lock-free (legal under the state lock); this
|
||||
// makes the epoch atomic with the registration's visibility to
|
||||
// grants and timeouts.
|
||||
let epoch = scheduler::begin_wait();
|
||||
@@ -149,30 +270,51 @@ impl<T> Mutex<T> {
|
||||
scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||
scheduler::park_current();
|
||||
|
||||
// Resumed — precisely: only our grant or our timer can wake this
|
||||
// Resumed, precisely: only our grant or our timer can wake this
|
||||
// wait (both epoch-stamped; a stop wake unwinds out of
|
||||
// park_current). The one-shot interpretation below is therefore
|
||||
// exhaustive. Are we the holder?
|
||||
let is_holder = self.core.state.lock().unwrap().holder == Some(me);
|
||||
let is_holder = match self.core.state.lock() {
|
||||
Ok(st) => st.holder == Some(me),
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if is_holder {
|
||||
let value = self.value.lock().unwrap().take()
|
||||
.expect("Mutex: value missing after grant");
|
||||
let taken = match self.value.lock() {
|
||||
Ok(mut g) => g.take(),
|
||||
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
let value = match taken {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: Mutex value missing after grant (core corrupt)"),
|
||||
};
|
||||
Ok(MutexGuard { mutex: self, value: Some(value) })
|
||||
} else {
|
||||
Err(LockTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the lock only if it is immediately available: never parks and
|
||||
/// never waits. Returns `Some` with a [`MutexGuard`] if the lock was
|
||||
/// free, `None` if it is currently held elsewhere.
|
||||
pub fn try_lock(&self) -> Option<MutexGuard<'_, T>> {
|
||||
let me = crate::actor::current_pid()?;
|
||||
let mut st = self.core.state.lock().unwrap();
|
||||
let mut st = match self.core.state.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if st.holder.is_some() {
|
||||
return None;
|
||||
}
|
||||
st.holder = Some(me);
|
||||
drop(st);
|
||||
let value = self.value.lock().unwrap().take()
|
||||
.expect("Mutex: value missing on try_lock free path");
|
||||
let taken = match self.value.lock() {
|
||||
Ok(mut g) => g.take(),
|
||||
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
let value = match taken {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: Mutex value missing on try_lock free path (core corrupt)"),
|
||||
};
|
||||
Some(MutexGuard { mutex: self, value: Some(value) })
|
||||
}
|
||||
|
||||
@@ -183,7 +325,10 @@ impl<T> Mutex<T> {
|
||||
// tracking and just grab the value mutex directly. This is safe because
|
||||
// outside the runtime there are no green threads competing.
|
||||
let value = loop {
|
||||
let v = self.value.lock().unwrap().take();
|
||||
let v = match self.value.lock() {
|
||||
Ok(mut g) => g.take(),
|
||||
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if let Some(v) = v { break v; }
|
||||
std::thread::yield_now();
|
||||
};
|
||||
@@ -192,6 +337,10 @@ impl<T> Mutex<T> {
|
||||
}
|
||||
|
||||
impl<T> Clone for Mutex<T> {
|
||||
/// Cheap: hands back another handle to the same underlying lock and
|
||||
/// value, the way `Arc::clone` does. All clones of a `Mutex` share one
|
||||
/// lock and one protected value; locking through any clone excludes
|
||||
/// every other clone.
|
||||
fn clone(&self) -> Self {
|
||||
Self { core: self.core.clone(), value: self.value.clone() }
|
||||
}
|
||||
@@ -205,6 +354,10 @@ unsafe impl<T: Send> Sync for Mutex<T> {}
|
||||
// Guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Grants access to the value inside a [`Mutex`] while the lock is held.
|
||||
/// Dereferences to `&T` and `&mut T`. Dropping the guard releases the lock
|
||||
/// and, if another actor is waiting, wakes the next one in arrival order.
|
||||
/// Returned by [`Mutex::lock`], [`Mutex::lock_timeout`], and [`Mutex::try_lock`].
|
||||
pub struct MutexGuard<'a, T> {
|
||||
mutex: &'a Mutex<T>,
|
||||
value: Option<T>,
|
||||
@@ -212,30 +365,51 @@ pub struct MutexGuard<'a, T> {
|
||||
|
||||
impl<T> std::ops::Deref for MutexGuard<'_, T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &T { self.value.as_ref().expect("MutexGuard: value missing") }
|
||||
fn deref(&self) -> &T {
|
||||
match self.value.as_ref() {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::DerefMut for MutexGuard<'_, T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
self.value.as_mut().expect("MutexGuard: value missing")
|
||||
match self.value.as_mut() {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: std::fmt::Debug> std::fmt::Debug for MutexGuard<'_, T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let value = match self.value.as_ref() {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: MutexGuard value missing (core corrupt)"),
|
||||
};
|
||||
f.debug_tuple("MutexGuard")
|
||||
.field(self.value.as_ref().expect("MutexGuard: value missing"))
|
||||
.field(value)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for MutexGuard<'_, T> {
|
||||
fn drop(&mut self) {
|
||||
let v = self.value.take().expect("MutexGuard: double drop");
|
||||
*self.mutex.value.lock().unwrap() = Some(v);
|
||||
let v = match self.value.take() {
|
||||
Some(v) => v,
|
||||
None => panic!("smarm: MutexGuard double drop (core corrupt)"),
|
||||
};
|
||||
match self.mutex.value.lock() {
|
||||
Ok(mut g) => *g = Some(v),
|
||||
Err(e) => panic!("smarm: mutex value lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
|
||||
let next = {
|
||||
let mut st = self.mutex.core.state.lock().unwrap();
|
||||
let mut st = match self.mutex.core.state.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
match st.waiters.pop_front() {
|
||||
Some(w) => {
|
||||
st.holder = Some(w.pid);
|
||||
|
||||
+994
@@ -0,0 +1,994 @@
|
||||
//! Scheduler park/wake coordination layer (RFC 018).
|
||||
//!
|
||||
//! Schedulers never touch an fd to sleep: they park on a per-thread
|
||||
//! [`Parker`] and are woken through an idle-mask protocol the runtime owns
|
||||
//! outright. IO backends (epoll today, io_uring later) are *producers*
|
||||
//! behind a two-call contract — make actors runnable, then wake — which is
|
||||
//! what makes backend selection tractable (RFC 018 §step-back).
|
||||
//!
|
||||
//! Three pieces, all in [`Coordinator`]:
|
||||
//!
|
||||
//! - **Parker** (one per scheduler): permit semantics, `std::thread::park`
|
||||
//! shaped — an unpark delivered before the park sets a permit; the next
|
||||
//! park consumes it and returns immediately. This single property closes
|
||||
//! the check-then-park race. Linux: `futex(2)` `FUTEX_WAIT`/`FUTEX_WAKE`
|
||||
//! (private) with a nanosecond-precision relative `timespec` — the
|
||||
//! `as_millis` truncation defect of the retired wake pipe is
|
||||
//! unrepresentable here. Loom / non-Linux: `Mutex<bool>` + `Condvar`
|
||||
//! (the loom models run against this build).
|
||||
//! - **Idle mask**: an `AtomicU64` bitmask of parked scheduler ids
|
||||
//! (construction asserts ≤ 64 schedulers). Park protocol: set own bit,
|
||||
//! run the caller's mandatory post-publish re-check, then wait. A
|
||||
//! producer that published work before observing our bit has left us
|
||||
//! work the re-check finds; one that observes the bit wakes us.
|
||||
//!
|
||||
//! The publish/re-check pair is a store-buffer (Dekker) shape. Two sound
|
||||
//! resolutions coexist here, chosen per call-site cost profile: the
|
||||
//! **fence handshake** on the hot producer path
|
||||
//! ([`Coordinator::wake_one_if_idle`]: publish work; `fence(SeqCst)`;
|
||||
//! one *Relaxed* mask load — pairing with the consumer's `fetch_or(bit)`;
|
||||
//! `fence(SeqCst)`; re-check inside [`Coordinator::park`]), so the
|
||||
//! pure-compute hot path (everyone busy, mask 0) never takes the shared
|
||||
//! mask line exclusive — the RFC's "one relaxed load" fast path, made
|
||||
//! sound; and the **same-location-RMW read** (`fetch_or(0)`, in
|
||||
//! [`Coordinator::wake_one`] / [`Coordinator::idle_mask`]) for the rare
|
||||
//! paths (chain rule — at most one per wake) where reading the latest
|
||||
//! mask by modification-order coherence is worth an RMW. The loom models
|
||||
//! drive the fence pattern end to end (a fence-less plain-load draft
|
||||
//! would — and did — fail model 1/2 with a lost wake, as it must).
|
||||
//! - **Timekeeper**: at most one parked scheduler holds the timer
|
||||
//! deadline (RFC 018 §timers) so a timer expiry wakes one scheduler,
|
||||
//! not a herd. The role is an atomic `(holder id, armed deadline)`
|
||||
//! pair; a timer insertion with an earlier deadline wakes the holder to
|
||||
//! re-peek. Arm / insert-check MUST be serialized by the caller (the
|
||||
//! timers mutex in the runtime) — the atomics exist so the *busy-path
|
||||
//! due-check* (one Relaxed load, [`Coordinator::armed_deadline_nanos`])
|
||||
//! and the wake stay lock-free. All races are biased over-wake: a
|
||||
//! spurious permit costs one failed pop; a missed wake would cost a
|
||||
//! stranded actor, and is unrepresentable under the serialization rule.
|
||||
//!
|
||||
//! Every wake here is *at most one* futex round-trip and wakes *exactly
|
||||
//! one* scheduler by construction (`wake_one` CASes a bit clear before
|
||||
//! unparking its owner) — there is no shared level-triggered anything
|
||||
//! left to herd on.
|
||||
//!
|
||||
//! Standalone until the runtime swap (RFC 018 commit 2): nothing outside
|
||||
//! tests constructs a [`Coordinator`] yet.
|
||||
|
||||
use crate::sync_shim::{fence, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Sentinel for "no timekeeper" in the holder word.
|
||||
const NO_TIMEKEEPER: u64 = u64::MAX;
|
||||
/// Sentinel for "no armed deadline" in the deadline word. Also what the
|
||||
/// busy-path due-check compares against: `now_nanos < armed` is one branch.
|
||||
pub(crate) const NO_DEADLINE: u64 = u64::MAX;
|
||||
|
||||
/// Outcome of a [`Coordinator::park`] call.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ParkResult {
|
||||
/// A permit was consumed (wake delivered before or during the park).
|
||||
Woken,
|
||||
/// The deadline passed with no wake. Only possible when a deadline was
|
||||
/// supplied (and never under loom, which has no time — see `park`).
|
||||
TimedOut,
|
||||
/// The post-publish re-check found work; the thread never blocked.
|
||||
/// A permit may still be pending (a racing `wake_one` picked us after
|
||||
/// the bit was set) — it will surface as one spurious `Woken` on a
|
||||
/// later park. Benign: over-wake by design.
|
||||
WorkFound,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parker — permit-semantics thread parking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Linux, non-loom: futex on a state word.
|
||||
///
|
||||
/// States: EMPTY (no permit, nobody waiting), PARKED (a thread is, or is
|
||||
/// about to be, in `futex_wait`), NOTIFIED (permit pending). The classic
|
||||
/// std-parker protocol: `unpark` swaps to NOTIFIED and futex-wakes iff it
|
||||
/// displaced PARKED; `park` CASes EMPTY→PARKED, waits, and consumes
|
||||
/// NOTIFIED on every exit path.
|
||||
#[cfg(all(target_os = "linux", not(loom)))]
|
||||
mod parker {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
const EMPTY: u32 = 0;
|
||||
const PARKED: u32 = 1;
|
||||
const NOTIFIED: u32 = 2;
|
||||
|
||||
pub(super) struct Parker {
|
||||
state: AtomicU32,
|
||||
}
|
||||
|
||||
impl Parker {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { state: AtomicU32::new(EMPTY) }
|
||||
}
|
||||
|
||||
/// Returns `true` = woken (permit consumed), `false` = timed out.
|
||||
pub(super) fn park(&self, deadline: Option<Instant>) -> bool {
|
||||
// Fast path: consume a pending permit without blocking.
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(EMPTY, PARKED, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
// Only NOTIFIED can be here (one thread parks at a time).
|
||||
self.state.store(EMPTY, Ordering::Release);
|
||||
return true;
|
||||
}
|
||||
loop {
|
||||
let timeout = match deadline {
|
||||
None => None,
|
||||
Some(d) => {
|
||||
let now = Instant::now();
|
||||
if d <= now {
|
||||
// Deadline passed: cancel the park. The swap
|
||||
// races a concurrent unpark — if it delivered
|
||||
// NOTIFIED first, report Woken (never lose a
|
||||
// permit).
|
||||
return self.state.swap(EMPTY, Ordering::AcqRel) == NOTIFIED;
|
||||
}
|
||||
Some(d - now)
|
||||
}
|
||||
};
|
||||
futex_wait(&self.state, PARKED, timeout);
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(NOTIFIED, EMPTY, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Spurious wake or timeout with state still PARKED: loop —
|
||||
// the deadline check at the top decides.
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unpark(&self) {
|
||||
if self.state.swap(NOTIFIED, Ordering::AcqRel) == PARKED {
|
||||
futex_wake(&self.state, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `FUTEX_WAIT` with a *relative* nanosecond timeout (`CLOCK_MONOTONIC`
|
||||
/// per futex(2) for relative waits). No millisecond conversion anywhere:
|
||||
/// the timespec carries the full sub-ms remainder (RFC 018 kills the
|
||||
/// `as_millis` truncation structurally).
|
||||
fn futex_wait(word: &AtomicU32, expected: u32, timeout: Option<std::time::Duration>) {
|
||||
let ts;
|
||||
let ts_ptr: *const libc::timespec = match timeout {
|
||||
Some(d) => {
|
||||
ts = libc::timespec {
|
||||
tv_sec: d.as_secs() as libc::time_t,
|
||||
tv_nsec: d.subsec_nanos() as libc::c_long,
|
||||
};
|
||||
&ts
|
||||
}
|
||||
None => std::ptr::null(),
|
||||
};
|
||||
// Errors (EAGAIN: word changed; ETIMEDOUT; EINTR) all mean "return
|
||||
// and let the caller's state machine decide" — deliberately ignored.
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_futex,
|
||||
word.as_ptr(),
|
||||
libc::FUTEX_WAIT | libc::FUTEX_PRIVATE_FLAG,
|
||||
expected,
|
||||
ts_ptr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn futex_wake(word: &AtomicU32, n: u32) {
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_futex,
|
||||
word.as_ptr(),
|
||||
libc::FUTEX_WAKE | libc::FUTEX_PRIVATE_FLAG,
|
||||
n,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loom / non-Linux: `Mutex<bool>` permit + `Condvar` — loom's own model
|
||||
/// of a parker, and the portable fallback. Under loom the deadline is
|
||||
/// ignored (loom has no time); the models exercise wake paths only.
|
||||
#[cfg(any(loom, not(target_os = "linux")))]
|
||||
mod parker {
|
||||
use crate::sync_shim::{Condvar, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
pub(super) struct Parker {
|
||||
permit: Mutex<bool>,
|
||||
cv: Condvar,
|
||||
}
|
||||
|
||||
impl Parker {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { permit: Mutex::new(false), cv: Condvar::new() }
|
||||
}
|
||||
|
||||
/// Returns `true` = woken (permit consumed), `false` = timed out.
|
||||
pub(super) fn park(&self, deadline: Option<Instant>) -> bool {
|
||||
let mut permit = match self.permit.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => panic!("smarm: parker permit lock poisoned (core corrupt)"),
|
||||
};
|
||||
loop {
|
||||
if *permit {
|
||||
*permit = false;
|
||||
return true;
|
||||
}
|
||||
#[cfg(loom)]
|
||||
{
|
||||
// Loom has no clock: block until a wake. Models must
|
||||
// deliver one (a park nobody wakes is a real deadlock
|
||||
// and loom reports it as such).
|
||||
let _ = deadline;
|
||||
permit = match self.cv.wait(permit) {
|
||||
Ok(g) => g,
|
||||
Err(_) => panic!("smarm: parker cv poisoned (core corrupt)"),
|
||||
};
|
||||
}
|
||||
#[cfg(not(loom))]
|
||||
{
|
||||
match deadline {
|
||||
None => {
|
||||
permit = match self.cv.wait(permit) {
|
||||
Ok(g) => g,
|
||||
Err(_) => panic!("smarm: parker cv poisoned (core corrupt)"),
|
||||
};
|
||||
}
|
||||
Some(d) => {
|
||||
let now = Instant::now();
|
||||
if d <= now {
|
||||
return false;
|
||||
}
|
||||
permit = match self.cv.wait_timeout(permit, d - now) {
|
||||
Ok((g, _)) => g,
|
||||
Err(_) => panic!("smarm: parker cv poisoned (core corrupt)"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unpark(&self) {
|
||||
let mut permit = match self.permit.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => panic!("smarm: parker permit lock poisoned (core corrupt)"),
|
||||
};
|
||||
*permit = true;
|
||||
self.cv.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use parker::Parker;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinator — idle mask + wake protocol + timekeeper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) struct Coordinator {
|
||||
parkers: Box<[Parker]>,
|
||||
/// Bit `i` set = scheduler `i` is parked or committed to parking (set
|
||||
/// before the re-check; cleared by `wake_one`'s CAS or by the parker
|
||||
/// itself on return). AcqRel same-location-RMW handshake — see module
|
||||
/// docs (no SeqCst needed: every producer-side read is an RMW).
|
||||
idle: AtomicU64,
|
||||
/// Timekeeper holder id, or `NO_TIMEKEEPER`. Written under the
|
||||
/// caller's timer serialization (arm/disarm/insert-check); read
|
||||
/// lock-free by the insert wake path.
|
||||
tk_holder: AtomicU64,
|
||||
/// Armed deadline as nanos since `origin`, or `NO_DEADLINE`. Written
|
||||
/// only by the timekeeper arm/disarm protocol.
|
||||
tk_armed: AtomicU64,
|
||||
/// Earliest KNOWN timer deadline (nanos since `origin`), or
|
||||
/// `NO_DEADLINE` — independent of whether any scheduler is parked,
|
||||
/// which is what the timekeeper's `tk_armed` cannot give: under
|
||||
/// saturation nobody parks and nobody arms, yet due timers must still
|
||||
/// fire (ratified design point (a)). Maintained under the caller's
|
||||
/// timers mutex (`note_deadline` on insert, `refresh_deadline` after a
|
||||
/// pop/peek); read lock-free by the busy-path due-check.
|
||||
next_deadline: AtomicU64,
|
||||
/// Time origin for the nanos encoding.
|
||||
origin: Instant,
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
pub(crate) fn new(schedulers: usize) -> Self {
|
||||
assert!(
|
||||
(1..=64).contains(&schedulers),
|
||||
"smarm: scheduler count must be 1..=64 (idle mask is one u64); got {schedulers}"
|
||||
);
|
||||
Self {
|
||||
parkers: (0..schedulers).map(|_| Parker::new()).collect(),
|
||||
idle: AtomicU64::new(0),
|
||||
tk_holder: AtomicU64::new(NO_TIMEKEEPER),
|
||||
tk_armed: AtomicU64::new(NO_DEADLINE),
|
||||
next_deadline: AtomicU64::new(NO_DEADLINE),
|
||||
origin: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a deadline for the armed snapshot / busy-path compare.
|
||||
/// Saturating: a deadline at-or-before `origin` encodes as 0 (always
|
||||
/// due), one beyond ~584 years as `NO_DEADLINE - 1`.
|
||||
pub(crate) fn deadline_nanos(&self, deadline: Instant) -> u64 {
|
||||
let nanos = deadline
|
||||
.checked_duration_since(self.origin)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
if nanos >= NO_DEADLINE as u128 {
|
||||
NO_DEADLINE - 1
|
||||
} else {
|
||||
nanos as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Park scheduler `id` until a wake, the deadline, or a positive
|
||||
/// re-check. Protocol: (1) publish own idle bit (SeqCst), (2) run
|
||||
/// `recheck` — it MUST re-read the work source with an ordering that
|
||||
/// pairs with the producer's publish (SeqCst load, or take the mutex
|
||||
/// the producer publishes under); a `true` aborts the park, (3) block.
|
||||
pub(crate) fn park(
|
||||
&self,
|
||||
id: usize,
|
||||
deadline: Option<Instant>,
|
||||
recheck: impl FnOnce() -> bool,
|
||||
) -> ParkResult {
|
||||
debug_assert!(id < self.parkers.len(), "park: scheduler id out of range");
|
||||
let bit = 1u64 << id;
|
||||
// (1) publish. AcqRel RMW: the acquire half is the handshake — if
|
||||
// this lands after a producer's mask RMW in modification order, we
|
||||
// read-from it and the producer's earlier work publication is
|
||||
// visible to the re-check below (see module docs).
|
||||
let prev = self.idle.fetch_or(bit, Ordering::AcqRel);
|
||||
debug_assert_eq!(prev & bit, 0, "park: idle bit already set for this id");
|
||||
// Fence half of the producer handshake (see `wake_one_if_idle`):
|
||||
// orders our bit-publish before the re-check's loads, so it pairs
|
||||
// with the producer's publish→fence→mask-load — at least one side
|
||||
// must see the other's store, whichever queue backend is in play.
|
||||
fence(Ordering::SeqCst);
|
||||
// (2) the mandatory post-publish re-check.
|
||||
if recheck() {
|
||||
self.idle.fetch_and(!bit, Ordering::AcqRel);
|
||||
return ParkResult::WorkFound;
|
||||
}
|
||||
// (3) block. The permit closes the window between the re-check and
|
||||
// the futex wait: a wake_one that picked us in that window has
|
||||
// already CASed our bit clear and set the permit.
|
||||
let woken = self.parkers[id].park(deadline);
|
||||
// Clear own bit — a no-op when a waker already CASed it clear.
|
||||
self.idle.fetch_and(!bit, Ordering::AcqRel);
|
||||
if woken {
|
||||
ParkResult::Woken
|
||||
} else {
|
||||
ParkResult::TimedOut
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake exactly one parked scheduler, if any: pick the highest set idle
|
||||
/// bit (LIFO-ish — warmest cache), CAS it clear, deliver a permit.
|
||||
/// Empty mask = no-op (everyone is awake and will find work by
|
||||
/// popping). Returns whether a scheduler was woken.
|
||||
pub(crate) fn wake_one(&self) -> bool {
|
||||
// RMW read, not a load: reads the latest mask by modification-order
|
||||
// coherence, closing the Dekker race with a parking consumer (see
|
||||
// module docs). The release side of the RMW is what a later-parking
|
||||
// consumer's fetch_or acquires to make its re-check sound.
|
||||
let mut mask = self.idle.fetch_or(0, Ordering::AcqRel);
|
||||
loop {
|
||||
if mask == 0 {
|
||||
return false;
|
||||
}
|
||||
let id = 63 - mask.leading_zeros() as usize; // highest set bit
|
||||
let bit = 1u64 << id;
|
||||
// The CAS is the exactly-one guarantee: whoever clears the bit
|
||||
// owns the wake; a racing wake_one retries on the observed value
|
||||
// (coherence: a failed CAS can never read older than `mask`).
|
||||
match self.idle.compare_exchange(
|
||||
mask,
|
||||
mask & !bit,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => {
|
||||
self.parkers[id].unpark();
|
||||
return true;
|
||||
}
|
||||
Err(m) => mask = m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The producer-side wake tail (`enqueue`'s fast path, RFC 018 "enqueue
|
||||
/// wakes"). The caller has just published work (queue push); we fence,
|
||||
/// then read the mask with ONE Relaxed load — 0 means every scheduler
|
||||
/// is awake and the pure-compute hot path pays no RMW on the shared
|
||||
/// mask line. Soundness is the fence handshake (module docs): our
|
||||
/// fence orders the caller's push before the mask load; the consumer's
|
||||
/// fence (in `park`) orders its bit-publish before its re-check — at
|
||||
/// least one side must observe the other's store, so a consumer we
|
||||
/// miss here is a consumer whose re-check finds the caller's work.
|
||||
pub(crate) fn wake_one_if_idle(&self) -> bool {
|
||||
fence(Ordering::SeqCst);
|
||||
if self.idle.load(Ordering::Relaxed) == 0 {
|
||||
return false;
|
||||
}
|
||||
self.wake_one()
|
||||
}
|
||||
|
||||
/// Terminal wake (replaces the AllDone wake-pipe byte): clear the mask
|
||||
/// and deliver a permit to *every* parker, parked or not. A permit set
|
||||
/// on a busy scheduler costs one spurious park return — nothing at the
|
||||
/// terminal boundary. Idempotent.
|
||||
pub(crate) fn wake_all(&self) {
|
||||
self.idle.store(0, Ordering::Release);
|
||||
for p in self.parkers.iter() {
|
||||
p.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
/// Latest idle mask (RMW read — same handshake as `wake_one`). A
|
||||
/// test-only observer: production expresses the chain rule through
|
||||
/// `wake_one_if_idle` (fence + Relaxed load), not a mask read.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn idle_mask(&self) -> u64 {
|
||||
self.idle.fetch_or(0, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
// ----- timekeeper -----
|
||||
|
||||
/// Try to take the timekeeper role for scheduler `id` with `deadline`.
|
||||
/// MUST be called under the caller's timer serialization (the timers
|
||||
/// mutex), with `deadline` the heap minimum peeked under that same
|
||||
/// hold — this is what makes the insert-check race-free. Returns
|
||||
/// whether the role was taken (false = someone else holds it; park
|
||||
/// with no deadline).
|
||||
pub(crate) fn try_arm_timer(&self, id: usize, deadline: Instant) -> bool {
|
||||
debug_assert!(id < self.parkers.len(), "try_arm_timer: id out of range");
|
||||
if self
|
||||
.tk_holder
|
||||
.compare_exchange(NO_TIMEKEEPER, id as u64, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Holder-then-deadline order: an insert-check that sees the holder
|
||||
// with the deadline still NO_DEADLINE compares `new < MAX` = true
|
||||
// and over-wakes — the benign direction. (Under the mandated timer
|
||||
// serialization this interleaving cannot occur anyway.)
|
||||
self.tk_armed.store(self.deadline_nanos(deadline), Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
|
||||
/// Release the timekeeper role (the holder, on wake, before it
|
||||
/// re-peeks/fires). Callable without the timer serialization: a
|
||||
/// racing insert may wake a no-longer-holder — over-wake, benign.
|
||||
pub(crate) fn disarm_timer(&self, id: usize) {
|
||||
debug_assert_eq!(
|
||||
self.tk_holder.load(Ordering::SeqCst),
|
||||
id as u64,
|
||||
"disarm_timer by a non-holder"
|
||||
);
|
||||
// Deadline first: a concurrent insert-check then sees NO_DEADLINE
|
||||
// and skips the (now pointless) wake instead of waking a stale
|
||||
// holder id. Either order is correct; this one wastes less.
|
||||
self.tk_armed.store(NO_DEADLINE, Ordering::SeqCst);
|
||||
self.tk_holder.store(NO_TIMEKEEPER, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Insert-side re-arm check: if `deadline` is earlier than the armed
|
||||
/// snapshot, wake the timekeeper to re-peek. MUST be called under the
|
||||
/// same timer serialization as `try_arm_timer` (see there).
|
||||
pub(crate) fn timer_inserted(&self, deadline: Instant) {
|
||||
if self.deadline_nanos(deadline) < self.tk_armed.load(Ordering::SeqCst) {
|
||||
let holder = self.tk_holder.load(Ordering::SeqCst);
|
||||
if holder != NO_TIMEKEEPER {
|
||||
// Direct unpark, not wake_one: the wake targets the
|
||||
// timekeeper specifically (it must re-peek the heap). Its
|
||||
// idle bit stays set until it returns from park — a
|
||||
// concurrent wake_one may pick it too; over-wake, benign.
|
||||
self.parkers[holder as usize].unpark();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The armed-deadline snapshot (nanos since origin; `NO_DEADLINE` =
|
||||
/// none). Test-only introspection on the timekeeper's armed value; the
|
||||
/// busy-path due-check reads `next_deadline`, not this.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn armed_deadline_nanos(&self) -> u64 {
|
||||
self.tk_armed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// ----- earliest-deadline snapshot (busy-path due-check) -----
|
||||
|
||||
/// Record a newly inserted timer deadline. MUST be called under the
|
||||
/// timers mutex (same serialization rule as `try_arm_timer`), which is
|
||||
/// why plain compare+store suffices for the min-maintenance. Also runs
|
||||
/// the timekeeper re-arm check (`timer_inserted`) — one call site for
|
||||
/// both consequences of an insert.
|
||||
pub(crate) fn note_deadline(&self, deadline: Instant) {
|
||||
let n = self.deadline_nanos(deadline);
|
||||
if n < self.next_deadline.load(Ordering::Relaxed) {
|
||||
self.next_deadline.store(n, Ordering::Release);
|
||||
}
|
||||
self.timer_inserted(deadline);
|
||||
}
|
||||
|
||||
/// Re-anchor the snapshot to the heap minimum (`None` = heap empty)
|
||||
/// after a `pop_due` / `clear`. MUST be called under the timers mutex.
|
||||
pub(crate) fn refresh_deadline(&self, next: Option<Instant>) {
|
||||
let n = next.map_or(NO_DEADLINE, |d| self.deadline_nanos(d));
|
||||
self.next_deadline.store(n, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Busy-path due-check: is the earliest known deadline at or past
|
||||
/// `now`? One Relaxed load when no deadline is armed — the clock is
|
||||
/// read only when a timer actually exists (matching the old drain
|
||||
/// phase's is_empty guard), so the pure-compute hot path pays a load
|
||||
/// and a branch.
|
||||
pub(crate) fn deadline_due(&self) -> bool {
|
||||
let n = self.next_deadline.load(Ordering::Relaxed);
|
||||
n != NO_DEADLINE && self.deadline_nanos(Instant::now()) >= n
|
||||
}
|
||||
|
||||
/// The earliest-deadline snapshot as an `Instant` (`None` = no timer
|
||||
/// pending). Test-only: the idle path arms the timekeeper from the
|
||||
/// timer heap's own `peek_deadline` under the timers mutex.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn next_deadline_instant(&self) -> Option<Instant> {
|
||||
let n = self.next_deadline.load(Ordering::Acquire);
|
||||
if n == NO_DEADLINE {
|
||||
None
|
||||
} else {
|
||||
self.origin.checked_add(std::time::Duration::from_nanos(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (std build)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, not(loom)))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as O};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn permit_before_park_returns_immediately() {
|
||||
let c = Coordinator::new(1);
|
||||
// Deliver the wake first (nobody parked: wake_one no-ops on the
|
||||
// mask, so use the timekeeper-direct path? No — permit semantics
|
||||
// are the parker's own; exercise via wake_all which permits all).
|
||||
c.wake_all();
|
||||
let t0 = Instant::now();
|
||||
let r = c.park(0, None, || false);
|
||||
assert_eq!(r, ParkResult::Woken);
|
||||
assert!(t0.elapsed() < Duration::from_millis(100), "park blocked despite permit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submillisecond_deadline_is_honored() {
|
||||
// Regression for the retired as_millis truncation: a 500µs deadline
|
||||
// must neither busy-return instantly forever nor round to 0/∞.
|
||||
let c = Coordinator::new(1);
|
||||
let t0 = Instant::now();
|
||||
let r = c.park(0, Some(t0 + Duration::from_micros(500)), || false);
|
||||
let dt = t0.elapsed();
|
||||
assert_eq!(r, ParkResult::TimedOut);
|
||||
assert!(dt >= Duration::from_micros(400), "woke too early: {dt:?}");
|
||||
assert!(dt < Duration::from_millis(50), "overslept: {dt:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recheck_true_aborts_park_and_clears_bit() {
|
||||
let c = Coordinator::new(2);
|
||||
let r = c.park(1, None, || true);
|
||||
assert_eq!(r, ParkResult::WorkFound);
|
||||
assert_eq!(c.idle_mask(), 0, "bit not cleared after WorkFound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recheck_observes_own_bit_published() {
|
||||
let c = Coordinator::new(2);
|
||||
let seen = std::cell::Cell::new(0u64);
|
||||
let r = c.park(1, None, || {
|
||||
seen.set(c.idle_mask());
|
||||
true
|
||||
});
|
||||
assert_eq!(r, ParkResult::WorkFound);
|
||||
assert_eq!(seen.get() & 0b10, 0b10, "bit not published before re-check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_one_wakes_exactly_one_of_n() {
|
||||
const N: usize = 4;
|
||||
let c = Arc::new(Coordinator::new(N));
|
||||
let woken = Arc::new(AtomicUsize::new(0));
|
||||
let mut ts = Vec::new();
|
||||
for id in 0..N {
|
||||
let c = c.clone();
|
||||
let woken = woken.clone();
|
||||
ts.push(std::thread::spawn(move || {
|
||||
let r = c.park(id, None, || false);
|
||||
assert_eq!(r, ParkResult::Woken);
|
||||
woken.fetch_add(1, O::SeqCst);
|
||||
}));
|
||||
}
|
||||
// Wait until all four are published idle.
|
||||
let t0 = Instant::now();
|
||||
while c.idle_mask().count_ones() != N as u32 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "threads never parked");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(c.wake_one());
|
||||
// Exactly one wakes; give the others a beat to (incorrectly) wake.
|
||||
let t0 = Instant::now();
|
||||
while woken.load(O::SeqCst) == 0 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "wake_one woke nobody");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(woken.load(O::SeqCst), 1, "wake_one woke more than one");
|
||||
assert_eq!(c.idle_mask().count_ones(), (N - 1) as u32);
|
||||
c.wake_all();
|
||||
for t in ts {
|
||||
match t.join() {
|
||||
Ok(()) => {}
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
}
|
||||
}
|
||||
assert_eq!(woken.load(O::SeqCst), N);
|
||||
assert_eq!(c.idle_mask(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_one_prefers_highest_bit() {
|
||||
let c = Arc::new(Coordinator::new(3));
|
||||
let woken_id = Arc::new(AtomicUsize::new(usize::MAX));
|
||||
let mut ts = Vec::new();
|
||||
for id in 0..3 {
|
||||
let c = c.clone();
|
||||
let woken_id = woken_id.clone();
|
||||
ts.push(std::thread::spawn(move || {
|
||||
if c.park(id, None, || false) == ParkResult::Woken {
|
||||
let _ = woken_id.compare_exchange(usize::MAX, id, O::SeqCst, O::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
while c.idle_mask() != 0b111 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5));
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(c.wake_one());
|
||||
let t0 = Instant::now();
|
||||
while woken_id.load(O::SeqCst) == usize::MAX {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5));
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert_eq!(woken_id.load(O::SeqCst), 2, "LIFO-ish: highest bit first");
|
||||
c.wake_all();
|
||||
for t in ts {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_one_on_empty_mask_is_noop() {
|
||||
let c = Coordinator::new(2);
|
||||
assert!(!c.wake_one());
|
||||
assert_eq!(c.idle_mask(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timekeeper_arm_is_exclusive_and_snapshot_readable() {
|
||||
let c = Coordinator::new(2);
|
||||
let d2 = Instant::now() + Duration::from_secs(10);
|
||||
let d1 = Instant::now() + Duration::from_secs(1);
|
||||
assert_eq!(c.armed_deadline_nanos(), NO_DEADLINE);
|
||||
assert!(c.try_arm_timer(0, d2));
|
||||
assert!(!c.try_arm_timer(1, d1), "second arm must fail while held");
|
||||
assert_eq!(c.armed_deadline_nanos(), c.deadline_nanos(d2));
|
||||
c.disarm_timer(0);
|
||||
assert_eq!(c.armed_deadline_nanos(), NO_DEADLINE);
|
||||
assert!(c.try_arm_timer(1, d1), "role must be re-takeable after disarm");
|
||||
c.disarm_timer(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_insert_wakes_timekeeper() {
|
||||
let c = Coordinator::new(2);
|
||||
let far = Instant::now() + Duration::from_secs(60);
|
||||
let near = Instant::now() + Duration::from_millis(1);
|
||||
assert!(c.try_arm_timer(0, far));
|
||||
// Holder not yet parked: the wake must land as a permit.
|
||||
c.timer_inserted(near);
|
||||
let t0 = Instant::now();
|
||||
let r = c.park(0, Some(far), || false);
|
||||
assert_eq!(r, ParkResult::Woken, "re-arm wake lost");
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "slept toward the stale deadline");
|
||||
c.disarm_timer(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_insert_does_not_wake_timekeeper() {
|
||||
let c = Coordinator::new(2);
|
||||
let near = Instant::now() + Duration::from_millis(20);
|
||||
let far = Instant::now() + Duration::from_secs(60);
|
||||
assert!(c.try_arm_timer(0, near));
|
||||
c.timer_inserted(far); // later than armed: no wake
|
||||
let r = c.park(0, Some(near), || false);
|
||||
assert_eq!(r, ParkResult::TimedOut, "spurious wake for a later insert");
|
||||
c.disarm_timer(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "1..=64")]
|
||||
fn more_than_64_schedulers_asserts() {
|
||||
let _ = Coordinator::new(65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_one_if_idle_noop_on_empty_and_wakes_on_parked() {
|
||||
let c = Arc::new(Coordinator::new(1));
|
||||
assert!(!c.wake_one_if_idle(), "empty mask must be a no-op");
|
||||
let c2 = c.clone();
|
||||
let t = std::thread::spawn(move || {
|
||||
assert_eq!(c2.park(0, None, || false), ParkResult::Woken);
|
||||
});
|
||||
let t0 = Instant::now();
|
||||
while c.idle_mask() == 0 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "never parked");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(c.wake_one_if_idle());
|
||||
match t.join() {
|
||||
Ok(()) => {}
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_snapshot_min_maintenance_and_due_check() {
|
||||
let c = Coordinator::new(1);
|
||||
assert!(!c.deadline_due(), "no deadline: never due");
|
||||
assert_eq!(c.next_deadline_instant(), None);
|
||||
let far = Instant::now() + Duration::from_secs(60);
|
||||
let near = Instant::now() + Duration::from_millis(1);
|
||||
c.note_deadline(far);
|
||||
assert!(!c.deadline_due());
|
||||
c.note_deadline(near); // min wins
|
||||
assert!(c.next_deadline_instant().is_some_and(|d| d <= near));
|
||||
c.note_deadline(far); // later insert must NOT raise the snapshot
|
||||
assert!(c.next_deadline_instant().is_some_and(|d| d <= near));
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
assert!(c.deadline_due(), "past deadline not reported due");
|
||||
c.refresh_deadline(Some(far));
|
||||
assert!(!c.deadline_due(), "refresh did not re-anchor");
|
||||
c.refresh_deadline(None);
|
||||
assert_eq!(c.next_deadline_instant(), None);
|
||||
// A deadline at/before origin encodes as 0: always due.
|
||||
c.note_deadline(Instant::now() - Duration::from_secs(1));
|
||||
assert!(c.deadline_due());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_deadline_earlier_wakes_timekeeper_via_snapshot_path() {
|
||||
// note_deadline must carry the timer_inserted re-arm wake too.
|
||||
let c = Coordinator::new(2);
|
||||
let far = Instant::now() + Duration::from_secs(60);
|
||||
let near = Instant::now() + Duration::from_millis(1);
|
||||
assert!(c.try_arm_timer(0, far));
|
||||
c.note_deadline(near);
|
||||
let r = c.park(0, Some(far), || false);
|
||||
assert_eq!(r, ParkResult::Woken, "re-arm wake lost through note_deadline");
|
||||
c.disarm_timer(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loom models — RUSTFLAGS="--cfg loom" cargo test --lib --release park
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, loom))]
|
||||
mod loom_tests {
|
||||
use super::*;
|
||||
use loom::sync::atomic::{AtomicU64 as LAtomicU64, Ordering as O};
|
||||
use loom::sync::{Arc, Mutex as LMutex};
|
||||
use loom::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// RFC 018 loom model 1 — no lost wake.
|
||||
/// producer{publish item; wake_one} ∥ consumer{set bit; re-check; park}:
|
||||
/// the consumer always observes the item or a permit; it can never
|
||||
/// sleep past a published item (loom's deadlock detector is the
|
||||
/// assertion — a consumer parked forever fails the model).
|
||||
#[test]
|
||||
fn no_lost_wake() {
|
||||
loom::model(|| {
|
||||
let c = Arc::new(Coordinator::new(1));
|
||||
let item = Arc::new(LAtomicU64::new(0));
|
||||
|
||||
let prod = {
|
||||
let c = c.clone();
|
||||
let item = item.clone();
|
||||
thread::spawn(move || {
|
||||
// The enqueue shape: publish, then the fenced fast-path
|
||||
// wake tail (this is what the runtime's enqueue calls).
|
||||
item.store(1, O::SeqCst);
|
||||
c.wake_one_if_idle();
|
||||
})
|
||||
};
|
||||
|
||||
// Consumer: loop until the item is popped. Guarded load+CAS,
|
||||
// not a blind swap — a swap writes 0 even when empty, and
|
||||
// coherence allows that write to land after the producer's
|
||||
// store in modification order, destroying the item (a model
|
||||
// bug loom caught in an earlier draft of this test).
|
||||
loop {
|
||||
if item.load(O::SeqCst) == 1
|
||||
&& item.compare_exchange(1, 0, O::SeqCst, O::SeqCst).is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
let _ = c.park(0, None, || item.load(O::SeqCst) == 1);
|
||||
}
|
||||
prod.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
/// RFC 018 loom model 2 — chain propagation.
|
||||
/// Two items, two sleepers, ONE producer wake: the chain rule (a woken
|
||||
/// consumer that sees surplus work and a non-empty mask wakes again)
|
||||
/// must get both items consumed with no further producer action.
|
||||
#[test]
|
||||
fn chain_propagation() {
|
||||
loom::model(|| {
|
||||
let c = Arc::new(Coordinator::new(2));
|
||||
let items = Arc::new(LAtomicU64::new(0));
|
||||
let consumed = Arc::new(LAtomicU64::new(0));
|
||||
|
||||
let mut hs = Vec::new();
|
||||
for id in 0..2usize {
|
||||
let c = c.clone();
|
||||
let items = items.clone();
|
||||
let consumed = consumed.clone();
|
||||
hs.push(thread::spawn(move || loop {
|
||||
if consumed.load(O::SeqCst) == 2 {
|
||||
c.wake_all(); // release a sibling still parked
|
||||
break;
|
||||
}
|
||||
let cur = items.load(O::SeqCst);
|
||||
if cur > 0
|
||||
&& items
|
||||
.compare_exchange(cur, cur - 1, O::SeqCst, O::SeqCst)
|
||||
.is_ok()
|
||||
{
|
||||
consumed.fetch_add(1, O::SeqCst);
|
||||
// THE CHAIN RULE, exactly as production expresses it
|
||||
// (runtime.rs schedule_loop): surplus ⇒ the fenced
|
||||
// fast-path wake. Models the Relaxed-load chain path,
|
||||
// not just the RMW one.
|
||||
if items.load(O::SeqCst) > 0 {
|
||||
c.wake_one_if_idle();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let _ = c.park(id, None, || {
|
||||
items.load(O::SeqCst) > 0 || consumed.load(O::SeqCst) == 2
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Producer (main): two items, ONE wake, via the enqueue-shaped
|
||||
// fenced fast path.
|
||||
items.store(2, O::SeqCst);
|
||||
c.wake_one_if_idle();
|
||||
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
assert_eq!(consumed.load(O::SeqCst), 2);
|
||||
assert_eq!(items.load(O::SeqCst), 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// RFC 018 loom model 3 — timekeeper handoff.
|
||||
/// An earlier-deadline insert racing the parking timekeeper: the
|
||||
/// earlier deadline is always honored — either the timekeeper armed it
|
||||
/// directly (insert landed first under the timers lock) or the insert
|
||||
/// wakes the timekeeper to re-peek. A timekeeper sleeping toward the
|
||||
/// stale later deadline would deadlock the model (loom has no time).
|
||||
#[test]
|
||||
fn timekeeper_handoff() {
|
||||
loom::model(|| {
|
||||
let origin = Instant::now();
|
||||
let d_far = origin + Duration::from_secs(60);
|
||||
let d_near = origin + Duration::from_secs(1);
|
||||
|
||||
let c = Arc::new(Coordinator::new(1));
|
||||
// The timers-mutex stand-in: heap min under a lock.
|
||||
let heap_min = Arc::new(LMutex::new(d_far));
|
||||
|
||||
let tk = {
|
||||
let c = c.clone();
|
||||
let heap_min = heap_min.clone();
|
||||
thread::spawn(move || {
|
||||
// Peek + arm under the lock (the serialization rule).
|
||||
let armed = {
|
||||
let g = heap_min.lock().unwrap();
|
||||
let min = *g;
|
||||
assert!(c.try_arm_timer(0, min));
|
||||
min
|
||||
};
|
||||
if armed == d_far {
|
||||
// Insert hadn't landed: it MUST wake us. Parking
|
||||
// toward d_far with no wake = model deadlock.
|
||||
let r = c.park(0, Some(armed), || false);
|
||||
assert_eq!(r, ParkResult::Woken, "re-arm wake lost");
|
||||
}
|
||||
// Woken (or armed the near deadline directly): re-peek.
|
||||
c.disarm_timer(0);
|
||||
let g = heap_min.lock().unwrap();
|
||||
assert_eq!(*g, d_near, "earlier deadline not visible on re-peek");
|
||||
})
|
||||
};
|
||||
|
||||
// Inserter (main): publish the earlier deadline under the lock,
|
||||
// then the insert-check.
|
||||
{
|
||||
let mut g = heap_min.lock().unwrap();
|
||||
*g = d_near;
|
||||
c.timer_inserted(d_near);
|
||||
}
|
||||
|
||||
tk.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
/// RFC 018 loom model 4 — termination.
|
||||
/// The AllDone verdict: producer flips done and wake_all()s; consumers
|
||||
/// must never park forever past done (park's re-check + wake_all's
|
||||
/// permits close every interleaving; a stuck consumer = loom deadlock).
|
||||
#[test]
|
||||
fn termination_no_park_past_done() {
|
||||
loom::model(|| {
|
||||
let c = Arc::new(Coordinator::new(2));
|
||||
let done = Arc::new(LAtomicU64::new(0));
|
||||
|
||||
let mut hs = Vec::new();
|
||||
for id in 0..2usize {
|
||||
let c = c.clone();
|
||||
let done = done.clone();
|
||||
hs.push(thread::spawn(move || loop {
|
||||
if done.load(O::SeqCst) == 1 {
|
||||
break;
|
||||
}
|
||||
let _ = c.park(id, None, || done.load(O::SeqCst) == 1);
|
||||
}));
|
||||
}
|
||||
|
||||
done.store(1, O::SeqCst);
|
||||
c.wake_all();
|
||||
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
//! Process groups — a `name → multiset<Member>` map (RFC 012).
|
||||
//! Process groups: one name, many actors.
|
||||
//!
|
||||
//! Sits parallel to [`registry`](crate::registry), not on top of it. The
|
||||
//! registry is a *bimap*: at most one pid per name. A group is the opposite —
|
||||
//! many pids per name, the same pid in many groups — so it cannot be a
|
||||
//! generalization of the bimap; it is its own keyspace sharing only the
|
||||
//! liveness signal source (monitors) and the lock *class*.
|
||||
//! A process group is a named set of actors that you can look up, fan out to,
|
||||
//! or pick a worker from. It is the natural home for a *worker pool* (several
|
||||
//! interchangeable actors doing the same job), for *service discovery* (find
|
||||
//! everyone currently offering some capability), and for *broadcast* (reach
|
||||
//! every member of a group at once).
|
||||
//!
|
||||
//! ## Example
|
||||
//! The set is *live*: members [`join`] it, and a member that dies is removed
|
||||
//! automatically. You never deregister a dead actor — there is no bookkeeping
|
||||
//! to get wrong, and [`members`] / [`pick`] never hand you an actor that has
|
||||
//! already gone.
|
||||
//!
|
||||
//! A group is a live membership view: members join, and a member that dies is
|
||||
//! evicted automatically — no deregistration call, no bookkeeping.
|
||||
//! ## Joining and reading a group
|
||||
//!
|
||||
//! ```
|
||||
//! use smarm::{channel, join, leave, members, pick, run, spawn};
|
||||
@@ -25,8 +27,8 @@
|
||||
//! join("pool", w2.pid());
|
||||
//! assert_eq!(members("pool").len(), 2);
|
||||
//!
|
||||
//! // One worker dies. Nobody told the group — the death hook evicts it,
|
||||
//! // so it is gone from `members` and never returned by `pick`.
|
||||
//! // One worker dies. Nothing tells the group — smarm evicts it
|
||||
//! // automatically, so it is gone from `members` and never picked.
|
||||
//! tx1.send(()).unwrap();
|
||||
//! w1.join().unwrap();
|
||||
//! assert_eq!(members("pool"), vec![w2.pid()]);
|
||||
@@ -41,60 +43,72 @@
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Cleanup is eager and monitor-driven (unlike the registry)
|
||||
//! [`members`] returns every live member in the order they joined; [`pick`]
|
||||
//! returns one of them, or `None` when the group is empty. The same actor can
|
||||
//! belong to any number of groups at once, and joining a group it is already in
|
||||
//! is a harmless no-op.
|
||||
//!
|
||||
//! The registry prunes a stale binding lazily, on contact, because it only
|
||||
//! ever resolves one binding at a time. A group is *iterated* — `members` fans
|
||||
//! out to every member — so it must not carry dead members across a broadcast.
|
||||
//! Each [`join`] installs a `monitor(pid)` and keeps the resulting [`Monitor`]
|
||||
//! *alongside the group entry*; the actor's one-shot `Down` lands in that
|
||||
//! monitor's channel when it dies. Every group operation [`reap`s][reap] the
|
||||
//! group it touches first — draining each membership's monitor with a
|
||||
//! non-blocking `try_recv` — and on the first sign of death sweeps that pid out
|
||||
//! of *every* group via the predicate primitive. So the set is self-pruning on
|
||||
//! contact rather than filtered on read; the read path additionally applies a
|
||||
//! generation-checked liveness backstop so a member already dead but not yet
|
||||
//! reaped is never *returned*, even though eviction remains the monitor's job.
|
||||
//! ## Membership ends on its own
|
||||
//!
|
||||
//! [reap]: ProcessGroups::reap_group
|
||||
//! You do not have to clean up after a member that dies. When an actor exits —
|
||||
//! for any reason — it is removed from every group it had joined, before any
|
||||
//! later read or send can observe it. [`leave`] is only for *voluntary*
|
||||
//! departure, when a still-living actor wants out of a group.
|
||||
//!
|
||||
//! ## Eviction is one dumb primitive
|
||||
//! This is the main difference from keeping your own `Vec<Pid>`: a plain list
|
||||
//! goes stale the instant a member dies, and you would have to notice and prune
|
||||
//! it yourself. A group prunes itself.
|
||||
//!
|
||||
//! [`ProcessGroups::remove_where`] removes every member matching a predicate
|
||||
//! from every group. The primitive never knows *why* a member leaves — that is
|
||||
//! the caller's concern. Its first caller is the monitor death hook (this RFC,
|
||||
//! via `reap_group`); the later `evict_incarnation(node, inc)` sweep (RFC 010)
|
||||
//! reuses the same predicate path, which is the whole reason to shape it as a
|
||||
//! predicate.
|
||||
//! ## Sending to a group
|
||||
//!
|
||||
//! ## Identity is cluster-shaped from the first commit
|
||||
//! For a worker pool you usually want to hand a job to *one* available member.
|
||||
//! [`dispatch`] picks a live member and sends it a message in a single step,
|
||||
//! returning the member it reached:
|
||||
//!
|
||||
//! A [`Member`] is not a bare [`Pid`]: it is `(NodeId, Incarnation, Pid)`, a
|
||||
//! field-for-field image of a modern BEAM pid (`NEW_PID_EXT`) so the eventual
|
||||
//! ETF codec (RFC 011) is a near-identity mapping. In this RFC `NodeId` and
|
||||
//! `Incarnation` are runtime-init constants — one node, one fixed incarnation
|
||||
//! — threaded through storage and eviction anyway so the public surface never
|
||||
//! has to change to acquire them once clustering (RFC 010) supplies real
|
||||
//! values. No cluster types leak out of the public functions: callers pass and
|
||||
//! receive [`Pid`]; the node/incarnation are filled from runtime identity.
|
||||
//! ```ignore
|
||||
//! use smarm::{dispatch, join, Addressable};
|
||||
//!
|
||||
//! ## Locking
|
||||
//! struct Job(String);
|
||||
//! struct Worker;
|
||||
//! impl Addressable for Worker { type Msg = Job; }
|
||||
//!
|
||||
//! One `RawMutex` (Leaf class) on `RuntimeInner`, mirroring `registry`. The
|
||||
//! group lock is never held with another Leaf (it never touches the registry
|
||||
//! or a slot's cold lock). The two places that *do* need another lock are kept
|
||||
//! off the group-lock path:
|
||||
//! // Each worker has published a `Pid<Worker>` inbox and joined the pool.
|
||||
//! join("workers", worker_a);
|
||||
//! join("workers", worker_b);
|
||||
//!
|
||||
//! - `monitor()` / `demonitor()` acquire the target's cold lock (Leaf) and
|
||||
//! so run *before* / *after* the group lock, never under it.
|
||||
//! - draining a monitor with `try_recv` takes the channel's Channel-class
|
||||
//! lock — permitted *under* a Leaf by the lock order (`raw_mutex.rs`), and
|
||||
//! a channel critical section only does the lock-free unpark protocol, so
|
||||
//! no Leaf is ever nested under it.
|
||||
//! // Route one job to whichever live worker `pick` lands on.
|
||||
//! match dispatch::<Worker>("workers", Job("resize image".into())) {
|
||||
//! Ok(who) => println!("sent to {who:?}"),
|
||||
//! Err(returned) => println!("no worker took it: {returned:?}"),
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Evicted and rejected [`Monitor`]s are dropped only *after* the group lock is
|
||||
//! released, so a receiver-drop never runs a wakeup under the lock (same
|
||||
//! discipline as `demonitor`).
|
||||
//! When you want the pids themselves rather than to send right away, [`pick_as`]
|
||||
//! and [`members_as`] return typed [`Pid<A>`](Pid)s for a homogeneous group, so
|
||||
//! the follow-up send stays compile-checked. The untyped [`pick`] and
|
||||
//! [`members`] are for mixed groups, where all you can rely on is identity.
|
||||
//!
|
||||
//! ## Groups vs. the registry
|
||||
//!
|
||||
//! A group is the many-actors counterpart to the [`registry`](crate::registry).
|
||||
//! The registry binds a name to *at most one* actor and re-resolves it on every
|
||||
//! send — what you want for a single well-known service. A group binds a name to
|
||||
//! *many* actors, and one actor may sit in many groups. Reach for the registry
|
||||
//! when there is exactly one of something; reach for a group when there is a set.
|
||||
//!
|
||||
//! ## Identity and clustering
|
||||
//!
|
||||
//! A group member is described by a [`Member`] — a [`Pid`] plus a [`NodeId`] and
|
||||
//! an [`Incarnation`]. Today everything is single-node, those two fields are
|
||||
//! fixed defaults, and you only ever pass and receive a plain [`Pid`]: the extra
|
||||
//! identity is carried so this API will not have to change when groups learn to
|
||||
//! span a cluster.
|
||||
//!
|
||||
//! ## Running context
|
||||
//!
|
||||
//! Every function here addresses the current runtime, so each must be called
|
||||
//! from inside [`run`](crate::run) (that is, on an actor thread). Calling one
|
||||
//! from outside a running runtime panics.
|
||||
|
||||
use crate::monitor::{demonitor, monitor, Monitor};
|
||||
use crate::pid::{assert_type, Addressable, Pid};
|
||||
@@ -103,7 +117,7 @@ use crate::scheduler::with_runtime;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A cluster node handle. A `u32` integer handle, *not* an interned atom — the
|
||||
/// single deliberate divergence from the BEAM wire shape (RFC 011 names it).
|
||||
/// single deliberate divergence from the BEAM wire shape.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct NodeId(u32);
|
||||
|
||||
@@ -149,9 +163,8 @@ impl From<u32> for Incarnation {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed single-node identity used until clustering (RFC 010) supplies
|
||||
/// real values. Carried like `wake_slot` so the public API never has to change
|
||||
/// to acquire it.
|
||||
/// The fixed single-node identity used until clustering supplies real values.
|
||||
/// Carried like `wake_slot` so the public API never has to change to acquire it.
|
||||
pub const DEFAULT_NODE_ID: NodeId = NodeId(0);
|
||||
/// The fixed incarnation for the single-node default. Non-zero so it never
|
||||
/// collides with a BEAM "any creation" wildcard at interop time.
|
||||
@@ -161,7 +174,7 @@ pub const DEFAULT_INCARNATION: Incarnation = Incarnation(1);
|
||||
///
|
||||
/// Deliberately a field-for-field image of a modern BEAM pid (`NEW_PID_EXT`).
|
||||
/// In memory it is a plain struct — no wire packing; the packed representation
|
||||
/// belongs to the `RemoteRef` boundary (RFC 010/011), not here.
|
||||
/// belongs to the remote-reference boundary, not here.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct Member {
|
||||
/// Which node the pid lives on. `DEFAULT_NODE_ID` while single-node.
|
||||
@@ -174,7 +187,7 @@ pub struct Member {
|
||||
}
|
||||
|
||||
/// One membership: a [`Member`] and the [`Monitor`] that watches its liveness.
|
||||
/// The monitor lives *alongside* the group entry (RFC 012) so a group is
|
||||
/// The monitor lives *alongside* the group entry so a group is
|
||||
/// self-contained: draining the membership tells us whether the member is
|
||||
/// still alive, and dropping the membership drops its monitor.
|
||||
struct Membership {
|
||||
@@ -185,7 +198,23 @@ struct Membership {
|
||||
/// The store: `name → multiset<Member>`. Within a single group a `Member`
|
||||
/// appears at most once (`join` is idempotent); the *multiset* framing is for
|
||||
/// cluster-readiness — the same pid is freely a member of many groups, and the
|
||||
/// width admits multiples in general. Held under one Leaf-class `RawMutex`.
|
||||
/// width admits multiples in general.
|
||||
///
|
||||
/// Locking discipline. Held under one Leaf-class `RawMutex` on `RuntimeInner`,
|
||||
/// mirroring the registry, and never held together with another Leaf lock (it
|
||||
/// never touches the registry or a slot's cold lock). The two operations that
|
||||
/// do need another lock are kept off the group-lock path:
|
||||
///
|
||||
/// - `monitor()` / `demonitor()` take the target's cold lock (also Leaf), so
|
||||
/// they run *before* / *after* the group lock, never under it.
|
||||
/// - draining a monitor with `try_recv` takes the channel's Channel-class
|
||||
/// lock, which the lock order permits *under* a Leaf; a channel critical
|
||||
/// section only does the lock-free unpark protocol, so no Leaf ever nests
|
||||
/// under it.
|
||||
///
|
||||
/// Evicted and rejected [`Monitor`]s are therefore dropped only *after* the
|
||||
/// group lock is released, so a receiver-drop never runs a wakeup under the
|
||||
/// lock — the same discipline as `demonitor`.
|
||||
pub(crate) struct ProcessGroups {
|
||||
groups: HashMap<String, Vec<Membership>>,
|
||||
}
|
||||
@@ -223,9 +252,11 @@ impl ProcessGroups {
|
||||
/// The one dumb eviction primitive: drop every member matching `pred` from
|
||||
/// every group, pruning emptied groups, and return the evicted memberships'
|
||||
/// monitors for the caller to drop outside the lock. The primitive does not
|
||||
/// know *why* a member leaves. Callers: the death hook (`reap_group`) and,
|
||||
/// later, `evict_incarnation` (RFC 010), over the same path. Insertion
|
||||
/// order within a group is preserved (`members` / `pick` are order-stable).
|
||||
/// know *why* a member leaves; that is the caller's concern. Its callers are
|
||||
/// the death hook (`reap_group`) and, once clustering lands, an
|
||||
/// incarnation-eviction sweep — both over this same predicate path, which is
|
||||
/// the whole reason to shape eviction as a predicate. Insertion order within
|
||||
/// a group is preserved (`members` / `pick` are order-stable).
|
||||
fn remove_where(&mut self, mut pred: impl FnMut(&Member) -> bool) -> Vec<Monitor> {
|
||||
let mut evicted = Vec::new();
|
||||
self.groups.retain(|_, v| {
|
||||
@@ -242,12 +273,18 @@ impl ProcessGroups {
|
||||
evicted
|
||||
}
|
||||
|
||||
/// Drain-on-contact death hook. Drains every membership monitor in `group`
|
||||
/// with a non-blocking `try_recv`: a delivered `Down` (any reason) or a
|
||||
/// closed channel means that member is dead. On the first death detected,
|
||||
/// sweep *all* of the dead pids out of *every* group via [`remove_where`]
|
||||
/// — a death is removed from each group it joined, not just the one being
|
||||
/// touched. Returns the evicted monitors to drop outside the lock.
|
||||
/// Drain-on-contact death hook. The registry can prune a stale binding
|
||||
/// lazily, on contact, because it only ever resolves one binding at a time;
|
||||
/// a group is *iterated* — `members` fans out to everyone — so it must not
|
||||
/// carry a dead member across a broadcast. Every group operation reaps the
|
||||
/// group it touches first.
|
||||
///
|
||||
/// Drains every membership monitor in `group` with a non-blocking
|
||||
/// `try_recv`: a delivered `Down` (any reason) or a closed channel means
|
||||
/// that member is dead. On the first death detected, sweep *all* of the
|
||||
/// dead pids out of *every* group via [`remove_where`] — a death is removed
|
||||
/// from each group it joined, not just the one being touched. Returns the
|
||||
/// evicted monitors to drop outside the lock.
|
||||
fn reap_group(&mut self, group: &str) -> Vec<Monitor> {
|
||||
let dead: Vec<Pid> = {
|
||||
let Some(v) = self.groups.get(group) else {
|
||||
@@ -279,7 +316,7 @@ impl ProcessGroups {
|
||||
}
|
||||
|
||||
/// Live members of `group`, in insertion order. The `is_live` oracle is the
|
||||
/// read-path backstop (Phase 3): a member whose slot is already dead is
|
||||
/// read-path backstop: a member whose slot is already dead is
|
||||
/// dropped from the *result* even if its `Down` has not been drained yet.
|
||||
/// Backstop only — the entry stays in storage; eviction is the monitor's
|
||||
/// job (`reap_group`).
|
||||
@@ -314,18 +351,19 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
|
||||
/// pid is a member at most once (idempotent). Returns `true` if this call newly
|
||||
/// added the membership, `false` if it was already a member.
|
||||
///
|
||||
/// Installs a `monitor(pid)` whose one-shot `Down` drives eviction: the
|
||||
/// registration races `finalize_actor` under the slot's cold lock exactly as
|
||||
/// every other monitor does, so no death slips between the join and the
|
||||
/// registration. A redundant (idempotent) join tears its extra monitor back
|
||||
/// down.
|
||||
/// Installs a monitor on `pid` so the actor's death evicts it from the group
|
||||
/// automatically — you never have to remove a dead member yourself. A redundant
|
||||
/// (idempotent) join tears its extra monitor back down.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn join<A>(group: impl Into<String>, pid: Pid<A>) -> bool {
|
||||
let group = group.into();
|
||||
let pid = pid.erase();
|
||||
// Install the monitor BEFORE taking the group lock: monitor() acquires the
|
||||
// target's cold lock (Leaf), and two Leaf locks are never held at once.
|
||||
// target's cold lock (Leaf), and two Leaf locks are never held at once. The
|
||||
// registration races `finalize_actor` under that cold lock exactly as every
|
||||
// other monitor does, so no death can slip between the join and the monitor
|
||||
// being in place.
|
||||
let mon = monitor(pid);
|
||||
|
||||
let (rejected, reaped) = with_runtime(|inner| {
|
||||
@@ -372,12 +410,13 @@ pub fn leave<A>(group: &str, pid: Pid<A>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan-out read: every live member of `group`.
|
||||
/// Every live member of `group`, in the order they joined. Returns an empty
|
||||
/// vector if the group does not exist or has no live members.
|
||||
///
|
||||
/// The touched group is reaped first, so dead members are evicted before the
|
||||
/// read. The generation-checked liveness read is a belt-and-braces backstop:
|
||||
/// even in the finalize window where a member is already dead but its `Down`
|
||||
/// has not yet landed, it is dropped from the result.
|
||||
/// Dead members are never returned: the group is pruned of anything that has
|
||||
/// died before the read, and as a backstop a member whose slot is already dead
|
||||
/// is dropped from the result even in the brief window before its death has
|
||||
/// been fully processed.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn members(group: &str) -> Vec<Pid> {
|
||||
@@ -391,11 +430,10 @@ pub fn members(group: &str) -> Vec<Pid> {
|
||||
pids
|
||||
}
|
||||
|
||||
/// Pool/discovery read: one live member of `group`, or `None` if empty.
|
||||
/// Stateless first-live selection: the touched group is reaped first, and the
|
||||
/// generation-checked liveness backstop skips any member already dead but not
|
||||
/// yet reaped. Smarter routing is an explicitly later, clustered concern per
|
||||
/// RFC 010.
|
||||
/// One live member of `group`, or `None` if the group is empty (or every
|
||||
/// member has died). Selection is a stateless first-live scan in join order,
|
||||
/// with the same dead-member backstop as [`members`]; smarter, load-aware
|
||||
/// routing is a later, clustered concern.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn pick(group: &str) -> Option<Pid> {
|
||||
@@ -409,11 +447,11 @@ pub fn pick(group: &str) -> Option<Pid> {
|
||||
picked
|
||||
}
|
||||
|
||||
/// Typed `pick`: one live member of `group` as a [`Pid<A>`] (RFC 014 §4.4).
|
||||
/// Typed [`pick`]: one live member of `group` as a [`Pid<A>`](Pid).
|
||||
/// For a homogeneous pool every member is an `A`, so the picked member comes
|
||||
/// back typed and dispatch is an ordinary compile-checked [`send_to`] rather
|
||||
/// than the [`send_dyn`](crate::send_dyn) escape hatch. Re-types via the
|
||||
/// unchecked [`assert_type`] primitive — a wrong `A` degrades to
|
||||
/// unchecked `assert_type` primitive — a wrong `A` degrades to
|
||||
/// [`SendError::NoChannel`] on the next send, never a misdelivery.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
|
||||
@@ -98,6 +98,25 @@ pub(crate) fn clear_current_slot() {
|
||||
CURRENT_SLOT.with(|c| c.set(std::ptr::null()));
|
||||
}
|
||||
|
||||
/// RFC 007 (`smarm-causal`) — raw pointer to the on-CPU actor's slot, null on
|
||||
/// the scheduler's own stack. Same lifetime argument as `note_overrun`: the
|
||||
/// slot is never reclaimed while its actor is on-CPU.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
#[inline]
|
||||
pub(crate) fn current_slot_ptr() -> *const crate::runtime::Slot {
|
||||
CURRENT_SLOT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// RFC 007 (`smarm-causal`) — push the slice start forward by `cycles`, so
|
||||
/// virtually-injected delay spun inside `maybe_preempt` does not count against
|
||||
/// the actor's timeslice (the clock-correction half of the RFC: the runtime
|
||||
/// owns this clock, so it can subtract its own perturbation).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
#[inline]
|
||||
pub(crate) fn extend_timeslice(cycles: u64) {
|
||||
TIMESLICE_START.with(|c| c.set(c.get().wrapping_add(cycles)));
|
||||
}
|
||||
|
||||
/// Tally a timeslice overrun against the on-CPU actor (RFC 016 Chunk 2). A
|
||||
/// no-op if no actor is bound (the scheduler's own stack). Reached only from
|
||||
/// the slice-expiry branch, which is already the yield path, so its cost is
|
||||
@@ -247,6 +266,11 @@ pub fn maybe_preempt() {
|
||||
// Observe a pending stop first: if we are being cancelled
|
||||
// there is no point yielding, we unwind instead.
|
||||
check_cancelled();
|
||||
// RFC 007: causal-profiling sample/absorb point. Shares the
|
||||
// amortised cadence, and the PREEMPTION_ENABLED gate — so it
|
||||
// can never spin inside a prep-to-park or no-preempt region.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::check();
|
||||
let start = TIMESLICE_START.with(|s| s.get());
|
||||
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
|
||||
// Tally the overrun (RFC 016 Chunk 2) before handing back —
|
||||
|
||||
+298
-216
@@ -1,55 +1,108 @@
|
||||
//! Named mailbox registry — resolve a name (or pid) to a *messageable* actor.
|
||||
|
||||
//! Give an actor a name so other actors can find it and message it.
|
||||
//!
|
||||
//! ## What changed (RFC 014)
|
||||
//! Without the registry, the only way to reach an actor is to already be
|
||||
//! holding its [`Pid`], usually because you spawned it yourself or someone
|
||||
//! passed it to you. That is fine for a worker you just created, but it does
|
||||
//! not work for a well-known service that arbitrary parts of your program
|
||||
//! need to find independently, like a logger, a config store, or a
|
||||
//! connection pool. The registry solves this: an actor claims a name once,
|
||||
//! and from then on any other actor can look that name up, or send to it
|
||||
//! directly, without ever having been handed a `Pid`.
|
||||
//!
|
||||
//! The old registry was a `name <-> pid` bimap: `whereis` handed back a `Pid`
|
||||
//! you could not send to, because a pid is just `(index, generation)` with no
|
||||
//! delivery endpoint. This rework makes resolution yield something messageable.
|
||||
//! ```
|
||||
//! use smarm::{channel, register, run, send, spawn, unregister, whereis, Name};
|
||||
//!
|
||||
//! Two facts shape the structure:
|
||||
//! const COUNTER: Name<u64> = Name::new("counter");
|
||||
//!
|
||||
//! 1. **A name resolves to a single actor.** Many actors under one label is
|
||||
//! what *process groups* (`pg`) are for; the registry is one-name-one-actor
|
||||
//! (several names *may* point at the same actor).
|
||||
//! 2. **Channels are typed**, so an actor has no single untyped mailbox. An
|
||||
//! actor instead owns a *set* of typed channels — one [`Sender`] per message
|
||||
//! type it accepts. So the registry maps name/pid to a [`Mailbox`]: a small
|
||||
//! structure holding that actor's pid plus all of its typed channels, keyed
|
||||
//! by message [`TypeId`].
|
||||
//! run(|| {
|
||||
//! let (ready_tx, ready_rx) = channel::<()>();
|
||||
//! let (tx, rx) = channel::<u64>();
|
||||
//!
|
||||
//! Resolution is therefore: `name -> pid` (single actor) `-> Mailbox -> the
|
||||
//! channel for message type M`. A `Name<Cmd>` and a `Name<Admin>` on the *same*
|
||||
//! actor select *different* channels purely by their type parameter, so
|
||||
//! capability separation (RFC 014 §4.7) needs no extra machinery.
|
||||
//! let worker = spawn(move || {
|
||||
//! // Claim the name for this actor's inbox. Any actor holding
|
||||
//! // `COUNTER` can now reach this one by name.
|
||||
//! register(COUNTER, tx).unwrap();
|
||||
//! ready_tx.send(()).unwrap();
|
||||
//! assert_eq!(rx.recv().unwrap(), 42);
|
||||
//! });
|
||||
//!
|
||||
//! ## Type erasure is contained
|
||||
//! ready_rx.recv().unwrap(); // wait for the worker to register
|
||||
//!
|
||||
//! Each stored channel is a `Box<dyn Any + Send>` that is concretely a
|
||||
//! `Sender<M>`, filed under `TypeId::of::<M>()`. A resolve for `M` looks up
|
||||
//! that exact `TypeId` and downcasts to `Sender<M>` — keyed by the very type we
|
||||
//! downcast to, so the downcast cannot fail on correct data; a failure is a
|
||||
//! smarm bug, asserted in debug. The phantom `M` on [`Name`] re-imposes the
|
||||
//! type at the call site, so callers never touch the erasure.
|
||||
//! // Look the name up, or just send to it directly.
|
||||
//! assert_eq!(whereis("counter"), Some(worker.pid()));
|
||||
//! send(COUNTER, 42).unwrap();
|
||||
//!
|
||||
//! ## Cleanup is lazy (prune-on-contact)
|
||||
//! worker.join().unwrap();
|
||||
//!
|
||||
//! As before, there is no `finalize` hook and no name field on the slot. Every
|
||||
//! operation that touches a binding checks the target pid's liveness via the
|
||||
//! generation-checked slot word; a binding to a dead actor behaves as absent
|
||||
//! and is pruned on contact (its [`Mailbox`] and every name pointing at it are
|
||||
//! dropped). The cost is a dead binding lingering until something looks at it;
|
||||
//! the payoff is zero coupling to the actor lifecycle.
|
||||
//! // The name dies with the actor: nobody holds it anymore.
|
||||
//! assert_eq!(whereis("counter"), None);
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Locking
|
||||
//! ## Names carry a message type
|
||||
//!
|
||||
//! One `RawMutex` (Leaf class) in `RuntimeInner`, exactly like the old
|
||||
//! registry. The fold (name index *and* handles under the one lock) is what
|
||||
//! keeps a name-addressed `send` on a single Leaf — `raw_mutex` panics on a
|
||||
//! second Leaf acquired while one is held. The send path clones the `Sender`
|
||||
//! **under** the Leaf lock (a `Sender::clone` takes a Channel lock, permitted
|
||||
//! under a Leaf), then **releases** the Leaf and only *then* sends — a send can
|
||||
//! unpark a receiver, and wakeup-bearing work runs outside the Leaf. Order is
|
||||
//! **Leaf -> Channel**, as `pg`/`finalize`.
|
||||
//! A [`Name<M>`] is a plain string plus a type parameter `M`: the message
|
||||
//! type that name expects to receive. [`Name::new`] is `const`, so the usual
|
||||
//! pattern is a module-level constant like `COUNTER` above, shared by every
|
||||
//! caller. The type parameter means a name is only ever sent the kind of
|
||||
//! message it was declared for. If two different constants share the same
|
||||
//! string but have different message types, they still address two
|
||||
//! independent channels on the same actor: registering both just gives that
|
||||
//! actor two ways to be reached, one per message type. This is how you give
|
||||
//! one actor a "public" channel and a separate, differently-typed "admin"
|
||||
//! channel under related names, without inventing an enum to merge them.
|
||||
//!
|
||||
//! ## One actor per name, looked up fresh every time
|
||||
//!
|
||||
//! A name always points at exactly one actor at a time (contrast a *process
|
||||
//! group*, from the [`pg`](crate::pg) module, which is one name mapping to
|
||||
//! many actors). Unlike a plain [`Pid`], which names one specific actor
|
||||
//! forever and stops working the moment that actor dies, a name is
|
||||
//! re-resolved on every [`send`]: if the actor holding it dies and a new one
|
||||
//! registers under the same name, the next `send` reaches the new holder
|
||||
//! automatically. Use a name for a long-lived service whose exact identity
|
||||
//! you do not want to track by hand; use a `Pid` when you already have one
|
||||
//! and want to talk to that exact actor.
|
||||
//!
|
||||
//! ## Registration ends when the actor does
|
||||
//!
|
||||
//! There is no separate step to clean up a name when its actor exits: dying
|
||||
//! is enough. The next operation that touches a dead binding (a [`whereis`],
|
||||
//! a [`send`], or another actor's [`register`] of the same name) notices the
|
||||
//! actor is gone and clears the stale entry as a side effect, so the name
|
||||
//! becomes free again. [`unregister`] is only for a live actor voluntarily
|
||||
//! giving up a name it no longer wants; nothing has to call it on the way
|
||||
//! out.
|
||||
//!
|
||||
//! ## Implementation notes
|
||||
//!
|
||||
//! These details matter if you are working on smarm itself; they are not
|
||||
//! part of the public contract.
|
||||
//!
|
||||
//! Internally, each live actor that has published at least one channel owns
|
||||
//! a `Mailbox`: its pid plus a set of typed channels, keyed by the message
|
||||
//! type's `TypeId`. A stored channel is a `Box<dyn Any + Send>` that
|
||||
//! is concretely a `Sender<M>`; resolving for `M` looks up that exact
|
||||
//! `TypeId` and downcasts, so the downcast cannot fail on correct data (a
|
||||
//! failure would be a bug in the registry itself, checked in debug builds).
|
||||
//! Registering a name therefore means: find or create the actor's mailbox,
|
||||
//! insert the channel under its type, and point the name at the actor's pid.
|
||||
//!
|
||||
//! There is no callback when an actor exits. Every operation that touches a
|
||||
//! binding checks the target pid's liveness directly against the scheduler's
|
||||
//! slot table (which also tracks a generation counter, so a dead actor's
|
||||
//! reused slot index is never mistaken for the same actor). A binding to a
|
||||
//! dead actor is treated as absent and dropped right there. This keeps the
|
||||
//! registry decoupled from actor teardown, at the cost of a dead binding
|
||||
//! lingering until something happens to look at it.
|
||||
//!
|
||||
//! The whole registry (both the name index and the per-actor mailboxes) sits
|
||||
//! behind one lock, which is what lets a name-addressed [`send`] resolve and
|
||||
//! clone the target's sender in a single critical section. The sender is
|
||||
//! cloned while that lock is held, then the lock is released before the
|
||||
//! actual send, since delivering a message can wake a parked receiver and
|
||||
//! that wakeup work should not run while the registry is locked.
|
||||
|
||||
use crate::channel::Sender;
|
||||
use crate::pid::{Addressable, Name, Pid};
|
||||
@@ -80,28 +133,33 @@ impl std::fmt::Display for RegisterError {
|
||||
|
||||
impl std::error::Error for RegisterError {}
|
||||
|
||||
/// Why a name-addressed [`send`] did not deliver. Carries the message back so
|
||||
/// the caller never loses it (mirrors [`crate::channel::SendError`]).
|
||||
/// Why a send did not deliver. Every variant carries the undelivered message
|
||||
/// back, mirroring [`crate::channel::SendError`], so a failed send never
|
||||
/// silently drops what you tried to send.
|
||||
///
|
||||
/// `Debug`/`Display` are hand-written so neither demands `M: Debug` — the
|
||||
/// payload is returned, not printed.
|
||||
/// `Debug` and `Display` are hand-written so neither requires `M: Debug`,
|
||||
/// since the payload is handed back to you, not printed.
|
||||
pub enum SendError<M> {
|
||||
/// No live actor is currently registered under this name. Name-addressed
|
||||
/// [`send`] only; the pid-addressed counterpart is [`SendError::Dead`].
|
||||
/// No live actor is currently registered under this name. Returned only
|
||||
/// by name-addressed [`send`]; the pid-addressed counterpart of "nothing
|
||||
/// there" is [`SendError::Dead`].
|
||||
Unresolved(M),
|
||||
/// The pid-addressed actor is no longer the live incarnation this pid names
|
||||
/// — it has died, even if its slot now holds a *different* actor (a direct
|
||||
/// `Pid<A>` send never redirects; contrast name-addressed [`send`]). Pid
|
||||
/// paths ([`send_to`] / [`send_dyn`]) only.
|
||||
/// The actor this pid identifies has died, even if its slot has since
|
||||
/// been taken over by a different, live actor. A direct `Pid<A>` send
|
||||
/// never redirects to that new occupant; contrast name-addressed
|
||||
/// [`send`], which would reach it. Returned by the pid-addressed sends,
|
||||
/// [`send_to`] and [`send_dyn`].
|
||||
Dead(M),
|
||||
/// The actor is live but exposes no channel for this message type.
|
||||
/// The actor is live but has not published a channel for this message
|
||||
/// type.
|
||||
NoChannel(M),
|
||||
/// The actor's channel for this message type is closed (its receiver is gone).
|
||||
/// The actor's channel for this message type is closed (its receiver has
|
||||
/// been dropped).
|
||||
Closed(M),
|
||||
/// No live member to deliver to — a [`dispatch`](crate::dispatch) over an
|
||||
/// empty (or all-dead) process group. Group-addressed dispatch only; the
|
||||
/// name-addressed counterpart is [`SendError::Unresolved`]. The message is
|
||||
/// handed back undelivered.
|
||||
/// No live member was available to deliver to: returned by
|
||||
/// [`dispatch`](crate::dispatch) when the target process group is empty
|
||||
/// or every member in it has died. The name-addressed counterpart of
|
||||
/// this case is [`SendError::Unresolved`].
|
||||
NoMember(M),
|
||||
}
|
||||
|
||||
@@ -150,9 +208,9 @@ impl<M> std::error::Error for SendError<M> {}
|
||||
|
||||
/// A registry-stored channel, type-erased over its message type. The stored
|
||||
/// object must serve two readers: `clone_sender` (downcast back to the concrete
|
||||
/// `Sender<M>`) and the RFC 016 snapshot (queued length without knowing `M`).
|
||||
/// A bare `Box<dyn Any>` gives the first but not the second, so we erase behind
|
||||
/// this small trait instead.
|
||||
/// `Sender<M>`) and the runtime introspection snapshot (queued length without
|
||||
/// knowing `M`). A bare `Box<dyn Any>` gives the first but not the second, so
|
||||
/// we erase behind this small trait instead.
|
||||
trait ErasedSender: Send {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn queued_len(&self) -> usize;
|
||||
@@ -169,7 +227,7 @@ impl<M: Send + 'static> ErasedSender for Sender<M> {
|
||||
|
||||
/// One typed channel of an actor, type-erased. Concretely a `Sender<M>` filed
|
||||
/// under `TypeId::of::<M>()`; `msg_type` is `type_name::<M>()`, kept for
|
||||
/// observers (RFC 014 §4.5) and as the debug cross-check on the downcast.
|
||||
/// observability tooling and as the debug cross-check on the downcast.
|
||||
struct Channel {
|
||||
sender: Box<dyn ErasedSender>,
|
||||
msg_type: &'static str,
|
||||
@@ -193,17 +251,18 @@ impl Mailbox {
|
||||
/// legal under a Leaf (Leaf -> Channel).
|
||||
fn clone_sender<M: Send + 'static>(&self) -> Option<Sender<M>> {
|
||||
let ch = self.channels.get(&TypeId::of::<M>())?;
|
||||
let tx = ch
|
||||
.sender
|
||||
.as_any()
|
||||
.downcast_ref::<Sender<M>>()
|
||||
.expect("channel keyed by TypeId but downcast to its own type failed — smarm bug");
|
||||
let tx = match ch.sender.as_any().downcast_ref::<Sender<M>>() {
|
||||
Some(tx) => tx,
|
||||
None => panic!(
|
||||
"smarm: channel keyed by TypeId but downcast to its own type failed (core corrupt)"
|
||||
),
|
||||
};
|
||||
debug_assert_eq!(ch.msg_type, type_name::<M>(), "msg_type / TypeId disagree");
|
||||
Some(tx.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-actor registry view handed to RFC 016 introspection: registered names
|
||||
/// Per-actor registry view handed to runtime introspection: registered names
|
||||
/// and summed mailbox depth, tagged with the mailbox's `pid` so a stale
|
||||
/// incarnation can be filtered against the slab. Covers only *published*
|
||||
/// channels (`register` / `install` / `spawn_addr` / gen_server start); an
|
||||
@@ -216,14 +275,21 @@ pub(crate) struct MailboxInfo {
|
||||
}
|
||||
|
||||
/// The directory. Invariant (held under the registry lock): every value in
|
||||
/// `by_name` is the index of a [`Mailbox`] present in `by_index`, and that
|
||||
/// mailbox's `pid.index()` equals the key. Stale entries (dead actors) violate
|
||||
/// nothing — they are simply pruned on contact.
|
||||
/// `by_name` is the full [`Pid`] (index *and* generation) of an actor that
|
||||
/// published a [`Mailbox`] into `by_index` at registration time. Stale entries
|
||||
/// (dead holders, including holders whose slot has since been re-tenanted by
|
||||
/// a different actor) violate nothing: they are pruned on contact, and the
|
||||
/// generation makes "dead" decidable even after slot reuse.
|
||||
pub(crate) struct Registry {
|
||||
/// `pid.index() -> the actor's mailbox`. The handle store.
|
||||
by_index: HashMap<u32, Mailbox>,
|
||||
/// `name -> pid.index()`. Several names may map to one actor.
|
||||
by_name: HashMap<&'static str, u32>,
|
||||
/// `name -> holder pid`. Several names may map to one actor. The full pid
|
||||
/// (not just the index) is load-bearing: an index alone cannot tell a dead
|
||||
/// holder from the live actor now tenanting its recycled slot. Comparing
|
||||
/// only the index would make such a name read as live-held (unresolvable
|
||||
/// and unregisterable at once) and could misdeliver to whatever new,
|
||||
/// same-typed actor now sits in that slot.
|
||||
by_name: HashMap<&'static str, Pid>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
@@ -231,24 +297,31 @@ impl Registry {
|
||||
Self { by_index: HashMap::new(), by_name: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Drop a dead actor's mailbox and every name that pointed at it.
|
||||
fn prune(&mut self, index: u32) {
|
||||
self.by_index.remove(&index);
|
||||
self.by_name.retain(|_, idx| *idx != index);
|
||||
/// Drop a dead holder's artifacts: every name bound to it, and its
|
||||
/// mailbox, but only while the mailbox is still *its own*. A recycled
|
||||
/// slot's mailbox belongs to the live tenant (publish replaces it
|
||||
/// wholesale on pid mismatch) and is left untouched.
|
||||
fn prune_holder(&mut self, holder: Pid) {
|
||||
self.by_name.retain(|_, p| *p != holder);
|
||||
if self.by_index.get(&holder.index()).is_some_and(|mb| mb.pid == holder) {
|
||||
self.by_index.remove(&holder.index());
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 016 snapshot input: per-slot-index registry view — the actor's
|
||||
/// registered names (inverted from `by_name`) and its mailbox depth (queued
|
||||
/// messages summed across every published typed channel). Built in one pass
|
||||
/// under the registry Leaf; the per-channel `queued_len` takes a Channel
|
||||
/// lock, legal under the Leaf (Leaf → Channel). Carries each mailbox's full
|
||||
/// `pid` so the caller can discard a stale incarnation's entry against the
|
||||
/// slab's live generation. Names that dangle (point at no mailbox) are
|
||||
/// dropped — they violate no invariant and get pruned on next contact.
|
||||
/// Runtime introspection input: per-slot-index registry view, giving the
|
||||
/// actor's registered names (inverted from `by_name`) and its mailbox
|
||||
/// depth (queued messages summed across every published typed channel).
|
||||
/// Carries each mailbox's full `pid` so the caller can discard a stale
|
||||
/// incarnation's entry against the slab's live generation. Names are
|
||||
/// matched to mailboxes by *full pid*, so a stale name (dead holder)
|
||||
/// still annotates the corpse's own mailbox if that survives, but never a
|
||||
/// recycled slot's new tenant; names that attach to no mailbox are
|
||||
/// dropped, since that violates no invariant and they get pruned on next
|
||||
/// contact.
|
||||
pub(crate) fn introspect_map(&self) -> HashMap<u32, MailboxInfo> {
|
||||
let mut names: HashMap<u32, Vec<&'static str>> = HashMap::new();
|
||||
for (&name, &idx) in &self.by_name {
|
||||
names.entry(idx).or_default().push(name);
|
||||
let mut names: HashMap<Pid, Vec<&'static str>> = HashMap::new();
|
||||
for (&name, &pid) in &self.by_name {
|
||||
names.entry(pid).or_default().push(name);
|
||||
}
|
||||
let mut out: HashMap<u32, MailboxInfo> = HashMap::with_capacity(self.by_index.len());
|
||||
for (&idx, mb) in &self.by_index {
|
||||
@@ -257,7 +330,7 @@ impl Registry {
|
||||
idx,
|
||||
MailboxInfo {
|
||||
pid: mb.pid,
|
||||
names: names.remove(&idx).unwrap_or_default(),
|
||||
names: names.remove(&mb.pid).unwrap_or_default(),
|
||||
depth: depth.min(u32::MAX as usize) as u32,
|
||||
},
|
||||
);
|
||||
@@ -267,15 +340,16 @@ impl Registry {
|
||||
|
||||
/// Single-actor form of [`introspect_map`](Self::introspect_map): the
|
||||
/// registry view for one slot index, or `None` if no mailbox is published
|
||||
/// there. Used by `actor_info` so its cost stays proportional to the one
|
||||
/// actor rather than locking every channel in the runtime.
|
||||
/// there. Used by the runtime's per-actor introspection so its cost stays
|
||||
/// proportional to the one actor rather than locking every channel in the
|
||||
/// runtime.
|
||||
pub(crate) fn introspect_one(&self, idx: u32) -> Option<MailboxInfo> {
|
||||
let mb = self.by_index.get(&idx)?;
|
||||
let depth: usize = mb.channels.values().map(|c| c.sender.queued_len()).sum();
|
||||
let names = self
|
||||
.by_name
|
||||
.iter()
|
||||
.filter_map(|(&n, &i)| (i == idx).then_some(n))
|
||||
.filter_map(|(&n, &p)| (p == mb.pid).then_some(n))
|
||||
.collect();
|
||||
Some(MailboxInfo { pid: mb.pid, names, depth: depth.min(u32::MAX as usize) as u32 })
|
||||
}
|
||||
@@ -286,14 +360,18 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
|
||||
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
|
||||
}
|
||||
|
||||
/// Publish the current actor's `Sender<M>` under `name`, capturing the channel
|
||||
/// so the name becomes messageable. Idempotent for the same `(name, type)`;
|
||||
/// registering a *second* type under the same (or another) name on the same
|
||||
/// actor just adds another channel to the actor's mailbox.
|
||||
/// Give the current actor's channel a name, so other actors can find and
|
||||
/// message it by that name instead of needing its [`Pid`].
|
||||
///
|
||||
/// Fails with [`RegisterError::NameTaken`] if the name is held by a *different*
|
||||
/// live actor (a binding to a dead actor is pruned and the name treated as
|
||||
/// free). Panics if called outside `Runtime::run()`.
|
||||
/// Calling this again with the same `(name, type)` from the same actor is
|
||||
/// harmless. Registering a *second* message type under the same (or a
|
||||
/// different) name from the same actor just adds another typed channel to
|
||||
/// that actor's mailbox; it does not replace the first.
|
||||
///
|
||||
/// Fails with [`RegisterError::NameTaken`] if the name is currently held by a
|
||||
/// *different* live actor. A name held by an actor that has since died is not
|
||||
/// considered taken: it is quietly reclaimed and handed to you. Panics if
|
||||
/// called outside [`run`](crate::run).
|
||||
pub fn register<M: Send + 'static>(name: Name<M>, tx: Sender<M>) -> Result<(), RegisterError> {
|
||||
register_with(self_pid(), name.as_str(), tx)
|
||||
}
|
||||
@@ -301,8 +379,8 @@ pub fn register<M: Send + 'static>(name: Name<M>, tx: Sender<M>) -> Result<(), R
|
||||
/// Bind `name` to `pid`'s mailbox and publish `tx` under `M`'s [`TypeId`], for
|
||||
/// an explicit (already-live) actor rather than `self`. The shared core of
|
||||
/// [`register`] (which passes `self_pid()`) and the parent-side server-name
|
||||
/// bind in `gen_server` (which names a freshly spawned server before its body
|
||||
/// has run, so the name resolves the instant `start()` returns). Same collision
|
||||
/// bind in `gen_server`, which names a freshly spawned server before its body
|
||||
/// has run, so the name resolves the instant `start()` returns. Same collision
|
||||
/// rules and lock discipline as `register`.
|
||||
pub(crate) fn register_with<M: Send + 'static>(
|
||||
me: Pid,
|
||||
@@ -314,21 +392,22 @@ pub(crate) fn register_with<M: Send + 'static>(
|
||||
if !live(inner, me) {
|
||||
return Err(RegisterError::NoProc);
|
||||
}
|
||||
if let Some(&holder_idx) = reg.by_name.get(key) {
|
||||
match reg.by_index.get(&holder_idx).map(|m| m.pid) {
|
||||
Some(holder) if holder == me => {} // same actor: just add the channel below
|
||||
Some(holder) if live(inner, holder) => {
|
||||
return Err(RegisterError::NameTaken { holder });
|
||||
}
|
||||
Some(_) => reg.prune(holder_idx), // dead holder: free the name
|
||||
None => {
|
||||
reg.by_name.remove(key); // dangling name: free it
|
||||
}
|
||||
if let Some(&holder) = reg.by_name.get(key) {
|
||||
if holder == me {
|
||||
// Same actor: just add the channel below.
|
||||
} else if live(inner, holder) {
|
||||
return Err(RegisterError::NameTaken { holder });
|
||||
} else {
|
||||
// Dead holder: free the name (and its other stale artifacts).
|
||||
// Liveness is judged against the *stored* pid, generation
|
||||
// included, so a recycled slot's live tenant no longer makes a
|
||||
// dead name read as taken.
|
||||
reg.prune_holder(holder);
|
||||
}
|
||||
}
|
||||
// Publish (or extend) the mailbox with this channel, then bind the name.
|
||||
publish_channel::<M>(&mut reg, me, tx);
|
||||
reg.by_name.insert(key, me.index());
|
||||
reg.by_name.insert(key, me);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -351,14 +430,14 @@ fn publish_channel<M: Send + 'static>(reg: &mut Registry, me: Pid, tx: Sender<M>
|
||||
|
||||
/// Publish the current actor's `Sender<A::Msg>` into its mailbox **without**
|
||||
/// binding a name, and hand back the typed [`Pid<A>`] that addresses this
|
||||
/// actor directly. This is the opt-in, lazy install of RFC 014 §5: an actor
|
||||
/// that wants to be reachable by a direct, identity-bound [`Pid<A>`] (rather
|
||||
/// than only via a re-resolving [`Name`]) calls this once with its inbox
|
||||
/// sender, then hands the returned pid out.
|
||||
/// actor directly.
|
||||
///
|
||||
/// Unlike [`register`] there is no name to collide on, and `self` is always a
|
||||
/// live actor inside `run()`, so this is infallible. Panics if called outside
|
||||
/// `Runtime::run()`.
|
||||
/// This is for an actor that wants to be reachable directly by its pid,
|
||||
/// rather than only through a re-resolving [`Name`]: call this once with your
|
||||
/// inbox sender, then hand the returned `Pid<A>` to whoever should be able to
|
||||
/// message you. Unlike [`register`] there is no name to collide on, and the
|
||||
/// current actor is always live while inside `run()`, so this cannot fail.
|
||||
/// Panics if called outside [`run`](crate::run).
|
||||
pub fn install<A: Addressable>(tx: Sender<A::Msg>) -> Pid<A> {
|
||||
let me = self_pid();
|
||||
with_runtime(|inner| {
|
||||
@@ -374,11 +453,12 @@ pub fn install<A: Addressable>(tx: Sender<A::Msg>) -> Pid<A> {
|
||||
/// Publish `tx` into `pid`'s mailbox under `M`'s [`TypeId`], for an explicit
|
||||
/// (freshly minted, already-live) actor rather than `self`. The parent-side
|
||||
/// half of [`spawn_addr`](crate::spawn_addr): the spawner makes the inbox and
|
||||
/// publishes the sender here *before* handing back the `Pid<A>`, so an immediate
|
||||
/// `send_to` on the returned pid always resolves — the address is live the
|
||||
/// instant the caller holds it, with no dependence on the body having run yet.
|
||||
/// publishes the sender here *before* handing back the `Pid<A>`, so an
|
||||
/// immediate `send_to` on the returned pid always resolves. The address is
|
||||
/// live the instant the caller holds it, with no dependence on the spawned
|
||||
/// actor's body having run yet.
|
||||
///
|
||||
/// Caller guarantees `pid` is the just-installed actor (Queued, this exact
|
||||
/// Caller guarantees `pid` is the just-installed actor (queued, this exact
|
||||
/// incarnation); `publish_channel` replaces any stale leftover at the slot.
|
||||
pub(crate) fn install_for<M: Send + 'static>(pid: Pid, tx: Sender<M>) {
|
||||
with_runtime(|inner| {
|
||||
@@ -388,109 +468,106 @@ pub(crate) fn install_for<M: Send + 'static>(pid: Pid, tx: Sender<M>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The single actor currently registered under `name`, or `None` if unbound or
|
||||
/// no longer live (the stale binding is pruned on the way out).
|
||||
/// Look up which actor currently holds `name`, if any. Returns `None` if the
|
||||
/// name is unbound, or if it was bound to an actor that has since died (the
|
||||
/// stale binding is cleared as a side effect of this call).
|
||||
pub fn whereis(name: &str) -> Option<Pid> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = *reg.by_name.get(name)?;
|
||||
match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => Some(pid),
|
||||
Some(_) => {
|
||||
reg.prune(idx);
|
||||
None
|
||||
}
|
||||
None => {
|
||||
reg.by_name.remove(name);
|
||||
None
|
||||
}
|
||||
let pid = *reg.by_name.get(name)?;
|
||||
if live(inner, pid) {
|
||||
Some(pid)
|
||||
} else {
|
||||
// Generation-checked against the stored holder: a recycled slot's
|
||||
// live tenant reads dead here, and the stale name heals.
|
||||
reg.prune_holder(pid);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `name` to a *typed* [`Pid<A>`] — the identity-bound counterpart of
|
||||
/// [`whereis`] (RFC 014 §4.4). Recovers the compile-checked
|
||||
/// [`send_to`] path from a durable name: looks the name up,
|
||||
/// then re-types the erased pid as `Pid<A>` via the unchecked
|
||||
/// [`assert_type`](crate::pid::assert_type) primitive. A wrong `A` is not
|
||||
/// unsound — it degrades to [`SendError::NoChannel`] on the next send (routing
|
||||
/// is by message `TypeId`), never a misdelivery. `None` if unbound or dead.
|
||||
/// Like [`whereis`], but returns a *typed* [`Pid<A>`] instead of a bare
|
||||
/// [`Pid`], so a follow-up [`send_to`] is compile-checked instead of needing
|
||||
/// the untyped [`send_dyn`] escape hatch. `None` if the name is unbound or its
|
||||
/// holder has died.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
/// The type `A` is not checked against what the name's holder actually
|
||||
/// published: if you pick the wrong `A`, this still succeeds, but the next
|
||||
/// send against the returned pid degrades to [`SendError::NoChannel`] rather
|
||||
/// than reaching the wrong actor or the wrong channel.
|
||||
///
|
||||
/// Panics if called outside [`run`](crate::run).
|
||||
pub fn lookup_as<A: Addressable>(name: &str) -> Option<Pid<A>> {
|
||||
whereis(name).map(crate::pid::assert_type::<A>)
|
||||
}
|
||||
|
||||
/// Resolve `name` to its actor's pid and a cloned `Sender<M>`, under the Leaf
|
||||
/// lock (clone-under-lock, then release). The crate-internal building block for
|
||||
/// `gen_server`'s by-name addressing: a named server publishes its inbox as a
|
||||
/// `Sender<Envelope<G>>` (via [`register_with`]), and `whereis_server` / `call`
|
||||
/// / `cast` recover that exact typed sender here to rebuild a `GenServerRef<G>`.
|
||||
/// `None` if unbound, dead (pruned on the way out), or holding no `M` channel.
|
||||
/// Resolve `name` to its actor's pid and a cloned `Sender<M>`, all under one
|
||||
/// lock acquisition. The crate-internal building block for `gen_server`'s
|
||||
/// by-name addressing: a named server publishes its inbox as a
|
||||
/// `Sender<Envelope<G>>` (via [`register_with`]), and the server's `call` /
|
||||
/// `cast` / `whereis_server` recover that exact typed sender here to rebuild a
|
||||
/// `GenServerRef<G>`. `None` if unbound, dead (pruned on the way out), or
|
||||
/// holding no `M` channel.
|
||||
pub(crate) fn resolve_named_sender<M: Send + 'static>(name: &str) -> Option<(Pid, Sender<M>)> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = *reg.by_name.get(name)?;
|
||||
let pid = match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => pid,
|
||||
Some(_) => {
|
||||
reg.prune(idx);
|
||||
return None;
|
||||
}
|
||||
None => {
|
||||
reg.by_name.remove(name);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let tx = reg.by_index.get(&idx).and_then(Mailbox::clone_sender::<M>)?;
|
||||
let pid = *reg.by_name.get(name)?;
|
||||
if !live(inner, pid) {
|
||||
// Stored-pid liveness, generation included: a name whose holder
|
||||
// died is pruned (heals) even if the slot has a new tenant.
|
||||
// Otherwise the tenant's mailbox would make the name unresolvable
|
||||
// without pruning, wedging it for the tenant's lifetime.
|
||||
reg.prune_holder(pid);
|
||||
return None;
|
||||
}
|
||||
// A live holder's mailbox is its own (publish replaces wholesale on
|
||||
// pid mismatch, and one live actor per slot), so index lookup is safe.
|
||||
let tx = reg.by_index.get(&pid.index()).and_then(Mailbox::clone_sender::<M>)?;
|
||||
Some((pid, tx))
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the binding for `name`, returning the actor it pointed at if still
|
||||
/// live. Only the *name* is freed; the actor's mailbox (and any other names for
|
||||
/// it) remain. A binding to a dead actor is reported as `None`.
|
||||
/// Give up a name. Returns the actor it pointed at, if that actor was still
|
||||
/// live. Only the *name* is freed; the actor's mailbox (and any other names
|
||||
/// bound to it) are unaffected. A binding to an already-dead actor reports
|
||||
/// `None`, since there was nothing live to release.
|
||||
pub fn unregister(name: &str) -> Option<Pid> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = reg.by_name.remove(name)?;
|
||||
match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => Some(pid),
|
||||
_ => None,
|
||||
}
|
||||
let pid = reg.by_name.remove(name)?;
|
||||
if live(inner, pid) { Some(pid) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `name` to its actor's `Sender<M>` and deliver `msg`. The whole point
|
||||
/// of the rework: a name you can *send* to.
|
||||
/// Look `name` up and deliver `msg` to whichever actor currently holds it.
|
||||
/// This is the point of naming an actor: a name you can send a message to
|
||||
/// directly, without a separate lookup step.
|
||||
///
|
||||
/// Errors (message returned in every case): [`SendError::Unresolved`] if no
|
||||
/// live actor holds the name, [`SendError::NoChannel`] if that actor has no
|
||||
/// channel for `M`, [`SendError::Closed`] if its `M` channel's receiver is
|
||||
/// gone. Panics if called outside `Runtime::run()`.
|
||||
/// On failure the message comes back to you, wrapped in the [`SendError`]
|
||||
/// variant that explains why: [`SendError::Unresolved`] if no live actor
|
||||
/// currently holds the name, [`SendError::NoChannel`] if the actor that holds
|
||||
/// it never published a channel for `M`, or [`SendError::Closed`] if it
|
||||
/// published one but has since dropped the receiving end. Panics if called
|
||||
/// outside [`run`](crate::run).
|
||||
pub fn send<M: Send + 'static>(name: Name<M>, msg: M) -> Result<(), SendError<M>> {
|
||||
let key = name.as_str();
|
||||
with_runtime(|inner| {
|
||||
// Resolve + clone the sender under the Leaf lock, then drop the lock
|
||||
// before sending (a send can unpark a receiver).
|
||||
// Resolve + clone the sender under the registry lock, then drop the
|
||||
// lock before sending (a send can unpark a receiver).
|
||||
let tx = {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = match reg.by_name.get(key) {
|
||||
Some(&i) => i,
|
||||
let pid = match reg.by_name.get(key) {
|
||||
Some(&p) => p,
|
||||
None => return Err(SendError::Unresolved(msg)),
|
||||
};
|
||||
let pid = match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
reg.by_name.remove(key);
|
||||
return Err(SendError::Unresolved(msg));
|
||||
}
|
||||
};
|
||||
if !live(inner, pid) {
|
||||
reg.prune(idx);
|
||||
// Stored-pid liveness (generation included), so a recycled
|
||||
// slot's new live tenant is never mistaken for the name's
|
||||
// original (now-dead) holder.
|
||||
reg.prune_holder(pid);
|
||||
return Err(SendError::Unresolved(msg));
|
||||
}
|
||||
match reg.by_index.get(&idx).and_then(Mailbox::clone_sender::<M>) {
|
||||
match reg.by_index.get(&pid.index()).and_then(Mailbox::clone_sender::<M>) {
|
||||
Some(tx) => tx,
|
||||
None => return Err(SendError::NoChannel(msg)),
|
||||
}
|
||||
@@ -501,18 +578,19 @@ pub fn send<M: Send + 'static>(name: Name<M>, msg: M) -> Result<(), SendError<M>
|
||||
|
||||
/// Resolve a *raw* pid to its mailbox and deliver `msg` on the channel for `M`,
|
||||
/// with **no redirect**. The stored mailbox must be this exact incarnation
|
||||
/// (generation included) and still live; otherwise the actor this pid named is
|
||||
/// gone and the result is [`SendError::Dead`] — even when the slot now holds a
|
||||
/// different, live actor (which we leave untouched). Shared by [`send_to`]
|
||||
/// (typed, `M = A::Msg`, channel guaranteed on an installed actor) and
|
||||
/// [`send_dyn`] (explicit `M`, where `NoChannel` is a real outcome).
|
||||
/// (generation included) and still live; otherwise the actor this pid named
|
||||
/// is gone and the result is [`SendError::Dead`], even when the slot now
|
||||
/// holds a different, live actor (which is left untouched). Shared by
|
||||
/// [`send_to`] (typed, `M = A::Msg`, channel guaranteed on an installed
|
||||
/// actor) and [`send_dyn`] (explicit `M`, where `NoChannel` is a real
|
||||
/// outcome).
|
||||
fn send_to_pid<M: Send + 'static>(
|
||||
inner: &crate::runtime::RuntimeInner,
|
||||
pid: Pid,
|
||||
msg: M,
|
||||
) -> Result<(), SendError<M>> {
|
||||
// Resolve + clone the sender under the Leaf lock, then drop the lock before
|
||||
// sending (a send can unpark a receiver) — Leaf -> Channel, as name `send`.
|
||||
// Resolve + clone the sender under the registry lock, then drop the lock
|
||||
// before sending (a send can unpark a receiver), same order as `send`.
|
||||
let tx = {
|
||||
let mut reg = inner.registry.lock();
|
||||
match reg.by_index.get(&pid.index()).map(|m| m.pid) {
|
||||
@@ -525,7 +603,7 @@ fn send_to_pid<M: Send + 'static>(
|
||||
}
|
||||
// Our incarnation's mailbox, but the actor has died: prune + Dead.
|
||||
Some(stored) if stored == pid => {
|
||||
reg.prune(pid.index());
|
||||
reg.prune_holder(pid);
|
||||
return Err(SendError::Dead(msg));
|
||||
}
|
||||
// A different incarnation (or nothing) occupies the slot: the actor
|
||||
@@ -536,32 +614,36 @@ fn send_to_pid<M: Send + 'static>(
|
||||
tx.send(msg).map_err(|crate::channel::SendError(m)| SendError::Closed(m))
|
||||
}
|
||||
|
||||
/// Deliver `msg` to the exact actor named by `pid` — RFC 014 §4.2's direct,
|
||||
/// identity-bound addressing mode. Unlike name-addressed [`send`] there is **no
|
||||
/// redirect**: if that incarnation has died the message comes back as
|
||||
/// [`SendError::Dead`], even if its slot now holds a different actor.
|
||||
/// Deliver `msg` directly to the exact actor identified by `pid`. Unlike
|
||||
/// name-addressed [`send`], there is **no redirect**: if that specific actor
|
||||
/// has died, the message comes back as [`SendError::Dead`], even if its slot
|
||||
/// has since been taken over by a different, live actor. Use this when you
|
||||
/// already hold a `Pid<A>` and want to talk to that one actor specifically;
|
||||
/// use [`send`] with a [`Name`] when you want whichever actor currently holds
|
||||
/// a name.
|
||||
///
|
||||
/// The message type is the actor's `A::Msg`, so on a live actor that has
|
||||
/// installed its inbox (via [`install`] or [`register`]) the channel is always
|
||||
/// present; [`SendError::NoChannel`] therefore means the actor is live but
|
||||
/// never published a `Pid<A>`-reachable inbox. Panics if called outside
|
||||
/// `Runtime::run()`.
|
||||
/// installed its inbox (via [`install`] or [`register`]) the channel is
|
||||
/// always present; [`SendError::NoChannel`] therefore means the actor is live
|
||||
/// but never published a `Pid<A>`-reachable inbox. Panics if called outside
|
||||
/// [`run`](crate::run).
|
||||
pub fn send_to<A: Addressable>(pid: Pid<A>, msg: A::Msg) -> Result<(), SendError<A::Msg>> {
|
||||
with_runtime(|inner| send_to_pid::<A::Msg>(inner, pid.erase(), msg))
|
||||
}
|
||||
|
||||
/// The explicit bare-pid escape hatch (RFC 014 §4.6): deliver `msg` of type `M`
|
||||
/// to `pid` when all you hold is an untyped [`Pid`] — a pid off a [`Down`], or
|
||||
/// out of a future `members()` — so the typed [`send_to`] is unavailable.
|
||||
/// The escape hatch for sending to a bare, untyped [`Pid`] when the typed
|
||||
/// [`send_to`] is unavailable, for example a pid recovered from a [`Down`]
|
||||
/// notification or a group's `members()` list, where you no longer know the
|
||||
/// actor's message type at compile time.
|
||||
///
|
||||
/// This is the one send whose message type can genuinely be wrong: the actor
|
||||
/// may be live yet expose no channel for `M`, returning [`SendError::NoChannel`]
|
||||
/// (on the typed paths that downcast collapses to a `debug_assert`). It is
|
||||
/// named and documented as the fallible fallback so the typed `Pid<A>` /
|
||||
/// `Name<M>` paths stay the obvious default and an agentic caller reaches for a
|
||||
/// present primitive instead of inventing a workaround. Liveness is identical
|
||||
/// to [`send_to`]: identity-bound, no redirect, [`SendError::Dead`] once the
|
||||
/// addressed incarnation is gone. Panics if called outside `Runtime::run()`.
|
||||
/// Because the message type is not checked at compile time here, this is the
|
||||
/// one send that can genuinely be live-but-wrong: the actor may be alive yet
|
||||
/// expose no channel for `M`, in which case you get [`SendError::NoChannel`]
|
||||
/// back instead of a misdelivery. Liveness and redirect behavior are
|
||||
/// otherwise identical to [`send_to`]: identity-bound, no redirect,
|
||||
/// [`SendError::Dead`] once the addressed incarnation is gone. Prefer
|
||||
/// `send_to` with a typed `Pid<A>` whenever you have one; reach for this only
|
||||
/// when you don't. Panics if called outside [`run`](crate::run).
|
||||
///
|
||||
/// [`Down`]: crate::Down
|
||||
pub fn send_dyn<M: Send + 'static>(pid: Pid, msg: M) -> Result<(), SendError<M>> {
|
||||
|
||||
+27
-3
@@ -113,16 +113,32 @@ impl MutexQueue {
|
||||
|
||||
pub fn push(&self, pid: Pid) {
|
||||
assert_no_preempt();
|
||||
self.q.lock().unwrap().push_back(pid);
|
||||
match self.q.lock() {
|
||||
Ok(mut g) => g.push_back(pid),
|
||||
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<Pid> {
|
||||
assert_no_preempt();
|
||||
self.q.lock().unwrap().pop_front()
|
||||
match self.q.lock() {
|
||||
Ok(mut g) => g.pop_front(),
|
||||
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
self.q.lock().unwrap().len() as u64
|
||||
match self.q.lock() {
|
||||
Ok(g) => g.len() as u64,
|
||||
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self.q.lock() {
|
||||
Ok(g) => g.is_empty(),
|
||||
Err(e) => panic!("smarm: run-queue q lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +278,10 @@ impl MpmcRing {
|
||||
let d = self.dequeue_pos.0.load(Ordering::Relaxed);
|
||||
e.saturating_sub(d) as u64
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -341,6 +361,10 @@ impl StripedRing {
|
||||
pub fn len(&self) -> u64 {
|
||||
self.stripes.iter().map(|s| s.len()).sum()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.stripes.iter().all(|s| s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+448
-158
@@ -86,21 +86,28 @@
|
||||
//! # Termination (counter-based)
|
||||
//!
|
||||
//! The old all-clear scanned the slot table under the big lock. Now:
|
||||
//! exit when `io_out == 0` (read *before* the queue lock, phase-1 ordering)
|
||||
//! and, under the queue lock, the queue is empty and `live_actors == 0`.
|
||||
//! `live_actors` is incremented in `spawn` before the enqueue and decremented
|
||||
//! at the very END of `finalize_actor`, strictly after every wakeup that
|
||||
//! finalize produces has been enqueued. The soundness crux: any enqueue
|
||||
//! targets a live (not-yet-finalized) actor, so `live == 0` implies no wakeup
|
||||
//! can still be in flight; combined with "spawner is itself live", observing
|
||||
//! `(queue empty, live == 0)` under the queue lock means no work can ever
|
||||
//! appear again.
|
||||
//! exit when `io_outstanding + io_fd_waiters == 0` (two Relaxed/Acquire
|
||||
//! atomic loads, read *before* the queue pop) and, under the queue lock,
|
||||
//! the queue is empty and `live_actors == 0`. `live_actors` is incremented
|
||||
//! in `spawn` before the enqueue and decremented at the very END of
|
||||
//! `finalize_actor`, strictly after every wakeup that finalize produces has
|
||||
//! been enqueued. The soundness crux: any enqueue targets a live
|
||||
//! (not-yet-finalized) actor, so `live == 0` implies no wakeup can still be
|
||||
//! in flight; combined with "spawner is itself live", observing
|
||||
//! `(queue empty, live == 0)` means no work can ever appear again.
|
||||
//!
|
||||
//! # Timer / IO drain (try-lock, one-winner)
|
||||
//! # Scheduler park/wake (RFC 018)
|
||||
//!
|
||||
//! Unchanged from phase 1: one winner per round drains due timers and IO
|
||||
//! completions from their own mutexes; wakeups go through the unpark
|
||||
//! protocol like everyone else's.
|
||||
//! Schedulers sleep on per-thread futex parkers via the coordination layer
|
||||
//! (`park.rs`), NOT on a shared wake pipe. IO backends are producers behind
|
||||
//! a two-call contract — make the actor runnable (`unpark_at`), whose
|
||||
//! `enqueue` tail wakes exactly one parked scheduler. The blocking pool and
|
||||
//! epoll thread each route their own completions (driver-enqueues); there
|
||||
//! is no shared completion queue, no drain lock, no one-winner drain phase.
|
||||
//! Timers fire two ways: a busy-path due-check every loop iteration (one
|
||||
//! Relaxed load of the earliest-deadline snapshot when no timer is armed),
|
||||
//! and the timekeeper — at most one parked scheduler holds the timer
|
||||
//! deadline, so an expiry wakes one scheduler, not a herd.
|
||||
|
||||
use crate::actor::{
|
||||
clear_current_pid, is_actor_done, reset_actor_done, set_current_actor_box,
|
||||
@@ -445,6 +452,42 @@ pub(crate) struct Slot {
|
||||
/// and only when the `budget-accounting` feature is on (it costs two RDTSC
|
||||
/// per resume); stays 0 otherwise. Same single-writer Relaxed discipline.
|
||||
budget_cycles: AtomicU64,
|
||||
/// RFC 007 (`smarm-causal`) — id of the causal-profiling site this actor
|
||||
/// is currently inside (0 = none). Lives in the slot, not a thread-local,
|
||||
/// so it survives preemption and cross-scheduler migration. Written only
|
||||
/// by the actor itself (guard enter/exit on its own thread), read by that
|
||||
/// same thread in `maybe_preempt` — single-writer Relaxed, like `overruns`.
|
||||
/// Exists regardless of the feature; stays 0 without it.
|
||||
causal_site: AtomicU32,
|
||||
/// RFC 007 (`smarm-causal`) — virtual-speedup delay cycles this actor has
|
||||
/// absorbed (or been credited). Compared against the global ledger in
|
||||
/// `causal::check`; fast-forwarded on resume-from-park so blocked time
|
||||
/// absorbs delay for free (Coz's blocked-thread rule). Same single-writer
|
||||
/// discipline.
|
||||
causal_delay: AtomicU64,
|
||||
/// RFC 007 (`smarm-causal`) — true iff this actor's last deschedule was a
|
||||
/// real park (a successful `park_return`, not a slice yield and not the
|
||||
/// instant-wake re-queue). Consumed by `causal::on_resume`: only a wake
|
||||
/// from genuine blocking forgives outstanding virtual delay; a merely
|
||||
/// preempted (runnable) actor stays in debt and must pay by spinning.
|
||||
/// Starts false: a fresh spawn is born current (`reset_counters` sets
|
||||
/// `causal_delay` to the global ledger), and anything injected while it
|
||||
/// sits spawn-queued is owed, not waived. Written by the owning
|
||||
/// scheduler thread at the deschedule /
|
||||
/// resume boundary only — same single-writer discipline as `causal_delay`.
|
||||
causal_parked: AtomicBool,
|
||||
/// RFC 007 — TSC at this actor's last *runnable* (yield) deschedule
|
||||
/// while it sat in the live experiment's target site; 0 = no gap
|
||||
/// pending. Paired with `causal_desched_epoch`; written on the
|
||||
/// deschedule path, consumed at the next resume — same single-writer
|
||||
/// discipline as `causal_delay`.
|
||||
causal_desched_tsc: AtomicU64,
|
||||
/// RFC 007 — experiment epoch live at that deschedule (see
|
||||
/// `causal::EXPERIMENT_EPOCH`): the resume counts the gap only into
|
||||
/// the same window, so one straddling `end()` — or a later `begin()`
|
||||
/// with an identical site+pct word — is dropped instead of leaking a
|
||||
/// cooldown across windows.
|
||||
causal_desched_epoch: AtomicU64,
|
||||
/// Cold lifecycle data. See [`SlotCold`].
|
||||
pub(crate) cold: RawMutex<SlotCold>,
|
||||
}
|
||||
@@ -459,6 +502,14 @@ impl Slot {
|
||||
overruns: AtomicU64::new(0),
|
||||
messages_received: AtomicU64::new(0),
|
||||
budget_cycles: AtomicU64::new(0),
|
||||
causal_site: AtomicU32::new(0),
|
||||
causal_delay: AtomicU64::new(0),
|
||||
// Overwritten at every occupancy by `reset_counters` (born
|
||||
// current); false so a hypothetical reset-skipping path cannot
|
||||
// waive the whole global backlog at first resume.
|
||||
causal_parked: AtomicBool::new(false),
|
||||
causal_desched_tsc: AtomicU64::new(0),
|
||||
causal_desched_epoch: AtomicU64::new(0),
|
||||
cold: RawMutex::new(SlotCold {
|
||||
actor: None,
|
||||
waiters: Vec::new(),
|
||||
@@ -547,6 +598,92 @@ impl Slot {
|
||||
self.overruns.store(0, Ordering::Relaxed);
|
||||
self.messages_received.store(0, Ordering::Relaxed);
|
||||
self.budget_cycles.store(0, Ordering::Relaxed);
|
||||
self.causal_site.store(0, Ordering::Relaxed);
|
||||
// Born current (Coz's new-thread rule): a fresh incarnation neither
|
||||
// owes the process's accumulated delay history nor books it as park
|
||||
// forgiveness. The old init (delay 0, parked true) waived the whole
|
||||
// monotone backlog once per spawn via the first resume — found live
|
||||
// under close-mode conn churn (~95k spawns/s): millions of phantom
|
||||
// forgiven ms per 700ms window, even in 0% cells. Parked starts
|
||||
// false: delay injected while spawn-queued is *owed* (the newborn is
|
||||
// runnable, not blocked) and paid at its first check — the semantics
|
||||
// `audit_zero_pct_window_absorbs_leftover_debt` pins.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
self.causal_delay
|
||||
.store(crate::causal::global_delay_cycles(), Ordering::Relaxed);
|
||||
#[cfg(not(feature = "smarm-causal"))]
|
||||
self.causal_delay.store(0, Ordering::Relaxed);
|
||||
self.causal_parked.store(false, Ordering::Relaxed);
|
||||
self.causal_desched_tsc.store(0, Ordering::Relaxed);
|
||||
self.causal_desched_epoch.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// RFC 007 — mark that this actor's deschedule was a genuine park.
|
||||
/// Called from the scheduler's `YieldIntent::Park` branch on a successful
|
||||
/// `park_return` only.
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn set_causal_parked(&self) {
|
||||
self.causal_parked.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// RFC 007 — consume the parked marker at resume: returns whether the
|
||||
/// last deschedule was a real park, and clears it so the next resume
|
||||
/// defaults to "was runnable" unless the park branch says otherwise.
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn take_causal_parked(&self) -> bool {
|
||||
self.causal_parked.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// RFC 007 — stash the runnable-deschedule instant and the experiment
|
||||
/// epoch it happened under (offcpu-gap audit; see `causal::on_resume`).
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn set_causal_desched(&self, tsc: u64, epoch: u64) {
|
||||
self.causal_desched_tsc.store(tsc, Ordering::Relaxed);
|
||||
self.causal_desched_epoch.store(epoch, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// RFC 007 — consume the stash: `(tsc, epoch)`; `(0, _)` = none pending.
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn take_causal_desched(&self) -> (u64, u64) {
|
||||
let tsc = self.causal_desched_tsc.swap(0, Ordering::Relaxed);
|
||||
let epoch = self.causal_desched_epoch.load(Ordering::Relaxed);
|
||||
(tsc, epoch)
|
||||
}
|
||||
|
||||
/// RFC 007 — current causal site id (0 = none). Single-writer: only the
|
||||
/// on-CPU actor's thread writes, via the site-guard enter/exit.
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn causal_site(&self) -> u32 {
|
||||
self.causal_site.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// RFC 007 — write the current causal site id (guard enter/exit).
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn set_causal_site(&self, id: u32) {
|
||||
self.causal_site.store(id, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// RFC 007 — absorbed/credited virtual-delay cycles.
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn causal_delay(&self) -> u64 {
|
||||
self.causal_delay.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// RFC 007 — set the absorbed-delay ledger (spin-absorb, credit, or the
|
||||
/// resume-path fast-forward). Single-writer per the resume protocol: the
|
||||
/// actor's own thread while on-CPU, the resuming scheduler thread at the
|
||||
/// resume boundary — never both at once.
|
||||
#[cfg_attr(not(feature = "smarm-causal"), allow(dead_code))]
|
||||
#[inline]
|
||||
pub(crate) fn set_causal_delay(&self, v: u64) {
|
||||
self.causal_delay.store(v, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A pid's-eye snapshot of the slot. Cold paths re-read this under the
|
||||
@@ -620,8 +757,21 @@ pub(crate) struct RuntimeInner {
|
||||
pub(crate) io: Mutex<Option<IoThread>>,
|
||||
/// Monotonic `MonitorId` source. Never reused.
|
||||
pub(crate) next_monitor_id: AtomicU64,
|
||||
/// Try-lock: exactly one scheduler thread drains timers/IO per iteration.
|
||||
drain_lock: Mutex<()>,
|
||||
/// RFC 018: the scheduler coordination layer — per-scheduler parkers,
|
||||
/// idle mask, wake protocol, timekeeper role, earliest-deadline
|
||||
/// snapshot. Arc'd because `Timers` shares it (insert-side deadline
|
||||
/// notes run under the timers mutex).
|
||||
pub(crate) coord: Arc<crate::park::Coordinator>,
|
||||
/// `block_on_io` requests in flight. Incremented by the submitter
|
||||
/// BEFORE submit (underflow-proof), decremented by the pool thread on
|
||||
/// completion. Read lock-free by the idle path's termination verdict —
|
||||
/// the per-pop `io.lock` of the drain era is gone.
|
||||
pub(crate) io_outstanding: AtomicU32,
|
||||
/// Parked fd waiters. Incremented by the registrar BEFORE
|
||||
/// `epoll_register` (rolled back on error), decremented by whoever
|
||||
/// consumes the registration (epoll thread on readiness, canceller on
|
||||
/// an unwound wait). Same lock-free verdict read as `io_outstanding`.
|
||||
pub(crate) io_fd_waiters: AtomicU32,
|
||||
/// Per-thread stats, indexed by scheduler thread slot (0..N).
|
||||
pub(crate) stats: Vec<SchedulerStats>,
|
||||
/// Global counters for RFC 000 primitives.
|
||||
@@ -655,6 +805,9 @@ pub(crate) struct RuntimeInner {
|
||||
}
|
||||
|
||||
impl RuntimeInner {
|
||||
// Private constructor taking the parsed Config fields one-for-one; a params
|
||||
// struct would only move the same 8 values across the call boundary.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
thread_count: usize,
|
||||
alloc_interval: u32,
|
||||
@@ -669,6 +822,12 @@ impl RuntimeInner {
|
||||
let slots: Box<[Slot]> = (0..max_actors).map(|_| Slot::vacant()).collect();
|
||||
// Low indices on top of the stack so early spawns get low pids.
|
||||
let free: Vec<u32> = (0..max_actors as u32).rev().collect();
|
||||
// RFC 018: the coordination layer (asserts thread_count <= 64), and
|
||||
// the timers' hook into it — every insert under the timers mutex
|
||||
// notes its deadline (busy-path snapshot + timekeeper re-arm).
|
||||
let coord = Arc::new(crate::park::Coordinator::new(thread_count));
|
||||
let mut timers = Timers::new();
|
||||
timers.attach_coordinator(coord.clone());
|
||||
Arc::new(Self {
|
||||
run_queue: crate::run_queue::RunQueue::new(thread_count, max_actors),
|
||||
slots,
|
||||
@@ -677,10 +836,12 @@ impl RuntimeInner {
|
||||
root_bits: AtomicU64::new(u64::MAX),
|
||||
root_exited: AtomicBool::new(false),
|
||||
root_swept: AtomicBool::new(false),
|
||||
timers: Mutex::new(Timers::new()),
|
||||
timers: Mutex::new(timers),
|
||||
io: Mutex::new(None),
|
||||
next_monitor_id: AtomicU64::new(0),
|
||||
drain_lock: Mutex::new(()),
|
||||
coord,
|
||||
io_outstanding: AtomicU32::new(0),
|
||||
io_fd_waiters: AtomicU32::new(0),
|
||||
stats,
|
||||
io_parked: AtomicU32::new(0),
|
||||
sleeping: AtomicU32::new(0),
|
||||
@@ -741,6 +902,13 @@ impl RuntimeInner {
|
||||
);
|
||||
self.run_queue.push(pid);
|
||||
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||
// RFC 018 enqueue wake (fixes the silent enqueue): if a scheduler
|
||||
// is parked, wake exactly one. The fast path when everyone is busy
|
||||
// is a fence + one Relaxed load of an unmodified line — the
|
||||
// pure-compute hot path pays (almost) nothing. Bias is over-wake:
|
||||
// a spurious wake costs one futex round-trip and a failed pop; a
|
||||
// missed wake would cost a stranded actor.
|
||||
self.coord.wake_one_if_idle();
|
||||
}
|
||||
|
||||
/// Make `pid` runnable if it is parked; coalesce or defer otherwise.
|
||||
@@ -762,7 +930,10 @@ impl RuntimeInner {
|
||||
/// park-epoch and return it. See slot_state.rs for the rules.
|
||||
#[must_use]
|
||||
pub(crate) fn begin_wait(&self, pid: Pid) -> u32 {
|
||||
let slot = self.slot_at(pid).expect("begin_wait: own slot vanished");
|
||||
let slot = match self.slot_at(pid) {
|
||||
Some(slot) => slot,
|
||||
None => panic!("begin_wait: own slot vanished: {:?}", pid),
|
||||
};
|
||||
slot.word.begin_wait(pid.generation())
|
||||
}
|
||||
|
||||
@@ -771,7 +942,10 @@ impl RuntimeInner {
|
||||
/// then eat a notification that already landed. The caller MUST
|
||||
/// re-check its stop flag afterwards — see `StateWord::clear_notify`.
|
||||
pub(crate) fn retire_wait(&self, pid: Pid) {
|
||||
let slot = self.slot_at(pid).expect("retire_wait: own slot vanished");
|
||||
let slot = match self.slot_at(pid) {
|
||||
Some(slot) => slot,
|
||||
None => panic!("retire_wait: own slot vanished: {:?}", pid),
|
||||
};
|
||||
let _ = slot.word.begin_wait(pid.generation());
|
||||
slot.word.clear_notify(pid.generation());
|
||||
}
|
||||
@@ -890,6 +1064,11 @@ pub fn init(config: Config) -> Runtime {
|
||||
impl Runtime {
|
||||
/// Run `f` as the initial actor, block until all actors finish.
|
||||
/// Can be called multiple times sequentially on the same `Runtime`.
|
||||
///
|
||||
/// A panic in the initial actor propagates out of `run` (re-raised
|
||||
/// after teardown, so the `Runtime` stays reusable even under a
|
||||
/// caller's `catch_unwind`). Panics in other actors surface through
|
||||
/// joins, links, monitors, and supervisors as usual.
|
||||
pub fn run(&self, f: impl FnOnce() + Send + 'static) {
|
||||
// Install smarm's panic hook on first call. The default Rust hook is
|
||||
// not reentrant — concurrent actor panics can trigger a double-panic
|
||||
@@ -932,7 +1111,23 @@ impl Runtime {
|
||||
self.inner.live_actors.load(Ordering::Acquire), 0,
|
||||
"run() called while previous run still active"
|
||||
);
|
||||
*self.inner.io.lock().unwrap() = Some(IoThread::start().expect("failed to start IO thread"));
|
||||
// RFC 018: the IO producers reach the runtime (slot table + unpark)
|
||||
// through a Weak, so no RuntimeInner → IoThread → RuntimeInner cycle
|
||||
// forms. Reset the in-flight counters BEFORE the threads can touch
|
||||
// them (a prior run left them at 0 on a clean exit; the asserts pin
|
||||
// that).
|
||||
debug_assert_eq!(self.inner.io_outstanding.load(Ordering::Acquire), 0);
|
||||
debug_assert_eq!(self.inner.io_fd_waiters.load(Ordering::Acquire), 0);
|
||||
self.inner.io_outstanding.store(0, Ordering::Release);
|
||||
self.inner.io_fd_waiters.store(0, Ordering::Release);
|
||||
let io_thread = match IoThread::start(Arc::downgrade(&self.inner)) {
|
||||
Ok(io) => io,
|
||||
Err(e) => panic!("failed to start IO thread: {e}"),
|
||||
};
|
||||
match self.inner.io.lock() {
|
||||
Ok(mut io) => *io = Some(io_thread),
|
||||
Err(e) => panic!("smarm: io lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
|
||||
// RFC 005: slot counters reset at the START of a run (not the end),
|
||||
// so `stats()` read after `run()` returns reports that run's totals.
|
||||
@@ -951,16 +1146,27 @@ impl Runtime {
|
||||
self.inner.root_swept.store(false, Ordering::Relaxed);
|
||||
self.inner.set_root(initial_handle.pid());
|
||||
|
||||
// Launch N-1 extra scheduler threads. The calling thread is thread 0.
|
||||
// Launch N-1 extra scheduler threads, named `smarm-sched-{slot}` so
|
||||
// they are identifiable in `/proc/<pid>/task/*/comm`, stack dumps and
|
||||
// debuggers. The calling thread is thread 0 and keeps its caller-given
|
||||
// name (an embedder typically names it when spawning `run`).
|
||||
let mut os_threads = Vec::new();
|
||||
for slot in 1..self.thread_count {
|
||||
let inner = self.inner.clone();
|
||||
let t = thread::spawn(move || {
|
||||
RUNTIME.with(|r| *r.borrow_mut() = Some(inner.clone()));
|
||||
SCHED_SLOT.with(|s| s.set(slot));
|
||||
schedule_loop(&inner, slot);
|
||||
RUNTIME.with(|r| *r.borrow_mut() = None);
|
||||
});
|
||||
let t = thread::Builder::new()
|
||||
.name(format!("smarm-sched-{slot}"))
|
||||
.spawn(move || {
|
||||
RUNTIME.with(|r| *r.borrow_mut() = Some(inner.clone()));
|
||||
SCHED_SLOT.with(|s| s.set(slot));
|
||||
schedule_loop(&inner, slot);
|
||||
RUNTIME.with(|r| *r.borrow_mut() = None);
|
||||
});
|
||||
// `thread::spawn` (the previous form) also panics when the OS
|
||||
// refuses a thread, so this keeps the failure semantics.
|
||||
let t = match t {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("failed to spawn smarm scheduler thread: {e}"),
|
||||
};
|
||||
os_threads.push(t);
|
||||
}
|
||||
|
||||
@@ -973,12 +1179,35 @@ impl Runtime {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
||||
// The root's outcome, read before its handle drops (the outstanding
|
||||
// handle pins the slot: no reclaim, no generation change under us).
|
||||
// A root panic must escape `run()` — propagated at the tail below,
|
||||
// after teardown. Dropping it unread here made every assert inside
|
||||
// `run` silently vacuous (found live: a failing-first test passed).
|
||||
let root_outcome = {
|
||||
let slot = match self.inner.slot_at(initial_handle.pid()) {
|
||||
Some(slot) => slot,
|
||||
None => panic!("run(): root pid out of range"),
|
||||
};
|
||||
debug_assert!(
|
||||
slot.status_for(initial_handle.pid()) == Status::Done,
|
||||
"root not Done at run() teardown"
|
||||
);
|
||||
slot.cold.lock().outcome.take()
|
||||
};
|
||||
|
||||
// Drop initial handle (decrements outstanding_handles count).
|
||||
drop(initial_handle);
|
||||
|
||||
// Tear down IO and clean up for the next run() call.
|
||||
drop(self.inner.io.lock().unwrap().take()); // joins IO threads
|
||||
self.inner.timers.lock().unwrap().clear();
|
||||
match self.inner.io.lock() {
|
||||
Ok(mut io) => drop(io.take()), // joins IO threads
|
||||
Err(e) => panic!("smarm: io lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
match self.inner.timers.lock() {
|
||||
Ok(mut timers) => timers.clear(),
|
||||
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
self.inner.next_monitor_id.store(0, Ordering::Relaxed);
|
||||
// Every slot must have come back: any leak here is a runtime bug
|
||||
// (a JoinHandle held across run() is decremented just above).
|
||||
@@ -994,12 +1223,34 @@ impl Runtime {
|
||||
}
|
||||
self.inner.io_parked.store(0, Ordering::Relaxed);
|
||||
self.inner.sleeping.store(0, Ordering::Relaxed);
|
||||
self.inner.io_outstanding.store(0, Ordering::Relaxed);
|
||||
self.inner.io_fd_waiters.store(0, Ordering::Relaxed);
|
||||
|
||||
RUNTIME.with(|r| *r.borrow_mut() = None);
|
||||
|
||||
// Flush trace to disk (no-op without smarm-trace).
|
||||
#[cfg(feature = "smarm-trace")]
|
||||
crate::trace::flush();
|
||||
|
||||
// Propagate a root panic — last, after the full teardown above, so
|
||||
// a `catch_unwind` around `run()` leaves the `Runtime` reusable.
|
||||
// `Exit` and `Stopped` return normally: a cooperatively stopped
|
||||
// root is not an error. `resume_unwind` re-raises the original
|
||||
// payload; the throw-site hook output was suppressed in-actor, so
|
||||
// the caller sees the payload message without the origin file:line.
|
||||
if let Some(Outcome::Panic(payload)) = root_outcome {
|
||||
// Surface the message: the throw-site hook output was suppressed
|
||||
// in-actor, so without this a harness shows a bare FAILED.
|
||||
let msg: Option<&str> = payload
|
||||
.downcast_ref::<&'static str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(String::as_str));
|
||||
eprintln!(
|
||||
"smarm: root actor panicked: {}",
|
||||
msg.unwrap_or("<non-string panic payload>")
|
||||
);
|
||||
std::panic::resume_unwind(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of runtime statistics for introspection / tests.
|
||||
@@ -1156,10 +1407,16 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
||||
Outcome::Stopped => (Outcome::Stopped, Signal::Stopped(pid), DownReason::Stopped),
|
||||
};
|
||||
|
||||
let slot = inner.slot_at(pid).expect("finalize_actor: pid out of range");
|
||||
let slot = match inner.slot_at(pid) {
|
||||
Some(slot) => slot,
|
||||
None => panic!("finalize_actor: pid out of range: {:?}", pid),
|
||||
};
|
||||
let (waiters, monitors, links, actor) = {
|
||||
let mut cold = slot.cold.lock();
|
||||
let actor = cold.actor.take().expect("finalize_actor: actor vanished");
|
||||
let actor = match cold.actor.take() {
|
||||
Some(actor) => actor,
|
||||
None => panic!("finalize_actor: actor vanished"),
|
||||
};
|
||||
cold.outcome = Some(joiner_outcome);
|
||||
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
|
||||
// Done is published under the cold lock, so join's
|
||||
@@ -1282,6 +1539,56 @@ fn stop_live_actors(inner: &Arc<RuntimeInner>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timer firing — shared by the busy-path due-check and the timekeeper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pop and dispatch every due timer. `pop_due` re-anchors the
|
||||
/// earliest-deadline snapshot under the timers mutex before returning, so
|
||||
/// a caller that raced a concurrent insert simply comes back on the next
|
||||
/// due-check. Dispatch runs with the timers lock released.
|
||||
fn fire_due_timers(inner: &Arc<RuntimeInner>, try_only: bool) {
|
||||
let due = if try_only {
|
||||
// Busy path: if another scheduler is already in the timers mutex
|
||||
// (firing, inserting, or peeking) skip — the snapshot stays due
|
||||
// until someone actually pops, so the check re-fires next loop.
|
||||
match inner.timers.try_lock() {
|
||||
Ok(mut t) => t.pop_due(std::time::Instant::now()),
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
panic!("smarm: timers lock poisoned (core corrupt): {e}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match inner.timers.lock() {
|
||||
Ok(mut t) => t.pop_due(std::time::Instant::now()),
|
||||
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
};
|
||||
for entry in due {
|
||||
match entry.reason {
|
||||
// A sleep expiry is just an unpark: the protocol handles
|
||||
// every interleaving — Parked (re-queue), Running (the
|
||||
// actor is between `timers.insert_sleep` and
|
||||
// `park_current`; RunningNotified makes the upcoming park
|
||||
// re-queue), or gone (no-op).
|
||||
crate::timer::Reason::Sleep { epoch } => inner.unpark_at(entry.pid, epoch),
|
||||
crate::timer::Reason::WaitTimeout { target, epoch } => {
|
||||
// The callback may call unpark_at itself.
|
||||
target.on_timeout(entry.pid, epoch);
|
||||
}
|
||||
// A `send_after` deadline: run the captured delivery thunk.
|
||||
// It resolves the destination through the registry and
|
||||
// sends now (a send can unpark a receiver) — same as any
|
||||
// other in-loop unpark. The timers lock is already
|
||||
// released; lock order Leaf -> Channel is preserved by the
|
||||
// send itself. `pop_due` only returns still-armed Sends, so
|
||||
// a cancelled one never reaches here.
|
||||
crate::timer::Reason::Send { fire } => fire(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schedule_loop — runs on each scheduler OS thread
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1292,89 +1599,14 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
|
||||
loop {
|
||||
// ----------------------------------------------------------------
|
||||
// 1. Try to win the drain lock (timers + IO). One winner per round;
|
||||
// losers skip immediately and proceed to step 2.
|
||||
// 1. Busy-path timer due-check (RFC 018 design point (a)): under
|
||||
// saturation nobody parks, so no timekeeper exists — due timers
|
||||
// must still fire. One Relaxed load + branch when no timer is
|
||||
// armed; the clock is read only when one is.
|
||||
// ----------------------------------------------------------------
|
||||
if let Ok(_drain_guard) = inner.drain_lock.try_lock() {
|
||||
// Timers and IO live behind their own mutexes (phase 1), so the
|
||||
// pure-yield / pure-compute hot path never contends a global lock
|
||||
// just to discover there is nothing to drain. The clock is read
|
||||
// only when the timer heap is non-empty.
|
||||
let due = {
|
||||
let mut t = inner.timers.lock().unwrap();
|
||||
if t.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
t.pop_due(std::time::Instant::now())
|
||||
}
|
||||
};
|
||||
let completions = inner.io.lock().unwrap()
|
||||
.as_mut()
|
||||
.map(|io| io.drain_completions())
|
||||
.unwrap_or_default();
|
||||
for entry in due {
|
||||
match entry.reason {
|
||||
// A sleep expiry is just an unpark: the protocol handles
|
||||
// every interleaving — Parked (re-queue), Running (the
|
||||
// actor is between `timers.insert_sleep` and
|
||||
// `park_current`; RunningNotified makes the upcoming park
|
||||
// re-queue), or gone (no-op).
|
||||
crate::timer::Reason::Sleep { epoch } => {
|
||||
inner.unpark_at(entry.pid, epoch)
|
||||
}
|
||||
crate::timer::Reason::WaitTimeout { target, epoch } => {
|
||||
// The callback may call unpark_at itself.
|
||||
target.on_timeout(entry.pid, epoch);
|
||||
}
|
||||
// A `send_after` deadline: run the captured delivery thunk.
|
||||
// It resolves the destination through the registry and
|
||||
// sends now (a send can unpark a receiver) — same as any
|
||||
// other in-loop unpark. The timers lock is already
|
||||
// released; lock order Leaf -> Channel is preserved by the
|
||||
// send itself. `pop_due` only returns still-armed Sends, so
|
||||
// a cancelled one never reaches here.
|
||||
crate::timer::Reason::Send { fire } => fire(),
|
||||
}
|
||||
}
|
||||
|
||||
for completion in completions {
|
||||
match completion {
|
||||
crate::io::Completion::Blocking { pid, epoch, result } => {
|
||||
if let Some(io) = inner.io.lock().unwrap().as_mut() {
|
||||
io.outstanding = io.outstanding.saturating_sub(1);
|
||||
}
|
||||
// Stash the result under the cold lock, then unpark.
|
||||
// The protocol also covers the submit→park window
|
||||
// (RunningNotified), which the old code missed for
|
||||
// Blocking completions — a latent lost wakeup.
|
||||
if let Some(slot) = inner.slot_at(pid) {
|
||||
{
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.generation() == pid.generation() {
|
||||
cold.pending_io_result = Some(result);
|
||||
} else {
|
||||
// Actor died (stopped) with the op in
|
||||
// flight; discard the result.
|
||||
}
|
||||
}
|
||||
inner.unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
crate::io::Completion::FdReady { fd, events: _ } => {
|
||||
// Resolve the parked pid under the io lock, then wake
|
||||
// through the protocol. Lock order: io before all.
|
||||
let parked = inner.io.lock().unwrap().as_mut().and_then(|io| {
|
||||
let entry = io.waiters.remove(&fd);
|
||||
io.epoll_deregister(fd);
|
||||
entry
|
||||
});
|
||||
if let Some((pid, epoch)) = parked {
|
||||
inner.unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // drain_guard drops here
|
||||
if inner.coord.deadline_due() {
|
||||
fire_due_timers(inner, true);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 2. Pop a runnable pid. Pop order (RFC 005): wake slot first, then
|
||||
@@ -1383,7 +1615,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
// ----------------------------------------------------------------
|
||||
enum Pop {
|
||||
Got(Pid),
|
||||
Idle { io_outstanding: u32, wake_fd: Option<std::os::fd::RawFd> },
|
||||
Idle,
|
||||
AllDone,
|
||||
/// Root has exited and nothing is runnable: stop the parked-forever
|
||||
/// remainder, then re-pop. Fires at most once per run.
|
||||
@@ -1408,13 +1640,12 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
crate::te!(crate::trace::Event::SlotPop(pid));
|
||||
pid
|
||||
} else {
|
||||
// Read IO liveness BEFORE the queue lock (phase-1 ordering: a
|
||||
// completion resurrects an actor only via the drain path, whose
|
||||
// enqueue would be visible under the queue lock we take next).
|
||||
let (io_out, io_fd) = match inner.io.lock().unwrap().as_ref() {
|
||||
Some(io) => (io.outstanding + io.waiters.len() as u32, Some(io.wake_fd())),
|
||||
None => (0, None),
|
||||
};
|
||||
// Read IO liveness BEFORE the queue pop — two atomic loads now
|
||||
// (RFC 018), not a per-pop `io.lock`: a completion resurrects
|
||||
// an actor via the producer's own unpark→enqueue, whose entry
|
||||
// would be visible to the pop below.
|
||||
let io_out = inner.io_outstanding.load(Ordering::Acquire)
|
||||
+ inner.io_fd_waiters.load(Ordering::Acquire);
|
||||
|
||||
stats.run_queue_len.store(inner.run_queue.len(), Ordering::Relaxed);
|
||||
let pop = match inner.run_queue.pop() {
|
||||
@@ -1446,7 +1677,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
// the idle wait below on the next pass.
|
||||
Pop::RootDrain
|
||||
} else {
|
||||
Pop::Idle { io_outstanding: io_out, wake_fd: io_fd }
|
||||
Pop::Idle
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1457,18 +1688,19 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
// Remaining timer entries are orphaned (no live actor can be
|
||||
// woken by them — e.g. a sleeper cancelled out of its sleep);
|
||||
// they must not keep the runtime alive. Drop them on the way out.
|
||||
inner.timers.lock().unwrap().clear();
|
||||
// Terminal wake: a sibling scheduler may be blocked in its
|
||||
// idle wait on a snapshot that is now terminally stale — an
|
||||
// orphaned long deadline (it would sleep it out in full) or
|
||||
// a stale `io_outstanding > 0` from a stop-cancelled waiter
|
||||
// (it would block in poll(-1) forever; cancellation produces
|
||||
// no completion, so nothing else writes the wake pipe).
|
||||
// One byte wakes every poller; each re-runs the verdict,
|
||||
// reaches AllDone itself, and re-wakes — idempotent.
|
||||
if let Some(io) = inner.io.lock().unwrap().as_ref() {
|
||||
io.wake();
|
||||
match inner.timers.lock() {
|
||||
Ok(mut timers) => timers.clear(),
|
||||
Err(e) => panic!("smarm: timers lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
// Terminal wake (replaces the wake-pipe byte): a sibling
|
||||
// may be parked on a snapshot that is now terminally
|
||||
// stale — an orphaned long deadline, or a stale
|
||||
// `io_fd_waiters > 0` from a stop-cancelled waiter
|
||||
// (cancellation produces no completion, so nothing else
|
||||
// will ever wake it). `wake_all` permits every parker;
|
||||
// each sibling re-runs the verdict, reaches AllDone
|
||||
// itself, and re-wakes — idempotent.
|
||||
inner.coord.wake_all();
|
||||
return;
|
||||
}
|
||||
Pop::RootDrain => {
|
||||
@@ -1478,31 +1710,51 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
stop_live_actors(inner);
|
||||
continue;
|
||||
}
|
||||
Pop::Idle { io_outstanding, wake_fd } => {
|
||||
// Something is still in flight. Sleep on the appropriate
|
||||
// source to avoid hammering the queue mutex; retry on wake.
|
||||
let next_deadline = inner.timers.lock().unwrap().peek_deadline();
|
||||
match (next_deadline, wake_fd) {
|
||||
(Some(deadline), fd_opt) => {
|
||||
let now = std::time::Instant::now();
|
||||
if deadline > now {
|
||||
let timeout = deadline - now;
|
||||
match fd_opt {
|
||||
Some(fd) => {
|
||||
crate::io::poll_wake(fd, Some(timeout));
|
||||
crate::io::drain_wake_pipe(fd);
|
||||
}
|
||||
None => thread::sleep(timeout),
|
||||
}
|
||||
Pop::Idle => {
|
||||
// Something is still in flight. Park on our own futex
|
||||
// until a producer wakes us (enqueue tail), a deadline
|
||||
// passes, or the re-check finds the world changed.
|
||||
//
|
||||
// Timekeeper (RFC 018): at most one parked scheduler
|
||||
// holds the timer deadline — the first idler to arm it
|
||||
// parks with a timeout, the rest park indefinitely, so a
|
||||
// timer expiry wakes one scheduler, not a herd. Peek and
|
||||
// arm under the timers mutex (the serialization that
|
||||
// makes the insert-side re-arm race-free).
|
||||
let tk_deadline = {
|
||||
let timers = match inner.timers.lock() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
panic!("smarm: timers lock poisoned (core corrupt): {e}")
|
||||
}
|
||||
}
|
||||
(None, Some(fd)) if io_outstanding > 0 => {
|
||||
crate::io::poll_wake(fd, None);
|
||||
crate::io::drain_wake_pipe(fd);
|
||||
}
|
||||
_ => {
|
||||
thread::sleep(std::time::Duration::from_micros(100));
|
||||
}
|
||||
};
|
||||
timers
|
||||
.peek_deadline()
|
||||
.filter(|d| inner.coord.try_arm_timer(slot_idx, *d))
|
||||
};
|
||||
// The mandatory post-publish re-check: a producer that
|
||||
// enqueued (or a verdict input that flipped) before it
|
||||
// could see our idle bit has left us the evidence.
|
||||
let _ = inner.coord.park(slot_idx, tk_deadline, || {
|
||||
!inner.run_queue.is_empty()
|
||||
|| (inner.live_actors.load(Ordering::Acquire) == 0
|
||||
&& inner.io_outstanding.load(Ordering::Acquire) == 0
|
||||
&& inner.io_fd_waiters.load(Ordering::Acquire) == 0)
|
||||
|| (inner.root_exited.load(Ordering::Acquire)
|
||||
&& !inner.root_swept.load(Ordering::Acquire))
|
||||
|| inner.coord.deadline_due()
|
||||
});
|
||||
if tk_deadline.is_some() {
|
||||
// Hand the role back BEFORE firing: pop_due can run
|
||||
// `Send` thunks that insert new timers, and the
|
||||
// insert-side re-arm check must see either no
|
||||
// timekeeper (skip) or a real parked one — never us,
|
||||
// awake and about to re-peek anyway.
|
||||
inner.coord.disarm_timer(slot_idx);
|
||||
// Woken for the deadline, for work, or to re-peek
|
||||
// after an earlier insert — fire whatever is due;
|
||||
// the next idle pass re-arms with the new minimum.
|
||||
fire_due_timers(inner, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1515,6 +1767,21 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
// by the at-most-once-enqueued invariant nothing else can have
|
||||
// changed the state of a queued actor.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
// RFC 018 chain rule: we just took one runnable; if more remain and
|
||||
// a sibling is parked, wake exactly one so the surplus runs in
|
||||
// PARALLEL rather than serially behind us (without this the surplus
|
||||
// is not stranded — we re-pop it after resuming — but it waits out
|
||||
// our whole timeslice while an idle core sits available). Cheap: the
|
||||
// queue-length check is queue-local, and `wake_one_if_idle` is a
|
||||
// fence + one Relaxed mask load when nobody is parked. A Relaxed
|
||||
// miss here is safe — the enqueue that created the surplus already
|
||||
// issued its own wake (RFC 018 no-lost-wake); this only sharpens
|
||||
// parallelism latency.
|
||||
if !inner.run_queue.is_empty() {
|
||||
inner.coord.wake_one_if_idle();
|
||||
}
|
||||
|
||||
let slot = match inner.slot_at(pid) {
|
||||
Some(s) => s,
|
||||
None => continue, // can't happen for real pids; defensive
|
||||
@@ -1553,6 +1820,11 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
crate::preempt::reset_timeslice();
|
||||
}
|
||||
PREEMPTION_ENABLED.with(|c| c.set(true));
|
||||
// RFC 007: delay accrued while this actor was off-CPU is absorbed for
|
||||
// free (Coz's blocked-thread rule) — fast-forward its ledger and arm
|
||||
// the per-thread sample clock before it runs.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_resume(slot);
|
||||
|
||||
crate::te!(crate::trace::Event::Resume(pid));
|
||||
unsafe { switch_to_actor() };
|
||||
@@ -1579,6 +1851,11 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
let gen = pid.generation();
|
||||
match intent {
|
||||
YieldIntent::Yield => {
|
||||
// RFC 007 audit: measure the sample tail this yield drops
|
||||
// (near-zero for slice-expiry yields, which sample at the
|
||||
// same checkpoint; fat for explicit yield_now in-site).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_deschedule(slot, false);
|
||||
// Running OR RunningNotified → Queued; a notification
|
||||
// arriving mid-run coalesces into the re-queue.
|
||||
crate::te!(crate::trace::Event::Yield(pid));
|
||||
@@ -1587,11 +1864,24 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
}
|
||||
YieldIntent::Park => {
|
||||
if slot.word.park_return(gen) {
|
||||
// RFC 007 audit: an in-site park drops its sample
|
||||
// tail (nothing flushes it; on_resume re-arms).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_deschedule(slot, true);
|
||||
// RFC 007: a real park — the eventual wake forgives
|
||||
// delay accrued while blocked (the instant-wake
|
||||
// re-queue below does NOT: that actor never blocked).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
slot.set_causal_parked();
|
||||
crate::te!(crate::trace::Event::Park(pid));
|
||||
} else {
|
||||
// An unpark landed in the prep-to-park window; the
|
||||
// word is back to Queued — re-queue instead of
|
||||
// parking. The lost-wakeup window, closed.
|
||||
// parking. The lost-wakeup window, closed: this actor
|
||||
// never blocked, so its dropped tail counts as a
|
||||
// yield in the RFC 007 audit.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_deschedule(slot, false);
|
||||
crate::te!(crate::trace::Event::UnparkFlagConsumed(pid));
|
||||
inner.enqueue(pid);
|
||||
}
|
||||
|
||||
+567
-209
File diff suppressed because it is too large
Load Diff
+111
-35
@@ -1,16 +1,114 @@
|
||||
//! Supervision signals.
|
||||
//! Supervision: keep a set of actors alive.
|
||||
//!
|
||||
//! Every actor has a supervisor, which is itself just an actor with a
|
||||
//! `Receiver<Signal>`. When a child actor terminates, the scheduler sends
|
||||
//! a `Signal` on the supervisor's channel. The supervisor decides what to
|
||||
//! do — restart, escalate, ignore.
|
||||
//! A *supervisor* is an actor whose only job is to start a fixed set of child
|
||||
//! actors and react when one of them terminates — restarting it (and, depending
|
||||
//! on the strategy, some of its siblings) according to a policy, or giving up
|
||||
//! when failures arrive too fast. It is how you turn "an actor that might crash"
|
||||
//! into "a service that stays up": a crash becomes a restart instead of a hole
|
||||
//! in the process tree.
|
||||
//!
|
||||
//! For v0.1 there is no built-in restart-intensity cap. That's policy and
|
||||
//! lives in user code; library is mechanism only.
|
||||
//! The supervisor type is [`OneForOne`]. The name is historical — the restart
|
||||
//! *strategy* is selectable via [`OneForOne::strategy`], and
|
||||
//! [`Strategy::OneForOne`] is merely the default. You declare the children up
|
||||
//! front as [`ChildSpec`]s, each carrying a [`Restart`] policy, then hand the
|
||||
//! supervision loop an actor of its own with [`OneForOne::run`].
|
||||
//!
|
||||
//! ## A child that crashes and recovers
|
||||
//!
|
||||
//! ```
|
||||
//! use smarm::{run, spawn, ChildSpec, OneForOne, Restart};
|
||||
//! use std::sync::Arc;
|
||||
//! use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
//! use std::time::Duration;
|
||||
//!
|
||||
//! run(|| {
|
||||
//! // A flaky child: it panics on its first two starts, then settles.
|
||||
//! let starts = Arc::new(AtomicUsize::new(0));
|
||||
//! let s = starts.clone();
|
||||
//! let child = move || {
|
||||
//! let n = s.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
//! if n < 3 {
|
||||
//! panic!("boom {n}");
|
||||
//! }
|
||||
//! // The third start returns normally.
|
||||
//! };
|
||||
//!
|
||||
//! // The supervisor runs on its own actor. `Transient` restarts a child
|
||||
//! // that panics but treats a clean return as "done", so once the child
|
||||
//! // finally succeeds the supervisor has nothing left to do and `run()`
|
||||
//! // returns. smarm catches the child's panic and turns it into a restart;
|
||||
//! // it never reaches the process as a real crash.
|
||||
//! let sup = spawn(move || {
|
||||
//! OneForOne::new()
|
||||
//! .intensity(5, Duration::from_secs(60))
|
||||
//! .child(ChildSpec::new(Restart::Transient, child))
|
||||
//! .run();
|
||||
//! });
|
||||
//! sup.join().unwrap();
|
||||
//!
|
||||
//! assert_eq!(starts.load(Ordering::SeqCst), 3); // one start, two restarts
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Restart policies
|
||||
//!
|
||||
//! Each child carries a [`Restart`] policy that decides whether *that child*
|
||||
//! comes back when it terminates:
|
||||
//!
|
||||
//! - [`Restart::Permanent`] restarts on any termination, normal or panic —
|
||||
//! for a service that should never be down.
|
||||
//! - [`Restart::Transient`] restarts only on an abnormal exit (a panic or a
|
||||
//! cooperative stop); a clean return means "done" — for work that runs to
|
||||
//! completion but should be retried if it crashes.
|
||||
//! - [`Restart::Temporary`] never restarts; the death is simply noted.
|
||||
//!
|
||||
//! ## Strategies: which siblings get cycled
|
||||
//!
|
||||
//! When a restart is due, the [`Strategy`] decides which *other* children are
|
||||
//! cycled along with the one that died. The triggering child's own policy still
|
||||
//! decides whether anything restarts at all.
|
||||
//!
|
||||
//! - [`Strategy::OneForOne`] restarts only the child that died — the default.
|
||||
//! - [`Strategy::OneForAll`] restarts every child: the survivors are stopped,
|
||||
//! then the whole set is restarted.
|
||||
//! - [`Strategy::RestForOne`] restarts the dead child and every child started
|
||||
//! after it, leaving earlier children untouched.
|
||||
//!
|
||||
//! ## Stopping a sibling is cooperative
|
||||
//!
|
||||
//! Cycling a sibling means stopping it first, and a supervisor never tears a
|
||||
//! running actor down from outside: smarm actors share a heap and rely on
|
||||
//! Drop/RAII, so unwinding a peer's stack from elsewhere would be unsound.
|
||||
//! Instead the supervisor *requests* the stop and the child unwinds at its next
|
||||
//! observation point — a `check!()`, an allocation, or a blocking call. A child
|
||||
//! wedged in a tight loop with no observation point cannot be stopped, for the
|
||||
//! same reason it cannot be preempted.
|
||||
//!
|
||||
//! ## Giving up: the restart-intensity cap
|
||||
//!
|
||||
//! A child that crashes the instant it starts would otherwise restart forever.
|
||||
//! [`OneForOne::intensity`] bounds that: at most `max` restarts within any
|
||||
//! `period`-long sliding window. One terminating child counts as a single
|
||||
//! restart event even when the strategy cycles several siblings. When the cap
|
||||
//! trips, the supervisor stops restarting, cooperatively stops any survivors in
|
||||
//! reverse start order, and `run()` returns.
|
||||
//!
|
||||
//! ## Running context
|
||||
//!
|
||||
//! [`OneForOne::run`] takes over the calling actor as the supervision loop, so
|
||||
//! a supervisor gets an actor of its own — typically
|
||||
//! `spawn(|| OneForOne::new()/* … */.run())`, all from inside
|
||||
//! [`run`](crate::run). Each child is spawned beneath the supervisor's pid, so
|
||||
//! every child termination funnels back to it as a [`Signal`].
|
||||
|
||||
use crate::pid::Pid;
|
||||
use std::any::Any;
|
||||
|
||||
/// A child-termination notice delivered to its supervisor.
|
||||
///
|
||||
/// Every child a supervisor starts is spawned beneath the supervisor's pid, so
|
||||
/// each child's termination funnels back to it as one of these. The variant
|
||||
/// records *how* the child went — which is what its [`Restart`] policy keys off.
|
||||
pub enum Signal {
|
||||
/// The child exited normally.
|
||||
Exit(Pid),
|
||||
@@ -42,27 +140,6 @@ impl Signal {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One-for-one supervisor
|
||||
//
|
||||
// A supervisor is itself an actor. It spawns each child under its own pid so
|
||||
// that every child death funnels into one mailbox (the `supervisor_channel`),
|
||||
// then loops: receive a Signal, decide per the child's `Restart` policy
|
||||
// whether to restart, and enforce a restart-intensity cap so a child that
|
||||
// crashes in a tight loop eventually gives up instead of spinning forever.
|
||||
//
|
||||
// What this does NOT do: *forcibly* terminate a running child. smarm actors
|
||||
// share a heap and rely on Drop/RAII, so tearing down a peer's stack from
|
||||
// outside is unsound. Stopping a sibling — whether for `one_for_all` /
|
||||
// `rest_for_one`, for a propagated link death, or for the ordered shutdown
|
||||
// below — is therefore *cooperative*: `request_stop` flags the child and it
|
||||
// unwinds at its next observation point. A child with no observation points
|
||||
// (a tight loop with no `check!()`, allocation, or blocking op) cannot be
|
||||
// stopped, exactly as it cannot be preempted. When the intensity cap trips,
|
||||
// this supervisor stops restarting and tears the remaining children down in
|
||||
// reverse start order before returning.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::channel::channel;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
@@ -115,11 +192,10 @@ pub enum Strategy {
|
||||
|
||||
/// A supervisor over a fixed set of children.
|
||||
///
|
||||
/// Despite the name (kept for backwards compatibility), the restart strategy
|
||||
/// is selectable via [`OneForOne::strategy`]; the default is
|
||||
/// [`Strategy::OneForOne`]. Build with `new()`, add children with `child()`,
|
||||
/// tune the cap with `intensity()`, then drive it with `run()` from inside an
|
||||
/// actor (typically `spawn(|| OneForOne::new()....run())`).
|
||||
/// Build it with [`new`](Self::new), add children with [`child`](Self::child),
|
||||
/// pick a [`strategy`](Self::strategy) and an [`intensity`](Self::intensity)
|
||||
/// cap, then drive the loop with [`run`](Self::run) on an actor of its own. See
|
||||
/// the [module docs](self) for the full picture.
|
||||
pub struct OneForOne {
|
||||
children: Vec<ChildSpec>,
|
||||
strategy: Strategy,
|
||||
@@ -253,7 +329,7 @@ impl OneForOne {
|
||||
.collect(),
|
||||
};
|
||||
// Stop survivors in reverse start order (highest child index first).
|
||||
to_stop.sort_unstable_by(|a, b| b.1.cmp(&a.1));
|
||||
to_stop.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
|
||||
|
||||
// The set we will restart: the failed child plus every sibling we
|
||||
// are about to stop, restarted in start (ascending index) order.
|
||||
@@ -299,7 +375,7 @@ impl OneForOne {
|
||||
// and this is a no-op; on a cap-trip or mailbox-closed break it tears
|
||||
// the remaining children down deterministically instead of leaking them.
|
||||
let mut survivors: Vec<(Pid, usize)> = by_pid.iter().map(|(p, i)| (*p, *i)).collect();
|
||||
survivors.sort_unstable_by(|a, b| b.1.cmp(&a.1));
|
||||
survivors.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
|
||||
let mut awaiting: Vec<Pid> = Vec::with_capacity(survivors.len());
|
||||
for (pid, _) in &survivors {
|
||||
crate::scheduler::request_stop(*pid);
|
||||
|
||||
+11
-2
@@ -6,10 +6,19 @@
|
||||
//! Build the loom models with: `RUSTFLAGS="--cfg loom" cargo test --lib --release`
|
||||
|
||||
#[cfg(loom)]
|
||||
pub(crate) use loom::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
pub(crate) use loom::sync::atomic::{fence, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(not(loom))]
|
||||
pub(crate) use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
pub(crate) use std::sync::atomic::{fence, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
// park.rs condvar-parker (loom + non-Linux builds only; the Linux non-loom
|
||||
// build parks on a futex and never touches these — gating them identically
|
||||
// keeps the default build free of unused imports).
|
||||
#[cfg(loom)]
|
||||
pub(crate) use loom::sync::{Condvar, Mutex};
|
||||
|
||||
#[cfg(all(not(loom), not(target_os = "linux")))]
|
||||
pub(crate) use std::sync::{Condvar, Mutex};
|
||||
|
||||
/// `UnsafeCell` with loom's `with`/`with_mut` access API; pass-through cost
|
||||
/// is zero in normal builds (`#[inline]`, newtype over std's cell).
|
||||
|
||||
+152
-16
@@ -102,6 +102,19 @@ pub struct Entry {
|
||||
seq: u64,
|
||||
pub pid: Pid,
|
||||
pub reason: Reason,
|
||||
/// RFC 007 virtual time: the global delay ledger reading when this entry
|
||||
/// was (re-)queued. `pop_due` shifts the effective deadline by any delay
|
||||
/// injected since, so timers dilate together with the causally-delayed
|
||||
/// workload instead of firing early in virtual terms.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
delay_stamp: u64,
|
||||
/// RFC 007: a wall-anchored entry opts out of the virtual-time shift —
|
||||
/// its deadline is honoured in wall time regardless of injected delay.
|
||||
/// Used by the causal controller's own measurement/cooldown sleeps so
|
||||
/// experiment windows keep a fixed wall length; ordinary workload timers
|
||||
/// stay virtual (`false`).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
wall: bool,
|
||||
}
|
||||
|
||||
impl PartialEq for Entry {
|
||||
@@ -128,6 +141,14 @@ impl PartialOrd for Entry {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Timers {
|
||||
/// RFC 018: the scheduler coordination layer. Attached once at
|
||||
/// `RuntimeInner::new`; every insert notes its deadline (min-maintained
|
||||
/// snapshot for the busy-path due-check + the timekeeper re-arm wake)
|
||||
/// and every pop/clear re-anchors the snapshot to the heap minimum.
|
||||
/// All calls happen under the timers mutex — the serialization the
|
||||
/// coordinator's timer protocol mandates. `None` only in unit tests
|
||||
/// that construct a bare `Timers`.
|
||||
coord: Option<std::sync::Arc<crate::park::Coordinator>>,
|
||||
/// Reverse-wrapped so the smallest deadline is at the top.
|
||||
heap: BinaryHeap<Reverse<Entry>>,
|
||||
/// Monotonic counter for the tiebreaker `seq` field (and the `TimerId` of a
|
||||
@@ -144,7 +165,18 @@ pub struct Timers {
|
||||
|
||||
impl Timers {
|
||||
pub fn new() -> Self {
|
||||
Self { heap: BinaryHeap::new(), next_seq: 0, armed: std::collections::HashSet::new() }
|
||||
Self {
|
||||
coord: None,
|
||||
heap: BinaryHeap::new(),
|
||||
next_seq: 0,
|
||||
armed: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the scheduler coordination layer (RFC 018). Called once, at
|
||||
/// runtime construction, before any scheduler thread exists.
|
||||
pub(crate) fn attach_coordinator(&mut self, c: std::sync::Arc<crate::park::Coordinator>) {
|
||||
self.coord = Some(c);
|
||||
}
|
||||
|
||||
/// Insert a `Sleep` timer. Convenience for the common case.
|
||||
@@ -152,6 +184,19 @@ impl Timers {
|
||||
self.insert(deadline, pid, Reason::Sleep { epoch });
|
||||
}
|
||||
|
||||
/// Insert a *wall-anchored* `Sleep` timer: fires at `deadline` in wall
|
||||
/// time even while causal profiling (feature `smarm-causal`) is injecting
|
||||
/// virtual delay — it never chases the delay ledger. Without the feature
|
||||
/// this is identical to [`insert_sleep`](Self::insert_sleep).
|
||||
///
|
||||
/// Intended for measurement machinery (the causal controller's window and
|
||||
/// cooldown sleeps, TSC calibration) whose durations *define* wall time
|
||||
/// rather than participate in the workload. Workload code should use the
|
||||
/// ordinary virtual-anchored timers.
|
||||
pub fn insert_sleep_wall(&mut self, deadline: Instant, pid: Pid, epoch: u32) {
|
||||
self.push(deadline, pid, Reason::Sleep { epoch }, true);
|
||||
}
|
||||
|
||||
/// Arm a cancellable `send_after` timer: run `fire` at `deadline` unless
|
||||
/// [`cancel`](Self::cancel)led first. `pid` is informational only (the
|
||||
/// destination, or who armed it — useful for introspection); it is *not*
|
||||
@@ -163,11 +208,24 @@ impl Timers {
|
||||
pid: Pid,
|
||||
fire: Box<dyn FnOnce() + Send>,
|
||||
) -> TimerId {
|
||||
let seq = self.next_seq;
|
||||
self.next_seq = self.next_seq.wrapping_add(1);
|
||||
self.armed.insert(seq);
|
||||
self.heap.push(Reverse(Entry { deadline, seq, pid, reason: Reason::Send { fire } }));
|
||||
TimerId(seq)
|
||||
self.armed.insert(self.next_seq);
|
||||
TimerId(self.push(deadline, pid, Reason::Send { fire }, false))
|
||||
}
|
||||
|
||||
/// Arm a *wall-anchored* cancellable `send_after` timer (RFC 007): the
|
||||
/// same contract as [`insert_send`](Self::insert_send), but the entry
|
||||
/// opts out of the virtual-time shift and fires at its raw deadline
|
||||
/// regardless of injected delay — the `Send`-reason sibling of
|
||||
/// [`insert_sleep_wall`](Self::insert_sleep_wall). Without the
|
||||
/// `smarm-causal` feature this is identical to `insert_send`.
|
||||
pub fn insert_send_wall(
|
||||
&mut self,
|
||||
deadline: Instant,
|
||||
pid: Pid,
|
||||
fire: Box<dyn FnOnce() + Send>,
|
||||
) -> TimerId {
|
||||
self.armed.insert(self.next_seq);
|
||||
TimerId(self.push(deadline, pid, Reason::Send { fire }, true))
|
||||
}
|
||||
|
||||
/// Cancel an armed `send_after` timer. Returns `true` if the timer was
|
||||
@@ -179,11 +237,38 @@ impl Timers {
|
||||
self.armed.remove(&id.0)
|
||||
}
|
||||
|
||||
/// Insert an arbitrary timer entry.
|
||||
/// Insert an arbitrary (virtual-anchored) timer entry.
|
||||
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
|
||||
self.push(deadline, pid, reason, false);
|
||||
}
|
||||
|
||||
/// Common insertion path. `wall` selects the RFC 007 anchor (see
|
||||
/// [`insert_sleep_wall`](Self::insert_sleep_wall)); it is accepted — and
|
||||
/// ignored — without the `smarm-causal` feature so callers don't fork.
|
||||
/// Returns the entry's `seq`.
|
||||
fn push(&mut self, deadline: Instant, pid: Pid, reason: Reason, wall: bool) -> u64 {
|
||||
#[cfg(not(feature = "smarm-causal"))]
|
||||
let _ = wall;
|
||||
let seq = self.next_seq;
|
||||
self.next_seq = self.next_seq.wrapping_add(1);
|
||||
self.heap.push(Reverse(Entry { deadline, seq, pid, reason }));
|
||||
self.heap.push(Reverse(Entry {
|
||||
deadline,
|
||||
seq,
|
||||
pid,
|
||||
reason,
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
delay_stamp: crate::causal::global_delay_cycles(),
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
wall,
|
||||
}));
|
||||
// RFC 018: publish the (possibly new-minimum) deadline to the
|
||||
// busy-path snapshot and wake the timekeeper if it is parked
|
||||
// toward a later one. We hold the timers mutex — the mandated
|
||||
// serialization for both.
|
||||
if let Some(c) = &self.coord {
|
||||
c.note_deadline(deadline);
|
||||
}
|
||||
seq
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
@@ -196,6 +281,9 @@ impl Timers {
|
||||
pub fn clear(&mut self) {
|
||||
self.heap.clear();
|
||||
self.armed.clear();
|
||||
if let Some(c) = &self.coord {
|
||||
c.refresh_deadline(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Soonest pending deadline, or `None` if the heap is empty.
|
||||
@@ -210,19 +298,67 @@ impl Timers {
|
||||
/// one is silently dropped here (its `seq` was already removed from
|
||||
/// `armed` by [`cancel`](Self::cancel)). Returning it removes it from
|
||||
/// `armed`, so a later `cancel` of a fired timer reports `false`.
|
||||
///
|
||||
/// RFC 007 virtual time (feature `smarm-causal`): before an entry fires,
|
||||
/// any global delay injected since it was (re-)queued is added to its
|
||||
/// deadline; an entry whose *effective* deadline hasn't passed is pushed
|
||||
/// back with the shifted deadline and a fresh stamp, so it keeps chasing
|
||||
/// delay injected while it waits. Consequences, both benign:
|
||||
/// [`peek_deadline`](Self::peek_deadline) may under-report (raw deadline
|
||||
/// earlier than effective), costing at most one spurious scheduler wake
|
||||
/// per injected chunk; and a shift never converts wall time — with zero
|
||||
/// debt the path is byte-identical to the featureless one. Wall-anchored
|
||||
/// entries ([`insert_sleep_wall`](Self::insert_sleep_wall)) are exempt
|
||||
/// from the shift and always fire at their raw deadline.
|
||||
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
|
||||
let mut out = Vec::new();
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
let global = crate::causal::global_delay_cycles();
|
||||
while let Some(r) = self.heap.peek() {
|
||||
if r.0.deadline <= now {
|
||||
let entry = self.heap.pop().unwrap().0;
|
||||
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.remove(&entry.seq) {
|
||||
// Cancelled before it came due: discard, do not deliver.
|
||||
continue;
|
||||
}
|
||||
out.push(entry);
|
||||
} else {
|
||||
if r.0.deadline > now {
|
||||
break;
|
||||
}
|
||||
#[allow(unused_mut)]
|
||||
let mut entry = match self.heap.pop() {
|
||||
Some(e) => e.0,
|
||||
None => panic!("smarm: timer heap pop after peek returned None (core corrupt)"),
|
||||
};
|
||||
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.contains(&entry.seq) {
|
||||
// Cancelled before it came due: discard, do not deliver.
|
||||
// (Checked before any shift so a cancelled entry is never
|
||||
// re-queued just to be discarded later.)
|
||||
continue;
|
||||
}
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
if !entry.wall {
|
||||
let debt = global.saturating_sub(entry.delay_stamp);
|
||||
if debt > 0 {
|
||||
let shifted = entry
|
||||
.deadline
|
||||
.checked_add(crate::causal::cycles_to_duration(debt))
|
||||
.unwrap_or(entry.deadline);
|
||||
if shifted > now {
|
||||
// Not due in virtual time: re-queue at the shifted
|
||||
// deadline, stamped, keeping `seq` (and thus `Send`
|
||||
// cancellation identity) intact.
|
||||
entry.deadline = shifted;
|
||||
entry.delay_stamp = global;
|
||||
self.heap.push(Reverse(entry));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(entry.reason, Reason::Send { .. }) {
|
||||
self.armed.remove(&entry.seq);
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
// RFC 018: re-anchor the busy-path snapshot to the new heap minimum
|
||||
// (still under the timers mutex). A causal-shift re-queue above went
|
||||
// through `heap.push` directly, so this peek is the one place the
|
||||
// snapshot is guaranteed to catch up.
|
||||
if let Some(c) = &self.coord {
|
||||
c.refresh_deadline(self.peek_deadline());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
+19
-6
@@ -101,7 +101,7 @@ mod inner {
|
||||
|
||||
thread_local! {
|
||||
static LOCAL_STATE: std::cell::RefCell<Option<LocalState>> =
|
||||
std::cell::RefCell::new(None);
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -115,14 +115,20 @@ mod inner {
|
||||
let (tx, rx) = mpsc::channel::<Msg>();
|
||||
let start = Instant::now();
|
||||
|
||||
*GLOBAL.lock().unwrap() = Some(Global { sender: tx, start });
|
||||
match GLOBAL.lock() {
|
||||
Ok(mut g) => *g = Some(Global { sender: tx, start }),
|
||||
Err(e) => panic!("smarm: trace lock poisoned (core corrupt): {e}"),
|
||||
}
|
||||
|
||||
// Drain thread: owns the Receiver, writes to disk.
|
||||
let path_for_thread = path.clone();
|
||||
std::thread::Builder::new()
|
||||
match std::thread::Builder::new()
|
||||
.name("smarm-trace-drain".into())
|
||||
.spawn(move || drain_thread(rx, &path_for_thread))
|
||||
.expect("failed to spawn trace drain thread");
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("smarm: failed to spawn trace drain thread: {e}"),
|
||||
}
|
||||
|
||||
eprintln!("[smarm-trace] writing to {}", path);
|
||||
}
|
||||
@@ -133,7 +139,10 @@ mod inner {
|
||||
// Drop the global sender so the drain thread's recv() returns Err
|
||||
// after the Flush sentinel, signalling clean shutdown.
|
||||
let sender = {
|
||||
let mut g = GLOBAL.lock().unwrap();
|
||||
let mut g = match GLOBAL.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: trace lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
g.take().map(|g| g.sender)
|
||||
};
|
||||
if let Some(tx) = sender {
|
||||
@@ -162,7 +171,11 @@ mod inner {
|
||||
let mut opt = cell.borrow_mut();
|
||||
// Lazily initialise: one mutex hit per thread, ever.
|
||||
if opt.is_none() {
|
||||
if let Some(g) = GLOBAL.lock().unwrap().as_ref() {
|
||||
let guard = match GLOBAL.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => panic!("smarm: trace lock poisoned (core corrupt): {e}"),
|
||||
};
|
||||
if let Some(g) = guard.as_ref() {
|
||||
let tx = g.sender.clone();
|
||||
*opt = Some(LocalState { tx, start: g.start });
|
||||
}
|
||||
|
||||
+1040
File diff suppressed because it is too large
Load Diff
+317
-45
@@ -1,76 +1,184 @@
|
||||
//! gen_statem behaviour tests, driven through the `gen_statem!` macro: cast/call
|
||||
//! round-trip, `enter` firing on start and on every real transition (but not on
|
||||
//! a stay), and the machine-down path when a handler panics.
|
||||
//! a stay), the machine-down path when a handler panics, and the timeout
|
||||
//! surface (state-timeout firing + auto-reset across transitions; named
|
||||
//! timeouts surviving transitions; cancellation).
|
||||
|
||||
use smarm::gen_statem;
|
||||
use smarm::gen_statem::{CallError, Reply};
|
||||
use smarm::run;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
// A two-state machine: Flip toggles, calls read counters, Boom panics.
|
||||
// ===========================================================================
|
||||
// Timeouts
|
||||
// ===========================================================================
|
||||
//
|
||||
// A small timer machine. `Idle` is quiet; `Armed` arms a state-timeout on entry
|
||||
// that — when it fires — bumps `st_fires` and returns to `Idle`. A named
|
||||
// timeout ("ping") is armed independently, survives the Idle/Armed transition,
|
||||
// and bumps `named_fires` when it fires. Counters are read back over calls.
|
||||
//
|
||||
// Durations are tiny and waits use `smarm::sleep` (parks the actor, leaves the
|
||||
// timer wheel turning). These tests run against real time, so they are a touch
|
||||
// slow; a controllable clock would tighten them.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Switch {
|
||||
Off,
|
||||
On,
|
||||
enum T {
|
||||
Idle,
|
||||
Armed,
|
||||
}
|
||||
|
||||
struct Counts {
|
||||
flips: u32,
|
||||
enters: u32,
|
||||
struct TData {
|
||||
enters: u32, // total state entries (incl. initial)
|
||||
st_fires: u32, // state-timeout fires
|
||||
named_fires: u32, // named-timeout fires
|
||||
st_window: u64, // ms for the Armed state-timeout (set per test intent)
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
Flip,
|
||||
enum TCast {
|
||||
Arm, // Idle -> Armed
|
||||
Disarm, // Armed -> Idle (a transition that should auto-reset the state-timeout)
|
||||
Ping(u64), // arm a named "ping" timeout after the given ms
|
||||
CancelPing, // cancel the named "ping"
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetFlips(Reply<u32>),
|
||||
GetEnters(Reply<u32>),
|
||||
Boom(Reply<u32>),
|
||||
enum TCall {
|
||||
Enters(Reply<u32>),
|
||||
StFires(Reply<u32>),
|
||||
NamedFires(Reply<u32>),
|
||||
Boom(Reply<u32>), // panics, to exercise the call-to-dead-machine path
|
||||
}
|
||||
|
||||
gen_statem! {
|
||||
machine: Sm { state: Switch, data: Counts };
|
||||
event: Ev { cast: Cast, call: Call };
|
||||
machine: TimerSm { state: T, data: TData };
|
||||
event: Ev2 { cast: TCast, call: TCall, info: () };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter {
|
||||
_ => data.enters += 1,
|
||||
// On entering Armed, arm the state-timeout. On Idle, nothing — and the
|
||||
// loop has already auto-reset any pending state-timeout on the way in.
|
||||
T::Armed => { data.enters += 1; cx.state_timeout(Duration::from_millis(data.st_window)); },
|
||||
T::Idle => data.enters += 1,
|
||||
}
|
||||
|
||||
on Switch::Off => {
|
||||
cast Cast::Flip => { data.flips += 1; Switch::On },
|
||||
}
|
||||
on Switch::On => {
|
||||
cast Cast::Flip => Switch::Off,
|
||||
on T::Idle => {
|
||||
cast TCast::Arm => T::Armed,
|
||||
cast TCast::Disarm => unhandled,
|
||||
state_timeout => unhandled,
|
||||
}
|
||||
|
||||
// State-independent queries: reply, then stay via `prev`. Boom panics
|
||||
// (`boom()` is typed as a state tag so the arm stays well-formed).
|
||||
on T::Armed => {
|
||||
// The state-timeout elapsed while still Armed: count it and go Idle.
|
||||
state_timeout => { data.st_fires += 1; T::Idle },
|
||||
cast TCast::Disarm => T::Idle,
|
||||
cast TCast::Arm => unhandled,
|
||||
}
|
||||
|
||||
// State-independent rows: the named-timeout (survives transitions), the
|
||||
// arm/cancel casts, the counter reads, and the panicking call.
|
||||
on _ => {
|
||||
call Call::GetFlips(r) => { r.reply(data.flips); prev },
|
||||
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||
call Call::Boom(_r) => boom(),
|
||||
cast TCast::Ping(ms) => { cx.timeout("ping", Duration::from_millis(ms)); prev },
|
||||
cast TCast::CancelPing => { cx.cancel_timeout("ping"); prev },
|
||||
timeout "ping" => { data.named_fires += 1; prev },
|
||||
timeout _ => unhandled,
|
||||
call TCall::Enters(r) => { r.reply(data.enters); prev },
|
||||
call TCall::StFires(r) => { r.reply(data.st_fires); prev },
|
||||
call TCall::NamedFires(r) => { r.reply(data.named_fires); prev },
|
||||
call TCall::Boom(_r) => boom(),
|
||||
}
|
||||
}
|
||||
|
||||
fn boom() -> Switch {
|
||||
fn boom() -> T {
|
||||
panic!("boom")
|
||||
}
|
||||
// Casts are applied in order and a later call observes the accumulated data.
|
||||
|
||||
// A state-timeout armed on entry to Armed fires after its window, bumping the
|
||||
// counter and returning the machine to Idle on its own.
|
||||
#[test]
|
||||
fn state_timeout_fires() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 5 });
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 5ms state-timeout
|
||||
smarm::sleep(Duration::from_millis(40)); // let it fire
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "state-timeout fired exactly once");
|
||||
}
|
||||
|
||||
// Leaving Armed before the window elapses auto-resets the state-timeout: it
|
||||
// must not fire afterward, even though we wait well past its original window.
|
||||
#[test]
|
||||
fn state_timeout_auto_resets_on_transition() {
|
||||
let got = Arc::new(Mutex::new(99u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
// Long window so the explicit Disarm beats it comfortably.
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 50 });
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 50ms state-timeout
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle, auto-resets it
|
||||
smarm::sleep(Duration::from_millis(80)); // past the original window
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 0, "auto-reset cancelled the pending state-timeout");
|
||||
}
|
||||
|
||||
// A named timeout survives a state change: armed in Idle, it still fires after
|
||||
// the machine has moved to Armed and back.
|
||||
#[test]
|
||||
fn named_timeout_survives_transition() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
// Armed's own state-timeout is long so it doesn't interfere.
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 200 });
|
||||
m.send(Ev2::Cast(TCast::Ping(20))).unwrap(); // arm "ping" for 20ms (in Idle)
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed (ping must survive this)
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle (and this)
|
||||
smarm::sleep(Duration::from_millis(60)); // let "ping" fire
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "named timeout fired across the transitions");
|
||||
}
|
||||
|
||||
// Cancelling a named timeout before its window prevents the fire.
|
||||
#[test]
|
||||
fn named_timeout_cancel() {
|
||||
let got = Arc::new(Mutex::new(99u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 200 });
|
||||
m.send(Ev2::Cast(TCast::Ping(30))).unwrap(); // arm "ping" for 30ms
|
||||
m.send(Ev2::Cast(TCast::CancelPing)).unwrap(); // cancel before it fires
|
||||
smarm::sleep(Duration::from_millis(60)); // past the original window
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 0, "cancel prevented the named-timeout fire");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Core behaviour (cast/call round-trip, enter semantics, panic -> Down)
|
||||
// ===========================================================================
|
||||
|
||||
// Casts are applied in order and a later call observes the resulting state via
|
||||
// its counters: two Arm/Disarm round-trips leave the machine back in Idle.
|
||||
#[test]
|
||||
fn cast_then_call_roundtrip() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flips = 1)
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (no flip count)
|
||||
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
*got2.lock().unwrap() = flips;
|
||||
// Long state-timeout window so it never fires during the test.
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
|
||||
// enters = 1 (start) + 4 transitions = 5.
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "turned On once across the two flips");
|
||||
assert_eq!(*got.lock().unwrap(), 5, "one enter on start, one per real transition");
|
||||
}
|
||||
|
||||
// `enter` fires once on start and once per *real* transition; a stay (a call
|
||||
@@ -80,14 +188,14 @@ fn enter_on_start_and_each_transition_but_not_stay() {
|
||||
let got = Arc::new(Mutex::new((0u32, 0u32, 0u32)));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); // enter -> 1
|
||||
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
// Two stays (the reads) must not bump enters.
|
||||
let _ = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
let still = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On -> enter -> 2
|
||||
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
*got2.lock().unwrap() = (after_start, still, after_flip);
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); // enter -> 1
|
||||
let after_start = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
// A stay (a counter read returns `prev`) must not bump enters.
|
||||
let _ = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
|
||||
let still = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed -> enter -> 2
|
||||
let after_arm = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
*got2.lock().unwrap() = (after_start, still, after_arm);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), (1, 1, 2));
|
||||
}
|
||||
@@ -100,9 +208,173 @@ fn call_to_panicking_handler_is_down() {
|
||||
let got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
let r = sw.call(|rep| Ev::Call(Call::Boom(rep)));
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
|
||||
let r = m.call(|rep| Ev2::Call(TCall::Boom(rep)));
|
||||
*got2.lock().unwrap() = Some(r);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down)));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Postpone
|
||||
// ===========================================================================
|
||||
//
|
||||
// Two machines. `LatchSm` defers a `Take` *call* while `Empty` and answers it
|
||||
// from `Filled` — the Reply rides inside the postponed event onto the queue and
|
||||
// is honoured by whichever later state handles the replay. `RelaySm` defers a
|
||||
// `Mark` cast through two states so a replayed event can postpone *again*,
|
||||
// landing only once the handling state is reached.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum L {
|
||||
Empty,
|
||||
Filled,
|
||||
}
|
||||
|
||||
struct LData {
|
||||
value: u32, // the value a Fill stored, handed back by a Take
|
||||
takes: u32, // completed takes
|
||||
}
|
||||
|
||||
enum LCast {
|
||||
Fill(u32), // Empty -> Filled, storing the value
|
||||
}
|
||||
|
||||
enum LCall {
|
||||
Take(Reply<u32>), // Filled: reply value & empty; Empty: postpone until filled
|
||||
Takes(Reply<u32>), // completed-take count (stay)
|
||||
Status(Reply<L>), // current state tag (stay)
|
||||
}
|
||||
|
||||
gen_statem! {
|
||||
machine: LatchSm { state: L, data: LData };
|
||||
event: LEv { cast: LCast, call: LCall, info: () };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter { _ => {} }
|
||||
|
||||
on L::Empty => {
|
||||
cast LCast::Fill(v) => { data.value = v; L::Filled },
|
||||
// No value yet: defer the take (with its Reply) until a Fill arrives.
|
||||
call LCall::Take(r) => postpone,
|
||||
}
|
||||
on L::Filled => {
|
||||
cast LCast::Fill(_) => unhandled, // already full
|
||||
call LCall::Take(r) => { r.reply(data.value); data.takes += 1; L::Empty },
|
||||
}
|
||||
on _ => {
|
||||
call LCall::Takes(r) => { r.reply(data.takes); prev },
|
||||
call LCall::Status(r) => { r.reply(prev); prev },
|
||||
state_timeout => unhandled,
|
||||
timeout _ => unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
// A `Take` issued while the latch is Empty parks the caller and is postponed
|
||||
// (Reply included). A later Fill transitions Empty -> Filled, whose replay
|
||||
// answers the deferred call — the parked caller wakes with the filled value.
|
||||
#[test]
|
||||
fn postponed_call_answered_after_transition() {
|
||||
let got = Arc::new(Mutex::new(None::<u32>));
|
||||
let g2 = got.clone();
|
||||
run(move || {
|
||||
let m = LatchSm::start(L::Empty, LData { value: 0, takes: 0 });
|
||||
|
||||
// Child actor issues the Take while Empty; its call parks on the reply.
|
||||
let m2 = m.clone();
|
||||
let taken = Arc::new(Mutex::new(None::<u32>));
|
||||
let t2 = taken.clone();
|
||||
smarm::scheduler::spawn(move || {
|
||||
let v = m2.call(|r| LEv::Call(LCall::Take(r))).unwrap();
|
||||
*t2.lock().unwrap() = Some(v);
|
||||
});
|
||||
smarm::sleep(Duration::from_millis(20)); // let the Take land and be deferred
|
||||
|
||||
// While deferred, the latch is untouched: still Empty, no take completed.
|
||||
// (These reads are stays — they don't disturb the postponed event.)
|
||||
assert_eq!(m.call(|r| LEv::Call(LCall::Status(r))).unwrap(), L::Empty);
|
||||
assert_eq!(m.call(|r| LEv::Call(LCall::Takes(r))).unwrap(), 0);
|
||||
|
||||
m.send(LEv::Cast(LCast::Fill(42))).unwrap(); // Empty -> Filled: replays the Take
|
||||
smarm::sleep(Duration::from_millis(20)); // let the child wake with its reply
|
||||
*g2.lock().unwrap() = *taken.lock().unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(42), "postponed call answered by the Filled state");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum R {
|
||||
S0,
|
||||
S1,
|
||||
S2,
|
||||
}
|
||||
|
||||
struct RData {
|
||||
marks: u32, // Marks handled (only S2 handles one)
|
||||
}
|
||||
|
||||
enum RCast {
|
||||
Go, // S0 -> S1 -> S2 -> S0
|
||||
Mark, // postponed in S0/S1, handled in S2
|
||||
}
|
||||
|
||||
enum RCall {
|
||||
Marks(Reply<u32>),
|
||||
State(Reply<R>),
|
||||
}
|
||||
|
||||
gen_statem! {
|
||||
machine: RelaySm { state: R, data: RData };
|
||||
event: REv { cast: RCast, call: RCall, info: () };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter { _ => {} }
|
||||
|
||||
on R::S0 => {
|
||||
cast RCast::Go => R::S1,
|
||||
cast RCast::Mark => postpone,
|
||||
}
|
||||
on R::S1 => {
|
||||
cast RCast::Go => R::S2,
|
||||
cast RCast::Mark => postpone, // a replay here defers again
|
||||
}
|
||||
on R::S2 => {
|
||||
cast RCast::Go => R::S0,
|
||||
cast RCast::Mark => { data.marks += 1; prev }, // finally handled
|
||||
}
|
||||
on _ => {
|
||||
call RCall::Marks(r) => { r.reply(data.marks); prev },
|
||||
call RCall::State(r) => { r.reply(prev); prev },
|
||||
state_timeout => unhandled,
|
||||
timeout _ => unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
// A postponed event that is replayed into a state which *also* postpones it
|
||||
// re-queues, and is handled only once a state that accepts it is reached. Each
|
||||
// `Marks` call is a stay that acts as a sync barrier after the preceding casts.
|
||||
#[test]
|
||||
fn replayed_event_can_postpone_again() {
|
||||
let got = Arc::new(Mutex::new((9u32, 9u32, 9u32, R::S0)));
|
||||
let g2 = got.clone();
|
||||
run(move || {
|
||||
let m = RelaySm::start(R::S0, RData { marks: 0 });
|
||||
|
||||
m.send(REv::Cast(RCast::Mark)).unwrap(); // S0: postponed
|
||||
let a = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // deferred -> 0
|
||||
|
||||
m.send(REv::Cast(RCast::Go)).unwrap(); // S0 -> S1: replay Mark -> postponed again
|
||||
let b = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // re-deferred -> 0
|
||||
|
||||
m.send(REv::Cast(RCast::Go)).unwrap(); // S1 -> S2: replay Mark -> handled
|
||||
let c = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // -> 1
|
||||
let s = m.call(|r| REv::Call(RCall::State(r))).unwrap(); // Mark was a stay in S2
|
||||
|
||||
*g2.lock().unwrap() = (a, b, c, s);
|
||||
});
|
||||
let (a, b, c, s) = *got.lock().unwrap();
|
||||
assert_eq!(a, 0, "deferred while S0");
|
||||
assert_eq!(b, 0, "re-deferred while S1");
|
||||
assert_eq!(c, 1, "handled once S2 is reached");
|
||||
assert_eq!(s, R::S2, "Mark handled as a stay in S2");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! RFC 018 scheduler park/wake — observable-behavior guards.
|
||||
//!
|
||||
//! These pin the two timer-latency properties the park/wake swap must
|
||||
//! preserve or introduce:
|
||||
//!
|
||||
//! - `sleep_fires_under_saturation`: due timers fire even when every
|
||||
//! scheduler is busy (nobody parked ⇒ no timekeeper) — the busy-path
|
||||
//! due-check, ratified design point (a). The old drain phase gave this
|
||||
//! for free (timers drained every loop iteration); the new design must
|
||||
//! not lose it.
|
||||
//! - `submillisecond_sleep_is_prompt`: a sub-ms sleep completes promptly.
|
||||
//! Under the old wake pipe, `poll_wake`'s `as_millis` truncation turned
|
||||
//! sub-ms deadlines into 0ms busy-polls (correct wall time, pathological
|
||||
//! CPU); under park/wake the futex timespec carries full nanosecond
|
||||
//! precision.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn sleep_fires_under_saturation() {
|
||||
let rt = smarm::runtime::init(smarm::runtime::Config::exact(4));
|
||||
rt.run(|| {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let mut spinners = Vec::new();
|
||||
// 8 spinners over 4 schedulers: the run queue never empties, so no
|
||||
// scheduler ever parks and no timekeeper exists. Only the busy-path
|
||||
// due-check can fire the sleeper's timer before the spinners quit.
|
||||
for _ in 0..8 {
|
||||
let stop = stop.clone();
|
||||
spinners.push(smarm::spawn(move || {
|
||||
let t0 = Instant::now();
|
||||
while !stop.load(Ordering::Relaxed) && t0.elapsed() < Duration::from_secs(5) {
|
||||
smarm::yield_now();
|
||||
}
|
||||
}));
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
let dt = t0.elapsed();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
for s in spinners {
|
||||
let _ = s.join();
|
||||
}
|
||||
assert!(
|
||||
dt < Duration::from_millis(500),
|
||||
"10ms sleep took {dt:?} under scheduler saturation — busy-path \
|
||||
timer firing is broken (timekeeper-only firing stalls under load)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submillisecond_sleep_is_prompt() {
|
||||
let rt = smarm::runtime::init(smarm::runtime::Config::exact(2));
|
||||
rt.run(|| {
|
||||
// Warm one iteration, then measure.
|
||||
smarm::sleep(Duration::from_micros(500));
|
||||
let t0 = Instant::now();
|
||||
smarm::sleep(Duration::from_micros(500));
|
||||
let dt = t0.elapsed();
|
||||
assert!(dt >= Duration::from_micros(400), "woke early: {dt:?}");
|
||||
assert!(
|
||||
dt < Duration::from_millis(100),
|
||||
"500µs sleep took {dt:?} — sub-ms deadline handling is broken"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -485,3 +485,35 @@ fn multi_thread_timer_only_no_pipe_contention() {
|
||||
SLEEP_MS * 2,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root panic propagation
|
||||
|
||||
/// A panic in the root actor escapes `run()` to the caller. Anything else
|
||||
/// makes every assert inside `run` silently vacuous — found live when a
|
||||
/// failing-first test passed: the tripped assert was caught by the
|
||||
/// trampoline, recorded as `Outcome::Panic` on the root slot, and dropped
|
||||
/// unread with the initial handle.
|
||||
#[test]
|
||||
#[should_panic(expected = "root actor panic escapes")]
|
||||
fn root_panic_escapes_run() {
|
||||
rt1().run(|| {
|
||||
panic!("root actor panic escapes");
|
||||
});
|
||||
}
|
||||
|
||||
/// Teardown completes before the root panic propagates: a caller that
|
||||
/// catches it can immediately `run()` again on the same `Runtime` (the
|
||||
/// documented sequential-reuse contract).
|
||||
#[test]
|
||||
fn runtime_reusable_after_root_panic() {
|
||||
let r = rt1();
|
||||
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
r.run(|| panic!("boom"));
|
||||
}));
|
||||
assert!(caught.is_err(), "root panic must escape run()");
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
let ran_t = ran.clone();
|
||||
r.run(move || ran_t.store(true, Ordering::Relaxed));
|
||||
assert!(ran.load(Ordering::Relaxed), "runtime unusable after root panic");
|
||||
}
|
||||
|
||||
+7
-1
@@ -108,8 +108,14 @@ fn loser_arm_wake_after_parked_select_stays_precise() {
|
||||
t0.elapsed() >= Duration::from_millis(40),
|
||||
"one-shot park returned early: a stale loser-arm wake landed"
|
||||
);
|
||||
// Arm 0 is now closed (the sender actor exited after its sends) and
|
||||
// a closed arm reports ready forever under priority order — observe
|
||||
// the disconnect and drop it from the set, per the documented
|
||||
// closed-arm rule.
|
||||
assert_eq!(select(&[&rxa, &rxb]), 0);
|
||||
assert!(rxa.try_recv().is_err(), "arm 0 must report disconnect");
|
||||
// The loser's message was never lost.
|
||||
assert_eq!(select(&[&rxa, &rxb]), 1);
|
||||
assert_eq!(select(&[&rxb]), 0);
|
||||
assert_eq!(rxb.try_recv().unwrap(), Some(2));
|
||||
h.join().unwrap();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Reproducer (soak20 signature 2, refcount_test.exs "watcher crash"):
|
||||
//! `by_name` stores only the slot *index*, so a name whose holder died — never
|
||||
//! unregistered, since no smarm stop path unregisters (prune is lazy) — and
|
||||
//! whose slot was then re-tenanted by an unrelated actor reads as *live-held*:
|
||||
//!
|
||||
//! - `register` of the name fails `NameTaken { holder: <unrelated tenant> }`,
|
||||
//! so the bridge's generated `start()` (a `let _ =`) silently no-ops and
|
||||
//! `start_server/1` reports `:ok` for a server that never came up;
|
||||
//! - a by-name `call` resolves the tenant's mailbox, misses on the message
|
||||
//! `TypeId`, and fails `ServerDown` fast — and does NOT prune (only the
|
||||
//! dead-holder and dangling-name arms prune), so the name never heals
|
||||
//! while the tenant lives. The wedge is self-sustaining.
|
||||
//!
|
||||
//! Wild signature: 110235x fast `{:error, :server_down}` probes over the full
|
||||
//! 5 s await window after a swallowed restart (200-run width-20 soak, run 59).
|
||||
//!
|
||||
//! The test asserts the *contract*: after its holder dies, a name must be
|
||||
//! re-registrable regardless of what happened to the slot. Red pre-fix.
|
||||
|
||||
use smarm::{
|
||||
call, init, request_stop, whereis, CallError, Config, GenServer, GenServerBuilder,
|
||||
GenServerName, RegisterError,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const TARGET: GenServerName<Target> = GenServerName::new("stale_reuse_target");
|
||||
|
||||
/// The named server whose death opens the window. Trivial on purpose.
|
||||
struct Target;
|
||||
|
||||
impl GenServer for Target {
|
||||
type Call = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, _req: ()) {}
|
||||
fn handle_cast(&mut self, _op: ()) {}
|
||||
}
|
||||
|
||||
/// The unrelated tenant. A *different* server type, so its mailbox holds a
|
||||
/// different `Envelope` `TypeId` — a same-typed tenant would make the by-name
|
||||
/// `call` *deliver to the wrong server* instead of failing, which is the same
|
||||
/// root hole wearing a worse hat.
|
||||
struct Filler;
|
||||
|
||||
impl GenServer for Filler {
|
||||
type Call = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, _req: ()) {}
|
||||
fn handle_cast(&mut self, _op: ()) {}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Observed {
|
||||
old_slot: (u32, u32),
|
||||
tenant_slot: (u32, u32),
|
||||
/// `whereis` of the dead name after re-tenanting — `Some` is the misread.
|
||||
whereis_after_reuse: Option<(u32, u32)>,
|
||||
/// By-name call after re-tenanting — the wild `server_down` fast-fail.
|
||||
call_after_reuse: Result<(), CallError>,
|
||||
/// The contract under test: re-registering the dead name.
|
||||
restart: Result<(), RegisterError>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_name_with_reused_slot_must_be_re_registrable() {
|
||||
let out: Arc<Mutex<Option<Observed>>> = Arc::new(Mutex::new(None));
|
||||
let out_w = out.clone();
|
||||
|
||||
// A deliberately tiny slab forces prompt slot recycling: with every filler
|
||||
// held alive, the freed slot is the only *recycled* one, so a filler lands
|
||||
// on it deterministically well before the slab (a loud panic) runs out.
|
||||
init(Config::exact(2).max_actors(32)).run(move || {
|
||||
// 1. Named server up; record its slot.
|
||||
let target = GenServerBuilder::new(Target)
|
||||
.named(TARGET)
|
||||
.start()
|
||||
.expect("name should be free at test start");
|
||||
let old_pid = target.pid();
|
||||
|
||||
// 2. Kill it WITHOUT unregistering (no stop path does). Death is
|
||||
// confirmed via the *ref*, never the name — a by-name resolve of a
|
||||
// dead-but-not-yet-reused holder takes the prune arm and heals the
|
||||
// name, destroying the precondition.
|
||||
request_stop(old_pid);
|
||||
loop {
|
||||
match target.call(()) {
|
||||
Err(CallError::ServerDown) => break,
|
||||
Ok(()) => smarm::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
drop(target);
|
||||
|
||||
// 3. Re-tenant the slot: spawn fillers (all kept alive) until one
|
||||
// lands on the old index.
|
||||
let mut fillers = Vec::new();
|
||||
let mut tenant = None;
|
||||
for i in 0..24 {
|
||||
let name: &'static str = Box::leak(format!("stale_filler_{i}").into_boxed_str());
|
||||
let f = GenServerBuilder::new(Filler)
|
||||
.named(GenServerName::<Filler>::new(name))
|
||||
.start()
|
||||
.expect("filler names are fresh");
|
||||
let fp = f.pid();
|
||||
fillers.push(f);
|
||||
if fp.index() == old_pid.index() {
|
||||
tenant = Some(fp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let tenant = tenant.expect(
|
||||
"precondition: the freed slot must be re-tenanted within the tiny slab \
|
||||
(slots are recycled; every filler is held alive)",
|
||||
);
|
||||
|
||||
// 4. Observe the poisoned state through the same paths the bridge uses.
|
||||
let whereis_after_reuse = whereis(TARGET.as_str()).map(|p| (p.index(), p.generation()));
|
||||
let call_after_reuse = call(TARGET, ());
|
||||
let restart = GenServerBuilder::new(Target)
|
||||
.named(TARGET)
|
||||
.start()
|
||||
.map(|_fresh_ref| ());
|
||||
|
||||
*out_w.lock().unwrap() = Some(Observed {
|
||||
old_slot: (old_pid.index(), old_pid.generation()),
|
||||
tenant_slot: (tenant.index(), tenant.generation()),
|
||||
whereis_after_reuse,
|
||||
call_after_reuse,
|
||||
restart,
|
||||
});
|
||||
|
||||
drop(fillers);
|
||||
});
|
||||
|
||||
let o = out.lock().unwrap().take().expect("run body completed");
|
||||
eprintln!("observed: {o:?}");
|
||||
|
||||
assert!(
|
||||
o.restart.is_ok(),
|
||||
"re-registering '{}' after its holder died failed with {:?}: the dead name \
|
||||
reads as held by the live, unrelated tenant {:?} because by_name kept only \
|
||||
the slot index (old slot {:?}). This is the silent-no-op start_server path \
|
||||
of soak20 signature 2.",
|
||||
TARGET.as_str(),
|
||||
o.restart,
|
||||
o.tenant_slot,
|
||||
o.old_slot,
|
||||
);
|
||||
|
||||
// The healed semantics around the re-register: the stale name reads
|
||||
// *unbound* (never the tenant), and a by-name call fails ServerDown rather
|
||||
// than resolving anything of the tenant's.
|
||||
assert_eq!(
|
||||
o.whereis_after_reuse, None,
|
||||
"whereis of a dead name must prune and report unbound, not the slot's new tenant",
|
||||
);
|
||||
assert_eq!(
|
||||
o.call_after_reuse,
|
||||
Err(CallError::ServerDown),
|
||||
"a by-name call to a dead name must fail ServerDown",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Reproducer: a *named* gen_server stopped with `request_stop` while a `call`
|
||||
//! sits **un-dequeued** in its inbox does NOT release the parked caller with
|
||||
//! `CallError::ServerDown`. The caller parks forever, contradicting the
|
||||
//! documented gen_server guarantee ("Any caller currently waiting in `call`
|
||||
//! sees `Err(ServerDown)`").
|
||||
//!
|
||||
//! Root cause (channel.rs): `Receiver::Drop` only flips `receiver_alive = false`
|
||||
//! and never drains `queue`. The queued `Envelope::Call(_, reply_tx)` therefore
|
||||
//! survives as long as the channel `Arc<Inner>` does — and for a *named* server
|
||||
//! the registry holds a `Sender` clone (lazy prune) that keeps the `Arc` alive
|
||||
//! after the server is gone. So the queued `reply_tx` is never dropped, the
|
||||
//! caller's `reply_rx` never closes, and `reply_rx.recv()` parks forever.
|
||||
//!
|
||||
//! Anonymous servers happen to dodge this: when their last `GenServerRef`
|
||||
//! drops, every `Sender` drops, the `Arc` refcount hits zero, `Inner` (and its
|
||||
//! queue) is dropped, and the queued `reply_tx` goes with it — waking the
|
||||
//! caller. The bug is specific to "a `Sender` outlives the `Receiver`", which a
|
||||
//! registry entry guarantees for every named server.
|
||||
|
||||
use smarm::{
|
||||
call, channel, init, request_stop, spawn, Config, GenServer, GenServerBuilder, GenServerName,
|
||||
CallError, Receiver, RecvTimeoutError,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const BLOCKER: GenServerName<Blocker> = GenServerName::new("repro_blocker");
|
||||
|
||||
/// A server that, on its single cast, parks forever on a gate channel the test
|
||||
/// never feeds. This deterministically holds the server *inside a handler* (not
|
||||
/// at the inbox recv), so any subsequent `call` queues behind it and stays
|
||||
/// un-dequeued — exactly the state `request_stop` then has to clean up.
|
||||
struct Blocker {
|
||||
gate: Option<Receiver<()>>,
|
||||
}
|
||||
|
||||
impl GenServer for Blocker {
|
||||
type Call = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
// Trivial + instant: if this ever ran for the queued call, the caller would
|
||||
// get Ok(()) immediately. It must NOT run — the server is parked on the gate
|
||||
// when the stop arrives.
|
||||
fn handle_call(&mut self, _req: ()) {}
|
||||
|
||||
// Park forever (until cancelled). recv() on an open channel with no message
|
||||
// parks the actor; the gate sender is held by the test and never fires.
|
||||
fn handle_cast(&mut self, _op: ()) {
|
||||
if let Some(gate) = self.gate.take() {
|
||||
let _ = gate.recv();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_server_request_stop_releases_queued_caller_with_server_down() {
|
||||
// Final observation, asserted after the run.
|
||||
// Some(Err(ServerDown)) -> contract honored (fixed)
|
||||
// None -> caller never released; parked past the 3s
|
||||
// bound (bug reproduced)
|
||||
let outcome: Arc<Mutex<Option<Result<(), CallError>>>> = Arc::new(Mutex::new(None));
|
||||
let outcome_w = outcome.clone();
|
||||
|
||||
init(Config::exact(2)).run(move || {
|
||||
// Gate the server will park on. Held for the whole run so the server's
|
||||
// gate.recv() parks (rather than seeing Disconnected and returning).
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
|
||||
// Channel the queued caller reports its result back on.
|
||||
let (res_tx, res_rx) = channel::<Result<(), CallError>>();
|
||||
|
||||
// 1. Start the named server and keep its ref alive.
|
||||
let server = GenServerBuilder::new(Blocker { gate: Some(gate_rx) })
|
||||
.named(BLOCKER)
|
||||
.start()
|
||||
.expect("name should be free");
|
||||
let spid = server.pid();
|
||||
|
||||
// 2. Send the cast and let the server dequeue it and park on the gate.
|
||||
server.cast(()).expect("server is live");
|
||||
smarm::sleep(Duration::from_millis(100));
|
||||
|
||||
// 3. A separate caller issues a by-name `call`. The server is parked on
|
||||
// the gate, so this Call envelope queues un-dequeued; the caller then
|
||||
// parks on its reply channel.
|
||||
spawn(move || {
|
||||
let r = call(BLOCKER, ());
|
||||
let _ = res_tx.send(r);
|
||||
});
|
||||
smarm::sleep(Duration::from_millis(100));
|
||||
|
||||
// 4. Stop the server. Its loop unwinds out of the gate.recv() and drops
|
||||
// the inbox Receiver — at which point the queued caller is *supposed*
|
||||
// to be released with ServerDown.
|
||||
request_stop(spid);
|
||||
|
||||
// 5. Bounded wait. A correct runtime releases the caller in well under
|
||||
// 3s; the bug leaves it parked, so we time out.
|
||||
let observed = match res_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(r) => Some(r),
|
||||
Err(RecvTimeoutError::Timeout) => None,
|
||||
Err(RecvTimeoutError::Disconnected) => None,
|
||||
};
|
||||
*outcome_w.lock().unwrap() = observed;
|
||||
|
||||
// Keep the gate sender alive until the very end.
|
||||
drop(gate_tx);
|
||||
});
|
||||
|
||||
let observed = outcome.lock().unwrap().take();
|
||||
assert_eq!(
|
||||
observed,
|
||||
Some(Err(CallError::ServerDown)),
|
||||
"queued caller was not released with ServerDown after the named server \
|
||||
was request_stop'd (None = parked forever => bug reproduced)"
|
||||
);
|
||||
}
|
||||
+61
-2
@@ -401,7 +401,66 @@ fn send_after_to_dead_typed_pid_is_silent() {
|
||||
assert_eq!(report_rx.recv().unwrap(), 1); // sink has now exited
|
||||
let _id = send_after(Duration::from_millis(15), sink, 2);
|
||||
sleep(Duration::from_millis(45)); // let it fire against the dead pid
|
||||
// No panic, and nothing further delivered.
|
||||
assert_eq!(report_rx.try_recv(), Ok(None));
|
||||
// No panic; the sink is gone, so its report sender dropped with it —
|
||||
// closed+empty is Err (documented), which also proves nothing
|
||||
// further was delivered.
|
||||
assert!(report_rx.try_recv().is_err(), "nothing further delivered");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wall-anchored send_after (RFC 007 user-facing opt-out). The API exists in
|
||||
// both feature configs; featureless it is behaviourally identical to
|
||||
// `send_after` — these tests pin exactly that.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn armed_wall_send_timer_is_returned_and_fires() {
|
||||
let mut t = Timers::new();
|
||||
let now = Instant::now();
|
||||
let fired = Arc::new(AtomicBool::new(false));
|
||||
let f = fired.clone();
|
||||
let _id = t.insert_send_wall(
|
||||
now + Duration::from_millis(10),
|
||||
Pid::new(0, 0),
|
||||
Box::new(move || f.store(true, Ordering::SeqCst)),
|
||||
);
|
||||
|
||||
let mut due = t.pop_due(now + Duration::from_millis(20));
|
||||
assert_eq!(due.len(), 1, "an armed wall send timer should pop when due");
|
||||
run_fire(due.pop().unwrap());
|
||||
assert!(fired.load(Ordering::SeqCst), "running the thunk delivers");
|
||||
assert!(t.is_empty());
|
||||
}
|
||||
|
||||
use smarm::send_after_named_wall;
|
||||
|
||||
#[test]
|
||||
fn send_after_named_wall_delivers_after_the_delay() {
|
||||
const WPING: Name<u64> = Name::new("send_after_wall_ping");
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
register(WPING, tx).unwrap();
|
||||
let t0 = Instant::now();
|
||||
let _id = send_after_named_wall(Duration::from_millis(30), WPING, 99);
|
||||
assert_eq!(rx.recv().unwrap(), 99);
|
||||
assert!(
|
||||
t0.elapsed() >= Duration::from_millis(25),
|
||||
"delivered too early: {:?}",
|
||||
t0.elapsed()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_after_named_wall_cancels() {
|
||||
const WC: Name<u64> = Name::new("send_after_wall_cancel");
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
register(WC, tx).unwrap();
|
||||
let id = send_after_named_wall(Duration::from_millis(50), WC, 7);
|
||||
assert!(cancel_timer(id), "cancel before fire returns true");
|
||||
sleep(Duration::from_millis(90));
|
||||
assert_eq!(rx.try_recv(), Ok(None), "cancelled wall timer delivered");
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user