Skip to content

core(mailbox): rebuild from kameo reference — zero-box Signal queue + backpressure (#112)#132

Closed
joeldsouzax wants to merge 6 commits into
mainfrom
feat/112-mailbox-rebuild
Closed

core(mailbox): rebuild from kameo reference — zero-box Signal queue + backpressure (#112)#132
joeldsouzax wants to merge 6 commits into
mainfrom
feat/112-mailbox-rebuild

Conversation

@joeldsouzax

Copy link
Copy Markdown
Contributor

Closes #112. First part of the M1 core-rebuild epic (#122): the mailbox, built standalone in a fresh bombay-core crate (born under the god-level bar — no #61 quarantine) so it can be hard-tested before the run-loop is grafted on (#114/#116/#117).

What

  • Signal<A> = Message(A::Msg) | Stop | LinkDied(Box<LinkDied>) — a concrete, closed envelope, no Box<dyn>. tell moves the message into the queue slot (zero-alloc), vs kameo's Box<dyn DynMessage> heap-box per message.
  • Capacity(NonZeroUsize) validated 1..=MAX_PERMITSbounded is infallible. Both illegal capacities are unrepresentable: tokio panics on 0 and on > usize::MAX>>3 (verified empirically), so we validate at our own boundary instead of relying on tokio's panic (rule 4).
  • bounded send/try_send/recv/close; SendError/TrySendError::{Full,Closed} hand the undelivered signal back (rule 3).
  • WeakMailboxSender — non-pinning liveness (upgrade() is None once the last strong sender drops). Keeps kameo's WeakSender, the reason the generational-arena was dropped in epic: rebuild the runtime core part-by-part (kameo = reference oracle) #122.
  • Large-variant discipline: LinkDied boxed; a size_of guard pins Signal<Probe> at 16 bytes.

Rigor (17 tests, TDD)

Sequence/FIFO · lifecycle (close-drains / send-after-drop / recv-none) · defensive boundary (proptest 0,1,MAX-1,MAX,usize::MAX) · 8-thread Barrier linearizability (FIFO-per-sender + no loss/dup) · single-sender FIFO proptest · a measured worst-case demonstration of the monomorphic slot-size tax.

Reproducibility

Every check runs through the pinned flake — cargo-mutants added to the devShell + packages.mutants; no floating nix run nixpkgs#….

Design risk filed

The monomorphic zero-box decision's worst case (silent fat-variant memory/copy blowup — measured 4.10 MB vs 16 KB for 1k queued msgs; closed-enum expression problem for heterogeneous/M3 group addressing) is documented on #122 with carry-over actions on #114.

nix flake check green locally (exit 0, full matrix).

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.
@joeldsouzax

Copy link
Copy Markdown
Contributor Author

Superseded by the principal-grade redesign in #133. The v1 mailbox here shipped without the channel-primitive survey #112's contract required and with a non-composing API; #133 does the evaluation (ADR-0001 → flume, measured ~2× faster than the tokio default this PR used), adds the Mailbox::<A>::bounded namespace + channel seam, and carries all of this PR's work forward. Closing in favour of the #133 PR.

@joeldsouzax joeldsouzax closed this Jul 4, 2026
joeldsouzax added a commit that referenced this pull request Jul 4, 2026
…API (#133)

Rebuilt mailbox in the new bombay-core crate: flume channel behind a seam (ADR-0001, benched ~2x the tokio default), composable Mailbox::<A>::bounded API + Capacity TryFrom, pure-transport shutdown (Stop+drain). 18 tests, clippy clean, mutation 0 missed. Supersedes #132. Closes #133.
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): rebuild from kameo reference — queue + Signal + backpressure

1 participant