Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bombay-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
105 changes: 105 additions & 0 deletions bombay-core/examples/msg_budget.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error>> {
println!(
"BankCmd: size_of = {:>4} B, SLOT_BUDGET = {} B (default)",
size_of::<BankCmd>(),
<BankCmd as Msg>::SLOT_BUDGET
);
println!(
"BulkImportCmd: size_of = {:>4} B, SLOT_BUDGET = {} B (raised via #[msg(budget = 8192)])",
size_of::<BulkImportCmd>(),
<BulkImportCmd as Msg>::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::<BankAccount>::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(())
}
1 change: 1 addition & 0 deletions bombay-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
//! surface is settled once the whole core lands (#112–#121).

pub mod mailbox;
pub mod message;
61 changes: 61 additions & 0 deletions bombay-core/src/message.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! The `Msg` marker trait: an actor's single closed message type (card #114).
//!
//! A mailbox queues `Signal<A>` **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::<Self>()` 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!(<Ping as Msg>::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!(<Roomy as Msg>::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<M: Msg>() -> usize {
M::SLOT_BUDGET
}
assert_eq!(budget_of::<Ping>(), 256);
}
}
66 changes: 66 additions & 0 deletions bombay-core/tests/msg_mailbox_compose.rs
Original file line number Diff line number Diff line change
@@ -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!(<BankCmd as Msg>::SLOT_BUDGET, 256);

let cap = Capacity::try_from(8).expect("valid capacity");
let (tx, mut rx) = Mailbox::<BankAccount>::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::<BankAccount>::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)));
}
Loading
Loading