Mainline now carries roadmap #1-#5; the aarch64 context-switch port is a single untested commit on the arm-port branch. Update the task.md resume block and the README build section accordingly.
17 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:
smarm. Two branches (the old singlearm-portstack was split):master— the mainline, and HEAD. Carries roadmap #1–#5: cooperative cancellation, supervisor strategies (one_for_one/all, rest_for_one) + the orphaned-timer shutdown fix, links/trap_exit, selective receive, gen_server, and demonitor/MonitorId. Taggedv0.4.0. x86-64 Linux only.arm-port—masterplus a single commit:feat(arch): aarch64 context switch + cycle counter. Extracts the x86-64 context-switch / stack-init / cycle-counter out ofcontext.rsinto atarget_arch-gatedsrc/arch/(x86_64 + aarch64 backends) and adds an AAPCS64 backend. ⚠️ UNTESTED: never built or run on real ARM hardware. The x86-64 path is unchanged (arch/x86_64.rsis the oldcontext.rsbody verbatim), so the x86 suite passing says nothing about the aarch64 backend. Build + test on-device before trusting it.
- Toolchain is installed but NOT on PATH in a fresh shell. First line of every
session:
. "$HOME/.cargo/env"(rustc/cargo 1.96). - Build
cargo build· all 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: README now points at the experimental, untested aarch64 port on the
arm-portbranch. The module table still callscontextx86-64-only — true formaster, since thesrc/arch/split rides onarm-port. Fold the arch/ split and ARM64-supported wording into the README module table + build section oncearm-portis validated on hardware and merged.
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.