Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1cb6457
docs(spec): #116 actor trait, lifecycle hooks & run-loop design
joeldsouzax Jul 5, 2026
602c327
docs(plan): #116 actor trait & run-loop implementation plan + spec Ru…
joeldsouzax Jul 5, 2026
5ac03eb
build(core): add tokio-util + futures for the #116 run-loop
joeldsouzax Jul 5, 2026
44fb124
core(error): PanicError::from_panic_any — bridge catch_unwind to a va…
joeldsouzax Jul 5, 2026
b34a551
core(actor): Actor trait + minimal ActorRef/WeakActorRef scaffold (#116)
joeldsouzax Jul 5, 2026
857aa8c
core(actor): run-loop walking skeleton — on_start → loop → on_stop (#…
joeldsouzax Jul 5, 2026
6da0aa5
core(actor): satisfy god-level clippy nursery bar on the new spine (#…
joeldsouzax Jul 5, 2026
9408183
docs(plan): #116 mandate lib clippy check on lib-touching tasks (nurs…
joeldsouzax Jul 5, 2026
88b6653
core(actor): test graceful cancel finishes in-flight then stops (#116)
joeldsouzax Jul 5, 2026
25b058d
core(actor): test *stop flag stops after the current handler (#116)
joeldsouzax Jul 5, 2026
35e83f3
core(actor): test on-start messages handled after, in order (no buffe…
joeldsouzax Jul 5, 2026
36c80bb
core(actor): test on_start Err/panic → StartupFailed (unwind pin) (#116)
joeldsouzax Jul 5, 2026
b437155
core(actor): test on_stop-after-panic, poison contract, post-panic se…
joeldsouzax Jul 5, 2026
2c6bb6c
core(actor): test hard kill skips on_stop and drops in-flight (#116)
joeldsouzax Jul 5, 2026
f60de95
core(actor): Spawn ext-trait + default capacity + concurrent single-w…
joeldsouzax Jul 5, 2026
b48de9e
docs(testing): record #116 actor spine coverage baseline
joeldsouzax Jul 5, 2026
5ee739f
chore(typos): allowlist coined test actor name Failer (#116)
joeldsouzax Jul 5, 2026
9f347b0
test(actor): kill cargo-mutants survivors — zero survivors on #116 sp…
joeldsouzax Jul 6, 2026
f6c03e3
test(actor): deterministic DST harness for stop/cancel/kill/startup r…
joeldsouzax Jul 6, 2026
27bde90
chore(mutants): taplo-format .cargo/mutants.toml (#116)
joeldsouzax Jul 6, 2026
32e41ae
test(actor): direct verification of actor-system invariants I1-I23 (#…
joeldsouzax Jul 6, 2026
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
10 changes: 10 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# cargo-mutants configuration (read by default at `.cargo/mutants.toml`).
#
# Skip list. `log_on_stop_outcome` (bombay-core/src/actor/spawn.rs) is
# diagnostic-only: it returns `()` and mutates no state — its sole effect is an
# `eprintln!` on the shutdown path (it is already `#[expect(clippy::print_stderr)]`
# for exactly that reason). The "replace ... with ()" mutant therefore has no
# observable behavior a test can assert without capturing stderr, so it is
# legitimately unkillable. This is the config-file equivalent of `#[mutants::skip]`,
# used here to avoid adding the no-op `mutants` crate as a production dependency.
exclude_re = ["replace log_on_stop_outcome"]
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.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ tokio = "1.47"
# Mailbox channel primitive — chosen on measured evidence (ADR-0001, card #133):
# fastest async candidate, executor-agnostic, move-based, has weak senders.
flume = "0.12"
# Cooperative cancellation for the actor run-loop (card #116, absorbs #55).
# CancellationToken::run_until_cancelled drives graceful stop without a select!.
tokio-util = "0.7"
cucumber = { version = "0.23", features = ["libtest"] }
proptest = "1.11"
rstest = "0.26"
Expand Down
4 changes: 4 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ seens = "seens"
# `Alph` — a literal input string in a TUI render test; changing it to "Alpha"
# alters the asserted terminal grid, so it is data, not a misspelling.
alph = "alph"
# `Failer` — a coined test-only actor type in the #116 run-loop tests (an actor
# whose handler deliberately fails/panics); an agent-noun, not a misspelling of
# "Failure".
failer = "failer"
2 changes: 2 additions & 0 deletions bombay-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ publish = false
[dependencies]
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
flume = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }
downcast-rs = { workspace = true }

Expand Down
258 changes: 258 additions & 0 deletions bombay-core/src/actor/actor_ref.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
//! The minimal handle to a running actor (card #116 scaffold).
//!
//! Each field is independently cheap to clone and shares state, so no outer
//! `Arc` is needed here — the Arc/Weak ref-count semantics (last strong drop
//! stops the actor), `Recipient` erasure, and the `tell`/`ask` builders are
//! #117/#118. #116 exposes only what the hooks, spawn, and loop need.

use core::fmt;

use futures::stream::AbortHandle;
use tokio_util::sync::CancellationToken;

use crate::{
actor::Actor,
mailbox::{ActorId, MailboxSender, WeakMailboxSender},
};

/// A cloneable handle to a running actor: enqueue signals, stop it gracefully,
/// or kill it. Does **not** (yet) drive ref-count shutdown — see the module doc.
pub struct ActorRef<A: Actor> {
id: ActorId,
mailbox: MailboxSender<A>,
cancel: CancellationToken,
abort: AbortHandle,
}

impl<A: Actor> Clone for ActorRef<A> {
fn clone(&self) -> Self {
Self {
id: self.id,
mailbox: self.mailbox.clone(),
cancel: self.cancel.clone(),
abort: self.abort.clone(),
}
}
}

impl<A: Actor> fmt::Debug for ActorRef<A> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ActorRef")
.field("id", &self.id)
.field("actor", &A::name())
.finish_non_exhaustive()
}
}

impl<A: Actor> ActorRef<A> {
pub(crate) const fn new(
id: ActorId,
mailbox: MailboxSender<A>,
cancel: CancellationToken,
abort: AbortHandle,
) -> Self {
Self {
id,
mailbox,
cancel,
abort,
}
}

/// The actor's scaffold identity (replaced by the AID in #121).
#[must_use]
pub const fn id(&self) -> ActorId {
self.id
}

/// The sender half of the actor's mailbox — used to enqueue `Signal`s. The
/// ergonomic `tell`/`ask` builders wrap this in #118.
#[must_use]
pub const fn mailbox_sender(&self) -> &MailboxSender<A> {
&self.mailbox
}

/// The loop's graceful-cancellation token (loop-internal).
pub(crate) const fn cancel_token(&self) -> &CancellationToken {
&self.cancel
}

/// Requests a graceful, out-of-band stop: the in-flight message finishes,
/// then the actor stops and `on_stop` runs. Queued messages are abandoned.
pub fn stop(&self) {
self.cancel.cancel();
}

/// Hard-kills the actor: the task is aborted at its next await point,
/// `on_stop` does **not** run, and any in-flight message is dropped.
pub fn kill(&self) {
self.abort.abort();
}

/// Downgrades to a non-pinning [`WeakActorRef`].
#[must_use]
pub fn downgrade(&self) -> WeakActorRef<A> {
WeakActorRef {
id: self.id,
mailbox: self.mailbox.downgrade(),
cancel: self.cancel.clone(),
abort: self.abort.clone(),
}
}
}

/// A non-pinning handle to an actor. [`upgrade`](WeakActorRef::upgrade) yields a
/// strong [`ActorRef`] only while the actor's mailbox is still open.
pub struct WeakActorRef<A: Actor> {
id: ActorId,
mailbox: WeakMailboxSender<A>,
cancel: CancellationToken,
abort: AbortHandle,
}

impl<A: Actor> Clone for WeakActorRef<A> {
fn clone(&self) -> Self {
Self {
id: self.id,
mailbox: self.mailbox.clone(),
cancel: self.cancel.clone(),
abort: self.abort.clone(),
}
}
}

impl<A: Actor> fmt::Debug for WeakActorRef<A> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WeakActorRef")
.field("id", &self.id)
.field("actor", &A::name())
.finish_non_exhaustive()
}
}

impl<A: Actor> WeakActorRef<A> {
/// The actor's scaffold identity.
#[must_use]
pub const fn id(&self) -> ActorId {
self.id
}

/// Upgrades to a strong [`ActorRef`], or `None` if the actor's mailbox has
/// closed (every strong sender dropped).
#[must_use]
pub fn upgrade(&self) -> Option<ActorRef<A>> {
self.mailbox.upgrade().map(|mailbox| ActorRef {
id: self.id,
mailbox,
cancel: self.cancel.clone(),
abort: self.abort.clone(),
})
}
}

#[cfg(test)]
mod tests {
use super::*;

use futures::stream::AbortHandle;
use tokio_util::sync::CancellationToken;

use crate::{
mailbox::{ActorId, Capacity, Mailbox, Mailboxed},
message::Msg,
};

// A minimal Actor purely to key the mailbox/ref. `on_start`/`handle` are
// never called in this task's tests (no loop yet) — they exist so the type
// satisfies `Actor`.
struct Probe;
struct ProbeMsg;
impl Msg for ProbeMsg {}
impl Mailboxed for Probe {
type Msg = ProbeMsg;
}
impl Actor for Probe {
type Args = ();
type Error = core::convert::Infallible;
async fn on_start(_: (), _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(Probe)
}
async fn handle(
&mut self,
_: ProbeMsg,
_: ActorRef<Self>,
_: &mut bool,
) -> Result<(), Self::Error> {
Ok(())
}
}

fn build_ref() -> (ActorRef<Probe>, WeakActorRef<Probe>) {
let cap = Capacity::try_from(4usize).expect("valid capacity");
let (tx, _rx) = Mailbox::<Probe>::bounded(cap);
let (abort, _reg) = AbortHandle::new_pair();
let actor_ref = ActorRef::new(ActorId::new(7), tx, CancellationToken::new(), abort);
let weak = actor_ref.downgrade();
(actor_ref, weak)
}

/// The `ActorRef` debug view names the struct and surfaces its id and actor
/// name — guards the hand-written `Debug` impl against being stubbed to an
/// empty formatter (`Ok(Default::default())`).
#[test]
fn actor_ref_debug_names_struct_id_and_actor() {
let (actor_ref, _weak) = build_ref();
let shown = format!("{actor_ref:?}");
assert!(
shown.contains("ActorRef"),
"debug names the struct: {shown}"
);
assert!(shown.contains('7'), "debug surfaces the id: {shown}");
assert!(
shown.contains("Probe"),
"debug surfaces the actor name: {shown}"
);
}

/// Same guard for the weak handle's `Debug` impl.
#[test]
fn weak_actor_ref_debug_names_struct_and_id() {
let (_actor_ref, weak) = build_ref();
let shown = format!("{weak:?}");
assert!(
shown.contains("WeakActorRef"),
"debug names the struct: {shown}"
);
assert!(shown.contains('7'), "debug surfaces the id: {shown}");
assert!(
shown.contains("Probe"),
"debug surfaces the actor name: {shown}"
);
}

/// `Actor::name` defaults to the concrete type name — guards the trait
/// default against being stubbed to a constant/empty string.
#[test]
fn actor_name_defaults_to_type_name() {
assert!(
Probe::name().contains("Probe"),
"name() returns the type name, got {:?}",
Probe::name(),
);
}

/// Lifecycle: a weak ref upgrades while the mailbox is open, and returns
/// `None` once every strong sender (incl. the one inside `ActorRef`) drops.
#[tokio::test]
async fn weak_upgrades_while_open_then_none_after_drop() {
let (actor_ref, weak) = build_ref();
assert_eq!(weak.id(), ActorId::new(7));
assert!(weak.upgrade().is_some(), "mailbox open -> upgradable");

drop(actor_ref);
assert!(
weak.upgrade().is_none(),
"all strong senders dropped -> not upgradable",
);
}
}
Loading
Loading