Filed from the urus v0.3 endpoint work. start_child spawns and moves on,
so a later sibling can whereis an earlier named child before that child's
actor has run. Notes why blocking spawn is not the fix ('has begun
executing' != 'has bound its name', plus a per-accept round-trip tax and
every spawn becoming a context-switch point), that OTP has the same async
spawn and synchronises one level up in gen_server:start_link, the
readiness-ack shape if scheduled, and the structural workaround urus uses
today (registrar spawns its own consumers).
15 KiB
urus / smarm handoff — updated 2026-08-19 (session 3)
TL;DR for the next session
smarm is done for now (5 unpushed commits on local master, see below).
Next = urus v0.3 endpoint refactor. You should NOT need to read smarm
scheduler internals; the contract you build on is fully described here and in
smarm_full/examples/graceful_shutdown.rs (read that file first — it is the
exact shape urus's tree will take) plus smarm_full/tests/root_exit.rs.
The smarm contract urus builds on (all on local master, verified by tests)
request_stop(pid)= kill (cooperative hard stop).request_shutdown(pid)= polite: trapping target getsExitSignal{reason: Shutdown}, non-trapping is stopped outright.RuntimeHandle::{request_stop,request_shutdown}do the same from any OS thread (signal handler); grabrt.handle()beforert.run.- Supervisor traps;
request_shutdown(sup)= ordered reverse-start shutdown, per-childChildSpec::shutdown(Shutdown::{Timeout(d)|Infinity|BrutalKill})(default Timeout(5s)); sup then returns normally.request_stop(sup)hard-stops children too (no orphans). - gen_server:
ctx.trap_exit()in init;handle_shutdown() -> Exit|Continue;handle_exit(sig);ctx.stop_handle().stop()= normal self-exit;terminate()may block only on the graceful path (Exit / stop / inbox close).GenServerRef::shutdown()is graceful and waits. - gen_statem: same in event clothes —
cx.trap_exit()in initial enter,shutdownrows (defaultstop),exit sigrows,cx.stop()/stoptail, optionalterminate { }block.GenStatemRef::shutdown(). - Root exit = program done: when the root actor returns, the runtime
request_shutdowns every forest root (live actor whose parent is the run or dead). Supervisors cascade; trapping actors may drain (timers keep working) and end the run when they stop; non-trapping are stopped; no forcing sweep (joinwhat must finish). The old "wait until nothing runnable then kill all" deferral is gone. - Gotcha for urus: a gen_server's lifetime is governed by its refs — drop
the last
GenServerRefand the inbox closes → clean exit even mid-drain. The endpoint must be pinned (named, or its ref held by the supervisor wrapper) or it will terminate the moment the root drops its ref. - Known gap (ROADMAP open item): a gen_server can't be a direct
ChildSpecchild; use the trapping wrapper pattern inexamples/graceful_shutdown.rs:: drainer_child(startsunder(self_pid()), forwards shutdown, waits). Doing an inlineGenServerBuilder::run()first may be worth a short smarm detour — decide with Markk.
smarm commits this session (local master, NOT pushed, NOT tagged)
250f312 root-exit = graceful shutdown of forest roots (tests/root_exit.rs)
6ceb138 gen_statem shutdown parity (tests/gen_statem_shutdown.rs)
849a424 docs + examples/graceful_shutdown.rs + README "Stopping actors"
On top of 1002777 (cross-thread wake) and 9c8f59c (graceful shutdown).
Cargo.toml still 0.6.1. Release cut (push, tag — v0.7 is justified by the
API surface — version bump) is Markk's. Full suite, doc tests, examples,
cargo fmt, cargo clippy --lib all clean. (clippy --tests has pre-existing
unwrap lints in tests/fd_select.rs, untouched.)
Decisions taken this session (Markk)
- Root exit means "program done" (Go/tokio/OTP), not "wait for pending work"; the previously agreed "sleep(50ms) must finish" test was dropped as encoding the wrong contract (a timer-wheel gate would re-wedge periodic-timer daemons).
- No behaviour-preserving deferral, no forcing second sweep.
- Examples/docs done in the same session; urus next session.
Previous handoff (still accurate where not superseded above)
Next-session goal
Phase 1 is done and committed; Phase 2 is next:
- smarm v0.6.2 — cross-thread wake root fix. DONE, committed on
masteras1002777. Not yet tagged, not yet version-bumped (Cargo.toml still reads0.6.1), and not yet pushed to origin — it exists only in the delivered snapshot zip and the local sandbox clone. Cutting the release (push + tagv0.6.2+ bump0.6.1→0.6.2) is Markk's step. - urus v0.3 — endpoint refactor. Working against
smarm = { path = "../smarm_full" }withgit update-index --skip-worktree Cargo.toml(Markk approved); release commit swaps back to the tag once Markk cuts it. Phase 2 plan below is STALE where it says drain-in-terminate; the endpoint is a trapping GenServer:handle_shutdown→Continue, enter Draining,StopHandle::stop()when the conn set empties.
Decisions below are locked unless marked (confirm).
Reconstruction (the sandbox resets between sessions)
A fresh sandbox has an empty home and no Rust toolchain. To restore:
- Install rustup/cargo. smarm reformats under rustc 1.97.1; urus
rust-versionis 1.95. Use 1.97.1. - urus:
git clone https://git.kalsbeek.dev/Markk116/urus—originis registered and public-read. master8bdec97= the v0.2.x line. (Zips in outputs are stale; prefer the remote now.) - smarm:
git clone https://git.kalsbeek.dev/Markk116/smarm. Latest tag v0.6.1 (ca1c983). The cross-thread wake fix is committed as1002777on top of the post-v0.6.1 README commit8f2d513(= origin/master). It is NOT on origin yet — a fresh clone won't have it until Markk pushes. Restore it from the snapshot zip if working before the push. v0.6.2 is not yet tagged. - urus pins smarm by git tag in
Cargo.toml(currentlyv0.6.0). A trivial first commit bumps it tov0.6.1(also picks uptry_spawn+ monitor terminal-outcome fixes).
Why (context — the finding that drives the plan)
urus's shutdown machinery (the AtomicBool listener flag + the SHUTDOWN_POLL
loop in serve.rs) is scaffolding around two smarm properties. Their statuses
differ, which is the whole point:
- Issue A — lossy stop vs a QUEUED actor: ALREADY FIXED in smarm. Commit
7bab4d2added an entry-sidecheck_cancelled()inpark_current. Arequest_stopagainst a listener parked inwait_readable_timeoutnow unwinds cleanly (it parks viatry_select_timeout → park_current). urus's flag + its stale "smarm's lossy stop-while-QUEUED window" comment can be deleted. - Issue B — foreign-thread wake is a no-op: FIXED in
1002777(was present through v0.6.1). The gap:unpark/unpark_at/request_stopall route throughtry_with_runtime, which reads a thread-local that isNoneon any non-scheduler thread, so no cross-thread wake worked — a signal handler / OS thread could not wake or stop a parked actor, which is whyserve.rspolls the shutdown signal instead of parking on it. Now closed (see Phase 1 below): urus'sSHUTDOWN_POLLloop can be deleted and itsHandle::shutdowncan park on a handle-driven stop.
Phase 1 — smarm cross-thread wake (root fix) — DONE (1002777)
Shipped as one commit generalizing RFC 018 (a producer reaches the runtime through
a Weak it holds) from the IO backend to channel senders and a new handle:
Runtime::handle() -> RuntimeHandle(Send + Sync), holding aWeak<RuntimeInner>. Grab it beforert.runand hand it to the signal thread.RuntimeHandle::request_stop<A>(Pid<A>)— upgrades the Weak and callsrequest_stop_inneron the inner; no-op if the runtime is gone. This is the signal-handler-drives-shutdown path; it cascades the ordered stop down the tree exactly like an in-runtimerequest_stop.- Send-wake: the receiver captures
scheduler::runtime_weak()into itsparked_receivertuple at park time (not at channel creation — the resolved sub-decision; a parked receiver is a live actor so the Weak is provably upgradable, and it scopes the capture to when a wake is possible).send()and last-senderdropwake viascheduler::unpark_at_via(pid, epoch, &weak): thread-local path when on a scheduler thread (preempt-gated, slot-eligible), captured Weak otherwise. In-runtime timer wakes (recv/select) were left onscheduler::unpark_at.
API scope decision (signed off): RuntimeHandle exposes request_stop only.
No public unpark/unpark_at on the handle — send-wake needs no user-facing handle,
and "unpark off-runtime" is covered because request_stop drives unpark on the
upgraded inner. No is_alive(). Both are one-line additions if a consumer appears.
No RFC written — pattern was already established (RFC 018), agreed not needed.
Tests: tests/cross_thread_wake.rs (foreign-thread send wakes a parked receiver;
foreign-thread request_stop wakes+stops a parked actor; a lingering handle never
blocks all-done and degrades to a no-op once the runtime drops). Full suite green;
cargo fmt + cargo clippy --lib clean.
Remaining release step (Markk): push master, tag v0.6.2, bump Cargo.toml
0.6.1→0.6.2. Left paired with the tag as the release cut, not done in 1002777.
Phase 1b — smarm graceful shutdown (OTP lift) — DONE (9c8f59c, on top of 1002777)
Decided this session (Markk): B — fix at the smarm level rather than a two-stop
split in urus. No RFC (Markk: "just implement it"). Shipped, tested, committed on
the local master, not pushed, not tagged. It should ship as the same
release as 1002777 (v0.6.2, or v0.7 given the API surface — Markk's call).
request_shutdown(pid)/RuntimeHandle::request_shutdown=exit(Pid, shutdown);request_stop=exit(Pid, kill). Trapping target getsExitSignal{reason: DownReason::Shutdown}; non-trapping is stopped outright.ChildSpec::shutdown(Shutdown::{BrutalKill, Timeout(d), Infinity}), default 5s. Supervisor traps exits;request_shutdown(sup)= ordered top-down shutdown, returns normally. Also fixed:request_stop(sup)used to ORPHAN children (probe-verified; the handoff's "cascade" claim was wrong) —Livedrop guard now hard-stops them.- gen_server:
ctx.trap_exit(),handle_shutdown() -> ShutdownAction::{Exit, Continue},handle_exit(ExitSignal),ctx.stop_handle().stop()= normal self-exit ({stop, normal}; previously impossible — only abnormalStopped).GenServerRef::shutdown()is graceful now. - Root finding that forced this: gen_server
terminate()runs from a Drop guard, mid-unwind on the stop path; any park in it = double panic = abort. So "drain-in-terminate()" (the old Phase 2 plan) was never viable.
Next-session smarm work DONE this session (see TL;DR)
- Root-exit sweep: make
Pop::RootDrainalso require an empty timer wheel (and it already requires nothing runnable; io_out is only checked for AllDone — check whether it should gate RootDrain too). TDD: an actor insleep(50ms)when the root returns must finish, not be swept. Then aReservoir-style test that a truly parked-forever daemon still gets swept. - gen_statem parity:
ctx.trap_exit(),handle_shutdown -> ShutdownAction,handle_exit, stop handle. Mirror gen_server; mechanical. - Examples review:
examples/*.rspredate all of this. Rework where they show shutdown/teardown to userequest_shutdown,Shutdownpolicies, andStopHandle;named_genserver.rsfirst (usesshutdown). Alsodocs/smarm - Deep Dive.htmlsays terminate() must be non-blocking — now only true on the unwind paths; and README could use a "Stopping actors" paragraph (request_stop = kill, request_shutdown = shutdown, Shutdown policy).
Open smarm items found on the way (noted, not scheduled)
- (root-exit sweep and gen_statem parity moved up to the scheduled list.)
- Sweep in the supervisor
Livedrop guard isrequest_stop(kill propagates as kill); OTP would deliver a trappablekilled. Chosen for boundedness.
Phase 2 — urus v0.3: endpoint refactor (after v0.6.2 is tagged)
Target = the spec's original shape (urus-spec.md §2.1/§6: listener_sup under the
user's root supervisor). Deviation to unwind: serve owning rt.run.
- App owns the runtime:
smarm::init(cfg).run(|| root_sup.run()), root e.g.RestForOne[ app actors…, urus::endpoint(config, pipeline) ]. This is what kills theArc<OnceLock>idiom for the right reason (app state born in-runtime as a supervised, ordered child). urus::endpoint= one GenServer child owning the registry + an internal listener sub-supervisor + drain-in-terminate(). Listeners stay internal, not app-visible peers.- Shutdown =
request_stopthe root supervisor (or via the runtime handle from a signal thread) → cascades down →endpoint.terminate()runs the drain (drain_timeout, force-stop sweep). - DELETE: the
AtomicBoollistener flag (A fixed) and theSHUTDOWN_POLLloop- its apologetic comment (B fixed → park, don't poll).
- Nuance:
request_stop→Signal::Stoppedis abnormal →Transientrestarts. Stop-without-restart = stop the supervisor, not the children. - (confirm) Keep
serve/serve_with/serve_with_shutdownas thin wrappers that build the one-child tree internally, so the simple case stays one line. - (confirm) Keep
Handle/ShutdownSignal? Now that cross-thread wake works,Handle::shutdowncan map to handle-drivenrequest_stopon the endpoint. - Breaking → cut urus v0.3; bump the smarm pin to
v0.6.2here.
Working norms
- Every bash call:
export PATH=$HOME/.cargo/bin:$PATH(once the toolchain's in). - TDD: failing test first, then implement; keep suites green.
- Hammer ritual for ANY connection-lifecycle change (the urus #2 shutdown work
qualifies): 35× subset (
shutdown timeout reaped slowloris streaming chunked sse stalled ws_ channels session) + 3× full + 1× trace.scripts/hammer.shdoes NOT pass feature flags — loop manually with--features phoenix. Subset filter must NOT use--test integration(session tests live in lib). - Example smoke tests: hold the server's stdin open (
mkfifo+sleep > fifo) or the Enter-to-shutdown thread fires on EOF instantly. - Background procs are reaped BETWEEN bash calls;
pkill -fmatches your own shell. - Artefact store (specs):
curl -H "Authorization: Bearer sk-llmingest-2e45d80c63db24c6781e761eb2a9a58e83d9f48ef77a42185bad311d07c80e68" https://artefacts.kalsbeek.dev/artifacts/<name>—urus-spec.md,urus-bench-spec.md,rfc_008-implementation-notes.md, … - smarm feature flags:
smarm-trace,smarm-causal(urus re-exports both).
Local cross-repo testing — KEEP OUT OF COMMITS
To test urus #2 against un-tagged smarm 0.6.2, point urus's Cargo.toml smarm dep
at a local path (smarm = { path = "../smarm" }) instead of the git tag.
- Must NOT land in commits. Guard:
git update-index --skip-worktree Cargo.tomlafter editing (undo with--no-skip-worktree), or stash before committing. - The committed
Cargo.tomlstays pinned to the git tag; restore the tag (bumped tov0.6.2) for the release commit.