From cb5dc027deea5635d60c4d055d65a12556136a64 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 02:02:12 +0200 Subject: [PATCH 01/10] =?UTF-8?q?docs(message):=20design=20spec=20for=20#1?= =?UTF-8?q?14=20=E2=80=94=20Msg=20model=20+=20size-tripwire=20derive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-07-05-msg-model-size-tripwire-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-msg-model-size-tripwire-design.md diff --git a/docs/superpowers/specs/2026-07-05-msg-model-size-tripwire-design.md b/docs/superpowers/specs/2026-07-05-msg-model-size-tripwire-design.md new file mode 100644 index 0000000..15a0a3e --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-msg-model-size-tripwire-design.md @@ -0,0 +1,222 @@ +# `Msg` model + size-tripwire derive (card #114) — design + +> Part of the core-rebuild epic (#122). Card #114's body describes the kameo +> reference (`Message`, `Context`, `DynMessage`/`BoxMessage`), but the card's +> **finalized comments** override that with the closed per-actor `Msg`-enum model: +> no open `Handler`, one closed `Msg` enum per actor, `handle` on the `Actor` +> trait (#116), the typed reply port on #115. Under that model, this card's real +> deliverable is the **`Msg` marker trait** plus a **compile-time slot-size +> tripwire derive** — the "box-oversized-variants discipline, first-class, not +> left to the `large_enum_variant` clippy lint" the #122 design-risk note calls +> for. + +## The gap this closes + +The mailbox (#133) queues `Signal` **by value** — no per-message heap box. +That is the whole point of the model (zero-alloc `tell`), but it has one sharp +edge, measured in the #122 note: a `tokio`/`flume` slot costs +`size_of::>()` = the size of the actor's **largest** `Msg` variant, so +a single fat inline variant taxes *every* queued slot, even tiny messages +(measured `4104 B` inline vs `16 B` boxed → **256×** for 1 000 queued messages), +and a by-value `send` `memcpy`s the whole message, so zero-box inverts to +pessimal above ~a cache line. + +`mailbox.rs` already guards its **own** `Signal` layout (`LinkDied` is boxed; the +`link_died_variant_is_boxed_so_message_slots_stay_small` test pins it). Nothing +yet extends that discipline to the **user's** `Msg` type — and `clippy`'s +`large_enum_variant` only fires above 200 B and can be `#[allow]`d away. This +card makes the budget a **real compile error** the user opts into, with an +explicit escape. + +## Decisions (locked with the card owner) + +1. **Scope** — `Msg` marker trait (`bombay-core`) + `#[derive(Msg)]` size + tripwire (`macros`). `handle` stays with #116, the reply port with #115. +2. **Mechanism A — opt-in derive.** Not a mandatory `Mailboxed::Msg: Msg` bound + (that would break the mailbox's deliberate "`Msg` is any concrete type" + design — its `u64`/`(u32, u32)` test actors — and over-constrain + single-message actors). Not a #116 run-loop guard (wrong card, and its error + would point at `Signal` not the user's enum). The derive is the blessed + path; "first-class" = a real compile error, not a silence-able lint. +3. **Default budget `SLOT_BUDGET = 256`** (4 cache lines) — fits identity-bearing + commands carrying several KERI AIDs/hashes (~32 B each) yet trips the KB-scale + inline blob. Overridable per type via `#[msg(budget = N)]`. + +## Architecture — two pieces, one boundary + +### 1. `bombay-core/src/message.rs` (new module) + +A marker trait, nothing more: + +```rust +/// The seam a mailbox and run-loop dispatch on: an actor's single closed +/// message type, stored in a queue slot **by value**. +/// +/// `Send + 'static` for now; #9 relaxes `Send` → the cfg-gated `MaybeSend` +/// for single-threaded client builds. #116 may bound its `Actor::Msg: Msg`; +/// this card does not touch `Mailboxed`, so arbitrary `type Msg` still compiles. +pub trait Msg: Send + 'static { + /// Per-slot byte budget. A mailbox queues `Signal` by value, so every + /// slot costs `size_of` of the largest `Msg` variant; this bounds it. + /// Default 256 B; `#[derive(Msg)]` overrides it from `#[msg(budget = N)]`. + const SLOT_BUDGET: usize = 256; +} +``` + +Wired into `bombay-core/src/lib.rs` as `pub mod message;`. It deliberately does +**not** tighten `Mailboxed::Msg` (still `Send + 'static`) — Mechanism A. + +### 2. `macros/src/derive_msg.rs` (new), wired in `macros/src/lib.rs` + +`#[proc_macro_derive(Msg, attributes(msg))]`: + +- **Accepts** a *concrete* `struct` or `enum` (a single-command actor's message + can be a struct). +- **Rejects** — with a clear `compile_error!`, not a downstream const-eval error: + - a **generic** type — a pure marker trait has no monomorphized site to hang a + generic const-assert on, so silently skipping the check would be a lie. + (YAGNI: a follow-up card handles generic `Msg` if a real need appears.) + - a **union**. +- **Generates**: + ```rust + impl ::bombay_core::message::Msg for T { + // emitted ONLY when #[msg(budget = N)] is present: + const SLOT_BUDGET: usize = N; + } + + const _: () = ::core::assert!( + ::core::mem::size_of::() + <= ::SLOT_BUDGET, + "`T` exceeds its Msg::SLOT_BUDGET — box the largest variant (as Signal \ + boxes LinkDied), or raise it with #[msg(budget = N)]", + ); + ``` + The macro cannot read `size_of` itself (it sees only tokens), so the budget + check is a **generated `const` static-assert** evaluated at const-eval / + monomorphization. The derive knows the type's identifier at expansion, so it + bakes the enum name into the message with `format!` (producing a plain + `&'static str` literal — const-`assert!` messages must be literals, so no + runtime formatting). The assert references `::SLOT_BUDGET`, so the + override flows through uniformly whether defaulted or set. + +**Path convention — `::bombay_core`, not `::bombay`.** The vendored derives emit +`::bombay::…` because they target the umbrella crate, but the umbrella **does not +yet depend on or re-export `bombay-core`** — the M1 spine is deliberately +standalone until the whole core lands (#112–#121; see the `bombay-core/src/lib.rs` +doc: "the surface is settled once the whole core lands"). So the `Msg` derive +emits `::bombay_core::message::Msg`, the crate where the trait actually lives. +The derive crate (`bombay_macros`) does **not** need a `bombay-core` dependency — +it only emits a path token; the *deriving* crate provides the trait. When the +spine lands and the umbrella re-exports it, a follow-up switches the path to +`::bombay` (or adopts `proc-macro-crate` to resolve renames robustly). + +**Remedy vs escape.** The primary remedy for a tripped budget is **boxing the fat +variant** (as `Signal` boxes `LinkDied`). The `#[msg(budget = N)]` attribute is +the deliberate, greppable escape for a measured, genuinely-large message — visible +in review, unlike an `#[allow]`. + +### Re-export (deferred, serde-style) + +The trait (`bombay_core::message::Msg`) and the derive (`bombay_macros::Msg`) +share the name `Msg` but live in different namespaces (type vs macro), so they +*can* be co-re-exported and `use …::Msg` brings in both — exactly as +`serde::Serialize` does. During M1 the derive tests import `bombay_core::message::Msg` +and `bombay_macros::Msg` directly (the spine isn't behind the umbrella yet); +folding both into a single `bombay::Msg` re-export is part of the umbrella +re-wiring when the whole core lands. + +## Data flow + +1. A user writes their closed command enum and `#[derive(Msg)]`s it. +2. At compile time the derive emits `impl Msg` + the `const _` static-assert. +3. If the enum's largest variant pushes `size_of` over the (possibly overridden) + budget, const-eval fails the build with a message naming the enum and the + remedy. Otherwise it compiles and the type is usable wherever `M: Msg` is + required (e.g. #116's `Actor::Msg`, if it opts into the bound). +4. Runtime is unchanged: `Msg` is a marker; the mailbox stores `Signal::Message(msg)` + by value exactly as before. + +## Testing (rule 7 categories + the card's "hard as fuck" bar) + +Three tiers, all inside the **existing** gate (`cargoNextest` + `cargoDocTest`) — +no new test runner, no `trybuild` (it shells out to `cargo` at test-time, which +crane's offline sandbox breaks). **Verified empirically:** doctests *do* run for a +`proc-macro` crate under this toolchain (a probe `//!` doctest failed as expected; +the vendored derives' examples are `​```ignore`d, which is why they don't). + +**Tier 1 — compile-fail via paired `compile_fail` doctests** (on the derive's doc +comment, run by `cargoDocTest`). Each guard is a **pass / fail / fixed triple** so +"fails to compile" is attributable to the budget, not an unrelated error (rule 8: +fails for the *right* reason). The paired *pass* doctest also forces +`::bombay_core::message::Msg` to resolve, so the *fail* doctest can only be failing +on the budget: +- within budget → compiles; +- fat inline variant (`Bulk([u8; 4096])`) → `compile_fail` (tripwire fires); +- boxed variant (`Bulk(Box<[u8; 4096]>)`) → compiles (the remedy, mirrors + `Signal`/`LinkDied`); +- `#[msg(budget = 8192)]` on the fat enum → compiles (escape works); +- **defensive boundary:** `#[derive(Msg)]` on a **generic** type and on a **union** + → `compile_fail` (the derive's own `compile_error!`). + +**Tier 2 — generated-impl behaviour via native runtime tests** (`macros/tests/`, +run by nextest; the derive expands at test-crate compile time, asserts at +runtime): +- a small enum and a struct derive → `::SLOT_BUDGET == 256` (impl + emitted, default flows through); +- `#[msg(budget = 8192)]` enum → `::SLOT_BUDGET == 8192` (override + emitted). These crates depend on `bombay_core` + `bombay_macros` as normal + deps; bombay-core's *own* tests never derive (a crate can't name itself + `::bombay_core` without an `extern crate self` alias), so the derive is only + ever exercised from `macros`. + +**Tier 3 — `parse_budget` unit tests** (`#[cfg(test)]` in `derive_msg.rs`, native; +`proc-macro2`/`syn` types work in unit tests): `#[msg(budget = N)]` → `Some(N)`; +absent → `None`; malformed (`budget = "x"`, bare `budget`, unknown key) → `Err`. +This is the derive's only real branching logic — exhaustively covered so a +mutation (default selection, the parse, the key match) is killed. + +**`bombay-core` — the trait** (native tests, and under the `--package bombay-core` +`cargo-mutants` gate → zero survivors): +- `Msg::SLOT_BUDGET` default is exactly `256` (pins the constant, like the + mailbox's `Capacity::MAX` test — kills a budget-constant mutation); +- a hand-written `impl Msg` overriding `SLOT_BUDGET` compiles (the escape hatch + the derive automates) and the trait is usable as a `fn f()` bound. + +**Mutants scope note:** the gate runs `cargo mutants --package bombay-core`, so the +`Msg` trait is under the zero-survivors bar; the `macros` derive is not (extending +the gate to a proc-macro crate risks nesting cargo invocations in the sandbox for +little gain). The derive's sole branching logic — `parse_budget` — is instead +pinned by exhaustive Tier-3 unit tests; the rest is token assembly (mutation there +is noise). + +## Documentation impact — coverage baseline, NOT the README public-API section + +The README's *"public API at a glance"* documents the **umbrella `bombay::prelude`**, +which still ships the *old kameo* surface (`Message`, the old `SendError`). The +rebuilt `bombay-core` spine is deliberately **not public yet** ("the surface is +settled once the whole core lands"), so #133 (mailbox) and #113 (error) did **not** +advertise their new types in the README — they recorded the module + tests in +[`docs/testing/coverage-baseline.md`](../../testing/coverage-baseline.md). #114 +follows that exact precedent: adding `Msg` to the README now would misdescribe the +shipped API. → **Update `docs/testing/coverage-baseline.md`** with the new +`message` module and the `macros` derive tests; leave the README untouched. The +`Msg`/`#[derive(Msg)]` surface joins the README when the umbrella re-export lands +with the rest of the spine. + +## Sequencing / boundaries + +- **Branch `core/114-msg-model-tripwire` off `main`.** The `Msg` trait has no + dependency on the #113 error types, so #114 is independently mergeable and does + not inherit PR #135's open state. (Confirmed: `main` has `mailbox.rs` but not + `error.rs`.) +- **Does not touch `Mailboxed`** (substrate) or the run-loop (#116). +- **Leaves `handle` to #116** and the **reply port to #115**. #116 may bound + `Actor::Msg: Msg`; that is #116's decision. + +## Out of scope (YAGNI) + +- Auto-boxing oversized variants (the model owner chose a **tripwire, not + auto-box** — the user boxes deliberately, as `Signal` does). +- Generic `Msg` types (rejected with a clear error; revisit only on real need). +- Any `Context`, `handle`, reply-port, or `DynMessage`/`BoxMessage` surface from + the card's kameo-reference body — superseded by the closed-enum model. From b2332b218905d83fa4a7902e91e00441bf1b7825 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 02:20:18 +0200 Subject: [PATCH 02/10] =?UTF-8?q?docs(message):=20implementation=20plan=20?= =?UTF-8?q?for=20#114=20=E2=80=94=20Msg=20model=20+=20size-tripwire=20deri?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-05-msg-model-size-tripwire.md | 755 ++++++++++++++++++ 1 file changed, 755 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-msg-model-size-tripwire.md diff --git a/docs/superpowers/plans/2026-07-05-msg-model-size-tripwire.md b/docs/superpowers/plans/2026-07-05-msg-model-size-tripwire.md new file mode 100644 index 0000000..c4874df --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-msg-model-size-tripwire.md @@ -0,0 +1,755 @@ +# `Msg` Model + Size-Tripwire Derive (card #114) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give bombay-core a `Msg` marker trait carrying a per-slot byte budget, plus a `#[derive(Msg)]` that emits a compile-time slot-size tripwire so a fat inline message variant fails the build instead of silently taxing every mailbox queue slot. + +**Architecture:** Two pieces. (1) `bombay-core/src/message.rs` — a `pub trait Msg: Send + 'static { const SLOT_BUDGET: usize = 256; }` marker, no coupling to `Mailboxed` (arbitrary `type Msg` still compiles). (2) `macros/src/derive_msg.rs` — `#[proc_macro_derive(Msg, attributes(msg))]` that emits `impl ::bombay_core::message::Msg` plus a generated `const _: () = assert!(size_of::() <= SLOT_BUDGET, …)` static-assert; `#[msg(budget = N)]` overrides the budget; generics and unions are rejected with a clear `compile_error!`. + +**Tech Stack:** Rust edition 2024, `syn` 2.0 / `quote` / `proc-macro2` (already deps of `macros`), Nix flake gate (`cargoNextest` + `cargoDocTest`). No new runtime deps; one dev-dep (`bombay-core`) added to `macros`. + +**Design spec:** [`docs/superpowers/specs/2026-07-05-msg-model-size-tripwire-design.md`](../specs/2026-07-05-msg-model-size-tripwire-design.md) + +**Conventions (non-negotiable — from CLAUDE.md + repo):** +- **TDD:** write the failing test, watch it fail, then implement. Use `superpowers:test-driven-development`. +- **God-level clippy bar applies to ALL new code.** `derive_msg.rs` and `message.rs` are NEW → they carry **no** `#[allow]` quarantine header (that block is only on vendored kameo files). No `unwrap`/`expect`/`panic` in production; functions ≤80 lines, ≤5 args, cognitive-complexity ≤9; all `use` at file top; doc every `pub` item. +- **Gate:** `nix develop --command cargo …` for iterating; `nix flake check` is the authoritative gate. Never invoke a raw `/nix/store` path. +- **Commits:** conventional, scoped `core(message)` / `macros(msg)`; **no** Claude/Anthropic attribution. +- **Branch:** already on `core/114-msg-model-tripwire` (off `main`). + +--- + +## File Structure + +- **Create** `bombay-core/src/message.rs` — the `Msg` trait + its trait-level tests. One responsibility: the message-model marker. +- **Modify** `bombay-core/src/lib.rs` — add `pub mod message;`. +- **Create** `macros/src/derive_msg.rs` — `DeriveMsg` (`Parse` + `ToTokens`) and the `parse_budget` helper + unit tests. One responsibility: the derive. +- **Modify** `macros/src/lib.rs` — `mod derive_msg;`, `use`, and the `#[proc_macro_derive(Msg, attributes(msg))]` entry point (with the paired `compile_fail` doctests). +- **Modify** `macros/Cargo.toml` — add `[dev-dependencies] bombay-core` (path) so the runtime tests + doctests can resolve `::bombay_core`. +- **Create** `macros/tests/derive_msg.rs` — native runtime tests of the generated impl + budget override. +- **Modify** `docs/testing/coverage-baseline.md` — record the new `message` module + `macros` derive tests. (README stays untouched — bombay-core is not yet behind the umbrella; matches #113/#133.) + +--- + +## Task 1: `Msg` marker trait in bombay-core + +**Files:** +- Create: `bombay-core/src/message.rs` +- Modify: `bombay-core/src/lib.rs` +- Test: `bombay-core/src/message.rs` (`#[cfg(test)] mod tests`) + +- [ ] **Step 1: Write the failing test** + +Create `bombay-core/src/message.rs` with ONLY the module doc + the test module (no trait yet), so the test fails to compile first: + +```rust +//! The `Msg` marker trait: an actor's single closed message type (card #114). +//! +//! A mailbox queues `Signal` **by value**, so every slot costs `size_of` of +//! the largest `A::Msg` variant. `Msg` carries the per-slot byte budget that +//! bounds it; `#[derive(Msg)]` (the `bombay_macros` crate) implements this trait +//! and emits a compile-time static-assert that trips when the budget is exceeded. +//! +//! This module deliberately does **not** tighten `mailbox::Mailboxed::Msg` +//! (still `Send + 'static`): arbitrary `type Msg` stays legal, and `#116` decides +//! whether `Actor::Msg` bounds `: Msg`. + +#[cfg(test)] +mod tests { + use super::Msg; + + struct Ping; + impl Msg for Ping {} + + struct Roomy; + impl Msg for Roomy { + const SLOT_BUDGET: usize = 4096; + } + + /// The default slot budget is exactly 256 B (4 cache lines) — pins the + /// constant so a mutation to it is caught (like the mailbox's `Capacity::MAX`). + #[test] + fn slot_budget_defaults_to_256() { + assert_eq!(::SLOT_BUDGET, 256); + } + + /// The budget is overridable by hand — the escape hatch `#[derive(Msg)]` + /// automates via `#[msg(budget = N)]`. + #[test] + fn slot_budget_is_overridable() { + assert_eq!(::SLOT_BUDGET, 4096); + } + + /// `Msg` is a usable generic bound (what `#116`'s `Actor::Msg` would rest on). + #[test] + fn msg_is_usable_as_a_generic_bound() { + fn budget_of() -> usize { + M::SLOT_BUDGET + } + assert_eq!(budget_of::(), 256); + } +} +``` + +Add to `bombay-core/src/lib.rs` after `pub mod mailbox;`: + +```rust +pub mod message; +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `nix develop --command cargo test -p bombay-core --lib message` +Expected: FAIL — compile error, `cannot find trait Msg in this scope` (trait not defined yet). + +- [ ] **Step 3: Write the minimal implementation** + +Insert the trait into `bombay-core/src/message.rs` between the module doc and the `#[cfg(test)]` block: + +```rust +/// An actor's single closed message type, stored in a mailbox slot **by value**. +/// +/// `Send + 'static` for now; `#9` relaxes `Send` to the cfg-gated `MaybeSend` +/// for single-threaded client builds. Implement with `#[derive(Msg)]` — it also +/// emits the slot-size tripwire — or by hand when you have a measured reason to +/// set a non-default [`SLOT_BUDGET`](Msg::SLOT_BUDGET). +pub trait Msg: Send + 'static { + /// The per-slot byte budget for this message type. A mailbox queues by + /// value, so this bounds `size_of` of the largest variant; the derive trips + /// the build if `size_of::()` exceeds it. Default 256 B (4 cache + /// lines) — enough for identity-bearing commands (several AIDs/hashes), tight + /// enough to catch the KB-scale inline blob. + const SLOT_BUDGET: usize = 256; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `nix develop --command cargo test -p bombay-core --lib message` +Expected: PASS — 3 tests pass. + +- [ ] **Step 5: Run the gate** + +Run: `nix flake check` +Expected: green (fmt, clippy, nextest, doctest all pass). If clippy flags a missing doc, add it; if fmt complains, run `nix develop --command cargo fmt`. + +- [ ] **Step 6: Commit** + +```bash +git add bombay-core/src/message.rs bombay-core/src/lib.rs +git commit -m "core(message): Msg marker trait with default slot budget (#114)" +``` + +--- + +## Task 2: `#[derive(Msg)]` — generated impl + default budget (native runtime test) + +Builds the derive minimally: emit `impl Msg` (no budget override, no tripwire, no validation yet). Proven by a native runtime test that the derive expands and the default budget flows through. + +**Files:** +- Modify: `macros/Cargo.toml` +- Create: `macros/src/derive_msg.rs` +- Modify: `macros/src/lib.rs` +- Test: `macros/tests/derive_msg.rs` + +- [ ] **Step 1: Add the dev-dependency** + +In `macros/Cargo.toml`, add a `[dev-dependencies]` section (after `[dependencies]`, before `[lints]`): + +```toml +[dev-dependencies] +bombay-core = { path = "../bombay-core" } +``` + +- [ ] **Step 2: Write the failing test** + +Create `macros/tests/derive_msg.rs`: + +```rust +//! Runtime behaviour of `#[derive(Msg)]`: the generated impl and the budget +//! override, exercised natively (the derive expands at this crate's compile +//! time, assertions run under nextest). Compile-fail behaviour (the tripwire, +//! generics, unions) lives in the paired `compile_fail` doctests on the derive. + +use bombay_core::message::Msg; + +#[derive(bombay_macros::Msg)] +enum Small { + Ping, + Pong(u64), +} + +#[derive(bombay_macros::Msg)] +struct Unit; + +/// The derive emits `impl Msg`, and an un-annotated type gets the default budget. +#[test] +fn derive_emits_impl_with_default_budget() { + assert_eq!(::SLOT_BUDGET, 256); + assert_eq!(::SLOT_BUDGET, 256); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `nix develop --command cargo test -p bombay_macros --test derive_msg` +Expected: FAIL — `cannot find derive macro Msg` (the derive doesn't exist yet). + +- [ ] **Step 4: Write the minimal derive** + +Create `macros/src/derive_msg.rs` (NEW code — NO `#[allow]` quarantine header): + +```rust +//! `#[derive(Msg)]` — implements the `Msg` marker trait and (from Task 3) emits +//! a compile-time slot-size tripwire. See card #114 and the design spec. + +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; +use syn::{ + DeriveInput, Ident, + parse::{Parse, ParseStream}, +}; + +/// A parsed `#[derive(Msg)]` input: the message type's identifier. +pub struct DeriveMsg { + ident: Ident, +} + +impl Parse for DeriveMsg { + fn parse(input: ParseStream) -> syn::Result { + let input: DeriveInput = input.parse()?; + Ok(Self { ident: input.ident }) + } +} + +impl ToTokens for DeriveMsg { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.ident; + tokens.extend(quote! { + #[automatically_derived] + impl ::bombay_core::message::Msg for #ident {} + }); + } +} +``` + +Wire it into `macros/src/lib.rs`. Add near the other `mod` lines (top of file): + +```rust +mod derive_msg; +``` + +Add to the `use` group: + +```rust +use derive_msg::DeriveMsg; +``` + +Add the entry point next to the other `#[proc_macro_derive]` fns (e.g. after `derive_reply`): + +```rust +/// Derive the [`Msg`](https://docs.rs/bombay-core/latest/bombay_core/message/trait.Msg.html) +/// marker trait and emit a compile-time slot-size tripwire. +/// +/// A mailbox queues messages by value, so a fat inline variant taxes every +/// queue slot. This derive trips the build when `size_of` of the message exceeds +/// its `Msg::SLOT_BUDGET` (default 256 B). Box the largest variant to fix it, or +/// raise the budget with `#[msg(budget = N)]`. +/// +/// Within budget — compiles: +/// ``` +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Ok { Small(u64) } +/// ``` +#[proc_macro_derive(Msg, attributes(msg))] +pub fn derive_msg(input: TokenStream) -> TokenStream { + let derive_msg = parse_macro_input!(input as DeriveMsg); + TokenStream::from(derive_msg.into_token_stream()) +} +``` + +(`TokenStream`, `parse_macro_input`, and `ToTokens` are already imported at the top of `lib.rs`.) + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `nix develop --command cargo test -p bombay_macros --test derive_msg` +Expected: PASS — `derive_emits_impl_with_default_budget` passes. + +- [ ] **Step 6: Run the gate** + +Run: `nix flake check` +Expected: green. The new `Ok` doctest on `derive_msg` compiles and runs (proc-macro doctests execute — verified). If clippy flags `derive_msg.rs`, fix it up to the bar (no quarantine header). + +- [ ] **Step 7: Commit** + +```bash +git add macros/Cargo.toml macros/src/derive_msg.rs macros/src/lib.rs macros/tests/derive_msg.rs +git commit -m "macros(msg): #[derive(Msg)] emits the Msg impl (#114)" +``` + +--- + +## Task 3: The slot-size tripwire (paired `compile_fail` doctests) + +Adds the generated `const` static-assert and its paired pass/fail/fixed doctests. This is the heart of the card. + +**Files:** +- Modify: `macros/src/derive_msg.rs` (add the assert to `to_tokens`) +- Modify: `macros/src/lib.rs` (add the paired doctests to the derive's doc comment) + +- [ ] **Step 1: Write the failing test (the compile_fail doctest)** + +In `macros/src/lib.rs`, extend the `derive_msg` doc comment (added in Task 2) with the fail + fixed doctests, directly under the "Within budget — compiles" block: + +```rust +/// A fat inline variant trips the budget: +/// ```compile_fail +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Bad { Bulk([u8; 4096]) } +/// ``` +/// +/// Boxing the fat variant fixes it (as `Signal` boxes `LinkDied`): +/// ``` +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Fixed { Bulk(Box<[u8; 4096]>) } +/// ``` +``` + +- [ ] **Step 2: Run the doctests to verify the tripwire is missing** + +Run: `nix develop --command cargo test -p bombay_macros --doc` +Expected: FAIL — the `Bad` block is marked `compile_fail` but currently **compiles** (no assert yet), so the doctest harness reports it as an unexpected success: +`Test compiled successfully, but it's marked \`compile_fail\`.` + +- [ ] **Step 3: Add the static-assert to the derive** + +Replace the `to_tokens` impl in `macros/src/derive_msg.rs` with the version that emits the tripwire: + +```rust +impl ToTokens for DeriveMsg { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.ident; + let over_budget = format!( + "`{ident}` exceeds its Msg::SLOT_BUDGET — box the largest variant \ + (as Signal boxes LinkDied), or raise it with #[msg(budget = N)]" + ); + tokens.extend(quote! { + #[automatically_derived] + impl ::bombay_core::message::Msg for #ident {} + + const _: () = ::core::assert!( + ::core::mem::size_of::<#ident>() + <= <#ident as ::bombay_core::message::Msg>::SLOT_BUDGET, + #over_budget + ); + }); + } +} +``` + +- [ ] **Step 4: Run the doctests to verify they pass** + +Run: `nix develop --command cargo test -p bombay_macros --doc` +Expected: PASS — `Ok` and `Fixed` compile; `Bad` now fails to compile, satisfying `compile_fail`. + +- [ ] **Step 5: Verify the runtime tests still pass** + +Run: `nix develop --command cargo test -p bombay_macros --test derive_msg` +Expected: PASS — `Small`/`Unit` are within budget, so the added assert is satisfied. + +- [ ] **Step 6: Run the gate** + +Run: `nix flake check` +Expected: green. + +- [ ] **Step 7: Commit** + +```bash +git add macros/src/derive_msg.rs macros/src/lib.rs +git commit -m "macros(msg): compile-time slot-size tripwire + paired doctests (#114)" +``` + +--- + +## Task 4: `#[msg(budget = N)]` override + `parse_budget` unit tests + +Adds the budget attribute (raises/lowers the per-type budget) and exhaustive unit tests on the parse logic — the derive's only real branching, the part `cargo-mutants` would probe. + +**Files:** +- Modify: `macros/src/derive_msg.rs` (add `budget` field, `parse_budget`, override const, unit tests) +- Modify: `macros/tests/derive_msg.rs` (runtime override test) +- Modify: `macros/src/lib.rs` (doctest: `#[msg(budget = N)]` lets a fat enum compile) + +- [ ] **Step 1: Write the failing unit tests (parse logic)** + +Append a `#[cfg(test)]` module to `macros/src/derive_msg.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::parse_budget; + use syn::{Attribute, parse_quote}; + + fn attrs(attr: Attribute) -> Vec { + vec![attr] + } + + #[test] + fn budget_attribute_yields_its_value() { + let parsed = parse_budget(&attrs(parse_quote!(#[msg(budget = 8192)]))).unwrap(); + assert_eq!(parsed, Some(8192)); + } + + #[test] + fn absent_attribute_yields_none() { + let parsed = parse_budget(&attrs(parse_quote!(#[derive(Clone)]))).unwrap(); + assert_eq!(parsed, None); + } + + #[test] + fn non_integer_budget_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(budget = "x")]))).is_err()); + } + + #[test] + fn bare_budget_without_value_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(budget)]))).is_err()); + } + + #[test] + fn unknown_msg_key_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(limit = 8)]))).is_err()); + } +} +``` + +> Note: `unwrap()` is allowed here because this is a `#[cfg(test)]` module (the god-level bar bans `unwrap` in production, not tests). Keep it out of the non-test code. + +- [ ] **Step 2: Run to verify it fails** + +Run: `nix develop --command cargo test -p bombay_macros --lib` +Expected: FAIL — `cannot find function parse_budget` (not defined yet). + +- [ ] **Step 3: Implement `parse_budget` + wire the override** + +In `macros/src/derive_msg.rs`, extend the imports: + +```rust +use syn::{ + Attribute, DeriveInput, Ident, LitInt, + parse::{Parse, ParseStream}, +}; +``` + +Add the `budget` field to the struct: + +```rust +/// A parsed `#[derive(Msg)]` input: the type's identifier and an optional +/// per-type slot budget from `#[msg(budget = N)]`. +pub struct DeriveMsg { + ident: Ident, + budget: Option, +} +``` + +Update `Parse` to read the budget: + +```rust +impl Parse for DeriveMsg { + fn parse(input: ParseStream) -> syn::Result { + let input: DeriveInput = input.parse()?; + let budget = parse_budget(&input.attrs)?; + Ok(Self { ident: input.ident, budget }) + } +} +``` + +Update `to_tokens` to emit the override const when present: + +```rust +impl ToTokens for DeriveMsg { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.ident; + let budget_const = self + .budget + .map(|n| quote! { const SLOT_BUDGET: usize = #n; }); + let over_budget = format!( + "`{ident}` exceeds its Msg::SLOT_BUDGET — box the largest variant \ + (as Signal boxes LinkDied), or raise it with #[msg(budget = N)]" + ); + tokens.extend(quote! { + #[automatically_derived] + impl ::bombay_core::message::Msg for #ident { + #budget_const + } + + const _: () = ::core::assert!( + ::core::mem::size_of::<#ident>() + <= <#ident as ::bombay_core::message::Msg>::SLOT_BUDGET, + #over_budget + ); + }); + } +} +``` + +Add the `parse_budget` free function (below the impls, above `#[cfg(test)]`): + +```rust +/// Extracts `budget = N` from `#[msg(...)]` attributes, if present. Errors on a +/// non-integer value, a bare `budget`, or any key other than `budget`. +fn parse_budget(attrs: &[Attribute]) -> syn::Result> { + let mut budget = None; + for attr in attrs.iter().filter(|attr| attr.path().is_ident("msg")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("budget") { + budget = Some(meta.value()?.parse::()?.base10_parse()?); + Ok(()) + } else { + Err(meta.error("unknown `msg` key; the only key is `budget`")) + } + })?; + } + Ok(budget) +} +``` + +- [ ] **Step 4: Run the unit tests to verify they pass** + +Run: `nix develop --command cargo test -p bombay_macros --lib` +Expected: PASS — all 5 `parse_budget` tests pass. + +- [ ] **Step 5: Add + run the runtime override test** + +Append to `macros/tests/derive_msg.rs`: + +```rust +#[derive(bombay_macros::Msg)] +#[msg(budget = 8192)] +enum Roomy { + Bulk([u8; 4096]), +} + +/// `#[msg(budget = N)]` overrides the default, and a message within the raised +/// budget still compiles (the assert reads the overridden const). +#[test] +fn budget_attribute_overrides_the_default() { + assert_eq!(::SLOT_BUDGET, 8192); +} +``` + +Run: `nix develop --command cargo test -p bombay_macros --test derive_msg` +Expected: PASS — `Roomy` compiles (4096 ≤ 8192) and reports budget 8192. + +- [ ] **Step 6: Add the override doctest** + +In `macros/src/lib.rs`, append to the `derive_msg` doc comment: + +```rust +/// Or raise the budget for a deliberately large message: +/// ``` +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// #[msg(budget = 8192)] +/// enum Big { Bulk([u8; 4096]) } +/// ``` +``` + +Run: `nix develop --command cargo test -p bombay_macros --doc` +Expected: PASS. + +- [ ] **Step 7: Run the gate** + +Run: `nix flake check` +Expected: green. + +- [ ] **Step 8: Commit** + +```bash +git add macros/src/derive_msg.rs macros/src/lib.rs macros/tests/derive_msg.rs +git commit -m "macros(msg): #[msg(budget = N)] override + parse_budget tests (#114)" +``` + +--- + +## Task 5: Defensive boundary — reject generics and unions + +The derive rejects what it cannot correctly check: a generic type (a marker trait has no monomorphized site for a generic const-assert) and a union. Both surface as the derive's own `compile_error!` via `parse_macro_input!`. + +**Files:** +- Modify: `macros/src/derive_msg.rs` (validation in `Parse`) +- Modify: `macros/src/lib.rs` (compile_fail doctests for generic + union) + +- [ ] **Step 1: Write the failing tests (compile_fail doctests)** + +In `macros/src/lib.rs`, append to the `derive_msg` doc comment: + +```rust +/// The derive needs a concrete type — a generic is rejected: +/// ```compile_fail +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Generic { A(T) } +/// ``` +/// +/// Unions are rejected (structs and enums only): +/// ```compile_fail +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// union U { a: u32, b: u64 } +/// ``` +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `nix develop --command cargo test -p bombay_macros --doc` +Expected: FAIL — the **union** block is the clean red signal: the un-validated derive emits `impl Msg for U {}` and `size_of` works on unions, so `U` compiles and the `compile_fail` block reports an unexpected success. + +> The **generic** block may *already* fail to compile — but for the **wrong reason**: the un-validated derive emits `impl ::bombay_core::message::Msg for Generic {}`, dropping ``, so rustc errors with "missing generics for enum `Generic`", not our rejection. `compile_fail` can't tell the two apart. Step 3 makes the rejection **explicit and correct** (our `compile_error!` fires first, before any malformed impl), which is the point of this task — so proceed regardless of whether the generic block is currently red or green. + +- [ ] **Step 3: Add validation to `Parse`** + +In `macros/src/derive_msg.rs`, extend imports with `Data`: + +```rust +use syn::{ + Attribute, Data, DeriveInput, Ident, LitInt, + parse::{Parse, ParseStream}, +}; +``` + +Replace the `Parse` impl with the validating version: + +```rust +impl Parse for DeriveMsg { + fn parse(input: ParseStream) -> syn::Result { + let input: DeriveInput = input.parse()?; + + if let Some(param) = input.generics.params.first() { + return Err(syn::Error::new_spanned( + param, + "`#[derive(Msg)]` needs a concrete type: the slot-size tripwire \ + cannot size an unmonomorphized generic", + )); + } + if let Data::Union(data) = &input.data { + return Err(syn::Error::new_spanned( + data.union_token, + "`#[derive(Msg)]` supports structs and enums, not unions", + )); + } + + let budget = parse_budget(&input.attrs)?; + Ok(Self { ident: input.ident, budget }) + } +} +``` + +- [ ] **Step 4: Run the doctests to verify they pass** + +Run: `nix develop --command cargo test -p bombay_macros --doc` +Expected: PASS — `Generic` and `U` now fail to compile with the derive's own error, satisfying `compile_fail`; the positive doctests still compile. + +- [ ] **Step 5: Verify runtime + unit tests still pass** + +Run: `nix develop --command cargo test -p bombay_macros` +Expected: PASS — the derive still accepts the concrete structs/enums in the runtime tests. + +- [ ] **Step 6: Run the gate** + +Run: `nix flake check` +Expected: green. + +- [ ] **Step 7: Commit** + +```bash +git add macros/src/derive_msg.rs macros/src/lib.rs +git commit -m "macros(msg): reject generic and union message types (#114)" +``` + +--- + +## Task 6: Coverage baseline doc + mutation verification + +Record the new surface in the coverage baseline (README stays untouched — bombay-core is not yet public), and confirm the `Msg` trait survives the mutation gate with zero survivors. + +**Files:** +- Modify: `docs/testing/coverage-baseline.md` + +- [ ] **Step 1: Update the coverage baseline** + +Open `docs/testing/coverage-baseline.md`, find where the mailbox (#133) / error (#113) modules are recorded, and add a sibling entry for the message model. Match the surrounding format; the content to convey: + +> `bombay-core/src/message.rs` (card #114) — the `Msg` marker trait (`SLOT_BUDGET`, default 256 B). Trait covered by `bombay-core` unit tests (default, hand-override, generic-bound). The `#[derive(Msg)]` proc-macro (`macros/src/derive_msg.rs`) is covered by: native runtime tests (`macros/tests/derive_msg.rs`) for the generated impl + `#[msg(budget = N)]` override; `parse_budget` unit tests for the attribute logic; and paired `compile_fail` doctests on the derive for the slot-size tripwire, boxed-remedy, budget-escape, and generic/union rejection. No README change — the rebuilt spine is not behind the umbrella yet (same as #113/#133). + +- [ ] **Step 2: Run the mutation gate on bombay-core** + +Run: `nix build .#mutants -L` +Expected: build succeeds → **zero survivors**. If the `Msg` trait produces a surviving mutant (e.g. `SLOT_BUDGET` changed and no test caught it), that means a test gap — add/tighten a `bombay-core` test to kill it, then re-run. (Do not touch the `macros` crate here; it is outside the `--package bombay-core` mutation gate by design — its logic is covered by the Task-4 unit tests.) + +- [ ] **Step 3: Final full gate** + +Run: `nix flake check` +Expected: green across the board. + +- [ ] **Step 4: Commit** + +```bash +git add docs/testing/coverage-baseline.md +git commit -m "docs(testing): record #114 message model + derive coverage" +``` + +--- + +## Task 7: Open the PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin core/114-msg-model-tripwire +``` +(If SSH times out, push over HTTPS via the `gh` credential helper.) + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --repo devrandom-labs/bombay --base main \ + --title "core(message): Msg model + size-tripwire derive (#114)" \ + --body "$(cat <<'EOF' +Card #114. Adds the `Msg` marker trait (`bombay-core`) carrying a per-slot byte +budget (default 256 B), and `#[derive(Msg)]` (`bombay_macros`) which emits the +`impl Msg` plus a compile-time static-assert that trips when `size_of` of the +message exceeds its budget — making the mailbox's by-value slot-size discipline a +real compile error instead of a silence-able clippy lint. `#[msg(budget = N)]` +overrides the budget; generics and unions are rejected. + +Scope per the card's finalized closed-enum model: `handle` stays with #116, the +reply port with #115. Does not touch `Mailboxed` (arbitrary `type Msg` still +compiles). Path is `::bombay_core` (spine not yet behind the umbrella). + +Tested: native runtime tests (generated impl + override), `parse_budget` unit +tests, and paired `compile_fail` doctests (tripwire fires, boxing fixes it, +budget escape, generic/union rejection). `Msg` trait under the `bombay-core` +cargo-mutants gate. + +Design: docs/superpowers/specs/2026-07-05-msg-model-size-tripwire-design.md +EOF +)" +``` + +- [ ] **Step 3: Confirm the card is on the board and CI is green** + +Run: `gh pr checks --repo devrandom-labs/bombay ` (wait for `Nix Flake Check`). +Confirm #114 is on project board #4; move its Status to In Progress / the PR links it. + +--- + +## Self-Review (completed during authoring) + +- **Spec coverage:** `Msg` trait (Task 1) ✓; derive impl (Task 2) ✓; tripwire (Task 3) ✓; budget override + parse tests (Task 4) ✓; generic/union rejection (Task 5) ✓; coverage-baseline + mutants (Task 6) ✓; path `::bombay_core`, no README change, no `Mailboxed` change — all honored. `#116`/`#115` boundary respected (nothing here builds `handle`/reply). +- **Type consistency:** `DeriveMsg { ident, budget }`, `parse_budget(&[Attribute]) -> syn::Result>`, `Msg::SLOT_BUDGET` used identically across Tasks 1–5. Trait path `::bombay_core::message::Msg` consistent in every generated snippet. +- **No placeholders:** every step has concrete code + exact `nix develop --command` invocations + expected pass/fail. +- **Known checkpoint:** Task 3 Step 2 and Task 5 Step 2 rely on `compile_fail` doctests *failing as unexpected successes* before the guard exists — that is the TDD red state, intended. From 7570eac338fd90f5c00b13d97b731b57d7d9b7fa Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 02:33:15 +0200 Subject: [PATCH 03/10] core(message): Msg marker trait with default slot budget (#114) --- bombay-core/src/lib.rs | 1 + bombay-core/src/message.rs | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 bombay-core/src/message.rs diff --git a/bombay-core/src/lib.rs b/bombay-core/src/lib.rs index b10f883..42eca54 100644 --- a/bombay-core/src/lib.rs +++ b/bombay-core/src/lib.rs @@ -8,3 +8,4 @@ //! surface is settled once the whole core lands (#112–#121). pub mod mailbox; +pub mod message; diff --git a/bombay-core/src/message.rs b/bombay-core/src/message.rs new file mode 100644 index 0000000..cd4cd78 --- /dev/null +++ b/bombay-core/src/message.rs @@ -0,0 +1,61 @@ +//! The `Msg` marker trait: an actor's single closed message type (card #114). +//! +//! A mailbox queues `Signal` **by value**, so every slot costs `size_of` of +//! the largest `A::Msg` variant. `Msg` carries the per-slot byte budget that +//! bounds it; `#[derive(Msg)]` (the `bombay_macros` crate) implements this trait +//! and emits a compile-time static-assert that trips when the budget is exceeded. +//! +//! This module deliberately does **not** tighten `mailbox::Mailboxed::Msg` +//! (still `Send + 'static`): arbitrary `type Msg` stays legal, and `#116` decides +//! whether `Actor::Msg` bounds `: Msg`. + +/// An actor's single closed message type, stored in a mailbox slot **by value**. +/// +/// `Send + 'static` for now; `#9` relaxes `Send` to the cfg-gated `MaybeSend` +/// for single-threaded client builds. Implement with `#[derive(Msg)]` — it also +/// emits the slot-size tripwire — or by hand when you have a measured reason to +/// set a non-default [`SLOT_BUDGET`](Msg::SLOT_BUDGET). +pub trait Msg: Send + 'static { + /// The per-slot byte budget for this message type. A mailbox queues by + /// value, so this bounds `size_of` of the largest variant; the derive trips + /// the build if `size_of::()` exceeds it. Default 256 B (4 cache + /// lines) — enough for identity-bearing commands (several AIDs/hashes), tight + /// enough to catch the KB-scale inline blob. + const SLOT_BUDGET: usize = 256; +} + +#[cfg(test)] +mod tests { + use super::Msg; + + struct Ping; + impl Msg for Ping {} + + struct Roomy; + impl Msg for Roomy { + const SLOT_BUDGET: usize = 4096; + } + + /// The default slot budget is exactly 256 B (4 cache lines) — pins the + /// constant so a mutation to it is caught (like the mailbox's `Capacity::MAX`). + #[test] + fn slot_budget_defaults_to_256() { + assert_eq!(::SLOT_BUDGET, 256); + } + + /// The budget is overridable by hand — the escape hatch `#[derive(Msg)]` + /// automates via `#[msg(budget = N)]`. + #[test] + fn slot_budget_is_overridable() { + assert_eq!(::SLOT_BUDGET, 4096); + } + + /// `Msg` is a usable generic bound (what `#116`'s `Actor::Msg` would rest on). + #[test] + fn msg_is_usable_as_a_generic_bound() { + fn budget_of() -> usize { + M::SLOT_BUDGET + } + assert_eq!(budget_of::(), 256); + } +} From be9b3169b674668184f951dab84ea21635111295 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 02:49:05 +0200 Subject: [PATCH 04/10] macros(msg): #[derive(Msg)] emits the Msg impl (#114) --- Cargo.lock | 1 + macros/Cargo.toml | 3 +++ macros/src/derive_msg.rs | 33 +++++++++++++++++++++++++++++++++ macros/src/lib.rs | 22 ++++++++++++++++++++++ macros/tests/derive_msg.rs | 22 ++++++++++++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 macros/src/derive_msg.rs create mode 100644 macros/tests/derive_msg.rs diff --git a/Cargo.lock b/Cargo.lock index 9af86a5..e0fb0eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,6 +523,7 @@ dependencies = [ name = "bombay_macros" version = "0.21.0" dependencies = [ + "bombay-core", "heck", "proc-macro2", "quote", diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 1c116ae..72e6c27 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -18,5 +18,8 @@ proc-macro2 = "1.0.78" quote = "1.0.35" syn = { version = "2.0.52", features = ["extra-traits", "full"] } +[dev-dependencies] +bombay-core = { path = "../bombay-core" } + [lints] workspace = true diff --git a/macros/src/derive_msg.rs b/macros/src/derive_msg.rs new file mode 100644 index 0000000..c950c1d --- /dev/null +++ b/macros/src/derive_msg.rs @@ -0,0 +1,33 @@ +//! `#[derive(Msg)]` — implements the `Msg` marker trait and (from Task 3) emits +//! a compile-time slot-size tripwire. See card #114 and the design spec. + +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; +use syn::{ + DeriveInput, Ident, + parse::{Parse, ParseStream}, +}; + +/// A parsed `#[derive(Msg)]` input: the message type's identifier. +pub struct DeriveMsg { + ident: Ident, +} + +impl Parse for DeriveMsg { + fn parse(input: ParseStream) -> syn::Result { + let derive: DeriveInput = input.parse()?; + Ok(Self { + ident: derive.ident, + }) + } +} + +impl ToTokens for DeriveMsg { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.ident; + tokens.extend(quote! { + #[automatically_derived] + impl ::bombay_core::message::Msg for #ident {} + }); + } +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4dcc7a9..a20077f 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,10 +1,12 @@ mod derive_actor; +mod derive_msg; mod derive_remote_actor; mod derive_reply; mod messages; mod remote_message; use derive_actor::DeriveActor; +use derive_msg::DeriveMsg; use derive_remote_actor::DeriveRemoteActor; use derive_reply::DeriveReply; use messages::Messages; @@ -170,6 +172,26 @@ pub fn derive_reply(input: TokenStream) -> TokenStream { TokenStream::from(derive_reply.into_token_stream()) } +/// Derive the [`Msg`](https://docs.rs/bombay-core/latest/bombay_core/message/trait.Msg.html) +/// marker trait and emit a compile-time slot-size tripwire. +/// +/// A mailbox queues messages by value, so a fat inline variant taxes every +/// queue slot. This derive trips the build when `size_of` of the message exceeds +/// its `Msg::SLOT_BUDGET` (default 256 B). Box the largest variant to fix it, or +/// raise the budget with `#[msg(budget = N)]`. +/// +/// Within budget — compiles: +/// ``` +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Ok { Small(u64) } +/// ``` +#[proc_macro_derive(Msg, attributes(msg))] +pub fn derive_msg(input: TokenStream) -> TokenStream { + let derive_msg = parse_macro_input!(input as DeriveMsg); + TokenStream::from(derive_msg.into_token_stream()) +} + /// Derive macro implementing the [RemoteActor](https://docs.rs/bombay/latest/bombay/actor/remote/trait.RemoteActor.html) /// trait with a default remote ID being the full path of the type being implemented. /// diff --git a/macros/tests/derive_msg.rs b/macros/tests/derive_msg.rs new file mode 100644 index 0000000..57ecefb --- /dev/null +++ b/macros/tests/derive_msg.rs @@ -0,0 +1,22 @@ +//! Runtime behaviour of `#[derive(Msg)]`: the generated impl and the budget +//! override, exercised natively (the derive expands at this crate's compile +//! time, assertions run under nextest). Compile-fail behaviour (the tripwire, +//! generics, unions) lives in the paired `compile_fail` doctests on the derive. + +use bombay_core::message::Msg; + +#[derive(bombay_macros::Msg)] +enum Small { + Ping, + Pong(u64), +} + +#[derive(bombay_macros::Msg)] +struct Unit; + +/// The derive emits `impl Msg`, and an un-annotated type gets the default budget. +#[test] +fn derive_emits_impl_with_default_budget() { + assert_eq!(::SLOT_BUDGET, 256); + assert_eq!(::SLOT_BUDGET, 256); +} From b22a3f3a936f8c6d412140d5058cae007cccdbea Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 15:24:00 +0200 Subject: [PATCH 05/10] macros(msg): compile-time slot-size tripwire + paired doctests (#114) --- macros/src/derive_msg.rs | 10 ++++++++++ macros/src/lib.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/macros/src/derive_msg.rs b/macros/src/derive_msg.rs index c950c1d..6864c08 100644 --- a/macros/src/derive_msg.rs +++ b/macros/src/derive_msg.rs @@ -25,9 +25,19 @@ impl Parse for DeriveMsg { impl ToTokens for DeriveMsg { fn to_tokens(&self, tokens: &mut TokenStream) { let ident = &self.ident; + let over_budget = format!( + "`{ident}` exceeds its Msg::SLOT_BUDGET — box the largest variant \ + (as Signal boxes LinkDied), or raise it with #[msg(budget = N)]" + ); tokens.extend(quote! { #[automatically_derived] impl ::bombay_core::message::Msg for #ident {} + + const _: () = ::core::assert!( + ::core::mem::size_of::<#ident>() + <= <#ident as ::bombay_core::message::Msg>::SLOT_BUDGET, + #over_budget + ); }); } } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index a20077f..65e222c 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -186,6 +186,20 @@ pub fn derive_reply(input: TokenStream) -> TokenStream { /// #[derive(bombay_macros::Msg)] /// enum Ok { Small(u64) } /// ``` +/// +/// A fat inline variant trips the budget: +/// ```compile_fail +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Bad { Bulk([u8; 4096]) } +/// ``` +/// +/// Boxing the fat variant fixes it (as `Signal` boxes `LinkDied`): +/// ``` +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Fixed { Bulk(Box<[u8; 4096]>) } +/// ``` #[proc_macro_derive(Msg, attributes(msg))] pub fn derive_msg(input: TokenStream) -> TokenStream { let derive_msg = parse_macro_input!(input as DeriveMsg); From cc082f4eaaa8266a7a1110e1520cbc9d3ff99a6f Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 15:35:40 +0200 Subject: [PATCH 06/10] macros(msg): #[msg(budget = N)] override + parse_budget tests (#114) --- macros/src/derive_msg.rs | 101 +++++++++++++++++++++++++++++++++++-- macros/src/lib.rs | 8 +++ macros/tests/derive_msg.rs | 13 +++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/macros/src/derive_msg.rs b/macros/src/derive_msg.rs index 6864c08..34ba84e 100644 --- a/macros/src/derive_msg.rs +++ b/macros/src/derive_msg.rs @@ -4,20 +4,24 @@ use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::{ - DeriveInput, Ident, + Attribute, DeriveInput, Ident, LitInt, parse::{Parse, ParseStream}, }; -/// A parsed `#[derive(Msg)]` input: the message type's identifier. +/// A parsed `#[derive(Msg)]` input: the type's identifier and an optional +/// per-type slot budget from `#[msg(budget = N)]`. pub struct DeriveMsg { ident: Ident, + budget: Option, } impl Parse for DeriveMsg { fn parse(input: ParseStream) -> syn::Result { let derive: DeriveInput = input.parse()?; + let budget = parse_budget(&derive.attrs)?; Ok(Self { ident: derive.ident, + budget, }) } } @@ -25,13 +29,18 @@ impl Parse for DeriveMsg { impl ToTokens for DeriveMsg { fn to_tokens(&self, tokens: &mut TokenStream) { let ident = &self.ident; + let budget_const = self + .budget + .map(|n| quote! { const SLOT_BUDGET: usize = #n; }); let over_budget = format!( "`{ident}` exceeds its Msg::SLOT_BUDGET — box the largest variant \ (as Signal boxes LinkDied), or raise it with #[msg(budget = N)]" ); tokens.extend(quote! { #[automatically_derived] - impl ::bombay_core::message::Msg for #ident {} + impl ::bombay_core::message::Msg for #ident { + #budget_const + } const _: () = ::core::assert!( ::core::mem::size_of::<#ident>() @@ -41,3 +50,89 @@ impl ToTokens for DeriveMsg { }); } } + +/// Extracts `budget = N` from `#[msg(...)]` attributes, if present. Errors on a +/// non-integer value, a bare `budget`, any key other than `budget`, or a +/// duplicate `budget`. +fn parse_budget(attrs: &[Attribute]) -> syn::Result> { + let mut budget = None; + for attr in attrs.iter().filter(|attr| attr.path().is_ident("msg")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("budget") { + if budget.is_some() { + return Err(meta.error("duplicate `budget`; specify it once")); + } + budget = Some(meta.value()?.parse::()?.base10_parse()?); + Ok(()) + } else { + Err(meta.error("unknown `msg` key; the only key is `budget`")) + } + })?; + } + Ok(budget) +} + +#[cfg(test)] +mod tests { + use super::parse_budget; + use syn::{Attribute, parse_quote}; + + fn attrs(attr: Attribute) -> Vec { + vec![attr] + } + + #[test] + fn budget_attribute_yields_its_value() { + let parsed = parse_budget(&attrs(parse_quote!(#[msg(budget = 8192)]))).unwrap(); + assert_eq!(parsed, Some(8192)); + } + + #[test] + fn absent_attribute_yields_none() { + let parsed = parse_budget(&attrs(parse_quote!(#[derive(Clone)]))).unwrap(); + assert_eq!(parsed, None); + } + + #[test] + fn non_integer_budget_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(budget = "x")]))).is_err()); + } + + #[test] + fn bare_budget_without_value_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(budget)]))).is_err()); + } + + #[test] + fn unknown_msg_key_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(limit = 8)]))).is_err()); + } + + #[test] + fn duplicate_budget_is_an_error() { + // repeated key within one #[msg(...)] + assert!(parse_budget(&attrs(parse_quote!(#[msg(budget = 1, budget = 2)]))).is_err()); + } + + #[test] + fn duplicate_budget_across_attrs_is_an_error() { + let a: Attribute = parse_quote!(#[msg(budget = 1)]); + let b: Attribute = parse_quote!(#[msg(budget = 2)]); + assert!(parse_budget(&[a, b]).is_err()); + } + + #[test] + fn negative_budget_is_an_error() { + assert!(parse_budget(&attrs(parse_quote!(#[msg(budget = -1)]))).is_err()); + } + + #[test] + fn overflowing_budget_is_an_error() { + assert!( + parse_budget(&attrs( + parse_quote!(#[msg(budget = 999999999999999999999999999999)]) + )) + .is_err() + ); + } +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 65e222c..9a987cf 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -200,6 +200,14 @@ pub fn derive_reply(input: TokenStream) -> TokenStream { /// #[derive(bombay_macros::Msg)] /// enum Fixed { Bulk(Box<[u8; 4096]>) } /// ``` +/// +/// Or raise the budget for a deliberately large message: +/// ``` +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// #[msg(budget = 8192)] +/// enum Big { Bulk([u8; 4096]) } +/// ``` #[proc_macro_derive(Msg, attributes(msg))] pub fn derive_msg(input: TokenStream) -> TokenStream { let derive_msg = parse_macro_input!(input as DeriveMsg); diff --git a/macros/tests/derive_msg.rs b/macros/tests/derive_msg.rs index 57ecefb..4e44470 100644 --- a/macros/tests/derive_msg.rs +++ b/macros/tests/derive_msg.rs @@ -20,3 +20,16 @@ fn derive_emits_impl_with_default_budget() { assert_eq!(::SLOT_BUDGET, 256); assert_eq!(::SLOT_BUDGET, 256); } + +#[derive(bombay_macros::Msg)] +#[msg(budget = 8192)] +enum Roomy { + Bulk([u8; 4096]), +} + +/// `#[msg(budget = N)]` overrides the default, and a message within the raised +/// budget still compiles (the assert reads the overridden const). +#[test] +fn budget_attribute_overrides_the_default() { + assert_eq!(::SLOT_BUDGET, 8192); +} From 4fe59e71c96982cdfb6488211adb8a98e9b9d1a3 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 16:04:25 +0200 Subject: [PATCH 07/10] macros(msg): reject generic and union message types (#114) --- macros/src/derive_msg.rs | 40 +++++++++++++++++++++++++++++++++++++++- macros/src/lib.rs | 14 ++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/macros/src/derive_msg.rs b/macros/src/derive_msg.rs index 34ba84e..2692877 100644 --- a/macros/src/derive_msg.rs +++ b/macros/src/derive_msg.rs @@ -4,12 +4,13 @@ use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::{ - Attribute, DeriveInput, Ident, LitInt, + Attribute, Data, DeriveInput, Ident, LitInt, parse::{Parse, ParseStream}, }; /// A parsed `#[derive(Msg)]` input: the type's identifier and an optional /// per-type slot budget from `#[msg(budget = N)]`. +#[derive(Debug)] pub struct DeriveMsg { ident: Ident, budget: Option, @@ -18,6 +19,24 @@ pub struct DeriveMsg { impl Parse for DeriveMsg { fn parse(input: ParseStream) -> syn::Result { let derive: DeriveInput = input.parse()?; + + // NB: the `compile_fail` doctest for generics can't regression-test this guard + // (an un-guarded derive also fails to compile a generic, for a different reason); + // `generic_type_is_rejected` below is the real guard test. + if let Some(param) = derive.generics.params.first() { + return Err(syn::Error::new_spanned( + param, + "`#[derive(Msg)]` needs a concrete type: the slot-size tripwire \ + cannot size an unmonomorphized generic", + )); + } + if let Data::Union(data) = &derive.data { + return Err(syn::Error::new_spanned( + data.union_token, + "`#[derive(Msg)]` supports structs and enums, not unions", + )); + } + let budget = parse_budget(&derive.attrs)?; Ok(Self { ident: derive.ident, @@ -74,6 +93,7 @@ fn parse_budget(attrs: &[Attribute]) -> syn::Result> { #[cfg(test)] mod tests { + use super::DeriveMsg; use super::parse_budget; use syn::{Attribute, parse_quote}; @@ -81,6 +101,24 @@ mod tests { vec![attr] } + #[test] + fn generic_type_is_rejected() { + let err = syn::parse_str::("enum Generic { A(T) }").unwrap_err(); + assert!( + err.to_string().contains("concrete type"), + "unexpected error: {err}" + ); + } + + #[test] + fn union_type_is_rejected() { + let err = syn::parse_str::("union U { a: u32, b: u64 }").unwrap_err(); + assert!( + err.to_string().contains("not unions"), + "unexpected error: {err}" + ); + } + #[test] fn budget_attribute_yields_its_value() { let parsed = parse_budget(&attrs(parse_quote!(#[msg(budget = 8192)]))).unwrap(); diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 9a987cf..ce67be7 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -208,6 +208,20 @@ pub fn derive_reply(input: TokenStream) -> TokenStream { /// #[msg(budget = 8192)] /// enum Big { Bulk([u8; 4096]) } /// ``` +/// +/// The derive needs a concrete type — a generic is rejected: +/// ```compile_fail +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// enum Generic { A(T) } +/// ``` +/// +/// Unions are rejected (structs and enums only): +/// ```compile_fail +/// use bombay_core::message::Msg; +/// #[derive(bombay_macros::Msg)] +/// union U { a: u32, b: u64 } +/// ``` #[proc_macro_derive(Msg, attributes(msg))] pub fn derive_msg(input: TokenStream) -> TokenStream { let derive_msg = parse_macro_input!(input as DeriveMsg); From 8ae3ac832e70968ef39be960ba41cdfee020e055 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 17:27:57 +0200 Subject: [PATCH 08/10] docs(testing): record #114 message model + derive coverage --- docs/testing/coverage-baseline.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/testing/coverage-baseline.md b/docs/testing/coverage-baseline.md index 5b4758f..3130928 100644 --- a/docs/testing/coverage-baseline.md +++ b/docs/testing/coverage-baseline.md @@ -64,6 +64,29 @@ bombay-owned concurrency is the run-loop `select` over signals (#116) and the de push (#120) — that is where loom/shuttle land. The 8-thread linearizability test is the mailbox's concurrency coverage until then. +### `message` (#114) — done +`bombay-core/src/message.rs` carries the `Msg` marker trait: an actor's single closed +message type, queued **by value**, with `SLOT_BUDGET` (default 256 B / 4 cache lines) +as the per-slot byte bound. Trait covered by 3 `bombay-core` unit tests — default-256 +pin, hand-override, and usability as a generic bound. Mutation testing yields no +signal for this module: `cargo-mutants` mutates function bodies only, so it +generates no mutants for a trait-const-only file — the `SLOT_BUDGET` default is +pinned by the `slot_budget_defaults_to_256` unit test instead. + +The `#[derive(Msg)]` proc-macro (`macros/src/derive_msg.rs`) implements the trait and +emits a compile-time slot-size tripwire; it sits outside the `bombay-core` mutation +gate by design (proc-macros compile out-of-process, same as the "known limitation" +below) and is instead covered by: native runtime tests (`macros/tests/derive_msg.rs`) +for the generated impl on the default budget and the `#[msg(budget = N)]` override; +`parse_budget` unit tests for the attribute grammar (value present, absent, +non-integer, bare key, unknown key, duplicate within one `#[msg(...)]` and across two, +negative, and overflowing-integer rejection); direct `syn::parse_str::` +unit tests for the generic- and union-rejection guards; and five paired `///` +doctests on the derive in `macros/src/lib.rs` — three `compile_fail` (budget +tripwire, generic rejected, union rejected) plus two regression doctests that must +keep compiling (boxed-remedy, the `#[msg(budget = N)]` escape). No README change — +the rebuilt spine is not behind the umbrella yet (same as #113/#133). + ## Baseline — 2026-06-29 (after #77) Workspace line coverage **60.85% (5686/9345)** — but that blends the SUT with untested crates From e700ee7f72f373f38722e1eb981bd8658e576778 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 17:46:43 +0200 Subject: [PATCH 09/10] test(msg): pin inclusive slot-budget boundary + tighten grammar-error asserts (#114) --- docs/testing/coverage-baseline.md | 9 +++++---- macros/src/derive_msg.rs | 21 ++++++++++++++++++--- macros/tests/derive_msg.rs | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/testing/coverage-baseline.md b/docs/testing/coverage-baseline.md index 3130928..dbe62b7 100644 --- a/docs/testing/coverage-baseline.md +++ b/docs/testing/coverage-baseline.md @@ -81,10 +81,11 @@ for the generated impl on the default budget and the `#[msg(budget = N)]` overri `parse_budget` unit tests for the attribute grammar (value present, absent, non-integer, bare key, unknown key, duplicate within one `#[msg(...)]` and across two, negative, and overflowing-integer rejection); direct `syn::parse_str::` -unit tests for the generic- and union-rejection guards; and five paired `///` -doctests on the derive in `macros/src/lib.rs` — three `compile_fail` (budget -tripwire, generic rejected, union rejected) plus two regression doctests that must -keep compiling (boxed-remedy, the `#[msg(budget = N)]` escape). No README change — +unit tests for the generic- and union-rejection guards; and six paired `///` +doctests on the derive in `macros/src/lib.rs` — three that must keep compiling +(the initial within-budget example, the boxed-remedy, and the `#[msg(budget = N)]` +escape) plus three `compile_fail` (budget tripwire, generic rejected, union +rejected). No README change — the rebuilt spine is not behind the umbrella yet (same as #113/#133). ## Baseline — 2026-06-29 (after #77) diff --git a/macros/src/derive_msg.rs b/macros/src/derive_msg.rs index 2692877..026d08f 100644 --- a/macros/src/derive_msg.rs +++ b/macros/src/derive_msg.rs @@ -81,6 +81,9 @@ fn parse_budget(attrs: &[Attribute]) -> syn::Result> { if budget.is_some() { return Err(meta.error("duplicate `budget`; specify it once")); } + // `-1` is rejected because it tokenizes as `-` + LitInt (so LitInt::parse fails), + // and an out-of-range literal fails base10_parse::() — both surface as + // clean syn::Errors, not an explicit sign/range check here. budget = Some(meta.value()?.parse::()?.base10_parse()?); Ok(()) } else { @@ -143,20 +146,32 @@ mod tests { #[test] fn unknown_msg_key_is_an_error() { - assert!(parse_budget(&attrs(parse_quote!(#[msg(limit = 8)]))).is_err()); + let err = parse_budget(&attrs(parse_quote!(#[msg(limit = 8)]))).unwrap_err(); + assert!( + err.to_string().contains("unknown"), + "unexpected error: {err}" + ); } #[test] fn duplicate_budget_is_an_error() { // repeated key within one #[msg(...)] - assert!(parse_budget(&attrs(parse_quote!(#[msg(budget = 1, budget = 2)]))).is_err()); + let err = parse_budget(&attrs(parse_quote!(#[msg(budget = 1, budget = 2)]))).unwrap_err(); + assert!( + err.to_string().contains("duplicate"), + "unexpected error: {err}" + ); } #[test] fn duplicate_budget_across_attrs_is_an_error() { let a: Attribute = parse_quote!(#[msg(budget = 1)]); let b: Attribute = parse_quote!(#[msg(budget = 2)]); - assert!(parse_budget(&[a, b]).is_err()); + let err = parse_budget(&[a, b]).unwrap_err(); + assert!( + err.to_string().contains("duplicate"), + "unexpected error: {err}" + ); } #[test] diff --git a/macros/tests/derive_msg.rs b/macros/tests/derive_msg.rs index 4e44470..2fd71f5 100644 --- a/macros/tests/derive_msg.rs +++ b/macros/tests/derive_msg.rs @@ -33,3 +33,25 @@ enum Roomy { fn budget_attribute_overrides_the_default() { assert_eq!(::SLOT_BUDGET, 8192); } + +// Exactly at the default budget: size_of == 256 == SLOT_BUDGET must still compile +// (guards the inclusive `<=` in the derive's static-assert against a `<` regression). +#[derive(bombay_macros::Msg)] +struct ExactDefault([u8; 256]); + +// Exactly at an overridden budget: size_of == 8192 == the raised SLOT_BUDGET. +#[derive(bombay_macros::Msg)] +#[msg(budget = 8192)] +struct ExactOverride([u8; 8192]); + +/// A message whose `size_of` is exactly its budget compiles — the tripwire is +/// inclusive (`size_of <= SLOT_BUDGET`), not strict. If the derive's comparison +/// regressed to `<`, `ExactDefault`/`ExactOverride` would fail to compile and +/// break this test's build. +#[test] +fn size_exactly_at_budget_compiles() { + assert_eq!(core::mem::size_of::(), 256); + assert_eq!(::SLOT_BUDGET, 256); + assert_eq!(core::mem::size_of::(), 8192); + assert_eq!(::SLOT_BUDGET, 8192); +} From 26261061f6384f0ca557abeb86966c6b8eaea636 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 18:30:37 +0200 Subject: [PATCH 10/10] test(msg): end-to-end derive(Msg) x real mailbox compose test + runnable example (#114) --- Cargo.lock | 1 + bombay-core/Cargo.toml | 1 + bombay-core/examples/msg_budget.rs | 105 +++++++++++++++++++++++ bombay-core/tests/msg_mailbox_compose.rs | 66 ++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 bombay-core/examples/msg_budget.rs create mode 100644 bombay-core/tests/msg_mailbox_compose.rs diff --git a/Cargo.lock b/Cargo.lock index e0fb0eb..a699da9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,7 @@ name = "bombay-core" version = "0.1.0" dependencies = [ "async-channel", + "bombay_macros", "criterion", "crossbeam-channel", "flume", diff --git a/bombay-core/Cargo.toml b/bombay-core/Cargo.toml index 1dff3b2..9496bfd 100644 --- a/bombay-core/Cargo.toml +++ b/bombay-core/Cargo.toml @@ -25,6 +25,7 @@ criterion = { version = "0.8", features = ["async_tokio"] } async-channel = "2.5.0" thingbuf = "0.1.6" crossbeam-channel = "0.5.15" +bombay_macros = { path = "../macros" } [[bench]] name = "mailbox" diff --git a/bombay-core/examples/msg_budget.rs b/bombay-core/examples/msg_budget.rs new file mode 100644 index 0000000..0e2a87a --- /dev/null +++ b/bombay-core/examples/msg_budget.rs @@ -0,0 +1,105 @@ +//! Runnable demonstration: `#[derive(Msg)]`'s compile-time slot-size tripwire +//! composed end to end with a real `bombay-core` mailbox (card #114). +//! +//! Run with `cargo run -p bombay-core --example msg_budget`. + +use bombay_core::mailbox::{Capacity, Mailbox, Mailboxed, Signal}; +use bombay_core::message::Msg; +use std::error::Error; +use std::mem::size_of; + +/// A realistic closed actor command set — well inside the default 256 B +/// budget, so `#[derive(Msg)]` accepts it silently. +#[derive(Debug, bombay_macros::Msg)] +enum BankCmd { + Deposit { cents: u64 }, + Withdraw { cents: u64 }, + Balance, +} + +struct BankAccount; + +impl Mailboxed for BankAccount { + type Msg = BankCmd; +} + +// Uncomment to watch the compile-time tripwire fire — `[u8; 4096]` blows the +// 256 B budget, so `#[derive(Msg)]` turns it into a *compile error*, not a +// silent per-slot tax: +// +// #[derive(bombay_macros::Msg)] +// enum TooFat { +// Bulk([u8; 4096]), +// } +// +// The escape hatch is `#[msg(budget = 8192)]` on the enum, or boxing the +// field (as `Signal` itself boxes the cold `LinkDied` payload). + +/// A command set that legitimately needs more than the default budget — +/// `#[msg(budget = N)]` raises the ceiling instead of forcing a box. +#[derive(Debug, bombay_macros::Msg)] +#[msg(budget = 8192)] +enum BulkImportCmd { + Chunk([u8; 4096]), +} + +#[allow( + clippy::print_stdout, + reason = "runnable demo example — printing the budget/queue trace is the point" +)] +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + println!( + "BankCmd: size_of = {:>4} B, SLOT_BUDGET = {} B (default)", + size_of::(), + ::SLOT_BUDGET + ); + println!( + "BulkImportCmd: size_of = {:>4} B, SLOT_BUDGET = {} B (raised via #[msg(budget = 8192)])", + size_of::(), + ::SLOT_BUDGET + ); + // A real instance of the raised-budget command still compiles and fits. + let BulkImportCmd::Chunk(payload) = BulkImportCmd::Chunk([7u8; 4096]); + println!( + "BulkImportCmd instance built ({} B payload, first byte = {})", + payload.len(), + payload[0] + ); + + let cap = Capacity::try_from(8)?; + let (tx, mut rx) = Mailbox::::bounded(cap); + + tx.send(Signal::Message(BankCmd::Deposit { cents: 500 })) + .await + .map_err(|err| format!("{err:?}"))?; + tx.send(Signal::Message(BankCmd::Withdraw { cents: 120 })) + .await + .map_err(|err| format!("{err:?}"))?; + tx.send(Signal::Message(BankCmd::Balance)) + .await + .map_err(|err| format!("{err:?}"))?; + tx.send(Signal::Stop) + .await + .map_err(|err| format!("{err:?}"))?; + + println!("draining the mailbox by value — no per-message heap box:"); + while let Some(signal) = rx.recv().await { + match signal { + Signal::Message(BankCmd::Deposit { cents }) => { + println!(" Signal::Message(Deposit {{ cents: {cents} }})"); + } + Signal::Message(BankCmd::Withdraw { cents }) => { + println!(" Signal::Message(Withdraw {{ cents: {cents} }})"); + } + Signal::Message(BankCmd::Balance) => println!(" Signal::Message(Balance)"), + Signal::Stop => { + println!(" Signal::Stop — done"); + break; + } + Signal::LinkDied(_) => println!(" Signal::LinkDied(..)"), + } + } + + Ok(()) +} diff --git a/bombay-core/tests/msg_mailbox_compose.rs b/bombay-core/tests/msg_mailbox_compose.rs new file mode 100644 index 0000000..8ba4d32 --- /dev/null +++ b/bombay-core/tests/msg_mailbox_compose.rs @@ -0,0 +1,66 @@ +//! End-to-end: a `#[derive(Msg)]` command enum used as a real `Mailboxed::Msg`, +//! round-tripped through the actual `bombay-core` mailbox by value (card #114). + +use bombay_core::mailbox::{Capacity, Mailbox, Mailboxed, Signal}; +use bombay_core::message::Msg; + +/// A realistic closed actor command set. `#[derive(Msg)]` gives it the +/// compile-time slot-size tripwire; it stays well under the 256 B default. +#[derive(Debug, PartialEq, Eq, bombay_macros::Msg)] +enum BankCmd { + Deposit { cents: u64 }, + Withdraw { cents: u64 }, + Balance, +} + +struct BankAccount; +impl Mailboxed for BankAccount { + type Msg = BankCmd; +} + +/// The derived `Msg` and the mailbox's `Mailboxed` coexist on one type, and a +/// guarded command survives a real by-value `send` -> `recv` round-trip. +#[tokio::test] +async fn derived_msg_command_round_trips_through_the_real_mailbox() { + // The derive implemented Msg on the same type the mailbox will queue. + assert_eq!(::SLOT_BUDGET, 256); + + let cap = Capacity::try_from(8).expect("valid capacity"); + let (tx, mut rx) = Mailbox::::bounded(cap); + + tx.send(Signal::Message(BankCmd::Deposit { cents: 250 })) + .await + .expect("send should succeed"); + tx.send(Signal::Message(BankCmd::Balance)) + .await + .expect("send should succeed"); + + assert!(matches!( + rx.recv().await, + Some(Signal::Message(BankCmd::Deposit { cents: 250 })) + )); + assert!(matches!( + rx.recv().await, + Some(Signal::Message(BankCmd::Balance)) + )); +} + +/// A `Signal::Stop` queued after a domain message is delivered in the same +/// FIFO order as it was sent — control signals and derived messages share one +/// by-value queue, there's no separate priority lane. +#[tokio::test] +async fn stop_signal_preserves_fifo_order_with_derived_messages() { + let cap = Capacity::try_from(8).expect("valid capacity"); + let (tx, mut rx) = Mailbox::::bounded(cap); + + tx.send(Signal::Message(BankCmd::Withdraw { cents: 100 })) + .await + .expect("send should succeed"); + tx.send(Signal::Stop).await.expect("send should succeed"); + + assert!(matches!( + rx.recv().await, + Some(Signal::Message(BankCmd::Withdraw { cents: 100 })) + )); + assert!(matches!(rx.recv().await, Some(Signal::Stop))); +}