Skip to content

core(mailbox): principal-grade redesign — flume (ADR-0001) + composable Mailbox API (#133)#134

Merged
joeldsouzax merged 9 commits into
mainfrom
redesign/133-mailbox-principal
Jul 4, 2026
Merged

core(mailbox): principal-grade redesign — flume (ADR-0001) + composable Mailbox API (#133)#134
joeldsouzax merged 9 commits into
mainfrom
redesign/133-mailbox-principal

Conversation

@joeldsouzax

Copy link
Copy Markdown
Contributor

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:

  1. the channel primitive was chosen without the survey core(mailbox): rebuild from kameo reference — queue + Signal + backpressure #112 required (defaulted to tokio::sync::mpsc);
  2. the API didn't compose (a free-floating bounded() + a disconnected Capacity).

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:

Channel Uncontended Contended (4→1)
flume 26.0 M/s 16.0 M/s
async-channel 22.2 M/s 13.0 M/s
tokio::mpsc (v1 default) 13.1 M/s 5.8 M/s
thingbuf 14.1 M/s 8.3 M/s (needs T: DefaultSignal can'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 async recv. docs/adr/ established as the decision-record practice.

Composable API + channel seam

  • Construction hangs off Mailbox::<A>::bounded(cap) (no free function); Capacity gains TryFrom<usize/NonZeroUsize> + a typed CapacityError.
  • flume is hidden behind the seam — it appears only inside 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 is Signal::Stop + drain at the run-loop (#116), where the policy belongs.

Verification

18 tests (sequence · lifecycle · boundary proptest incl. 0/MAX±1/usize::MAX · 8-thread Barrier linearizability · FIFO proptest · size_of guards + monomorphic worst-case demo). Clippy --all-features clean. Mutation: 0 missed (nix build .#mutants, pinned). flume beat tokio on the mailbox micro-bench too (tell 5.7 ns, round-trip 18.4 ns; ~40% faster than v1). Full nix flake check green 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.

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.
@joeldsouzax joeldsouzax merged commit b0c212a into main Jul 4, 2026
6 checks passed
@joeldsouzax joeldsouzax deleted the redesign/133-mailbox-principal branch July 4, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core(mailbox): principal-grade redesign — evaluate channel primitive + composable API

1 participant