Compare commits
93
Commits
393cdd01f4
..
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 | ||
|
|
0cf6b80396 | ||
|
|
3e316066c3 | ||
|
|
f646c5cd72 | ||
|
|
07867b91f6 | ||
|
|
8d1605638e | ||
|
|
acc37c5fc9 | ||
|
|
0d6fc970a7 | ||
|
|
e9c39bff46 | ||
|
|
2d15834b24 | ||
|
|
6df4cd4a0b | ||
|
|
48d47c45c9 | ||
|
|
e93b3120ec | ||
|
|
354eef9f88 | ||
|
|
7ef915c81e | ||
|
|
c66691943d | ||
|
|
fc014c4e54 | ||
|
|
f454a9195a | ||
|
|
1df85e2384 | ||
|
|
8c8af55928 | ||
|
|
c0cfa01f37 | ||
|
|
401a1465d5 | ||
|
|
57eadb5c6c | ||
|
|
47d75d1ead | ||
|
|
61520bf2cc | ||
|
|
5cb7f6a491 | ||
|
|
ecb0835aa7 | ||
|
|
a866e34b52 | ||
|
|
4c56938f0b | ||
|
|
3cec3ba1a1 | ||
|
|
f5fbb5b144 | ||
|
|
48bdbada2b | ||
|
|
48bc636552 | ||
|
|
ae9f0e864a | ||
|
|
56f2fc573c | ||
|
|
3262c9527b | ||
|
|
6464c3e92b | ||
|
|
b78311bc88 | ||
|
|
4d4a2a6c9b | ||
|
|
6566e2f613 | ||
|
|
793941693f | ||
|
|
64bd7e81a5 | ||
|
|
371915ad07 | ||
|
|
7a42d49746 | ||
|
|
b0c9685c89 | ||
|
|
cc4000a165 | ||
|
|
156dcca496 | ||
|
|
8e5b754249 | ||
|
|
7bab4d23ea | ||
|
|
eddf3fe929 | ||
|
|
51f1a61a40 | ||
|
|
1ed179fc12 | ||
|
|
f9f60a43d1 | ||
|
|
37d931968e | ||
|
|
2708042990 | ||
|
|
f09e992f32 |
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
|
||||||
@@ -2,3 +2,6 @@ target
|
|||||||
Cargo.lock
|
Cargo.lock
|
||||||
smarm_trace.json
|
smarm_trace.json
|
||||||
/bench_results/
|
/bench_results/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
profile.coz
|
||||||
|
|||||||
+44
@@ -7,9 +7,32 @@ rust-version = "1.95"
|
|||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
|
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]
|
[features]
|
||||||
default = ["rq-mutex"]
|
default = ["rq-mutex"]
|
||||||
smarm-trace = []
|
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
|
||||||
|
# unless this is enabled.
|
||||||
|
budget-accounting = []
|
||||||
|
# RFC 016 Chunk 4: the live observer gen_server (src/observer.rs). Off by
|
||||||
|
# default (DECISION D10) — the read primitive (Chunks 1–3) is always present
|
||||||
|
# and unflagged; only the optional gen_server transport sits behind this, so a
|
||||||
|
# release build pays nothing for an observer it never starts.
|
||||||
|
observer = []
|
||||||
# Run-queue selection: exactly one, compile-time (see src/run_queue.rs).
|
# Run-queue selection: exactly one, compile-time (see src/run_queue.rs).
|
||||||
# Non-default variants need --no-default-features (features are additive).
|
# Non-default variants need --no-default-features (features are additive).
|
||||||
rq-mutex = []
|
rq-mutex = []
|
||||||
@@ -61,3 +84,24 @@ harness = false
|
|||||||
[[bench]]
|
[[bench]]
|
||||||
name = "rq_runtime"
|
name = "rq_runtime"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "switch_cost"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
# RFC 016 Chunk 4 — the live observer dump. Needs the optional gen_server.
|
||||||
|
[[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"]
|
||||||
|
|||||||
+206
-76
@@ -2,20 +2,9 @@
|
|||||||
|
|
||||||
## Shipped (compacted — full cycle plans and deviation records live in git history)
|
## Shipped (compacted — full cycle plans and deviation records live in git history)
|
||||||
|
|
||||||
Cycles before v0.7 (v0.4 actor primitives, v0.5 runtime decomposition &
|
Cycles before v0.8 (v0.4 actor primitives, v0.5 runtime decomposition &
|
||||||
pluggable run queue, v0.6 actor ergonomics): see `git log ROADMAP.md`.
|
pluggable run queue, v0.6 actor ergonomics, v0.7 select on epoch-stamped
|
||||||
|
consuming wakes): see `git log ROADMAP.md`.
|
||||||
### v0.7 — select, on epoch-stamped consuming wakes ✅
|
|
||||||
A 24-bit park-epoch packed into the slot word gives every wait an identity:
|
|
||||||
registrations carry `(pid, epoch)`, every successful wake **consumes** the
|
|
||||||
epoch, stale wakes die at one failed CAS. Subsumed the per-primitive wait
|
|
||||||
seqs (channel, mutex, timer); the only wildcard wake left is `request_stop`
|
|
||||||
(terminal). On top: `select`/`select_timeout` — ready-index wait over many
|
|
||||||
receivers, priority order, no cancellation pass. Loom theorems re-proved on
|
|
||||||
the new word. Notable deviations: `retire_wait` for the no-park exit path
|
|
||||||
(plan missed it); the Drop-guard deregistration was superseded by relaxed
|
|
||||||
single-receiver asserts; a closed arm is ready *forever* (documented gotcha).
|
|
||||||
Commits `4913835`…`00128f3`, `a0a93b6`.
|
|
||||||
|
|
||||||
### v0.8 — gen_server: handle_info / handle_down + io fd hygiene ✅
|
### v0.8 — gen_server: handle_info / handle_down + io fd hygiene ✅
|
||||||
Spent `select` on the server loop: static info arms (`type Info`,
|
Spent `select` on the server loop: static info arms (`type Info`,
|
||||||
@@ -27,6 +16,19 @@ entry permanently poisoned the fd). Deviation: the plain-inbox fast path
|
|||||||
narrowed; servers holding a `Watcher` select forever.
|
narrowed; servers holding a `Watcher` select forever.
|
||||||
Commits `e5d1b3b`, `24b95c9`, `f6969e5`.
|
Commits `e5d1b3b`, `24b95c9`, `f6969e5`.
|
||||||
|
|
||||||
|
### v0.9 — Wake-path latency ✅
|
||||||
|
Attacked per-wake latency with the RFC 005 **wake slot**: a per-scheduler,
|
||||||
|
thread-local, capacity-one wake cache checked before the shared queue, pushed
|
||||||
|
only from actor context, slot-then-shared pop with the waker's residual slice
|
||||||
|
as the starvation bound (`slot_hits`/`slot_displacements`). Benched via the
|
||||||
|
slot on/off dimension of `rq_runtime` — ping-pong-pairs (win), yield-storm
|
||||||
|
(regression guard), spawn-storm (neutrality); results annotated in RFC 005.
|
||||||
|
The RFC 004 spinning-workers experiment, originally scoped here, was evaluated
|
||||||
|
and **excised** (not worth the code cost; preserved on branch
|
||||||
|
`rfc-004-spinning`). Also a false-sharing fix (`align(64)` on `SchedulerStats`)
|
||||||
|
and a termination wake for idle siblings.
|
||||||
|
Commits `2708042`, `37d9319`, `eddf3fe`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decision record — queue topology 🔒 CLOSED (2026-06-10)
|
## Decision record — queue topology 🔒 CLOSED (2026-06-10)
|
||||||
@@ -34,86 +36,114 @@ Commits `e5d1b3b`, `24b95c9`, `f6969e5`.
|
|||||||
The run-queue shootout (harness `6d9f369`, 24-core sweep 1–24 schedulers,
|
The run-queue shootout (harness `6d9f369`, 24-core sweep 1–24 schedulers,
|
||||||
report `bench_report_rq_shootout.html`) landed in **RFC 005's World 3**: the
|
report `bench_report_rq_shootout.html`) landed in **RFC 005's World 3**: the
|
||||||
three queue variants are within 10–15% of each other in `rq_runtime` at every
|
three queue variants are within 10–15% of each other in `rq_runtime` at every
|
||||||
scheduler count ≥ 4, on all three workloads. Striped's real micro-bench
|
scheduler count ≥ 4, on all three workloads.
|
||||||
dominance (24.8 M ops/s at 24t, 4.3× over mutex) does not propagate to the
|
|
||||||
runtime because the queue is not the hot path in any measured workload. USL
|
|
||||||
fits put σ at 5.5–7.6 (super-serial) for ping-pong: the ceiling is per-wake
|
|
||||||
latency through the park/unpark protocol, not queue push/pop.
|
|
||||||
|
|
||||||
Consequences:
|
Consequences:
|
||||||
- **`rq-mutex` stays the default** — simplest correct, no capacity
|
- **`rq-mutex` stays the default** — simplest correct, no capacity
|
||||||
constraints, locking model already integrated with the preemption-disabled
|
constraints, locking model already integrated.
|
||||||
queue-op invariant.
|
|
||||||
- **Feature plumbing stays as is.** All three variants keep compiling in
|
- **Feature plumbing stays as is.** All three variants keep compiling in
|
||||||
every build; `rq-mpmc`/`rq-striped` remain selectable for benching.
|
every build; `rq-mpmc`/`rq-striped` remain selectable for benching.
|
||||||
- **Reopening is benchmark-driven only.** The report documents the
|
- **Reopening is benchmark-driven only.** The report documents the
|
||||||
conditional upgrade paths if a future workload qualifies: mpmc for
|
conditional upgrade paths if a future workload qualifies: mpmc for
|
||||||
message-passing-dominant loads at N ≤ 8 (1.3× lower σ, 0.16 µs ping-pong
|
message-passing-dominant loads at N ≤ 8; striped for high-contention balanced push/pop at N ≥ 16. Neither is a scheduler workload as measured.
|
||||||
at 1t); striped for high-contention balanced push/pop at N ≥ 16 — neither
|
|
||||||
is a scheduler workload as measured.
|
|
||||||
- **Effort redirects to the wake path**: RFC 005 (billed as a latency patch,
|
- **Effort redirects to the wake path**: RFC 005 (billed as a latency patch,
|
||||||
per its own World 3 framing), RFC 004, and eventually per-switch cost.
|
per its own World 3 framing), RFC 004, and eventually per-switch cost.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.9 — Wake-path latency
|
## Typed addressable mailboxes ✅ SHIPPED (RFC 013 + RFC 014)
|
||||||
|
|
||||||
Goal: attack per-wake latency — the measured ceiling — with the two specified
|
The unblocker several later items quietly assumed: a `Pid` is now messageable.
|
||||||
mechanisms, each benched against a clean baseline before the next lands.
|
RFC 013 reworked `registry.rs` from a name↔pid *bimap* into a name→**live
|
||||||
Order: RFC 005 first (the slot is measured against the just-benched idle
|
mailbox** directory off the cold leaf; RFC 014 layered the typed addressing and
|
||||||
policy), then RFC 004 (measured against the slot-accepted baseline), then an
|
producers on top. Two addressing modes — `Pid<A>` (direct, identity-bound) and
|
||||||
interaction pass.
|
`Name<M>` (durable, re-resolving, location-transparent) — compile-time typing
|
||||||
|
preserved via phantom tokens over *contained* `Box<dyn Any>` erasure (the
|
||||||
|
global-enum alternative was rejected: it breaks library-extensibility for
|
||||||
|
out-of-crate actors). Channel store keyed by message `TypeId` in every path.
|
||||||
|
|
||||||
Full specs: `rfc_005-wake-slot.md`, `rfc_004-tunable-scheduler-idle-policy.md`
|
Delivered surface:
|
||||||
(artefacts.kalsbeek.dev).
|
- **Sends:** `send_to` (`Pid<A>`), `send` (`Name<M>`), `send_dyn` (bare-pid
|
||||||
|
escape hatch, names the message type) — earlier 014 phases.
|
||||||
|
- **Producers & discovery** (final phase, `a866e34`): `spawn_addr` (typed-path
|
||||||
|
producer; parent-side inbox publish so an immediate `send_to` resolves, no
|
||||||
|
race on the body); `lookup_as` / `pick_as` / `members_as` (unchecked-but-sound
|
||||||
|
re-type of an erased pid — a wrong `A` degrades to `NoChannel`, never
|
||||||
|
misdelivery); `dispatch` (pick-a-live-member-and-send, `SendError::NoMember`
|
||||||
|
on empty pool).
|
||||||
|
- **By-name gen_servers** (final phase): `ServerName<G>` over the existing
|
||||||
|
typed-channel store (keyed by `TypeId::of::<Envelope<G>>()`, so `Envelope`
|
||||||
|
stays private and no separate directory is needed); type-state
|
||||||
|
`NamedServerBuilder<G>` (fallible `start`, `NameTaken`) leaving the infallible
|
||||||
|
`ServerBuilder::start` untouched; free `call` / `cast` / `whereis_server`;
|
||||||
|
`ServerRef::shutdown` + free `shutdown` as the sys-style synchronous stop.
|
||||||
|
- **Root-exit teardown** (final phase): the run's initial actor is the root;
|
||||||
|
when it exits, the scheduler's idle verdict stops the parked-forever remainder
|
||||||
|
(deferred past the queue drain, so actors with in-flight work finish rather
|
||||||
|
than unwinding on the stop). Closes the "app actor blocks AllDone" stall — see
|
||||||
|
Look into, below.
|
||||||
|
|
||||||
### 1. Wake slot (RFC 005)
|
Extends — does not retire — the "select exists; a unified per-process mailbox
|
||||||
Per-scheduler, thread-local, capacity-one wake cache, checked before the
|
still does not" invariant: 014 adds addressable *delivery*, not a unified inbox;
|
||||||
shared queue. Runtime-selected via `Config { wake_slot: bool }`, default off
|
multi-port stays `select` composition over named channels. Examples:
|
||||||
until accepted (one binary benches both arms).
|
`examples/{typed_actor,named_genserver,worker_pool}.rs`. Earlier-phase commits
|
||||||
- **Push policy:** slot-eligible iff the wake originates from actor context
|
and the full 013/014 history in git.
|
||||||
(`current_pid().is_some()`). Scheduler-context wakes (timer/IO drain) and
|
|
||||||
spawns always go shared.
|
|
||||||
- **Displacement:** newest wake takes the slot, occupant pushed shared (Go
|
|
||||||
semantics); the branch swap is the fallback if benches look pathological.
|
|
||||||
- **Pop order:** slot, then shared. Slot-popped actors inherit the waker's
|
|
||||||
remaining timeslice — a handoff chain is bounded by one slice, so the
|
|
||||||
shared queue is consulted at least once per slice per scheduler (the
|
|
||||||
starvation bound, zero new counters).
|
|
||||||
- **Invariant care:** the slot push replaces `run_queue.push` at the tail of
|
|
||||||
the `Parked → Queued` CAS; at-most-once-enqueued holds verbatim (pid in
|
|
||||||
slot ⊕ shared queue). Stall blast radius grows by exactly one actor.
|
|
||||||
- Counters: `slot_hits`, `slot_displacements`.
|
|
||||||
|
|
||||||
### 2. Slot shootout
|
---
|
||||||
Extend `rq_runtime`/`bench_rq.sh` with the slot on/off dimension (cheap —
|
|
||||||
it's a Config knob, not a feature rebuild). Same sweep as the rq shootout.
|
|
||||||
- **ping-pong-pairs** — target metric; expect the win, single- and
|
|
||||||
multi-scheduler.
|
|
||||||
- **yield-storm** — regression guard; yields never touch the slot, any delta
|
|
||||||
is pop-path overhead.
|
|
||||||
- **spawn-storm** — neutrality check; spawns bypass the slot by policy.
|
|
||||||
Acceptance flips the default on and re-baselines for RFC 004.
|
|
||||||
|
|
||||||
### 3. Spinning workers (RFC 004)
|
## gen_server time-related patterns ✅ SHIPPED (RFC 015)
|
||||||
Bounded spin-before-park for idle schedulers, killing the ~100 µs
|
*(layer 2; seven commits `47d75d1`→`f454a91`, each a reviewable chunk. Builds on
|
||||||
`thread::sleep` worst case on cross-thread handoff. Two Config knobs:
|
the `send_after` substrate below.)*
|
||||||
`spin_budget_cycles` (0 recovers today's behaviour) and `max_spinners`
|
|
||||||
(default N/2); one new atomic `n_spinning`. No run-queue changes — lands on
|
|
||||||
the frozen `rq-mutex` substrate, independent of the slot mechanically.
|
|
||||||
|
|
||||||
### 4. RFC 004 bench + interaction pass
|
The OTP time vocabulary against the v0.8 server loop, with **no handler-signature
|
||||||
Re-run the sweep with spinning enabled, slot on and off. The known
|
change** — the capability is a handle stashed on `self` (the `Watcher` pattern),
|
||||||
interaction: once idle pickup latency ≪ slice, the slot's latency trade
|
not a return-directive or a `&ctx` threaded through handlers. New trait surface:
|
||||||
(occupant waits out the waker's slice while other schedulers idle) may stop
|
`type Timer` (server's own scheduled payload, `()` if unused, kept distinct from
|
||||||
paying — RFC 005's documented escape hatch is Go's "spinners exist → push
|
the external `type Info`), `handle_timer`, `handle_idle` (both no-op defaults).
|
||||||
shared instead" check. In scope only if the data demands it.
|
|
||||||
|
- **One-shot / debounce / retry-backoff** — `ctx.timer()` hands out a clonable
|
||||||
|
`TimerHandle`; `arm_after(d, msg)` arms, `cancel(id)` carries the substrate's
|
||||||
|
race bool. Debounce/backoff are just arm-and-`cancel` against the latest event
|
||||||
|
(no special mechanism).
|
||||||
|
- **Periodic tick / heartbeat** — `tick_every(d, msg)`: loop-managed sugar over
|
||||||
|
the one-shot substrate (re-arm at `now + d`), one stable id, `cancel` stops the
|
||||||
|
re-arm *and* the pending instance. Requires `Timer: Clone` (method-level bound
|
||||||
|
only); the loop re-delivers via a stored factory, so the bound never leaks onto
|
||||||
|
`type Timer` or the loop.
|
||||||
|
- **Idle / receive timeout** — *not* a channel: it is the timeout on the loop's
|
||||||
|
`select_timeout` / `recv_timeout`. `ctx.idle_after(d)` (set once in `init`)
|
||||||
|
fixes the window; reset on any dispatched message; `None` from the wait ⇒
|
||||||
|
`handle_idle`; re-arms steady. No generation-tag race — there is no token.
|
||||||
|
|
||||||
|
Mechanics: control (monitor intake) + armed timers fold into one loop-internal
|
||||||
|
`Sys` channel selected above the inbox, so **armed timers outrank infos** (a
|
||||||
|
heartbeat can't be starved). One substrate addition — `send_after_to` (a
|
||||||
|
channel-targeting sibling of `send_after`, lands the fire on the loop's own arm).
|
||||||
|
Exit is leak-free: the drop guard (same one that runs `terminate`) drains and
|
||||||
|
cancels every live timer id, then `debug_assert!`s none survive. `call_timeout`
|
||||||
|
is unchanged — it's a *client-side* call deadline, disambiguated in docs from the
|
||||||
|
server-side idle timeout (same word, two axes). `gen_statem` state timeouts
|
||||||
|
(deferred, Low) will reuse the loop-owned idle deadline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Process groups — the primitive pubsub & channels should have sat on ✅ SHIPPED (RFC 012)
|
||||||
|
*(`src/pg.rs`, four commits `b78311b`→`56f2fc5`; see HANDOFF + git history. Context retained below.)*
|
||||||
|
|
||||||
|
Context: urus is a webserver written on top of smarm to provide a testing target.
|
||||||
|
A named pid→multiset map with monitor-backed removal: `registry.rs` generalised
|
||||||
|
from name↔pid *bimap* to name→*multiset*, the death hook reused verbatim. Local
|
||||||
|
first. The point is that urus pubsub collapses into a pg consumer (`subscribe` =
|
||||||
|
join, `broadcast` = send-to-members) instead of being a bespoke mechanism, and the
|
||||||
|
same group set reads two ways — fan-out (all members) vs discovery/pool (one
|
||||||
|
member), with different netsplit consequences. Urus shipped pubsub/channels predate this
|
||||||
|
and want reframing on top of it. Foundational, so early in the post-v0.9 stack.
|
||||||
|
Needs an RFC.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
|
### Highest priority
|
||||||
### Per-switch cost (context shims, epoch protocol)
|
#### Per-switch cost (context shims, epoch protocol)
|
||||||
The shootout's residual: per-wake latency is 0.16–0.18 µs at N=1 and
|
The shootout's residual: per-wake latency is 0.16–0.18 µs at N=1 and
|
||||||
0.8–1.2 µs at N=8+, dominated by the context-switch shims and the epoch
|
0.8–1.2 µs at N=8+, dominated by the context-switch shims and the epoch
|
||||||
protocol, not the queue. On current evidence this is the larger constant —
|
protocol, not the queue. On current evidence this is the larger constant —
|
||||||
@@ -121,16 +151,115 @@ protocol, not the queue. On current evidence this is the larger constant —
|
|||||||
profiling spike (where do the cycles actually go per park/unpark round-trip)
|
profiling spike (where do the cycles actually go per park/unpark round-trip)
|
||||||
and then an RFC before it can be scheduled.
|
and then an RFC before it can be scheduled.
|
||||||
|
|
||||||
### Unbounded / configurable-bounded actor count
|
#### send_after / cancel_timer
|
||||||
|
✅ SHIPPED (`61520bf`) — message-delivery timer on the `timer.rs` min-heap:
|
||||||
|
deliver a value to an address (`Pid<A>` via `send_to`, `Name<M>` via `send`),
|
||||||
|
resolved *on fire* so a dead target / restarted name is observed at fire time;
|
||||||
|
failed resolve dropped (Erlang `erlang:send_after`). `Reason::Send { fire }`
|
||||||
|
carries delivery type-erased; cancellation is an `armed` set keyed on entry
|
||||||
|
`seq` (only `Send` uses it — `Sleep`/`WaitTimeout` stay inert-stale), exposed as
|
||||||
|
an opaque `TimerId`; `cancel` is unscoped and returns the race signal.
|
||||||
|
`peek_deadline` relaxed to "≤ true next deadline" for a future timing wheel. The
|
||||||
|
gen_server time layer (RFC 015, shipped above) lands on this, adding only the
|
||||||
|
channel-targeting `send_after_to` sibling.
|
||||||
|
|
||||||
|
#### Introspection — process_info / get_state / tree dump
|
||||||
|
✅ SHIPPED (RFC 016) — runtime introspection & observability, superseding the
|
||||||
|
RFC 000 / 006 / 009 sketches. The mechanism is an internal synchronous read,
|
||||||
|
not a C ABI: `snapshot()` / `actor_info(pid)` return owned data (pid, names,
|
||||||
|
fine scheduling state, parent edge, trap, monitor/link/joiner counts, mailbox
|
||||||
|
depth) carrying `SNAPSHOT_FORMAT_VERSION` (Chunk 1, ps-semantics tearing);
|
||||||
|
`tree()` folds that into a parentage forest with orphan re-rooting (Chunk 3).
|
||||||
|
Per-actor counters — timeslice overruns, messages-received, and a feature-gated
|
||||||
|
approximate time budget — ride hot `AtomicU64` slot fields (Chunk 2). The live
|
||||||
|
`observer` gen_server is the read transport over that primitive, behind the
|
||||||
|
off-by-default `observer` feature (Chunk 4); it is the read half of the future
|
||||||
|
RFC 003 control plane. Wedged-runtime dumps stay gdb's job, and park-reason
|
||||||
|
detail / C ABI are explicit non-goals.
|
||||||
|
|
||||||
|
#### Worker pool behaviour
|
||||||
|
Supervised, interchangeable workers with restart semantics over a shared inbox
|
||||||
|
(poolboy / NimblePool shape) — distinct from connection pools (bb8/deadpool), which
|
||||||
|
pool *resources*, not *supervised processes*. Sits on `supervisor.rs` +
|
||||||
|
`gen_server.rs`. Needs an RFC.
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
#### Demand-driven pipelines — GenStage / Broadway shape
|
||||||
|
Supervised producer/consumer stages where consumers signal demand upstream, with
|
||||||
|
batching, ack, partitioning. The clearest thing hex has and crates.io lacks (stream
|
||||||
|
combinators and bounded channels are not a supervised demand-contract stage graph),
|
||||||
|
and the natural fit for ingestion-shaped workloads. Builds on channels + gen_server
|
||||||
|
+ supervisor. Needs an RFC.
|
||||||
|
#### Unwakeable idle sleep when io is absent (terminal-wake residual)
|
||||||
|
The `(Some(deadline), None)` idle branch — timers pending, io subsystem never
|
||||||
|
initialized — blocks in `thread::sleep` with no wake mechanism at all. The
|
||||||
|
terminal wake (writes the wake pipe at AllDone) cannot reach it: no io, no
|
||||||
|
pipe. Same stall as the fixed bug, in any no-io runtime: a sibling that
|
||||||
|
blocked on an orphaned deadline sleeps it out in full after everything else
|
||||||
|
finished. Candidates, mutually exclusive: (a) clamp the sleep (cheap, but
|
||||||
|
turns idle into periodic wakeups), or (b) park the branch on a condvar/futex
|
||||||
|
the AllDone path signals — and at that point consider making the condvar the
|
||||||
|
idle primitive for the no-io runtime generally (a cross-thread unpark could
|
||||||
|
signal it too, see below). Decide before any no-io deployment.
|
||||||
|
#### Cross-thread unpark
|
||||||
|
`RuntimeInner::enqueue` does not wake idle sibling schedulers — only io
|
||||||
|
completions write the wake pipe. Mid-flight this is masked (the enqueuing
|
||||||
|
thread is awake and eats the work itself), but it costs parallelism: work
|
||||||
|
enqueued by a busy thread waits until the sibling's idle poll times out. Needs bench evidence (does the shared-queue handoff latency actually show up?) before a mechanism is picked.
|
||||||
|
#### Unbounded / configurable-bounded actor count
|
||||||
Fixed slab with a loud assert (`Config::max_actors(n)`, default 16 384).
|
Fixed slab with a loud assert (`Config::max_actors(n)`, default 16 384).
|
||||||
Revisit with a segmented slab (array of `AtomicPtr<Segment>`, doubling segment
|
Revisit with a segmented slab (array of `AtomicPtr<Segment>`, doubling segment
|
||||||
sizes, append-only) once the cap is actually hit. Do not let it calcify.
|
sizes, append-only) once the cap is actually hit. Do not let it calcify.
|
||||||
|
#### arm-port validation & merge
|
||||||
### arm-port validation & merge
|
|
||||||
`arm-port` branch carries an AAPCS64 context-switch backend, never run on
|
`arm-port` branch carries an AAPCS64 context-switch backend, never run on
|
||||||
hardware. Build + run full test suite on an aarch64 device; check
|
hardware. Build + run full test suite on an aarch64 device; check
|
||||||
`chained_spawn` / `yield_many` bench medians; merge and update README.
|
`chained_spawn` / `yield_many` bench medians; merge and update README.
|
||||||
|
|
||||||
|
### Low priority
|
||||||
|
#### gen_statem — postponement + state timeouts only
|
||||||
|
A thin layer over gen_server, not a new behaviour. The (state, event) dispatch
|
||||||
|
matrix is free from the type system and not worth porting. The two mechanisms that
|
||||||
|
are: event **postponement** (defer events in the wrong state, replay on transition
|
||||||
|
— selective receive, codified) and **state timeouts** (auto-cancel on state
|
||||||
|
change). Device-connection FSMs are the canonical use. Wants send_after underneath.
|
||||||
|
Needs an RFC.
|
||||||
|
|
||||||
|
#### Clustering — distribution epic
|
||||||
|
Sequenced deliberately after v0.9 and the per-switch-cost spike. A fat stack of
|
||||||
|
RFCs, not one. Spine settled in discussion; decisions still open:
|
||||||
|
- **Explicit remote boundary, never transparency.** Serialization colours *edges*
|
||||||
|
(channel types), not functions — local edges stay zero-copy `Send`, only remote
|
||||||
|
edges take a `RemoteRef<T: Serialize + DeserializeOwned>`. No hidden latency when
|
||||||
|
a peer migrates; the refactor is visible by construction.
|
||||||
|
- **One binary, role as runtime config** (`ROLE=… REGION=… SEEDS=…`); a build-hash
|
||||||
|
handshake enforces same-binary type identity and sidesteps cross-version type
|
||||||
|
agreement. Roles select which supervision subtree mounts.
|
||||||
|
- **Distributed pg falls out of local pg + a membership/gossip layer**, and
|
||||||
|
distributed pubsub falls out of that for free; per-member metadata (region, load)
|
||||||
|
enables fly-style nearest-member routing.
|
||||||
|
- **Migratable gen_servers** as a sub-layer: only behaviours migrate (a raw actor's
|
||||||
|
stack is opaque; a gen_server *between callbacks* is just its `State`), gated by
|
||||||
|
`Serialize` bounds + an `on_arrive` reacquire hook, addressed by name not pid. The
|
||||||
|
BEAM can't do this — leaning on the behaviour layer is what buys it. Requires `State` to be serializable, so should probaby spec a `trait MigratableGenServer: GenServer where Self::State: Migratable` , or something to that extent, so we can lean on the type system to make sure we don't accidentally make state that cannot be serialised. (The "addressed by name not pid" half is RFC 013's `Name<M>` durable address — its local form is the foundation this remote layer extends.)
|
||||||
|
- **CRDT presence** is the high-value, genuinely-hard layer above distributed pg,
|
||||||
|
kept *out* of the pg primitive (the pg2 strong-consistency lesson). Furthest out. Can maybe defer to rust ecosystem
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Look into
|
||||||
|
|
||||||
|
### app actors block AllDone; no external stop path — ADDRESSED (RFC 014 root-exit teardown)
|
||||||
|
Agent working on urus (see same git server as smarm) reported a lazily spawned actor never returning, blocking program shutdown. Maybe we should do something about it. Agent worked around it by giving the actor an atomic bool to spin on. See urus example crud for exact impl.
|
||||||
|
|
||||||
|
**Update (RFC 014):** root-exit teardown stops the parked-forever remainder when
|
||||||
|
the root actor exits, so a lazily spawned daemon no longer wedges shutdown on
|
||||||
|
`live_actors > 0`; `ServerRef::shutdown` (+ free `shutdown`) is the explicit stop
|
||||||
|
path the atomic-bool workaround stood in for. Re-check the urus crud repro to
|
||||||
|
confirm the workaround can be retired (the teardown is cooperative — an actor in
|
||||||
|
a tight loop with no observation point still can't be stopped).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Invariants & gotchas (respect these across all cycles)
|
## Invariants & gotchas (respect these across all cycles)
|
||||||
@@ -174,3 +303,4 @@ hardware. Build + run full test suite on an aarch64 device; check
|
|||||||
`RawMutex` guards all disable preemption for their span.
|
`RawMutex` guards all disable preemption for their span.
|
||||||
- **`run()` is single-thread** (`Config::exact(1)`); tests rely on deterministic
|
- **`run()` is single-thread** (`Config::exact(1)`); tests rely on deterministic
|
||||||
single-thread ordering. Multi-thread via `runtime::init(Config…)`.
|
single-thread ordering. Multi-thread via `runtime::init(Config…)`.
|
||||||
|
|
||||||
|
|||||||
+142
-142
@@ -2,313 +2,313 @@
|
|||||||
"chained_spawn": {
|
"chained_spawn": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 266,
|
"median": 413,
|
||||||
"min": 242,
|
"min": 410,
|
||||||
"max": 351
|
"max": 439
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 742,
|
"median": 909,
|
||||||
"min": 696,
|
"min": 888,
|
||||||
"max": 860
|
"max": 951
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 62,
|
"median": 62,
|
||||||
"min": 61,
|
"min": 61,
|
||||||
"max": 68
|
"max": 62
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 190,
|
"median": 197,
|
||||||
"min": 169,
|
"min": 194,
|
||||||
"max": 207
|
"max": 210
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"yield_many": {
|
"yield_many": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 200000,
|
"result": 200000,
|
||||||
"median": 19071,
|
"median": 16475,
|
||||||
"min": 18776,
|
"min": 16393,
|
||||||
"max": 19396
|
"max": 16732
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 200000,
|
"result": 200000,
|
||||||
"median": 172454,
|
"median": 148708,
|
||||||
"min": 166246,
|
"min": 111213,
|
||||||
"max": 174230
|
"max": 156462
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 200000,
|
"result": 200000,
|
||||||
"median": 4737,
|
"median": 4751,
|
||||||
"min": 4644,
|
"min": 4740,
|
||||||
"max": 5065
|
"max": 5259
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 200000,
|
"result": 200000,
|
||||||
"median": 8738,
|
"median": 8320,
|
||||||
"min": 7852,
|
"min": 7862,
|
||||||
"max": 9770
|
"max": 8882
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fan_out_compute": {
|
"fan_out_compute": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 13234,
|
"median": 13453,
|
||||||
"min": 13196,
|
"min": 13305,
|
||||||
"max": 13390
|
"max": 15077
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 2244,
|
"median": 2451,
|
||||||
"min": 2162,
|
"min": 2330,
|
||||||
"max": 2380
|
"max": 2520
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 14049,
|
"median": 14019,
|
||||||
"min": 14035,
|
"min": 12339,
|
||||||
"max": 14300
|
"max": 14045
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 1474,
|
"median": 1500,
|
||||||
"min": 1285,
|
"min": 1426,
|
||||||
"max": 1823
|
"max": 1600
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ping_pong_oneshot": {
|
"ping_pong_oneshot": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 751,
|
"median": 898,
|
||||||
"min": 727,
|
"min": 782,
|
||||||
"max": 913
|
"max": 920
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 1308,
|
"median": 1494,
|
||||||
"min": 1227,
|
"min": 1489,
|
||||||
"max": 1396
|
"max": 1546
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 407,
|
"median": 396,
|
||||||
"min": 400,
|
"min": 389,
|
||||||
"max": 444
|
"max": 409
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 1000,
|
"result": 1000,
|
||||||
"median": 10869,
|
"median": 10382,
|
||||||
"min": 8683,
|
"min": 9559,
|
||||||
"max": 11688
|
"max": 10924
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"spawn_storm_busy": {
|
"spawn_storm_busy": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 112045,
|
"median": 106232,
|
||||||
"min": 99936,
|
"min": 105627,
|
||||||
"max": 117329
|
"max": 107693
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 137105,
|
"median": 49198,
|
||||||
"min": 130852,
|
"min": 48894,
|
||||||
"max": 147707
|
"max": 52606
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 1128,
|
"median": 1140,
|
||||||
"min": 1123,
|
"min": 1018,
|
||||||
"max": 1435
|
"max": 1169
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 19674,
|
"median": 18650,
|
||||||
"min": 16013,
|
"min": 16486,
|
||||||
"max": 27234
|
"max": 19396
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mpsc_contention": {
|
"mpsc_contention": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 320000,
|
"result": 320000,
|
||||||
"median": 3667,
|
"median": 6173,
|
||||||
"min": 3608,
|
"min": 5446,
|
||||||
"max": 4126
|
"max": 6226
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 320000,
|
"result": 320000,
|
||||||
"median": 45681,
|
"median": 36329,
|
||||||
"min": 31908,
|
"min": 35121,
|
||||||
"max": 51287
|
"max": 37590
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 320000,
|
"result": 320000,
|
||||||
"median": 6228,
|
"median": 5563,
|
||||||
"min": 6210,
|
"min": 5527,
|
||||||
"max": 6514
|
"max": 6239
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 320000,
|
"result": 320000,
|
||||||
"median": 66173,
|
"median": 63966,
|
||||||
"min": 42208,
|
"min": 59972,
|
||||||
"max": 83255
|
"max": 67534
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"many_timers": {
|
"many_timers": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 119988,
|
"median": 107826,
|
||||||
"min": 107308,
|
"min": 107093,
|
||||||
"max": 123557
|
"max": 119034
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 218842,
|
"median": 96539,
|
||||||
"min": 182009,
|
"min": 95413,
|
||||||
"max": 256988
|
"max": 97804
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 12432,
|
"median": 12584,
|
||||||
"min": 12308,
|
"min": 12539,
|
||||||
"max": 13468
|
"max": 12627
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 16311,
|
"median": 16183,
|
||||||
"min": 15026,
|
"min": 16024,
|
||||||
"max": 16897
|
"max": 16541
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"multi_thread_scaling": {
|
"multi_thread_scaling": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 14908,
|
"median": 15083,
|
||||||
"min": 14857,
|
"min": 15071,
|
||||||
"max": 15218
|
"max": 15283
|
||||||
},
|
},
|
||||||
"smarm 2-thread": {
|
"smarm 2-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 7834,
|
"median": 8070,
|
||||||
"min": 7717,
|
"min": 8003,
|
||||||
"max": 8033
|
"max": 8096
|
||||||
},
|
},
|
||||||
"smarm 4-thread": {
|
"smarm 4-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 4393,
|
"median": 4460,
|
||||||
"min": 4326,
|
"min": 4454,
|
||||||
"max": 4435
|
"max": 4516
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 2173,
|
"median": 2333,
|
||||||
"min": 2068,
|
"min": 2294,
|
||||||
"max": 2405
|
"max": 2348
|
||||||
},
|
},
|
||||||
"tokio multi 1-thread": {
|
"tokio multi 1-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 14432,
|
"median": 14504,
|
||||||
"min": 14219,
|
"min": 14193,
|
||||||
"max": 14763
|
"max": 14562
|
||||||
},
|
},
|
||||||
"tokio multi 2-thread": {
|
"tokio multi 2-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 7333,
|
"median": 7297,
|
||||||
"min": 7222,
|
"min": 7289,
|
||||||
"max": 7477
|
"max": 7398
|
||||||
},
|
},
|
||||||
"tokio multi 4-thread": {
|
"tokio multi 4-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 3741,
|
"median": 3795,
|
||||||
"min": 3681,
|
"min": 3756,
|
||||||
"max": 3876
|
"max": 3799
|
||||||
},
|
},
|
||||||
"tokio multi 24-thread": {
|
"tokio multi 24-thread": {
|
||||||
"result": 33860,
|
"result": 33860,
|
||||||
"median": 1513,
|
"median": 1544,
|
||||||
"min": 1375,
|
"min": 1520,
|
||||||
"max": 1979
|
"max": 1610
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deep_recursion": {
|
"deep_recursion": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 1,
|
"result": 1,
|
||||||
"median": 102,
|
"median": 226,
|
||||||
"min": 96,
|
"min": 222,
|
||||||
"max": 123
|
"max": 243
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 1,
|
"result": 1,
|
||||||
"median": 597,
|
"median": 745,
|
||||||
"min": 576,
|
"min": 744,
|
||||||
"max": 682
|
"max": 776
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 1,
|
"result": 1,
|
||||||
"median": 13,
|
"median": 11,
|
||||||
"min": 11,
|
"min": 10,
|
||||||
"max": 35
|
"max": 13
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 1,
|
"result": 1,
|
||||||
"median": 56,
|
"median": 53,
|
||||||
"min": 46,
|
"min": 53,
|
||||||
"max": 65
|
"max": 57
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"yield_in_hot_loop": {
|
"yield_in_hot_loop": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 1000000,
|
"result": 1000000,
|
||||||
"median": 80680,
|
"median": 64849,
|
||||||
"min": 80308,
|
"min": 64396,
|
||||||
"max": 81845
|
"max": 65283
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 1000000,
|
"result": 1000000,
|
||||||
"median": 72606,
|
"median": 68507,
|
||||||
"min": 72154,
|
"min": 62018,
|
||||||
"max": 77206
|
"max": 72341
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uncontended_channel": {
|
"uncontended_channel": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 1000000,
|
"result": 1000000,
|
||||||
"median": 9257,
|
"median": 11949,
|
||||||
"min": 9223,
|
"min": 11928,
|
||||||
"max": 12049
|
"max": 13596
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 1000000,
|
"result": 1000000,
|
||||||
"median": 16925,
|
"median": 15083,
|
||||||
"min": 16848,
|
"min": 15038,
|
||||||
"max": 17019
|
"max": 16994
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"catch_unwind_panics": {
|
"catch_unwind_panics": {
|
||||||
"smarm 1-thread": {
|
"smarm 1-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 116821,
|
"median": 110932,
|
||||||
"min": 111345,
|
"min": 110182,
|
||||||
"max": 128261
|
"max": 124147
|
||||||
},
|
},
|
||||||
"smarm 24-thread": {
|
"smarm 24-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 117487,
|
"median": 13665,
|
||||||
"min": 107011,
|
"min": 13172,
|
||||||
"max": 129307
|
"max": 13784
|
||||||
},
|
},
|
||||||
"tokio current_thread": {
|
"tokio current_thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 10425,
|
"median": 10431,
|
||||||
"min": 10141,
|
"min": 9346,
|
||||||
"max": 10604
|
"max": 10915
|
||||||
},
|
},
|
||||||
"tokio multi-thread": {
|
"tokio multi-thread": {
|
||||||
"result": 10000,
|
"result": 10000,
|
||||||
"median": 6418,
|
"median": 6171,
|
||||||
"min": 3715,
|
"min": 5626,
|
||||||
"max": 7144
|
"max": 6555
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run-queue shootout driver (ROADMAP_v0.5 phase 4; RFC 005 slot dimension
|
||||||
|
# added for the v0.9 slot shootout).
|
||||||
|
#
|
||||||
|
# Rebuilds the runtime bench once per rq-* feature and runs the raw-structure
|
||||||
|
# microbench once (it covers all structures in a single binary). The RFC 005
|
||||||
|
# wake slot is a runtime Config knob, NOT a feature — each rq_runtime binary
|
||||||
|
# sweeps slot off/on internally (SMARM_BENCH_SLOT, default "0 1"). Results
|
||||||
|
# land in bench_results/ as full logs; the RQCSV lines are aggregated into
|
||||||
|
# bench_results/summary.csv and the RQSLOT counter lines (slot hits /
|
||||||
|
# displacements, slot-on configs only) into bench_results/slot_counters.csv.
|
||||||
|
#
|
||||||
|
# Tune the sweep for the box, e.g. on the 20-core machine:
|
||||||
|
# SMARM_BENCH_THREADS="1 2 4 8 16 20" ./scripts/bench_rq.sh
|
||||||
|
# Slot-only re-run against the frozen rq-mutex substrate:
|
||||||
|
# SMARM_BENCH_SLOT="0 1" cargo bench --bench rq_runtime
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
OUT=bench_results
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
: "${SMARM_BENCH_THREADS:=1 2 4}"
|
||||||
|
: "${SMARM_BENCH_SLOT:=0 1}"
|
||||||
|
export SMARM_BENCH_THREADS SMARM_BENCH_SLOT
|
||||||
|
|
||||||
|
echo "== raw structures (one binary, all variants) =="
|
||||||
|
cargo bench --bench rq_micro 2>&1 | tee "$OUT/micro.txt"
|
||||||
|
|
||||||
|
for v in rq-mutex rq-mpmc rq-striped; do
|
||||||
|
echo "== runtime benches: $v (slot sweep: $SMARM_BENCH_SLOT) =="
|
||||||
|
cargo bench --bench rq_runtime --no-default-features --features "$v" \
|
||||||
|
2>&1 | tee "$OUT/runtime-$v.txt"
|
||||||
|
done
|
||||||
|
|
||||||
|
# runtime rows: kind,variant,slot,bench,threads,work,median_us,ops_per_s
|
||||||
|
# micro rows: kind,structure,threads,p:c,items,median_us,items_per_s (one
|
||||||
|
# column narrower, as before — split on kind when plotting)
|
||||||
|
echo "kind,a,b,c,d,e,median_us,ops_per_s" > "$OUT/summary.csv"
|
||||||
|
grep -h '^RQCSV,' "$OUT"/*.txt | sed 's/^RQCSV,//' >> "$OUT/summary.csv"
|
||||||
|
|
||||||
|
echo "variant,bench,threads,slot_hits,slot_displacements" > "$OUT/slot_counters.csv"
|
||||||
|
grep -h '^RQSLOT,' "$OUT"/*.txt | sed 's/^RQSLOT,//' >> "$OUT/slot_counters.csv" || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Summary: $OUT/summary.csv ($(($(wc -l < "$OUT/summary.csv") - 1)) rows)"
|
||||||
|
echo "Slot counters: $OUT/slot_counters.csv ($(($(wc -l < "$OUT/slot_counters.csv") - 1)) rows)"
|
||||||
+18
-7
@@ -29,6 +29,13 @@ fn available_threads() -> usize {
|
|||||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_sets() -> u32 {
|
||||||
|
std::env::var("SMARM_BENCH_SETS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(5)
|
||||||
|
}
|
||||||
|
|
||||||
fn print_header(title: &str) {
|
fn print_header(title: &str) {
|
||||||
println!("\n{}", "=".repeat(80));
|
println!("\n{}", "=".repeat(80));
|
||||||
println!(" {title}");
|
println!(" {title}");
|
||||||
@@ -41,14 +48,17 @@ fn print_header(title: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
|
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
|
||||||
let mut times = Vec::new();
|
let sets = env_sets();
|
||||||
|
let mut times = Vec::with_capacity((n * sets) as usize);
|
||||||
let mut last = 0u64;
|
let mut last = 0u64;
|
||||||
// One warmup iteration, discarded.
|
// One warmup before all sets, discarded.
|
||||||
let _ = f();
|
let _ = f();
|
||||||
for _ in 0..n {
|
for _ in 0..sets {
|
||||||
let (v, t) = f();
|
for _ in 0..n {
|
||||||
times.push(t);
|
let (v, t) = f();
|
||||||
last = v;
|
times.push(t);
|
||||||
|
last = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
times.sort_unstable();
|
times.sort_unstable();
|
||||||
let median = times[times.len() / 2];
|
let median = times[times.len() / 2];
|
||||||
@@ -406,7 +416,8 @@ fn main() {
|
|||||||
let n = available_threads();
|
let n = available_threads();
|
||||||
println!("smarm general benchmarks");
|
println!("smarm general benchmarks");
|
||||||
println!("available parallelism: {n} threads");
|
println!("available parallelism: {n} threads");
|
||||||
println!("ITERS={ITERS} (+1 warmup, discarded)");
|
let sets = env_sets();
|
||||||
|
println!("ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)", ITERS * sets);
|
||||||
println!(
|
println!(
|
||||||
"CHAIN_DEPTH={CHAIN_DEPTH}, YIELD_TASKS={YIELD_TASKS}×{YIELD_ROUNDS}, \
|
"CHAIN_DEPTH={CHAIN_DEPTH}, YIELD_TASKS={YIELD_TASKS}×{YIELD_ROUNDS}, \
|
||||||
PRIME_N={PRIME_N}/{PRIME_WORKERS} workers, PP_ROUNDS={PP_ROUNDS}"
|
PRIME_N={PRIME_N}/{PRIME_WORKERS} workers, PP_ROUNDS={PP_ROUNDS}"
|
||||||
|
|||||||
+106
-42
@@ -1,25 +1,39 @@
|
|||||||
//! Runtime-level run-queue benches (ROADMAP_v0.5 phase 4).
|
//! Runtime-level run-queue benches (ROADMAP_v0.5 phase 4; slot dimension
|
||||||
|
//! added for the v0.9 slot shootout, RFC 005).
|
||||||
//!
|
//!
|
||||||
//! These exercise the WHOLE scheduler with the compile-time-selected queue,
|
//! These exercise the WHOLE scheduler with the compile-time-selected queue,
|
||||||
//! so comparing variants means rebuilding per rq-* feature — that's what
|
//! so comparing variants means rebuilding per rq-* feature — that's what
|
||||||
//! scripts/bench_rq.sh does. Workloads:
|
//! scripts/bench_rq.sh does. The RFC 005 wake slot is a *runtime* Config
|
||||||
|
//! knob, so one binary benches both arms; the slot on/off sweep happens
|
||||||
|
//! inside this binary. Workloads:
|
||||||
//!
|
//!
|
||||||
//! yield-storm — N actors yield K times each. Pure queue churn:
|
//! yield-storm — N actors yield K times each. Pure queue churn:
|
||||||
//! every yield is a push + pop with nothing in between.
|
//! every yield is a push + pop with nothing in between.
|
||||||
|
//! Slot role: REGRESSION GUARD — yields never touch the
|
||||||
|
//! slot, any slot-on delta is pop-path overhead.
|
||||||
//! ping-pong-pairs — P channel pairs, M roundtrips each. Park/unpark
|
//! ping-pong-pairs — P channel pairs, M roundtrips each. Park/unpark
|
||||||
//! latency through the queue.
|
//! latency through the queue.
|
||||||
|
//! Slot role: TARGET METRIC — every send-wake is an
|
||||||
|
//! actor-context unpark, the slot's home pattern.
|
||||||
//! spawn-storm — S spawn+join of trivial actors. Slab + queue + free
|
//! spawn-storm — S spawn+join of trivial actors. Slab + queue + free
|
||||||
//! list under churn.
|
//! list under churn.
|
||||||
|
//! Slot role: NEUTRALITY CHECK — spawns bypass the slot
|
||||||
|
//! by policy; join wakes fire from finalize (scheduler
|
||||||
|
//! context), also shared.
|
||||||
//!
|
//!
|
||||||
//! Knobs (env):
|
//! Knobs (env):
|
||||||
//! SMARM_BENCH_THREADS scheduler-count sweep, default "1 2 4"
|
//! SMARM_BENCH_THREADS scheduler-count sweep, default "1 2 4"
|
||||||
|
//! SMARM_BENCH_SLOT wake-slot sweep, default "0 1" (off then on)
|
||||||
//! SMARM_BENCH_RUNS repetitions per config (median), default 5
|
//! SMARM_BENCH_RUNS repetitions per config (median), default 5
|
||||||
//! SMARM_BENCH_YIELD_ACTORS / _YIELDS default 200 / 500
|
//! SMARM_BENCH_YIELD_ACTORS / _YIELDS default 200 / 500
|
||||||
//! SMARM_BENCH_PAIRS / _ROUNDTRIPS default 32 / 1000
|
//! SMARM_BENCH_PAIRS / _ROUNDTRIPS default 32 / 1000
|
||||||
//! SMARM_BENCH_SPAWNS default 5000
|
//! SMARM_BENCH_SPAWNS default 5000
|
||||||
//!
|
//!
|
||||||
//! Output: house table + one line per config:
|
//! Output: house table + one line per config:
|
||||||
//! RQCSV,runtime,<variant>,<bench>,<threads>,<work>,<median_us>,<ops_per_s>
|
//! RQCSV,runtime,<variant>,<slot>,<bench>,<threads>,<work>,<median_us>,<ops_per_s>
|
||||||
|
//! plus, for slot-on configs, the RFC 005 observability counters:
|
||||||
|
//! RQSLOT,<variant>,<bench>,<threads>,<slot_hits>,<slot_displacements>
|
||||||
|
//! (hits/displacements are taken from the same run as the median time).
|
||||||
//!
|
//!
|
||||||
//! NOTE: a 1-core sandbox validates the harness, not the scaling story;
|
//! NOTE: a 1-core sandbox validates the harness, not the scaling story;
|
||||||
//! real curves come from the many-core box.
|
//! real curves come from the many-core box.
|
||||||
@@ -49,9 +63,30 @@ fn env_threads() -> Vec<usize> {
|
|||||||
.unwrap_or_else(|_| vec![1, 2, 4])
|
.unwrap_or_else(|_| vec![1, 2, 4])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// (total_ops, elapsed_µs) for one measured run.
|
fn env_slots() -> Vec<bool> {
|
||||||
fn yield_storm(threads: usize, actors: usize, yields: usize) -> (u64, u128) {
|
std::env::var("SMARM_BENCH_SLOT")
|
||||||
let rt = init(Config::exact(threads));
|
.map(|v| {
|
||||||
|
v.split_whitespace()
|
||||||
|
.filter_map(|t| match t {
|
||||||
|
"0" | "off" | "false" => Some(false),
|
||||||
|
"1" | "on" | "true" => Some(true),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| vec![false, true])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One measured run: (total_ops, elapsed_µs, slot_hits, slot_displacements).
|
||||||
|
struct Sample {
|
||||||
|
ops: u64,
|
||||||
|
us: u128,
|
||||||
|
hits: u64,
|
||||||
|
displacements: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yield_storm(threads: usize, slot: bool, actors: usize, yields: usize) -> Sample {
|
||||||
|
let rt = init(Config::exact(threads).wake_slot(slot));
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
rt.run(move || {
|
rt.run(move || {
|
||||||
let handles: Vec<_> = (0..actors)
|
let handles: Vec<_> = (0..actors)
|
||||||
@@ -67,11 +102,18 @@ fn yield_storm(threads: usize, actors: usize, yields: usize) -> (u64, u128) {
|
|||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
((actors * yields) as u64, start.elapsed().as_micros())
|
let us = start.elapsed().as_micros();
|
||||||
|
let stats = rt.stats();
|
||||||
|
Sample {
|
||||||
|
ops: (actors * yields) as u64,
|
||||||
|
us,
|
||||||
|
hits: stats.slot_hits(),
|
||||||
|
displacements: stats.slot_displacements(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ping_pong_pairs(threads: usize, pairs: usize, roundtrips: usize) -> (u64, u128) {
|
fn ping_pong_pairs(threads: usize, slot: bool, pairs: usize, roundtrips: usize) -> Sample {
|
||||||
let rt = init(Config::exact(threads));
|
let rt = init(Config::exact(threads).wake_slot(slot));
|
||||||
let total = Arc::new(AtomicU64::new(0));
|
let total = Arc::new(AtomicU64::new(0));
|
||||||
let t2 = total.clone();
|
let t2 = total.clone();
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -103,11 +145,18 @@ fn ping_pong_pairs(threads: usize, pairs: usize, roundtrips: usize) -> (u64, u12
|
|||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
|
let us = start.elapsed().as_micros();
|
||||||
|
let stats = rt.stats();
|
||||||
|
Sample {
|
||||||
|
ops: total.load(Ordering::Relaxed),
|
||||||
|
us,
|
||||||
|
hits: stats.slot_hits(),
|
||||||
|
displacements: stats.slot_displacements(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_storm(threads: usize, spawns: usize) -> (u64, u128) {
|
fn spawn_storm(threads: usize, slot: bool, spawns: usize) -> Sample {
|
||||||
let rt = init(Config::exact(threads));
|
let rt = init(Config::exact(threads).wake_slot(slot));
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
rt.run(move || {
|
rt.run(move || {
|
||||||
// Batches bound simultaneous liveness well below the slab cap.
|
// Batches bound simultaneous liveness well below the slab cap.
|
||||||
@@ -122,11 +171,19 @@ fn spawn_storm(threads: usize, spawns: usize) -> (u64, u128) {
|
|||||||
left -= n;
|
left -= n;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(spawns as u64, start.elapsed().as_micros())
|
let us = start.elapsed().as_micros();
|
||||||
|
let stats = rt.stats();
|
||||||
|
Sample {
|
||||||
|
ops: spawns as u64,
|
||||||
|
us,
|
||||||
|
hits: stats.slot_hits(),
|
||||||
|
displacements: stats.slot_displacements(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let threads_sweep = env_threads();
|
let threads_sweep = env_threads();
|
||||||
|
let slot_sweep = env_slots();
|
||||||
let runs = env_usize("SMARM_BENCH_RUNS", 5);
|
let runs = env_usize("SMARM_BENCH_RUNS", 5);
|
||||||
let ya = env_usize("SMARM_BENCH_YIELD_ACTORS", 200);
|
let ya = env_usize("SMARM_BENCH_YIELD_ACTORS", 200);
|
||||||
let yy = env_usize("SMARM_BENCH_YIELDS", 500);
|
let yy = env_usize("SMARM_BENCH_YIELDS", 500);
|
||||||
@@ -134,55 +191,62 @@ fn main() {
|
|||||||
let pr = env_usize("SMARM_BENCH_ROUNDTRIPS", 1000);
|
let pr = env_usize("SMARM_BENCH_ROUNDTRIPS", 1000);
|
||||||
let ss = env_usize("SMARM_BENCH_SPAWNS", 5000);
|
let ss = env_usize("SMARM_BENCH_SPAWNS", 5000);
|
||||||
|
|
||||||
println!("\n{}", "=".repeat(86));
|
println!("\n{}", "=".repeat(106));
|
||||||
println!(" runtime benches — variant={}, runs={runs} (median)", variant());
|
|
||||||
println!("{}", "=".repeat(86));
|
|
||||||
println!(
|
println!(
|
||||||
"{:>16} | {:>7} | {:>16} | {:>10} | {:>14}",
|
" runtime benches — variant={}, runs={runs} (median)",
|
||||||
"bench", "threads", "work", "median µs", "ops/s"
|
variant()
|
||||||
);
|
);
|
||||||
println!("{}", "-".repeat(86));
|
println!("{}", "=".repeat(106));
|
||||||
|
println!(
|
||||||
|
"{:>16} | {:>7} | {:>4} | {:>16} | {:>10} | {:>14} | {:>10} | {:>9}",
|
||||||
|
"bench", "threads", "slot", "work", "median µs", "ops/s", "slot hits", "displaced"
|
||||||
|
);
|
||||||
|
println!("{}", "-".repeat(106));
|
||||||
|
|
||||||
type Bench = (&'static str, String, Box<dyn Fn(usize) -> (u64, u128)>);
|
type Bench = (&'static str, String, Box<dyn Fn(usize, bool) -> Sample>);
|
||||||
let benches: Vec<Bench> = vec![
|
let benches: Vec<Bench> = vec![
|
||||||
(
|
(
|
||||||
"yield-storm",
|
"yield-storm",
|
||||||
format!("{ya}x{yy}"),
|
format!("{ya}x{yy}"),
|
||||||
Box::new(move |t| yield_storm(t, ya, yy)),
|
Box::new(move |t, s| yield_storm(t, s, ya, yy)),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"ping-pong-pairs",
|
"ping-pong-pairs",
|
||||||
format!("{pp}x{pr}"),
|
format!("{pp}x{pr}"),
|
||||||
Box::new(move |t| ping_pong_pairs(t, pp, pr)),
|
Box::new(move |t, s| ping_pong_pairs(t, s, pp, pr)),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"spawn-storm",
|
"spawn-storm",
|
||||||
format!("{ss}"),
|
format!("{ss}"),
|
||||||
Box::new(move |t| spawn_storm(t, ss)),
|
Box::new(move |t, s| spawn_storm(t, s, ss)),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, work, f) in &benches {
|
for (name, work, f) in &benches {
|
||||||
for &t in &threads_sweep {
|
for &t in &threads_sweep {
|
||||||
let mut ops = 0u64;
|
for &slot in &slot_sweep {
|
||||||
let mut times: Vec<u128> = (0..runs)
|
let mut samples: Vec<Sample> = (0..runs).map(|_| f(t, slot)).collect();
|
||||||
.map(|_| {
|
// Median by elapsed time; report the counters from that
|
||||||
let (o, us) = f(t);
|
// same run so hits/time stay paired.
|
||||||
ops = o;
|
samples.sort_unstable_by_key(|s| s.us);
|
||||||
us
|
let mid = &samples[samples.len() / 2];
|
||||||
})
|
let per_s = (mid.ops as f64 / (mid.us as f64 / 1e6)) as u64;
|
||||||
.collect();
|
let slot_str = if slot { "on" } else { "off" };
|
||||||
times.sort_unstable();
|
println!(
|
||||||
let median = times[times.len() / 2];
|
"{:>16} | {:>7} | {:>4} | {:>16} | {:>10} | {:>14} | {:>10} | {:>9}",
|
||||||
let per_s = (ops as f64 / (median as f64 / 1e6)) as u64;
|
name, t, slot_str, work, mid.us, per_s, mid.hits, mid.displacements
|
||||||
println!(
|
);
|
||||||
"{:>16} | {:>7} | {:>16} | {:>10} | {:>14}",
|
println!(
|
||||||
name, t, work, median, per_s
|
"RQCSV,runtime,{},{},{},{},{},{},{}",
|
||||||
);
|
variant(), slot_str, name, t, work, mid.us, per_s
|
||||||
println!(
|
);
|
||||||
"RQCSV,runtime,{},{},{},{},{},{}",
|
if slot {
|
||||||
variant(), name, t, work, median, per_s
|
println!(
|
||||||
);
|
"RQSLOT,{},{},{},{},{}",
|
||||||
|
variant(), name, t, mid.hits, mid.displacements
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ fn available_threads() -> usize {
|
|||||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_sets() -> u32 {
|
||||||
|
std::env::var("SMARM_BENCH_SETS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(5)
|
||||||
|
}
|
||||||
|
|
||||||
fn print_header(title: &str) {
|
fn print_header(title: &str) {
|
||||||
println!("\n{}", "=".repeat(80));
|
println!("\n{}", "=".repeat(80));
|
||||||
println!(" {title}");
|
println!(" {title}");
|
||||||
@@ -52,13 +59,17 @@ fn print_header(title: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
|
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
|
||||||
let mut times = Vec::new();
|
let sets = env_sets();
|
||||||
|
let mut times = Vec::with_capacity((n * sets) as usize);
|
||||||
let mut last = 0u64;
|
let mut last = 0u64;
|
||||||
let _ = f(); // warmup
|
// One warmup before all sets, discarded.
|
||||||
for _ in 0..n {
|
let _ = f();
|
||||||
let (v, t) = f();
|
for _ in 0..sets {
|
||||||
times.push(t);
|
for _ in 0..n {
|
||||||
last = v;
|
let (v, t) = f();
|
||||||
|
times.push(t);
|
||||||
|
last = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
times.sort_unstable();
|
times.sort_unstable();
|
||||||
let median = times[times.len() / 2];
|
let median = times[times.len() / 2];
|
||||||
@@ -385,7 +396,8 @@ fn main() {
|
|||||||
let n = available_threads();
|
let n = available_threads();
|
||||||
println!("smarm smarm-favored benchmarks");
|
println!("smarm smarm-favored benchmarks");
|
||||||
println!("available parallelism: {n} threads");
|
println!("available parallelism: {n} threads");
|
||||||
println!("ITERS={ITERS} (+1 warmup, discarded)");
|
let sets = env_sets();
|
||||||
|
println!("ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)", ITERS * sets);
|
||||||
println!(
|
println!(
|
||||||
"RECURSE_DEPTH={RECURSE_DEPTH}, HOT_YIELDS={HOT_YIELDS}×2, \
|
"RECURSE_DEPTH={RECURSE_DEPTH}, HOT_YIELDS={HOT_YIELDS}×2, \
|
||||||
UNCONT_MSGS={UNCONT_MSGS}, PANIC_TASKS={PANIC_TASKS}"
|
UNCONT_MSGS={UNCONT_MSGS}, PANIC_TASKS={PANIC_TASKS}"
|
||||||
|
|||||||
+137
-5
@@ -50,6 +50,11 @@ SWEEP_GRID = [
|
|||||||
(128, 1_200_000),
|
(128, 1_200_000),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Number of independent cargo bench processes per measurement point.
|
||||||
|
# Each process is a fully isolated run (fresh warmup, cold caches, new PID),
|
||||||
|
# so the final median is a median of independent samples — robust to OS noise.
|
||||||
|
BENCH_SETS = 5
|
||||||
|
|
||||||
# Regression threshold: warn if median is more than this % worse than baseline.
|
# Regression threshold: warn if median is more than this % worse than baseline.
|
||||||
REGRESSION_THRESHOLD_PCT = 10
|
REGRESSION_THRESHOLD_PCT = 10
|
||||||
|
|
||||||
@@ -103,9 +108,12 @@ def parse_output(text: str) -> dict[str, dict[str, dict]]:
|
|||||||
# Running
|
# Running
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def run_benches(env_extra: dict[str, str] | None = None) -> dict[str, dict[str, dict]]:
|
def run_benches_once(env_extra: dict[str, str] | None = None) -> dict[str, dict[str, dict]]:
|
||||||
"""Run all BENCHES and return merged parsed results."""
|
"""Run all BENCHES once and return merged parsed results."""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
# Each process does exactly one set of ITERS samples — no within-process
|
||||||
|
# accumulation; the caller handles multi-set aggregation.
|
||||||
|
env["SMARM_BENCH_SETS"] = "1"
|
||||||
if env_extra:
|
if env_extra:
|
||||||
env.update(env_extra)
|
env.update(env_extra)
|
||||||
|
|
||||||
@@ -129,6 +137,45 @@ def run_benches(env_extra: dict[str, str] | None = None) -> dict[str, dict[str,
|
|||||||
return all_results
|
return all_results
|
||||||
|
|
||||||
|
|
||||||
|
def run_benches(env_extra: dict[str, str] | None = None, sets: int = BENCH_SETS) -> dict[str, dict[str, dict]]:
|
||||||
|
"""Run BENCH_SETS independent processes and return median-of-medians per label.
|
||||||
|
|
||||||
|
Each set is a separate cargo bench invocation with its own warmup and OS
|
||||||
|
context, so samples are statistically independent. The final median and
|
||||||
|
min/max are computed over the per-set medians.
|
||||||
|
"""
|
||||||
|
# Accumulate per-set medians: {bench: {label: [median_set1, median_set2, ...]}}
|
||||||
|
accumulated: dict[str, dict[str, list[int]]] = {}
|
||||||
|
last_result: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
|
for i in range(sets):
|
||||||
|
print(f" set {i + 1}/{sets}…", flush=True)
|
||||||
|
set_results = run_benches_once(env_extra)
|
||||||
|
for bench, labels in set_results.items():
|
||||||
|
accumulated.setdefault(bench, {})
|
||||||
|
last_result.setdefault(bench, {})
|
||||||
|
for label, data in labels.items():
|
||||||
|
accumulated[bench].setdefault(label, [])
|
||||||
|
accumulated[bench][label].append(data["median"])
|
||||||
|
last_result[bench][label] = data["result"]
|
||||||
|
|
||||||
|
# Collapse to final stats.
|
||||||
|
final: dict[str, dict[str, dict]] = {}
|
||||||
|
for bench, labels in accumulated.items():
|
||||||
|
final[bench] = {}
|
||||||
|
for label, medians in labels.items():
|
||||||
|
medians.sort()
|
||||||
|
mid = medians[len(medians) // 2]
|
||||||
|
final[bench][label] = {
|
||||||
|
"result": last_result[bench][label],
|
||||||
|
"median": mid,
|
||||||
|
"min": medians[0],
|
||||||
|
"max": medians[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Baseline JSON
|
# Baseline JSON
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -196,6 +243,90 @@ def check_regressions(current: dict, baseline: dict) -> bool:
|
|||||||
# Pretty print
|
# Pretty print
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _threads(label: str) -> int | None:
|
||||||
|
"""Worker-thread count implied by a runtime label.
|
||||||
|
|
||||||
|
tokio's `current_thread` is a single-threaded executor (1); an explicit
|
||||||
|
`multi N-thread` is N; a bare `multi-thread` has no count (None) — tokio's
|
||||||
|
default work-stealing pool, paired against smarm's widest config.
|
||||||
|
"""
|
||||||
|
if "current_thread" in label:
|
||||||
|
return 1
|
||||||
|
m = re.search(r"(\d+)-thread", label)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def vs_tokio(results: dict) -> list[tuple]:
|
||||||
|
"""Per bench, like-for-like smarm-vs-tokio rows matched by thread count.
|
||||||
|
|
||||||
|
Exact thread-count matches are paired directly (smarm 1-thread vs tokio
|
||||||
|
current_thread, smarm 4-thread vs tokio multi 4-thread, …). tokio's bare
|
||||||
|
`multi-thread` (no explicit count) is paired against the widest unmatched
|
||||||
|
smarm multi-thread config. Lower median µs = faster; ratio = tokio_med /
|
||||||
|
smarm_med, so ratio > 1 means smarm is that many times faster.
|
||||||
|
|
||||||
|
Returns rows of (bench, smarm_label, smarm_med, tokio_label, tokio_med,
|
||||||
|
ratio, winner). Benches without a comparable pair are skipped.
|
||||||
|
"""
|
||||||
|
rows: list[tuple] = []
|
||||||
|
for bench, runtimes in sorted(results.items()):
|
||||||
|
smarm: dict[int, tuple[str, int]] = {}
|
||||||
|
tokio: dict[int, tuple[str, int]] = {}
|
||||||
|
tokio_default: tuple[str, int] | None = None # bare 'multi-thread'
|
||||||
|
for label, data in runtimes.items():
|
||||||
|
n = _threads(label)
|
||||||
|
if label.startswith("smarm"):
|
||||||
|
if n is not None:
|
||||||
|
smarm[n] = (label, data["median"])
|
||||||
|
elif label.startswith("tokio"):
|
||||||
|
if n is None:
|
||||||
|
tokio_default = (label, data["median"])
|
||||||
|
else:
|
||||||
|
tokio[n] = (label, data["median"])
|
||||||
|
|
||||||
|
def row(s: tuple[str, int], t: tuple[str, int]):
|
||||||
|
s_label, s_med = s
|
||||||
|
t_label, t_med = t
|
||||||
|
if s_med == 0:
|
||||||
|
return None
|
||||||
|
ratio = t_med / s_med
|
||||||
|
return (bench, s_label, s_med, t_label, t_med, ratio,
|
||||||
|
"smarm" if ratio >= 1.0 else "tokio")
|
||||||
|
|
||||||
|
matched: set[int] = set()
|
||||||
|
for n in sorted(set(smarm) & set(tokio)):
|
||||||
|
r = row(smarm[n], tokio[n])
|
||||||
|
if r:
|
||||||
|
rows.append(r)
|
||||||
|
matched.add(n)
|
||||||
|
# tokio's default multi pool vs the widest smarm config not already paired.
|
||||||
|
if tokio_default is not None:
|
||||||
|
rem = [n for n in smarm if n not in matched and n > 1]
|
||||||
|
if rem:
|
||||||
|
r = row(smarm[max(rem)], tokio_default)
|
||||||
|
if r:
|
||||||
|
rows.append(r)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def print_vs_tokio(results: dict) -> None:
|
||||||
|
"""Human summary + greppable VSTOKIO lines (best smarm vs best tokio)."""
|
||||||
|
rows = vs_tokio(results)
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
print("\n vs tokio (like-for-like by thread count; ratio>1 = smarm faster, lower µs better)")
|
||||||
|
print(f" {'-'*78}")
|
||||||
|
for bench, s_label, s_med, t_label, t_med, ratio, winner in rows:
|
||||||
|
print(
|
||||||
|
f" {bench:<22} {s_label} {s_med}µs vs {t_label} {t_med}µs"
|
||||||
|
f" → {ratio:.2f}x ({winner})"
|
||||||
|
)
|
||||||
|
# Machine-readable, one line per bench:
|
||||||
|
# VSTOKIO,<bench>,<smarm_label>,<smarm_us>,<tokio_label>,<tokio_us>,<ratio>,<winner>
|
||||||
|
for bench, s_label, s_med, t_label, t_med, ratio, winner in rows:
|
||||||
|
print(f"VSTOKIO,{bench},{s_label},{s_med},{t_label},{t_med},{ratio:.3f},{winner}")
|
||||||
|
|
||||||
|
|
||||||
def print_results(results: dict, label: str = "") -> None:
|
def print_results(results: dict, label: str = "") -> None:
|
||||||
if label:
|
if label:
|
||||||
print(f"\n{'='*70}")
|
print(f"\n{'='*70}")
|
||||||
@@ -210,6 +341,7 @@ def print_results(results: dict, label: str = "") -> None:
|
|||||||
f" {rt_label:>28} | {data['result']:>10} | "
|
f" {rt_label:>28} | {data['result']:>10} | "
|
||||||
f"{data['median']:>10} | {data['min']:>8} | {data['max']:>8}"
|
f"{data['median']:>10} | {data['min']:>8} | {data['max']:>8}"
|
||||||
)
|
)
|
||||||
|
print_vs_tokio(results)
|
||||||
|
|
||||||
|
|
||||||
def print_sweep_table(sweep_results: list[tuple[int, int, dict]]) -> None:
|
def print_sweep_table(sweep_results: list[tuple[int, int, dict]]) -> None:
|
||||||
@@ -252,7 +384,7 @@ def cmd_run(args) -> None:
|
|||||||
["cargo", "build", "--release", "--benches"],
|
["cargo", "build", "--release", "--benches"],
|
||||||
cwd=REPO, check=True, capture_output=True,
|
cwd=REPO, check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
print("Running benches…")
|
print(f"Running benches ({BENCH_SETS} independent sets)…")
|
||||||
results = run_benches()
|
results = run_benches()
|
||||||
print_results(results, "Results (default knobs)")
|
print_results(results, "Results (default knobs)")
|
||||||
if args.save_baseline:
|
if args.save_baseline:
|
||||||
@@ -266,7 +398,7 @@ def cmd_regress(args) -> None:
|
|||||||
["cargo", "build", "--release", "--benches"],
|
["cargo", "build", "--release", "--benches"],
|
||||||
cwd=REPO, check=True, capture_output=True,
|
cwd=REPO, check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
print("Running benches…")
|
print(f"Running benches ({BENCH_SETS} independent sets)…")
|
||||||
current = run_benches()
|
current = run_benches()
|
||||||
print_results(current, "Current results")
|
print_results(current, "Current results")
|
||||||
print(f"\nRegression check (threshold: >{REGRESSION_THRESHOLD_PCT}% slower than baseline)")
|
print(f"\nRegression check (threshold: >{REGRESSION_THRESHOLD_PCT}% slower than baseline)")
|
||||||
@@ -288,7 +420,7 @@ def cmd_sweep(args) -> None:
|
|||||||
|
|
||||||
for interval, cycles in SWEEP_GRID:
|
for interval, cycles in SWEEP_GRID:
|
||||||
tag = f"alloc_interval={interval}, timeslice_cycles={cycles}"
|
tag = f"alloc_interval={interval}, timeslice_cycles={cycles}"
|
||||||
print(f" Running: {tag} …", flush=True)
|
print(f" Running: {tag} ({BENCH_SETS} sets)…", flush=True)
|
||||||
env_extra = {
|
env_extra = {
|
||||||
"SMARM_ALLOC_INTERVAL": str(interval),
|
"SMARM_ALLOC_INTERVAL": str(interval),
|
||||||
"SMARM_TIMESLICE_CYCLES": str(cycles),
|
"SMARM_TIMESLICE_CYCLES": str(cycles),
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//! Per-switch (context-switch) cost microbench — the profiling-spike harness
|
||||||
|
//! for the ROADMAP "Per-switch cost (context shims, epoch protocol)" item.
|
||||||
|
//!
|
||||||
|
//! The spin work (RFC 004) is closed; the next perf target is the per-switch
|
||||||
|
//! cost itself. Shootout evidence: per-wake latency is ~0.16–0.18µs at N=1 but
|
||||||
|
//! ~0.8–1.2µs at N=8+, and the residual is attributed to the context-switch
|
||||||
|
//! shims (`src/context.rs`) and the epoch protocol — NOT the queue. This binary
|
||||||
|
//! isolates that round-trip so the cycles can be attributed under `perf` and an
|
||||||
|
//! rdtsc bracket, feeding the RFC.
|
||||||
|
//!
|
||||||
|
//! WHAT THE ROUND-TRIP IS
|
||||||
|
//!
|
||||||
|
//! `yield_now()` from inside an actor does exactly one park/unpark round-trip
|
||||||
|
//! with nothing else attached:
|
||||||
|
//!
|
||||||
|
//! actor: switch_to_scheduler ──► scheduler re-queues the actor (slot-word
|
||||||
|
//! (context.rs shim) epoch/state transition, run_queue push),
|
||||||
|
//! pops it straight back, switch_to_actor
|
||||||
|
//! actor resumes ◄──────────────────────────────────────────────────────
|
||||||
|
//!
|
||||||
|
//! No IO thread traffic, no channel, no timer, no cross-thread wake. On a
|
||||||
|
//! single-scheduler runtime the re-queue+repop never leaves this core, so the
|
||||||
|
//! sample is the *pure* shim + epoch + queue-op cost with zero coherency
|
||||||
|
//! traffic. That is the `local` baseline; a `remote` mode (wake straddling two
|
||||||
|
//! schedulers, to expose the N=1→N=8 coherency/TLS-mode jump) is a deliberate
|
||||||
|
//! follow-up and is NOT in this file yet — local first, per the spike plan.
|
||||||
|
//!
|
||||||
|
//! TWO LENSES ON THE SAME LOOP
|
||||||
|
//!
|
||||||
|
//! wall — `Instant` bracket per round-trip. Source of truth for µs, directly
|
||||||
|
//! comparable to the shootout's per-wake latency numbers.
|
||||||
|
//! cycles — `rdtsc` bracket per round-trip. Source of truth for the cycle
|
||||||
|
//! budget the RFC will reason in (the spin budget is in cycles too).
|
||||||
|
//!
|
||||||
|
//! Reporting both lets us *derive* the effective TSC frequency (cycles/ns) from
|
||||||
|
//! the same samples instead of hardcoding a nominal 3.7GHz — the spin_sweep
|
||||||
|
//! lesson was that nominal-vs-actual TSC drift is exactly what produces
|
||||||
|
//! red-herring numbers. If the derived freq matches the box's known base clock,
|
||||||
|
//! the two lenses corroborate; if not, that mismatch is itself a finding.
|
||||||
|
//!
|
||||||
|
//! Knobs (env):
|
||||||
|
//! SMARM_SWITCH_ROUNDS round-trips timed per run default 200000
|
||||||
|
//! SMARM_SWITCH_WARMUP untimed warmup round-trips default 10000
|
||||||
|
//! SMARM_SWITCH_RUNS runs (pooled latency, median) default 5
|
||||||
|
//!
|
||||||
|
//! Output: house table + one greppable line per run-set:
|
||||||
|
//! SWITCHCSV,<variant>,<mode>,<rounds>,<runs>,<n>,<p50_ns>,<p90_ns>,<p99_ns>,
|
||||||
|
//! <min_ns>,<max_ns>,<mean_ns>,<mean_cyc>,<derived_ghz>
|
||||||
|
//!
|
||||||
|
//! NOTE: a single yielding actor is the cooperative-scheduling tightest loop —
|
||||||
|
//! it never parks on a futex (the work is always immediately re-queued), so
|
||||||
|
//! this measures the switch+epoch+queue path, NOT the futex park. That is
|
||||||
|
//! intentional: the futex park is the spin work's territory (RFC 004), already
|
||||||
|
//! characterised. The unattributed constant the shootout flagged lives in the
|
||||||
|
//! switch itself, which is what this loop hammers.
|
||||||
|
|
||||||
|
use smarm::runtime::{init, Config};
|
||||||
|
use smarm::{run, spawn, yield_now};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// env helpers (house style, matching spin_sweep.rs / rq_runtime.rs)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn variant() -> &'static str {
|
||||||
|
if cfg!(feature = "rq-mpmc") {
|
||||||
|
"rq-mpmc"
|
||||||
|
} else if cfg!(feature = "rq-striped") {
|
||||||
|
"rq-striped"
|
||||||
|
} else {
|
||||||
|
"rq-mutex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_usize(key: &str, default: usize) -> usize {
|
||||||
|
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// rdtsc — serialised so the bracket actually fences the round-trip.
|
||||||
|
//
|
||||||
|
// Plain `rdtsc` can be reordered around the work by an out-of-order core, which
|
||||||
|
// would smear the bracket. `rdtscp` retires prior instructions before reading
|
||||||
|
// the counter, and the trailing `lfence` blocks later instructions from
|
||||||
|
// climbing above the second read. Pair = (rdtscp; lfence) … work … (rdtscp;
|
||||||
|
// lfence): a standard cycle-accurate bracket. We read TSC_AUX too but ignore
|
||||||
|
// it; the point is the ordering guarantee, not the core id.
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn rdtsc_serialised() -> u64 {
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
unsafe {
|
||||||
|
let mut aux = 0u32;
|
||||||
|
let t = core::arch::x86_64::__rdtscp(&mut aux);
|
||||||
|
core::arch::x86_64::_mm_lfence();
|
||||||
|
t
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "x86_64"))]
|
||||||
|
{
|
||||||
|
// Non-x86 fallback: nanosecond clock standing in for cycles. The derived
|
||||||
|
// "GHz" column then reads ~1.0 and is meaningless, but the wall lens and
|
||||||
|
// the harness still work. The spike target box is x86-64.
|
||||||
|
use std::time::Instant;
|
||||||
|
thread_local! { static T0: Instant = Instant::now(); }
|
||||||
|
T0.with(|t0| t0.elapsed().as_nanos() as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// percentile / median helpers (verbatim house idiom from spin_sweep.rs)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Nearest-rank percentile over an already-sorted slice. `p` in [0, 100].
|
||||||
|
fn pct(sorted: &[u64], p: f64) -> u64 {
|
||||||
|
if sorted.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let idx = ((p / 100.0) * (sorted.len() - 1) as f64).round() as usize;
|
||||||
|
sorted[idx.min(sorted.len() - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// one run: a single actor yields ROUNDS times; we bracket each yield from
|
||||||
|
// inside the actor (the only vantage point — the actor is suspended during the
|
||||||
|
// scheduler half, so an external timer can't see a single round-trip).
|
||||||
|
//
|
||||||
|
// Per iteration we capture BOTH a wall-ns delta and a TSC-cycle delta around
|
||||||
|
// the same `yield_now()`. The loop overhead (two clock reads + a Vec push +
|
||||||
|
// the branch) rides along in every sample equally; we subtract an empty-loop
|
||||||
|
// self-calibration below so the reported number is the round-trip, not the
|
||||||
|
// instrumentation.
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct RunSample {
|
||||||
|
lat_ns: Vec<u64>,
|
||||||
|
cyc: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_run(threads: usize, rounds: usize, warmup: usize) -> RunSample {
|
||||||
|
let out: Arc<Mutex<Option<RunSample>>> = Arc::new(Mutex::new(None));
|
||||||
|
let out2 = out.clone();
|
||||||
|
|
||||||
|
let cfg = Config::exact(threads);
|
||||||
|
init(cfg);
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
// Warmup: let the actor's stack/queue slot go hot, JIT-free but
|
||||||
|
// cache-warm, before any sample is kept.
|
||||||
|
for _ in 0..warmup {
|
||||||
|
yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lat_ns = Vec::with_capacity(rounds);
|
||||||
|
let mut cyc = Vec::with_capacity(rounds);
|
||||||
|
|
||||||
|
for _ in 0..rounds {
|
||||||
|
let w0 = std::time::Instant::now();
|
||||||
|
let c0 = rdtsc_serialised();
|
||||||
|
yield_now();
|
||||||
|
let c1 = rdtsc_serialised();
|
||||||
|
let w1 = w0.elapsed();
|
||||||
|
cyc.push(c1.saturating_sub(c0));
|
||||||
|
lat_ns.push(w1.as_nanos() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
*out2.lock().unwrap() = Some(RunSample { lat_ns, cyc });
|
||||||
|
});
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sample = out.lock().unwrap().take().expect("actor stored a sample");
|
||||||
|
sample
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty-loop self-calibration: the same bracket with the `yield_now()` removed,
|
||||||
|
/// run inline (no runtime). Gives the floor cost of two serialised clock reads +
|
||||||
|
/// the push, in both lenses, to subtract from the round-trip samples.
|
||||||
|
fn calibrate(rounds: usize) -> (u64, u64) {
|
||||||
|
let mut lat_ns = Vec::with_capacity(rounds);
|
||||||
|
let mut cyc = Vec::with_capacity(rounds);
|
||||||
|
let mut sink = 0u64;
|
||||||
|
for _ in 0..rounds {
|
||||||
|
let w0 = std::time::Instant::now();
|
||||||
|
let c0 = rdtsc_serialised();
|
||||||
|
// no yield — measure the bracket itself
|
||||||
|
let c1 = rdtsc_serialised();
|
||||||
|
let w1 = w0.elapsed();
|
||||||
|
sink ^= c1;
|
||||||
|
cyc.push(c1.saturating_sub(c0));
|
||||||
|
lat_ns.push(w1.as_nanos() as u64);
|
||||||
|
}
|
||||||
|
std::hint::black_box(sink);
|
||||||
|
cyc.sort_unstable();
|
||||||
|
lat_ns.sort_unstable();
|
||||||
|
// Use the medians as the floor — robust to the occasional interrupt.
|
||||||
|
(pct(&lat_ns, 50.0), pct(&cyc, 50.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let rounds = env_usize("SMARM_SWITCH_ROUNDS", 200_000);
|
||||||
|
let warmup = env_usize("SMARM_SWITCH_WARMUP", 10_000);
|
||||||
|
let runs = env_usize("SMARM_SWITCH_RUNS", 5);
|
||||||
|
let mode = "local";
|
||||||
|
|
||||||
|
// Calibrate the instrumentation floor once, with a healthy sample.
|
||||||
|
let (floor_ns, floor_cyc) = calibrate(rounds.min(50_000).max(10_000));
|
||||||
|
|
||||||
|
let mut pooled_ns: Vec<u64> = Vec::new();
|
||||||
|
let mut pooled_cyc: Vec<u64> = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..runs {
|
||||||
|
let s = one_run(1, rounds, warmup);
|
||||||
|
// Subtract the instrumentation floor; saturating so a sub-floor outlier
|
||||||
|
// (clock granularity) clamps to 0 rather than wrapping.
|
||||||
|
pooled_ns.extend(s.lat_ns.iter().map(|&v| v.saturating_sub(floor_ns)));
|
||||||
|
pooled_cyc.extend(s.cyc.iter().map(|&v| v.saturating_sub(floor_cyc)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pooled_ns.sort_unstable();
|
||||||
|
pooled_cyc.sort_unstable();
|
||||||
|
|
||||||
|
let n = pooled_ns.len();
|
||||||
|
let mean_ns = pooled_ns.iter().map(|&v| v as f64).sum::<f64>() / n.max(1) as f64;
|
||||||
|
let mean_cyc = pooled_cyc.iter().map(|&v| v as f64).sum::<f64>() / n.max(1) as f64;
|
||||||
|
// Derived effective frequency: cycles per ns = GHz. Cross-checks the two
|
||||||
|
// lenses against the box's known base clock.
|
||||||
|
let derived_ghz = if mean_ns > 0.0 { mean_cyc / mean_ns } else { 0.0 };
|
||||||
|
|
||||||
|
let p50 = pct(&pooled_ns, 50.0);
|
||||||
|
let p90 = pct(&pooled_ns, 90.0);
|
||||||
|
let p99 = pct(&pooled_ns, 99.0);
|
||||||
|
let lo = *pooled_ns.first().unwrap_or(&0);
|
||||||
|
let hi = *pooled_ns.last().unwrap_or(&0);
|
||||||
|
|
||||||
|
// House table.
|
||||||
|
println!();
|
||||||
|
println!("per-switch cost — {} mode, variant={}", mode, variant());
|
||||||
|
println!(
|
||||||
|
" rounds={} warmup={} runs={} (instrumentation floor: {} ns / {} cyc, subtracted)",
|
||||||
|
rounds, warmup, runs, floor_ns, floor_cyc
|
||||||
|
);
|
||||||
|
println!(" {:<10} {:<10} {:<10} {:<10} {:<10}", "p50 ns", "p90 ns", "p99 ns", "min ns", "max ns");
|
||||||
|
println!(" {:<10} {:<10} {:<10} {:<10} {:<10}", p50, p90, p99, lo, hi);
|
||||||
|
println!(
|
||||||
|
" mean {:.1} ns | mean {:.0} cyc | derived {:.3} GHz",
|
||||||
|
mean_ns, mean_cyc, derived_ghz
|
||||||
|
);
|
||||||
|
|
||||||
|
// Greppable line — same spirit as SPINCSV.
|
||||||
|
println!(
|
||||||
|
"SWITCHCSV,{},{},{},{},{},{},{},{},{},{},{:.1},{:.0},{:.3}",
|
||||||
|
variant(), mode, rounds, runs, n, p50, p90, p99, lo, hi, mean_ns, mean_cyc, derived_ghz
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,13 @@ fn available_threads() -> usize {
|
|||||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_sets() -> u32 {
|
||||||
|
std::env::var("SMARM_BENCH_SETS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(5)
|
||||||
|
}
|
||||||
|
|
||||||
fn print_header(title: &str) {
|
fn print_header(title: &str) {
|
||||||
println!("\n{}", "=".repeat(80));
|
println!("\n{}", "=".repeat(80));
|
||||||
println!(" {title}");
|
println!(" {title}");
|
||||||
@@ -51,13 +58,17 @@ fn print_header(title: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
|
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
|
||||||
let mut times = Vec::new();
|
let sets = env_sets();
|
||||||
|
let mut times = Vec::with_capacity((n * sets) as usize);
|
||||||
let mut last = 0u64;
|
let mut last = 0u64;
|
||||||
let _ = f(); // warmup
|
// One warmup before all sets, discarded.
|
||||||
for _ in 0..n {
|
let _ = f();
|
||||||
let (v, t) = f();
|
for _ in 0..sets {
|
||||||
times.push(t);
|
for _ in 0..n {
|
||||||
last = v;
|
let (v, t) = f();
|
||||||
|
times.push(t);
|
||||||
|
last = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
times.sort_unstable();
|
times.sort_unstable();
|
||||||
let median = times[times.len() / 2];
|
let median = times[times.len() / 2];
|
||||||
@@ -434,7 +445,8 @@ fn main() {
|
|||||||
let n = available_threads();
|
let n = available_threads();
|
||||||
println!("smarm tokio-favored benchmarks");
|
println!("smarm tokio-favored benchmarks");
|
||||||
println!("available parallelism: {n} threads");
|
println!("available parallelism: {n} threads");
|
||||||
println!("ITERS={ITERS} (+1 warmup, discarded)");
|
let sets = env_sets();
|
||||||
|
println!("ITERS={ITERS}×{sets} sets = {} samples (+1 warmup, discarded)", ITERS * sets);
|
||||||
println!(
|
println!(
|
||||||
"STORM_BACKGROUND={STORM_BACKGROUND}, STORM_SPAWN={STORM_SPAWN}, \
|
"STORM_BACKGROUND={STORM_BACKGROUND}, STORM_SPAWN={STORM_SPAWN}, \
|
||||||
MPSC={MPSC_PRODUCERS}×{MPSC_PER_PRODUCER}, \
|
MPSC={MPSC_PRODUCERS}×{MPSC_PER_PRODUCER}, \
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Per-switch cost — N=1 local profile (spike findings)
|
||||||
|
|
||||||
|
Measured with `benches/switch_cost.rs` (local mode: one actor, one scheduler,
|
||||||
|
tight `yield_now()` loop = one park/unpark round-trip with no IO/channel/timer
|
||||||
|
and no cross-core traffic). Sandbox: 1 core, kernel 6.18, **no PMU** (hardware
|
||||||
|
counters unavailable), so attribution is from `perf record -e task-clock`
|
||||||
|
(software timer sampling) plus the bench's own rdtsc/wall brackets.
|
||||||
|
|
||||||
|
> **Provenance (post-excision).** This profile was captured against the
|
||||||
|
> spin-enabled build (the pre-excision HEAD, with the RFC 004 spinning workers
|
||||||
|
> live in `src/runtime.rs`). The RFC 004 spinning experiment has since been
|
||||||
|
> excised from `master`; idle schedulers are back to the historical
|
||||||
|
> `thread::sleep` wait. The `futex_wake` attribution below therefore reflects
|
||||||
|
> spinning machinery that is **no longer present on current master** — see the
|
||||||
|
> per-row and per-finding notes. The shim, `schedule_loop`, and run-queue
|
||||||
|
> findings are spin-independent and remain valid.
|
||||||
|
|
||||||
|
## Numbers (stable across runs)
|
||||||
|
|
||||||
|
- Round-trip p50 ≈ **303 ns ≈ 828 cyc** (instrumentation floor subtracted).
|
||||||
|
- Derived effective clock ≈ **2.73 GHz** (rdtsc cyc / wall ns — the two lenses
|
||||||
|
corroborate, so the cycle counts are trustworthy).
|
||||||
|
- p90 316 ns, p99 472 ns; max is a multi-ms OS-deschedule outlier (1 shared
|
||||||
|
core) — ignore the max, trust the percentiles (the harness pools them).
|
||||||
|
|
||||||
|
## Attribution (perf task-clock, self-time, 28.6k samples / 12M round-trips)
|
||||||
|
|
||||||
|
| share | symbol | bucket |
|
||||||
|
|------:|--------|--------|
|
||||||
|
| 24.8% | `runtime::schedule_loop` | scheduler logic (slot-word/epoch + dispatch) |
|
||||||
|
| 8.6% | `MutexQueue::push`/`pop`/`len` | run-queue ops |
|
||||||
|
| ~12% | `do_syscall_64`+`syscall`+`futex_*` | **futex_wake on the hot path** — spinning submit-rule wake; removed by the RFC 004 excision (not on current master) |
|
||||||
|
| 3.5% | `IoThread::drain_completions` | the always-on IO thread (`run()` starts one) |
|
||||||
|
| ~30% | `main` + `clock_gettime`/Timespec + `quicksort` | **instrumentation** (timing + percentile sort) |
|
||||||
|
| ~1% | `switch_to_scheduler`+`switch_to_actor_asm`+ sp accessors | **the context shims + TLS** |
|
||||||
|
|
||||||
|
## Headline finding — revises the handoff hypothesis
|
||||||
|
|
||||||
|
The handoff named the **context shims** (`context.rs`: two `call`s into the
|
||||||
|
TLS sp accessors per switch) as the prime suspect for the per-switch cost.
|
||||||
|
**At N=1 that is not where the time goes — the shims + TLS are ~1% of
|
||||||
|
self-time.** The N=1 cost is dominated by:
|
||||||
|
|
||||||
|
1. **`schedule_loop` + run-queue ops (~33%)** — the epoch/slot-word transition
|
||||||
|
and the mutex run-queue push/pop on every re-queue.
|
||||||
|
2. **A `futex_wake` syscall (~12%)** fired on the hot path even though nothing
|
||||||
|
was parked. This was the spinning **submit-rule wake** introduced by the RFC
|
||||||
|
004 experiment — a parallelism/latency optimisation, not a liveness guard. In
|
||||||
|
a single-scheduler always-runnable loop it was pure cost (no one was ever
|
||||||
|
parked to wake). The RFC 004 excision removed this wake with the rest of the
|
||||||
|
spinning machinery: on current master idle schedulers use `thread::sleep`
|
||||||
|
again, so the N=1 hot path no longer makes this syscall.
|
||||||
|
|
||||||
|
## What this does and does NOT show
|
||||||
|
|
||||||
|
- The handoff's shim hypothesis was a **many-core** hypothesis: its evidence was
|
||||||
|
the N=1→N=8 jump (0.18→1.2µs), attributed to TLS access mode (`__tls_get_addr`
|
||||||
|
vs `#[thread_local]`) and cross-core coherency on the sp/epoch words. **None of
|
||||||
|
that is observable at N=1 on one core.** This profile does NOT refute it; it
|
||||||
|
establishes that the shim is cheap *until cores contend*.
|
||||||
|
- So the spike question sharpens into two separable costs:
|
||||||
|
- **N=1 floor:** scheduler logic (`schedule_loop` + run-queue ops). The
|
||||||
|
futex_wake component was spinning machinery and is gone post-excision, so the
|
||||||
|
remaining N=1 floor is the scheduler core itself.
|
||||||
|
- **N→8 slope:** the shim/TLS/coherency cost. Needs the many-core box + a
|
||||||
|
`remote` bench mode (wake straddling two schedulers) + hardware PMU counters
|
||||||
|
(cache-misses, `MEM_LOAD…HITM` for coherency) — none available in this sandbox.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
cargo build --release --bench switch_cost
|
||||||
|
BIN=$(ls -t target/release/deps/switch_cost-* | grep -v '\.d$' | head -1)
|
||||||
|
PERF=/usr/lib/linux-tools-6.8.0-124/perf # 6.8 perf on 6.18 kernel; sw events only here
|
||||||
|
|
||||||
|
# bench alone (numbers):
|
||||||
|
SMARM_SWITCH_ROUNDS=3000000 SMARM_SWITCH_WARMUP=50000 SMARM_SWITCH_RUNS=4 "$BIN"
|
||||||
|
|
||||||
|
# attribution (sw task-clock; HW counters need a real PMU / the 5900X):
|
||||||
|
SMARM_SWITCH_ROUNDS=3000000 "$PERF" record -F 4000 -g --call-graph fp -o /tmp/switch.data -- "$BIN"
|
||||||
|
"$PERF" report -i /tmp/switch.data --stdio --no-children
|
||||||
|
```
|
||||||
|
|
||||||
|
On the 5900X with a real PMU, drop `-e task-clock` for `-e cycles,instructions,
|
||||||
|
cache-misses,mem_load_retired.l3_miss` to get the coherency picture the N=8 case
|
||||||
|
needs.
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
//! The hand-written **expansion target** of the `gen_statem!` macro: the same
|
||||||
|
//! machine as `examples/gen_statem_macro.rs`, written out in full so the
|
||||||
|
//! primitives can be judged standing on their own. The macro generates exactly
|
||||||
|
//! this shape; nothing here needs the macro to be correct or safe.
|
||||||
|
//!
|
||||||
|
//! The design:
|
||||||
|
//!
|
||||||
|
//! * States and events are real enums. An invalid state is unrepresentable;
|
||||||
|
//! there are no bitflags, no `u32` superpositions, no unsafe unions.
|
||||||
|
//!
|
||||||
|
//! * The dispatch `match (state, event)` IS the transition table. It is
|
||||||
|
//! *total* — no catch-all `_` arm — so:
|
||||||
|
//! - a forgotten (state, event) pair is a non-exhaustive `match` (E0004),
|
||||||
|
//! - a duplicated/conflicting row is `unreachable_patterns` (denied below).
|
||||||
|
//!
|
||||||
|
//! * Single-target rows name their target in the table; the handler (if any)
|
||||||
|
//! is side-effect-only. The table owns the target, so it cannot be wrong.
|
||||||
|
//!
|
||||||
|
//! * Branching rows have the handler return a per-row *successor enum*. A
|
||||||
|
//! target outside that enum is E0599; a missing one is E0004. (See
|
||||||
|
//! `UnlockOutcome` and `on_unlock`.)
|
||||||
|
//!
|
||||||
|
//! * Handlers are module-private. The only way to reach one is through the
|
||||||
|
//! actor's message interface via this table, so a handler that is never
|
||||||
|
//! wired in is dead code — and dead_code is denied below, making an orphan
|
||||||
|
//! handler a compile error.
|
||||||
|
//!
|
||||||
|
//! * Drops onto the `Machine` / `Resolution` / `Cx` primitives with no change
|
||||||
|
//! to `src/gen_statem.rs`.
|
||||||
|
//!
|
||||||
|
//! 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_expanded`
|
||||||
|
|
||||||
|
#![deny(dead_code, unreachable_patterns)]
|
||||||
|
|
||||||
|
use smarm::run;
|
||||||
|
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, Step, GenStatemRef};
|
||||||
|
|
||||||
|
// === user types ============================================================
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Door {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Push,
|
||||||
|
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;
|
||||||
|
|
||||||
|
// === per-row successor enum for the one branching row ======================
|
||||||
|
// `Locked + Unlock` may end in Closed (right key) or Locked (wrong key) — and
|
||||||
|
// nothing else. This type IS that declared set; `on_unlock` cannot name Open.
|
||||||
|
enum UnlockOutcome {
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UnlockOutcome> for Door {
|
||||||
|
fn from(o: UnlockOutcome) -> Door {
|
||||||
|
match o {
|
||||||
|
UnlockOutcome::Closed => Door::Closed,
|
||||||
|
UnlockOutcome::Locked => Door::Locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === module-private handlers (side effects / branch choice only) ===========
|
||||||
|
// Reachable solely through the table below. Orphan one and it is dead_code.
|
||||||
|
|
||||||
|
fn on_push(data: &mut Data) {
|
||||||
|
data.pushes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_unlock(key: u32) -> UnlockOutcome {
|
||||||
|
if key == CODE {
|
||||||
|
UnlockOutcome::Closed
|
||||||
|
} else {
|
||||||
|
UnlockOutcome::Locked // wrong key: caller will see this == current -> stay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === the machine ===========================================================
|
||||||
|
|
||||||
|
struct DoorSm {
|
||||||
|
state: Door,
|
||||||
|
data: Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DoorSm {
|
||||||
|
fn start(init: Door) -> GenStatemRef<DoorSm> {
|
||||||
|
spawn(DoorSm {
|
||||||
|
state: init,
|
||||||
|
data: Data { enters: 0, pushes: 0, knocks: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) -> Step<Ev> {
|
||||||
|
let prev = self.state;
|
||||||
|
|
||||||
|
// ---- 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- state-independent queries (reply, then stay) ---------------
|
||||||
|
(_, Ev::Call(Call::GetState(r))) => {
|
||||||
|
r.reply(prev);
|
||||||
|
Resolution::To(prev)
|
||||||
|
}
|
||||||
|
(_, Ev::Call(Call::GetEnters(r))) => {
|
||||||
|
r.reply(self.data.enters);
|
||||||
|
Resolution::To(prev)
|
||||||
|
}
|
||||||
|
(_, Ev::Call(Call::GetPushes(r))) => {
|
||||||
|
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 => 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
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 (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} 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, 0); // no push ever closed it this run
|
||||||
|
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
|
||||||
|
println!("ok");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// BREAK-CASE MENU — each makes one compile-time guarantee fire.
|
||||||
|
//
|
||||||
|
// 1. ORPHAN HANDLER (dead_code -> error):
|
||||||
|
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
|
||||||
|
// => error: function `on_slam` is never used
|
||||||
|
//
|
||||||
|
// 2. CONFLICTING ROW (unreachable_patterns -> error):
|
||||||
|
// duplicate an arm, e.g. add a second
|
||||||
|
// `(Door::Open, Ev::Cast(Cast::Push)) => Resolution::Unhandled,`
|
||||||
|
// => error: unreachable pattern
|
||||||
|
//
|
||||||
|
// 3. MISSING PAIR (non-exhaustive match, E0004):
|
||||||
|
// delete the `(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock))`
|
||||||
|
// arm.
|
||||||
|
// => error[E0004]: non-exhaustive patterns: ... not covered
|
||||||
|
//
|
||||||
|
// 4. OUT-OF-SET TARGET (E0599):
|
||||||
|
// in `on_unlock`, return `UnlockOutcome::Open`.
|
||||||
|
// => error[E0599]: no variant ... named `Open` found for enum `UnlockOutcome`
|
||||||
|
// ===========================================================================
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! 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`
|
||||||
|
//! dispatch, the stay/transition apply-tail) collapses into the invocation
|
||||||
|
//! below. What stays hand-written is what carries meaning: the four types, the
|
||||||
|
//! per-state successor enum, and the handler fns.
|
||||||
|
//!
|
||||||
|
//! The point of the exercise is that the macro is *pure sugar*: the four
|
||||||
|
//! compile-time guarantees the hand-written form demonstrates are properties of
|
||||||
|
//! the emitted code, not of the macro, so they survive expansion unchanged. The
|
||||||
|
//! BREAK-CASE MENU at the bottom is the same four cases, re-expressed against
|
||||||
|
//! the macro surface — flip any one on and the compiler fires identically.
|
||||||
|
//!
|
||||||
|
//! Run: `cargo run --example gen_statem_macro`
|
||||||
|
|
||||||
|
#![deny(dead_code)] // guarantee #3 (orphan handlers); the macro denies the
|
||||||
|
// dispatch's own unreachable_patterns internally.
|
||||||
|
|
||||||
|
use smarm::gen_statem;
|
||||||
|
use smarm::run;
|
||||||
|
use smarm::gen_statem::Reply;
|
||||||
|
|
||||||
|
// === user types (identical to gen_statem_expanded.rs) =========================
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Door {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Push,
|
||||||
|
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;
|
||||||
|
|
||||||
|
// === per-row successor enum for the one branching row ======================
|
||||||
|
// `Locked + Unlock` may end in Closed (right key) or Locked (wrong key) — and
|
||||||
|
// nothing else. This type IS that declared set; `on_unlock` cannot name Open.
|
||||||
|
enum UnlockOutcome {
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UnlockOutcome> for Door {
|
||||||
|
fn from(o: UnlockOutcome) -> Door {
|
||||||
|
match o {
|
||||||
|
UnlockOutcome::Closed => Door::Closed,
|
||||||
|
UnlockOutcome::Locked => Door::Locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === module-private handlers (side effects / branch choice only) ===========
|
||||||
|
// Reachable solely through the table below. Orphan one and it is dead_code.
|
||||||
|
|
||||||
|
fn on_push(data: &mut Data) {
|
||||||
|
data.pushes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_unlock(key: u32) -> UnlockOutcome {
|
||||||
|
if key == CODE {
|
||||||
|
UnlockOutcome::Closed
|
||||||
|
} else {
|
||||||
|
UnlockOutcome::Locked // wrong key: caller will see this == current -> stay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === the machine ===========================================================
|
||||||
|
// Everything below — Ev, DoorSm, start, Machine, enter — is generated.
|
||||||
|
|
||||||
|
gen_statem! {
|
||||||
|
machine: DoorSm { state: Door, data: Data };
|
||||||
|
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
|
||||||
|
// state tag, `cx` = context handle (unused here).
|
||||||
|
context(data, prev, cx);
|
||||||
|
|
||||||
|
enter {
|
||||||
|
_ => data.enters += 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// state-independent queries (reply, then stay via `prev`)
|
||||||
|
on _ => {
|
||||||
|
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, 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 (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} 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_expanded.rs.
|
||||||
|
//
|
||||||
|
// 1. ORPHAN HANDLER (dead_code -> error):
|
||||||
|
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
|
||||||
|
// => error: function `on_slam` is never used
|
||||||
|
//
|
||||||
|
// 2. CONFLICTING ROW (unreachable_patterns):
|
||||||
|
// duplicate a row, e.g. add a second
|
||||||
|
// `cast Cast::Push => unhandled,` under `on Door::Open`.
|
||||||
|
// NOTE: this example is a separate crate from `smarm`, so rustc's
|
||||||
|
// in_external_macro rule SILENCES this lint here even though the macro
|
||||||
|
// denies it — the duplicate compiles. The guarantee is real only for
|
||||||
|
// machines defined inside the `smarm` crate itself. This is the documented
|
||||||
|
// macro_rules! limitation.
|
||||||
|
//
|
||||||
|
// 3. MISSING PAIR (non-exhaustive match, E0004):
|
||||||
|
// delete the `cast Cast::Push | Cast::Pull | Cast::Lock => unhandled,`
|
||||||
|
// row under `on Door::Locked`.
|
||||||
|
// => error[E0004]: non-exhaustive patterns: ... not covered
|
||||||
|
//
|
||||||
|
// 4. OUT-OF-SET TARGET (E0599):
|
||||||
|
// in `on_unlock`, return `UnlockOutcome::Open`.
|
||||||
|
// => error[E0599]: no variant ... named `Open` found for enum `UnlockOutcome`
|
||||||
|
// ===========================================================================
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! Addressing a gen_server by a durable name.
|
||||||
|
//!
|
||||||
|
//! A gen_server is multi-message (call / cast over one inbox), so it is named
|
||||||
|
//! by the *server* type rather than by a single message type. Registering it
|
||||||
|
//! under a [`GenServerName`] lets clients `call` and `cast` by name, resolving on
|
||||||
|
//! every use — so the address keeps working across a supervised restart, with
|
||||||
|
//! no stale [`GenServerRef`] to refresh.
|
||||||
|
|
||||||
|
use smarm::{call, cast, run, whereis_server, GenServer, GenServerBuilder, GenServerName, GenServerRef};
|
||||||
|
|
||||||
|
/// A counter server: synchronous `Get`, asynchronous `Inc` / `Add`.
|
||||||
|
struct Counter {
|
||||||
|
n: u64,
|
||||||
|
}
|
||||||
|
enum Query {
|
||||||
|
Get,
|
||||||
|
}
|
||||||
|
enum Update {
|
||||||
|
Inc,
|
||||||
|
Add(u64),
|
||||||
|
}
|
||||||
|
impl GenServer for Counter {
|
||||||
|
type Call = Query;
|
||||||
|
type Reply = u64;
|
||||||
|
type Cast = Update;
|
||||||
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
|
fn handle_call(&mut self, q: Query) -> u64 {
|
||||||
|
match q {
|
||||||
|
Query::Get => self.n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn handle_cast(&mut self, u: Update) {
|
||||||
|
match u {
|
||||||
|
Update::Inc => self.n += 1,
|
||||||
|
Update::Add(k) => self.n += k,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A durable name typed by the server, so by-name `call` / `cast` check against
|
||||||
|
/// `Counter`'s `Call` / `Cast` / `Reply`.
|
||||||
|
const COUNTER: GenServerName<Counter> = GenServerName::new("counter");
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
// Start the server and bind its name in one step. A named start is
|
||||||
|
// fallible: the name may already be held by another live server.
|
||||||
|
GenServerBuilder::new(Counter { n: 0 })
|
||||||
|
.named(COUNTER)
|
||||||
|
.start()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Address it purely by name. Each call/cast resolves through the
|
||||||
|
// registry, so a server restarted under the same name is reached
|
||||||
|
// transparently.
|
||||||
|
cast(COUNTER, Update::Inc).unwrap();
|
||||||
|
cast(COUNTER, Update::Add(41)).unwrap();
|
||||||
|
assert_eq!(call(COUNTER, Query::Get).unwrap(), 42);
|
||||||
|
|
||||||
|
// When you want a handle to hold or pass on rather than resolve per
|
||||||
|
// call, recover a typed `GenServerRef` from the name.
|
||||||
|
let svc: Option<GenServerRef<Counter>> = whereis_server(COUNTER);
|
||||||
|
if let Some(svc) = svc {
|
||||||
|
let _ = svc.call(Query::Get);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! The live observer (RFC 016 Chunk 4) producing an OTP `observer`-flavoured
|
||||||
|
//! dump of a running system.
|
||||||
|
//!
|
||||||
|
//! Run it with the feature on:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example observer --features observer
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! It stands up a tiny tree — a named service plus two workers parked on a gate
|
||||||
|
//! — starts the [`observer`](smarm::observer) gen_server, then asks it for a
|
||||||
|
//! snapshot and a tree over the call channel and renders both. The observer is
|
||||||
|
//! pure transport: every line below is the Chunk-1 read
|
||||||
|
//! ([`snapshot`](smarm::snapshot) / [`tree`](smarm::tree)) marshalled across a
|
||||||
|
//! `call`, nothing more.
|
||||||
|
|
||||||
|
use smarm::observer::{self, ObserverReply, ObserverRequest};
|
||||||
|
use smarm::{channel, register, run, spawn, ActorState, Name, RuntimeSnapshot, RuntimeTree, TreeNode};
|
||||||
|
|
||||||
|
const ECHO: Name<u64> = Name::new("echo");
|
||||||
|
|
||||||
|
fn state_glyph(s: ActorState) -> &'static str {
|
||||||
|
match s {
|
||||||
|
ActorState::Queued => "queued",
|
||||||
|
ActorState::Running => "running",
|
||||||
|
ActorState::Notified => "notified",
|
||||||
|
ActorState::Parked => "parked",
|
||||||
|
ActorState::Done => "done",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `ps`-style table over the flat snapshot.
|
||||||
|
fn print_snapshot(snap: &RuntimeSnapshot) {
|
||||||
|
println!("snapshot (format v{}, {} actors)", snap.format_version, snap.actors.len());
|
||||||
|
println!(
|
||||||
|
" {:<10} {:<9} {:<10} {:>4} {:>4} {:>4} {:>4} {:>5} {}",
|
||||||
|
"pid", "state", "parent", "mon", "lnk", "joi", "mbox", "msgs", "names"
|
||||||
|
);
|
||||||
|
for a in &snap.actors {
|
||||||
|
let parent = if a.supervisor.index() == u32::MAX {
|
||||||
|
"<root>".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}.{}", a.supervisor.index(), a.supervisor.generation())
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {:<10} {:<9} {:<10} {:>4} {:>4} {:>4} {:>4} {:>5} {}",
|
||||||
|
format!("{}.{}", a.pid.index(), a.pid.generation()),
|
||||||
|
state_glyph(a.state),
|
||||||
|
parent,
|
||||||
|
a.monitors,
|
||||||
|
a.links,
|
||||||
|
a.joiners,
|
||||||
|
a.mailbox_depth,
|
||||||
|
a.messages_received,
|
||||||
|
if a.names.is_empty() { "-".to_string() } else { a.names.join(",") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parentage forest, indented.
|
||||||
|
fn print_tree(t: &RuntimeTree) {
|
||||||
|
println!("tree (format v{})", t.format_version);
|
||||||
|
fn walk(node: &TreeNode, depth: usize) {
|
||||||
|
let indent = " ".repeat(depth + 1);
|
||||||
|
let flag = if node.orphaned { " [orphaned]" } else { "" };
|
||||||
|
let names = if node.info.names.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" ({})", node.info.names.join(","))
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{indent}{}.{} {}{names}{flag}",
|
||||||
|
node.info.pid.index(),
|
||||||
|
node.info.pid.generation(),
|
||||||
|
state_glyph(node.info.state),
|
||||||
|
);
|
||||||
|
for child in &node.children {
|
||||||
|
walk(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for root in &t.roots {
|
||||||
|
walk(root, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
// A named echo service and two anonymous workers, all parked on a gate
|
||||||
|
// so the system holds still while we observe it. Each gets its own gate
|
||||||
|
// receiver (a Receiver is single-consumer); we keep the senders to
|
||||||
|
// release them at the end.
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let mut gates = Vec::new();
|
||||||
|
|
||||||
|
let svc = {
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
gates.push(gate_tx);
|
||||||
|
let ready_tx = ready_tx.clone();
|
||||||
|
spawn(move || {
|
||||||
|
let (cmd_tx, cmd_rx) = channel::<u64>();
|
||||||
|
register(ECHO, cmd_tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
drop(cmd_rx);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let workers: Vec<_> = (0..2)
|
||||||
|
.map(|_| {
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
gates.push(gate_tx);
|
||||||
|
let ready_tx = ready_tx.clone();
|
||||||
|
spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Wait until all three have announced and parked.
|
||||||
|
for _ in 0..3 {
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
}
|
||||||
|
// Queue two commands at the echo service so its mailbox depth is visible.
|
||||||
|
smarm::send(ECHO, 1).unwrap();
|
||||||
|
smarm::send(ECHO, 2).unwrap();
|
||||||
|
|
||||||
|
// Start the observer and dump the system through it.
|
||||||
|
let obs = observer::start();
|
||||||
|
|
||||||
|
let ObserverReply::Snapshot(snap) = obs.call(ObserverRequest::Snapshot).unwrap() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let ObserverReply::Tree(t) = obs.call(ObserverRequest::Tree).unwrap() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
print_snapshot(&snap);
|
||||||
|
println!();
|
||||||
|
print_tree(&t);
|
||||||
|
|
||||||
|
// Release everyone and drain.
|
||||||
|
for gate_tx in gates {
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
}
|
||||||
|
svc.join().unwrap();
|
||||||
|
for w in workers {
|
||||||
|
w.join().unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Addressing a single-message actor two ways: by identity and by name.
|
||||||
|
//!
|
||||||
|
//! A single-message actor (one implementing [`Addressable`]) is reachable
|
||||||
|
//! through either of smarm's two address kinds:
|
||||||
|
//!
|
||||||
|
//! - [`Pid<A>`] — identity-bound. Names the exact incarnation, never
|
||||||
|
//! redirects, and stops resolving once that actor dies.
|
||||||
|
//! - [`Name<M>`] — durable. Re-resolves through the registry on every send,
|
||||||
|
//! so it always reaches whoever currently holds the name.
|
||||||
|
//!
|
||||||
|
//! A name is typed by the *message* it carries; a pid by the *actor* type
|
||||||
|
//! (whose [`Addressable::Msg`] is that message).
|
||||||
|
|
||||||
|
use smarm::{
|
||||||
|
channel, lookup_as, register, run, send, send_to, spawn, spawn_addr, Addressable, Name, Pid,
|
||||||
|
Receiver,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A single-message actor. Its one message type is what a `Pid<Echo>` delivers.
|
||||||
|
struct Echo;
|
||||||
|
impl Addressable for Echo {
|
||||||
|
type Msg = EchoMsg;
|
||||||
|
}
|
||||||
|
enum EchoMsg {
|
||||||
|
Say(String),
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A durable, message-typed name. Declared as a constant and shared freely.
|
||||||
|
const ECHO: Name<EchoMsg> = Name::new("echo");
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
// --- Identity-bound: Pid<A> ---------------------------------------
|
||||||
|
//
|
||||||
|
// `spawn_addr` makes the actor's inbox, hands the body its receiver,
|
||||||
|
// installs the sender, and returns a typed `Pid<Echo>`. The inbox is
|
||||||
|
// published before the pid is returned, so the address is live the
|
||||||
|
// instant we hold it — an immediate `send_to` always resolves.
|
||||||
|
let echo: Pid<Echo> = spawn_addr::<Echo>(|rx: Receiver<EchoMsg>| {
|
||||||
|
while let Ok(msg) = rx.recv() {
|
||||||
|
match msg {
|
||||||
|
EchoMsg::Say(s) => println!("echo: {s}"),
|
||||||
|
EchoMsg::Stop => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
send_to(echo, EchoMsg::Say("hello".into())).unwrap();
|
||||||
|
send_to(echo, EchoMsg::Stop).unwrap();
|
||||||
|
|
||||||
|
// --- Durable: Name<M> ---------------------------------------------
|
||||||
|
//
|
||||||
|
// An actor claims a name for its own inbox; senders resolve it on every
|
||||||
|
// send, so the binding outlives any single holder.
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
spawn(move || {
|
||||||
|
let (tx, rx) = channel::<EchoMsg>();
|
||||||
|
register(ECHO, tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
while let Ok(msg) = rx.recv() {
|
||||||
|
match msg {
|
||||||
|
EchoMsg::Say(s) => println!("named echo: {s}"),
|
||||||
|
EchoMsg::Stop => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap(); // the actor has claimed the name
|
||||||
|
|
||||||
|
send(ECHO, EchoMsg::Say("by name".into())).unwrap();
|
||||||
|
|
||||||
|
// Recover a typed pid from the name when you want identity-bound sends:
|
||||||
|
// `lookup_as` re-types the registry's erased pid as `Pid<Echo>`, so you
|
||||||
|
// drop back onto the compile-checked `send_to`.
|
||||||
|
if let Some(p) = lookup_as::<Echo>("echo") {
|
||||||
|
send_to(p, EchoMsg::Stop).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! A typed worker pool over process groups (`pg`).
|
||||||
|
//!
|
||||||
|
//! Workers enroll in a named group; the dispatcher reaches them through it.
|
||||||
|
//! Because the pool is homogeneous — every member is a `Worker` — the group's
|
||||||
|
//! typed reads (`pick_as` / `members_as`) and the `dispatch` combinator hand
|
||||||
|
//! members back as `Pid<Worker>`, so every send is an ordinary compile-checked
|
||||||
|
//! `send_to` rather than the untyped escape hatch. The untyped reads
|
||||||
|
//! (`members` / `pick` + `send_dyn`) stay available for identity-only use —
|
||||||
|
//! counting, logging, monitoring — where the message type isn't known.
|
||||||
|
//!
|
||||||
|
//! The pool drains itself: each worker retires on a sentinel and reports its
|
||||||
|
//! tally back, and the dispatcher waits the pool out before returning. (A pool
|
||||||
|
//! left running would be stopped anyway when the root actor exits, but draining
|
||||||
|
//! explicitly keeps the example deterministic.)
|
||||||
|
|
||||||
|
use smarm::{
|
||||||
|
channel, dispatch, join, members, members_as, pick, pick_as, run, send_dyn, send_to,
|
||||||
|
spawn_addr, Addressable, Pid,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The pool's worker actor. One message type, carried by `Pid<Worker>`.
|
||||||
|
struct Worker;
|
||||||
|
impl Addressable for Worker {
|
||||||
|
type Msg = Job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unit of work, or the sentinel that retires a worker.
|
||||||
|
enum Job {
|
||||||
|
Task { id: u64 },
|
||||||
|
Retire,
|
||||||
|
}
|
||||||
|
|
||||||
|
const POOL: &str = "pool";
|
||||||
|
const WORKERS: u64 = 4;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
// Mint four typed workers and enroll them. `spawn_addr` yields a
|
||||||
|
// `Pid<Worker>`; `join` takes a typed pid directly, erasing internally.
|
||||||
|
// Each worker reports how many tasks it handled over a shared channel,
|
||||||
|
// so the dispatcher can wait the pool out at the end.
|
||||||
|
let (done_tx, done_rx) = channel::<(u64, u64)>();
|
||||||
|
for id in 0..WORKERS {
|
||||||
|
let done_tx = done_tx.clone();
|
||||||
|
let w: Pid<Worker> = spawn_addr::<Worker>(move |rx| {
|
||||||
|
let mut handled = 0;
|
||||||
|
while let Ok(job) = rx.recv() {
|
||||||
|
match job {
|
||||||
|
Job::Task { id: job } => {
|
||||||
|
println!("worker {id} handling job {job}");
|
||||||
|
handled += 1;
|
||||||
|
}
|
||||||
|
Job::Retire => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done_tx.send((id, handled)).unwrap();
|
||||||
|
});
|
||||||
|
join(POOL, w);
|
||||||
|
}
|
||||||
|
drop(done_tx); // from here only the workers hold senders
|
||||||
|
|
||||||
|
// Pick one live member and hand it a job — typed end to end.
|
||||||
|
if let Some(w) = pick_as::<Worker>(POOL) {
|
||||||
|
send_to(w, Job::Task { id: 1 }).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `dispatch` rolls pick-a-live-member-and-send into one call, returning
|
||||||
|
// the member it reached (or handing the job back if the pool is empty).
|
||||||
|
let _ = dispatch::<Worker>(POOL, Job::Task { id: 2 });
|
||||||
|
|
||||||
|
// Fan a job out to the whole pool — typed, so each send is `send_to`.
|
||||||
|
for w in members_as::<Worker>(POOL) {
|
||||||
|
let _ = send_to(w, Job::Task { id: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The untyped reads stay available for identity-only use. To message a
|
||||||
|
// member reached this way, the explicit `send_dyn` escape hatch names
|
||||||
|
// the message type.
|
||||||
|
println!("pool size: {}", members(POOL).len());
|
||||||
|
if let Some(any) = pick(POOL) {
|
||||||
|
let _ = send_dyn::<Job>(any, Job::Task { id: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retire every worker, then drain their tallies so the program winds
|
||||||
|
// down on its own.
|
||||||
|
for w in members_as::<Worker>(POOL) {
|
||||||
|
let _ = send_to(w, Job::Retire);
|
||||||
|
}
|
||||||
|
for _ in 0..WORKERS {
|
||||||
|
if let Ok((id, handled)) = done_rx.recv() {
|
||||||
|
println!("worker {id} retired after {handled} jobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Run-queue shootout driver (ROADMAP_v0.5 phase 4).
|
|
||||||
#
|
|
||||||
# Rebuilds the runtime bench once per rq-* feature and runs the raw-structure
|
|
||||||
# microbench once (it covers all structures in a single binary). Results land
|
|
||||||
# in bench_results/ as full logs; the RQCSV lines are aggregated into
|
|
||||||
# bench_results/summary.csv for plotting.
|
|
||||||
#
|
|
||||||
# Tune the sweep for the box, e.g. on the 20-core machine:
|
|
||||||
# SMARM_BENCH_THREADS="1 2 4 8 16 20" ./scripts/bench_rq.sh
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
OUT=bench_results
|
|
||||||
mkdir -p "$OUT"
|
|
||||||
: "${SMARM_BENCH_THREADS:=1 2 4}"
|
|
||||||
export SMARM_BENCH_THREADS
|
|
||||||
|
|
||||||
echo "== raw structures (one binary, all variants) =="
|
|
||||||
cargo bench --bench rq_micro 2>&1 | tee "$OUT/micro.txt"
|
|
||||||
|
|
||||||
for v in rq-mutex rq-mpmc rq-striped; do
|
|
||||||
echo "== runtime benches: $v =="
|
|
||||||
cargo bench --bench rq_runtime --no-default-features --features "$v" \
|
|
||||||
2>&1 | tee "$OUT/runtime-$v.txt"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "bench,kind,a,b,c,d,median_us,ops_per_s" > "$OUT/summary.csv"
|
|
||||||
grep -h '^RQCSV,' "$OUT"/*.txt | sed 's/^RQCSV,//' >> "$OUT/summary.csv"
|
|
||||||
echo
|
|
||||||
echo "Summary: $OUT/summary.csv ($(($(wc -l < "$OUT/summary.csv") - 1)) rows)"
|
|
||||||
+4
-2
@@ -93,8 +93,10 @@ pub fn take_last_outcome() -> Option<Outcome> {
|
|||||||
/// unwinding to cross the boundary, but `catch_unwind` here means unwinding
|
/// unwinding to cross the boundary, but `catch_unwind` here means unwinding
|
||||||
/// never actually does.
|
/// never actually does.
|
||||||
pub extern "C-unwind" fn trampoline() {
|
pub extern "C-unwind" fn trampoline() {
|
||||||
let b = CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take())
|
let b = match CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take()) {
|
||||||
.expect("trampoline entered without a closure set");
|
Some(b) => b,
|
||||||
|
None => panic!("smarm: trampoline entered without a closure set (core corrupt)"),
|
||||||
|
};
|
||||||
|
|
||||||
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
|
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
|
||||||
Ok(()) => Outcome::Exit,
|
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) => {
|
||||||
|
()
|
||||||
|
};
|
||||||
|
}
|
||||||
+346
-212
@@ -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
|
//! A channel is a queue with a typed [`Sender`] on one end and a typed
|
||||||
//! threads (required for the multi-scheduler runtime where a sender and
|
//! [`Receiver`] on the other. Any number of actors can hold a clone of the
|
||||||
//! receiver may run on different scheduler threads simultaneously).
|
//! `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
|
//! use smarm::{channel, run, spawn};
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Semantics:
|
//! run(|| {
|
||||||
//! - Senders are clonable; the last sender drop closes the channel.
|
//! let (tx, rx) = channel::<u64>();
|
||||||
//! - `Receiver::recv` on an empty open channel parks the receiver.
|
//!
|
||||||
//! - `Receiver::recv` on an empty closed channel returns `Err(RecvError)`.
|
//! let worker = spawn(move || {
|
||||||
//! - `Sender::send` on an open channel always succeeds.
|
//! // Blocks until a message arrives.
|
||||||
//! - `Sender::send` on a closed channel (receiver dropped) returns
|
//! let n = rx.recv().unwrap();
|
||||||
//! `Err(SendError(value))`.
|
//! assert_eq!(n, 42);
|
||||||
//! - When a send pushes to a previously empty queue and a receiver is
|
//!
|
||||||
//! parked, the receiver is unparked.
|
//! // 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::pid::Pid;
|
||||||
use crate::raw_mutex::RawMutex;
|
use crate::raw_mutex::RawMutex;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
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>) {
|
pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
|
||||||
let inner = Arc::new(RawMutex::new_channel(Inner {
|
let inner = Arc::new(RawMutex::new_channel(Inner {
|
||||||
queue: VecDeque::new(),
|
queue: VecDeque::new(),
|
||||||
@@ -45,29 +109,40 @@ pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
|
|||||||
|
|
||||||
struct Inner<T> {
|
struct Inner<T> {
|
||||||
queue: VecDeque<T>,
|
queue: VecDeque<T>,
|
||||||
/// The parked receiver's `(pid, park-epoch)`. The epoch is the slot
|
/// The parked receiver's `(pid, park-epoch)`, if one is currently
|
||||||
/// word's runtime-wide wait identity (see slot_state.rs): wakers call
|
/// waiting. The epoch identifies exactly which wait this is, so a waker
|
||||||
/// `unpark_at(pid, epoch)`, so an entry left over from an already-woken
|
/// left over from a wait that already ended (a losing `select` arm, a
|
||||||
/// wait — a `select` loser arm, a satisfied `recv_timeout`'s timer — is
|
/// `recv_timeout` whose timer fired after it was already satisfied) is
|
||||||
/// inert: the wake fails the word's epoch CAS and no-ops. This replaces
|
/// inert and does nothing when it fires.
|
||||||
/// the old per-channel `cur_wait`/`next_wait_seq`/`timed_out` trio: wait
|
|
||||||
/// identity now exists exactly once, in the slot word.
|
|
||||||
parked_receiver: Option<(Pid, u32)>,
|
parked_receiver: Option<(Pid, u32)>,
|
||||||
senders: usize,
|
senders: usize,
|
||||||
receiver_alive: bool,
|
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> {
|
pub struct Sender<T> {
|
||||||
inner: Arc<RawMutex<Inner<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> {
|
pub struct Receiver<T> {
|
||||||
inner: Arc<RawMutex<Inner<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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct SendError<T>(pub T);
|
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)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
pub struct RecvError;
|
pub struct RecvError;
|
||||||
|
|
||||||
@@ -84,8 +159,8 @@ impl std::error::Error for RecvError {}
|
|||||||
pub enum RecvTimeoutError {
|
pub enum RecvTimeoutError {
|
||||||
/// The deadline passed with no message available.
|
/// The deadline passed with no message available.
|
||||||
Timeout,
|
Timeout,
|
||||||
/// All senders dropped with no message available — the bounded analogue
|
/// Every sender was dropped with no message available. The
|
||||||
/// of [`RecvError`].
|
/// timeout-aware counterpart of plain [`RecvError`].
|
||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +190,8 @@ impl<T> Drop for Sender<T> {
|
|||||||
// Wake the parked receiver on the last sender drop regardless of
|
// Wake the parked receiver on the last sender drop regardless of
|
||||||
// whether the queue is empty. A plain `recv` only ever parks on an
|
// whether the queue is empty. A plain `recv` only ever parks on an
|
||||||
// empty queue (so this is unchanged for it), but a selective
|
// empty queue (so this is unchanged for it), but a selective
|
||||||
// `recv_match` may be parked on a *non-empty* queue holding only
|
// `recv_match` may be parked on a non-empty queue holding only
|
||||||
// non-matching messages — it must wake to observe closure and
|
// non-matching messages. It must wake to observe closure and
|
||||||
// return Err rather than sleep forever.
|
// return Err rather than sleep forever.
|
||||||
if g.senders == 0 {
|
if g.senders == 0 {
|
||||||
g.parked_receiver.take()
|
g.parked_receiver.take()
|
||||||
@@ -132,11 +207,37 @@ impl<T> Drop for Sender<T> {
|
|||||||
|
|
||||||
impl<T> Drop for Receiver<T> {
|
impl<T> Drop for Receiver<T> {
|
||||||
fn drop(&mut self) {
|
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> {
|
impl<T> Sender<T> {
|
||||||
|
/// 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>> {
|
pub fn send(&self, value: T) -> Result<(), SendError<T>> {
|
||||||
let unpark = {
|
let unpark = {
|
||||||
let mut g = self.inner.lock();
|
let mut g = self.inner.lock();
|
||||||
@@ -157,70 +258,72 @@ impl<T> Sender<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Receiver<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> {
|
pub fn recv(&self) -> Result<T, RecvError> {
|
||||||
loop {
|
loop {
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock();
|
let mut g = self.inner.lock();
|
||||||
if let Some(v) = g.queue.pop_front() {
|
if let Some(v) = g.queue.pop_front() {
|
||||||
|
crate::preempt::note_message_received();
|
||||||
return Ok(v);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
if g.senders == 0 {
|
if g.senders == 0 {
|
||||||
return Err(RecvError);
|
return Err(RecvError);
|
||||||
}
|
}
|
||||||
let me = crate::actor::current_pid()
|
let me = match crate::actor::current_pid() {
|
||||||
.expect("recv() called outside an actor");
|
Some(me) => me,
|
||||||
|
None => panic!("smarm: recv() called outside an actor"),
|
||||||
|
};
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||||
"channel has more than one receiver"
|
"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
|
// registering in the same critical section makes the epoch
|
||||||
// atomic with the senders' view of the registration.
|
// atomic with the senders' view of the registration.
|
||||||
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
||||||
crate::te!(crate::trace::Event::RecvPark(me));
|
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::scheduler::park_current();
|
||||||
// Woken up — record it before looping to check the queue.
|
// Woken up. Record it before looping to check the queue.
|
||||||
crate::te!(crate::trace::Event::RecvWake(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)"),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded receive: like [`recv`](Self::recv), but gives up once
|
/// Like [`recv`](Self::recv), but gives up and returns
|
||||||
/// `timeout` has elapsed, returning [`RecvTimeoutError::Timeout`].
|
/// [`RecvTimeoutError::Timeout`] if no message has arrived by the time
|
||||||
|
/// `timeout` elapses.
|
||||||
///
|
///
|
||||||
/// Built on the same timer machinery as `Mutex::lock_timeout`: the wait
|
/// If a message arrives at essentially the same moment the deadline
|
||||||
/// registers a `WaitTimeout` entry stamped with the wait's park-epoch;
|
/// passes, the message wins: you get `Ok` rather than `Timeout`. If
|
||||||
/// on expiry the channel (as the
|
/// every sender is dropped before a message arrives or the deadline
|
||||||
/// [`TimerTarget`](crate::timer::TimerTarget)) checks whether *this*
|
/// passes, you get [`RecvTimeoutError::Disconnected`].
|
||||||
/// 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`.
|
|
||||||
///
|
///
|
||||||
/// The wake is classified from state alone — wakes are precise (the only
|
/// `Duration::ZERO` is a valid timeout: it still gives any
|
||||||
/// stamped wakers of this wait are a send, the last-sender drop, and the
|
/// already-queued message a chance to be returned, and only then
|
||||||
/// timer; a stop wake unwinds out of `park_current` and never reaches
|
/// reports `Timeout`.
|
||||||
/// 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.
|
|
||||||
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Result<T, RecvTimeoutError>
|
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Result<T, RecvTimeoutError>
|
||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let me = crate::actor::current_pid()
|
let me = match crate::actor::current_pid() {
|
||||||
.expect("recv_timeout() called outside an actor");
|
Some(me) => me,
|
||||||
|
None => panic!("smarm: recv_timeout() called outside an actor"),
|
||||||
|
};
|
||||||
|
|
||||||
// Fast path + wait registration, one critical section.
|
// Fast path + wait registration, one critical section.
|
||||||
let epoch;
|
let epoch;
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock();
|
let mut g = self.inner.lock();
|
||||||
if let Some(v) = g.queue.pop_front() {
|
if let Some(v) = g.queue.pop_front() {
|
||||||
|
crate::preempt::note_message_received();
|
||||||
return Ok(v);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
if g.senders == 0 {
|
if g.senders == 0 {
|
||||||
@@ -237,16 +340,20 @@ impl<T> Receiver<T> {
|
|||||||
|
|
||||||
// Arm the timer after releasing the channel lock (insert takes the
|
// Arm the timer after releasing the channel lock (insert takes the
|
||||||
// timers lock; never nest under a Channel lock). A send or even 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.
|
// protocol makes the park below return immediately in that case.
|
||||||
let deadline = crate::timer::deadline_from_now(timeout);
|
let deadline = crate::timer::deadline_from_now(timeout);
|
||||||
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = self.inner.clone();
|
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = self.inner.clone();
|
||||||
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||||
|
|
||||||
crate::scheduler::park_current();
|
crate::scheduler::park_current();
|
||||||
crate::te!(crate::trace::Event::RecvWake(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();
|
let mut g = self.inner.lock();
|
||||||
if let Some(v) = g.queue.pop_front() {
|
if let Some(v) = g.queue.pop_front() {
|
||||||
|
crate::preempt::note_message_received();
|
||||||
return Ok(v);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
if g.senders == 0 {
|
if g.senders == 0 {
|
||||||
@@ -255,16 +362,23 @@ impl<T> Receiver<T> {
|
|||||||
Err(RecvTimeoutError::Timeout)
|
Err(RecvTimeoutError::Timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selective receive: remove and return the first queued message for which
|
/// Selective receive: find and return the first queued message for
|
||||||
/// `pred` holds, leaving the rest in arrival order. If no queued message
|
/// which `pred` returns `true`, leaving every other message in the
|
||||||
/// matches, parks and re-scans on every send (a selective receiver may park
|
/// queue untouched and in order. Useful when an actor's inbox mixes
|
||||||
/// on a *non-empty* queue). Returns `Err(RecvError)` only once the channel
|
/// message kinds and it wants to handle one kind out of turn, without
|
||||||
/// is closed and no queued message matches.
|
/// discarding the rest.
|
||||||
///
|
///
|
||||||
/// `pred` is run while the channel lock is held: keep it cheap and pure,
|
/// If nothing queued matches, this blocks and re-checks every time a new
|
||||||
/// and do not call back into this channel from inside it. It is modelled as
|
/// message arrives, the same way [`recv`](Self::recv) blocks on an empty
|
||||||
/// `Fn` (not `FnMut`) deliberately — it is re-run from scratch on every
|
/// queue: a selective receiver can be waiting even while the queue holds
|
||||||
/// scan, so a stateful predicate would observe surprising re-counting.
|
/// 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>
|
pub fn recv_match<F>(&self, pred: F) -> Result<T, RecvError>
|
||||||
where
|
where
|
||||||
F: Fn(&T) -> bool,
|
F: Fn(&T) -> bool,
|
||||||
@@ -272,16 +386,23 @@ impl<T> Receiver<T> {
|
|||||||
loop {
|
loop {
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock();
|
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.
|
// position() found it, so remove() returns Some.
|
||||||
return Ok(g.queue.remove(i).unwrap());
|
crate::preempt::note_message_received();
|
||||||
|
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 {
|
if g.senders == 0 {
|
||||||
// Closed and nothing queued can ever match.
|
// Closed and nothing queued can ever match.
|
||||||
return Err(RecvError);
|
return Err(RecvError);
|
||||||
}
|
}
|
||||||
let me = crate::actor::current_pid()
|
let me = match crate::actor::current_pid() {
|
||||||
.expect("recv_match() called outside an actor");
|
Some(me) => me,
|
||||||
|
None => panic!("smarm: recv_match() called outside an actor"),
|
||||||
|
};
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||||
"channel has more than one receiver"
|
"channel has more than one receiver"
|
||||||
@@ -289,23 +410,33 @@ impl<T> Receiver<T> {
|
|||||||
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
||||||
crate::te!(crate::trace::Event::RecvPark(me));
|
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::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
|
/// The non-blocking counterpart of [`recv_match`](Self::recv_match):
|
||||||
/// matched `pred` (removed, rest left in order), `Ok(None)` if the channel
|
/// returns immediately either way. `Ok(Some(v))` if a queued message
|
||||||
/// is open but nothing matched, `Err(RecvError)` if closed and nothing
|
/// matched `pred` (removed; the rest stay queued in order), `Ok(None)`
|
||||||
/// matched. Same predicate contract as [`recv_match`](Self::recv_match).
|
/// 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>
|
pub fn try_recv_match<F>(&self, pred: F) -> Result<Option<T>, RecvError>
|
||||||
where
|
where
|
||||||
F: Fn(&T) -> bool,
|
F: Fn(&T) -> bool,
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock();
|
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) {
|
||||||
return Ok(Some(g.queue.remove(i).unwrap()));
|
crate::preempt::note_message_received();
|
||||||
|
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 {
|
if g.senders == 0 {
|
||||||
return Err(RecvError);
|
return Err(RecvError);
|
||||||
@@ -313,11 +444,14 @@ impl<T> Receiver<T> {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
|
/// The non-blocking counterpart of [`recv`](Self::recv): returns
|
||||||
/// the channel is empty but open, `Err(RecvError)` if closed and drained.
|
/// 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> {
|
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
|
||||||
let mut g = self.inner.lock();
|
let mut g = self.inner.lock();
|
||||||
if let Some(v) = g.queue.pop_front() {
|
if let Some(v) = g.queue.pop_front() {
|
||||||
|
crate::preempt::note_message_received();
|
||||||
return Ok(Some(v));
|
return Ok(Some(v));
|
||||||
}
|
}
|
||||||
if g.senders == 0 {
|
if g.senders == 0 {
|
||||||
@@ -328,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>> {
|
impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
|
||||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||||
// Cancel the wait only if THIS wait (epoch match) is still
|
// Cancel the wait only if THIS wait (epoch match) is still
|
||||||
// registered. If a sender already took `parked_receiver`, the
|
// 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
|
// no-ops. If a later wait by the same receiver is registered, the
|
||||||
// epoch mismatches — stale entry, no-op. (The unpark_at would fail
|
// epoch mismatches: stale entry, no-op. (unpark_at would fail its
|
||||||
// its word CAS in either case anyway; checking under the lock keeps
|
// internal check in either case anyway; checking under the lock
|
||||||
// the registration bookkeeping exact.)
|
// keeps the registration bookkeeping exact.)
|
||||||
let unpark = {
|
let unpark = {
|
||||||
let mut g = self.lock();
|
let mut g = self.lock();
|
||||||
if g.parked_receiver == Some((pid, epoch)) {
|
if g.parked_receiver == Some((pid, epoch)) {
|
||||||
@@ -349,7 +483,7 @@ impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
|
|||||||
false
|
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.
|
// legal under a Channel lock, but pointless to nest.
|
||||||
if unpark {
|
if unpark {
|
||||||
crate::scheduler::unpark_at(pid, epoch);
|
crate::scheduler::unpark_at(pid, epoch);
|
||||||
@@ -358,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 {
|
pub(crate) mod sealed {
|
||||||
@@ -366,33 +500,34 @@ pub(crate) mod sealed {
|
|||||||
}
|
}
|
||||||
impl<T> sealed::Sealed for Receiver<T> {}
|
impl<T> sealed::Sealed for Receiver<T> {}
|
||||||
|
|
||||||
/// An arm of a [`select`]. Implemented by [`Receiver`]; sealed, because the
|
/// An arm of a [`select`]: something you can wait on alongside other arms
|
||||||
/// registration contract below is part of the runtime's wake protocol.
|
/// 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-
|
/// 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
|
/// returns `Ok(false)`; otherwise it publishes `(pid, epoch)` where its
|
||||||
/// wakers will find it and returns `Ok(true)`. "Ready" means a receive
|
/// 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
|
/// 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.
|
/// eager-cleanup arms unregistered.
|
||||||
pub trait Selectable: sealed::Sealed {
|
pub trait Selectable: sealed::Sealed {
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool>;
|
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool>;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
fn sel_ready(&self) -> bool;
|
fn sel_ready(&self) -> bool;
|
||||||
/// Remove this arm's `(pid, epoch)` registration if — and only if — it
|
/// Remove this arm's `(pid, epoch)` registration if, and only if, it is
|
||||||
/// is still in place. Default no-op: a losing channel arm's stale
|
/// still in place. Default no-op: a losing channel arm's stale
|
||||||
/// registration is inert (its wakers die at the epoch CAS; the next
|
/// registration is harmless and self-cleans. Fd arms override this:
|
||||||
/// wait overwrites the slot). Fd arms override this: their staleness
|
/// their staleness would otherwise leave the fd unusable for future
|
||||||
/// poisons the fd (waiters entry + kernel-side ONESHOT registration)
|
/// selects, so they need an eager cleanup pass.
|
||||||
/// and needs an eager cleanup pass.
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
fn sel_unregister(&self, _pid: Pid, _epoch: u32) {}
|
fn sel_unregister(&self, _pid: Pid, _epoch: u32) {}
|
||||||
/// Whether this arm requires the eager cleanup pass at all. Gates the
|
/// Whether this arm requires the eager cleanup pass at all. Gates the
|
||||||
/// post-wake `sel_unregister` sweep so channel-only selects keep
|
/// post-wake `sel_unregister` sweep so channel-only selects keep their
|
||||||
/// today's zero-cancellation hot path.
|
/// cheap, cleanup-free path.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
fn sel_eager_cleanup(&self) -> bool {
|
fn sel_eager_cleanup(&self) -> bool {
|
||||||
false
|
false
|
||||||
@@ -419,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,
|
/// "Ready" means a receive on that arm would not block: a message is
|
||||||
/// or the arm is **closed** (so the caller's `try_recv` observes the
|
/// queued, or the arm is closed (so the caller's own `try_recv` observes
|
||||||
/// disconnect — a dead arm is an event, not a hang). The caller consumes the
|
/// the disconnect: a dead arm is something to react to, not something to
|
||||||
/// arm itself, typically via [`Receiver::try_recv`]; single-receiver
|
/// hang on). `select` only tells you which arm is ready; read the actual
|
||||||
/// channels guarantee nothing can steal the message in between.
|
/// message yourself, typically with [`Receiver::try_recv`] on that arm.
|
||||||
///
|
///
|
||||||
/// A closed arm stays ready *forever*: once its disconnect has been
|
/// A closed arm stays ready forever. Once you have observed its disconnect,
|
||||||
/// observed, drop it from the arm set — under priority order it would
|
/// drop it from the arm set you pass in next time: otherwise, under the
|
||||||
/// otherwise win every subsequent call and starve every higher-indexed arm.
|
/// 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
|
/// Arms are checked **in order**: index 0 is the highest priority, both
|
||||||
/// the immediate-ready path and after a wake. This is a documented
|
/// when checking immediately and after being woken. This is a deliberate,
|
||||||
/// guarantee (compose like BEAM receive clauses: put control channels
|
/// documented guarantee, not an accident of implementation: put a control
|
||||||
/// first), not an accident — and therefore there is NO fairness promise; a
|
/// or shutdown channel first so it is always noticed promptly. The
|
||||||
/// saturated arm 0 starves arm 1 by design.
|
/// 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
|
/// One actor can `select` on a channel and later plain `recv` on it (or
|
||||||
/// overlapping sets) freely. What stays illegal is what was always illegal:
|
/// `select` again on an overlapping set of arms) with no restriction. What
|
||||||
/// two *different* actors receiving on one channel.
|
/// 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
|
/// Panics if `arms` is empty, if called outside an actor, or if an fd arm
|
||||||
/// registered under one wait epoch; the winning wake consumes it, so losing
|
/// fails to register (see [`try_select`] for the fallible form; a
|
||||||
/// arms' registrations are inert and need no cancellation pass — they
|
/// channel-only `select` can never fail).
|
||||||
/// 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).
|
|
||||||
pub fn select(arms: &[&dyn Selectable]) -> usize {
|
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
|
/// The fallible form of [`select`]: `Err` when an arm fails to register.
|
||||||
/// arms can — EBADF, EMFILE on the epoll set, or a second waiter on an
|
/// Only fd arms can fail this way (for example, the file descriptor is
|
||||||
/// fd that already has one). On `Err` the wait is fully retired and no
|
/// 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
|
/// registration is left behind: every arm registered before the failing
|
||||||
/// one has been unregistered.
|
/// one has been unregistered.
|
||||||
pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
||||||
assert!(!arms.is_empty(), "select() on an empty arm list");
|
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 {
|
loop {
|
||||||
let epoch = crate::scheduler::begin_wait();
|
let epoch = crate::scheduler::begin_wait();
|
||||||
if let Some(i) = register_arms(me, epoch, arms)? {
|
if let Some(i) = register_arms(me, epoch, arms)? {
|
||||||
@@ -470,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
|
// Stale fd registrations are not harmless (a losing fd arm's
|
||||||
// waiters entry poisons the fd with AlreadyExists and its
|
// leftover registration can make the fd unusable for the next
|
||||||
// kernel-side ONESHOT registration can fire arbitrarily late), so
|
// select until a kernel event happens to clear it), so selects
|
||||||
// selects containing fd arms run an eager cleanup pass after the
|
// containing fd arms run an eager cleanup pass after the park,
|
||||||
// park — including when a terminal stop unwinds out of it, via
|
// including when a terminal stop unwinds out of it, via the guard.
|
||||||
// the guard. Channel-only selects skip all of it: `eager` is
|
// Channel-only selects skip all of it: `eager` is false, the guard
|
||||||
// false, the guard is disarmed, and the loser-arm self-cleaning
|
// is disarmed, and the loser-arm self-cleaning story is unchanged.
|
||||||
// story is unchanged.
|
|
||||||
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
||||||
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
||||||
|
|
||||||
@@ -489,22 +627,22 @@ pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
|||||||
drop(guard);
|
drop(guard);
|
||||||
|
|
||||||
// Woken precisely: an arm's send (message) or last-sender drop
|
// Woken precisely: an arm's send (message) or last-sender drop
|
||||||
// (closure) consumed our epoch, and both leave their arm ready —
|
// (closure) is what woke us, and both leave their arm ready.
|
||||||
// return the first one, in priority order (which may be a
|
// Return the first ready one, in priority order (which may be a
|
||||||
// different, higher-priority arm than the one that woke us; its
|
// different, higher-priority arm than the one that woke us; its
|
||||||
// message stays queued and re-reports ready on the next call).
|
// message stays queued and re-reports ready on the next call).
|
||||||
// Fd arms classify by a fresh zero-timeout poll, so they too are
|
// Fd arms classify by a fresh zero-timeout poll, so they too are
|
||||||
// a pure function of state — independent of the registration the
|
// a pure function of current state, independent of the
|
||||||
// cleanup pass just removed.
|
// registration the cleanup pass just removed.
|
||||||
for (i, arm) in arms.iter().enumerate() {
|
for (i, arm) in arms.iter().enumerate() {
|
||||||
if arm.sel_ready() {
|
if arm.sel_ready() {
|
||||||
return Ok(i);
|
return Ok(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unreachable by protocol (a stop wake unwinds out of
|
// Unreachable in practice (a stop wake unwinds out of
|
||||||
// park_current). Defensive: re-open the wait and re-register —
|
// park_current before we get here). Defensive: re-open the wait
|
||||||
// stale own-registrations are overwritten (channels) or were
|
// and re-register; stale own-registrations are overwritten
|
||||||
// removed by the cleanup pass above (fds).
|
// (channels) or were removed by the cleanup pass above (fds).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,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
|
// 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
|
// out of `park_current`, and a registered fd arm must not outlive its
|
||||||
/// actor (the generalization of `wait_fd`'s `Dereg`). Disarmed on the
|
// actor. Disarmed on the normal path after the explicit pass runs; never
|
||||||
/// normal path after the explicit pass runs; never armed when no fd arm
|
// armed when no fd arm is registered, keeping the channel-only path
|
||||||
/// registered, keeping the channel-only path guard-free in effect.
|
// guard-free in effect.
|
||||||
struct UnregisterGuard<'a> {
|
struct UnregisterGuard<'a> {
|
||||||
arms: &'a [&'a dyn Selectable],
|
arms: &'a [&'a dyn Selectable],
|
||||||
me: Pid,
|
me: Pid,
|
||||||
@@ -539,20 +677,16 @@ impl Drop for UnregisterGuard<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The registration pass shared by [`select`] and [`select_timeout`]:
|
// The registration pass shared by `select` and `select_timeout`: check-or-
|
||||||
/// check-or-register each arm, in priority order, each atomically under its
|
// register each arm, in priority order, each atomically under its own lock.
|
||||||
/// own lock. Cross-arm atomicity is unnecessary: an arm becoming ready
|
// Cross-arm atomicity is unnecessary: an arm becoming ready right after its
|
||||||
/// right after its registration wakes the caller through the protocol (the
|
// registration still wakes the caller through the normal wake path.
|
||||||
/// prep-to-park window is closed by RunningNotified).
|
//
|
||||||
///
|
// `Ok(Some(i))` = arm `i` was already ready, the pass stopped, and the wait
|
||||||
/// `Ok(Some(i))` = arm `i` was ready, the pass stopped, and the wait has
|
// has been fully retired (no park may follow): earlier fd arms are
|
||||||
/// been RETIRED (no park may follow): earlier arms hold live-epoch
|
// unregistered eagerly so none are left dangling. `Err` = an arm failed to
|
||||||
/// registrations, so earlier *fd* arms are unregistered eagerly, then the
|
// register; same unwind (earlier fd arms unregistered, wait retired).
|
||||||
/// epoch is bumped, a landed notification eaten, and a pending stop
|
// `Ok(None)` = every arm registered successfully; the caller parks.
|
||||||
/// re-observed — without which a stale arm wake could fault a later
|
|
||||||
/// one-shot park. `Err` = an arm failed to register; identical unwind
|
|
||||||
/// (earlier fd arms unregistered, wait retired). `Ok(None)` = every arm
|
|
||||||
/// registered; the caller parks.
|
|
||||||
fn register_arms(
|
fn register_arms(
|
||||||
me: Pid,
|
me: Pid,
|
||||||
epoch: u32,
|
epoch: u32,
|
||||||
@@ -576,10 +710,10 @@ fn register_arms(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The [`select_timeout`] timer target: stateless, because precise wakes
|
// The `select_timeout` timer target: stateless, because a wake's cause can
|
||||||
/// make classification a pure function of channel state. The entry is
|
// always be read back off plain channel state (an arm ready, or not). If
|
||||||
/// stamped with the select's epoch; if an arm already won, this unpark dies
|
// an arm already won before the deadline, this timer's fire is simply
|
||||||
/// at the word's epoch CAS (the no-cancellation convention in `timer.rs`).
|
// ignored, the way any other stale wakeup is.
|
||||||
struct SelectTimeout;
|
struct SelectTimeout;
|
||||||
impl crate::timer::TimerTarget for SelectTimeout {
|
impl crate::timer::TimerTarget for SelectTimeout {
|
||||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||||
@@ -587,60 +721,60 @@ impl crate::timer::TimerTarget for SelectTimeout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`select`] with a deadline: returns `Some(index)` like `select`, or
|
/// Like [`select`], but gives up and returns `None` if no arm becomes
|
||||||
/// `None` once `timeout` elapses with no arm ready.
|
/// ready before `timeout` elapses.
|
||||||
///
|
///
|
||||||
/// All of `select`'s semantics carry over (priority order, closed arms
|
/// All of `select`'s semantics carry over: arms are still checked in
|
||||||
/// permanently ready, no fairness promise). The timeout is one more stamped
|
/// priority order, a closed arm is still permanently ready, and there is
|
||||||
/// waker on the same wait epoch — nothing is registered in any arm for it,
|
/// still no fairness guarantee across arms. A message that arrives at
|
||||||
/// so there is nothing to cancel or leak: an arm winning leaves the timer
|
/// essentially the same moment the deadline passes still wins, the same
|
||||||
/// entry to expire as a stale-epoch no-op; the timer winning leaves the
|
/// way [`Receiver::recv_timeout`] resolves that race.
|
||||||
/// arms' registrations to self-clean exactly as a `select` loser's would.
|
|
||||||
///
|
///
|
||||||
/// The wake is classified from state alone (wakes are precise): some arm
|
/// `Duration::ZERO` is a valid timeout: it still gives an already-ready arm
|
||||||
/// ready → `Some` of the first, in priority order; none ready → the timer
|
/// a chance to be reported before falling through to `None`.
|
||||||
/// 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 parks until the immediately-due
|
/// Panics if `arms` is empty, if called outside an actor, or if an fd arm
|
||||||
/// timer is drained, then reports `None` unless an arm was already ready.
|
/// fails to register (see [`try_select_timeout`] for the fallible form; a
|
||||||
///
|
/// channel-only select can never fail).
|
||||||
/// Panics if `arms` is empty, when called outside an actor, or if an fd
|
|
||||||
/// arm fails to register (see [`try_select_timeout`] for the fallible
|
|
||||||
/// form; channel-only selects cannot fail).
|
|
||||||
pub fn select_timeout(
|
pub fn select_timeout(
|
||||||
arms: &[&dyn Selectable],
|
arms: &[&dyn Selectable],
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Option<usize> {
|
) -> Option<usize> {
|
||||||
try_select_timeout(arms, timeout)
|
match try_select_timeout(arms, timeout) {
|
||||||
.expect("select_timeout(): fd arm failed to register (use try_select_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
|
/// The fallible form of [`select_timeout`]: `Err` when an arm fails to
|
||||||
/// (only fd arms can). On `Err` the wait is fully retired and no
|
/// register (only fd arms can). On `Err` the wait is fully retired and no
|
||||||
/// registration — arm-side or kernel-side — is left behind.
|
/// registration is left behind on any arm.
|
||||||
pub fn try_select_timeout(
|
pub fn try_select_timeout(
|
||||||
arms: &[&dyn Selectable],
|
arms: &[&dyn Selectable],
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> std::io::Result<Option<usize>> {
|
) -> std::io::Result<Option<usize>> {
|
||||||
assert!(!arms.is_empty(), "select_timeout() on an empty arm list");
|
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();
|
let epoch = crate::scheduler::begin_wait();
|
||||||
if let Some(i) = register_arms(me, epoch, arms)? {
|
if let Some(i) = register_arms(me, epoch, arms)? {
|
||||||
return Ok(Some(i)); // ready now: the timer was never armed
|
return Ok(Some(i)); // ready now: the timer was never armed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arm the timer after the registration pass, outside every Channel
|
// Arm the timer after the registration pass, outside every channel
|
||||||
// lock (insert takes the timers lock).
|
// lock (inserting a timer takes the timers lock).
|
||||||
let deadline = crate::timer::deadline_from_now(timeout);
|
let deadline = crate::timer::deadline_from_now(timeout);
|
||||||
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = std::sync::Arc::new(SelectTimeout);
|
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = std::sync::Arc::new(SelectTimeout);
|
||||||
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||||
|
|
||||||
// Same eager-cleanup story as `try_select`: the timer arm needs none
|
// Same eager-cleanup story as `try_select`: a timer win in particular
|
||||||
// (stateless, stale entries die at the epoch CAS), channel arms need
|
// leaves every fd arm's registration behind, which without this pass
|
||||||
// none, fd arms do — and a timer win in particular leaves every fd
|
// would leave those fds unusable until a kernel event happened to
|
||||||
// arm's registration behind, which without this pass would poison
|
// clear them.
|
||||||
// those fds until a kernel event happened to fire.
|
|
||||||
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
||||||
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
/// 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() {
|
pub unsafe fn switch_to_actor() {
|
||||||
unsafe { switch_to_actor_asm() };
|
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)]
|
#[unsafe(naked)]
|
||||||
pub unsafe extern "C" fn switch_to_scheduler() {
|
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||||
core::arch::naked_asm!(
|
core::arch::naked_asm!(
|
||||||
|
|||||||
+826
-145
File diff suppressed because it is too large
Load Diff
+1024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
|||||||
|
//! Inspect what is running right now: which actors exist, what state each one
|
||||||
|
//! is in, and how they are related.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! Three entry points, in order of scope:
|
||||||
|
//!
|
||||||
|
//! - [`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.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use smarm::{actor_info, channel, run, snapshot, spawn, ActorState};
|
||||||
|
//!
|
||||||
|
//! 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;
|
||||||
|
use crate::runtime::{Slot, ROOT_PID};
|
||||||
|
use crate::scheduler::with_runtime;
|
||||||
|
use crate::slot_state::{
|
||||||
|
word_gen, word_state, ST_DONE, ST_PARKED, ST_QUEUED, ST_RUNNING, ST_RUNNING_NOTIFIED,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
Running,
|
||||||
|
Notified,
|
||||||
|
Parked,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
ST_RUNNING => ActorState::Running,
|
||||||
|
ST_RUNNING_NOTIFIED => ActorState::Notified,
|
||||||
|
ST_PARKED => ActorState::Parked,
|
||||||
|
ST_DONE => ActorState::Done,
|
||||||
|
_ => return None, // ST_VACANT
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// 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 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 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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| {
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
if let Some(info) = read_slot(slot, idx, mail.get(&idx)) {
|
||||||
|
actors.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuntimeSnapshot { format_version: SNAPSHOT_FORMAT_VERSION, actors }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A coherent view of exactly one actor, or `None` if `pid` does not name a
|
||||||
|
/// currently-live entry: it is stale (that actor has already exited and its
|
||||||
|
/// slot was reused by another), out of range, or was never a real pid at
|
||||||
|
/// all. Unlike [`snapshot`], every field of the result describes the same
|
||||||
|
/// instant, since there is only one actor to read.
|
||||||
|
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
|
||||||
|
with_runtime(|inner| {
|
||||||
|
let slot = inner.slot_at(pid)?;
|
||||||
|
let mail = inner.registry.lock().introspect_one(pid.index());
|
||||||
|
let info = read_slot(slot, pid.index(), mail.as_ref())?;
|
||||||
|
// read_slot keys on the slab's *current* generation; reject if that
|
||||||
|
// isn't the incarnation the caller asked about.
|
||||||
|
(info.pid.generation() == pid.generation()).then_some(info)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)?;
|
||||||
|
let gen = word_gen(w);
|
||||||
|
let pid = Pid::new(idx, gen);
|
||||||
|
|
||||||
|
let cold = slot.cold.lock();
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
let (supervisor, trap_exit) = match cold.actor.as_ref() {
|
||||||
|
// Live incarnation: parent + trap live on the Actor.
|
||||||
|
Some(actor) => (actor.supervisor, actor.trap.is_some()),
|
||||||
|
// Done tombstone: the Actor was taken at finalize and the collections
|
||||||
|
// cleared, so report it root-less with empty counts.
|
||||||
|
None => (ROOT_PID, false),
|
||||||
|
};
|
||||||
|
let monitors = cold.monitors.len() as u32;
|
||||||
|
let links = cold.links.len() as u32;
|
||||||
|
let joiners = cold.waiters.len() as u32;
|
||||||
|
drop(cold);
|
||||||
|
|
||||||
|
// Counters are plain atomics, read lock-free.
|
||||||
|
let overruns = slot.overruns();
|
||||||
|
let messages_received = slot.messages_received();
|
||||||
|
let budget_cycles = slot.budget_cycles();
|
||||||
|
|
||||||
|
// Names + depth belong to this incarnation only if the registry mailbox's
|
||||||
|
// pid matches the slab generation; a stale registry entry (dead prior
|
||||||
|
// occupant, not yet pruned) contributes nothing.
|
||||||
|
let (names, mailbox_depth) = match mail {
|
||||||
|
Some(mi) if mi.pid.generation() == gen => (mi.names.clone(), mi.depth),
|
||||||
|
_ => (Vec::new(), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(ActorInfo {
|
||||||
|
pid,
|
||||||
|
names,
|
||||||
|
state,
|
||||||
|
supervisor,
|
||||||
|
trap_exit,
|
||||||
|
monitors,
|
||||||
|
links,
|
||||||
|
joiners,
|
||||||
|
mailbox_depth,
|
||||||
|
overruns,
|
||||||
|
messages_received,
|
||||||
|
budget_cycles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tree view: a pure derivation over a snapshot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
/// 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: 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 fresh [`snapshot`] and fold it into the parentage forest.
|
||||||
|
pub fn tree() -> RuntimeTree {
|
||||||
|
tree_from(snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold an existing snapshot into a parentage forest by grouping each actor
|
||||||
|
/// under its parent, without taking a new snapshot. Useful if you already
|
||||||
|
/// have one (for example, one built in a test, or one you took earlier and
|
||||||
|
/// want to inspect again) and want the tree view of it without re-reading
|
||||||
|
/// the runtime.
|
||||||
|
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
|
||||||
|
let RuntimeSnapshot { format_version, actors } = snap;
|
||||||
|
|
||||||
|
let mut index_of: HashMap<Pid, usize> = HashMap::with_capacity(actors.len());
|
||||||
|
for (i, a) in actors.iter().enumerate() {
|
||||||
|
index_of.insert(a.pid, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group children under their present parent; everything else is a root.
|
||||||
|
// Scan order is preserved within each parent's child list.
|
||||||
|
let mut children_of: HashMap<Pid, Vec<usize>> = HashMap::new();
|
||||||
|
let mut roots: Vec<usize> = Vec::new();
|
||||||
|
let mut orphaned = vec![false; actors.len()];
|
||||||
|
for (i, a) in actors.iter().enumerate() {
|
||||||
|
let parent = a.supervisor;
|
||||||
|
if parent != ROOT_PID && index_of.contains_key(&parent) {
|
||||||
|
children_of.entry(parent).or_default().push(i);
|
||||||
|
} else {
|
||||||
|
// Parent is the forest sentinel (genuine root) or absent from the
|
||||||
|
// snapshot (orphan): either way, a root of the forest.
|
||||||
|
orphaned[i] = parent != ROOT_PID;
|
||||||
|
roots.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `take()` each actor as it is placed, which also guards against a
|
||||||
|
// (constructionally impossible) parentage cycle re-entering a node.
|
||||||
|
let mut slots: Vec<Option<ActorInfo>> = actors.into_iter().map(Some).collect();
|
||||||
|
let root_nodes = roots
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|i| build_node(i, &children_of, &orphaned, &mut slots))
|
||||||
|
.collect();
|
||||||
|
RuntimeTree { format_version, roots: root_nodes }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_node(
|
||||||
|
i: usize,
|
||||||
|
children_of: &HashMap<Pid, Vec<usize>>,
|
||||||
|
orphaned: &[bool],
|
||||||
|
slots: &mut [Option<ActorInfo>],
|
||||||
|
) -> Option<TreeNode> {
|
||||||
|
let info = slots[i].take()?; // already placed → cycle guard / no double-attach
|
||||||
|
let children = children_of
|
||||||
|
.get(&info.pid)
|
||||||
|
.map(|kids| {
|
||||||
|
kids.iter()
|
||||||
|
.filter_map(|&c| build_node(c, children_of, orphaned, slots))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(TreeNode { info, orphaned: orphaned[i], children })
|
||||||
|
}
|
||||||
@@ -13,44 +13,68 @@
|
|||||||
//! leaves the actor, no copying through an intermediary thread. Built on
|
//! leaves the actor, no copying through an intermediary thread. Built on
|
||||||
//! these are the conveniences `read(fd, &mut buf)` and `write(fd, &buf)`.
|
//! these are the conveniences `read(fd, &mut buf)` and `write(fd, &buf)`.
|
||||||
//!
|
//!
|
||||||
//! Architecture
|
//! Architecture (RFC 018: driver-enqueues)
|
||||||
//! ============
|
//! =======================================
|
||||||
//! Per `run()`, two OS threads:
|
//! Per `run()`, two OS threads, each a *producer* behind the runtime's
|
||||||
//! - **epoll thread**: owns the epollfd. Loops in `epoll_wait`. On a
|
//! two-call contract — make the actor runnable (`unpark_at`, whose enqueue
|
||||||
//! ready fd, pushes `Completion::FdReady { pid, fd, events }` to the
|
//! tail wakes a parked scheduler), nothing else:
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Both threads share a single `completions: Arc<Mutex<VecDeque<Completion>>>`
|
//! - **epoll thread**: owns `epoll_wait` on the epollfd. On a ready fd it
|
||||||
//! and the same scheduler-wake pipe.
|
//! 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
|
//! There is no shared completion queue and no wake pipe: each producer
|
||||||
//! scheduler thread *directly* on the epollfd. That's well-defined per
|
//! routes its own completion, so the whole byte-vs-completion visibility
|
||||||
//! `epoll_ctl(2)`: a thread may be calling `epoll_wait` on the epollfd
|
//! discipline of the drain era — and the stranded-completion hazards it
|
||||||
//! while another thread calls `epoll_ctl`. Avoids needing a second mpsc
|
//! defended against — is unrepresentable. Producers reach the runtime
|
||||||
//! and a second wake mechanism.
|
//! 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
|
//! Epoll mode
|
||||||
//! ==========
|
//! ==========
|
||||||
//! Level-triggered with EPOLLONESHOT. After a wakeup the kernel
|
//! Level-triggered with EPOLLONESHOT. After a wakeup the kernel
|
||||||
//! auto-disarms the fd, so we never get two wakeups for one
|
//! auto-disarms the fd, so we never get two wakeups for one
|
||||||
//! `wait_readable` call. The scheduler explicitly `EPOLL_CTL_DEL`s the fd
|
//! `wait_readable` call. The epoll thread explicitly `EPOLL_CTL_DEL`s the
|
||||||
//! on completion to free the slot for re-registration. Net effect: each
|
//! 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
|
//! `wait_readable(fd)` is one ADD, one wakeup, one DEL — symmetric and
|
||||||
//! stateless between calls.
|
//! 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
|
//! Fd hygiene
|
||||||
//! ==========
|
//! ==========
|
||||||
//! An actor stopped while waiting on an fd unwinds out of `wait_fd`'s park;
|
//! 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
|
//! 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
|
//! normal wake) calls [`IoThread::cancel_waiter`], which removes the
|
||||||
//! `(pid, epoch)` and only then `EPOLL_CTL_DEL`s the fd — an entry already
|
//! `waiters` entry iff it is still that wait's `(pid, epoch)` and only then
|
||||||
//! consumed by a racing `FdReady` means the fd may carry someone else's
|
//! `EPOLL_CTL_DEL`s the fd — an entry already consumed by the epoll thread
|
||||||
//! fresh registration, which must be left alone. `epoll_register` keeps a
|
//! means the fd may carry someone else's fresh registration, which must be
|
||||||
//! defensive bare DEL before ADD as belt-and-braces.
|
//! 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
|
//! Buffers used with `read`/`write` should be on fds opened with
|
||||||
//! `O_NONBLOCK`. If they aren't, the syscall may block the scheduler
|
//! `O_NONBLOCK`. If they aren't, the syscall may block the scheduler
|
||||||
@@ -68,13 +92,14 @@
|
|||||||
//! they have no equivalent panic-propagation path.
|
//! they have no equivalent panic-propagation path.
|
||||||
|
|
||||||
use crate::pid::Pid;
|
use crate::pid::Pid;
|
||||||
|
use crate::runtime::RuntimeInner;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::HashMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::RawFd;
|
use std::os::fd::RawFd;
|
||||||
use std::panic;
|
use std::panic;
|
||||||
use std::sync::mpsc;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{mpsc, Arc, Mutex, Weak};
|
||||||
use std::thread::JoinHandle as OsJoinHandle;
|
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>>;
|
pub type IoResult = Result<Box<dyn Any + Send>, Box<dyn Any + Send>>;
|
||||||
|
|
||||||
struct Request {
|
struct Request {
|
||||||
/// The submitter's park-epoch — carried through to the `Blocking`
|
/// The submitter's park-epoch — the eventual wake is epoch-matched.
|
||||||
/// completion so the wake is epoch-matched.
|
|
||||||
epoch: u32,
|
epoch: u32,
|
||||||
pid: Pid,
|
pid: Pid,
|
||||||
/// The work to perform. Returns the wire-form result directly.
|
/// The work to perform. Returns the wire-form result directly.
|
||||||
work: Box<dyn FnOnce() -> IoResult + Send>,
|
work: Box<dyn FnOnce() -> IoResult + Send>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completion message from either IO thread back to the scheduler.
|
/// The parked-waiter map, shared between scheduler-side registration and
|
||||||
pub enum Completion {
|
/// the epoll thread's readiness consumption. See the module docs on why
|
||||||
/// A `block_on_io` closure has finished (Ok = return value, Err = panic
|
/// this single lock is the ADD/DEL serialization.
|
||||||
/// payload).
|
type Waiters = Arc<Mutex<HashMap<RawFd, (Pid, u32)>>>;
|
||||||
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 },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IoThread — created per `run()`, owned by `SchedulerState`.
|
// IoThread — created per `run()`, owned by `RuntimeInner::io`.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub struct IoThread {
|
pub struct IoThread {
|
||||||
// ----- Channels & queues -----
|
|
||||||
|
|
||||||
/// Submission queue into the blocking-work pool.
|
/// Submission queue into the blocking-work pool.
|
||||||
tx: mpsc::Sender<Request>,
|
tx: mpsc::Sender<Request>,
|
||||||
/// Shared completion queue, fed by both the pool and the epoll thread.
|
/// One parked actor per registered fd. Populated by `epoll_register`,
|
||||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
/// consumed by the epoll thread on readiness or `cancel_waiter` on an
|
||||||
/// Pipe the scheduler polls in its idle path. Both IO threads write to
|
/// unwound wait.
|
||||||
/// `wake_write` after pushing a completion.
|
waiters: Waiters,
|
||||||
wake_read: RawFd,
|
|
||||||
wake_write: RawFd,
|
|
||||||
|
|
||||||
// ----- Epoll machinery -----
|
// ----- Epoll machinery -----
|
||||||
|
|
||||||
@@ -133,39 +145,25 @@ pub struct IoThread {
|
|||||||
/// shutdown.
|
/// shutdown.
|
||||||
shutdown_read: RawFd,
|
shutdown_read: RawFd,
|
||||||
shutdown_write: 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 -----
|
// ----- Threads -----
|
||||||
|
|
||||||
pool_thread: Option<OsJoinHandle<()>>,
|
pool_thread: Option<OsJoinHandle<()>>,
|
||||||
epoll_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 {
|
impl IoThread {
|
||||||
pub fn start() -> io::Result<Self> {
|
/// Start the pool and epoll threads. `rt` is the producers' route back
|
||||||
// Scheduler-facing wake pipe.
|
/// into the runtime (slot table + unpark protocol); a `Weak` so the
|
||||||
let (wake_read, wake_write) = make_pipe()?;
|
/// `RuntimeInner → IoThread → RuntimeInner` cycle never forms.
|
||||||
// Pool submission channel + shared completion queue.
|
pub(crate) fn start(rt: Weak<RuntimeInner>) -> io::Result<Self> {
|
||||||
|
// Pool submission channel.
|
||||||
let (tx, rx) = mpsc::channel::<Request>();
|
let (tx, rx) = mpsc::channel::<Request>();
|
||||||
let completions: Arc<Mutex<VecDeque<Completion>>> =
|
let waiters: Waiters = Arc::new(Mutex::new(HashMap::new()));
|
||||||
Arc::new(Mutex::new(VecDeque::new()));
|
|
||||||
|
|
||||||
// Epoll machinery.
|
// Epoll machinery.
|
||||||
let epollfd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
|
let epollfd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
|
||||||
if epollfd < 0 {
|
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());
|
return Err(io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +172,6 @@ impl IoThread {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::close(epollfd);
|
libc::close(epollfd);
|
||||||
libc::close(wake_read);
|
|
||||||
libc::close(wake_write);
|
|
||||||
}
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
@@ -202,70 +198,51 @@ impl IoThread {
|
|||||||
libc::close(epollfd);
|
libc::close(epollfd);
|
||||||
libc::close(shutdown_read);
|
libc::close(shutdown_read);
|
||||||
libc::close(shutdown_write);
|
libc::close(shutdown_write);
|
||||||
libc::close(wake_read);
|
|
||||||
libc::close(wake_write);
|
|
||||||
}
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn pool thread.
|
// Spawn pool thread.
|
||||||
let pool_comps = completions.clone();
|
let pool_rt = rt.clone();
|
||||||
let pool_thread = std::thread::Builder::new()
|
let pool_thread = std::thread::Builder::new()
|
||||||
.name("smarm-io-pool".into())
|
.name("smarm-io-pool".into())
|
||||||
.spawn(move || pool_loop(rx, pool_comps, wake_write))?;
|
.spawn(move || pool_loop(rx, pool_rt))?;
|
||||||
|
|
||||||
// Spawn epoll thread.
|
// Spawn epoll thread.
|
||||||
let epoll_comps = completions.clone();
|
let epoll_waiters = waiters.clone();
|
||||||
let epoll_thread = std::thread::Builder::new()
|
let epoll_thread = std::thread::Builder::new()
|
||||||
.name("smarm-io-epoll".into())
|
.name("smarm-io-epoll".into())
|
||||||
.spawn(move || epoll_loop(epollfd, epoll_comps, wake_write))?;
|
.spawn(move || epoll_loop(epollfd, epoll_waiters, rt))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
tx,
|
tx,
|
||||||
completions,
|
waiters,
|
||||||
wake_read,
|
|
||||||
wake_write,
|
|
||||||
epollfd,
|
epollfd,
|
||||||
shutdown_read,
|
shutdown_read,
|
||||||
shutdown_write,
|
shutdown_write,
|
||||||
waiters: HashMap::new(),
|
|
||||||
pool_thread: Some(pool_thread),
|
pool_thread: Some(pool_thread),
|
||||||
epoll_thread: Some(epoll_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>) {
|
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
|
// Send can only fail if the pool has hung up, which only happens
|
||||||
// on shutdown. submit during shutdown is a bug.
|
// on shutdown. submit during shutdown is a bug.
|
||||||
self.tx
|
if self.tx.send(Request { pid, epoch, work }).is_err() {
|
||||||
.send(Request { pid, epoch, work })
|
panic!("smarm: io pool hung up unexpectedly (submit during shutdown)");
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wake_fd(&self) -> RawFd {
|
|
||||||
self.wake_read
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register interest in `fd` becoming readable/writable; record `pid`
|
/// Register interest in `fd` becoming readable/writable; record `pid`
|
||||||
/// as the parked waiter. The epoll thread will push a `FdReady`
|
/// as the parked waiter. The epoll thread unparks it on readiness.
|
||||||
/// completion when the kernel signals.
|
/// 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
|
/// EPOLLONESHOT: one wakeup per registration; the epoll thread DELs on
|
||||||
/// `epoll_del` on completion to free the slot for re-registration.
|
/// readiness, `cancel_waiter` DELs on an unwound wait.
|
||||||
pub fn epoll_register(
|
pub fn epoll_register(
|
||||||
&mut self,
|
&mut self,
|
||||||
fd: RawFd,
|
fd: RawFd,
|
||||||
@@ -274,20 +251,24 @@ impl IoThread {
|
|||||||
readable: bool,
|
readable: bool,
|
||||||
writable: bool,
|
writable: bool,
|
||||||
) -> io::Result<()> {
|
) -> 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
|
// Two actors waiting on the same fd would be a misuse: the kernel
|
||||||
// delivers exactly one EPOLLONESHOT wakeup, so the second waiter
|
// delivers exactly one EPOLLONESHOT wakeup, so the second waiter
|
||||||
// would hang. Reject up front.
|
// would hang. Reject up front.
|
||||||
if self.waiters.contains_key(&fd) {
|
if waiters.contains_key(&fd) {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::AlreadyExists,
|
io::ErrorKind::AlreadyExists,
|
||||||
"fd already has a parked waiter",
|
"fd already has a parked waiter",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Belt-and-braces: the unwind guard in `wait_fd` is responsible for
|
// Belt-and-braces: `cancel_waiter` is responsible for cleaning up a
|
||||||
// cleaning up a stopped waiter's registration, but a bare DEL is
|
// stopped waiter's registration, but a bare DEL is harmless if the
|
||||||
// harmless if the fd isn't registered (ENOENT) and removes any leak
|
// fd isn't registered (ENOENT) and removes any leak a path we
|
||||||
// a path we haven't thought of might leave behind.
|
// haven't thought of might leave behind.
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
||||||
}
|
}
|
||||||
@@ -309,19 +290,29 @@ impl IoThread {
|
|||||||
if r < 0 {
|
if r < 0 {
|
||||||
return Err(io::Error::last_os_error());
|
return Err(io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
self.waiters.insert(fd, (pid, epoch));
|
waiters.insert(fd, (pid, epoch));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove `fd` from the epollfd. Called by the scheduler after a
|
/// Remove `fd`'s waiter iff it is still `(pid, epoch)`, DELing the fd
|
||||||
/// `FdReady` completion, so the next `wait_readable(fd)` can ADD again.
|
/// from the epollfd in the same critical section. Returns whether the
|
||||||
///
|
/// entry was removed (the caller then decrements `io_fd_waiters`).
|
||||||
/// Does NOT touch `waiters` — that's the scheduler's bookkeeping; this
|
/// `false` means the epoll thread consumed the registration first —
|
||||||
/// is purely the kernel-side cleanup.
|
/// the fd may already carry someone else's fresh ADD; hands off.
|
||||||
pub fn epoll_deregister(&mut self, fd: RawFd) {
|
pub fn cancel_waiter(&mut self, fd: RawFd, pid: Pid, epoch: u32) -> bool {
|
||||||
// EPOLL_CTL_DEL of an already-removed fd returns ENOENT; ignore.
|
let mut waiters = match self.waiters.lock() {
|
||||||
unsafe {
|
Ok(g) => g,
|
||||||
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +333,10 @@ impl Drop for IoThread {
|
|||||||
let real_tx = std::mem::replace(&mut self.tx, dead_tx);
|
let real_tx = std::mem::replace(&mut self.tx, dead_tx);
|
||||||
drop(real_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() {
|
if let Some(h) = self.epoll_thread.take() {
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
@@ -355,8 +349,6 @@ impl Drop for IoThread {
|
|||||||
libc::close(self.epollfd);
|
libc::close(self.epollfd);
|
||||||
libc::close(self.shutdown_read);
|
libc::close(self.shutdown_read);
|
||||||
libc::close(self.shutdown_write);
|
libc::close(self.shutdown_write);
|
||||||
libc::close(self.wake_read);
|
|
||||||
libc::close(self.wake_write);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,36 +359,38 @@ impl Drop for IoThread {
|
|||||||
const SHUTDOWN_EPOLL_TOKEN: u64 = u64::MAX;
|
const SHUTDOWN_EPOLL_TOKEN: u64 = u64::MAX;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pool loop
|
// Pool loop (producer: Blocking completions)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn pool_loop(
|
fn pool_loop(rx: mpsc::Receiver<Request>, rt: Weak<RuntimeInner>) {
|
||||||
rx: mpsc::Receiver<Request>,
|
|
||||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
|
||||||
wake_write: RawFd,
|
|
||||||
) {
|
|
||||||
while let Ok(Request { pid, epoch, work }) = rx.recv() {
|
while let Ok(Request { pid, epoch, work }) = rx.recv() {
|
||||||
let result: IoResult = match panic::catch_unwind(panic::AssertUnwindSafe(work)) {
|
let result: IoResult = match panic::catch_unwind(panic::AssertUnwindSafe(work)) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(payload) => Err(payload),
|
Err(payload) => Err(payload),
|
||||||
};
|
};
|
||||||
completions
|
let Some(inner) = rt.upgrade() else { return };
|
||||||
.lock()
|
// Stash the result under the cold lock (generation-checked: an
|
||||||
.unwrap()
|
// actor stopped with the op in flight discards it), decrement the
|
||||||
.push_back(Completion::Blocking { pid, epoch, result });
|
// in-flight count, then wake through the epoch-matched unpark. The
|
||||||
wake_scheduler(wake_write);
|
// 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(
|
fn epoll_loop(epollfd: RawFd, waiters: Waiters, rt: Weak<RuntimeInner>) {
|
||||||
epollfd: RawFd,
|
|
||||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
|
||||||
wake_write: RawFd,
|
|
||||||
) {
|
|
||||||
// Buffer for epoll_wait. 64 is plenty for our scale; if a real load
|
// Buffer for epoll_wait. 64 is plenty for our scale; if a real load
|
||||||
// appears that needs more, this is a one-line change.
|
// appears that needs more, this is a one-line change.
|
||||||
const MAX_EVENTS: usize = 64;
|
const MAX_EVENTS: usize = 64;
|
||||||
@@ -424,26 +418,41 @@ fn epoll_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut shutdown_requested = false;
|
let mut shutdown_requested = false;
|
||||||
let mut pushed_any = false;
|
for ev in events.iter().take(n as usize) {
|
||||||
{
|
if ev.u64 == SHUTDOWN_EPOLL_TOKEN {
|
||||||
let mut q = completions.lock().unwrap();
|
shutdown_requested = true;
|
||||||
for ev in events.iter().take(n as usize) {
|
continue;
|
||||||
if ev.u64 == SHUTDOWN_EPOLL_TOKEN {
|
}
|
||||||
shutdown_requested = true;
|
let fd = ev.u64 as RawFd;
|
||||||
continue;
|
// 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;
|
entry
|
||||||
let evs = ev.events;
|
};
|
||||||
q.push_back(Completion::FdReady {
|
if let Some((pid, epoch)) = entry {
|
||||||
fd,
|
let Some(inner) = rt.upgrade() else { return };
|
||||||
events: evs,
|
inner.io_fd_waiters.fetch_sub(1, Ordering::AcqRel);
|
||||||
});
|
inner.unpark_at(pid, epoch);
|
||||||
pushed_any = true;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if pushed_any {
|
|
||||||
wake_scheduler(wake_write);
|
|
||||||
}
|
}
|
||||||
if shutdown_requested {
|
if shutdown_requested {
|
||||||
return;
|
return;
|
||||||
@@ -451,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)> {
|
fn make_pipe() -> io::Result<(RawFd, RawFd)> {
|
||||||
@@ -482,46 +472,3 @@ fn make_pipe() -> io::Result<(RawFd, RawFd)> {
|
|||||||
}
|
}
|
||||||
Ok((fds[0], fds[1]))
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+32
-6
@@ -24,15 +24,22 @@ pub mod io;
|
|||||||
pub mod mutex;
|
pub mod mutex;
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod pg;
|
||||||
pub mod link;
|
pub mod link;
|
||||||
pub mod gen_server;
|
pub mod gen_server;
|
||||||
|
pub mod gen_statem;
|
||||||
|
pub mod introspect;
|
||||||
|
#[cfg(feature = "observer")]
|
||||||
|
pub mod observer;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub(crate) mod park;
|
||||||
pub(crate) mod raw_mutex;
|
pub(crate) mod raw_mutex;
|
||||||
pub(crate) mod slot_state;
|
pub(crate) mod slot_state;
|
||||||
pub(crate) mod sync_shim;
|
pub(crate) mod sync_shim;
|
||||||
#[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures
|
#[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures
|
||||||
pub mod run_queue;
|
pub mod run_queue;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
|
pub mod causal;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Global allocator
|
// Global allocator
|
||||||
@@ -49,19 +56,38 @@ pub use channel::{
|
|||||||
channel, select, select_timeout, try_select, try_select_timeout, Receiver, RecvError,
|
channel, select, select_timeout, try_select, try_select_timeout, Receiver, RecvError,
|
||||||
RecvTimeoutError, Selectable, Sender,
|
RecvTimeoutError, Selectable, Sender,
|
||||||
};
|
};
|
||||||
pub use gen_server::{CallError, CallTimeoutError, CastError, GenServer, ServerBuilder, ServerCtx, ServerRef, Watcher};
|
pub use gen_server::{
|
||||||
|
call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer,
|
||||||
|
NamedGenServerBuilder, GenServerBuilder, GenServerCtx, GenServerName, GenServerRef, TimerHandle, Watcher,
|
||||||
|
};
|
||||||
|
pub use gen_statem::{
|
||||||
|
CallError as GenStatemCallError, Cx, Machine, Reply, Resolution, SendError as GenStatemSendError,
|
||||||
|
GenStatemRef,
|
||||||
|
};
|
||||||
|
pub use introspect::{
|
||||||
|
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
|
||||||
|
TreeNode, SNAPSHOT_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "observer")]
|
||||||
|
pub use observer::{ObserverReply, ObserverRequest};
|
||||||
pub use link::{link, trap_exit, unlink, ExitSignal};
|
pub use link::{link, trap_exit, unlink, ExitSignal};
|
||||||
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
|
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
|
||||||
pub use mutex::{LockTimeout, Mutex, MutexGuard};
|
pub use mutex::{LockTimeout, Mutex, MutexGuard};
|
||||||
pub use pid::Pid;
|
pub use pid::{Addressable, Erased, Name, Pid, RawPid};
|
||||||
pub use registry::{name_of, register, unregister, whereis, RegisterError};
|
pub use pg::{dispatch, join, leave, members, members_as, pick, pick_as, Incarnation, Member, NodeId};
|
||||||
|
pub use registry::{
|
||||||
|
install, lookup_as, register, send, send_dyn, send_to, unregister, whereis, RegisterError,
|
||||||
|
SendError,
|
||||||
|
};
|
||||||
pub use runtime::{init, Config, Runtime};
|
pub use runtime::{init, Config, Runtime};
|
||||||
pub use scheduler::{
|
pub use scheduler::{
|
||||||
block_on_io, request_stop, run, self_pid, sleep, spawn, spawn_under, wait_readable,
|
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named,
|
||||||
wait_readable_timeout, wait_writable, wait_writable_timeout, yield_now, FdArm, JoinError,
|
send_after_named_wall, send_after_wall, sleep, sleep_wall,
|
||||||
JoinHandle,
|
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable,
|
||||||
|
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle,
|
||||||
};
|
};
|
||||||
pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
|
pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
|
||||||
|
pub use timer::TimerId;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// check!()
|
// check!()
|
||||||
|
|||||||
+8
-3
@@ -95,7 +95,8 @@ pub fn trap_exit() -> Receiver<ExitSignal> {
|
|||||||
/// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller
|
/// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller
|
||||||
/// (a message if trapping, otherwise a cooperative stop). Linking yourself, or
|
/// (a message if trapping, otherwise a cooperative stop). Linking yourself, or
|
||||||
/// re-linking an existing peer, is a no-op.
|
/// re-linking an existing peer, is a no-op.
|
||||||
pub fn link(target: Pid) {
|
pub fn link<A>(target: Pid<A>) {
|
||||||
|
let target = target.erase();
|
||||||
let me = self_pid();
|
let me = self_pid();
|
||||||
if target == me {
|
if target == me {
|
||||||
return;
|
return;
|
||||||
@@ -134,7 +135,10 @@ pub fn link(target: Pid) {
|
|||||||
|
|
||||||
if registered_on_target {
|
if registered_on_target {
|
||||||
with_runtime(|inner| {
|
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();
|
let mut cold = slot.cold.lock();
|
||||||
if !cold.links.contains(&target) {
|
if !cold.links.contains(&target) {
|
||||||
cold.links.push(target);
|
cold.links.push(target);
|
||||||
@@ -163,7 +167,8 @@ pub fn link(target: Pid) {
|
|||||||
///
|
///
|
||||||
/// After this, neither actor's death propagates to the other. A no-op if the
|
/// After this, neither actor's death propagates to the other. A no-op if the
|
||||||
/// two were not linked.
|
/// two were not linked.
|
||||||
pub fn unlink(target: Pid) {
|
pub fn unlink<A>(target: Pid<A>) {
|
||||||
|
let target = target.erase();
|
||||||
let me = self_pid();
|
let me = self_pid();
|
||||||
if target == me {
|
if target == me {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+104
-63
@@ -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
|
//! Say one actor manages a pool of workers and needs to know when a worker
|
||||||
//! `target` terminates, and hands back a [`Monitor`] — the [`Receiver`] to read
|
//! exits, so it can replace it. The worker does not need to know it is being
|
||||||
//! it from, plus the identity (`id`, `target`) needed to take the registration
|
//! watched, and nothing about the worker's own behavior should change because
|
||||||
//! back down with [`demonitor`]. A monitor is:
|
//! 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
|
//! use smarm::{monitor, run, spawn, DownReason};
|
||||||
//! the death beyond the notification (contrast a *link*, which propagates
|
|
||||||
//! failure);
|
|
||||||
//! - **one-shot** — exactly one `Down` is ever sent for a given monitor.
|
|
||||||
//! The returned channel closes afterwards, so a second `recv()` yields
|
|
||||||
//! `Err(RecvError)`.
|
|
||||||
//!
|
//!
|
||||||
//! This generalizes the older single-`supervisor_channel` mechanism: a
|
//! run(|| {
|
||||||
//! supervisor is just a hard-wired monitor that the parent installs at spawn
|
//! let worker = spawn(|| {
|
||||||
//! time. Here any actor may monitor any pid, any number of times.
|
//! // 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
|
//! let down = m.rx.recv().expect("monitor channel closed before Down");
|
||||||
//! has a single owner and is delivered to whoever `join()`s the actor (as
|
//! assert_eq!(down.pid, pid);
|
||||||
//! `JoinError`); a monitor only learns *that* it panicked, not the value.
|
//! assert_eq!(down.reason, DownReason::Exit);
|
||||||
//! Monitoring a pid that is already gone (reclaimed, or never alive) yields
|
//! });
|
||||||
//! [`DownReason::NoProc`] immediately, mirroring Erlang's `noproc`.
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! ## Demonitoring
|
//! A monitor is one-directional and one-shot:
|
||||||
//!
|
//!
|
||||||
//! Each `monitor()` registration is tagged with a process-unique [`MonitorId`].
|
//! - **One-directional**: the watcher learns that the target died, but the
|
||||||
//! [`demonitor`] removes the registration named by a [`Monitor`] from its
|
//! target is completely unaffected. It never learns it was being watched,
|
||||||
//! target's slot, returning `Some(id)` if a live registration was found or
|
//! and its own behavior and lifetime do not change because of the monitor.
|
||||||
//! `None` if it had already fired (or the target is gone). Dropping the
|
//! This is the opposite of a [`link`](mod@crate::link), which is bidirectional:
|
||||||
//! [`Monitor`] afterwards discards any `Down` that the target had *already*
|
//! linking two actors means an abnormal death on either side can bring the
|
||||||
//! queued — the equivalent of Erlang's `demonitor(Ref, [flush])`.
|
//! 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
|
//! If the target panicked, [`Down`] tells you *that* it panicked
|
||||||
//! shared-state mutex, so a target that is still alive when its monitor is
|
//! ([`DownReason::Panic`]), but not the panic's payload. The payload has a
|
||||||
//! registered is guaranteed to deliver a real `Down`; there is no window in
|
//! single owner: it is handed to whichever caller `join()`s the actor's
|
||||||
//! which the death slips between the liveness check and the registration.
|
//! [`JoinHandle`](crate::JoinHandle), as a `JoinError`. A monitor only needs
|
||||||
//! `demonitor` is protected by the generation half of the pid: if the target
|
//! to know that something went wrong, not reproduce the exact value that
|
||||||
//! has died and its slot index been recycled, `slot_mut(target)` fails the
|
//! caused it, so it gets the reason and nothing else.
|
||||||
//! 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.
|
//! 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::channel::{channel, Receiver, Sender};
|
||||||
use crate::pid::Pid;
|
use crate::pid::Pid;
|
||||||
@@ -51,8 +87,8 @@ use crate::scheduler::with_runtime;
|
|||||||
|
|
||||||
/// Why a monitored actor went down.
|
/// Why a monitored actor went down.
|
||||||
///
|
///
|
||||||
/// `Copy` because it carries no payload — see the module docs for why the
|
/// Carries no payload: see the module docs for why a monitor never receives
|
||||||
/// panic payload is *not* included here.
|
/// the panic value itself.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DownReason {
|
pub enum DownReason {
|
||||||
/// The target returned normally.
|
/// The target returned normally.
|
||||||
@@ -76,21 +112,22 @@ pub struct Down {
|
|||||||
pub reason: DownReason,
|
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
|
/// Opaque and `Copy`. Never reused for the life of the runtime, so if you
|
||||||
/// it is never reused for the lifetime of the runtime — distinct `monitor()`
|
/// monitor the same target more than once, each call's id is distinct. This
|
||||||
/// calls on the same target get distinct ids, which is what lets [`demonitor`]
|
/// is what lets [`demonitor`] tear down exactly one of several monitors on
|
||||||
/// tear down exactly one of several monitors on a target.
|
/// the same target without disturbing the others.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct MonitorId(pub(crate) u64);
|
pub struct MonitorId(pub(crate) u64);
|
||||||
|
|
||||||
/// A live monitor: the receiving end of the one-shot [`Down`] channel, plus the
|
/// A live monitor: the receiving end of the one-shot [`Down`] channel, plus the
|
||||||
/// identity needed to [`demonitor`] it.
|
/// identity needed to [`demonitor`] it.
|
||||||
///
|
///
|
||||||
/// Read the notification from [`Monitor::rx`]. Not `Clone` (the receiver is a
|
/// Read the notification from [`Monitor::rx`]. Not `Clone`, since only one
|
||||||
/// single consumer). Dropping it closes the receiving end; if a `Down` was
|
/// side is meant to consume it. Dropping a `Monitor` closes the receiving
|
||||||
/// already queued it is discarded with the channel.
|
/// end; if a `Down` had already arrived but was never read, it is discarded
|
||||||
|
/// along with it.
|
||||||
pub struct Monitor {
|
pub struct Monitor {
|
||||||
/// This registration's process-unique id.
|
/// This registration's process-unique id.
|
||||||
pub id: MonitorId,
|
pub id: MonitorId,
|
||||||
@@ -106,13 +143,15 @@ pub struct Monitor {
|
|||||||
/// If `target` is still live, the `Down` arrives when it terminates. If
|
/// If `target` is still live, the `Down` arrives when it terminates. If
|
||||||
/// `target` is already gone, a [`DownReason::NoProc`] `Down` is queued
|
/// `target` is already gone, a [`DownReason::NoProc`] `Down` is queued
|
||||||
/// immediately so the caller's `rx.recv()` returns without parking.
|
/// immediately so the caller's `rx.recv()` returns without parking.
|
||||||
pub fn monitor(target: Pid) -> Monitor {
|
pub fn monitor<A>(target: Pid<A>) -> Monitor {
|
||||||
|
let target = target.erase();
|
||||||
let (tx, rx) = channel::<Down>();
|
let (tx, rx) = channel::<Down>();
|
||||||
|
|
||||||
// Register under the target's cold lock. `tx.clone()` takes the channel's
|
// Implementation note: registration happens under the target's cold
|
||||||
// own lock — a Channel-class RawMutex, explicitly permitted *under* a Leaf
|
// lock. `tx.clone()` takes the channel's own lock, a Channel-class
|
||||||
// (cold) lock by the lock order (see raw_mutex.rs). We must still not
|
// RawMutex, which is explicitly permitted under a Leaf (cold) lock by
|
||||||
// *send* under the lock, as `Sender::send` can unpark a parked receiver,
|
// 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.
|
// and there's no reason to nest that.
|
||||||
let (id, registered) = with_runtime(|inner| {
|
let (id, registered) = with_runtime(|inner| {
|
||||||
let id = inner.alloc_monitor_id();
|
let id = inner.alloc_monitor_id();
|
||||||
@@ -139,19 +178,21 @@ pub fn monitor(target: Pid) -> Monitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
|
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
|
||||||
/// on the target's slot and removed, or `None` if there was nothing to remove
|
/// and removed, so no `Down` will arrive on `m.rx` from here on. Returns
|
||||||
/// — the target already fired its `Down` (the registration is drained on
|
/// `None` if there was nothing left to remove: the target had already gone
|
||||||
/// finalize), was never alive (`NoProc`), or has been reclaimed.
|
/// 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
|
/// This only stops a *future* `Down`. If you also want to discard a `Down`
|
||||||
/// *already* queued (the finalize-races-demonitor case), drop `m` afterwards;
|
/// that already arrived (or is about to, in a race with this call), drop `m`
|
||||||
/// dropping the [`Monitor`] closes its receiver and the queued notice goes with
|
/// instead of, or in addition to, calling this: dropping the [`Monitor`]
|
||||||
/// it — the analogue of Erlang's `demonitor(Ref, [flush])`.
|
/// closes its receiver and any queued notice is discarded with it.
|
||||||
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
|
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
|
||||||
// Remove the registration under the target's cold lock, but move the
|
// Implementation note: the registration is removed under the target's
|
||||||
// `Sender` *out* and let it drop only after the lock is released:
|
// cold lock, but the `Sender` is moved *out* and dropped only after the
|
||||||
// dropping the last sender runs `Sender::drop`, which may unpark a parked
|
// lock is released. Dropping the last sender runs `Sender::drop`, which
|
||||||
// receiver — legal under a cold lock, but pointless to nest.
|
// may unpark a parked receiver; legal under a cold lock, but pointless
|
||||||
|
// to nest.
|
||||||
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
|
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
|
||||||
let slot = inner.slot_at(m.target)?;
|
let slot = inner.slot_at(m.target)?;
|
||||||
let mut cold = slot.cold.lock();
|
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
|
//! smarm actors normally coordinate by sending messages, and for a piece of
|
||||||
//! blocking the OS thread. Every lock attempt is bounded by a timeout.
|
//! 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
|
//! The key difference from `std::sync::Mutex` is what happens on contention.
|
||||||
//! `Send + Sync` and can be shared across scheduler threads.
|
//! [`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::pid::Pid;
|
||||||
use crate::scheduler;
|
use crate::scheduler;
|
||||||
@@ -15,8 +92,14 @@ use std::collections::VecDeque;
|
|||||||
use std::sync::{Arc, Mutex as StdMutex};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::Duration;
|
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);
|
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)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
pub struct LockTimeout;
|
pub struct LockTimeout;
|
||||||
|
|
||||||
@@ -64,20 +147,23 @@ impl MutexCore {
|
|||||||
impl TimerTarget for MutexCore {
|
impl TimerTarget for MutexCore {
|
||||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||||
let unpark = {
|
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.
|
// Remove from waiters only if still there with matching epoch.
|
||||||
// If the lock was already granted (holder == Some(pid)), the
|
// 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.
|
// will see `is_holder == true` and return Ok.
|
||||||
if st.holder == Some(pid) {
|
if st.holder == Some(pid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let pos = st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch);
|
match st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch) {
|
||||||
if pos.is_some() {
|
Some(pos) => {
|
||||||
st.waiters.remove(pos.unwrap());
|
st.waiters.remove(pos);
|
||||||
true
|
true
|
||||||
} else {
|
}
|
||||||
false
|
None => false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if unpark {
|
if unpark {
|
||||||
@@ -97,6 +183,8 @@ pub struct Mutex<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T> 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 {
|
pub fn new(value: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
core: Arc::new(MutexCore::new(DEFAULT_TIMEOUT)),
|
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) {
|
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> {
|
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)
|
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> {
|
pub fn lock_timeout(&self, timeout: Duration) -> Result<MutexGuard<'_, T>, LockTimeout> {
|
||||||
// Outside the runtime (e.g. in tests, after run() returns) there is no
|
// 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.
|
// 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.
|
// 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() {
|
if st.holder.is_none() {
|
||||||
st.holder = Some(me);
|
st.holder = Some(me);
|
||||||
drop(st);
|
drop(st);
|
||||||
let value = self.value.lock().unwrap().take()
|
let taken = match self.value.lock() {
|
||||||
.expect("Mutex: value missing on free fast path");
|
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) });
|
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.
|
// Slow path: register as a waiter, set timeout, park.
|
||||||
let _np = scheduler::NoPreempt::enter();
|
let _np = scheduler::NoPreempt::enter();
|
||||||
let epoch = {
|
let epoch = {
|
||||||
let mut st = self.core.state.lock().unwrap();
|
let mut st = match self.core.state.lock() {
|
||||||
// begin_wait is lock-free — legal under the state lock; this
|
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
|
// makes the epoch atomic with the registration's visibility to
|
||||||
// grants and timeouts.
|
// grants and timeouts.
|
||||||
let epoch = scheduler::begin_wait();
|
let epoch = scheduler::begin_wait();
|
||||||
@@ -149,30 +270,51 @@ impl<T> Mutex<T> {
|
|||||||
scheduler::insert_wait_timer(deadline, me, target, epoch);
|
scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||||
scheduler::park_current();
|
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
|
// wait (both epoch-stamped; a stop wake unwinds out of
|
||||||
// park_current). The one-shot interpretation below is therefore
|
// park_current). The one-shot interpretation below is therefore
|
||||||
// exhaustive. Are we the holder?
|
// 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 {
|
if is_holder {
|
||||||
let value = self.value.lock().unwrap().take()
|
let taken = match self.value.lock() {
|
||||||
.expect("Mutex: value missing after grant");
|
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) })
|
Ok(MutexGuard { mutex: self, value: Some(value) })
|
||||||
} else {
|
} else {
|
||||||
Err(LockTimeout)
|
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>> {
|
pub fn try_lock(&self) -> Option<MutexGuard<'_, T>> {
|
||||||
let me = crate::actor::current_pid()?;
|
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() {
|
if st.holder.is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
st.holder = Some(me);
|
st.holder = Some(me);
|
||||||
drop(st);
|
drop(st);
|
||||||
let value = self.value.lock().unwrap().take()
|
let taken = match self.value.lock() {
|
||||||
.expect("Mutex: value missing on try_lock free path");
|
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) })
|
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
|
// tracking and just grab the value mutex directly. This is safe because
|
||||||
// outside the runtime there are no green threads competing.
|
// outside the runtime there are no green threads competing.
|
||||||
let value = loop {
|
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; }
|
if let Some(v) = v { break v; }
|
||||||
std::thread::yield_now();
|
std::thread::yield_now();
|
||||||
};
|
};
|
||||||
@@ -192,6 +337,10 @@ impl<T> Mutex<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Clone for 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 {
|
fn clone(&self) -> Self {
|
||||||
Self { core: self.core.clone(), value: self.value.clone() }
|
Self { core: self.core.clone(), value: self.value.clone() }
|
||||||
}
|
}
|
||||||
@@ -205,6 +354,10 @@ unsafe impl<T: Send> Sync for Mutex<T> {}
|
|||||||
// Guard
|
// 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> {
|
pub struct MutexGuard<'a, T> {
|
||||||
mutex: &'a Mutex<T>,
|
mutex: &'a Mutex<T>,
|
||||||
value: Option<T>,
|
value: Option<T>,
|
||||||
@@ -212,30 +365,51 @@ pub struct MutexGuard<'a, T> {
|
|||||||
|
|
||||||
impl<T> std::ops::Deref for MutexGuard<'_, T> {
|
impl<T> std::ops::Deref for MutexGuard<'_, T> {
|
||||||
type Target = 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> {
|
impl<T> std::ops::DerefMut for MutexGuard<'_, T> {
|
||||||
fn deref_mut(&mut self) -> &mut 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> {
|
impl<T: std::fmt::Debug> std::fmt::Debug for MutexGuard<'_, T> {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
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")
|
f.debug_tuple("MutexGuard")
|
||||||
.field(self.value.as_ref().expect("MutexGuard: value missing"))
|
.field(value)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Drop for MutexGuard<'_, T> {
|
impl<T> Drop for MutexGuard<'_, T> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let v = self.value.take().expect("MutexGuard: double drop");
|
let v = match self.value.take() {
|
||||||
*self.mutex.value.lock().unwrap() = Some(v);
|
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 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() {
|
match st.waiters.pop_front() {
|
||||||
Some(w) => {
|
Some(w) => {
|
||||||
st.holder = Some(w.pid);
|
st.holder = Some(w.pid);
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
//! RFC 016 — runtime observability (Chunk 4: the observer gen_server).
|
||||||
|
//!
|
||||||
|
//! A thin [`GenServer`] that consumes the Chunk-1 read primitive
|
||||||
|
//! ([`snapshot`](crate::snapshot) / [`tree`](crate::tree) /
|
||||||
|
//! [`actor_info`](crate::actor_info)) over a message interface — the live
|
||||||
|
//! `observer` process, in the OTP sense. It is a *transport*, not the
|
||||||
|
//! mechanism: the synchronous internal read stays the primitive, and the
|
||||||
|
//! observer is just one more consumer of it alongside the test suite. This is
|
||||||
|
//! also the read half of the future RFC 003 control plane — the same actor
|
||||||
|
//! gains write verbs there rather than a second consumer being spun up
|
||||||
|
//! (DECISION D12).
|
||||||
|
//!
|
||||||
|
//! ## Why it is feature-gated (DECISION D10)
|
||||||
|
//!
|
||||||
|
//! The read primitive (Chunks 1–3) is always present and unflagged: it is pure
|
||||||
|
//! reads and the test suite leans on it. The *gen_server* sits behind the
|
||||||
|
//! `observer` Cargo feature, off by default, matching RFC 003's dev-only
|
||||||
|
//! feature-flag stance — a release build pays nothing for a live observer it
|
||||||
|
//! never starts.
|
||||||
|
//!
|
||||||
|
//! ## The protocol is the contract (DECISION D11)
|
||||||
|
//!
|
||||||
|
//! [`ObserverRequest`] / [`ObserverReply`] *are* the wire contract. They carry
|
||||||
|
//! no version field of their own because the payloads already do:
|
||||||
|
//! [`RuntimeSnapshot`](crate::RuntimeSnapshot) and
|
||||||
|
//! [`RuntimeTree`](crate::RuntimeTree) each carry
|
||||||
|
//! [`SNAPSHOT_FORMAT_VERSION`](crate::SNAPSHOT_FORMAT_VERSION) (D1). The owned
|
||||||
|
//! snapshot — a potentially large `Vec<ActorInfo>` — travels over the call
|
||||||
|
//! channel by value; that is intended, it is exactly what a remote observer
|
||||||
|
//! (RFC 011) will serialize across a node boundary.
|
||||||
|
|
||||||
|
use crate::gen_server::{GenServer, GenServerBuilder, GenServerRef};
|
||||||
|
use crate::introspect::{actor_info, snapshot, tree};
|
||||||
|
use crate::introspect::{ActorInfo, RuntimeSnapshot, RuntimeTree};
|
||||||
|
use crate::pid::Pid;
|
||||||
|
|
||||||
|
/// A read-only request to the observer. Each verb maps one-to-one onto a
|
||||||
|
/// Chunk-1 read; there are deliberately no mutating verbs here (those are RFC
|
||||||
|
/// 003, D12).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ObserverRequest {
|
||||||
|
/// Whole-runtime [`snapshot`].
|
||||||
|
Snapshot,
|
||||||
|
/// Parentage forest, folded from a snapshot ([`tree`]).
|
||||||
|
Tree,
|
||||||
|
/// Coherent view of one actor ([`actor_info`]); `None` reply if the pid is
|
||||||
|
/// stale, forged, or names a vacant slot.
|
||||||
|
ActorInfo(Pid),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The observer's reply, tagged to match the [`ObserverRequest`] verb. Each
|
||||||
|
/// variant wraps the owned Chunk-1 read result unchanged — the observer adds no
|
||||||
|
/// interpretation, it is pure transport.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ObserverReply {
|
||||||
|
Snapshot(RuntimeSnapshot),
|
||||||
|
Tree(RuntimeTree),
|
||||||
|
ActorInfo(Option<ActorInfo>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The observer server. Stateless by construction (a ZST): every reply is
|
||||||
|
/// derived freshly from the live runtime on each call, so there is nothing to
|
||||||
|
/// keep between requests.
|
||||||
|
pub struct Observer;
|
||||||
|
|
||||||
|
impl GenServer for Observer {
|
||||||
|
type Call = ObserverRequest;
|
||||||
|
type Reply = ObserverReply;
|
||||||
|
/// No async verbs: the observer is request/reply only. `Infallible` is
|
||||||
|
/// uninhabited, so a `cast` can never be constructed and
|
||||||
|
/// [`handle_cast`](GenServer::handle_cast) is statically unreachable.
|
||||||
|
type Cast = core::convert::Infallible;
|
||||||
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
|
fn handle_call(&mut self, request: ObserverRequest) -> ObserverReply {
|
||||||
|
match request {
|
||||||
|
ObserverRequest::Snapshot => ObserverReply::Snapshot(snapshot()),
|
||||||
|
ObserverRequest::Tree => ObserverReply::Tree(tree()),
|
||||||
|
ObserverRequest::ActorInfo(pid) => ObserverReply::ActorInfo(actor_info(pid)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_cast(&mut self, request: core::convert::Infallible) {
|
||||||
|
// Uninhabited: this match has no arms because `Cast` cannot be
|
||||||
|
// constructed. It documents at the type level that the observer takes
|
||||||
|
// no fire-and-forget traffic.
|
||||||
|
match request {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the observer under the current actor and hand back its [`GenServerRef`].
|
||||||
|
/// Shorthand for `GenServerBuilder::new(Observer).start()`; use the builder
|
||||||
|
/// directly (e.g. `.under(sup)`) to slot it into a supervision tree.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use smarm::run;
|
||||||
|
/// use smarm::observer::{self, ObserverRequest, ObserverReply};
|
||||||
|
///
|
||||||
|
/// run(|| {
|
||||||
|
/// let obs = observer::start();
|
||||||
|
///
|
||||||
|
/// // Ask for a whole-runtime snapshot over the call channel.
|
||||||
|
/// let ObserverReply::Snapshot(snap) = obs.call(ObserverRequest::Snapshot).unwrap()
|
||||||
|
/// else { panic!("snapshot verb must reply with a snapshot") };
|
||||||
|
///
|
||||||
|
/// // The observer is itself a scheduled actor, so it appears in the very
|
||||||
|
/// // snapshot it produced — transport over the same read every consumer sees.
|
||||||
|
/// assert!(snap.actors.iter().any(|a| a.pid == obs.pid()));
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
pub fn start() -> GenServerRef<Observer> {
|
||||||
|
GenServerBuilder::new(Observer).start()
|
||||||
|
}
|
||||||
+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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
//! Process groups: one name, many actors.
|
||||||
|
//!
|
||||||
|
//! 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).
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! ## Joining and reading a group
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use smarm::{channel, join, leave, members, pick, run, spawn};
|
||||||
|
//!
|
||||||
|
//! run(|| {
|
||||||
|
//! let (tx1, rx1) = channel::<()>();
|
||||||
|
//! let (tx2, rx2) = channel::<()>();
|
||||||
|
//! let w1 = spawn(move || { rx1.recv().unwrap(); });
|
||||||
|
//! let w2 = spawn(move || { rx2.recv().unwrap(); });
|
||||||
|
//!
|
||||||
|
//! // Workers join the group; `members` is the live view of it.
|
||||||
|
//! join("pool", w1.pid());
|
||||||
|
//! join("pool", w2.pid());
|
||||||
|
//! assert_eq!(members("pool").len(), 2);
|
||||||
|
//!
|
||||||
|
//! // 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()]);
|
||||||
|
//! assert_eq!(pick("pool"), Some(w2.pid()));
|
||||||
|
//!
|
||||||
|
//! // Voluntary departure works too.
|
||||||
|
//! leave("pool", w2.pid());
|
||||||
|
//! assert!(pick("pool").is_none());
|
||||||
|
//!
|
||||||
|
//! tx2.send(()).unwrap();
|
||||||
|
//! w2.join().unwrap();
|
||||||
|
//! });
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! [`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.
|
||||||
|
//!
|
||||||
|
//! ## Membership ends on its own
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! ## Sending to a group
|
||||||
|
//!
|
||||||
|
//! 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:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use smarm::{dispatch, join, Addressable};
|
||||||
|
//!
|
||||||
|
//! struct Job(String);
|
||||||
|
//! struct Worker;
|
||||||
|
//! impl Addressable for Worker { type Msg = Job; }
|
||||||
|
//!
|
||||||
|
//! // Each worker has published a `Pid<Worker>` inbox and joined the pool.
|
||||||
|
//! join("workers", worker_a);
|
||||||
|
//! join("workers", worker_b);
|
||||||
|
//!
|
||||||
|
//! // 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:?}"),
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 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};
|
||||||
|
use crate::registry::{send_to, SendError};
|
||||||
|
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.
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub struct NodeId(u32);
|
||||||
|
|
||||||
|
impl NodeId {
|
||||||
|
#[inline]
|
||||||
|
pub const fn new(v: u32) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
#[inline]
|
||||||
|
pub const fn get(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for NodeId {
|
||||||
|
#[inline]
|
||||||
|
fn from(v: u32) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node's incarnation epoch — the BEAM `Creation` field adopted verbatim: it
|
||||||
|
/// separates a crashed node from its restart. Fixed for the life of a run
|
||||||
|
/// until clustering supplies a real one.
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub struct Incarnation(u32);
|
||||||
|
|
||||||
|
impl Incarnation {
|
||||||
|
#[inline]
|
||||||
|
pub const fn new(v: u32) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
#[inline]
|
||||||
|
pub const fn get(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for Incarnation {
|
||||||
|
#[inline]
|
||||||
|
fn from(v: u32) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub const DEFAULT_INCARNATION: Incarnation = Incarnation(1);
|
||||||
|
|
||||||
|
/// A group member's full identity: `(node, incarnation, pid)`.
|
||||||
|
///
|
||||||
|
/// 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 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.
|
||||||
|
pub node: NodeId,
|
||||||
|
/// The node's incarnation epoch at the time of joining.
|
||||||
|
pub incarnation: Incarnation,
|
||||||
|
/// Pure local slot identity — unchanged; cluster identity is layered
|
||||||
|
/// *around* it here rather than overloading `Pid::generation`.
|
||||||
|
pub pid: Pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One membership: a [`Member`] and the [`Monitor`] that watches its liveness.
|
||||||
|
/// 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 {
|
||||||
|
member: Member,
|
||||||
|
monitor: Monitor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Locking discipline. Held under one Leaf-class `RawMutex` on `RuntimeInner`,
|
||||||
|
/// mirroring the registry, and never held together with another Leaf lock (it
|
||||||
|
/// never touches the registry or a slot's cold lock). The two operations that
|
||||||
|
/// do need another lock are kept off the group-lock path:
|
||||||
|
///
|
||||||
|
/// - `monitor()` / `demonitor()` take the target's cold lock (also Leaf), so
|
||||||
|
/// they run *before* / *after* the group lock, never under it.
|
||||||
|
/// - draining a monitor with `try_recv` takes the channel's Channel-class
|
||||||
|
/// lock, which the lock order permits *under* a Leaf; a channel critical
|
||||||
|
/// section only does the lock-free unpark protocol, so no Leaf ever nests
|
||||||
|
/// under it.
|
||||||
|
///
|
||||||
|
/// Evicted and rejected [`Monitor`]s are therefore dropped only *after* the
|
||||||
|
/// group lock is released, so a receiver-drop never runs a wakeup under the
|
||||||
|
/// lock — the same discipline as `demonitor`.
|
||||||
|
pub(crate) struct ProcessGroups {
|
||||||
|
groups: HashMap<String, Vec<Membership>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessGroups {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self { groups: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert `ms` into `group`. Idempotent on the *member*: if the member is
|
||||||
|
/// already present the new membership is handed back (`Some`) so the caller
|
||||||
|
/// can tear its now-redundant monitor down outside the lock; `None` means
|
||||||
|
/// it was inserted.
|
||||||
|
fn join(&mut self, group: &str, ms: Membership) -> Option<Membership> {
|
||||||
|
let v = self.groups.entry(group.to_owned()).or_default();
|
||||||
|
if v.iter().any(|e| e.member == ms.member) {
|
||||||
|
return Some(ms);
|
||||||
|
}
|
||||||
|
v.push(ms);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove `member`'s membership from `group`, returning it (so the caller
|
||||||
|
/// can `demonitor` it outside the lock). An emptied group is pruned.
|
||||||
|
fn leave(&mut self, group: &str, member: Member) -> Option<Membership> {
|
||||||
|
let v = self.groups.get_mut(group)?;
|
||||||
|
let pos = v.iter().position(|e| e.member == member)?;
|
||||||
|
let removed = v.remove(pos);
|
||||||
|
if v.is_empty() {
|
||||||
|
self.groups.remove(group);
|
||||||
|
}
|
||||||
|
Some(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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; 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| {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < v.len() {
|
||||||
|
if pred(&v[i].member) {
|
||||||
|
evicted.push(v.remove(i).monitor);
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!v.is_empty()
|
||||||
|
});
|
||||||
|
evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
v.iter()
|
||||||
|
.filter_map(|e| match e.monitor.rx.try_recv() {
|
||||||
|
// A Down arrived, or the channel closed and drained: dead.
|
||||||
|
Ok(Some(_)) | Err(_) => Some(e.member.pid),
|
||||||
|
// Empty but open — the sender still lives in the slot: alive.
|
||||||
|
Ok(None) => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if dead.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.remove_where(|m| dead.contains(&m.pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw enumeration of a group's members — no liveness filtering. Used by
|
||||||
|
/// tests to assert storage state independently of the read-path backstop.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn members_of(&self, group: &str) -> Vec<Member> {
|
||||||
|
self.groups
|
||||||
|
.get(group)
|
||||||
|
.map(|v| v.iter().map(|e| e.member).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live members of `group`, in insertion order. The `is_live` oracle is the
|
||||||
|
/// read-path backstop: a member whose slot is already dead is
|
||||||
|
/// dropped from the *result* even if its `Down` has not been drained yet.
|
||||||
|
/// Backstop only — the entry stays in storage; eviction is the monitor's
|
||||||
|
/// job (`reap_group`).
|
||||||
|
fn members_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Vec<Pid> {
|
||||||
|
self.groups
|
||||||
|
.get(group)
|
||||||
|
.map(|v| v.iter().map(|e| e.member.pid).filter(|&p| is_live(p)).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first live member of `group` in insertion order — stateless
|
||||||
|
/// first-live `pick`, with the same read-path backstop as `members_where`.
|
||||||
|
fn first_member_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Option<Pid> {
|
||||||
|
self.groups.get(group)?.iter().map(|e| e.member.pid).find(|&p| is_live(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the full member identity for `pid` from runtime identity.
|
||||||
|
fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member {
|
||||||
|
Member { node: inner.node_id, incarnation: inner.incarnation, pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `pid` a live actor right now? Generation-checked atomic slot-word read,
|
||||||
|
/// no lock — identical to the registry's guard. The read-path backstop: a
|
||||||
|
/// generation is never reused, so a dead member is detectable independently of
|
||||||
|
/// whether its monitor `Down` has been drained yet.
|
||||||
|
fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
|
||||||
|
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `pid` to `group`. The same pid may join many groups; within one group a
|
||||||
|
/// 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 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. The
|
||||||
|
// registration races `finalize_actor` under that cold lock exactly as every
|
||||||
|
// other monitor does, so no death can slip between the join and the monitor
|
||||||
|
// being in place.
|
||||||
|
let mon = monitor(pid);
|
||||||
|
|
||||||
|
let (rejected, reaped) = with_runtime(|inner| {
|
||||||
|
let ms = Membership { member: member_for(inner, pid), monitor: mon };
|
||||||
|
let mut pg = inner.process_groups.lock();
|
||||||
|
let reaped = pg.reap_group(&group);
|
||||||
|
let rejected = pg.join(&group, ms);
|
||||||
|
(rejected, reaped)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Outside the group lock: drop the reaped (dead) monitors, and if this join
|
||||||
|
// was redundant, demonitor + drop the extra monitor we just installed.
|
||||||
|
drop(reaped);
|
||||||
|
match rejected {
|
||||||
|
Some(dup) => {
|
||||||
|
demonitor(&dup.monitor);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop `pid`'s membership of `group`. Returns whether a membership was
|
||||||
|
/// removed. The membership's monitor is demonitored and dropped.
|
||||||
|
///
|
||||||
|
/// Panics if called outside `Runtime::run()`.
|
||||||
|
pub fn leave<A>(group: &str, pid: Pid<A>) -> bool {
|
||||||
|
let pid = pid.erase();
|
||||||
|
let (removed, reaped) = with_runtime(|inner| {
|
||||||
|
let member = member_for(inner, pid);
|
||||||
|
let mut pg = inner.process_groups.lock();
|
||||||
|
let reaped = pg.reap_group(group);
|
||||||
|
let removed = pg.leave(group, member);
|
||||||
|
(removed, reaped)
|
||||||
|
});
|
||||||
|
|
||||||
|
drop(reaped);
|
||||||
|
match removed {
|
||||||
|
Some(ms) => {
|
||||||
|
demonitor(&ms.monitor);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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> {
|
||||||
|
let (pids, reaped) = with_runtime(|inner| {
|
||||||
|
let mut pg = inner.process_groups.lock();
|
||||||
|
let reaped = pg.reap_group(group);
|
||||||
|
let pids = pg.members_where(group, |pid| live(inner, pid));
|
||||||
|
(pids, reaped)
|
||||||
|
});
|
||||||
|
drop(reaped);
|
||||||
|
pids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let (picked, reaped) = with_runtime(|inner| {
|
||||||
|
let mut pg = inner.process_groups.lock();
|
||||||
|
let reaped = pg.reap_group(group);
|
||||||
|
let picked = pg.first_member_where(group, |pid| live(inner, pid));
|
||||||
|
(picked, reaped)
|
||||||
|
});
|
||||||
|
drop(reaped);
|
||||||
|
picked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// [`SendError::NoChannel`] on the next send, never a misdelivery.
|
||||||
|
///
|
||||||
|
/// Panics if called outside `Runtime::run()`.
|
||||||
|
pub fn pick_as<A: Addressable>(group: &str) -> Option<Pid<A>> {
|
||||||
|
pick(group).map(assert_type::<A>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed `members`: every live member of `group` as a [`Pid<A>`], same
|
||||||
|
/// unchecked re-type as [`pick_as`]. Fan-out stays compile-checked end to end.
|
||||||
|
///
|
||||||
|
/// Panics if called outside `Runtime::run()`.
|
||||||
|
pub fn members_as<A: Addressable>(group: &str) -> Vec<Pid<A>> {
|
||||||
|
members(group).into_iter().map(assert_type::<A>).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a live member of `group` and send it `msg` in one step, returning the
|
||||||
|
/// member it reached on success. The pick-a-live-member-and-send combinator
|
||||||
|
/// over [`pick_as`] + [`send_to`].
|
||||||
|
///
|
||||||
|
/// Errors hand `msg` back undelivered: [`SendError::NoMember`] if the pool is
|
||||||
|
/// empty (or all-dead), otherwise whatever the underlying [`send_to`] returns
|
||||||
|
/// (e.g. the picked member died in the window between pick and send →
|
||||||
|
/// [`SendError::Dead`]).
|
||||||
|
///
|
||||||
|
/// Panics if called outside `Runtime::run()`.
|
||||||
|
pub fn dispatch<A: Addressable>(group: &str, msg: A::Msg) -> Result<Pid<A>, SendError<A::Msg>> {
|
||||||
|
match pick_as::<A>(group) {
|
||||||
|
Some(pid) => send_to::<A>(pid, msg).map(|()| pid),
|
||||||
|
None => Err(SendError::NoMember(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::channel::{channel, Sender};
|
||||||
|
use crate::monitor::{Down, DownReason, MonitorId};
|
||||||
|
|
||||||
|
fn member(index: u32, generation: u32) -> Member {
|
||||||
|
Member {
|
||||||
|
node: DEFAULT_NODE_ID,
|
||||||
|
incarnation: DEFAULT_INCARNATION,
|
||||||
|
pid: Pid::new(index, generation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A synthetic membership with a real (but slot-less) monitor channel. The
|
||||||
|
/// returned `Sender` stands in for the slot's `Down` sender: hold it to
|
||||||
|
/// keep the member "alive" (`try_recv` → `Ok(None)`), `send` a `Down` to
|
||||||
|
/// simulate death, or `drop` it to simulate a drained/closed channel.
|
||||||
|
fn synth(index: u32, generation: u32) -> (Membership, Sender<Down>) {
|
||||||
|
let pid = Pid::new(index, generation);
|
||||||
|
let (tx, rx) = channel::<Down>();
|
||||||
|
let ms = Membership {
|
||||||
|
member: member(index, generation),
|
||||||
|
monitor: Monitor { id: MonitorId(0), target: pid, rx },
|
||||||
|
};
|
||||||
|
(ms, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_is_idempotent_within_a_group() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a, _ta) = synth(1, 0);
|
||||||
|
let (b, _tb) = synth(1, 0);
|
||||||
|
assert!(pg.join("workers", a).is_none(), "first join inserts");
|
||||||
|
assert!(pg.join("workers", b).is_some(), "second identical join is handed back");
|
||||||
|
assert_eq!(pg.members_of("workers"), vec![member(1, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_pid_in_many_groups_is_independent() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a, _ta) = synth(1, 0);
|
||||||
|
let (b, _tb) = synth(1, 0);
|
||||||
|
let (c, _tc) = synth(2, 0);
|
||||||
|
pg.join("a", a);
|
||||||
|
pg.join("b", b);
|
||||||
|
pg.join("b", c);
|
||||||
|
assert_eq!(pg.members_of("a"), vec![member(1, 0)]);
|
||||||
|
assert_eq!(pg.members_of("b"), vec![member(1, 0), member(2, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_generations_are_distinct_members() {
|
||||||
|
// ABA guard: same slot index, different generation = different actor.
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a, _ta) = synth(1, 0);
|
||||||
|
let (b, _tb) = synth(1, 1);
|
||||||
|
assert!(pg.join("g", a).is_none());
|
||||||
|
assert!(pg.join("g", b).is_none(), "different generation is a distinct member");
|
||||||
|
assert_eq!(pg.members_of("g"), vec![member(1, 0), member(1, 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leave_removes_one_membership_and_prunes_empty_groups() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a, _ta) = synth(1, 0);
|
||||||
|
let (b, _tb) = synth(2, 0);
|
||||||
|
pg.join("g", a);
|
||||||
|
pg.join("g", b);
|
||||||
|
assert!(pg.leave("g", member(1, 0)).is_some());
|
||||||
|
assert_eq!(pg.members_of("g"), vec![member(2, 0)]);
|
||||||
|
assert!(pg.leave("g", member(1, 0)).is_none(), "second leave finds nothing");
|
||||||
|
assert!(pg.leave("g", member(2, 0)).is_some());
|
||||||
|
assert!(pg.members_of("g").is_empty(), "group is now empty");
|
||||||
|
assert!(pg.leave("never", member(9, 0)).is_none(), "leaving an unknown group is a no-op");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_where_sweeps_every_group() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
for (g, (m, _t)) in [("a", synth(1, 0)), ("a", synth(2, 0)), ("b", synth(1, 0)), ("c", synth(3, 0))] {
|
||||||
|
pg.join(g, m);
|
||||||
|
}
|
||||||
|
// Death of pid index 1 (any generation) evicts it everywhere.
|
||||||
|
let evicted = pg.remove_where(|mem| mem.pid.index() == 1);
|
||||||
|
assert_eq!(evicted.len(), 2, "pid 1 was in a and b");
|
||||||
|
assert_eq!(pg.members_of("a"), vec![member(2, 0)]);
|
||||||
|
assert!(pg.members_of("b").is_empty(), "b held only pid 1; pruned");
|
||||||
|
assert_eq!(pg.members_of("c"), vec![member(3, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_where_can_match_an_incarnation_sweep() {
|
||||||
|
// Shape check for the later evict_incarnation(node, inc) caller.
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let pid = Pid::new(1, 0);
|
||||||
|
let (tx, rx) = channel::<Down>();
|
||||||
|
let dead = Membership {
|
||||||
|
member: Member { node: DEFAULT_NODE_ID, incarnation: Incarnation::new(7), pid },
|
||||||
|
monitor: Monitor { id: MonitorId(0), target: pid, rx },
|
||||||
|
};
|
||||||
|
let _keep = tx;
|
||||||
|
let (live, _tl) = synth(2, 0);
|
||||||
|
pg.join("g", dead);
|
||||||
|
pg.join("g", live);
|
||||||
|
let evicted = pg.remove_where(|mem| mem.incarnation == Incarnation::new(7));
|
||||||
|
assert_eq!(evicted.len(), 1);
|
||||||
|
assert_eq!(pg.members_of("g"), vec![member(2, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_keeps_live_members() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a, _ta) = synth(1, 0); // sender held: member stays alive
|
||||||
|
pg.join("a", a);
|
||||||
|
assert!(pg.reap_group("a").is_empty(), "no deaths");
|
||||||
|
assert_eq!(pg.members_of("a"), vec![member(1, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_evicts_a_dead_member_and_sweeps_all_its_groups() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a1, ta1) = synth(1, 0); // pid 1 in group a
|
||||||
|
let (a2, _ta2) = synth(2, 0); // pid 2 in group a (stays alive)
|
||||||
|
let (b1, _tb1) = synth(1, 0); // pid 1 in group b
|
||||||
|
pg.join("a", a1);
|
||||||
|
pg.join("a", a2);
|
||||||
|
pg.join("b", b1);
|
||||||
|
// pid 1 dies: its group-a monitor receives a Down. Its group-b monitor
|
||||||
|
// has not — reap must still sweep pid 1 out of b by the pid predicate.
|
||||||
|
ta1.send(Down { pid: Pid::new(1, 0), reason: DownReason::Exit }).unwrap();
|
||||||
|
let evicted = pg.reap_group("a");
|
||||||
|
assert_eq!(evicted.len(), 2, "pid 1's memberships in both a and b are evicted");
|
||||||
|
assert_eq!(pg.members_of("a"), vec![member(2, 0)]);
|
||||||
|
assert!(pg.members_of("b").is_empty(), "swept from b too; pruned");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_treats_a_closed_channel_as_dead() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
let (a, ta) = synth(1, 0);
|
||||||
|
pg.join("a", a);
|
||||||
|
drop(ta); // sender gone, queue empty → try_recv = Err(RecvError) = dead
|
||||||
|
let evicted = pg.reap_group("a");
|
||||||
|
assert_eq!(evicted.len(), 1);
|
||||||
|
assert!(pg.members_of("a").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_backstop_hides_a_member_the_monitor_has_not_yet_reaped() {
|
||||||
|
let mut pg = ProcessGroups::new();
|
||||||
|
// Both senders held: reap_group would see Ok(None) and evict neither.
|
||||||
|
let (a, _ta) = synth(1, 0);
|
||||||
|
let (b, _tb) = synth(2, 0);
|
||||||
|
pg.join("g", a);
|
||||||
|
pg.join("g", b);
|
||||||
|
|
||||||
|
// The slot-word oracle already reports pid 1 dead (finalize window),
|
||||||
|
// ahead of any Down delivery.
|
||||||
|
let dead = Pid::new(1, 0);
|
||||||
|
let oracle = |pid: Pid| pid != dead;
|
||||||
|
|
||||||
|
assert_eq!(pg.members_where("g", oracle), vec![Pid::new(2, 0)], "dead pid filtered from read");
|
||||||
|
assert_eq!(pg.first_member_where("g", oracle), Some(Pid::new(2, 0)), "pick skips the dead first member");
|
||||||
|
|
||||||
|
// Backstop does not evict — that stays the monitor's job; raw storage
|
||||||
|
// still holds both until reap runs.
|
||||||
|
assert_eq!(pg.members_of("g"), vec![member(1, 0), member(2, 0)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
-13
@@ -1,38 +1,285 @@
|
|||||||
//! Process identifiers.
|
//! Process identifiers.
|
||||||
//!
|
//!
|
||||||
//! A `Pid` is `(index, generation)`. The index is a slot in the scheduler's
|
//! Identity is `(index, generation)`: the index is a slot in the scheduler's
|
||||||
//! actor table; the generation increments every time that slot is reused.
|
//! actor table, the generation increments every time that slot is reused, so a
|
||||||
//! A stale `Pid` (correct index, wrong generation) is a detectable error,
|
//! stale id (right index, wrong generation) is a *detectable* error rather than
|
||||||
//! not a silent misdirection — solves the ABA problem without exhausting
|
//! a silent misdirection — the ABA problem solved without exhausting the id
|
||||||
//! the PID space.
|
//! space. Those raw numbers live in [`RawPid`].
|
||||||
|
//!
|
||||||
|
//! The public identity is the *typed* [`Pid<A>`] (RFC 014): `RawPid` plus a
|
||||||
|
//! phantom actor type, so a pid is simultaneously an identity and a direct,
|
||||||
|
//! identity-bound address. `Pid<Erased>` — the default — is the untyped pid
|
||||||
|
//! used for identity-only plumbing and for actors with no single message type
|
||||||
|
//! (raw `spawn`, gen_servers). Resolving a name yields the durable, re-resolving
|
||||||
|
//! [`Name`] instead.
|
||||||
|
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
/// The raw identity numbers, with no actor type. The key for everything that
|
||||||
|
/// only cares about *which* actor: slab indexing, generation checks, and the
|
||||||
|
/// heterogeneous monitor / link / pg tables (which hold actors of every type at
|
||||||
|
/// once, so they cannot be parameterised by one).
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct Pid {
|
pub struct RawPid {
|
||||||
index: u32,
|
index: u32,
|
||||||
generation: u32,
|
generation: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pid {
|
impl RawPid {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub const fn new(index: u32, generation: u32) -> Self {
|
pub const fn new(index: u32, generation: u32) -> Self {
|
||||||
Self { index, generation }
|
Self { index, generation }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub const fn index(self) -> u32 { self.index }
|
pub const fn index(self) -> u32 {
|
||||||
|
self.index
|
||||||
|
}
|
||||||
#[inline]
|
#[inline]
|
||||||
pub const fn generation(self) -> u32 { self.generation }
|
pub const fn generation(self) -> u32 {
|
||||||
|
self.generation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Pid {
|
impl std::fmt::Debug for RawPid {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "Pid({}.{})", self.index, self.generation)
|
write!(f, "Pid({}.{})", self.index, self.generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Pid {
|
impl std::fmt::Display for RawPid {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "<{}.{}>", self.index, self.generation)
|
write!(f, "<{}.{}>", self.index, self.generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phantom actor type for a pid that has no single message type: raw `spawn`
|
||||||
|
/// actors, gen_servers (intrinsically multi-message, addressed via `GenServerRef`),
|
||||||
|
/// and every identity-only context. Deliberately **not** [`Addressable`], so a
|
||||||
|
/// typed `send` to a `Pid<Erased>` does not compile; the runtime-checked
|
||||||
|
/// `send_dyn` escape hatch (RFC 014 §4.6) is the sanctioned bare-pid path.
|
||||||
|
pub enum Erased {}
|
||||||
|
|
||||||
|
/// A process identifier parameterised by the actor's type `A` (default
|
||||||
|
/// [`Erased`]). Wraps the raw `(index, generation)` plus a zero-sized phantom,
|
||||||
|
/// so a `Pid<A>` is both an identity and a direct, identity-bound address: when
|
||||||
|
/// `A: Addressable`, a `send` delivers `A::Msg` to exactly the incarnation this
|
||||||
|
/// pid names — no redirect (contrast the re-resolving [`Name`]).
|
||||||
|
///
|
||||||
|
/// Equality, hashing, and formatting are the raw identity's; the phantom is
|
||||||
|
/// `fn() -> A`, so `Pid<A>` is unconditionally `Copy + Send + Sync` and borrows
|
||||||
|
/// nothing from `A`. The trait impls are hand-written so no `A: Trait` bound
|
||||||
|
/// leaks in from a `#[derive]`.
|
||||||
|
pub struct Pid<A = Erased> {
|
||||||
|
raw: RawPid,
|
||||||
|
_marker: PhantomData<fn() -> A>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pid<Erased> {
|
||||||
|
/// Build an untyped pid from raw numbers. The runtime mints identities
|
||||||
|
/// here; typing happens at typed-actor boundaries via [`Pid::from_raw`].
|
||||||
|
#[inline]
|
||||||
|
pub const fn new(index: u32, generation: u32) -> Self {
|
||||||
|
Self { raw: RawPid::new(index, generation), _marker: PhantomData }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A> Pid<A> {
|
||||||
|
/// Wrap a raw identity as a typed pid. Crate-internal: minting a typed pid
|
||||||
|
/// from raw numbers asserts an actor's type *unchecked*, which is exactly
|
||||||
|
/// what the typed API exists to avoid outside the runtime's own spawn /
|
||||||
|
/// resolution paths.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) const fn from_raw(raw: RawPid) -> Self {
|
||||||
|
Self { raw, _marker: PhantomData }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw identity, dropping the actor type — the key for identity-only
|
||||||
|
/// tables and internal plumbing.
|
||||||
|
#[inline]
|
||||||
|
pub const fn raw(self) -> RawPid {
|
||||||
|
self.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the actor type.
|
||||||
|
#[inline]
|
||||||
|
pub const fn erase(self) -> Pid<Erased> {
|
||||||
|
Pid::from_raw(self.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slot index in the actor table.
|
||||||
|
#[inline]
|
||||||
|
pub const fn index(self) -> u32 {
|
||||||
|
self.raw.index()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reuse generation of the slot (ABA guard).
|
||||||
|
#[inline]
|
||||||
|
pub const fn generation(self) -> u32 {
|
||||||
|
self.raw.generation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-type an erased pid as `Pid<A>` *unchecked* — the one shared primitive
|
||||||
|
/// behind `lookup_as` / `pick_as` / `members_as` (RFC 014 §4.4). The registry
|
||||||
|
/// and pg stores are heterogeneous in `A` (they hold actors of every type at
|
||||||
|
/// once), so resolving them yields a bare [`Pid`]; recovering the typed address
|
||||||
|
/// is necessarily an assertion the store cannot make for us.
|
||||||
|
///
|
||||||
|
/// **Not unsound.** Delivery routes on the message's [`TypeId`](std::any::TypeId)
|
||||||
|
/// (every send path keys the channel store by it), so a wrong `A` here does not
|
||||||
|
/// mis-deliver: the next [`send_to`](crate::send_to) finds no channel for
|
||||||
|
/// `A::Msg` on that actor and returns [`SendError::NoChannel`](crate::SendError::NoChannel).
|
||||||
|
/// A mistyped pid degrades to a clean send error, never a silent misroute.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn assert_type<A>(pid: Pid) -> Pid<A> {
|
||||||
|
Pid::from_raw(pid.raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A> Copy for Pid<A> {}
|
||||||
|
impl<A> Clone for Pid<A> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<A> PartialEq for Pid<A> {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.raw == other.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<A> Eq for Pid<A> {}
|
||||||
|
impl<A> std::hash::Hash for Pid<A> {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
self.raw.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<A> std::fmt::Debug for Pid<A> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
std::fmt::Debug::fmt(&self.raw, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<A> std::fmt::Display for Pid<A> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
std::fmt::Display::fmt(&self.raw, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An actor type with a single associated message type, so a [`Pid<Self>`] is a
|
||||||
|
/// typed address. The raw channel layer has no such trait (actors are closures
|
||||||
|
/// over channels) and `GenServer` is intrinsically multi-message (addressed via
|
||||||
|
/// its own `GenServerRef`); this is the minimal hook that lets the single-message
|
||||||
|
/// actors carry their message type in their pid. (RFC 014 §4.2.)
|
||||||
|
pub trait Addressable: 'static {
|
||||||
|
/// The message this actor receives. A `Pid<Self>` delivers `Self::Msg`.
|
||||||
|
type Msg: Send + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A durable, re-resolving address: a static name plus a phantom message type
|
||||||
|
/// `M` (RFC 014's `Name<M>`). Declared as a constant and shared freely:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// const COUNTER: Name<CounterMsg> = Name::new("counter");
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Unlike a [`Pid`], a `Name` is resolved through the registry on *every* send,
|
||||||
|
/// so it always reaches whoever currently holds the name.
|
||||||
|
pub struct Name<M> {
|
||||||
|
name: &'static str,
|
||||||
|
_marker: PhantomData<fn() -> M>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M> Name<M> {
|
||||||
|
/// Bind a static string as a typed name. `const`, so names live as
|
||||||
|
/// associated constants at call sites.
|
||||||
|
#[inline]
|
||||||
|
pub const fn new(name: &'static str) -> Self {
|
||||||
|
Self { name, _marker: PhantomData }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The underlying registry key.
|
||||||
|
#[inline]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M> Copy for Name<M> {}
|
||||||
|
impl<M> Clone for Name<M> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<M> PartialEq for Name<M> {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.name == other.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<M> Eq for Name<M> {}
|
||||||
|
impl<M> std::hash::Hash for Name<M> {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
self.name.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<M> std::fmt::Debug for Name<M> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "Name<{}>({:?})", std::any::type_name::<M>(), self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod typed_pid_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// A stand-in actor type with one message type, exercising `Addressable`.
|
||||||
|
struct Counter;
|
||||||
|
struct CounterMsg; // used only as a phantom key; no variants needed
|
||||||
|
impl Addressable for Counter {
|
||||||
|
type Msg = CounterMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg_type_name<A: Addressable>() -> &'static str {
|
||||||
|
std::any::type_name::<A::Msg>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_pid_is_a_copyable_identity() {
|
||||||
|
let p = Pid::<Counter>::from_raw(RawPid::new(3, 1));
|
||||||
|
let q = p; // Copy, not move
|
||||||
|
assert_eq!(p.index(), 3);
|
||||||
|
assert_eq!(p.generation(), 1);
|
||||||
|
assert_eq!(p, q);
|
||||||
|
// Same index, different generation = different incarnation.
|
||||||
|
assert_ne!(p, Pid::<Counter>::from_raw(RawPid::new(3, 2)));
|
||||||
|
assert!(format!("{p:?}").starts_with("Pid("));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn erase_drops_the_type_but_keeps_identity() {
|
||||||
|
let p = Pid::<Counter>::from_raw(RawPid::new(7, 4));
|
||||||
|
assert_eq!(p.erase(), Pid::new(7, 4)); // Pid<Erased>
|
||||||
|
assert_eq!(p.raw(), RawPid::new(7, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_is_a_copyable_string_token() {
|
||||||
|
const COUNTER: Name<CounterMsg> = Name::new("counter");
|
||||||
|
let n = COUNTER; // Copy
|
||||||
|
assert_eq!(n.as_str(), "counter");
|
||||||
|
assert_eq!(n, COUNTER);
|
||||||
|
assert_ne!(n, Name::<CounterMsg>::new("other"));
|
||||||
|
assert!(format!("{n:?}").contains("\"counter\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addressable_exposes_the_message_type() {
|
||||||
|
assert!(msg_type_name::<Counter>().ends_with("CounterMsg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity tokens must be usable across threads.
|
||||||
|
#[test]
|
||||||
|
fn tokens_are_send_sync_without_key_bounds() {
|
||||||
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
assert_send_sync::<Pid<Counter>>();
|
||||||
|
assert_send_sync::<Pid<Erased>>();
|
||||||
|
assert_send_sync::<Name<CounterMsg>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ thread_local! {
|
|||||||
/// resume path free of atomic ref-count traffic; see `check_cancelled` for
|
/// resume path free of atomic ref-count traffic; see `check_cancelled` for
|
||||||
/// the safety argument.
|
/// the safety argument.
|
||||||
static CURRENT_STOP: Cell<*const AtomicBool> = const { Cell::new(std::ptr::null()) };
|
static CURRENT_STOP: Cell<*const AtomicBool> = const { Cell::new(std::ptr::null()) };
|
||||||
|
|
||||||
|
/// Raw pointer to the on-CPU actor's slot, set/cleared by the scheduler on
|
||||||
|
/// the same resume/return boundary as `CURRENT_STOP` (RFC 016 Chunk 2).
|
||||||
|
/// Lets the rare slice-expiry site bump that actor's overrun counter with
|
||||||
|
/// one TLS load and no runtime lookup. Null while no actor is on-CPU. The
|
||||||
|
/// slot lives in the fixed slab and is never reclaimed while the actor is
|
||||||
|
/// running, so the pointer is valid for the whole resume (same lifetime
|
||||||
|
/// argument as `CURRENT_STOP`).
|
||||||
|
static CURRENT_SLOT: Cell<*const crate::runtime::Slot> =
|
||||||
|
const { Cell::new(std::ptr::null()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -77,6 +87,65 @@ pub(crate) fn clear_current_stop() {
|
|||||||
CURRENT_STOP.with(|c| c.set(std::ptr::null()));
|
CURRENT_STOP.with(|c| c.set(std::ptr::null()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bind the on-CPU actor's slot. Called by the scheduler immediately before
|
||||||
|
/// `switch_to_actor`, beside `set_current_stop`.
|
||||||
|
pub(crate) fn set_current_slot(slot: *const crate::runtime::Slot) {
|
||||||
|
CURRENT_SLOT.with(|c| c.set(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unbind the slot pointer on the return path, beside `clear_current_stop`.
|
||||||
|
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
|
||||||
|
/// irrelevant.
|
||||||
|
#[inline]
|
||||||
|
fn note_overrun() {
|
||||||
|
let p = CURRENT_SLOT.with(|c| c.get());
|
||||||
|
// SAFETY: `p` is null (no actor on-CPU) or a pointer to the on-CPU actor's
|
||||||
|
// slot in the fixed slab. The slot is not reclaimed while the actor runs
|
||||||
|
// (finalize/reclaim happen only after it yields back), so the deref is
|
||||||
|
// valid for the whole resume — the same argument as `check_cancelled`.
|
||||||
|
if !p.is_null() {
|
||||||
|
unsafe { (*p).record_overrun() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tally one received message against the on-CPU actor (RFC 016 Chunk 2),
|
||||||
|
/// called from the channel receive path on each successful dequeue. A no-op
|
||||||
|
/// outside an actor (null slot). One TLS load + one Relaxed load/store on a
|
||||||
|
/// cache line the receiving thread already owns — no atomic RMW, no lock. Same
|
||||||
|
/// slot-lifetime safety argument as `note_overrun`.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn note_message_received() {
|
||||||
|
let p = CURRENT_SLOT.with(|c| c.get());
|
||||||
|
if !p.is_null() {
|
||||||
|
unsafe { (*p).record_message() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Observation point for cooperative cancellation. If the on-CPU actor has
|
/// Observation point for cooperative cancellation. If the on-CPU actor has
|
||||||
/// been flagged for stop, raise the sentinel panic so the trampoline's
|
/// been flagged for stop, raise the sentinel panic so the trampoline's
|
||||||
/// `catch_unwind` tears the stack down (running Drop) and reports
|
/// `catch_unwind` tears the stack down (running Drop) and reports
|
||||||
@@ -116,6 +185,16 @@ pub fn reset_timeslice() {
|
|||||||
TIMESLICE_START.with(|c| c.set(rdtsc()));
|
TIMESLICE_START.with(|c| c.set(rdtsc()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cycles elapsed since the current slice started (RFC 016 Chunk 2,
|
||||||
|
/// `budget-accounting`). Read by the scheduler right after an actor yields back,
|
||||||
|
/// on the same thread that armed `TIMESLICE_START`. Approximate for wake-slot
|
||||||
|
/// resumes, which inherit the slice (see `Slot::add_budget`).
|
||||||
|
#[cfg(feature = "budget-accounting")]
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn elapsed_slice_cycles() -> u64 {
|
||||||
|
rdtsc().saturating_sub(TIMESLICE_START.with(|c| c.get()))
|
||||||
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn rdtsc() -> u64 {
|
pub fn rdtsc() -> u64 {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -187,8 +266,17 @@ pub fn maybe_preempt() {
|
|||||||
// Observe a pending stop first: if we are being cancelled
|
// Observe a pending stop first: if we are being cancelled
|
||||||
// there is no point yielding, we unwind instead.
|
// there is no point yielding, we unwind instead.
|
||||||
check_cancelled();
|
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());
|
let start = TIMESLICE_START.with(|s| s.get());
|
||||||
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
|
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
|
||||||
|
// Tally the overrun (RFC 016 Chunk 2) before handing back —
|
||||||
|
// this is the slice-expiry site RFC 006 wanted, and it's
|
||||||
|
// already the yield path, so the counter is near-free.
|
||||||
|
note_overrun();
|
||||||
// SAFETY: reachable only inside an actor (the scheduler
|
// SAFETY: reachable only inside an actor (the scheduler
|
||||||
// sets PREEMPTION_ENABLED on resume and clears it on
|
// sets PREEMPTION_ENABLED on resume and clears it on
|
||||||
// return). The scheduler stack is therefore valid.
|
// return). The scheduler stack is therefore valid.
|
||||||
|
|||||||
+578
-96
@@ -1,32 +1,113 @@
|
|||||||
//! Named pid registry.
|
|
||||||
//!
|
|
||||||
//! `register(name, pid)` binds a name to a live actor; `whereis(name)` looks
|
|
||||||
//! it up; `name_of(pid)` is the inverse. Erlang semantics: at most one name
|
|
||||||
//! per pid, at most one pid per name, registering over a live binding is an
|
|
||||||
//! error. The registry is a *bimap* (two `HashMap`s kept in exact inverse
|
|
||||||
//! under one lock) so both lookup directions are O(1) — `name_of` exists so
|
|
||||||
//! supervisors, tracing, and diagnostics can label a pid without scanning.
|
|
||||||
//!
|
|
||||||
//! ## Cleanup is lazy
|
|
||||||
//!
|
|
||||||
//! There is no hook in `finalize_actor` and no name field in `Slot` (which
|
|
||||||
//! would buy into the reset-in-three-places slot invariant). Instead every
|
|
||||||
//! operation that touches a binding checks the bound pid's liveness via the
|
|
||||||
//! generation-checked slot word — a `Pid` is `(index, generation)` and a
|
|
||||||
//! generation is never reused, so a stale binding is *detectable*, never
|
|
||||||
//! misdirected. Bindings to dead actors behave as absent and are pruned on
|
|
||||||
//! contact: `whereis`/`name_of` return `None`, `register` treats the name as
|
|
||||||
//! free. The cost is that a dead binding lingers until something looks at it;
|
|
||||||
//! the payoff is zero coupling to the actor lifecycle.
|
|
||||||
//!
|
|
||||||
//! ## Locking
|
|
||||||
//!
|
|
||||||
//! One `RawMutex` (Leaf class) in `RuntimeInner`. Liveness checks under it
|
|
||||||
//! read only the atomic slot state word — no second lock is ever taken, so
|
|
||||||
//! the leaf rule holds trivially.
|
|
||||||
|
|
||||||
use crate::pid::Pid;
|
//! Give an actor a name so other actors can find it and message it.
|
||||||
use crate::scheduler::with_runtime;
|
//!
|
||||||
|
//! 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`.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use smarm::{channel, register, run, send, spawn, unregister, whereis, Name};
|
||||||
|
//!
|
||||||
|
//! const COUNTER: Name<u64> = Name::new("counter");
|
||||||
|
//!
|
||||||
|
//! run(|| {
|
||||||
|
//! let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
//! let (tx, rx) = channel::<u64>();
|
||||||
|
//!
|
||||||
|
//! 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);
|
||||||
|
//! });
|
||||||
|
//!
|
||||||
|
//! ready_rx.recv().unwrap(); // wait for the worker to register
|
||||||
|
//!
|
||||||
|
//! // Look the name up, or just send to it directly.
|
||||||
|
//! assert_eq!(whereis("counter"), Some(worker.pid()));
|
||||||
|
//! send(COUNTER, 42).unwrap();
|
||||||
|
//!
|
||||||
|
//! worker.join().unwrap();
|
||||||
|
//!
|
||||||
|
//! // The name dies with the actor: nobody holds it anymore.
|
||||||
|
//! assert_eq!(whereis("counter"), None);
|
||||||
|
//! });
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Names carry a message type
|
||||||
|
//!
|
||||||
|
//! 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};
|
||||||
|
use crate::scheduler::{self_pid, with_runtime};
|
||||||
|
use std::any::{type_name, Any, TypeId};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Why a [`register`] call was rejected.
|
/// Why a [`register`] call was rejected.
|
||||||
@@ -34,9 +115,8 @@ use std::collections::HashMap;
|
|||||||
pub enum RegisterError {
|
pub enum RegisterError {
|
||||||
/// The name is bound to a different, still-live actor.
|
/// The name is bound to a different, still-live actor.
|
||||||
NameTaken { holder: Pid },
|
NameTaken { holder: Pid },
|
||||||
/// The pid already has a (live) registered name; one name per pid.
|
/// The caller is not a live actor (cannot happen for `self`, kept for
|
||||||
PidAlreadyRegistered { name: String },
|
/// symmetry / future explicit-pid registration).
|
||||||
/// The pid does not refer to a live actor.
|
|
||||||
NoProc,
|
NoProc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,38 +126,232 @@ impl std::fmt::Display for RegisterError {
|
|||||||
RegisterError::NameTaken { holder } => {
|
RegisterError::NameTaken { holder } => {
|
||||||
write!(f, "name is already registered to live actor {holder}")
|
write!(f, "name is already registered to live actor {holder}")
|
||||||
}
|
}
|
||||||
RegisterError::PidAlreadyRegistered { name } => {
|
RegisterError::NoProc => write!(f, "caller is not a live actor"),
|
||||||
write!(f, "pid is already registered as {name:?}")
|
|
||||||
}
|
|
||||||
RegisterError::NoProc => write!(f, "pid does not refer to a live actor"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for RegisterError {}
|
impl std::error::Error for RegisterError {}
|
||||||
|
|
||||||
/// The bimap. Invariant (held under the registry lock): `by_name` and
|
/// Why a send did not deliver. Every variant carries the undelivered message
|
||||||
/// `by_pid` are exact inverses of each other at all times.
|
/// back, mirroring [`crate::channel::SendError`], so a failed send never
|
||||||
|
/// silently drops what you tried to send.
|
||||||
|
///
|
||||||
|
/// `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. Returned only
|
||||||
|
/// by name-addressed [`send`]; the pid-addressed counterpart of "nothing
|
||||||
|
/// there" is [`SendError::Dead`].
|
||||||
|
Unresolved(M),
|
||||||
|
/// 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 has not published a channel for this message
|
||||||
|
/// type.
|
||||||
|
NoChannel(M),
|
||||||
|
/// The actor's channel for this message type is closed (its receiver has
|
||||||
|
/// been dropped).
|
||||||
|
Closed(M),
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M> SendError<M> {
|
||||||
|
/// Recover the undelivered message.
|
||||||
|
pub fn into_inner(self) -> M {
|
||||||
|
match self {
|
||||||
|
SendError::Unresolved(m)
|
||||||
|
| SendError::Dead(m)
|
||||||
|
| SendError::NoChannel(m)
|
||||||
|
| SendError::Closed(m)
|
||||||
|
| SendError::NoMember(m) => m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variant(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SendError::Unresolved(_) => "Unresolved",
|
||||||
|
SendError::Dead(_) => "Dead",
|
||||||
|
SendError::NoChannel(_) => "NoChannel",
|
||||||
|
SendError::Closed(_) => "Closed",
|
||||||
|
SendError::NoMember(_) => "NoMember",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M> std::fmt::Debug for SendError<M> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "SendError::{}", self.variant())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M> std::fmt::Display for SendError<M> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SendError::Unresolved(_) => write!(f, "no live actor registered under that name"),
|
||||||
|
SendError::Dead(_) => write!(f, "the addressed actor is no longer the live incarnation"),
|
||||||
|
SendError::NoChannel(_) => write!(f, "actor has no channel for this message type"),
|
||||||
|
SendError::Closed(_) => write!(f, "the actor's channel for this type is closed"),
|
||||||
|
SendError::NoMember(_) => write!(f, "no live member in the process group"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M: Send + 'static> ErasedSender for Sender<M> {
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn queued_len(&self) -> usize {
|
||||||
|
Sender::queued_len(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// observability tooling and as the debug cross-check on the downcast.
|
||||||
|
struct Channel {
|
||||||
|
sender: Box<dyn ErasedSender>,
|
||||||
|
msg_type: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An actor's messageable surface: its identity plus every typed channel it has
|
||||||
|
/// published, keyed by message [`TypeId`]. Stored once per live actor; reached
|
||||||
|
/// by pid (directly) or by any name pointing at that pid.
|
||||||
|
struct Mailbox {
|
||||||
|
pid: Pid,
|
||||||
|
channels: HashMap<TypeId, Channel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mailbox {
|
||||||
|
fn new(pid: Pid) -> Self {
|
||||||
|
Self { pid, channels: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clone the `Sender<M>` for this actor, if it has one. Called **under the
|
||||||
|
/// registry Leaf lock**: `Sender::clone` takes a Channel lock, which is
|
||||||
|
/// 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 = 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 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
|
||||||
|
/// actor that holds only a private `channel()` receiver is invisible here and
|
||||||
|
/// reports depth 0.
|
||||||
|
pub(crate) struct MailboxInfo {
|
||||||
|
pub(crate) pid: Pid,
|
||||||
|
pub(crate) names: Vec<&'static str>,
|
||||||
|
pub(crate) depth: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory. Invariant (held under the registry lock): every value in
|
||||||
|
/// `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 {
|
pub(crate) struct Registry {
|
||||||
by_name: HashMap<String, Pid>,
|
/// `pid.index() -> the actor's mailbox`. The handle store.
|
||||||
by_pid: HashMap<Pid, String>,
|
by_index: HashMap<u32, Mailbox>,
|
||||||
|
/// `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 {
|
impl Registry {
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self { by_name: HashMap::new(), by_pid: HashMap::new() }
|
Self { by_index: HashMap::new(), by_name: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_binding(&mut self, name: &str, pid: Pid) {
|
/// Drop a dead holder's artifacts: every name bound to it, and its
|
||||||
self.by_name.remove(name);
|
/// mailbox, but only while the mailbox is still *its own*. A recycled
|
||||||
self.by_pid.remove(&pid);
|
/// slot's mailbox belongs to the live tenant (publish replaces it
|
||||||
debug_assert_eq!(self.by_name.len(), self.by_pid.len(), "bimap out of sync");
|
/// 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_binding(&mut self, name: String, pid: Pid) {
|
/// Runtime introspection input: per-slot-index registry view, giving the
|
||||||
self.by_pid.insert(pid, name.clone());
|
/// actor's registered names (inverted from `by_name`) and its mailbox
|
||||||
self.by_name.insert(name, pid);
|
/// depth (queued messages summed across every published typed channel).
|
||||||
debug_assert_eq!(self.by_name.len(), self.by_pid.len(), "bimap out of sync");
|
/// 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<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 {
|
||||||
|
let depth: usize = mb.channels.values().map(|c| c.sender.queued_len()).sum();
|
||||||
|
out.insert(
|
||||||
|
idx,
|
||||||
|
MailboxInfo {
|
||||||
|
pid: mb.pid,
|
||||||
|
names: names.remove(&mb.pid).unwrap_or_default(),
|
||||||
|
depth: depth.min(u32::MAX as usize) as u32,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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, &p)| (p == mb.pid).then_some(n))
|
||||||
|
.collect();
|
||||||
|
Some(MailboxInfo { pid: mb.pid, names, depth: depth.min(u32::MAX as usize) as u32 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,44 +360,117 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
|
|||||||
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
|
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind `name` to the live actor `pid`.
|
/// 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::NoProc`] if `pid` is not live,
|
/// Calling this again with the same `(name, type)` from the same actor is
|
||||||
/// [`RegisterError::NameTaken`] if the name is bound to a *live* actor
|
/// harmless. Registering a *second* message type under the same (or a
|
||||||
/// (a binding to a dead actor is evicted and the name treated as free), and
|
/// different) name from the same actor just adds another typed channel to
|
||||||
/// [`RegisterError::PidAlreadyRegistered`] if `pid` already has a name.
|
/// that actor's mailbox; it does not replace the first.
|
||||||
///
|
///
|
||||||
/// Panics if called outside `Runtime::run()`.
|
/// Fails with [`RegisterError::NameTaken`] if the name is currently held by a
|
||||||
pub fn register(name: impl Into<String>, pid: Pid) -> Result<(), RegisterError> {
|
/// *different* live actor. A name held by an actor that has since died is not
|
||||||
let name = name.into();
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// rules and lock discipline as `register`.
|
||||||
|
pub(crate) fn register_with<M: Send + 'static>(
|
||||||
|
me: Pid,
|
||||||
|
key: &'static str,
|
||||||
|
tx: Sender<M>,
|
||||||
|
) -> Result<(), RegisterError> {
|
||||||
with_runtime(|inner| {
|
with_runtime(|inner| {
|
||||||
let mut reg = inner.registry.lock();
|
let mut reg = inner.registry.lock();
|
||||||
if !live(inner, pid) {
|
if !live(inner, me) {
|
||||||
return Err(RegisterError::NoProc);
|
return Err(RegisterError::NoProc);
|
||||||
}
|
}
|
||||||
let prior = reg.by_name.get(&name).copied();
|
if let Some(&holder) = reg.by_name.get(key) {
|
||||||
if let Some(holder) = prior {
|
if holder == me {
|
||||||
if holder == pid {
|
// Same actor: just add the channel below.
|
||||||
return Ok(()); // already exactly this binding: idempotent
|
} else if live(inner, holder) {
|
||||||
}
|
|
||||||
if live(inner, holder) {
|
|
||||||
return Err(RegisterError::NameTaken { 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);
|
||||||
}
|
}
|
||||||
// Stale binding: the holder died. Evict and treat the name as free.
|
|
||||||
reg.remove_binding(&name, holder);
|
|
||||||
}
|
}
|
||||||
if let Some(existing) = reg.by_pid.get(&pid) {
|
// Publish (or extend) the mailbox with this channel, then bind the name.
|
||||||
// `pid` is live (checked above) and pids are never reused, so this
|
publish_channel::<M>(&mut reg, me, tx);
|
||||||
// binding is necessarily current — one name per pid.
|
reg.by_name.insert(key, me);
|
||||||
return Err(RegisterError::PidAlreadyRegistered { name: existing.clone() });
|
|
||||||
}
|
|
||||||
reg.insert_binding(name, pid);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up the pid bound to `name`. `None` if unbound, or if the bound actor
|
/// Insert or extend the current actor's mailbox with one typed channel, filed
|
||||||
/// is no longer live (the stale binding is pruned on the way out).
|
/// under its message [`TypeId`]. Shared by [`register`] (which then binds a
|
||||||
|
/// name) and [`install`] (which does not). A leftover mailbox at this slot
|
||||||
|
/// index from a dead prior incarnation (pid mismatch) is replaced wholesale.
|
||||||
|
/// Caller holds the registry lock and has established that `me` is live.
|
||||||
|
fn publish_channel<M: Send + 'static>(reg: &mut Registry, me: Pid, tx: Sender<M>) {
|
||||||
|
let mb = reg.by_index.entry(me.index()).or_insert_with(|| Mailbox::new(me));
|
||||||
|
if mb.pid != me {
|
||||||
|
*mb = Mailbox::new(me);
|
||||||
|
}
|
||||||
|
mb.channels.insert(
|
||||||
|
TypeId::of::<M>(),
|
||||||
|
Channel { sender: Box::new(tx), msg_type: type_name::<M>() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the current actor's `Sender<A::Msg>` into its mailbox **without**
|
||||||
|
/// binding a name, and hand back the typed [`Pid<A>`] that addresses this
|
||||||
|
/// actor directly.
|
||||||
|
///
|
||||||
|
/// This is 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| {
|
||||||
|
let mut reg = inner.registry.lock();
|
||||||
|
debug_assert!(live(inner, me), "self_pid() is a live actor inside run()");
|
||||||
|
publish_channel::<A::Msg>(&mut reg, me, tx);
|
||||||
|
});
|
||||||
|
// `me` is this actor; re-type the identity as `Pid<A>` (the channel for
|
||||||
|
// `A::Msg` was just published, so the typed address is now messageable).
|
||||||
|
Pid::from_raw(me.raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 spawned
|
||||||
|
/// actor's body having run yet.
|
||||||
|
///
|
||||||
|
/// Caller guarantees `pid` is the just-installed actor (queued, this exact
|
||||||
|
/// incarnation); `publish_channel` replaces any stale leftover at the slot.
|
||||||
|
pub(crate) fn install_for<M: Send + 'static>(pid: Pid, tx: Sender<M>) {
|
||||||
|
with_runtime(|inner| {
|
||||||
|
let mut reg = inner.registry.lock();
|
||||||
|
debug_assert!(live(inner, pid), "install_for: pid must be a freshly spawned, live actor");
|
||||||
|
publish_channel::<M>(&mut reg, pid, tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
pub fn whereis(name: &str) -> Option<Pid> {
|
||||||
with_runtime(|inner| {
|
with_runtime(|inner| {
|
||||||
let mut reg = inner.registry.lock();
|
let mut reg = inner.registry.lock();
|
||||||
@@ -131,39 +478,174 @@ pub fn whereis(name: &str) -> Option<Pid> {
|
|||||||
if live(inner, pid) {
|
if live(inner, pid) {
|
||||||
Some(pid)
|
Some(pid)
|
||||||
} else {
|
} else {
|
||||||
reg.remove_binding(name, pid);
|
// 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
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inverse lookup: the name `pid` is registered under, if any. `None` if
|
/// Like [`whereis`], but returns a *typed* [`Pid<A>`] instead of a bare
|
||||||
/// unregistered or no longer live (pruning the stale binding).
|
/// [`Pid`], so a follow-up [`send_to`] is compile-checked instead of needing
|
||||||
pub fn name_of(pid: Pid) -> Option<String> {
|
/// the untyped [`send_dyn`] escape hatch. `None` if the name is unbound or its
|
||||||
with_runtime(|inner| {
|
/// holder has died.
|
||||||
let mut reg = inner.registry.lock();
|
///
|
||||||
let name = reg.by_pid.get(&pid)?.clone();
|
/// The type `A` is not checked against what the name's holder actually
|
||||||
if live(inner, pid) {
|
/// published: if you pick the wrong `A`, this still succeeds, but the next
|
||||||
Some(name)
|
/// send against the returned pid degrades to [`SendError::NoChannel`] rather
|
||||||
} else {
|
/// than reaching the wrong actor or the wrong channel.
|
||||||
reg.remove_binding(&name, pid);
|
///
|
||||||
None
|
/// 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>)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the binding for `name`, returning the pid it was bound to if that
|
/// Resolve `name` to its actor's pid and a cloned `Sender<M>`, all under one
|
||||||
/// actor is still live. A binding to a dead actor is pruned and reported as
|
/// lock acquisition. The crate-internal building block for `gen_server`'s
|
||||||
/// `None`, consistent with [`whereis`].
|
/// by-name addressing: a named server publishes its inbox as a
|
||||||
pub fn unregister(name: &str) -> Option<Pid> {
|
/// `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| {
|
with_runtime(|inner| {
|
||||||
let mut reg = inner.registry.lock();
|
let mut reg = inner.registry.lock();
|
||||||
let pid = *reg.by_name.get(name)?;
|
let pid = *reg.by_name.get(name)?;
|
||||||
reg.remove_binding(name, pid);
|
if !live(inner, pid) {
|
||||||
if live(inner, pid) {
|
// Stored-pid liveness, generation included: a name whose holder
|
||||||
Some(pid)
|
// died is pruned (heals) even if the slot has a new tenant.
|
||||||
} else {
|
// Otherwise the tenant's mailbox would make the name unresolvable
|
||||||
None
|
// 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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 pid = reg.by_name.remove(name)?;
|
||||||
|
if live(inner, pid) { Some(pid) } else { None }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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 registry lock, then drop the
|
||||||
|
// lock before sending (a send can unpark a receiver).
|
||||||
|
let tx = {
|
||||||
|
let mut reg = inner.registry.lock();
|
||||||
|
let pid = match reg.by_name.get(key) {
|
||||||
|
Some(&p) => p,
|
||||||
|
None => return Err(SendError::Unresolved(msg)),
|
||||||
|
};
|
||||||
|
if !live(inner, pid) {
|
||||||
|
// 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(&pid.index()).and_then(Mailbox::clone_sender::<M>) {
|
||||||
|
Some(tx) => tx,
|
||||||
|
None => return Err(SendError::NoChannel(msg)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.send(msg).map_err(|crate::channel::SendError(m)| SendError::Closed(m))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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 registry lock, then drop the lock
|
||||||
|
// before sending (a send can unpark a receiver), same order as `send`.
|
||||||
|
let tx = {
|
||||||
|
let mut reg = inner.registry.lock();
|
||||||
|
match reg.by_index.get(&pid.index()).map(|m| m.pid) {
|
||||||
|
// Exact incarnation, still alive: its `M` channel, or NoChannel.
|
||||||
|
Some(stored) if stored == pid && live(inner, pid) => {
|
||||||
|
match reg.by_index.get(&pid.index()).and_then(Mailbox::clone_sender::<M>) {
|
||||||
|
Some(tx) => tx,
|
||||||
|
None => return Err(SendError::NoChannel(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Our incarnation's mailbox, but the actor has died: prune + Dead.
|
||||||
|
Some(stored) if stored == pid => {
|
||||||
|
reg.prune_holder(pid);
|
||||||
|
return Err(SendError::Dead(msg));
|
||||||
|
}
|
||||||
|
// A different incarnation (or nothing) occupies the slot: the actor
|
||||||
|
// this pid named is gone. Do not disturb any newer occupant.
|
||||||
|
_ => return Err(SendError::Dead(msg)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.send(msg).map_err(|crate::channel::SendError(m)| SendError::Closed(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// [`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 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.
|
||||||
|
///
|
||||||
|
/// 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>> {
|
||||||
|
with_runtime(|inner| send_to_pid::<M>(inner, pid, msg))
|
||||||
|
}
|
||||||
|
|||||||
+33
-5
@@ -25,8 +25,12 @@
|
|||||||
//! - **Occupancy is bounded by `max_actors`.** A pid is in the queue at most
|
//! - **Occupancy is bounded by `max_actors`.** A pid is in the queue at most
|
||||||
//! once (pushes pair 1:1 with transitions into `Queued`; only the
|
//! once (pushes pair 1:1 with transitions into `Queued`; only the
|
||||||
//! scheduler transitions `Queued → Running` — see the state-machine docs
|
//! scheduler transitions `Queued → Running` — see the state-machine docs
|
||||||
//! in `runtime.rs`), and at most `max_actors` actors exist. The bounded
|
//! in `runtime.rs`), and at most `max_actors` actors exist. With the RFC
|
||||||
//! rings are sized ≥ `max_actors`, so **`push` is infallible**; a full
|
//! 005 wake slot enabled the invariant reads "in (slot ⊕ shared queue) at
|
||||||
|
//! most once" — a slot push *replaces* the queue push at the same
|
||||||
|
//! protocol point, and a displacement moves the occupant, never copies
|
||||||
|
//! it — so the bound holds verbatim. The bounded rings are sized ≥
|
||||||
|
//! `max_actors`, so **`push` is infallible**; a full
|
||||||
//! ring is an invariant violation and panics loudly rather than spinning.
|
//! ring is an invariant violation and panics loudly rather than spinning.
|
||||||
//! - **Preemption must be disabled around every push/pop** (debug-asserted).
|
//! - **Preemption must be disabled around every push/pop** (debug-asserted).
|
||||||
//! For the mutex variant this is the usual no-switch/no-unwind-under-lock
|
//! For the mutex variant this is the usual no-switch/no-unwind-under-lock
|
||||||
@@ -109,16 +113,32 @@ impl MutexQueue {
|
|||||||
|
|
||||||
pub fn push(&self, pid: Pid) {
|
pub fn push(&self, pid: Pid) {
|
||||||
assert_no_preempt();
|
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> {
|
pub fn pop(&self) -> Option<Pid> {
|
||||||
assert_no_preempt();
|
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 {
|
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}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +278,10 @@ impl MpmcRing {
|
|||||||
let d = self.dequeue_pos.0.load(Ordering::Relaxed);
|
let d = self.dequeue_pos.0.load(Ordering::Relaxed);
|
||||||
e.saturating_sub(d) as u64
|
e.saturating_sub(d) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -337,6 +361,10 @@ impl StripedRing {
|
|||||||
pub fn len(&self) -> u64 {
|
pub fn len(&self) -> u64 {
|
||||||
self.stripes.iter().map(|s| s.len()).sum()
|
self.stripes.iter().map(|s| s.len()).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.stripes.iter().all(|s| s.is_empty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+849
-174
File diff suppressed because it is too large
Load Diff
+695
-163
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
|
//! A *supervisor* is an actor whose only job is to start a fixed set of child
|
||||||
//! `Receiver<Signal>`. When a child actor terminates, the scheduler sends
|
//! actors and react when one of them terminates — restarting it (and, depending
|
||||||
//! a `Signal` on the supervisor's channel. The supervisor decides what to
|
//! on the strategy, some of its siblings) according to a policy, or giving up
|
||||||
//! do — restart, escalate, ignore.
|
//! 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
|
//! The supervisor type is [`OneForOne`]. The name is historical — the restart
|
||||||
//! lives in user code; library is mechanism only.
|
//! *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 crate::pid::Pid;
|
||||||
use std::any::Any;
|
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 {
|
pub enum Signal {
|
||||||
/// The child exited normally.
|
/// The child exited normally.
|
||||||
Exit(Pid),
|
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 crate::channel::channel;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -115,11 +192,10 @@ pub enum Strategy {
|
|||||||
|
|
||||||
/// A supervisor over a fixed set of children.
|
/// A supervisor over a fixed set of children.
|
||||||
///
|
///
|
||||||
/// Despite the name (kept for backwards compatibility), the restart strategy
|
/// Build it with [`new`](Self::new), add children with [`child`](Self::child),
|
||||||
/// is selectable via [`OneForOne::strategy`]; the default is
|
/// pick a [`strategy`](Self::strategy) and an [`intensity`](Self::intensity)
|
||||||
/// [`Strategy::OneForOne`]. Build with `new()`, add children with `child()`,
|
/// cap, then drive the loop with [`run`](Self::run) on an actor of its own. See
|
||||||
/// tune the cap with `intensity()`, then drive it with `run()` from inside an
|
/// the [module docs](self) for the full picture.
|
||||||
/// actor (typically `spawn(|| OneForOne::new()....run())`).
|
|
||||||
pub struct OneForOne {
|
pub struct OneForOne {
|
||||||
children: Vec<ChildSpec>,
|
children: Vec<ChildSpec>,
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
@@ -253,7 +329,7 @@ impl OneForOne {
|
|||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
// Stop survivors in reverse start order (highest child index first).
|
// 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
|
// The set we will restart: the failed child plus every sibling we
|
||||||
// are about to stop, restarted in start (ascending index) order.
|
// 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
|
// 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.
|
// the remaining children down deterministically instead of leaking them.
|
||||||
let mut survivors: Vec<(Pid, usize)> = by_pid.iter().map(|(p, i)| (*p, *i)).collect();
|
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());
|
let mut awaiting: Vec<Pid> = Vec::with_capacity(survivors.len());
|
||||||
for (pid, _) in &survivors {
|
for (pid, _) in &survivors {
|
||||||
crate::scheduler::request_stop(*pid);
|
crate::scheduler::request_stop(*pid);
|
||||||
|
|||||||
+11
-2
@@ -6,10 +6,19 @@
|
|||||||
//! Build the loom models with: `RUSTFLAGS="--cfg loom" cargo test --lib --release`
|
//! Build the loom models with: `RUSTFLAGS="--cfg loom" cargo test --lib --release`
|
||||||
|
|
||||||
#[cfg(loom)]
|
#[cfg(loom)]
|
||||||
pub(crate) use loom::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
pub(crate) use loom::sync::atomic::{fence, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
|
||||||
#[cfg(not(loom))]
|
#[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
|
/// `UnsafeCell` with loom's `with`/`with_mut` access API; pass-through cost
|
||||||
/// is zero in normal builds (`#[inline]`, newtype over std's cell).
|
/// is zero in normal builds (`#[inline]`, newtype over std's cell).
|
||||||
|
|||||||
+228
-13
@@ -15,12 +15,14 @@
|
|||||||
//! `BinaryHeap` is a max-heap; entries are wrapped in `Reverse` to get
|
//! `BinaryHeap` is a max-heap; entries are wrapped in `Reverse` to get
|
||||||
//! min-heap behaviour.
|
//! min-heap behaviour.
|
||||||
//!
|
//!
|
||||||
//! No cancellation. When a non-timer wakeup happens (e.g. lock granted
|
//! Cancellation is selective. A `Sleep` / `WaitTimeout` entry is left in the
|
||||||
//! before timeout), the timer entry is left in the heap. It will be popped
|
//! heap on a non-timer wakeup (lock granted before timeout): it is popped
|
||||||
//! eventually and the dispatch will observe "actor is no longer parked /
|
//! eventually and no-ops because a stale unpark fails its epoch CAS — cheap
|
||||||
//! the wait's epoch was consumed" and no-op. Cost is ~32 bytes per stale
|
//! (~32 bytes per stale entry plus a few cycles on pop), bounded by one entry
|
||||||
//! entry plus a few cycles on pop; acceptable given the upper bound is "one
|
//! per parked actor. A `Send` entry is different: running its thunk delivers a
|
||||||
//! entry per parked actor".
|
//! real message, so a stale one is *not* inert. `send_after` therefore carries
|
||||||
|
//! true cancellation via the `armed` set keyed on the entry's `seq`; `pop_due`
|
||||||
|
//! fires a `Send` only while it is still armed, and `cancel` removes the arm.
|
||||||
//!
|
//!
|
||||||
//! Stale pids (slot reused since the timer was inserted) are filtered on
|
//! Stale pids (slot reused since the timer was inserted) are filtered on
|
||||||
//! pop by the scheduler — same convention as the run queue.
|
//! pop by the scheduler — same convention as the run queue.
|
||||||
@@ -51,6 +53,37 @@ pub enum Reason {
|
|||||||
target: Arc<dyn TimerTarget>,
|
target: Arc<dyn TimerTarget>,
|
||||||
epoch: u32,
|
epoch: u32,
|
||||||
},
|
},
|
||||||
|
/// `send_after`: deliver a message to an address at the deadline,
|
||||||
|
/// cancellable. The destination (a `Pid<A>` / `Name<M>`) and the message
|
||||||
|
/// are captured inside `fire`, which resolves the address through the
|
||||||
|
/// registry and sends *when run* — so a target that died or, for a name,
|
||||||
|
/// was restarted is observed at fire time, not arm time. A failed resolve
|
||||||
|
/// or send is dropped (Erlang `erlang:send_after` semantics).
|
||||||
|
///
|
||||||
|
/// Unlike `Sleep` / `WaitTimeout`, a stale `Send` is **not** inert — running
|
||||||
|
/// the thunk delivers a real message — so these are the only timers that
|
||||||
|
/// carry true cancellation (the `armed` set on [`Timers`], keyed by the
|
||||||
|
/// entry's `seq`). `pop_due` fires the thunk only for an entry still armed.
|
||||||
|
Send { fire: Box<dyn FnOnce() + Send> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque handle to an armed `send_after` timer, returned by
|
||||||
|
/// [`Timers::insert_send`] and consumed by [`Timers::cancel`]. The inner value
|
||||||
|
/// is the entry's insertion `seq`; callers must treat it as opaque so the
|
||||||
|
/// backing structure can change (e.g. a future hierarchical timing wheel) with
|
||||||
|
/// no API churn.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct TimerId(u64);
|
||||||
|
|
||||||
|
impl TimerId {
|
||||||
|
/// Wrap a raw value. Crate-internal: the gen_server timer layer mints its
|
||||||
|
/// own loop-local `TimerId`s (the public ids it hands out, decoupled from
|
||||||
|
/// the per-re-arm substrate `seq`) and maps them to live substrate ids.
|
||||||
|
/// These local ids are only ever resolved through that layer's registry —
|
||||||
|
/// never passed back to [`Timers::cancel`] — so the two id roles do not mix.
|
||||||
|
pub(crate) fn from_raw(v: u64) -> Self {
|
||||||
|
TimerId(v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback the scheduler invokes when a `WaitTimeout` entry pops.
|
/// Callback the scheduler invokes when a `WaitTimeout` entry pops.
|
||||||
@@ -69,6 +102,19 @@ pub struct Entry {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
pub pid: Pid,
|
pub pid: Pid,
|
||||||
pub reason: Reason,
|
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 {
|
impl PartialEq for Entry {
|
||||||
@@ -95,15 +141,42 @@ impl PartialOrd for Entry {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Timers {
|
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.
|
/// Reverse-wrapped so the smallest deadline is at the top.
|
||||||
heap: BinaryHeap<Reverse<Entry>>,
|
heap: BinaryHeap<Reverse<Entry>>,
|
||||||
/// Monotonic counter for the tiebreaker `seq` field.
|
/// Monotonic counter for the tiebreaker `seq` field (and the `TimerId` of a
|
||||||
|
/// `Send` timer — the two are the same value).
|
||||||
next_seq: u64,
|
next_seq: u64,
|
||||||
|
/// Presence set of *live* `Send` timers, keyed by `seq`. Populated on
|
||||||
|
/// `insert_send`, removed on fire (in `pop_due`) and on `cancel`. A `Send`
|
||||||
|
/// entry fires only while present, so a `cancel` that lands before the
|
||||||
|
/// entry pops prevents delivery; a `cancel` after it has fired finds
|
||||||
|
/// nothing (the race signal). Bounded by armed-but-not-yet-resolved timers
|
||||||
|
/// and self-collecting — no sweep. `Sleep` / `WaitTimeout` never touch it.
|
||||||
|
armed: std::collections::HashSet<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Timers {
|
impl Timers {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { heap: BinaryHeap::new(), next_seq: 0 }
|
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.
|
/// Insert a `Sleep` timer. Convenience for the common case.
|
||||||
@@ -111,11 +184,91 @@ impl Timers {
|
|||||||
self.insert(deadline, pid, Reason::Sleep { epoch });
|
self.insert(deadline, pid, Reason::Sleep { epoch });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert an arbitrary timer entry.
|
/// 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*
|
||||||
|
/// used to wake anyone, the delivery lives entirely inside `fire`. Returns
|
||||||
|
/// a [`TimerId`] for cancellation.
|
||||||
|
pub fn insert_send(
|
||||||
|
&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 }, 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
|
||||||
|
/// still armed (delivery is now prevented), `false` if it had already
|
||||||
|
/// fired or been cancelled. The heap entry, if still pending, is left to be
|
||||||
|
/// discarded when its deadline passes — `pop_due` drops any `Send` entry
|
||||||
|
/// whose `seq` is no longer armed.
|
||||||
|
pub fn cancel(&mut self, id: TimerId) -> bool {
|
||||||
|
self.armed.remove(&id.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an arbitrary (virtual-anchored) timer entry.
|
||||||
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
|
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;
|
let seq = self.next_seq;
|
||||||
self.next_seq = self.next_seq.wrapping_add(1);
|
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 {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -127,6 +280,10 @@ impl Timers {
|
|||||||
/// discarded so it can't keep the runtime alive.
|
/// discarded so it can't keep the runtime alive.
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.heap.clear();
|
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.
|
/// Soonest pending deadline, or `None` if the heap is empty.
|
||||||
@@ -136,14 +293,72 @@ impl Timers {
|
|||||||
|
|
||||||
/// Pop every entry whose deadline is ≤ `now`, in deadline order.
|
/// Pop every entry whose deadline is ≤ `now`, in deadline order.
|
||||||
/// The scheduler dispatches each entry by inspecting `entry.reason`.
|
/// The scheduler dispatches each entry by inspecting `entry.reason`.
|
||||||
|
///
|
||||||
|
/// A due `Send` entry is returned only if it is still armed; a cancelled
|
||||||
|
/// 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> {
|
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
#[cfg(feature = "smarm-causal")]
|
||||||
|
let global = crate::causal::global_delay_cycles();
|
||||||
while let Some(r) = self.heap.peek() {
|
while let Some(r) = self.heap.peek() {
|
||||||
if r.0.deadline <= now {
|
if r.0.deadline > now {
|
||||||
out.push(self.heap.pop().unwrap().0);
|
|
||||||
} else {
|
|
||||||
break;
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-7
@@ -11,7 +11,7 @@
|
|||||||
//! cargo test --test runtime <test_name> --features smarm-trace
|
//! cargo test --test runtime <test_name> --features smarm-trace
|
||||||
//!
|
//!
|
||||||
//! Output: smarm_trace.json in cwd, or $SMARM_TRACE_FILE.
|
//! Output: smarm_trace.json in cwd, or $SMARM_TRACE_FILE.
|
||||||
//! View: https://ui.perfetto.dev or chrome://tracing
|
//! View: <https://ui.perfetto.dev> or chrome://tracing
|
||||||
|
|
||||||
#[cfg(feature = "smarm-trace")]
|
#[cfg(feature = "smarm-trace")]
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
@@ -58,6 +58,9 @@ mod inner {
|
|||||||
// Queue
|
// Queue
|
||||||
Enqueue(Pid),
|
Enqueue(Pid),
|
||||||
Dequeue(Pid),
|
Dequeue(Pid),
|
||||||
|
// RFC 005 wake slot
|
||||||
|
SlotPush(Pid), // actor-context wake parked in the waking thread's slot
|
||||||
|
SlotPop(Pid), // scheduler resumed a pid from its own slot
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -98,7 +101,7 @@ mod inner {
|
|||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static LOCAL_STATE: std::cell::RefCell<Option<LocalState>> =
|
static LOCAL_STATE: std::cell::RefCell<Option<LocalState>> =
|
||||||
std::cell::RefCell::new(None);
|
const { std::cell::RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -112,14 +115,20 @@ mod inner {
|
|||||||
let (tx, rx) = mpsc::channel::<Msg>();
|
let (tx, rx) = mpsc::channel::<Msg>();
|
||||||
let start = Instant::now();
|
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.
|
// Drain thread: owns the Receiver, writes to disk.
|
||||||
let path_for_thread = path.clone();
|
let path_for_thread = path.clone();
|
||||||
std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("smarm-trace-drain".into())
|
.name("smarm-trace-drain".into())
|
||||||
.spawn(move || drain_thread(rx, &path_for_thread))
|
.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);
|
eprintln!("[smarm-trace] writing to {}", path);
|
||||||
}
|
}
|
||||||
@@ -130,7 +139,10 @@ mod inner {
|
|||||||
// Drop the global sender so the drain thread's recv() returns Err
|
// Drop the global sender so the drain thread's recv() returns Err
|
||||||
// after the Flush sentinel, signalling clean shutdown.
|
// after the Flush sentinel, signalling clean shutdown.
|
||||||
let sender = {
|
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)
|
g.take().map(|g| g.sender)
|
||||||
};
|
};
|
||||||
if let Some(tx) = sender {
|
if let Some(tx) = sender {
|
||||||
@@ -159,7 +171,11 @@ mod inner {
|
|||||||
let mut opt = cell.borrow_mut();
|
let mut opt = cell.borrow_mut();
|
||||||
// Lazily initialise: one mutex hit per thread, ever.
|
// Lazily initialise: one mutex hit per thread, ever.
|
||||||
if opt.is_none() {
|
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();
|
let tx = g.sender.clone();
|
||||||
*opt = Some(LocalState { tx, start: g.start });
|
*opt = Some(LocalState { tx, start: g.start });
|
||||||
}
|
}
|
||||||
@@ -237,6 +253,8 @@ mod inner {
|
|||||||
Event::RecvWake(p) => ("recv_wake".into(), p.index()),
|
Event::RecvWake(p) => ("recv_wake".into(), p.index()),
|
||||||
Event::Enqueue(p) => ("enqueue".into(), p.index()),
|
Event::Enqueue(p) => ("enqueue".into(), p.index()),
|
||||||
Event::Dequeue(p) => ("dequeue".into(), p.index()),
|
Event::Dequeue(p) => ("dequeue".into(), p.index()),
|
||||||
|
Event::SlotPush(p) => ("slot_push".into(), p.index()),
|
||||||
|
Event::SlotPop(p) => ("slot_pop".into(), p.index()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,3 +130,61 @@ fn join_on_stopped_actor_returns_ok() {
|
|||||||
assert!(h.join().is_ok(), "join on a stopped actor returns Ok(())");
|
assert!(h.join().is_ok(), "join on a stopped actor returns Ok(())");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: `request_stop` against a QUEUED actor must not be lossy.
|
||||||
|
///
|
||||||
|
/// The stop flag is set, but the wildcard unpark no-ops on a Queued actor
|
||||||
|
/// (the pending run "is" the wake). If the actor's first action on resume
|
||||||
|
/// is a blocking park — no allocation, no `check!()` on the way — a
|
||||||
|
/// wake-side-only check in `park_current` never runs: the actor parks with
|
||||||
|
/// the stop flag already set, and nothing will ever wake it. The runtime
|
||||||
|
/// then idles forever (the root is parked on the monitor channel).
|
||||||
|
///
|
||||||
|
/// Fix: an entry-side `check_cancelled` in `park_current`. The remaining
|
||||||
|
/// window (flag set after the entry check, before the park lands) is closed
|
||||||
|
/// by the existing protocol: the stop's unpark then finds Running /
|
||||||
|
/// the prep-to-park window, sets Notified, and the park-return re-queues
|
||||||
|
/// into the wake-side check.
|
||||||
|
///
|
||||||
|
/// Watchdog harness: without the fix this deadlocks, so the runtime runs on
|
||||||
|
/// a side thread and the test fails on a timeout instead of hanging cargo.
|
||||||
|
#[test]
|
||||||
|
fn stop_flagged_while_queued_lands_at_first_park() {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let saw_stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let (d, s) = (dropped.clone(), saw_stopped.clone());
|
||||||
|
|
||||||
|
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = smarm::init(smarm::Config::exact(1));
|
||||||
|
rt.run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
let _g = DropFlag(d);
|
||||||
|
let (tx, rx) = channel::<u8>();
|
||||||
|
let _keep = tx; // keep the channel open: recv() parks
|
||||||
|
let _ = rx.recv(); // first observation point is this park
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
let down = monitor(pid);
|
||||||
|
// Stop while the child is still QUEUED — before it ever runs.
|
||||||
|
// The unpark no-ops; only the flag is left behind.
|
||||||
|
request_stop(pid);
|
||||||
|
let dn = down.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
assert_eq!(dn.pid, pid);
|
||||||
|
if matches!(dn.reason, DownReason::Stopped) {
|
||||||
|
s.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
});
|
||||||
|
done_rx
|
||||||
|
.recv_timeout(Duration::from_secs(10))
|
||||||
|
.expect("runtime deadlocked: stop against a QUEUED actor was lost at its first park");
|
||||||
|
|
||||||
|
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
|
||||||
|
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind");
|
||||||
|
}
|
||||||
|
|||||||
+1040
File diff suppressed because it is too large
Load Diff
+241
-8
@@ -1,7 +1,7 @@
|
|||||||
//! gen_server tests: call round-trip, cast, lifecycle callbacks, and the two
|
//! gen_server tests: call round-trip, cast, lifecycle callbacks, and the two
|
||||||
//! server-down detection paths (reply-channel close vs. inbox-send failure).
|
//! server-down detection paths (reply-channel close vs. inbox-send failure).
|
||||||
|
|
||||||
use smarm::gen_server::{start, CallError, GenServer, ServerBuilder};
|
use smarm::gen_server::{start, CallError, GenServer, GenServerBuilder};
|
||||||
use smarm::run;
|
use smarm::run;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ impl GenServer for Counter {
|
|||||||
type Reply = i64;
|
type Reply = i64;
|
||||||
type Cast = Op;
|
type Cast = Op;
|
||||||
type Info = ();
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
fn handle_call(&mut self, req: Req) -> i64 {
|
fn handle_call(&mut self, req: Req) -> i64 {
|
||||||
match req {
|
match req {
|
||||||
@@ -70,8 +71,9 @@ impl GenServer for Lifecycle {
|
|||||||
type Reply = ();
|
type Reply = ();
|
||||||
type Cast = ();
|
type Cast = ();
|
||||||
type Info = ();
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
fn init(&mut self, _ctx: &smarm::gen_server::ServerCtx) {
|
fn init(&mut self, _ctx: &smarm::gen_server::GenServerCtx<Self>) {
|
||||||
self.log.lock().unwrap().push("init");
|
self.log.lock().unwrap().push("init");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +156,7 @@ impl GenServer for Slow {
|
|||||||
type Reply = u64;
|
type Reply = u64;
|
||||||
type Cast = ();
|
type Cast = ();
|
||||||
type Info = ();
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
fn handle_call(&mut self, delay_ms: u64) -> u64 {
|
fn handle_call(&mut self, delay_ms: u64) -> u64 {
|
||||||
if delay_ms > 0 {
|
if delay_ms > 0 {
|
||||||
@@ -210,6 +213,7 @@ fn call_timeout_to_dead_server_is_server_down_not_timeout() {
|
|||||||
impl GenServer for Bomb {
|
impl GenServer for Bomb {
|
||||||
type Call = ();
|
type Call = ();
|
||||||
type Info = ();
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
type Reply = ();
|
type Reply = ();
|
||||||
type Cast = ();
|
type Cast = ();
|
||||||
fn handle_call(&mut self, _: ()) {
|
fn handle_call(&mut self, _: ()) {
|
||||||
@@ -248,6 +252,7 @@ impl GenServer for Logger {
|
|||||||
type Reply = Vec<&'static str>;
|
type Reply = Vec<&'static str>;
|
||||||
type Cast = ();
|
type Cast = ();
|
||||||
type Info = &'static str;
|
type Info = &'static str;
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
fn handle_call(&mut self, _: ()) -> Vec<&'static str> {
|
fn handle_call(&mut self, _: ()) -> Vec<&'static str> {
|
||||||
self.log.clone()
|
self.log.clone()
|
||||||
@@ -270,7 +275,7 @@ fn info_is_dispatched() {
|
|||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
run(move || {
|
run(move || {
|
||||||
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
||||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
let server = GenServerBuilder::new(Logger { log: Vec::new() })
|
||||||
.with_info(info_rx)
|
.with_info(info_rx)
|
||||||
.start();
|
.start();
|
||||||
info_tx.send("info").unwrap();
|
info_tx.send("info").unwrap();
|
||||||
@@ -288,7 +293,7 @@ fn info_outranks_inbox() {
|
|||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
run(move || {
|
run(move || {
|
||||||
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
||||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
let server = GenServerBuilder::new(Logger { log: Vec::new() })
|
||||||
.with_info(info_rx)
|
.with_info(info_rx)
|
||||||
.start();
|
.start();
|
||||||
// The server actor hasn't run yet: both messages are queued before
|
// The server actor hasn't run yet: both messages are queued before
|
||||||
@@ -309,7 +314,7 @@ fn info_arms_keep_declaration_priority() {
|
|||||||
run(move || {
|
run(move || {
|
||||||
let (hi_tx, hi_rx) = smarm::channel::<&'static str>();
|
let (hi_tx, hi_rx) = smarm::channel::<&'static str>();
|
||||||
let (lo_tx, lo_rx) = smarm::channel::<&'static str>();
|
let (lo_tx, lo_rx) = smarm::channel::<&'static str>();
|
||||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
let server = GenServerBuilder::new(Logger { log: Vec::new() })
|
||||||
.with_info(hi_rx)
|
.with_info(hi_rx)
|
||||||
.with_info(lo_rx)
|
.with_info(lo_rx)
|
||||||
.start();
|
.start();
|
||||||
@@ -330,7 +335,7 @@ fn closed_info_arm_is_dropped_silently() {
|
|||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
run(move || {
|
run(move || {
|
||||||
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
||||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
let server = GenServerBuilder::new(Logger { log: Vec::new() })
|
||||||
.with_info(info_rx)
|
.with_info(info_rx)
|
||||||
.start();
|
.start();
|
||||||
drop(info_tx); // closed before the server's first select
|
drop(info_tx); // closed before the server's first select
|
||||||
@@ -350,7 +355,7 @@ use smarm::{monitor, spawn, DownReason, Pid};
|
|||||||
/// The motivating pattern: a server that spawns workers from a handler,
|
/// The motivating pattern: a server that spawns workers from a handler,
|
||||||
/// watches them, and logs their deaths.
|
/// watches them, and logs their deaths.
|
||||||
struct Pool {
|
struct Pool {
|
||||||
watcher: Option<Watcher>,
|
watcher: Option<Watcher<Self>>,
|
||||||
log: Vec<DownReason>,
|
log: Vec<DownReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,8 +369,9 @@ impl GenServer for Pool {
|
|||||||
type Reply = Vec<DownReason>;
|
type Reply = Vec<DownReason>;
|
||||||
type Cast = PoolCast;
|
type Cast = PoolCast;
|
||||||
type Info = ();
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
fn init(&mut self, ctx: &smarm::gen_server::ServerCtx) {
|
fn init(&mut self, ctx: &smarm::gen_server::GenServerCtx<Self>) {
|
||||||
self.watcher = Some(ctx.watcher());
|
self.watcher = Some(ctx.watcher());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,3 +443,230 @@ fn unused_ctx_closes_control_arm_silently() {
|
|||||||
});
|
});
|
||||||
assert_eq!(*got.lock().unwrap(), 42);
|
assert_eq!(*got.lock().unwrap(), 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RFC 015 — gen_server timers. A server that arms one-shot timers from a cast
|
||||||
|
// and records each fire's payload, plus the cancel race signal.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
use smarm::gen_server::{GenServerCtx, TimerHandle};
|
||||||
|
use smarm::TimerId;
|
||||||
|
|
||||||
|
enum TkCast {
|
||||||
|
Arm(Duration),
|
||||||
|
Tick(Duration),
|
||||||
|
CancelLast,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Timed {
|
||||||
|
timer: Option<TimerHandle<Self>>,
|
||||||
|
fired: Arc<Mutex<Vec<u32>>>,
|
||||||
|
cancel_won: Arc<Mutex<Option<bool>>>,
|
||||||
|
last: Option<TimerId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenServer for Timed {
|
||||||
|
type Call = ();
|
||||||
|
type Reply = usize; // count of fires so far (a sync read point)
|
||||||
|
type Cast = TkCast;
|
||||||
|
type Info = ();
|
||||||
|
type Timer = u32;
|
||||||
|
|
||||||
|
fn init(&mut self, ctx: &GenServerCtx<Self>) {
|
||||||
|
self.timer = Some(ctx.timer());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_call(&mut self, _: ()) -> usize {
|
||||||
|
self.fired.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_cast(&mut self, c: TkCast) {
|
||||||
|
let t = self.timer.as_ref().expect("init ran first");
|
||||||
|
match c {
|
||||||
|
TkCast::Arm(d) => self.last = Some(t.arm_after(d, 7)),
|
||||||
|
TkCast::Tick(d) => self.last = Some(t.tick_every(d, 9)),
|
||||||
|
TkCast::CancelLast => {
|
||||||
|
let id = self.last.take().expect("a timer was armed");
|
||||||
|
*self.cancel_won.lock().unwrap() = Some(t.cancel(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_timer(&mut self, msg: u32) {
|
||||||
|
self.fired.lock().unwrap().push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timed(fired: Arc<Mutex<Vec<u32>>>, cancel_won: Arc<Mutex<Option<bool>>>) -> Timed {
|
||||||
|
Timed { timer: None, fired, cancel_won, last: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A one-shot armed from a handler fires into handle_timer with its payload.
|
||||||
|
#[test]
|
||||||
|
fn arm_after_fires_into_handle_timer() {
|
||||||
|
let fired = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let f2 = fired.clone();
|
||||||
|
run(move || {
|
||||||
|
let cw = Arc::new(Mutex::new(None));
|
||||||
|
let server = start(timed(f2, cw));
|
||||||
|
server.cast(TkCast::Arm(Duration::from_millis(10))).unwrap();
|
||||||
|
let _ = server.call(()).unwrap(); // sync: arm done
|
||||||
|
smarm::sleep(Duration::from_millis(40)); // let the timer fire
|
||||||
|
let count = server.call(()).unwrap(); // timer arm outranks this inbox call
|
||||||
|
assert_eq!(count, 1, "the one-shot should have fired exactly once");
|
||||||
|
});
|
||||||
|
assert_eq!(*fired.lock().unwrap(), vec![7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel before the deadline wins the race (returns true) and suppresses the
|
||||||
|
// fire entirely.
|
||||||
|
#[test]
|
||||||
|
fn cancel_before_fire_suppresses_it() {
|
||||||
|
let fired = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let cancel_won = Arc::new(Mutex::new(None));
|
||||||
|
let f2 = fired.clone();
|
||||||
|
let c2 = cancel_won.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(timed(f2, c2));
|
||||||
|
server.cast(TkCast::Arm(Duration::from_millis(50))).unwrap();
|
||||||
|
server.cast(TkCast::CancelLast).unwrap();
|
||||||
|
let _ = server.call(()).unwrap(); // sync: arm + cancel both handled
|
||||||
|
smarm::sleep(Duration::from_millis(80)); // past the original deadline
|
||||||
|
let count = server.call(()).unwrap();
|
||||||
|
assert_eq!(count, 0, "cancelled timer must not fire");
|
||||||
|
});
|
||||||
|
assert_eq!(*cancel_won.lock().unwrap(), Some(true), "cancel beat the fire");
|
||||||
|
assert!(fired.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// tick_every re-arms: a periodic fires repeatedly off one arm, each tick
|
||||||
|
// carrying a fresh payload. (Timer fires outrank the inbox by arm position, so
|
||||||
|
// a periodic cannot be starved by userspace traffic — the same property the
|
||||||
|
// info_outranks_inbox test pins for infos.)
|
||||||
|
#[test]
|
||||||
|
fn tick_every_rearms_repeatedly() {
|
||||||
|
let fired = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let f2 = fired.clone();
|
||||||
|
run(move || {
|
||||||
|
let cw = Arc::new(Mutex::new(None));
|
||||||
|
let server = start(timed(f2, cw));
|
||||||
|
server.cast(TkCast::Tick(Duration::from_millis(20))).unwrap();
|
||||||
|
let _ = server.call(()).unwrap(); // sync: periodic armed
|
||||||
|
smarm::sleep(Duration::from_millis(130)); // ~6 periods
|
||||||
|
let count = server.call(()).unwrap();
|
||||||
|
assert!(count >= 3, "periodic should have re-armed several times, got {count}");
|
||||||
|
});
|
||||||
|
// Every tick delivered the same payload.
|
||||||
|
assert!(fired.lock().unwrap().iter().all(|&v| v == 9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelling a periodic stops the re-arm: no further ticks land after cancel.
|
||||||
|
#[test]
|
||||||
|
fn cancel_stops_a_periodic() {
|
||||||
|
let fired = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let cancel_won = Arc::new(Mutex::new(None));
|
||||||
|
let f2 = fired.clone();
|
||||||
|
let c2 = cancel_won.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(timed(f2, c2));
|
||||||
|
server.cast(TkCast::Tick(Duration::from_millis(20))).unwrap();
|
||||||
|
let _ = server.call(()).unwrap();
|
||||||
|
smarm::sleep(Duration::from_millis(70)); // a few ticks
|
||||||
|
server.cast(TkCast::CancelLast).unwrap();
|
||||||
|
let after_cancel = server.call(()).unwrap(); // sync: cancel handled
|
||||||
|
smarm::sleep(Duration::from_millis(80)); // would be several more ticks
|
||||||
|
let later = server.call(()).unwrap();
|
||||||
|
assert_eq!(later, after_cancel, "no ticks may land after cancel");
|
||||||
|
});
|
||||||
|
// The periodic had fired at least once before being cancelled.
|
||||||
|
assert!(!fired.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RFC 015 §4.4 — idle / receive timeout. A server that sets an idle window in
|
||||||
|
// init and counts handle_idle fires; casts are traffic that resets the window.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
struct Idler {
|
||||||
|
window: Duration,
|
||||||
|
idles: Arc<Mutex<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenServer for Idler {
|
||||||
|
type Call = ();
|
||||||
|
type Reply = u32; // idle fire count
|
||||||
|
type Cast = (); // a poke: traffic that resets the idle window
|
||||||
|
type Info = ();
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
|
fn init(&mut self, ctx: &GenServerCtx<Self>) {
|
||||||
|
ctx.idle_after(self.window);
|
||||||
|
}
|
||||||
|
fn handle_call(&mut self, _: ()) -> u32 {
|
||||||
|
*self.idles.lock().unwrap()
|
||||||
|
}
|
||||||
|
fn handle_cast(&mut self, _: ()) {}
|
||||||
|
fn handle_idle(&mut self) {
|
||||||
|
*self.idles.lock().unwrap() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quiet inbox fires handle_idle, and the window re-arms (steady detector):
|
||||||
|
// several fires across a quiet span.
|
||||||
|
#[test]
|
||||||
|
fn idle_fires_repeatedly_on_quiet() {
|
||||||
|
let idles = Arc::new(Mutex::new(0));
|
||||||
|
let i2 = idles.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(Idler { window: Duration::from_millis(25), idles: i2 });
|
||||||
|
smarm::sleep(Duration::from_millis(130)); // quiet ⇒ ~5 windows
|
||||||
|
drop(server); // keep the server alive across the quiet span
|
||||||
|
});
|
||||||
|
assert!(*idles.lock().unwrap() >= 2, "idle should re-arm and fire several times");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic within the window keeps idle from firing; only once the inbox goes
|
||||||
|
// quiet does handle_idle fire.
|
||||||
|
#[test]
|
||||||
|
fn traffic_resets_the_idle_window() {
|
||||||
|
let idles = Arc::new(Mutex::new(0));
|
||||||
|
let i2 = idles.clone();
|
||||||
|
let before_quiet = Arc::new(Mutex::new(u32::MAX));
|
||||||
|
let bq = before_quiet.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(Idler { window: Duration::from_millis(60), idles: i2 });
|
||||||
|
// Poke every 25ms (< 60ms window) for ~100ms: each cast resets the
|
||||||
|
// window before it can elapse.
|
||||||
|
for _ in 0..4 {
|
||||||
|
server.cast(()).unwrap();
|
||||||
|
smarm::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
*bq.lock().unwrap() = server.call(()).unwrap(); // count while traffic kept it quiet-free
|
||||||
|
smarm::sleep(Duration::from_millis(140)); // now genuinely quiet
|
||||||
|
drop(server);
|
||||||
|
});
|
||||||
|
assert_eq!(*before_quiet.lock().unwrap(), 0, "steady traffic must suppress idle");
|
||||||
|
assert!(*idles.lock().unwrap() >= 1, "idle fires once the inbox falls quiet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 015 §4.7 — no armed timer survives loop exit. A server with a live
|
||||||
|
// periodic is dropped; the loop's drop guard drains and cancels it (a surviving
|
||||||
|
// re-arming timer would trip the in-Drop debug_assert), runs terminate, and no
|
||||||
|
// further tick lands after exit.
|
||||||
|
#[test]
|
||||||
|
fn no_timer_survives_exit() {
|
||||||
|
let fired = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let f_server = fired.clone();
|
||||||
|
let f_read = fired.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(timed(f_server, Arc::new(Mutex::new(None))));
|
||||||
|
server.cast(TkCast::Tick(Duration::from_millis(15))).unwrap();
|
||||||
|
let _ = server.call(()).unwrap(); // sync: periodic armed
|
||||||
|
smarm::sleep(Duration::from_millis(45)); // a couple of ticks
|
||||||
|
let mon = smarm::monitor(server.pid());
|
||||||
|
drop(server); // inbox closes → loop exits → guard drains timers
|
||||||
|
// Clean Down ⇒ the loop returned without the no-leak assert aborting.
|
||||||
|
assert!(mon.rx.recv().is_ok());
|
||||||
|
let at_exit = f_read.lock().unwrap().len();
|
||||||
|
smarm::sleep(Duration::from_millis(90)); // would be several more ticks
|
||||||
|
assert_eq!(f_read.lock().unwrap().len(), at_exit, "no tick may fire after exit");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
//! 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), 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;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 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 T {
|
||||||
|
Idle,
|
||||||
|
Armed,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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: TimerSm { state: T, data: TData };
|
||||||
|
event: Ev2 { cast: TCast, call: TCall, info: () };
|
||||||
|
context(data, prev, cx);
|
||||||
|
|
||||||
|
enter {
|
||||||
|
// 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 T::Idle => {
|
||||||
|
cast TCast::Arm => T::Armed,
|
||||||
|
cast TCast::Disarm => unhandled,
|
||||||
|
state_timeout => unhandled,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _ => {
|
||||||
|
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() -> T {
|
||||||
|
panic!("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || {
|
||||||
|
// 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(), 5, "one enter on start, one per real transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `enter` fires once on start and once per *real* transition; a stay (a call
|
||||||
|
// that returns the current tag) does not re-enter.
|
||||||
|
#[test]
|
||||||
|
fn enter_on_start_and_each_transition_but_not_stay() {
|
||||||
|
let got = Arc::new(Mutex::new((0u32, 0u32, 0u32)));
|
||||||
|
let got2 = got.clone();
|
||||||
|
run(move || {
|
||||||
|
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); // enter -> 1
|
||||||
|
let 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A handler that panics tears the loop down; the in-flight call's reply channel
|
||||||
|
// closes as the stack unwinds, so the parked caller wakes with Down (mirrors
|
||||||
|
// gen_server's panicking-handler path).
|
||||||
|
#[test]
|
||||||
|
fn call_to_panicking_handler_is_down() {
|
||||||
|
let got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
|
||||||
|
let got2 = got.clone();
|
||||||
|
run(move || {
|
||||||
|
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
|
||||||
|
let 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,354 @@
|
|||||||
|
//! RFC 016 Chunk 1 — the read primitive. These exercise exactly what the RFC
|
||||||
|
//! promised: tests that assert an actor's state, parentage, names, mailbox
|
||||||
|
//! depth, and lifecycle counts directly off `snapshot()` / `actor_info()`
|
||||||
|
//! instead of sleeping-and-hoping.
|
||||||
|
|
||||||
|
use smarm::{
|
||||||
|
actor_info, channel, monitor, register, run, self_pid, send, snapshot, spawn, tree, tree_from,
|
||||||
|
ActorInfo, ActorState, Name, Pid, RuntimeSnapshot, SNAPSHOT_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SVC: Name<u64> = Name::new("svc");
|
||||||
|
|
||||||
|
/// Bounded poll on the introspection result itself (not a wall-clock sleep):
|
||||||
|
/// yield until `pred` holds for the given pid, panicking if it never does.
|
||||||
|
fn spin_until(pid: Pid, mut pred: impl FnMut(&smarm::ActorInfo) -> bool) -> smarm::ActorInfo {
|
||||||
|
for _ in 0..100_000 {
|
||||||
|
if let Some(info) = actor_info(pid) {
|
||||||
|
if pred(&info) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smarm::yield_now();
|
||||||
|
}
|
||||||
|
panic!("actor {pid:?} never reached the expected state");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_lists_actors_with_parent_edge() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let (_cmd_tx, cmd_rx) = channel::<u64>();
|
||||||
|
|
||||||
|
let h = spawn(move || {
|
||||||
|
register(Name::<u64>::new("w"), _cmd_tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap(); // park here until released
|
||||||
|
drop(cmd_rx);
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let me = self_pid();
|
||||||
|
let snap = snapshot();
|
||||||
|
assert_eq!(snap.format_version, SNAPSHOT_FORMAT_VERSION);
|
||||||
|
|
||||||
|
let worker = snap
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.pid == h.pid())
|
||||||
|
.expect("worker present in snapshot");
|
||||||
|
assert_eq!(worker.names, vec!["w"]);
|
||||||
|
// `spawn` records the spawning actor as the parent (D9).
|
||||||
|
assert_eq!(worker.supervisor, me);
|
||||||
|
assert!(!worker.trap_exit);
|
||||||
|
assert_eq!((worker.monitors, worker.links, worker.joiners), (0, 0, 0));
|
||||||
|
|
||||||
|
// The root itself is on-CPU (it's running this code) and rooted under
|
||||||
|
// the forest sentinel.
|
||||||
|
let root = snap.actors.iter().find(|a| a.pid == me).expect("root present");
|
||||||
|
assert_eq!(root.state, ActorState::Running);
|
||||||
|
assert_eq!(root.supervisor, smarm::Pid::new(u32::MAX, u32::MAX));
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parked_state_is_observable() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
// The worker has nothing to do but block on the empty gate channel, so
|
||||||
|
// it must reach Parked.
|
||||||
|
let info = spin_until(h.pid(), |a| a.state == ActorState::Parked);
|
||||||
|
assert_eq!(info.state, ActorState::Parked);
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_depth_counts_queued_messages() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let (cmd_tx, _cmd_rx) = channel::<u64>();
|
||||||
|
|
||||||
|
let h = spawn(move || {
|
||||||
|
// Publish the command inbox, then block on an unrelated gate so the
|
||||||
|
// queued commands are never drained while we observe them.
|
||||||
|
register(SVC, cmd_tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
drop(_cmd_rx);
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
send(SVC, i).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth is a property of the queue, set synchronously by `send`, so it
|
||||||
|
// reads 3 regardless of the worker's scheduling state.
|
||||||
|
let via_snapshot = snapshot()
|
||||||
|
.actors
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.pid == h.pid())
|
||||||
|
.expect("worker present");
|
||||||
|
assert_eq!(via_snapshot.mailbox_depth, 3);
|
||||||
|
assert_eq!(actor_info(h.pid()).unwrap().mailbox_depth, 3);
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_count_is_visible() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let _m = monitor(h.pid());
|
||||||
|
let info = actor_info(h.pid()).expect("worker present");
|
||||||
|
assert_eq!(info.monitors, 1);
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_and_forged_pids_return_none() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
let live = h.pid();
|
||||||
|
|
||||||
|
// Same slot index, wrong generation → stale, no such incarnation.
|
||||||
|
let stale = Pid::new(live.index(), live.generation().wrapping_add(7));
|
||||||
|
assert!(actor_info(stale).is_none());
|
||||||
|
|
||||||
|
// Out-of-range index → not in the slab at all.
|
||||||
|
let forged = Pid::new(u32::MAX - 1, 0);
|
||||||
|
assert!(actor_info(forged).is_none());
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn done_actor_is_a_tombstone() {
|
||||||
|
run(|| {
|
||||||
|
// Hold the join handle so the slot is NOT reclaimed when the actor
|
||||||
|
// exits: outstanding_handles stays > 0, leaving a Done tombstone to
|
||||||
|
// observe.
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
let info = spin_until(pid, |a| a.state == ActorState::Done);
|
||||||
|
assert_eq!(info.state, ActorState::Done);
|
||||||
|
// The Actor record is gone at finalize, so a tombstone reports root-less
|
||||||
|
// with empty lifecycle counts.
|
||||||
|
assert_eq!(info.supervisor, Pid::new(u32::MAX, u32::MAX));
|
||||||
|
assert_eq!((info.monitors, info.links, info.joiners), (0, 0, 0));
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_places_child_under_its_spawner() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let me = self_pid();
|
||||||
|
let t = tree();
|
||||||
|
assert_eq!(t.format_version, SNAPSHOT_FORMAT_VERSION);
|
||||||
|
|
||||||
|
// The root is parented at the forest sentinel, so it's a genuine root,
|
||||||
|
// and the worker it spawned hangs beneath it.
|
||||||
|
let root = t.roots.iter().find(|n| n.info.pid == me).expect("root in forest");
|
||||||
|
assert!(!root.orphaned);
|
||||||
|
assert!(
|
||||||
|
root.children.iter().any(|c| c.info.pid == h.pid()),
|
||||||
|
"spawned worker should be a child of its spawner"
|
||||||
|
);
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D8 re-rooting and nesting, exercised on a synthetic snapshot via the public
|
||||||
|
/// `tree_from` — the live lifecycle race (parent reclaimed while child lives)
|
||||||
|
/// is exactly what's awkward to stage deterministically, which is why the fold
|
||||||
|
/// is testable in isolation.
|
||||||
|
#[test]
|
||||||
|
fn tree_from_nests_children_and_reroots_orphans() {
|
||||||
|
let root_sentinel = Pid::new(u32::MAX, u32::MAX);
|
||||||
|
let root_pid = Pid::new(0, 1);
|
||||||
|
let child = Pid::new(1, 1);
|
||||||
|
let orphan = Pid::new(2, 1);
|
||||||
|
let absent_parent = Pid::new(99, 1);
|
||||||
|
|
||||||
|
let mk = |pid: Pid, supervisor: Pid| ActorInfo {
|
||||||
|
pid,
|
||||||
|
names: Vec::new(),
|
||||||
|
state: ActorState::Running,
|
||||||
|
supervisor,
|
||||||
|
trap_exit: false,
|
||||||
|
monitors: 0,
|
||||||
|
links: 0,
|
||||||
|
joiners: 0,
|
||||||
|
mailbox_depth: 0,
|
||||||
|
overruns: 0,
|
||||||
|
messages_received: 0,
|
||||||
|
budget_cycles: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let snap = RuntimeSnapshot {
|
||||||
|
format_version: SNAPSHOT_FORMAT_VERSION,
|
||||||
|
actors: vec![
|
||||||
|
mk(root_pid, root_sentinel),
|
||||||
|
mk(child, root_pid),
|
||||||
|
mk(orphan, absent_parent),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let t = tree_from(snap);
|
||||||
|
assert_eq!(t.roots.len(), 2);
|
||||||
|
|
||||||
|
let root = t.roots.iter().find(|n| n.info.pid == root_pid).expect("root present");
|
||||||
|
assert!(!root.orphaned);
|
||||||
|
assert_eq!(root.children.len(), 1);
|
||||||
|
assert_eq!(root.children[0].info.pid, child);
|
||||||
|
assert!(!root.children[0].orphaned);
|
||||||
|
|
||||||
|
let o = t.roots.iter().find(|n| n.info.pid == orphan).expect("orphan re-rooted");
|
||||||
|
assert!(o.orphaned, "an actor whose parent is absent must be flagged orphaned");
|
||||||
|
assert!(o.children.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overrun_count_increments_on_forced_preemption() {
|
||||||
|
run(|| {
|
||||||
|
// A worker that forces its slice to expire, then hits an observation
|
||||||
|
// point so the slice-expiry site fires and tallies one overrun.
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
smarm::preempt::expire_timeslice_for_test();
|
||||||
|
smarm::check!(); // preempt-yield here → one overrun tallied
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap(); // worker is past the forced preemption
|
||||||
|
|
||||||
|
let info = actor_info(h.pid()).expect("worker present");
|
||||||
|
assert!(
|
||||||
|
info.overruns >= 1,
|
||||||
|
"forced timeslice expiry should tally at least one overrun, got {}",
|
||||||
|
info.overruns
|
||||||
|
);
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_received_counts_dequeues() {
|
||||||
|
const MQ: Name<u64> = Name::new("mq");
|
||||||
|
const N: u64 = 5;
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (done_tx, done_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let (cmd_tx, cmd_rx) = channel::<u64>();
|
||||||
|
|
||||||
|
let h = spawn(move || {
|
||||||
|
register(MQ, cmd_tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap(); // sends don't count toward received
|
||||||
|
for _ in 0..N {
|
||||||
|
cmd_rx.recv().unwrap(); // each dequeue tallies one
|
||||||
|
}
|
||||||
|
done_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap(); // happens only after we've checked
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
for i in 0..N {
|
||||||
|
send(MQ, i).unwrap();
|
||||||
|
}
|
||||||
|
done_rx.recv().unwrap(); // worker has drained all N
|
||||||
|
|
||||||
|
let info = actor_info(h.pid()).expect("worker present");
|
||||||
|
assert_eq!(info.messages_received, N, "one tally per dequeued message");
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "budget-accounting")]
|
||||||
|
#[test]
|
||||||
|
fn budget_cycles_accumulate_when_enabled() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
// A little work so the consumed slice is non-trivial, then park.
|
||||||
|
let mut acc = 0u64;
|
||||||
|
for i in 0..10_000u64 {
|
||||||
|
acc = acc.wrapping_add(i);
|
||||||
|
smarm::check!();
|
||||||
|
}
|
||||||
|
std::hint::black_box(acc);
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
// Once the worker has run and yielded (here, parked), its slice is
|
||||||
|
// charged.
|
||||||
|
let info = spin_until(h.pid(), |a| a.state == ActorState::Parked);
|
||||||
|
assert!(
|
||||||
|
info.budget_cycles > 0,
|
||||||
|
"budget should accrue after the actor runs and yields"
|
||||||
|
);
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! RFC 016 Chunk 4 — the observer gen_server. The whole file is gated on the
|
||||||
|
//! `observer` feature (run with `cargo test --features observer`); without it
|
||||||
|
//! the module does not exist and there is nothing to compile.
|
||||||
|
//!
|
||||||
|
//! The point these prove: the observer is *transport over the same reads*. Each
|
||||||
|
//! verb returns exactly what the corresponding Chunk-1 primitive would, just
|
||||||
|
//! marshalled over the gen_server call channel — so a known spawned actor that
|
||||||
|
//! `snapshot()` / `actor_info()` would see is equally visible through the
|
||||||
|
//! observer.
|
||||||
|
#![cfg(feature = "observer")]
|
||||||
|
|
||||||
|
use smarm::observer::{self, ObserverReply, ObserverRequest};
|
||||||
|
use smarm::{channel, run, ActorState, SNAPSHOT_FORMAT_VERSION};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observer_relays_snapshot_tree_and_actor_info() {
|
||||||
|
run(|| {
|
||||||
|
// A worker parked on an empty gate: a known, stable actor for the
|
||||||
|
// observer to find across all three verbs.
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let worker = smarm::spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let obs = observer::start();
|
||||||
|
|
||||||
|
// Snapshot: the worker is present, and so is the observer itself — it
|
||||||
|
// is a scheduled actor like any other.
|
||||||
|
let ObserverReply::Snapshot(snap) = obs.call(ObserverRequest::Snapshot).unwrap() else {
|
||||||
|
panic!("Snapshot verb must reply Snapshot");
|
||||||
|
};
|
||||||
|
assert_eq!(snap.format_version, SNAPSHOT_FORMAT_VERSION);
|
||||||
|
assert!(
|
||||||
|
snap.actors.iter().any(|a| a.pid == worker.pid()),
|
||||||
|
"observer's snapshot should contain the spawned worker"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
snap.actors.iter().any(|a| a.pid == obs.pid()),
|
||||||
|
"observer should appear in the snapshot it produced"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tree: same data folded into the parentage forest, same version.
|
||||||
|
let ObserverReply::Tree(t) = obs.call(ObserverRequest::Tree).unwrap() else {
|
||||||
|
panic!("Tree verb must reply Tree");
|
||||||
|
};
|
||||||
|
assert_eq!(t.format_version, SNAPSHOT_FORMAT_VERSION);
|
||||||
|
fn contains(nodes: &[smarm::TreeNode], pid: smarm::Pid) -> bool {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.any(|n| n.info.pid == pid || contains(&n.children, pid))
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
contains(&t.roots, worker.pid()),
|
||||||
|
"worker should appear somewhere in the observer's tree"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ActorInfo: coherent single-actor view, matching a direct read.
|
||||||
|
let ObserverReply::ActorInfo(Some(info)) =
|
||||||
|
obs.call(ObserverRequest::ActorInfo(worker.pid())).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("ActorInfo verb must reply ActorInfo(Some) for a live worker");
|
||||||
|
};
|
||||||
|
assert_eq!(info.pid, worker.pid());
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
worker.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observer_reports_none_for_a_forged_pid() {
|
||||||
|
run(|| {
|
||||||
|
let obs = observer::start();
|
||||||
|
// An index that is not in the slab at all — the verb relays the
|
||||||
|
// primitive's `None` faithfully.
|
||||||
|
let forged = smarm::Pid::new(u32::MAX - 1, 0);
|
||||||
|
let ObserverReply::ActorInfo(none) =
|
||||||
|
obs.call(ObserverRequest::ActorInfo(forged)).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("ActorInfo verb must reply ActorInfo");
|
||||||
|
};
|
||||||
|
assert!(none.is_none(), "a forged pid should relay as None");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observer_sees_a_parked_actor_as_parked() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
let worker = smarm::spawn(move || {
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap(); // nothing to do but park on the gate
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let obs = observer::start();
|
||||||
|
// Bounded poll through the observer until the worker reaches Parked —
|
||||||
|
// proving the live state classification rides the call channel intact.
|
||||||
|
let mut parked = false;
|
||||||
|
for _ in 0..100_000 {
|
||||||
|
let ObserverReply::ActorInfo(info) =
|
||||||
|
obs.call(ObserverRequest::ActorInfo(worker.pid())).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("ActorInfo verb must reply ActorInfo");
|
||||||
|
};
|
||||||
|
if matches!(info, Some(i) if i.state == ActorState::Parked) {
|
||||||
|
parked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
smarm::yield_now();
|
||||||
|
}
|
||||||
|
assert!(parked, "observer should eventually report the worker as Parked");
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
worker.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
//! Process-group tests that run under the scheduler: `join` installs a real
|
||||||
|
//! monitor on a live actor, and a real death drives eviction on next contact.
|
||||||
|
//! (Pure structural invariants live in the `pg` unit tests.)
|
||||||
|
|
||||||
|
use smarm::{channel, members, pick, run, spawn};
|
||||||
|
use smarm::{join, leave};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_then_members_lists_a_live_member() {
|
||||||
|
run(|| {
|
||||||
|
let (tx, rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
assert!(join("workers", pid), "first join is new");
|
||||||
|
assert!(!join("workers", pid), "second join is idempotent");
|
||||||
|
assert_eq!(members("workers"), vec![pid]);
|
||||||
|
assert_eq!(pick("workers"), Some(pid));
|
||||||
|
tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dead_actor_vanishes_from_every_group_it_joined() {
|
||||||
|
run(|| {
|
||||||
|
let (tx, rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
join("g1", pid);
|
||||||
|
join("g2", pid);
|
||||||
|
assert_eq!(members("g1"), vec![pid]);
|
||||||
|
assert_eq!(members("g2"), vec![pid]);
|
||||||
|
|
||||||
|
// Release and reap the actor. finalize_actor queues the Down to our
|
||||||
|
// monitors before unparking joiners, so by the time join() returns the
|
||||||
|
// Down is already waiting in the membership channel.
|
||||||
|
tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
|
||||||
|
// Drain-on-contact: touching g1 detects the death and sweeps the pid
|
||||||
|
// out of every group (g2 included), not just g1.
|
||||||
|
assert!(members("g1").is_empty(), "evicted from the touched group");
|
||||||
|
assert!(members("g2").is_empty(), "and swept from the untouched group");
|
||||||
|
assert_eq!(pick("g1"), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_returns_none_when_the_only_member_is_dead() {
|
||||||
|
run(|| {
|
||||||
|
let (tx, rx) = channel::<()>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
join("pool", pid);
|
||||||
|
tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
assert_eq!(pick("pool"), None);
|
||||||
|
assert!(members("pool").is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_members_survive_a_peers_death() {
|
||||||
|
run(|| {
|
||||||
|
let (tx_a, rx_a) = channel::<()>();
|
||||||
|
let (tx_b, rx_b) = channel::<()>();
|
||||||
|
let a = spawn(move || {
|
||||||
|
rx_a.recv().unwrap();
|
||||||
|
});
|
||||||
|
let b = spawn(move || {
|
||||||
|
rx_b.recv().unwrap();
|
||||||
|
});
|
||||||
|
join("svc", a.pid());
|
||||||
|
join("svc", b.pid());
|
||||||
|
|
||||||
|
// Kill a; b is still parked on its channel.
|
||||||
|
tx_a.send(()).unwrap();
|
||||||
|
a.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(members("svc"), vec![b.pid()], "only the dead peer is reaped");
|
||||||
|
assert_eq!(pick("svc"), Some(b.pid()));
|
||||||
|
|
||||||
|
tx_b.send(()).unwrap();
|
||||||
|
b.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leave_drops_a_membership_without_affecting_others() {
|
||||||
|
run(|| {
|
||||||
|
let (tx_a, rx_a) = channel::<()>();
|
||||||
|
let (tx_b, rx_b) = channel::<()>();
|
||||||
|
let a = spawn(move || {
|
||||||
|
rx_a.recv().unwrap();
|
||||||
|
});
|
||||||
|
let b = spawn(move || {
|
||||||
|
rx_b.recv().unwrap();
|
||||||
|
});
|
||||||
|
join("g", a.pid());
|
||||||
|
join("g", b.pid());
|
||||||
|
assert!(leave("g", a.pid()), "a was a member");
|
||||||
|
assert!(!leave("g", a.pid()), "leaving twice finds nothing");
|
||||||
|
assert_eq!(members("g"), vec![b.pid()]);
|
||||||
|
|
||||||
|
tx_a.send(()).unwrap();
|
||||||
|
tx_b.send(()).unwrap();
|
||||||
|
a.join().unwrap();
|
||||||
|
b.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joining_an_already_dead_pid_is_evicted_on_next_contact() {
|
||||||
|
run(|| {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
h.join().unwrap(); // actor is finalized before we join it to anything
|
||||||
|
|
||||||
|
// monitor() on a gone pid queues a NoProc Down immediately, so the
|
||||||
|
// membership is reaped the next time the group is touched.
|
||||||
|
join("late", pid);
|
||||||
|
assert!(members("late").is_empty(), "dead-at-join member is reaped on read");
|
||||||
|
assert_eq!(pick("late"), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
+195
-165
@@ -1,221 +1,251 @@
|
|||||||
//! Named registry tests. Run under the scheduler: registration requires a
|
//! Mailbox-registry tests (RFC 014). Run under the scheduler: registration
|
||||||
//! live runtime and live actors.
|
//! captures a live actor's channel, resolution checks liveness.
|
||||||
|
//!
|
||||||
|
//! Workers register their *own* inbox (`register` claims the current actor);
|
||||||
|
//! the root closure resolves and sends by name. A `ready` handshake closes the
|
||||||
|
//! register-then-send race without busy-waiting on `whereis`.
|
||||||
|
|
||||||
use smarm::{channel, name_of, register, run, spawn, unregister, whereis, RegisterError};
|
use smarm::{
|
||||||
|
channel, install, register, run, send, send_dyn, send_to, spawn, unregister, whereis,
|
||||||
|
Addressable, Name, Pid, RegisterError, SendError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SVC: Name<u64> = Name::new("svc");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_whereis_roundtrip() {
|
fn register_then_send_by_name_delivers() {
|
||||||
run(|| {
|
run(|| {
|
||||||
let (tx, rx) = channel::<()>();
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (tx, rx) = channel::<u64>();
|
||||||
let h = spawn(move || {
|
let h = spawn(move || {
|
||||||
rx.recv().unwrap();
|
register(SVC, tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
assert_eq!(rx.recv().unwrap(), 42);
|
||||||
});
|
});
|
||||||
register("worker", h.pid()).unwrap();
|
ready_rx.recv().unwrap(); // worker has registered
|
||||||
assert_eq!(whereis("worker"), Some(h.pid()));
|
assert_eq!(whereis("svc"), Some(h.pid()));
|
||||||
assert_eq!(name_of(h.pid()).as_deref(), Some("worker"));
|
send(SVC, 42).unwrap();
|
||||||
tx.send(()).unwrap();
|
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_is_idempotent_for_same_binding() {
|
fn one_actor_many_typed_channels_route_by_type() {
|
||||||
|
// Same name, two message types: capability separation falls out of the
|
||||||
|
// type parameter — `Name<u64>` and `Name<&str>` hit different channels of
|
||||||
|
// the one actor (RFC 014 §4.7).
|
||||||
run(|| {
|
run(|| {
|
||||||
let (tx, rx) = channel::<()>();
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (cmd_tx, cmd_rx) = channel::<u64>();
|
||||||
|
let (adm_tx, adm_rx) = channel::<&'static str>();
|
||||||
let h = spawn(move || {
|
let h = spawn(move || {
|
||||||
rx.recv().unwrap();
|
register(Name::<u64>::new("port"), cmd_tx).unwrap();
|
||||||
|
register(Name::<&'static str>::new("port"), adm_tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
assert_eq!(cmd_rx.recv().unwrap(), 7);
|
||||||
|
assert_eq!(adm_rx.recv().unwrap(), "halt");
|
||||||
});
|
});
|
||||||
register("svc", h.pid()).unwrap();
|
ready_rx.recv().unwrap();
|
||||||
// Same name, same pid: a no-op Ok, not NameTaken.
|
send(Name::<u64>::new("port"), 7u64).unwrap();
|
||||||
register("svc", h.pid()).unwrap();
|
send(Name::<&'static str>::new("port"), "halt").unwrap();
|
||||||
tx.send(()).unwrap();
|
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_name_on_live_holder_is_rejected() {
|
fn name_held_by_live_actor_is_taken() {
|
||||||
run(|| {
|
run(|| {
|
||||||
let (tx, rx) = channel::<()>();
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
let (tx2, rx2) = channel::<()>();
|
let (tx_a, rx_a) = channel::<u64>();
|
||||||
let a = spawn(move || {
|
let a = spawn(move || {
|
||||||
rx.recv().unwrap();
|
register(SVC, tx_a).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
assert_eq!(rx_a.recv().unwrap(), 0); // wait to be released
|
||||||
});
|
});
|
||||||
let b = spawn(move || {
|
ready_rx.recv().unwrap();
|
||||||
rx2.recv().unwrap();
|
// Root tries to claim a live actor's name for itself -> NameTaken.
|
||||||
});
|
let (tx_b, _rx_b) = channel::<u64>();
|
||||||
register("svc", a.pid()).unwrap();
|
assert_eq!(register(SVC, tx_b), Err(RegisterError::NameTaken { holder: a.pid() }));
|
||||||
assert_eq!(
|
send(SVC, 0).unwrap(); // release a (delivers to the holder, a)
|
||||||
register("svc", b.pid()),
|
|
||||||
Err(RegisterError::NameTaken { holder: a.pid() })
|
|
||||||
);
|
|
||||||
tx.send(()).unwrap();
|
|
||||||
tx2.send(()).unwrap();
|
|
||||||
a.join().unwrap();
|
a.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_holder_is_pruned_and_name_taken_over() {
|
||||||
|
// a registers, signals, then dies. Its slot index is typically reused by b;
|
||||||
|
// b's registration must prune the stale binding and take the name over.
|
||||||
|
run(|| {
|
||||||
|
let (rt1, rr1) = channel::<()>();
|
||||||
|
let (tx1, _rx1) = channel::<u64>();
|
||||||
|
let a = spawn(move || {
|
||||||
|
register(SVC, tx1).unwrap();
|
||||||
|
rt1.send(()).unwrap(); // then return -> die, _rx1 dropped
|
||||||
|
});
|
||||||
|
rr1.recv().unwrap();
|
||||||
|
let a_pid = a.pid();
|
||||||
|
a.join().unwrap(); // a is dead; "svc" now points at a stale pid
|
||||||
|
|
||||||
|
let (rt2, rr2) = channel::<()>();
|
||||||
|
let (tx2, rx2) = channel::<u64>();
|
||||||
|
let b = spawn(move || {
|
||||||
|
register(SVC, tx2).unwrap(); // takes over the freed name
|
||||||
|
rt2.send(()).unwrap();
|
||||||
|
assert_eq!(rx2.recv().unwrap(), 9);
|
||||||
|
});
|
||||||
|
rr2.recv().unwrap();
|
||||||
|
assert_ne!(b.pid(), a_pid); // distinct incarnation even if slot reused
|
||||||
|
assert_eq!(whereis("svc"), Some(b.pid()));
|
||||||
|
send(SVC, 9).unwrap();
|
||||||
b.join().unwrap();
|
b.join().unwrap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_name_per_pid() {
|
fn send_errors_unresolved_and_no_channel() {
|
||||||
run(|| {
|
run(|| {
|
||||||
let (tx, rx) = channel::<()>();
|
// No actor at all.
|
||||||
|
assert!(matches!(send(Name::<u64>::new("ghost"), 1u64), Err(SendError::Unresolved(_))));
|
||||||
|
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (tx, rx) = channel::<u64>();
|
||||||
let h = spawn(move || {
|
let h = spawn(move || {
|
||||||
rx.recv().unwrap();
|
register(Name::<u64>::new("svc2"), tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
assert_eq!(rx.recv().unwrap(), 0);
|
||||||
});
|
});
|
||||||
register("first", h.pid()).unwrap();
|
ready_rx.recv().unwrap();
|
||||||
assert_eq!(
|
// Right actor, wrong message type: it has a u64 channel, not a String.
|
||||||
register("second", h.pid()),
|
let e = send(Name::<String>::new("svc2"), "x".to_string());
|
||||||
Err(RegisterError::PidAlreadyRegistered { name: "first".into() })
|
assert!(matches!(e, Err(SendError::NoChannel(_))));
|
||||||
);
|
assert_eq!(e.unwrap_err().into_inner(), "x"); // message handed back
|
||||||
tx.send(()).unwrap();
|
send(Name::<u64>::new("svc2"), 0u64).unwrap();
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registering_a_dead_pid_is_noproc() {
|
fn unregister_frees_the_name_only() {
|
||||||
run(|| {
|
run(|| {
|
||||||
let h = spawn(|| {});
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
let pid = h.pid();
|
let (done_tx, done_rx) = channel::<()>();
|
||||||
h.join().unwrap();
|
let (tx, _rx) = channel::<u64>(); // _rx moves into the actor, kept open
|
||||||
assert_eq!(register("ghost", pid), Err(RegisterError::NoProc));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn binding_evaporates_on_death_and_name_is_reusable() {
|
|
||||||
run(|| {
|
|
||||||
let a = spawn(|| {});
|
|
||||||
let pid_a = a.pid();
|
|
||||||
register("svc", pid_a).unwrap();
|
|
||||||
a.join().unwrap();
|
|
||||||
|
|
||||||
// Dead holder: both lookup directions report unbound.
|
|
||||||
assert_eq!(whereis("svc"), None);
|
|
||||||
assert_eq!(name_of(pid_a), None);
|
|
||||||
|
|
||||||
// And the name is free for a successor.
|
|
||||||
let (tx, rx) = channel::<()>();
|
|
||||||
let b = spawn(move || {
|
|
||||||
rx.recv().unwrap();
|
|
||||||
});
|
|
||||||
register("svc", b.pid()).unwrap();
|
|
||||||
assert_eq!(whereis("svc"), Some(b.pid()));
|
|
||||||
tx.send(()).unwrap();
|
|
||||||
b.join().unwrap();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn register_over_a_dead_holder_succeeds_without_lookup_in_between() {
|
|
||||||
// The eviction path inside register() itself (not via whereis pruning).
|
|
||||||
run(|| {
|
|
||||||
let a = spawn(|| {});
|
|
||||||
let pid_a = a.pid();
|
|
||||||
register("svc", pid_a).unwrap();
|
|
||||||
a.join().unwrap();
|
|
||||||
|
|
||||||
let (tx, rx) = channel::<()>();
|
|
||||||
let b = spawn(move || {
|
|
||||||
rx.recv().unwrap();
|
|
||||||
});
|
|
||||||
register("svc", b.pid()).unwrap();
|
|
||||||
assert_eq!(whereis("svc"), Some(b.pid()));
|
|
||||||
assert_eq!(name_of(pid_a), None);
|
|
||||||
tx.send(()).unwrap();
|
|
||||||
b.join().unwrap();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unregister_frees_both_directions() {
|
|
||||||
run(|| {
|
|
||||||
let (tx, rx) = channel::<()>();
|
|
||||||
let h = spawn(move || {
|
let h = spawn(move || {
|
||||||
rx.recv().unwrap();
|
register(SVC, tx).unwrap();
|
||||||
|
let _keep_open = _rx;
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
done_rx.recv().unwrap(); // released over a separate channel
|
||||||
});
|
});
|
||||||
register("svc", h.pid()).unwrap();
|
ready_rx.recv().unwrap();
|
||||||
|
assert_eq!(whereis("svc"), Some(h.pid()));
|
||||||
assert_eq!(unregister("svc"), Some(h.pid()));
|
assert_eq!(unregister("svc"), Some(h.pid()));
|
||||||
assert_eq!(whereis("svc"), None);
|
assert_eq!(whereis("svc"), None);
|
||||||
assert_eq!(name_of(h.pid()), None);
|
assert!(matches!(send(SVC, 1u64), Err(SendError::Unresolved(_))));
|
||||||
assert_eq!(unregister("svc"), None);
|
done_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The pid may take a new name afterwards.
|
// --- RFC 014 §4.2: direct, identity-bound addressing via `Pid<A>` ----------
|
||||||
register("svc2", h.pid()).unwrap();
|
|
||||||
assert_eq!(name_of(h.pid()).as_deref(), Some("svc2"));
|
// A stand-in single-message actor. `install::<Worker>` publishes its inbox and
|
||||||
tx.send(()).unwrap();
|
// returns a `Pid<Worker>` that delivers `u64` to exactly that incarnation.
|
||||||
|
struct Worker;
|
||||||
|
impl Addressable for Worker {
|
||||||
|
type Msg = u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_then_send_to_pid_delivers() {
|
||||||
|
run(|| {
|
||||||
|
let (addr_tx, addr_rx) = channel::<Pid<Worker>>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
let (tx, rx) = channel::<u64>();
|
||||||
|
let me = install::<Worker>(tx); // nameless publish, typed pid back
|
||||||
|
addr_tx.send(me).unwrap();
|
||||||
|
assert_eq!(rx.recv().unwrap(), 42);
|
||||||
|
});
|
||||||
|
let addr = addr_rx.recv().unwrap(); // worker installed, handed its Pid<Worker>
|
||||||
|
assert_eq!(addr.erase(), h.pid()); // same identity, just re-typed
|
||||||
|
send_to(addr, 42u64).unwrap();
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whereis_from_another_actor_and_usable_with_runtime_apis() {
|
fn send_to_does_not_redirect_after_takeover() {
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
// The load-bearing §4.2 property: a `Pid<A>` is identity-bound. When the
|
||||||
use std::sync::Arc;
|
// actor dies and a new incarnation reuses the slot, sending to the *old*
|
||||||
|
// address fails `Dead` — it must never silently reach the new occupant
|
||||||
let stopped = Arc::new(AtomicBool::new(false));
|
// (that redirect is the re-resolving `Name`'s job, not a pid's).
|
||||||
let stopped2 = stopped.clone();
|
run(|| {
|
||||||
run(move || {
|
let (addr_a_tx, addr_a_rx) = channel::<Pid<Worker>>();
|
||||||
let (tx, rx) = channel::<()>();
|
let (rt1, rr1) = channel::<()>();
|
||||||
let svc = spawn(move || {
|
let a = spawn(move || {
|
||||||
// Parks forever; only a request_stop ends it.
|
let (tx, _rx) = channel::<u64>();
|
||||||
let _ = rx.recv();
|
let me = install::<Worker>(tx);
|
||||||
|
addr_a_tx.send(me).unwrap();
|
||||||
|
rt1.send(()).unwrap(); // then return -> die
|
||||||
});
|
});
|
||||||
register("stoppable", svc.pid()).unwrap();
|
let a_addr = addr_a_rx.recv().unwrap();
|
||||||
|
rr1.recv().unwrap();
|
||||||
|
a.join().unwrap(); // a dead; a_addr names a dead incarnation
|
||||||
|
|
||||||
let stopped3 = stopped2.clone();
|
let (addr_b_tx, addr_b_rx) = channel::<Pid<Worker>>();
|
||||||
let client = spawn(move || {
|
let (rt2, rr2) = channel::<()>();
|
||||||
let pid = whereis("stoppable").expect("name must resolve cross-actor");
|
let b = spawn(move || {
|
||||||
// The registry's pids plug into the rest of the runtime API.
|
let (tx, rx) = channel::<u64>();
|
||||||
smarm::request_stop(pid);
|
let me = install::<Worker>(tx); // reuses a's slot index, new generation
|
||||||
stopped3.store(true, Ordering::Relaxed);
|
addr_b_tx.send(me).unwrap();
|
||||||
|
rt2.send(()).unwrap();
|
||||||
|
// Only b's own message ever arrives; the stale send never redirects.
|
||||||
|
assert_eq!(rx.recv().unwrap(), 7);
|
||||||
});
|
});
|
||||||
client.join().unwrap();
|
let b_addr = addr_b_rx.recv().unwrap();
|
||||||
svc.join().unwrap(); // a stopped actor joins Ok
|
rr2.recv().unwrap();
|
||||||
drop(tx);
|
assert_ne!(b_addr.erase(), a_addr.erase()); // distinct incarnation
|
||||||
|
assert!(matches!(send_to(a_addr, 99u64), Err(SendError::Dead(_)))); // no redirect
|
||||||
|
send_to(b_addr, 7u64).unwrap(); // b's real message
|
||||||
|
b.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RFC 014 §4.6: explicit bare-pid escape hatch ---------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_dyn_delivers_and_reports_wrong_type() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (done_tx, done_rx) = channel::<()>();
|
||||||
|
let (tx, rx) = channel::<u64>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
register(Name::<u64>::new("dyn"), tx).unwrap(); // publishes a u64 channel
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
assert_eq!(rx.recv().unwrap(), 3);
|
||||||
|
done_rx.recv().unwrap(); // stay alive for the wrong-type probe
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
let p = h.pid(); // a bare Pid<Erased>, as if recovered off a Down
|
||||||
|
send_dyn::<u64>(p, 3u64).unwrap(); // right type: delivered
|
||||||
|
// Live actor, but it has no channel for &str — the genuinely-fallible case.
|
||||||
|
assert!(matches!(send_dyn::<&'static str>(p, "nope"), Err(SendError::NoChannel(_))));
|
||||||
|
done_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
assert!(stopped.load(Ordering::Relaxed));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_under_concurrent_churn_multi_thread() {
|
fn send_dyn_to_dead_pid_is_dead() {
|
||||||
// Many actors racing to claim the same names while holders die: the
|
run(|| {
|
||||||
// bimap invariant (debug-asserted internally) and Erlang semantics must
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
// hold under real parallelism.
|
let (tx, _rx) = channel::<u64>();
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
let h = spawn(move || {
|
||||||
use std::sync::Arc;
|
register(Name::<u64>::new("dyn2"), tx).unwrap();
|
||||||
|
ready_tx.send(()).unwrap(); // then return -> die
|
||||||
let wins = Arc::new(AtomicU32::new(0));
|
});
|
||||||
let wins2 = wins.clone();
|
ready_rx.recv().unwrap();
|
||||||
smarm::init(smarm::Config::exact(4)).run(move || {
|
let p = h.pid();
|
||||||
let mut handles = Vec::new();
|
h.join().unwrap(); // dead; the bare pid now names a dead incarnation
|
||||||
for round in 0..8 {
|
assert!(matches!(send_dyn::<u64>(p, 1u64), Err(SendError::Dead(_))));
|
||||||
let name = format!("contested-{}", round % 2);
|
|
||||||
for _ in 0..8 {
|
|
||||||
let name = name.clone();
|
|
||||||
let wins = wins2.clone();
|
|
||||||
handles.push(spawn(move || {
|
|
||||||
let me = smarm::self_pid();
|
|
||||||
match register(&name, me) {
|
|
||||||
Ok(()) => {
|
|
||||||
wins.fetch_add(1, Ordering::Relaxed);
|
|
||||||
smarm::yield_now();
|
|
||||||
// May have been pruned-by-contact never; we are
|
|
||||||
// alive, so our binding must still resolve to us.
|
|
||||||
assert_eq!(whereis(&name), Some(me));
|
|
||||||
assert_eq!(unregister(&name), Some(me));
|
|
||||||
}
|
|
||||||
Err(RegisterError::NameTaken { .. }) => {}
|
|
||||||
Err(e) => panic!("unexpected register error: {e}"),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for h in handles {
|
|
||||||
h.join().unwrap();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// At least one registration per name must have succeeded.
|
|
||||||
assert!(wins.load(Ordering::Relaxed) >= 2);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,3 +485,35 @@ fn multi_thread_timer_only_no_pipe_contention() {
|
|||||||
SLEEP_MS * 2,
|
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");
|
||||||
|
}
|
||||||
|
|||||||
+23
-15
@@ -6,22 +6,22 @@
|
|||||||
|
|
||||||
use smarm::{channel, run, select, spawn};
|
use smarm::{channel, run, select, spawn};
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
static OUT: AtomicI64 = AtomicI64::new(0);
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ready_arm_returns_immediately_without_parking() {
|
fn ready_arm_returns_immediately_without_parking() {
|
||||||
OUT.store(0, Ordering::SeqCst);
|
let out = Arc::new(AtomicI64::new(0));
|
||||||
run(|| {
|
let out2 = out.clone();
|
||||||
|
run(move || {
|
||||||
let (txa, rxa) = channel::<i64>();
|
let (txa, rxa) = channel::<i64>();
|
||||||
let (_txb, rxb) = channel::<i64>();
|
let (_txb, rxb) = channel::<i64>();
|
||||||
txa.send(42).unwrap();
|
txa.send(42).unwrap();
|
||||||
let i = select(&[&rxb, &rxa]);
|
let i = select(&[&rxb, &rxa]);
|
||||||
assert_eq!(i, 1);
|
assert_eq!(i, 1);
|
||||||
OUT.store(rxa.try_recv().unwrap().expect("ready arm must hold a message"), Ordering::SeqCst);
|
out2.store(rxa.try_recv().unwrap().expect("ready arm must hold a message"), Ordering::SeqCst);
|
||||||
});
|
});
|
||||||
assert_eq!(OUT.load(Ordering::SeqCst), 42);
|
assert_eq!(out.load(Ordering::SeqCst), 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -39,8 +39,9 @@ fn lower_index_wins_when_several_arms_are_ready() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parks_until_any_arm_fires() {
|
fn parks_until_any_arm_fires() {
|
||||||
OUT.store(0, Ordering::SeqCst);
|
let out = Arc::new(AtomicI64::new(0));
|
||||||
run(|| {
|
let out2 = out.clone();
|
||||||
|
run(move || {
|
||||||
let (txa, _rxa_keepalive) = (channel::<i64>().0, ());
|
let (txa, _rxa_keepalive) = (channel::<i64>().0, ());
|
||||||
let _hold = txa; // arm a: sender alive, never sends
|
let _hold = txa; // arm a: sender alive, never sends
|
||||||
let (txa, rxa) = channel::<i64>();
|
let (txa, rxa) = channel::<i64>();
|
||||||
@@ -52,10 +53,10 @@ fn parks_until_any_arm_fires() {
|
|||||||
});
|
});
|
||||||
let i = select(&[&rxa, &rxb]);
|
let i = select(&[&rxa, &rxb]);
|
||||||
assert_eq!(i, 1);
|
assert_eq!(i, 1);
|
||||||
OUT.store(rxb.try_recv().unwrap().unwrap(), Ordering::SeqCst);
|
out2.store(rxb.try_recv().unwrap().unwrap(), Ordering::SeqCst);
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
assert_eq!(OUT.load(Ordering::SeqCst), 7);
|
assert_eq!(out.load(Ordering::SeqCst), 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -107,8 +108,14 @@ fn loser_arm_wake_after_parked_select_stays_precise() {
|
|||||||
t0.elapsed() >= Duration::from_millis(40),
|
t0.elapsed() >= Duration::from_millis(40),
|
||||||
"one-shot park returned early: a stale loser-arm wake landed"
|
"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.
|
// 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));
|
assert_eq!(rxb.try_recv().unwrap(), Some(2));
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
@@ -164,8 +171,9 @@ fn select_then_plain_recv_on_a_loser_arm() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn select_loop_drains_two_producers_completely() {
|
fn select_loop_drains_two_producers_completely() {
|
||||||
const N: i64 = 200;
|
const N: i64 = 200;
|
||||||
OUT.store(0, Ordering::SeqCst);
|
let out = Arc::new(AtomicI64::new(0));
|
||||||
run(|| {
|
let out2 = out.clone();
|
||||||
|
run(move || {
|
||||||
let (txa, rxa) = channel::<i64>();
|
let (txa, rxa) = channel::<i64>();
|
||||||
let (txb, rxb) = channel::<i64>();
|
let (txb, rxb) = channel::<i64>();
|
||||||
let ha = spawn(move || {
|
let ha = spawn(move || {
|
||||||
@@ -204,11 +212,11 @@ fn select_loop_drains_two_producers_completely() {
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OUT.store(sum, Ordering::SeqCst);
|
out2.store(sum, Ordering::SeqCst);
|
||||||
ha.join().unwrap();
|
ha.join().unwrap();
|
||||||
hb.join().unwrap();
|
hb.join().unwrap();
|
||||||
});
|
});
|
||||||
assert_eq!(OUT.load(Ordering::SeqCst), 2 * (0..200i64).sum::<i64>());
|
assert_eq!(out.load(Ordering::SeqCst), 2 * (0..200i64).sum::<i64>());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Terminal wake: a scheduler thread blocked in the idle wait must be woken
|
||||||
|
//! when a sibling reaches the all-done verdict, or `rt.run` stalls in its
|
||||||
|
//! worker join.
|
||||||
|
//!
|
||||||
|
//! Mechanism (runtime.rs, `Pop::Idle`): an idle scheduler snapshots
|
||||||
|
//! `peek_deadline()` / `io_outstanding` and blocks in `poll_wake` (or
|
||||||
|
//! `thread::sleep`) on that snapshot. `enqueue` does not write the wake
|
||||||
|
//! pipe — only IO completions do — so the snapshot can go terminally stale:
|
||||||
|
//!
|
||||||
|
//! - An actor parked in `wait_readable` that is `request_stop`ped produces
|
||||||
|
//! NO completion (cancellation deregisters the waiter); a sibling that
|
||||||
|
//! blocked on `io_outstanding > 0` with no timers is in `poll(-1)` forever.
|
||||||
|
//! - An actor cancelled out of a long `sleep` leaves its timer entry
|
||||||
|
//! orphaned; a sibling that blocked on that deadline sleeps it out in full.
|
||||||
|
//!
|
||||||
|
//! In both cases the remaining work completes on the *other* scheduler
|
||||||
|
//! thread, which hits AllDone and returns — and nothing wakes the blocked
|
||||||
|
//! one. `Runtime::run` joins it: a permanent hang in the first case, a
|
||||||
|
//! full-deadline stall in the second.
|
||||||
|
//!
|
||||||
|
//! Both tests force the window deterministically: the root busy-spins
|
||||||
|
//! (creating no timer entries and occupying one scheduler thread) so the
|
||||||
|
//! other thread settles into the stale idle wait before the stop is issued.
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
struct PipePair {
|
||||||
|
read: libc::c_int,
|
||||||
|
write: libc::c_int,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipePair {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut fds: [libc::c_int; 2] = [0; 2];
|
||||||
|
let r = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
|
||||||
|
assert_eq!(r, 0, "pipe2 failed");
|
||||||
|
PipePair { read: fds[0], write: fds[1] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PipePair {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
libc::close(self.read);
|
||||||
|
libc::close(self.write);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Occupy the current scheduler thread without creating timer entries or
|
||||||
|
/// parking. A plain spin has no observation points, so the root stays
|
||||||
|
/// on-CPU and the sibling scheduler is left alone with the idle branch.
|
||||||
|
fn spin_for(d: Duration) {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
while t0.elapsed() < d {
|
||||||
|
std::hint::spin_loop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `body` under a 2-scheduler runtime on a watchdog thread; fail if
|
||||||
|
/// `Runtime::run` has not returned within `limit`.
|
||||||
|
fn run_with_watchdog(limit: Duration, body: impl FnOnce() + Send + 'static) {
|
||||||
|
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = smarm::init(smarm::Config::exact(2));
|
||||||
|
rt.run(body);
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
});
|
||||||
|
done_rx
|
||||||
|
.recv_timeout(limit)
|
||||||
|
.expect("Runtime::run did not return: idle scheduler thread was never woken at termination");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanent-hang variant: sibling blocked in `poll_wake(wake_fd, None)`
|
||||||
|
/// because `io_outstanding > 0` (one actor parked in `wait_readable`) and no
|
||||||
|
/// timers are pending. The waiter is then stop-cancelled — no IO completion
|
||||||
|
/// ever writes the wake pipe — and everything else finishes on the root's
|
||||||
|
/// thread. Without a terminal wake, `rt.run` never returns.
|
||||||
|
#[test]
|
||||||
|
fn run_returns_after_io_waiter_is_stop_cancelled() {
|
||||||
|
run_with_watchdog(Duration::from_secs(10), || {
|
||||||
|
let pipe = PipePair::new();
|
||||||
|
let rfd = pipe.read;
|
||||||
|
let h = smarm::spawn(move || {
|
||||||
|
// Never-readable fd (write end open, nothing written).
|
||||||
|
let _ = smarm::wait_readable(rfd);
|
||||||
|
});
|
||||||
|
// Let the waiter park and the sibling scheduler settle into the
|
||||||
|
// io_outstanding>0 / no-timers idle wait: poll(wake_fd, -1).
|
||||||
|
spin_for(Duration::from_millis(200));
|
||||||
|
smarm::request_stop(h.pid());
|
||||||
|
let _ = h.join();
|
||||||
|
drop(pipe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finite-stall variant: sibling blocked on an orphaned long timer
|
||||||
|
/// deadline. An actor cancelled out of `sleep(60s)` leaves its timer entry
|
||||||
|
/// behind (documented as harmless at AllDone — but a sibling that already
|
||||||
|
/// blocked on that deadline sleeps it out in full, stalling `rt.run` for
|
||||||
|
/// the better part of a minute).
|
||||||
|
#[test]
|
||||||
|
fn run_returns_after_long_sleeper_is_stop_cancelled() {
|
||||||
|
run_with_watchdog(Duration::from_secs(10), || {
|
||||||
|
let h = smarm::spawn(|| {
|
||||||
|
smarm::sleep(Duration::from_secs(60));
|
||||||
|
});
|
||||||
|
// Let the sleeper park and the sibling scheduler block on the 60s
|
||||||
|
// deadline: poll(wake_fd, ~60_000ms).
|
||||||
|
spin_for(Duration::from_millis(200));
|
||||||
|
smarm::request_stop(h.pid());
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
}
|
||||||
+259
@@ -205,3 +205,262 @@ fn same_deadline_entries_pop_in_insertion_order() {
|
|||||||
let pids: Vec<u32> = due.iter().map(|e| e.pid.index()).collect();
|
let pids: Vec<u32> = due.iter().map(|e| e.pid.index()).collect();
|
||||||
assert_eq!(pids, vec![0, 1, 2]);
|
assert_eq!(pids, vec![0, 1, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// send_after / cancel_timer — the message-delivery timer substrate.
|
||||||
|
//
|
||||||
|
// Unit tests drive `Timers` directly with a flag-flipping fire thunk (no
|
||||||
|
// runtime needed, mirroring RecordingTarget above). Integration tests drive
|
||||||
|
// the public scheduler API and assert real registry-resolved delivery.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
// Pull the Send fire thunk out of a popped entry and run it.
|
||||||
|
fn run_fire(entry: smarm::timer::Entry) {
|
||||||
|
match entry.reason {
|
||||||
|
Reason::Send { fire } => fire(),
|
||||||
|
_ => panic!("expected a Send entry"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn armed_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(
|
||||||
|
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 send timer should pop when due");
|
||||||
|
assert!(!fired.load(Ordering::SeqCst), "pop must not fire on its own");
|
||||||
|
run_fire(due.pop().unwrap());
|
||||||
|
assert!(fired.load(Ordering::SeqCst), "running the thunk delivers");
|
||||||
|
assert!(t.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_send_timer_is_discarded_not_returned() {
|
||||||
|
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(
|
||||||
|
now + Duration::from_millis(10),
|
||||||
|
Pid::new(0, 0),
|
||||||
|
Box::new(move || f.store(true, Ordering::SeqCst)),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(t.cancel(id), "cancel before fire returns true");
|
||||||
|
let due = t.pop_due(now + Duration::from_millis(20));
|
||||||
|
assert!(due.is_empty(), "a cancelled send timer must not pop");
|
||||||
|
assert!(!fired.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_after_fire_returns_false() {
|
||||||
|
// The race signal Mark wanted: cancelling a timer that already fired tells
|
||||||
|
// you it was too late.
|
||||||
|
let mut t = Timers::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
let id = t.insert_send(
|
||||||
|
now + Duration::from_millis(5),
|
||||||
|
Pid::new(0, 0),
|
||||||
|
Box::new(|| {}),
|
||||||
|
);
|
||||||
|
let due = t.pop_due(now + Duration::from_millis(10));
|
||||||
|
assert_eq!(due.len(), 1);
|
||||||
|
assert!(!t.cancel(id), "cancel after the timer fired returns false");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_unknown_id_returns_false() {
|
||||||
|
let mut t = Timers::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
let id = t.insert_send(now + Duration::from_millis(5), Pid::new(0, 0), Box::new(|| {}));
|
||||||
|
assert!(t.cancel(id));
|
||||||
|
// Second cancel of the same id: already gone.
|
||||||
|
assert!(!t.cancel(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_timers_interleave_with_sleep_in_deadline_order() {
|
||||||
|
let mut t = Timers::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
t.insert_sleep(now + Duration::from_millis(30), Pid::new(0, 0), 1);
|
||||||
|
let _id = t.insert_send(now + Duration::from_millis(10), Pid::new(1, 0), Box::new(|| {}));
|
||||||
|
t.insert_sleep(now + Duration::from_millis(20), Pid::new(2, 0), 1);
|
||||||
|
|
||||||
|
let due = t.pop_due(now + Duration::from_millis(50));
|
||||||
|
assert_eq!(due.len(), 3);
|
||||||
|
// 10ms Send, then 20ms Sleep, then 30ms Sleep.
|
||||||
|
assert!(matches!(due[0].reason, Reason::Send { .. }));
|
||||||
|
assert_eq!(due[1].pid.index(), 2);
|
||||||
|
assert_eq!(due[2].pid.index(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_drops_armed_send_timers() {
|
||||||
|
let mut t = Timers::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
let id = t.insert_send(now + Duration::from_millis(10), Pid::new(0, 0), Box::new(|| {}));
|
||||||
|
t.clear();
|
||||||
|
assert!(t.is_empty());
|
||||||
|
// The arm record is gone too: cancelling reports nothing to cancel.
|
||||||
|
assert!(!t.cancel(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Integration: real delivery through the scheduler + registry. ---
|
||||||
|
|
||||||
|
use smarm::{cancel_timer, channel, register, send_after_named, Name};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_after_named_delivers_after_the_delay() {
|
||||||
|
const PING: Name<u64> = Name::new("send_after_ping");
|
||||||
|
run(|| {
|
||||||
|
let (tx, rx) = channel::<u64>();
|
||||||
|
register(PING, tx).unwrap();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _id = send_after_named(Duration::from_millis(30), PING, 99);
|
||||||
|
assert_eq!(rx.recv().unwrap(), 99);
|
||||||
|
assert!(
|
||||||
|
t0.elapsed() >= Duration::from_millis(25),
|
||||||
|
"delivered too early: {:?}",
|
||||||
|
t0.elapsed()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_timer_prevents_delivery() {
|
||||||
|
const C: Name<u64> = Name::new("send_after_cancel");
|
||||||
|
run(|| {
|
||||||
|
let (tx, rx) = channel::<u64>();
|
||||||
|
register(C, tx).unwrap();
|
||||||
|
let id = send_after_named(Duration::from_millis(50), C, 7);
|
||||||
|
assert!(cancel_timer(id), "cancel before fire returns true");
|
||||||
|
sleep(Duration::from_millis(90));
|
||||||
|
assert_eq!(rx.try_recv(), Ok(None), "cancelled timer delivered anyway");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_after_to_unresolved_name_is_silent() {
|
||||||
|
const NOPE: Name<u64> = Name::new("send_after_nobody_home");
|
||||||
|
run(|| {
|
||||||
|
// Nobody registered NOPE; firing resolves to nothing and is dropped.
|
||||||
|
let _id = send_after_named(Duration::from_millis(10), NOPE, 1);
|
||||||
|
sleep(Duration::from_millis(40)); // let it fire and no-op
|
||||||
|
// Reaching here without a panic is the assertion.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Integration: typed Pid<A> delivery (exercises send_to on fire). ---
|
||||||
|
|
||||||
|
use smarm::{send_after, send_to, spawn_addr, Addressable, Receiver};
|
||||||
|
|
||||||
|
struct Sink;
|
||||||
|
impl Addressable for Sink {
|
||||||
|
type Msg = u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_after_delivers_to_typed_pid() {
|
||||||
|
run(|| {
|
||||||
|
// A reply channel so the test actor learns what Sink received.
|
||||||
|
let (report_tx, report_rx) = channel::<u64>();
|
||||||
|
let sink: Pid<Sink> = spawn_addr::<Sink>(move |rx: Receiver<u64>| {
|
||||||
|
if let Ok(v) = rx.recv() {
|
||||||
|
let _ = report_tx.send(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _id = send_after(Duration::from_millis(25), sink, 1234);
|
||||||
|
assert_eq!(report_rx.recv().unwrap(), 1234);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_after_to_dead_typed_pid_is_silent() {
|
||||||
|
run(|| {
|
||||||
|
// Sink exits immediately after handling one message; arm a second
|
||||||
|
// delivery for after it's gone. The fire-time send_to returns Dead and
|
||||||
|
// is dropped — no panic.
|
||||||
|
let (report_tx, report_rx) = channel::<u64>();
|
||||||
|
let sink: Pid<Sink> = spawn_addr::<Sink>(move |rx: Receiver<u64>| {
|
||||||
|
if let Ok(v) = rx.recv() {
|
||||||
|
let _ = report_tx.send(v);
|
||||||
|
}
|
||||||
|
// body returns -> actor exits
|
||||||
|
});
|
||||||
|
send_to(sink, 1).unwrap();
|
||||||
|
assert_eq!(report_rx.recv().unwrap(), 1); // sink has now exited
|
||||||
|
let _id = send_after(Duration::from_millis(15), sink, 2);
|
||||||
|
sleep(Duration::from_millis(45)); // let it fire against the dead pid
|
||||||
|
// No panic; the sink is gone, so its report sender dropped with it —
|
||||||
|
// closed+empty is Err (documented), which also proves nothing
|
||||||
|
// further was delivered.
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! RFC 005 wake-slot tests: correctness with the slot on, push-policy
|
||||||
|
//! discrimination (actor vs scheduler context, spawns), displacement, the
|
||||||
|
//! default-off contract, and per-run counter reset.
|
||||||
|
//!
|
||||||
|
//! The slot is same-thread plain ops behind the existing queue-op contract,
|
||||||
|
//! so there is nothing new to model-check (RFC 005 §Summary); these tests
|
||||||
|
//! pin the *policy* — who lands in the slot, who never does — which is
|
||||||
|
//! observable through the `slot_hits` / `slot_displacements` counters.
|
||||||
|
|
||||||
|
use smarm::channel::channel;
|
||||||
|
use smarm::runtime::{init, Config};
|
||||||
|
use smarm::spawn;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Ping-pong over channels: the slot's home pattern. Returns total roundtrips.
|
||||||
|
fn ping_pong(pairs: usize, roundtrips: u64) -> impl FnOnce() + Send + 'static {
|
||||||
|
move || {
|
||||||
|
let handles: Vec<_> = (0..pairs)
|
||||||
|
.map(|_| {
|
||||||
|
spawn(move || {
|
||||||
|
let (tx_ab, rx_ab) = channel::<u64>();
|
||||||
|
let (tx_ba, rx_ba) = channel::<u64>();
|
||||||
|
let echo = spawn(move || {
|
||||||
|
for _ in 0..roundtrips {
|
||||||
|
let v = rx_ab.recv().expect("echo recv");
|
||||||
|
tx_ba.send(v + 1).expect("echo send");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for i in 0..roundtrips {
|
||||||
|
tx_ab.send(i).expect("ping send");
|
||||||
|
assert_eq!(rx_ba.recv().expect("ping recv"), i + 1);
|
||||||
|
}
|
||||||
|
let _ = echo.join();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for h in handles {
|
||||||
|
h.join().expect("pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Correctness: messaging workloads complete with the slot on, 1 and N threads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ping_pong_correct_with_slot_on_single_thread() {
|
||||||
|
let rt = init(Config::exact(1).wake_slot(true));
|
||||||
|
rt.run(ping_pong(4, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ping_pong_correct_with_slot_on_multi_thread() {
|
||||||
|
let rt = init(Config::exact(4).wake_slot(true));
|
||||||
|
rt.run(ping_pong(8, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Push policy: actor-context wakes hit the slot; the default is off
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messaging_workload_exercises_the_slot() {
|
||||||
|
let rt = init(Config::exact(1).wake_slot(true));
|
||||||
|
rt.run(ping_pong(2, 100));
|
||||||
|
let stats = rt.stats();
|
||||||
|
assert!(
|
||||||
|
stats.slot_hits() > 0,
|
||||||
|
"send-wakes are actor-context unparks — the slot must see hits, got 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_off_means_zero_slot_traffic() {
|
||||||
|
// Off explicitly…
|
||||||
|
let rt = init(Config::exact(1).wake_slot(false));
|
||||||
|
rt.run(ping_pong(2, 100));
|
||||||
|
assert_eq!(rt.stats().slot_hits(), 0);
|
||||||
|
assert_eq!(rt.stats().slot_displacements(), 0);
|
||||||
|
|
||||||
|
// …and off by default (RFC 005: default off until the shootout accepts).
|
||||||
|
let rt = init(Config::exact(1));
|
||||||
|
rt.run(ping_pong(2, 100));
|
||||||
|
assert_eq!(rt.stats().slot_hits(), 0, "wake_slot must default to OFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Push policy: spawns and join-wakes (finalize = scheduler context) bypass
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_join_workload_bypasses_the_slot() {
|
||||||
|
let rt = init(Config::exact(1).wake_slot(true));
|
||||||
|
rt.run(|| {
|
||||||
|
for _ in 0..20 {
|
||||||
|
let handles: Vec<_> = (0..50).map(|_| spawn(|| {})).collect();
|
||||||
|
for h in handles {
|
||||||
|
h.join().expect("trivial actor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let stats = rt.stats();
|
||||||
|
assert_eq!(
|
||||||
|
stats.slot_hits(),
|
||||||
|
0,
|
||||||
|
"spawns go shared by policy and joiner wakes fire from finalize \
|
||||||
|
(scheduler context) — a pure spawn/join workload must never touch \
|
||||||
|
the slot"
|
||||||
|
);
|
||||||
|
assert_eq!(stats.slot_displacements(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Displacement: newest wake takes the slot, occupant goes shared — and runs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn displaced_occupant_reaches_the_shared_queue_and_runs() {
|
||||||
|
// Single thread, strict FIFO (rq-mutex default): rx1 and rx2 park on
|
||||||
|
// their recvs before the sender runs; the sender's back-to-back sends
|
||||||
|
// then produce two actor-context wakes — the second displaces the first.
|
||||||
|
let rt = init(Config::exact(1).wake_slot(true));
|
||||||
|
let ran = Arc::new(AtomicU64::new(0));
|
||||||
|
let (r1, r2) = (ran.clone(), ran.clone());
|
||||||
|
rt.run(move || {
|
||||||
|
let (tx1, rx1) = channel::<u32>();
|
||||||
|
let (tx2, rx2) = channel::<u32>();
|
||||||
|
let h1 = spawn(move || {
|
||||||
|
assert_eq!(rx1.recv().expect("rx1"), 1);
|
||||||
|
r1.fetch_add(1, Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
let h2 = spawn(move || {
|
||||||
|
assert_eq!(rx2.recv().expect("rx2"), 2);
|
||||||
|
r2.fetch_add(1, Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
let sender = spawn(move || {
|
||||||
|
tx1.send(1).expect("send 1"); // rx1 → slot
|
||||||
|
tx2.send(2).expect("send 2"); // rx2 → slot, rx1 displaced → shared
|
||||||
|
});
|
||||||
|
sender.join().expect("sender");
|
||||||
|
h1.join().expect("h1");
|
||||||
|
h2.join().expect("h2");
|
||||||
|
});
|
||||||
|
assert_eq!(ran.load(Ordering::Relaxed), 2, "both receivers must run");
|
||||||
|
let stats = rt.stats();
|
||||||
|
assert!(
|
||||||
|
stats.slot_displacements() >= 1,
|
||||||
|
"back-to-back wakes of parked receivers must displace at least once"
|
||||||
|
);
|
||||||
|
assert!(stats.slot_hits() >= 1, "the displacing wake is slot-popped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Counters reset at the start of each run() on a reused Runtime
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_counters_reset_per_run() {
|
||||||
|
let rt = init(Config::exact(1).wake_slot(true));
|
||||||
|
rt.run(ping_pong(2, 100));
|
||||||
|
let first = rt.stats().slot_hits();
|
||||||
|
assert!(first > 0);
|
||||||
|
|
||||||
|
// A slot-bypassing run on the same handle must read 0, not `first`.
|
||||||
|
rt.run(|| {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
h.join().expect("trivial");
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
rt.stats().slot_hits(),
|
||||||
|
0,
|
||||||
|
"counters are reset at run() start; the second run had no slot traffic"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user