Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f011f6ce4 | ||
|
|
debcb4e33b | ||
|
|
683f2ed02d | ||
|
|
4c1e5f19f1 | ||
|
|
7fcc9f2ec5 | ||
|
|
7ec453920b | ||
|
|
87416f903a | ||
|
|
2ab82ac032 | ||
|
|
d8b5c8db02 |
+4
-15
@@ -10,16 +10,6 @@ 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 = []
|
||||
@@ -73,10 +63,9 @@ name = "rq_runtime"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "switch_cost"
|
||||
name = "spin_sweep"
|
||||
harness = false
|
||||
|
||||
# RFC 016 Chunk 4 — the live observer dump. Needs the optional gen_server.
|
||||
[[example]]
|
||||
name = "observer"
|
||||
required-features = ["observer"]
|
||||
[[bench]]
|
||||
name = "switch_cost"
|
||||
harness = false
|
||||
|
||||
+105
-129
@@ -2,9 +2,20 @@
|
||||
|
||||
## 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`.
|
||||
Cycles before v0.7 (v0.4 actor primitives, v0.5 runtime decomposition &
|
||||
pluggable run queue, v0.6 actor ergonomics): see `git log ROADMAP.md`.
|
||||
|
||||
### v0.7 — select, on epoch-stamped consuming wakes ✅
|
||||
A 24-bit park-epoch packed into the slot word gives every wait an identity:
|
||||
registrations carry `(pid, epoch)`, every successful wake **consumes** the
|
||||
epoch, stale wakes die at one failed CAS. Subsumed the per-primitive wait
|
||||
seqs (channel, mutex, timer); the only wildcard wake left is `request_stop`
|
||||
(terminal). On top: `select`/`select_timeout` — ready-index wait over many
|
||||
receivers, priority order, no cancellation pass. Loom theorems re-proved on
|
||||
the new word. Notable deviations: `retire_wait` for the no-park exit path
|
||||
(plan missed it); the Drop-guard deregistration was superseded by relaxed
|
||||
single-receiver asserts; a closed arm is ready *forever* (documented gotcha).
|
||||
Commits `4913835`…`00128f3`, `a0a93b6`.
|
||||
|
||||
### v0.8 — gen_server: handle_info / handle_down + io fd hygiene ✅
|
||||
Spent `select` on the server loop: static info arms (`type Info`,
|
||||
@@ -16,19 +27,6 @@ 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)
|
||||
@@ -50,85 +48,66 @@ Consequences:
|
||||
|
||||
---
|
||||
|
||||
## Typed addressable mailboxes ✅ SHIPPED (RFC 013 + RFC 014)
|
||||
## v0.9 — Wake-path latency
|
||||
|
||||
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.
|
||||
Goal: attack per-wake latency — the measured ceiling — with the two specified
|
||||
mechanisms, each benched against a clean baseline before the next lands.
|
||||
Order: RFC 005 first (the slot is measured against the just-benched idle
|
||||
policy), then RFC 004 (measured against the slot-accepted baseline), then an
|
||||
interaction pass.
|
||||
|
||||
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.
|
||||
Full specs: `rfc_005-wake-slot.md`, `rfc_004-tunable-scheduler-idle-policy.md`
|
||||
(artefacts.kalsbeek.dev).
|
||||
|
||||
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.
|
||||
### 1. Wake slot (RFC 005)
|
||||
Per-scheduler, thread-local, capacity-one wake cache, checked before the
|
||||
shared queue. Runtime-selected via `Config { wake_slot: bool }`, default off
|
||||
until accepted (one binary benches both arms).
|
||||
- **Push policy:** slot-eligible iff the wake originates from actor context
|
||||
(`current_pid().is_some()`). Scheduler-context wakes (timer/IO drain) and
|
||||
spawns always go shared.
|
||||
- **Displacement:** newest wake takes the slot, occupant pushed shared (Go
|
||||
semantics); the branch swap is the fallback if benches look pathological.
|
||||
- **Pop order:** slot, then shared. Slot-popped actors inherit the waker's
|
||||
remaining timeslice — a handoff chain is bounded by one slice, so the
|
||||
shared queue is consulted at least once per slice per scheduler (the
|
||||
starvation bound, zero new counters).
|
||||
- **Invariant care:** the slot push replaces `run_queue.push` at the tail of
|
||||
the `Parked → Queued` CAS; at-most-once-enqueued holds verbatim (pid in
|
||||
slot ⊕ shared queue). Stall blast radius grows by exactly one actor.
|
||||
- Counters: `slot_hits`, `slot_displacements`.
|
||||
|
||||
### 2. Slot shootout
|
||||
Extend `rq_runtime`/`bench_rq.sh` with the slot on/off dimension (cheap —
|
||||
it's a Config knob, not a feature rebuild). Same sweep as the rq shootout.
|
||||
- **ping-pong-pairs** — target metric; expect the win, single- and
|
||||
multi-scheduler.
|
||||
- **yield-storm** — regression guard; yields never touch the slot, any delta
|
||||
is pop-path overhead.
|
||||
- **spawn-storm** — neutrality check; spawns bypass the slot by policy.
|
||||
Acceptance flips the default on and re-baselines for RFC 004.
|
||||
This is done, see results annotated in RFC005.
|
||||
|
||||
### 3. Spinning workers (RFC 004)
|
||||
Bounded spin-before-park for idle schedulers, killing the ~100 µs
|
||||
`thread::sleep` worst case on cross-thread handoff. Two Config knobs:
|
||||
`spin_budget_cycles` (0 recovers today's behaviour) and `max_spinners`
|
||||
(default N/2); one new atomic `n_spinning`. No run-queue changes — lands on
|
||||
the frozen `rq-mutex` substrate, independent of the slot mechanically.
|
||||
Credit for the idea: Dennis Gustafsson - [Parallelizing the physics solver BSC 2025](https://www.youtube.com/watch?v=Kvsvd67XUKw).
|
||||
|
||||
### 4. RFC 004 bench + interaction pass
|
||||
Re-run the sweep with spinning enabled, slot on and off. The known
|
||||
interaction: once idle pickup latency ≪ slice, the slot's latency trade
|
||||
(occupant waits out the waker's slice while other schedulers idle) may stop
|
||||
paying — RFC 005's documented escape hatch is Go's "spinners exist → push
|
||||
shared instead" check. In scope only if the data demands it.
|
||||
|
||||
---
|
||||
|
||||
## 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.)*
|
||||
|
||||
## Later
|
||||
### Highest priority
|
||||
#### Process groups: the primitive pubsub & channels should have sat on
|
||||
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
|
||||
@@ -139,43 +118,47 @@ member), with different netsplit consequences. Urus shipped pubsub/channels pred
|
||||
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.
|
||||
0.8–1.2 µs at N=8+. The N=1 profiling spike is **done** (`docs/perswitch-profile-n1.md`):
|
||||
it **revises the old shim-first framing**. At N=1 the context shims + TLS sp
|
||||
accessors are ~1% of self-time, not the dominant cost — the shim hypothesis was
|
||||
always a *many-core* one (its only evidence was the N=1→N=8 jump, blamed on TLS
|
||||
access mode and cross-core coherency on the sp/epoch words), and nothing at N=1
|
||||
on one core can confirm or refute it. The N=1 round-trip is instead dominated by
|
||||
the scheduler core (`schedule_loop` + run-queue, ~33%) plus a removable
|
||||
wake-nobody `futex_wake`.
|
||||
|
||||
That `futex_wake` is now **fixed**: it was an interaction between the RFC 004
|
||||
spin defaults and the N=1 pool-size cap of 0 — `spinning` was gated on
|
||||
`budget > 0` alone, so at N=1 spinning was enabled while the cap forbade any
|
||||
spinner from enlisting, pinning `n_spinning` at 0 and firing a wake-nobody
|
||||
`futex_wake` on every enqueue. `spinning` is now gated on
|
||||
`budget > 0 && max_spinners > 0` (a `debug_assert` guards the invariant). On the
|
||||
1-core sandbox this cut N=1 p50 from ~466 ns to ~186 ns — the syscall was ~60%
|
||||
of the round-trip in wall-clock terms (the handoff's "~12%" was perf *self-time*,
|
||||
not latency share).
|
||||
|
||||
Two separable targets remain: **(a)** the N=1 floor — `schedule_loop` + run-queue,
|
||||
the irreducible scheduler core, no obvious cheap win left; **(b)** the N→8 slope —
|
||||
shim / TLS / coherency, which needs the 5900X (HW-counter profiling; this sandbox
|
||||
has no PMU). Still "the whole game" alongside v0.9. Next code chunk is a `remote`
|
||||
mode for `benches/switch_cost.rs` (wake straddling two schedulers) — writable in
|
||||
the sandbox, only measurable on the box. Do **not** optimise the shim until the
|
||||
N=8 HW-counter data confirms it is the cost. Then an RFC; the
|
||||
`spinning`-gating fix above may also warrant a small RFC since it touches the
|
||||
RFC-004-settled futex path.
|
||||
|
||||
#### 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.
|
||||
Message-delivery timer on the existing min-heap (`timer.rs`): deliver a value to a
|
||||
channel at a deadline, cancellable. Unlocks the gen_server idioms with no clean
|
||||
expression today — heartbeat, debounce, retry backoff, session expiry. Small;
|
||||
|
||||
#### 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.
|
||||
`trace.rs` is the seed. What an actor is parked on, queue depth, stack size; a
|
||||
gen_server state snapshot; a supervision-tree walk. The native edge over tokio —
|
||||
actors already carry pid, name, parent where tokio tasks are anonymous — so it
|
||||
costs little and differentiates a lot. Needs an RFC.
|
||||
|
||||
#### Worker pool behaviour
|
||||
Supervised, interchangeable workers with restart semantics over a shared inbox
|
||||
@@ -241,7 +224,7 @@ RFCs, not one. Spine settled in discussion; decisions still open:
|
||||
- **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.)
|
||||
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.
|
||||
- **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
|
||||
|
||||
@@ -250,16 +233,9 @@ RFCs, not one. Spine settled in discussion; decisions still open:
|
||||
|
||||
## Look into
|
||||
|
||||
### app actors block AllDone; no external stop path — ADDRESSED (RFC 014 root-exit teardown)
|
||||
### app actors block AllDone; no external stop path
|
||||
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)
|
||||
|
||||
@@ -21,6 +21,7 @@ cargo bench # all of them (slow; rarely what you want)
|
||||
| `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. |
|
||||
| `spin_sweep.rs` | RFC 004 spinning workers: wake **latency vs idle-CPU** over `spin_budget_cycles`. Four workloads (remote-wake, fork-join, half-load, pure-idle) × budget × `max_spinners` × scheduler count; latency percentiles plus cores-busy from `getrusage`. The data that picks the budget default and on/off posture. |
|
||||
|
||||
## The run-queue shootout
|
||||
|
||||
@@ -55,6 +56,70 @@ cargo bench --bench rq_runtime --no-default-features --features rq-striped
|
||||
| `SMARM_BENCH_PAIRS` / `_ROUNDTRIPS` | `32` / `1000` | `rq_runtime` ping-pong |
|
||||
| `SMARM_BENCH_SPAWNS` | `5000` | `rq_runtime` spawn-storm |
|
||||
|
||||
## The spin sweep (RFC 004)
|
||||
|
||||
Spinning workers trade idle CPU for wake latency: an idle worker polls
|
||||
`queue_len` for up to `spin_budget_cycles` (at most `max_spinners` at once)
|
||||
before falling back to a futex park, so work that lands during the spin is
|
||||
claimed with no syscall. `spin_budget_cycles = 0` is the feature fully off —
|
||||
the historical `thread::sleep` park, exactly preserved. `spin_sweep` measures
|
||||
both sides of that trade across the budget so the curve can pick the default.
|
||||
|
||||
```
|
||||
cargo bench --bench spin_sweep
|
||||
# on a big box (scale the work up from the sub-second sandbox defaults):
|
||||
SMARM_BENCH_THREADS="1 2 4 8 16" SMARM_BENCH_RUNS=9 \
|
||||
SMARM_SPIN_BUDGET="0 1000 5000 20000 100000 500000 2000000" \
|
||||
SMARM_SPIN_ROUNDS=2000 SMARM_SPIN_BURSTS=500 SMARM_SPIN_ITEMS=5000 \
|
||||
cargo bench --bench spin_sweep | tee bench_results/spin.txt
|
||||
grep '^SPINCSV' bench_results/spin.txt > bench_results/spin.csv
|
||||
```
|
||||
|
||||
Two axes per config: **latency** (p50/p90/p99/min/max, pooled across runs) and
|
||||
**cost** (cores-busy = process CPU-seconds via `getrusage(RUSAGE_SELF)` over
|
||||
wall-seconds; `1.0` = one core pegged, up to `max_spinners` when every spinner
|
||||
is hot). The four workloads:
|
||||
|
||||
- **remote-wake** — the latency target. A consumer parks on `recv`; a producer
|
||||
on another scheduler timestamps just before `send`. Latency is send→receipt.
|
||||
- **fork-join** — fan-out wake latency: root forks `FANOUT` actors, joins them,
|
||||
gap, repeat.
|
||||
- **half-load** — the cost target. Work dispatched every `PERIOD_US`, so workers
|
||||
idle a fraction of each period; cores-busy climbs with budget as spin fills
|
||||
the gaps. Spin cost scales with **park frequency** × budget, so this (not
|
||||
pure-idle) is where the cost curve lives.
|
||||
- **pure-idle** — a floor check: one actor sleeps, nothing else runs. Confirms
|
||||
budget 0 stays parked and a large budget doesn't leak *continuous* CPU (a
|
||||
spinner spends its budget once per park, so a truly idle window barely moves).
|
||||
|
||||
The crossover the sweep reveals: spinning only wins when work arrives while a
|
||||
worker is still inside its budget. With a fixed inter-arrival gap, budgets whose
|
||||
spin-time (`budget_cycles / TSC_freq`) exceeds the gap start catching work
|
||||
before the park — so `SMARM_SPIN_GAP_US` sets the knee; tune it against your
|
||||
box's TSC frequency.
|
||||
|
||||
### Knobs (env vars, all optional)
|
||||
|
||||
| var | default | used by |
|
||||
|---|---|---|
|
||||
| `SMARM_BENCH_THREADS` | `"1 2"` | scheduler-count sweep |
|
||||
| `SMARM_BENCH_RUNS` | `3` | runs per config (latency pooled, cores median) |
|
||||
| `SMARM_SPIN_BUDGET` | `"0 1000 10000 100000 1000000"` | `spin_budget_cycles` sweep |
|
||||
| `SMARM_MAX_SPINNERS` | `"-"` | `max_spinners` sweep; `-` = runtime default (N/2) |
|
||||
| `SMARM_SPIN_GAP_US` | `150` | idle gap between rounds/bursts (sets the latency knee) |
|
||||
| `SMARM_SPIN_ROUNDS` | `100` | remote-wake rounds |
|
||||
| `SMARM_SPIN_FANOUT` / `_BURSTS` | `64` / `30` | fork-join |
|
||||
| `SMARM_SPIN_ITEMS` / `_PERIOD_US` | `150` / `300` | half-load |
|
||||
| `SMARM_SPIN_WINDOW_MS` | `25` | pure-idle window |
|
||||
|
||||
Output is the house table plus a greppable `SPINCSV,...` line per config.
|
||||
|
||||
**On 1 core spinning is pathological** — a hot spinner starves the very thread
|
||||
that would enqueue work, so the optimal budget there is 0 and you only ever see
|
||||
the cost, never the benefit (the sweep will show remote-wake latency getting
|
||||
*worse* with budget on one core). One core validates the harness; the real curve
|
||||
comes from the many-core box.
|
||||
|
||||
## Reading the numbers honestly
|
||||
|
||||
- **Core count is the experiment.** On a 1-core machine (CI, sandboxes) the
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
//! RFC 004 spinning-worker measurement tool — the latency-vs-idle-CPU curve
|
||||
//! over `spin_budget_cycles`.
|
||||
//!
|
||||
//! Spinning workers trade idle CPU for wake latency: an idle worker burns
|
||||
//! cycles polling `queue_len` (up to `spin_budget_cycles`, capped at
|
||||
//! `max_spinners` concurrent spinners) so that incoming work is claimed with
|
||||
//! no futex syscall. `spin_budget_cycles = 0` is the feature fully off — the
|
||||
//! historical `thread::sleep` park, exactly preserved. This binary measures
|
||||
//! both sides of that trade so the sweep can pick the budget default (RFC 004
|
||||
//! Q3) and the on/off posture (Q4).
|
||||
//!
|
||||
//! Workloads — each one is a fresh runtime per sample (no cross-contamination):
|
||||
//!
|
||||
//! remote-wake — a consumer blocks on recv (its worker idles); a producer
|
||||
//! on another scheduler timestamps just before send. Latency
|
||||
//! = send -> receipt, the quantity spinning is meant to cut.
|
||||
//! A fixed gap (SMARM_SPIN_GAP_US) between rounds lets the
|
||||
//! consumer re-idle each round. THE LATENCY TARGET.
|
||||
//! fork-join — root forks FANOUT trivial actors and joins them (one
|
||||
//! burst), gap, repeat. Latency = fork -> all-joined; stresses
|
||||
//! the fan-out wake. Secondary latency target.
|
||||
//! half-load — work dispatched every PERIOD_US for ITEMS items, so
|
||||
//! workers idle a fraction of each period. THE COST TARGET:
|
||||
//! cores-busy climbs with budget as spin fills the idle gaps.
|
||||
//! pure-idle — one actor sleeps WINDOW_MS, nothing else runs. The cleanest
|
||||
//! single picture of spin burn: cores-busy is ~0 at budget 0
|
||||
//! and climbs toward max_spinners as the budget grows.
|
||||
//!
|
||||
//! Two axes, reported together per config:
|
||||
//! latency — p50 / p90 / p99 / min / max over the POOLED rounds of every run
|
||||
//! (pooling gives a stabler tail than a median-of-percentiles).
|
||||
//! cost — cores-busy = process CPU-seconds (getrusage RUSAGE_SELF, all
|
||||
//! runtime threads) / wall-seconds over the measured window. 1.0 =
|
||||
//! one core pegged; approaches max_spinners when every spinner is
|
||||
//! hot. Reported as the median over runs.
|
||||
//!
|
||||
//! The crossover the sweep reveals: spinning only wins when work arrives while
|
||||
//! a worker is still inside its budget. With a fixed inter-arrival gap, that
|
||||
//! means budgets whose spin-time (budget_cycles / TSC_freq) exceeds the gap
|
||||
//! start catching work before the park; smaller budgets park and pay the full
|
||||
//! wake. So GAP_US sets the knee — tune it against your box's TSC frequency.
|
||||
//!
|
||||
//! Knobs (env):
|
||||
//! SMARM_BENCH_THREADS scheduler-count sweep default "1 2"
|
||||
//! SMARM_BENCH_RUNS runs per config (pooled/median) default 3
|
||||
//! SMARM_SPIN_BUDGET spin_budget_cycles sweep default "0 1000 10000 100000 1000000"
|
||||
//! SMARM_MAX_SPINNERS max_spinners sweep; "-" = runtime default (N/2) default "-"
|
||||
//! SMARM_SPIN_GAP_US idle gap between rounds/bursts default 150
|
||||
//! SMARM_SPIN_ROUNDS remote-wake rounds default 100
|
||||
//! SMARM_SPIN_FANOUT fork-join actors per burst default 64
|
||||
//! SMARM_SPIN_BURSTS fork-join bursts default 30
|
||||
//! SMARM_SPIN_ITEMS half-load dispatch count default 150
|
||||
//! SMARM_SPIN_PERIOD_US half-load inter-dispatch period default 300
|
||||
//! SMARM_SPIN_WINDOW_MS pure-idle window default 25
|
||||
//!
|
||||
//! Output: house table + one greppable line per config:
|
||||
//! SPINCSV,<variant>,<workload>,<threads>,<budget>,<spinners>,<gap_us>,
|
||||
//! <n>,<p50_us>,<p90_us>,<p99_us>,<min_us>,<max_us>,<cores_busy>,<ops_s>
|
||||
//! ("spinners" is the literal "default" when SMARM_MAX_SPINNERS is "-".)
|
||||
//!
|
||||
//! NOTE: on a 1-core box (CI, sandboxes) spinning is *pathological* — a hot
|
||||
//! spinner starves the very thread that would enqueue work, so the optimal
|
||||
//! budget there is 0 and you only ever see the cost, never the benefit. This
|
||||
//! binary on one core validates the harness and catches gross pathologies; the
|
||||
//! real latency-vs-idle-CPU curve comes from the many-core box.
|
||||
|
||||
use smarm::runtime::{init, Config};
|
||||
use smarm::{channel, sleep, spawn};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// env helpers (house style, matching 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)
|
||||
}
|
||||
|
||||
fn env_u64(key: &str, default: u64) -> u64 {
|
||||
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])
|
||||
}
|
||||
|
||||
fn env_budgets() -> Vec<u64> {
|
||||
std::env::var("SMARM_SPIN_BUDGET")
|
||||
.map(|v| v.split_whitespace().filter_map(|t| t.parse().ok()).collect())
|
||||
.unwrap_or_else(|_| vec![0, 1_000, 10_000, 100_000, 1_000_000])
|
||||
}
|
||||
|
||||
/// `None` means "leave the runtime default (N/2)"; encoded in env as "-".
|
||||
fn env_spinners() -> Vec<Option<u32>> {
|
||||
std::env::var("SMARM_MAX_SPINNERS")
|
||||
.map(|v| {
|
||||
v.split_whitespace()
|
||||
.map(|t| if t == "-" { None } else { t.parse().ok() })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| vec![None])
|
||||
}
|
||||
|
||||
fn make_config(threads: usize, budget: u64, spinners: Option<u32>) -> Config {
|
||||
let cfg = Config::exact(threads).spin_budget_cycles(budget);
|
||||
match spinners {
|
||||
Some(n) => cfg.max_spinners(n),
|
||||
None => cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CPU accounting — process-wide (all runtime threads) via getrusage
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/// Total CPU seconds (user + system) consumed by the whole process so far.
|
||||
/// RUSAGE_SELF aggregates every thread, which is exactly what "how much did
|
||||
/// the runtime burn" needs — the spinners are separate threads from the one
|
||||
/// reading this.
|
||||
fn cpu_seconds() -> f64 {
|
||||
// SAFETY: getrusage with RUSAGE_SELF and a valid out-pointer; we check the
|
||||
// return code and only read the (now-initialised) timeval fields.
|
||||
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) };
|
||||
assert_eq!(rc, 0, "getrusage(RUSAGE_SELF) failed");
|
||||
let tv = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 * 1e-6;
|
||||
tv(ru.ru_utime) + tv(ru.ru_stime)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// One sample = one fresh runtime run
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
struct Sample {
|
||||
/// Per-event latencies in nanoseconds (empty for pure-idle).
|
||||
lat_ns: Vec<u64>,
|
||||
/// CPU-seconds / wall-seconds over the measured window.
|
||||
cores_busy: f64,
|
||||
/// Completed work units (for ops/s); 0 where not meaningful.
|
||||
ops: u64,
|
||||
/// Wall-seconds of the measured window (for ops/s).
|
||||
wall_s: f64,
|
||||
}
|
||||
|
||||
/// Shared scratch the root closure writes and the caller reads after `run()`
|
||||
/// returns. (`run` takes a `FnOnce` and yields no value, so we hand results
|
||||
/// out through an Arc rather than a return.)
|
||||
#[derive(Default)]
|
||||
struct Scratch {
|
||||
lat_ns: Vec<u64>,
|
||||
cpu_delta: f64,
|
||||
wall_s: f64,
|
||||
ops: u64,
|
||||
}
|
||||
|
||||
fn finish(scratch: Arc<Mutex<Scratch>>) -> Sample {
|
||||
let mut s = scratch.lock().unwrap();
|
||||
let cores_busy = if s.wall_s > 0.0 { s.cpu_delta / s.wall_s } else { 0.0 };
|
||||
Sample {
|
||||
lat_ns: std::mem::take(&mut s.lat_ns),
|
||||
cores_busy,
|
||||
ops: s.ops,
|
||||
wall_s: s.wall_s,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_wake(threads: usize, budget: u64, spinners: Option<u32>, rounds: usize, gap: Duration) -> Sample {
|
||||
let rt = init(make_config(threads, budget, spinners));
|
||||
let scratch = Arc::new(Mutex::new(Scratch::default()));
|
||||
let out = scratch.clone();
|
||||
rt.run(move || {
|
||||
let (tx, rx) = channel::channel::<Instant>();
|
||||
let lat_sink = out.clone();
|
||||
// Consumer parks on recv; its worker idles (spins or parks per budget)
|
||||
// until each send wakes it. It records the wake latency itself.
|
||||
let consumer = spawn(move || {
|
||||
let mut lats = Vec::with_capacity(rounds);
|
||||
for _ in 0..rounds {
|
||||
let sent = rx.recv().expect("remote-wake recv");
|
||||
lats.push(sent.elapsed().as_nanos() as u64);
|
||||
}
|
||||
lat_sink.lock().unwrap().lat_ns = lats;
|
||||
});
|
||||
// Producer: sleep the gap (so the consumer's worker re-idles), then
|
||||
// timestamp and send. The send is the wake event under measurement.
|
||||
let cpu0 = cpu_seconds();
|
||||
let w0 = Instant::now();
|
||||
for _ in 0..rounds {
|
||||
sleep(gap);
|
||||
tx.send(Instant::now()).expect("remote-wake send");
|
||||
}
|
||||
let wall = w0.elapsed().as_secs_f64();
|
||||
let cpu = cpu_seconds() - cpu0;
|
||||
let _ = consumer.join();
|
||||
let mut s = out.lock().unwrap();
|
||||
s.cpu_delta = cpu;
|
||||
s.wall_s = wall;
|
||||
s.ops = rounds as u64;
|
||||
});
|
||||
finish(scratch)
|
||||
}
|
||||
|
||||
fn fork_join(threads: usize, budget: u64, spinners: Option<u32>, fanout: usize, bursts: usize, gap: Duration) -> Sample {
|
||||
let rt = init(make_config(threads, budget, spinners));
|
||||
let scratch = Arc::new(Mutex::new(Scratch::default()));
|
||||
let out = scratch.clone();
|
||||
rt.run(move || {
|
||||
let mut lats = Vec::with_capacity(bursts);
|
||||
let cpu0 = cpu_seconds();
|
||||
let w0 = Instant::now();
|
||||
for _ in 0..bursts {
|
||||
// Gap lets the workers park between bursts, so the fan-out has real
|
||||
// wakes to do rather than landing on already-hot workers.
|
||||
sleep(gap);
|
||||
let t = Instant::now();
|
||||
let handles: Vec<_> = (0..fanout).map(|_| spawn(|| {})).collect();
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
lats.push(t.elapsed().as_nanos() as u64);
|
||||
}
|
||||
let wall = w0.elapsed().as_secs_f64();
|
||||
let cpu = cpu_seconds() - cpu0;
|
||||
let mut s = out.lock().unwrap();
|
||||
s.lat_ns = lats;
|
||||
s.cpu_delta = cpu;
|
||||
s.wall_s = wall;
|
||||
s.ops = (bursts * fanout) as u64;
|
||||
});
|
||||
finish(scratch)
|
||||
}
|
||||
|
||||
fn half_load(threads: usize, budget: u64, spinners: Option<u32>, items: usize, period: Duration) -> Sample {
|
||||
let rt = init(make_config(threads, budget, spinners));
|
||||
let scratch = Arc::new(Mutex::new(Scratch::default()));
|
||||
let out = scratch.clone();
|
||||
rt.run(move || {
|
||||
let cpu0 = cpu_seconds();
|
||||
let w0 = Instant::now();
|
||||
// Dispatch one trivial actor every `period`. Between dispatches the
|
||||
// workers have nothing to do, so they idle for a fraction of each
|
||||
// period — that idle fraction is what spinning fills (or doesn't).
|
||||
let mut handles = Vec::with_capacity(items);
|
||||
for i in 0..items {
|
||||
let target = period * i as u32;
|
||||
let elapsed = w0.elapsed();
|
||||
if target > elapsed {
|
||||
sleep(target - elapsed);
|
||||
}
|
||||
handles.push(spawn(|| {}));
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
let wall = w0.elapsed().as_secs_f64();
|
||||
let cpu = cpu_seconds() - cpu0;
|
||||
let mut s = out.lock().unwrap();
|
||||
s.cpu_delta = cpu;
|
||||
s.wall_s = wall;
|
||||
s.ops = items as u64;
|
||||
});
|
||||
finish(scratch)
|
||||
}
|
||||
|
||||
fn pure_idle(threads: usize, budget: u64, spinners: Option<u32>, window: Duration) -> Sample {
|
||||
let rt = init(make_config(threads, budget, spinners));
|
||||
let scratch = Arc::new(Mutex::new(Scratch::default()));
|
||||
let out = scratch.clone();
|
||||
rt.run(move || {
|
||||
// One actor sleeps; every other worker thread has nothing to do and
|
||||
// spins-or-parks per budget for the whole window.
|
||||
let cpu0 = cpu_seconds();
|
||||
let w0 = Instant::now();
|
||||
sleep(window);
|
||||
let wall = w0.elapsed().as_secs_f64();
|
||||
let cpu = cpu_seconds() - cpu0;
|
||||
let mut s = out.lock().unwrap();
|
||||
s.cpu_delta = cpu;
|
||||
s.wall_s = wall;
|
||||
s.ops = 0;
|
||||
});
|
||||
finish(scratch)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// stats
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/// 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)]
|
||||
}
|
||||
|
||||
fn median_f64(xs: &mut [f64]) -> f64 {
|
||||
if xs.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
struct Aggregate {
|
||||
n: usize,
|
||||
p50_us: f64,
|
||||
p90_us: f64,
|
||||
p99_us: f64,
|
||||
min_us: f64,
|
||||
max_us: f64,
|
||||
cores_busy: f64,
|
||||
ops_s: f64,
|
||||
}
|
||||
|
||||
/// Pool latencies across all runs (stabler tail); take the median cores-busy
|
||||
/// and median ops/s across runs.
|
||||
fn aggregate(samples: Vec<Sample>) -> Aggregate {
|
||||
let mut pooled: Vec<u64> = Vec::new();
|
||||
let mut cores: Vec<f64> = Vec::new();
|
||||
let mut ops_s: Vec<f64> = Vec::new();
|
||||
for s in &samples {
|
||||
pooled.extend_from_slice(&s.lat_ns);
|
||||
cores.push(s.cores_busy);
|
||||
if s.wall_s > 0.0 && s.ops > 0 {
|
||||
ops_s.push(s.ops as f64 / s.wall_s);
|
||||
}
|
||||
}
|
||||
pooled.sort_unstable();
|
||||
let ns_to_us = |ns: u64| ns as f64 / 1000.0;
|
||||
Aggregate {
|
||||
n: pooled.len(),
|
||||
p50_us: ns_to_us(pct(&pooled, 50.0)),
|
||||
p90_us: ns_to_us(pct(&pooled, 90.0)),
|
||||
p99_us: ns_to_us(pct(&pooled, 99.0)),
|
||||
min_us: pooled.first().map(|&v| ns_to_us(v)).unwrap_or(0.0),
|
||||
max_us: pooled.last().map(|&v| ns_to_us(v)).unwrap_or(0.0),
|
||||
cores_busy: median_f64(&mut cores),
|
||||
ops_s: median_f64(&mut ops_s),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// driver
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let threads_sweep = env_threads();
|
||||
let budgets = env_budgets();
|
||||
let spinners_sweep = env_spinners();
|
||||
let runs = env_usize("SMARM_BENCH_RUNS", 3);
|
||||
let gap = Duration::from_micros(env_u64("SMARM_SPIN_GAP_US", 150));
|
||||
let rounds = env_usize("SMARM_SPIN_ROUNDS", 100);
|
||||
let fanout = env_usize("SMARM_SPIN_FANOUT", 64);
|
||||
let bursts = env_usize("SMARM_SPIN_BURSTS", 30);
|
||||
let items = env_usize("SMARM_SPIN_ITEMS", 150);
|
||||
let period = Duration::from_micros(env_u64("SMARM_SPIN_PERIOD_US", 300));
|
||||
let window = Duration::from_millis(env_u64("SMARM_SPIN_WINDOW_MS", 25));
|
||||
|
||||
// (name, closure producing one Sample for a (threads,budget,spinners) point)
|
||||
type Workload = (&'static str, Box<dyn Fn(usize, u64, Option<u32>) -> Sample>);
|
||||
let workloads: Vec<Workload> = vec![
|
||||
("remote-wake", Box::new(move |t, b, s| remote_wake(t, b, s, rounds, gap))),
|
||||
("fork-join", Box::new(move |t, b, s| fork_join(t, b, s, fanout, bursts, gap))),
|
||||
("half-load", Box::new(move |t, b, s| half_load(t, b, s, items, period))),
|
||||
("pure-idle", Box::new(move |t, b, s| pure_idle(t, b, s, window))),
|
||||
];
|
||||
|
||||
let width = 122;
|
||||
println!("\n{}", "=".repeat(width));
|
||||
println!(
|
||||
" spin sweep — variant={}, runs={runs} (latency pooled, cores median), gap={}µs",
|
||||
variant(),
|
||||
gap.as_micros()
|
||||
);
|
||||
println!("{}", "=".repeat(width));
|
||||
println!(
|
||||
"{:>11} | {:>3} | {:>9} | {:>8} | {:>6} | {:>9} | {:>9} | {:>9} | {:>9} | {:>9} | {:>7} | {:>10}",
|
||||
"workload", "thr", "budget", "spinners", "n", "p50 µs", "p90 µs", "p99 µs", "min µs", "max µs", "cores", "ops/s"
|
||||
);
|
||||
println!("{}", "-".repeat(width));
|
||||
|
||||
for (name, run_one) in &workloads {
|
||||
for &threads in &threads_sweep {
|
||||
for &budget in &budgets {
|
||||
for &spinners in &spinners_sweep {
|
||||
let samples: Vec<Sample> =
|
||||
(0..runs).map(|_| run_one(threads, budget, spinners)).collect();
|
||||
let a = aggregate(samples);
|
||||
let sp_str = match spinners {
|
||||
Some(n) => n.to_string(),
|
||||
None => "default".to_string(),
|
||||
};
|
||||
// Latency columns are blank for the no-latency probe.
|
||||
let has_lat = a.n > 0;
|
||||
let lat = |v: f64| if has_lat { format!("{v:.2}") } else { "-".to_string() };
|
||||
println!(
|
||||
"{:>11} | {:>3} | {:>9} | {:>8} | {:>6} | {:>9} | {:>9} | {:>9} | {:>9} | {:>9} | {:>7.3} | {:>10}",
|
||||
name,
|
||||
threads,
|
||||
budget,
|
||||
sp_str,
|
||||
a.n,
|
||||
lat(a.p50_us),
|
||||
lat(a.p90_us),
|
||||
lat(a.p99_us),
|
||||
lat(a.min_us),
|
||||
lat(a.max_us),
|
||||
a.cores_busy,
|
||||
if a.ops_s > 0.0 { format!("{:.0}", a.ops_s) } else { "-".to_string() },
|
||||
);
|
||||
println!(
|
||||
"SPINCSV,{},{},{},{},{},{},{},{:.3},{:.3},{:.3},{:.3},{:.3},{:.4},{:.0}",
|
||||
variant(), name, threads, budget, sp_str, gap.as_micros(),
|
||||
a.n, a.p50_us, a.p90_us, a.p99_us, a.min_us, a.max_us, a.cores_busy, a.ops_s
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,6 @@ 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).
|
||||
@@ -29,7 +20,7 @@ counters unavailable), so attribution is from `perf record -e task-clock`
|
||||
|------:|--------|--------|
|
||||
| 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) |
|
||||
| ~12% | `do_syscall_64`+`syscall`+`futex_*` | **futex_wake on the hot path** |
|
||||
| 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** |
|
||||
@@ -43,13 +34,14 @@ 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.
|
||||
2. **A `futex_wake` syscall (~12%)** firing on the hot path even though nothing
|
||||
is parked. This is the **submit-rule wake** the handoff itself flagged (RFC
|
||||
004 finding #1: "a parallelism/latency optimisation, NOT a liveness guard").
|
||||
In a single-scheduler always-runnable loop it is pure cost — no one is ever
|
||||
parked to wake. **First cheap-win candidate: suppress the submit wake when
|
||||
there is no parked waiter** (a parked-count / n_spinning check before the
|
||||
syscall). Needs care — finding #2's AllDone broadcast is the liveness-critical
|
||||
one and must NOT be touched.
|
||||
|
||||
## What this does and does NOT show
|
||||
|
||||
@@ -59,9 +51,7 @@ self-time.** The N=1 cost is dominated by:
|
||||
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=1 floor:** scheduler logic + a likely-removable futex_wake. Actionable now.
|
||||
- **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.
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
//! 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`
|
||||
// ===========================================================================
|
||||
@@ -1,171 +0,0 @@
|
||||
//! 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`
|
||||
// ===========================================================================
|
||||
@@ -1,152 +0,0 @@
|
||||
//! 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");
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -137,14 +137,6 @@ impl<T> Drop for Receiver<T> {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -170,7 +162,6 @@ impl<T> Receiver<T> {
|
||||
{
|
||||
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 {
|
||||
@@ -230,7 +221,6 @@ impl<T> Receiver<T> {
|
||||
{
|
||||
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 {
|
||||
@@ -257,7 +247,6 @@ impl<T> Receiver<T> {
|
||||
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 {
|
||||
@@ -285,7 +274,6 @@ impl<T> Receiver<T> {
|
||||
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 {
|
||||
@@ -317,7 +305,6 @@ impl<T> Receiver<T> {
|
||||
{
|
||||
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 {
|
||||
@@ -331,7 +318,6 @@ impl<T> Receiver<T> {
|
||||
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
|
||||
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 {
|
||||
|
||||
+47
-596
@@ -43,41 +43,16 @@
|
||||
//! 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 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, select, select_timeout, Receiver, RecvTimeoutError, Selectable, Sender};
|
||||
use crate::monitor::{demonitor, monitor, Down, Monitor};
|
||||
use crate::channel::{channel, select, Receiver, Selectable, Sender};
|
||||
use crate::monitor::{Down, Monitor};
|
||||
use crate::pid::Pid;
|
||||
use crate::registry::{register_with, resolve_named_sender, RegisterError};
|
||||
use crate::scheduler::{cancel_timer, request_stop, send_after_to, spawn, spawn_under};
|
||||
use crate::timer::TimerId;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use crate::scheduler::{spawn, spawn_under};
|
||||
|
||||
/// Behaviour for a gen_server: a state value plus call/cast handlers.
|
||||
///
|
||||
@@ -96,25 +71,12 @@ pub trait GenServer: Send + 'static {
|
||||
/// ([`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. 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,
|
||||
{
|
||||
}
|
||||
fn init(&mut self, _ctx: &ServerCtx) {}
|
||||
|
||||
/// Handle a synchronous call and produce the reply sent back to the caller.
|
||||
fn handle_call(&mut self, request: Self::Call) -> Self::Reply;
|
||||
@@ -130,21 +92,6 @@ pub trait GenServer: Send + 'static {
|
||||
/// [`Watcher::watch`]. Default: drop it.
|
||||
fn handle_down(&mut self, _down: Down) {}
|
||||
|
||||
/// Handle a fired timer armed through the loop's [`TimerHandle`]
|
||||
/// ([`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) {}
|
||||
}
|
||||
@@ -217,13 +164,6 @@ impl<G: GenServer> ServerRef<G> {
|
||||
/// 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
|
||||
@@ -256,245 +196,24 @@ 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`]. Currently
|
||||
/// carries only the [`Watcher`]; opaque so fields can grow without breaking.
|
||||
pub struct ServerCtx {
|
||||
watcher: Watcher,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
impl ServerCtx {
|
||||
/// 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() }
|
||||
pub fn watcher(&self) -> Watcher {
|
||||
self.watcher.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
|
||||
self.watcher.watch(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,24 +221,17 @@ impl<G: GenServer> TimerHandle<G> {
|
||||
/// [`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>>,
|
||||
#[derive(Clone)]
|
||||
pub struct Watcher {
|
||||
tx: Sender<Monitor>,
|
||||
}
|
||||
|
||||
// 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> {
|
||||
impl Watcher {
|
||||
/// 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));
|
||||
let _ = self.tx.send(m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,22 +274,6 @@ impl<G: GenServer> ServerBuilder<G> {
|
||||
/// 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 {
|
||||
@@ -588,133 +284,6 @@ impl<G: GenServer> ServerBuilder<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable name typed by the *server* (RFC 014): a gen_server is multi-message
|
||||
/// (call / cast over one inbox), so it is addressed by `G` rather than by a
|
||||
/// single message type. By-name [`call`] / [`cast`] check the request against
|
||||
/// `G`'s `Call` / `Cast` / `Reply`. Declared as a constant and shared freely:
|
||||
///
|
||||
/// ```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`]. Shorthand for `ServerBuilder::new(state).start()`.
|
||||
pub fn start<G: GenServer>(state: G) -> ServerRef<G> {
|
||||
@@ -733,30 +302,10 @@ fn server_loop<G: GenServer>(
|
||||
mut infos: Vec<Receiver<G::Info>>,
|
||||
) {
|
||||
// terminate() must run on every exit path (clean close, panic, stop), so it
|
||||
// lives in this guard's Drop rather than after the loop. The guard also owns
|
||||
// a registry handle so the *same* drop drains every live timer (RFC 015
|
||||
// §4.7): once the loop is gone no periodic can re-arm anyway, but cancelling
|
||||
// here keeps no armed substrate entry lingering past the server, and the
|
||||
// assert pins the invariant.
|
||||
struct Terminate<G: GenServer>(G, Arc<Mutex<TimerReg<G>>>);
|
||||
// lives in this guard's Drop rather than after the loop.
|
||||
struct Terminate<G: GenServer>(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();
|
||||
}
|
||||
}
|
||||
@@ -773,100 +322,47 @@ fn server_loop<G: GenServer>(
|
||||
}
|
||||
}
|
||||
|
||||
// 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());
|
||||
let mut guard = Terminate(state);
|
||||
|
||||
// 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);
|
||||
// The control arm: Watchers feed Monitors to the loop through it. The
|
||||
// ctx (and with it the loop's own sender) drops right after init — a
|
||||
// state that didn't clone the Watcher closes the arm, the first select
|
||||
// observes the closure, and the loop falls back to the plain-inbox park.
|
||||
let (watch_tx, watch_rx) = channel::<Monitor>();
|
||||
guard.0.init(&ServerCtx { watcher: Watcher { tx: watch_tx } });
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
let mut watch_open = true;
|
||||
|
||||
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,
|
||||
},
|
||||
if monitors.is_empty() && !watch_open && infos.is_empty() {
|
||||
// Nothing to select over: park on the inbox alone, exactly the
|
||||
// pre-v0.8 loop.
|
||||
match rx.recv() {
|
||||
Ok(env) => dispatch(&mut guard.0, env),
|
||||
// All ServerRefs dropped → inbox closed → graceful shutdown.
|
||||
Err(_) => break,
|
||||
}
|
||||
} else {
|
||||
// Arm priority: downs, then the system arm, then infos (each in
|
||||
// Arm priority: downs, then the control 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 nw = watch_open as usize;
|
||||
let i = {
|
||||
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);
|
||||
if watch_open {
|
||||
arms.push(&watch_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;
|
||||
}
|
||||
select(&arms)
|
||||
};
|
||||
if i < nd {
|
||||
// A Down retires its arm either way: delivered (one-shot) or
|
||||
@@ -874,62 +370,20 @@ fn server_loop<G: GenServer>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
match watch_rx.try_recv() {
|
||||
Ok(Some(m)) => monitors.push(m),
|
||||
// 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,
|
||||
Ok(None) => debug_assert!(false, "ready control arm was empty"),
|
||||
// Every Watcher gone: stop selecting on the arm.
|
||||
Err(_) => watch_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(Some(info)) => guard.0.handle_info(info),
|
||||
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.
|
||||
@@ -939,10 +393,7 @@ fn server_loop<G: GenServer>(
|
||||
}
|
||||
} else {
|
||||
match rx.try_recv() {
|
||||
Ok(Some(env)) => {
|
||||
dispatch(&mut guard.0, env);
|
||||
reset_idle(&mut idle_deadline);
|
||||
}
|
||||
Ok(Some(env)) => dispatch(&mut guard.0, env),
|
||||
Ok(None) => debug_assert!(false, "ready inbox was empty"),
|
||||
Err(_) => break,
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
//! 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 })
|
||||
}
|
||||
+6
-29
@@ -24,13 +24,8 @@ 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;
|
||||
@@ -54,37 +49,19 @@ 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 gen_server::{CallError, CallTimeoutError, CastError, GenServer, ServerBuilder, ServerCtx, ServerRef, Watcher};
|
||||
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::{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 pid::Pid;
|
||||
pub use registry::{name_of, register, unregister, whereis, RegisterError};
|
||||
pub use runtime::{init, Config, Runtime};
|
||||
pub use scheduler::{
|
||||
block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named, sleep,
|
||||
spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable,
|
||||
wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle,
|
||||
block_on_io, request_stop, run, self_pid, sleep, spawn, 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!()
|
||||
|
||||
+2
-4
@@ -95,8 +95,7 @@ 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<A>(target: Pid<A>) {
|
||||
let target = target.erase();
|
||||
pub fn link(target: Pid) {
|
||||
let me = self_pid();
|
||||
if target == me {
|
||||
return;
|
||||
@@ -164,8 +163,7 @@ pub fn link<A>(target: Pid<A>) {
|
||||
///
|
||||
/// After this, neither actor's death propagates to the other. A no-op if the
|
||||
/// two were not linked.
|
||||
pub fn unlink<A>(target: Pid<A>) {
|
||||
let target = target.erase();
|
||||
pub fn unlink(target: Pid) {
|
||||
let me = self_pid();
|
||||
if target == me {
|
||||
return;
|
||||
|
||||
+1
-2
@@ -106,8 +106,7 @@ 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<A>(target: Pid<A>) -> Monitor {
|
||||
let target = target.erase();
|
||||
pub fn monitor(target: Pid) -> Monitor {
|
||||
let (tx, rx) = channel::<Down>();
|
||||
|
||||
// Register under the target's cold lock. `tx.clone()` takes the channel's
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
//! 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)]);
|
||||
}
|
||||
}
|
||||
+13
-260
@@ -1,285 +1,38 @@
|
||||
//! Process identifiers.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
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 RawPid {
|
||||
pub struct Pid {
|
||||
index: u32,
|
||||
generation: u32,
|
||||
}
|
||||
|
||||
impl RawPid {
|
||||
impl Pid {
|
||||
#[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 RawPid {
|
||||
impl std::fmt::Debug for Pid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Pid({}.{})", self.index, self.generation)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RawPid {
|
||||
impl std::fmt::Display for Pid {
|
||||
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>>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,16 +58,6 @@ 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,46 +77,6 @@ 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
|
||||
@@ -166,16 +116,6 @@ pub fn reset_timeslice() {
|
||||
TIMESLICE_START.with(|c| c.set(rdtsc()));
|
||||
}
|
||||
|
||||
/// Cycles elapsed since the current slice started (RFC 016 Chunk 2,
|
||||
/// `budget-accounting`). Read by the scheduler right after an actor yields back,
|
||||
/// on the same thread that armed `TIMESLICE_START`. Approximate for wake-slot
|
||||
/// resumes, which inherit the slice (see `Slot::add_budget`).
|
||||
#[cfg(feature = "budget-accounting")]
|
||||
#[inline]
|
||||
pub(crate) fn elapsed_slice_cycles() -> u64 {
|
||||
rdtsc().saturating_sub(TIMESLICE_START.with(|c| c.get()))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn rdtsc() -> u64 {
|
||||
unsafe {
|
||||
@@ -249,10 +189,6 @@ pub fn maybe_preempt() {
|
||||
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.
|
||||
|
||||
+2
-2
@@ -242,7 +242,7 @@ impl<T> Drop for RawMutexGuard<'_, T> {
|
||||
// futex (x86-64 Linux; master is x86-only, see arm-port branch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn futex_wait(state: &AtomicU32, expected: u32) {
|
||||
pub(crate) 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.
|
||||
@@ -257,7 +257,7 @@ fn futex_wait(state: &AtomicU32, expected: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
fn futex_wake(state: &AtomicU32, n: i32) {
|
||||
pub(crate) fn futex_wake(state: &AtomicU32, n: i32) {
|
||||
// SAFETY: as above.
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
|
||||
+93
-493
@@ -1,60 +1,32 @@
|
||||
//! Named mailbox registry — resolve a name (or pid) to a *messageable* actor.
|
||||
//! Named pid registry.
|
||||
//!
|
||||
//! ## What changed (RFC 014)
|
||||
//! `register(name, pid)` binds a name to a live actor; `whereis(name)` looks
|
||||
//! it up; `name_of(pid)` is the inverse. Erlang semantics: at most one name
|
||||
//! per pid, at most one pid per name, registering over a live binding is an
|
||||
//! error. The registry is a *bimap* (two `HashMap`s kept in exact inverse
|
||||
//! under one lock) so both lookup directions are O(1) — `name_of` exists so
|
||||
//! supervisors, tracing, and diagnostics can label a pid without scanning.
|
||||
//!
|
||||
//! 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.
|
||||
//! ## Cleanup is lazy
|
||||
//!
|
||||
//! 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;
|
||||
//! There is no hook in `finalize_actor` and no name field in `Slot` (which
|
||||
//! would buy into the reset-in-three-places slot invariant). Instead every
|
||||
//! operation that touches a binding checks the bound pid's liveness via the
|
||||
//! generation-checked slot word — a `Pid` is `(index, generation)` and a
|
||||
//! generation is never reused, so a stale binding is *detectable*, never
|
||||
//! misdirected. Bindings to dead actors behave as absent and are pruned on
|
||||
//! contact: `whereis`/`name_of` return `None`, `register` treats the name as
|
||||
//! free. The cost is that a dead binding lingers until something looks at it;
|
||||
//! the payoff is zero coupling to the actor lifecycle.
|
||||
//!
|
||||
//! ## Locking
|
||||
//!
|
||||
//! One `RawMutex` (Leaf class) in `RuntimeInner`, 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`.
|
||||
//! One `RawMutex` (Leaf class) in `RuntimeInner`. Liveness checks under it
|
||||
//! read only the atomic slot state word — no second lock is ever taken, so
|
||||
//! the leaf rule holds trivially.
|
||||
|
||||
use crate::channel::Sender;
|
||||
use crate::pid::{Addressable, Name, Pid};
|
||||
use crate::scheduler::{self_pid, with_runtime};
|
||||
use std::any::{type_name, Any, TypeId};
|
||||
use crate::pid::Pid;
|
||||
use crate::scheduler::with_runtime;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Why a [`register`] call was rejected.
|
||||
@@ -62,8 +34,9 @@ use std::collections::HashMap;
|
||||
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).
|
||||
/// The pid already has a (live) registered name; one name per pid.
|
||||
PidAlreadyRegistered { name: String },
|
||||
/// The pid does not refer to a live actor.
|
||||
NoProc,
|
||||
}
|
||||
|
||||
@@ -73,211 +46,38 @@ impl std::fmt::Display for RegisterError {
|
||||
RegisterError::NameTaken { holder } => {
|
||||
write!(f, "name is already registered to live actor {holder}")
|
||||
}
|
||||
RegisterError::NoProc => write!(f, "caller is not a live actor"),
|
||||
RegisterError::PidAlreadyRegistered { name } => {
|
||||
write!(f, "pid is already registered as {name:?}")
|
||||
}
|
||||
RegisterError::NoProc => write!(f, "pid does not refer to a live actor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RegisterError {}
|
||||
|
||||
/// 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.
|
||||
/// The bimap. Invariant (held under the registry lock): `by_name` and
|
||||
/// `by_pid` are exact inverses of each other at all times.
|
||||
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>,
|
||||
by_name: HashMap<String, Pid>,
|
||||
by_pid: HashMap<Pid, String>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { by_index: HashMap::new(), by_name: HashMap::new() }
|
||||
Self { by_name: HashMap::new(), by_pid: 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);
|
||||
fn remove_binding(&mut self, name: &str, pid: Pid) {
|
||||
self.by_name.remove(name);
|
||||
self.by_pid.remove(&pid);
|
||||
debug_assert_eq!(self.by_name.len(), self.by_pid.len(), "bimap out of sync");
|
||||
}
|
||||
|
||||
/// 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 })
|
||||
fn insert_binding(&mut self, name: String, pid: Pid) {
|
||||
self.by_pid.insert(pid, name.clone());
|
||||
self.by_name.insert(name, pid);
|
||||
debug_assert_eq!(self.by_name.len(), self.by_pid.len(), "bimap out of sync");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,284 +86,84 @@ 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.
|
||||
/// Bind `name` to the live actor `pid`.
|
||||
///
|
||||
/// Fails with [`RegisterError::NameTaken`] if the name is held by a *different*
|
||||
/// live actor (a binding to a dead actor is pruned and the name treated as
|
||||
/// free). Panics if called outside `Runtime::run()`.
|
||||
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> {
|
||||
/// Fails with [`RegisterError::NoProc`] if `pid` is not live,
|
||||
/// [`RegisterError::NameTaken`] if the name is bound to a *live* actor
|
||||
/// (a binding to a dead actor is evicted and the name treated as free), and
|
||||
/// [`RegisterError::PidAlreadyRegistered`] if `pid` already has a name.
|
||||
///
|
||||
/// Panics if called outside `Runtime::run()`.
|
||||
pub fn register(name: impl Into<String>, pid: Pid) -> Result<(), RegisterError> {
|
||||
let name = name.into();
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
if !live(inner, me) {
|
||||
if !live(inner, pid) {
|
||||
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
|
||||
}
|
||||
let prior = reg.by_name.get(&name).copied();
|
||||
if let Some(holder) = prior {
|
||||
if holder == pid {
|
||||
return Ok(()); // already exactly this binding: idempotent
|
||||
}
|
||||
if live(inner, holder) {
|
||||
return Err(RegisterError::NameTaken { holder });
|
||||
}
|
||||
// Stale binding: the holder died. Evict and treat the name as free.
|
||||
reg.remove_binding(&name, holder);
|
||||
}
|
||||
// Publish (or extend) the mailbox with this channel, then bind the name.
|
||||
publish_channel::<M>(&mut reg, me, tx);
|
||||
reg.by_name.insert(key, me.index());
|
||||
if let Some(existing) = reg.by_pid.get(&pid) {
|
||||
// `pid` is live (checked above) and pids are never reused, so this
|
||||
// binding is necessarily current — one name per pid.
|
||||
return Err(RegisterError::PidAlreadyRegistered { name: existing.clone() });
|
||||
}
|
||||
reg.insert_binding(name, pid);
|
||||
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).
|
||||
/// Look up the pid bound to `name`. `None` if unbound, or if the bound actor
|
||||
/// is 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
|
||||
}
|
||||
let pid = *reg.by_name.get(name)?;
|
||||
if live(inner, pid) {
|
||||
Some(pid)
|
||||
} else {
|
||||
reg.remove_binding(name, pid);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `name` to a *typed* [`Pid<A>`] — the identity-bound counterpart of
|
||||
/// [`whereis`] (RFC 014 §4.4). Recovers the compile-checked
|
||||
/// [`send_to`](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>)> {
|
||||
/// The inverse lookup: the name `pid` is registered under, if any. `None` if
|
||||
/// unregistered or no longer live (pruning the stale binding).
|
||||
pub fn name_of(pid: Pid) -> Option<String> {
|
||||
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))
|
||||
let name = reg.by_pid.get(&pid)?.clone();
|
||||
if live(inner, pid) {
|
||||
Some(name)
|
||||
} else {
|
||||
reg.remove_binding(&name, pid);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Remove the binding for `name`, returning the pid it was bound to if that
|
||||
/// actor is still live. A binding to a dead actor is pruned and reported as
|
||||
/// `None`, consistent with [`whereis`].
|
||||
pub fn unregister(name: &str) -> Option<Pid> {
|
||||
with_runtime(|inner| {
|
||||
let mut reg = inner.registry.lock();
|
||||
let idx = reg.by_name.remove(name)?;
|
||||
match reg.by_index.get(&idx).map(|m| m.pid) {
|
||||
Some(pid) if live(inner, pid) => Some(pid),
|
||||
_ => None,
|
||||
let pid = *reg.by_name.get(name)?;
|
||||
reg.remove_binding(name, pid);
|
||||
if live(inner, pid) {
|
||||
Some(pid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `name` to its actor's `Sender<M>` and deliver `msg`. The whole point
|
||||
/// of the rework: a name you can *send* to.
|
||||
///
|
||||
/// 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))
|
||||
}
|
||||
|
||||
+248
-231
@@ -118,8 +118,9 @@ use crate::timer::Timers;
|
||||
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
|
||||
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering,
|
||||
fence, AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering,
|
||||
};
|
||||
use crate::raw_mutex::{futex_wait, futex_wake};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
@@ -130,6 +131,13 @@ use std::thread;
|
||||
/// Default capacity of the actor slot table. Slots are ~256 bytes, so the
|
||||
/// default costs ~4 MiB, allocated once at `init`. See [`Config::max_actors`].
|
||||
pub const DEFAULT_MAX_ACTORS: usize = 16_384;
|
||||
/// RFC 004: default scheduler spin budget before parking, in TSC cycles.
|
||||
/// ≈0.5µs at a 3.7 GHz nominal TSC. Sized from the spin_sweep fork-join knee:
|
||||
/// the fork→join handoff is sub-µs, so ~0.5µs of spin keeps a worker hot across
|
||||
/// it for a ~4× p50 latency win, and budgets past ~5k cycles buy nothing but
|
||||
/// idle CPU. Only effective where the default spinner cap permits spinning
|
||||
/// (small pools, n≤4); see the cap resolution in `init`.
|
||||
pub const DEFAULT_SPIN_BUDGET_CYCLES: u64 = 2_000;
|
||||
|
||||
/// Runtime configuration.
|
||||
///
|
||||
@@ -155,8 +163,11 @@ pub struct Config {
|
||||
stack_pool_cap: usize,
|
||||
max_actors: usize,
|
||||
wake_slot: bool,
|
||||
node_id: crate::pg::NodeId,
|
||||
incarnation: crate::pg::Incarnation,
|
||||
/// RFC 004: TSC cycles a scheduler spins before parking. `0` (default)
|
||||
/// disables spinning entirely — the historical park-immediately behaviour.
|
||||
spin_budget_cycles: u64,
|
||||
/// RFC 004: cap on concurrent spinners. `None` resolves to N/2 at init.
|
||||
max_spinners: Option<u32>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -170,8 +181,8 @@ impl Config {
|
||||
stack_pool_cap: n * 4,
|
||||
max_actors: DEFAULT_MAX_ACTORS,
|
||||
wake_slot: false,
|
||||
node_id: crate::pg::DEFAULT_NODE_ID,
|
||||
incarnation: crate::pg::DEFAULT_INCARNATION,
|
||||
spin_budget_cycles: DEFAULT_SPIN_BUDGET_CYCLES,
|
||||
max_spinners: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +200,8 @@ impl Config {
|
||||
stack_pool_cap: max * 4,
|
||||
max_actors: DEFAULT_MAX_ACTORS,
|
||||
wake_slot: false,
|
||||
node_id: crate::pg::DEFAULT_NODE_ID,
|
||||
incarnation: crate::pg::DEFAULT_INCARNATION,
|
||||
spin_budget_cycles: DEFAULT_SPIN_BUDGET_CYCLES,
|
||||
max_spinners: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,20 +258,22 @@ impl Config {
|
||||
self
|
||||
}
|
||||
|
||||
/// This runtime's node identity (RFC 012). Defaults to a fixed single-node
|
||||
/// value; clustering (RFC 010) will supply a real one. Threaded through pg
|
||||
/// storage/eviction so the process-group public API never changes to
|
||||
/// acquire it.
|
||||
pub fn node_id(mut self, id: impl Into<crate::pg::NodeId>) -> Self {
|
||||
self.node_id = id.into();
|
||||
/// RFC 004: TSC cycles an idle scheduler spins searching for work before
|
||||
/// parking on the futex. The idle-CPU-vs-wake-latency dial, in the same
|
||||
/// unit as [`timeslice_cycles`](Self::timeslice_cycles) (uses `rdtsc()`).
|
||||
/// `0` (the default) disables spinning and the futex park entirely,
|
||||
/// recovering the historical `thread::sleep` idle behaviour exactly.
|
||||
pub fn spin_budget_cycles(mut self, n: u64) -> Self {
|
||||
self.spin_budget_cycles = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// This runtime's incarnation epoch (RFC 012) — the BEAM `Creation` analogue
|
||||
/// that separates a crashed node from its restart. Defaults to a fixed
|
||||
/// single-node value; constant for the life of a run.
|
||||
pub fn incarnation(mut self, inc: impl Into<crate::pg::Incarnation>) -> Self {
|
||||
self.incarnation = inc.into();
|
||||
/// RFC 004: cap on the number of schedulers that may spin concurrently.
|
||||
/// Default (`None`) resolves to N/2 at init. Capping at half keeps half
|
||||
/// the pool hot and parks the rest — the half-load case is then the easy
|
||||
/// case. Ignored when `spin_budget_cycles == 0`.
|
||||
pub fn max_spinners(mut self, n: u32) -> Self {
|
||||
self.max_spinners = Some(n);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -288,8 +301,8 @@ impl Default for Config {
|
||||
stack_pool_cap: avail * 4,
|
||||
max_actors: DEFAULT_MAX_ACTORS,
|
||||
wake_slot: false,
|
||||
node_id: crate::pg::DEFAULT_NODE_ID,
|
||||
incarnation: crate::pg::DEFAULT_INCARNATION,
|
||||
spin_budget_cycles: DEFAULT_SPIN_BUDGET_CYCLES,
|
||||
max_spinners: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,25 +439,6 @@ pub(crate) struct Slot {
|
||||
/// First-resume closure, double-boxed so it fits an `AtomicPtr`
|
||||
/// (`Box<Closure>` is a thin pointer). Swap-to-take; null when absent.
|
||||
closure: AtomicPtr<Closure>,
|
||||
/// RFC 016 Chunk 2 — per-actor timeslice overrun tally. Single-writer: only
|
||||
/// the on-CPU actor's scheduler thread increments it (at the slice-expiry
|
||||
/// site in `preempt.rs`, reached via the stashed slot pointer), so the
|
||||
/// writes are plain Relaxed load+store with no atomic-RMW traffic; the
|
||||
/// snapshot reads it Relaxed from any thread. Lives in the hot region rather
|
||||
/// than `SlotCold` so the increment needs no lock; reset across reuse like
|
||||
/// every other slot field (`vacant` / `reclaim_slot` / `install_actor`).
|
||||
overruns: AtomicU64,
|
||||
/// RFC 016 Chunk 2 — messages this actor has received (dequeued). Same
|
||||
/// single-writer discipline as `overruns`: only the receiving actor, on its
|
||||
/// own thread, increments it on the receive path (D4/D5), Relaxed load+store
|
||||
/// with no RMW; snapshot reads Relaxed.
|
||||
messages_received: AtomicU64,
|
||||
/// RFC 016 Chunk 2 — cumulative on-CPU cycles this incarnation has consumed
|
||||
/// (the cycle-accurate "budget used", an analogue of OTP reductions).
|
||||
/// Written only by the scheduler thread that ran the actor, once per resume,
|
||||
/// and only when the `budget-accounting` feature is on (it costs two RDTSC
|
||||
/// per resume); stays 0 otherwise. Same single-writer Relaxed discipline.
|
||||
budget_cycles: AtomicU64,
|
||||
/// Cold lifecycle data. See [`SlotCold`].
|
||||
pub(crate) cold: RawMutex<SlotCold>,
|
||||
}
|
||||
@@ -456,9 +450,6 @@ impl Slot {
|
||||
sp: AtomicUsize::new(0),
|
||||
stop_ptr: AtomicPtr::new(std::ptr::null_mut()),
|
||||
closure: AtomicPtr::new(std::ptr::null_mut()),
|
||||
overruns: AtomicU64::new(0),
|
||||
messages_received: AtomicU64::new(0),
|
||||
budget_cycles: AtomicU64::new(0),
|
||||
cold: RawMutex::new(SlotCold {
|
||||
actor: None,
|
||||
waiters: Vec::new(),
|
||||
@@ -479,76 +470,6 @@ impl Slot {
|
||||
self.word.generation()
|
||||
}
|
||||
|
||||
/// Raw packed state word, for introspection's lock-free classify
|
||||
/// (`introspect.rs`). The coarse `status_for` only distinguishes
|
||||
/// Live/Done/Stale; the snapshot needs the fine scheduling state.
|
||||
#[inline]
|
||||
pub(crate) fn state_word(&self) -> u64 {
|
||||
self.word.load()
|
||||
}
|
||||
|
||||
/// Tally one timeslice overrun (RFC 016 Chunk 2). Single-writer: only the
|
||||
/// on-CPU actor's own thread calls this, at the slice-expiry site, so a
|
||||
/// Relaxed load+store is sufficient and avoids the cache-line lock of an
|
||||
/// atomic RMW.
|
||||
#[inline]
|
||||
pub(crate) fn record_overrun(&self) {
|
||||
let v = self.overruns.load(Ordering::Relaxed);
|
||||
self.overruns.store(v.wrapping_add(1), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the overrun tally (Relaxed; the snapshot reads cross-thread).
|
||||
#[inline]
|
||||
pub(crate) fn overruns(&self) -> u64 {
|
||||
self.overruns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Tally one received (dequeued) message. Same single-writer Relaxed
|
||||
/// discipline as `record_overrun`; called by the receiving actor on its own
|
||||
/// thread, so no RMW.
|
||||
#[inline]
|
||||
pub(crate) fn record_message(&self) {
|
||||
let v = self.messages_received.load(Ordering::Relaxed);
|
||||
self.messages_received.store(v.wrapping_add(1), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the received-message tally (Relaxed; cross-thread snapshot read).
|
||||
#[inline]
|
||||
pub(crate) fn messages_received(&self) -> u64 {
|
||||
self.messages_received.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Accumulate on-CPU cycles consumed in one resume (RFC 016 Chunk 2,
|
||||
/// `budget-accounting`). Single-writer (the scheduler thread that ran the
|
||||
/// actor), Relaxed load+store. Approximate by design: the figure is
|
||||
/// `now − slice-start`, and a wake-slot resume inherits the waker's slice,
|
||||
/// so a handed-off actor is charged a little of the chain's time — noise
|
||||
/// that averages out across runs, traded for one RDTSC instead of two.
|
||||
#[cfg(feature = "budget-accounting")]
|
||||
#[inline]
|
||||
pub(crate) fn add_budget(&self, cycles: u64) {
|
||||
let v = self.budget_cycles.load(Ordering::Relaxed);
|
||||
self.budget_cycles.store(v.wrapping_add(cycles), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the accumulated budget cycles (Relaxed). Always 0 unless the
|
||||
/// `budget-accounting` feature is enabled.
|
||||
#[inline]
|
||||
pub(crate) fn budget_cycles(&self) -> u64 {
|
||||
self.budget_cycles.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Zero the per-actor introspection counters. Called at every point a slot
|
||||
/// is recycled or freshly occupied (`reclaim_slot`, `install_actor`) so a
|
||||
/// reused slot never carries a previous incarnation's counts — the standing
|
||||
/// slot-lifecycle reset invariant (RFC 016 D7).
|
||||
#[inline]
|
||||
pub(crate) fn reset_counters(&self) {
|
||||
self.overruns.store(0, Ordering::Relaxed);
|
||||
self.messages_received.store(0, Ordering::Relaxed);
|
||||
self.budget_cycles.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A pid's-eye snapshot of the slot. Cold paths re-read this under the
|
||||
/// cold lock (generation can't change while it is held).
|
||||
#[inline]
|
||||
@@ -601,19 +522,36 @@ pub(crate) struct RuntimeInner {
|
||||
/// Incremented in `spawn` before the enqueue; decremented at the very end
|
||||
/// of `finalize_actor`, after every wakeup finalize produces.
|
||||
pub(crate) live_actors: AtomicU32,
|
||||
/// Packed `(index << 32 | generation)` of the run's root (initial) actor,
|
||||
/// or `u64::MAX` (the ROOT_PID sentinel) before one is set. When this actor
|
||||
/// finalizes it flags `root_exited`; the scheduler's idle verdict then
|
||||
/// stops the remaining (parked-forever) actors. Set once per `run()`, right
|
||||
/// after the initial spawn.
|
||||
pub(crate) root_bits: AtomicU64,
|
||||
/// Set when the root actor finalizes; read by the scheduler's idle verdict
|
||||
/// to trigger the one-shot teardown sweep. Reset per `run()`.
|
||||
pub(crate) root_exited: AtomicBool,
|
||||
/// Guards the teardown sweep to fire at most once per run (a parked-forever
|
||||
/// remainder that survives the sweep falls through to the normal idle wait
|
||||
/// rather than busy-spinning). Reset per `run()`.
|
||||
pub(crate) root_swept: AtomicBool,
|
||||
/// RFC 004: lock-free shared-run-queue depth. `+1` in `enqueue` (the sole
|
||||
/// `run_queue.push` site — covers spawn-publish, unpark, and slot
|
||||
/// displacement), `-1` on a successful pop. This is the poll source the
|
||||
/// idle spinners read; it never takes the queue mutex. Slot-parked work is
|
||||
/// deliberately NOT counted: it is thread-local and only its owning thread
|
||||
/// can resume it, so it is not work a spinning sibling could grab.
|
||||
pub(crate) queue_len: AtomicU64,
|
||||
/// RFC 004: schedulers currently *searching* for work (spinning) — the
|
||||
/// middle state between running and parked. The submit rule loads this to
|
||||
/// decide whether a wake is needed (a live spinner will catch the work
|
||||
/// with no syscall). Capped at `max_spinners`.
|
||||
pub(crate) n_spinning: AtomicU32,
|
||||
/// RFC 004: the scheduler park futex word. A monotonic sequence: a waker
|
||||
/// does `fetch_add(1) + futex_wake`, a parker captures the value and
|
||||
/// `futex_wait`s on it, so a bump landing in the arm→wait gap returns the
|
||||
/// wait immediately (EAGAIN) instead of sleeping — the lost-wakeup guard.
|
||||
/// One shared word; `futex_wake(1)` wakes exactly one parked scheduler.
|
||||
pub(crate) park_seq: AtomicU32,
|
||||
/// RFC 004: `spin_budget_cycles > 0 && max_spinners > 0`. When false the
|
||||
/// feature is fully off — the idle path is the historical `thread::sleep`
|
||||
/// park, `n_spinning` and `park_seq` are never touched, and the submit rule
|
||||
/// is a no-op. Note the cap term: a budget with a 0 cap (e.g. the N=1
|
||||
/// pool-size gate) can enlist no spinner, so it is treated as off — this is
|
||||
/// what makes `spin_budget_cycles = 0` AND the 0-cap case recover today's
|
||||
/// behaviour exactly.
|
||||
pub(crate) spinning: bool,
|
||||
/// RFC 004: TSC cycles a scheduler searches before parking (`Config`).
|
||||
pub(crate) spin_budget_cycles: u64,
|
||||
/// RFC 004: cap on concurrent spinners (`Config`, default N/2).
|
||||
pub(crate) max_spinners: u32,
|
||||
/// Timer heap. Independent lock: never nested with any other.
|
||||
pub(crate) timers: Mutex<Timers>,
|
||||
/// IO subsystem. `None` between runs. Lock order: io before everything.
|
||||
@@ -637,17 +575,6 @@ pub(crate) struct RuntimeInner {
|
||||
/// with any other lock; liveness checks under it read only the atomic
|
||||
/// slot word.
|
||||
pub(crate) registry: RawMutex<crate::registry::Registry>,
|
||||
/// Runtime identity (RFC 012). Read-only after init — one node, one fixed
|
||||
/// incarnation until clustering (RFC 010) supplies real values. Carried
|
||||
/// like `wake_slot`; pg fills these into every `Member` so the public
|
||||
/// surface stays Pid-shaped.
|
||||
pub(crate) node_id: crate::pg::NodeId,
|
||||
pub(crate) incarnation: crate::pg::Incarnation,
|
||||
/// Process groups: `name -> multiset<Member>` (RFC 012). RawMutex Leaf,
|
||||
/// exactly like `registry`: never held with any other lock; liveness
|
||||
/// checks under it read only the atomic slot word, and the eviction path
|
||||
/// keeps it off the send path.
|
||||
pub(crate) process_groups: RawMutex<crate::pg::ProcessGroups>,
|
||||
/// Recycled stacks waiting to be reused by the next spawn.
|
||||
pub(crate) stack_pool: RawMutex<Vec<crate::stack::Stack>>,
|
||||
/// Maximum number of stacks to retain in the pool.
|
||||
@@ -662,21 +589,42 @@ impl RuntimeInner {
|
||||
stack_pool_cap: usize,
|
||||
max_actors: usize,
|
||||
wake_slot: bool,
|
||||
node_id: crate::pg::NodeId,
|
||||
incarnation: crate::pg::Incarnation,
|
||||
spin_budget_cycles: u64,
|
||||
max_spinners: u32,
|
||||
) -> Arc<Self> {
|
||||
let stats = (0..thread_count).map(|_| SchedulerStats::new()).collect();
|
||||
let slots: Box<[Slot]> = (0..max_actors).map(|_| Slot::vacant()).collect();
|
||||
// Low indices on top of the stack so early spawns get low pids.
|
||||
let free: Vec<u32> = (0..max_actors as u32).rev().collect();
|
||||
// RFC 004: the spin/futex machinery is only live when BOTH a budget is
|
||||
// set AND the cap permits at least one spinner. A budget with a 0 cap
|
||||
// (e.g. the N=1 pool-size gate) can never enlist a spinner, so
|
||||
// `n_spinning` is pinned at 0 forever — which would make the enqueue
|
||||
// submit-rule guard (`n_spinning == 0`) fire a wake-nobody futex_wake on
|
||||
// every push. Gating on the cap too keeps that path inert exactly when
|
||||
// it can do no work. Equivalent to budget=0 here, with no behavioural
|
||||
// change (a 0-cap runtime never parks on the futex either way).
|
||||
let spinning = spin_budget_cycles > 0 && max_spinners > 0;
|
||||
// Guardrail: catch any future config path that re-enables spinning while
|
||||
// leaving the cap at 0 — the pathology this gate exists to prevent. If
|
||||
// we are spinning, at least one spinner must be enlistable.
|
||||
debug_assert!(
|
||||
!spinning || max_spinners > 0,
|
||||
"spinning enabled (budget={spin_budget_cycles}) with max_spinners=0: \
|
||||
no scheduler can ever enlist, so every enqueue would fire a \
|
||||
wake-nobody futex_wake"
|
||||
);
|
||||
Arc::new(Self {
|
||||
run_queue: crate::run_queue::RunQueue::new(thread_count, max_actors),
|
||||
slots,
|
||||
free: RawMutex::new(free),
|
||||
live_actors: AtomicU32::new(0),
|
||||
root_bits: AtomicU64::new(u64::MAX),
|
||||
root_exited: AtomicBool::new(false),
|
||||
root_swept: AtomicBool::new(false),
|
||||
queue_len: AtomicU64::new(0),
|
||||
n_spinning: AtomicU32::new(0),
|
||||
park_seq: AtomicU32::new(0),
|
||||
spinning,
|
||||
spin_budget_cycles,
|
||||
max_spinners,
|
||||
timers: Mutex::new(Timers::new()),
|
||||
io: Mutex::new(None),
|
||||
next_monitor_id: AtomicU64::new(0),
|
||||
@@ -688,9 +636,6 @@ impl RuntimeInner {
|
||||
timeslice_cycles,
|
||||
wake_slot,
|
||||
registry: RawMutex::new(crate::registry::Registry::new()),
|
||||
node_id,
|
||||
incarnation,
|
||||
process_groups: RawMutex::new(crate::pg::ProcessGroups::new()),
|
||||
stack_pool: RawMutex::new(Vec::new()),
|
||||
stack_pool_cap,
|
||||
})
|
||||
@@ -705,25 +650,6 @@ impl RuntimeInner {
|
||||
self.slots.get(pid.index() as usize)
|
||||
}
|
||||
|
||||
/// Record `pid` as this run's root actor. Called once per `run()`, right
|
||||
/// after the initial spawn and before any scheduler thread starts, so no
|
||||
/// finalize can observe the count before the root is set.
|
||||
#[inline]
|
||||
pub(crate) fn set_root(&self, pid: Pid) {
|
||||
self.root_bits.store(Self::pack(pid), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Is `pid` (index + generation) this run's root actor?
|
||||
#[inline]
|
||||
pub(crate) fn is_root(&self, pid: Pid) -> bool {
|
||||
self.root_bits.load(Ordering::Relaxed) == Self::pack(pid)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pack(pid: Pid) -> u64 {
|
||||
((pid.index() as u64) << 32) | pid.generation() as u64
|
||||
}
|
||||
|
||||
/// Push to the run queue. Callers must have just transitioned the pid
|
||||
/// into `Queued` (spawn's publish, the unpark protocol, or the
|
||||
/// scheduler's yield/notified-park return paths).
|
||||
@@ -740,9 +666,127 @@ impl RuntimeInner {
|
||||
"enqueue of a pid not in (gen, Queued)"
|
||||
);
|
||||
self.run_queue.push(pid);
|
||||
// RFC 004: maintain the lock-free depth the idle spinners poll. Release
|
||||
// so a spinner's Acquire load observes the pushed entry.
|
||||
self.queue_len.fetch_add(1, Ordering::Release);
|
||||
// RFC 004 submit rule: ensure a searcher will see this work. A live
|
||||
// spinner (n_spinning > 0) claims it with no syscall — the common,
|
||||
// cheap path; only when nobody is searching do we pay a wake. The
|
||||
// SeqCst fence forbids reordering the n_spinning load ahead of the
|
||||
// queue_len store above. Paired with the parker's symmetric
|
||||
// (decrement n_spinning; SeqCst fence; re-check queue_len), it
|
||||
// guarantees at least one of {we observe the spinner, the parker
|
||||
// observes this work} — the Dekker property that keeps work from
|
||||
// stranding with every scheduler asleep. Loom does not model the
|
||||
// futex path (see sync_shim.rs), so this is correct by construction.
|
||||
if self.spinning {
|
||||
fence(Ordering::SeqCst);
|
||||
if self.n_spinning.load(Ordering::Acquire) == 0 {
|
||||
self.unpark_one_scheduler();
|
||||
}
|
||||
}
|
||||
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||
}
|
||||
|
||||
/// RFC 004: wake exactly one parked scheduler. Bumping the futex sequence
|
||||
/// before the wake closes the arm→wait race: a parker that already
|
||||
/// captured the old sequence fails its `futex_wait` value-compare and
|
||||
/// returns (EAGAIN) instead of sleeping through the wake. Only called when
|
||||
/// no scheduler is searching, so it is off the common path.
|
||||
pub(crate) fn unpark_one_scheduler(&self) {
|
||||
self.park_seq.fetch_add(1, Ordering::Release);
|
||||
futex_wake(&self.park_seq, 1);
|
||||
}
|
||||
|
||||
/// RFC 004: release ALL futex-parked schedulers (termination). Required in
|
||||
/// a no-io runtime, where there is no wake pipe to fall back on: a sibling
|
||||
/// parked indefinitely on the futex would otherwise sleep past program
|
||||
/// end. Each woken thread re-runs the idle verdict, reaches AllDone, and
|
||||
/// returns — idempotent, mirroring the io wake-pipe terminal broadcast.
|
||||
pub(crate) fn unpark_all_schedulers(&self) {
|
||||
self.park_seq.fetch_add(1, Ordering::Release);
|
||||
futex_wake(&self.park_seq, i32::MAX);
|
||||
}
|
||||
|
||||
/// RFC 004: the no-timer / no-io idle policy. Spin on `queue_len` for the
|
||||
/// configured budget (if under the spinner cap), then park on the futex.
|
||||
/// Returns when the caller should loop back and re-attempt a pop. Only
|
||||
/// invoked when `self.spinning` (budget > 0); with budget 0 the caller
|
||||
/// keeps the historical `thread::sleep` park.
|
||||
fn idle_spin_then_park(&self) {
|
||||
// 1. Try to enlist as a spinner, strictly honouring the cap (CAS, so
|
||||
// `n_spinning` never exceeds `max_spinners` even transiently).
|
||||
let mut spinning = false;
|
||||
loop {
|
||||
let c = self.n_spinning.load(Ordering::Acquire);
|
||||
if c >= self.max_spinners {
|
||||
break; // cap met (or 0): skip the spin, park directly
|
||||
}
|
||||
if self
|
||||
.n_spinning
|
||||
.compare_exchange_weak(c, c + 1, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
spinning = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if spinning {
|
||||
let start = crate::preempt::rdtsc();
|
||||
loop {
|
||||
if self.queue_len.load(Ordering::Acquire) > 0 {
|
||||
// Found work. Leave the spinner set; if we were the LAST
|
||||
// spinner (1 → 0), wake one parked sibling before going
|
||||
// heads-down (rule 4) so a burst still arriving is still
|
||||
// observed by a searcher.
|
||||
let prev = self.n_spinning.fetch_sub(1, Ordering::AcqRel);
|
||||
if prev == 1 {
|
||||
fence(Ordering::SeqCst);
|
||||
if self.queue_len.load(Ordering::Acquire) > 0 {
|
||||
self.unpark_one_scheduler();
|
||||
}
|
||||
}
|
||||
return; // back to the pop
|
||||
}
|
||||
if crate::preempt::rdtsc().wrapping_sub(start) >= self.spin_budget_cycles {
|
||||
break; // budget exhausted: park
|
||||
}
|
||||
core::hint::spin_loop();
|
||||
}
|
||||
// Budget exhausted: leave the spinner set, then park (below). The
|
||||
// decrement must precede the park's queue re-check — that is the
|
||||
// parker half of the submit-rule Dekker pairing.
|
||||
self.n_spinning.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
// 2. Park on the futex. Three guards close every lost-wakeup / hang:
|
||||
// - SeqCst fence + queue_len re-check: the Dekker pairing with the
|
||||
// submit rule (which loads n_spinning AFTER publishing the work),
|
||||
// so a submit that declined to wake us because it saw us spinning
|
||||
// is observed here instead.
|
||||
// - capture park_seq BEFORE the final re-check: a submit-wake
|
||||
// landing in the gap bumps the sequence, so futex_wait's
|
||||
// value-compare returns immediately rather than sleeping.
|
||||
// - live_actors re-check: the shutdown guard. In this arm io_out is
|
||||
// 0 by construction (the io>0 case is handled by a sibling arm),
|
||||
// so live_actors == 0 IS the AllDone verdict; if termination is
|
||||
// racing our park we loop back and reach AllDone instead of
|
||||
// sleeping past program end. (unpark_all_schedulers covers the
|
||||
// already-parked case from the other side.)
|
||||
fence(Ordering::SeqCst);
|
||||
let observed = self.park_seq.load(Ordering::Acquire);
|
||||
if self.queue_len.load(Ordering::Acquire) > 0 {
|
||||
return;
|
||||
}
|
||||
if self.live_actors.load(Ordering::Acquire) == 0 {
|
||||
return;
|
||||
}
|
||||
futex_wait(&self.park_seq, observed);
|
||||
// Woken by a wake, a stale-sequence EAGAIN, or spuriously: loop back
|
||||
// and re-pop. The verdict re-runs from scratch, so any wake is benign.
|
||||
}
|
||||
|
||||
/// Make `pid` runnable if it is parked; coalesce or defer otherwise.
|
||||
/// The runtime-internal core of `scheduler::unpark`. WILDCARD wake:
|
||||
/// consumes the epoch but does not check it — reserved for terminal
|
||||
@@ -872,6 +916,22 @@ pub struct Runtime {
|
||||
/// Initialise the runtime with the given config. Returns a reusable handle.
|
||||
pub fn init(config: Config) -> Runtime {
|
||||
let n = config.resolved_thread_count();
|
||||
// RFC 004: default spinner cap is gated on pool size, from the spin_sweep
|
||||
// fork-join data. Spinning is a ~4× p50 latency win at n≤4 workers but pure
|
||||
// cost at n≥8 (large pools stay hot on their own, so a spin budget only
|
||||
// steals CPU from workers doing real drain). So:
|
||||
// n == 1 → 0 : a lone thread has no cross-thread handoff to hide.
|
||||
// 2..=4 → n : the win regime; let every worker spin its budget.
|
||||
// n >= 5 → 0 : park immediately, exactly the historical behaviour —
|
||||
// the non-zero DEFAULT_SPIN_BUDGET_CYCLES is inert here
|
||||
// because no worker is permitted to enter the spin path.
|
||||
// An explicit Config::max_spinners(_) overrides this (e.g. a future autotune
|
||||
// utility that picks budget+cap from measured load).
|
||||
let max_spinners = config.max_spinners.unwrap_or(match n {
|
||||
0 | 1 => 0,
|
||||
2..=4 => n as u32,
|
||||
_ => 0,
|
||||
});
|
||||
Runtime {
|
||||
inner: RuntimeInner::new(
|
||||
n,
|
||||
@@ -880,8 +940,8 @@ pub fn init(config: Config) -> Runtime {
|
||||
config.stack_pool_cap,
|
||||
config.max_actors,
|
||||
config.wake_slot,
|
||||
config.node_id,
|
||||
config.incarnation,
|
||||
config.spin_budget_cycles,
|
||||
max_spinners,
|
||||
),
|
||||
thread_count: n,
|
||||
}
|
||||
@@ -945,11 +1005,6 @@ impl Runtime {
|
||||
// requires a running runtime in the thread-local).
|
||||
RUNTIME.with(|r| *r.borrow_mut() = Some(self.inner.clone()));
|
||||
let initial_handle = crate::scheduler::spawn(f);
|
||||
// The initial actor is the run's root: when it exits, remaining actors
|
||||
// are stopped so the run winds down (see finalize_actor / schedule_loop).
|
||||
self.inner.root_exited.store(false, Ordering::Relaxed);
|
||||
self.inner.root_swept.store(false, Ordering::Relaxed);
|
||||
self.inner.set_root(initial_handle.pid());
|
||||
|
||||
// Launch N-1 extra scheduler threads. The calling thread is thread 0.
|
||||
let mut os_threads = Vec::new();
|
||||
@@ -1087,7 +1142,6 @@ pub(crate) fn install_actor(
|
||||
}
|
||||
slot.sp.store(sp, Ordering::Relaxed);
|
||||
slot.store_closure(closure);
|
||||
slot.reset_counters();
|
||||
inner.live_actors.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Publish: only now can pops, unparks, or stops find the actor. The
|
||||
@@ -1128,7 +1182,6 @@ pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) {
|
||||
cold.waiters.clear();
|
||||
cold.monitors.clear();
|
||||
cold.links.clear();
|
||||
slot.reset_counters();
|
||||
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
|
||||
// The generation bump IS the reclaim: every stale pid is dead from
|
||||
// this store onwards (unpark protocol, pops, cold-path re-verifies).
|
||||
@@ -1251,16 +1304,6 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
||||
// Reclaim if no outstanding handles (re-verified inside).
|
||||
reclaim_slot(inner, pid);
|
||||
|
||||
// Root-exit teardown is DEFERRED to the scheduler's idle verdict, not done
|
||||
// here: stopping eagerly would cut off actors that still have queued work
|
||||
// (they'd unwind on the stop before draining their mailbox). Flagging it
|
||||
// instead lets the run queue drain naturally first; only the parked-forever
|
||||
// remainder (e.g. a server pinned alive by a registered name) is then
|
||||
// stopped, once nothing runnable is left. See `schedule_loop`.
|
||||
if inner.is_root(pid) {
|
||||
inner.root_exited.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
// The decrement is LAST: every wakeup this finalize produced (joiners,
|
||||
// monitor/trap sends, stop cascades) is enqueued before `live_actors`
|
||||
// can be observed at its decremented value. See the termination note in
|
||||
@@ -1269,19 +1312,6 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
||||
debug_assert!(prev >= 1, "live_actors underflow — double finalize");
|
||||
}
|
||||
|
||||
/// Cooperatively stop every live actor — the root-exit teardown sweep, run from
|
||||
/// `schedule_loop` once the run queue is empty after the root has exited. Each
|
||||
/// [`request_stop_inner`](crate::scheduler::request_stop_inner) re-verifies the
|
||||
/// target under its cold lock, so the racy per-slot generation read is safe: a
|
||||
/// vacant, dead, or reused slot no-ops. The swept actors unpark, unwind at their
|
||||
/// next observation point, and finalize, dropping `live_actors` to zero.
|
||||
fn stop_live_actors(inner: &Arc<RuntimeInner>) {
|
||||
for idx in 0..inner.slots.len() as u32 {
|
||||
let pid = Pid::new(idx, inner.slots[idx as usize].generation());
|
||||
crate::scheduler::request_stop_inner(inner, pid);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schedule_loop — runs on each scheduler OS thread
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1326,14 +1356,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
// The callback may call unpark_at itself.
|
||||
target.on_timeout(entry.pid, epoch);
|
||||
}
|
||||
// A `send_after` deadline: run the captured delivery thunk.
|
||||
// It resolves the destination through the registry and
|
||||
// sends now (a send can unpark a receiver) — same as any
|
||||
// other in-loop unpark. The timers lock is already
|
||||
// released; lock order Leaf -> Channel is preserved by the
|
||||
// send itself. `pop_due` only returns still-armed Sends, so
|
||||
// a cancelled one never reaches here.
|
||||
crate::timer::Reason::Send { fire } => fire(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,9 +1407,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
Got(Pid),
|
||||
Idle { io_outstanding: u32, wake_fd: Option<std::os::fd::RawFd> },
|
||||
AllDone,
|
||||
/// Root has exited and nothing is runnable: stop the parked-forever
|
||||
/// remainder, then re-pop. Fires at most once per run.
|
||||
RootDrain,
|
||||
}
|
||||
|
||||
// 2a. RFC 005: drain this thread's wake slot before touching the
|
||||
@@ -1418,7 +1437,12 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
|
||||
stats.run_queue_len.store(inner.run_queue.len(), Ordering::Relaxed);
|
||||
let pop = match inner.run_queue.pop() {
|
||||
Some(pid) => Pop::Got(pid),
|
||||
Some(pid) => {
|
||||
// RFC 004: mirror the enqueue increment. Release pairs with
|
||||
// the spinners' Acquire load of queue_len.
|
||||
inner.queue_len.fetch_sub(1, Ordering::Release);
|
||||
Pop::Got(pid)
|
||||
}
|
||||
None => {
|
||||
// Termination does not lean on pop-None being a fence (with
|
||||
// the ring queues it is only a snapshot). The argument is
|
||||
@@ -1436,15 +1460,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
let live = inner.live_actors.load(Ordering::Acquire);
|
||||
if live == 0 && io_out == 0 {
|
||||
Pop::AllDone
|
||||
} else if inner.root_exited.load(Ordering::Acquire)
|
||||
&& !inner.root_swept.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
// Root gone and nothing runnable — the live remainder
|
||||
// are parked-forever daemons (Queued actors with pending
|
||||
// work drained before the queue emptied). Stop them so
|
||||
// the run can end. One-shot: a survivor falls through to
|
||||
// the idle wait below on the next pass.
|
||||
Pop::RootDrain
|
||||
} else {
|
||||
Pop::Idle { io_outstanding: io_out, wake_fd: io_fd }
|
||||
}
|
||||
@@ -1469,15 +1484,15 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
if let Some(io) = inner.io.lock().unwrap().as_ref() {
|
||||
io.wake();
|
||||
}
|
||||
// RFC 004: a no-io runtime has no wake pipe; futex-parked
|
||||
// siblings must be released or shutdown hangs. Broadcast;
|
||||
// each re-runs the verdict and reaches AllDone itself. The
|
||||
// sequence bump also EAGAINs any sibling mid-park.
|
||||
if inner.spinning {
|
||||
inner.unpark_all_schedulers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Pop::RootDrain => {
|
||||
// Root has exited and nothing is runnable: stop the
|
||||
// parked-forever remainder, then loop back to re-pop the
|
||||
// now-runnable (stopping) actors.
|
||||
stop_live_actors(inner);
|
||||
continue;
|
||||
}
|
||||
Pop::Idle { io_outstanding, wake_fd } => {
|
||||
// Something is still in flight. Sleep on the appropriate
|
||||
// source to avoid hammering the queue mutex; retry on wake.
|
||||
@@ -1501,7 +1516,16 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
crate::io::drain_wake_pipe(fd);
|
||||
}
|
||||
_ => {
|
||||
thread::sleep(std::time::Duration::from_micros(100));
|
||||
// RFC 004: the no-timer / no-io idle arm — the
|
||||
// ~100µs thread::sleep worst case this RFC targets.
|
||||
// With spinning enabled, spin-before-park on a
|
||||
// wakeable futex; with budget 0 the historical
|
||||
// sleep is preserved exactly.
|
||||
if inner.spinning {
|
||||
inner.idle_spin_then_park();
|
||||
} else {
|
||||
thread::sleep(std::time::Duration::from_micros(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -1537,7 +1561,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
set_actor_sp(sp);
|
||||
set_current_pid(pid);
|
||||
crate::preempt::set_current_stop(stop_flag);
|
||||
crate::preempt::set_current_slot(slot as *const Slot);
|
||||
reset_actor_done();
|
||||
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
|
||||
// RFC 005 timeslice inheritance: a slot-popped actor does NOT get a
|
||||
@@ -1558,15 +1581,9 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
unsafe { switch_to_actor() };
|
||||
|
||||
PREEMPTION_ENABLED.with(|c| c.set(false));
|
||||
// RFC 016 Chunk 2: charge the cycles this resume consumed to the actor
|
||||
// (approximate; reuses the slice-start timestamp — one RDTSC). Read
|
||||
// before the next resume re-arms TIMESLICE_START.
|
||||
#[cfg(feature = "budget-accounting")]
|
||||
slot.add_budget(crate::preempt::elapsed_slice_cycles());
|
||||
stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
|
||||
clear_current_pid();
|
||||
crate::preempt::clear_current_stop();
|
||||
crate::preempt::clear_current_slot();
|
||||
|
||||
let intent = YIELD_INTENT.with(|c| c.get());
|
||||
slot.sp.store(get_actor_sp(), Ordering::Relaxed);
|
||||
|
||||
+15
-177
@@ -9,7 +9,7 @@
|
||||
|
||||
use crate::actor::current_pid;
|
||||
use crate::channel::Sender;
|
||||
use crate::pid::{Name, Pid};
|
||||
use crate::pid::Pid;
|
||||
use crate::runtime::{
|
||||
self, RuntimeInner, YieldIntent, RUNTIME,
|
||||
};
|
||||
@@ -175,8 +175,7 @@ pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle {
|
||||
spawn_under(parent, f)
|
||||
}
|
||||
|
||||
pub fn spawn_under<A>(supervisor: Pid<A>, f: impl FnOnce() + Send + 'static) -> JoinHandle {
|
||||
let supervisor = supervisor.erase();
|
||||
pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle {
|
||||
// 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())
|
||||
@@ -195,33 +194,6 @@ pub fn spawn_under<A>(supervisor: Pid<A>, f: impl FnOnce() + Send + 'static) ->
|
||||
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 {
|
||||
@@ -315,30 +287,22 @@ pub(crate) fn retire_wait() {
|
||||
/// 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<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);
|
||||
pub fn request_stop(pid: Pid) {
|
||||
let _ = try_with_runtime(|inner| {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
inner.unpark(pid);
|
||||
}
|
||||
inner.unpark(pid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -388,81 +352,6 @@ pub fn insert_wait_timer(
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -725,54 +614,3 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
-599
@@ -1,599 +0,0 @@
|
||||
//! 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
|
||||
});
|
||||
}
|
||||
}
|
||||
+9
-88
@@ -15,14 +15,12 @@
|
||||
//! `BinaryHeap` is a max-heap; entries are wrapped in `Reverse` to get
|
||||
//! min-heap behaviour.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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 /
|
||||
//! the wait's epoch was consumed" 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".
|
||||
//!
|
||||
//! Stale pids (slot reused since the timer was inserted) are filtered on
|
||||
//! pop by the scheduler — same convention as the run queue.
|
||||
@@ -53,37 +51,6 @@ pub enum Reason {
|
||||
target: Arc<dyn TimerTarget>,
|
||||
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.
|
||||
@@ -130,21 +97,13 @@ 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 (and the `TimerId` of a
|
||||
/// `Send` timer — the two are the same value).
|
||||
/// Monotonic counter for the tiebreaker `seq` field.
|
||||
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, armed: std::collections::HashSet::new() }
|
||||
Self { heap: BinaryHeap::new(), next_seq: 0 }
|
||||
}
|
||||
|
||||
/// Insert a `Sleep` timer. Convenience for the common case.
|
||||
@@ -152,33 +111,6 @@ impl Timers {
|
||||
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.
|
||||
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
|
||||
let seq = self.next_seq;
|
||||
@@ -195,7 +127,6 @@ 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.
|
||||
@@ -205,21 +136,11 @@ 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 {
|
||||
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);
|
||||
out.push(self.heap.pop().unwrap().0);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
+3
-236
@@ -27,7 +27,6 @@ impl GenServer for Counter {
|
||||
type Reply = i64;
|
||||
type Cast = Op;
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, req: Req) -> i64 {
|
||||
match req {
|
||||
@@ -71,9 +70,8 @@ impl GenServer for Lifecycle {
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn init(&mut self, _ctx: &smarm::gen_server::ServerCtx<Self>) {
|
||||
fn init(&mut self, _ctx: &smarm::gen_server::ServerCtx) {
|
||||
self.log.lock().unwrap().push("init");
|
||||
}
|
||||
|
||||
@@ -156,7 +154,6 @@ impl GenServer for Slow {
|
||||
type Reply = u64;
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, delay_ms: u64) -> u64 {
|
||||
if delay_ms > 0 {
|
||||
@@ -213,7 +210,6 @@ fn call_timeout_to_dead_server_is_server_down_not_timeout() {
|
||||
impl GenServer for Bomb {
|
||||
type Call = ();
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
fn handle_call(&mut self, _: ()) {
|
||||
@@ -252,7 +248,6 @@ impl GenServer for Logger {
|
||||
type Reply = Vec<&'static str>;
|
||||
type Cast = ();
|
||||
type Info = &'static str;
|
||||
type Timer = ();
|
||||
|
||||
fn handle_call(&mut self, _: ()) -> Vec<&'static str> {
|
||||
self.log.clone()
|
||||
@@ -355,7 +350,7 @@ 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>>,
|
||||
watcher: Option<Watcher>,
|
||||
log: Vec<DownReason>,
|
||||
}
|
||||
|
||||
@@ -369,9 +364,8 @@ impl GenServer for Pool {
|
||||
type Reply = Vec<DownReason>;
|
||||
type Cast = PoolCast;
|
||||
type Info = ();
|
||||
type Timer = ();
|
||||
|
||||
fn init(&mut self, ctx: &smarm::gen_server::ServerCtx<Self>) {
|
||||
fn init(&mut self, ctx: &smarm::gen_server::ServerCtx) {
|
||||
self.watcher = Some(ctx.watcher());
|
||||
}
|
||||
|
||||
@@ -443,230 +437,3 @@ fn unused_ctx_closes_control_arm_silently() {
|
||||
});
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
//! 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();
|
||||
});
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//! 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
@@ -1,131 +0,0 @@
|
||||
//! 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);
|
||||
});
|
||||
}
|
||||
+164
-194
@@ -1,251 +1,221 @@
|
||||
//! 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`.
|
||||
//! Named registry tests. Run under the scheduler: registration requires a
|
||||
//! live runtime and live actors.
|
||||
|
||||
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");
|
||||
use smarm::{channel, name_of, register, run, spawn, unregister, whereis, RegisterError};
|
||||
|
||||
#[test]
|
||||
fn register_then_send_by_name_delivers() {
|
||||
fn register_whereis_roundtrip() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let (tx, rx) = channel::<()>();
|
||||
let h = spawn(move || {
|
||||
register(SVC, tx).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
assert_eq!(rx.recv().unwrap(), 42);
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
ready_rx.recv().unwrap(); // worker has registered
|
||||
assert_eq!(whereis("svc"), Some(h.pid()));
|
||||
send(SVC, 42).unwrap();
|
||||
register("worker", h.pid()).unwrap();
|
||||
assert_eq!(whereis("worker"), Some(h.pid()));
|
||||
assert_eq!(name_of(h.pid()).as_deref(), Some("worker"));
|
||||
tx.send(()).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).
|
||||
fn register_is_idempotent_for_same_binding() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (cmd_tx, cmd_rx) = channel::<u64>();
|
||||
let (adm_tx, adm_rx) = channel::<&'static str>();
|
||||
let (tx, rx) = channel::<()>();
|
||||
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");
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
send(Name::<u64>::new("port"), 7u64).unwrap();
|
||||
send(Name::<&'static str>::new("port"), "halt").unwrap();
|
||||
register("svc", h.pid()).unwrap();
|
||||
// Same name, same pid: a no-op Ok, not NameTaken.
|
||||
register("svc", h.pid()).unwrap();
|
||||
tx.send(()).unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_held_by_live_actor_is_taken() {
|
||||
fn duplicate_name_on_live_holder_is_rejected() {
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (tx_a, rx_a) = channel::<u64>();
|
||||
let (tx, rx) = channel::<()>();
|
||||
let (tx2, rx2) = channel::<()>();
|
||||
let a = spawn(move || {
|
||||
register(SVC, tx_a).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
assert_eq!(rx_a.recv().unwrap(), 0); // wait to be released
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
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);
|
||||
rx2.recv().unwrap();
|
||||
});
|
||||
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();
|
||||
register("svc", a.pid()).unwrap();
|
||||
assert_eq!(
|
||||
register("svc", b.pid()),
|
||||
Err(RegisterError::NameTaken { holder: a.pid() })
|
||||
);
|
||||
tx.send(()).unwrap();
|
||||
tx2.send(()).unwrap();
|
||||
a.join().unwrap();
|
||||
b.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_errors_unresolved_and_no_channel() {
|
||||
fn one_name_per_pid() {
|
||||
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 (tx, rx) = channel::<()>();
|
||||
let h = spawn(move || {
|
||||
register(Name::<u64>::new("svc2"), tx).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
assert_eq!(rx.recv().unwrap(), 0);
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
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();
|
||||
register("first", h.pid()).unwrap();
|
||||
assert_eq!(
|
||||
register("second", h.pid()),
|
||||
Err(RegisterError::PidAlreadyRegistered { name: "first".into() })
|
||||
);
|
||||
tx.send(()).unwrap();
|
||||
h.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_frees_the_name_only() {
|
||||
fn registering_a_dead_pid_is_noproc() {
|
||||
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
|
||||
let h = spawn(|| {});
|
||||
let pid = h.pid();
|
||||
h.join().unwrap();
|
||||
assert_eq!(register("ghost", pid), Err(RegisterError::NoProc));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_evaporates_on_death_and_name_is_reusable() {
|
||||
run(|| {
|
||||
let a = spawn(|| {});
|
||||
let pid_a = a.pid();
|
||||
register("svc", pid_a).unwrap();
|
||||
a.join().unwrap();
|
||||
|
||||
// Dead holder: both lookup directions report unbound.
|
||||
assert_eq!(whereis("svc"), None);
|
||||
assert_eq!(name_of(pid_a), None);
|
||||
|
||||
// And the name is free for a successor.
|
||||
let (tx, rx) = channel::<()>();
|
||||
let b = spawn(move || {
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
ready_rx.recv().unwrap();
|
||||
assert_eq!(whereis("svc"), Some(h.pid()));
|
||||
register("svc", b.pid()).unwrap();
|
||||
assert_eq!(whereis("svc"), Some(b.pid()));
|
||||
tx.send(()).unwrap();
|
||||
b.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_over_a_dead_holder_succeeds_without_lookup_in_between() {
|
||||
// The eviction path inside register() itself (not via whereis pruning).
|
||||
run(|| {
|
||||
let a = spawn(|| {});
|
||||
let pid_a = a.pid();
|
||||
register("svc", pid_a).unwrap();
|
||||
a.join().unwrap();
|
||||
|
||||
let (tx, rx) = channel::<()>();
|
||||
let b = spawn(move || {
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
register("svc", b.pid()).unwrap();
|
||||
assert_eq!(whereis("svc"), Some(b.pid()));
|
||||
assert_eq!(name_of(pid_a), None);
|
||||
tx.send(()).unwrap();
|
||||
b.join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_frees_both_directions() {
|
||||
run(|| {
|
||||
let (tx, rx) = channel::<()>();
|
||||
let h = spawn(move || {
|
||||
rx.recv().unwrap();
|
||||
});
|
||||
register("svc", h.pid()).unwrap();
|
||||
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();
|
||||
});
|
||||
}
|
||||
assert_eq!(name_of(h.pid()), None);
|
||||
assert_eq!(unregister("svc"), None);
|
||||
|
||||
// --- 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();
|
||||
// The pid may take a new name afterwards.
|
||||
register("svc2", h.pid()).unwrap();
|
||||
assert_eq!(name_of(h.pid()).as_deref(), Some("svc2"));
|
||||
tx.send(()).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
|
||||
fn whereis_from_another_actor_and_usable_with_runtime_apis() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
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 stopped = Arc::new(AtomicBool::new(false));
|
||||
let stopped2 = stopped.clone();
|
||||
run(move || {
|
||||
let (tx, rx) = channel::<()>();
|
||||
let svc = spawn(move || {
|
||||
// Parks forever; only a request_stop ends it.
|
||||
let _ = rx.recv();
|
||||
});
|
||||
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();
|
||||
});
|
||||
}
|
||||
register("stoppable", svc.pid()).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
|
||||
let stopped3 = stopped2.clone();
|
||||
let client = spawn(move || {
|
||||
let pid = whereis("stoppable").expect("name must resolve cross-actor");
|
||||
// The registry's pids plug into the rest of the runtime API.
|
||||
smarm::request_stop(pid);
|
||||
stopped3.store(true, Ordering::Relaxed);
|
||||
});
|
||||
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();
|
||||
client.join().unwrap();
|
||||
svc.join().unwrap(); // a stopped actor joins Ok
|
||||
drop(tx);
|
||||
});
|
||||
assert!(stopped.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[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(_))));
|
||||
fn registry_under_concurrent_churn_multi_thread() {
|
||||
// Many actors racing to claim the same names while holders die: the
|
||||
// bimap invariant (debug-asserted internally) and Erlang semantics must
|
||||
// hold under real parallelism.
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let wins = Arc::new(AtomicU32::new(0));
|
||||
let wins2 = wins.clone();
|
||||
smarm::init(smarm::Config::exact(4)).run(move || {
|
||||
let mut handles = Vec::new();
|
||||
for round in 0..8 {
|
||||
let name = format!("contested-{}", round % 2);
|
||||
for _ in 0..8 {
|
||||
let name = name.clone();
|
||||
let wins = wins2.clone();
|
||||
handles.push(spawn(move || {
|
||||
let me = smarm::self_pid();
|
||||
match register(&name, me) {
|
||||
Ok(()) => {
|
||||
wins.fetch_add(1, Ordering::Relaxed);
|
||||
smarm::yield_now();
|
||||
// May have been pruned-by-contact never; we are
|
||||
// alive, so our binding must still resolve to us.
|
||||
assert_eq!(whereis(&name), Some(me));
|
||||
assert_eq!(unregister(&name), Some(me));
|
||||
}
|
||||
Err(RegisterError::NameTaken { .. }) => {}
|
||||
Err(e) => panic!("unexpected register error: {e}"),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
// At least one registration per name must have succeeded.
|
||||
assert!(wins.load(Ordering::Relaxed) >= 2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! RFC 004 spinning-worker tests.
|
||||
//!
|
||||
//! The futex scheduler-park path is NOT loom-modelled (sync_shim.rs: the full
|
||||
//! runtime — context switches, futexes, TLS — is never run under cfg(loom)),
|
||||
//! so the Dekker submit/park ordering is guarded here instead, by driving the
|
||||
//! one true deadlock state and catching it as a timeout rather than a hung
|
||||
//! binary: every scheduler parked on the futex while runnable work exists.
|
||||
//!
|
||||
//! All tests use a NO-IO runtime (pure message passing) so the idle path is
|
||||
//! the bare `_` arm RFC 004 replaces — the futex park — not the timer/io arms,
|
||||
//! which keep their own wake sources. A lost wakeup therefore manifests as a
|
||||
//! permanent stall: a `recv` that never returns, an `rt.run` that never
|
||||
//! reaches AllDone. The watchdog turns that into a failed assertion.
|
||||
|
||||
use smarm::channel::channel;
|
||||
use smarm::runtime::{init, Config};
|
||||
use smarm::spawn;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Run `body` on a side thread; fail (rather than hang the test binary) if it
|
||||
/// does not finish within `secs`. A hang here means a scheduler parked on the
|
||||
/// futex with work outstanding and nothing woke it — exactly the RFC 004
|
||||
/// failure mode. The leaked thread is acceptable: the process exits non-zero.
|
||||
fn run_with_timeout(secs: u64, body: impl FnOnce() + Send + 'static) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
body();
|
||||
let _ = tx.send(());
|
||||
});
|
||||
if rx.recv_timeout(Duration::from_secs(secs)).is_err() {
|
||||
panic!(
|
||||
"RFC 004 deadlock: workload did not terminate within {secs}s \
|
||||
— a scheduler wakeup was lost (work stranded with all schedulers \
|
||||
parked on the futex)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single token ping-ponging between two actors for `rounds` rounds, on a
|
||||
/// `threads`-wide no-io runtime with the given spin budget. With more threads
|
||||
/// than active actors, the surplus schedulers go idle and park; every send to
|
||||
/// the (parked) peer is a cross-thread wakeup. A lost wake stalls the bounce.
|
||||
/// The post-run count confirms no message was silently dropped.
|
||||
fn bounce(threads: usize, rounds: u64, budget: u64) {
|
||||
run_with_timeout(30, move || {
|
||||
let rt = init(Config::exact(threads).spin_budget_cycles(budget));
|
||||
let echoed = Arc::new(AtomicU64::new(0));
|
||||
let echoed_actor = echoed.clone();
|
||||
rt.run(move || {
|
||||
let (tx_ab, rx_ab) = channel::<u64>();
|
||||
let (tx_ba, rx_ba) = channel::<u64>();
|
||||
let echo = spawn(move || {
|
||||
for _ in 0..rounds {
|
||||
let v = rx_ab.recv().expect("echo recv");
|
||||
echoed_actor.fetch_add(1, Ordering::Relaxed);
|
||||
tx_ba.send(v + 1).expect("echo send");
|
||||
}
|
||||
});
|
||||
for i in 0..rounds {
|
||||
tx_ab.send(i).expect("ping send");
|
||||
let r = rx_ba.recv().expect("ping recv");
|
||||
assert_eq!(r, i + 1, "token corrupted in flight");
|
||||
}
|
||||
echo.join().expect("echo join");
|
||||
});
|
||||
assert_eq!(
|
||||
echoed.load(Ordering::Relaxed),
|
||||
rounds,
|
||||
"messages were lost, not just delayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Tiny budget: schedulers park almost immediately, so essentially every
|
||||
/// cross-thread handoff exercises the futex wake. Sharpest stress on the
|
||||
/// submit-rule / lost-wakeup guards.
|
||||
#[test]
|
||||
fn bounce_forces_park_and_wake() {
|
||||
bounce(4, 20_000, 2_000);
|
||||
}
|
||||
|
||||
/// Large budget: idle schedulers stay hot spinners, so the submit rule mostly
|
||||
/// takes the `n_spinning > 0` skip and the last-spinner handoff (rule 4) does
|
||||
/// the waking. Confirms that path also makes progress and stays correct.
|
||||
#[test]
|
||||
fn bounce_with_hot_spinners() {
|
||||
bounce(4, 20_000, 2_000_000);
|
||||
}
|
||||
|
||||
/// Oversubscribed: more active actors than schedulers, so the run queue is
|
||||
/// rarely empty and the park/spin/submit machinery is hammered from every
|
||||
/// thread at once.
|
||||
#[test]
|
||||
fn bounce_oversubscribed() {
|
||||
bounce(2, 30_000, 5_000);
|
||||
}
|
||||
|
||||
/// Bursty fork: a parent makes many children runnable at once (the motivating
|
||||
/// fan-out), each sends one reply, the parent collects all of them. Exercises
|
||||
/// the submit rule under a burst — the first job eats one wake, the woken
|
||||
/// worker becomes a spinner and absorbs the rest — and confirms none are lost.
|
||||
fn fanout(threads: usize, children: u64, budget: u64) {
|
||||
run_with_timeout(30, move || {
|
||||
let rt = init(Config::exact(threads).spin_budget_cycles(budget));
|
||||
rt.run(move || {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
for c in 0..children {
|
||||
let tx = tx.clone();
|
||||
spawn(move || {
|
||||
tx.send(c).expect("child send");
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let mut sum = 0u64;
|
||||
for _ in 0..children {
|
||||
sum += rx.recv().expect("collector recv");
|
||||
}
|
||||
assert_eq!(sum, (0..children).sum(), "a child reply was lost");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_burst_parks() {
|
||||
fanout(4, 256, 2_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_burst_hot() {
|
||||
fanout(4, 256, 2_000_000);
|
||||
}
|
||||
|
||||
/// Shutdown guard: a mostly-idle spinning runtime must still terminate. Most
|
||||
/// schedulers park on the futex with no work ever coming; only the AllDone
|
||||
/// broadcast (`unpark_all_schedulers`) can release them. If that broadcast is
|
||||
/// missing, `rt.run` never returns and the watchdog fires.
|
||||
#[test]
|
||||
fn idle_runtime_shuts_down() {
|
||||
run_with_timeout(15, || {
|
||||
let rt = init(Config::exact(8).spin_budget_cycles(2_000));
|
||||
rt.run(|| {
|
||||
// One trivial cross-thread handoff, then everyone goes idle and the
|
||||
// runtime must drain to AllDone through parked schedulers.
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let h = spawn(move || {
|
||||
tx.send(42).expect("send");
|
||||
});
|
||||
assert_eq!(rx.recv().expect("recv"), 42);
|
||||
h.join().expect("join");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Repeated lifecycles: init→run→AllDone several times in one process, to
|
||||
/// catch any park/broadcast state that fails to reset between runtimes.
|
||||
#[test]
|
||||
fn repeated_spinning_lifecycles() {
|
||||
run_with_timeout(30, || {
|
||||
for _ in 0..20 {
|
||||
let rt = init(Config::exact(4).spin_budget_cycles(2_000));
|
||||
let echoed = Arc::new(AtomicU64::new(0));
|
||||
let e = echoed.clone();
|
||||
rt.run(move || {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let worker = spawn(move || {
|
||||
for _ in 0..500 {
|
||||
let v = rx.recv().expect("recv");
|
||||
e.fetch_add(v, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
for i in 0..500u64 {
|
||||
tx.send(i).expect("send");
|
||||
}
|
||||
worker.join().expect("worker join");
|
||||
});
|
||||
assert_eq!(echoed.load(Ordering::Relaxed), (0..500u64).sum());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Shutdown with siblings *pinned* in the futex park — the test that actually
|
||||
/// exercises the AllDone broadcast. A single long-running actor holds `live`
|
||||
/// at 1 (CPU-bound, never sending or spawning) long enough for every other
|
||||
/// scheduler to idle, spin out its budget, and commit to `futex_wait`. Because
|
||||
/// nothing is ever enqueued during that window, the submit-rule wakes never
|
||||
/// fire and those siblings stay parked; the final finalize enqueues no one, so
|
||||
/// the live-check self-termination cannot save them either. Only
|
||||
/// `unpark_all_schedulers` at AllDone can. Gut that broadcast and this hangs.
|
||||
#[test]
|
||||
fn shutdown_releases_pinned_parked_siblings() {
|
||||
run_with_timeout(15, || {
|
||||
let rt = init(Config::exact(8).spin_budget_cycles(2_000));
|
||||
rt.run(|| {
|
||||
// ~50ms of pure CPU: far longer than the µs it takes the other
|
||||
// seven schedulers to park, so they are provably in futex_wait
|
||||
// before this returns and live drops to 0.
|
||||
let start = std::time::Instant::now();
|
||||
let mut x = 0u64;
|
||||
while start.elapsed() < Duration::from_millis(50) {
|
||||
x = x.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
std::hint::black_box(x);
|
||||
}
|
||||
std::hint::black_box(x);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// cross-thread handoff — but the futex park path must still be inert and
|
||||
/// correct (the lone thread is never parked while it has work). Pins the
|
||||
/// degenerate cap=0 case.
|
||||
#[test]
|
||||
fn single_thread_spinning_is_inert() {
|
||||
run_with_timeout(15, || {
|
||||
let rt = init(Config::exact(1).spin_budget_cycles(2_000));
|
||||
let echoed = Arc::new(AtomicU64::new(0));
|
||||
let e = echoed.clone();
|
||||
rt.run(move || {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let worker = spawn(move || {
|
||||
for _ in 0..1_000 {
|
||||
let v = rx.recv().expect("recv");
|
||||
e.fetch_add(v, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
for i in 0..1_000u64 {
|
||||
tx.send(i).expect("send");
|
||||
}
|
||||
worker.join().expect("join");
|
||||
});
|
||||
assert_eq!(echoed.load(Ordering::Relaxed), (0..1_000u64).sum());
|
||||
});
|
||||
}
|
||||
|
||||
/// `spin_budget_cycles == 0` is the opt-out: the feature is fully off and the
|
||||
/// runtime uses the historical `thread::sleep` idle path. Must still run a
|
||||
/// cross-thread workload to completion (it always did) — this just guards that
|
||||
/// the gating left the off-path intact.
|
||||
#[test]
|
||||
fn budget_zero_keeps_old_behaviour() {
|
||||
run_with_timeout(20, || {
|
||||
let rt = init(Config::exact(4).spin_budget_cycles(0));
|
||||
let sum = Arc::new(AtomicU64::new(0));
|
||||
let s = sum.clone();
|
||||
rt.run(move || {
|
||||
let (tx, rx) = channel::<u64>();
|
||||
let worker = spawn(move || {
|
||||
for _ in 0..1_000 {
|
||||
s.fetch_add(rx.recv().expect("recv"), Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
for i in 0..1_000u64 {
|
||||
tx.send(i).expect("send");
|
||||
}
|
||||
worker.join().expect("join");
|
||||
});
|
||||
assert_eq!(sum.load(Ordering::Relaxed), (0..1_000u64).sum());
|
||||
});
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
//! 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)));
|
||||
}
|
||||
-200
@@ -205,203 +205,3 @@ fn same_deadline_entries_pop_in_insertion_order() {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user