Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
393cdd01f4 | ||
|
|
4e4b617559 | ||
|
|
7a244d057c | ||
|
|
0e3df6ec5b | ||
|
|
f6969e538b | ||
|
|
24b95c99ae | ||
|
|
e5d1b3b54b | ||
|
|
e545f818fa | ||
|
|
a69b296190 | ||
|
|
a0a93b61bb | ||
|
|
00128f32a2 | ||
|
|
400854ac5d | ||
|
|
4913835c02 | ||
|
|
aa295582c4 | ||
|
|
088280f3c9 | ||
|
|
90b7040504 | ||
|
|
134ff52c8a | ||
|
|
2c7cf0b811 | ||
|
|
8ff6cf4afd | ||
|
|
d789d301e0 | ||
|
|
5428697f9e | ||
|
|
a7ca6646d7 | ||
|
|
039703dbeb | ||
|
|
6d9f3698d4 | ||
|
|
1b3b618aa7 | ||
|
|
a78e17e1eb | ||
|
|
5e0c9d45da | ||
|
|
453f6f491f | ||
|
|
3c7e26bc98 |
@@ -1,2 +0,0 @@
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
@@ -1,3 +1,6 @@
|
||||
target
|
||||
Cargo.lock
|
||||
smarm_trace.json
|
||||
/bench_results/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+39
@@ -4,12 +4,34 @@ version = "0.4.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
|
||||
|
||||
[features]
|
||||
default = ["rq-mutex"]
|
||||
smarm-trace = []
|
||||
# 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).
|
||||
# Non-default variants need --no-default-features (features are additive).
|
||||
rq-mutex = []
|
||||
rq-mpmc = []
|
||||
rq-striped = []
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(loom)'.dependencies]
|
||||
loom = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
libc = "0.2"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
@@ -41,3 +63,20 @@ harness = false
|
||||
[[bench]]
|
||||
name = "tokio_favored"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "rq_micro"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "rq_runtime"
|
||||
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"]
|
||||
|
||||
@@ -21,14 +21,15 @@ convenience wrapper around `runtime::init(Config::exact(1)).run(f)`.
|
||||
| `pid` | `(index, generation)` PIDs; stale handles are detectable, not silent |
|
||||
| `actor` | Trampoline + `catch_unwind` boundary at the actor entry point |
|
||||
| `scheduler` | Run queue, slot table, spawn/join, parking, idle path |
|
||||
| `channel` | Unbounded MPSC channel; `recv` parks the actor |
|
||||
| `channel` | Unbounded MPSC channel; `recv` parks the actor; `recv_timeout` bounds it; `select`/`select_timeout` park on many receivers at once (ready-index, priority order) |
|
||||
| `mutex` | `Mutex<T>` with mandatory timeout; FIFO waiters; parks the green thread |
|
||||
| `timer` | Min-heap of `(deadline, reason)`; `Sleep` and `WaitTimeout` reasons |
|
||||
| `io` | `block_on_io` for blocking work; `wait_readable`/`wait_writable` + `read`/`write` via epoll |
|
||||
| `supervisor` | `Signal::Exit`/`Panic`/`Stopped` funnelled to a parent; `OneForOne`/`OneForAll`/`RestForOne` strategies + restart-intensity cap |
|
||||
| `monitor` | `monitor(pid)` → `Monitor { id, target, rx }`; one-shot `Down` via `rx`; `demonitor(&m)` tears one registration down; unidirectional death notice |
|
||||
| `link` | bidirectional `link`/`unlink`; abnormal death propagates (cooperative stop, or an `ExitSignal` message under `trap_exit`) |
|
||||
| `gen_server` | `call` (sync request-reply) / `cast` (async) over one inbox; `ServerRef` + `init`/`terminate` hooks; server-down via channel closure |
|
||||
| `gen_server` | `call`/`call_timeout` (sync request-reply) / `cast` (async) over one inbox; `handle_info` over static info arms + `handle_down` via `Watcher`-fed monitors, selected ahead of the inbox; `ServerRef`/`ServerBuilder` + `init`/`terminate` hooks; server-down via channel closure |
|
||||
| `registry` | `register`/`whereis`/`name_of`: name ↔ pid bimap; lazy generation-checked cleanup |
|
||||
|
||||
## Quick taste
|
||||
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
# smarm — Roadmap
|
||||
|
||||
## Shipped (compacted — full cycle plans and deviation records live in git history)
|
||||
|
||||
Cycles before v0.8 (v0.4 actor primitives, v0.5 runtime decomposition &
|
||||
pluggable run queue, v0.6 actor ergonomics, v0.7 select on epoch-stamped
|
||||
consuming wakes): see `git log ROADMAP.md`.
|
||||
|
||||
### v0.8 — gen_server: handle_info / handle_down + io fd hygiene ✅
|
||||
Spent `select` on the server loop: static info arms (`type Info`,
|
||||
`ServerBuilder::with_info`) and dynamic monitor forwarding
|
||||
(`ServerCtx`/`Watcher` + a control arm), priority downs → control → infos →
|
||||
inbox. Closed the v0.2 fd hole: a drop guard in `wait_fd` DELs the kernel
|
||||
registration on unwind (the leak was worse than documented — a stale waiters
|
||||
entry permanently poisoned the fd). Deviation: the plain-inbox fast path
|
||||
narrowed; servers holding a `Watcher` select forever.
|
||||
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)
|
||||
|
||||
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
|
||||
three queue variants are within 10–15% of each other in `rq_runtime` at every
|
||||
scheduler count ≥ 4, on all three workloads.
|
||||
Consequences:
|
||||
- **`rq-mutex` stays the default** — simplest correct, no capacity
|
||||
constraints, locking model already integrated.
|
||||
- **Feature plumbing stays as is.** All three variants keep compiling in
|
||||
every build; `rq-mpmc`/`rq-striped` remain selectable for benching.
|
||||
- **Reopening is benchmark-driven only.** The report documents the
|
||||
conditional upgrade paths if a future workload qualifies: mpmc for
|
||||
message-passing-dominant loads at N ≤ 8; 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,
|
||||
per its own World 3 framing), RFC 004, and eventually per-switch cost.
|
||||
|
||||
---
|
||||
|
||||
## Typed addressable mailboxes ✅ SHIPPED (RFC 013 + RFC 014)
|
||||
|
||||
The unblocker several later items quietly assumed: a `Pid` is now messageable.
|
||||
RFC 013 reworked `registry.rs` from a name↔pid *bimap* into a name→**live
|
||||
mailbox** directory off the cold leaf; RFC 014 layered the typed addressing and
|
||||
producers on top. Two addressing modes — `Pid<A>` (direct, identity-bound) and
|
||||
`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.
|
||||
|
||||
Delivered surface:
|
||||
- **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.
|
||||
|
||||
Extends — does not retire — the "select exists; a unified per-process mailbox
|
||||
still does not" invariant: 014 adds addressable *delivery*, not a unified inbox;
|
||||
multi-port stays `select` composition over named channels. Examples:
|
||||
`examples/{typed_actor,named_genserver,worker_pool}.rs`. Earlier-phase commits
|
||||
and the full 013/014 history in git.
|
||||
|
||||
---
|
||||
|
||||
## gen_server time-related patterns ✅ SHIPPED (RFC 015)
|
||||
*(layer 2; seven commits `47d75d1`→`f454a91`, each a reviewable chunk. Builds on
|
||||
the `send_after` substrate below.)*
|
||||
|
||||
The OTP time vocabulary against the v0.8 server loop, with **no handler-signature
|
||||
change** — the capability is a handle stashed on `self` (the `Watcher` pattern),
|
||||
not a return-directive or a `&ctx` threaded through handlers. New trait surface:
|
||||
`type Timer` (server's own scheduled payload, `()` if unused, kept distinct from
|
||||
the external `type Info`), `handle_timer`, `handle_idle` (both no-op defaults).
|
||||
|
||||
- **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
|
||||
### Highest priority
|
||||
#### Per-switch cost (context shims, epoch protocol)
|
||||
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
|
||||
protocol, not the queue. On current evidence this is the larger constant —
|
||||
"the whole game" alongside the v0.9 work — but there is no spec yet. Needs a
|
||||
profiling spike (where do the cycles actually go per park/unpark round-trip)
|
||||
and then an RFC before it can be scheduled.
|
||||
|
||||
#### 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).
|
||||
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.
|
||||
#### arm-port validation & merge
|
||||
`arm-port` branch carries an AAPCS64 context-switch backend, never run on
|
||||
hardware. Build + run full test suite on an aarch64 device; check
|
||||
`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)
|
||||
|
||||
- **Shared mutex is non-reentrant.** `Sender::send` can call `unpark` →
|
||||
`with_shared`. Never send on a channel while holding the shared lock. Pattern:
|
||||
`mem::take` data under the lock, send after releasing. See `finalize_actor`.
|
||||
- **`finalize_actor` order:** take stack/waiters/monitors under lock + set
|
||||
Done/outcome → recycle stack → deliver supervisor Signal + monitor Downs →
|
||||
unpark joiners → reclaim slot if `outstanding_handles==0`. Death notifications
|
||||
always precede reclamation.
|
||||
- **Slot lifecycle reset in THREE places:** `Slot::vacant()`, `reclaim_slot()`
|
||||
(runtime.rs), slot-init block in `spawn_under` (scheduler.rs). Any new `Slot`
|
||||
field must be reset in all three.
|
||||
- **Pid = (index, generation).** Stale handles caught by generation mismatch in
|
||||
`slot()/slot_mut()`. The monitor `NoProc` path relies on this.
|
||||
- **The only wildcard wake is `request_stop`, and it is terminal.** Every
|
||||
registration-based waker (channel sends, mutex grants, wait-timers, io
|
||||
completions, joiner wakes, `select` arms) carries the wait's park-epoch
|
||||
and wakes through `unpark_at`; every successful wake consumes the epoch.
|
||||
Wakes are therefore *meaningful*: one-shot park sites interpret them
|
||||
without loops, and `select` needs no cancellation pass. When adding a new
|
||||
waker, decide which form it is — if its registration handle can outlive
|
||||
the wait it was created for, it MUST be epoch-stamped; a wait that can
|
||||
exit without parking MUST `retire_wait` first (see slot_state.rs).
|
||||
- **`select` exists; a unified per-process mailbox still does not.** The
|
||||
supervisor keeps its single `supervisor_channel` funnel; `recv_match`
|
||||
stays per-channel. `select` composes channels at the wait, not into one
|
||||
queue — gen_server's `handle_info`/`handle_down` (v0.8) are built on
|
||||
exactly that composition, with documented arm priority (downs → control
|
||||
→ infos → inbox) instead of mailbox FIFO. A hot higher-priority arm
|
||||
starves lower ones by design; that's the contract.
|
||||
- **Cooperative-only.** Preemption and cancellation both depend on the actor
|
||||
reaching `check!()`/yield/alloc/blocking points.
|
||||
- **Lock order is Leaf → Channel, one of each at most** (debug-asserted in
|
||||
`raw_mutex.rs`). Leaf = cold locks / free list / stack pool / registry,
|
||||
mutual leaves. A channel lock may be taken under a Leaf (finalize/monitor
|
||||
clone senders living in slots); nothing may be locked under a channel lock.
|
||||
- **Queue ops require preemption disabled.** A producer suspended mid-publish
|
||||
stalls every consumer — livelock. `with_runtime`, `with_shared`, and
|
||||
`RawMutex` guards all disable preemption for their span.
|
||||
- **`run()` is single-thread** (`Config::exact(1)`); tests rely on deterministic
|
||||
single-thread ordering. Multi-thread via `runtime::init(Config…)`.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Benches
|
||||
|
||||
Two families live here: **comparison benches** (smarm vs tokio, predating
|
||||
v0.5) and the **run-queue shootout** (v0.5 phase 4). All are plain binaries
|
||||
(`harness = false` in `Cargo.toml`), so `cargo bench` just builds in release
|
||||
and runs `main()` — no criterion, no magic.
|
||||
|
||||
```
|
||||
cargo bench --bench <name> # one bench
|
||||
cargo bench # all of them (slow; rarely what you want)
|
||||
```
|
||||
|
||||
## Catalog
|
||||
|
||||
| file | what it measures |
|
||||
|---|---|
|
||||
| `primes.rs` | Compute fan-out/fan-in: counts primes across W workers. Pure compute throughput + spawn/join/channel cost. |
|
||||
| `multi_scheduler.rs` | The original cross-runtime matrix: smarm (1 thread / N threads) vs tokio (current_thread / multi_thread) on compute, ping-pong, and spawn throughput. |
|
||||
| `general.rs` | Workloads where neither runtime has a structural edge. Large gaps here mean real per-task/per-yield overhead differences — watch these for regressions. |
|
||||
| `smarm_favored.rs` | Workloads the stackful green-thread model is built for. Single-thread numbers isolate per-switch cost from contention. |
|
||||
| `tokio_favored.rs` | Workloads tokio's model is built for. Expect to lose; the value is knowing *by how much* and catching the gap widening. |
|
||||
| `rq_micro.rs` | Run-queue **structures** in isolation (no runtime, no actors): push/pop throughput sweeping thread count × producer:consumer ratio. Covers all three queue types in one binary — the types compile in every build; only the runtime's alias is feature-selected. |
|
||||
| `rq_runtime.rs` | The **whole scheduler** with the compile-time-selected queue: yield-storm (pure queue churn), ping-pong-pairs (park/unpark latency), spawn-storm (slab + free list + queue churn), sweeping scheduler count. Comparing variants requires rebuilding per `rq-*` feature. |
|
||||
|
||||
## The run-queue shootout
|
||||
|
||||
One command; it rebuilds `rq_runtime` once per queue variant, runs `rq_micro`
|
||||
once, and aggregates:
|
||||
|
||||
```
|
||||
./scripts/bench_rq.sh
|
||||
# on a big box:
|
||||
SMARM_BENCH_THREADS="1 2 4 8 16 20" ./scripts/bench_rq.sh
|
||||
```
|
||||
|
||||
Outputs land in `bench_results/` (gitignored): one full log per run, plus
|
||||
`summary.csv` assembled from the machine-readable `RQCSV,...` lines every
|
||||
config prints alongside the human table.
|
||||
|
||||
Manual single-variant runs need the feature dance (features are additive, so
|
||||
the default `rq-mutex` must be switched off):
|
||||
|
||||
```
|
||||
cargo bench --bench rq_runtime --no-default-features --features rq-striped
|
||||
```
|
||||
|
||||
### Knobs (env vars, all optional)
|
||||
|
||||
| var | default | used by |
|
||||
|---|---|---|
|
||||
| `SMARM_BENCH_THREADS` | `"1 2 4"` | both — space-separated sweep |
|
||||
| `SMARM_BENCH_RUNS` | `5` | both — repetitions; the **median** is reported |
|
||||
| `SMARM_BENCH_ITEMS` | `200000` | `rq_micro` — items per measurement |
|
||||
| `SMARM_BENCH_YIELD_ACTORS` / `_YIELDS` | `200` / `500` | `rq_runtime` yield-storm |
|
||||
| `SMARM_BENCH_PAIRS` / `_ROUNDTRIPS` | `32` / `1000` | `rq_runtime` ping-pong |
|
||||
| `SMARM_BENCH_SPAWNS` | `5000` | `rq_runtime` spawn-storm |
|
||||
|
||||
## Reading the numbers honestly
|
||||
|
||||
- **Core count is the experiment.** On a 1-core machine (CI, sandboxes) the
|
||||
sweep only validates the harness and catches gross pathologies —
|
||||
oversubscribed schedulers measure context-switch noise, not contention.
|
||||
Variant decisions come from a many-core box.
|
||||
- The striped queue *should lose* at low thread counts (ticket overhead with
|
||||
no contention to amortize) — that's expected, not a bug.
|
||||
- Medians over `SMARM_BENCH_RUNS` absorb scheduling noise but not thermal /
|
||||
turbo drift; for publishable numbers, pin the CPU governor and run a warmup
|
||||
pass first.
|
||||
- `spawn-storm` batches joins (1024 at a time) to stay well under the slab
|
||||
cap; if you raise `SMARM_BENCH_SPAWNS` massively, that batching is why it
|
||||
still works.
|
||||
|
||||
## Adding a bench
|
||||
|
||||
1. `benches/<name>.rs` with a plain `main()`; print the house table (see any
|
||||
existing bench) and, if it belongs to a sweep, a greppable CSV line with a
|
||||
distinctive prefix (`RQCSV,` for the shootout family).
|
||||
2. Register it in `Cargo.toml`:
|
||||
```toml
|
||||
[[bench]]
|
||||
name = "<name>"
|
||||
harness = false
|
||||
```
|
||||
3. Take parameters from `SMARM_BENCH_*` env vars with modest defaults — the
|
||||
defaults must finish in seconds on one core, the env scales them up on
|
||||
real hardware.
|
||||
4. Report **medians**, and keep one measurement = one fresh runtime
|
||||
(`init(Config::exact(t))` inside the measured closure constructor, the
|
||||
`run()` inside the timed region) so runs don't contaminate each other.
|
||||
+142
-142
@@ -2,313 +2,313 @@
|
||||
"chained_spawn": {
|
||||
"smarm 1-thread": {
|
||||
"result": 1000,
|
||||
"median": 266,
|
||||
"min": 242,
|
||||
"max": 351
|
||||
"median": 413,
|
||||
"min": 410,
|
||||
"max": 439
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 1000,
|
||||
"median": 742,
|
||||
"min": 696,
|
||||
"max": 860
|
||||
"median": 909,
|
||||
"min": 888,
|
||||
"max": 951
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 1000,
|
||||
"median": 62,
|
||||
"min": 61,
|
||||
"max": 68
|
||||
"max": 62
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 1000,
|
||||
"median": 190,
|
||||
"min": 169,
|
||||
"max": 207
|
||||
"median": 197,
|
||||
"min": 194,
|
||||
"max": 210
|
||||
}
|
||||
},
|
||||
"yield_many": {
|
||||
"smarm 1-thread": {
|
||||
"result": 200000,
|
||||
"median": 19071,
|
||||
"min": 18776,
|
||||
"max": 19396
|
||||
"median": 16475,
|
||||
"min": 16393,
|
||||
"max": 16732
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 200000,
|
||||
"median": 172454,
|
||||
"min": 166246,
|
||||
"max": 174230
|
||||
"median": 148708,
|
||||
"min": 111213,
|
||||
"max": 156462
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 200000,
|
||||
"median": 4737,
|
||||
"min": 4644,
|
||||
"max": 5065
|
||||
"median": 4751,
|
||||
"min": 4740,
|
||||
"max": 5259
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 200000,
|
||||
"median": 8738,
|
||||
"min": 7852,
|
||||
"max": 9770
|
||||
"median": 8320,
|
||||
"min": 7862,
|
||||
"max": 8882
|
||||
}
|
||||
},
|
||||
"fan_out_compute": {
|
||||
"smarm 1-thread": {
|
||||
"result": 33860,
|
||||
"median": 13234,
|
||||
"min": 13196,
|
||||
"max": 13390
|
||||
"median": 13453,
|
||||
"min": 13305,
|
||||
"max": 15077
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 33860,
|
||||
"median": 2244,
|
||||
"min": 2162,
|
||||
"max": 2380
|
||||
"median": 2451,
|
||||
"min": 2330,
|
||||
"max": 2520
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 33860,
|
||||
"median": 14049,
|
||||
"min": 14035,
|
||||
"max": 14300
|
||||
"median": 14019,
|
||||
"min": 12339,
|
||||
"max": 14045
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 33860,
|
||||
"median": 1474,
|
||||
"min": 1285,
|
||||
"max": 1823
|
||||
"median": 1500,
|
||||
"min": 1426,
|
||||
"max": 1600
|
||||
}
|
||||
},
|
||||
"ping_pong_oneshot": {
|
||||
"smarm 1-thread": {
|
||||
"result": 1000,
|
||||
"median": 751,
|
||||
"min": 727,
|
||||
"max": 913
|
||||
"median": 898,
|
||||
"min": 782,
|
||||
"max": 920
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 1000,
|
||||
"median": 1308,
|
||||
"min": 1227,
|
||||
"max": 1396
|
||||
"median": 1494,
|
||||
"min": 1489,
|
||||
"max": 1546
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 1000,
|
||||
"median": 407,
|
||||
"min": 400,
|
||||
"max": 444
|
||||
"median": 396,
|
||||
"min": 389,
|
||||
"max": 409
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 1000,
|
||||
"median": 10869,
|
||||
"min": 8683,
|
||||
"max": 11688
|
||||
"median": 10382,
|
||||
"min": 9559,
|
||||
"max": 10924
|
||||
}
|
||||
},
|
||||
"spawn_storm_busy": {
|
||||
"smarm 1-thread": {
|
||||
"result": 10000,
|
||||
"median": 112045,
|
||||
"min": 99936,
|
||||
"max": 117329
|
||||
"median": 106232,
|
||||
"min": 105627,
|
||||
"max": 107693
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 10000,
|
||||
"median": 137105,
|
||||
"min": 130852,
|
||||
"max": 147707
|
||||
"median": 49198,
|
||||
"min": 48894,
|
||||
"max": 52606
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 10000,
|
||||
"median": 1128,
|
||||
"min": 1123,
|
||||
"max": 1435
|
||||
"median": 1140,
|
||||
"min": 1018,
|
||||
"max": 1169
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 10000,
|
||||
"median": 19674,
|
||||
"min": 16013,
|
||||
"max": 27234
|
||||
"median": 18650,
|
||||
"min": 16486,
|
||||
"max": 19396
|
||||
}
|
||||
},
|
||||
"mpsc_contention": {
|
||||
"smarm 1-thread": {
|
||||
"result": 320000,
|
||||
"median": 3667,
|
||||
"min": 3608,
|
||||
"max": 4126
|
||||
"median": 6173,
|
||||
"min": 5446,
|
||||
"max": 6226
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 320000,
|
||||
"median": 45681,
|
||||
"min": 31908,
|
||||
"max": 51287
|
||||
"median": 36329,
|
||||
"min": 35121,
|
||||
"max": 37590
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 320000,
|
||||
"median": 6228,
|
||||
"min": 6210,
|
||||
"max": 6514
|
||||
"median": 5563,
|
||||
"min": 5527,
|
||||
"max": 6239
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 320000,
|
||||
"median": 66173,
|
||||
"min": 42208,
|
||||
"max": 83255
|
||||
"median": 63966,
|
||||
"min": 59972,
|
||||
"max": 67534
|
||||
}
|
||||
},
|
||||
"many_timers": {
|
||||
"smarm 1-thread": {
|
||||
"result": 10000,
|
||||
"median": 119988,
|
||||
"min": 107308,
|
||||
"max": 123557
|
||||
"median": 107826,
|
||||
"min": 107093,
|
||||
"max": 119034
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 10000,
|
||||
"median": 218842,
|
||||
"min": 182009,
|
||||
"max": 256988
|
||||
"median": 96539,
|
||||
"min": 95413,
|
||||
"max": 97804
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 10000,
|
||||
"median": 12432,
|
||||
"min": 12308,
|
||||
"max": 13468
|
||||
"median": 12584,
|
||||
"min": 12539,
|
||||
"max": 12627
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 10000,
|
||||
"median": 16311,
|
||||
"min": 15026,
|
||||
"max": 16897
|
||||
"median": 16183,
|
||||
"min": 16024,
|
||||
"max": 16541
|
||||
}
|
||||
},
|
||||
"multi_thread_scaling": {
|
||||
"smarm 1-thread": {
|
||||
"result": 33860,
|
||||
"median": 14908,
|
||||
"min": 14857,
|
||||
"max": 15218
|
||||
"median": 15083,
|
||||
"min": 15071,
|
||||
"max": 15283
|
||||
},
|
||||
"smarm 2-thread": {
|
||||
"result": 33860,
|
||||
"median": 7834,
|
||||
"min": 7717,
|
||||
"max": 8033
|
||||
"median": 8070,
|
||||
"min": 8003,
|
||||
"max": 8096
|
||||
},
|
||||
"smarm 4-thread": {
|
||||
"result": 33860,
|
||||
"median": 4393,
|
||||
"min": 4326,
|
||||
"max": 4435
|
||||
"median": 4460,
|
||||
"min": 4454,
|
||||
"max": 4516
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 33860,
|
||||
"median": 2173,
|
||||
"min": 2068,
|
||||
"max": 2405
|
||||
"median": 2333,
|
||||
"min": 2294,
|
||||
"max": 2348
|
||||
},
|
||||
"tokio multi 1-thread": {
|
||||
"result": 33860,
|
||||
"median": 14432,
|
||||
"min": 14219,
|
||||
"max": 14763
|
||||
"median": 14504,
|
||||
"min": 14193,
|
||||
"max": 14562
|
||||
},
|
||||
"tokio multi 2-thread": {
|
||||
"result": 33860,
|
||||
"median": 7333,
|
||||
"min": 7222,
|
||||
"max": 7477
|
||||
"median": 7297,
|
||||
"min": 7289,
|
||||
"max": 7398
|
||||
},
|
||||
"tokio multi 4-thread": {
|
||||
"result": 33860,
|
||||
"median": 3741,
|
||||
"min": 3681,
|
||||
"max": 3876
|
||||
"median": 3795,
|
||||
"min": 3756,
|
||||
"max": 3799
|
||||
},
|
||||
"tokio multi 24-thread": {
|
||||
"result": 33860,
|
||||
"median": 1513,
|
||||
"min": 1375,
|
||||
"max": 1979
|
||||
"median": 1544,
|
||||
"min": 1520,
|
||||
"max": 1610
|
||||
}
|
||||
},
|
||||
"deep_recursion": {
|
||||
"smarm 1-thread": {
|
||||
"result": 1,
|
||||
"median": 102,
|
||||
"min": 96,
|
||||
"max": 123
|
||||
"median": 226,
|
||||
"min": 222,
|
||||
"max": 243
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 1,
|
||||
"median": 597,
|
||||
"min": 576,
|
||||
"max": 682
|
||||
"median": 745,
|
||||
"min": 744,
|
||||
"max": 776
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 1,
|
||||
"median": 13,
|
||||
"min": 11,
|
||||
"max": 35
|
||||
"median": 11,
|
||||
"min": 10,
|
||||
"max": 13
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 1,
|
||||
"median": 56,
|
||||
"min": 46,
|
||||
"max": 65
|
||||
"median": 53,
|
||||
"min": 53,
|
||||
"max": 57
|
||||
}
|
||||
},
|
||||
"yield_in_hot_loop": {
|
||||
"smarm 1-thread": {
|
||||
"result": 1000000,
|
||||
"median": 80680,
|
||||
"min": 80308,
|
||||
"max": 81845
|
||||
"median": 64849,
|
||||
"min": 64396,
|
||||
"max": 65283
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 1000000,
|
||||
"median": 72606,
|
||||
"min": 72154,
|
||||
"max": 77206
|
||||
"median": 68507,
|
||||
"min": 62018,
|
||||
"max": 72341
|
||||
}
|
||||
},
|
||||
"uncontended_channel": {
|
||||
"smarm 1-thread": {
|
||||
"result": 1000000,
|
||||
"median": 9257,
|
||||
"min": 9223,
|
||||
"max": 12049
|
||||
"median": 11949,
|
||||
"min": 11928,
|
||||
"max": 13596
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 1000000,
|
||||
"median": 16925,
|
||||
"min": 16848,
|
||||
"max": 17019
|
||||
"median": 15083,
|
||||
"min": 15038,
|
||||
"max": 16994
|
||||
}
|
||||
},
|
||||
"catch_unwind_panics": {
|
||||
"smarm 1-thread": {
|
||||
"result": 10000,
|
||||
"median": 116821,
|
||||
"min": 111345,
|
||||
"max": 128261
|
||||
"median": 110932,
|
||||
"min": 110182,
|
||||
"max": 124147
|
||||
},
|
||||
"smarm 24-thread": {
|
||||
"result": 10000,
|
||||
"median": 117487,
|
||||
"min": 107011,
|
||||
"max": 129307
|
||||
"median": 13665,
|
||||
"min": 13172,
|
||||
"max": 13784
|
||||
},
|
||||
"tokio current_thread": {
|
||||
"result": 10000,
|
||||
"median": 10425,
|
||||
"min": 10141,
|
||||
"max": 10604
|
||||
"median": 10431,
|
||||
"min": 9346,
|
||||
"max": 10915
|
||||
},
|
||||
"tokio multi-thread": {
|
||||
"result": 10000,
|
||||
"median": 6418,
|
||||
"min": 3715,
|
||||
"max": 7144
|
||||
"median": 6171,
|
||||
"min": 5626,
|
||||
"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)
|
||||
}
|
||||
|
||||
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) {
|
||||
println!("\n{}", "=".repeat(80));
|
||||
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) {
|
||||
let mut times = Vec::new();
|
||||
let sets = env_sets();
|
||||
let mut times = Vec::with_capacity((n * sets) as usize);
|
||||
let mut last = 0u64;
|
||||
// One warmup iteration, discarded.
|
||||
// One warmup before all sets, discarded.
|
||||
let _ = f();
|
||||
for _ in 0..n {
|
||||
let (v, t) = f();
|
||||
times.push(t);
|
||||
last = v;
|
||||
for _ in 0..sets {
|
||||
for _ in 0..n {
|
||||
let (v, t) = f();
|
||||
times.push(t);
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
times.sort_unstable();
|
||||
let median = times[times.len() / 2];
|
||||
@@ -406,7 +416,8 @@ fn main() {
|
||||
let n = available_threads();
|
||||
println!("smarm general benchmarks");
|
||||
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!(
|
||||
"CHAIN_DEPTH={CHAIN_DEPTH}, YIELD_TASKS={YIELD_TASKS}×{YIELD_ROUNDS}, \
|
||||
PRIME_N={PRIME_N}/{PRIME_WORKERS} workers, PP_ROUNDS={PP_ROUNDS}"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Raw run-queue microbench (ROADMAP_v0.5 phase 4).
|
||||
//!
|
||||
//! Benches the three queue STRUCTURES directly — no runtime, no actors — to
|
||||
//! isolate the data structure under contention. All three types compile in
|
||||
//! every build, so this binary covers the whole matrix in one run; it does
|
||||
//! NOT need the rq-* feature rebuild dance (that's `rq_runtime`).
|
||||
//!
|
||||
//! Sweeps thread count × producer:consumer ratio. Queues are sized to the
|
||||
//! item count, so the occupancy contract holds trivially and producers never
|
||||
//! block on capacity.
|
||||
//!
|
||||
//! Knobs (env):
|
||||
//! SMARM_BENCH_THREADS space-separated sweep, default "1 2 4"
|
||||
//! SMARM_BENCH_ITEMS items per measurement, default 200_000
|
||||
//! SMARM_BENCH_RUNS repetitions per config (median reported), default 5
|
||||
//!
|
||||
//! Output: the house table, plus one machine-readable line per config:
|
||||
//! RQCSV,micro,<structure>,<threads>,<p:c>,<items>,<median_us>,<items_per_s>
|
||||
//!
|
||||
//! NOTE: numbers from a 1-core sandbox only validate the harness; real
|
||||
//! contention curves come from the many-core box (scripts/bench_rq.sh).
|
||||
|
||||
use smarm::pid::Pid;
|
||||
use smarm::run_queue::{MpmcRing, MutexQueue, StripedRing};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn env_usize(key: &str, default: usize) -> usize {
|
||||
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_threads() -> Vec<usize> {
|
||||
std::env::var("SMARM_BENCH_THREADS")
|
||||
.map(|v| v.split_whitespace().filter_map(|t| t.parse().ok()).collect())
|
||||
.unwrap_or_else(|_| vec![1, 2, 4])
|
||||
}
|
||||
|
||||
/// Generic driver: `producers` threads push `items` total, `consumers`
|
||||
/// threads pop until everything is accounted for. Returns elapsed µs.
|
||||
fn drive<Q: Send + Sync + 'static>(
|
||||
q: Arc<Q>,
|
||||
push: fn(&Q, Pid),
|
||||
pop: fn(&Q) -> Option<Pid>,
|
||||
producers: usize,
|
||||
consumers: usize,
|
||||
items: usize,
|
||||
) -> u128 {
|
||||
let remaining = Arc::new(AtomicUsize::new(items));
|
||||
let start = Instant::now();
|
||||
let mut hs = Vec::new();
|
||||
let per = items / producers;
|
||||
for p in 0..producers {
|
||||
let q = q.clone();
|
||||
// Give the last producer the remainder.
|
||||
let n = if p == producers - 1 { items - per * (producers - 1) } else { per };
|
||||
hs.push(std::thread::spawn(move || {
|
||||
let pid = Pid::new(p as u32, 0);
|
||||
for _ in 0..n {
|
||||
push(&q, pid);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for _ in 0..consumers {
|
||||
let q = q.clone();
|
||||
let remaining = remaining.clone();
|
||||
hs.push(std::thread::spawn(move || loop {
|
||||
// Claim-then-pop so consumers exit promptly when the budget hits
|
||||
// zero; the claim is backed out on a miss.
|
||||
let r = remaining.load(Ordering::Relaxed);
|
||||
if r == 0 {
|
||||
return;
|
||||
}
|
||||
if pop(&q).is_some() {
|
||||
remaining.fetch_sub(1, Ordering::Relaxed);
|
||||
} else {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
start.elapsed().as_micros()
|
||||
}
|
||||
|
||||
/// Single-thread alternating push/pop (the T = 1 case).
|
||||
fn drive_single<Q>(q: &Q, push: fn(&Q, Pid), pop: fn(&Q) -> Option<Pid>, items: usize) -> u128 {
|
||||
let pid = Pid::new(0, 0);
|
||||
let start = Instant::now();
|
||||
for _ in 0..items {
|
||||
push(q, pid);
|
||||
assert!(pop(q).is_some());
|
||||
}
|
||||
start.elapsed().as_micros()
|
||||
}
|
||||
|
||||
struct Case {
|
||||
structure: &'static str,
|
||||
threads: usize,
|
||||
producers: usize,
|
||||
consumers: usize,
|
||||
}
|
||||
|
||||
fn ratios_for(threads: usize) -> Vec<(usize, usize)> {
|
||||
if threads < 2 {
|
||||
return vec![(1, 1)]; // label only; T=1 runs the alternating driver
|
||||
}
|
||||
let mut v = vec![(threads / 2, threads - threads / 2)]; // balanced
|
||||
if threads >= 4 {
|
||||
v.push((3 * threads / 4, threads - 3 * threads / 4)); // producer-heavy
|
||||
v.push((threads / 4, threads - threads / 4)); // consumer-heavy
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let threads_sweep = env_threads();
|
||||
let items = env_usize("SMARM_BENCH_ITEMS", 200_000);
|
||||
let runs = env_usize("SMARM_BENCH_RUNS", 5);
|
||||
|
||||
println!("\n{}", "=".repeat(86));
|
||||
println!(" run-queue raw structures — items={items}, runs={runs} (median)");
|
||||
println!("{}", "=".repeat(86));
|
||||
println!(
|
||||
"{:>10} | {:>7} | {:>7} | {:>10} | {:>14}",
|
||||
"structure", "threads", "p:c", "median µs", "items/s"
|
||||
);
|
||||
println!("{}", "-".repeat(86));
|
||||
|
||||
let mut cases = Vec::new();
|
||||
for &t in &threads_sweep {
|
||||
for (p, c) in ratios_for(t) {
|
||||
for s in ["mutex", "mpmc", "striped"] {
|
||||
cases.push(Case { structure: s, threads: t, producers: p, consumers: c });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for case in cases {
|
||||
let mut times: Vec<u128> = (0..runs)
|
||||
.map(|_| {
|
||||
// Fresh queue per run; capacity = items so pushes never stall.
|
||||
match case.structure {
|
||||
"mutex" => {
|
||||
let q = Arc::new(MutexQueue::new(case.threads, items));
|
||||
if case.threads < 2 {
|
||||
drive_single(&*q, MutexQueue::push, MutexQueue::pop, items)
|
||||
} else {
|
||||
drive(q, MutexQueue::push, MutexQueue::pop, case.producers, case.consumers, items)
|
||||
}
|
||||
}
|
||||
"mpmc" => {
|
||||
let q = Arc::new(MpmcRing::with_capacity(items));
|
||||
if case.threads < 2 {
|
||||
drive_single(&*q, MpmcRing::push, MpmcRing::pop, items)
|
||||
} else {
|
||||
drive(q, MpmcRing::push, MpmcRing::pop, case.producers, case.consumers, items)
|
||||
}
|
||||
}
|
||||
"striped" => {
|
||||
let q = Arc::new(StripedRing::new(case.threads.max(1), items));
|
||||
if case.threads < 2 {
|
||||
drive_single(&*q, StripedRing::push, StripedRing::pop, items)
|
||||
} else {
|
||||
drive(q, StripedRing::push, StripedRing::pop, case.producers, case.consumers, items)
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
times.sort_unstable();
|
||||
let median = times[times.len() / 2];
|
||||
let per_s = (items as f64 / (median as f64 / 1e6)) as u64;
|
||||
let ratio = format!("{}:{}", case.producers, case.consumers);
|
||||
println!(
|
||||
"{:>10} | {:>7} | {:>7} | {:>10} | {:>14}",
|
||||
case.structure, case.threads, ratio, median, per_s
|
||||
);
|
||||
println!(
|
||||
"RQCSV,micro,{},{},{},{},{},{}",
|
||||
case.structure, case.threads, ratio, items, median, per_s
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 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,
|
||||
//! so comparing variants means rebuilding per rq-* feature — that's what
|
||||
//! 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:
|
||||
//! 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
|
||||
//! 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
|
||||
//! list under churn.
|
||||
//! Slot role: NEUTRALITY CHECK — spawns bypass the slot
|
||||
//! by policy; join wakes fire from finalize (scheduler
|
||||
//! context), also shared.
|
||||
//!
|
||||
//! Knobs (env):
|
||||
//! 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_YIELD_ACTORS / _YIELDS default 200 / 500
|
||||
//! SMARM_BENCH_PAIRS / _ROUNDTRIPS default 32 / 1000
|
||||
//! SMARM_BENCH_SPAWNS default 5000
|
||||
//!
|
||||
//! Output: house table + one line per config:
|
||||
//! 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;
|
||||
//! real curves come from the many-core box.
|
||||
|
||||
use smarm::runtime::{init, Config};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn env_threads() -> Vec<usize> {
|
||||
std::env::var("SMARM_BENCH_THREADS")
|
||||
.map(|v| v.split_whitespace().filter_map(|t| t.parse().ok()).collect())
|
||||
.unwrap_or_else(|_| vec![1, 2, 4])
|
||||
}
|
||||
|
||||
fn env_slots() -> Vec<bool> {
|
||||
std::env::var("SMARM_BENCH_SLOT")
|
||||
.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();
|
||||
rt.run(move || {
|
||||
let handles: Vec<_> = (0..actors)
|
||||
.map(|_| {
|
||||
smarm::spawn(move || {
|
||||
for _ in 0..yields {
|
||||
smarm::yield_now();
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
});
|
||||
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, slot: bool, pairs: usize, roundtrips: usize) -> Sample {
|
||||
let rt = init(Config::exact(threads).wake_slot(slot));
|
||||
let total = Arc::new(AtomicU64::new(0));
|
||||
let t2 = total.clone();
|
||||
let start = Instant::now();
|
||||
rt.run(move || {
|
||||
let handles: Vec<_> = (0..pairs)
|
||||
.map(|_| {
|
||||
let total = t2.clone();
|
||||
smarm::spawn(move || {
|
||||
let (tx_ab, rx_ab) = smarm::channel::channel::<u64>();
|
||||
let (tx_ba, rx_ba) = smarm::channel::channel::<u64>();
|
||||
let n = roundtrips as u64;
|
||||
let echo = smarm::spawn(move || {
|
||||
for _ in 0..n {
|
||||
let v = rx_ab.recv().expect("echo recv");
|
||||
tx_ba.send(v + 1).expect("echo send");
|
||||
}
|
||||
});
|
||||
for i in 0..n {
|
||||
tx_ab.send(i).expect("ping send");
|
||||
let v = rx_ba.recv().expect("ping recv");
|
||||
assert_eq!(v, i + 1);
|
||||
}
|
||||
let _ = echo.join();
|
||||
total.fetch_add(n, Ordering::Relaxed);
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
});
|
||||
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, slot: bool, spawns: usize) -> Sample {
|
||||
let rt = init(Config::exact(threads).wake_slot(slot));
|
||||
let start = Instant::now();
|
||||
rt.run(move || {
|
||||
// Batches bound simultaneous liveness well below the slab cap.
|
||||
const BATCH: usize = 1024;
|
||||
let mut left = spawns;
|
||||
while left > 0 {
|
||||
let n = left.min(BATCH);
|
||||
let handles: Vec<_> = (0..n).map(|_| smarm::spawn(|| {})).collect();
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
left -= n;
|
||||
}
|
||||
});
|
||||
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() {
|
||||
let threads_sweep = env_threads();
|
||||
let slot_sweep = env_slots();
|
||||
let runs = env_usize("SMARM_BENCH_RUNS", 5);
|
||||
let ya = env_usize("SMARM_BENCH_YIELD_ACTORS", 200);
|
||||
let yy = env_usize("SMARM_BENCH_YIELDS", 500);
|
||||
let pp = env_usize("SMARM_BENCH_PAIRS", 32);
|
||||
let pr = env_usize("SMARM_BENCH_ROUNDTRIPS", 1000);
|
||||
let ss = env_usize("SMARM_BENCH_SPAWNS", 5000);
|
||||
|
||||
println!("\n{}", "=".repeat(106));
|
||||
println!(
|
||||
" runtime benches — variant={}, runs={runs} (median)",
|
||||
variant()
|
||||
);
|
||||
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, bool) -> Sample>);
|
||||
let benches: Vec<Bench> = vec![
|
||||
(
|
||||
"yield-storm",
|
||||
format!("{ya}x{yy}"),
|
||||
Box::new(move |t, s| yield_storm(t, s, ya, yy)),
|
||||
),
|
||||
(
|
||||
"ping-pong-pairs",
|
||||
format!("{pp}x{pr}"),
|
||||
Box::new(move |t, s| ping_pong_pairs(t, s, pp, pr)),
|
||||
),
|
||||
(
|
||||
"spawn-storm",
|
||||
format!("{ss}"),
|
||||
Box::new(move |t, s| spawn_storm(t, s, ss)),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, work, f) in &benches {
|
||||
for &t in &threads_sweep {
|
||||
for &slot in &slot_sweep {
|
||||
let mut samples: Vec<Sample> = (0..runs).map(|_| f(t, slot)).collect();
|
||||
// Median by elapsed time; report the counters from that
|
||||
// same run so hits/time stay paired.
|
||||
samples.sort_unstable_by_key(|s| s.us);
|
||||
let mid = &samples[samples.len() / 2];
|
||||
let per_s = (mid.ops as f64 / (mid.us as f64 / 1e6)) as u64;
|
||||
let slot_str = if slot { "on" } else { "off" };
|
||||
println!(
|
||||
"{:>16} | {:>7} | {:>4} | {:>16} | {:>10} | {:>14} | {:>10} | {:>9}",
|
||||
name, t, slot_str, work, mid.us, per_s, mid.hits, mid.displacements
|
||||
);
|
||||
println!(
|
||||
"RQCSV,runtime,{},{},{},{},{},{},{}",
|
||||
variant(), slot_str, name, t, work, mid.us, per_s
|
||||
);
|
||||
if slot {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
println!("\n{}", "=".repeat(80));
|
||||
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) {
|
||||
let mut times = Vec::new();
|
||||
let sets = env_sets();
|
||||
let mut times = Vec::with_capacity((n * sets) as usize);
|
||||
let mut last = 0u64;
|
||||
let _ = f(); // warmup
|
||||
for _ in 0..n {
|
||||
let (v, t) = f();
|
||||
times.push(t);
|
||||
last = v;
|
||||
// One warmup before all sets, discarded.
|
||||
let _ = f();
|
||||
for _ in 0..sets {
|
||||
for _ in 0..n {
|
||||
let (v, t) = f();
|
||||
times.push(t);
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
times.sort_unstable();
|
||||
let median = times[times.len() / 2];
|
||||
@@ -385,7 +396,8 @@ fn main() {
|
||||
let n = available_threads();
|
||||
println!("smarm smarm-favored benchmarks");
|
||||
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!(
|
||||
"RECURSE_DEPTH={RECURSE_DEPTH}, HOT_YIELDS={HOT_YIELDS}×2, \
|
||||
UNCONT_MSGS={UNCONT_MSGS}, PANIC_TASKS={PANIC_TASKS}"
|
||||
|
||||
+137
-5
@@ -50,6 +50,11 @@ SWEEP_GRID = [
|
||||
(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_PCT = 10
|
||||
|
||||
@@ -103,9 +108,12 @@ def parse_output(text: str) -> dict[str, dict[str, dict]]:
|
||||
# Running
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_benches(env_extra: dict[str, str] | None = None) -> dict[str, dict[str, dict]]:
|
||||
"""Run all BENCHES and return merged parsed results."""
|
||||
def run_benches_once(env_extra: dict[str, str] | None = None) -> dict[str, dict[str, dict]]:
|
||||
"""Run all BENCHES once and return merged parsed results."""
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -196,6 +243,90 @@ def check_regressions(current: dict, baseline: dict) -> bool:
|
||||
# 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:
|
||||
if label:
|
||||
print(f"\n{'='*70}")
|
||||
@@ -210,6 +341,7 @@ def print_results(results: dict, label: str = "") -> None:
|
||||
f" {rt_label:>28} | {data['result']:>10} | "
|
||||
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:
|
||||
@@ -252,7 +384,7 @@ def cmd_run(args) -> None:
|
||||
["cargo", "build", "--release", "--benches"],
|
||||
cwd=REPO, check=True, capture_output=True,
|
||||
)
|
||||
print("Running benches…")
|
||||
print(f"Running benches ({BENCH_SETS} independent sets)…")
|
||||
results = run_benches()
|
||||
print_results(results, "Results (default knobs)")
|
||||
if args.save_baseline:
|
||||
@@ -266,7 +398,7 @@ def cmd_regress(args) -> None:
|
||||
["cargo", "build", "--release", "--benches"],
|
||||
cwd=REPO, check=True, capture_output=True,
|
||||
)
|
||||
print("Running benches…")
|
||||
print(f"Running benches ({BENCH_SETS} independent sets)…")
|
||||
current = run_benches()
|
||||
print_results(current, "Current results")
|
||||
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:
|
||||
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 = {
|
||||
"SMARM_ALLOC_INTERVAL": str(interval),
|
||||
"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)
|
||||
}
|
||||
|
||||
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) {
|
||||
println!("\n{}", "=".repeat(80));
|
||||
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) {
|
||||
let mut times = Vec::new();
|
||||
let sets = env_sets();
|
||||
let mut times = Vec::with_capacity((n * sets) as usize);
|
||||
let mut last = 0u64;
|
||||
let _ = f(); // warmup
|
||||
for _ in 0..n {
|
||||
let (v, t) = f();
|
||||
times.push(t);
|
||||
last = v;
|
||||
// One warmup before all sets, discarded.
|
||||
let _ = f();
|
||||
for _ in 0..sets {
|
||||
for _ in 0..n {
|
||||
let (v, t) = f();
|
||||
times.push(t);
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
times.sort_unstable();
|
||||
let median = times[times.len() / 2];
|
||||
@@ -434,7 +445,8 @@ fn main() {
|
||||
let n = available_threads();
|
||||
println!("smarm tokio-favored benchmarks");
|
||||
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!(
|
||||
"STORM_BACKGROUND={STORM_BACKGROUND}, STORM_SPAWN={STORM_SPAWN}, \
|
||||
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.
|
||||
+877
-439
File diff suppressed because it is too large
Load Diff
@@ -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 [`ServerName`] lets clients `call` and `cast` by name, resolving on
|
||||
//! every use — so the address keeps working across a supervised restart, with
|
||||
//! no stale [`ServerRef`] to refresh.
|
||||
|
||||
use smarm::{call, cast, run, whereis_server, GenServer, ServerBuilder, ServerName, ServerRef};
|
||||
|
||||
/// 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: ServerName<Counter> = ServerName::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.
|
||||
ServerBuilder::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 `ServerRef` from the name.
|
||||
let svc: Option<ServerRef<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,236 @@
|
||||
//! SPIKE (throwaway) — RFC 017 "fused" approach.
|
||||
//!
|
||||
//! This is the hand-written *expansion target* of the eventual `statem!` macro,
|
||||
//! written out in full so the primitives can be judged standing on their own.
|
||||
//! A macro would generate exactly this from a `transitions { … }` block; nothing
|
||||
//! here needs the macro to be correct or safe.
|
||||
//!
|
||||
//! The design, as settled over the prior iterations:
|
||||
//!
|
||||
//! * 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 committed `Machine` / `Resolution` / `Cx` primitives with
|
||||
//! no change to `src/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 statem_fused`
|
||||
|
||||
#![deny(dead_code, unreachable_patterns)]
|
||||
|
||||
use smarm::run;
|
||||
use smarm::statem::{spawn, Cx, Machine, Reply, Resolution, StatemRef};
|
||||
|
||||
// === 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
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
Push,
|
||||
Pull,
|
||||
Lock,
|
||||
Unlock(u32), // carries a key
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetState(Reply<Door>),
|
||||
GetEnters(Reply<u32>),
|
||||
GetPushes(Reply<u32>),
|
||||
}
|
||||
|
||||
enum Ev {
|
||||
Cast(Cast),
|
||||
Call(Call),
|
||||
}
|
||||
|
||||
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) -> StatemRef<DoorSm> {
|
||||
spawn(DoorSm {
|
||||
state: init,
|
||||
data: Data { enters: 0, pushes: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
fn enter(&mut self, _cx: &mut Cx<Ev>) {
|
||||
self.data.enters += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine for DoorSm {
|
||||
type Ev = Ev;
|
||||
|
||||
fn on_start(&mut self, cx: &mut Cx<Ev>) {
|
||||
self.enter(cx);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
|
||||
let prev = self.state;
|
||||
|
||||
// ---- the transition table -----------------------------------------
|
||||
// This match is total over (Door, Ev). Read it as the declared graph:
|
||||
// each `=> To(x)` is an edge, each `=> Unhandled` an explicit refusal.
|
||||
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::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::Push | Cast::Unlock(_))) => Resolution::Unhandled,
|
||||
|
||||
// --- Locked (branching row: handler picks within UnlockOutcome) -
|
||||
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => {
|
||||
Resolution::To(on_unlock(key).into())
|
||||
}
|
||||
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => {
|
||||
Resolution::Unhandled
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
};
|
||||
|
||||
// ---- apply the resolution -----------------------------------------
|
||||
match res {
|
||||
Resolution::To(s) if s == prev => {} // stay: no enter
|
||||
Resolution::To(s) => {
|
||||
self.state = s; // sole writer of the state cell
|
||||
self.enter(cx);
|
||||
}
|
||||
Resolution::Postpone => unreachable!("postpone lands in chunk 3"),
|
||||
Resolution::Unhandled => cx.on_unhandled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
run(|| {
|
||||
let door = DoorSm::start(Door::Closed);
|
||||
|
||||
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
||||
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
||||
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
||||
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
|
||||
|
||||
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();
|
||||
|
||||
println!("state={st:?} enters={enters} pushes={pushes}");
|
||||
assert_eq!(st, Door::Closed);
|
||||
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
||||
assert_eq!(pushes, 1);
|
||||
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,171 @@
|
||||
//! RFC 017 — the **same** machine as `examples/statem_fused.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 fused spike 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 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::statem::Reply;
|
||||
|
||||
// === user types (identical to statem_fused.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
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
Push,
|
||||
Pull,
|
||||
Lock,
|
||||
Unlock(u32), // carries a key
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetState(Reply<Door>),
|
||||
GetEnters(Reply<u32>),
|
||||
GetPushes(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 };
|
||||
|
||||
// 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 in chunk 1).
|
||||
context(data, prev, cx);
|
||||
|
||||
enter {
|
||||
_ => data.enters += 1,
|
||||
}
|
||||
|
||||
on Door::Open => {
|
||||
cast Cast::Push => { on_push(data); Door::Closed },
|
||||
cast Cast::Pull | Cast::Lock | Cast::Unlock(_) => unhandled,
|
||||
}
|
||||
on Door::Closed => {
|
||||
cast Cast::Pull => Door::Open,
|
||||
cast Cast::Lock => Door::Locked,
|
||||
cast Cast::Push | Cast::Unlock(_) => unhandled,
|
||||
}
|
||||
on Door::Locked => {
|
||||
cast Cast::Unlock(key) => on_unlock(key), // branch -> UnlockOutcome
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
run(|| {
|
||||
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0 });
|
||||
|
||||
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
||||
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
||||
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
||||
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
|
||||
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
|
||||
|
||||
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();
|
||||
|
||||
println!("state={st:?} enters={enters} pushes={pushes}");
|
||||
assert_eq!(st, Door::Closed);
|
||||
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
||||
assert_eq!(pushes, 1);
|
||||
println!("ok");
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// BREAK-CASE MENU — the four guarantees, through the macro. Each fires exactly
|
||||
// as it does in the hand-written statem_fused.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 (see the unit test in
|
||||
// src/statem.rs). 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,152 @@
|
||||
//! A hand-written `gen_statem`, written directly against the chunk-1 primitives
|
||||
//! in `smarm::statem` — no macro. This is the RFC 017 `Switch` example, and its
|
||||
//! purpose is to be the **evaluation artifact**: it shows exactly the shape the
|
||||
//! deferred `statem!` macro would have to generate, so we can judge what the
|
||||
//! macro actually buys before committing to building one.
|
||||
//!
|
||||
//! Run with: `cargo run --example statem_switch`
|
||||
//!
|
||||
//! Sections are tagged // USER (you'd hand-write this with or without a macro)
|
||||
//! and // MACRO (the boilerplate a `statem!` would emit for you).
|
||||
|
||||
use smarm::run;
|
||||
use smarm::statem::{self, Cx, Machine, Reply, Resolution, StatemRef};
|
||||
|
||||
// ---- USER: the four hand-written types ------------------------------------
|
||||
// In the macro world these are unchanged — the macro never generates them.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Switch {
|
||||
Off,
|
||||
On,
|
||||
}
|
||||
|
||||
struct Counts {
|
||||
flips: u32,
|
||||
enters: u32,
|
||||
}
|
||||
|
||||
enum SwitchCast {
|
||||
Flip,
|
||||
}
|
||||
|
||||
enum SwitchCall {
|
||||
GetCount(Reply<u32>),
|
||||
GetEnters(Reply<u32>),
|
||||
}
|
||||
|
||||
// ---- MACRO: the unified event ---------------------------------------------
|
||||
// The user's two message enums folded together. Chunk 1 has no internal
|
||||
// variants yet; chunks 2–3 add `StateTimeout` / `Timeout(name)` here.
|
||||
|
||||
enum Ev {
|
||||
Cast(SwitchCast),
|
||||
Call(SwitchCall),
|
||||
}
|
||||
|
||||
// ---- MACRO: the machine struct + state cell -------------------------------
|
||||
// `state` is the private cell; the `handle` body below is its sole writer.
|
||||
|
||||
struct SwitchSm {
|
||||
state: Switch,
|
||||
data: Counts,
|
||||
}
|
||||
|
||||
impl SwitchSm {
|
||||
// MACRO: `Switch::start` in the RFC; spawns the actor, hands back a ref.
|
||||
fn start(init: Switch, data: Counts) -> StatemRef<SwitchSm> {
|
||||
statem::spawn(SwitchSm { state: init, data })
|
||||
}
|
||||
|
||||
// MACRO: the `enter` dispatch, assembled from the per-state `enter` arms.
|
||||
// USER wrote the arm bodies (`data.enters += 1`); the macro wrote the match
|
||||
// and the `()` return. `enter` runs side effects only — it cannot transition.
|
||||
fn enter(&mut self, s: Switch, _cx: &mut Cx<Ev>) {
|
||||
match s {
|
||||
Switch::Off => self.data.enters += 1,
|
||||
Switch::On => self.data.enters += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine for SwitchSm {
|
||||
type Ev = Ev;
|
||||
|
||||
// MACRO: run the initial state's enter.
|
||||
fn on_start(&mut self, cx: &mut Cx<Ev>) {
|
||||
let s = self.state;
|
||||
self.enter(s, cx);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
|
||||
let prev = self.state;
|
||||
|
||||
// MACRO: the `(state, event)` dispatch table. USER wrote each arm tail
|
||||
// (the body + the ending state tag); the macro qualified bare tags
|
||||
// (`On` -> `Switch::On`), wrapped them in `.into()`, assembled the match,
|
||||
// and — in the real macro — would have edge-checked each tail tag
|
||||
// against the `transitions { Off => On, On => Off }` table at expand
|
||||
// time. Here that check is on the honour system.
|
||||
let next: Resolution<Switch> = match (self.state, ev) {
|
||||
(Switch::Off, Ev::Cast(SwitchCast::Flip)) => {
|
||||
self.data.flips += 1;
|
||||
Switch::On.into()
|
||||
}
|
||||
(Switch::On, Ev::Cast(SwitchCast::Flip)) => Switch::Off.into(),
|
||||
(Switch::Off, Ev::Call(SwitchCall::GetCount(r))) => {
|
||||
r.reply(self.data.flips);
|
||||
Switch::Off.into() // stay = return the current tag
|
||||
}
|
||||
(Switch::On, Ev::Call(SwitchCall::GetCount(r))) => {
|
||||
r.reply(self.data.flips);
|
||||
Switch::On.into()
|
||||
}
|
||||
(Switch::Off, Ev::Call(SwitchCall::GetEnters(r))) => {
|
||||
r.reply(self.data.enters);
|
||||
Switch::Off.into()
|
||||
}
|
||||
(Switch::On, Ev::Call(SwitchCall::GetEnters(r))) => {
|
||||
r.reply(self.data.enters);
|
||||
Switch::On.into()
|
||||
}
|
||||
// The macro would emit `_ => Resolution::Unhandled` here for a
|
||||
// machine whose table is non-exhaustive; this one covers every
|
||||
// (state, event) pair, so an added arm would be unreachable.
|
||||
};
|
||||
|
||||
// MACRO: the resolution dispatch — identical in every generated machine.
|
||||
match next {
|
||||
Resolution::To(s) if s == prev => {} // stay: no enter, no reset
|
||||
Resolution::To(s) => {
|
||||
self.state = s; // <- sole writer of the state cell
|
||||
// chunk 2: self.timers.clear_state_timeout();
|
||||
self.enter(s, cx);
|
||||
// chunk 3: cx.replay(&mut self.postponed);
|
||||
}
|
||||
Resolution::Postpone => {} // chunk 3
|
||||
Resolution::Unhandled => cx.on_unhandled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
run(|| {
|
||||
// USER: the client side. With a macro these would be `sw.cast(..)` /
|
||||
// `sw.call(SwitchCall::GetCount)`; without it, the `Ev::Cast` / `Ev::Call`
|
||||
// wrap is explicit — note how mechanical it is.
|
||||
let sw = SwitchSm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
|
||||
sw.send(Ev::Cast(SwitchCast::Flip)).unwrap(); // Off -> On
|
||||
sw.send(Ev::Cast(SwitchCast::Flip)).unwrap(); // On -> Off
|
||||
|
||||
let flips = sw.call(|r| Ev::Call(SwitchCall::GetCount(r))).unwrap();
|
||||
let enters = sw.call(|r| Ev::Call(SwitchCall::GetEnters(r))).unwrap();
|
||||
|
||||
// RFC's stated end state after two flips: Counts { flips: 1, enters: 3 }
|
||||
// (initial Off entry + On entry + Off entry).
|
||||
println!("after two flips: flips={flips}, enters={enters}");
|
||||
assert_eq!(flips, 1, "turned On once across the two flips");
|
||||
assert_eq!(enters, 3, "initial Off + On + Off entries");
|
||||
println!("ok");
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -122,9 +122,9 @@ pub struct Actor {
|
||||
/// The PID this actor was assigned at spawn time.
|
||||
pub pid: Pid,
|
||||
/// The stack the actor runs on. Dropped (munmap'd) when the actor dies.
|
||||
/// (The saved stack pointer lives on the `Slot` as an atomic, not here:
|
||||
/// it is hot scheduling state, read/written without the cold lock.)
|
||||
pub stack: Stack,
|
||||
/// The saved stack pointer. Updated on every yield.
|
||||
pub sp: usize,
|
||||
/// The PID of this actor's supervisor. Used to deliver `Signal` on death.
|
||||
pub supervisor: Pid,
|
||||
/// Cooperative-cancellation flag. `request_stop` sets it (and unparks a
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
//! Cooperative context switching and cycle counter, aarch64 (AAPCS64).
|
||||
//!
|
||||
//! The aarch64 mirror of the x86-64 backend. The protocol is identical — save
|
||||
//! callee-saved state, swap stack pointers via the shared `*_SP` thread-locals,
|
||||
//! restore, return — but the mechanics differ from x86 in two ways worth
|
||||
//! stating up front, because they drive the stack layout:
|
||||
//!
|
||||
//! 1. **Return is via the link register, not the stack.** x86 `ret` pops the
|
||||
//! return address off the stack; aarch64 `ret` jumps to whatever is in
|
||||
//! `x30` (lr). So the entry point is parked in the saved-`x30` slot, and
|
||||
//! the shim restores `x30` and `ret`s to it. There is no "ret target word"
|
||||
//! sitting on the stack the way there is on x86.
|
||||
//!
|
||||
//! 2. **sp must be 16-byte aligned at all times**, not just at call entry.
|
||||
//! We save 12 registers (x19–x28, x29, x30) = 96 bytes, already a multiple
|
||||
//! of 16, so the frame is aligned by construction.
|
||||
//!
|
||||
//! FP/SIMD registers (v8–v15, whose low 64 bits are callee-saved under AAPCS64)
|
||||
//! are NOT saved, for the same reason the x86 backend skips XMM: every yield
|
||||
//! goes through a Rust call boundary, so the compiler has already spilled any
|
||||
//! live vector state. If we ever yield from a non-call-boundary, this breaks.
|
||||
|
||||
use super::{get_actor_sp, get_scheduler_sp, set_actor_sp, set_scheduler_sp};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial stack layout
|
||||
//
|
||||
// Twelve 8-byte slots, all zero except the x30 slot which holds `entry`. The
|
||||
// first `switch_to_actor` loads x19–x28/x29/x30 back and `ret`s to x30,
|
||||
// landing at the top of `entry` with sp 16-aligned (the AAPCS64 requirement).
|
||||
//
|
||||
// Layout (high → low), relative to aligned_top = top & ~15:
|
||||
//
|
||||
// aligned_top - 8 : x30 = entry ← `ret` jumps here.
|
||||
// aligned_top - 16 : x29 = 0 (fp)
|
||||
// aligned_top - 24 : x28 = 0
|
||||
// aligned_top - 32 : x27 = 0
|
||||
// aligned_top - 40 : x26 = 0
|
||||
// aligned_top - 48 : x25 = 0
|
||||
// aligned_top - 56 : x24 = 0
|
||||
// aligned_top - 64 : x23 = 0
|
||||
// aligned_top - 72 : x22 = 0
|
||||
// aligned_top - 80 : x21 = 0
|
||||
// aligned_top - 88 : x20 = 0
|
||||
// aligned_top - 96 : x19 = 0 ← initial sp (16-aligned)
|
||||
//
|
||||
// The restore order in the shim (ldp pairs, low→high) must match this exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
|
||||
unsafe {
|
||||
let aligned_top = top as usize & !15;
|
||||
let mut sp = aligned_top;
|
||||
sp -= 8; (sp as *mut usize).write(entry as usize); // x30 (lr) → entry
|
||||
sp -= 8; (sp as *mut usize).write(0); // x29 (fp)
|
||||
sp -= 8; (sp as *mut usize).write(0); // x28
|
||||
sp -= 8; (sp as *mut usize).write(0); // x27
|
||||
sp -= 8; (sp as *mut usize).write(0); // x26
|
||||
sp -= 8; (sp as *mut usize).write(0); // x25
|
||||
sp -= 8; (sp as *mut usize).write(0); // x24
|
||||
sp -= 8; (sp as *mut usize).write(0); // x23
|
||||
sp -= 8; (sp as *mut usize).write(0); // x22
|
||||
sp -= 8; (sp as *mut usize).write(0); // x21
|
||||
sp -= 8; (sp as *mut usize).write(0); // x20
|
||||
sp -= 8; (sp as *mut usize).write(0); // x19 ← initial sp
|
||||
sp
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context switch shims
|
||||
//
|
||||
// Each shim:
|
||||
// 1. Pushes x19–x28, x29, x30 as six 16-byte stp pairs (sp pre-decrement).
|
||||
// 2. Moves sp into x0 and calls the Rust helper that stores it.
|
||||
// 3. Calls the Rust helper that returns the *other* side's saved sp.
|
||||
// 4. Moves that into sp.
|
||||
// 5. Restores the twelve registers and rets (to the restored x30).
|
||||
//
|
||||
// The helper calls clobber x30 themselves, but that's fine: the outgoing x30
|
||||
// was already saved to the stack in step 1, and step 5 restores the incoming
|
||||
// side's x30 before `ret`. The push/pop register order is paired so that the
|
||||
// `ldp` sequence reverses the `stp` sequence exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[unsafe(naked)]
|
||||
unsafe extern "C" fn switch_to_actor_asm() {
|
||||
core::arch::naked_asm!(
|
||||
"stp x19, x20, [sp, #-96]!",
|
||||
"stp x21, x22, [sp, #16]",
|
||||
"stp x23, x24, [sp, #32]",
|
||||
"stp x25, x26, [sp, #48]",
|
||||
"stp x27, x28, [sp, #64]",
|
||||
"stp x29, x30, [sp, #80]",
|
||||
"mov x0, sp",
|
||||
"bl {set_sched_sp}",
|
||||
"bl {get_actor_sp}",
|
||||
"mov sp, x0",
|
||||
"ldp x21, x22, [sp, #16]",
|
||||
"ldp x23, x24, [sp, #32]",
|
||||
"ldp x25, x26, [sp, #48]",
|
||||
"ldp x27, x28, [sp, #64]",
|
||||
"ldp x29, x30, [sp, #80]",
|
||||
"ldp x19, x20, [sp], #96",
|
||||
"ret",
|
||||
set_sched_sp = sym set_scheduler_sp,
|
||||
get_actor_sp = sym get_actor_sp,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
||||
pub unsafe fn switch_to_actor() {
|
||||
unsafe { switch_to_actor_asm() };
|
||||
}
|
||||
|
||||
#[unsafe(naked)]
|
||||
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||
core::arch::naked_asm!(
|
||||
"stp x19, x20, [sp, #-96]!",
|
||||
"stp x21, x22, [sp, #16]",
|
||||
"stp x23, x24, [sp, #32]",
|
||||
"stp x25, x26, [sp, #48]",
|
||||
"stp x27, x28, [sp, #64]",
|
||||
"stp x29, x30, [sp, #80]",
|
||||
"mov x0, sp",
|
||||
"bl {set_actor_sp}",
|
||||
"bl {get_sched_sp}",
|
||||
"mov sp, x0",
|
||||
"ldp x21, x22, [sp, #16]",
|
||||
"ldp x23, x24, [sp, #32]",
|
||||
"ldp x25, x26, [sp, #48]",
|
||||
"ldp x27, x28, [sp, #64]",
|
||||
"ldp x29, x30, [sp, #80]",
|
||||
"ldp x19, x20, [sp], #96",
|
||||
"ret",
|
||||
set_actor_sp = sym set_actor_sp,
|
||||
get_sched_sp = sym get_scheduler_sp,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cycle counter
|
||||
//
|
||||
// CNTVCT_EL0 is the virtual count register, readable from EL0 on Linux. `isb`
|
||||
// serialises so we don't sample the counter before prior instructions retire
|
||||
// (the aarch64 analogue of x86's `lfence` before rdtsc).
|
||||
//
|
||||
// Unit: CNTVCT ticks, whose frequency is CNTFRQ_EL0 — typically 1–50 MHz,
|
||||
// NOT the CPU clock. This is a different and much coarser unit than the x86
|
||||
// TSC, which is why `TIMESLICE_CYCLES` must be tuned separately for aarch64.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[inline(always)]
|
||||
pub fn read_cycle_counter() -> u64 {
|
||||
let cnt: u64;
|
||||
unsafe {
|
||||
core::arch::asm!(
|
||||
"isb",
|
||||
"mrs {cnt}, cntvct_el0",
|
||||
cnt = out(reg) cnt,
|
||||
options(nostack, nomem),
|
||||
);
|
||||
}
|
||||
cnt
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! Architecture abstraction layer.
|
||||
//!
|
||||
//! Everything that depends on the target ISA lives behind this module: the
|
||||
//! cooperative context-switch shims, the initial-stack layout, and the
|
||||
//! cycle counter used by the preemption timeslice. The rest of the runtime
|
||||
//! talks only to the API re-exported here and never names a register.
|
||||
//!
|
||||
//! Each backend (`x86_64`, `aarch64`) exposes the same four items:
|
||||
//!
|
||||
//! - `init_actor_stack(top, entry) -> usize`
|
||||
//! Build a fresh actor stack so the first `switch_to_actor` lands
|
||||
//! inside `entry` with the ABI-correct stack alignment.
|
||||
//! - `switch_to_actor()`
|
||||
//! Resume the actor whose sp is in `ACTOR_SP`; returns when it yields.
|
||||
//! - `switch_to_scheduler()`
|
||||
//! Yield from the current actor back to its scheduler thread.
|
||||
//! - `read_cycle_counter() -> u64`
|
||||
//! Monotonic per-core cycle counter for the timeslice clock. The unit
|
||||
//! is ISA-defined (x86 TSC ticks vs. aarch64 virtual-counter ticks),
|
||||
//! so `TIMESLICE_CYCLES` must be tuned per-arch — see `preempt`.
|
||||
//!
|
||||
//! The `SCHEDULER_SP` / `ACTOR_SP` thread-locals are shared across backends
|
||||
//! and live here, since the saved-stack-pointer protocol is identical
|
||||
//! regardless of which registers the shim happens to push.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
|
||||
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
// Used by the naked shims (via `sym`) and by the scheduler/tests through the
|
||||
// re-exports below. `pub` because the asm references them as symbols.
|
||||
pub(crate) fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
|
||||
pub(crate) fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
|
||||
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
|
||||
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
mod x86_64;
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub use x86_64::{init_actor_stack, read_cycle_counter, switch_to_actor, switch_to_scheduler};
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
mod aarch64;
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
pub use aarch64::{init_actor_stack, read_cycle_counter, switch_to_actor, switch_to_scheduler};
|
||||
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
compile_error!(
|
||||
"smarm's context-switch and cycle-counter layer supports only x86_64 and \
|
||||
aarch64. Add a backend in src/arch/ for this target."
|
||||
);
|
||||
@@ -1,118 +0,0 @@
|
||||
//! Cooperative context switching and cycle counter, x86-64 (SysV AMD64).
|
||||
//!
|
||||
//! Two naked-asm functions move execution between a scheduler thread and an
|
||||
//! actor running on its own mmap'd stack. The compiler cannot do this; the
|
||||
//! whole point of `#[unsafe(naked)]` is that we control every instruction.
|
||||
//!
|
||||
//! `init_actor_stack` builds the initial stack so that the first
|
||||
//! `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
|
||||
//! (the x86-64 ABI requirement at function entry).
|
||||
|
||||
use super::{get_actor_sp, get_scheduler_sp, set_actor_sp, set_scheduler_sp};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial stack layout
|
||||
//
|
||||
// We start from aligned_top = top & ~15, then bias by 8 and push seven 8-byte
|
||||
// slots (downward). The first `switch_to_actor` pops r15..rbx and `ret`s —
|
||||
// landing in `entry` with rsp % 16 == 8 (the x86-64 ABI state at the point
|
||||
// just after a `call`, which is what `entry` is compiled to expect).
|
||||
//
|
||||
// Layout (high → low), relative to aligned_top = top & ~15. Note the entry
|
||||
// slot is at aligned_top - 16, NOT aligned_top - 8: the function does
|
||||
// `(top & ~15) - 8` and *then* a `-= 8` before the first write, so the first
|
||||
// stored word lands at aligned_top - 16. Verified by single-stepping the
|
||||
// `ret` under llmdbg: entry sits at an address with %16 == 0, so the post-ret
|
||||
// rsp is %16 == 8.
|
||||
//
|
||||
// aligned_top - 8 : (unused padding; keeps entry's slot %16 == 0)
|
||||
// aligned_top - 16 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
|
||||
// aligned_top - 24 : rbx = 0
|
||||
// aligned_top - 32 : rbp = 0
|
||||
// aligned_top - 40 : r12 = 0
|
||||
// aligned_top - 48 : r13 = 0
|
||||
// aligned_top - 56 : r14 = 0
|
||||
// aligned_top - 64 : r15 = 0 ← initial rsp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
|
||||
unsafe {
|
||||
let mut sp = (top as usize & !15) - 8;
|
||||
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
|
||||
sp -= 8; (sp as *mut usize).write(0); // rbx
|
||||
sp -= 8; (sp as *mut usize).write(0); // rbp
|
||||
sp -= 8; (sp as *mut usize).write(0); // r12
|
||||
sp -= 8; (sp as *mut usize).write(0); // r13
|
||||
sp -= 8; (sp as *mut usize).write(0); // r14
|
||||
sp -= 8; (sp as *mut usize).write(0); // r15
|
||||
sp
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context switch shims
|
||||
//
|
||||
// Each shim:
|
||||
// 1. Pushes the six callee-saved integer registers.
|
||||
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
|
||||
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
|
||||
// 4. Moves that into rsp.
|
||||
// 5. Pops the six registers and rets.
|
||||
//
|
||||
// XMM registers are NOT saved here. We rely on every yield happening through
|
||||
// a Rust call site, which means the compiler has spilled any live XMM state
|
||||
// to the stack before we get here. (This is the same argument the compiler
|
||||
// uses internally — callee-saved regs are what survive a `call`, and the
|
||||
// SysV AMD64 ABI says XMM0–15 are all caller-saved.) If we ever yield from
|
||||
// a place that isn't a Rust call boundary, this assumption breaks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[unsafe(naked)]
|
||||
unsafe extern "C" fn switch_to_actor_asm() {
|
||||
core::arch::naked_asm!(
|
||||
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
||||
"mov rdi, rsp",
|
||||
"call {set_sched_sp}",
|
||||
"call {get_actor_sp}",
|
||||
"mov rsp, rax",
|
||||
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
||||
"ret",
|
||||
set_sched_sp = sym set_scheduler_sp,
|
||||
get_actor_sp = sym get_actor_sp,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
||||
pub unsafe fn switch_to_actor() {
|
||||
unsafe { switch_to_actor_asm() };
|
||||
}
|
||||
|
||||
#[unsafe(naked)]
|
||||
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||
core::arch::naked_asm!(
|
||||
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
||||
"mov rdi, rsp",
|
||||
"call {set_actor_sp}",
|
||||
"call {get_sched_sp}",
|
||||
"mov rsp, rax",
|
||||
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
||||
"ret",
|
||||
set_actor_sp = sym set_actor_sp,
|
||||
get_sched_sp = sym get_scheduler_sp,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cycle counter
|
||||
//
|
||||
// `lfence` serialises the instruction stream so we don't measure time before
|
||||
// prior instructions retire. Unit: TSC ticks (≈ CPU base clock).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[inline(always)]
|
||||
pub fn read_cycle_counter() -> u64 {
|
||||
unsafe {
|
||||
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
|
||||
core::arch::x86_64::_rdtsc()
|
||||
}
|
||||
}
|
||||
+480
-22
@@ -1,9 +1,23 @@
|
||||
//! Unbounded MPSC channels.
|
||||
//!
|
||||
//! Inner state is `Arc<Mutex<Inner<T>>>` so channels can be sent across OS
|
||||
//! Inner state is `Arc<RawMutex<Inner<T>>>` so channels can be sent across OS
|
||||
//! threads (required for the multi-scheduler runtime where a sender and
|
||||
//! receiver may run on different scheduler threads simultaneously).
|
||||
//!
|
||||
//! ## Why `RawMutex` (Channel class), not `std::sync::Mutex`
|
||||
//!
|
||||
//! An actor holding a guard with preemption *enabled* can be timesliced
|
||||
//! inside the critical section and resume on a different OS thread — the
|
||||
//! pthread mutex would then be released from a thread that didn't lock it,
|
||||
//! which is UB (Linux futexes happen to tolerate it, but it's not
|
||||
//! guaranteed). `RawMutex` disables preemption for the guard's span and is
|
||||
//! cross-thread-release sound by construction, closing the hole. It also
|
||||
//! cannot poison. Channel locks form their own [`LockClass::Channel`]
|
||||
//! (raw_mutex.rs): they may be taken under a cold (Leaf) lock — finalize and
|
||||
//! `monitor()` clone senders that live in slots — but nothing may be locked
|
||||
//! under them, which the debug build enforces. `recv_match` runs its user
|
||||
//! predicate under this lock: keep it cheap, pure, and channel-free.
|
||||
//!
|
||||
//! Semantics:
|
||||
//! - Senders are clonable; the last sender drop closes the channel.
|
||||
//! - `Receiver::recv` on an empty open channel parks the receiver.
|
||||
@@ -15,11 +29,12 @@
|
||||
//! parked, the receiver is unparked.
|
||||
|
||||
use crate::pid::Pid;
|
||||
use crate::raw_mutex::RawMutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
|
||||
let inner = Arc::new(Mutex::new(Inner {
|
||||
let inner = Arc::new(RawMutex::new_channel(Inner {
|
||||
queue: VecDeque::new(),
|
||||
parked_receiver: None,
|
||||
senders: 1,
|
||||
@@ -30,17 +45,24 @@ pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
|
||||
|
||||
struct Inner<T> {
|
||||
queue: VecDeque<T>,
|
||||
parked_receiver: Option<Pid>,
|
||||
/// The parked receiver's `(pid, park-epoch)`. The epoch is the slot
|
||||
/// word's runtime-wide wait identity (see slot_state.rs): wakers call
|
||||
/// `unpark_at(pid, epoch)`, so an entry left over from an already-woken
|
||||
/// wait — a `select` loser arm, a satisfied `recv_timeout`'s timer — is
|
||||
/// inert: the wake fails the word's epoch CAS and no-ops. This replaces
|
||||
/// the old per-channel `cur_wait`/`next_wait_seq`/`timed_out` trio: wait
|
||||
/// identity now exists exactly once, in the slot word.
|
||||
parked_receiver: Option<(Pid, u32)>,
|
||||
senders: usize,
|
||||
receiver_alive: bool,
|
||||
}
|
||||
|
||||
pub struct Sender<T> {
|
||||
inner: Arc<Mutex<Inner<T>>>,
|
||||
inner: Arc<RawMutex<Inner<T>>>,
|
||||
}
|
||||
|
||||
pub struct Receiver<T> {
|
||||
inner: Arc<Mutex<Inner<T>>>,
|
||||
inner: Arc<RawMutex<Inner<T>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -57,9 +79,30 @@ impl std::fmt::Display for RecvError {
|
||||
|
||||
impl std::error::Error for RecvError {}
|
||||
|
||||
/// Returned by [`Receiver::recv_timeout`].
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum RecvTimeoutError {
|
||||
/// The deadline passed with no message available.
|
||||
Timeout,
|
||||
/// All senders dropped with no message available — the bounded analogue
|
||||
/// of [`RecvError`].
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RecvTimeoutError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RecvTimeoutError::Timeout => write!(f, "recv timed out"),
|
||||
RecvTimeoutError::Disconnected => write!(f, "channel closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RecvTimeoutError {}
|
||||
|
||||
impl<T> Clone for Sender<T> {
|
||||
fn clone(&self) -> Self {
|
||||
self.inner.lock().unwrap().senders += 1;
|
||||
self.inner.lock().senders += 1;
|
||||
Sender { inner: self.inner.clone() }
|
||||
}
|
||||
}
|
||||
@@ -67,7 +110,7 @@ impl<T> Clone for Sender<T> {
|
||||
impl<T> Drop for Sender<T> {
|
||||
fn drop(&mut self) {
|
||||
let unpark = {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let mut g = self.inner.lock();
|
||||
g.senders -= 1;
|
||||
// Wake the parked receiver on the last sender drop regardless of
|
||||
// whether the queue is empty. A plain `recv` only ever parks on an
|
||||
@@ -81,31 +124,39 @@ impl<T> Drop for Sender<T> {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(pid) = unpark {
|
||||
crate::scheduler::unpark(pid);
|
||||
if let Some((pid, epoch)) = unpark {
|
||||
crate::scheduler::unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for Receiver<T> {
|
||||
fn drop(&mut self) {
|
||||
self.inner.lock().unwrap().receiver_alive = false;
|
||||
self.inner.lock().receiver_alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Sender<T> {
|
||||
/// Number of messages currently queued behind this channel. Introspection
|
||||
/// only (RFC 016 mailbox depth); takes the channel lock, so callers reach
|
||||
/// it under the registry Leaf (Leaf → Channel) via the erased probe in
|
||||
/// `registry.rs`, never on a hot path.
|
||||
pub(crate) fn queued_len(&self) -> usize {
|
||||
self.inner.lock().queue.len()
|
||||
}
|
||||
|
||||
pub fn send(&self, value: T) -> Result<(), SendError<T>> {
|
||||
let unpark = {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let mut g = self.inner.lock();
|
||||
if !g.receiver_alive {
|
||||
return Err(SendError(value));
|
||||
}
|
||||
g.queue.push_back(value);
|
||||
g.parked_receiver.take()
|
||||
};
|
||||
if let Some(pid) = unpark {
|
||||
if let Some((pid, epoch)) = unpark {
|
||||
crate::te!(crate::trace::Event::Send { sender: crate::actor::current_pid().unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)), receiver: Some(pid) });
|
||||
crate::scheduler::unpark(pid);
|
||||
crate::scheduler::unpark_at(pid, epoch);
|
||||
} else {
|
||||
crate::te!(crate::trace::Event::Send { sender: crate::actor::current_pid().unwrap_or(crate::pid::Pid::new(u32::MAX, u32::MAX)), receiver: None });
|
||||
}
|
||||
@@ -117,8 +168,9 @@ impl<T> Receiver<T> {
|
||||
pub fn recv(&self) -> Result<T, RecvError> {
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(v) = g.queue.pop_front() {
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(v);
|
||||
}
|
||||
if g.senders == 0 {
|
||||
@@ -127,10 +179,13 @@ impl<T> Receiver<T> {
|
||||
let me = crate::actor::current_pid()
|
||||
.expect("recv() called outside an actor");
|
||||
debug_assert!(
|
||||
g.parked_receiver.is_none(),
|
||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||
"channel has more than one receiver"
|
||||
);
|
||||
g.parked_receiver = Some(me);
|
||||
// begin_wait is lock-free — legal under the Channel lock;
|
||||
// registering in the same critical section makes the epoch
|
||||
// atomic with the senders' view of the registration.
|
||||
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
||||
crate::te!(crate::trace::Event::RecvPark(me));
|
||||
}
|
||||
// Release the lock before parking — the unparker will need it.
|
||||
@@ -140,6 +195,77 @@ impl<T> Receiver<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded receive: like [`recv`](Self::recv), but gives up once
|
||||
/// `timeout` has elapsed, returning [`RecvTimeoutError::Timeout`].
|
||||
///
|
||||
/// Built on the same timer machinery as `Mutex::lock_timeout`: the wait
|
||||
/// registers a `WaitTimeout` entry stamped with the wait's park-epoch;
|
||||
/// on expiry the channel (as the
|
||||
/// [`TimerTarget`](crate::timer::TimerTarget)) checks whether *this*
|
||||
/// wait is still parked and, only then, cancels it. A wake that races
|
||||
/// the deadline resolves message-first: if a message is available when
|
||||
/// the receiver runs, it is delivered even if the timer had already
|
||||
/// fired. A satisfied or abandoned wait leaves its timer entry to expire
|
||||
/// as a no-op (registration gone; epoch consumed), per the
|
||||
/// no-cancellation convention in `timer.rs`.
|
||||
///
|
||||
/// The wake is classified from state alone — wakes are precise (the only
|
||||
/// stamped wakers of this wait are a send, the last-sender drop, and the
|
||||
/// timer; a stop wake unwinds out of `park_current` and never reaches
|
||||
/// the classification), so: message queued → `Ok`; `senders == 0` →
|
||||
/// `Disconnected`; neither → it was the timer → `Timeout`.
|
||||
///
|
||||
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-
|
||||
/// due timer is drained, then reports `Timeout` unless a message was
|
||||
/// already queued.
|
||||
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Result<T, RecvTimeoutError>
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let me = crate::actor::current_pid()
|
||||
.expect("recv_timeout() called outside an actor");
|
||||
|
||||
// Fast path + wait registration, one critical section.
|
||||
let epoch;
|
||||
{
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(v) = g.queue.pop_front() {
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(v);
|
||||
}
|
||||
if g.senders == 0 {
|
||||
return Err(RecvTimeoutError::Disconnected);
|
||||
}
|
||||
debug_assert!(
|
||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||
"channel has more than one receiver"
|
||||
);
|
||||
epoch = crate::scheduler::begin_wait();
|
||||
g.parked_receiver = Some((me, epoch));
|
||||
crate::te!(crate::trace::Event::RecvPark(me));
|
||||
}
|
||||
|
||||
// Arm the timer after releasing the channel lock (insert takes the
|
||||
// timers lock; never nest under a Channel lock). A send or even the
|
||||
// timer itself may unpark us before we park — the RunningNotified
|
||||
// protocol makes the park below return immediately in that case.
|
||||
let deadline = crate::timer::deadline_from_now(timeout);
|
||||
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = self.inner.clone();
|
||||
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||
|
||||
crate::scheduler::park_current();
|
||||
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(v) = g.queue.pop_front() {
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(v);
|
||||
}
|
||||
if g.senders == 0 {
|
||||
return Err(RecvTimeoutError::Disconnected);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout)
|
||||
}
|
||||
|
||||
/// Selective receive: remove and return the first queued message for which
|
||||
/// `pred` holds, leaving the rest in arrival order. If no queued message
|
||||
/// matches, parks and re-scans on every send (a selective receiver may park
|
||||
@@ -156,9 +282,10 @@ impl<T> Receiver<T> {
|
||||
{
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
|
||||
// position() found it, so remove() returns Some.
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(g.queue.remove(i).unwrap());
|
||||
}
|
||||
if g.senders == 0 {
|
||||
@@ -168,10 +295,10 @@ impl<T> Receiver<T> {
|
||||
let me = crate::actor::current_pid()
|
||||
.expect("recv_match() called outside an actor");
|
||||
debug_assert!(
|
||||
g.parked_receiver.is_none(),
|
||||
g.parked_receiver.is_none_or(|(p, _)| p == me),
|
||||
"channel has more than one receiver"
|
||||
);
|
||||
g.parked_receiver = Some(me);
|
||||
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
|
||||
crate::te!(crate::trace::Event::RecvPark(me));
|
||||
}
|
||||
// Release the lock before parking — the unparker will need it.
|
||||
@@ -188,8 +315,9 @@ impl<T> Receiver<T> {
|
||||
where
|
||||
F: Fn(&T) -> bool,
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(Some(g.queue.remove(i).unwrap()));
|
||||
}
|
||||
if g.senders == 0 {
|
||||
@@ -201,8 +329,9 @@ impl<T> Receiver<T> {
|
||||
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
|
||||
/// the channel is empty but open, `Err(RecvError)` if closed and drained.
|
||||
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(v) = g.queue.pop_front() {
|
||||
crate::preempt::note_message_received();
|
||||
return Ok(Some(v));
|
||||
}
|
||||
if g.senders == 0 {
|
||||
@@ -211,3 +340,332 @@ impl<T> Receiver<T> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimerTarget — the expiry half of recv_timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
// Cancel the wait only if THIS wait (epoch match) is still
|
||||
// registered. If a sender already took `parked_receiver`, the
|
||||
// receiver is waking with a message — message wins, the timer
|
||||
// no-ops. If a later wait by the same receiver is registered, the
|
||||
// epoch mismatches — stale entry, no-op. (The unpark_at would fail
|
||||
// its word CAS in either case anyway; checking under the lock keeps
|
||||
// the registration bookkeeping exact.)
|
||||
let unpark = {
|
||||
let mut g = self.lock();
|
||||
if g.parked_receiver == Some((pid, epoch)) {
|
||||
g.parked_receiver = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
// Unpark outside the channel lock — it may take the run-queue lock;
|
||||
// legal under a Channel lock, but pointless to nest.
|
||||
if unpark {
|
||||
crate::scheduler::unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// select — ready-index wait over multiple receivers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) mod sealed {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
impl<T> sealed::Sealed for Receiver<T> {}
|
||||
|
||||
/// An arm of a [`select`]. Implemented by [`Receiver`]; sealed, because the
|
||||
/// registration contract below is part of the runtime's wake protocol.
|
||||
///
|
||||
/// Contract (all under the arm's own lock): `sel_register` checks-or-
|
||||
/// registers atomically — if the arm is ready it does NOT register and
|
||||
/// returns `Ok(false)`; otherwise it publishes `(pid, epoch)` where its
|
||||
/// wakers will find it and returns `Ok(true)`. "Ready" means a receive
|
||||
/// would not park: a message is queued, or the arm is closed. `Err` means
|
||||
/// the arm could not register at all (only fd arms can fail; channel
|
||||
/// registration is infallible) — the wait must be retired and earlier
|
||||
/// eager-cleanup arms unregistered.
|
||||
pub trait Selectable: sealed::Sealed {
|
||||
#[doc(hidden)]
|
||||
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool>;
|
||||
#[doc(hidden)]
|
||||
fn sel_ready(&self) -> bool;
|
||||
/// Remove this arm's `(pid, epoch)` registration if — and only if — it
|
||||
/// is still in place. Default no-op: a losing channel arm's stale
|
||||
/// registration is inert (its wakers die at the epoch CAS; the next
|
||||
/// wait overwrites the slot). Fd arms override this: their staleness
|
||||
/// poisons the fd (waiters entry + kernel-side ONESHOT registration)
|
||||
/// and needs an eager cleanup pass.
|
||||
#[doc(hidden)]
|
||||
fn sel_unregister(&self, _pid: Pid, _epoch: u32) {}
|
||||
/// Whether this arm requires the eager cleanup pass at all. Gates the
|
||||
/// post-wake `sel_unregister` sweep so channel-only selects keep
|
||||
/// today's zero-cancellation hot path.
|
||||
#[doc(hidden)]
|
||||
fn sel_eager_cleanup(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Selectable for Receiver<T> {
|
||||
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool> {
|
||||
let mut g = self.inner.lock();
|
||||
if !g.queue.is_empty() || g.senders == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
debug_assert!(
|
||||
g.parked_receiver.is_none_or(|(p, _)| p == pid),
|
||||
"channel has more than one receiver"
|
||||
);
|
||||
g.parked_receiver = Some((pid, epoch));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn sel_ready(&self) -> bool {
|
||||
let g = self.inner.lock();
|
||||
!g.queue.is_empty() || g.senders == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Park on every arm at once; return the index of the first ready one.
|
||||
///
|
||||
/// "Ready" means a receive on that arm would not park: a message is queued,
|
||||
/// or the arm is **closed** (so the caller's `try_recv` observes the
|
||||
/// disconnect — a dead arm is an event, not a hang). The caller consumes the
|
||||
/// arm itself, typically via [`Receiver::try_recv`]; single-receiver
|
||||
/// channels guarantee nothing can steal the message in between.
|
||||
///
|
||||
/// A closed arm stays ready *forever*: once its disconnect has been
|
||||
/// observed, drop it from the arm set — under priority order it would
|
||||
/// otherwise win every subsequent call and starve every higher-indexed arm.
|
||||
///
|
||||
/// Arms are scanned **in order**: index 0 is the highest priority, both on
|
||||
/// the immediate-ready path and after a wake. This is a documented
|
||||
/// guarantee (compose like BEAM receive clauses: put control channels
|
||||
/// first), not an accident — and therefore there is NO fairness promise; a
|
||||
/// saturated arm 0 starves arm 1 by design.
|
||||
///
|
||||
/// One actor may select on a channel and later `recv` on it (or select on
|
||||
/// overlapping sets) freely. What stays illegal is what was always illegal:
|
||||
/// two *different* actors receiving on one channel.
|
||||
///
|
||||
/// Built on the consuming-wake protocol (see slot_state.rs): all arms are
|
||||
/// registered under one wait epoch; the winning wake consumes it, so losing
|
||||
/// arms' registrations are inert and need no cancellation pass — they
|
||||
/// self-clean at their wakers' failed CAS, or get overwritten by this
|
||||
/// receiver's next wait on that channel.
|
||||
///
|
||||
/// Panics if `arms` is empty, when called outside an actor, or if an fd
|
||||
/// arm fails to register (EBADF, EMFILE, a second waiter on one fd —
|
||||
/// see [`try_select`] for the fallible form; channel-only selects cannot
|
||||
/// fail).
|
||||
pub fn select(arms: &[&dyn Selectable]) -> usize {
|
||||
try_select(arms).expect("select(): fd arm failed to register (use try_select)")
|
||||
}
|
||||
|
||||
/// [`select`], fallible: `Err` when an arm fails to register (only fd
|
||||
/// arms can — EBADF, EMFILE on the epoll set, or a second waiter on an
|
||||
/// fd that already has one). On `Err` the wait is fully retired and no
|
||||
/// registration is left behind: every arm registered before the failing
|
||||
/// one has been unregistered.
|
||||
pub fn try_select(arms: &[&dyn Selectable]) -> std::io::Result<usize> {
|
||||
assert!(!arms.is_empty(), "select() on an empty arm list");
|
||||
let me = crate::actor::current_pid().expect("select() called outside an actor");
|
||||
loop {
|
||||
let epoch = crate::scheduler::begin_wait();
|
||||
if let Some(i) = register_arms(me, epoch, arms)? {
|
||||
return Ok(i);
|
||||
}
|
||||
|
||||
// Stale fd registrations are not harmless (a losing fd arm's
|
||||
// waiters entry poisons the fd with AlreadyExists and its
|
||||
// kernel-side ONESHOT registration can fire arbitrarily late), so
|
||||
// selects containing fd arms run an eager cleanup pass after the
|
||||
// park — including when a terminal stop unwinds out of it, via
|
||||
// the guard. Channel-only selects skip all of it: `eager` is
|
||||
// false, the guard is disarmed, and the loser-arm self-cleaning
|
||||
// story is unchanged.
|
||||
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
||||
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
||||
|
||||
crate::scheduler::park_current();
|
||||
|
||||
if eager {
|
||||
unregister_arms(arms, me, epoch);
|
||||
}
|
||||
guard.armed = false;
|
||||
drop(guard);
|
||||
|
||||
// Woken precisely: an arm's send (message) or last-sender drop
|
||||
// (closure) consumed our epoch, and both leave their arm ready —
|
||||
// return the first one, in priority order (which may be a
|
||||
// different, higher-priority arm than the one that woke us; its
|
||||
// message stays queued and re-reports ready on the next call).
|
||||
// Fd arms classify by a fresh zero-timeout poll, so they too are
|
||||
// a pure function of state — independent of the registration the
|
||||
// cleanup pass just removed.
|
||||
for (i, arm) in arms.iter().enumerate() {
|
||||
if arm.sel_ready() {
|
||||
return Ok(i);
|
||||
}
|
||||
}
|
||||
// Unreachable by protocol (a stop wake unwinds out of
|
||||
// park_current). Defensive: re-open the wait and re-register —
|
||||
// stale own-registrations are overwritten (channels) or were
|
||||
// removed by the cleanup pass above (fds).
|
||||
}
|
||||
}
|
||||
|
||||
/// Eager-cleanup sweep: remove every fd arm's registration that is still
|
||||
/// ours. No-op per channel arm (one virtual call); one io-lock visit per
|
||||
/// fd arm.
|
||||
fn unregister_arms(arms: &[&dyn Selectable], me: Pid, epoch: u32) {
|
||||
for arm in arms {
|
||||
if arm.sel_eager_cleanup() {
|
||||
arm.sel_unregister(me, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop-unwind twin of the explicit cleanup pass: a terminal stop unwinds
|
||||
/// out of `park_current`, and a registered fd arm must not outlive its
|
||||
/// actor (the generalization of `wait_fd`'s `Dereg`). Disarmed on the
|
||||
/// normal path after the explicit pass runs; never armed when no fd arm
|
||||
/// registered, keeping the channel-only path guard-free in effect.
|
||||
struct UnregisterGuard<'a> {
|
||||
arms: &'a [&'a dyn Selectable],
|
||||
me: Pid,
|
||||
epoch: u32,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl Drop for UnregisterGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
unregister_arms(self.arms, self.me, self.epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The registration pass shared by [`select`] and [`select_timeout`]:
|
||||
/// check-or-register each arm, in priority order, each atomically under its
|
||||
/// own lock. Cross-arm atomicity is unnecessary: an arm becoming ready
|
||||
/// right after its registration wakes the caller through the protocol (the
|
||||
/// prep-to-park window is closed by RunningNotified).
|
||||
///
|
||||
/// `Ok(Some(i))` = arm `i` was ready, the pass stopped, and the wait has
|
||||
/// been RETIRED (no park may follow): earlier arms hold live-epoch
|
||||
/// registrations, so earlier *fd* arms are unregistered eagerly, then the
|
||||
/// epoch is bumped, a landed notification eaten, and a pending stop
|
||||
/// re-observed — without which a stale arm wake could fault a later
|
||||
/// one-shot park. `Err` = an arm failed to register; identical unwind
|
||||
/// (earlier fd arms unregistered, wait retired). `Ok(None)` = every arm
|
||||
/// registered; the caller parks.
|
||||
fn register_arms(
|
||||
me: Pid,
|
||||
epoch: u32,
|
||||
arms: &[&dyn Selectable],
|
||||
) -> std::io::Result<Option<usize>> {
|
||||
for (i, arm) in arms.iter().enumerate() {
|
||||
let registered = match arm.sel_register(me, epoch) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
unregister_arms(&arms[..i], me, epoch);
|
||||
crate::scheduler::retire_wait();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if !registered {
|
||||
unregister_arms(&arms[..i], me, epoch);
|
||||
crate::scheduler::retire_wait();
|
||||
return Ok(Some(i));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The [`select_timeout`] timer target: stateless, because precise wakes
|
||||
/// make classification a pure function of channel state. The entry is
|
||||
/// stamped with the select's epoch; if an arm already won, this unpark dies
|
||||
/// at the word's epoch CAS (the no-cancellation convention in `timer.rs`).
|
||||
struct SelectTimeout;
|
||||
impl crate::timer::TimerTarget for SelectTimeout {
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
crate::scheduler::unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`select`] with a deadline: returns `Some(index)` like `select`, or
|
||||
/// `None` once `timeout` elapses with no arm ready.
|
||||
///
|
||||
/// All of `select`'s semantics carry over (priority order, closed arms
|
||||
/// permanently ready, no fairness promise). The timeout is one more stamped
|
||||
/// waker on the same wait epoch — nothing is registered in any arm for it,
|
||||
/// so there is nothing to cancel or leak: an arm winning leaves the timer
|
||||
/// entry to expire as a stale-epoch no-op; the timer winning leaves the
|
||||
/// arms' registrations to self-clean exactly as a `select` loser's would.
|
||||
///
|
||||
/// The wake is classified from state alone (wakes are precise): some arm
|
||||
/// ready → `Some` of the first, in priority order; none ready → the timer
|
||||
/// was the only remaining stamped waker → `None`. A message that races the
|
||||
/// deadline resolves message-first, as `recv_timeout` does.
|
||||
///
|
||||
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-due
|
||||
/// timer is drained, then reports `None` unless an arm was already ready.
|
||||
///
|
||||
/// Panics if `arms` is empty, when called outside an actor, or if an fd
|
||||
/// arm fails to register (see [`try_select_timeout`] for the fallible
|
||||
/// form; channel-only selects cannot fail).
|
||||
pub fn select_timeout(
|
||||
arms: &[&dyn Selectable],
|
||||
timeout: std::time::Duration,
|
||||
) -> Option<usize> {
|
||||
try_select_timeout(arms, timeout)
|
||||
.expect("select_timeout(): fd arm failed to register (use try_select_timeout)")
|
||||
}
|
||||
|
||||
/// [`select_timeout`], fallible: `Err` when an arm fails to register
|
||||
/// (only fd arms can). On `Err` the wait is fully retired and no
|
||||
/// registration — arm-side or kernel-side — is left behind.
|
||||
pub fn try_select_timeout(
|
||||
arms: &[&dyn Selectable],
|
||||
timeout: std::time::Duration,
|
||||
) -> std::io::Result<Option<usize>> {
|
||||
assert!(!arms.is_empty(), "select_timeout() on an empty arm list");
|
||||
let me = crate::actor::current_pid().expect("select_timeout() called outside an actor");
|
||||
let epoch = crate::scheduler::begin_wait();
|
||||
if let Some(i) = register_arms(me, epoch, arms)? {
|
||||
return Ok(Some(i)); // ready now: the timer was never armed
|
||||
}
|
||||
|
||||
// Arm the timer after the registration pass, outside every Channel
|
||||
// lock (insert takes the timers lock).
|
||||
let deadline = crate::timer::deadline_from_now(timeout);
|
||||
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = std::sync::Arc::new(SelectTimeout);
|
||||
crate::scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||
|
||||
// Same eager-cleanup story as `try_select`: the timer arm needs none
|
||||
// (stateless, stale entries die at the epoch CAS), channel arms need
|
||||
// none, fd arms do — and a timer win in particular leaves every fd
|
||||
// arm's registration behind, which without this pass would poison
|
||||
// those fds until a kernel event happened to fire.
|
||||
let eager = arms.iter().any(|a| a.sel_eager_cleanup());
|
||||
let mut guard = UnregisterGuard { arms, me, epoch, armed: eager };
|
||||
|
||||
crate::scheduler::park_current();
|
||||
|
||||
if eager {
|
||||
unregister_arms(arms, me, epoch);
|
||||
}
|
||||
guard.armed = false;
|
||||
drop(guard);
|
||||
|
||||
// Woken precisely: an arm (ready below) or the timer (nothing ready).
|
||||
Ok(arms.iter().position(|arm| arm.sel_ready()))
|
||||
}
|
||||
|
||||
+111
-11
@@ -1,14 +1,114 @@
|
||||
//! Cooperative context switching — public façade over the arch backend.
|
||||
//! Cooperative context switching, x86-64.
|
||||
//!
|
||||
//! The real implementation lives in `crate::arch`, selected per `target_arch`.
|
||||
//! This module re-exports the stable surface (`init_actor_stack`, the two
|
||||
//! `switch_to_*` shims, and the `*_actor_sp` accessors) so the scheduler,
|
||||
//! `preempt`, and the `tests/context.rs` integration tests keep importing
|
||||
//! `smarm::context::{...}` unchanged regardless of which ISA is built.
|
||||
//! Two naked-asm functions move execution between a scheduler thread and an
|
||||
//! actor running on its own mmap'd stack. The compiler cannot do this; the
|
||||
//! whole point of `#[unsafe(naked)]` is that we control every instruction.
|
||||
//!
|
||||
//! See `crate::arch` for the contract every backend implements, and
|
||||
//! `crate::arch::{x86_64, aarch64}` for the per-ISA register choreography.
|
||||
//! `SCHEDULER_SP` and `ACTOR_SP` are thread-locals holding each side's saved
|
||||
//! stack pointer. `init_actor_stack` builds the initial stack so that the
|
||||
//! first `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
|
||||
//! (the x86-64 ABI requirement at function entry).
|
||||
|
||||
pub use crate::arch::{
|
||||
get_actor_sp, init_actor_stack, set_actor_sp, switch_to_actor, switch_to_scheduler,
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
|
||||
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
|
||||
fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
|
||||
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
|
||||
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial stack layout
|
||||
//
|
||||
// We start from aligned_top = top & ~15, then bias by 8 and push seven 8-byte
|
||||
// slots (downward). The first `switch_to_actor` pops r15..rbx and `ret`s —
|
||||
// landing in `entry` with rsp % 16 == 8 (the x86-64 ABI state at the point
|
||||
// just after a `call`, which is what `entry` is compiled to expect).
|
||||
//
|
||||
// Layout (high → low), relative to aligned_top = top & ~15. Note the entry
|
||||
// slot is at aligned_top - 16, NOT aligned_top - 8: the function does
|
||||
// `(top & ~15) - 8` and *then* a `-= 8` before the first write, so the first
|
||||
// stored word lands at aligned_top - 16. Verified by single-stepping the
|
||||
// `ret` under llmdbg: entry sits at an address with %16 == 0, so the post-ret
|
||||
// rsp is %16 == 8.
|
||||
//
|
||||
// aligned_top - 8 : (unused padding; keeps entry's slot %16 == 0)
|
||||
// aligned_top - 16 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
|
||||
// aligned_top - 24 : rbx = 0
|
||||
// aligned_top - 32 : rbp = 0
|
||||
// aligned_top - 40 : r12 = 0
|
||||
// aligned_top - 48 : r13 = 0
|
||||
// aligned_top - 56 : r14 = 0
|
||||
// aligned_top - 64 : r15 = 0 ← initial rsp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
|
||||
unsafe {
|
||||
let mut sp = (top as usize & !15) - 8;
|
||||
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
|
||||
sp -= 8; (sp as *mut usize).write(0); // rbx
|
||||
sp -= 8; (sp as *mut usize).write(0); // rbp
|
||||
sp -= 8; (sp as *mut usize).write(0); // r12
|
||||
sp -= 8; (sp as *mut usize).write(0); // r13
|
||||
sp -= 8; (sp as *mut usize).write(0); // r14
|
||||
sp -= 8; (sp as *mut usize).write(0); // r15
|
||||
sp
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context switch shims
|
||||
//
|
||||
// Each shim:
|
||||
// 1. Pushes the six callee-saved integer registers.
|
||||
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
|
||||
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
|
||||
// 4. Moves that into rsp.
|
||||
// 5. Pops the six registers and rets.
|
||||
//
|
||||
// XMM registers are NOT saved here. We rely on every yield happening through
|
||||
// a Rust call site, which means the compiler has spilled any live XMM state
|
||||
// to the stack before we get here. (This is the same argument the compiler
|
||||
// uses internally — callee-saved regs are what survive a `call`, and the
|
||||
// SysV AMD64 ABI says XMM0–15 are all caller-saved.) If we ever yield from
|
||||
// a place that isn't a Rust call boundary, this assumption breaks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[unsafe(naked)]
|
||||
unsafe extern "C" fn switch_to_actor_asm() {
|
||||
core::arch::naked_asm!(
|
||||
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
||||
"mov rdi, rsp",
|
||||
"call {set_sched_sp}",
|
||||
"call {get_actor_sp}",
|
||||
"mov rsp, rax",
|
||||
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
||||
"ret",
|
||||
set_sched_sp = sym set_scheduler_sp,
|
||||
get_actor_sp = sym get_actor_sp,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
||||
pub unsafe fn switch_to_actor() {
|
||||
unsafe { switch_to_actor_asm() };
|
||||
}
|
||||
|
||||
#[unsafe(naked)]
|
||||
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||
core::arch::naked_asm!(
|
||||
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
||||
"mov rdi, rsp",
|
||||
"call {set_actor_sp}",
|
||||
"call {get_sched_sp}",
|
||||
"mov rsp, rax",
|
||||
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
||||
"ret",
|
||||
set_actor_sp = sym set_actor_sp,
|
||||
get_sched_sp = sym get_scheduler_sp,
|
||||
);
|
||||
}
|
||||
|
||||
+810
-38
@@ -6,14 +6,23 @@
|
||||
//! [`call`](ServerRef::call) (synchronous, returns a reply) or
|
||||
//! [`cast`](ServerRef::cast) (fire-and-forget).
|
||||
//!
|
||||
//! ## Why a single inbox
|
||||
//! ## One inbox, many arms
|
||||
//!
|
||||
//! smarm has no `select` and no unified per-process mailbox (see the runtime
|
||||
//! gotchas in `task.md`), so a server cannot wait on a "call channel" and a
|
||||
//! "cast channel" simultaneously. Instead every message — call or cast —
|
||||
//! travels the *same* inbox channel as an [`Envelope`], and the server loop
|
||||
//! dispatches by variant. A `call` carries a freshly made one-shot reply
|
||||
//! channel; the server sends the reply straight back down it.
|
||||
//! Every call and cast travels the *same* inbox channel as an [`Envelope`]
|
||||
//! (calls and casts are ordered relative to each other, like Erlang); the
|
||||
//! server loop dispatches by variant. A `call` carries a freshly made
|
||||
//! one-shot reply channel; the server sends the reply straight back down it.
|
||||
//!
|
||||
//! Out-of-band messages ride *separate* channels composed at the wait via
|
||||
//! [`select`](crate::channel::select): info channels handed over at start
|
||||
//! ([`ServerBuilder::with_info`]) are dispatched to
|
||||
//! [`handle_info`](GenServer::handle_info). Arm priority is
|
||||
//! **infos before inbox**, in declaration order — a hot inbox cannot starve
|
||||
//! an out-of-band message; conversely a hot info channel CAN starve the
|
||||
//! inbox, deliberately (system-message semantics). An info channel whose
|
||||
//! senders are all gone is silently dropped from the arm set (per the
|
||||
//! closed-arm-is-ready-forever rule on `select`); a closed *inbox* still
|
||||
//! means graceful shutdown.
|
||||
//!
|
||||
//! ## Server death
|
||||
//!
|
||||
@@ -34,17 +43,41 @@
|
||||
//! one. Keep it cheap and non-blocking: it may run mid-unwind, and a panic
|
||||
//! inside it during an unwind aborts the process (a double panic).
|
||||
//!
|
||||
//! ## Time (timers and idle)
|
||||
//!
|
||||
//! A server arms timers through a [`TimerHandle`] cloned from the [`ServerCtx`]
|
||||
//! in `init` and stored on the state — the same shape as [`Watcher`] for
|
||||
//! monitors. [`arm_after`](TimerHandle::arm_after) is a one-shot,
|
||||
//! [`tick_every`](TimerHandle::tick_every) a periodic; both fire into
|
||||
//! [`handle_timer`](GenServer::handle_timer) at *system priority* (above infos
|
||||
//! and the inbox, by arm position), and [`cancel`](TimerHandle::cancel) carries
|
||||
//! the substrate's race signal. Separately, [`ServerCtx::idle_after`] sets a
|
||||
//! receive-timeout window: quiet for the whole window fires
|
||||
//! [`handle_idle`](GenServer::handle_idle).
|
||||
//!
|
||||
//! Two unrelated things share the word *timeout*: the server-side **idle /
|
||||
//! receive timeout** above, and the client-side **call deadline**
|
||||
//! ([`ServerRef::call_timeout`]) — how long a caller waits for a reply. They sit
|
||||
//! on different axes and never interact (RFC 015 §7).
|
||||
//!
|
||||
//! ## Not here (yet)
|
||||
//!
|
||||
//! No `handle_info` and no call timeout. Both need machinery smarm currently
|
||||
//! defers: a call timeout needs a per-`recv` deadline (cf. the deferred
|
||||
//! `Signal::Timeout`), and `handle_info` needs the cross-channel mailbox merge
|
||||
//! that selective receive deliberately left unmade. They are slated to land
|
||||
//! together with timeouts.
|
||||
//! No dynamic info subscription: the info-channel set is fixed at start.
|
||||
//! Revisit against a real consumer. (Monitor `Down` forwarding is dynamic —
|
||||
//! see `handle_down` — because monitors are inherently created at runtime.)
|
||||
//! The idle window is likewise set once, in `init` (RFC 015 §4.4).
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::channel::{channel, select, select_timeout, Receiver, RecvTimeoutError, Selectable, Sender};
|
||||
use crate::monitor::{demonitor, monitor, Down, Monitor};
|
||||
use crate::pid::Pid;
|
||||
use crate::scheduler::{spawn, spawn_under};
|
||||
use crate::registry::{register_with, resolve_named_sender, RegisterError};
|
||||
use crate::scheduler::{cancel_timer, request_stop, send_after_to, spawn, spawn_under};
|
||||
use crate::timer::TimerId;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Behaviour for a gen_server: a state value plus call/cast handlers.
|
||||
///
|
||||
@@ -58,9 +91,30 @@ pub trait GenServer: Send + 'static {
|
||||
type Reply: Send + 'static;
|
||||
/// Asynchronous request type (carried by [`ServerRef::cast`]).
|
||||
type Cast: Send + 'static;
|
||||
/// Out-of-band message type, delivered to [`handle_info`](Self::handle_info)
|
||||
/// from the info channels registered at start
|
||||
/// ([`ServerBuilder::with_info`]). Servers with several out-of-band
|
||||
/// sources enum them up into one `Info`. Use `()` if unused.
|
||||
type Info: Send + 'static;
|
||||
/// The server's own scheduled-timer payload, delivered to
|
||||
/// [`handle_timer`](Self::handle_timer) when a timer armed via the loop's
|
||||
/// [`TimerHandle`] fires (RFC 015). Kept distinct from
|
||||
/// [`Info`](Self::Info) — `Info` is *external* out-of-band traffic, `Timer`
|
||||
/// is the server's *own* fires — so the system/userspace split stays
|
||||
/// legible and a tagged timer round-trips
|
||||
/// (`arm_after(d, Tk::Retry(n))` → `handle_timer(Tk::Retry(n))`). Use `()`
|
||||
/// if unused, exactly as `Info` does.
|
||||
type Timer: Send + 'static;
|
||||
|
||||
/// Runs once inside the server actor before any message is handled.
|
||||
fn init(&mut self) {}
|
||||
/// Runs once inside the server actor before any message is handled. The
|
||||
/// [`ServerCtx`] is the loop's one runtime hook: clone its [`Watcher`]
|
||||
/// into the state here to be able to [`watch`](Watcher::watch) monitors
|
||||
/// from any later handler.
|
||||
fn init(&mut self, _ctx: &ServerCtx<Self>)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
}
|
||||
|
||||
/// Handle a synchronous call and produce the reply sent back to the caller.
|
||||
fn handle_call(&mut self, request: Self::Call) -> Self::Reply;
|
||||
@@ -68,6 +122,29 @@ pub trait GenServer: Send + 'static {
|
||||
/// Handle a fire-and-forget cast.
|
||||
fn handle_cast(&mut self, request: Self::Cast);
|
||||
|
||||
/// Handle an out-of-band message from one of the info channels. Default:
|
||||
/// drop it.
|
||||
fn handle_info(&mut self, _info: Self::Info) {}
|
||||
|
||||
/// Handle a [`Down`] from a monitor handed to the loop via
|
||||
/// [`Watcher::watch`]. Default: drop it.
|
||||
fn handle_down(&mut self, _down: Down) {}
|
||||
|
||||
/// Handle a fired timer armed through the loop's [`TimerHandle`]
|
||||
/// ([`arm_after`](TimerHandle::arm_after) /
|
||||
/// [`tick_every`](TimerHandle::tick_every)). Timer fires re-enter at system
|
||||
/// priority — above infos and the inbox — so a heartbeat cannot be starved
|
||||
/// by userspace traffic. Default: drop it (RFC 015 §6).
|
||||
fn handle_timer(&mut self, _msg: Self::Timer) {}
|
||||
|
||||
/// Handle a receive/idle timeout: fired when the loop has waited a full
|
||||
/// idle window (set once via [`ServerCtx::idle_after`]) with no message of
|
||||
/// any kind dispatched. The window resets on every dispatched message and
|
||||
/// re-arms after this fires (a steady idle detector); a server wanting
|
||||
/// one-shot idle-shutdown simply requests its own stop here. Default: no-op
|
||||
/// (RFC 015 §4.4, §6).
|
||||
fn handle_idle(&mut self) {}
|
||||
|
||||
/// Runs as the server actor exits, on any exit path (see module docs).
|
||||
fn terminate(&mut self) {}
|
||||
}
|
||||
@@ -100,6 +177,19 @@ pub enum CallError {
|
||||
ServerDown,
|
||||
}
|
||||
|
||||
/// Returned by [`ServerRef::call_timeout`].
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum CallTimeoutError {
|
||||
/// The server was already gone, or died before replying.
|
||||
ServerDown,
|
||||
/// The deadline passed before a reply arrived. The request stays in the
|
||||
/// server's inbox: it will still be *handled*, but the reply is discarded
|
||||
/// (the abandoned reply channel's receiver is dropped, so the server's
|
||||
/// reply send fails harmlessly). Erlang behaves the same way; design
|
||||
/// idempotent calls accordingly.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Returned by [`ServerRef::cast`] when the server is no longer reachable.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum CastError {
|
||||
@@ -124,6 +214,40 @@ impl<G: GenServer> ServerRef<G> {
|
||||
reply_rx.recv().map_err(|_| CallError::ServerDown)
|
||||
}
|
||||
|
||||
/// Bounded synchronous request-reply: like [`call`](Self::call), but
|
||||
/// gives up after `timeout`, returning [`CallTimeoutError::Timeout`].
|
||||
///
|
||||
/// This `timeout` is a **client-side call deadline** — how long *this caller*
|
||||
/// waits for a reply — and is wholly separate from the server-side idle /
|
||||
/// receive timeout ([`ServerCtx::idle_after`] →
|
||||
/// [`GenServer::handle_idle`]), which measures quiet on the *server's* inbox.
|
||||
/// Same word ("timeout"), two different axes (RFC 015 §7); neither touches
|
||||
/// the other.
|
||||
///
|
||||
/// The roadmap sketched this as monitor + wait-reply-or-Down + demonitor;
|
||||
/// that machinery is unnecessary here because server death is already
|
||||
/// observable on the reply channel itself — the reply sender is dropped
|
||||
/// as the server's stack unwinds, closing the channel and waking the
|
||||
/// parked caller (see the module docs). So a bounded call is exactly
|
||||
/// [`Receiver::recv_timeout`] on the reply channel: `Ok` on a reply,
|
||||
/// `Disconnected` → [`CallTimeoutError::ServerDown`], `Timeout` →
|
||||
/// [`CallTimeoutError::Timeout`]. Nothing is registered, so nothing can
|
||||
/// leak on the timeout path by construction.
|
||||
pub fn call_timeout(
|
||||
&self,
|
||||
request: G::Call,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<G::Reply, CallTimeoutError> {
|
||||
let (reply_tx, reply_rx) = channel::<G::Reply>();
|
||||
self.tx
|
||||
.send(Envelope::Call(request, reply_tx))
|
||||
.map_err(|_| CallTimeoutError::ServerDown)?;
|
||||
reply_rx.recv_timeout(timeout).map_err(|e| match e {
|
||||
crate::channel::RecvTimeoutError::Disconnected => CallTimeoutError::ServerDown,
|
||||
crate::channel::RecvTimeoutError::Timeout => CallTimeoutError::Timeout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fire-and-forget request. Returns once the message is enqueued; does not
|
||||
/// wait for the server to handle it. [`CastError::ServerDown`] if the inbox
|
||||
/// is already closed.
|
||||
@@ -132,51 +256,699 @@ impl<G: GenServer> ServerRef<G> {
|
||||
.send(Envelope::Cast(request))
|
||||
.map_err(|_| CastError::ServerDown)
|
||||
}
|
||||
|
||||
/// Ask the server to terminate and block until it has — the `sys`-style
|
||||
/// stop (cf. Erlang's `gen_server:stop/1`). Sends a cooperative stop to the
|
||||
/// server actor and waits for its `Down`, so [`GenServer::terminate`] has
|
||||
/// run by the time this returns (it fires from the loop's drop guard on the
|
||||
/// stop unwind). Returns immediately if the server was already gone.
|
||||
///
|
||||
/// This is the explicit teardown for a server pinned alive by a registered
|
||||
/// [`ServerName`] (whose stored sender means dropping every external
|
||||
/// [`ServerRef`] no longer closes the inbox). Best-effort like all
|
||||
/// cooperative cancellation: a server wedged in a tight loop with no
|
||||
/// observation point cannot be stopped. Panics if called outside
|
||||
/// `Runtime::run()`.
|
||||
pub fn shutdown(&self) {
|
||||
let mon = monitor(self.pid);
|
||||
request_stop(self.pid);
|
||||
// The Down lands when the server finalizes; an already-dead target makes
|
||||
// `monitor` deliver NoProc immediately, so this never blocks forever.
|
||||
let _ = mon.rx.recv();
|
||||
demonitor(&mon);
|
||||
}
|
||||
}
|
||||
|
||||
/// The server loop's internal *system intake* channel (RFC 015 §4.5). Control
|
||||
/// (monitor handoff) and armed-timer fires are structurally the same — both are
|
||||
/// loop-internal sources selected above the inbox — so they fold into one
|
||||
/// channel, dispatched by variant. The arm stays open while any [`Watcher`] or
|
||||
/// [`TimerHandle`] clone (or an in-flight fire thunk) still holds a sender.
|
||||
enum Sys<G: GenServer> {
|
||||
/// A monitor handed in via [`Watcher::watch`]; pushed onto the loop's
|
||||
/// monitor set.
|
||||
Watch(Monitor),
|
||||
/// A fired one-shot timer carrying its loop-local id (so the loop can retire
|
||||
/// it from the live set) and the server's payload, dispatched to
|
||||
/// [`GenServer::handle_timer`].
|
||||
Timer(crate::timer::TimerId, G::Timer),
|
||||
/// A fired periodic tick carrying its stable local id. The loop resolves the
|
||||
/// payload from the registry's factory, dispatches it to
|
||||
/// [`GenServer::handle_timer`], and re-arms the next tick (RFC 015 §4.3).
|
||||
Tick(crate::timer::TimerId),
|
||||
}
|
||||
|
||||
/// The server loop's runtime hook, passed to [`GenServer::init`]. Hands out the
|
||||
/// loop's two clonable intake handles — the [`Watcher`] (monitors) and the
|
||||
/// [`TimerHandle`] (timers) — plus the one-shot idle-window setter. Opaque, so
|
||||
/// fields can grow without breaking.
|
||||
pub struct ServerCtx<G: GenServer> {
|
||||
sys_tx: Sender<Sys<G>>,
|
||||
reg: Arc<Mutex<TimerReg<G>>>,
|
||||
/// The idle/receive-timeout window, set once via [`idle_after`](Self::idle_after)
|
||||
/// during `init` and read by the loop after `init` returns. Interior-mutable
|
||||
/// because `init` holds only `&ctx`; not `Send`, but `ServerCtx` is only ever
|
||||
/// borrowed on the actor's own stack during `init`, never sent.
|
||||
idle: Cell<Option<Duration>>,
|
||||
}
|
||||
|
||||
impl<G: GenServer> ServerCtx<G> {
|
||||
/// A clonable handle to the loop's monitor intake. Store it in the state
|
||||
/// during `init` to watch monitors from later handlers.
|
||||
pub fn watcher(&self) -> Watcher<G> {
|
||||
Watcher { tx: self.sys_tx.clone() }
|
||||
}
|
||||
|
||||
/// Shorthand for `ctx.watcher().watch(m)` when watching during `init`.
|
||||
pub fn watch(&self, m: Monitor) {
|
||||
self.watcher().watch(m)
|
||||
}
|
||||
|
||||
/// A clonable handle to the loop's timer intake (the [`Watcher`] pattern,
|
||||
/// for time). Store it on the state during `init` to
|
||||
/// [`arm_after`](TimerHandle::arm_after) /
|
||||
/// [`tick_every`](TimerHandle::tick_every) /
|
||||
/// [`cancel`](TimerHandle::cancel) from any later handler.
|
||||
pub fn timer(&self) -> TimerHandle<G> {
|
||||
TimerHandle { sys_tx: self.sys_tx.clone(), reg: self.reg.clone() }
|
||||
}
|
||||
|
||||
/// Set the idle / receive-timeout window: if no message of any kind is
|
||||
/// dispatched for `after`, the loop fires [`GenServer::handle_idle`]
|
||||
/// (RFC 015 §4.4). Set once, in `init`; the window is loop-owned and fixed
|
||||
/// (dynamic per-message reconfiguration is a non-goal). The window resets on
|
||||
/// every dispatched message and re-arms after `handle_idle` (a steady idle
|
||||
/// detector); a server wanting one-shot idle-shutdown requests its own stop
|
||||
/// in `handle_idle`.
|
||||
///
|
||||
/// Distinct from [`ServerRef::call_timeout`], which is a *client-side* call
|
||||
/// deadline — same word, different axis (§7).
|
||||
pub fn idle_after(&self, after: Duration) {
|
||||
self.idle.set(Some(after));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-server timer bookkeeping, shared between the loop and every
|
||||
/// [`TimerHandle`] clone. A gen_server actor is single-threaded — handle calls
|
||||
/// (made from inside handlers) and the loop never run concurrently — so this
|
||||
/// `Mutex` is always uncontended; it is `Arc<Mutex>` rather than `Rc<RefCell>`
|
||||
/// only because the handle is stored on `self` and `GenServer: Send` forces the
|
||||
/// handle (hence its shared state) to be `Send`.
|
||||
struct TimerReg<G: GenServer> {
|
||||
/// Monotonic minter for loop-local [`TimerId`]s — the ids handed to users,
|
||||
/// kept distinct from the substrate `seq` (which changes on every periodic
|
||||
/// re-arm). A local id is resolved only through this registry, never passed
|
||||
/// to [`cancel_timer`], so the two id roles never mix.
|
||||
next_local: u64,
|
||||
/// Live one-shot timers: local id → current substrate id. Present from
|
||||
/// [`arm_after`](TimerHandle::arm_after) until the loop retires it on fire
|
||||
/// or [`cancel`](TimerHandle::cancel) removes it.
|
||||
oneshots: HashMap<TimerId, crate::timer::TimerId>,
|
||||
/// Live periodic timers: stable local id → its bookkeeping. The stable id
|
||||
/// is minted once by [`tick_every`](TimerHandle::tick_every) and survives
|
||||
/// every re-arm; the entry lives until [`cancel`](TimerHandle::cancel)
|
||||
/// removes it (steady re-arm never self-removes).
|
||||
periodics: HashMap<TimerId, Periodic<G>>,
|
||||
/// A `Sys` sender held *only while ≥1 periodic is live*, used by the loop to
|
||||
/// re-arm the next tick. `Some` exactly when `periodics` is non-empty: this
|
||||
/// keeps the system arm open for a server whose only system use is a
|
||||
/// periodic, while leaving a non-timer server's arm to auto-close (the
|
||||
/// `unused_ctx` behaviour) since this stays `None` for it.
|
||||
rearm_tx: Option<Sender<Sys<G>>>,
|
||||
}
|
||||
|
||||
/// One periodic timer's loop-side bookkeeping.
|
||||
struct Periodic<G: GenServer> {
|
||||
/// Re-arm interval; each fire schedules the next at `now + every` (§4.3).
|
||||
every: Duration,
|
||||
/// The currently-armed substrate timer for this periodic (changes on every
|
||||
/// re-arm); cancelled by [`cancel`](TimerHandle::cancel).
|
||||
live: crate::timer::TimerId,
|
||||
/// Produces a fresh payload for each tick. Built in `tick_every` as
|
||||
/// `move || msg.clone()` — so the `Clone` bound lives there, on the one
|
||||
/// method that needs it, and never leaks onto `type Timer` or the loop
|
||||
/// (which is monomorphised per server and only *calls* this box).
|
||||
make: Box<dyn FnMut() -> G::Timer + Send>,
|
||||
}
|
||||
|
||||
// Manual Default: deriving would demand `G: Default`, but a fresh registry is
|
||||
// independent of the server type.
|
||||
impl<G: GenServer> Default for TimerReg<G> {
|
||||
fn default() -> Self {
|
||||
TimerReg {
|
||||
next_local: 0,
|
||||
oneshots: HashMap::new(),
|
||||
periodics: HashMap::new(),
|
||||
rearm_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<G: GenServer> TimerReg<G> {
|
||||
/// Mint the next loop-local id.
|
||||
fn mint(&mut self) -> TimerId {
|
||||
let id = TimerId::from_raw(self.next_local);
|
||||
self.next_local = self.next_local.wrapping_add(1);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms, cancels, and (chunk 4) periodically ticks server timers, the time-side
|
||||
/// twin of [`Watcher`] (RFC 015 §4.3). Handed out by [`ServerCtx::timer`] in
|
||||
/// `init` and stored on the state; later handlers arm timers without any change
|
||||
/// to their signatures. Clonable; the arm stays open while any clone (or an
|
||||
/// in-flight fire) lives.
|
||||
pub struct TimerHandle<G: GenServer> {
|
||||
sys_tx: Sender<Sys<G>>,
|
||||
reg: Arc<Mutex<TimerReg<G>>>,
|
||||
}
|
||||
|
||||
// Manual Clone for the same reason as `Watcher`: no `G: Clone` needed.
|
||||
impl<G: GenServer> Clone for TimerHandle<G> {
|
||||
fn clone(&self) -> Self {
|
||||
TimerHandle { sys_tx: self.sys_tx.clone(), reg: self.reg.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<G: GenServer> TimerHandle<G> {
|
||||
/// Arm a one-shot timer: deliver `msg` to [`GenServer::handle_timer`] after
|
||||
/// `after`, unless [`cancel`](Self::cancel)led first. Returns a loop-local
|
||||
/// [`TimerId`] for cancellation. A thin wrapper over the `send_after`
|
||||
/// substrate that lands the fire on the loop's own system arm (above infos
|
||||
/// and the inbox), not in the inbox.
|
||||
pub fn arm_after(&self, after: Duration, msg: G::Timer) -> TimerId {
|
||||
let mut reg = self.reg.lock().unwrap();
|
||||
let local = reg.mint();
|
||||
// The fire thunk carries the local id so the loop can retire the entry
|
||||
// on dispatch; `msg` is moved in (no `Clone` needed for one-shots).
|
||||
let sub = send_after_to(after, self.sys_tx.clone(), Sys::Timer(local, msg));
|
||||
reg.oneshots.insert(local, sub);
|
||||
local
|
||||
}
|
||||
|
||||
/// Arm a periodic timer: deliver a fresh `msg` to
|
||||
/// [`GenServer::handle_timer`] every `every`, until
|
||||
/// [`cancel`](Self::cancel)led. Loop-managed sugar over the one-shot
|
||||
/// substrate (RFC 015 §4.3): the substrate stays one-shot; the loop re-arms
|
||||
/// at `now + every` after each fire and exposes one stable [`TimerId`], so a
|
||||
/// single `cancel` stops the re-arm *and* the pending instance.
|
||||
///
|
||||
/// Requires `Self::Timer: Clone` — a periodic re-delivers the same logical
|
||||
/// message each tick, so the loop needs a fresh copy per period. The bound
|
||||
/// sits on this method alone; one-shot [`arm_after`](Self::arm_after) and
|
||||
/// the `type Timer` declaration stay unconstrained.
|
||||
pub fn tick_every(&self, every: Duration, msg: G::Timer) -> TimerId
|
||||
where
|
||||
G::Timer: Clone,
|
||||
{
|
||||
let mut reg = self.reg.lock().unwrap();
|
||||
let local = reg.mint();
|
||||
// A live periodic must keep the system arm open so the next tick can be
|
||||
// re-armed; the first periodic installs the loop's re-arm sender.
|
||||
if reg.periodics.is_empty() {
|
||||
reg.rearm_tx = Some(self.sys_tx.clone());
|
||||
}
|
||||
let make: Box<dyn FnMut() -> G::Timer + Send> = Box::new(move || msg.clone());
|
||||
// First instance fires after `every`; the payload is produced loop-side
|
||||
// from `make` on fire, so the tick carries only the stable id.
|
||||
let sub = send_after_to(every, self.sys_tx.clone(), Sys::Tick(local));
|
||||
reg.periodics.insert(local, Periodic { every, live: sub, make });
|
||||
local
|
||||
}
|
||||
|
||||
/// Cancel an armed timer (one-shot or periodic). Returns the substrate's
|
||||
/// race signal — `true` if the cancel beat the (pending) fire, `false` if
|
||||
/// it had already fired / been cancelled / is unknown. For a periodic this
|
||||
/// also stops the re-arm: the entry is dropped, so the loop will not
|
||||
/// schedule another tick even if the pending one already escaped onto the
|
||||
/// channel (that in-flight tick is discarded on dispatch).
|
||||
pub fn cancel(&self, id: TimerId) -> bool {
|
||||
let mut reg = self.reg.lock().unwrap();
|
||||
if let Some(sub) = reg.oneshots.remove(&id) {
|
||||
return cancel_timer(sub);
|
||||
}
|
||||
if let Some(p) = reg.periodics.remove(&id) {
|
||||
let beat = cancel_timer(p.live);
|
||||
if reg.periodics.is_empty() {
|
||||
reg.rearm_tx = None;
|
||||
}
|
||||
return beat;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands [`Monitor`]s to a server loop; their [`Down`]s are dispatched to
|
||||
/// [`GenServer::handle_down`]. Down arms outrank every other arm (a death
|
||||
/// notice cannot be starved), and a delivered `Down` retires its arm —
|
||||
/// monitors are one-shot.
|
||||
pub struct Watcher<G: GenServer> {
|
||||
tx: Sender<Sys<G>>,
|
||||
}
|
||||
|
||||
// Manual Clone: deriving would demand `G: Clone`, but the handle is clonable
|
||||
// regardless of the server type (it clones only the inner sender).
|
||||
impl<G: GenServer> Clone for Watcher<G> {
|
||||
fn clone(&self) -> Self {
|
||||
Watcher { tx: self.tx.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<G: GenServer> Watcher<G> {
|
||||
/// Transfer `m` to the server loop. If the server is already gone the
|
||||
/// monitor is silently dropped (its queued `Down`, if any, with it) —
|
||||
/// there is no loop left to care.
|
||||
pub fn watch(&self, m: Monitor) {
|
||||
let _ = self.tx.send(Sys::Watch(m));
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure-then-start construction for servers that need more than a bare
|
||||
/// [`start`]: info channels, a supervisor. Consumed by [`start`](Self::start).
|
||||
///
|
||||
/// ```ignore
|
||||
/// let server = ServerBuilder::new(state)
|
||||
/// .with_info(events_rx)
|
||||
/// .under(supervisor_pid)
|
||||
/// .start();
|
||||
/// ```
|
||||
pub struct ServerBuilder<G: GenServer> {
|
||||
state: G,
|
||||
infos: Vec<Receiver<G::Info>>,
|
||||
supervisor: Option<Pid>,
|
||||
}
|
||||
|
||||
impl<G: GenServer> ServerBuilder<G> {
|
||||
pub fn new(state: G) -> Self {
|
||||
ServerBuilder { state, infos: Vec::new(), supervisor: None }
|
||||
}
|
||||
|
||||
/// Add an out-of-band channel; messages arriving on it are dispatched to
|
||||
/// [`GenServer::handle_info`]. Repeatable; arm priority is call order
|
||||
/// (earlier = higher), and every info channel outranks the inbox.
|
||||
pub fn with_info(mut self, rx: Receiver<G::Info>) -> Self {
|
||||
self.infos.push(rx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Spawn the server under an explicit supervisor pid (via [`spawn_under`])
|
||||
/// so it slots into the supervision tree.
|
||||
pub fn under(mut self, supervisor: Pid) -> Self {
|
||||
self.supervisor = Some(supervisor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Spawn the server actor and hand back its [`ServerRef`]. The server's
|
||||
/// lifetime is governed by its refs, not by joining, so the backing join
|
||||
/// handle is dropped.
|
||||
pub fn start(self) -> ServerRef<G> {
|
||||
self.spawn_server()
|
||||
}
|
||||
|
||||
/// Bind the server to a durable [`ServerName`] as it starts. Switches to the
|
||||
/// fallible [`NamedServerBuilder::start`] (the name may already be held by a
|
||||
/// live server). Consumes the builder, carrying its `with_info` / `under`
|
||||
/// configuration through.
|
||||
pub fn named(self, name: ServerName<G>) -> NamedServerBuilder<G> {
|
||||
NamedServerBuilder { builder: self, name: name.as_str() }
|
||||
}
|
||||
|
||||
/// The shared spawn body behind [`start`](Self::start) and
|
||||
/// [`NamedServerBuilder::start`]: make the inbox, spawn the loop, return the
|
||||
/// ref. (The named path additionally publishes the inbox sender under the
|
||||
/// name before returning.)
|
||||
fn spawn_server(self) -> ServerRef<G> {
|
||||
let (tx, rx) = channel::<Envelope<G>>();
|
||||
let ServerBuilder { state, infos, supervisor } = self;
|
||||
let handle = match supervisor {
|
||||
Some(sup) => spawn_under(sup, move || server_loop::<G>(rx, state, infos)),
|
||||
None => spawn(move || server_loop::<G>(rx, state, infos)),
|
||||
};
|
||||
ServerRef { tx, pid: handle.pid() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable name typed by the *server* (RFC 014): a gen_server is multi-message
|
||||
/// (call / cast over one inbox), so it is addressed by `G` rather than by a
|
||||
/// single message type. By-name [`call`] / [`cast`] check the request against
|
||||
/// `G`'s `Call` / `Cast` / `Reply`. Declared as a constant and shared freely:
|
||||
///
|
||||
/// ```ignore
|
||||
/// const COUNTER: ServerName<Counter> = ServerName::new("counter");
|
||||
/// ```
|
||||
///
|
||||
/// Under the hood the server's inbox is published into the registry as a
|
||||
/// `Sender<Envelope<G>>` keyed by its message `TypeId` — the same channel store
|
||||
/// every other name uses — so naming needs no separate directory. `Envelope`
|
||||
/// stays private: only `ServerName<G>` opens that door.
|
||||
pub struct ServerName<G> {
|
||||
name: &'static str,
|
||||
_marker: PhantomData<fn() -> G>,
|
||||
}
|
||||
|
||||
impl<G> ServerName<G> {
|
||||
/// Bind a static string as a server 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<G> Copy for ServerName<G> {}
|
||||
impl<G> Clone for ServerName<G> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`ServerBuilder`] that will bind a [`ServerName`] as it starts. Reached via
|
||||
/// [`ServerBuilder::named`]; its [`start`](Self::start) is fallible because the
|
||||
/// name may already be held by a live server. `with_info` / `under` stay
|
||||
/// available so configuration can come before or after `named`.
|
||||
pub struct NamedServerBuilder<G: GenServer> {
|
||||
builder: ServerBuilder<G>,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl<G: GenServer> NamedServerBuilder<G> {
|
||||
/// Add an out-of-band info channel (see [`ServerBuilder::with_info`]).
|
||||
pub fn with_info(mut self, rx: Receiver<G::Info>) -> Self {
|
||||
self.builder = self.builder.with_info(rx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Spawn under an explicit supervisor (see [`ServerBuilder::under`]).
|
||||
pub fn under(mut self, supervisor: Pid) -> Self {
|
||||
self.builder = self.builder.under(supervisor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Spawn the server and bind its name in one step. Fallible: returns
|
||||
/// [`RegisterError::NameTaken`] if the name is already held by a different
|
||||
/// live server.
|
||||
///
|
||||
/// The inbox sender is published under the name **from the parent side,
|
||||
/// before this returns**, so a by-name `call` / `cast` resolves the instant
|
||||
/// `start()` returns — no race with the server body. On a name clash the
|
||||
/// just-spawned server is wound down (its only ref is dropped, closing the
|
||||
/// inbox), so a failed bind leaks no actor.
|
||||
pub fn start(self) -> Result<ServerRef<G>, RegisterError> {
|
||||
let NamedServerBuilder { builder, name } = self;
|
||||
let server = builder.spawn_server();
|
||||
match register_with::<Envelope<G>>(server.pid, name, server.tx.clone()) {
|
||||
Ok(()) => Ok(server),
|
||||
Err(e) => {
|
||||
drop(server); // inbox closes → loop exits gracefully
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a [`ServerName`] to a [`ServerRef`] when you want a handle to hold or
|
||||
/// pass on rather than resolve per call. Rebuilds the ref from the registry's
|
||||
/// stored inbox sender; `None` if no live server holds the name.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn whereis_server<G: GenServer>(name: ServerName<G>) -> Option<ServerRef<G>> {
|
||||
resolve_named_sender::<Envelope<G>>(name.as_str()).map(|(pid, tx)| ServerRef { tx, pid })
|
||||
}
|
||||
|
||||
/// Synchronous request-reply to the server currently registered under `name`,
|
||||
/// resolving through the registry on every call (so a server restarted under
|
||||
/// the same name is reached transparently). [`CallError::ServerDown`] if no live
|
||||
/// server holds the name, or if it dies before replying.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn call<G: GenServer>(name: ServerName<G>, request: G::Call) -> Result<G::Reply, CallError> {
|
||||
match whereis_server(name) {
|
||||
Some(server) => server.call(request),
|
||||
None => Err(CallError::ServerDown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget to the server registered under `name`, resolving per cast.
|
||||
/// [`CastError::ServerDown`] if no live server holds the name.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn cast<G: GenServer>(name: ServerName<G>, request: G::Cast) -> Result<(), CastError> {
|
||||
match whereis_server(name) {
|
||||
Some(server) => server.cast(request),
|
||||
None => Err(CastError::ServerDown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the server registered under `name` and block until it is down (see
|
||||
/// [`ServerRef::shutdown`]). A no-op if no live server holds the name.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn shutdown<G: GenServer>(name: ServerName<G>) {
|
||||
if let Some(server) = whereis_server(name) {
|
||||
server.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `state` as a server under the current actor (via [`spawn`]). Returns a
|
||||
/// [`ServerRef`]; the server's lifetime is governed by its refs, not by
|
||||
/// joining, so the backing join handle is dropped.
|
||||
/// [`ServerRef`]. Shorthand for `ServerBuilder::new(state).start()`.
|
||||
pub fn start<G: GenServer>(state: G) -> ServerRef<G> {
|
||||
let (tx, rx) = channel::<Envelope<G>>();
|
||||
let handle = spawn(move || server_loop::<G>(rx, state));
|
||||
ServerRef { tx, pid: handle.pid() }
|
||||
ServerBuilder::new(state).start()
|
||||
}
|
||||
|
||||
/// Like [`start`], but spawns the server under an explicit supervisor pid (via
|
||||
/// [`spawn_under`]) so it slots into the supervision tree.
|
||||
pub fn start_under<G: GenServer>(supervisor: Pid, state: G) -> ServerRef<G> {
|
||||
let (tx, rx) = channel::<Envelope<G>>();
|
||||
let handle = spawn_under(supervisor, move || server_loop::<G>(rx, state));
|
||||
ServerRef { tx, pid: handle.pid() }
|
||||
ServerBuilder::new(state).under(supervisor).start()
|
||||
}
|
||||
|
||||
fn server_loop<G: GenServer>(rx: Receiver<Envelope<G>>, state: G) {
|
||||
fn server_loop<G: GenServer>(
|
||||
rx: Receiver<Envelope<G>>,
|
||||
state: G,
|
||||
mut infos: Vec<Receiver<G::Info>>,
|
||||
) {
|
||||
// terminate() must run on every exit path (clean close, panic, stop), so it
|
||||
// lives in this guard's Drop rather than after the loop.
|
||||
struct Terminate<G: GenServer>(G);
|
||||
// lives in this guard's Drop rather than after the loop. The guard also owns
|
||||
// a registry handle so the *same* drop drains every live timer (RFC 015
|
||||
// §4.7): once the loop is gone no periodic can re-arm anyway, but cancelling
|
||||
// here keeps no armed substrate entry lingering past the server, and the
|
||||
// assert pins the invariant.
|
||||
struct Terminate<G: GenServer>(G, Arc<Mutex<TimerReg<G>>>);
|
||||
impl<G: GenServer> Drop for Terminate<G> {
|
||||
fn drop(&mut self) {
|
||||
{
|
||||
let mut reg = self.1.lock().unwrap();
|
||||
for (_, sub) in reg.oneshots.drain() {
|
||||
cancel_timer(sub);
|
||||
}
|
||||
for (_, p) in reg.periodics.drain() {
|
||||
cancel_timer(p.live);
|
||||
}
|
||||
reg.rearm_tx = None;
|
||||
// A fired-but-gone send already no-ops on the closed Sys channel;
|
||||
// this guards the armed-but-unfired re-arming case specifically.
|
||||
debug_assert!(
|
||||
reg.oneshots.is_empty() && reg.periodics.is_empty(),
|
||||
"an armed timer survived gen_server loop exit"
|
||||
);
|
||||
}
|
||||
self.0.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
let mut guard = Terminate(state);
|
||||
guard.0.init();
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Envelope::Call(request, reply_tx)) => {
|
||||
let reply = guard.0.handle_call(request);
|
||||
fn dispatch<G: GenServer>(state: &mut G, env: Envelope<G>) {
|
||||
match env {
|
||||
Envelope::Call(request, reply_tx) => {
|
||||
let reply = state.handle_call(request);
|
||||
// The caller may have gone away (e.g. cancelled while parked);
|
||||
// a failed reply send is not the server's problem.
|
||||
let _ = reply_tx.send(reply);
|
||||
}
|
||||
Ok(Envelope::Cast(request)) => guard.0.handle_cast(request),
|
||||
// All ServerRefs dropped → inbox closed → graceful shutdown.
|
||||
Err(_) => break,
|
||||
Envelope::Cast(request) => state.handle_cast(request),
|
||||
}
|
||||
// Observation point so a server whose inbox is never empty stays
|
||||
}
|
||||
|
||||
// Shared timer bookkeeping: the loop keeps one clone (to retire one-shots on
|
||||
// fire, re-arm periodics, and read the idle window); the guard holds another
|
||||
// to drain on exit; every TimerHandle the state stores holds more.
|
||||
let reg = Arc::new(Mutex::new(TimerReg::default()));
|
||||
let mut guard = Terminate(state, reg.clone());
|
||||
|
||||
// The system intake arm: Watchers feed Monitors and armed timers feed fires
|
||||
// to the loop through it. The ctx (and with it the loop's own sender) drops
|
||||
// right after init — a state that cloned no Watcher/TimerHandle closes the
|
||||
// arm, the first select observes the closure, and the loop falls back to the
|
||||
// plain-inbox park.
|
||||
let (sys_tx, sys_rx) = channel::<Sys<G>>();
|
||||
// Bind the ctx so the idle window set during init can be read back, then
|
||||
// drop it — that drops the loop's own Sys sender, so a state that cloned no
|
||||
// Watcher/TimerHandle lets the arm auto-close (the unused-ctx behaviour).
|
||||
let ctx = ServerCtx { sys_tx, reg: reg.clone(), idle: Cell::new(None) };
|
||||
guard.0.init(&ctx);
|
||||
let idle = ctx.idle.get();
|
||||
drop(ctx);
|
||||
|
||||
let mut monitors: Vec<Monitor> = Vec::new();
|
||||
let mut sys_open = true;
|
||||
// The receive-timeout deadline, live only when an idle window is set. Reset
|
||||
// to `now + idle` after any dispatched message and after handle_idle fires
|
||||
// (steady detector); `None` disables the timeout entirely.
|
||||
let mut idle_deadline: Option<Instant> = idle.map(|d| Instant::now() + d);
|
||||
let reset_idle = |dl: &mut Option<Instant>| {
|
||||
if let Some(d) = idle {
|
||||
*dl = Some(Instant::now() + d);
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
if monitors.is_empty() && !sys_open && infos.is_empty() {
|
||||
// Nothing to select over: park on the inbox alone (the pre-v0.8
|
||||
// loop), with the idle window as the recv timeout when one is set.
|
||||
match idle_deadline {
|
||||
Some(dl) => {
|
||||
let wait = dl.saturating_duration_since(Instant::now());
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(env) => {
|
||||
dispatch(&mut guard.0, env);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
// Quiet for the whole window → the idle event.
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
guard.0.handle_idle();
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
// All ServerRefs dropped → inbox closed → shutdown.
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
None => match rx.recv() {
|
||||
Ok(env) => dispatch(&mut guard.0, env),
|
||||
Err(_) => break,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Arm priority: downs, then the system arm, then infos (each in
|
||||
// declaration order), then the inbox. The arm slice is rebuilt
|
||||
// per iteration because every set but the inbox shrinks or grows.
|
||||
// The whole wait carries the idle window as its timeout.
|
||||
let nd = monitors.len();
|
||||
let nw = sys_open as usize;
|
||||
let sel = {
|
||||
let mut arms: Vec<&dyn Selectable> =
|
||||
Vec::with_capacity(nd + nw + infos.len() + 1);
|
||||
for m in &monitors {
|
||||
arms.push(&m.rx);
|
||||
}
|
||||
if sys_open {
|
||||
arms.push(&sys_rx);
|
||||
}
|
||||
for r in &infos {
|
||||
arms.push(r);
|
||||
}
|
||||
arms.push(&rx);
|
||||
match idle_deadline {
|
||||
Some(dl) => {
|
||||
select_timeout(&arms, dl.saturating_duration_since(Instant::now()))
|
||||
}
|
||||
None => Some(select(&arms)),
|
||||
}
|
||||
};
|
||||
let i = match sel {
|
||||
Some(i) => i,
|
||||
// No arm ready for the whole window → idle, then re-arm steady.
|
||||
None => {
|
||||
guard.0.handle_idle();
|
||||
reset_idle(&mut idle_deadline);
|
||||
crate::check!();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if i < nd {
|
||||
// A Down retires its arm either way: delivered (one-shot) or
|
||||
// closed without delivering (defensive; shouldn't happen).
|
||||
let m = monitors.remove(i);
|
||||
if let Ok(Some(down)) = m.rx.try_recv() {
|
||||
guard.0.handle_down(down);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
} else if i < nd + nw {
|
||||
match sys_rx.try_recv() {
|
||||
// Control intake, not a dispatched message: no idle reset.
|
||||
Ok(Some(Sys::Watch(m))) => monitors.push(m),
|
||||
Ok(Some(Sys::Timer(id, msg))) => {
|
||||
// The one-shot fired: retire its registry entry so the
|
||||
// live set tracks only still-pending timers, then
|
||||
// dispatch.
|
||||
reg.lock().unwrap().oneshots.remove(&id);
|
||||
guard.0.handle_timer(msg);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
Ok(Some(Sys::Tick(id))) => {
|
||||
// A periodic fired. Under the lock: if it is still live
|
||||
// (cancel didn't beat us here), produce its payload and
|
||||
// re-arm the next tick at now + every *before* dispatch,
|
||||
// so the period is measured from fire-handling and a
|
||||
// handler that cancels stops the just-armed instance.
|
||||
let msg = {
|
||||
let mut g = reg.lock().unwrap();
|
||||
let r = &mut *g;
|
||||
if let Some(p) = r.periodics.get_mut(&id) {
|
||||
let every = p.every;
|
||||
let msg = (p.make)();
|
||||
let tx = r
|
||||
.rearm_tx
|
||||
.as_ref()
|
||||
.expect("a live periodic keeps rearm_tx Some")
|
||||
.clone();
|
||||
p.live = send_after_to(every, tx, Sys::Tick(id));
|
||||
Some(msg)
|
||||
} else {
|
||||
// Cancelled between fire and dispatch: discard.
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(msg) = msg {
|
||||
guard.0.handle_timer(msg);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
}
|
||||
// Single-receiver: nothing can drain the arm between
|
||||
// select's ready and our try_recv.
|
||||
Ok(None) => debug_assert!(false, "ready system arm was empty"),
|
||||
// Every Watcher/TimerHandle gone: stop selecting on the arm.
|
||||
Err(_) => sys_open = false,
|
||||
}
|
||||
} else if i < nd + nw + infos.len() {
|
||||
let j = i - nd - nw;
|
||||
match infos[j].try_recv() {
|
||||
Ok(Some(info)) => {
|
||||
guard.0.handle_info(info);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
Ok(None) => debug_assert!(false, "ready info arm was empty"),
|
||||
// Senders all gone: drop the arm, keep serving. `remove`
|
||||
// (not swap_remove) — order is priority.
|
||||
Err(_) => {
|
||||
infos.remove(j);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match rx.try_recv() {
|
||||
Ok(Some(env)) => {
|
||||
dispatch(&mut guard.0, env);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
Ok(None) => debug_assert!(false, "ready inbox was empty"),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Observation point so a server whose arms are never empty stays
|
||||
// preemptible and cancellable.
|
||||
crate::check!();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! RFC 016 — runtime introspection (Chunk 1: the read primitive).
|
||||
//!
|
||||
//! A synchronous, internal read of the slab that returns *owned* data. This is
|
||||
//! the mechanism the whole RFC hangs off: tests, the future observer
|
||||
//! gen_server (Chunk 4), and a later control plane (RFC 003) are all consumers
|
||||
//! of [`snapshot`] / [`actor_info`], never of the runtime internals directly.
|
||||
//!
|
||||
//! ## Consistency (DECISION D2 — per-slot tearing, `ps` semantics)
|
||||
//!
|
||||
//! [`snapshot`] is point-in-time and mildly racy *across* actors: each slot's
|
||||
//! scheduling state is a lock-free word load, so an actor reported `Running`
|
||||
//! may already be `Parked`, and an actor can die mid-scan. This is the cheap,
|
||||
//! useful model (a coherent stop-the-world cut is expensive and rarely wanted).
|
||||
//! [`actor_info`] is coherent for the single actor it names.
|
||||
//!
|
||||
//! ## Locking
|
||||
//!
|
||||
//! The lock order is **Leaf → Channel, at most one of each** (`raw_mutex.rs`);
|
||||
//! cold locks, the registry, and the free list are all Leaves, so we may never
|
||||
//! hold two at once. The read is therefore phased: first a single registry-leaf
|
||||
//! pass for names and mailbox depth (the per-channel length read is a Channel
|
||||
//! lock taken under that Leaf — legal), released before the slab scan takes any
|
||||
//! per-slot cold Leaf.
|
||||
|
||||
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;
|
||||
|
||||
/// Snapshot wire-format version (DECISION D1). [`RuntimeSnapshot`] is treated as
|
||||
/// a stable type from day one: it becomes the observer protocol (Chunk 4) and
|
||||
/// crosses a version boundary the moment a remote observer attaches (RFC 011),
|
||||
/// so the version travels with the data from the start.
|
||||
pub const SNAPSHOT_FORMAT_VERSION: u16 = 1;
|
||||
|
||||
/// Fine-grained scheduling state, mapped from the packed slot word with no new
|
||||
/// storage. `RunningNotified` collapses into `Notified` — a wake landed while
|
||||
/// the actor was on-CPU and it will re-queue when it yields.
|
||||
#[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)
|
||||
/// — the only state that is not an actor.
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// Owned, point-in-time view of one actor — no borrows of runtime internals, so
|
||||
/// it is safe to hand to any consumer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorInfo {
|
||||
pub pid: Pid,
|
||||
/// Registered names, inverted from the registry (usually 0 or 1).
|
||||
pub names: Vec<&'static str>,
|
||||
pub state: ActorState,
|
||||
/// Spawn-time parent edge (DECISION D9): `spawn_under` sets it to the
|
||||
/// supervisor, plain `spawn` to the spawning actor — so it is parentage,
|
||||
/// not necessarily a supervision relationship. `ROOT_PID` for the run's
|
||||
/// root actor and for `Done` tombstones (whose `Actor` is already gone).
|
||||
pub supervisor: Pid,
|
||||
pub trap_exit: bool,
|
||||
pub monitors: u32,
|
||||
pub links: u32,
|
||||
pub joiners: u32,
|
||||
/// Queued messages summed over the actor's *published* channels (register /
|
||||
/// install / spawn_addr / gen_server). 0 for an actor that holds only a
|
||||
/// private `channel()` receiver — those are invisible to the registry.
|
||||
pub mailbox_depth: u32,
|
||||
/// Timeslice overruns tallied for this incarnation (RFC 016 Chunk 2): how
|
||||
/// many times the actor was preempted for exceeding its slice. Resets on
|
||||
/// restart (per-incarnation, D7).
|
||||
pub overruns: u64,
|
||||
/// Messages this actor has received (dequeued) this incarnation (RFC 016
|
||||
/// Chunk 2) — answers "is this actor a hotspot / draining slower than its
|
||||
/// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7).
|
||||
pub messages_received: u64,
|
||||
/// Approximate on-CPU cycles this incarnation has consumed (RFC 016 Chunk 2)
|
||||
/// — a reductions-like work metric for relative comparison. Always 0 unless
|
||||
/// the `budget-accounting` feature is enabled (it costs an RDTSC per resume,
|
||||
/// D6). Per-incarnation (D7).
|
||||
pub budget_cycles: u64,
|
||||
}
|
||||
|
||||
/// A whole-runtime snapshot. See the module docs for the D2 tearing model.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeSnapshot {
|
||||
pub format_version: u16,
|
||||
pub actors: Vec<ActorInfo>,
|
||||
}
|
||||
|
||||
/// Snapshot every live (and `Done`-but-not-yet-reclaimed) actor on the slab.
|
||||
/// O(n) over the slot table, running with preemption disabled (like every
|
||||
/// runtime primitive) but holding no lock across the scan. Panics outside
|
||||
/// `Runtime::run()`; callable from actor code and the run thread.
|
||||
pub fn snapshot() -> RuntimeSnapshot {
|
||||
with_runtime(|inner| {
|
||||
// Phase A: one registry-leaf pass for names + mailbox depth, released
|
||||
// before any cold leaf (no two Leaves at once).
|
||||
let mail = inner.registry.lock().introspect_map();
|
||||
|
||||
// Phase B: lock-free slab scan; per-slot cold leaf only to copy cold
|
||||
// fields. Tearing across slots is intentional (D2).
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
/// Coherent view of a single actor, or `None` if the pid is stale, out of
|
||||
/// range, or names a Vacant slot.
|
||||
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
|
||||
with_runtime(|inner| {
|
||||
let slot = inner.slot_at(pid)?;
|
||||
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 Vacant or
|
||||
/// racing-reclaimed. State is classified from a lock-free word load (the torn
|
||||
/// read); the cold lock then pins the generation (reclaim bumps it under that
|
||||
/// same lock) so the cold fields are coherent for this incarnation. `mail` is
|
||||
/// this slot's registry entry, if any.
|
||||
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 cold
|
||||
// lock, the slot was reclaimed (and maybe reused) — drop it rather than mix
|
||||
// one incarnation's state with another's cold data. (ps semantics: a racing
|
||||
// actor may simply be missed mid-scan.)
|
||||
if 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 hot-region atomics, read lock-free (RFC 016 Chunk 2).
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunk 3 — tree view (pure derivation over a Chunk-1 snapshot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One node in the parentage forest. `children` are the actors whose recorded
|
||||
/// parent edge points at this node's pid.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeNode {
|
||||
pub info: ActorInfo,
|
||||
/// The actor's recorded parent was absent from the snapshot (already
|
||||
/// Done/Vacant, or itself a tombstone), so it was re-rooted under the forest
|
||||
/// sentinel rather than dropped — the tree stays total (DECISION D8).
|
||||
pub orphaned: bool,
|
||||
pub children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// The parentage forest. Roots are actors parented at `ROOT_PID` (genuine
|
||||
/// roots) plus re-rooted orphans. The edge is *spawned-by / parent*, not
|
||||
/// necessarily supervision (DECISION D9) — see [`ActorInfo::supervisor`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeTree {
|
||||
pub format_version: u16,
|
||||
pub roots: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// Take a live [`snapshot`] and fold it into the parentage forest.
|
||||
pub fn tree() -> RuntimeTree {
|
||||
tree_from(snapshot())
|
||||
}
|
||||
|
||||
/// Fold an existing snapshot into a forest by grouping each actor under its
|
||||
/// parent pid — a single O(n) pass, no new reads. Exposed separately so a
|
||||
/// consumer that already holds a snapshot (or a synthetic one, in tests) can
|
||||
/// derive the tree without a second scan.
|
||||
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, D8) — 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 })
|
||||
}
|
||||
@@ -44,11 +44,13 @@
|
||||
//!
|
||||
//! Fd hygiene
|
||||
//! ==========
|
||||
//! If an actor dies while waiting on an fd, the registration is leaked
|
||||
//! (the fd stays in the epollfd, armed). EPOLLONESHOT bounds the damage:
|
||||
//! at most one stale wakeup, after which the kernel disarms. The stale
|
||||
//! wakeup hits a dead pid in `waiters` and is dropped. Acceptable for v0.2;
|
||||
//! a future pass should DEL on actor death.
|
||||
//! An actor stopped while waiting on an fd unwinds out of `wait_fd`'s park;
|
||||
//! a drop guard there (armed after a successful register, forgotten on a
|
||||
//! normal wake) removes the `waiters` entry iff it is still that wait's
|
||||
//! `(pid, epoch)` and only then `EPOLL_CTL_DEL`s the fd — an entry already
|
||||
//! consumed by a racing `FdReady` means the fd may carry someone else's
|
||||
//! fresh registration, which must be left alone. `epoll_register` keeps a
|
||||
//! defensive bare DEL before ADD as belt-and-braces.
|
||||
//!
|
||||
//! Buffers used with `read`/`write` should be on fds opened with
|
||||
//! `O_NONBLOCK`. If they aren't, the syscall may block the scheduler
|
||||
@@ -84,6 +86,9 @@ use std::thread::JoinHandle as OsJoinHandle;
|
||||
pub type IoResult = Result<Box<dyn Any + Send>, Box<dyn Any + Send>>;
|
||||
|
||||
struct Request {
|
||||
/// The submitter's park-epoch — carried through to the `Blocking`
|
||||
/// completion so the wake is epoch-matched.
|
||||
epoch: u32,
|
||||
pid: Pid,
|
||||
/// The work to perform. Returns the wire-form result directly.
|
||||
work: Box<dyn FnOnce() -> IoResult + Send>,
|
||||
@@ -93,7 +98,7 @@ struct Request {
|
||||
pub enum Completion {
|
||||
/// A `block_on_io` closure has finished (Ok = return value, Err = panic
|
||||
/// payload).
|
||||
Blocking { pid: Pid, result: IoResult },
|
||||
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
|
||||
@@ -131,7 +136,7 @@ pub struct IoThread {
|
||||
/// 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>,
|
||||
pub waiters: HashMap<RawFd, (Pid, u32)>,
|
||||
|
||||
// ----- Threads -----
|
||||
|
||||
@@ -231,12 +236,12 @@ impl IoThread {
|
||||
}
|
||||
|
||||
/// Hand a request to the pool. Increments `outstanding`.
|
||||
pub fn submit(&mut self, pid: Pid, 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
|
||||
// on shutdown. submit during shutdown is a bug.
|
||||
self.tx
|
||||
.send(Request { pid, work })
|
||||
.send(Request { pid, epoch, work })
|
||||
.expect("io pool hung up unexpectedly");
|
||||
}
|
||||
|
||||
@@ -255,6 +260,15 @@ impl IoThread {
|
||||
self.wake_read
|
||||
}
|
||||
|
||||
/// Write the wake pipe directly: rouse every scheduler thread blocked in
|
||||
/// its idle `poll_wake`. Used by the terminal (AllDone) path — an idle
|
||||
/// sibling may be blocked on a snapshot that nothing will ever refresh
|
||||
/// (an orphaned timer deadline, or `io_outstanding` from a waiter that
|
||||
/// was stop-cancelled and so never produces a completion).
|
||||
pub fn wake(&self) {
|
||||
wake_scheduler(self.wake_write);
|
||||
}
|
||||
|
||||
/// Register interest in `fd` becoming readable/writable; record `pid`
|
||||
/// as the parked waiter. The epoll thread will push a `FdReady`
|
||||
/// completion when the kernel signals.
|
||||
@@ -265,6 +279,7 @@ impl IoThread {
|
||||
&mut self,
|
||||
fd: RawFd,
|
||||
pid: Pid,
|
||||
epoch: u32,
|
||||
readable: bool,
|
||||
writable: bool,
|
||||
) -> io::Result<()> {
|
||||
@@ -278,10 +293,10 @@ impl IoThread {
|
||||
));
|
||||
}
|
||||
|
||||
// Defensive cleanup: if a previous actor died while waiting on this
|
||||
// fd, the kernel-side registration was leaked (we don't walk all
|
||||
// waiters on actor death). A bare DEL is harmless if the fd isn't
|
||||
// registered (ENOENT), and removes any leak.
|
||||
// Belt-and-braces: the unwind guard in `wait_fd` is responsible for
|
||||
// cleaning up a stopped waiter's registration, but a bare DEL is
|
||||
// harmless if the fd isn't registered (ENOENT) and removes any leak
|
||||
// a path we haven't thought of might leave behind.
|
||||
unsafe {
|
||||
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
|
||||
}
|
||||
@@ -303,7 +318,7 @@ impl IoThread {
|
||||
if r < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
self.waiters.insert(fd, pid);
|
||||
self.waiters.insert(fd, (pid, epoch));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -369,7 +384,7 @@ fn pool_loop(
|
||||
completions: Arc<Mutex<VecDeque<Completion>>>,
|
||||
wake_write: RawFd,
|
||||
) {
|
||||
while let Ok(Request { pid, work }) = rx.recv() {
|
||||
while let Ok(Request { pid, epoch, work }) = rx.recv() {
|
||||
let result: IoResult = match panic::catch_unwind(panic::AssertUnwindSafe(work)) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => Err(payload),
|
||||
@@ -377,7 +392,7 @@ fn pool_loop(
|
||||
completions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(Completion::Blocking { pid, result });
|
||||
.push_back(Completion::Blocking { pid, epoch, result });
|
||||
wake_scheduler(wake_write);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-6
@@ -11,7 +11,6 @@
|
||||
//!
|
||||
//! See `LOOM.md` for the design intent and the deferred-for-later list.
|
||||
|
||||
pub mod arch;
|
||||
pub mod stack;
|
||||
pub mod context;
|
||||
pub mod preempt;
|
||||
@@ -24,9 +23,20 @@ pub mod timer;
|
||||
pub mod io;
|
||||
pub mod mutex;
|
||||
pub mod monitor;
|
||||
pub mod registry;
|
||||
pub mod pg;
|
||||
pub mod link;
|
||||
pub mod gen_server;
|
||||
pub mod statem;
|
||||
pub mod introspect;
|
||||
#[cfg(feature = "observer")]
|
||||
pub mod observer;
|
||||
pub mod runtime;
|
||||
pub(crate) mod raw_mutex;
|
||||
pub(crate) mod slot_state;
|
||||
pub(crate) mod sync_shim;
|
||||
#[doc(hidden)] // pub only so benches/rq_micro.rs can drive the raw structures
|
||||
pub mod run_queue;
|
||||
pub mod trace;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,18 +50,41 @@ static ALLOCATOR: preempt::PreemptingAllocator = preempt::PreemptingAllocator;
|
||||
// Public API re-exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use channel::{channel, Receiver, RecvError, Sender};
|
||||
pub use gen_server::{CallError, CastError, GenServer, ServerRef};
|
||||
pub use channel::{
|
||||
channel, select, select_timeout, try_select, try_select_timeout, Receiver, RecvError,
|
||||
RecvTimeoutError, Selectable, Sender,
|
||||
};
|
||||
pub use gen_server::{
|
||||
call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer,
|
||||
NamedServerBuilder, ServerBuilder, ServerCtx, ServerName, ServerRef, TimerHandle, Watcher,
|
||||
};
|
||||
pub use statem::{
|
||||
CallError as StatemCallError, Cx, Machine, Reply, Resolution, SendError as StatemSendError,
|
||||
StatemRef,
|
||||
};
|
||||
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 monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
|
||||
pub use mutex::{LockTimeout, Mutex, MutexGuard};
|
||||
pub use pid::Pid;
|
||||
pub use pid::{Addressable, Erased, Name, Pid, RawPid};
|
||||
pub use pg::{dispatch, join, leave, members, members_as, pick, pick_as, Incarnation, Member, NodeId};
|
||||
pub use registry::{
|
||||
install, lookup_as, register, send, send_dyn, send_to, unregister, whereis, RegisterError,
|
||||
SendError,
|
||||
};
|
||||
pub use runtime::{init, Config, Runtime};
|
||||
pub use scheduler::{
|
||||
block_on_io, request_stop, run, self_pid, sleep, spawn, spawn_under, wait_readable,
|
||||
wait_writable, yield_now, JoinError, JoinHandle,
|
||||
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named, sleep,
|
||||
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable,
|
||||
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle,
|
||||
};
|
||||
pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
|
||||
pub use timer::TimerId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check!()
|
||||
|
||||
+70
-46
@@ -49,10 +49,9 @@
|
||||
//! [`Down`]: crate::monitor::Down
|
||||
//! [`request_stop`]: crate::scheduler::request_stop
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::channel::{channel, Receiver};
|
||||
use crate::monitor::DownReason;
|
||||
use crate::pid::Pid;
|
||||
use crate::runtime::State;
|
||||
use crate::scheduler::{request_stop, self_pid, with_runtime};
|
||||
|
||||
/// A linked peer's death, delivered to a trapping actor's inbox.
|
||||
@@ -78,11 +77,13 @@ pub fn trap_exit() -> Receiver<ExitSignal> {
|
||||
let (tx, rx) = channel::<ExitSignal>();
|
||||
let me = self_pid();
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(actor) = s.slot_mut(me).and_then(|slot| slot.actor.as_mut()) {
|
||||
if let Some(slot) = inner.slot_at(me) {
|
||||
let mut cold = slot.cold.lock();
|
||||
// Own slot: generation is necessarily current (we're running).
|
||||
if let Some(actor) = cold.actor.as_mut() {
|
||||
actor.trap = Some(tx);
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
@@ -94,51 +95,68 @@ pub fn trap_exit() -> Receiver<ExitSignal> {
|
||||
/// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller
|
||||
/// (a message if trapping, otherwise a cooperative stop). Linking yourself, or
|
||||
/// 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();
|
||||
if target == me {
|
||||
return;
|
||||
}
|
||||
|
||||
// Under the lock: if the target is live, record the link both ways and
|
||||
// return `None`. If it is gone, return the caller's trap sender (if any)
|
||||
// so we can deliver the NoProc signal after releasing the lock.
|
||||
let dead_action: Option<Option<Sender<ExitSignal>>> = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let target_live = matches!(
|
||||
s.slot(target),
|
||||
Some(slot) if slot.actor.is_some() && !matches!(slot.state, State::Done)
|
||||
);
|
||||
if target_live {
|
||||
if let Some(slot) = s.slot_mut(me) {
|
||||
if !slot.links.contains(&target) {
|
||||
slot.links.push(target);
|
||||
}
|
||||
// Cold locks are leaves: never hold two at once. The link is recorded one
|
||||
// side at a time, TARGET FIRST — that ordering is what makes the race
|
||||
// window sound:
|
||||
//
|
||||
// - Once `me` is in `target.links`, the target's death always reaches us
|
||||
// (its finalize cascade walks that list). So after step 1 succeeds, the
|
||||
// link semantics are already live.
|
||||
// - If the target dies between step 1 and step 2, its cascade removes
|
||||
// `target` from OUR links (a no-op, we haven't added it yet) and
|
||||
// delivers the exit signal — correct, the link was established. Our
|
||||
// subsequent step-2 insert leaves a stale `target` entry in `me.links`;
|
||||
// stale entries are benign by construction (every cascade walk
|
||||
// re-verifies the peer's word; `unlink` removes them like any other).
|
||||
//
|
||||
// The reverse order would be unsound: target dying in the window would
|
||||
// walk its links WITHOUT us — a silently dead link that we believe is live.
|
||||
let registered_on_target = with_runtime(|inner| match inner.slot_at(target) {
|
||||
Some(slot) => {
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.is_live_for(target) && cold.actor.is_some() {
|
||||
if !cold.links.contains(&me) {
|
||||
cold.links.push(me);
|
||||
}
|
||||
if let Some(slot) = s.slot_mut(target) {
|
||||
if !slot.links.contains(&me) {
|
||||
slot.links.push(me);
|
||||
}
|
||||
}
|
||||
None
|
||||
true
|
||||
} else {
|
||||
// Grab our own trap sender so the NoProc delivery (below)
|
||||
// doesn't need a second lock acquisition.
|
||||
Some(
|
||||
s.slot(me)
|
||||
.and_then(|slot| slot.actor.as_ref())
|
||||
.and_then(|a| a.trap.clone()),
|
||||
)
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
|
||||
match dead_action {
|
||||
None => {} // linked successfully
|
||||
Some(Some(tx)) => {
|
||||
if registered_on_target {
|
||||
with_runtime(|inner| {
|
||||
let slot = inner.slot_at(me).expect("link: own slot vanished");
|
||||
let mut cold = slot.cold.lock();
|
||||
if !cold.links.contains(&target) {
|
||||
cold.links.push(target);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Target already gone: deliver NoProc to ourselves — as a message if
|
||||
// trapping, otherwise as a cooperative stop.
|
||||
let my_trap = with_runtime(|inner| {
|
||||
inner.slot_at(me).and_then(|slot| {
|
||||
let cold = slot.cold.lock();
|
||||
cold.actor.as_ref().and_then(|a| a.trap.clone())
|
||||
})
|
||||
});
|
||||
match my_trap {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc });
|
||||
}
|
||||
Some(None) => request_stop(me),
|
||||
None => request_stop(me),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,19 +164,25 @@ pub fn link(target: Pid) {
|
||||
///
|
||||
/// After this, neither actor's death propagates to the other. A no-op if the
|
||||
/// two were not linked.
|
||||
pub fn unlink(target: Pid) {
|
||||
pub fn unlink<A>(target: Pid<A>) {
|
||||
let target = target.erase();
|
||||
let me = self_pid();
|
||||
if target == me {
|
||||
return;
|
||||
}
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(slot) = s.slot_mut(me) {
|
||||
slot.links.retain(|p| *p != target);
|
||||
// One cold lock at a time (leaf rule). Order is immaterial here:
|
||||
// a half-removed link is just a stale entry on one side, and stale
|
||||
// entries are benign (re-verified on every cascade walk).
|
||||
if let Some(slot) = inner.slot_at(me) {
|
||||
let mut cold = slot.cold.lock();
|
||||
cold.links.retain(|p| *p != target);
|
||||
}
|
||||
if let Some(slot) = inner.slot_at(target) {
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.generation() == target.generation() {
|
||||
cold.links.retain(|p| *p != me);
|
||||
}
|
||||
if let Some(slot) = s.slot_mut(target) {
|
||||
slot.links.retain(|p| *p != me);
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+30
-26
@@ -47,7 +47,6 @@
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::pid::Pid;
|
||||
use crate::runtime::State;
|
||||
use crate::scheduler::with_runtime;
|
||||
|
||||
/// Why a monitored actor went down.
|
||||
@@ -107,27 +106,30 @@ pub struct Monitor {
|
||||
/// If `target` is still live, the `Down` arrives when it terminates. If
|
||||
/// `target` is already gone, a [`DownReason::NoProc`] `Down` is queued
|
||||
/// 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>();
|
||||
|
||||
// Register under the shared lock. We allocate the id and (if the target is
|
||||
// live) clone the sender into its monitor list, keeping the original `tx`
|
||||
// for the NoProc fallback. `tx.clone()` only touches the channel's own
|
||||
// mutex, never the shared runtime mutex, so it is safe under the lock — but
|
||||
// we must not *send* here, as `Sender::send` can call back in to unpark a
|
||||
// parked receiver and the shared mutex is not reentrant.
|
||||
// Register under the target's cold lock. `tx.clone()` takes the channel's
|
||||
// own lock — a Channel-class RawMutex, explicitly permitted *under* a Leaf
|
||||
// (cold) lock by the lock order (see raw_mutex.rs). We must still not
|
||||
// *send* under the lock, as `Sender::send` can unpark a parked receiver,
|
||||
// and there's no reason to nest that.
|
||||
let (id, registered) = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let id = s.alloc_monitor_id();
|
||||
let registered = match s.slot_mut(target) {
|
||||
Some(slot) if !matches!(slot.state, State::Done) => {
|
||||
slot.monitors.push((id, tx.clone()));
|
||||
let id = inner.alloc_monitor_id();
|
||||
let registered = match inner.slot_at(target) {
|
||||
Some(slot) => {
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.is_live_for(target) {
|
||||
cold.monitors.push((id, tx.clone()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
(id, registered)
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
(id, registered)
|
||||
});
|
||||
|
||||
if !registered {
|
||||
@@ -147,16 +149,18 @@ pub fn monitor(target: Pid) -> Monitor {
|
||||
/// dropping the [`Monitor`] closes its receiver and the queued notice goes with
|
||||
/// it — the analogue of Erlang's `demonitor(Ref, [flush])`.
|
||||
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
|
||||
// Remove the registration under the lock, but move the `Sender` *out* and
|
||||
// let it drop only after the lock is released: dropping the last sender
|
||||
// runs `Sender::drop`, which may unpark a parked receiver → `with_shared`,
|
||||
// and the shared mutex is not reentrant.
|
||||
// Remove the registration under the target's cold lock, but move the
|
||||
// `Sender` *out* and let it drop only after the lock is released:
|
||||
// dropping the last sender runs `Sender::drop`, which may unpark a parked
|
||||
// receiver — legal under a cold lock, but pointless to nest.
|
||||
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let slot = s.slot_mut(m.target)?;
|
||||
let pos = slot.monitors.iter().position(|(mid, _)| *mid == m.id)?;
|
||||
Some(slot.monitors.remove(pos))
|
||||
})
|
||||
let slot = inner.slot_at(m.target)?;
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.generation() != m.target.generation() {
|
||||
return None; // slot reused; the Down already fired
|
||||
}
|
||||
let pos = cold.monitors.iter().position(|(mid, _)| *mid == m.id)?;
|
||||
Some(cold.monitors.remove(pos))
|
||||
});
|
||||
// `removed`'s sender drops here, outside the lock.
|
||||
removed.map(|(id, _sender)| id)
|
||||
|
||||
+24
-18
@@ -33,13 +33,15 @@ impl std::error::Error for LockTimeout {}
|
||||
|
||||
struct Wait {
|
||||
pid: Pid,
|
||||
seq: u64,
|
||||
/// The wait's park-epoch (slot-word wait identity, see slot_state.rs).
|
||||
/// Grants and timeouts wake via `unpark_at(pid, epoch)`; a stale entry
|
||||
/// can neither be granted by mistake nor wake the wrong wait.
|
||||
epoch: u32,
|
||||
}
|
||||
|
||||
struct MutexState {
|
||||
holder: Option<Pid>,
|
||||
waiters: VecDeque<Wait>,
|
||||
next_seq: u64,
|
||||
default_timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -53,7 +55,6 @@ impl MutexCore {
|
||||
state: StdMutex::new(MutexState {
|
||||
holder: None,
|
||||
waiters: VecDeque::new(),
|
||||
next_seq: 0,
|
||||
default_timeout,
|
||||
}),
|
||||
}
|
||||
@@ -61,17 +62,17 @@ impl MutexCore {
|
||||
}
|
||||
|
||||
impl TimerTarget for MutexCore {
|
||||
fn on_timeout(&self, pid: Pid, wait_seq: u64) {
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
let unpark = {
|
||||
let mut st = self.state.lock().unwrap();
|
||||
// Remove from waiters only if still there with matching seq.
|
||||
// Remove from waiters only if still there with matching epoch.
|
||||
// If the lock was already granted (holder == Some(pid)), the
|
||||
// timer fired after the grant — treat as no-op; the actor
|
||||
// will see `is_holder == true` and return Ok.
|
||||
if st.holder == Some(pid) {
|
||||
return;
|
||||
}
|
||||
let pos = st.waiters.iter().position(|w| w.pid == pid && w.seq == wait_seq);
|
||||
let pos = st.waiters.iter().position(|w| w.pid == pid && w.epoch == epoch);
|
||||
if pos.is_some() {
|
||||
st.waiters.remove(pos.unwrap());
|
||||
true
|
||||
@@ -80,7 +81,7 @@ impl TimerTarget for MutexCore {
|
||||
}
|
||||
};
|
||||
if unpark {
|
||||
scheduler::unpark(pid);
|
||||
scheduler::unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,20 +134,25 @@ impl<T> Mutex<T> {
|
||||
|
||||
// Slow path: register as a waiter, set timeout, park.
|
||||
let _np = scheduler::NoPreempt::enter();
|
||||
let seq = {
|
||||
let epoch = {
|
||||
let mut st = self.core.state.lock().unwrap();
|
||||
let seq = st.next_seq;
|
||||
st.next_seq = st.next_seq.wrapping_add(1);
|
||||
st.waiters.push_back(Wait { pid: me, seq });
|
||||
seq
|
||||
// begin_wait is lock-free — legal under the state lock; this
|
||||
// makes the epoch atomic with the registration's visibility to
|
||||
// grants and timeouts.
|
||||
let epoch = scheduler::begin_wait();
|
||||
st.waiters.push_back(Wait { pid: me, epoch });
|
||||
epoch
|
||||
};
|
||||
|
||||
let target: Arc<dyn TimerTarget> = self.core.clone();
|
||||
let deadline = timer::deadline_from_now(timeout);
|
||||
scheduler::insert_wait_timer(deadline, me, target, seq);
|
||||
scheduler::insert_wait_timer(deadline, me, target, epoch);
|
||||
scheduler::park_current();
|
||||
|
||||
// Resumed. Are we the holder?
|
||||
// Resumed — precisely: only our grant or our timer can wake this
|
||||
// wait (both epoch-stamped; a stop wake unwinds out of
|
||||
// park_current). The one-shot interpretation below is therefore
|
||||
// exhaustive. Are we the holder?
|
||||
let is_holder = self.core.state.lock().unwrap().holder == Some(me);
|
||||
if is_holder {
|
||||
let value = self.value.lock().unwrap().take()
|
||||
@@ -228,12 +234,12 @@ impl<T> Drop for MutexGuard<'_, T> {
|
||||
let v = self.value.take().expect("MutexGuard: double drop");
|
||||
*self.mutex.value.lock().unwrap() = Some(v);
|
||||
|
||||
let next_pid = {
|
||||
let next = {
|
||||
let mut st = self.mutex.core.state.lock().unwrap();
|
||||
match st.waiters.pop_front() {
|
||||
Some(w) => {
|
||||
st.holder = Some(w.pid);
|
||||
Some(w.pid)
|
||||
Some((w.pid, w.epoch))
|
||||
}
|
||||
None => {
|
||||
st.holder = None;
|
||||
@@ -241,8 +247,8 @@ impl<T> Drop for MutexGuard<'_, T> {
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(pid) = next_pid {
|
||||
scheduler::unpark(pid);
|
||||
if let Some((pid, epoch)) = next {
|
||||
scheduler::unpark_at(pid, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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, ServerBuilder, ServerRef};
|
||||
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 [`ServerRef`].
|
||||
/// Shorthand for `ServerBuilder::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() -> ServerRef<Observer> {
|
||||
ServerBuilder::new(Observer).start()
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
//! Process groups — a `name → multiset<Member>` map (RFC 012).
|
||||
//!
|
||||
//! Sits parallel to [`registry`](crate::registry), not on top of it. The
|
||||
//! registry is a *bimap*: at most one pid per name. A group is the opposite —
|
||||
//! many pids per name, the same pid in many groups — so it cannot be a
|
||||
//! generalization of the bimap; it is its own keyspace sharing only the
|
||||
//! liveness signal source (monitors) and the lock *class*.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! A group is a live membership view: members join, and a member that dies is
|
||||
//! evicted automatically — no deregistration call, no bookkeeping.
|
||||
//!
|
||||
//! ```
|
||||
//! 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. Nobody told the group — the death hook evicts it,
|
||||
//! // so it is gone from `members` and never returned by `pick`.
|
||||
//! 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();
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! ## Cleanup is eager and monitor-driven (unlike the registry)
|
||||
//!
|
||||
//! The registry prunes a stale binding lazily, on contact, because it only
|
||||
//! ever resolves one binding at a time. A group is *iterated* — `members` fans
|
||||
//! out to every member — so it must not carry dead members across a broadcast.
|
||||
//! Each [`join`] installs a `monitor(pid)` and keeps the resulting [`Monitor`]
|
||||
//! *alongside the group entry*; the actor's one-shot `Down` lands in that
|
||||
//! monitor's channel when it dies. Every group operation [`reap`s][reap] the
|
||||
//! group it touches first — draining each membership's monitor with a
|
||||
//! non-blocking `try_recv` — and on the first sign of death sweeps that pid out
|
||||
//! of *every* group via the predicate primitive. So the set is self-pruning on
|
||||
//! contact rather than filtered on read; the read path additionally applies a
|
||||
//! generation-checked liveness backstop so a member already dead but not yet
|
||||
//! reaped is never *returned*, even though eviction remains the monitor's job.
|
||||
//!
|
||||
//! [reap]: ProcessGroups::reap_group
|
||||
//!
|
||||
//! ## Eviction is one dumb primitive
|
||||
//!
|
||||
//! [`ProcessGroups::remove_where`] removes every member matching a predicate
|
||||
//! from every group. The primitive never knows *why* a member leaves — that is
|
||||
//! the caller's concern. Its first caller is the monitor death hook (this RFC,
|
||||
//! via `reap_group`); the later `evict_incarnation(node, inc)` sweep (RFC 010)
|
||||
//! reuses the same predicate path, which is the whole reason to shape it as a
|
||||
//! predicate.
|
||||
//!
|
||||
//! ## Identity is cluster-shaped from the first commit
|
||||
//!
|
||||
//! A [`Member`] is not a bare [`Pid`]: it is `(NodeId, Incarnation, Pid)`, a
|
||||
//! field-for-field image of a modern BEAM pid (`NEW_PID_EXT`) so the eventual
|
||||
//! ETF codec (RFC 011) is a near-identity mapping. In this RFC `NodeId` and
|
||||
//! `Incarnation` are runtime-init constants — one node, one fixed incarnation
|
||||
//! — threaded through storage and eviction anyway so the public surface never
|
||||
//! has to change to acquire them once clustering (RFC 010) supplies real
|
||||
//! values. No cluster types leak out of the public functions: callers pass and
|
||||
//! receive [`Pid`]; the node/incarnation are filled from runtime identity.
|
||||
//!
|
||||
//! ## Locking
|
||||
//!
|
||||
//! One `RawMutex` (Leaf class) on `RuntimeInner`, mirroring `registry`. The
|
||||
//! group lock is never held with another Leaf (it never touches the registry
|
||||
//! or a slot's cold lock). The two places that *do* need another lock are kept
|
||||
//! off the group-lock path:
|
||||
//!
|
||||
//! - `monitor()` / `demonitor()` acquire the target's cold lock (Leaf) and
|
||||
//! so run *before* / *after* the group lock, never under it.
|
||||
//! - draining a monitor with `try_recv` takes the channel's Channel-class
|
||||
//! lock — permitted *under* a Leaf by the lock order (`raw_mutex.rs`), and
|
||||
//! a channel critical section only does the lock-free unpark protocol, so
|
||||
//! no Leaf is ever nested under it.
|
||||
//!
|
||||
//! Evicted and rejected [`Monitor`]s are dropped only *after* the group lock is
|
||||
//! released, so a receiver-drop never runs a wakeup under the lock (same
|
||||
//! discipline as `demonitor`).
|
||||
|
||||
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 (RFC 011 names it).
|
||||
#[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 (RFC 010) 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 `RemoteRef` boundary (RFC 010/011), 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 (RFC 012) 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. Held under one Leaf-class `RawMutex`.
|
||||
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. Callers: the death hook (`reap_group`) and,
|
||||
/// later, `evict_incarnation` (RFC 010), over the same path. Insertion
|
||||
/// order within a group is preserved (`members` / `pick` are order-stable).
|
||||
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. 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 (Phase 3): 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(pid)` whose one-shot `Down` drives eviction: the
|
||||
/// registration races `finalize_actor` under the slot's cold lock exactly as
|
||||
/// every other monitor does, so no death slips between the join and the
|
||||
/// registration. A redundant (idempotent) join tears its extra monitor back
|
||||
/// down.
|
||||
///
|
||||
/// 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.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan-out read: every live member of `group`.
|
||||
///
|
||||
/// The touched group is reaped first, so dead members are evicted before the
|
||||
/// read. The generation-checked liveness read is a belt-and-braces backstop:
|
||||
/// even in the finalize window where a member is already dead but its `Down`
|
||||
/// has not yet landed, it is dropped from the result.
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Pool/discovery read: one live member of `group`, or `None` if empty.
|
||||
/// Stateless first-live selection: the touched group is reaped first, and the
|
||||
/// generation-checked liveness backstop skips any member already dead but not
|
||||
/// yet reaped. Smarter routing is an explicitly later, clustered concern per
|
||||
/// RFC 010.
|
||||
///
|
||||
/// 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>`] (RFC 014 §4.4).
|
||||
/// 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.
|
||||
//!
|
||||
//! A `Pid` is `(index, generation)`. The index is a slot in the scheduler's
|
||||
//! actor table; the generation increments every time that slot is reused.
|
||||
//! A stale `Pid` (correct index, wrong generation) is a detectable error,
|
||||
//! not a silent misdirection — solves the ABA problem without exhausting
|
||||
//! the PID space.
|
||||
//! Identity is `(index, generation)`: the index is a slot in the scheduler's
|
||||
//! actor table, the generation increments every time that slot is reused, so a
|
||||
//! stale id (right index, wrong generation) is a *detectable* error rather than
|
||||
//! a silent misdirection — the ABA problem solved without exhausting the id
|
||||
//! 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)]
|
||||
pub struct Pid {
|
||||
pub struct RawPid {
|
||||
index: u32,
|
||||
generation: u32,
|
||||
}
|
||||
|
||||
impl Pid {
|
||||
impl RawPid {
|
||||
#[inline]
|
||||
pub const fn new(index: u32, generation: u32) -> Self {
|
||||
Self { index, generation }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub const fn index(self) -> u32 { self.index }
|
||||
|
||||
pub const fn index(self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
#[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 {
|
||||
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 {
|
||||
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 `ServerRef`),
|
||||
/// 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 `ServerRef`); 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>>();
|
||||
}
|
||||
}
|
||||
|
||||
+85
-18
@@ -30,17 +30,7 @@ use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub const DEFAULT_ALLOC_INTERVAL: u32 = 128;
|
||||
|
||||
// The timeslice is measured in `rdtsc()` ticks, and the tick *unit* differs by
|
||||
// ISA (see `crate::arch::read_cycle_counter`). Both constants target ~100µs.
|
||||
// x86-64 : TSC ≈ CPU base clock; 300_000 ticks ≈ 100µs on a 3 GHz core.
|
||||
// aarch64: CNTVCT runs at CNTFRQ_EL0, commonly ~24 MHz; 2_400 ticks ≈ 100µs.
|
||||
// A real implementation would read the frequency at startup and compute this;
|
||||
// for now the constant is calibrated per-arch. Tune on-device if needed.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 2_400; // ≈ 100µs at a 24 MHz CNTVCT
|
||||
|
||||
thread_local! {
|
||||
/// While `false`, the allocator hook is a no-op.
|
||||
@@ -68,6 +58,16 @@ thread_local! {
|
||||
/// resume path free of atomic ref-count traffic; see `check_cancelled` for
|
||||
/// the safety argument.
|
||||
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()) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +87,46 @@ pub(crate) fn clear_current_stop() {
|
||||
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()));
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// been flagged for stop, raise the sentinel panic so the trampoline's
|
||||
/// `catch_unwind` tears the stack down (running Drop) and reports
|
||||
@@ -126,12 +166,24 @@ pub fn reset_timeslice() {
|
||||
TIMESLICE_START.with(|c| c.set(rdtsc()));
|
||||
}
|
||||
|
||||
/// Per-core cycle counter for the timeslice clock. Delegates to the active
|
||||
/// arch backend (`rdtsc` on x86-64, `CNTVCT_EL0` on aarch64). The tick unit
|
||||
/// is ISA-defined, so `DEFAULT_TIMESLICE_CYCLES` is calibrated per-arch below.
|
||||
/// 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)]
|
||||
pub fn rdtsc() -> u64 {
|
||||
crate::arch::read_cycle_counter()
|
||||
unsafe {
|
||||
// SAFETY: x86-64 only. `lfence` serialises the instruction stream so
|
||||
// we don't measure time before prior instructions retire.
|
||||
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
|
||||
core::arch::x86_64::_rdtsc()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PreemptingAllocator;
|
||||
@@ -179,13 +231,28 @@ pub fn maybe_preempt() {
|
||||
let n = c.get();
|
||||
if n == 0 {
|
||||
c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get()));
|
||||
// Cooperative cancellation shares the amortised cadence with the
|
||||
// timeslice check. Observe a pending stop first: if we are being
|
||||
// cancelled there is no point yielding, we unwind instead.
|
||||
check_cancelled();
|
||||
if PREEMPTION_ENABLED.with(|e| e.get()) {
|
||||
// Cooperative cancellation shares the amortised cadence with
|
||||
// the timeslice check, and shares its gate: while preemption
|
||||
// is disabled (`NoPreempt`, `with_shared`, channel critical
|
||||
// sections) the stop sentinel must NOT be raised, because an
|
||||
// allocation-triggered unwind inside a region holding a
|
||||
// `std::sync::Mutex` would poison it — one `request_stop` at
|
||||
// the wrong moment would then cascade `lock().unwrap()`
|
||||
// panics through every later user of that lock. Observation
|
||||
// is merely deferred to the next enabled allocation or the
|
||||
// wakeup side of the next park/yield, both of which are
|
||||
// lock-free points by construction.
|
||||
//
|
||||
// Observe a pending stop first: if we are being cancelled
|
||||
// there is no point yielding, we unwind instead.
|
||||
check_cancelled();
|
||||
let start = TIMESLICE_START.with(|s| s.get());
|
||||
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
|
||||
// Tally the overrun (RFC 016 Chunk 2) before handing back —
|
||||
// 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
|
||||
// sets PREEMPTION_ENABLED on resume and clears it on
|
||||
// return). The scheduler stack is therefore valid.
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! A minimal futex-based mutex that cannot poison.
|
||||
//!
|
||||
//! `std::sync::Mutex` poisons on unwind, turning one panic into a cascade of
|
||||
//! `lock().unwrap()` panics in every later user. The runtime's internal
|
||||
//! critical sections must never unwind anyway (the stop sentinel is gated
|
||||
//! behind `PREEMPTION_ENABLED`, and the guard below disables preemption), so
|
||||
//! poisoning buys nothing and costs a failure mode. This mutex has no poison
|
||||
//! state by construction.
|
||||
//!
|
||||
//! Two further properties the runtime wants:
|
||||
//!
|
||||
//! - **The guard enters `NoPreempt`.** A timeslice switch while holding an OS
|
||||
//! mutex would suspend the actor with the lock held, stalling every other
|
||||
//! OS thread that touches it until the actor is resumed. Disabling
|
||||
//! preemption for the (short) critical section keeps lock hold times
|
||||
//! bounded. It also closes the unwind hole structurally: with
|
||||
//! `PREEMPTION_ENABLED` false, `maybe_preempt` neither yields nor raises
|
||||
//! the stop sentinel, so no allocation inside the critical section can
|
||||
//! unwind it.
|
||||
//! - **No std machinery.** One `AtomicU32` and two futex syscalls; friendlier
|
||||
//! to an eventual embedded port than `std::sync::Mutex` (swap the futex for
|
||||
//! a spin or WFE backend).
|
||||
//!
|
||||
//! Algorithm: the classic three-state futex mutex (Drepper, "Futexes Are
|
||||
//! Tricky", mutex3). 0 = unlocked, 1 = locked, 2 = locked with (possible)
|
||||
//! waiters. Uncontended lock/unlock is one CAS / one swap, no syscall.
|
||||
//!
|
||||
//! Lock-order position — two classes (see [`LockClass`]):
|
||||
//!
|
||||
//! - **Leaf**: slot cold locks, the free list, the stack pool, the name
|
||||
//! registry. Mutual leaves — never hold two at once.
|
||||
//! - **Channel**: a channel's internal lock. May be acquired *under* a Leaf
|
||||
//! (finalize clones the supervisor/trap senders, and `monitor()` clones the
|
||||
//! Down sender, all under a cold lock — structural, the sender lives in the
|
||||
//! slot), but nothing may be acquired under a Channel lock: channel
|
||||
//! critical sections call only the lock-free unpark protocol.
|
||||
//!
|
||||
//! So the total order is Leaf → Channel, one of each at most. Holding either
|
||||
//! while pushing to the run queue is permitted (unpark from inside a cold or
|
||||
//! channel section); the reverse — taking any `RawMutex` from inside a
|
||||
//! run-queue op — cannot arise (queue ops call nothing).
|
||||
|
||||
use std::cell::UnsafeCell;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
const UNLOCKED: u32 = 0;
|
||||
const LOCKED: u32 = 1;
|
||||
const CONTENDED: u32 = 2;
|
||||
|
||||
/// How many `pause` spins to burn before falling back to the futex. Critical
|
||||
/// sections under this lock are tens of nanoseconds (push to a Vec, clone a
|
||||
/// sender), so a short spin almost always avoids the syscall.
|
||||
const SPIN_LIMIT: u32 = 64;
|
||||
|
||||
/// Which rung of the two-rung lock order a `RawMutex` occupies. Debug builds
|
||||
/// enforce the order mechanically (see the module docs); release builds carry
|
||||
/// no state and no checks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum LockClass {
|
||||
/// Runtime cold data: slot cold locks, free list, stack pool, registry.
|
||||
/// Mutual leaves among themselves; a Channel lock may be taken under one.
|
||||
Leaf,
|
||||
/// A channel's internal lock. One at a time, nothing acquired under it;
|
||||
/// may itself be acquired under a Leaf.
|
||||
Channel,
|
||||
}
|
||||
|
||||
// The ordering rules, mechanically enforced (debug builds): a deadlock from a
|
||||
// violated order is a hang waiting for the right interleaving, so fail at the
|
||||
// acquisition that violates it, not in the eventual hang.
|
||||
#[cfg(debug_assertions)]
|
||||
thread_local! {
|
||||
static LEAVES_HELD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
static CHANNELS_HELD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn order_check_acquire(class: LockClass) {
|
||||
#[cfg(debug_assertions)]
|
||||
match class {
|
||||
LockClass::Leaf => LEAVES_HELD.with(|l| {
|
||||
debug_assert_eq!(
|
||||
l.get(),
|
||||
0,
|
||||
"lock order violated: acquiring a Leaf RawMutex while already \
|
||||
holding one (cold locks / free list / stack pool / registry \
|
||||
are mutual leaves)"
|
||||
);
|
||||
CHANNELS_HELD.with(|c| {
|
||||
debug_assert_eq!(
|
||||
c.get(),
|
||||
0,
|
||||
"lock order violated: acquiring a Leaf RawMutex under a \
|
||||
channel lock (order is Leaf -> Channel, never the reverse)"
|
||||
);
|
||||
});
|
||||
l.set(l.get() + 1);
|
||||
}),
|
||||
LockClass::Channel => CHANNELS_HELD.with(|c| {
|
||||
debug_assert_eq!(
|
||||
c.get(),
|
||||
0,
|
||||
"lock order violated: acquiring a channel lock while already \
|
||||
holding one (channel locks are mutual leaves)"
|
||||
);
|
||||
c.set(c.get() + 1);
|
||||
}),
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
let _ = class;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn order_check_release(class: LockClass) {
|
||||
#[cfg(debug_assertions)]
|
||||
match class {
|
||||
LockClass::Leaf => LEAVES_HELD.with(|c| c.set(c.get() - 1)),
|
||||
LockClass::Channel => CHANNELS_HELD.with(|c| c.set(c.get() - 1)),
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
let _ = class;
|
||||
}
|
||||
|
||||
pub(crate) struct RawMutex<T> {
|
||||
state: AtomicU32,
|
||||
class: LockClass,
|
||||
data: UnsafeCell<T>,
|
||||
}
|
||||
|
||||
// SAFETY: standard mutex argument — exclusive access to `data` is mediated by
|
||||
// `state`; `T: Send` suffices for both because `&RawMutex` only ever hands out
|
||||
// access to one thread at a time.
|
||||
unsafe impl<T: Send> Send for RawMutex<T> {}
|
||||
unsafe impl<T: Send> Sync for RawMutex<T> {}
|
||||
|
||||
impl<T> RawMutex<T> {
|
||||
/// A Leaf-class mutex — the default for runtime cold data.
|
||||
pub(crate) const fn new(data: T) -> Self {
|
||||
Self::with_class(data, LockClass::Leaf)
|
||||
}
|
||||
|
||||
/// A Channel-class mutex — for channel internals only.
|
||||
pub(crate) const fn new_channel(data: T) -> Self {
|
||||
Self::with_class(data, LockClass::Channel)
|
||||
}
|
||||
|
||||
pub(crate) const fn with_class(data: T, class: LockClass) -> Self {
|
||||
Self {
|
||||
state: AtomicU32::new(UNLOCKED),
|
||||
class,
|
||||
data: UnsafeCell::new(data),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn lock(&self) -> RawMutexGuard<'_, T> {
|
||||
// Enter NoPreempt *before* acquiring, so a preemption can't fire
|
||||
// between acquisition and guard construction.
|
||||
let prev_preempt = crate::preempt::PREEMPTION_ENABLED.with(|c| c.replace(false));
|
||||
order_check_acquire(self.class);
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(UNLOCKED, LOCKED, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
self.lock_slow();
|
||||
}
|
||||
RawMutexGuard { m: self, prev_preempt }
|
||||
}
|
||||
|
||||
#[cold]
|
||||
fn lock_slow(&self) {
|
||||
// Bounded spin first: the expected hold time is far below the cost of
|
||||
// a futex round trip.
|
||||
let mut spins = 0;
|
||||
loop {
|
||||
let s = self.state.load(Ordering::Relaxed);
|
||||
if s == UNLOCKED
|
||||
&& self
|
||||
.state
|
||||
.compare_exchange_weak(UNLOCKED, LOCKED, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
spins += 1;
|
||||
if spins >= SPIN_LIMIT {
|
||||
break;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
// Futex path. Mark contended and sleep until woken; on wake, retake
|
||||
// by swapping to CONTENDED (we cannot know whether other waiters
|
||||
// remain, so we must conservatively keep the contended marker).
|
||||
while self.state.swap(CONTENDED, Ordering::Acquire) != UNLOCKED {
|
||||
futex_wait(&self.state, CONTENDED);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn unlock(&self) {
|
||||
if self.state.swap(UNLOCKED, Ordering::Release) == CONTENDED {
|
||||
futex_wake(&self.state, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RawMutexGuard<'a, T> {
|
||||
m: &'a RawMutex<T>,
|
||||
prev_preempt: bool,
|
||||
}
|
||||
|
||||
impl<T> Deref for RawMutexGuard<'_, T> {
|
||||
type Target = T;
|
||||
#[inline]
|
||||
fn deref(&self) -> &T {
|
||||
// SAFETY: guard existence implies exclusive ownership of the lock.
|
||||
unsafe { &*self.m.data.get() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for RawMutexGuard<'_, T> {
|
||||
#[inline]
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
// SAFETY: as above, plus &mut self.
|
||||
unsafe { &mut *self.m.data.get() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for RawMutexGuard<'_, T> {
|
||||
#[inline]
|
||||
fn drop(&mut self) {
|
||||
self.m.unlock();
|
||||
order_check_release(self.m.class);
|
||||
// Restore preemption only after the lock is released.
|
||||
crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(self.prev_preempt));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// futex (x86-64 Linux; master is x86-only, see arm-port branch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn futex_wait(state: &AtomicU32, expected: u32) {
|
||||
// SAFETY: `state` is a valid, aligned u32 for the duration of the call.
|
||||
// Spurious wakeups and EAGAIN (value already changed) are both handled by
|
||||
// the caller's retry loop.
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_futex,
|
||||
state.as_ptr(),
|
||||
libc::FUTEX_WAIT | libc::FUTEX_PRIVATE_FLAG,
|
||||
expected,
|
||||
std::ptr::null::<libc::timespec>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn futex_wake(state: &AtomicU32, n: i32) {
|
||||
// SAFETY: as above.
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_futex,
|
||||
state.as_ptr(),
|
||||
libc::FUTEX_WAKE | libc::FUTEX_PRIVATE_FLAG,
|
||||
n,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn uncontended_lock_unlock() {
|
||||
let m = RawMutex::new(0u64);
|
||||
for _ in 0..1000 {
|
||||
*m.lock() += 1;
|
||||
}
|
||||
assert_eq!(*m.lock(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contended_counter_is_exact() {
|
||||
const THREADS: usize = 8;
|
||||
const PER: u64 = 50_000;
|
||||
let m = Arc::new(RawMutex::new(0u64));
|
||||
let hs: Vec<_> = (0..THREADS)
|
||||
.map(|_| {
|
||||
let m = m.clone();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..PER {
|
||||
*m.lock() += 1;
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
assert_eq!(*m.lock(), THREADS as u64 * PER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_poison_on_unwind() {
|
||||
let m = Arc::new(RawMutex::new(0u64));
|
||||
let m2 = m.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _g = m2.lock();
|
||||
panic!("unwind while holding");
|
||||
})
|
||||
.join();
|
||||
// A std Mutex would now be poisoned; this one just works.
|
||||
*m.lock() += 1;
|
||||
assert_eq!(*m.lock(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_lock_nests_under_leaf() {
|
||||
// The permitted ordering: Leaf -> Channel (finalize/monitor clone a
|
||||
// sender under a cold lock). Must not trip the order check.
|
||||
let leaf = RawMutex::new(0u64);
|
||||
let chan = RawMutex::new_channel(0u64);
|
||||
let _l = leaf.lock();
|
||||
let _c = chan.lock();
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
#[should_panic(expected = "lock order violated")]
|
||||
fn leaf_under_channel_is_rejected() {
|
||||
let leaf = RawMutex::new(0u64);
|
||||
let chan = RawMutex::new_channel(0u64);
|
||||
let _c = chan.lock();
|
||||
let _l = leaf.lock(); // Channel -> Leaf: forbidden
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
#[should_panic(expected = "lock order violated")]
|
||||
fn two_leaves_are_rejected() {
|
||||
let a = RawMutex::new(0u64);
|
||||
let b = RawMutex::new(0u64);
|
||||
let _ga = a.lock();
|
||||
let _gb = b.lock(); // leaves are mutual: forbidden
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
#[should_panic(expected = "lock order violated")]
|
||||
fn two_channel_locks_are_rejected() {
|
||||
let a = RawMutex::new_channel(0u64);
|
||||
let b = RawMutex::new_channel(0u64);
|
||||
let _ga = a.lock();
|
||||
let _gb = b.lock(); // channel locks are mutual leaves: forbidden
|
||||
}
|
||||
}
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
//! Named mailbox registry — resolve a name (or pid) to a *messageable* actor.
|
||||
//!
|
||||
//! ## What changed (RFC 014)
|
||||
//!
|
||||
//! The old registry was a `name <-> pid` bimap: `whereis` handed back a `Pid`
|
||||
//! you could not send to, because a pid is just `(index, generation)` with no
|
||||
//! delivery endpoint. This rework makes resolution yield something messageable.
|
||||
//!
|
||||
//! Two facts shape the structure:
|
||||
//!
|
||||
//! 1. **A name resolves to a single actor.** Many actors under one label is
|
||||
//! what *process groups* (`pg`) are for; the registry is one-name-one-actor
|
||||
//! (several names *may* point at the same actor).
|
||||
//! 2. **Channels are typed**, so an actor has no single untyped mailbox. An
|
||||
//! actor instead owns a *set* of typed channels — one [`Sender`] per message
|
||||
//! type it accepts. So the registry maps name/pid to a [`Mailbox`]: a small
|
||||
//! structure holding that actor's pid plus all of its typed channels, keyed
|
||||
//! by message [`TypeId`].
|
||||
//!
|
||||
//! Resolution is therefore: `name -> pid` (single actor) `-> Mailbox -> the
|
||||
//! channel for message type M`. A `Name<Cmd>` and a `Name<Admin>` on the *same*
|
||||
//! actor select *different* channels purely by their type parameter, so
|
||||
//! capability separation (RFC 014 §4.7) needs no extra machinery.
|
||||
//!
|
||||
//! ## Type erasure is contained
|
||||
//!
|
||||
//! Each stored channel is a `Box<dyn Any + Send>` that is concretely a
|
||||
//! `Sender<M>`, filed under `TypeId::of::<M>()`. A resolve for `M` looks up
|
||||
//! that exact `TypeId` and downcasts to `Sender<M>` — keyed by the very type we
|
||||
//! downcast to, so the downcast cannot fail on correct data; a failure is a
|
||||
//! smarm bug, asserted in debug. The phantom `M` on [`Name`] re-imposes the
|
||||
//! type at the call site, so callers never touch the erasure.
|
||||
//!
|
||||
//! ## Cleanup is lazy (prune-on-contact)
|
||||
//!
|
||||
//! As before, there is no `finalize` hook and no name field on the slot. Every
|
||||
//! operation that touches a binding checks the target pid's liveness via the
|
||||
//! generation-checked slot word; a binding to a dead actor behaves as absent
|
||||
//! and is pruned on contact (its [`Mailbox`] and every name pointing at it are
|
||||
//! dropped). The cost is a dead binding lingering until something looks at it;
|
||||
//! the payoff is zero coupling to the actor lifecycle.
|
||||
//!
|
||||
//! ## Locking
|
||||
//!
|
||||
//! One `RawMutex` (Leaf class) in `RuntimeInner`, exactly like the old
|
||||
//! registry. The fold (name index *and* handles under the one lock) is what
|
||||
//! keeps a name-addressed `send` on a single Leaf — `raw_mutex` panics on a
|
||||
//! second Leaf acquired while one is held. The send path clones the `Sender`
|
||||
//! **under** the Leaf lock (a `Sender::clone` takes a Channel lock, permitted
|
||||
//! under a Leaf), then **releases** the Leaf and only *then* sends — a send can
|
||||
//! unpark a receiver, and wakeup-bearing work runs outside the Leaf. Order is
|
||||
//! **Leaf -> Channel**, as `pg`/`finalize`.
|
||||
|
||||
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;
|
||||
|
||||
/// Why a [`register`] call was rejected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RegisterError {
|
||||
/// The name is bound to a different, still-live actor.
|
||||
NameTaken { holder: Pid },
|
||||
/// The caller is not a live actor (cannot happen for `self`, kept for
|
||||
/// symmetry / future explicit-pid registration).
|
||||
NoProc,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RegisterError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RegisterError::NameTaken { holder } => {
|
||||
write!(f, "name is already registered to live actor {holder}")
|
||||
}
|
||||
RegisterError::NoProc => write!(f, "caller is not a live actor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RegisterError {}
|
||||
|
||||
/// Why a name-addressed [`send`] did not deliver. Carries the message back so
|
||||
/// the caller never loses it (mirrors [`crate::channel::SendError`]).
|
||||
///
|
||||
/// `Debug`/`Display` are hand-written so neither demands `M: Debug` — the
|
||||
/// payload is returned, not printed.
|
||||
pub enum SendError<M> {
|
||||
/// No live actor is currently registered under this name. Name-addressed
|
||||
/// [`send`] only; the pid-addressed counterpart is [`SendError::Dead`].
|
||||
Unresolved(M),
|
||||
/// The pid-addressed actor is no longer the live incarnation this pid names
|
||||
/// — it has died, even if its slot now holds a *different* actor (a direct
|
||||
/// `Pid<A>` send never redirects; contrast name-addressed [`send`]). Pid
|
||||
/// paths ([`send_to`] / [`send_dyn`]) only.
|
||||
Dead(M),
|
||||
/// The actor is live but exposes no channel for this message type.
|
||||
NoChannel(M),
|
||||
/// The actor's channel for this message type is closed (its receiver is gone).
|
||||
Closed(M),
|
||||
/// No live member to deliver to — a [`dispatch`](crate::dispatch) over an
|
||||
/// empty (or all-dead) process group. Group-addressed dispatch only; the
|
||||
/// name-addressed counterpart is [`SendError::Unresolved`]. The message is
|
||||
/// handed back undelivered.
|
||||
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 RFC 016 snapshot (queued length without knowing `M`).
|
||||
/// A bare `Box<dyn Any>` gives the first but not the second, so we erase behind
|
||||
/// this small trait instead.
|
||||
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
|
||||
/// observers (RFC 014 §4.5) 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 = ch
|
||||
.sender
|
||||
.as_any()
|
||||
.downcast_ref::<Sender<M>>()
|
||||
.expect("channel keyed by TypeId but downcast to its own type failed — smarm bug");
|
||||
debug_assert_eq!(ch.msg_type, type_name::<M>(), "msg_type / TypeId disagree");
|
||||
Some(tx.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-actor registry view handed to RFC 016 introspection: registered names
|
||||
/// 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 index of a [`Mailbox`] present in `by_index`, and that
|
||||
/// mailbox's `pid.index()` equals the key. Stale entries (dead actors) violate
|
||||
/// nothing — they are simply pruned on contact.
|
||||
pub(crate) struct Registry {
|
||||
/// `pid.index() -> the actor's mailbox`. The handle store.
|
||||
by_index: HashMap<u32, Mailbox>,
|
||||
/// `name -> pid.index()`. Several names may map to one actor.
|
||||
by_name: HashMap<&'static str, u32>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { by_index: HashMap::new(), by_name: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Drop a dead actor's mailbox and every name that pointed at it.
|
||||
fn prune(&mut self, index: u32) {
|
||||
self.by_index.remove(&index);
|
||||
self.by_name.retain(|_, idx| *idx != index);
|
||||
}
|
||||
|
||||
/// RFC 016 snapshot input: per-slot-index registry view — the actor's
|
||||
/// registered names (inverted from `by_name`) and its mailbox depth (queued
|
||||
/// messages summed across every published typed channel). Built in one pass
|
||||
/// under the registry Leaf; the per-channel `queued_len` takes a Channel
|
||||
/// lock, legal under the Leaf (Leaf → Channel). Carries each mailbox's full
|
||||
/// `pid` so the caller can discard a stale incarnation's entry against the
|
||||
/// slab's live generation. Names that dangle (point at no mailbox) are
|
||||
/// dropped — they violate no invariant and get pruned on next contact.
|
||||
pub(crate) fn introspect_map(&self) -> HashMap<u32, MailboxInfo> {
|
||||
let mut names: HashMap<u32, Vec<&'static str>> = HashMap::new();
|
||||
for (&name, &idx) in &self.by_name {
|
||||
names.entry(idx).or_default().push(name);
|
||||
}
|
||||
let mut 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(&idx).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 `actor_info` so its cost stays proportional to the one
|
||||
/// actor rather than locking every channel in the runtime.
|
||||
pub(crate) fn introspect_one(&self, idx: u32) -> Option<MailboxInfo> {
|
||||
let mb = self.by_index.get(&idx)?;
|
||||
let depth: usize = mb.channels.values().map(|c| c.sender.queued_len()).sum();
|
||||
let names = self
|
||||
.by_name
|
||||
.iter()
|
||||
.filter_map(|(&n, &i)| (i == idx).then_some(n))
|
||||
.collect();
|
||||
Some(MailboxInfo { pid: mb.pid, names, depth: depth.min(u32::MAX as usize) as u32 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `pid` a live actor right now? Atomic slot-word read; no lock.
|
||||
fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
|
||||
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
|
||||
}
|
||||
|
||||
/// Publish the current actor's `Sender<M>` under `name`, capturing the channel
|
||||
/// so the name becomes messageable. Idempotent for the same `(name, type)`;
|
||||
/// registering a *second* type under the same (or another) name on the same
|
||||
/// actor just adds another channel to the actor's mailbox.
|
||||
///
|
||||
/// Fails with [`RegisterError::NameTaken`] if the name is held by a *different*
|
||||
/// live actor (a binding to a dead actor is pruned and the name treated as
|
||||
/// free). Panics if called outside `Runtime::run()`.
|
||||
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| {
|
||||
let mut reg = inner.registry.lock();
|
||||
if !live(inner, me) {
|
||||
return Err(RegisterError::NoProc);
|
||||
}
|
||||
if let Some(&holder_idx) = reg.by_name.get(key) {
|
||||
match reg.by_index.get(&holder_idx).map(|m| m.pid) {
|
||||
Some(holder) if holder == me => {} // same actor: just add the channel below
|
||||
Some(holder) if live(inner, holder) => {
|
||||
return Err(RegisterError::NameTaken { holder });
|
||||
}
|
||||
Some(_) => reg.prune(holder_idx), // dead holder: free the name
|
||||
None => {
|
||||
reg.by_name.remove(key); // dangling name: free it
|
||||
}
|
||||
}
|
||||
}
|
||||
// Publish (or extend) the mailbox with this channel, then bind the name.
|
||||
publish_channel::<M>(&mut reg, me, tx);
|
||||
reg.by_name.insert(key, me.index());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or extend the current actor's mailbox with one typed channel, filed
|
||||
/// 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 the opt-in, lazy install of RFC 014 §5: an actor
|
||||
/// that wants to be reachable by a direct, identity-bound [`Pid<A>`] (rather
|
||||
/// than only via a re-resolving [`Name`]) calls this once with its inbox
|
||||
/// sender, then hands the returned pid out.
|
||||
///
|
||||
/// Unlike [`register`] there is no name to collide on, and `self` is always a
|
||||
/// live actor inside `run()`, so this is infallible. Panics if called outside
|
||||
/// `Runtime::run()`.
|
||||
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 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);
|
||||
});
|
||||
}
|
||||
|
||||
/// The single actor currently registered under `name`, or `None` if unbound or
|
||||
/// no longer live (the stale binding is pruned on the way out).
|
||||
pub fn whereis(name: &str) -> Option<Pid> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = *reg.by_name.get(name)?;
|
||||
match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => Some(pid),
|
||||
Some(_) => {
|
||||
reg.prune(idx);
|
||||
None
|
||||
}
|
||||
None => {
|
||||
reg.by_name.remove(name);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `name` to a *typed* [`Pid<A>`] — the identity-bound counterpart of
|
||||
/// [`whereis`] (RFC 014 §4.4). Recovers the compile-checked
|
||||
/// [`send_to`](crate::send_to) path from a durable name: looks the name up,
|
||||
/// then re-types the erased pid as `Pid<A>` via the unchecked
|
||||
/// [`assert_type`](crate::pid::assert_type) primitive. A wrong `A` is not
|
||||
/// unsound — it degrades to [`SendError::NoChannel`] on the next send (routing
|
||||
/// is by message `TypeId`), never a misdelivery. `None` if unbound or dead.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn lookup_as<A: Addressable>(name: &str) -> Option<Pid<A>> {
|
||||
whereis(name).map(crate::pid::assert_type::<A>)
|
||||
}
|
||||
|
||||
/// Resolve `name` to its actor's pid and a cloned `Sender<M>`, under the Leaf
|
||||
/// lock (clone-under-lock, then release). The crate-internal building block for
|
||||
/// `gen_server`'s by-name addressing: a named server publishes its inbox as a
|
||||
/// `Sender<Envelope<G>>` (via [`register_with`]), and `whereis_server` / `call`
|
||||
/// / `cast` recover that exact typed sender here to rebuild a `ServerRef<G>`.
|
||||
/// `None` if unbound, dead (pruned on the way out), or holding no `M` channel.
|
||||
pub(crate) fn resolve_named_sender<M: Send + 'static>(name: &str) -> Option<(Pid, Sender<M>)> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = *reg.by_name.get(name)?;
|
||||
let pid = match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => pid,
|
||||
Some(_) => {
|
||||
reg.prune(idx);
|
||||
return None;
|
||||
}
|
||||
None => {
|
||||
reg.by_name.remove(name);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let tx = reg.by_index.get(&idx).and_then(Mailbox::clone_sender::<M>)?;
|
||||
Some((pid, tx))
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the binding for `name`, returning the actor it pointed at if still
|
||||
/// live. Only the *name* is freed; the actor's mailbox (and any other names for
|
||||
/// it) remain. A binding to a dead actor is reported as `None`.
|
||||
pub fn unregister(name: &str) -> Option<Pid> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = reg.by_name.remove(name)?;
|
||||
match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => Some(pid),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `name` to its actor's `Sender<M>` and deliver `msg`. The whole point
|
||||
/// of the rework: a name you can *send* to.
|
||||
///
|
||||
/// Errors (message returned in every case): [`SendError::Unresolved`] if no
|
||||
/// live actor holds the name, [`SendError::NoChannel`] if that actor has no
|
||||
/// channel for `M`, [`SendError::Closed`] if its `M` channel's receiver is
|
||||
/// gone. Panics if called outside `Runtime::run()`.
|
||||
pub fn send<M: Send + 'static>(name: Name<M>, msg: M) -> Result<(), SendError<M>> {
|
||||
let key = name.as_str();
|
||||
with_runtime(|inner| {
|
||||
// Resolve + clone the sender under the Leaf lock, then drop the lock
|
||||
// before sending (a send can unpark a receiver).
|
||||
let tx = {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = match reg.by_name.get(key) {
|
||||
Some(&i) => i,
|
||||
None => return Err(SendError::Unresolved(msg)),
|
||||
};
|
||||
let pid = match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
reg.by_name.remove(key);
|
||||
return Err(SendError::Unresolved(msg));
|
||||
}
|
||||
};
|
||||
if !live(inner, pid) {
|
||||
reg.prune(idx);
|
||||
return Err(SendError::Unresolved(msg));
|
||||
}
|
||||
match reg.by_index.get(&idx).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 we leave untouched). Shared by [`send_to`]
|
||||
/// (typed, `M = A::Msg`, channel guaranteed on an installed actor) and
|
||||
/// [`send_dyn`] (explicit `M`, where `NoChannel` is a real outcome).
|
||||
fn send_to_pid<M: Send + 'static>(
|
||||
inner: &crate::runtime::RuntimeInner,
|
||||
pid: Pid,
|
||||
msg: M,
|
||||
) -> Result<(), SendError<M>> {
|
||||
// Resolve + clone the sender under the Leaf lock, then drop the lock before
|
||||
// sending (a send can unpark a receiver) — Leaf -> Channel, as name `send`.
|
||||
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(pid.index());
|
||||
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` to the exact actor named by `pid` — RFC 014 §4.2's direct,
|
||||
/// identity-bound addressing mode. Unlike name-addressed [`send`] there is **no
|
||||
/// redirect**: if that incarnation has died the message comes back as
|
||||
/// [`SendError::Dead`], even if its slot now holds a different actor.
|
||||
///
|
||||
/// The message type is the actor's `A::Msg`, so on a live actor that has
|
||||
/// installed its inbox (via [`install`] or [`register`]) the channel is always
|
||||
/// present; [`SendError::NoChannel`] therefore means the actor is live but
|
||||
/// never published a `Pid<A>`-reachable inbox. Panics if called outside
|
||||
/// `Runtime::run()`.
|
||||
pub fn send_to<A: Addressable>(pid: Pid<A>, msg: A::Msg) -> Result<(), SendError<A::Msg>> {
|
||||
with_runtime(|inner| send_to_pid::<A::Msg>(inner, pid.erase(), msg))
|
||||
}
|
||||
|
||||
/// The explicit bare-pid escape hatch (RFC 014 §4.6): deliver `msg` of type `M`
|
||||
/// to `pid` when all you hold is an untyped [`Pid`] — a pid off a [`Down`], or
|
||||
/// out of a future `members()` — so the typed [`send_to`] is unavailable.
|
||||
///
|
||||
/// This is the one send whose message type can genuinely be wrong: the actor
|
||||
/// may be live yet expose no channel for `M`, returning [`SendError::NoChannel`]
|
||||
/// (on the typed paths that downcast collapses to a `debug_assert`). It is
|
||||
/// named and documented as the fallible fallback so the typed `Pid<A>` /
|
||||
/// `Name<M>` paths stay the obvious default and an agentic caller reaches for a
|
||||
/// present primitive instead of inventing a workaround. Liveness is identical
|
||||
/// to [`send_to`]: identity-bound, no redirect, [`SendError::Dead`] once the
|
||||
/// addressed incarnation is gone. Panics if called outside `Runtime::run()`.
|
||||
///
|
||||
/// [`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))
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! The run queue, selected at COMPILE TIME by mutually-exclusive cargo
|
||||
//! features (no runtime dispatch — the scheduler's pop loop is the hottest
|
||||
//! code in the runtime):
|
||||
//!
|
||||
//! - `rq-mutex` (default) — `Mutex<VecDeque>`. The control/baseline:
|
||||
//! strictly FIFO, trivially correct, one global lock.
|
||||
//! - `rq-mpmc` — a single hand-rolled Vyukov bounded MPMC ring (per-cell
|
||||
//! sequence numbers). Strict FIFO, lock-free, one hot
|
||||
//! enqueue/dequeue cache-line pair.
|
||||
//! - `rq-striped` — M Vyukov rings with fetch-add ticket distribution.
|
||||
//! *Relaxed* FIFO: ordering across stripes is bounded-skewed
|
||||
//! (≈ one ring's worth of reordering per stripe), in exchange
|
||||
//! for spreading the hot line M ways. Predicted winner at
|
||||
//! high core counts; phase 4's shootout decides.
|
||||
//!
|
||||
//! Select non-default variants with `--no-default-features --features rq-…`
|
||||
//! (cargo features are additive, so the default must be switched off).
|
||||
//!
|
||||
//! All variants are compiled unconditionally (so every build runs every
|
||||
//! variant's unit tests); the feature only picks which one the runtime uses
|
||||
//! via the [`RunQueue`] alias.
|
||||
//!
|
||||
//! # Contract (shared by all variants)
|
||||
//!
|
||||
//! - **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
|
||||
//! scheduler transitions `Queued → Running` — see the state-machine docs
|
||||
//! in `runtime.rs`), and at most `max_actors` actors exist. With the RFC
|
||||
//! 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.
|
||||
//! - **Preemption must be disabled around every push/pop** (debug-asserted).
|
||||
//! For the mutex variant this is the usual no-switch/no-unwind-under-lock
|
||||
//! rule. For the rings it is *load-bearing in a sharper way*: a producer
|
||||
//! suspended between claiming a cell and publishing its sequence number
|
||||
//! stalls every consumer behind that cell — on a busy runtime that is a
|
||||
//! livelock, since the suspended actor's own resume entry sits behind the
|
||||
//! hole. Callers get this for free: every queue op happens inside
|
||||
//! `with_runtime`/`try_with_runtime` (NoPreempt for their span since
|
||||
//! phase 2) or on a scheduler thread between resumes (preemption off).
|
||||
//! - **`pop() == None` is a snapshot, not a fence.** A push that is mid-
|
||||
//! publish (or in a stripe the probe already passed) may be missed; the
|
||||
//! caller's idle path sleeps ≤ 100µs and retries, so the cost is a bounded
|
||||
//! latency blip, never a lost entry. Termination does not lean on this:
|
||||
//! the all-clear is `live_actors == 0` (+ io quiescent), and `live == 0`
|
||||
//! already implies the queue holds nothing actionable — see the argument
|
||||
//! in `schedule_loop`.
|
||||
//! - `len()` is approximate (stats only).
|
||||
|
||||
use crate::pid::Pid;
|
||||
use crate::sync_shim::{AtomicUsize, Ordering, UnsafeCell};
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(any(feature = "rq-mutex", feature = "rq-mpmc", feature = "rq-striped")))]
|
||||
compile_error!(
|
||||
"smarm: no run queue selected. Enable exactly one of the features \
|
||||
`rq-mutex` (default), `rq-mpmc`, `rq-striped`."
|
||||
);
|
||||
#[cfg(all(feature = "rq-mutex", feature = "rq-mpmc"))]
|
||||
compile_error!(
|
||||
"smarm: features `rq-mutex` and `rq-mpmc` are mutually exclusive \
|
||||
(use --no-default-features to drop the default `rq-mutex`)."
|
||||
);
|
||||
#[cfg(all(feature = "rq-mutex", feature = "rq-striped"))]
|
||||
compile_error!(
|
||||
"smarm: features `rq-mutex` and `rq-striped` are mutually exclusive \
|
||||
(use --no-default-features to drop the default `rq-mutex`)."
|
||||
);
|
||||
#[cfg(all(feature = "rq-mpmc", feature = "rq-striped"))]
|
||||
compile_error!("smarm: features `rq-mpmc` and `rq-striped` are mutually exclusive.");
|
||||
|
||||
#[cfg(feature = "rq-mutex")]
|
||||
pub(crate) type RunQueue = MutexQueue;
|
||||
#[cfg(feature = "rq-mpmc")]
|
||||
pub(crate) type RunQueue = MpmcRing;
|
||||
#[cfg(feature = "rq-striped")]
|
||||
pub(crate) type RunQueue = StripedRing;
|
||||
|
||||
#[inline]
|
||||
fn assert_no_preempt() {
|
||||
debug_assert!(
|
||||
!crate::preempt::PREEMPTION_ENABLED.with(|c| c.get()),
|
||||
"run-queue op with preemption enabled — a switch mid-op stalls or \
|
||||
corrupts the queue; route through with_runtime or scheduler context"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rq-mutex — the baseline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct MutexQueue {
|
||||
q: std::sync::Mutex<std::collections::VecDeque<Pid>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MutexQueue {
|
||||
pub fn new(_threads: usize, max_actors: usize) -> Self {
|
||||
Self {
|
||||
// Pre-size: the queue can never outgrow the slab, and one
|
||||
// allocation at init beats reallocating under the lock later.
|
||||
q: std::sync::Mutex::new(std::collections::VecDeque::with_capacity(max_actors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, pid: Pid) {
|
||||
assert_no_preempt();
|
||||
self.q.lock().unwrap().push_back(pid);
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<Pid> {
|
||||
assert_no_preempt();
|
||||
self.q.lock().unwrap().pop_front()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
self.q.lock().unwrap().len() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rq-mpmc — Vyukov bounded MPMC ring
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Dmitry Vyukov's bounded MPMC queue: each cell carries a sequence number.
|
||||
// A producer may write cell `i` when `seq == pos` (its turn); it publishes
|
||||
// with `seq = pos + 1`. A consumer may read when `seq == pos + 1`; it
|
||||
// releases the cell to the next lap with `seq = pos + capacity`. Producers
|
||||
// and consumers each contend on one counter; cell handoff is a per-cell
|
||||
// Acquire/Release pair, so unrelated push/pop pairs don't serialize.
|
||||
|
||||
/// Pad to a cache-line pair so the producer and consumer counters (and the
|
||||
/// cells) don't false-share.
|
||||
#[repr(align(128))]
|
||||
struct CachePadded<T>(T);
|
||||
|
||||
struct Cell {
|
||||
seq: AtomicUsize,
|
||||
pid: UnsafeCell<MaybeUninit<Pid>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct MpmcRing {
|
||||
buf: Box<[Cell]>,
|
||||
mask: usize,
|
||||
enqueue_pos: CachePadded<AtomicUsize>,
|
||||
dequeue_pos: CachePadded<AtomicUsize>,
|
||||
}
|
||||
|
||||
// SAFETY: cells are handed off between threads via the per-cell seq
|
||||
// (Release on publish, Acquire on claim); Pid is Copy + Send.
|
||||
unsafe impl Send for MpmcRing {}
|
||||
unsafe impl Sync for MpmcRing {}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MpmcRing {
|
||||
pub fn new(_threads: usize, max_actors: usize) -> Self {
|
||||
Self::with_capacity(max_actors)
|
||||
}
|
||||
|
||||
/// Pub for the raw-structure microbench (sized to the op count so the
|
||||
/// occupancy contract is trivially met there). Runtime code uses `new`.
|
||||
pub fn with_capacity(min_cap: usize) -> Self {
|
||||
// Occupancy ≤ max_actors (queue contract), so capacity = the next
|
||||
// power of two ≥ max_actors can never overflow. (≥ 2 so mask works.)
|
||||
let cap = min_cap.next_power_of_two().max(2);
|
||||
let buf: Box<[Cell]> = (0..cap)
|
||||
.map(|i| Cell {
|
||||
seq: AtomicUsize::new(i),
|
||||
pid: UnsafeCell::new(MaybeUninit::uninit()),
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
buf,
|
||||
mask: cap - 1,
|
||||
enqueue_pos: CachePadded(AtomicUsize::new(0)),
|
||||
dequeue_pos: CachePadded(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, pid: Pid) {
|
||||
assert_no_preempt();
|
||||
assert!(
|
||||
self.try_push(pid),
|
||||
"smarm: run queue overflow — occupancy exceeded the slab bound, \
|
||||
which the at-most-once-enqueued invariant forbids. This is a \
|
||||
runtime bug (double enqueue), not a capacity tuning problem."
|
||||
);
|
||||
}
|
||||
|
||||
/// One full claim attempt; `false` only if the ring is full.
|
||||
fn try_push(&self, pid: Pid) -> bool {
|
||||
let mut pos = self.enqueue_pos.0.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let cell = &self.buf[pos & self.mask];
|
||||
let seq = cell.seq.load(Ordering::Acquire);
|
||||
let diff = seq as isize - pos as isize;
|
||||
if diff == 0 {
|
||||
// Our turn: claim the position.
|
||||
match self.enqueue_pos.0.compare_exchange_weak(
|
||||
pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// SAFETY: the claim gives us exclusive write access
|
||||
// to this cell until we publish below.
|
||||
cell.pid.with_mut(|p| unsafe { (*p).write(pid) });
|
||||
cell.seq.store(pos + 1, Ordering::Release);
|
||||
return true;
|
||||
}
|
||||
Err(actual) => pos = actual,
|
||||
}
|
||||
} else if diff < 0 {
|
||||
return false; // full — a whole lap behind
|
||||
} else {
|
||||
pos = self.enqueue_pos.0.load(Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<Pid> {
|
||||
assert_no_preempt();
|
||||
let mut pos = self.dequeue_pos.0.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let cell = &self.buf[pos & self.mask];
|
||||
let seq = cell.seq.load(Ordering::Acquire);
|
||||
let diff = seq as isize - (pos + 1) as isize;
|
||||
if diff == 0 {
|
||||
match self.dequeue_pos.0.compare_exchange_weak(
|
||||
pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// SAFETY: the claim gives us exclusive read access;
|
||||
// the producer's Release publish made `pid` visible
|
||||
// to our Acquire load of `seq`.
|
||||
let pid = cell.pid.with(|p| unsafe { (*p).assume_init_read() });
|
||||
// Release the cell for the next lap.
|
||||
cell.seq.store(pos + self.mask + 1, Ordering::Release);
|
||||
return Some(pid);
|
||||
}
|
||||
Err(actual) => pos = actual,
|
||||
}
|
||||
} else if diff < 0 {
|
||||
// Empty (or the producer at this cell hasn't published yet —
|
||||
// a snapshot miss the caller's idle-retry loop absorbs).
|
||||
return None;
|
||||
} else {
|
||||
pos = self.dequeue_pos.0.load(Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
let e = self.enqueue_pos.0.load(Ordering::Relaxed);
|
||||
let d = self.dequeue_pos.0.load(Ordering::Relaxed);
|
||||
e.saturating_sub(d) as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rq-striped — M Vyukov rings, ticket-distributed
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Producers fetch-add a ticket and start probing at stripe `ticket % M`;
|
||||
// consumers do the same with their own ticket. Under symmetric load the
|
||||
// tickets spread producers and consumers uniformly, so each stripe sees
|
||||
// ~1/M of the traffic and the single hot cache-line pair becomes M cooler
|
||||
// ones. FIFO is relaxed: two pushes that land in different stripes can be
|
||||
// popped in either order, with skew bounded by stripe occupancy imbalance.
|
||||
//
|
||||
// Push probes forward from its home stripe until a `try_push` succeeds.
|
||||
// Σ stripe capacity ≥ 2 × max_actors while occupancy ≤ max_actors, so at
|
||||
// every instant at least half the total capacity is free and the probe
|
||||
// terminates (in practice on the first stripe).
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct StripedRing {
|
||||
stripes: Box<[MpmcRing]>,
|
||||
/// Stripe count minus one (count is a power of two).
|
||||
stripe_mask: usize,
|
||||
push_ticket: CachePadded<AtomicUsize>,
|
||||
pop_ticket: CachePadded<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl StripedRing {
|
||||
pub fn new(threads: usize, max_actors: usize) -> Self {
|
||||
// One stripe per scheduler thread, rounded up to a power of two —
|
||||
// more stripes than threads buys nothing (at most `threads` ops are
|
||||
// in flight) and costs pop-probe latency when mostly empty.
|
||||
let n = threads.max(1).next_power_of_two();
|
||||
// Per-stripe capacity: 2 × max_actors / n in total, and never below
|
||||
// a floor that keeps degenerate configs (tiny slab, many threads)
|
||||
// trivially correct.
|
||||
let per = ((2 * max_actors) / n).next_power_of_two().max(8);
|
||||
let stripes: Box<[MpmcRing]> = (0..n).map(|_| MpmcRing::with_capacity(per)).collect();
|
||||
Self {
|
||||
stripes,
|
||||
stripe_mask: n - 1,
|
||||
push_ticket: CachePadded(AtomicUsize::new(0)),
|
||||
pop_ticket: CachePadded(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, pid: Pid) {
|
||||
assert_no_preempt();
|
||||
let home = self.push_ticket.0.fetch_add(1, Ordering::Relaxed);
|
||||
// Probe from the home stripe; capacity headroom (Σ ≥ 2×occupancy)
|
||||
// guarantees a free stripe exists, so the outer loop terminates.
|
||||
// The retry-from-home lap handles the racy case where every stripe
|
||||
// momentarily refused us.
|
||||
loop {
|
||||
for i in 0..=self.stripe_mask {
|
||||
let s = &self.stripes[(home + i) & self.stripe_mask];
|
||||
if s.try_push(pid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<Pid> {
|
||||
assert_no_preempt();
|
||||
let home = self.pop_ticket.0.fetch_add(1, Ordering::Relaxed);
|
||||
for i in 0..=self.stripe_mask {
|
||||
if let Some(pid) = self.stripes[(home + i) & self.stripe_mask].pop() {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
None // snapshot miss possible across stripes; idle-retry absorbs it
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
self.stripes.iter().map(|s| s.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — all variants, in every build (the feature only picks the alias)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, not(loom)))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn pid(i: u32) -> Pid {
|
||||
Pid::new(i, 0)
|
||||
}
|
||||
|
||||
fn fifo_smoke<Q>(q: &Q, push: impl Fn(&Q, Pid), pop: impl Fn(&Q) -> Option<Pid>) {
|
||||
for i in 0..100 {
|
||||
push(q, pid(i));
|
||||
}
|
||||
for i in 0..100 {
|
||||
assert_eq!(pop(q), Some(pid(i)));
|
||||
}
|
||||
assert_eq!(pop(q), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutex_fifo() {
|
||||
let q = MutexQueue::new(1, 1024);
|
||||
fifo_smoke(&q, |q, p| q.push(p), |q| q.pop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpmc_fifo_single_thread() {
|
||||
let q = MpmcRing::new(1, 1024);
|
||||
fifo_smoke(&q, |q, p| q.push(p), |q| q.pop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpmc_wraps_many_laps() {
|
||||
let q = MpmcRing::with_capacity(8);
|
||||
for lap in 0..1000u32 {
|
||||
for i in 0..8 {
|
||||
q.push(pid(lap * 8 + i));
|
||||
}
|
||||
for i in 0..8 {
|
||||
assert_eq!(q.pop(), Some(pid(lap * 8 + i)));
|
||||
}
|
||||
}
|
||||
assert_eq!(q.pop(), None);
|
||||
}
|
||||
|
||||
/// N producers, M consumers, every element exactly once. Run on plain OS
|
||||
/// threads (PREEMPTION_ENABLED defaults false, satisfying the contract).
|
||||
fn exactly_once<Q: Send + Sync + 'static>(
|
||||
q: Q,
|
||||
push: fn(&Q, Pid),
|
||||
pop: fn(&Q) -> Option<Pid>,
|
||||
producers: u32,
|
||||
consumers: u32,
|
||||
per_producer: u32,
|
||||
) {
|
||||
let q = Arc::new(q);
|
||||
let total = (producers * per_producer) as usize;
|
||||
let popped = Arc::new(std::sync::Mutex::new(Vec::with_capacity(total)));
|
||||
let remaining = Arc::new(AtomicUsize::new(total));
|
||||
|
||||
let mut hs = Vec::new();
|
||||
for p in 0..producers {
|
||||
let q = q.clone();
|
||||
hs.push(std::thread::spawn(move || {
|
||||
for i in 0..per_producer {
|
||||
push(&q, pid(p * per_producer + i));
|
||||
}
|
||||
}));
|
||||
}
|
||||
for _ in 0..consumers {
|
||||
let q = q.clone();
|
||||
let popped = popped.clone();
|
||||
let remaining = remaining.clone();
|
||||
hs.push(std::thread::spawn(move || {
|
||||
let mut local = Vec::new();
|
||||
while remaining.load(Ordering::Relaxed) > 0 {
|
||||
if let Some(pid) = pop(&q) {
|
||||
remaining.fetch_sub(1, Ordering::Relaxed);
|
||||
local.push(pid);
|
||||
} else {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
popped.lock().unwrap().extend(local);
|
||||
}));
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
let popped = popped.lock().unwrap();
|
||||
assert_eq!(popped.len(), total, "count mismatch");
|
||||
let set: HashSet<u64> = popped.iter().map(|p| ((p.index() as u64) << 32) | p.generation() as u64).collect();
|
||||
assert_eq!(set.len(), total, "duplicate or lost element");
|
||||
assert_eq!(pop(&q), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpmc_exactly_once_contended() {
|
||||
exactly_once(MpmcRing::new(8, 4096), |q, p| q.push(p), |q| q.pop(), 4, 4, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn striped_exactly_once_contended() {
|
||||
exactly_once(StripedRing::new(8, 4096), |q, p| q.push(p), |q| q.pop(), 4, 4, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn striped_drains_after_skewed_load() {
|
||||
// Hammer pushes from one thread (all tickets walk the stripes in
|
||||
// order) and verify a single consumer sees every element.
|
||||
let q = StripedRing::new(4, 64);
|
||||
let mut seen = HashSet::new();
|
||||
for i in 0..64 {
|
||||
q.push(pid(i));
|
||||
}
|
||||
while let Some(p) = q.pop() {
|
||||
assert!(seen.insert(p.index()));
|
||||
}
|
||||
assert_eq!(seen.len(), 64);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loom model tests — RUSTFLAGS="--cfg loom" cargo test --lib --release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, loom))]
|
||||
mod loom_tests {
|
||||
use super::*;
|
||||
use loom::sync::Arc;
|
||||
use loom::thread;
|
||||
|
||||
fn pid(i: u32) -> Pid {
|
||||
Pid::new(i, 0)
|
||||
}
|
||||
|
||||
/// Two producers, main-thread consumer: both elements arrive exactly
|
||||
/// once, across every interleaving — including through a lap wraparound
|
||||
/// (capacity 2 forces cell reuse).
|
||||
#[test]
|
||||
fn mpmc_two_producers_exactly_once() {
|
||||
loom::model(|| {
|
||||
let q = Arc::new(MpmcRing::with_capacity(2));
|
||||
let mut hs = Vec::new();
|
||||
for i in 0..2u32 {
|
||||
let q = q.clone();
|
||||
hs.push(thread::spawn(move || q.push(pid(i))));
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
while got.len() < 2 {
|
||||
match q.pop() {
|
||||
Some(p) => got.push(p.index()),
|
||||
None => thread::yield_now(),
|
||||
}
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, vec![0, 1]);
|
||||
assert!(q.pop().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
/// Producer races a consumer on a single element: the consumer either
|
||||
/// gets it or sees a clean None — never a torn/duplicated element.
|
||||
#[test]
|
||||
fn mpmc_push_pop_race() {
|
||||
loom::model(|| {
|
||||
let q = Arc::new(MpmcRing::with_capacity(2));
|
||||
let q2 = q.clone();
|
||||
let prod = thread::spawn(move || q2.push(pid(7)));
|
||||
let seen = q.pop();
|
||||
prod.join().unwrap();
|
||||
match seen {
|
||||
Some(p) => {
|
||||
assert_eq!(p.index(), 7);
|
||||
assert!(q.pop().is_none());
|
||||
}
|
||||
None => assert_eq!(q.pop().map(|p| p.index()), Some(7)),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Striped: two producers landing in (potentially) different stripes,
|
||||
/// main-thread consumer drains both exactly once.
|
||||
#[test]
|
||||
fn striped_two_producers_exactly_once() {
|
||||
loom::model(|| {
|
||||
let q = Arc::new(StripedRing::new(2, 4));
|
||||
let mut hs = Vec::new();
|
||||
for i in 0..2u32 {
|
||||
let q = q.clone();
|
||||
hs.push(thread::spawn(move || q.push(pid(i))));
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
while got.len() < 2 {
|
||||
match q.pop() {
|
||||
Some(p) => got.push(p.index()),
|
||||
None => thread::yield_now(),
|
||||
}
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, vec![0, 1]);
|
||||
assert!(q.pop().is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
+1068
-438
File diff suppressed because it is too large
Load Diff
+507
-138
@@ -9,7 +9,7 @@
|
||||
|
||||
use crate::actor::current_pid;
|
||||
use crate::channel::Sender;
|
||||
use crate::pid::Pid;
|
||||
use crate::pid::{Name, Pid};
|
||||
use crate::runtime::{
|
||||
self, RuntimeInner, YieldIntent, RUNTIME,
|
||||
};
|
||||
@@ -21,18 +21,39 @@ use std::sync::Arc;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Borrow the current runtime. Panics if called outside `Runtime::run()`.
|
||||
///
|
||||
/// The whole span runs with preemption disabled. Two reasons, both load-
|
||||
/// bearing:
|
||||
///
|
||||
/// - The `RUNTIME` thread-local borrow is live across `f`. A preemption-
|
||||
/// driven context switch inside `f` can resume the actor on a DIFFERENT OS
|
||||
/// thread; the borrow guard would then increment this thread's RefCell but
|
||||
/// decrement the other's — a count underflow that leaves that thread's
|
||||
/// RefCell permanently "mutably borrowed". Holding a thread-local guard
|
||||
/// across a potential switch point is the one unforgivable sin of green
|
||||
/// threads; disabling preemption makes "no switch inside `f`" structural
|
||||
/// instead of an accident of which bodies happen to allocate.
|
||||
/// - `f` is runtime bookkeeping. Suspending an actor halfway through it (or
|
||||
/// unwinding via the stop sentinel, which shares the gate) is never wanted.
|
||||
pub(crate) fn with_runtime<R>(f: impl FnOnce(&Arc<RuntimeInner>) -> R) -> R {
|
||||
RUNTIME.with(|r| {
|
||||
let prev = crate::preempt::PREEMPTION_ENABLED.with(|c| c.replace(false));
|
||||
let result = RUNTIME.with(|r| {
|
||||
let b = r.borrow();
|
||||
let inner = b.as_ref().expect("smarm: not inside Runtime::run()");
|
||||
f(inner)
|
||||
})
|
||||
});
|
||||
crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(prev));
|
||||
result
|
||||
}
|
||||
|
||||
/// Borrow the runtime if present; returns `None` otherwise.
|
||||
/// Used on cleanup paths (channel Drop during teardown).
|
||||
/// Same preemption gate as [`with_runtime`]; same reasons.
|
||||
pub(crate) fn try_with_runtime<R>(f: impl FnOnce(&Arc<RuntimeInner>) -> R) -> Option<R> {
|
||||
RUNTIME.with(|r| r.borrow().as_ref().map(|inner| f(inner)))
|
||||
let prev = crate::preempt::PREEMPTION_ENABLED.with(|c| c.replace(false));
|
||||
let result = RUNTIME.with(|r| r.borrow().as_ref().map(|inner| f(inner)));
|
||||
crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(prev));
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,22 +75,34 @@ impl JoinHandle {
|
||||
|
||||
pub fn join(mut self) -> Result<(), JoinError> {
|
||||
use crate::actor::Outcome;
|
||||
use crate::runtime::State; // need State visibility
|
||||
|
||||
let me = current_pid().expect("join() called outside an actor");
|
||||
|
||||
loop {
|
||||
// Check-Done-or-register-waiter is atomic under the target's cold
|
||||
// lock; finalize publishes Done and takes the waiter list under
|
||||
// the same lock, so we either see the outcome or are woken.
|
||||
let outcome = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let slot = s.slot_mut(self.pid)
|
||||
.expect("join: target slot has been reused");
|
||||
if matches!(slot.state, State::Done) {
|
||||
Some(slot.outcome.take().expect("Done slot must have outcome"))
|
||||
} else {
|
||||
slot.waiters.push(me);
|
||||
let slot = inner.slot_at(self.pid)
|
||||
.expect("join: pid index out of range");
|
||||
let mut cold = slot.cold.lock();
|
||||
match slot.status_for(self.pid) {
|
||||
// Our outstanding handle pins the slot: it cannot be
|
||||
// reclaimed (generation cannot change) while we hold it.
|
||||
crate::slot_state::Status::Stale => {
|
||||
panic!("join: target slot has been reused")
|
||||
}
|
||||
crate::slot_state::Status::Done => {
|
||||
Some(cold.outcome.take().expect("Done slot must have outcome"))
|
||||
}
|
||||
crate::slot_state::Status::Live => {
|
||||
// begin_wait is lock-free, legal under the cold lock;
|
||||
// registering under it makes the epoch atomic with
|
||||
// the check-Done-or-register linearization point.
|
||||
cold.waiters.push((me, begin_wait()));
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
match outcome {
|
||||
@@ -95,20 +128,25 @@ impl JoinHandle {
|
||||
|
||||
fn decrement_handle_count(&mut self) {
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let should_reclaim = match s.slot_mut(self.pid) {
|
||||
Some(slot) => {
|
||||
slot.outstanding_handles =
|
||||
slot.outstanding_handles.saturating_sub(1);
|
||||
matches!(slot.state, crate::runtime::State::Done)
|
||||
&& slot.outstanding_handles == 0
|
||||
let should_reclaim = match inner.slot_at(self.pid) {
|
||||
Some(slot) => {
|
||||
let mut cold = slot.cold.lock();
|
||||
match slot.status_for(self.pid) {
|
||||
crate::slot_state::Status::Stale => false,
|
||||
status => {
|
||||
cold.outstanding_handles =
|
||||
cold.outstanding_handles.saturating_sub(1);
|
||||
cold.outstanding_handles == 0
|
||||
&& status == crate::slot_state::Status::Done
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if should_reclaim {
|
||||
crate::runtime::reclaim_slot(s, self.pid);
|
||||
}
|
||||
})
|
||||
None => false,
|
||||
};
|
||||
if should_reclaim {
|
||||
// Re-verified inside; benign if finalize's reclaim won a race.
|
||||
crate::runtime::reclaim_slot(inner, self.pid);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -129,61 +167,61 @@ impl Drop for JoinHandle {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle {
|
||||
let parent = current_pid()
|
||||
.or_else(|| with_runtime(|inner| inner.with_shared(|s| s.root_pid)))
|
||||
.expect("spawn() before run()");
|
||||
let parent = current_pid().unwrap_or_else(|| {
|
||||
// Outside an actor but inside run(): the initial spawn. with_runtime
|
||||
// panics with "not inside Runtime::run()" if there's no runtime at all.
|
||||
with_runtime(|_| crate::runtime::ROOT_PID)
|
||||
});
|
||||
spawn_under(parent, f)
|
||||
}
|
||||
|
||||
pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle {
|
||||
// Try to reuse a stack from the pool; fall back to a fresh mmap if empty.
|
||||
// Allocation happens before taking the shared lock so any syscall doesn't
|
||||
// stall other scheduler threads.
|
||||
let stack = with_runtime(|inner| inner.stack_pool.lock().unwrap().pop())
|
||||
pub fn spawn_under<A>(supervisor: Pid<A>, f: impl FnOnce() + Send + 'static) -> JoinHandle {
|
||||
let supervisor = supervisor.erase();
|
||||
// Stack + closure boxing happen before ANY runtime lock is taken: no
|
||||
// syscall and no allocation ever stalls another scheduler thread.
|
||||
let stack = with_runtime(|inner| inner.stack_pool.lock().pop())
|
||||
.unwrap_or_else(|| {
|
||||
crate::stack::Stack::new(crate::runtime::ACTOR_STACK_SIZE)
|
||||
.expect("stack allocation failed")
|
||||
});
|
||||
let sp = init_actor_stack(stack.top(), crate::actor::trampoline);
|
||||
let closure: crate::runtime::Closure = Box::new(f);
|
||||
|
||||
let pid = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let (idx, gen) = s.allocate_slot();
|
||||
let pid = Pid::new(idx, gen);
|
||||
let slot = &mut s.slots[idx as usize];
|
||||
slot.actor = Some(crate::actor::Actor {
|
||||
pid,
|
||||
stack,
|
||||
sp,
|
||||
supervisor,
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
trap: None,
|
||||
});
|
||||
slot.state = crate::runtime::State::Runnable;
|
||||
slot.outstanding_handles = 1;
|
||||
slot.outcome = None;
|
||||
slot.waiters.clear();
|
||||
slot.supervisor_channel = None;
|
||||
slot.monitors.clear();
|
||||
slot.links.clear();
|
||||
slot.pending_unpark = false;
|
||||
slot.pending_io_result = None;
|
||||
s.run_queue.push_back(pid);
|
||||
// Grow the closures vec to cover this slot index, then store.
|
||||
let idx = idx as usize;
|
||||
if s.pending_closures.len() <= idx {
|
||||
s.pending_closures.resize_with(idx + 1, || None);
|
||||
}
|
||||
s.pending_closures[idx] = Some(Box::new(f) as crate::runtime::Closure);
|
||||
crate::te!(crate::trace::Event::Spawn { parent: supervisor, child: pid });
|
||||
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||
pid
|
||||
})
|
||||
let idx = inner.allocate_slot(); // panics loudly on slab exhaustion
|
||||
crate::runtime::install_actor(inner, idx, sp, stack, supervisor, closure)
|
||||
});
|
||||
|
||||
JoinHandle { pid, consumed: false }
|
||||
}
|
||||
|
||||
/// Spawn a typed, single-message actor and hand back its identity-bound
|
||||
/// [`Pid<A>`] (RFC 014's typed-path producer). The runtime makes the actor's
|
||||
/// inbox, hands the body its [`Receiver<A::Msg>`], installs the sender, and
|
||||
/// returns the parent a `Pid<A>`.
|
||||
///
|
||||
/// The inbox is published from the parent side **before** the pid is returned
|
||||
/// (see [`registry::install_for`](crate::registry::install_for)), so the address
|
||||
/// is live the instant the caller holds it: an immediate
|
||||
/// [`send_to`](crate::send_to) always resolves, never racing the body's first
|
||||
/// instruction. The actor is detached — its lifetime is governed by its own
|
||||
/// logic (an explicit stop message, or returning), like
|
||||
/// [`ServerBuilder::start`](crate::ServerBuilder::start) — so the backing join
|
||||
/// handle is dropped. Spawns under the current actor (via [`spawn`]).
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn spawn_addr<A: crate::pid::Addressable>(
|
||||
body: impl FnOnce(crate::channel::Receiver<A::Msg>) + Send + 'static,
|
||||
) -> Pid<A> {
|
||||
let (tx, rx) = crate::channel::channel::<A::Msg>();
|
||||
let handle = spawn(move || body(rx));
|
||||
let pid = handle.pid();
|
||||
// Publish the sender for `pid` before returning the typed address. `handle`
|
||||
// drops at end of scope (detached).
|
||||
crate::registry::install_for::<A::Msg>(pid, tx);
|
||||
crate::pid::assert_type::<A>(pid)
|
||||
}
|
||||
|
||||
use crate::context::init_actor_stack;
|
||||
|
||||
pub fn self_pid() -> Pid {
|
||||
@@ -202,6 +240,18 @@ pub fn yield_now() {
|
||||
}
|
||||
|
||||
pub fn park_current() {
|
||||
// Entry-side observation point: a stop flagged while we were QUEUED is
|
||||
// otherwise lost — the stop's wildcard unpark no-ops on a Queued actor
|
||||
// (the pending run "is" the wake), so if our first action on resume is
|
||||
// this park, no wake is ever coming and the wake-side check below is
|
||||
// unreachable. Checking here closes that hole; a flag that lands after
|
||||
// this check is covered by the existing protocol (the stop's unpark
|
||||
// finds Running / the prep-to-park window, sets Notified, and the
|
||||
// park-return re-queues us into the wake-side check). Unwinding from
|
||||
// here is the same unwind path as the wake side: leftover wait
|
||||
// registrations are stale-epoch / dead-generation and self-clean at
|
||||
// their wakers' failed CAS.
|
||||
crate::preempt::check_cancelled();
|
||||
runtime::set_yield_intent(YieldIntent::Park);
|
||||
unsafe { crate::context::switch_to_scheduler() };
|
||||
// Observation point on the wakeup side of every blocking primitive
|
||||
@@ -212,62 +262,83 @@ pub fn park_current() {
|
||||
}
|
||||
|
||||
pub fn unpark(pid: Pid) {
|
||||
let result = try_with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(slot) = s.slot_mut(pid) {
|
||||
match slot.state {
|
||||
crate::runtime::State::Parked => {
|
||||
// Actor is suspended — safe to re-queue immediately.
|
||||
slot.state = crate::runtime::State::Runnable;
|
||||
s.run_queue.push_back(pid);
|
||||
crate::te!(crate::trace::Event::UnparkDirect(pid));
|
||||
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||
}
|
||||
crate::runtime::State::Runnable => {
|
||||
// Actor is still running (between registering its
|
||||
// parked_receiver and calling park_current). Set the
|
||||
// flag; the scheduler will re-queue after the Park
|
||||
// yield instead of sleeping.
|
||||
slot.pending_unpark = true;
|
||||
crate::te!(crate::trace::Event::UnparkDeferred(pid));
|
||||
}
|
||||
crate::runtime::State::Done => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
let _ = result;
|
||||
// The whole protocol lives on the slot's packed word (gen + epoch +
|
||||
// state in one CAS) — see slot_state.rs. No runtime lock unless we
|
||||
// enqueue. WILDCARD form: reserved for terminal wakes (request_stop);
|
||||
// every registration-based waker must use `unpark_at`.
|
||||
let _ = try_with_runtime(|inner| inner.unpark(pid));
|
||||
}
|
||||
|
||||
/// Epoch-matched unpark: wake `pid` only if its current wait is still the
|
||||
/// one this waker registered for. The form every registration-based waker
|
||||
/// (channel senders, mutex grants, wait-timers, io completions, joiner
|
||||
/// wakes) must use — see slot_state.rs for the consuming-wake rules.
|
||||
pub(crate) fn unpark_at(pid: Pid, epoch: u32) {
|
||||
let _ = try_with_runtime(|inner| inner.unpark_at(pid, epoch));
|
||||
}
|
||||
|
||||
/// Open a new wait for the CURRENT actor: bump its park-epoch and return
|
||||
/// it. Call once per wait, before registering `(pid, epoch)` with any
|
||||
/// waker. Lock-free (one CAS on the own slot word), so it is legal under
|
||||
/// any lock, including a Channel-class lock.
|
||||
pub(crate) fn begin_wait() -> u32 {
|
||||
let me = current_pid().expect("begin_wait() called outside an actor");
|
||||
with_runtime(|inner| inner.begin_wait(me))
|
||||
}
|
||||
|
||||
/// Close the current actor's wait WITHOUT parking on it. For the no-park
|
||||
/// exit of multi-registration waits (`select` finding an arm ready at
|
||||
/// registration time): bumps the epoch so in-flight wakes die at their CAS,
|
||||
/// eats a notification that already landed, then observes a pending stop —
|
||||
/// in that order. After this, leftover registrations are stale-epoch and
|
||||
/// self-clean at their wakers' failed CAS; nothing can fault the actor's
|
||||
/// next one-shot park.
|
||||
pub(crate) fn retire_wait() {
|
||||
let me = current_pid().expect("retire_wait() called outside an actor");
|
||||
with_runtime(|inner| inner.retire_wait(me));
|
||||
// A request_stop that fired before the clear had its notification
|
||||
// eaten, but it set the stop flag first — observe it here and unwind.
|
||||
// One that fires after re-notifies a Running word as usual.
|
||||
crate::preempt::check_cancelled();
|
||||
}
|
||||
|
||||
/// Request cooperative cancellation of `pid`.
|
||||
///
|
||||
/// Sets the actor's stop flag and, if it is parked, wakes it so it observes
|
||||
/// the flag promptly. The actor realises the stop as a controlled unwind at
|
||||
/// its next observation point (`check!()`/allocation, or the wakeup side of a
|
||||
/// blocking park), terminating with `Outcome::Stopped`.
|
||||
/// Sets the actor's stop flag and wakes it so it observes the flag promptly:
|
||||
/// a parked target is re-queued; a running target is marked notified, so its
|
||||
/// next park returns immediately to an observation point. The actor realises
|
||||
/// the stop as a controlled unwind at its next observation point
|
||||
/// (`check!()`/allocation, or the wakeup side of a blocking park),
|
||||
/// terminating with `Outcome::Stopped`.
|
||||
///
|
||||
/// This is best-effort and cooperative: an actor that never reaches an
|
||||
/// observation point — a tight loop with no `check!()`, no allocation, and no
|
||||
/// blocking op — cannot be stopped, exactly as it cannot be preempted. A no-op
|
||||
/// if `pid` is already gone.
|
||||
pub fn request_stop(pid: Pid) {
|
||||
let _ = try_with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(slot) = s.slot_mut(pid) {
|
||||
if let Some(actor) = slot.actor.as_ref() {
|
||||
pub fn request_stop<A>(pid: Pid<A>) {
|
||||
let pid = pid.erase();
|
||||
let _ = try_with_runtime(|inner| request_stop_inner(inner, pid));
|
||||
}
|
||||
|
||||
/// The core of [`request_stop`], taking the runtime directly so it can be
|
||||
/// driven from inside the runtime (e.g. the root-exit sweep in
|
||||
/// `finalize_actor`) without re-borrowing the thread-local. Sets the stop flag
|
||||
/// under the target's cold lock (generation re-verified there; a mismatch or a
|
||||
/// slot with no live actor is a no-op) and wakes it.
|
||||
pub(crate) fn request_stop_inner(inner: &RuntimeInner, pid: Pid) {
|
||||
if let Some(slot) = inner.slot_at(pid) {
|
||||
{
|
||||
let cold = slot.cold.lock();
|
||||
// Verify under the cold lock: generation can't change while
|
||||
// we hold it (reclaim takes the same lock).
|
||||
if slot.generation() == pid.generation() {
|
||||
if let Some(actor) = cold.actor.as_ref() {
|
||||
actor.stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
// Wake a parked target so it reaches its post-park observation
|
||||
// point now. A Runnable target will observe on its next yield;
|
||||
// a Done target has nothing to stop.
|
||||
if matches!(slot.state, crate::runtime::State::Parked) {
|
||||
slot.state = crate::runtime::State::Runnable;
|
||||
s.run_queue.push_back(pid);
|
||||
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
inner.unpark(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -296,8 +367,9 @@ impl Drop for NoPreempt {
|
||||
pub fn sleep(duration: std::time::Duration) {
|
||||
let me = current_pid().expect("sleep() called outside an actor");
|
||||
let _np = NoPreempt::enter();
|
||||
let epoch = begin_wait();
|
||||
let deadline = crate::timer::deadline_from_now(duration);
|
||||
with_runtime(|inner| inner.with_shared(|s| s.timers.insert_sleep(deadline, me)));
|
||||
with_runtime(|inner| inner.timers.lock().unwrap().insert_sleep(deadline, me, epoch));
|
||||
park_current();
|
||||
}
|
||||
|
||||
@@ -305,19 +377,92 @@ pub fn insert_wait_timer(
|
||||
deadline: std::time::Instant,
|
||||
pid: Pid,
|
||||
target: std::sync::Arc<dyn crate::timer::TimerTarget>,
|
||||
wait_seq: u64,
|
||||
epoch: u32,
|
||||
) {
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
s.timers.insert(
|
||||
deadline,
|
||||
pid,
|
||||
crate::timer::Reason::WaitTimeout { target, wait_seq },
|
||||
);
|
||||
})
|
||||
inner.timers.lock().unwrap().insert(
|
||||
deadline,
|
||||
pid,
|
||||
crate::timer::Reason::WaitTimeout { target, epoch },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// send_after / cancel_timer — message-delivery timers (Erlang send_after).
|
||||
//
|
||||
// Arm a timer that delivers `msg` to an address after `after`, returning a
|
||||
// `TimerId`. The destination is resolved *on fire*, not at arm time: a
|
||||
// `Pid<A>` that has since died yields `SendError::Dead`, a `Name<M>` resolves
|
||||
// to whoever currently holds it (so a restarted server is reached). Either way
|
||||
// a failed resolve / closed inbox is dropped, matching `erlang:send_after`.
|
||||
// `cancel_timer` prevents an as-yet-unfired delivery; it returns whether the
|
||||
// timer was still armed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Deliver `msg` to the exact actor named by `dest` (identity-bound, no
|
||||
/// redirect — see [`send_to`](crate::registry::send_to)) after `after`.
|
||||
/// Returns a [`TimerId`](crate::timer::TimerId) for [`cancel_timer`].
|
||||
pub fn send_after<A: crate::pid::Addressable>(
|
||||
after: std::time::Duration,
|
||||
dest: Pid<A>,
|
||||
msg: A::Msg,
|
||||
) -> crate::timer::TimerId {
|
||||
let deadline = crate::timer::deadline_from_now(after);
|
||||
let fire = Box::new(move || {
|
||||
let _ = crate::registry::send_to(dest, msg);
|
||||
});
|
||||
with_runtime(|inner| inner.timers.lock().unwrap().insert_send(deadline, dest.erase(), fire))
|
||||
}
|
||||
|
||||
/// Deliver `msg` to whichever actor holds the name `dest` at fire time
|
||||
/// (re-resolving [`send`](crate::registry::send) semantics) after `after`.
|
||||
/// Returns a [`TimerId`](crate::timer::TimerId) for [`cancel_timer`].
|
||||
pub fn send_after_named<M: Send + 'static>(
|
||||
after: std::time::Duration,
|
||||
dest: Name<M>,
|
||||
msg: M,
|
||||
) -> crate::timer::TimerId {
|
||||
let deadline = crate::timer::deadline_from_now(after);
|
||||
// Informational only (who armed it); not used for delivery.
|
||||
let armed_by = current_pid().unwrap_or(Pid::new(0, 0));
|
||||
let fire = Box::new(move || {
|
||||
let _ = crate::registry::send(dest, msg);
|
||||
});
|
||||
with_runtime(|inner| inner.timers.lock().unwrap().insert_send(deadline, armed_by, fire))
|
||||
}
|
||||
|
||||
/// Deliver `msg` onto a channel the caller owns after `after`, rather than to a
|
||||
/// registry address. The sibling of [`send_after`] used by the gen_server timer
|
||||
/// layer (RFC 015 §5): arming a server timer must land the fire on the loop's
|
||||
/// own `Sys` channel — giving it the loop's arm position — not in the inbox.
|
||||
///
|
||||
/// Same substrate as [`send_after`]: a `Reason::Send` entry, the same `armed`
|
||||
/// set, the same [`TimerId`](crate::timer::TimerId) for [`cancel_timer`]. Only
|
||||
/// the fire thunk differs — `tx.send(msg)` instead of a registry resolve — so a
|
||||
/// send onto a channel whose receiver is gone is dropped, exactly as a failed
|
||||
/// address resolve is (Erlang `send_after` semantics). `pid` is informational
|
||||
/// (who armed it); delivery lives entirely in the thunk.
|
||||
pub(crate) fn send_after_to<T: Send + 'static>(
|
||||
after: std::time::Duration,
|
||||
tx: Sender<T>,
|
||||
msg: T,
|
||||
) -> crate::timer::TimerId {
|
||||
let deadline = crate::timer::deadline_from_now(after);
|
||||
let armed_by = current_pid().unwrap_or(Pid::new(0, 0));
|
||||
let fire = Box::new(move || {
|
||||
let _ = tx.send(msg);
|
||||
});
|
||||
with_runtime(|inner| inner.timers.lock().unwrap().insert_send(deadline, armed_by, fire))
|
||||
}
|
||||
|
||||
/// Cancel a timer armed by [`send_after`] / [`send_after_named`]. Returns
|
||||
/// `true` if it was still pending (delivery now prevented), `false` if it had
|
||||
/// already fired or been cancelled.
|
||||
pub fn cancel_timer(id: crate::timer::TimerId) -> bool {
|
||||
with_runtime(|inner| inner.timers.lock().unwrap().cancel(id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// block_on_io / wait_readable / wait_writable / read / write
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -334,19 +479,24 @@ where
|
||||
});
|
||||
{
|
||||
let _np = NoPreempt::enter();
|
||||
with_runtime(|inner| inner.with_shared(|s| {
|
||||
let io = s.io.as_mut().expect("io thread not started");
|
||||
io.submit(me, work);
|
||||
}));
|
||||
let epoch = begin_wait();
|
||||
with_runtime(|inner| {
|
||||
let mut io = inner.io.lock().unwrap();
|
||||
io.as_mut().expect("io thread not started").submit(me, epoch, work);
|
||||
});
|
||||
park_current();
|
||||
}
|
||||
let result = with_runtime(|inner| inner.with_shared(|s| {
|
||||
s.slot_mut(me)
|
||||
.expect("block_on_io: own slot vanished")
|
||||
.pending_io_result
|
||||
let result = with_runtime(|inner| {
|
||||
let slot = inner.slot_at(me).expect("block_on_io: own slot vanished");
|
||||
let mut cold = slot.cold.lock();
|
||||
debug_assert_eq!(
|
||||
slot.generation(), me.generation(),
|
||||
"block_on_io: own slot reused mid-park"
|
||||
);
|
||||
cold.pending_io_result
|
||||
.take()
|
||||
.expect("block_on_io: resumed without a result")
|
||||
}));
|
||||
});
|
||||
match result {
|
||||
Ok(any) => *any.downcast::<T>().expect("block_on_io: type mismatch"),
|
||||
Err(payload) => std::panic::resume_unwind(payload),
|
||||
@@ -364,14 +514,179 @@ pub fn wait_writable(fd: std::os::fd::RawFd) -> std::io::Result<()> {
|
||||
fn wait_fd(fd: std::os::fd::RawFd, readable: bool, writable: bool) -> std::io::Result<()> {
|
||||
let me = current_pid().expect("wait_*() called outside an actor");
|
||||
let _np = NoPreempt::enter();
|
||||
with_runtime(|inner| inner.with_shared(|s| {
|
||||
let io = s.io.as_mut().expect("io thread not started");
|
||||
io.epoll_register(fd, me, readable, writable)
|
||||
}))?;
|
||||
let epoch = begin_wait();
|
||||
with_runtime(|inner| {
|
||||
let mut io = inner.io.lock().unwrap();
|
||||
io.as_mut().expect("io thread not started").epoll_register(fd, me, epoch, readable, writable)
|
||||
})?;
|
||||
|
||||
// If a terminal stop unwinds us out of the park below, the registration
|
||||
// must not outlive us: a stale `waiters` entry fails every future
|
||||
// `wait_*` on this fd with AlreadyExists, and the kernel-side ADD leaks
|
||||
// until the fd happens to be reused. Clean up iff the entry is still
|
||||
// ours — a `FdReady` racing the stop may have consumed it already (it
|
||||
// removes + DELs under the io lock), after which the fd may even carry
|
||||
// ANOTHER actor's fresh registration; in that case touch nothing.
|
||||
struct Dereg {
|
||||
fd: std::os::fd::RawFd,
|
||||
me: Pid,
|
||||
epoch: u32,
|
||||
}
|
||||
impl Drop for Dereg {
|
||||
fn drop(&mut self) {
|
||||
with_runtime(|inner| {
|
||||
let mut io = inner.io.lock().unwrap();
|
||||
if let Some(io) = io.as_mut() {
|
||||
if io.waiters.get(&self.fd) == Some(&(self.me, self.epoch)) {
|
||||
io.waiters.remove(&self.fd);
|
||||
io.epoll_deregister(self.fd);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let guard = Dereg { fd, me, epoch };
|
||||
park_current();
|
||||
// Normal wake: the FdReady path removed the entry and DEL'd the fd
|
||||
// before the unpark, so the guard's check would be a guaranteed no-op —
|
||||
// skip the io lock on the hot path. (Dereg owns nothing; forget leaks
|
||||
// no resource.)
|
||||
std::mem::forget(guard);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FdArm — fd readiness as a select arm (RFC 008)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An fd-readiness arm for [`crate::select`] / [`crate::select_timeout`]:
|
||||
/// ready when the fd is readable (resp. writable), composable with channel
|
||||
/// receivers on one wait epoch. Phase-1 rules apply: one waiter per fd at a
|
||||
/// time, one direction per arm (duplex on a single fd needs `dup`; epoll
|
||||
/// registrations key on the open file description, so dup'd fds register
|
||||
/// independently).
|
||||
pub struct FdArm {
|
||||
fd: std::os::fd::RawFd,
|
||||
readable: bool,
|
||||
writable: bool,
|
||||
}
|
||||
|
||||
impl FdArm {
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> Self {
|
||||
FdArm { fd, readable: true, writable: false }
|
||||
}
|
||||
|
||||
pub fn writable(fd: std::os::fd::RawFd) -> Self {
|
||||
FdArm { fd, readable: false, writable: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::channel::sealed::Sealed for FdArm {}
|
||||
|
||||
impl crate::channel::Selectable for FdArm {
|
||||
/// Ready-now check is a zero-timeout `poll(2)`; if the requested events
|
||||
/// are pending the wait is retired without registering (`Ok(false)`,
|
||||
/// the channel-arm contract). Otherwise register with the io thread —
|
||||
/// every failure surfaces as `Err` (EBADF including a closed-fd
|
||||
/// POLLNVAL, EMFILE on the epoll set, AlreadyExists for a second
|
||||
/// waiter on the fd): the fallible-out, nothing-left-behind rule, in
|
||||
/// deviation from RFC 008's permanently-ready lean, which would spin a
|
||||
/// consumer whose fd is healthy but unregistrable (EMFILE).
|
||||
fn sel_register(&self, pid: Pid, epoch: u32) -> std::io::Result<bool> {
|
||||
if poll_events(self.fd, self.readable, self.writable)? {
|
||||
return Ok(false);
|
||||
}
|
||||
with_runtime(|inner| {
|
||||
let mut io = inner.io.lock().unwrap();
|
||||
io.as_mut()
|
||||
.expect("io thread not started")
|
||||
.epoll_register(self.fd, pid, epoch, self.readable, self.writable)
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Classification is the same zero-timeout poll: a pure function of fd
|
||||
/// state, independent of the registration the cleanup pass removed. An
|
||||
/// error here (EBADF: fd closed mid-wait) reports READY — the
|
||||
/// consumer's read/write surfaces the errno; a dead arm is an event,
|
||||
/// not a hang.
|
||||
fn sel_ready(&self) -> bool {
|
||||
poll_events(self.fd, self.readable, self.writable).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// `wait_fd`'s `Dereg` compare, verbatim: remove the waiters entry and
|
||||
/// kernel-side registration iff the entry is still `(pid, epoch)`-ours.
|
||||
/// A `FdReady` racing the wake may have consumed it (it removes + DELs
|
||||
/// under the io lock), after which the fd may even carry ANOTHER
|
||||
/// actor's fresh registration; in that case touch nothing.
|
||||
fn sel_unregister(&self, pid: Pid, epoch: u32) {
|
||||
with_runtime(|inner| {
|
||||
let mut io = inner.io.lock().unwrap();
|
||||
if let Some(io) = io.as_mut() {
|
||||
if io.waiters.get(&self.fd) == Some(&(pid, epoch)) {
|
||||
io.waiters.remove(&self.fd);
|
||||
io.epoll_deregister(self.fd);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn sel_eager_cleanup(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-timeout `poll(2)`: are any of the requested events (or ERR/HUP,
|
||||
/// which make the consumer's read/write fail loudly rather than park
|
||||
/// forever) pending on `fd`? POLLNVAL maps to `Err(EBADF)`.
|
||||
fn poll_events(fd: std::os::fd::RawFd, readable: bool, writable: bool) -> std::io::Result<bool> {
|
||||
let mut events: libc::c_short = 0;
|
||||
if readable {
|
||||
events |= libc::POLLIN;
|
||||
}
|
||||
if writable {
|
||||
events |= libc::POLLOUT;
|
||||
}
|
||||
let mut pfd = libc::pollfd { fd, events, revents: 0 };
|
||||
loop {
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, 0) };
|
||||
if r < 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if r == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if pfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(std::io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
return Ok(pfd.revents & (events | libc::POLLERR | libc::POLLHUP) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until `fd` is readable or `timeout` elapses: `Ok(true)` = ready,
|
||||
/// `Ok(false)` = timed out. A one-arm [`crate::try_select_timeout`].
|
||||
pub fn wait_readable_timeout(
|
||||
fd: std::os::fd::RawFd,
|
||||
timeout: std::time::Duration,
|
||||
) -> std::io::Result<bool> {
|
||||
let arm = FdArm::readable(fd);
|
||||
Ok(crate::channel::try_select_timeout(&[&arm], timeout)?.is_some())
|
||||
}
|
||||
|
||||
/// Wait until `fd` is writable or `timeout` elapses: `Ok(true)` = ready,
|
||||
/// `Ok(false)` = timed out.
|
||||
pub fn wait_writable_timeout(
|
||||
fd: std::os::fd::RawFd,
|
||||
timeout: std::time::Duration,
|
||||
) -> std::io::Result<bool> {
|
||||
let arm = FdArm::writable(fd);
|
||||
Ok(crate::channel::try_select_timeout(&[&arm], timeout)?.is_some())
|
||||
}
|
||||
|
||||
pub fn read(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
wait_readable(fd)?;
|
||||
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
@@ -389,13 +704,16 @@ pub fn write(fd: std::os::fd::RawFd, buf: &[u8]) -> std::io::Result<usize> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn register_supervisor_channel(pid: Pid, sender: Sender<Signal>) {
|
||||
with_runtime(|inner| inner.with_shared(|s| {
|
||||
if let Some(slot) = s.slot_mut(pid) {
|
||||
slot.supervisor_channel = Some(sender);
|
||||
} else {
|
||||
panic!("register_supervisor_channel: pid {:?} not found", pid);
|
||||
}
|
||||
}));
|
||||
with_runtime(|inner| {
|
||||
let slot = inner.slot_at(pid)
|
||||
.unwrap_or_else(|| panic!("register_supervisor_channel: pid {:?} not found", pid));
|
||||
let mut cold = slot.cold.lock();
|
||||
assert_eq!(
|
||||
slot.generation(), pid.generation(),
|
||||
"register_supervisor_channel: pid {:?} not found", pid
|
||||
);
|
||||
cold.supervisor_channel = Some(sender);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -407,3 +725,54 @@ pub fn register_supervisor_channel(pid: Pid, sender: Sender<Signal>) {
|
||||
pub fn run<F: FnOnce() + Send + 'static>(f: F) {
|
||||
crate::runtime::init(crate::runtime::Config::exact(1)).run(f);
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(loom)))]
|
||||
mod send_after_to_tests {
|
||||
use super::*;
|
||||
use crate::channel::channel;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// A fired send_after_to lands its message on the caller's own channel.
|
||||
#[test]
|
||||
fn delivers_onto_the_channel() {
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<u32>();
|
||||
send_after_to(Duration::from_millis(10), tx, 42);
|
||||
// recv parks until the scheduler fires the timer thunk.
|
||||
assert_eq!(rx.recv().unwrap(), 42);
|
||||
});
|
||||
}
|
||||
|
||||
// cancel_timer before the deadline prevents delivery and reports the race
|
||||
// win; the channel then closes with no message once the lone sender (moved
|
||||
// into the now-discarded thunk) is gone.
|
||||
#[test]
|
||||
fn cancel_prevents_delivery() {
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<u32>();
|
||||
let id = send_after_to(Duration::from_millis(50), tx, 7);
|
||||
assert!(cancel_timer(id), "cancel before fire should win the race");
|
||||
// No delivery: the discarded thunk drops the only sender, so recv
|
||||
// sees a closed channel rather than the value.
|
||||
assert!(rx.recv().is_err());
|
||||
});
|
||||
}
|
||||
|
||||
// The thunk runs on the scheduler thread; a closed receiver makes the send
|
||||
// a harmless no-op (Erlang send_after semantics) rather than a panic.
|
||||
#[test]
|
||||
fn send_to_closed_channel_is_harmless() {
|
||||
let reached = Arc::new(AtomicBool::new(false));
|
||||
let r2 = reached.clone();
|
||||
run(move || {
|
||||
let (tx, rx) = channel::<u32>();
|
||||
send_after_to(Duration::from_millis(10), tx, 1);
|
||||
drop(rx); // receiver gone before the timer fires
|
||||
crate::sleep(Duration::from_millis(30));
|
||||
r2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(reached.load(Ordering::SeqCst), "runtime survived the dead-channel fire");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
//! The per-slot scheduling state machine, as a standalone unit.
|
||||
//!
|
||||
//! One atomic word packs `(generation << 32) | (epoch << 8) | state`; every
|
||||
//! transition is a CAS on the packed word, so the generation check is atomic
|
||||
//! with the transition — no ABA, no acting on a recycled slot. The diagram
|
||||
//! and the full protocol rationale live in `runtime.rs`; this module is the
|
||||
//! mechanism, factored out so that:
|
||||
//!
|
||||
//! - loom can model-check the production transitions directly (see the
|
||||
//! `loom_tests` module; built with `RUSTFLAGS="--cfg loom"`), and
|
||||
//! - every method asserts the precondition it relies on (`debug_assert!` —
|
||||
//! these are hot paths), per the assert-the-invariants house rule.
|
||||
//!
|
||||
//! ## The park-epoch (wait identity)
|
||||
//!
|
||||
//! The middle 24 bits carry the slot's *park-epoch*: the identity of the
|
||||
//! actor's current (or most recent) wait. The rules:
|
||||
//!
|
||||
//! - [`begin_wait`](StateWord::begin_wait) bumps the epoch and returns it;
|
||||
//! the actor calls it once per wait, *before* registering itself with any
|
||||
//! waker. Registrations carry `(pid, epoch)`.
|
||||
//! - A wake may be **epoch-matched** (`unpark(gen, Some(epoch))`): it lands
|
||||
//! only if the word still carries that epoch. Wakers whose registration
|
||||
//! handle can outlive the wait it was created for (channel senders, mutex
|
||||
//! grants, wait-timers) MUST use this form.
|
||||
//! - Every successful wake **consumes** the epoch — `Parked(e) → Queued(e+1)`,
|
||||
//! `Running(e) → RunningNotified(e+1)` — so at most one wake can ever land
|
||||
//! per wait, by construction. A loser in a multi-waker race (e.g. the
|
||||
//! non-winning arms of a `select`) fails the epoch check and no-ops; it can
|
||||
//! neither steal a future wait's wake nor leave a pending notification that
|
||||
//! would fault a later one-shot park (`Mutex::lock_timeout`, `sleep`,
|
||||
//! `block_on_io`, `wait_fd` all rely on wakes being *meaningful*).
|
||||
//! - The wildcard form (`unpark(gen, None)`) also consumes, and is reserved
|
||||
//! for terminal wakes — `request_stop` — which never return control to the
|
||||
//! code that parked.
|
||||
//!
|
||||
//! Epoch wrap (24 bits = 16.7M waits) is harmless: a collision would require
|
||||
//! a taken registration to stay in flight across a full wrap of the *same
|
||||
//! actor's* waits, and registrations are consumed at take-time under their
|
||||
//! primitive's lock — the exposure is the taker's instruction window.
|
||||
//!
|
||||
//! Atomics come from `sync_shim` (std normally, `loom::sync` under
|
||||
//! `cfg(loom)`).
|
||||
|
||||
use crate::sync_shim::{AtomicU64, Ordering};
|
||||
|
||||
pub(crate) const ST_VACANT: u64 = 0;
|
||||
pub(crate) const ST_QUEUED: u64 = 1;
|
||||
pub(crate) const ST_RUNNING: u64 = 2;
|
||||
pub(crate) const ST_RUNNING_NOTIFIED: u64 = 3;
|
||||
pub(crate) const ST_PARKED: u64 = 4;
|
||||
pub(crate) const ST_DONE: u64 = 5;
|
||||
|
||||
/// Park-epoch width: 24 bits, packed at word bits 8..32.
|
||||
pub(crate) const EPOCH_MASK: u32 = 0x00FF_FFFF;
|
||||
|
||||
#[inline]
|
||||
pub(crate) const fn pack(gen: u32, epoch: u32, st: u64) -> u64 {
|
||||
debug_assert!(epoch & !EPOCH_MASK == 0);
|
||||
((gen as u64) << 32) | ((epoch as u64) << 8) | st
|
||||
}
|
||||
#[inline]
|
||||
pub(crate) const fn word_gen(w: u64) -> u32 {
|
||||
(w >> 32) as u32
|
||||
}
|
||||
#[inline]
|
||||
pub(crate) const fn word_epoch(w: u64) -> u32 {
|
||||
((w >> 8) as u32) & EPOCH_MASK
|
||||
}
|
||||
#[inline]
|
||||
pub(crate) const fn word_state(w: u64) -> u64 {
|
||||
w & 0xFF
|
||||
}
|
||||
|
||||
/// What an unpark amounted to. The caller owns the side effects (enqueue,
|
||||
/// trace events) — this module is pure state.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub(crate) enum Unpark {
|
||||
/// Parked → Queued: the caller must enqueue the pid.
|
||||
Enqueue,
|
||||
/// Running → RunningNotified: the scheduler's park-return will re-queue.
|
||||
Notified,
|
||||
/// Stale generation, stale epoch, already queued/notified, done, or
|
||||
/// vacant.
|
||||
Noop,
|
||||
}
|
||||
|
||||
/// A pid's-eye view of the slot, for cold paths that hold the slot lock.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub(crate) enum Status {
|
||||
/// The generation no longer matches: the slot was reclaimed (and possibly
|
||||
/// reused) — the pid is stale.
|
||||
Stale,
|
||||
/// The actor terminated; its outcome is (or was) in the slot.
|
||||
Done,
|
||||
/// Alive in some scheduling state (Queued / Running / Notified / Parked).
|
||||
Live,
|
||||
}
|
||||
|
||||
pub(crate) struct StateWord(AtomicU64);
|
||||
|
||||
impl StateWord {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(AtomicU64::new(pack(0, 0, ST_VACANT)))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn load(&self) -> u64 {
|
||||
self.0.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn generation(&self) -> u32 {
|
||||
word_gen(self.load())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn status_for(&self, gen: u32) -> Status {
|
||||
let w = self.load();
|
||||
if word_gen(w) != gen {
|
||||
return Status::Stale;
|
||||
}
|
||||
match word_state(w) {
|
||||
ST_DONE => Status::Done,
|
||||
// A matching generation on a Vacant slot is unreachable for any
|
||||
// ISSUED pid — reclaim bumps the generation in the very store
|
||||
// that vacates, and install publishes Queued before the pid
|
||||
// escapes. But `Pid::new` is public, so a forged / never-issued
|
||||
// pid (e.g. `Pid::new(5, 0)` against a fresh slab) can land
|
||||
// here; for those, "no such actor" is the correct total answer.
|
||||
ST_VACANT => Status::Stale,
|
||||
_ => Status::Live,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn-side publish: Vacant → Queued. The caller owns the vacant slot
|
||||
/// exclusively (it popped the index from the free list), so this is a
|
||||
/// plain Release store; it is the moment the actor becomes visible to
|
||||
/// pops, unparks, and stops. The epoch starts at 0 for each occupancy
|
||||
/// (`set_done` zeroes it; wait identity never crosses a lifetime).
|
||||
pub(crate) fn publish_queued(&self, gen: u32) {
|
||||
debug_assert_eq!(
|
||||
self.load(),
|
||||
pack(gen, 0, ST_VACANT),
|
||||
"publish over a non-vacant slot"
|
||||
);
|
||||
self.0.store(pack(gen, 0, ST_QUEUED), Ordering::Release);
|
||||
}
|
||||
|
||||
/// Scheduler pop-side claim: Queued → Running, epoch preserved. `false`
|
||||
/// means the popped pid is stale — by the at-most-once-enqueued
|
||||
/// invariant, a generation mismatch is the only possible failure
|
||||
/// (asserted). Nothing can move a matching-gen word off Queued (wakes
|
||||
/// no-op on Queued), so the CAS loop is single-shot in practice.
|
||||
#[must_use]
|
||||
pub(crate) fn try_claim(&self, gen: u32) -> bool {
|
||||
loop {
|
||||
let w = self.load();
|
||||
if word_gen(w) != gen {
|
||||
return false;
|
||||
}
|
||||
debug_assert_eq!(
|
||||
word_state(w),
|
||||
ST_QUEUED,
|
||||
"queued pid found in unexpected state {} — double enqueue?",
|
||||
word_state(w)
|
||||
);
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, word_epoch(w), ST_RUNNING),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Yield return path: Running | RunningNotified → Queued, epoch
|
||||
/// preserved. A notification that arrived mid-run coalesces into the
|
||||
/// re-queue. Caller must enqueue. CAS loop because a notify can bump the
|
||||
/// epoch between the read and the exchange.
|
||||
pub(crate) fn yield_return(&self, gen: u32) {
|
||||
loop {
|
||||
let w = self.load();
|
||||
debug_assert!(
|
||||
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
|
||||
&& word_gen(w) == gen,
|
||||
"yield return from invalid word {w:#x}"
|
||||
);
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, word_epoch(w), ST_QUEUED),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Park return path. `true` = actually parked. `false` = an unpark landed
|
||||
/// in the prep-to-park window (RunningNotified); the word is already back
|
||||
/// to Queued and the caller must enqueue — the lost-wakeup window,
|
||||
/// closed. Epoch preserved on both paths (the notify already consumed
|
||||
/// it).
|
||||
#[must_use]
|
||||
pub(crate) fn park_return(&self, gen: u32) -> bool {
|
||||
loop {
|
||||
let w = self.load();
|
||||
debug_assert_eq!(word_gen(w), gen, "park return with stale gen");
|
||||
let target = match word_state(w) {
|
||||
ST_RUNNING => ST_PARKED,
|
||||
ST_RUNNING_NOTIFIED => ST_QUEUED,
|
||||
st => unreachable!("park return from invalid state {st}"),
|
||||
};
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, word_epoch(w), target),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return target == ST_PARKED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a new wait: bump the park-epoch and return it. Called by the
|
||||
/// waiting actor itself (so the state is Running, or RunningNotified if
|
||||
/// a terminal wake is already pending — the bump preserves the pending
|
||||
/// notification), once per wait, BEFORE registering `(pid, epoch)` with
|
||||
/// any waker.
|
||||
#[must_use]
|
||||
pub(crate) fn begin_wait(&self, gen: u32) -> u32 {
|
||||
loop {
|
||||
let w = self.load();
|
||||
debug_assert!(
|
||||
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
|
||||
&& word_gen(w) == gen,
|
||||
"begin_wait from invalid word {w:#x}"
|
||||
);
|
||||
let next = word_epoch(w).wrapping_add(1) & EPOCH_MASK;
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, next, word_state(w)),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unpark protocol — the one way anything outside the scheduler makes
|
||||
/// an actor runnable. See [`Unpark`] for the caller's obligations.
|
||||
///
|
||||
/// `want = Some(epoch)` is the epoch-matched form: lands only if the word
|
||||
/// still carries that epoch (i.e. the wait it was registered for is still
|
||||
/// the current, un-woken wait). `want = None` is the wildcard, reserved
|
||||
/// for terminal wakes. Both forms CONSUME the epoch on success.
|
||||
#[must_use]
|
||||
pub(crate) fn unpark(&self, gen: u32, want: Option<u32>) -> Unpark {
|
||||
loop {
|
||||
let w = self.load();
|
||||
if word_gen(w) != gen {
|
||||
return Unpark::Noop;
|
||||
}
|
||||
if let Some(e) = want {
|
||||
if word_epoch(w) != e {
|
||||
return Unpark::Noop;
|
||||
}
|
||||
}
|
||||
let bumped = word_epoch(w).wrapping_add(1) & EPOCH_MASK;
|
||||
match word_state(w) {
|
||||
ST_PARKED => {
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, bumped, ST_QUEUED),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Unpark::Enqueue;
|
||||
}
|
||||
}
|
||||
ST_RUNNING => {
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, bumped, ST_RUNNING_NOTIFIED),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Unpark::Notified;
|
||||
}
|
||||
}
|
||||
_ => return Unpark::Noop, // Queued | Notified | Done | Vacant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eat a pending notification: RunningNotified → Running, epoch
|
||||
/// preserved; no-op on Running. Called by the RUNNING actor itself, on
|
||||
/// the no-park exit of a wait it registered for but never parked on
|
||||
/// (`select` returning a ready arm at registration time), AFTER bumping
|
||||
/// the epoch and BEFORE re-checking its stop flag:
|
||||
///
|
||||
/// - post-bump, the only wakers that can have set RunningNotified are
|
||||
/// ones stamped with the just-retired epoch (a select arm) or a
|
||||
/// terminal wildcard (`request_stop`);
|
||||
/// - the caller's stop-flag check AFTER the clear catches the terminal
|
||||
/// case (the flag is set before the wake fires), so eating its
|
||||
/// notification loses nothing — and a stop arriving later re-notifies
|
||||
/// a Running word as usual;
|
||||
/// - what remains eaten is exactly the stale arm wake that would
|
||||
/// otherwise fault the actor's next one-shot park.
|
||||
///
|
||||
/// Returns whether a notification was eaten.
|
||||
pub(crate) fn clear_notify(&self, gen: u32) -> bool {
|
||||
loop {
|
||||
let w = self.load();
|
||||
debug_assert!(
|
||||
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
|
||||
&& word_gen(w) == gen,
|
||||
"clear_notify from invalid word {w:#x}"
|
||||
);
|
||||
if word_state(w) != ST_RUNNING_NOTIFIED {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, word_epoch(w), ST_RUNNING),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize: Running | RunningNotified → Done, epoch zeroed (wait
|
||||
/// identity never crosses an occupancy). Called by the scheduler that
|
||||
/// just ran the actor to completion (so those are the only legal prior
|
||||
/// states), under the slot's cold lock so join's check-or-register is
|
||||
/// linearized against it.
|
||||
pub(crate) fn set_done(&self, gen: u32) {
|
||||
let prev = self.0.swap(pack(gen, 0, ST_DONE), Ordering::AcqRel);
|
||||
debug_assert!(
|
||||
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED)
|
||||
&& word_gen(prev) == gen,
|
||||
"finalize from invalid word {prev:#x}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Reclaim: Done → Vacant(gen + 1). The generation bump IS the reclaim:
|
||||
/// every stale pid is dead from this store onwards. Caller holds the cold
|
||||
/// lock and has verified eligibility (asserted).
|
||||
pub(crate) fn reclaim(&self, gen: u32) {
|
||||
debug_assert_eq!(
|
||||
self.load(),
|
||||
pack(gen, 0, ST_DONE),
|
||||
"reclaim of a non-Done slot"
|
||||
);
|
||||
self.0
|
||||
.store(pack(gen.wrapping_add(1), 0, ST_VACANT), Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loom model tests — RUSTFLAGS="--cfg loom" cargo test --lib --release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, loom))]
|
||||
mod loom_tests {
|
||||
use super::*;
|
||||
use loom::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use loom::sync::Arc;
|
||||
use loom::thread;
|
||||
use std::sync::atomic::Ordering as O;
|
||||
|
||||
/// THE lost-wakeup theorem. A waiter registers a condition check then
|
||||
/// parks (as every parking site does); a waker sets the condition then
|
||||
/// unparks. In every interleaving the waiter must end up runnable —
|
||||
/// parked-forever-with-condition-set must be unreachable.
|
||||
#[test]
|
||||
fn no_lost_wakeup_park_vs_unpark() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0)); // scheduler claimed: actor Running
|
||||
let epoch = word.begin_wait(0); // actor opens the wait
|
||||
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let enqueues = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Waker: make the condition true, then wake the registered wait.
|
||||
let w = word.clone();
|
||||
let r = ready.clone();
|
||||
let e = enqueues.clone();
|
||||
let waker = thread::spawn(move || {
|
||||
r.store(true, O::SeqCst);
|
||||
if w.unpark(0, Some(epoch)) == Unpark::Enqueue {
|
||||
e.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
// Waiter (as the scheduler executes it): re-check the condition,
|
||||
// park only if still false; a Notified park-return re-queues.
|
||||
let parked = if ready.load(O::SeqCst) {
|
||||
false // condition already visible: doesn't park at all
|
||||
} else if word.park_return(0) {
|
||||
true
|
||||
} else {
|
||||
enqueues.fetch_add(1, O::SeqCst); // notified → re-queued
|
||||
false
|
||||
};
|
||||
|
||||
waker.join().unwrap();
|
||||
|
||||
let w = word.load();
|
||||
if parked {
|
||||
// Parked is only a FINAL state if the waker's unpark moved it
|
||||
// back to Queued (+ one enqueue). Parked-and-stays-parked
|
||||
// would be the lost wakeup.
|
||||
assert_eq!(word_state(w), ST_QUEUED, "lost wakeup: parked forever");
|
||||
assert_eq!(enqueues.load(O::SeqCst), 1);
|
||||
} else {
|
||||
// Never more than one enqueue (at-most-once-enqueued).
|
||||
assert!(enqueues.load(O::SeqCst) <= 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Two concurrent unparkers, one parked actor: exactly one wins the
|
||||
/// enqueue (at-most-once), regardless of interleaving. Both stamped with
|
||||
/// the live epoch — the consuming bump is what serializes them.
|
||||
#[test]
|
||||
fn two_unparkers_one_enqueue() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0));
|
||||
let epoch = word.begin_wait(0);
|
||||
assert!(word.park_return(0)); // actor parked
|
||||
|
||||
let enqueues = Arc::new(AtomicUsize::new(0));
|
||||
let mut hs = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let w = word.clone();
|
||||
let e = enqueues.clone();
|
||||
hs.push(thread::spawn(move || {
|
||||
if w.unpark(0, Some(epoch)) == Unpark::Enqueue {
|
||||
e.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
assert_eq!(enqueues.load(O::SeqCst), 1);
|
||||
assert_eq!(word_state(word.load()), ST_QUEUED);
|
||||
});
|
||||
}
|
||||
|
||||
/// The stale-epoch theorem — what `select`'s loser arms lean on. An
|
||||
/// actor opens a wait, two registered wakers race it (against the park
|
||||
/// itself, covering the prep-to-park window); afterwards the actor is
|
||||
/// runnable exactly once, and a LATE waker still stamped with the
|
||||
/// consumed epoch can neither enqueue nor notify — in every
|
||||
/// interleaving. (Under wildcard semantics the late waker would corrupt
|
||||
/// the actor's NEXT one-shot park; this is the theorem that buys
|
||||
/// `Mutex::lock_timeout`/`sleep`/`block_on_io` their unchanged code.)
|
||||
#[test]
|
||||
fn consumed_epoch_unpark_never_lands() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0));
|
||||
let epoch = word.begin_wait(0);
|
||||
|
||||
// Two arms race the wake, concurrent with the park itself.
|
||||
let enqueues = Arc::new(AtomicUsize::new(0));
|
||||
let mut hs = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let w = word.clone();
|
||||
let e = enqueues.clone();
|
||||
hs.push(thread::spawn(move || {
|
||||
if w.unpark(0, Some(epoch)) == Unpark::Enqueue {
|
||||
e.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
let mut runnable_via_notify = false;
|
||||
if !word.park_return(0) {
|
||||
runnable_via_notify = true; // notified in prep-to-park
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
// Exactly one path made the actor runnable.
|
||||
let direct = enqueues.load(O::SeqCst);
|
||||
if runnable_via_notify {
|
||||
assert_eq!(direct, 0, "woken twice: notify AND enqueue");
|
||||
} else {
|
||||
assert_eq!(direct, 1, "parked forever, or woken twice");
|
||||
}
|
||||
assert_eq!(word_state(word.load()), ST_QUEUED);
|
||||
|
||||
// The actor runs again. A waker still holding the OLD epoch —
|
||||
// a select loser arm firing later — must be a strict no-op,
|
||||
// not a pending notification.
|
||||
assert!(word.try_claim(0));
|
||||
assert_eq!(word.unpark(0, Some(epoch)), Unpark::Noop);
|
||||
assert_eq!(word_state(word.load()), ST_RUNNING, "stale epoch notified a live run");
|
||||
});
|
||||
}
|
||||
|
||||
/// The retire theorem — `select`'s no-park exit. An actor opens a wait
|
||||
/// and registers, then finds an arm ready and returns WITHOUT parking;
|
||||
/// a loser arm's waker fires concurrently, stamped with the live epoch.
|
||||
/// The exit retires the wait (bump, then eat): in every interleaving
|
||||
/// the run ends on a clean Running word — no pending notification
|
||||
/// survives to fault the actor's next one-shot park — and the waker
|
||||
/// never enqueues.
|
||||
#[test]
|
||||
fn retire_eats_late_arm_notification() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0));
|
||||
let epoch = word.begin_wait(0); // select opens + registers
|
||||
|
||||
let w = word.clone();
|
||||
let waker = thread::spawn(move || w.unpark(0, Some(epoch)));
|
||||
|
||||
// No-park exit: bump (invalidates in-flight wakes), then eat
|
||||
// (consumes one that already landed).
|
||||
let _ = word.begin_wait(0);
|
||||
word.clear_notify(0);
|
||||
|
||||
assert_ne!(waker.join().unwrap(), Unpark::Enqueue);
|
||||
assert_eq!(
|
||||
word_state(word.load()),
|
||||
ST_RUNNING,
|
||||
"stale arm wake survived the retire"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The ABA theorem: a stale-generation unpark racing reclaim + reuse can
|
||||
/// never touch the slot's new occupant.
|
||||
#[test]
|
||||
fn stale_unpark_never_hits_reused_slot() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
// Gen-0 actor runs to completion.
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0));
|
||||
|
||||
let w = word.clone();
|
||||
let stale = thread::spawn(move || w.unpark(0, None));
|
||||
|
||||
// Scheduler: finalize, reclaim, and a new spawn reuses the slot.
|
||||
word.set_done(0);
|
||||
word.reclaim(0);
|
||||
word.publish_queued(1);
|
||||
|
||||
// The stale unpark may have squeezed in only while gen 0 was
|
||||
// still Running (→ Notified) — in which case set_done's swap
|
||||
// absorbed it — or it observed Done/Vacant/gen-1 and no-op'd.
|
||||
// Either way it must never claim an enqueue.
|
||||
assert_ne!(stale.join().unwrap(), Unpark::Enqueue);
|
||||
// And the new occupant is exactly where its spawn put it.
|
||||
assert_eq!(word.load(), pack(1, 0, ST_QUEUED));
|
||||
});
|
||||
}
|
||||
|
||||
/// Unpark racing the claim itself: whatever the interleaving, the actor
|
||||
/// is Running or RunningNotified afterwards and nobody enqueued (it was
|
||||
/// never parked).
|
||||
#[test]
|
||||
fn unpark_vs_claim_coalesces() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
|
||||
let w = word.clone();
|
||||
let unparker = thread::spawn(move || w.unpark(0, None));
|
||||
|
||||
assert!(word.try_claim(0)); // the entry is ours; claim must win
|
||||
let r = unparker.join().unwrap();
|
||||
assert_ne!(r, Unpark::Enqueue);
|
||||
let st = word_state(word.load());
|
||||
assert!(matches!(st, ST_RUNNING | ST_RUNNING_NOTIFIED));
|
||||
});
|
||||
}
|
||||
}
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
//! gen_statem — generic finite state machine behaviour (RFC 017).
|
||||
//!
|
||||
//! The sibling of [`gen_server`](crate::gen_server): where a `gen_server`
|
||||
//! carries one undifferentiated blob of state and a single `handle` that
|
||||
//! re-derives "what mode am I in" on every message, a `gen_statem` makes the
|
||||
//! state an explicit **tag**, routes event handling by it, and (in later
|
||||
//! chunks) adds the machinery state machines need — state-entry callbacks, a
|
||||
//! timeout taxonomy, and event postponement.
|
||||
//!
|
||||
//! ## What this layer is
|
||||
//!
|
||||
//! This module is the **runtime support** a state machine runs on, *not* the
|
||||
//! authoring surface. A machine is any type implementing [`Machine`]: it owns
|
||||
//! its state tag and data, and its [`handle`](Machine::handle) reduces a
|
||||
//! `(state, event)` pair to a [`Resolution`]. The loop here drives it — spawn,
|
||||
//! [`on_start`](Machine::on_start), then one [`handle`](Machine::handle) per
|
||||
//! inbox event — mirroring `gen_server`'s spawn/teardown idioms.
|
||||
//!
|
||||
//! The RFC's `statem!` macro (deferred) would *generate* a `Machine` impl from
|
||||
//! a declarative `transitions { … }` graph plus per-state handler blocks, and
|
||||
//! add the expansion-time edge-lint. Until then a machine is hand-written
|
||||
//! against these primitives; see `examples/statem_switch.rs` for the shape the
|
||||
//! macro would target.
|
||||
//!
|
||||
//! ## The unified event
|
||||
//!
|
||||
//! A machine's [`Ev`](Machine::Ev) is the single payload its inbox carries.
|
||||
//! By convention (and in the macro's desugaring) it folds the user's `cast`
|
||||
//! and `call` enums together with the runtime's own internal events:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! enum Ev { Cast(MyCast), Call(MyCall) /* later: StateTimeout, Timeout(name) */ }
|
||||
//! ```
|
||||
//!
|
||||
//! [`StatemRef::send`] pushes any event (a cast is just a `send`);
|
||||
//! [`StatemRef::call`] builds a one-shot [`Reply`] channel, hands it to a
|
||||
//! `call` variant, and parks until the machine answers — exactly the
|
||||
//! `gen_server` call round-trip, but with the reply handle riding *inside* the
|
||||
//! user's own event so a handler can answer (or, later, postpone) it.
|
||||
//!
|
||||
//! ## Chunk status (RFC 017 §Sequencing)
|
||||
//!
|
||||
//! This is **chunk 1**: macro-free dispatch against real time — spawn,
|
||||
//! `on_start`, stay/transition, and the `enter` callback (a method on the
|
||||
//! machine, run on entry to a state). [`Resolution::Postpone`] and the timeout
|
||||
//! arming on [`Cx`] are part of the type surface but are not yet acted on; they
|
||||
//! land in chunks 2–3.
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::pid::Pid;
|
||||
use crate::scheduler::spawn as spawn_actor;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A finite state machine driven by the [`statem`](crate::statem) loop.
|
||||
///
|
||||
/// The implementor owns its current state tag and its persistent data. It is
|
||||
/// the **sole writer** of the state cell (the loop never touches it): both
|
||||
/// transition legality and, later, observer reads have a single source of
|
||||
/// truth. The loop only calls [`on_start`](Self::on_start) once and
|
||||
/// [`handle`](Self::handle) per event.
|
||||
pub trait Machine: Send + 'static {
|
||||
/// The single payload this machine's inbox carries — the user's `cast` and
|
||||
/// `call` enums folded together with the runtime's internal events. See the
|
||||
/// module docs.
|
||||
type Ev: Send + 'static;
|
||||
|
||||
/// Runs once inside the actor before the first event. The canonical body
|
||||
/// runs the `enter` arm for the initial state.
|
||||
fn on_start(&mut self, cx: &mut Cx<Self::Ev>);
|
||||
|
||||
/// React to one event. The body matches `(state, event)`, performs side
|
||||
/// effects / replies, and ends each arm in a [`Resolution`] — typically a
|
||||
/// state tag via `Tag.into()` (transition, or "stay" when it equals the
|
||||
/// current tag). On a transition the body sets the state cell and runs the
|
||||
/// new state's `enter`.
|
||||
fn handle(&mut self, ev: Self::Ev, cx: &mut Cx<Self::Ev>);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The outcome of handling one event, before the loop/handler acts on it:
|
||||
/// dispatch reduces to `(state, event) -> Resolution<State>` (RFC §Semantics).
|
||||
///
|
||||
/// [`From<S>`](From) is why a bare state tag works as an arm tail and why
|
||||
/// "stay" needs no keyword — `Tag.into()` is `To(Tag)`, and the handler treats
|
||||
/// `To(s)` with `s == current` as stay.
|
||||
pub enum Resolution<S> {
|
||||
/// End in state `s`. A **transition** when `s != current` (set the cell,
|
||||
/// run `enter`); a **stay** when `s == current` (no `enter`).
|
||||
To(S),
|
||||
/// Defer the current event onto the postpone queue, to be replayed after
|
||||
/// the next real transition. Produced by `cx.postpone()` (chunk 3); present
|
||||
/// here for forward-compatibility but not yet generated.
|
||||
Postpone,
|
||||
/// No arm matched `(state, event)`: log-and-drop via
|
||||
/// [`Cx::on_unhandled`], mirroring `gen_server`'s handling of unexpected
|
||||
/// messages.
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl<S> From<S> for Resolution<S> {
|
||||
fn from(s: S) -> Self {
|
||||
Resolution::To(s)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cx — the per-handler context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The context handle injected into [`Machine::on_start`] and
|
||||
/// [`Machine::handle`]. Non-state outcomes (postpone, timeout arming) live here
|
||||
/// as method calls rather than keywords (RFC §"Non-state outcomes live on cx").
|
||||
///
|
||||
/// In chunk 1 it carries only the [`on_unhandled`](Self::on_unhandled) hook;
|
||||
/// `cx.state_timeout(d)` / `cx.timeout(name, d)` (chunk 2) and `cx.postpone()`
|
||||
/// (chunk 3) attach here as those chunks land. It lives only on the actor's own
|
||||
/// stack and is never sent.
|
||||
pub struct Cx<Ev> {
|
||||
_ev: PhantomData<fn() -> Ev>,
|
||||
}
|
||||
|
||||
impl<Ev> Cx<Ev> {
|
||||
fn new() -> Self {
|
||||
Cx { _ev: PhantomData }
|
||||
}
|
||||
|
||||
/// The default for an event no arm matched: **log-and-drop**. Overridable
|
||||
/// hook wiring is a follow-on (RFC Open Q5); for now an unmatched event is
|
||||
/// silently dropped, as `gen_server` does with unexpected messages.
|
||||
pub fn on_unhandled(&mut self) {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reply — the move-only reply handle a `call` variant carries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The reply side of a synchronous `call`, carried *inside* the machine's own
|
||||
/// `call` event variant (`GetCount(Reply<u32>)`). Move-only: answering consumes
|
||||
/// it, so a handler replies at most once. Built by [`StatemRef::call`]; the
|
||||
/// caller parks on the matching receiver until `reply` is invoked (or the
|
||||
/// machine dies, closing the channel).
|
||||
///
|
||||
/// Carrying the handle in the event — rather than the loop owning a reply slot
|
||||
/// — is what lets a later chunk **postpone a call**: the whole event, reply
|
||||
/// handle included, moves onto the postpone queue and is answered by a later
|
||||
/// state (RFC §Postpone).
|
||||
pub struct Reply<T> {
|
||||
tx: Sender<T>,
|
||||
}
|
||||
|
||||
impl<T> Reply<T> {
|
||||
/// Answer the call. A dropped/abandoned caller (e.g. one that timed out)
|
||||
/// makes the send fail harmlessly — the machine's reply is simply
|
||||
/// discarded, exactly as `gen_server`'s reply send behaves.
|
||||
pub fn reply(self, value: T) {
|
||||
let _ = self.tx.send(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returned by [`StatemRef::call`] when the machine is unreachable.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum CallError {
|
||||
/// The machine was already gone, or died before replying.
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Returned by [`StatemRef::send`] when the machine's inbox is closed.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SendError {
|
||||
/// The machine is gone (its inbox is closed).
|
||||
Down,
|
||||
}
|
||||
|
||||
/// A clonable handle to a running machine. Cloning yields another sender to the
|
||||
/// same inbox; the machine lives until the last `StatemRef` is dropped, at which
|
||||
/// point its inbox closes and the loop exits.
|
||||
pub struct StatemRef<M: Machine> {
|
||||
tx: Sender<M::Ev>,
|
||||
pid: Pid,
|
||||
}
|
||||
|
||||
impl<M: Machine> Clone for StatemRef<M> {
|
||||
fn clone(&self) -> Self {
|
||||
StatemRef { tx: self.tx.clone(), pid: self.pid }
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Machine> StatemRef<M> {
|
||||
/// The machine actor's pid — usable with `monitor`, `request_stop`, `link`.
|
||||
pub fn pid(&self) -> Pid {
|
||||
self.pid
|
||||
}
|
||||
|
||||
/// Push one event into the inbox and return immediately (fire-and-forget).
|
||||
/// A cast is just a `send` of the cast-tagged event. [`SendError::Down`] if
|
||||
/// the inbox is already closed.
|
||||
pub fn send(&self, ev: M::Ev) -> Result<(), SendError> {
|
||||
self.tx.send(ev).map_err(|_| SendError::Down)
|
||||
}
|
||||
|
||||
/// Synchronous request-reply. Builds a one-shot [`Reply`] channel, hands it
|
||||
/// to `make` to construct the call-tagged event, sends it, and parks until
|
||||
/// the machine replies — or returns [`CallError::Down`] if the machine is or
|
||||
/// becomes unreachable first (the reply sender is dropped as the machine's
|
||||
/// stack unwinds, closing the channel and waking the caller).
|
||||
///
|
||||
/// `make` wraps the [`Reply`] all the way to `M::Ev` — typically
|
||||
/// `|r| Ev::Call(MyCall::GetCount(r))`. (The deferred macro would generate a
|
||||
/// thinner `call` that hides the `Ev::Call` wrap and takes the bare variant
|
||||
/// constructor.)
|
||||
pub fn call<T, F>(&self, make: F) -> Result<T, CallError>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(Reply<T>) -> M::Ev,
|
||||
{
|
||||
let (tx, rx) = channel::<T>();
|
||||
let ev = make(Reply { tx });
|
||||
self.send(ev).map_err(|_| CallError::Down)?;
|
||||
rx.recv().map_err(|_| CallError::Down)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn + loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawn `machine` as an actor and hand back its [`StatemRef`]. Shape mirrors
|
||||
/// `gen_server::start`: make the inbox, spawn the loop, return the ref; the
|
||||
/// backing join handle is dropped (lifetime is governed by refs, not joining).
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn spawn<M: Machine>(machine: M) -> StatemRef<M> {
|
||||
let (tx, rx) = channel::<M::Ev>();
|
||||
let handle = spawn_actor(move || statem_loop(rx, machine));
|
||||
StatemRef { tx, pid: handle.pid() }
|
||||
}
|
||||
|
||||
/// The machine actor body: `on_start`, then one `handle` per inbox event until
|
||||
/// the inbox closes (all refs dropped → graceful shutdown). Chunk 1 parks on
|
||||
/// the inbox alone — no system arm, no timers — the analogue of `gen_server`'s
|
||||
/// plain-inbox park.
|
||||
fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
let mut cx = Cx::new();
|
||||
machine.on_start(&mut cx);
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(ev) => machine.handle(ev, &mut cx),
|
||||
// All StatemRefs dropped → inbox closed → shutdown.
|
||||
Err(_) => break,
|
||||
}
|
||||
// Observation point so a machine fed a hot inbox stays preemptible and
|
||||
// cancellable.
|
||||
crate::check!();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// gen_statem! — the authoring macro (RFC 017 §Surface, fused variant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Assemble a complete [`Machine`] from hand-written types, a glanceable
|
||||
/// transition table, and free handler functions.
|
||||
///
|
||||
/// This is the **fused** authoring surface (see `examples/statem_fused.rs` for
|
||||
/// the same machine written out by hand). You keep ownership of every type that
|
||||
/// carries meaning — the state enum, the data struct, the `cast`/`call` enums,
|
||||
/// and any per-row successor enums — and the macro emits only the mechanical
|
||||
/// scaffolding: the unified event enum, the machine struct and its private
|
||||
/// state cell, `start`, the `Machine` impl, and the `enter` dispatch.
|
||||
///
|
||||
/// The macro is **pure sugar with no semantic analysis of its own** — it never
|
||||
/// inspects your types and never validates the graph. Every safety property is
|
||||
/// a property of the code it emits, enforced by the *compiler* — which is what
|
||||
/// lets a plain `macro_rules!` carry almost the whole guarantee set a proc-macro
|
||||
/// would (the one exception, conflicting rows across a crate boundary, is
|
||||
/// spelled out below).
|
||||
///
|
||||
/// # The four guarantees (and exactly how strong each is)
|
||||
///
|
||||
/// Two of these are hard `rustc` errors that fire unconditionally. The other
|
||||
/// two are *lints*, so they need a deny in force — and lints interact with
|
||||
/// macros, which is called out below. Put `#![deny(dead_code)]` on the crate
|
||||
/// using the macro (the dispatch already denies its own pattern lint).
|
||||
///
|
||||
/// 1. **Forgotten `(state, event)` pair → `E0004` (hard error, always).** The
|
||||
/// dispatch is a *total* `match (state, event)` with **no macro-injected
|
||||
/// catch-all**: a pair you never wrote makes the match non-exhaustive.
|
||||
/// Totality is the price — refuse the events you don't handle with explicit
|
||||
/// `=> unhandled` rows (OR-patterns keep that to one row per state).
|
||||
/// 4. **Out-of-set branch target → `E0599` (hard error, always).** A branching
|
||||
/// row returns a per-state *successor enum* (its variants are that state's
|
||||
/// declared targets); naming a tag outside it is a missing variant.
|
||||
/// 3. **Orphan handler → `dead_code` (lint; needs your `#![deny(dead_code)]`).**
|
||||
/// Handlers are ordinary module-private `fn`s in *your* crate, reachable only
|
||||
/// through the table, so this lint sees them normally.
|
||||
/// 2. **Duplicated / conflicting row → `unreachable_patterns` (lint; with a
|
||||
/// caveat).** Your patterns are emitted verbatim into one match, so a second
|
||||
/// arm for the same pair is unreachable. The macro applies
|
||||
/// `#[deny(unreachable_patterns)]` to the dispatch itself — **but** `rustc`
|
||||
/// silences this lint for code expanded from a macro defined in *another*
|
||||
/// crate (`in_external_macro`). So a conflicting row is a hard error when the
|
||||
/// machine lives in the same crate as this macro, and is **silently dropped
|
||||
/// when you call `gen_statem!` from a downstream crate** — neither a crate
|
||||
/// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one
|
||||
/// guarantee a `macro_rules!` cannot carry across the crate boundary; the
|
||||
/// RFC's proc-macro could (it would stamp the arms with call-site spans).
|
||||
/// Guard against it in review, or with an in-crate test of the machine.
|
||||
///
|
||||
/// There is deliberately **no separate `transitions { … }` adjacency block**:
|
||||
/// in this variant the `match` *is* the table and the successor enums *are* the
|
||||
/// per-state target sets, so a second listing would be redundant and
|
||||
/// un-cross-checkable without a proc-macro. The graph is read off the `on`
|
||||
/// blocks and the successor enums at the top of the file.
|
||||
///
|
||||
/// # Surface
|
||||
///
|
||||
/// ```ignore
|
||||
/// // ── your types (the macro never generates or inspects these) ───────────
|
||||
/// #[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
/// enum Switch { Off, On }
|
||||
/// struct Counts { flips: u32, enters: u32 }
|
||||
/// enum SwitchCast { Flip }
|
||||
/// enum SwitchCall { GetCount(Reply<u32>) }
|
||||
///
|
||||
/// gen_statem! {
|
||||
/// machine: SwitchSm { state: Switch, data: Counts };
|
||||
/// event: Ev { cast: SwitchCast, call: SwitchCall };
|
||||
///
|
||||
/// // Name the bindings your handler bodies use. A declarative macro can't
|
||||
/// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers
|
||||
/// // and the macro binds them: `data` = &mut your Data, `prev` = the
|
||||
/// // current state tag, `cx` = the context handle.
|
||||
/// context(data, prev, cx);
|
||||
///
|
||||
/// // `enter` runs on entry to a state; side effects only, returns ().
|
||||
/// // Match the state tag (or `_`); the arm body is statements.
|
||||
/// enter {
|
||||
/// _ => data.enters += 1,
|
||||
/// }
|
||||
///
|
||||
/// // The transition table. Group rows by current state with `on <pat>`.
|
||||
/// // A row is: cast|call <event-pattern> [if <guard>] => <tail> ,
|
||||
/// // and the tail is one of:
|
||||
/// // * a state tag `Switch::On` (transition, or "stay"
|
||||
/// // if it equals current)
|
||||
/// // * a block ending in one `{ data.flips += 1; Switch::On }`
|
||||
/// // * a successor-enum value `unlock(key)` (branching row)
|
||||
/// // * the keyword `unhandled` (explicit refusal)
|
||||
/// on Switch::Off => {
|
||||
/// cast SwitchCast::Flip => { data.flips += 1; Switch::On },
|
||||
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
|
||||
/// }
|
||||
/// on Switch::On => {
|
||||
/// cast SwitchCast::Flip => Switch::Off,
|
||||
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Writing the rows
|
||||
///
|
||||
/// * **Every row ends in a comma** (block-bodied ones too) and every `on` block
|
||||
/// is `on <state-pat> => { … }`. These two are macro-grammar requirements, not
|
||||
/// style: a declarative matcher can't otherwise tell where a row's tail ends.
|
||||
/// * **Qualify event patterns** (`SwitchCast::Flip`) and **state tags**
|
||||
/// (`Switch::On`). A declarative macro can't prepend the enum name inside an
|
||||
/// opaque pattern fragment, so the `cast`/`call` keyword only selects the
|
||||
/// event wrapper — it does not qualify for you. The keyword also reads as a
|
||||
/// sync/async marker at a glance.
|
||||
/// * **Stay** = return the current tag. The `prev` you named in `context` is
|
||||
/// bound to the pre-handler state for exactly this — handy in any-state
|
||||
/// (`on _`) rows where there is no single literal tag to write.
|
||||
/// * **`data` and `cx`** (the names you chose) are in scope in every body:
|
||||
/// mutate `data`, reply through a `Reply` bound in the pattern, and (chunks
|
||||
/// 2–3) arm timeouts or postpone via `cx`. You never write `self`.
|
||||
/// * **Guards** are plain match guards. A guarded arm does not count toward
|
||||
/// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly)
|
||||
/// trip `E0004`.
|
||||
/// * **Branching rows** return a successor enum that `impl`s `From<_>` for the
|
||||
/// state type; the macro supplies the outer `.into()`.
|
||||
///
|
||||
/// # What it emits
|
||||
///
|
||||
/// `enum $Ev { Cast($Cast), Call($Call) }`, `struct $Sm { state, data }`,
|
||||
/// `$Sm::start(init, data) -> StatemRef<$Sm>`, the `Machine` impl (`on_start`
|
||||
/// running the initial `enter`; `handle` = the dispatch match + the
|
||||
/// stay/transition/unhandled apply-tail, the cell's sole writer), and the
|
||||
/// `enter` dispatch. Chunk 1: real time, no timers, no postpone.
|
||||
///
|
||||
/// # Limitation
|
||||
///
|
||||
/// Diagnostics point at the macro expansion, not the offending source row —
|
||||
/// the cost of staying a `macro_rules!`. The errors are the standard `E0004` /
|
||||
/// `E0599` / lint messages, just sited at the invocation.
|
||||
#[macro_export]
|
||||
macro_rules! gen_statem {
|
||||
// ===== public entry =====================================================
|
||||
(
|
||||
machine: $sm:ident { state: $State:ty, data: $Data:ty } ;
|
||||
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty } ;
|
||||
context ( $data:ident , $cur:ident , $cx:ident ) ;
|
||||
enter { $( $est:pat => $ebody:expr ),+ $(,)? }
|
||||
$( on $st:pat => { $($rows:tt)* } )+
|
||||
) => {
|
||||
/// Unified inbox payload: the user's `cast`/`call` enums folded together
|
||||
/// (chunks 2–3 add the runtime's internal timeout variants here).
|
||||
enum $Ev {
|
||||
Cast($Cast),
|
||||
Call($Call),
|
||||
}
|
||||
|
||||
struct $sm {
|
||||
state: $State,
|
||||
data: $Data,
|
||||
}
|
||||
|
||||
impl $sm {
|
||||
fn start(init: $State, data: $Data) -> $crate::statem::StatemRef<$sm> {
|
||||
$crate::statem::spawn($sm { state: init, data })
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
#[deny(unreachable_patterns)]
|
||||
fn enter(&mut self, state: $State, $cx: &mut $crate::statem::Cx<$Ev>) {
|
||||
let $data = &mut self.data;
|
||||
match state {
|
||||
$( $est => { $ebody } ),+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl $crate::statem::Machine for $sm {
|
||||
type Ev = $Ev;
|
||||
|
||||
fn on_start(&mut self, $cx: &mut $crate::statem::Cx<$Ev>) {
|
||||
let s = self.state;
|
||||
self.enter(s, $cx);
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
#[deny(unreachable_patterns)] // conflicting rows must fail even though
|
||||
// this match is external-macro-expanded
|
||||
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::statem::Cx<$Ev>) {
|
||||
// Caller-named bindings (shared call-site hygiene, so row bodies
|
||||
// can see them): `$cur` = current state tag, `$data` = &mut Data.
|
||||
let $cur = self.state;
|
||||
let $data = &mut self.data;
|
||||
let next: $crate::statem::Resolution<$State> =
|
||||
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ]
|
||||
$( on $st => { $($rows)* } )+);
|
||||
match next {
|
||||
$crate::statem::Resolution::To(s) if s == $cur => {}
|
||||
$crate::statem::Resolution::To(s) => {
|
||||
self.state = s; // <- sole writer of the state cell
|
||||
self.enter(s, $cx);
|
||||
}
|
||||
$crate::statem::Resolution::Postpone => {
|
||||
unreachable!("postpone is unreachable until chunk 3")
|
||||
}
|
||||
$crate::statem::Resolution::Unhandled => $cx.on_unhandled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ===== @arms: build the dispatch match ==================================
|
||||
// No more on-blocks: emit the (total, catch-all-free) match.
|
||||
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => {
|
||||
match ($ss, $se) { $($arms)* }
|
||||
};
|
||||
// Open an on-block: remember its state pat, drain its rows, then continue.
|
||||
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]
|
||||
on $st:pat => { $($rows:tt)* } $($more:tt)*
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] ($st)
|
||||
{ $($rows)* } { $($more)* })
|
||||
};
|
||||
|
||||
// ===== @rows: drain one on-block's rows, threading the global acc ========
|
||||
// cast, explicit refusal
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
{ cast $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::statem::Resolution::Unhandled, ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// cast, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
{ cast $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::statem::Resolution::To($tail.into()), ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// call, explicit refusal
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
{ call $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::statem::Resolution::Unhandled, ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// call, transition / stay / branch
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
{ call $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::statem::Resolution::To($tail.into()), ]
|
||||
($st) { $($rows)* } { $($more)* })
|
||||
};
|
||||
// this block is drained: hand the remaining on-blocks back to @arms
|
||||
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||
{ } { $($more:tt)* }
|
||||
) => {
|
||||
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] $($more)*)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gen_statem_tests {
|
||||
use crate::statem::Reply;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Switch {
|
||||
Off,
|
||||
On,
|
||||
}
|
||||
struct Counts {
|
||||
flips: u32,
|
||||
enters: u32,
|
||||
}
|
||||
enum Cast {
|
||||
Flip,
|
||||
Nudge,
|
||||
}
|
||||
enum Call {
|
||||
GetFlips(Reply<u32>),
|
||||
GetEnters(Reply<u32>),
|
||||
}
|
||||
|
||||
fn on_flip(data: &mut Counts) {
|
||||
data.flips += 1;
|
||||
}
|
||||
|
||||
crate::gen_statem! {
|
||||
machine: SwitchSm { state: Switch, data: Counts };
|
||||
event: Ev { cast: Cast, call: Call };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter {
|
||||
_ => data.enters += 1,
|
||||
}
|
||||
|
||||
on Switch::Off => {
|
||||
cast Cast::Flip => { on_flip(data); Switch::On },
|
||||
cast Cast::Nudge => unhandled,
|
||||
}
|
||||
on Switch::On => {
|
||||
cast Cast::Flip => { on_flip(data); Switch::Off },
|
||||
cast Cast::Nudge => prev, // explicit stay
|
||||
}
|
||||
on _ => {
|
||||
call Call::GetFlips(r) => { r.reply(data.flips); prev },
|
||||
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE (guarantee #2): because this machine lives in the SAME crate as
|
||||
// `gen_statem!`, adding a duplicate row here — e.g. a second
|
||||
// `cast Cast::Nudge => unhandled,` under `on Switch::Off` — is a hard
|
||||
// `unreachable pattern` error. (From a downstream crate it is silently
|
||||
// suppressed by rustc's in_external_macro rule; see the macro docs.)
|
||||
#[test]
|
||||
fn macro_machine_drives_and_counts() {
|
||||
crate::run(|| {
|
||||
let sm = SwitchSm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
sm.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flip=1, enter)
|
||||
sm.send(Ev::Cast(Cast::Nudge)).unwrap(); // On: stay (no enter)
|
||||
sm.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (flip=2, enter)
|
||||
|
||||
let flips = sm.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
let enters = sm.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
assert_eq!(flips, 2);
|
||||
assert_eq!(enters, 3); // Off(start) + On + Off
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! std vs loom indirection for the modules that loom model-checks
|
||||
//! (`slot_state`, `run_queue`). Everything else uses std paths directly —
|
||||
//! the full runtime (context switches, futexes, real TLS) is not loom-able
|
||||
//! and is never executed under `cfg(loom)`.
|
||||
//!
|
||||
//! Build the loom models with: `RUSTFLAGS="--cfg loom" cargo test --lib --release`
|
||||
|
||||
#[cfg(loom)]
|
||||
pub(crate) use loom::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(not(loom))]
|
||||
pub(crate) use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
/// `UnsafeCell` with loom's `with`/`with_mut` access API; pass-through cost
|
||||
/// is zero in normal builds (`#[inline]`, newtype over std's cell).
|
||||
#[cfg(loom)]
|
||||
pub(crate) use loom::cell::UnsafeCell;
|
||||
|
||||
#[cfg(not(loom))]
|
||||
pub(crate) struct UnsafeCell<T>(std::cell::UnsafeCell<T>);
|
||||
|
||||
#[cfg(not(loom))]
|
||||
impl<T> UnsafeCell<T> {
|
||||
pub(crate) fn new(v: T) -> Self {
|
||||
Self(std::cell::UnsafeCell::new(v))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn with<R>(&self, f: impl FnOnce(*const T) -> R) -> R {
|
||||
f(self.0.get())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn with_mut<R>(&self, f: impl FnOnce(*mut T) -> R) -> R {
|
||||
f(self.0.get())
|
||||
}
|
||||
}
|
||||
+104
-22
@@ -15,12 +15,14 @@
|
||||
//! `BinaryHeap` is a max-heap; entries are wrapped in `Reverse` to get
|
||||
//! min-heap behaviour.
|
||||
//!
|
||||
//! No cancellation. When a non-timer wakeup happens (e.g. lock granted
|
||||
//! before timeout), the timer entry is left in the heap. It will be popped
|
||||
//! eventually and the dispatch will observe "actor is no longer parked /
|
||||
//! wait_seq is stale" and no-op. Cost is ~32 bytes per stale entry plus a
|
||||
//! few cycles on pop; acceptable given the upper bound is "one entry per
|
||||
//! parked actor".
|
||||
//! Cancellation is selective. A `Sleep` / `WaitTimeout` entry is left in the
|
||||
//! heap on a non-timer wakeup (lock granted before timeout): it is popped
|
||||
//! eventually and no-ops because a stale unpark fails its epoch CAS — cheap
|
||||
//! (~32 bytes per stale entry plus a few cycles on pop), bounded by one entry
|
||||
//! per parked actor. A `Send` entry is different: running its thunk delivers a
|
||||
//! 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
|
||||
//! pop by the scheduler — same convention as the run queue.
|
||||
@@ -35,19 +37,53 @@ use std::time::{Duration, Instant};
|
||||
///
|
||||
/// Held inside `Entry`, dispatched by the scheduler in `pop_due`.
|
||||
pub enum Reason {
|
||||
/// `loom::sleep(d)`. Unpark `pid` unconditionally (modulo the usual
|
||||
/// "still parked?" check the scheduler applies).
|
||||
Sleep,
|
||||
/// A bounded wait — currently only `Mutex::lock_timeout`. On expiry the
|
||||
/// scheduler calls `target.on_timeout(pid, wait_seq)`. The target then
|
||||
/// decides whether `pid` was actually still waiting, and if so unparks
|
||||
/// it with whatever error the wait was bounded for. `wait_seq` lets the
|
||||
/// target tell apart "this wait" from "a later wait by the same actor
|
||||
/// on the same target".
|
||||
/// `sleep(d)`. Wake `pid` via the epoch-matched unpark: if anything
|
||||
/// else (necessarily a terminal wake) already consumed the wait, the
|
||||
/// entry is stale and no-ops at the CAS.
|
||||
Sleep { epoch: u32 },
|
||||
/// A bounded wait (`Mutex::lock_timeout`, `Receiver::recv_timeout`,
|
||||
/// `select_timeout`). On expiry the scheduler calls
|
||||
/// `target.on_timeout(pid, epoch)`. The target then decides whether
|
||||
/// `pid` was actually still waiting (registration still present under
|
||||
/// its lock), and if so takes the registration and unparks via
|
||||
/// `unpark_at`. The epoch is the slot-word park-epoch — the runtime-wide
|
||||
/// wait identity — so a stale entry is doubly inert: the registration
|
||||
/// check misses, and even a racing unpark fails the word's epoch CAS.
|
||||
WaitTimeout {
|
||||
target: Arc<dyn TimerTarget>,
|
||||
wait_seq: u64,
|
||||
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.
|
||||
@@ -55,7 +91,7 @@ pub enum Reason {
|
||||
/// Implementors: do not touch `SchedulerState` other than via the public
|
||||
/// `unpark` / channel APIs. The scheduler is mid-iteration when this fires.
|
||||
pub trait TimerTarget: Send + Sync {
|
||||
fn on_timeout(&self, pid: Pid, wait_seq: u64);
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32);
|
||||
}
|
||||
|
||||
pub struct Entry {
|
||||
@@ -94,18 +130,53 @@ impl PartialOrd for Entry {
|
||||
pub struct Timers {
|
||||
/// Reverse-wrapped so the smallest deadline is at the top.
|
||||
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,
|
||||
/// 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 {
|
||||
pub fn new() -> Self {
|
||||
Self { heap: BinaryHeap::new(), next_seq: 0 }
|
||||
Self { heap: BinaryHeap::new(), next_seq: 0, armed: std::collections::HashSet::new() }
|
||||
}
|
||||
|
||||
/// Insert a `Sleep` timer. Convenience for the common case.
|
||||
pub fn insert_sleep(&mut self, deadline: Instant, pid: Pid) {
|
||||
self.insert(deadline, pid, Reason::Sleep);
|
||||
pub fn insert_sleep(&mut self, deadline: Instant, pid: Pid, epoch: u32) {
|
||||
self.insert(deadline, pid, Reason::Sleep { epoch });
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let seq = self.next_seq;
|
||||
self.next_seq = self.next_seq.wrapping_add(1);
|
||||
self.armed.insert(seq);
|
||||
self.heap.push(Reverse(Entry { deadline, seq, pid, reason: Reason::Send { fire } }));
|
||||
TimerId(seq)
|
||||
}
|
||||
|
||||
/// 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 timer entry.
|
||||
@@ -124,6 +195,7 @@ impl Timers {
|
||||
/// discarded so it can't keep the runtime alive.
|
||||
pub fn clear(&mut self) {
|
||||
self.heap.clear();
|
||||
self.armed.clear();
|
||||
}
|
||||
|
||||
/// Soonest pending deadline, or `None` if the heap is empty.
|
||||
@@ -133,11 +205,21 @@ impl Timers {
|
||||
|
||||
/// Pop every entry whose deadline is ≤ `now`, in deadline order.
|
||||
/// 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`.
|
||||
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(r) = self.heap.peek() {
|
||||
if r.0.deadline <= now {
|
||||
out.push(self.heap.pop().unwrap().0);
|
||||
let entry = self.heap.pop().unwrap().0;
|
||||
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.remove(&entry.seq) {
|
||||
// Cancelled before it came due: discard, do not deliver.
|
||||
continue;
|
||||
}
|
||||
out.push(entry);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ mod inner {
|
||||
// Queue
|
||||
Enqueue(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
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -237,6 +240,8 @@ mod inner {
|
||||
Event::RecvWake(p) => ("recv_wake".into(), p.index()),
|
||||
Event::Enqueue(p) => ("enqueue".into(), p.index()),
|
||||
Event::Dequeue(p) => ("dequeue".into(), p.index()),
|
||||
Event::SlotPush(p) => ("slot_push".into(), p.index()),
|
||||
Event::SlotPop(p) => ("slot_pop".into(), p.index()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
# smarm — task.md (next steps)
|
||||
|
||||
Handoff for a future session reusing this sandbox. Read top to bottom once
|
||||
before starting; the gotchas section is hard-won and will save you a faceplant.
|
||||
|
||||
## Resume the environment
|
||||
|
||||
- Repo: `smarm`. Two branches (the old single `arm-port` stack was split):
|
||||
- `master` — the mainline, and HEAD. Carries roadmap #1–#5: cooperative
|
||||
cancellation, supervisor strategies (one_for_one/all, rest_for_one) + the
|
||||
orphaned-timer shutdown fix, links/trap_exit, selective receive, gen_server,
|
||||
and demonitor/`MonitorId`. Tagged `v0.4.0`. x86-64 Linux only.
|
||||
- `arm-port` — `master` plus a single commit: `feat(arch): aarch64 context
|
||||
switch + cycle counter`. Extracts the x86-64 context-switch / stack-init /
|
||||
cycle-counter out of `context.rs` into a `target_arch`-gated `src/arch/`
|
||||
(x86_64 + aarch64 backends) and adds an AAPCS64 backend. ⚠️ UNTESTED: never
|
||||
built or run on real ARM hardware. The x86-64 path is unchanged
|
||||
(`arch/x86_64.rs` is the old `context.rs` body verbatim), so the x86 suite
|
||||
passing says nothing about the aarch64 backend. Build + test on-device
|
||||
before trusting it.
|
||||
- Toolchain is installed but NOT on PATH in a fresh shell. First line of every
|
||||
session: `. "$HOME/.cargo/env"` (rustc/cargo 1.96).
|
||||
- Build `cargo build` · all tests `cargo test` · one suite `cargo test --test monitor`.
|
||||
- Bench probe `cargo bench --bench general` (custom print-only harness; compiles
|
||||
tokio in release the first time — slow — but `target/` persists across git
|
||||
checkouts so it's paid once).
|
||||
- Perf regression check: `git checkout <pre-change-sha>` →
|
||||
`cargo bench --bench general | tee before.txt` → `git checkout arm-port` →
|
||||
run again → diff the `smarm 1-thread` medians for `chained_spawn` and
|
||||
`yield_many` (those exercise spawn/finalize/scheduler). Numbers are noisy on
|
||||
this shared CPU; treat as "regression beyond noise?" not a precise delta.
|
||||
|
||||
## Roadmap (dependency order)
|
||||
|
||||
### 1. Cooperative cancellation — the keystone ✅ DONE (`a8ddb4a`)
|
||||
Everything below (one_for_all/rest_for_one, links) needs a *safe* way to stop a
|
||||
running peer. Forcible teardown of another green thread's stack is unsound here
|
||||
(shared heap + Drop). So: cooperative stop the actor observes and unwinds itself.
|
||||
|
||||
Shipped as designed (sentinel unwind, not Result-threading). Notes for what
|
||||
came next / future readers:
|
||||
- Stop flag lives on `Actor` behind `Arc<AtomicBool>` (fresh per spawn), NOT a
|
||||
`Slot` field — sidesteps the three-place reset, at the cost of one small
|
||||
alloc per spawn. The scheduler hands the resume path a raw `*const AtomicBool`
|
||||
(no per-resume refcount traffic); `yield_many` bench stayed at baseline,
|
||||
`chained_spawn` ~+6% from that alloc (left as-is; move to a `Slot` field if it
|
||||
ever matters).
|
||||
- Observation points: amortised `maybe_preempt`/`check!()` path + the wakeup
|
||||
side of `park_current`/`yield_now`. Sentinel = `StopSentinel` (zero-size),
|
||||
recognised in the trampoline → `Outcome::Stopped`. `join()` on a stopped actor
|
||||
returns `Ok(())` (no payload to propagate; reason is on the monitor channel).
|
||||
- Documented gaps confirmed by tests: no-observation-point loop can't be stopped
|
||||
(same as preemption); a user `catch_unwind` can swallow the sentinel but the
|
||||
flag stays set so the next yield re-raises.
|
||||
|
||||
Original plan, for reference:
|
||||
- Add a per-actor stop flag (Slot field + atomic, or check via shared state).
|
||||
- `request_stop(pid)`: set the flag, unpark if parked.
|
||||
- Realize the stop as a **controlled unwind**: when the scheduler resumes a
|
||||
stop-requested actor, inject a sentinel panic (dedicated payload type) so the
|
||||
existing `trampoline` `catch_unwind` tears the stack down and runs Drop. The
|
||||
trampoline recognizes the sentinel and reports a new `Outcome::Stopped`
|
||||
(distinct from a user `Panic`). This avoids changing every blocking-op
|
||||
signature.
|
||||
- Alternative considered: thread `Result<_, Cancelled>` through recv/sleep/
|
||||
lock/io. Rejected — large API churn. Go with the sentinel unwind.
|
||||
- Caveat to document: user code with its own `catch_unwind` can swallow the
|
||||
sentinel (cf. Erlang `catch`); re-check the flag at the next yield and/or
|
||||
re-raise. And a tight no-alloc loop without `check!()` can't be stopped —
|
||||
same inherent limitation as preemption.
|
||||
- Observation points: `maybe_preempt()`/`check!()` (cheap flag check) and the
|
||||
blocking parks (recv/sleep/mutex/io) on the stop-driven unpark.
|
||||
- Tests: looping actor on `check!()` gets stopped → `Outcome::Stopped`, Drop
|
||||
guards ran; parked-on-recv actor gets stopped; no-check loop documents the gap.
|
||||
|
||||
### 2. one_for_all / rest_for_one + ordered shutdown ✅ DONE (`351dc9c`)
|
||||
Shipped. What landed vs the plan:
|
||||
- `Strategy::{OneForOne,OneForAll,RestForOne}` selected via `.strategy()`,
|
||||
default `OneForOne`. The struct keeps the `OneForOne` name (compat; existing
|
||||
tests untouched) — a rename to `Supervisor` is a deferred refactor.
|
||||
- The *triggering* child's `Restart` policy decides whether anything restarts;
|
||||
the strategy decides which live siblings are cycled (all / index-> after the
|
||||
failed one). Survivors are `request_stop`'d in reverse start order, awaited on
|
||||
the existing `supervisor_channel` funnel (no new channel, no `select`),
|
||||
restarted in start order. One failure = one intensity tick regardless of group
|
||||
size. Out-of-band signals during an await are stashed and replayed.
|
||||
- `Signal::Stopped(pid)` + `DownReason::Stopped` added (kept distinct from Exit,
|
||||
as planned). A `Stopped` signal counts as abnormal for the restart decision.
|
||||
- Ordered shutdown: on cap-trip / mailbox-close-with-survivors, stop remaining
|
||||
children in reverse start order and await them (no-op on the normal exit).
|
||||
- ⚠️ Surfaced + fixed a latent keystone bug (`e80334b`): a cancelled
|
||||
sleeping/timeout actor orphans its timer entry, and the scheduler's shutdown
|
||||
check counted pending timers → `run()` hung until the dead actor's deadline
|
||||
fired (a `sleep(30s)` sleeper hung shutdown 30s). Fix: timers no longer gate
|
||||
shutdown (`live == 0` already implies nothing a timer could wake); heap is
|
||||
cleared on exit. Independent of the supervisor work.
|
||||
- Tests: all-restart (sibling cycled despite clean exit), suffix-restart
|
||||
(prefix child left alone), reverse-order teardown.
|
||||
|
||||
Original plan, for reference:
|
||||
- one_for_all: on any child failure, `request_stop` all siblings, await their
|
||||
termination signals, restart all per spec.
|
||||
- rest_for_one: stop+restart the failed child and those started after it.
|
||||
- Supervisor shutdown: stop children in reverse start order.
|
||||
- Decide signal surface: add `Signal::Stopped(pid)` + `DownReason::Stopped`
|
||||
rather than folding into Exit (clearer for the supervisor's await logic).
|
||||
- Tests: all-restart, suffix-restart, reverse-order shutdown.
|
||||
|
||||
### 3. Links + trap_exit ✅ DONE (`6581484`)
|
||||
- `Slot.links: Vec<Pid>` (bidirectional); `link`/`unlink`; `trap_exit()` flag
|
||||
lives on `Actor` (fresh per spawn → a restarted child starts un-trapped, and
|
||||
no fourth slot-reset site).
|
||||
- On finalize, reverse links are cleared under the lock (always — keeps the
|
||||
cascade acyclic), then for each linked peer: abnormal death (`Panic`/
|
||||
`Stopped`) → `request_stop(peer)` unless peer traps, in which case deliver an
|
||||
`ExitSignal` *message* instead. Normal exit does NOT propagate. Linking an
|
||||
already-dead pid delivers an immediate `NoProc` signal (message if trapping,
|
||||
else `request_stop(self)` — not a silent no-op).
|
||||
- Resolved: the trap inbox is a **dedicated** channel (`trap_exit() ->
|
||||
Receiver<ExitSignal>`), distinct from the monitor `Down` channel; `ExitSignal`
|
||||
reuses `DownReason` and carries no panic payload (joiner-only, as with
|
||||
monitors). `spawn_link` deferred to #5.
|
||||
- Tests (`tests/link.rs`): linked pair one panics → other stopped (+Drop ran);
|
||||
trap_exit → other gets a message and survives; normal exit doesn't propagate;
|
||||
dead-pid link stops a non-trapper / messages a trapper; `unlink` prevents
|
||||
propagation.
|
||||
|
||||
### 4. Selective receive (independent track) ✅ DONE (`03f3875`)
|
||||
Shipped. What landed vs the plan:
|
||||
- `Receiver::recv_match(pred) -> Result<T, RecvError>` scans the queued
|
||||
`VecDeque` front-to-back, removes+returns the first match, leaves the rest in
|
||||
arrival order; parks and re-scans when nothing matches. `try_recv_match` (the
|
||||
non-blocking variant, mirroring `try_recv`) rolled in same commit.
|
||||
- Wakeup turned out cheaper than feared: `Sender::send` *already* took
|
||||
`parked_receiver` on every push, so "wake on ANY send" needed no send-side
|
||||
change. The one real edit was relaxing `Sender::drop` to unpark the parked
|
||||
receiver on the last-sender drop regardless of queue emptiness — a selective
|
||||
receiver can park on a non-empty no-match queue and must wake to observe
|
||||
closure. No-op for plain `recv` (only ever parks on an empty queue); stress
|
||||
suite stays green.
|
||||
- `pred` is `Fn(&T) -> bool` (not `FnMut`) on purpose: it's re-run from scratch
|
||||
on every scan, so a stateful predicate would re-count surprisingly. It runs
|
||||
under the channel lock — keep it cheap/pure, don't re-enter the channel.
|
||||
- Close semantics: `recv_match` returns `Err(RecvError)` only when closed AND no
|
||||
queued message matches; a match is still returned on a closed channel.
|
||||
Non-matches are left for a later `recv`.
|
||||
- Tests (`tests/selective_recv.rs`): out-of-order match pulled first; non-matches
|
||||
remain in order; park-on-non-empty then wake on a match; closed-with-only-
|
||||
non-matches → Err; closed-but-match-present → match; `try_recv_match` states.
|
||||
|
||||
Original plan, for reference:
|
||||
- Add `Receiver::recv_match(pred) -> T`: scan the queued `VecDeque`, remove+return
|
||||
first match, leave the rest in order; park and re-scan on new arrivals.
|
||||
- This changes channel wakeup: a parked selective receiver must wake on ANY send
|
||||
(not just empty→nonempty) and re-scan. Touches `channel.rs` carefully — the
|
||||
stress tests guard lost-wakeup invariants; keep them green.
|
||||
- Tests: messages arrive out of interest-order; match pulled first; non-matches
|
||||
remain for a later `recv`.
|
||||
|
||||
### 5. Grab-bag (each its own small commit)
|
||||
- `spawn_link`: spawn-and-link atomically (deferred from #3); thin wrapper over
|
||||
`spawn_under` + `link`, but do it under one lock so there's no window where
|
||||
the child dies before the link is recorded.
|
||||
- `demonitor`: needs a per-monitor id to remove a specific sender. Decide the
|
||||
monitor API NOW before more code depends on it — likely return a
|
||||
`Monitor { id, rx }` instead of a bare `Receiver<Down>`.
|
||||
✅ DONE (this commit). What landed vs the plan:
|
||||
- `monitor()` now returns `Monitor { id, target, rx }` (added `target` over
|
||||
the sketched `{id, rx}` so `demonitor` jumps straight to the slot instead of
|
||||
scanning every slot for the id). `MonitorId(u64)` is opaque, from a
|
||||
monotonic `next_monitor_id` counter on `SharedState`, bumped under the
|
||||
shared lock in `monitor()` — no atomics, deterministic, never reused.
|
||||
- `Slot.monitors: Vec<(MonitorId, Sender<Down>)>`. The three slot-reset sites
|
||||
were untouched — they `.clear()`/`Vec::new()`, which is element-type-
|
||||
agnostic, so no new reset obligation. `finalize_actor` just destructures
|
||||
`(_, m)` and sends as before.
|
||||
- `demonitor(&Monitor) -> Option<MonitorId>`: `Some(id)` when a live
|
||||
registration was found+removed, `None` when it had already fired (drained on
|
||||
finalize), was `NoProc`, or the slot was reclaimed. Chose `Option<MonitorId>`
|
||||
over a bare bool — names which registration went. Generation half of the pid
|
||||
makes a stale demonitor a clean no-op: a recycled slot index fails
|
||||
`slot_mut`'s generation check, so it can never strip a different actor's
|
||||
monitor.
|
||||
- ⚠️ Reentrancy: the removed `Sender` is `remove`d out of the Vec under the
|
||||
lock but **dropped after the lock is released** — `Sender::drop` can unpark a
|
||||
parked receiver → `with_shared`, and the shared mutex is non-reentrant. Same
|
||||
discipline as `finalize_actor`.
|
||||
- "Flush" (discard a `Down` the target already queued) falls out of dropping
|
||||
the `Monitor`: `demonitor(&m); drop(m)`. That's the cleanup the still-to-come
|
||||
gen_server **call timeout** wants — monitor the server, wait reply-or-Down-
|
||||
or-deadline, then demonitor+drop so a timed-out call leaks no registration
|
||||
and no stale `Down`.
|
||||
- Perf: touches `Slot` + `finalize_actor`, but `chained_spawn`/`yield_many`
|
||||
register no monitors, so the Vec stays empty (take-empty is identical cost,
|
||||
finalize loop runs zero times). before/after `general` probe medians within
|
||||
noise. Tests (`tests/monitor.rs`): demonitor-stops-delivery, one-of-many
|
||||
(siblings untouched), after-fire-is-None.
|
||||
- Named registry: `register(name,pid)`/`whereis`/`send_by_name`; a
|
||||
`HashMap<String,Pid>` in `SharedState`.
|
||||
- gen_server-style call/cast: request-reply correlation as a thin layer over
|
||||
channels (`call` sends `{req, reply_tx}`, awaits `reply_rx`); no runtime change.
|
||||
✅ DONE (`a4fcf6c`). What landed vs the plan:
|
||||
- `GenServer` trait on the state value: assoc `Call`/`Reply`/`Cast` types,
|
||||
required `handle_call`/`handle_cast`, optional `init`/`terminate` hooks.
|
||||
`ServerRef<G>` is a clonable inbox sender + `pid()`; `start` / `start_under`.
|
||||
- One inbox, not two: a single `Envelope { Call(req, reply_tx) | Cast }`
|
||||
channel, dispatched by variant. Forced by no-`select`/no-unified-mailbox —
|
||||
a server can't wait on a call channel and a cast channel at once.
|
||||
- Server-down falls out of channel closure (no monitor needed): `send` fails
|
||||
if the inbox is gone; the reply sender drops on the server's unwind so a
|
||||
parked caller wakes to `Err`. Both → `Call/CastError::ServerDown`.
|
||||
- `terminate` runs via a drop guard → fires on *every* exit path (clean inbox
|
||||
close, handler panic, `request_stop`), not just the clean one. Caveat: it
|
||||
may run mid-unwind, so keep it non-blocking (a panic inside it during an
|
||||
unwind double-panics → abort).
|
||||
- No `handle_info`, no call timeout — both deferred to land with timeouts
|
||||
(`handle_info` needs the still-unmade cross-channel mailbox merge; a call
|
||||
timeout needs a per-`recv` deadline / `Signal::Timeout`).
|
||||
- Pure additive layer (no Slot/scheduler/spawn/finalize change) → no perf
|
||||
check run. Tests (`tests/gen_server.rs`): cast→call roundtrip, init/terminate
|
||||
ordering, both server-down paths.
|
||||
- Docs: README now points at the experimental, untested aarch64 port on the
|
||||
`arm-port` branch. The module table still calls `context` x86-64-only — true
|
||||
for `master`, since the `src/arch/` split rides on `arm-port`. Fold the arch/
|
||||
split and ARM64-supported wording into the README module table + build section
|
||||
once `arm-port` is validated on hardware and merged.
|
||||
|
||||
## Gotchas / invariants (respect these)
|
||||
|
||||
- **Shared mutex is non-reentrant.** `Sender::send` can call `unpark` →
|
||||
`with_shared`. NEVER send on a channel while holding the shared lock. Pattern:
|
||||
`mem::take` the senders/data under the lock, send after releasing. See
|
||||
`finalize_actor` (supervisor signal + monitor Downs both sent post-lock).
|
||||
- **`finalize_actor` order:** take stack/waiters/monitors under lock + set
|
||||
Done/outcome → recycle stack (post-lock) → deliver supervisor Signal + monitor
|
||||
Downs (post-lock) → unpark joiners → reclaim slot iff `outstanding_handles==0`.
|
||||
Death notifications always precede reclamation, so a pid carried in a
|
||||
Signal/Down is still matchable even as its slot is about to be reused.
|
||||
- **Slot lifecycle is reset in THREE places** — `Slot::vacant()`,
|
||||
`reclaim_slot()` (runtime.rs), and the slot-init block in `spawn_under`
|
||||
(scheduler.rs). Any new Slot field must be reset in all three (monitors was).
|
||||
- **Pid = (index, generation);** stale handles caught by generation mismatch in
|
||||
`slot()/slot_mut()`. The monitor `NoProc` path relies on this.
|
||||
- **No `select`, no unified per-process mailbox.** Why the supervisor uses the
|
||||
single `supervisor_channel` funnel rather than N monitor channels. trap_exit
|
||||
resolved this by giving each trapping actor a dedicated `Receiver<ExitSignal>`
|
||||
inbox (see #3); selective receive (#4) stayed *per-channel* (`recv_match`
|
||||
scans one channel's queue) rather than introducing a cross-channel mailbox —
|
||||
if selective receive ever needs to span the monitor/trap inboxes too, that
|
||||
cross-channel merge is the still-unmade decision.
|
||||
- **Cooperative-only**: preemption and (future) cancellation both depend on the
|
||||
actor reaching `check!()`/yield/alloc/blocking points.
|
||||
- `run()` is single-thread (`Config::exact(1)`); tests rely on deterministic
|
||||
single-thread ordering (parent runs until it parks). Multi-thread via
|
||||
`runtime::init(Config…)`.
|
||||
|
||||
## Workflow expectations (from the human)
|
||||
|
||||
- TDD: write the failing test first, then implement.
|
||||
- Commit incrementally with conventional-commit messages; keep each commit a
|
||||
reviewable unit (they diff in their IDE and are the filter to the codebase).
|
||||
- Run the full suite before each commit; check perf when a change touches
|
||||
Slot/scheduler/spawn/finalize hot paths.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# Tests
|
||||
|
||||
Integration tests for the runtime. Each file owns one feature area or one
|
||||
class of bug. Everything here runs under plain `cargo test`; the loom model
|
||||
tests are the exception — they live **in the library** (`src/slot_state.rs`,
|
||||
`src/run_queue.rs`), not in this directory, because loom must compile the
|
||||
production code with shimmed atomics (see "Loom" below).
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
cargo test # debug build — RUN THIS ONE: all invariant asserts live
|
||||
cargo test --release # what users actually execute (LTO, no debug_asserts)
|
||||
```
|
||||
|
||||
Debug builds are not just "slower tests": the runtime self-checks its
|
||||
invariants only there — every `StateWord` transition asserts its
|
||||
precondition, `enqueue` asserts the exact `(gen, Queued)` word, `RawMutex`
|
||||
enforces the never-two-cold-locks leaf rule with a per-thread held-count,
|
||||
`live_actors` checks for double-finalize underflow. A green release run with
|
||||
a red debug run means an invariant broke without (yet) corrupting behavior —
|
||||
treat it as a real failure.
|
||||
|
||||
### The queue-variant matrix
|
||||
|
||||
The run queue is compile-time selected; the suite must pass under all three
|
||||
(features are additive, so drop the default first):
|
||||
|
||||
```
|
||||
cargo test # rq-mutex (default)
|
||||
cargo test --no-default-features --features rq-mpmc
|
||||
cargo test --no-default-features --features rq-striped
|
||||
```
|
||||
|
||||
### Loom (model checking)
|
||||
|
||||
```
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib --release
|
||||
```
|
||||
|
||||
Exhaustively explores interleavings of the slot state machine
|
||||
(`src/slot_state.rs`: lost-wakeup, at-most-once-enqueue, the stale-pid ABA
|
||||
theorem, unpark-vs-claim) and the ring queues (`src/run_queue.rs`:
|
||||
exactly-once through lap wraparound, push/pop races). Models run the
|
||||
production transitions through `src/sync_shim.rs` — std atomics normally,
|
||||
`loom::sync` under `--cfg loom`. `RawMutex` is deliberately not modeled:
|
||||
futexes can't be, and it's the textbook Drepper mutex3 with stress and
|
||||
unwind-safety tests of its own.
|
||||
|
||||
### Trace feature
|
||||
|
||||
`cargo test --features smarm-trace` exists mainly to catch bit-rot in the
|
||||
`te!()` call sites; run it after touching scheduler paths.
|
||||
|
||||
### Before a runtime-core PR
|
||||
|
||||
The full matrix, in rough order of bug-finding power per minute:
|
||||
|
||||
1. `cargo test` (debug, default variant)
|
||||
2. debug under `rq-mpmc` and `rq-striped`
|
||||
3. `cargo test --release`
|
||||
4. loom
|
||||
5. `cargo build --features smarm-trace`
|
||||
|
||||
## Catalog
|
||||
|
||||
**Low-level units (no scheduler)**
|
||||
| file | covers |
|
||||
|---|---|
|
||||
| `context.rs` | `init_actor_stack` + the naked-asm context-switch shims, poked directly |
|
||||
| `stack.rs` | the mmap'd stack allocator |
|
||||
| `pid.rs` | pid packing/equality |
|
||||
|
||||
**Feature areas (run under a real runtime)**
|
||||
| file | covers |
|
||||
|---|---|
|
||||
| `runtime.rs` | `Config`, `Runtime::run`, re-running a runtime, correctness under genuine parallelism |
|
||||
| `scheduler.rs` | spawn / join / panic delivery / `yield_now` / `self_pid` |
|
||||
| `channel.rs` | send/recv (recv parks, so these need the runtime) |
|
||||
| `selective_recv.rs` | `recv_match` / `try_recv_match` |
|
||||
| `mutex.rs` | the actor-blocking `Mutex<T>` (lock parks) |
|
||||
| `timer.rs` | `sleep` ordering — time-sensitive, generous tolerances by design |
|
||||
| `io.rs` | `block_on_io`: blocking closures on the pool while the actor parks |
|
||||
| `io_epoll.rs` | `wait_readable` / `wait_writable` + the `read`/`write` sugar |
|
||||
| `preempt.rs` | explicit preemption via `smarm::check!()` |
|
||||
| `cancel.rs` | cooperative cancellation (`request_stop`) — the keystone semantics |
|
||||
| `monitor.rs` | `monitor` delivers exactly one `Down`; `demonitor` |
|
||||
| `link.rs` | bidirectional links + `trap_exit` |
|
||||
| `supervisor.rs` | one-for-one supervision |
|
||||
| `gen_server.rs` | call/cast round-trips, lifecycle callbacks, server-down detection |
|
||||
|
||||
**Regression & stress**
|
||||
| file | covers |
|
||||
|---|---|
|
||||
| `stress.rs` | lost wakeups, pid-table pressure, thundering herds, panic isolation under concurrency. Where the phase-2 RefCell-migration bug was caught. |
|
||||
| `poison_stop.rs` | `request_stop` racing an alloc-under-lock must not poison/abort. See its header for the full story. |
|
||||
| `many_timers_multi_thread.rs` | multi-thread sleep-timer lost-wakeup regression |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Each test owns its runtime.** `init(Config::exact(N))` + `rt.run(...)`;
|
||||
never share a `Runtime` between tests. Oversubscription (`exact(4)` on one
|
||||
core) is deliberate — forced interleaving at yield points is how
|
||||
single-core CI finds races at all.
|
||||
- **Regression tests must be validated against the bug.** A regression test
|
||||
that passes with the bug reintroduced is documentation, not a test.
|
||||
Reintroduce the fix's inverse locally and watch it fail before trusting it
|
||||
(`poison_stop.rs` went through exactly this: its first version never fired
|
||||
the sentinel under a lock, and was rewritten until it SIGABRT'd pre-fix).
|
||||
- **Stochastic tests get the odds stacked.** Use `Config::alloc_interval(1)`
|
||||
to make every allocation an observation point, many actors, and both
|
||||
phases of any every-other-allocation cadence (see
|
||||
`poison_stop::self_stop_during_spawn...`).
|
||||
- **Time-based assertions use ordering, not durations.** Assert
|
||||
"didn't return instantly" / "A woke before B", with generous tolerances;
|
||||
CI machines are slow and noisy.
|
||||
- New invariants added to the runtime should come with the assert at the
|
||||
point of reliance (debug_assert on hot paths) *and*, where the invariant is
|
||||
a protocol, a loom model in the owning module — that combination is what
|
||||
made phases 2–5 land without a single post-merge race so far.
|
||||
@@ -130,3 +130,61 @@ fn join_on_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");
|
||||
}
|
||||
|
||||
@@ -108,3 +108,189 @@ fn recv_returns_err_when_all_senders_dropped() {
|
||||
|
||||
assert!(saw_err.load(std::sync::atomic::Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_ops_interleaved_with_monitor_churn_multi_thread() {
|
||||
// Regression for the RawMutex migration: monitor registration clones the
|
||||
// Down sender under the target's cold (Leaf) lock, which now nests a
|
||||
// Channel-class lock under it. Debug builds enforce the Leaf -> Channel
|
||||
// ordering on every acquisition, so driving channels, monitors, and actor
|
||||
// death concurrently across schedulers makes any ordering regression
|
||||
// panic here rather than deadlock in the field.
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let total = Arc::new(AtomicI64::new(0));
|
||||
let total2 = total.clone();
|
||||
smarm::init(smarm::Config::exact(4)).run(move || {
|
||||
let (tx, rx) = channel::<i64>();
|
||||
let consumer = spawn(move || {
|
||||
let mut sum = 0;
|
||||
while let Ok(v) = rx.recv() {
|
||||
sum += v;
|
||||
}
|
||||
OUT.with(|c| c.set(sum)); // not asserted cross-thread; see total
|
||||
total2.fetch_add(sum, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..32i64 {
|
||||
let tx = tx.clone();
|
||||
handles.push(spawn(move || {
|
||||
// Short-lived target whose death fires the monitor below.
|
||||
let t = spawn(move || {
|
||||
tx.send(i).unwrap();
|
||||
});
|
||||
let m = smarm::monitor(t.pid());
|
||||
t.join().unwrap();
|
||||
// Down delivery exercises send-from-finalize.
|
||||
let d = m.rx.recv().unwrap();
|
||||
assert_eq!(d.reason, smarm::DownReason::Exit);
|
||||
}));
|
||||
}
|
||||
drop(tx);
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
consumer.join().unwrap();
|
||||
});
|
||||
assert_eq!(total.load(std::sync::atomic::Ordering::Relaxed), (0..32).sum::<i64>());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recv_timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use smarm::RecvTimeoutError;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn recv_timeout_returns_queued_message_immediately() {
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<i64>();
|
||||
tx.send(5).unwrap();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)), Ok(5));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_timeout_times_out_on_silent_channel() {
|
||||
run(|| {
|
||||
let (_tx, rx) = channel::<i64>();
|
||||
let start = Instant::now();
|
||||
let r = rx.recv_timeout(Duration::from_millis(50));
|
||||
assert_eq!(r, Err(RecvTimeoutError::Timeout));
|
||||
assert!(start.elapsed() >= Duration::from_millis(50));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_timeout_wakes_promptly_on_send() {
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<i64>();
|
||||
let h = spawn(move || {
|
||||
let start = Instant::now();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)), Ok(9));
|
||||
// Far below the timeout: the send woke us, not the deadline.
|
||||
assert!(start.elapsed() < Duration::from_secs(1));
|
||||
});
|
||||
smarm::yield_now();
|
||||
tx.send(9).unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_timeout_reports_disconnected_on_close() {
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<i64>();
|
||||
let h = spawn(move || {
|
||||
assert_eq!(
|
||||
rx.recv_timeout(Duration::from_secs(10)),
|
||||
Err(RecvTimeoutError::Disconnected)
|
||||
);
|
||||
});
|
||||
smarm::yield_now();
|
||||
drop(tx);
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_timeout_zero_duration_is_a_bounded_poll() {
|
||||
run(|| {
|
||||
let (_tx, rx) = channel::<i64>();
|
||||
assert_eq!(rx.recv_timeout(Duration::ZERO), Err(RecvTimeoutError::Timeout));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_remains_usable_after_a_timeout() {
|
||||
// The stale timer entry from the first (timed-out) wait must not cancel
|
||||
// or corrupt later waits — seq isolation.
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<i64>();
|
||||
assert_eq!(
|
||||
rx.recv_timeout(Duration::from_millis(10)),
|
||||
Err(RecvTimeoutError::Timeout)
|
||||
);
|
||||
// Plain recv still works...
|
||||
tx.send(1).unwrap();
|
||||
assert_eq!(rx.recv(), Ok(1));
|
||||
// ...and so does a second bounded wait, woken by a send.
|
||||
let h = spawn(move || {
|
||||
tx.send(2).unwrap();
|
||||
});
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)), Ok(2));
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_timeout_many_waiters_multi_thread() {
|
||||
// Mixed outcomes under real parallelism: half the channels get fed,
|
||||
// half time out; every actor must resolve correctly.
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let got = Arc::new(AtomicU32::new(0));
|
||||
let timed_out = Arc::new(AtomicU32::new(0));
|
||||
let (got2, timed_out2) = (got.clone(), timed_out.clone());
|
||||
smarm::init(smarm::Config::exact(4)).run(move || {
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..24i64 {
|
||||
let (tx, rx) = channel::<i64>();
|
||||
let got = got2.clone();
|
||||
let timed_out = timed_out2.clone();
|
||||
handles.push(spawn(move || match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(v) => {
|
||||
assert_eq!(v, i);
|
||||
got.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
timed_out.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => panic!("unexpected: {e}"),
|
||||
}));
|
||||
if i % 2 == 0 {
|
||||
handles.push(spawn(move || {
|
||||
tx.send(i).unwrap();
|
||||
}));
|
||||
}
|
||||
// odd i: tx drops here -> Disconnected, not Timeout! Keep it alive
|
||||
// instead by leaking the sender into a holder actor that outlives
|
||||
// the deadline.
|
||||
else {
|
||||
handles.push(spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(200));
|
||||
drop(tx);
|
||||
}));
|
||||
}
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
assert_eq!(got.load(std::sync::atomic::Ordering::Relaxed), 12);
|
||||
assert_eq!(timed_out.load(std::sync::atomic::Ordering::Relaxed), 12);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ use std::sync::OnceLock;
|
||||
static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new();
|
||||
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
extern "C-unwind" fn actor_reg_check() {
|
||||
unsafe {
|
||||
let s0: u64 = 0xAAAA_BBBB_0000_0001;
|
||||
@@ -84,39 +83,6 @@ extern "C-unwind" fn actor_reg_check() {
|
||||
}
|
||||
}
|
||||
|
||||
// aarch64 sibling: same intent, AAPCS64 callee-saved integer registers.
|
||||
// LLVM reserves x19 (and uses x29/x30 as fp/lr), so those can't be named as
|
||||
// asm operands. We use x23–x26 instead — callee-saved and bindable — moving
|
||||
// known values into them, yielding, then reading them back, exactly mirroring
|
||||
// the x86 test's move-into-r12..r15 pattern. The shim saves x23–x26 in its
|
||||
// `stp x23,x24` / `stp x25,x26` pairs, so a clean round-trip proves they
|
||||
// survive the switch.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
extern "C-unwind" fn actor_reg_check() {
|
||||
unsafe {
|
||||
let s0: u64 = 0xAAAA_BBBB_0000_0001;
|
||||
let s1: u64 = 0xCCCC_DDDD_0000_0002;
|
||||
let s2: u64 = 0xEEEE_FFFF_0000_0003;
|
||||
let s3: u64 = 0x1111_2222_0000_0004;
|
||||
|
||||
core::arch::asm!(
|
||||
"mov x23, {s0}", "mov x24, {s1}", "mov x25, {s2}", "mov x26, {s3}",
|
||||
s0 = in(reg) s0, s1 = in(reg) s1, s2 = in(reg) s2, s3 = in(reg) s3,
|
||||
out("x23") _, out("x24") _, out("x25") _, out("x26") _,
|
||||
);
|
||||
REG_BEFORE.set([s0, s1, s2, s3]).ok();
|
||||
switch_to_scheduler();
|
||||
|
||||
let a0: u64; let a1: u64; let a2: u64; let a3: u64;
|
||||
core::arch::asm!(
|
||||
"mov {a0}, x23", "mov {a1}, x24", "mov {a2}, x25", "mov {a3}, x26",
|
||||
a0 = out(reg) a0, a1 = out(reg) a1, a2 = out(reg) a2, a3 = out(reg) a3,
|
||||
);
|
||||
REG_AFTER.set([a0, a1, a2, a3]).ok();
|
||||
switch_to_scheduler();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callee_saved_registers_survive_yield() {
|
||||
let stack = Stack::new(64 * 1024).unwrap();
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//! RFC 008 — fd arms in select. Beyond the functional cases, the
|
||||
//! *_stays_usable tests are the soundness probes for the one asymmetry the
|
||||
//! RFC must close: a losing CHANNEL arm's stale registration is inert, but
|
||||
//! a losing FD arm's registration (waiters entry + kernel ONESHOT) poisons
|
||||
//! the fd with AlreadyExists until the eager cleanup pass removes it. Every
|
||||
//! "loser" scenario therefore re-waits on the same fd afterwards and must
|
||||
//! succeed — pre-cleanup, each of those re-waits errors or hangs.
|
||||
//!
|
||||
//! House pattern: actor panics are trampoline-caught and `run` returns
|
||||
//! normally, so every test funnels its result into an outcome flag asserted
|
||||
//! OUTSIDE `run` — an in-actor assertion alone passes vacuously.
|
||||
|
||||
use smarm::{
|
||||
channel, run, select, select_timeout, spawn, try_select, wait_readable,
|
||||
wait_readable_timeout, wait_writable_timeout, yield_now, FdArm,
|
||||
};
|
||||
use std::os::fd::RawFd;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipe helper (as in io_epoll.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Pipe {
|
||||
read: RawFd,
|
||||
write: RawFd,
|
||||
}
|
||||
|
||||
impl Pipe {
|
||||
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");
|
||||
Pipe { read: fds[0], write: fds[1] }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pipe {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::close(self.read);
|
||||
libc::close(self.write);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_write(fd: RawFd, buf: &[u8]) -> isize {
|
||||
unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) }
|
||||
}
|
||||
|
||||
fn raw_read(fd: RawFd, buf: &mut [u8]) -> isize {
|
||||
unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }
|
||||
}
|
||||
|
||||
fn flag() -> (Arc<AtomicBool>, Arc<AtomicBool>) {
|
||||
let f = Arc::new(AtomicBool::new(false));
|
||||
(f.clone(), f)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ready-now: data already pending retires the wait without parking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fd_arm_ready_now_returns_without_parking() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
assert_eq!(raw_write(p.write, b"x"), 1);
|
||||
let (_tx, rx) = channel::<i64>();
|
||||
let fd_arm = FdArm::readable(p.read);
|
||||
// fd arm at index 1: the ready-now path must also work for a
|
||||
// non-first arm (and clean nothing — channel arms are inert).
|
||||
let i = select(&[&rx, &fd_arm]);
|
||||
assert_eq!(i, 1);
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(p.read, &mut buf), 1);
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Park-then-wake: fd arm wins against an idle channel arm.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fd_arm_parks_until_data_then_wins() {
|
||||
let got = Arc::new(AtomicU32::new(0));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
let (_tx_keepalive, rx) = channel::<i64>();
|
||||
|
||||
let h = spawn(move || {
|
||||
let fd_arm = FdArm::readable(rfd);
|
||||
let i = select(&[&fd_arm, &rx]);
|
||||
assert_eq!(i, 0);
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
got2.store(buf[0] as u32, Ordering::SeqCst);
|
||||
});
|
||||
yield_now(); // let it park
|
||||
assert_eq!(raw_write(wfd, b"y"), 1);
|
||||
let _ = h.join();
|
||||
});
|
||||
assert_eq!(got.load(Ordering::SeqCst), b'y' as u32);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE asymmetry probe: channel arm wins, losing fd arm must be cleaned —
|
||||
// the same actor (and the io thread) must be able to wait that fd again.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn losing_fd_arm_is_cleaned_up_and_fd_stays_usable() {
|
||||
let got = Arc::new(AtomicU32::new(0));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
let (tx, rx) = channel::<i64>();
|
||||
|
||||
let h = spawn(move || {
|
||||
let fd_arm = FdArm::readable(rfd);
|
||||
// Channel wins; the fd arm registered and lost.
|
||||
let i = select(&[&fd_arm, &rx]);
|
||||
assert_eq!(i, 1);
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(7));
|
||||
|
||||
// Pre-cleanup this wait_readable fails AlreadyExists (the
|
||||
// waiters entry is stale-ours) — the cleanup pass must have
|
||||
// removed it.
|
||||
wait_readable(rfd).unwrap();
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
got2.store(buf[0] as u32, Ordering::SeqCst);
|
||||
});
|
||||
yield_now(); // let it park in the select
|
||||
tx.send(7).unwrap();
|
||||
yield_now(); // let it reach the second wait
|
||||
assert_eq!(raw_write(wfd, b"z"), 1);
|
||||
let _ = h.join();
|
||||
});
|
||||
assert_eq!(got.load(Ordering::SeqCst), b'z' as u32);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ready-now on a LATER arm must unregister an earlier fd arm (the
|
||||
// register_arms prefix-cleanup path: no park ever happens).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ready_now_later_arm_cleans_earlier_fd_arm() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
let (tx, rx) = channel::<i64>();
|
||||
tx.send(1).unwrap(); // arm 1 ready before the select
|
||||
|
||||
let fd_arm = FdArm::readable(rfd);
|
||||
let i = select(&[&fd_arm, &rx]);
|
||||
assert_eq!(i, 1);
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(1));
|
||||
|
||||
// The fd arm registered (idle pipe), then arm 1 retired the wait.
|
||||
// Its registration must have been removed in the same pass.
|
||||
assert_eq!(raw_write(wfd, b"a"), 1);
|
||||
wait_readable(rfd).unwrap();
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two fd arms in one select (phase-1: distinct fds, nothing special).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn two_fd_arms_second_fires_first_stays_usable() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let pa = Pipe::new();
|
||||
let pb = Pipe::new();
|
||||
let (rfd_a, wfd_a) = (pa.read, pa.write);
|
||||
let (rfd_b, wfd_b) = (pb.read, pb.write);
|
||||
|
||||
let h = spawn(move || {
|
||||
let a = FdArm::readable(rfd_a);
|
||||
let b = FdArm::readable(rfd_b);
|
||||
let i = select(&[&a, &b]);
|
||||
assert_eq!(i, 1);
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd_b, &mut buf), 1);
|
||||
|
||||
// Arm a lost; its fd must be immediately re-waitable.
|
||||
assert_eq!(raw_write(wfd_a, b"q"), 1);
|
||||
wait_readable(rfd_a).unwrap();
|
||||
assert_eq!(raw_read(rfd_a, &mut buf), 1);
|
||||
assert_eq!(buf[0], b'q');
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
yield_now();
|
||||
assert_eq!(raw_write(wfd_b, b"b"), 1);
|
||||
let _ = h.join();
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// select_timeout: timer wins over an idle fd arm; the fd is left clean.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn select_timeout_timer_beats_idle_fd_arm_and_fd_stays_usable() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
|
||||
let fd_arm = FdArm::readable(rfd);
|
||||
let start = Instant::now();
|
||||
let r = select_timeout(&[&fd_arm], Duration::from_millis(30));
|
||||
assert!(r.is_none(), "idle fd must time out");
|
||||
assert!(start.elapsed() >= Duration::from_millis(30));
|
||||
|
||||
// Timer win is exactly the case where the fd arm's registration is
|
||||
// left behind without an eager pass.
|
||||
assert_eq!(raw_write(wfd, b"c"), 1);
|
||||
wait_readable(rfd).unwrap();
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived wrappers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn wait_readable_timeout_times_out_then_succeeds_with_data() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
|
||||
let start = Instant::now();
|
||||
assert_eq!(wait_readable_timeout(rfd, Duration::from_millis(30)).unwrap(), false);
|
||||
assert!(start.elapsed() >= Duration::from_millis(30));
|
||||
|
||||
// Timed-out wait must leave the fd clean; ready path returns true.
|
||||
assert_eq!(raw_write(wfd, b"d"), 1);
|
||||
assert_eq!(wait_readable_timeout(rfd, Duration::from_secs(5)).unwrap(), true);
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_readable_timeout_wakes_on_late_data() {
|
||||
let got = Arc::new(AtomicU32::new(0));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
let h = spawn(move || {
|
||||
assert_eq!(wait_readable_timeout(rfd, Duration::from_secs(5)).unwrap(), true);
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
got2.store(buf[0] as u32, Ordering::SeqCst);
|
||||
});
|
||||
yield_now();
|
||||
assert_eq!(raw_write(wfd, b"e"), 1);
|
||||
let _ = h.join();
|
||||
});
|
||||
assert_eq!(got.load(Ordering::SeqCst), b'e' as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_writable_timeout_ready_now_on_empty_pipe() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
// An empty pipe's write end is writable: ready-now path, no park.
|
||||
assert_eq!(wait_writable_timeout(p.write, Duration::from_secs(5)).unwrap(), true);
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error surface: registration failure is an Err from try_select, with the
|
||||
// wait retired (the actor can immediately wait on something else).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn try_select_surfaces_registration_error_and_retires_the_wait() {
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let bad: RawFd = {
|
||||
let p = Pipe::new();
|
||||
p.read
|
||||
}; // both ends closed by Drop: EBADF on registration
|
||||
|
||||
let fd_arm = FdArm::readable(bad);
|
||||
let err = try_select(&[&fd_arm]).unwrap_err();
|
||||
// EBADF, whether the pre-poll or epoll_ctl ADD reports it.
|
||||
assert_eq!(err.raw_os_error(), Some(libc::EBADF));
|
||||
|
||||
// The wait was retired: a normal select right after works.
|
||||
let (tx, rx) = channel::<i64>();
|
||||
tx.send(9).unwrap();
|
||||
let i = select(&[&rx]);
|
||||
assert_eq!(i, 0);
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(9));
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop-unwind: an actor stopped while parked in an fd-arm select must not
|
||||
// poison the fd (the UnregisterGuard generalization of wait_fd's Dereg).
|
||||
// Mirrors io_epoll.rs::stopped_waiter_does_not_poison_the_fd.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn stopped_selector_does_not_poison_the_fd() {
|
||||
let seen = Arc::new(AtomicU32::new(0));
|
||||
let seen_outer = seen.clone();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
let (_tx_keepalive, rx) = channel::<i64>();
|
||||
|
||||
let h = spawn(move || {
|
||||
let fd_arm = FdArm::readable(rfd);
|
||||
select(&[&fd_arm, &rx]);
|
||||
unreachable!("neither arm ever fires while this actor lives");
|
||||
});
|
||||
yield_now(); // let it reach the park
|
||||
smarm::request_stop(h.pid());
|
||||
let _ = h.join(); // Ok(()): stopped, not panicked
|
||||
|
||||
// Second waiter on the SAME fd must register and be woken.
|
||||
let seen2 = seen.clone();
|
||||
let h2 = spawn(move || {
|
||||
wait_readable(rfd).unwrap();
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
seen2.store(buf[0] as u32, Ordering::SeqCst);
|
||||
});
|
||||
yield_now();
|
||||
assert_eq!(raw_write(wfd, b"x"), 1);
|
||||
let _ = h2.join();
|
||||
});
|
||||
assert_eq!(seen_outer.load(Ordering::SeqCst), b'x' as u32);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase-1 misuse: a second waiter on an fd that already has one is an Err
|
||||
// (AlreadyExists), not a hang — and does not disturb the first waiter.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn second_waiter_on_same_fd_errs_without_disturbing_the_first() {
|
||||
let got = Arc::new(AtomicU32::new(0));
|
||||
let got2 = got.clone();
|
||||
let (ok, ok2) = flag();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
|
||||
let h = spawn(move || {
|
||||
wait_readable(rfd).unwrap();
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
got2.store(buf[0] as u32, Ordering::SeqCst);
|
||||
});
|
||||
yield_now(); // first waiter parked
|
||||
|
||||
let fd_arm = FdArm::readable(rfd);
|
||||
let err = try_select(&[&fd_arm]).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
|
||||
// First waiter still wakes normally.
|
||||
assert_eq!(raw_write(wfd, b"w"), 1);
|
||||
let _ = h.join();
|
||||
ok2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
assert_eq!(got.load(Ordering::SeqCst), b'w' as u32);
|
||||
assert!(ok.load(Ordering::SeqCst));
|
||||
}
|
||||
+537
-2
@@ -1,7 +1,7 @@
|
||||
//! gen_server tests: call round-trip, cast, lifecycle callbacks, and the two
|
||||
//! server-down detection paths (reply-channel close vs. inbox-send failure).
|
||||
|
||||
use smarm::gen_server::{start, CallError, GenServer};
|
||||
use smarm::gen_server::{start, CallError, GenServer, ServerBuilder};
|
||||
use smarm::run;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -26,6 +26,8 @@ impl GenServer for Counter {
|
||||
type Call = Req;
|
||||
type Reply = i64;
|
||||
type Cast = Op;
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, req: Req) -> i64 {
|
||||
match req {
|
||||
@@ -68,8 +70,10 @@ impl GenServer for Lifecycle {
|
||||
type Call = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn init(&mut self) {
|
||||
fn init(&mut self, _ctx: &smarm::gen_server::ServerCtx<Self>) {
|
||||
self.log.lock().unwrap().push("init");
|
||||
}
|
||||
|
||||
@@ -135,3 +139,534 @@ fn call_after_server_gone_is_server_down() {
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// call_timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use smarm::gen_server::CallTimeoutError;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Replies after sleeping `delay_ms` (parking the server actor, not the OS
|
||||
/// thread), so callers can race a deadline against the reply.
|
||||
struct Slow;
|
||||
|
||||
impl GenServer for Slow {
|
||||
type Call = u64; // delay in ms
|
||||
type Reply = u64;
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, delay_ms: u64) -> u64 {
|
||||
if delay_ms > 0 {
|
||||
smarm::sleep(Duration::from_millis(delay_ms));
|
||||
}
|
||||
delay_ms
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, _: ()) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_timeout_returns_reply_within_deadline() {
|
||||
run(|| {
|
||||
let srv = start(Slow);
|
||||
assert_eq!(srv.call_timeout(0, Duration::from_secs(10)), Ok(0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_timeout_times_out_on_slow_handler() {
|
||||
run(|| {
|
||||
let srv = start(Slow);
|
||||
let start_t = Instant::now();
|
||||
let r = srv.call_timeout(500, Duration::from_millis(50));
|
||||
assert_eq!(r, Err(CallTimeoutError::Timeout));
|
||||
let elapsed = start_t.elapsed();
|
||||
// Gave up at the deadline, not at the reply.
|
||||
assert!(elapsed >= Duration::from_millis(50));
|
||||
assert!(elapsed < Duration::from_millis(500));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_survives_an_abandoned_call_and_late_reply_is_discarded() {
|
||||
run(|| {
|
||||
let srv = start(Slow);
|
||||
assert_eq!(
|
||||
srv.call_timeout(100, Duration::from_millis(20)),
|
||||
Err(CallTimeoutError::Timeout)
|
||||
);
|
||||
// The timed-out request is still handled; its reply send fails
|
||||
// harmlessly (receiver dropped). The server must keep serving, and
|
||||
// the late reply must not leak into THIS call's reply channel.
|
||||
assert_eq!(srv.call_timeout(0, Duration::from_secs(10)), Ok(0));
|
||||
// Plain unbounded call still fine too.
|
||||
assert_eq!(srv.call(0), Ok(0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_timeout_to_dead_server_is_server_down_not_timeout() {
|
||||
struct Bomb;
|
||||
impl GenServer for Bomb {
|
||||
type Call = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
fn handle_call(&mut self, _: ()) {
|
||||
panic!("kaboom");
|
||||
}
|
||||
fn handle_cast(&mut self, _: ()) {}
|
||||
}
|
||||
|
||||
run(|| {
|
||||
let srv = start(Bomb);
|
||||
// Dies mid-call: reply channel closes -> ServerDown (even though the
|
||||
// generous deadline never fires).
|
||||
assert_eq!(
|
||||
srv.call_timeout((), Duration::from_secs(10)),
|
||||
Err(CallTimeoutError::ServerDown)
|
||||
);
|
||||
// Already gone: inbox send fails -> ServerDown.
|
||||
assert_eq!(
|
||||
srv.call_timeout((), Duration::from_secs(10)),
|
||||
Err(CallTimeoutError::ServerDown)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handle_info: out-of-band channels selected alongside the inbox (v0.8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Logs every message it handles, in order; a call reads the log back.
|
||||
struct Logger {
|
||||
log: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl GenServer for Logger {
|
||||
type Call = ();
|
||||
type Reply = Vec<&'static str>;
|
||||
type Cast = ();
|
||||
type Info = &'static str;
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, _: ()) -> Vec<&'static str> {
|
||||
self.log.clone()
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, _: ()) {
|
||||
self.log.push("cast");
|
||||
}
|
||||
|
||||
fn handle_info(&mut self, info: &'static str) {
|
||||
self.log.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// An info message is dispatched to handle_info, interleaved with normal
|
||||
// service.
|
||||
#[test]
|
||||
fn info_is_dispatched() {
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
||||
.with_info(info_rx)
|
||||
.start();
|
||||
info_tx.send("info").unwrap();
|
||||
*got2.lock().unwrap() = server.call(()).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec!["info"]);
|
||||
}
|
||||
|
||||
// Arm priority: with a cast AND an info both queued before the server first
|
||||
// runs, the info is handled first — info arms outrank the inbox. Relies on
|
||||
// run()'s deterministic single-thread ordering.
|
||||
#[test]
|
||||
fn info_outranks_inbox() {
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
||||
.with_info(info_rx)
|
||||
.start();
|
||||
// The server actor hasn't run yet: both messages are queued before
|
||||
// its first select. Inbox first in *send* order, info first in *arm*
|
||||
// order — arm order must win.
|
||||
server.cast(()).unwrap();
|
||||
info_tx.send("info").unwrap();
|
||||
*got2.lock().unwrap() = server.call(()).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec!["info", "cast"]);
|
||||
}
|
||||
|
||||
// Two info channels: declaration order is priority order.
|
||||
#[test]
|
||||
fn info_arms_keep_declaration_priority() {
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let (hi_tx, hi_rx) = smarm::channel::<&'static str>();
|
||||
let (lo_tx, lo_rx) = smarm::channel::<&'static str>();
|
||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
||||
.with_info(hi_rx)
|
||||
.with_info(lo_rx)
|
||||
.start();
|
||||
// Sent low-priority first; handled high-priority first.
|
||||
lo_tx.send("lo").unwrap();
|
||||
hi_tx.send("hi").unwrap();
|
||||
*got2.lock().unwrap() = server.call(()).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec!["hi", "lo"]);
|
||||
}
|
||||
|
||||
// A closed info arm is silently dropped and the server keeps serving; the
|
||||
// closure does NOT reach handle_info and does NOT starve the inbox (the
|
||||
// closed-arm-is-ready-forever gotcha).
|
||||
#[test]
|
||||
fn closed_info_arm_is_dropped_silently() {
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let (info_tx, info_rx) = smarm::channel::<&'static str>();
|
||||
let server = ServerBuilder::new(Logger { log: Vec::new() })
|
||||
.with_info(info_rx)
|
||||
.start();
|
||||
drop(info_tx); // closed before the server's first select
|
||||
server.cast(()).unwrap();
|
||||
*got2.lock().unwrap() = server.call(()).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec!["cast"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handle_down: monitors handed to the loop via Watcher (v0.8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use smarm::gen_server::Watcher;
|
||||
use smarm::{monitor, spawn, DownReason, Pid};
|
||||
|
||||
/// The motivating pattern: a server that spawns workers from a handler,
|
||||
/// watches them, and logs their deaths.
|
||||
struct Pool {
|
||||
watcher: Option<Watcher<Self>>,
|
||||
log: Vec<DownReason>,
|
||||
}
|
||||
|
||||
enum PoolCast {
|
||||
SpawnDoomedWorker,
|
||||
Watch(Pid),
|
||||
}
|
||||
|
||||
impl GenServer for Pool {
|
||||
type Call = ();
|
||||
type Reply = Vec<DownReason>;
|
||||
type Cast = PoolCast;
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn init(&mut self, ctx: &smarm::gen_server::ServerCtx<Self>) {
|
||||
self.watcher = Some(ctx.watcher());
|
||||
}
|
||||
|
||||
fn handle_call(&mut self, _: ()) -> Vec<DownReason> {
|
||||
self.log.clone()
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, cast: PoolCast) {
|
||||
let watcher = self.watcher.as_ref().expect("init ran first");
|
||||
match cast {
|
||||
PoolCast::SpawnDoomedWorker => {
|
||||
let h = spawn(|| panic!("worker died"));
|
||||
watcher.watch(monitor(h.pid()));
|
||||
}
|
||||
PoolCast::Watch(pid) => watcher.watch(monitor(pid)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_down(&mut self, down: smarm::Down) {
|
||||
self.log.push(down.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// A worker spawned and watched from inside a handler delivers its Down to
|
||||
// handle_down. Down arms outrank the inbox, so the death is in the log by
|
||||
// the time the follow-up call is answered.
|
||||
#[test]
|
||||
fn worker_pool_down_reaches_handle_down() {
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let server = start(Pool { watcher: None, log: Vec::new() });
|
||||
server.cast(PoolCast::SpawnDoomedWorker).unwrap();
|
||||
let _ = server.call(()).unwrap(); // sync point: cast handled, worker live
|
||||
*got2.lock().unwrap() = server.call(()).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec![DownReason::Panic]);
|
||||
}
|
||||
|
||||
// Watching an already-dead pid yields an immediate NoProc Down, and the down
|
||||
// arm outranks the inbox: the call cast *after* the watch still observes it.
|
||||
#[test]
|
||||
fn watch_dead_pid_is_noproc_down() {
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let h = spawn(|| {});
|
||||
let dead = h.pid();
|
||||
h.join().unwrap();
|
||||
let server = start(Pool { watcher: None, log: Vec::new() });
|
||||
server.cast(PoolCast::Watch(dead)).unwrap();
|
||||
*got2.lock().unwrap() = server.call(()).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec![DownReason::NoProc]);
|
||||
}
|
||||
|
||||
// A state that never clones the Watcher closes the control arm; the loop
|
||||
// falls back to the plain-inbox park and keeps serving. (Every pre-v0.8 test
|
||||
// in this file also exercises this path.)
|
||||
#[test]
|
||||
fn unused_ctx_closes_control_arm_silently() {
|
||||
let got = Arc::new(Mutex::new(0i64));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let server = start(Counter { n: 0 });
|
||||
server.cast(Op::Add(2)).unwrap();
|
||||
server.cast(Op::Add(40)).unwrap();
|
||||
*got2.lock().unwrap() = server.call(Req::Get).unwrap();
|
||||
});
|
||||
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::{ServerCtx, 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: &ServerCtx<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: &ServerCtx<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,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();
|
||||
});
|
||||
}
|
||||
@@ -322,3 +322,46 @@ fn wait_writable_on_empty_pipe_returns_quickly() {
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fd hygiene on actor death (v0.8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// An actor stopped while parked on an fd must not leak its `waiters` entry:
|
||||
// before the unwind-path guard in wait_fd, the stale entry made every
|
||||
// future wait_*() on that fd fail with AlreadyExists, forever.
|
||||
#[test]
|
||||
fn stopped_waiter_does_not_poison_the_fd() {
|
||||
let outcome = Arc::new(StdMutex::new(None::<u8>));
|
||||
let outcome2 = outcome.clone();
|
||||
run(move || {
|
||||
let p = Pipe::new();
|
||||
let (rfd, wfd) = (p.read, p.write);
|
||||
|
||||
// First waiter parks on the (empty) pipe and is stopped in place.
|
||||
let h = smarm::spawn(move || {
|
||||
wait_readable(rfd).unwrap();
|
||||
unreachable!("the pipe is never written while this actor lives");
|
||||
});
|
||||
yield_now(); // let it reach the park
|
||||
smarm::request_stop(h.pid());
|
||||
h.join().unwrap(); // Ok(()): stopped, not panicked
|
||||
|
||||
// Second waiter on the SAME fd must be able to register...
|
||||
let seen = Arc::new(AtomicU32::new(0));
|
||||
let seen2 = seen.clone();
|
||||
let h2 = smarm::spawn(move || {
|
||||
wait_readable(rfd).unwrap();
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(raw_read(rfd, &mut buf), 1);
|
||||
seen2.store(buf[0] as u32, Ordering::SeqCst);
|
||||
});
|
||||
yield_now(); // ...and park (a failed register would panic the unwrap)
|
||||
|
||||
// ...and actually be woken by readiness.
|
||||
assert_eq!(raw_write(wfd, b"x"), 1);
|
||||
h2.join().unwrap();
|
||||
*outcome2.lock().unwrap() = Some(seen.load(Ordering::SeqCst) as u8);
|
||||
});
|
||||
assert_eq!(*outcome.lock().unwrap(), Some(b'x'));
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
//! `loom::Mutex<T>` tests. All run under the scheduler because `lock()`
|
||||
//! `smarm::Mutex<T>` (the actor-blocking mutex) tests. All run under the
|
||||
//! scheduler because `lock()`
|
||||
//! needs to be able to park.
|
||||
|
||||
use smarm::{run, spawn, yield_now, LockTimeout, Mutex};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
+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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Regression: request_stop racing an alloc-under-lock must not poison any
|
||||
//! runtime mutex. Before the fix, check_cancelled() fired regardless of
|
||||
//! PREEMPTION_ENABLED, so a sentinel unwind could trigger inside with_shared
|
||||
//! (which allocates: run_queue.push_back, waiters.push, etc), poisoning the
|
||||
//! shared mutex and cascading lock().unwrap() panics.
|
||||
|
||||
use smarm::runtime::{init, Config};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn stop_storm_does_not_poison_runtime() {
|
||||
// alloc_interval(1): every allocation is an observation point, so pre-fix
|
||||
// the sentinel fires inside with_shared's alloc sites (run_queue.push_back,
|
||||
// waiters.push, ...) with near-certainty rather than 1-in-128.
|
||||
let rt = init(Config::exact(4).alloc_interval(1));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let c = completed.clone();
|
||||
rt.run(move || {
|
||||
// Spawn many short actors that allocate + yield heavily (hammering
|
||||
// with_shared), and request_stop each one mid-flight from siblings.
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..200 {
|
||||
let h = smarm::spawn(|| {
|
||||
for _ in 0..50 {
|
||||
let _v: Vec<u8> = Vec::with_capacity(64); // alloc → maybe_preempt
|
||||
smarm::yield_now();
|
||||
}
|
||||
});
|
||||
handles.push(h);
|
||||
}
|
||||
// Cancel half of them; the others run to completion. If any lock got
|
||||
// poisoned the runtime would panic on a subsequent lock().unwrap().
|
||||
for (i, h) in handles.iter().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
smarm::request_stop(h.pid());
|
||||
}
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
assert_eq!(completed.load(Ordering::SeqCst), 1, "root completed cleanly");
|
||||
}
|
||||
|
||||
/// The sharper repro: a stop-flagged actor whose *next allocation* is the
|
||||
/// `Box::new(closure)` inside `spawn`'s `with_shared` critical section.
|
||||
/// Pre-fix, the ungated `check_cancelled` in `maybe_preempt` raises the
|
||||
/// sentinel right there, unwinding while the global shared mutex is held →
|
||||
/// poisoned → every later `with_shared` panics on every scheduler thread and
|
||||
/// the whole runtime collapses. Post-fix, observation is deferred to the next
|
||||
/// lock-free point and each actor dies a clean `Outcome::Stopped`.
|
||||
///
|
||||
/// `request_stop(self_pid())` sets the flag without parking (we're running),
|
||||
/// and the capturing closure makes `Box::new(f)` a real allocation. With
|
||||
/// `alloc_interval(1)` the check fires every other allocation, so each actor
|
||||
/// has ~50% odds of the check landing on the under-lock alloc; 64 actors make
|
||||
/// a pre-fix escape astronomically unlikely.
|
||||
#[test]
|
||||
fn self_stop_during_spawn_does_not_poison_shared_mutex() {
|
||||
let rt = init(Config::exact(4).alloc_interval(1));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let c = completed.clone();
|
||||
rt.run(move || {
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..64usize {
|
||||
let h = smarm::spawn(move || {
|
||||
// Vary pre-stop allocation count to cover both phases of the
|
||||
// every-other-allocation check cadence.
|
||||
for _ in 0..(i % 2) {
|
||||
let _phase: Vec<u8> = Vec::with_capacity(8);
|
||||
}
|
||||
let payload = vec![0u8; 64]; // captured → Box::new(f) allocates
|
||||
smarm::request_stop(smarm::self_pid());
|
||||
let grandchild = smarm::spawn(move || drop(payload));
|
||||
let _ = grandchild.join();
|
||||
});
|
||||
handles.push(h);
|
||||
}
|
||||
for h in handles {
|
||||
// Stopped reports Ok from join; the point is that join itself
|
||||
// (with_shared) doesn't panic on a poisoned mutex.
|
||||
let _ = h.join();
|
||||
}
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
assert_eq!(completed.load(Ordering::SeqCst), 1, "root completed cleanly");
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Mailbox-registry tests (RFC 014). Run under the scheduler: registration
|
||||
//! 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, install, register, run, send, send_dyn, send_to, spawn, unregister, whereis,
|
||||
Addressable, Name, Pid, RegisterError, SendError,
|
||||
};
|
||||
|
||||
const SVC: Name<u64> = Name::new("svc");
|
||||
|
||||
#[test]
|
||||
fn register_then_send_by_name_delivers() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let h = spawn(move || {
|
||||
register(SVC, tx).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
assert_eq!(rx.recv().unwrap(), 42);
|
||||
});
|
||||
ready_rx.recv().unwrap(); // worker has registered
|
||||
assert_eq!(whereis("svc"), Some(h.pid()));
|
||||
send(SVC, 42).unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(|| {
|
||||
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 || {
|
||||
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");
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
send(Name::<u64>::new("port"), 7u64).unwrap();
|
||||
send(Name::<&'static str>::new("port"), "halt").unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_held_by_live_actor_is_taken() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (tx_a, rx_a) = channel::<u64>();
|
||||
let a = spawn(move || {
|
||||
register(SVC, tx_a).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
assert_eq!(rx_a.recv().unwrap(), 0); // wait to be released
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
// Root tries to claim a live actor's name for itself -> NameTaken.
|
||||
let (tx_b, _rx_b) = channel::<u64>();
|
||||
assert_eq!(register(SVC, tx_b), Err(RegisterError::NameTaken { holder: a.pid() }));
|
||||
send(SVC, 0).unwrap(); // release a (delivers to the holder, a)
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_errors_unresolved_and_no_channel() {
|
||||
run(|| {
|
||||
// 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 || {
|
||||
register(Name::<u64>::new("svc2"), tx).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
assert_eq!(rx.recv().unwrap(), 0);
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
// Right actor, wrong message type: it has a u64 channel, not a String.
|
||||
let e = send(Name::<String>::new("svc2"), "x".to_string());
|
||||
assert!(matches!(e, Err(SendError::NoChannel(_))));
|
||||
assert_eq!(e.unwrap_err().into_inner(), "x"); // message handed back
|
||||
send(Name::<u64>::new("svc2"), 0u64).unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_frees_the_name_only() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (done_tx, done_rx) = channel::<()>();
|
||||
let (tx, _rx) = channel::<u64>(); // _rx moves into the actor, kept open
|
||||
let h = spawn(move || {
|
||||
register(SVC, tx).unwrap();
|
||||
let _keep_open = _rx;
|
||||
ready_tx.send(()).unwrap();
|
||||
done_rx.recv().unwrap(); // released over a separate channel
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
assert_eq!(whereis("svc"), Some(h.pid()));
|
||||
assert_eq!(unregister("svc"), Some(h.pid()));
|
||||
assert_eq!(whereis("svc"), None);
|
||||
assert!(matches!(send(SVC, 1u64), Err(SendError::Unresolved(_))));
|
||||
done_tx.send(()).unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
// --- RFC 014 §4.2: direct, identity-bound addressing via `Pid<A>` ----------
|
||||
|
||||
// A stand-in single-message actor. `install::<Worker>` publishes its inbox and
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_to_does_not_redirect_after_takeover() {
|
||||
// The load-bearing §4.2 property: a `Pid<A>` is identity-bound. When the
|
||||
// actor dies and a new incarnation reuses the slot, sending to the *old*
|
||||
// address fails `Dead` — it must never silently reach the new occupant
|
||||
// (that redirect is the re-resolving `Name`'s job, not a pid's).
|
||||
run(|| {
|
||||
let (addr_a_tx, addr_a_rx) = channel::<Pid<Worker>>();
|
||||
let (rt1, rr1) = channel::<()>();
|
||||
let a = spawn(move || {
|
||||
let (tx, _rx) = channel::<u64>();
|
||||
let me = install::<Worker>(tx);
|
||||
addr_a_tx.send(me).unwrap();
|
||||
rt1.send(()).unwrap(); // then return -> die
|
||||
});
|
||||
let a_addr = addr_a_rx.recv().unwrap();
|
||||
rr1.recv().unwrap();
|
||||
a.join().unwrap(); // a dead; a_addr names a dead incarnation
|
||||
|
||||
let (addr_b_tx, addr_b_rx) = channel::<Pid<Worker>>();
|
||||
let (rt2, rr2) = channel::<()>();
|
||||
let b = spawn(move || {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let me = install::<Worker>(tx); // reuses a's slot index, new generation
|
||||
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);
|
||||
});
|
||||
let b_addr = addr_b_rx.recv().unwrap();
|
||||
rr2.recv().unwrap();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_dyn_to_dead_pid_is_dead() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (tx, _rx) = channel::<u64>();
|
||||
let h = spawn(move || {
|
||||
register(Name::<u64>::new("dyn2"), tx).unwrap();
|
||||
ready_tx.send(()).unwrap(); // then return -> die
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
let p = h.pid();
|
||||
h.join().unwrap(); // dead; the bare pid now names a dead incarnation
|
||||
assert!(matches!(send_dyn::<u64>(p, 1u64), Err(SendError::Dead(_))));
|
||||
});
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
//! select tests. Beyond the functional cases, the *_stays_precise tests are
|
||||
//! soundness probes for the consuming-wake protocol: they fire stale loser-
|
||||
//! arm wakes at an actor and then run a one-shot park (`sleep`, whose early
|
||||
//! return would be observable as a short elapsed time) to prove the stale
|
||||
//! wake died at its epoch CAS instead of corrupting the next wait.
|
||||
|
||||
use smarm::{channel, run, select, spawn};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn ready_arm_returns_immediately_without_parking() {
|
||||
let out = Arc::new(AtomicI64::new(0));
|
||||
let out2 = out.clone();
|
||||
run(move || {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (_txb, rxb) = channel::<i64>();
|
||||
txa.send(42).unwrap();
|
||||
let i = select(&[&rxb, &rxa]);
|
||||
assert_eq!(i, 1);
|
||||
out2.store(rxa.try_recv().unwrap().expect("ready arm must hold a message"), Ordering::SeqCst);
|
||||
});
|
||||
assert_eq!(out.load(Ordering::SeqCst), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_index_wins_when_several_arms_are_ready() {
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
txa.send(1).unwrap();
|
||||
txb.send(2).unwrap();
|
||||
// Documented priority order: index 0 first, regardless of send order.
|
||||
assert_eq!(select(&[&rxa, &rxb]), 0);
|
||||
assert_eq!(select(&[&rxb, &rxa]), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parks_until_any_arm_fires() {
|
||||
let out = Arc::new(AtomicI64::new(0));
|
||||
let out2 = out.clone();
|
||||
run(move || {
|
||||
let (txa, _rxa_keepalive) = (channel::<i64>().0, ());
|
||||
let _hold = txa; // arm a: sender alive, never sends
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
let _keep_a_open = txa;
|
||||
let h = spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
txb.send(7).unwrap();
|
||||
});
|
||||
let i = select(&[&rxa, &rxb]);
|
||||
assert_eq!(i, 1);
|
||||
out2.store(rxb.try_recv().unwrap().unwrap(), Ordering::SeqCst);
|
||||
h.join().unwrap();
|
||||
});
|
||||
assert_eq!(out.load(Ordering::SeqCst), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_arm_counts_as_ready() {
|
||||
run(|| {
|
||||
let (_keep, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
drop(txb);
|
||||
let i = select(&[&rxa, &rxb]);
|
||||
assert_eq!(i, 1);
|
||||
// The caller observes the closure on the arm itself.
|
||||
assert!(rxb.try_recv().is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_while_parked_wakes_the_select() {
|
||||
run(|| {
|
||||
let (_keep, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
let h = spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
drop(txb);
|
||||
});
|
||||
assert_eq!(select(&[&rxa, &rxb]), 1);
|
||||
assert!(rxb.try_recv().is_err());
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loser_arm_wake_after_parked_select_stays_precise() {
|
||||
// Both arms registered; arm 0 wins the wake; arm 1's sender then fires
|
||||
// a wake stamped with the consumed epoch. The subsequent sleep is a
|
||||
// one-shot park: an early return would mean the stale wake landed.
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
let h = spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
txa.send(1).unwrap(); // wins
|
||||
txb.send(2).unwrap(); // loser arm: stale-epoch wake
|
||||
});
|
||||
assert_eq!(select(&[&rxa, &rxb]), 0);
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(1));
|
||||
let t0 = Instant::now();
|
||||
smarm::sleep(Duration::from_millis(40));
|
||||
assert!(
|
||||
t0.elapsed() >= Duration::from_millis(40),
|
||||
"one-shot park returned early: a stale loser-arm wake landed"
|
||||
);
|
||||
// The loser's message was never lost.
|
||||
assert_eq!(select(&[&rxa, &rxb]), 1);
|
||||
assert_eq!(rxb.try_recv().unwrap(), Some(2));
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_park_exit_retires_the_wait() {
|
||||
// Arm 1 is ready at registration time, so select returns WITHOUT
|
||||
// parking while arm 0 holds a live-epoch registration. Arm 0's sender
|
||||
// then fires. retire_wait must have invalidated/eaten that wake;
|
||||
// the sleep proves it.
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
txb.send(2).unwrap();
|
||||
assert_eq!(select(&[&rxa, &rxb]), 1);
|
||||
assert_eq!(rxb.try_recv().unwrap(), Some(2));
|
||||
|
||||
let h = spawn(move || {
|
||||
txa.send(1).unwrap(); // fires at the (retired) select epoch
|
||||
});
|
||||
h.join().unwrap();
|
||||
|
||||
let t0 = Instant::now();
|
||||
smarm::sleep(Duration::from_millis(40));
|
||||
assert!(
|
||||
t0.elapsed() >= Duration::from_millis(40),
|
||||
"one-shot park returned early: the no-park exit leaked a wake"
|
||||
);
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(1));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_then_plain_recv_on_a_loser_arm() {
|
||||
// A leftover own-registration from a select must not trip the next
|
||||
// direct wait on that channel (it is overwritten, not asserted away).
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
txa.send(1).unwrap();
|
||||
assert_eq!(select(&[&rxa, &rxb]), 0); // rxb may keep a registration
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(1));
|
||||
let h = spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(5));
|
||||
txb.send(9).unwrap();
|
||||
});
|
||||
assert_eq!(rxb.recv().unwrap(), 9);
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_loop_drains_two_producers_completely() {
|
||||
const N: i64 = 200;
|
||||
let out = Arc::new(AtomicI64::new(0));
|
||||
let out2 = out.clone();
|
||||
run(move || {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
let ha = spawn(move || {
|
||||
for v in 0..N {
|
||||
txa.send(v).unwrap();
|
||||
if v % 7 == 0 {
|
||||
smarm::yield_now();
|
||||
}
|
||||
}
|
||||
});
|
||||
let hb = spawn(move || {
|
||||
for v in 0..N {
|
||||
txb.send(v).unwrap();
|
||||
if v % 5 == 0 {
|
||||
smarm::yield_now();
|
||||
}
|
||||
}
|
||||
});
|
||||
// A closed arm stays permanently ready, so it must leave the arm
|
||||
// set once observed (keeping it would starve the other arm at
|
||||
// priority order) — select until the FIRST closure, then drain the
|
||||
// survivor with plain recv.
|
||||
let mut sum = 0i64;
|
||||
let survivor: &smarm::Receiver<i64> = loop {
|
||||
let i = select(&[&rxa, &rxb]);
|
||||
let arm: &smarm::Receiver<i64> = if i == 0 { &rxa } else { &rxb };
|
||||
match arm.try_recv() {
|
||||
Ok(Some(v)) => sum += v,
|
||||
Ok(None) => {} // defensive: ready arm already drained
|
||||
Err(_) => break if i == 0 { &rxb } else { &rxa },
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match survivor.recv() {
|
||||
Ok(v) => sum += v,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
out2.store(sum, Ordering::SeqCst);
|
||||
ha.join().unwrap();
|
||||
hb.join().unwrap();
|
||||
});
|
||||
assert_eq!(out.load(Ordering::SeqCst), 2 * (0..200i64).sum::<i64>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motivating_pattern_inbox_plus_monitor_down() {
|
||||
// The gen_server/handle_info shape select unlocks: a server waiting on
|
||||
// its request inbox AND a monitor Down channel in one park.
|
||||
use smarm::monitor;
|
||||
run(|| {
|
||||
let (req_tx, req_rx) = channel::<i64>();
|
||||
let worker = spawn(|| {
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
// worker exits -> Down fires
|
||||
});
|
||||
let m = monitor(worker.pid());
|
||||
let _keep_inbox_open = req_tx;
|
||||
|
||||
let mut served = 0;
|
||||
loop {
|
||||
match select(&[&m.rx, &req_rx]) {
|
||||
0 => {
|
||||
let _down = m.rx.try_recv().unwrap().expect("Down message");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
if let Ok(Some(_)) = req_rx.try_recv() {
|
||||
served += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(served, 0);
|
||||
worker.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// select_timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use smarm::select_timeout;
|
||||
|
||||
#[test]
|
||||
fn select_timeout_returns_none_after_the_deadline() {
|
||||
run(|| {
|
||||
let (_keep_a, rxa) = channel::<i64>();
|
||||
let (_keep_b, rxb) = channel::<i64>();
|
||||
let t0 = Instant::now();
|
||||
let r = select_timeout(&[&rxa, &rxb], Duration::from_millis(30));
|
||||
assert_eq!(r, None);
|
||||
assert!(t0.elapsed() >= Duration::from_millis(30));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_timeout_ready_arm_wins_without_arming_a_timer() {
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (_keep_b, rxb) = channel::<i64>();
|
||||
txa.send(5).unwrap();
|
||||
assert_eq!(select_timeout(&[&rxb, &rxa], Duration::from_millis(500)), Some(1));
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(5));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_timeout_arm_beats_timer_and_stale_entry_stays_inert() {
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (_keep_b, rxb) = channel::<i64>();
|
||||
let h = spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(5));
|
||||
txa.send(1).unwrap();
|
||||
});
|
||||
let t0 = Instant::now();
|
||||
let r = select_timeout(&[&rxa, &rxb], Duration::from_millis(200));
|
||||
assert_eq!(r, Some(0));
|
||||
assert!(t0.elapsed() < Duration::from_millis(200));
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(1));
|
||||
h.join().unwrap();
|
||||
// The abandoned timer entry expires mid-sleep; a stale-epoch wake
|
||||
// landing would return this one-shot park early.
|
||||
let t1 = Instant::now();
|
||||
smarm::sleep(Duration::from_millis(250));
|
||||
assert!(
|
||||
t1.elapsed() >= Duration::from_millis(250),
|
||||
"one-shot park returned early: the stale select timer landed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_timeout_timer_first_message_still_delivered_later() {
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let h = spawn(move || {
|
||||
smarm::sleep(Duration::from_millis(60));
|
||||
txa.send(9).unwrap();
|
||||
});
|
||||
assert_eq!(select_timeout(&[&rxa], Duration::from_millis(10)), None);
|
||||
// The wait is over but the channel is intact: the late message
|
||||
// arrives and a fresh select sees it.
|
||||
assert_eq!(select(&[&rxa]), 0);
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(9));
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_timeout_zero_duration_polls() {
|
||||
run(|| {
|
||||
let (txa, rxa) = channel::<i64>();
|
||||
let (_keep_b, rxb) = channel::<i64>();
|
||||
assert_eq!(select_timeout(&[&rxa, &rxb], Duration::ZERO), None);
|
||||
txa.send(3).unwrap();
|
||||
assert_eq!(select_timeout(&[&rxa, &rxb], Duration::ZERO), Some(0));
|
||||
assert_eq!(rxa.try_recv().unwrap(), Some(3));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_timeout_closed_arm_is_ready_not_a_timeout() {
|
||||
run(|| {
|
||||
let (_keep_a, rxa) = channel::<i64>();
|
||||
let (txb, rxb) = channel::<i64>();
|
||||
drop(txb);
|
||||
assert_eq!(select_timeout(&[&rxa, &rxb], Duration::from_millis(200)), Some(1));
|
||||
assert!(rxb.try_recv().is_err());
|
||||
});
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//! gen_statem (RFC 017) chunk-1 tests: call/cast round-trip against a
|
||||
//! hand-written machine, `enter` firing on start and on every real transition
|
||||
//! (but not on a stay), and the machine-down path when a handler panics.
|
||||
//!
|
||||
//! These drive the `smarm::statem` primitives directly, the same way a
|
||||
//! `statem!`-generated machine eventually will.
|
||||
|
||||
use smarm::run;
|
||||
use smarm::statem::{self, CallError, Cx, Machine, Reply, Resolution, StatemRef};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A hand-written two-state machine: Flip toggles, calls read, Boom panics.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Switch {
|
||||
Off,
|
||||
On,
|
||||
}
|
||||
|
||||
struct Counts {
|
||||
flips: u32,
|
||||
enters: u32,
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
Flip,
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetFlips(Reply<u32>),
|
||||
GetEnters(Reply<u32>),
|
||||
Boom(Reply<u32>),
|
||||
}
|
||||
|
||||
enum Ev {
|
||||
Cast(Cast),
|
||||
Call(Call),
|
||||
}
|
||||
|
||||
struct Sm {
|
||||
state: Switch,
|
||||
data: Counts,
|
||||
}
|
||||
|
||||
impl Sm {
|
||||
fn start(init: Switch) -> StatemRef<Sm> {
|
||||
statem::spawn(Sm { state: init, data: Counts { flips: 0, enters: 0 } })
|
||||
}
|
||||
|
||||
fn enter(&mut self, _s: Switch, _cx: &mut Cx<Ev>) {
|
||||
self.data.enters += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine for Sm {
|
||||
type Ev = Ev;
|
||||
|
||||
fn on_start(&mut self, cx: &mut Cx<Ev>) {
|
||||
let s = self.state;
|
||||
self.enter(s, cx);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
|
||||
let prev = self.state;
|
||||
let next: Resolution<Switch> = match (self.state, ev) {
|
||||
(Switch::Off, Ev::Cast(Cast::Flip)) => {
|
||||
self.data.flips += 1;
|
||||
Switch::On.into()
|
||||
}
|
||||
(Switch::On, Ev::Cast(Cast::Flip)) => Switch::Off.into(),
|
||||
(s, Ev::Call(Call::GetFlips(r))) => {
|
||||
r.reply(self.data.flips);
|
||||
s.into() // stay
|
||||
}
|
||||
(s, Ev::Call(Call::GetEnters(r))) => {
|
||||
r.reply(self.data.enters);
|
||||
s.into() // stay
|
||||
}
|
||||
(_, Ev::Call(Call::Boom(_r))) => panic!("boom"),
|
||||
};
|
||||
match next {
|
||||
Resolution::To(s) if s == prev => {}
|
||||
Resolution::To(s) => {
|
||||
self.state = s;
|
||||
self.enter(s, cx);
|
||||
}
|
||||
Resolution::Postpone => {}
|
||||
Resolution::Unhandled => cx.on_unhandled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Casts are applied in order and a later call observes the accumulated data.
|
||||
#[test]
|
||||
fn cast_then_call_roundtrip() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off);
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off
|
||||
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
*got2.lock().unwrap() = flips;
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "turned On once across the two flips");
|
||||
}
|
||||
|
||||
// `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 sw = Sm::start(Switch::Off); // on_start enter -> enters = 1
|
||||
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
// Two stays (the reads above + below) must not bump enters.
|
||||
let _ = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
let still = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On -> enter -> enters = 2
|
||||
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
*got2.lock().unwrap() = (after_start, still, after_flip);
|
||||
});
|
||||
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 sw = Sm::start(Switch::Off);
|
||||
let r = sw.call(|rep| Ev::Call(Call::Boom(rep)));
|
||||
*got2.lock().unwrap() = Some(r);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down)));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
+215
-15
@@ -124,11 +124,11 @@ use smarm::pid::Pid;
|
||||
use smarm::timer::{Reason, TimerTarget, Timers};
|
||||
|
||||
struct RecordingTarget {
|
||||
calls: Mutex<Vec<(Pid, u64)>>,
|
||||
calls: Mutex<Vec<(Pid, u32)>>,
|
||||
}
|
||||
impl TimerTarget for RecordingTarget {
|
||||
fn on_timeout(&self, pid: Pid, seq: u64) {
|
||||
self.calls.lock().unwrap().push((pid, seq));
|
||||
fn on_timeout(&self, pid: Pid, epoch: u32) {
|
||||
self.calls.lock().unwrap().push((pid, epoch));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +137,9 @@ fn timers_pop_due_returns_entries_in_deadline_order() {
|
||||
let mut t = Timers::new();
|
||||
let now = Instant::now();
|
||||
// Insert out of order; pop_due should hand them back sorted by deadline.
|
||||
t.insert_sleep(now + Duration::from_millis(30), Pid::new(0, 0));
|
||||
t.insert_sleep(now + Duration::from_millis(10), Pid::new(1, 0));
|
||||
t.insert_sleep(now + Duration::from_millis(20), Pid::new(2, 0));
|
||||
t.insert_sleep(now + Duration::from_millis(30), Pid::new(0, 0), 1);
|
||||
t.insert_sleep(now + Duration::from_millis(10), Pid::new(1, 0), 1);
|
||||
t.insert_sleep(now + Duration::from_millis(20), Pid::new(2, 0), 1);
|
||||
|
||||
// Advance past all of them.
|
||||
let due = t.pop_due(now + Duration::from_millis(50));
|
||||
@@ -152,8 +152,8 @@ fn timers_pop_due_returns_entries_in_deadline_order() {
|
||||
fn timers_only_pop_entries_whose_deadline_has_passed() {
|
||||
let mut t = Timers::new();
|
||||
let now = Instant::now();
|
||||
t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0));
|
||||
t.insert_sleep(now + Duration::from_millis(100), Pid::new(1, 0));
|
||||
t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0), 1);
|
||||
t.insert_sleep(now + Duration::from_millis(100), Pid::new(1, 0), 1);
|
||||
|
||||
let due = t.pop_due(now + Duration::from_millis(20));
|
||||
assert_eq!(due.len(), 1);
|
||||
@@ -169,11 +169,11 @@ fn timers_mix_sleep_and_wait_timeout_reasons() {
|
||||
let target = Arc::new(RecordingTarget { calls: Mutex::new(Vec::new()) });
|
||||
let now = Instant::now();
|
||||
|
||||
t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0));
|
||||
t.insert_sleep(now + Duration::from_millis(5), Pid::new(0, 0), 1);
|
||||
t.insert(
|
||||
now + Duration::from_millis(10),
|
||||
Pid::new(1, 0),
|
||||
Reason::WaitTimeout { target: target.clone(), wait_seq: 42 },
|
||||
Reason::WaitTimeout { target: target.clone(), epoch: 42 },
|
||||
);
|
||||
|
||||
let due = t.pop_due(now + Duration::from_millis(20));
|
||||
@@ -181,11 +181,11 @@ fn timers_mix_sleep_and_wait_timeout_reasons() {
|
||||
|
||||
// Order: Sleep (5ms) first, WaitTimeout (10ms) second.
|
||||
match &due[0].reason {
|
||||
Reason::Sleep => {}
|
||||
Reason::Sleep { .. } => {}
|
||||
_ => panic!("first entry should be a Sleep"),
|
||||
}
|
||||
match &due[1].reason {
|
||||
Reason::WaitTimeout { wait_seq, .. } => assert_eq!(*wait_seq, 42),
|
||||
Reason::WaitTimeout { epoch, .. } => assert_eq!(*epoch, 42),
|
||||
_ => panic!("second entry should be a WaitTimeout"),
|
||||
}
|
||||
}
|
||||
@@ -197,11 +197,211 @@ fn same_deadline_entries_pop_in_insertion_order() {
|
||||
let mut t = Timers::new();
|
||||
let now = Instant::now();
|
||||
let d = now + Duration::from_millis(10);
|
||||
t.insert_sleep(d, Pid::new(0, 0));
|
||||
t.insert_sleep(d, Pid::new(1, 0));
|
||||
t.insert_sleep(d, Pid::new(2, 0));
|
||||
t.insert_sleep(d, Pid::new(0, 0), 1);
|
||||
t.insert_sleep(d, Pid::new(1, 0), 1);
|
||||
t.insert_sleep(d, Pid::new(2, 0), 1);
|
||||
|
||||
let due = t.pop_due(now + Duration::from_millis(20));
|
||||
let pids: Vec<u32> = due.iter().map(|e| e.pid.index()).collect();
|
||||
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, and nothing further delivered.
|
||||
assert_eq!(report_rx.try_recv(), Ok(None));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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