docs(roadmap): v0.7 plan — select via epoch-stamped consuming wakes

This commit is contained in:
smarm
2026-06-10 07:02:03 +00:00
parent 088280f3c9
commit aa295582c4
+77 -3
View File
@@ -152,6 +152,79 @@ Landed as two primitives, *without* the sketched monitor machinery:
--- ---
## v0.7 — select, on epoch-stamped consuming wakes
Goal: cross-channel wait (`select`) without surrendering the precise-wake
invariant. Decision record: the alternative ("wakes are hints", loop-and-
recheck at every park site) was evaluated and rejected — it distributes the
soundness obligation across every present and future park site and turns
every stale wake into a wasted reschedule. Instead the slot protocol itself
gains *wait identity*: a park-epoch packed into the spare bits of the slot
word, so that a stale unpark dies at one failed CAS in the waker.
Protocol (the consuming-wake rules):
- Word becomes `(gen << 32) | (epoch << 8) | state` — 24-bit epoch in the
previously unused bits; every transition still one CAS, gen check still
atomic with it.
- `begin_wait` (actor-side, before registering with any waker) bumps the
epoch and returns it; registrations carry `(pid, epoch)`.
- Every successful wake transition **consumes** the epoch (bumps it):
`Parked(e) → Queued(e+1)`, `Running(e) → RunningNotified(e+1)`. A second
unpark stamped with the same epoch can never land — at most one wake per
wait, by construction. This is what makes select's *loser* arms harmless
without a cancellation pass: their stale registrations fire one futile CAS
on next send and self-clean.
- `unpark_at(pid, epoch)` = epoch-matched wake. Bare `unpark(pid)` remains as
the wildcard and is reserved for **terminal** wakes (`request_stop`), which
never return control to the code that parked.
- One-shot park sites (`Mutex::lock_timeout`, `sleep`, `block_on_io`,
`wait_fd`) stay exactly as written: precise wakes are preserved, so a wake
still *means* something.
### 1. Slot-word epoch + consuming unparks
`slot_state.rs`: repack, `begin_wait`, epoch-matched/consuming `unpark`,
epoch-preserving claim/yield/park transitions. Loom: existing theorems
re-proved on the new word + a new stale-epoch theorem (an unpark stamped with
a consumed epoch can never enqueue *or* notify). Plumbing only — no caller
stamps yet; behaviour identical.
### 2. Stamp every registration-based wake; retire per-primitive wait seqs
The epoch IS the wait identity, so `channel::Inner.{cur_wait,next_wait_seq}`
and `mutex`'s per-wait `seq` are subsumed: registrations become
`(pid, epoch)`, `TimerTarget::on_timeout(pid, epoch)`, wakers call
`unpark_at`. Sleep timers, io completions (`Blocking` + `FdReady`) and join
waiters stamped likewise. End-state invariant, auditable in one sentence:
**the only wildcard wake is `request_stop`.**
### 3. `select` — ready-index wait over multiple receivers
`select(&[&dyn Selectable]) -> usize`: register self in each arm's
`parked_receiver` (sequentially — the park protocol makes cross-arm atomicity
unnecessary), park once, wake, scan, return the first ready index; caller
`try_recv`s it. Sound as ready-index *because channels are single-receiver*:
nobody can steal between ready and recv. A closed arm counts as ready (its
`try_recv` reports the closure). Arms are scanned in order — priority order,
documented, like BEAM receive clauses; no fairness promise. No cancel pass
(see protocol above). Drop-guard deregistration on the unwind path only, to
keep tests' single-parked-receiver asserts honest.
### 4. `select_timeout`
A `WaitTimeout` timer stamped with the select's epoch, whose target is a
stateless `unpark_at`. The woken selector classifies the wake from state
alone: some arm ready → that arm; none ready → with precise wakes the timer
is the only remaining stamped waker → `None`. No flag, no shared waiter
struct, no registration to leak. The timer entry expires as a stale-epoch
no-op when an arm wins, per the no-cancellation convention in `timer.rs`.
### 5. Docs + integration proof
README module table, gotchas rewrite (the "No `select`" bullet becomes the
consuming-wake invariant), and an integration test of the motivating
pattern: a server selecting over its inbox and a monitor `Down` channel.
Unblocked but deliberately out of scope: `gen_server::handle_info` (now an
API-design question, not a runtime question — see Later).
---
## Later ## Later
### Tunable scheduler idle policy (RFC 004) ### Tunable scheduler idle policy (RFC 004)
@@ -175,9 +248,10 @@ Revisit with a segmented slab (array of `AtomicPtr<Segment>`, doubling segment
sizes, append-only) once the cap is actually hit. Do not let it calcify. sizes, append-only) once the cap is actually hit. Do not let it calcify.
### gen_server: handle_info ### gen_server: handle_info
Needs a cross-channel mailbox merge (call/cast inbox + out-of-band signals) — Runtime prerequisite landed in v0.7 (`select`). What remains is API design:
the still-unmade decision between a unified per-actor mailbox and per-channel the `Info` message type, who owns the extra channels, whether monitor `Down`s
`recv_match` composition. auto-forward into the server loop. Decide against real consumers, not
speculatively.
### IO fd hygiene on actor death ### IO fd hygiene on actor death
Pre-existing v0.2 TODO in `io.rs`: fds registered with epoll for a dead actor Pre-existing v0.2 TODO in `io.rs`: fds registered with epoll for a dead actor