Files
smarm/TODO_HANDOFF.md
T
Claude (sandbox) e570138da5 docs(roadmap): supervisor start order is not start readiness
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).
2026-08-20 13:20:42 +00:00

15 KiB
Raw Blame History

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 gets ExitSignal{reason: Shutdown}, non-trapping is stopped outright. RuntimeHandle::{request_stop,request_shutdown} do the same from any OS thread (signal handler); grab rt.handle() before rt.run.
  • Supervisor traps; request_shutdown(sup) = ordered reverse-start shutdown, per-child ChildSpec::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, shutdown rows (default stop), exit sig rows, cx.stop() / stop tail, optional terminate { } 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 (join what 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 GenServerRef and 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 ChildSpec child; use the trapping wrapper pattern in examples/graceful_shutdown.rs:: drainer_child (starts under(self_pid()), forwards shutdown, waits). Doing an inline GenServerBuilder::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:

  1. smarm v0.6.2 — cross-thread wake root fix. DONE, committed on master as 1002777. Not yet tagged, not yet version-bumped (Cargo.toml still reads 0.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 + tag v0.6.2 + bump 0.6.1→0.6.2) is Markk's step.
  2. urus v0.3 — endpoint refactor. Working against smarm = { path = "../smarm_full" } with git 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-version is 1.95. Use 1.97.1.
  • urus: git clone https://git.kalsbeek.dev/Markk116/urus — origin is registered and public-read. master 8bdec97 = 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 as 1002777 on top of the post-v0.6.1 README commit 8f2d513 (= 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 (currently v0.6.0). A trivial first commit bumps it to v0.6.1 (also picks up try_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 7bab4d2 added an entry-side check_cancelled() in park_current. A request_stop against a listener parked in wait_readable_timeout now unwinds cleanly (it parks via try_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_stop all route through try_with_runtime, which reads a thread-local that is None on 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 why serve.rs polls the shutdown signal instead of parking on it. Now closed (see Phase 1 below): urus's SHUTDOWN_POLL loop can be deleted and its Handle::shutdown can 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 a Weak<RuntimeInner>. Grab it before rt.run and hand it to the signal thread.
  • RuntimeHandle::request_stop<A>(Pid<A>) — upgrades the Weak and calls request_stop_inner on 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-runtime request_stop.
  • Send-wake: the receiver captures scheduler::runtime_weak() into its parked_receiver tuple 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-sender drop wake via scheduler::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 on scheduler::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 gets ExitSignal{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) — Live drop 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 abnormal Stopped). 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)

  1. Root-exit sweep: make Pop::RootDrain also 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 in sleep(50ms) when the root returns must finish, not be swept. Then a Reservoir-style test that a truly parked-forever daemon still gets swept.
  2. gen_statem parity: ctx.trap_exit(), handle_shutdown -> ShutdownAction, handle_exit, stop handle. Mirror gen_server; mechanical.
  3. Examples review: examples/*.rs predate all of this. Rework where they show shutdown/teardown to use request_shutdown, Shutdown policies, and StopHandle; named_genserver.rs first (uses shutdown). Also docs/smarm - Deep Dive.html says 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 Live drop guard is request_stop (kill propagates as kill); OTP would deliver a trappable killed. 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 the Arc<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_stop the 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 AtomicBool listener flag (A fixed) and the SHUTDOWN_POLL loop
    • its apologetic comment (B fixed → park, don't poll).
  • Nuance: request_stop → Signal::Stopped is abnormal → Transient restarts. Stop-without-restart = stop the supervisor, not the children.
  • (confirm) Keep serve/serve_with/serve_with_shutdown as 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::shutdown can map to handle-driven request_stop on the endpoint.
  • Breaking → cut urus v0.3; bump the smarm pin to v0.6.2 here.

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.sh does 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 -f matches 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.toml after editing (undo with --no-skip-worktree), or stash before committing.
  • The committed Cargo.toml stays pinned to the git tag; restore the tag (bumped to v0.6.2) for the release commit.