Monitors could be installed but never taken down. That gap was about to
bite: the gen_server call timeout we want next is the Erlang dance —
monitor the server, wait for the reply or a Down or a deadline, then
demonitor — and without a way to remove a registration, every timed-out
call would leak a monitor on the server's slot and risk a stale Down
arriving later. So this lands the cleanup primitive before anything
depends on the old shape.
The decision flagged in the roadmap ("decide the monitor API NOW") is
resolved by giving each registration a process-unique MonitorId and
returning it to the caller. monitor() now hands back a
Monitor { id, target, rx } rather than a bare Receiver<Down>: read the
notice from rx as before, and pass &Monitor to demonitor to tear exactly
that registration down. The id comes from a monotonic counter in shared
state, bumped under the same lock that does the registration, so it's
deterministic and never reused — which is what lets demonitor name one of
several monitors on the same target unambiguously. target rode along on
the struct (over the sketched {id, rx}) purely so demonitor can go
straight to the slot instead of scanning every slot for the id.
demonitor returns Option<MonitorId> rather than a bool: Some(id) when a
live registration was found and removed, None when there was nothing left
to remove — it already fired (the registration is drained on finalize),
it was a NoProc, or the slot has been reclaimed. The generation half of
the pid quietly protects that last case: a recycled slot index fails
slot_mut's generation check, so a late demonitor is a clean no-op and can
never strip a different actor's monitor that happens to share the index.
The one piece of real care is reentrancy. Removing a registration drops
the slot's Sender, and Sender::drop can unpark a parked receiver, which
re-enters the shared mutex — which is not reentrant. So demonitor moves
the sender out of the Vec under the lock and lets it drop only after the
lock is released, the same discipline finalize_actor already follows for
its monitor and supervisor sends.
Flushing a Down the target already queued isn't a separate flag; it falls
out of dropping the Monitor. demonitor(&m); drop(m) stops future notices
and discards any queued one — the gen_server-call cleanup in one move.
Storage is now Vec<(MonitorId, Sender<Down>)>. The three slot-reset sites
were left alone on purpose: they clear/rebuild the Vec, which doesn't care
about the element type, so there's no fourth reset obligation. finalize
just destructures (_, m). Because chained_spawn and yield_many register no
monitors, that Vec stays empty on the hot path — taking an empty Vec costs
the same and the notify loop runs zero times — and a before/after general
probe confirmed both medians sit within noise.
Tests cover the three behaviours that matter: a demonitored watcher gets
no Down (its channel closes), demonitoring one of several leaves the
siblings firing normally, and demonitoring after the Down has already
fired reports None.
16 KiB
smarm — task.md (next steps)
Handoff for a future session reusing this sandbox. Read top to bottom once before starting; the gotchas section is hard-won and will save you a faceplant.
Resume the environment
- Repo:
/home/claude/work/smarm, brancharm-port, currently 8 commits pastmaster:a8fec02aarch64 context-switch port183fd05monitors (monitor()→Receiver<Down>)f617266one-for-one supervisor (restart policies + intensity cap)a8ddb4acooperative cancellation (roadmap #1 — done)e80334bfix: orphaned timers no longer block runtime shutdown351dc9cone_for_all / rest_for_one + ordered shutdown (roadmap #2 — done)31133a6docs(roadmap): mark #1 and #2 done6581484links + trap_exit (roadmap #3 — done)- (+ a trailing docs commit marking #3 done and refreshing supervisor/README)
- Toolchain is installed but NOT on PATH in a fresh shell. First line of every
session:
. "$HOME/.cargo/env"(rustc/cargo 1.96). - Build
cargo build· all testscargo test· one suitecargo test --test monitor. - Bench probe
cargo bench --bench general(custom print-only harness; compiles tokio in release the first time — slow — buttarget/persists across git checkouts so it's paid once). - Perf regression check:
git checkout <pre-change-sha>→cargo bench --bench general | tee before.txt→git checkout arm-port→ run again → diff thesmarm 1-threadmedians forchained_spawnandyield_many(those exercise spawn/finalize/scheduler). Numbers are noisy on this shared CPU; treat as "regression beyond noise?" not a precise delta.
Roadmap (dependency order)
1. Cooperative cancellation — the keystone ✅ DONE (a8ddb4a)
Everything below (one_for_all/rest_for_one, links) needs a safe way to stop a running peer. Forcible teardown of another green thread's stack is unsound here (shared heap + Drop). So: cooperative stop the actor observes and unwinds itself.
Shipped as designed (sentinel unwind, not Result-threading). Notes for what came next / future readers:
-
Stop flag lives on
ActorbehindArc<AtomicBool>(fresh per spawn), NOT aSlotfield — sidesteps the three-place reset, at the cost of one small alloc per spawn. The scheduler hands the resume path a raw*const AtomicBool(no per-resume refcount traffic);yield_manybench stayed at baseline,chained_spawn~+6% from that alloc (left as-is; move to aSlotfield if it ever matters). -
Observation points: amortised
maybe_preempt/check!()path + the wakeup side ofpark_current/yield_now. Sentinel =StopSentinel(zero-size), recognised in the trampoline →Outcome::Stopped.join()on a stopped actor returnsOk(())(no payload to propagate; reason is on the monitor channel). -
Documented gaps confirmed by tests: no-observation-point loop can't be stopped (same as preemption); a user
catch_unwindcan swallow the sentinel but the flag stays set so the next yield re-raises.Original plan, for reference:
-
Add a per-actor stop flag (Slot field + atomic, or check via shared state).
-
request_stop(pid): set the flag, unpark if parked. -
Realize the stop as a controlled unwind: when the scheduler resumes a stop-requested actor, inject a sentinel panic (dedicated payload type) so the existing
trampolinecatch_unwindtears the stack down and runs Drop. The trampoline recognizes the sentinel and reports a newOutcome::Stopped(distinct from a userPanic). This avoids changing every blocking-op signature.- Alternative considered: thread
Result<_, Cancelled>through recv/sleep/ lock/io. Rejected — large API churn. Go with the sentinel unwind. - Caveat to document: user code with its own
catch_unwindcan swallow the sentinel (cf. Erlangcatch); re-check the flag at the next yield and/or re-raise. And a tight no-alloc loop withoutcheck!()can't be stopped — same inherent limitation as preemption.
- Alternative considered: thread
-
Observation points:
maybe_preempt()/check!()(cheap flag check) and the blocking parks (recv/sleep/mutex/io) on the stop-driven unpark. -
Tests: looping actor on
check!()gets stopped →Outcome::Stopped, Drop guards ran; parked-on-recv actor gets stopped; no-check loop documents the gap.
2. one_for_all / rest_for_one + ordered shutdown ✅ DONE (351dc9c)
Shipped. What landed vs the plan:
-
Strategy::{OneForOne,OneForAll,RestForOne}selected via.strategy(), defaultOneForOne. The struct keeps theOneForOnename (compat; existing tests untouched) — a rename toSupervisoris a deferred refactor. -
The triggering child's
Restartpolicy decides whether anything restarts; the strategy decides which live siblings are cycled (all / index-> after the failed one). Survivors arerequest_stop'd in reverse start order, awaited on the existingsupervisor_channelfunnel (no new channel, noselect), restarted in start order. One failure = one intensity tick regardless of group size. Out-of-band signals during an await are stashed and replayed. -
Signal::Stopped(pid)+DownReason::Stoppedadded (kept distinct from Exit, as planned). AStoppedsignal counts as abnormal for the restart decision. -
Ordered shutdown: on cap-trip / mailbox-close-with-survivors, stop remaining children in reverse start order and await them (no-op on the normal exit).
-
⚠️ Surfaced + fixed a latent keystone bug (
e80334b): a cancelled sleeping/timeout actor orphans its timer entry, and the scheduler's shutdown check counted pending timers →run()hung until the dead actor's deadline fired (asleep(30s)sleeper hung shutdown 30s). Fix: timers no longer gate shutdown (live == 0already implies nothing a timer could wake); heap is cleared on exit. Independent of the supervisor work. -
Tests: all-restart (sibling cycled despite clean exit), suffix-restart (prefix child left alone), reverse-order teardown.
Original plan, for reference:
-
one_for_all: on any child failure,
request_stopall siblings, await their termination signals, restart all per spec. -
rest_for_one: stop+restart the failed child and those started after it.
-
Supervisor shutdown: stop children in reverse start order.
-
Decide signal surface: add
Signal::Stopped(pid)+DownReason::Stoppedrather than folding into Exit (clearer for the supervisor's await logic). -
Tests: all-restart, suffix-restart, reverse-order shutdown.
3. Links + trap_exit ✅ DONE (6581484)
Slot.links: Vec<Pid>(bidirectional);link/unlink;trap_exit()flag lives onActor(fresh per spawn → a restarted child starts un-trapped, and no fourth slot-reset site).- On finalize, reverse links are cleared under the lock (always — keeps the
cascade acyclic), then for each linked peer: abnormal death (
Panic/Stopped) →request_stop(peer)unless peer traps, in which case deliver anExitSignalmessage instead. Normal exit does NOT propagate. Linking an already-dead pid delivers an immediateNoProcsignal (message if trapping, elserequest_stop(self)— not a silent no-op). - Resolved: the trap inbox is a dedicated channel (
trap_exit() -> Receiver<ExitSignal>), distinct from the monitorDownchannel;ExitSignalreusesDownReasonand carries no panic payload (joiner-only, as with monitors).spawn_linkdeferred to #5. - Tests (
tests/link.rs): linked pair one panics → other stopped (+Drop ran); trap_exit → other gets a message and survives; normal exit doesn't propagate; dead-pid link stops a non-trapper / messages a trapper;unlinkprevents propagation.
4. Selective receive (independent track) ✅ DONE (03f3875)
Shipped. What landed vs the plan:
-
Receiver::recv_match(pred) -> Result<T, RecvError>scans the queuedVecDequefront-to-back, removes+returns the first match, leaves the rest in arrival order; parks and re-scans when nothing matches.try_recv_match(the non-blocking variant, mirroringtry_recv) rolled in same commit. -
Wakeup turned out cheaper than feared:
Sender::sendalready tookparked_receiveron every push, so "wake on ANY send" needed no send-side change. The one real edit was relaxingSender::dropto unpark the parked receiver on the last-sender drop regardless of queue emptiness — a selective receiver can park on a non-empty no-match queue and must wake to observe closure. No-op for plainrecv(only ever parks on an empty queue); stress suite stays green. -
predisFn(&T) -> bool(notFnMut) on purpose: it's re-run from scratch on every scan, so a stateful predicate would re-count surprisingly. It runs under the channel lock — keep it cheap/pure, don't re-enter the channel. -
Close semantics:
recv_matchreturnsErr(RecvError)only when closed AND no queued message matches; a match is still returned on a closed channel. Non-matches are left for a laterrecv. -
Tests (
tests/selective_recv.rs): out-of-order match pulled first; non-matches remain in order; park-on-non-empty then wake on a match; closed-with-only- non-matches → Err; closed-but-match-present → match;try_recv_matchstates.Original plan, for reference:
-
Add
Receiver::recv_match(pred) -> T: scan the queuedVecDeque, remove+return first match, leave the rest in order; park and re-scan on new arrivals. -
This changes channel wakeup: a parked selective receiver must wake on ANY send (not just empty→nonempty) and re-scan. Touches
channel.rscarefully — the stress tests guard lost-wakeup invariants; keep them green. -
Tests: messages arrive out of interest-order; match pulled first; non-matches remain for a later
recv.
5. Grab-bag (each its own small commit)
spawn_link: spawn-and-link atomically (deferred from #3); thin wrapper overspawn_under+link, but do it under one lock so there's no window where the child dies before the link is recorded.demonitor: needs a per-monitor id to remove a specific sender. Decide the monitor API NOW before more code depends on it — likely return aMonitor { id, rx }instead of a bareReceiver<Down>. ✅ DONE (this commit). What landed vs the plan:monitor()now returnsMonitor { id, target, rx }(addedtargetover the sketched{id, rx}sodemonitorjumps straight to the slot instead of scanning every slot for the id).MonitorId(u64)is opaque, from a monotonicnext_monitor_idcounter onSharedState, bumped under the shared lock inmonitor()— no atomics, deterministic, never reused.Slot.monitors: Vec<(MonitorId, Sender<Down>)>. The three slot-reset sites were untouched — they.clear()/Vec::new(), which is element-type- agnostic, so no new reset obligation.finalize_actorjust destructures(_, m)and sends as before.demonitor(&Monitor) -> Option<MonitorId>:Some(id)when a live registration was found+removed,Nonewhen it had already fired (drained on finalize), wasNoProc, or the slot was reclaimed. ChoseOption<MonitorId>over a bare bool — names which registration went. Generation half of the pid makes a stale demonitor a clean no-op: a recycled slot index failsslot_mut's generation check, so it can never strip a different actor's monitor.- ⚠️ Reentrancy: the removed
Senderisremoved out of the Vec under the lock but dropped after the lock is released —Sender::dropcan unpark a parked receiver →with_shared, and the shared mutex is non-reentrant. Same discipline asfinalize_actor. - "Flush" (discard a
Downthe target already queued) falls out of dropping theMonitor:demonitor(&m); drop(m). That's the cleanup the still-to-come gen_server call timeout wants — monitor the server, wait reply-or-Down- or-deadline, then demonitor+drop so a timed-out call leaks no registration and no staleDown. - Perf: touches
Slot+finalize_actor, butchained_spawn/yield_manyregister no monitors, so the Vec stays empty (take-empty is identical cost, finalize loop runs zero times). before/aftergeneralprobe medians within noise. Tests (tests/monitor.rs): demonitor-stops-delivery, one-of-many (siblings untouched), after-fire-is-None.
- Named registry:
register(name,pid)/whereis/send_by_name; aHashMap<String,Pid>inSharedState. - gen_server-style call/cast: request-reply correlation as a thin layer over
channels (
callsends{req, reply_tx}, awaitsreply_rx); no runtime change. ✅ DONE (a4fcf6c). What landed vs the plan:GenServertrait on the state value: assocCall/Reply/Casttypes, requiredhandle_call/handle_cast, optionalinit/terminatehooks.ServerRef<G>is a clonable inbox sender +pid();start/start_under.- One inbox, not two: a single
Envelope { Call(req, reply_tx) | Cast }channel, dispatched by variant. Forced by no-select/no-unified-mailbox — a server can't wait on a call channel and a cast channel at once. - Server-down falls out of channel closure (no monitor needed):
sendfails if the inbox is gone; the reply sender drops on the server's unwind so a parked caller wakes toErr. Both →Call/CastError::ServerDown. terminateruns via a drop guard → fires on every exit path (clean inbox close, handler panic,request_stop), not just the clean one. Caveat: it may run mid-unwind, so keep it non-blocking (a panic inside it during an unwind double-panics → abort).- No
handle_info, no call timeout — both deferred to land with timeouts (handle_infoneeds the still-unmade cross-channel mailbox merge; a call timeout needs a per-recvdeadline /Signal::Timeout). - Pure additive layer (no Slot/scheduler/spawn/finalize change) → no perf
check run. Tests (
tests/gen_server.rs): cast→call roundtrip, init/terminate ordering, both server-down paths.
- Docs lag: README still says "x86-64 Linux only — ARM64 … deferred" and the
module table calls
contextx86-64-only; update for the aarch64 backend.
Gotchas / invariants (respect these)
- Shared mutex is non-reentrant.
Sender::sendcan callunpark→with_shared. NEVER send on a channel while holding the shared lock. Pattern:mem::takethe senders/data under the lock, send after releasing. Seefinalize_actor(supervisor signal + monitor Downs both sent post-lock). finalize_actororder: take stack/waiters/monitors under lock + set Done/outcome → recycle stack (post-lock) → deliver supervisor Signal + monitor Downs (post-lock) → unpark joiners → reclaim slot iffoutstanding_handles==0. Death notifications always precede reclamation, so a pid carried in a Signal/Down is still matchable even as its slot is about to be reused.- Slot lifecycle is reset in THREE places —
Slot::vacant(),reclaim_slot()(runtime.rs), and the slot-init block inspawn_under(scheduler.rs). Any new Slot field must be reset in all three (monitors was). - Pid = (index, generation); stale handles caught by generation mismatch in
slot()/slot_mut(). The monitorNoProcpath relies on this. - No
select, no unified per-process mailbox. Why the supervisor uses the singlesupervisor_channelfunnel rather than N monitor channels. trap_exit resolved this by giving each trapping actor a dedicatedReceiver<ExitSignal>inbox (see #3); selective receive (#4) stayed per-channel (recv_matchscans one channel's queue) rather than introducing a cross-channel mailbox — if selective receive ever needs to span the monitor/trap inboxes too, that cross-channel merge is the still-unmade decision. - Cooperative-only: preemption and (future) cancellation both depend on the
actor reaching
check!()/yield/alloc/blocking points. run()is single-thread (Config::exact(1)); tests rely on deterministic single-thread ordering (parent runs until it parks). Multi-thread viaruntime::init(Config…).
Workflow expectations (from the human)
- TDD: write the failing test first, then implement.
- Commit incrementally with conventional-commit messages; keep each commit a reviewable unit (they diff in their IDE and are the filter to the codebase).
- Run the full suite before each commit; check perf when a change touches Slot/scheduler/spawn/finalize hot paths.