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

230 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_shutdown`s 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.