core(mailbox): principal-grade redesign — flume (ADR-0001) + composable Mailbox API (#133)#134
Merged
Merged
Conversation
Start the part-by-part core rebuild (epic #122) in a fresh, god-level-bar crate rather than mutating the vendored kameo modules. The mailbox is the first spine part (#112), built standalone so it can be hard-tested before the run-loop is grafted onto it (#114/#116/#117). - `Mailboxed { type Msg }`: the scaffold seam the rebuilt `Actor` trait will later subsume. - `Signal<A>` = `Message(A::Msg) | Stop`: a concrete, closed envelope with no `Box<dyn>` — a `tell` moves the message into the queue slot (zero per-message alloc). - `Capacity(NonZeroUsize)`: makes both illegal capacities unrepresentable (0, and > tokio's MAX_PERMITS which panics), so `bounded` is infallible — validated at our own boundary rather than relying on tokio's panic. - bounded `send`/`try_send`/`recv`; `SendError`/`TrySendError::{Full,Closed}` carry the undelivered signal back to the caller. TDD: 5 tests (round-trip, backpressure hand-back, capacity boundaries, Stop-after-message FIFO). Clippy `--all-features` clean.
Continue the standalone mailbox (#112) through the behaviour-test tier. Production additions: - `close()`: graceful stop — reject new sends, still drain what's queued. - `Signal::LinkDied(Box<LinkDied>)`: the first control arm, deliberately boxed. A `size_of` test pins `Signal<Probe>` at 16 bytes, proving the cold fat variant does not inflate the hot message slot (large-variant discipline). - `WeakMailboxSender` + `downgrade`/`upgrade` and `Clone` on the sender: the non-pinning liveness handle death-watch is built on — `upgrade()` is `None` once the last strong sender drops. (This keeps kameo's WeakSender approach, the reason the generational-arena was dropped in #122.) - scaffold `ActorId`/`StopReason`/`LinkDied` placeholders (owned by #121/#113/ #120), present only to give the LinkDied arm a concrete shape. Tests (TDD): +10 → 15 total. Lifecycle (close-drains, send-after-drop, recv-none-after-drain), LinkDied round-trip + slot-size guard, weak-sender tracks-last-strong + upgraded-can-send, a real multi-thread linearizability test (8 senders × 64 msgs, Barrier-synced, FIFO-per-sender + no loss/dup), and two proptests (capacity boundaries incl. MAX±1/usize::MAX; single-sender FIFO round-trip over arbitrary sequences × capacities). Clippy --all-features clean.
Measure, don't assume, and wire the tooling into the pinned flake so every check is reproducible (no floating `nix run nixpkgs#…`). - criterion bench (`benches/mailbox.rs`): zero-box `tell` enqueue ≈ 5.6 ns; send+recv round-trip ≈ 21.4 ns. Baseline for the #114/#118 two-tier wiring. - cargo-mutants: added to the devShell (pinned) and exposed as an on-demand `packages.mutants` (`nix build .#mutants`, mirrors the coverage package) that fails if any mutant survives. Result on mailbox.rs: 0 missed — 6 caught, 30 unviable, 4 detected-via-timeout. - Killed the 2 initial survivors (the hand-written SendError/TrySendError Debug impls) with an explicit Debug-format assertion. Tests: +1 → 16. Clippy --all-features clean; flake nixfmt-clean.
…erral (#112) Add a bombay-core section to the coverage baseline: the `nix build .#mutants` reproducible mutation gate, the mailbox test/bench summary, and the decision to defer loom/shuttle DST to #116/#120 — the real tokio::sync::mpsc is opaque to both model checkers, so they land with the first bombay-owned concurrency (run-loop select / death-watch push), not on the tokio wrapper.
Measured guard for the zero-box decision's worst case: size_of::<Signal<A>>() = the largest Msg variant, so one fat inline command variant taxes every queue slot. Measured: small=16B, fat inline([u8;4096])=4104B, boxed=16B -> 4.10 MB vs 16 KB for 1_000 queued messages (256x). Boxing the fat variant (as Signal boxes LinkDied) restores the small slot. Documents + guards the tradeoff for #122.
…133) Do the channel survey #112's rigor contract required but skipped. Add a comparative benchmark (bombay-core/benches/channels.rs) of the real async candidates under the mailbox access pattern (N producers -> 1 consumer, bounded cap 256), on one shared tokio runtime for fairness, plus crossbeam as a sync throughput ceiling. Measured (aarch64, throughput, higher=better): uncontended 1->1 | contended 4->1 flume 25.7 M/s | 14.8 M/s <- winner async-channel 18.1 M/s | 11.1 M/s tokio::mpsc 13.4 M/s | 4.9 M/s <- v1 default, ~3x slower contended thingbuf 8.6 M/s | ~4.1 M/s <- slowest + needs T: Default Decision (ADR-0001, accepted): flume behind a channel seam — fastest async, executor-agnostic, move-based (no Default/Clone wart like thingbuf), lazily bounded (light idle memory). Sync designs (crossbeam/Vyukov/LMAX) are ceiling references only — the mailbox needs async recv. Establishes docs/adr/ as the decision-record practice.
…ox API (#133) Redesign the #112 mailbox on the accepted evidence (ADR-0001), fixing the two principal-grade gaps: an unsurveyed channel default and a non-composing API. - **Channel = flume** behind a seam: flume appears only inside MailboxSender/ WeakMailboxSender/MailboxReceiver, never in the public API. Swapping the primitive (no_std/Embassy for M6, or a deterministic DST channel) reimplements those three wrappers and nothing else. No premature trait for one impl. - **Composable API**: construction hangs off `Mailbox::<A>::bounded(cap)` instead of a free-floating `bounded()`; `Capacity` gains `TryFrom<usize/NonZeroUsize>` + a typed `CapacityError`. - **Pure transport**: send/try_send/recv/downgrade + `drain`. Dropped `close()` (flume has no receiver-side close) — graceful shutdown is `Signal::Stop` + `drain` at the run-loop (#116), where shutdown policy belongs. flume also beat tokio on the mailbox micro-bench: `tell` 5.6 -> 4.7 ns, send+recv 21.4 -> 13 ns. 18 tests green, clippy --all-features clean, mutation 0 missed. flume moves to a real (workspace) dependency; the channel-eval crates stay dev.
…ches (#133) u64 is the zero-box best case, not a typical message. Switch both benches to a realistic ~40-byte command (a few fields) so the by-value copy cost a real Signal slot pays is measured. Decision holds: flume still wins (26.0 M/s uncontended, 16.0 M/s contended 4->1; ~2x tokio, which stays slowest async). Ordering is payload-robust — u64 gave the same ends, only the middle two reordered. Mailbox: tell ~5.7 ns, roundtrip ~18.4 ns (still ~40% faster than the tokio v1). ADR-0001 + coverage doc updated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #133. Supersedes #132 (the #112 mailbox draft, now closed). The first part of the M1 core-rebuild epic (#122), redone to a principal-grade bar.
Why a redesign
The #112 mailbox shipped with two gaps its own rigor contract flagged:
tokio::sync::mpsc);bounded()+ a disconnectedCapacity).What changed
Channel = flume, on measured evidence (ADR-0001)
Benchmarked the real async candidates under the mailbox access pattern (N producers → 1 consumer, bounded), realistic ~40 B command payload, one shared runtime for fairness — see
benches/channels.rs+docs/adr/0001:T: Default—Signalcan't)flume wins ~2× tokio, is executor-agnostic, move-based, has weak senders (death-watch survives), and the pick is payload-robust (same ends at
u64). Sync designs (crossbeam/Vyukov/LMAX) are ceiling references — the mailbox needs asyncrecv.docs/adr/established as the decision-record practice.Composable API + channel seam
Mailbox::<A>::bounded(cap)(no free function);CapacitygainsTryFrom<usize/NonZeroUsize>+ a typedCapacityError.MailboxSender/WeakMailboxSender/MailboxReceiver, never the public API. Swapping the primitive (no_std/Embassy for M6, or a deterministic DST channel) reimplements those three wrappers and nothing else. Deliberately not a GAT trait for one impl — that lands at the second impl.Pure-transport shutdown
flume has no receiver-side
close(), which forced a cleaner separation: the mailbox is pure transport (send/try_send/recv/downgrade/drain); graceful shutdown isSignal::Stop+drainat the run-loop (#116), where the policy belongs.Verification
18 tests (sequence · lifecycle · boundary proptest incl.
0/MAX±1/usize::MAX· 8-threadBarrierlinearizability · FIFO proptest ·size_ofguards + monomorphic worst-case demo). Clippy--all-featuresclean. Mutation: 0 missed (nix build .#mutants, pinned). flume beat tokio on the mailbox micro-bench too (tell5.7 ns, round-trip 18.4 ns; ~40% faster than v1). Fullnix flake checkgreen locally.Deferred (recorded, not dropped)
loom/shuttle DST → #116/#120 (tokio/flume internals are opaque to both model checkers; the seam now hosts the deterministic impl). Fat-variant macro enforcement → #114 (design-risk note on #122). Everything reproducible via the pinned flake.