From 1cb64574144fea74c198ecaa7dc3cfc0a3b90a60 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 22:50:06 +0200 Subject: [PATCH 01/21] docs(spec): #116 actor trait, lifecycle hooks & run-loop design --- .../2026-07-05-116-actor-trait-loop-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md diff --git a/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md b/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md new file mode 100644 index 0000000..0b0a18b --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md @@ -0,0 +1,277 @@ +# Actor trait, lifecycle hooks & run-loop — design (card #116) + +**Status:** approved 2026-07-05 · epic #122 · follows the per-card rigor contract there. + +## What this is + +The local actor **spine**: the `Actor` trait (`on_start` / `handle` / `on_panic` +/ `on_stop`), the run-loop that drives it (`kind.rs`), and the spawn entry points +(`spawn.rs`). It is the identity-agnostic body that runs whatever bears the +identity — it survives the identity-first inversion (#121) unchanged in spirit. + +This card builds on the shipped primitives — `mailbox` (#112), `message`/`Msg` +(#114), `reply` (#115), `error` (#113) — and introduces a **minimal `ActorRef` +scaffold** that #117 later expands. It deliberately does **not** build: +supervision/links/restart (#120), the full `ActorRef`/`Recipient`/counting +(#117), the `tell`/`ask` request builders (#118), or identity (#121). + +## Scope decisions (approved) + +Two forks the card's comments left implicit, decided with the user: + +1. **`MaybeSend` (#9) is deferred.** #116 ships **Send-saturated** (`Actor`, + `Args`, `Msg`, `Error`: `Send`; every hook `impl Future + Send`), matching the + already-shipped mailbox (`Mailboxed::Msg: Send + 'static`). The cfg-gated + `MaybeSend`/`MaybeSync` relaxation threads through every signature, so it is + retro-applied across mailbox + actor + ref + registry in **one coherent #9 + sweep** — not partially here. + +2. **Minimal `ActorRef` scaffold.** #116 builds only what the hooks, spawn, and + loop need. Ref-count-driven-stop (last strong drop stops the actor), + `Recipient` erasure, and the `tell`/`ask` entry points are **#117/#118**. + +## The reference, and why it is replaced + +`src/actor.rs` + `src/actor/kind.rs` + `src/actor/spawn.rs` (~1,983 LOC) are +built around three concerns the rebuilt loop **amputates**, because they belong +to later cards: + +- **Supervision coordination** (`CoordinationState`, `OneForOne`/`OneForAll`/ + `RestForOne`, restart intensity) → #120. +- **Link death-watch** (`handle_link_died`, `notify_links`, sibling fan-out) → + #120. +- **Console monitoring** (`ActorMonitor`, `set_running`/`set_stopping`) → console + feature, out of the core spine. + +Stripped of those, the loop is roughly 1/5th the size: `on_start` → loop → +`on_stop`, with four `catch_unwind` sites. + +## The `Actor` trait — `bombay-core/src/actor/mod.rs` + +Send-saturated, exactly as finalized on the card: + +```rust +pub trait Actor: Mailboxed + Sized + Send + 'static { + type Args: Send; + type Error: ReplyError; + fn name() -> &'static str { type_name::() } + + fn on_start(args: Self::Args, actor_ref: ActorRef) + -> impl Future> + Send; + + fn handle(&mut self, msg: Self::Msg, actor_ref: ActorRef, stop: &mut bool) + -> impl Future> + Send; + + fn on_panic(&mut self, actor_ref: WeakActorRef, err: PanicError) // infallible, stop-only + -> impl Future + Send + { async move { ActorStopReason::Panicked(err) } } + + fn on_stop(&mut self, actor_ref: WeakActorRef, reason: ActorStopReason) + -> impl Future> + Send { async { Ok(()) } } + // Err logged + surfaced; NEVER unwrapped; &mut self is POISONED after a panic +} +``` + +### Actor ↔ mailbox coupling + +The shipped mailbox is keyed on `A: Mailboxed` (`Signal`, +`Mailbox::::bounded`). Rather than refactor the just-shipped mailbox, **`Actor` +is a subtrait of `Mailboxed`**, and the `: Msg` slot-budget tripwire is pulled in +via an associated-type bound: `Mailboxed`. This resolves the decision +`message.rs` explicitly deferred to #116 ("whether `Actor::Msg` bounds `: Msg`") +— **yes**, so every actor's message type gets the compile-time size tripwire. + +- `Mailboxed::Msg` itself stays `Send + 'static` (**no #112 change**). +- `type Msg` is **not** re-declared on `Actor` (a second `type Msg` would + shadow-clash); the message type surfaces as `A::Msg` through the supertrait. + +### The three finalized failure decisions (locked on the card, restated) + +- **`on_panic` is infallible, stop-only.** No `Result`, no resume. It only names + the terminal `ActorStopReason`; it cannot veto the stop. +- **supervision + links leave the base trait** (`supervision_strategy` / + `on_link_died` → a `Supervisor: Actor` supertrait in #120). Keeps the base + trait lean. +- **`on_stop` `Err` is logged + surfaced, never unwrapped.** A double-panic on + the shutdown path "will likely abort the program" (std `Drop` docs); unwrapping + would also replace the real cause with a second panic, blinding watchers. The + original stop `reason` is preserved for death-watch. + +## `&mut self` — kept, poisoned-on-panic + +The actor instance lives as a plain `&mut self` field in the loop's +`ActorBehaviour`, owned by the task. We do **not** adopt ractor's +framework-owned `State`; the single-writer boundary is enforced structurally by +the loop (one task, one mailbox, sequential `handle`). + +`&mut A` is not `UnwindSafe`, so each hook is wrapped in +`AssertUnwindSafe(...).catch_unwind()`. This is correct, not a smell: the danger +of `AssertUnwindSafe` is *witnessing* torn state (resuming on it), and we never +do — the loop breaks to shutdown the instant a panic is caught, and no resume +path exists. + +**Poison contract (baked onto the trait, #122-#12):** after a handler panic — + +1. the loop catches it → `on_panic(&mut self, …)` yields the stop reason; +2. `on_stop(&mut self, …, Panicked)` **still runs** (OTP `terminate` precedent); +3. but `&mut self` is **poisoned** (std `Mutex`-poison analogy): `on_stop` may do + **reason-independent resource release only** — never flush/derive/persist from + domain fields, which are torn and would corrupt the event log; +4. then `self` is dropped. Never resumed, never reused. + +*Loop-guaranteed:* zero further `handle` calls hit torn `self`; `Panicked` is +passed to `on_stop`. *Contract (user code, can't be statically enforced):* don't +read torn domain fields on the poisoned path — covered by a spy test. The +recovery (re-fold committed events) is the durability layer's job (#12/#120), +*above* this discarded `self`. + +## The run-loop — `bombay-core/src/actor/kind.rs` + +Lifecycle, top to bottom: + +``` +on_start(args) ─► loop { run_until_cancelled(recv) } ─► on_stop ─► drop self + catch_unwind catch_unwind per handle catch_unwind +``` + +**No startup buffer, no `select!`, no `VecDeque`** (decided against kameo): + +- **No startup buffer.** `on_start` *builds* the state (`-> Result`), so no + message is handleable until it completes. We `await on_start` fully, then loop. + Early messages wait in the **bounded flume mailbox** (already FIFO) and drain + after start. A separate `VecDeque` would double-buffer *and* be an unbounded + side-queue — contradicting the mailbox card's bounded-only / backpressure + principle. Slow hydration (the nexus event-replay case) correctly backpressures + senders instead of ballooning a side queue. + +- **`run_until_cancelled`, not `select!`.** tokio-util 0.7.18 provides + `CancellationToken::run_until_cancelled(&self, fut: F) -> Option`. + The loop body is a plain `match` — no macro, no biased/fairness footguns, no + per-branch cancellation-safety reasoning, and `Future`-generic so it survives + the M6/executor-seam swap: + + ```rust + loop { + match cancel.run_until_cancelled(mailbox_rx.recv()).await { + None => break ActorStopReason::Normal, // token cancelled (out-of-band graceful) + Some(None) => break ActorStopReason::Normal, // all senders gone + Some(Some(sig)) => match sig { + Signal::Message(m) => { /* AssertUnwindSafe(handle(m)).catch_unwind(); break if stop/err */ } + Signal::Stop => break ActorStopReason::Normal, // in-band graceful (FIFO) + Signal::LinkDied(_)=> { /* no-op: nothing produces this pre-#120; continue */ } + } + } + } + ``` + + Because `handle(m).await` runs **outside** the cancellation wrapper, an + in-flight handler always finishes before either stop is observed — the + "finish-current-then-stop, **no drain**" guarantee. Queued-behind messages are + abandoned (nexus re-drives them). + +### Three ways to stop + +| Trigger | Mechanism | `on_stop`? | In-flight msg | +|---|---|---|---| +| `Signal::Stop` (in-band, FIFO) | mailbox | ✅ runs | finishes | +| `CancellationToken` (out-of-band) | `run_until_cancelled` | ✅ runs | finishes | +| **kill** (hard) | `futures::Abortable` + `AbortHandle` | ❌ skipped | dropped mid-await | + +`futures::Abortable` (not `JoinHandle::abort`) wraps the whole lifecycle future, +so kill works uniformly for both `spawn` (background task) and `run` (current +task, for deterministic tests). The `AbortHandle` lives in the `ActorRef`. + +### Four `catch_unwind` sites → `PanicReason` + +`on_start` → `OnStart`; `handle` → `HandlerPanic`; `on_panic` → `OnPanic`; +`on_stop` → `OnStop` (all already in `error.rs`). A caught panic becomes a +`PanicError` via a **new `PanicError::from_panic_any(Box, +PanicReason)`** — the one addition to `error.rs`, already earmarked in its +`DEFERRED` comment for #116. + +### Two failure-routing decisions (card implicit — follow kameo) + +- **`handle` returns `Err(E)`** → treated as a controlled crash: + `Panicked(PanicError::new(Box::new(e), HandlerPanic))` → `on_panic` → stop. The + reply to the asker already went through the embedded port; a returned `Err` is + the handler escalating a fatal condition. (Matches kameo's `on_message` + `Ok(Err)` → `Panicked` arm.) +- **`stop: &mut bool`** set true by a handler → after the handler returns `Ok`, + the loop breaks with `Normal`. + +## Minimal `ActorRef` scaffold — `bombay-core/src/actor/actor_ref.rs` + +Each field is independently cheap-clone and shares state, so **no outer `Arc`** in +#116 (the Arc/Weak *counting* semantics are #117): + +```rust +pub struct ActorRef { // #[derive(Clone)] + id: ActorId, // mailbox's scaffold ActorId + mailbox: MailboxSender, // flume sender + cancel: CancellationToken, // graceful stop + abort: AbortHandle, // hard kill +} +pub struct WeakActorRef { // holds WeakMailboxSender; upgrade() fails once senders gone + id: ActorId, + mailbox: WeakMailboxSender, + cancel: CancellationToken, + abort: AbortHandle, +} +``` + +**#116 methods only:** `id()`, `downgrade()` / `upgrade()`, `stop()` (cancels the +token → graceful), `kill()` (abort → skips `on_stop`), `mailbox_sender()` (to +enqueue `Signal`s). The ergonomic `tell` / `ask` and ref-count-driven-stop are +**#117/#118** — not here. + +## Spawn — `bombay-core/src/actor/spawn.rs` + +- `PreparedActor`: created before running; holds `actor_ref` + `mailbox_rx` + + `AbortRegistration`. Methods: + - `.actor_ref() -> &ActorRef` — usable before the loop starts (pre-send). + - `.run(args) -> Result<(A, ActorStopReason), PanicError>` — runs in the current + task (deterministic tests; returns the final actor + reason). + - `.spawn(args) -> JoinHandle>`. +- Convenience: `Actor::spawn(args) -> ActorRef` (default cap); + `spawn_with_capacity(cap, args)`. `DEFAULT_MAILBOX_CAPACITY = 64`. +- **`run`/`spawn` return contract:** `on_start` panic/`Err` → `Err(PanicError{ + OnStart})` (no `A` to return). Handler panic → `Ok((torn A, Panicked))` (state + poisoned — see contract). `on_stop` panic → logged/surfaced, **original reason + preserved** in the returned tuple. + +## Testing (TDD — write failing first) + +Rule #7 categories + rule #8 (`@bug` FAILS while the bug exists). Handler-panic +probes use an `on_stop`/reason spy, **not** `should_panic` (handler panics are +actor-internal). + +| Test | Category | Note | +|---|---|---| +| `messages_during_on_start_handled_after_in_order` | sequence | flume FIFO, no buffer | +| `stop_flag_in_handle_stops_normally_after_ok` | sequence | `*stop = true` | +| `graceful_stop_finishes_current_then_stops` | lifecycle | cancel → in-flight completes, `on_stop` runs | +| `kill_skips_on_stop_and_drops_in_flight` | lifecycle | abort path | +| `@bug on_stop_runs_after_panic_with_panicked_reason` | lifecycle | spy sees `Panicked` | +| `@bug on_stop_after_panic_does_not_flush_torn_state` | defensive | poison contract | +| `@bug handler_panic_stops_actor_and_send_fails` | lifecycle | post-panic `send` → `SendError` | +| `on_start_panic_caught_as_panicerror` | defensive | **fails under `panic = abort`** (card's pin) | +| `on_start_error_never_handles_messages` | lifecycle | `Err` from `on_start` | +| `concurrent_senders_single_writer_ordering` | linearizability | real overlap (`spawn` + `Barrier`) | + +**Deferred:** `client_tier…single_threaded` → #9; full `rehydrate_from_log` → +#120 (#116 covers the *discard* half via the poison test). + +## Dependencies & README + +- New `bombay-core` dep: `tokio-util` (`CancellationToken`, absorbs #55). + `futures` (Abortable) is already available; add if not. +- `PanicError::from_panic_any` added to `error.rs`. +- README: `bombay-core` is not yet public API (the spine settles at the end of + #112–#121), so no user-facing README change; coverage moves go to + `docs/testing/coverage-baseline.md`. + +## Cross-links + +message model #114 · mailbox #112 · reply #115 · error #113 · ActorRef #117 · +request builders #118 · supervision/links #120 · identity #121 · `MaybeSend` #9 · +cancellation #55. From 602c3270217e35f3bf74d342d15a8f74d71f3f45 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:02:24 +0200 Subject: [PATCH 02/21] docs(plan): #116 actor trait & run-loop implementation plan + spec RunResult amendment --- .../plans/2026-07-05-116-actor-trait-loop.md | 1769 +++++++++++++++++ .../2026-07-05-116-actor-trait-loop-design.md | 33 +- 2 files changed, 1793 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md diff --git a/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md b/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md new file mode 100644 index 0000000..684f39d --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md @@ -0,0 +1,1769 @@ +# Actor trait, lifecycle hooks & run-loop (#116) — 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:** Build the local actor spine in `bombay-core` — the `Actor` trait, its lifecycle hooks (`on_start`/`handle`/`on_panic`/`on_stop`), the run-loop that drives it, and the spawn entry points — Send-saturated, with a minimal `ActorRef` scaffold. + +**Architecture:** `on_start` (builds state) → `loop { CancellationToken::run_until_cancelled(mailbox.recv()) }` → `on_stop`, with four `catch_unwind` sites tagging `PanicReason`. No startup buffer (the bounded flume mailbox is the FIFO buffer), no `select!` (a plain `match` on `run_until_cancelled`), no `VecDeque`. `&mut self` is kept and **poisoned-on-panic** (discard, never resume). Graceful stop via `CancellationToken`/`Signal::Stop`; hard kill via `futures::Abortable` (skips `on_stop`). + +**Tech Stack:** Rust (edition 2024, stable), `tokio` + `tokio-util` (`CancellationToken`), `futures` (`Abortable`, `catch_unwind`), `flume` (mailbox, already shipped), `thiserror`/`downcast-rs` (errors, already shipped). + +**Spec:** `docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md` + +--- + +## File Structure + +| File | Responsibility | Task | +|---|---|---| +| `Cargo.toml` (root) | add `tokio-util` to `[workspace.dependencies]` | 1 | +| `bombay-core/Cargo.toml` | add `tokio-util`, `futures` deps | 1 | +| `bombay-core/src/error.rs` | add `PanicError::from_panic_any` | 2 | +| `bombay-core/src/actor/mod.rs` | the `Actor` trait + `Spawn` ext-trait + re-exports | 3, 11 | +| `bombay-core/src/actor/actor_ref.rs` | minimal `ActorRef`/`WeakActorRef` scaffold | 3 | +| `bombay-core/src/actor/kind.rs` | run-loop (`ActorBehaviour` helpers) | 4–10 | +| `bombay-core/src/actor/spawn.rs` | `PreparedActor`, `RunResult`, `run`/`spawn` | 4–11 | +| `bombay-core/src/lib.rs` | `pub mod actor;` | 3 | + +**Clippy note (god-level bar, all new code):** functions ≤ 80 lines, cognitive-complexity ≤ 9, ≤ 5 args, **no** `unwrap`/`expect`/`panic`/`unreachable` in library code. Where a provably-safe `expect` is unavoidable (the default-capacity constant), use `#[expect(clippy::expect_used, reason = "…")]` on that line and cover it with a unit test. Run `cargo fmt` before every commit (the fmt gate is strict). The clippy gate checks lib + bins only, so `#[cfg(test)]` code is currently ungated — but write it clean anyway. + +**Test harness note:** all tests live in `#[cfg(test)] mod tests` at the bottom of each file. Behavioral tests use `#[tokio::test]`; deterministic ordering tests use `flavor = "current_thread"`; real-overlap tests use `flavor = "multi_thread"` + `tokio::sync::Barrier`. Run a single test with `cargo test -p bombay-core `. + +--- + +## Task 1: Add dependencies + +**Files:** +- Modify: `Cargo.toml` (root `[workspace.dependencies]`) +- Modify: `bombay-core/Cargo.toml` + +- [ ] **Step 1: Add `tokio-util` to the workspace dependency list** + +In root `Cargo.toml`, inside `[workspace.dependencies]`, add after the `flume` line: + +```toml +# 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" +``` + +- [ ] **Step 2: Add the deps to `bombay-core`** + +In `bombay-core/Cargo.toml`, under `[dependencies]`, add: + +```toml +tokio-util = { workspace = true } +futures = { workspace = true } +``` + +(`futures` is already declared in the root `[workspace.dependencies]` as `futures = "0.3"`; `tokio-util` was added in Step 1.) + +- [ ] **Step 3: Verify it builds and regenerate the workspace-hack** + +Run: `nix develop --command cargo build -p bombay-core` +Expected: builds clean (no code uses the new deps yet). + +Then regenerate hakari (mirrors nexus; required after any dependency change): + +Run: `nix develop --command cargo hakari generate` +Expected: updates the workspace-hack crate (may be a no-op). + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml bombay-core/Cargo.toml +git add -A # picks up any hakari-generated changes +git commit -m "build(core): add tokio-util + futures for the #116 run-loop" +``` + +--- + +## Task 2: `PanicError::from_panic_any` + +The run-loop's `catch_unwind` yields `Box`; this converts it into an inspectable `PanicError`. `error.rs` already earmarks this in a `DEFERRED` comment. The overwhelmingly common panic payloads are `&'static str` and `String`; other types are recorded with a placeholder (the concrete type is not recoverable from `dyn Any` without knowing it). + +**Files:** +- Modify: `bombay-core/src/error.rs` (the `impl PanicError` block, ~line 215; and its `tests` module) + +- [ ] **Step 1: Write the failing test** + +In `bombay-core/src/error.rs`, inside `mod tests`, add: + +```rust +/// A caught panic arrives as `Box` from `catch_unwind`. The two +/// common payloads — `&'static str` and `String` — are recovered as a string; +/// the phase is preserved. This is the loop's bridge from an unwind to a value. +#[test] +fn from_panic_any_recovers_string_payloads() { + let from_str = PanicError::from_panic_any(Box::new("boom"), PanicReason::HandlerPanic); + assert_eq!(from_str.with_str(str::to_owned), Some(String::from("boom"))); + assert_eq!(from_str.reason(), PanicReason::HandlerPanic); + + let from_string = + PanicError::from_panic_any(Box::new(String::from("kaboom")), PanicReason::OnStart); + assert_eq!(from_string.with_str(str::to_owned), Some(String::from("kaboom"))); + assert_eq!(from_string.reason(), PanicReason::OnStart); +} + +/// A non-string panic payload (an arbitrary type) cannot be recovered as its +/// concrete type from `dyn Any` without knowing it, so `from_panic_any` records +/// a stable placeholder string and preserves the phase. The placeholder must be +/// a recoverable `&str`, so a supervisor can still log *something*. +#[test] +fn from_panic_any_records_placeholder_for_non_string_payload() { + let panic = PanicError::from_panic_any(Box::new(42_u64), PanicReason::OnPanic); + assert_eq!(panic.reason(), PanicReason::OnPanic); + assert_eq!( + panic.with_str(str::to_owned), + Some(String::from("non-string panic payload")), + ); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `nix develop --command cargo test -p bombay-core from_panic_any` +Expected: FAIL — `no function or associated item named 'from_panic_any' found`. + +- [ ] **Step 3: Implement `from_panic_any`** + +In `bombay-core/src/error.rs`, add `use std::any::Any;` — the file already has `use std::{fmt, sync::Arc};`, so change it to: + +```rust +use std::{any::Any, fmt, sync::Arc}; +``` + +Then inside `impl PanicError`, replace the `// DEFERRED — from_panic_any …` comment with: + +```rust + /// Builds a `PanicError` from a caught unwind payload (`catch_unwind` yields + /// `Box`), tagging it with the phase that produced it. + /// + /// The common payloads — `&'static str` and `String` — are recovered as a + /// string. An arbitrary payload cannot be recovered as its concrete type + /// from `dyn Any` without naming it, so it is recorded as a stable + /// placeholder string (still inspectable via [`with_str`](Self::with_str)). + #[must_use] + pub fn from_panic_any(payload: Box, reason: PanicReason) -> Self { + let err: Box = match payload.downcast::() { + Ok(message) => Box::new(*message), + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => Box::new(*message), + Err(_unknown) => Box::new("non-string panic payload"), + }, + }; + Self::new(err, reason) + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `nix develop --command cargo test -p bombay-core from_panic_any` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +cargo fmt +git add bombay-core/src/error.rs +git commit -m "core(error): PanicError::from_panic_any — bridge catch_unwind to a value (#116)" +``` + +--- + +## Task 3: `Actor` trait + minimal `ActorRef`/`WeakActorRef` + +Introduce the `actor` module, the trait, and the minimal ref scaffold. This task compiles the trait + ref together (they are mutually referential) and unit-tests the ref's `downgrade`/`upgrade`/`id`. + +**Files:** +- Create: `bombay-core/src/actor/mod.rs` +- Create: `bombay-core/src/actor/actor_ref.rs` +- Modify: `bombay-core/src/lib.rs` + +- [ ] **Step 1: Write the module skeleton (compile target for the test)** + +Create `bombay-core/src/actor/mod.rs`: + +```rust +//! The local actor spine (card #116): the `Actor` trait, its lifecycle hooks, +//! the run-loop that drives it, and the spawn entry points. +//! +//! Send-saturated for now; the cfg-gated `MaybeSend` relaxation for +//! single-threaded client builds is a dedicated later sweep (#9). The `ActorRef` +//! here is a **minimal scaffold** — ref-count-driven stop, `Recipient` erasure, +//! and the `tell`/`ask` builders are #117/#118. + +use core::{any::type_name, future::Future}; + +use crate::{ + error::{ActorStopReason, PanicError, ReplyError}, + mailbox::Mailboxed, + message::Msg, +}; + +mod actor_ref; + +pub use self::actor_ref::{ActorRef, WeakActorRef}; + +/// A single-writer, identity-agnostic unit of concurrency: owned state behind a +/// mailbox, driven by one task that handles messages sequentially. +/// +/// `Actor` is a subtrait of [`Mailboxed`] (the mailbox is keyed on the actor), +/// and its message type is bounded `: Msg` so every actor's `Msg` gets the +/// compile-time slot-size tripwire (card #114). +/// +/// # Panics & poisoning +/// +/// A panic in `handle` is caught and routed to [`on_panic`](Actor::on_panic); +/// the actor then **stops** (there is no resume). After a panic `&mut self` is +/// **poisoned** (torn state): [`on_stop`](Actor::on_stop) still runs and may do +/// reason-independent resource release only — it must **never** flush or derive +/// from domain fields, which are torn. +pub trait Actor: Mailboxed + Sized + Send + 'static { + /// The argument passed to [`on_start`](Actor::on_start) to build the state. + type Args: Send; + /// The actor's own domain error, kept typed end to end. + type Error: ReplyError; + + /// A human-readable name for logs/tracing. Defaults to the type name. + #[must_use] + fn name() -> &'static str { + type_name::() + } + + /// Builds (or hydrates) the actor state. Runs to completion before any + /// message is handled; messages that arrive meanwhile wait in the mailbox. + fn on_start( + args: Self::Args, + actor_ref: ActorRef, + ) -> impl Future> + Send; + + /// Handles one message. Set `*stop = true` to stop the actor cleanly after + /// this handler returns `Ok`. A returned `Err` is treated as a controlled + /// crash (routed to `on_panic`, then stop). + fn handle( + &mut self, + msg: Self::Msg, + actor_ref: ActorRef, + stop: &mut bool, + ) -> impl Future> + Send; + + /// Observes a caught panic and names the terminal stop reason. Infallible + /// and stop-only — it cannot resume the actor. `&mut self` is poisoned. + fn on_panic( + &mut self, + actor_ref: WeakActorRef, + err: PanicError, + ) -> impl Future + Send { + let _ = actor_ref; + async move { ActorStopReason::Panicked(err) } + } + + /// Terminal cleanup. A returned `Err` is logged/surfaced, **never** + /// unwrapped, and the original `reason` is preserved. On the poisoned + /// (post-panic) path, do resource release only — never read domain fields. + fn on_stop( + &mut self, + actor_ref: WeakActorRef, + reason: ActorStopReason, + ) -> impl Future> + Send { + let _ = (actor_ref, reason); + async { Ok(()) } + } +} +``` + +- [ ] **Step 2: Write the minimal `ActorRef` scaffold with a failing unit test** + +Create `bombay-core/src/actor/actor_ref.rs`: + +```rust +//! 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 { + id: ActorId, + mailbox: MailboxSender, + cancel: CancellationToken, + abort: AbortHandle, +} + +impl Clone for ActorRef { + fn clone(&self) -> Self { + Self { + id: self.id, + mailbox: self.mailbox.clone(), + cancel: self.cancel.clone(), + abort: self.abort.clone(), + } + } +} + +impl fmt::Debug for ActorRef { + 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 ActorRef { + pub(crate) fn new( + id: ActorId, + mailbox: MailboxSender, + 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 { + &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 { + 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 { + id: ActorId, + mailbox: WeakMailboxSender, + cancel: CancellationToken, + abort: AbortHandle, +} + +impl Clone for WeakActorRef { + fn clone(&self) -> Self { + Self { + id: self.id, + mailbox: self.mailbox.clone(), + cancel: self.cancel.clone(), + abort: self.abort.clone(), + } + } +} + +impl fmt::Debug for WeakActorRef { + 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 WeakActorRef { + /// 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> { + 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::{Capacity, Mailbox, Mailboxed}; + + // 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) -> Result { + Ok(Probe) + } + async fn handle( + &mut self, + _: ProbeMsg, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + use crate::message::Msg; + + fn build_ref() -> (ActorRef, super::super::actor_ref::WeakActorRef) { + let cap = Capacity::try_from(4usize).expect("valid capacity"); + let (tx, _rx) = Mailbox::::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) + } + + /// 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", + ); + } +} +``` + +Note: the `use crate::message::Msg;` placement above is intentionally awkward to avoid an unused-import warning ordering issue; when implementing, put **all** `use` at the top of the `tests` module (house rule #6). The final tidy form is: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + use futures::stream::AbortHandle; + use tokio_util::sync::CancellationToken; + + use crate::{ + mailbox::{ActorId, Capacity, Mailbox, Mailboxed}, + message::Msg, + }; + // … struct Probe / impls / build_ref / the test … +} +``` + +- [ ] **Step 3: Wire the module into the crate** + +In `bombay-core/src/lib.rs`, add `pub mod actor;` in alphabetical order (before `pub mod error;`): + +```rust +pub mod actor; +pub mod error; +pub mod mailbox; +pub mod message; +pub mod reply; +``` + +- [ ] **Step 4: Run the test to verify it passes (and the module compiles)** + +Run: `nix develop --command cargo test -p bombay-core weak_upgrades_while_open` +Expected: PASS. + +If the associated-type-bound supertrait `Mailboxed` fails to parse on the pinned toolchain, fall back to a `where` clause: declare `pub trait Actor: Mailboxed + Sized + Send + 'static where Self::Msg: Msg` — semantically identical. (Associated-type bounds are stable since Rust 1.79; the repo pins ≥ 1.85, so the inline form should work.) + +- [ ] **Step 5: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/mod.rs bombay-core/src/actor/actor_ref.rs bombay-core/src/lib.rs +git commit -m "core(actor): Actor trait + minimal ActorRef/WeakActorRef scaffold (#116)" +``` + +--- + +## Task 4: Walking skeleton — the run-loop + `PreparedActor::run` + +Build the minimal loop that: runs `on_start`, drains the mailbox handling messages, stops on `Signal::Stop`, and runs `on_stop`. This is the biggest task (the pieces are mutually dependent); subsequent tasks add one guarantee each against this skeleton. + +**Files:** +- Create: `bombay-core/src/actor/kind.rs` +- Create: `bombay-core/src/actor/spawn.rs` +- Modify: `bombay-core/src/actor/mod.rs` (declare the two submodules + re-exports) + +- [ ] **Step 1: Write the failing behavioral test** + +Create `bombay-core/src/actor/spawn.rs` with just a `tests` module for now (the impl comes in Step 3): + +```rust +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, + }; + + use crate::{ + actor::{ActorRef, PreparedActor, RunResult, WeakActorRef}, + error::ActorStopReason, + mailbox::{Capacity, Mailboxed, Signal}, + message::Msg, + }; + + /// Counts handled messages and records whether `on_stop` ran, via shared + /// atomics the test inspects — the SUT is the real loop, not a reimpl. + struct Counter { + handled: Arc, + stopped: Arc, + } + struct Tick; + impl Msg for Tick {} + impl Mailboxed for Counter { + type Msg = Tick; + } + impl crate::actor::Actor for Counter { + type Args = (Arc, Arc); + type Error = core::convert::Infallible; + + async fn on_start( + (handled, stopped): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { handled, stopped }) + } + + async fn handle( + &mut self, + _: Tick, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + fn cap(n: usize) -> Capacity { + Capacity::try_from(n).expect("valid test capacity") + } + + /// Sequence: two messages then a `Stop` — both are handled (FIFO, before the + /// stop), `on_stop` runs exactly once, and the outcome is a normal stop. + #[tokio::test] + async fn handles_queued_messages_then_stops_normally() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref.mailbox_sender().send(Signal::Message(Tick)).await.expect("send 1"); + actor_ref.mailbox_sender().send(Signal::Message(Tick)).await.expect("send 2"); + actor_ref.mailbox_sender().send(Signal::Stop).await.expect("stop"); + + let outcome = prepared.run((Arc::clone(&handled), Arc::clone(&stopped))).await; + + assert_eq!(handled.load(Ordering::SeqCst), 2, "both messages handled before stop"); + assert_eq!(stopped.load(Ordering::SeqCst), 1, "on_stop ran once"); + assert!( + matches!(outcome, RunResult::Stopped { reason: ActorStopReason::Normal, .. }), + "clean normal stop", + ); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `nix develop --command cargo test -p bombay-core handles_queued_messages_then_stops_normally` +Expected: FAIL — `PreparedActor`, `RunResult` not found. + +- [ ] **Step 3: Implement the run-loop (`kind.rs`)** + +Create `bombay-core/src/actor/kind.rs`: + +```rust +//! The actor run-loop (card #116): drive `on_start` → message loop → `on_stop`, +//! with a `catch_unwind` around each hook so a panic becomes an inspectable +//! `PanicError` instead of tearing down the task. + +use std::{ops::ControlFlow, panic::AssertUnwindSafe}; + +use futures::FutureExt; + +use crate::{ + actor::{Actor, ActorRef}, + error::{ActorStopReason, PanicError, PanicReason}, + mailbox::{MailboxReceiver, Signal}, +}; + +/// Runs the message loop until a stop condition, returning the stop reason. +/// +/// `state` is the live actor; `actor_ref` is its strong self-handle (kept strong +/// in #116 — ref-count-driven stop is #117). The loop finishes any in-flight +/// handler before observing a graceful stop ("finish-current-then-stop, no +/// drain"). +pub(crate) async fn run_message_loop( + state: &mut A, + actor_ref: &ActorRef, + mailbox_rx: &mut MailboxReceiver, +) -> ActorStopReason { + let cancel = actor_ref.cancel_token(); + loop { + match cancel.run_until_cancelled(mailbox_rx.recv()).await { + // Token cancelled (out-of-band graceful stop). + None => return ActorStopReason::Normal, + // All senders dropped (unreachable in #116 — the loop holds one). + Some(None) => return ActorStopReason::Normal, + Some(Some(signal)) => match signal { + Signal::Message(msg) => { + if let ControlFlow::Break(reason) = + handle_message(state, actor_ref, msg).await + { + return reason; + } + } + // In-band graceful stop (FIFO): everything queued ahead was + // already handled above. + Signal::Stop => return ActorStopReason::Normal, + // Nothing produces LinkDied pre-#120; ignore and keep running. + Signal::LinkDied(_) => {} + }, + } + } +} + +/// Handles one message under `catch_unwind`. `Continue` keeps looping; `Break` +/// carries the terminal stop reason. +async fn handle_message( + state: &mut A, + actor_ref: &ActorRef, + msg: A::Msg, +) -> ControlFlow { + let mut stop = false; + let result = AssertUnwindSafe(state.handle(msg, actor_ref.clone(), &mut stop)) + .catch_unwind() + .await; + match result { + Ok(Ok(())) if stop => ControlFlow::Break(ActorStopReason::Normal), + Ok(Ok(())) => ControlFlow::Continue(()), + // A returned Err is a controlled crash: observe via on_panic, then stop. + Ok(Err(err)) => { + let panic = PanicError::new(Box::new(err), PanicReason::HandlerPanic); + ControlFlow::Break(run_on_panic(state, actor_ref, panic).await) + } + // The handler unwound: catch, observe via on_panic, then stop. + Err(payload) => { + let panic = PanicError::from_panic_any(payload, PanicReason::HandlerPanic); + ControlFlow::Break(run_on_panic(state, actor_ref, panic).await) + } + } +} + +/// Runs `on_panic` (infallible, stop-only) under `catch_unwind`; if the hook +/// itself panics, that becomes the terminal reason instead. +async fn run_on_panic( + state: &mut A, + actor_ref: &ActorRef, + err: PanicError, +) -> ActorStopReason { + let weak = actor_ref.downgrade(); + match AssertUnwindSafe(state.on_panic(weak, err)).catch_unwind().await { + Ok(reason) => reason, + Err(payload) => { + ActorStopReason::Panicked(PanicError::from_panic_any(payload, PanicReason::OnPanic)) + } + } +} +``` + +- [ ] **Step 4: Implement `spawn.rs` (`PreparedActor`, `RunResult`, the lifecycle driver)** + +At the **top** of `bombay-core/src/actor/spawn.rs` (above the `tests` module), add: + +```rust +//! Spawn entry points (card #116): prepare an actor, then run it in the current +//! task or a background tokio task. Kill is uniform across both via +//! `futures::Abortable` wrapping the whole lifecycle (so a hard kill skips +//! `on_stop`). + +use std::{ + fmt, + panic::AssertUnwindSafe, + sync::atomic::{AtomicU64, Ordering}, +}; + +use futures::{ + FutureExt, + stream::{AbortHandle, AbortRegistration, Abortable}, +}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::{ + actor::{Actor, ActorRef, kind::run_message_loop}, + error::{ActorStopReason, PanicError, PanicReason}, + mailbox::{ActorId, Capacity, Mailbox, MailboxReceiver}, +}; + +/// The default mailbox capacity for the ergonomic spawn path (4 cache-lines' +/// worth of slots is a sane starting point; tune with `spawn_with_capacity`). +pub const DEFAULT_MAILBOX_CAPACITY: usize = 64; + +/// Monotonic scaffold id source (#121 replaces this with the AID). +static NEXT_ACTOR_ID: AtomicU64 = AtomicU64::new(1); + +fn next_actor_id() -> ActorId { + // Relaxed is sufficient: correctness needs only that each `fetch_add` returns + // a distinct value. Uniqueness is a property of atomic increment alone and + // requires no happens-before with any other memory (CLAUDE rule #5). + ActorId::new(NEXT_ACTOR_ID.fetch_add(1, Ordering::Relaxed)) +} + +/// The total outcome of running an actor to completion in the current task. +pub enum RunResult { + /// Ran and stopped. If `reason` is [`ActorStopReason::Panicked`], `actor` is + /// **poisoned** (torn state): resource-release only, never read domain fields. + Stopped { + /// The final actor state. + actor: A, + /// Why it stopped. + reason: ActorStopReason, + }, + /// `on_start` returned `Err` or panicked — no actor was produced. + StartupFailed(PanicError), + /// Hard-killed via [`ActorRef::kill`] — `on_stop` was skipped, state dropped. + Killed, +} + +impl fmt::Debug for RunResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stopped { reason, .. } => { + f.debug_struct("Stopped").field("reason", reason).finish_non_exhaustive() + } + Self::StartupFailed(err) => f.debug_tuple("StartupFailed").field(err).finish(), + Self::Killed => f.write_str("Killed"), + } + } +} + +/// An actor initialized and ready to run, with its [`ActorRef`] available before +/// the loop starts (so callers can pre-send messages). +#[must_use = "a prepared actor must be run or spawned"] +pub struct PreparedActor { + actor_ref: ActorRef, + mailbox_rx: MailboxReceiver, + abort_registration: AbortRegistration, +} + +impl fmt::Debug for PreparedActor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PreparedActor").field("actor_ref", &self.actor_ref).finish_non_exhaustive() + } +} + +impl PreparedActor { + /// Prepares an actor with a mailbox of the given `capacity`. + pub fn new(capacity: Capacity) -> Self { + let (mailbox_tx, mailbox_rx) = Mailbox::::bounded(capacity); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let actor_ref = + ActorRef::new(next_actor_id(), mailbox_tx, CancellationToken::new(), abort_handle); + Self { actor_ref, mailbox_rx, abort_registration } + } + + /// The handle to the actor, usable before the loop starts. + #[must_use] + pub const fn actor_ref(&self) -> &ActorRef { + &self.actor_ref + } + + /// Runs the actor in the current task until it stops. Aborts (hard kill) + /// short-circuit to [`RunResult::Killed`], skipping `on_stop`. + pub async fn run(self, args: A::Args) -> RunResult { + let lifecycle = run_lifecycle(args, self.actor_ref, self.mailbox_rx); + Abortable::new(lifecycle, self.abort_registration) + .await + .unwrap_or(RunResult::Killed) + } + + /// Spawns the actor in a background tokio task. + pub fn spawn(self, args: A::Args) -> JoinHandle> { + tokio::spawn(self.run(args)) + } +} + +/// `on_start` (catch) → message loop → `on_stop` (catch; Err logged, reason +/// preserved). Returns `StartupFailed` if `on_start` fails, else `Stopped`. +async fn run_lifecycle( + args: A::Args, + actor_ref: ActorRef, + mut mailbox_rx: MailboxReceiver, +) -> RunResult { + let started = AssertUnwindSafe(A::on_start(args, actor_ref.clone())).catch_unwind().await; + let mut state = match started { + Ok(Ok(actor)) => actor, + Ok(Err(err)) => { + return RunResult::StartupFailed(PanicError::new(Box::new(err), PanicReason::OnStart)); + } + Err(payload) => { + return RunResult::StartupFailed(PanicError::from_panic_any( + payload, + PanicReason::OnStart, + )); + } + }; + + let reason = run_message_loop(&mut state, &actor_ref, &mut mailbox_rx).await; + + let weak = actor_ref.downgrade(); + let stop_result = + AssertUnwindSafe(state.on_stop(weak, reason.clone())).catch_unwind().await; + log_on_stop_outcome::(&reason, stop_result); + + RunResult::Stopped { actor: state, reason } +} + +/// Logs a failed/panicked `on_stop` without altering the preserved stop reason +/// and without unwrapping (a double-panic on the shutdown path can abort the +/// process — std `Drop` docs). +fn log_on_stop_outcome( + reason: &ActorStopReason, + stop_result: Result, Box>, +) { + match stop_result { + Ok(Ok(())) => {} + Ok(Err(err)) => { + eprintln!("[bombay] on_stop for {} returned an error: {err:?} (stop reason: {reason})", A::name()); + } + Err(_payload) => { + eprintln!("[bombay] on_stop for {} panicked (stop reason: {reason})", A::name()); + } + } +} +``` + +Note on `eprintln!`: the god-level bar bans `clippy::print_stderr` in library code. The tracing wiring is a later concern (the `tracing` feature is repurposed in #66). For #116, wrap the two `eprintln!` lines with `#[expect(clippy::print_stderr, reason = "diagnostic-only until the tracing feature lands (#66); on_stop failure must be surfaced, never swallowed")]` on the enclosing `match` arms, or gate them behind `#[cfg(...)]`. Simplest: put the attribute on the `log_on_stop_outcome` function: + +```rust +#[expect( + clippy::print_stderr, + reason = "diagnostic-only surface until the tracing feature lands (#66); \ + an on_stop failure must be surfaced, never swallowed" +)] +fn log_on_stop_outcome(/* … */) { /* … */ } +``` + +- [ ] **Step 5: Declare the submodules + re-exports in `mod.rs`** + +In `bombay-core/src/actor/mod.rs`, update the module declarations and re-exports: + +```rust +mod actor_ref; +mod kind; +mod spawn; + +pub use self::{ + actor_ref::{ActorRef, WeakActorRef}, + spawn::{DEFAULT_MAILBOX_CAPACITY, PreparedActor, RunResult}, +}; +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `nix develop --command cargo test -p bombay-core handles_queued_messages_then_stops_normally` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/ +git commit -m "core(actor): run-loop walking skeleton — on_start → loop → on_stop (#116)" +``` + +--- + +## Task 5: Graceful stop via `CancellationToken` finishes the in-flight message + +**Files:** +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `spawn.rs`: + +```rust +/// Lifecycle: `stop()` (out-of-band cancel) while a handler is mid-flight lets +/// that handler finish, then stops and runs `on_stop`. The queued-behind message +/// is abandoned (finish-current-then-stop, no drain). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_finishes_in_flight_then_stops() { + use tokio::sync::oneshot; + + struct Slow { + entered: Option>, + release: Option>, + handled: Arc, + } + struct Work; + impl Msg for Work {} + impl Mailboxed for Slow { + type Msg = Work; + } + impl crate::actor::Actor for Slow { + type Args = (oneshot::Sender<()>, oneshot::Receiver<()>, Arc); + type Error = core::convert::Infallible; + async fn on_start( + (entered, release, handled): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { entered: Some(entered), release: Some(release), handled }) + } + async fn handle(&mut self, _: Work, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + if let Some(release) = self.release.take() { + let _ = release.await; + } + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let handled = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + // Two messages: the first blocks until released; the second must be abandoned. + actor_ref.mailbox_sender().send(Signal::Message(Work)).await.expect("send 1"); + actor_ref.mailbox_sender().send(Signal::Message(Work)).await.expect("send 2"); + + let run = tokio::spawn(prepared.run((entered_tx, release_rx, Arc::clone(&handled)))); + + entered_rx.await.expect("handler entered"); // handler #1 is mid-flight + actor_ref.stop(); // cancel while in-flight + release_tx.send(()).expect("release handler"); // let handler #1 finish + + let outcome = run.await.expect("run task"); + assert_eq!(handled.load(Ordering::SeqCst), 1, "only the in-flight message finished; the queued one was abandoned"); + assert!(matches!(outcome, RunResult::Stopped { reason: ActorStopReason::Normal, .. })); +} +``` + +- [ ] **Step 2: Run the test to verify it passes (the skeleton already supports cancel)** + +Run: `nix develop --command cargo test -p bombay-core cancel_finishes_in_flight_then_stops` +Expected: PASS — the loop already awaits `handle` outside the cancellation wrapper, so the in-flight handler finishes before the next `run_until_cancelled` observes the cancel. + +If it FAILS (e.g. the queued message is also handled), the bug is that cancellation was checked in the wrong place; the `run_until_cancelled(mailbox_rx.recv())` in `kind.rs` must wrap **only the recv**, never the `handle`. Fix in `kind.rs`. + +- [ ] **Step 3: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/spawn.rs +git commit -m "core(actor): test graceful cancel finishes in-flight then stops (#116)" +``` + +--- + +## Task 6: `stop: &mut bool` stops the actor after the handler returns + +**Files:** +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module: + +```rust +/// Sequence: a handler that sets `*stop = true` stops the actor cleanly after it +/// returns `Ok` — a following queued message is never handled. +#[tokio::test] +async fn stop_flag_stops_after_current_handler() { + struct Once { + handled: Arc, + } + struct Go; + impl Msg for Go {} + impl Mailboxed for Once { + type Msg = Go; + } + impl crate::actor::Actor for Once { + type Args = Arc; + type Error = core::convert::Infallible; + async fn on_start(handled: Self::Args, _: ActorRef) -> Result { + Ok(Self { handled }) + } + async fn handle(&mut self, _: Go, _: ActorRef, stop: &mut bool) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + *stop = true; + Ok(()) + } + } + + let handled = Arc::new(AtomicU32::new(0)); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref.mailbox_sender().send(Signal::Message(Go)).await.expect("send 1"); + actor_ref.mailbox_sender().send(Signal::Message(Go)).await.expect("send 2"); + + let outcome = prepared.run(Arc::clone(&handled)).await; + assert_eq!(handled.load(Ordering::SeqCst), 1, "stopped after the first handler; second never ran"); + assert!(matches!(outcome, RunResult::Stopped { reason: ActorStopReason::Normal, .. })); +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `nix develop --command cargo test -p bombay-core stop_flag_stops_after_current_handler` +Expected: PASS — the skeleton's `handle_message` already returns `Break(Normal)` when `stop` is set. + +- [ ] **Step 3: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/spawn.rs +git commit -m "core(actor): test *stop flag stops after the current handler (#116)" +``` + +--- + +## Task 7: Messages sent during `on_start` are handled after, in order + +**Files:** +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module: + +```rust +/// Sequence (no startup buffer): messages that arrive while `on_start` is still +/// running wait in the bounded mailbox and are handled *after* start, in FIFO +/// order — the ordering guarantee comes from the flume channel, not a buffer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn messages_during_on_start_are_handled_after_in_order() { + use std::sync::Mutex; + use tokio::sync::oneshot; + + struct Recorder { + seen: Arc>>, + } + struct N(u32); + impl Msg for N {} + impl Mailboxed for Recorder { + type Msg = N; + } + impl crate::actor::Actor for Recorder { + type Args = (oneshot::Receiver<()>, Arc>>); + type Error = core::convert::Infallible; + async fn on_start( + (gate, seen): Self::Args, + _: ActorRef, + ) -> Result { + let _ = gate.await; // block startup until the test has enqueued messages + Ok(Self { seen }) + } + async fn handle(&mut self, N(n): N, _: ActorRef, stop: &mut bool) -> Result<(), Self::Error> { + self.seen.lock().expect("lock").push(n); + if n == 2 { + *stop = true; + } + Ok(()) + } + } + + let (gate_tx, gate_rx) = oneshot::channel(); + let seen = Arc::new(Mutex::new(Vec::new())); + + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + let run = tokio::spawn(prepared.run((gate_rx, Arc::clone(&seen)))); + + // Enqueue BEFORE releasing on_start — these must be buffered by the mailbox. + actor_ref.mailbox_sender().send(Signal::Message(N(0))).await.expect("send 0"); + actor_ref.mailbox_sender().send(Signal::Message(N(1))).await.expect("send 1"); + actor_ref.mailbox_sender().send(Signal::Message(N(2))).await.expect("send 2"); + gate_tx.send(()).expect("release on_start"); + + run.await.expect("run task"); + assert_eq!(*seen.lock().expect("lock"), vec![0, 1, 2], "handled after start, in FIFO order"); +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `nix develop --command cargo test -p bombay-core messages_during_on_start_are_handled_after_in_order` +Expected: PASS — `on_start` is awaited fully before the loop starts; the mailbox holds the early messages in order. + +- [ ] **Step 3: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/spawn.rs +git commit -m "core(actor): test on-start messages handled after, in order (no buffer) (#116)" +``` + +--- + +## Task 8: `on_start` failure — `Err` and panic both yield `StartupFailed` + +The `on_start` panic test is the card's **"fails under `panic = abort`"** pin: under `panic = "abort"` the process would abort instead of producing a `StartupFailed`, so this test only passes under `panic = "unwind"`. + +**Files:** +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module: + +```rust +/// Lifecycle: `on_start` returning `Err` produces `StartupFailed` (no actor, no +/// message ever handled) — tagged as an `OnStart`-phase panic reason. +#[tokio::test] +async fn on_start_error_yields_startup_failed() { + #[derive(Debug)] + struct Boom; + struct NeverStarts; + struct Never; + impl Msg for Never {} + impl Mailboxed for NeverStarts { + type Msg = Never; + } + impl crate::actor::Actor for NeverStarts { + type Args = (); + type Error = Boom; + async fn on_start(_: (), _: ActorRef) -> Result { + Err(Boom) + } + async fn handle(&mut self, _: Never, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + Ok(()) + } + } + + let outcome = PreparedActor::::new(cap(4)).run(()).await; + let RunResult::StartupFailed(err) = outcome else { + panic!("expected StartupFailed, got {outcome:?}"); + }; + assert_eq!(err.reason(), crate::error::PanicReason::OnStart); +} + +/// Defensive: a panic in `on_start` is CAUGHT (not a process abort) and becomes +/// `StartupFailed` with the `OnStart` reason and the recoverable message. +/// +/// This is the card's `panic = "unwind"` pin: under `panic = "abort"` the +/// process aborts here instead, and the test cannot pass. +#[tokio::test] +async fn on_start_panic_is_caught_as_startup_failed() { + struct PanicsOnStart; + struct Never; + impl Msg for Never {} + impl Mailboxed for PanicsOnStart { + type Msg = Never; + } + impl crate::actor::Actor for PanicsOnStart { + type Args = (); + type Error = core::convert::Infallible; + async fn on_start(_: (), _: ActorRef) -> Result { + panic!("startup boom") + } + async fn handle(&mut self, _: Never, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + Ok(()) + } + } + + let outcome = PreparedActor::::new(cap(4)).run(()).await; + let RunResult::StartupFailed(err) = outcome else { + panic!("expected StartupFailed, got {outcome:?}"); + }; + assert_eq!(err.reason(), crate::error::PanicReason::OnStart); + assert_eq!(err.with_str(str::to_owned), Some(String::from("startup boom"))); +} +``` + +- [ ] **Step 2: Run the tests to verify they pass** + +Run: `nix develop --command cargo test -p bombay-core on_start_` +Expected: PASS (both) — the skeleton's `run_lifecycle` already maps `Err`/panic in `on_start` to `StartupFailed`. + +- [ ] **Step 3: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/spawn.rs +git commit -m "core(actor): test on_start Err/panic → StartupFailed (unwind pin) (#116)" +``` + +--- + +## Task 9: Handler panic — `on_stop` runs with `Panicked`, poison contract, post-panic send fails + +Three guarantees in one task (they share the panic setup): (a) `on_stop` runs after a handler panic and sees `Panicked`; (b) the poison contract — `on_stop` must not flush torn state, verified by a spy; (c) after the panic-stop the mailbox is closed, so a later send fails. + +**Files:** +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module: + +```rust +/// A handler that panics mid-mutation, with an `on_stop` spy that records the +/// reason it received and whether it observed torn state. Shared across the +/// three panic guarantees below. +mod panic_probe { + use super::*; + use std::sync::Mutex; + + pub(super) struct Torn { + pub(super) counter: u32, + pub(super) stop_reason: Arc>>, + pub(super) counter_at_stop: Arc>>, + } + pub(super) struct Explode; + impl Msg for Explode {} + impl Mailboxed for Torn { + type Msg = Explode; + } + impl crate::actor::Actor for Torn { + type Args = (Arc>>, Arc>>); + type Error = core::convert::Infallible; + async fn on_start( + (stop_reason, counter_at_stop): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { counter: 0, stop_reason, counter_at_stop }) + } + async fn handle(&mut self, _: Explode, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + self.counter = 99; // torn write BEFORE the panic + panic!("handler boom"); + } + async fn on_stop( + &mut self, + _: WeakActorRef, + reason: ActorStopReason, + ) -> Result<(), Self::Error> { + // Records the reason and the poisoned field value — a real on_stop + // must NOT persist `self.counter` (torn); this spy only records it so + // the test can assert the loop DID run on_stop with the torn state + // present (the contract is "don't flush", enforced by review + this + // documented probe). + *self.stop_reason.lock().expect("lock") = Some(reason); + *self.counter_at_stop.lock().expect("lock") = Some(self.counter); + Ok(()) + } + } +} + +/// `@bug` Lifecycle: after a handler panic, `on_stop` STILL runs and receives +/// `ActorStopReason::Panicked` (OTP `terminate` precedent). Fails if the loop +/// skips `on_stop` on the panic path. +#[tokio::test] +async fn on_stop_runs_after_panic_with_panicked_reason() { + use panic_probe::*; + use std::sync::Mutex; + + let stop_reason: Arc>> = Arc::new(Mutex::new(None)); + let counter_at_stop = Arc::new(Mutex::new(None)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref.mailbox_sender().send(Signal::Message(Explode)).await.expect("send"); + + let outcome = prepared.run((Arc::clone(&stop_reason), Arc::clone(&counter_at_stop))).await; + + assert!( + matches!(&outcome, RunResult::Stopped { reason: ActorStopReason::Panicked(_), .. }), + "panic → Stopped with Panicked, got {outcome:?}", + ); + let recorded = stop_reason.lock().expect("lock").clone(); + assert!( + matches!(recorded, Some(ActorStopReason::Panicked(_))), + "on_stop ran and saw Panicked, got {recorded:?}", + ); +} + +/// `@bug` Defensive (poison contract): the field mutated just before the panic +/// (`counter = 99`) IS still visible to `on_stop` (proving the state is torn, not +/// rolled back) — which is exactly why a real `on_stop` must NOT flush it. This +/// pins that the loop surfaces torn state to `on_stop` rather than silently +/// discarding before cleanup, so the "don't flush" contract is meaningful. +#[tokio::test] +async fn on_stop_after_panic_observes_torn_state() { + use panic_probe::*; + use std::sync::Mutex; + + let stop_reason = Arc::new(Mutex::new(None)); + let counter_at_stop: Arc>> = Arc::new(Mutex::new(None)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref.mailbox_sender().send(Signal::Message(Explode)).await.expect("send"); + let _ = prepared.run((Arc::clone(&stop_reason), Arc::clone(&counter_at_stop))).await; + + assert_eq!( + *counter_at_stop.lock().expect("lock"), + Some(99), + "on_stop sees the torn (pre-panic-mutated) field — hence must not flush it", + ); +} +``` + +Add the post-panic-send test (uses the simpler `Counter`-style actor that panics): + +```rust +/// `@bug` Lifecycle: once a handler panic stops the actor, its mailbox receiver +/// is dropped, so a later `send` fails (the actor is gone). Fails if teardown +/// leaves the receiver alive on the panic path. +#[tokio::test] +async fn send_after_handler_panic_fails() { + struct Bomb; + struct Trigger; + impl Msg for Trigger {} + impl Mailboxed for Bomb { + type Msg = Trigger; + } + impl crate::actor::Actor for Bomb { + type Args = (); + type Error = core::convert::Infallible; + async fn on_start(_: (), _: ActorRef) -> Result { + Ok(Bomb) + } + async fn handle(&mut self, _: Trigger, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + panic!("boom") + } + } + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let handle = prepared.spawn(()); + actor_ref.mailbox_sender().send(Signal::Message(Trigger)).await.expect("send trigger"); + + let outcome = handle.await.expect("run task"); + assert!(matches!(outcome, RunResult::Stopped { reason: ActorStopReason::Panicked(_), .. })); + + let resend = actor_ref.mailbox_sender().send(Signal::Message(Trigger)).await; + assert!(resend.is_err(), "the actor's mailbox is closed after the panic-stop"); +} +``` + +Add the `handle`-returns-`Err` test (exercises the `Ok(Err(err))` arm of `handle_message`, which no panic test hits — a returned error is NOT an unwind): + +```rust +/// Lifecycle: a handler that RETURNS `Err` (not a panic) is a controlled crash — +/// it stops the actor with `Panicked(HandlerPanic)` and runs `on_stop`. This is +/// the only test that exercises the `Ok(Err(_))` arm of the loop's dispatch. +#[tokio::test] +async fn handle_returning_err_stops_as_panicked() { + #[derive(Debug)] + struct Nope; + struct Failer; + struct Do; + impl Msg for Do {} + impl Mailboxed for Failer { + type Msg = Do; + } + impl crate::actor::Actor for Failer { + type Args = (); + type Error = Nope; + async fn on_start(_: (), _: ActorRef) -> Result { + Ok(Failer) + } + async fn handle(&mut self, _: Do, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + Err(Nope) + } + } + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref.mailbox_sender().send(Signal::Message(Do)).await.expect("send"); + let outcome = prepared.run(()).await; + + let RunResult::Stopped { reason: ActorStopReason::Panicked(err), .. } = outcome else { + panic!("expected Stopped/Panicked, got {outcome:?}"); + }; + assert_eq!(err.reason(), crate::error::PanicReason::HandlerPanic); +} +``` + +- [ ] **Step 2: Run the tests to verify they pass** + +Run: `nix develop --command cargo test -p bombay-core -- on_stop_runs_after_panic on_stop_after_panic_observes_torn_state send_after_handler_panic handle_returning_err_stops_as_panicked` +Expected: PASS (all four) — the skeleton runs `on_stop` on every non-kill exit, maps a returned `Err` to `Panicked(HandlerPanic)`, and the `run` future drops `mailbox_rx` on completion. + +- [ ] **Step 3: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/spawn.rs +git commit -m "core(actor): test on_stop-after-panic, poison contract, post-panic send (#116)" +``` + +--- + +## Task 10: Hard kill skips `on_stop` and drops the in-flight message + +**Files:** +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module: + +```rust +/// Lifecycle: `kill()` while a handler is mid-flight aborts the task at its next +/// await point — the handler never completes, `on_stop` does NOT run, and the +/// outcome is `Killed`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn kill_skips_on_stop_and_drops_in_flight() { + use tokio::sync::oneshot; + + struct Blocker { + entered: Option>, + finished: Arc, + stopped: Arc, + } + struct Block; + impl Msg for Block {} + impl Mailboxed for Blocker { + type Msg = Block; + } + impl crate::actor::Actor for Blocker { + type Args = (oneshot::Sender<()>, Arc, Arc); + type Error = core::convert::Infallible; + async fn on_start( + (entered, finished, stopped): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { entered: Some(entered), finished, stopped }) + } + async fn handle(&mut self, _: Block, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + std::future::pending::<()>().await; // never completes until aborted + self.finished.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn on_stop(&mut self, _: WeakActorRef, _: ActorStopReason) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let finished = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref.mailbox_sender().send(Signal::Message(Block)).await.expect("send"); + let handle = prepared.spawn((entered_tx, Arc::clone(&finished), Arc::clone(&stopped))); + + entered_rx.await.expect("handler entered"); // handler is now parked forever + actor_ref.kill(); // hard abort + + let outcome = handle.await.expect("join"); + assert!(matches!(outcome, RunResult::Killed), "kill → Killed, got {outcome:?}"); + assert_eq!(finished.load(Ordering::SeqCst), 0, "in-flight handler dropped, never finished"); + assert_eq!(stopped.load(Ordering::SeqCst), 0, "on_stop skipped on hard kill"); +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `nix develop --command cargo test -p bombay-core kill_skips_on_stop_and_drops_in_flight` +Expected: PASS — `Abortable` wraps the whole lifecycle, so `kill()` drops the future mid-handler; `run` maps the `Aborted` to `RunResult::Killed`. + +- [ ] **Step 3: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/spawn.rs +git commit -m "core(actor): test hard kill skips on_stop and drops in-flight (#116)" +``` + +--- + +## Task 11: `Spawn` ext-trait + `DEFAULT_MAILBOX_CAPACITY` + concurrent ordering + +**Files:** +- Modify: `bombay-core/src/actor/mod.rs` (add the `Spawn` trait) +- Modify: `bombay-core/src/actor/spawn.rs` (add `default_capacity`, re-export via mod) +- Test: `bombay-core/src/actor/spawn.rs` (`tests` module) + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module in `spawn.rs`: + +```rust +/// The ergonomic spawn path uses the default mailbox capacity; pin the constant +/// and that `default_capacity()` yields exactly it (guards a wrong default). +#[test] +fn default_capacity_is_64() { + assert_eq!(DEFAULT_MAILBOX_CAPACITY, 64); + assert_eq!(super::default_capacity().get(), 64); +} + +/// Linearizability / single-writer: many senders race messages at one actor from +/// the same instant; the actor handles them sequentially, so the total count is +/// exact (none lost or double-counted) despite real concurrency. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_senders_single_writer_exact_count() { + use crate::actor::Spawn; + use tokio::sync::{Barrier, oneshot}; + + const SENDERS: u32 = 8; + const PER_SENDER: u32 = 50; + + struct Sink { + count: u32, + done_at: u32, + done: Option>, + } + struct Bump; + impl Msg for Bump {} + impl Mailboxed for Sink { + type Msg = Bump; + } + impl crate::actor::Actor for Sink { + type Args = (u32, oneshot::Sender); + type Error = core::convert::Infallible; + async fn on_start((done_at, done): Self::Args, _: ActorRef) -> Result { + Ok(Self { count: 0, done_at, done: Some(done) }) + } + async fn handle(&mut self, _: Bump, _: ActorRef, stop: &mut bool) -> Result<(), Self::Error> { + self.count += 1; + if self.count == self.done_at { + if let Some(done) = self.done.take() { + let _ = done.send(self.count); + } + *stop = true; + } + Ok(()) + } + } + + let (done_tx, done_rx) = oneshot::channel(); + let total = SENDERS * PER_SENDER; + let actor_ref = Sink::spawn_with_capacity(cap(4), (total, done_tx)); + + let start = Arc::new(Barrier::new(SENDERS as usize)); + let mut tasks = Vec::new(); + for _ in 0..SENDERS { + let sender = actor_ref.mailbox_sender().clone(); + let start = Arc::clone(&start); + tasks.push(tokio::spawn(async move { + start.wait().await; + for _ in 0..PER_SENDER { + sender.send(Signal::Message(Bump)).await.expect("send"); + } + })); + } + + let final_count = done_rx.await.expect("actor finished"); + assert_eq!(final_count, total, "single writer counted every message exactly once"); + for task in tasks { + task.await.expect("sender task"); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `nix develop --command cargo test -p bombay-core -- default_capacity_is_64 concurrent_senders_single_writer_exact_count` +Expected: FAIL — `Spawn`, `default_capacity` not found. + +- [ ] **Step 3: Add `default_capacity` to `spawn.rs`** + +In `bombay-core/src/actor/spawn.rs`, add below the `DEFAULT_MAILBOX_CAPACITY` const: + +```rust +/// The default capacity as a validated [`Capacity`]. Infallible for the fixed +/// constant 64 (in `1..=Capacity::MAX`); the `expect` is proven by +/// `default_capacity_is_64` and can never trip at runtime. +pub(crate) fn default_capacity() -> Capacity { + #[expect( + clippy::expect_used, + reason = "DEFAULT_MAILBOX_CAPACITY (64) is a compile-time-valid capacity; \ + the conversion is infallible and pinned by a unit test" + )] + Capacity::try_from(DEFAULT_MAILBOX_CAPACITY).expect("64 is a valid capacity") +} +``` + +- [ ] **Step 4: Add the `Spawn` ext-trait to `mod.rs`** + +In `bombay-core/src/actor/mod.rs`, add after the `Actor` trait (and update the `use` for `Capacity` + `spawn` items): + +At the top, extend the imports: + +```rust +use crate::{ + actor::spawn::{PreparedActor, default_capacity}, + error::{ActorStopReason, PanicError, ReplyError}, + mailbox::{Capacity, Mailboxed}, + message::Msg, +}; +``` + +Then add: + +```rust +/// Ergonomic spawn entry points, provided for every [`Actor`]. Spawns onto the +/// current tokio runtime and returns the [`ActorRef`]; the actor stops via +/// `Signal::Stop`, [`ActorRef::stop`], [`ActorRef::kill`], a handler crash, or +/// startup failure (ref-count-driven stop is #117). +pub trait Spawn: Actor { + /// Spawns with the [`DEFAULT_MAILBOX_CAPACITY`](spawn::DEFAULT_MAILBOX_CAPACITY). + #[must_use] + fn spawn(args: Self::Args) -> ActorRef { + Self::spawn_with_capacity(default_capacity(), args) + } + + /// Spawns with an explicit mailbox `capacity`. + #[must_use] + fn spawn_with_capacity(capacity: Capacity, args: Self::Args) -> ActorRef { + let prepared = PreparedActor::::new(capacity); + let actor_ref = prepared.actor_ref().clone(); + let _join = prepared.spawn(args); + actor_ref + } +} + +impl Spawn for A {} +``` + +Ensure `Spawn` is re-exported: in the `pub use self::{…}` block, the trait is defined in `mod.rs` itself so it is already `pub`. No re-export needed, but confirm `Spawn` is reachable as `crate::actor::Spawn`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `nix develop --command cargo test -p bombay-core -- default_capacity_is_64 concurrent_senders_single_writer_exact_count` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +cargo fmt +git add bombay-core/src/actor/ +git commit -m "core(actor): Spawn ext-trait + default capacity + concurrent single-writer test (#116)" +``` + +--- + +## Task 12: Full gate, coverage doc, final commit + +**Files:** +- Modify: `docs/testing/coverage-baseline.md` (add the `actor` module) + +- [ ] **Step 1: Run the full test suite for the crate** + +Run: `nix develop --command cargo test -p bombay-core` +Expected: PASS — all existing (error/mailbox/message/reply) tests plus every new `actor` test. + +- [ ] **Step 2: Update the coverage baseline** + +In `docs/testing/coverage-baseline.md`, add a row/section for `bombay-core/src/actor/` (mod.rs / actor_ref.rs / kind.rs / spawn.rs) noting the categories covered: sequence (queued-then-stop, stop-flag, on-start ordering), lifecycle (cancel, kill, on_start failure, panic → on_stop), defensive (poison contract, on_start panic caught), linearizability (concurrent single-writer). Match the file's existing format (read it first to mirror the columns/wording). + +- [ ] **Step 3: Run the single authoritative gate** + +Run: `nix develop --command cargo fmt --check && nix flake check` +Expected: PASS — build + clippy (god-level bar) + fmt + tests all green. + +Common gate failures and fixes: +- **clippy `print_stderr`** in `log_on_stop_outcome` → confirm the `#[expect(clippy::print_stderr, reason = …)]` is present. +- **clippy `expect_used`** in `default_capacity` → confirm the `#[expect(clippy::expect_used, reason = …)]` is present. +- **clippy cognitive-complexity / >80 lines** in `run_lifecycle` → it is already split into `run_message_loop`/`handle_message`/`run_on_panic`/`log_on_stop_outcome`; if it still trips, extract the `on_start` match into a `start_actor` helper. +- **`missing_docs`** → every `pub` item (trait, methods, `RunResult` variants + fields, `PreparedActor`, `Spawn`) needs a doc comment; the code above includes them — don't drop any. +- **fmt** → re-run `cargo fmt`. + +- [ ] **Step 4: Final commit** + +```bash +git add docs/testing/coverage-baseline.md +git commit -m "docs(testing): record #116 actor spine coverage baseline" +``` + +- [ ] **Step 5: Push and open the PR** (only when the user asks — main is protected; PR gated on green `Nix Flake Check`) + +```bash +git push -u origin core/116-actor-trait-loop +# then, when asked: +gh pr create --repo devrandom-labs/bombay --base main \ + --title "core(actor): rebuild Actor trait, lifecycle hooks & run-loop (#116)" \ + --body "Implements #116 per docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md. …" +``` + +--- + +## Self-Review Checklist (run before handing off) + +- **Spec coverage:** trait (T3) · `&mut self`/poison (T9) · loop no-buffer/`run_until_cancelled` (T4/T5/T7) · three stop paths (T4 Signal::Stop, T5 cancel, T10 kill) · four catch_unwind (T4 handle/on_stop, T8 on_start, T9 on_panic) · `from_panic_any` (T2) · `handle` Err→Panicked (T9 `handle_returning_err_stops_as_panicked`) · minimal ActorRef (T3) · `RunResult`/spawn (T4/T11) · deps + hakari (T1) · coverage doc (T12). +- **Placeholder scan:** none — every step has concrete code/commands. +- **Type consistency:** `run_message_loop` (kind.rs) ↔ called in `run_lifecycle` (spawn.rs); `RunResult::{Stopped{actor,reason},StartupFailed,Killed}` used identically in impl + tests; `cancel_token()`/`downgrade()`/`mailbox_sender()` names match between `actor_ref.rs` and `kind.rs`/`spawn.rs`. diff --git a/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md b/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md index 0b0a18b..f2b7571 100644 --- a/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md +++ b/docs/superpowers/specs/2026-07-05-116-actor-trait-loop-design.md @@ -226,18 +226,33 @@ enqueue `Signal`s). The ergonomic `tell` / `ask` and ref-count-driven-stop are ## Spawn — `bombay-core/src/actor/spawn.rs` +- **`RunResult`** — the honest, total outcome of running an actor. `run()` + can't return `Result<(A, ActorStopReason), PanicError>`: the **kill** path drops + the state mid-flight (no `A`), and startup failure produces no `A` either. + + ```rust + pub enum RunResult { + /// Ran and stopped. If `reason` is `Panicked`, `actor` is POISONED + /// (torn state — resource-release only; never read domain fields). + Stopped { actor: A, reason: ActorStopReason }, + /// `on_start` returned `Err` or panicked; no actor was produced. + StartupFailed(PanicError), + /// Hard-killed via `kill()`; `on_stop` was skipped, state dropped. + Killed, + } + ``` + - `PreparedActor`: created before running; holds `actor_ref` + `mailbox_rx` + `AbortRegistration`. Methods: - `.actor_ref() -> &ActorRef` — usable before the loop starts (pre-send). - - `.run(args) -> Result<(A, ActorStopReason), PanicError>` — runs in the current - task (deterministic tests; returns the final actor + reason). - - `.spawn(args) -> JoinHandle>`. -- Convenience: `Actor::spawn(args) -> ActorRef` (default cap); - `spawn_with_capacity(cap, args)`. `DEFAULT_MAILBOX_CAPACITY = 64`. -- **`run`/`spawn` return contract:** `on_start` panic/`Err` → `Err(PanicError{ - OnStart})` (no `A` to return). Handler panic → `Ok((torn A, Panicked))` (state - poisoned — see contract). `on_stop` panic → logged/surfaced, **original reason - preserved** in the returned tuple. + - `.run(args) -> RunResult` — runs in the current task (deterministic tests). + - `.spawn(args) -> JoinHandle>`. +- Convenience (`Spawn: Actor` blanket ext-trait): `A::spawn(args) -> ActorRef` + (default cap); `A::spawn_with_capacity(cap, args) -> ActorRef`. + `DEFAULT_MAILBOX_CAPACITY = 64`. +- **Return contract:** `on_start` panic/`Err` → `StartupFailed`. Handler panic → + `Stopped { actor: torn A, reason: Panicked }` (poisoned). `on_stop` panic → + logged/surfaced, **original `reason` preserved** in `Stopped`. Kill → `Killed`. ## Testing (TDD — write failing first) From 5ac03eb573cd85613076ef4192c957c716510bc4 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:16:59 +0200 Subject: [PATCH 03/21] build(core): add tokio-util + futures for the #116 run-loop --- Cargo.lock | 2 ++ Cargo.toml | 3 +++ bombay-core/Cargo.toml | 2 ++ 3 files changed, 7 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index da9b74b..568daf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,10 +480,12 @@ dependencies = [ "crossbeam-channel", "downcast-rs 2.0.2", "flume", + "futures", "proptest", "thingbuf", "thiserror 2.0.18", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1614132..f8be630 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/bombay-core/Cargo.toml b/bombay-core/Cargo.toml index 396e8b8..03baf0c 100644 --- a/bombay-core/Cargo.toml +++ b/bombay-core/Cargo.toml @@ -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 } From 44fb124a7a68b41444538371340455e541e10a19 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:28:48 +0200 Subject: [PATCH 04/21] =?UTF-8?q?core(error):=20PanicError::from=5Fpanic?= =?UTF-8?q?=5Fany=20=E2=80=94=20bridge=20catch=5Funwind=20to=20a=20value?= =?UTF-8?q?=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bombay-core/src/error.rs | 54 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/bombay-core/src/error.rs b/bombay-core/src/error.rs index 4622d53..76aefa3 100644 --- a/bombay-core/src/error.rs +++ b/bombay-core/src/error.rs @@ -15,7 +15,7 @@ //! even name `Timeout`/`Handler`, and whether the message is returned is //! encoded in the type rather than left to `Option`. -use std::{fmt, sync::Arc}; +use std::{any::Any, fmt, sync::Arc}; use downcast_rs::{DowncastSync, impl_downcast}; @@ -243,8 +243,24 @@ impl PanicError { .or_else(|| self.err.downcast_ref::().map(String::as_str)) .map(f) } - // DEFERRED — from_panic_any (Box → PanicError) lands with the - // run-loop that catches panics (#116); nothing produces one until then. + /// Builds a `PanicError` from a caught unwind payload (`catch_unwind` yields + /// `Box`), tagging it with the phase that produced it. + /// + /// The common payloads — `&'static str` and `String` — are recovered as a + /// string. An arbitrary payload cannot be recovered as its concrete type + /// from `dyn Any` without naming it, so it is recorded as a stable + /// placeholder string (still inspectable via [`with_str`](Self::with_str)). + #[must_use] + pub fn from_panic_any(payload: Box, reason: PanicReason) -> Self { + let err: Box = match payload.downcast::() { + Ok(message) => Box::new(*message), + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => Box::new(*message), + Err(_unknown) => Box::new("non-string panic payload"), + }, + }; + Self::new(err, reason) + } } /// Why an actor stopped. Exhaustive (no `#[non_exhaustive]`, rule #3) and @@ -505,6 +521,38 @@ mod tests { assert_eq!(original.reason(), PanicReason::OnPanic); } + /// A caught panic arrives as `Box` from `catch_unwind`. The two + /// common payloads — `&'static str` and `String` — are recovered as a string; + /// the phase is preserved. This is the loop's bridge from an unwind to a value. + #[test] + fn from_panic_any_recovers_string_payloads() { + let from_str = PanicError::from_panic_any(Box::new("boom"), PanicReason::HandlerPanic); + assert_eq!(from_str.with_str(str::to_owned), Some(String::from("boom"))); + assert_eq!(from_str.reason(), PanicReason::HandlerPanic); + + let from_string = + PanicError::from_panic_any(Box::new(String::from("kaboom")), PanicReason::OnStart); + assert_eq!( + from_string.with_str(str::to_owned), + Some(String::from("kaboom")) + ); + assert_eq!(from_string.reason(), PanicReason::OnStart); + } + + /// A non-string panic payload (an arbitrary type) cannot be recovered as its + /// concrete type from `dyn Any` without knowing it, so `from_panic_any` records + /// a stable placeholder string and preserves the phase. The placeholder must be + /// a recoverable `&str`, so a supervisor can still log *something*. + #[test] + fn from_panic_any_records_placeholder_for_non_string_payload() { + let panic = PanicError::from_panic_any(Box::new(42_u64), PanicReason::OnPanic); + assert_eq!(panic.reason(), PanicReason::OnPanic); + assert_eq!( + panic.with_str(str::to_owned), + Some(String::from("non-string panic payload")), + ); + } + /// Display strings are public surface (they show up in logs and `?` chains), /// so pin them. `Deliver`/`Handler` are transparent — they delegate to the /// inner error's own message rather than inventing a wrapper line. From b34a55115627ad3c5bdadf6727a55ce129c1ea02 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:32:04 +0200 Subject: [PATCH 05/21] core(actor): Actor trait + minimal ActorRef/WeakActorRef scaffold (#116) --- bombay-core/src/actor/actor_ref.rs | 213 +++++++++++++++++++++++++++++ bombay-core/src/actor/mod.rs | 86 ++++++++++++ bombay-core/src/lib.rs | 1 + 3 files changed, 300 insertions(+) create mode 100644 bombay-core/src/actor/actor_ref.rs create mode 100644 bombay-core/src/actor/mod.rs diff --git a/bombay-core/src/actor/actor_ref.rs b/bombay-core/src/actor/actor_ref.rs new file mode 100644 index 0000000..1ec2f00 --- /dev/null +++ b/bombay-core/src/actor/actor_ref.rs @@ -0,0 +1,213 @@ +//! 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 { + id: ActorId, + mailbox: MailboxSender, + cancel: CancellationToken, + abort: AbortHandle, +} + +impl Clone for ActorRef { + fn clone(&self) -> Self { + Self { + id: self.id, + mailbox: self.mailbox.clone(), + cancel: self.cancel.clone(), + abort: self.abort.clone(), + } + } +} + +impl fmt::Debug for ActorRef { + 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 ActorRef { + pub(crate) fn new( + id: ActorId, + mailbox: MailboxSender, + 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 { + &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 { + 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 { + id: ActorId, + mailbox: WeakMailboxSender, + cancel: CancellationToken, + abort: AbortHandle, +} + +impl Clone for WeakActorRef { + fn clone(&self) -> Self { + Self { + id: self.id, + mailbox: self.mailbox.clone(), + cancel: self.cancel.clone(), + abort: self.abort.clone(), + } + } +} + +impl fmt::Debug for WeakActorRef { + 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 WeakActorRef { + /// 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> { + 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) -> Result { + Ok(Probe) + } + async fn handle( + &mut self, + _: ProbeMsg, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + fn build_ref() -> (ActorRef, WeakActorRef) { + let cap = Capacity::try_from(4usize).expect("valid capacity"); + let (tx, _rx) = Mailbox::::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) + } + + /// 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", + ); + } +} diff --git a/bombay-core/src/actor/mod.rs b/bombay-core/src/actor/mod.rs new file mode 100644 index 0000000..6dd2884 --- /dev/null +++ b/bombay-core/src/actor/mod.rs @@ -0,0 +1,86 @@ +//! The local actor spine (card #116): the `Actor` trait, its lifecycle hooks, +//! the run-loop that drives it, and the spawn entry points. +//! +//! Send-saturated for now; the cfg-gated `MaybeSend` relaxation for +//! single-threaded client builds is a dedicated later sweep (#9). The `ActorRef` +//! here is a **minimal scaffold** — ref-count-driven stop, `Recipient` erasure, +//! and the `tell`/`ask` builders are #117/#118. + +use core::{any::type_name, future::Future}; + +use crate::{ + error::{ActorStopReason, PanicError, ReplyError}, + mailbox::Mailboxed, + message::Msg, +}; + +mod actor_ref; + +pub use self::actor_ref::{ActorRef, WeakActorRef}; + +/// A single-writer, identity-agnostic unit of concurrency: owned state behind a +/// mailbox, driven by one task that handles messages sequentially. +/// +/// `Actor` is a subtrait of [`Mailboxed`] (the mailbox is keyed on the actor), +/// and its message type is bounded `: Msg` so every actor's `Msg` gets the +/// compile-time slot-size tripwire (card #114). +/// +/// # Panics & poisoning +/// +/// A panic in `handle` is caught and routed to [`on_panic`](Actor::on_panic); +/// the actor then **stops** (there is no resume). After a panic `&mut self` is +/// **poisoned** (torn state): [`on_stop`](Actor::on_stop) still runs and may do +/// reason-independent resource release only — it must **never** flush or derive +/// from domain fields, which are torn. +pub trait Actor: Mailboxed + Sized + Send + 'static { + /// The argument passed to [`on_start`](Actor::on_start) to build the state. + type Args: Send; + /// The actor's own domain error, kept typed end to end. + type Error: ReplyError; + + /// A human-readable name for logs/tracing. Defaults to the type name. + #[must_use] + fn name() -> &'static str { + type_name::() + } + + /// Builds (or hydrates) the actor state. Runs to completion before any + /// message is handled; messages that arrive meanwhile wait in the mailbox. + fn on_start( + args: Self::Args, + actor_ref: ActorRef, + ) -> impl Future> + Send; + + /// Handles one message. Set `*stop = true` to stop the actor cleanly after + /// this handler returns `Ok`. A returned `Err` is treated as a controlled + /// crash (routed to `on_panic`, then stop). + fn handle( + &mut self, + msg: Self::Msg, + actor_ref: ActorRef, + stop: &mut bool, + ) -> impl Future> + Send; + + /// Observes a caught panic and names the terminal stop reason. Infallible + /// and stop-only — it cannot resume the actor. `&mut self` is poisoned. + fn on_panic( + &mut self, + actor_ref: WeakActorRef, + err: PanicError, + ) -> impl Future + Send { + let _ = actor_ref; + async move { ActorStopReason::Panicked(err) } + } + + /// Terminal cleanup. A returned `Err` is logged/surfaced, **never** + /// unwrapped, and the original `reason` is preserved. On the poisoned + /// (post-panic) path, do resource release only — never read domain fields. + fn on_stop( + &mut self, + actor_ref: WeakActorRef, + reason: ActorStopReason, + ) -> impl Future> + Send { + let _ = (actor_ref, reason); + async { Ok(()) } + } +} diff --git a/bombay-core/src/lib.rs b/bombay-core/src/lib.rs index 3d3dbc3..e45a733 100644 --- a/bombay-core/src/lib.rs +++ b/bombay-core/src/lib.rs @@ -7,6 +7,7 @@ //! Nothing here is public API yet — the spine is assembled part-by-part and the //! surface is settled once the whole core lands (#112–#121). +pub mod actor; pub mod error; pub mod mailbox; pub mod message; From 857aa8ce9289a43e4727fe7f3f8b4956434fd05d Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:39:02 +0200 Subject: [PATCH 06/21] =?UTF-8?q?core(actor):=20run-loop=20walking=20skele?= =?UTF-8?q?ton=20=E2=80=94=20on=5Fstart=20=E2=86=92=20loop=20=E2=86=92=20o?= =?UTF-8?q?n=5Fstop=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bombay-core/src/actor/kind.rs | 94 +++++++++++ bombay-core/src/actor/mod.rs | 7 +- bombay-core/src/actor/spawn.rs | 296 +++++++++++++++++++++++++++++++++ 3 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 bombay-core/src/actor/kind.rs create mode 100644 bombay-core/src/actor/spawn.rs diff --git a/bombay-core/src/actor/kind.rs b/bombay-core/src/actor/kind.rs new file mode 100644 index 0000000..45c7129 --- /dev/null +++ b/bombay-core/src/actor/kind.rs @@ -0,0 +1,94 @@ +//! The actor run-loop (card #116): drive `on_start` → message loop → `on_stop`, +//! with a `catch_unwind` around each hook so a panic becomes an inspectable +//! `PanicError` instead of tearing down the task. + +use std::{ops::ControlFlow, panic::AssertUnwindSafe}; + +use futures::FutureExt; + +use crate::{ + actor::{Actor, ActorRef}, + error::{ActorStopReason, PanicError, PanicReason}, + mailbox::{MailboxReceiver, Signal}, +}; + +/// Runs the message loop until a stop condition, returning the stop reason. +/// +/// `state` is the live actor; `actor_ref` is its strong self-handle (kept strong +/// in #116 — ref-count-driven stop is #117). The loop finishes any in-flight +/// handler before observing a graceful stop ("finish-current-then-stop, no +/// drain"). +pub(crate) async fn run_message_loop( + state: &mut A, + actor_ref: &ActorRef, + mailbox_rx: &mut MailboxReceiver, +) -> ActorStopReason { + let cancel = actor_ref.cancel_token(); + loop { + match cancel.run_until_cancelled(mailbox_rx.recv()).await { + // Token cancelled (out-of-band graceful stop). + None => return ActorStopReason::Normal, + // All senders dropped (unreachable in #116 — the loop holds one). + Some(None) => return ActorStopReason::Normal, + Some(Some(signal)) => match signal { + Signal::Message(msg) => { + if let ControlFlow::Break(reason) = handle_message(state, actor_ref, msg).await + { + return reason; + } + } + // In-band graceful stop (FIFO): everything queued ahead was + // already handled above. + Signal::Stop => return ActorStopReason::Normal, + // Nothing produces LinkDied pre-#120; ignore and keep running. + Signal::LinkDied(_) => {} + }, + } + } +} + +/// Handles one message under `catch_unwind`. `Continue` keeps looping; `Break` +/// carries the terminal stop reason. +async fn handle_message( + state: &mut A, + actor_ref: &ActorRef, + msg: A::Msg, +) -> ControlFlow { + let mut stop = false; + let result = AssertUnwindSafe(state.handle(msg, actor_ref.clone(), &mut stop)) + .catch_unwind() + .await; + match result { + Ok(Ok(())) if stop => ControlFlow::Break(ActorStopReason::Normal), + Ok(Ok(())) => ControlFlow::Continue(()), + // A returned Err is a controlled crash: observe via on_panic, then stop. + Ok(Err(err)) => { + let panic = PanicError::new(Box::new(err), PanicReason::HandlerPanic); + ControlFlow::Break(run_on_panic(state, actor_ref, panic).await) + } + // The handler unwound: catch, observe via on_panic, then stop. + Err(payload) => { + let panic = PanicError::from_panic_any(payload, PanicReason::HandlerPanic); + ControlFlow::Break(run_on_panic(state, actor_ref, panic).await) + } + } +} + +/// Runs `on_panic` (infallible, stop-only) under `catch_unwind`; if the hook +/// itself panics, that becomes the terminal reason instead. +async fn run_on_panic( + state: &mut A, + actor_ref: &ActorRef, + err: PanicError, +) -> ActorStopReason { + let weak = actor_ref.downgrade(); + match AssertUnwindSafe(state.on_panic(weak, err)) + .catch_unwind() + .await + { + Ok(reason) => reason, + Err(payload) => { + ActorStopReason::Panicked(PanicError::from_panic_any(payload, PanicReason::OnPanic)) + } + } +} diff --git a/bombay-core/src/actor/mod.rs b/bombay-core/src/actor/mod.rs index 6dd2884..e02ec7f 100644 --- a/bombay-core/src/actor/mod.rs +++ b/bombay-core/src/actor/mod.rs @@ -15,8 +15,13 @@ use crate::{ }; mod actor_ref; +mod kind; +mod spawn; -pub use self::actor_ref::{ActorRef, WeakActorRef}; +pub use self::{ + actor_ref::{ActorRef, WeakActorRef}, + spawn::{DEFAULT_MAILBOX_CAPACITY, PreparedActor, RunResult}, +}; /// A single-writer, identity-agnostic unit of concurrency: owned state behind a /// mailbox, driven by one task that handles messages sequentially. diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs new file mode 100644 index 0000000..3ad7a35 --- /dev/null +++ b/bombay-core/src/actor/spawn.rs @@ -0,0 +1,296 @@ +//! Spawn entry points (card #116): prepare an actor, then run it in the current +//! task or a background tokio task. Kill is uniform across both via +//! `futures::Abortable` wrapping the whole lifecycle (so a hard kill skips +//! `on_stop`). + +use std::{ + fmt, + panic::AssertUnwindSafe, + sync::atomic::{AtomicU64, Ordering}, +}; + +use futures::{ + FutureExt, + stream::{AbortHandle, AbortRegistration, Abortable}, +}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::{ + actor::{Actor, ActorRef, kind::run_message_loop}, + error::{ActorStopReason, PanicError, PanicReason}, + mailbox::{ActorId, Capacity, Mailbox, MailboxReceiver}, +}; + +/// The default mailbox capacity for the ergonomic spawn path (4 cache-lines' +/// worth of slots is a sane starting point; tune with `spawn_with_capacity`). +pub const DEFAULT_MAILBOX_CAPACITY: usize = 64; + +/// Monotonic scaffold id source (#121 replaces this with the AID). +static NEXT_ACTOR_ID: AtomicU64 = AtomicU64::new(1); + +fn next_actor_id() -> ActorId { + // Relaxed is sufficient: correctness needs only that each `fetch_add` returns + // a distinct value. Uniqueness is a property of atomic increment alone and + // requires no happens-before with any other memory (CLAUDE rule #5). + ActorId::new(NEXT_ACTOR_ID.fetch_add(1, Ordering::Relaxed)) +} + +/// The total outcome of running an actor to completion in the current task. +pub enum RunResult { + /// Ran and stopped. If `reason` is [`ActorStopReason::Panicked`], `actor` is + /// **poisoned** (torn state): resource-release only, never read domain fields. + Stopped { + /// The final actor state. + actor: A, + /// Why it stopped. + reason: ActorStopReason, + }, + /// `on_start` returned `Err` or panicked — no actor was produced. + StartupFailed(PanicError), + /// Hard-killed via [`ActorRef::kill`] — `on_stop` was skipped, state dropped. + Killed, +} + +impl fmt::Debug for RunResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stopped { reason, .. } => f + .debug_struct("Stopped") + .field("reason", reason) + .finish_non_exhaustive(), + Self::StartupFailed(err) => f.debug_tuple("StartupFailed").field(err).finish(), + Self::Killed => f.write_str("Killed"), + } + } +} + +/// An actor initialized and ready to run, with its [`ActorRef`] available before +/// the loop starts (so callers can pre-send messages). +#[must_use = "a prepared actor must be run or spawned"] +pub struct PreparedActor { + actor_ref: ActorRef, + mailbox_rx: MailboxReceiver, + abort_registration: AbortRegistration, +} + +impl fmt::Debug for PreparedActor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PreparedActor") + .field("actor_ref", &self.actor_ref) + .finish_non_exhaustive() + } +} + +impl PreparedActor { + /// Prepares an actor with a mailbox of the given `capacity`. + pub fn new(capacity: Capacity) -> Self { + let (mailbox_tx, mailbox_rx) = Mailbox::::bounded(capacity); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let actor_ref = ActorRef::new( + next_actor_id(), + mailbox_tx, + CancellationToken::new(), + abort_handle, + ); + Self { + actor_ref, + mailbox_rx, + abort_registration, + } + } + + /// The handle to the actor, usable before the loop starts. + #[must_use] + pub const fn actor_ref(&self) -> &ActorRef { + &self.actor_ref + } + + /// Runs the actor in the current task until it stops. Aborts (hard kill) + /// short-circuit to [`RunResult::Killed`], skipping `on_stop`. + pub async fn run(self, args: A::Args) -> RunResult { + let lifecycle = run_lifecycle(args, self.actor_ref, self.mailbox_rx); + Abortable::new(lifecycle, self.abort_registration) + .await + .unwrap_or(RunResult::Killed) + } + + /// Spawns the actor in a background tokio task. + pub fn spawn(self, args: A::Args) -> JoinHandle> { + tokio::spawn(self.run(args)) + } +} + +/// `on_start` (catch) → message loop → `on_stop` (catch; Err logged, reason +/// preserved). Returns `StartupFailed` if `on_start` fails, else `Stopped`. +async fn run_lifecycle( + args: A::Args, + actor_ref: ActorRef, + mut mailbox_rx: MailboxReceiver, +) -> RunResult { + let started = AssertUnwindSafe(A::on_start(args, actor_ref.clone())) + .catch_unwind() + .await; + let mut state = match started { + Ok(Ok(actor)) => actor, + Ok(Err(err)) => { + return RunResult::StartupFailed(PanicError::new(Box::new(err), PanicReason::OnStart)); + } + Err(payload) => { + return RunResult::StartupFailed(PanicError::from_panic_any( + payload, + PanicReason::OnStart, + )); + } + }; + + let reason = run_message_loop(&mut state, &actor_ref, &mut mailbox_rx).await; + + let weak = actor_ref.downgrade(); + let stop_result = AssertUnwindSafe(state.on_stop(weak, reason.clone())) + .catch_unwind() + .await; + log_on_stop_outcome::(&reason, stop_result); + + RunResult::Stopped { + actor: state, + reason, + } +} + +/// Logs a failed/panicked `on_stop` without altering the preserved stop reason +/// and without unwrapping (a double-panic on the shutdown path can abort the +/// process — std `Drop` docs). +#[expect( + clippy::print_stderr, + reason = "diagnostic-only surface until the tracing feature lands (#66); \ + an on_stop failure must be surfaced, never swallowed" +)] +fn log_on_stop_outcome( + reason: &ActorStopReason, + stop_result: Result, Box>, +) { + match stop_result { + Ok(Ok(())) => {} + Ok(Err(err)) => { + eprintln!( + "[bombay] on_stop for {} returned an error: {err:?} (stop reason: {reason})", + A::name() + ); + } + Err(_payload) => { + eprintln!( + "[bombay] on_stop for {} panicked (stop reason: {reason})", + A::name() + ); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, + }; + + use crate::{ + actor::{ActorRef, PreparedActor, RunResult, WeakActorRef}, + error::ActorStopReason, + mailbox::{Capacity, Mailboxed, Signal}, + message::Msg, + }; + + /// Counts handled messages and records whether `on_stop` ran, via shared + /// atomics the test inspects — the SUT is the real loop, not a reimpl. + struct Counter { + handled: Arc, + stopped: Arc, + } + struct Tick; + impl Msg for Tick {} + impl Mailboxed for Counter { + type Msg = Tick; + } + impl crate::actor::Actor for Counter { + type Args = (Arc, Arc); + type Error = core::convert::Infallible; + + async fn on_start( + (handled, stopped): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { handled, stopped }) + } + + async fn handle( + &mut self, + _: Tick, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + fn cap(n: usize) -> Capacity { + Capacity::try_from(n).expect("valid test capacity") + } + + /// Sequence: two messages then a `Stop` — both are handled (FIFO, before the + /// stop), `on_stop` runs exactly once, and the outcome is a normal stop. + #[tokio::test] + async fn handles_queued_messages_then_stops_normally() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Tick)) + .await + .expect("send 1"); + actor_ref + .mailbox_sender() + .send(Signal::Message(Tick)) + .await + .expect("send 2"); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + + let outcome = prepared + .run((Arc::clone(&handled), Arc::clone(&stopped))) + .await; + + assert_eq!( + handled.load(Ordering::SeqCst), + 2, + "both messages handled before stop" + ); + assert_eq!(stopped.load(Ordering::SeqCst), 1, "on_stop ran once"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "clean normal stop", + ); + } +} From 6da0aa5f786717a91e2d9ce195bc2b35ed2450bc Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:44:58 +0200 Subject: [PATCH 07/21] core(actor): satisfy god-level clippy nursery bar on the new spine (#116) --- bombay-core/src/actor/actor_ref.rs | 2 +- bombay-core/src/actor/kind.rs | 10 +++++----- bombay-core/src/error.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bombay-core/src/actor/actor_ref.rs b/bombay-core/src/actor/actor_ref.rs index 1ec2f00..7e50453 100644 --- a/bombay-core/src/actor/actor_ref.rs +++ b/bombay-core/src/actor/actor_ref.rs @@ -45,7 +45,7 @@ impl fmt::Debug for ActorRef { } impl ActorRef { - pub(crate) fn new( + pub(crate) const fn new( id: ActorId, mailbox: MailboxSender, cancel: CancellationToken, diff --git a/bombay-core/src/actor/kind.rs b/bombay-core/src/actor/kind.rs index 45c7129..fe3eb14 100644 --- a/bombay-core/src/actor/kind.rs +++ b/bombay-core/src/actor/kind.rs @@ -18,7 +18,7 @@ use crate::{ /// in #116 — ref-count-driven stop is #117). The loop finishes any in-flight /// handler before observing a graceful stop ("finish-current-then-stop, no /// drain"). -pub(crate) async fn run_message_loop( +pub(super) async fn run_message_loop( state: &mut A, actor_ref: &ActorRef, mailbox_rx: &mut MailboxReceiver, @@ -26,10 +26,10 @@ pub(crate) async fn run_message_loop( let cancel = actor_ref.cancel_token(); loop { match cancel.run_until_cancelled(mailbox_rx.recv()).await { - // Token cancelled (out-of-band graceful stop). - None => return ActorStopReason::Normal, - // All senders dropped (unreachable in #116 — the loop holds one). - Some(None) => return ActorStopReason::Normal, + // Either the cancel token fired (out-of-band graceful stop) or + // every sender dropped (all-senders-gone; unreachable in #116 since + // the loop holds one). Both are a clean, normal stop. + None | Some(None) => return ActorStopReason::Normal, Some(Some(signal)) => match signal { Signal::Message(msg) => { if let ControlFlow::Break(reason) = handle_message(state, actor_ref, msg).await diff --git a/bombay-core/src/error.rs b/bombay-core/src/error.rs index 76aefa3..bb0a745 100644 --- a/bombay-core/src/error.rs +++ b/bombay-core/src/error.rs @@ -254,7 +254,7 @@ impl PanicError { pub fn from_panic_any(payload: Box, reason: PanicReason) -> Self { let err: Box = match payload.downcast::() { Ok(message) => Box::new(*message), - Err(payload) => match payload.downcast::<&'static str>() { + Err(not_a_string) => match not_a_string.downcast::<&'static str>() { Ok(message) => Box::new(*message), Err(_unknown) => Box::new("non-string panic payload"), }, From 9408183f8842db08f97f2757805de27b906d6740 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:45:36 +0200 Subject: [PATCH 08/21] docs(plan): #116 mandate lib clippy check on lib-touching tasks (nursery bar) --- docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md b/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md index 684f39d..e2990fc 100644 --- a/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md +++ b/docs/superpowers/plans/2026-07-05-116-actor-trait-loop.md @@ -25,7 +25,7 @@ | `bombay-core/src/actor/spawn.rs` | `PreparedActor`, `RunResult`, `run`/`spawn` | 4–11 | | `bombay-core/src/lib.rs` | `pub mod actor;` | 3 | -**Clippy note (god-level bar, all new code):** functions ≤ 80 lines, cognitive-complexity ≤ 9, ≤ 5 args, **no** `unwrap`/`expect`/`panic`/`unreachable` in library code. Where a provably-safe `expect` is unavoidable (the default-capacity constant), use `#[expect(clippy::expect_used, reason = "…")]` on that line and cover it with a unit test. Run `cargo fmt` before every commit (the fmt gate is strict). The clippy gate checks lib + bins only, so `#[cfg(test)]` code is currently ungated — but write it clean anyway. +**Clippy note (god-level bar, all new code):** `[workspace.lints.clippy]` sets `all` + `pedantic` + `nursery` all to **deny**, plus `shadow_reuse`/`shadow_same`/`shadow_unrelated`, `unwrap_used`/`expect_used`/`panic`/`print_stderr`, and `allow_attributes_without_reason` (every `#[allow]`/`#[expect]` MUST carry a `reason = "…"`). Functions ≤ 80 lines, cognitive-complexity ≤ 9, ≤ 5 args. Nursery bites in non-obvious ways — watch for `match_same_arms` (merge identical arms with `|`), `redundant_pub_crate` (use `pub(super)` for cross-sibling-module items in a private mod, not `pub(crate)`), `missing_const_for_fn` (make trivial constructors `const fn`), and `shadow_reuse` (don't reuse a binding name in a nested match). Where a provably-safe `expect` is unavoidable (the default-capacity constant), use `#[expect(clippy::expect_used, reason = "…")]` and cover it with a unit test. **Any task that touches library (non-`#[cfg(test)]`) code must run `nix develop --command cargo clippy -p bombay-core --all-features` and get zero warnings before committing** — that is the gate's exact scope (lib + bins, `--all-features`). The gate does NOT lint `#[cfg(test)]` code, so test-only tasks are on the lighter bar — but write them clean anyway. Run `cargo fmt` before every commit (the fmt gate is strict). **Test harness note:** all tests live in `#[cfg(test)] mod tests` at the bottom of each file. Behavioral tests use `#[tokio::test]`; deterministic ordering tests use `flavor = "current_thread"`; real-overlap tests use `flavor = "multi_thread"` + `tokio::sync::Barrier`. Run a single test with `cargo test -p bombay-core `. From 88b66536b82e90e7d8870de514c5ad7297619473 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:48:54 +0200 Subject: [PATCH 09/21] core(actor): test graceful cancel finishes in-flight then stops (#116) --- bombay-core/src/actor/spawn.rs | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index 3ad7a35..4d0d70f 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -293,4 +293,90 @@ mod tests { "clean normal stop", ); } + + /// Lifecycle: `stop()` (out-of-band cancel) while a handler is mid-flight lets + /// that handler finish, then stops and runs `on_stop`. The queued-behind message + /// is abandoned (finish-current-then-stop, no drain). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancel_finishes_in_flight_then_stops() { + use tokio::sync::oneshot; + + struct Slow { + entered: Option>, + release: Option>, + handled: Arc, + } + struct Work; + impl Msg for Work {} + impl Mailboxed for Slow { + type Msg = Work; + } + impl crate::actor::Actor for Slow { + type Args = (oneshot::Sender<()>, oneshot::Receiver<()>, Arc); + type Error = core::convert::Infallible; + async fn on_start( + (entered, release, handled): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + entered: Some(entered), + release: Some(release), + handled, + }) + } + async fn handle( + &mut self, + _: Work, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + if let Some(release) = self.release.take() { + let _ = release.await; + } + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let handled = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + // Two messages: the first blocks until released; the second must be abandoned. + actor_ref + .mailbox_sender() + .send(Signal::Message(Work)) + .await + .expect("send 1"); + actor_ref + .mailbox_sender() + .send(Signal::Message(Work)) + .await + .expect("send 2"); + + let run = tokio::spawn(prepared.run((entered_tx, release_rx, Arc::clone(&handled)))); + + entered_rx.await.expect("handler entered"); // handler #1 is mid-flight + actor_ref.stop(); // cancel while in-flight + release_tx.send(()).expect("release handler"); // let handler #1 finish + + let outcome = run.await.expect("run task"); + assert_eq!( + handled.load(Ordering::SeqCst), + 1, + "only the in-flight message finished; the queued one was abandoned" + ); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + } } From 25b058d5bd7d430cae4deeae34a736de98b7b5e9 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:49:29 +0200 Subject: [PATCH 10/21] core(actor): test *stop flag stops after the current handler (#116) --- bombay-core/src/actor/spawn.rs | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index 4d0d70f..0715db5 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -379,4 +379,63 @@ mod tests { } )); } + + /// Sequence: a handler that sets `*stop = true` stops the actor cleanly after it + /// returns `Ok` — a following queued message is never handled. + #[tokio::test] + async fn stop_flag_stops_after_current_handler() { + struct Once { + handled: Arc, + } + struct Go; + impl Msg for Go {} + impl Mailboxed for Once { + type Msg = Go; + } + impl crate::actor::Actor for Once { + type Args = Arc; + type Error = core::convert::Infallible; + async fn on_start(handled: Self::Args, _: ActorRef) -> Result { + Ok(Self { handled }) + } + async fn handle( + &mut self, + _: Go, + _: ActorRef, + stop: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + *stop = true; + Ok(()) + } + } + + let handled = Arc::new(AtomicU32::new(0)); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Go)) + .await + .expect("send 1"); + actor_ref + .mailbox_sender() + .send(Signal::Message(Go)) + .await + .expect("send 2"); + + let outcome = prepared.run(Arc::clone(&handled)).await; + assert_eq!( + handled.load(Ordering::SeqCst), + 1, + "stopped after the first handler; second never ran" + ); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + } } From 35e83f398890acbc94212593e1118520a6083f45 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:49:59 +0200 Subject: [PATCH 11/21] core(actor): test on-start messages handled after, in order (no buffer) (#116) --- bombay-core/src/actor/spawn.rs | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index 0715db5..b49f6b1 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -438,4 +438,77 @@ mod tests { } )); } + + /// Sequence (no startup buffer): messages that arrive while `on_start` is still + /// running wait in the bounded mailbox and are handled *after* start, in FIFO + /// order — the ordering guarantee comes from the flume channel, not a buffer. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn messages_during_on_start_are_handled_after_in_order() { + use std::sync::Mutex; + use tokio::sync::oneshot; + + struct Recorder { + seen: Arc>>, + } + struct N(u32); + impl Msg for N {} + impl Mailboxed for Recorder { + type Msg = N; + } + impl crate::actor::Actor for Recorder { + type Args = (oneshot::Receiver<()>, Arc>>); + type Error = core::convert::Infallible; + async fn on_start( + (gate, seen): Self::Args, + _: ActorRef, + ) -> Result { + let _ = gate.await; // block startup until the test has enqueued messages + Ok(Self { seen }) + } + async fn handle( + &mut self, + N(n): N, + _: ActorRef, + stop: &mut bool, + ) -> Result<(), Self::Error> { + self.seen.lock().expect("lock").push(n); + if n == 2 { + *stop = true; + } + Ok(()) + } + } + + let (gate_tx, gate_rx) = oneshot::channel(); + let seen = Arc::new(Mutex::new(Vec::new())); + + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + let run = tokio::spawn(prepared.run((gate_rx, Arc::clone(&seen)))); + + // Enqueue BEFORE releasing on_start — these must be buffered by the mailbox. + actor_ref + .mailbox_sender() + .send(Signal::Message(N(0))) + .await + .expect("send 0"); + actor_ref + .mailbox_sender() + .send(Signal::Message(N(1))) + .await + .expect("send 1"); + actor_ref + .mailbox_sender() + .send(Signal::Message(N(2))) + .await + .expect("send 2"); + gate_tx.send(()).expect("release on_start"); + + run.await.expect("run task"); + assert_eq!( + *seen.lock().expect("lock"), + vec![0, 1, 2], + "handled after start, in FIFO order" + ); + } } From 36c80bb0ffd9e70404403b6d5ce5647860a4de89 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:50:36 +0200 Subject: [PATCH 12/21] =?UTF-8?q?core(actor):=20test=20on=5Fstart=20Err/pa?= =?UTF-8?q?nic=20=E2=86=92=20StartupFailed=20(unwind=20pin)=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bombay-core/src/actor/spawn.rs | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index b49f6b1..ff60150 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -511,4 +511,79 @@ mod tests { "handled after start, in FIFO order" ); } + + /// Lifecycle: `on_start` returning `Err` produces `StartupFailed` (no actor, no + /// message ever handled) — tagged as an `OnStart`-phase panic reason. + #[tokio::test] + async fn on_start_error_yields_startup_failed() { + #[derive(Debug)] + struct Boom; + struct NeverStarts; + struct Never; + impl Msg for Never {} + impl Mailboxed for NeverStarts { + type Msg = Never; + } + impl crate::actor::Actor for NeverStarts { + type Args = (); + type Error = Boom; + async fn on_start(_: (), _: ActorRef) -> Result { + Err(Boom) + } + async fn handle( + &mut self, + _: Never, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + let outcome = PreparedActor::::new(cap(4)).run(()).await; + let RunResult::StartupFailed(err) = outcome else { + panic!("expected StartupFailed, got {outcome:?}"); + }; + assert_eq!(err.reason(), crate::error::PanicReason::OnStart); + } + + /// Defensive: a panic in `on_start` is CAUGHT (not a process abort) and becomes + /// `StartupFailed` with the `OnStart` reason and the recoverable message. + /// + /// This is the card's `panic = "unwind"` pin: under `panic = "abort"` the + /// process aborts here instead, and the test cannot pass. + #[tokio::test] + async fn on_start_panic_is_caught_as_startup_failed() { + struct PanicsOnStart; + struct Never; + impl Msg for Never {} + impl Mailboxed for PanicsOnStart { + type Msg = Never; + } + impl crate::actor::Actor for PanicsOnStart { + type Args = (); + type Error = core::convert::Infallible; + async fn on_start(_: (), _: ActorRef) -> Result { + panic!("startup boom") + } + async fn handle( + &mut self, + _: Never, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + let outcome = PreparedActor::::new(cap(4)).run(()).await; + let RunResult::StartupFailed(err) = outcome else { + panic!("expected StartupFailed, got {outcome:?}"); + }; + assert_eq!(err.reason(), crate::error::PanicReason::OnStart); + assert_eq!( + err.with_str(str::to_owned), + Some(String::from("startup boom")) + ); + } } From b437155c81a83dc72c5f33cf471282351920771b Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:53:30 +0200 Subject: [PATCH 13/21] core(actor): test on_stop-after-panic, poison contract, post-panic send (#116) --- bombay-core/src/actor/spawn.rs | 230 +++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index ff60150..da79ddd 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -586,4 +586,234 @@ mod tests { Some(String::from("startup boom")) ); } + + /// A handler that panics mid-mutation, with an `on_stop` spy that records the + /// reason it received and whether it observed torn state. Shared across the + /// three panic guarantees below. + mod panic_probe { + use super::*; + use std::sync::Mutex; + + pub(super) struct Torn { + pub(super) counter: u32, + pub(super) stop_reason: Arc>>, + pub(super) counter_at_stop: Arc>>, + } + pub(super) struct Explode; + impl Msg for Explode {} + impl Mailboxed for Torn { + type Msg = Explode; + } + impl crate::actor::Actor for Torn { + type Args = (Arc>>, Arc>>); + type Error = core::convert::Infallible; + async fn on_start( + (stop_reason, counter_at_stop): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + counter: 0, + stop_reason, + counter_at_stop, + }) + } + async fn handle( + &mut self, + _: Explode, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.counter = 99; // torn write BEFORE the panic + panic!("handler boom"); + } + async fn on_stop( + &mut self, + _: WeakActorRef, + reason: ActorStopReason, + ) -> Result<(), Self::Error> { + // Records the reason and the poisoned field value — a real on_stop + // must NOT persist `self.counter` (torn); this spy only records it so + // the test can assert the loop DID run on_stop with the torn state + // present (the contract is "don't flush", enforced by review + this + // documented probe). + *self.stop_reason.lock().expect("lock") = Some(reason); + *self.counter_at_stop.lock().expect("lock") = Some(self.counter); + Ok(()) + } + } + } + + /// `@bug` Lifecycle: after a handler panic, `on_stop` STILL runs and receives + /// `ActorStopReason::Panicked` (OTP `terminate` precedent). Fails if the loop + /// skips `on_stop` on the panic path. + #[tokio::test] + async fn on_stop_runs_after_panic_with_panicked_reason() { + use panic_probe::*; + use std::sync::Mutex; + + let stop_reason: Arc>> = Arc::new(Mutex::new(None)); + let counter_at_stop = Arc::new(Mutex::new(None)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Explode)) + .await + .expect("send"); + + let outcome = prepared + .run((Arc::clone(&stop_reason), Arc::clone(&counter_at_stop))) + .await; + + assert!( + matches!( + &outcome, + RunResult::Stopped { + reason: ActorStopReason::Panicked(_), + .. + } + ), + "panic → Stopped with Panicked, got {outcome:?}", + ); + let recorded = stop_reason.lock().expect("lock").clone(); + assert!( + matches!(recorded, Some(ActorStopReason::Panicked(_))), + "on_stop ran and saw Panicked, got {recorded:?}", + ); + } + + /// `@bug` Defensive (poison contract): the field mutated just before the panic + /// (`counter = 99`) IS still visible to `on_stop` (proving the state is torn, not + /// rolled back) — which is exactly why a real `on_stop` must NOT flush it. This + /// pins that the loop surfaces torn state to `on_stop` rather than silently + /// discarding before cleanup, so the "don't flush" contract is meaningful. + #[tokio::test] + async fn on_stop_after_panic_observes_torn_state() { + use panic_probe::*; + use std::sync::Mutex; + + let stop_reason = Arc::new(Mutex::new(None)); + let counter_at_stop: Arc>> = Arc::new(Mutex::new(None)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Explode)) + .await + .expect("send"); + let _ = prepared + .run((Arc::clone(&stop_reason), Arc::clone(&counter_at_stop))) + .await; + + assert_eq!( + *counter_at_stop.lock().expect("lock"), + Some(99), + "on_stop sees the torn (pre-panic-mutated) field — hence must not flush it", + ); + } + + /// `@bug` Lifecycle: once a handler panic stops the actor, its mailbox receiver + /// is dropped, so a later `send` fails (the actor is gone). Fails if teardown + /// leaves the receiver alive on the panic path. + #[tokio::test] + async fn send_after_handler_panic_fails() { + struct Bomb; + struct Trigger; + impl Msg for Trigger {} + impl Mailboxed for Bomb { + type Msg = Trigger; + } + impl crate::actor::Actor for Bomb { + type Args = (); + type Error = core::convert::Infallible; + async fn on_start(_: (), _: ActorRef) -> Result { + Ok(Bomb) + } + async fn handle( + &mut self, + _: Trigger, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + panic!("boom") + } + } + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let handle = prepared.spawn(()); + actor_ref + .mailbox_sender() + .send(Signal::Message(Trigger)) + .await + .expect("send trigger"); + + let outcome = handle.await.expect("run task"); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Panicked(_), + .. + } + )); + + let resend = actor_ref + .mailbox_sender() + .send(Signal::Message(Trigger)) + .await; + assert!( + resend.is_err(), + "the actor's mailbox is closed after the panic-stop" + ); + } + + /// Lifecycle: a handler that RETURNS `Err` (not a panic) is a controlled crash — + /// it stops the actor with `Panicked(HandlerPanic)` and runs `on_stop`. This is + /// the only test that exercises the `Ok(Err(_))` arm of the loop's dispatch. + #[tokio::test] + async fn handle_returning_err_stops_as_panicked() { + #[derive(Debug)] + struct Nope; + struct Failer; + struct Do; + impl Msg for Do {} + impl Mailboxed for Failer { + type Msg = Do; + } + impl crate::actor::Actor for Failer { + type Args = (); + type Error = Nope; + async fn on_start(_: (), _: ActorRef) -> Result { + Ok(Failer) + } + async fn handle( + &mut self, + _: Do, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Err(Nope) + } + } + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Do)) + .await + .expect("send"); + let outcome = prepared.run(()).await; + + let RunResult::Stopped { + reason: ActorStopReason::Panicked(err), + .. + } = outcome + else { + panic!("expected Stopped/Panicked, got {outcome:?}"); + }; + assert_eq!(err.reason(), crate::error::PanicReason::HandlerPanic); + } } From 2c6bb6c048ca05305f1e908513c0b17dcdf47af7 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sun, 5 Jul 2026 23:55:36 +0200 Subject: [PATCH 14/21] core(actor): test hard kill skips on_stop and drops in-flight (#116) --- bombay-core/src/actor/spawn.rs | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index da79ddd..fb0344e 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -816,4 +816,90 @@ mod tests { }; assert_eq!(err.reason(), crate::error::PanicReason::HandlerPanic); } + + /// Lifecycle: `kill()` while a handler is mid-flight aborts the task at its next + /// await point — the handler never completes, `on_stop` does NOT run, and the + /// outcome is `Killed`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kill_skips_on_stop_and_drops_in_flight() { + use tokio::sync::oneshot; + + struct Blocker { + entered: Option>, + finished: Arc, + stopped: Arc, + } + struct Block; + impl Msg for Block {} + impl Mailboxed for Blocker { + type Msg = Block; + } + impl crate::actor::Actor for Blocker { + type Args = (oneshot::Sender<()>, Arc, Arc); + type Error = core::convert::Infallible; + async fn on_start( + (entered, finished, stopped): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + entered: Some(entered), + finished, + stopped, + }) + } + async fn handle( + &mut self, + _: Block, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + std::future::pending::<()>().await; // never completes until aborted + self.finished.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let finished = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Block)) + .await + .expect("send"); + let handle = prepared.spawn((entered_tx, Arc::clone(&finished), Arc::clone(&stopped))); + + entered_rx.await.expect("handler entered"); // handler is now parked forever + actor_ref.kill(); // hard abort + + let outcome = handle.await.expect("join"); + assert!( + matches!(outcome, RunResult::Killed), + "kill → Killed, got {outcome:?}" + ); + assert_eq!( + finished.load(Ordering::SeqCst), + 0, + "in-flight handler dropped, never finished" + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 0, + "on_stop skipped on hard kill" + ); + } } From f60de95675c5d04591ae1a45fe7ff35e5c6715ce Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 00:00:11 +0200 Subject: [PATCH 15/21] core(actor): Spawn ext-trait + default capacity + concurrent single-writer test (#116) --- bombay-core/src/actor/mod.rs | 27 +++++++++- bombay-core/src/actor/spawn.rs | 99 ++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/bombay-core/src/actor/mod.rs b/bombay-core/src/actor/mod.rs index e02ec7f..e897e35 100644 --- a/bombay-core/src/actor/mod.rs +++ b/bombay-core/src/actor/mod.rs @@ -9,8 +9,9 @@ use core::{any::type_name, future::Future}; use crate::{ + actor::spawn::default_capacity, error::{ActorStopReason, PanicError, ReplyError}, - mailbox::Mailboxed, + mailbox::{Capacity, Mailboxed}, message::Msg, }; @@ -89,3 +90,27 @@ pub trait Actor: Mailboxed + Sized + Send + 'static { async { Ok(()) } } } + +/// Ergonomic spawn entry points, provided for every [`Actor`]. +/// +/// Spawns onto the current tokio runtime and returns the [`ActorRef`]; the actor +/// stops via `Signal::Stop`, [`ActorRef::stop`], [`ActorRef::kill`], a handler +/// crash, or startup failure (ref-count-driven stop is #117). +pub trait Spawn: Actor { + /// Spawns with the [`DEFAULT_MAILBOX_CAPACITY`](spawn::DEFAULT_MAILBOX_CAPACITY). + #[must_use] + fn spawn(args: Self::Args) -> ActorRef { + Self::spawn_with_capacity(default_capacity(), args) + } + + /// Spawns with an explicit mailbox `capacity`. + #[must_use] + fn spawn_with_capacity(capacity: Capacity, args: Self::Args) -> ActorRef { + let prepared = PreparedActor::::new(capacity); + let actor_ref = prepared.actor_ref().clone(); + let _join = prepared.spawn(args); + actor_ref + } +} + +impl Spawn for A {} diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index fb0344e..5393376 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -26,6 +26,18 @@ use crate::{ /// worth of slots is a sane starting point; tune with `spawn_with_capacity`). pub const DEFAULT_MAILBOX_CAPACITY: usize = 64; +/// The default capacity as a validated [`Capacity`]. Infallible for the fixed +/// constant 64 (in `1..=Capacity::MAX`); the `expect` is proven by +/// `default_capacity_is_64` and can never trip at runtime. +pub(super) fn default_capacity() -> Capacity { + #[expect( + clippy::expect_used, + reason = "DEFAULT_MAILBOX_CAPACITY (64) is a compile-time-valid capacity; \ + the conversion is infallible and pinned by a unit test" + )] + Capacity::try_from(DEFAULT_MAILBOX_CAPACITY).expect("64 is a valid capacity") +} + /// Monotonic scaffold id source (#121 replaces this with the AID). static NEXT_ACTOR_ID: AtomicU64 = AtomicU64::new(1); @@ -194,6 +206,7 @@ mod tests { atomic::{AtomicU32, Ordering}, }; + use super::DEFAULT_MAILBOX_CAPACITY; use crate::{ actor::{ActorRef, PreparedActor, RunResult, WeakActorRef}, error::ActorStopReason, @@ -902,4 +915,90 @@ mod tests { "on_stop skipped on hard kill" ); } + + /// The ergonomic spawn path uses the default mailbox capacity; pin the constant + /// and that `default_capacity()` yields exactly it (guards a wrong default). + #[test] + fn default_capacity_is_64() { + assert_eq!(DEFAULT_MAILBOX_CAPACITY, 64); + assert_eq!(super::default_capacity().get(), 64); + } + + /// Linearizability / single-writer: many senders race messages at one actor from + /// the same instant; the actor handles them sequentially, so the total count is + /// exact (none lost or double-counted) despite real concurrency. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_senders_single_writer_exact_count() { + use crate::actor::Spawn; + use tokio::sync::{Barrier, oneshot}; + + const SENDERS: u32 = 8; + const PER_SENDER: u32 = 50; + + struct Sink { + count: u32, + done_at: u32, + done: Option>, + } + struct Bump; + impl Msg for Bump {} + impl Mailboxed for Sink { + type Msg = Bump; + } + impl crate::actor::Actor for Sink { + type Args = (u32, oneshot::Sender); + type Error = core::convert::Infallible; + async fn on_start( + (done_at, done): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + count: 0, + done_at, + done: Some(done), + }) + } + async fn handle( + &mut self, + _: Bump, + _: ActorRef, + stop: &mut bool, + ) -> Result<(), Self::Error> { + self.count += 1; + if self.count == self.done_at { + if let Some(done) = self.done.take() { + let _ = done.send(self.count); + } + *stop = true; + } + Ok(()) + } + } + + let (done_tx, done_rx) = oneshot::channel(); + let total = SENDERS * PER_SENDER; + let actor_ref = Sink::spawn_with_capacity(cap(4), (total, done_tx)); + + let start = Arc::new(Barrier::new(SENDERS as usize)); + let mut tasks = Vec::new(); + for _ in 0..SENDERS { + let sender = actor_ref.mailbox_sender().clone(); + let start = Arc::clone(&start); + tasks.push(tokio::spawn(async move { + start.wait().await; + for _ in 0..PER_SENDER { + sender.send(Signal::Message(Bump)).await.expect("send"); + } + })); + } + + let final_count = done_rx.await.expect("actor finished"); + assert_eq!( + final_count, total, + "single writer counted every message exactly once" + ); + for task in tasks { + task.await.expect("sender task"); + } + } } From b48de9ecb48cde63ff73ba550b210ecc1e85a318 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 00:03:13 +0200 Subject: [PATCH 16/21] docs(testing): record #116 actor spine coverage baseline --- docs/testing/coverage-baseline.md | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/testing/coverage-baseline.md b/docs/testing/coverage-baseline.md index 59bc180..0085054 100644 --- a/docs/testing/coverage-baseline.md +++ b/docs/testing/coverage-baseline.md @@ -141,6 +141,44 @@ behaviorally by the ok/handler/drop tests + the DST). No bombay-owned atomics loom N/A (delegated to tokio oneshot), same as #113. `DelegatedReply`/`ForwardedReply` deferred to #116/#118 (recorded on #115). +### `actor` (#116) — done +`bombay-core/src/actor/` carries the local actor spine: the `Actor` trait + +lifecycle hooks (`mod.rs`), the run-loop (`kind.rs`), the minimal +`ActorRef`/`WeakActorRef` handle (`actor_ref.rs`), and the spawn entry points +`PreparedActor`/`RunResult` + the `Spawn` ext-trait (`spawn.rs`). The loop is +**finish-current-then-stop, no drain**: `on_start` (caught) → a `select` over +`CancellationToken::run_until_cancelled(recv)` → `on_stop` (caught; a returned +`Err` is logged via `log_on_stop_outcome`, never unwrapped, and the stop +`reason` is preserved). Four `catch_unwind` boundaries turn a panic into an +inspectable `PanicError` instead of tearing down the task — `handle`, `on_stop`, +`on_start`, and `on_panic` — and a hard kill is a uniform `futures::Abortable` +wrap of the whole lifecycle (skips `on_stop` → `RunResult::Killed`). A `handle` +that returns `Err` is a controlled crash routed through `on_panic` exactly like a +caught unwind (both → `ActorStopReason::Panicked`). `default_capacity()` is +pinned by a unit test so its `expect` can never trip. + +**14 tests** (13 in `spawn.rs`, 1 in `actor_ref.rs`), organized by the rule-#7 +cross-cutting categories: +- **Sequence/protocol** — queued-messages-then-`Signal::Stop` handled in order + then stop; `*stop = true` stops after the current handler returns; on-start + messages handled *after* `on_start` in FIFO order (proves the no-buffer / + mailbox-waits contract); a returned `Err` stops as `Panicked`. +- **Lifecycle** — graceful cancel finishes the in-flight handler then stops; + hard `kill` skips `on_stop` and drops in-flight; `on_start` `Err` and + `on_start` panic both → `RunResult::StartupFailed` (unwind pinned to the + `on_start` boundary); a handler panic → `on_stop` runs with a `Panicked` + reason; weak-ref upgrades while open then `None` after the strong ref drops. +- **Defensive boundary** — the poison contract: `on_stop` after a panic observes + torn state (release-only, never reads domain fields); a post-panic `send` + fails; the `on_start` panic is caught (unwind never escapes the pin). +- **Linearizability** — concurrent senders drive a single-writer actor to an + exact final count (real overlap via `tokio::spawn`). + +Mutation (`nix build .#mutants`) and loom/DST are not re-measured in this card; +the first bombay-owned concurrency the run-loop introduces (the `select` over +signals) is the loom/shuttle target noted under `mailbox` above. No README +change — the rebuilt spine is not behind the umbrella yet (same as #113/#115). + ## Baseline — 2026-06-29 (after #77) Workspace line coverage **60.85% (5686/9345)** — but that blends the SUT with untested crates From 5ee739f9f8e6fbffc3217de1b5768b80e13882de Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 00:04:46 +0200 Subject: [PATCH 17/21] chore(typos): allowlist coined test actor name Failer (#116) --- _typos.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/_typos.toml b/_typos.toml index b49a0a2..2c377bf 100644 --- a/_typos.toml +++ b/_typos.toml @@ -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" From 9f347b00cb1b6e0c5c794ba35f719e2abc5fc08d Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 13:33:02 +0200 Subject: [PATCH 18/21] =?UTF-8?q?test(actor):=20kill=20cargo-mutants=20sur?= =?UTF-8?q?vivors=20=E2=80=94=20zero=20survivors=20on=20#116=20spine=20(#1?= =?UTF-8?q?16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped cargo-mutants over the #116 spine (kind.rs, spawn.rs, actor_ref.rs, mod.rs) surfaced 7 missed + 3 timeout mutants. Add/strengthen tests to kill each; no production behavior changed. Missed (now caught): - Actor::name default stubbed to ""/"xyzzy" -> assert name() contains the concrete type name. - Debug impls (ActorRef, WeakActorRef, RunResult, PreparedActor) stubbed to an empty formatter -> assert the rendered debug names the struct/variant. Timeouts (now caught, converted to fail-fast): stop/kill/stop-flag mutants turned hangs into 20s harness timeouts because tests that rely on stop()/kill()/`*stop` to terminate would park forever. Bound every such await (cancel_finishes, messages_during_on_start, stop_flag, kill) in a 5s tokio::time::timeout, and add stop_terminates_idle_actor, so the mutant fails fast instead of hanging. log_on_stop_outcome is diagnostic-only (eprintln, no state) and its "replace with ()" mutant is unobservable without capturing stderr; skipped via .cargo/mutants.toml exclude_re (config-file equivalent of #[mutants::skip], avoiding a no-op production dependency on the `mutants` crate). Scoped mutants: 0 missed / 0 timeout on the four actor files and on error.rs (from_panic_any covered). --- .cargo/mutants.toml | 12 +++ bombay-core/src/actor/actor_ref.rs | 45 ++++++++++++ bombay-core/src/actor/spawn.rs | 113 ++++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 .cargo/mutants.toml diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..fb70d71 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,12 @@ +# 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", +] diff --git a/bombay-core/src/actor/actor_ref.rs b/bombay-core/src/actor/actor_ref.rs index 7e50453..61eabcd 100644 --- a/bombay-core/src/actor/actor_ref.rs +++ b/bombay-core/src/actor/actor_ref.rs @@ -196,6 +196,51 @@ mod tests { (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] diff --git a/bombay-core/src/actor/spawn.rs b/bombay-core/src/actor/spawn.rs index 5393376..e03a66b 100644 --- a/bombay-core/src/actor/spawn.rs +++ b/bombay-core/src/actor/spawn.rs @@ -378,7 +378,13 @@ mod tests { actor_ref.stop(); // cancel while in-flight release_tx.send(()).expect("release handler"); // let handler #1 finish - let outcome = run.await.expect("run task"); + // Bounded so that if `stop` is a no-op the loop never ends (it would go on + // to handle the queued message and park on `recv`), FAILING FAST here + // rather than hanging until the harness timeout. + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), run) + .await + .expect("stop() must terminate the actor after the in-flight handler") + .expect("run task"); assert_eq!( handled.load(Ordering::SeqCst), 1, @@ -437,7 +443,15 @@ mod tests { .await .expect("send 2"); - let outcome = prepared.run(Arc::clone(&handled)).await; + // Bounded so that if the `stop` flag is ignored (the loop keeps running + // and parks on `recv`, since this test still holds a strong sender), the + // test FAILS FAST here rather than hanging until the harness timeout. + let outcome = tokio::time::timeout( + std::time::Duration::from_secs(5), + prepared.run(Arc::clone(&handled)), + ) + .await + .expect("the stop flag must terminate the actor"); assert_eq!( handled.load(Ordering::SeqCst), 1, @@ -517,7 +531,13 @@ mod tests { .expect("send 2"); gate_tx.send(()).expect("release on_start"); - run.await.expect("run task"); + // Bounded so that if the `stop` flag is ignored the loop parks on `recv` + // after handling all three, FAILING FAST here rather than hanging until + // the harness timeout. + tokio::time::timeout(std::time::Duration::from_secs(5), run) + .await + .expect("the stop flag must terminate the actor") + .expect("run task"); assert_eq!( *seen.lock().expect("lock"), vec![0, 1, 2], @@ -899,7 +919,12 @@ mod tests { entered_rx.await.expect("handler entered"); // handler is now parked forever actor_ref.kill(); // hard abort - let outcome = handle.await.expect("join"); + // Bounded so that if `kill` is a no-op, the parked handler never aborts + // and this FAILS FAST rather than hanging until the harness timeout. + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("kill() must abort the parked actor") + .expect("join"); assert!( matches!(outcome, RunResult::Killed), "kill → Killed, got {outcome:?}" @@ -924,6 +949,86 @@ mod tests { assert_eq!(super::default_capacity().get(), 64); } + /// Lifecycle: `stop()` on an otherwise-idle actor (empty mailbox, loop parked + /// on `recv`) wakes the loop and stops it normally, running `on_stop`. Bounded + /// so that if `stop` is a no-op the loop parks forever and this FAILS FAST + /// instead of hanging until the harness timeout. + #[tokio::test] + async fn stop_terminates_idle_actor() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let run = tokio::spawn(prepared.run((Arc::clone(&handled), Arc::clone(&stopped)))); + + // No messages are ever sent: the loop is parked on `recv`. The cancel must + // wake it. (`actor_ref` still holds a strong sender, so `recv` will NOT + // return `None` on its own — only the cancel can end the loop.) + actor_ref.stop(); + + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), run) + .await + .expect("stop() must terminate the idle actor") + .expect("run task"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "clean normal stop, got {outcome:?}", + ); + assert_eq!(handled.load(Ordering::SeqCst), 0, "no message was handled"); + assert_eq!(stopped.load(Ordering::SeqCst), 1, "on_stop ran once"); + } + + /// The `RunResult` debug view distinguishes each variant by name — guards the + /// hand-written `Debug` impl against being stubbed to an empty formatter. + #[test] + fn run_result_debug_distinguishes_variants() { + let killed: RunResult = RunResult::Killed; + assert_eq!(format!("{killed:?}"), "Killed", "Killed prints its name"); + + let stopped: RunResult = RunResult::Stopped { + actor: Counter { + handled: Arc::new(AtomicU32::new(0)), + stopped: Arc::new(AtomicU32::new(0)), + }, + reason: ActorStopReason::Normal, + }; + let shown = format!("{stopped:?}"); + assert!(shown.contains("Stopped"), "names the variant: {shown}"); + assert!( + shown.contains("reason"), + "surfaces the reason field: {shown}" + ); + + let failed: RunResult = + RunResult::StartupFailed(crate::error::PanicError::from_panic_any( + Box::new("boom"), + crate::error::PanicReason::OnStart, + )); + assert!( + format!("{failed:?}").contains("StartupFailed"), + "names the variant: {failed:?}", + ); + } + + /// The `PreparedActor` debug view names the struct — guards its hand-written + /// `Debug` impl against being stubbed to an empty formatter. + #[test] + fn prepared_actor_debug_names_struct() { + let prepared = PreparedActor::::new(cap(4)); + let shown = format!("{prepared:?}"); + assert!( + shown.contains("PreparedActor"), + "debug names the struct: {shown}" + ); + } + /// Linearizability / single-writer: many senders race messages at one actor from /// the same instant; the actor handles them sequentially, so the total count is /// exact (none lost or double-counted) despite real concurrency. From f6c03e3c08d39af5e890523eaddf0fb5a905d6f3 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 13:50:23 +0200 Subject: [PATCH 19/21] test(actor): deterministic DST harness for stop/cancel/kill/startup races (#116) --- bombay-core/tests/dst_races.rs | 641 +++++++++++++++++++++++++++++++++ 1 file changed, 641 insertions(+) create mode 100644 bombay-core/tests/dst_races.rs diff --git a/bombay-core/tests/dst_races.rs b/bombay-core/tests/dst_races.rs new file mode 100644 index 0000000..b2bc398 --- /dev/null +++ b/bombay-core/tests/dst_races.rs @@ -0,0 +1,641 @@ +//! Deterministic interleaving suite for the actor run-loop (card #116). +//! +//! The stop / cancel / kill / startup races are covered here by *forcing* one +//! specific ordering per test with `oneshot` barriers and (where a stop must win +//! or lose a race with a kill) the single-threaded runtime's no-preemption +//! guarantee — so each interleaving is exercised deterministically rather than +//! left to timing luck. Every "must terminate" await is wrapped in a 5 s +//! `tokio::time::timeout`, so a regression that hangs the loop FAILS FAST here +//! instead of stalling the suite. Each test asserts a *specific* outcome (which +//! hooks ran, via `Arc` spies, and the exact `RunResult` variant), not +//! merely "didn't hang". +//! +//! These are the GAP scenarios: the happy-path "finish-in-flight-on-cancel" and +//! "kill-mid-handler" races already live in `spawn.rs` unit tests and are not +//! duplicated here. +//! +//! # loom: justified N/A (not applied) +//! +//! loom explores permutations of **std synchronization primitives** — the +//! interleavings of `atomic` / `Mutex` / `UnsafeCell` operations admitted by the +//! C11 memory model. It does **not** model an async executor's task-scheduling +//! choices; that is outside its scope. #116's run-state is a single tokio task +//! that owns `&mut self` and drives the actor sequentially — there is no shared +//! mutable state read concurrently from two threads for loom to permute. The one +//! atomic in the whole spine is `NEXT_ACTOR_ID` (a `Relaxed` monotonic counter in +//! `spawn.rs`), whose correctness is "each `fetch_add` returns a distinct value" +//! — a property of atomic increment alone, needing no happens-before. A loom +//! model of it here would require either (a) an invasive `#[cfg(loom)]` swap of +//! the production `static` plus a production loom dependency, or (b) +//! reimplementing the counter inside the test — which would then assert on the +//! reimplementation, not the SUT (test-quality rule #8). Neither is worth doing +//! for a lone Relaxed counter, so loom is deliberately not applied. The async +//! orderings that DO matter for #116 are covered deterministically below with +//! barriers and the single-threaded runtime. + +use core::convert::Infallible; +use std::{ + sync::{ + Arc, + atomic::{AtomicU32, Ordering}, + }, + time::Duration, +}; + +use tokio::{sync::oneshot, time::timeout}; + +use bombay_core::{ + actor::{Actor, ActorRef, PreparedActor, RunResult, WeakActorRef}, + error::ActorStopReason, + mailbox::{Capacity, Mailboxed, Signal}, + message::Msg, +}; + +/// The suite-wide fail-fast bound: any terminal await that exceeds this is a hung +/// loop, and the test fails here rather than stalling the whole run. +const TERMINATE: Duration = Duration::from_secs(5); + +fn cap(n: usize) -> Capacity { + Capacity::try_from(n).expect("valid test capacity") +} + +/// A reusable spy actor: counts handled messages and how many times `on_stop` +/// ran, via shared atomics the test inspects. The SUT is the real loop. +struct Spy { + handled: Arc, + stopped: Arc, +} +struct Ping; +impl Msg for Ping {} +impl Mailboxed for Spy { + type Msg = Ping; +} +impl Actor for Spy { + type Args = (Arc, Arc); + type Error = Infallible; + async fn on_start( + (handled, stopped): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { handled, stopped }) + } + async fn handle( + &mut self, + _: Ping, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Scenario 1 — kill during `on_start`, before any state is built. +// --------------------------------------------------------------------------- + +/// `kill()` while `on_start` is parked (state not yet built) aborts the whole +/// lifecycle: the outcome is `Killed`, `on_stop` never runs, and message handling +/// never begins. A message is pre-queued precisely to prove it is never handled, +/// since `on_start` never completes to reach the loop. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn kill_during_on_start_yields_killed_no_on_stop_no_handling() { + struct StartGate { + handled: Arc, + stopped: Arc, + } + impl Mailboxed for StartGate { + type Msg = Ping; + } + impl Actor for StartGate { + // (entered, release, handled, stopped) + type Args = ( + oneshot::Sender<()>, + oneshot::Receiver<()>, + Arc, + Arc, + ); + type Error = Infallible; + async fn on_start( + (entered, release, handled, stopped): Self::Args, + _: ActorRef, + ) -> Result { + let _ = entered.send(()); // "on_start reached the gate" + let _ = release.await; // park here forever (test never releases) + Ok(Self { handled, stopped }) + } + async fn handle( + &mut self, + _: Ping, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let (_release_tx, release_rx) = oneshot::channel(); // never fired + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + // Pre-queue a message: it must never be handled, because on_start never ends. + actor_ref + .mailbox_sender() + .send(Signal::Message(Ping)) + .await + .expect("pre-queue"); + let run = prepared.spawn(( + entered_tx, + release_rx, + Arc::clone(&handled), + Arc::clone(&stopped), + )); + + entered_rx.await.expect("on_start reached the gate"); + actor_ref.kill(); // abort while on_start is parked + + let outcome = timeout(TERMINATE, run) + .await + .expect("kill() must abort the parked on_start") + .expect("join"); + assert!( + matches!(outcome, RunResult::Killed), + "kill mid-on_start → Killed, got {outcome:?}", + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 0, + "on_stop never ran (no state was built)" + ); + assert_eq!( + handled.load(Ordering::SeqCst), + 0, + "message handling never began" + ); +} + +// --------------------------------------------------------------------------- +// Scenario 2 — kill during `on_stop`, while the cleanup hook is parked. +// --------------------------------------------------------------------------- + +/// A graceful stop drives `on_stop`; `kill()` while `on_stop` is parked aborts the +/// lifecycle → `Killed`, and the hook's post-park side effect never fires. This +/// pins that a hard kill wins even against the shutdown hook already in progress. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn kill_during_on_stop_yields_killed_and_skips_post_park_effect() { + struct StopGate { + entered: Option>, + release: Option>, + post_park: Arc, + } + impl Mailboxed for StopGate { + type Msg = Ping; + } + impl Actor for StopGate { + // (entered, release, post_park) + type Args = (oneshot::Sender<()>, oneshot::Receiver<()>, Arc); + type Error = Infallible; + async fn on_start( + (entered, release, post_park): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + entered: Some(entered), + release: Some(release), + post_park, + }) + } + async fn handle( + &mut self, + _: Ping, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); // "on_stop reached the gate" + } + if let Some(release) = self.release.take() { + let _ = release.await; // park here forever (test never releases) + } + self.post_park.fetch_add(1, Ordering::SeqCst); // must NOT run if killed here + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let (_release_tx, release_rx) = oneshot::channel(); // never fired + let post_park = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn((entered_tx, release_rx, Arc::clone(&post_park))); + + actor_ref.stop(); // graceful → loop returns Normal → on_stop runs + entered_rx.await.expect("on_stop reached the gate"); + actor_ref.kill(); // abort while on_stop is parked + + let outcome = timeout(TERMINATE, run) + .await + .expect("kill() must abort the parked on_stop") + .expect("join"); + assert!( + matches!(outcome, RunResult::Killed), + "kill mid-on_stop → Killed, got {outcome:?}", + ); + assert_eq!( + post_park.load(Ordering::SeqCst), + 0, + "on_stop's post-park side effect never fired", + ); +} + +// --------------------------------------------------------------------------- +// Scenario 3a — `stop()` then `kill()` before the loop observes the stop. +// --------------------------------------------------------------------------- + +/// An actor that signals when `on_start` has completed (so the test knows the loop +/// is parked on `recv`), and counts `on_stop`. +struct StartSignaled { + stopped: Arc, +} +impl Mailboxed for StartSignaled { + type Msg = Ping; +} +impl Actor for StartSignaled { + type Args = (oneshot::Sender<()>, Arc); + type Error = Infallible; + async fn on_start( + (started, stopped): Self::Args, + _: ActorRef, + ) -> Result { + let _ = started.send(()); // on_start done; the loop is about to park on recv + Ok(Self { stopped }) + } + async fn handle( + &mut self, + _: Ping, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// `stop()` immediately followed by `kill()` — with no await between them on the +/// single-threaded runtime, so the loop task is not polled in the gap — means the +/// abort flag is already set when the loop is next polled. `Abortable` checks +/// `is_aborted()` before polling the inner future, so the kill WINS: the outcome +/// is `Killed` and `on_stop` never runs, even though a graceful stop was requested +/// first. (current_thread is load-bearing: on a multi-thread runtime the loop +/// could observe the cancel on another worker before the kill lands.) +#[tokio::test] // current_thread — no preemption between stop() and kill() +async fn stop_then_kill_before_observe_is_killed_and_skips_on_stop() { + let (started_tx, started_rx) = oneshot::channel(); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let run = tokio::spawn(prepared.run((started_tx, Arc::clone(&stopped)))); + + started_rx + .await + .expect("on_start done, loop parked on recv"); + actor_ref.stop(); // graceful cancel requested… + actor_ref.kill(); // …but killed before the loop task is polled to observe it + + let outcome = timeout(TERMINATE, run) + .await + .expect("must terminate") + .expect("join"); + assert!( + matches!(outcome, RunResult::Killed), + "kill wins the race → Killed, got {outcome:?}", + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 0, + "on_stop never ran — kill won" + ); +} + +// --------------------------------------------------------------------------- +// Scenario 3b — a graceful stop that FULLY completes, THEN `kill()` (no-op). +// --------------------------------------------------------------------------- + +/// A queued `Signal::Stop` stops the actor normally (running `on_stop` once); a +/// `kill()` issued AFTER the run has fully returned is a harmless no-op on an +/// already-stopped actor — no panic, and the recorded outcome is unchanged. +#[tokio::test] +async fn graceful_stop_completes_then_kill_is_a_noop() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("enqueue Stop"); + + let outcome = timeout( + TERMINATE, + prepared.run((Arc::clone(&handled), Arc::clone(&stopped))), + ) + .await + .expect("Signal::Stop must terminate the actor"); + + // The actor is fully stopped; killing it now must not panic or change anything. + actor_ref.kill(); + + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "graceful stop → Normal, got {outcome:?}", + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 1, + "on_stop ran exactly once" + ); + assert_eq!( + handled.load(Ordering::SeqCst), + 0, + "no domain message was handled" + ); +} + +// --------------------------------------------------------------------------- +// Scenario 4 — idempotent `stop()` from multiple ref clones. +// --------------------------------------------------------------------------- + +/// Calling `stop()` several times — twice on one ref and once on a clone — stops +/// the actor exactly once: `on_stop` runs once and the outcome is `Normal`. The +/// cancellation is sticky, so pre-run `stop()`s collapse into a single stop. +#[tokio::test] +async fn idempotent_stop_stops_once_and_runs_on_stop_once() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let clone = actor_ref.clone(); + + actor_ref.stop(); + actor_ref.stop(); // repeated on the same ref + clone.stop(); // and from a distinct clone + + let outcome = timeout( + TERMINATE, + prepared.run((Arc::clone(&handled), Arc::clone(&stopped))), + ) + .await + .expect("stop() must terminate the actor"); + + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "idempotent stop → Normal, got {outcome:?}", + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 1, + "on_stop ran exactly once despite 3 stop() calls" + ); + assert_eq!(handled.load(Ordering::SeqCst), 0, "no message handled"); +} + +// --------------------------------------------------------------------------- +// Scenario 5 — `stop()` racing a `Signal::Stop` already queued. +// --------------------------------------------------------------------------- + +/// A `Signal::Stop` is enqueued AND `stop()` (the cancel token) is fired: whichever +/// the loop observes first, the result is a single `Normal` stop with `on_stop` +/// run exactly once — no hang, no double `on_stop`, and no message handled. +#[tokio::test] +async fn stop_racing_a_queued_stop_signal_stops_normally_once() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("enqueue Stop"); + actor_ref.stop(); // cancel token races the queued Stop + + let outcome = timeout( + TERMINATE, + prepared.run((Arc::clone(&handled), Arc::clone(&stopped))), + ) + .await + .expect("the queued Stop / cancel race must terminate the actor"); + + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "either path → Normal, got {outcome:?}", + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 1, + "on_stop ran exactly once — not twice" + ); + assert_eq!(handled.load(Ordering::SeqCst), 0, "no message handled"); +} + +// --------------------------------------------------------------------------- +// Scenario 6 — `send` racing termination: send after a graceful stop fails. +// --------------------------------------------------------------------------- + +/// After a graceful stop completes the run-loop drops its mailbox receiver, so a +/// subsequent `send` on a still-held sender fails (the actor is gone) — the +/// message is handed back rather than lost into the void. +#[tokio::test] +async fn send_after_graceful_stop_fails() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("enqueue Stop"); + + let outcome = timeout( + TERMINATE, + prepared.run((Arc::clone(&handled), Arc::clone(&stopped))), + ) + .await + .expect("Signal::Stop must terminate the actor"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "graceful stop → Normal, got {outcome:?}", + ); + assert_eq!(stopped.load(Ordering::SeqCst), 1, "on_stop ran once"); + + // The receiver is gone; the send must fail and return the undelivered message. + let resend = actor_ref.mailbox_sender().send(Signal::Message(Ping)).await; + assert!( + matches!( + resend, + Err(bombay_core::mailbox::SendError(Signal::Message(Ping))) + ), + "send after the actor stopped must fail with the message handed back", + ); + assert_eq!( + handled.load(Ordering::SeqCst), + 0, + "the post-stop message was never handled" + ); +} + +// --------------------------------------------------------------------------- +// Scenario 7 — `kill()` after a normal completion (via the handler stop flag). +// --------------------------------------------------------------------------- + +/// An actor that finishes itself by setting the stop flag in its handler, then +/// counts `on_stop`. +struct SelfStop { + handled: Arc, + stopped: Arc, +} +impl Mailboxed for SelfStop { + type Msg = Ping; +} +impl Actor for SelfStop { + type Args = (Arc, Arc); + type Error = Infallible; + async fn on_start( + (handled, stopped): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { handled, stopped }) + } + async fn handle( + &mut self, + _: Ping, + _: ActorRef, + stop: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + *stop = true; // stop cleanly after this handler returns Ok + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.stopped.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// The actor stops normally via its handler's stop flag; a `kill()` issued AFTER +/// the run has returned is a no-op — no panic, and the outcome stays `Normal` with +/// `on_stop` having run once. +#[tokio::test] +async fn kill_after_normal_completion_is_a_noop() { + let handled = Arc::new(AtomicU32::new(0)); + let stopped = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Ping)) + .await + .expect("enqueue one message"); + + let outcome = timeout( + TERMINATE, + prepared.run((Arc::clone(&handled), Arc::clone(&stopped))), + ) + .await + .expect("the stop flag must terminate the actor"); + + // Actor already finished normally; killing the corpse must not panic. + actor_ref.kill(); + + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "self-stop → Normal, got {outcome:?}", + ); + assert_eq!( + handled.load(Ordering::SeqCst), + 1, + "the single message was handled" + ); + assert_eq!( + stopped.load(Ordering::SeqCst), + 1, + "on_stop ran exactly once; kill added nothing" + ); +} From 27bde906c6a3b18974c7493c02585261d514f6b3 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 13:53:08 +0200 Subject: [PATCH 20/21] chore(mutants): taplo-format .cargo/mutants.toml (#116) --- .cargo/mutants.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index fb70d71..a06ff20 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -7,6 +7,4 @@ # 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", -] +exclude_re = ["replace log_on_stop_outcome"] From 32e41aec0ac2a00f8d0cd316d01957e80d9daeee Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 14:11:06 +0200 Subject: [PATCH 21/21] test(actor): direct verification of actor-system invariants I1-I23 (#116) --- bombay-core/tests/invariants.rs | 1186 +++++++++++++++++++++++++++++++ 1 file changed, 1186 insertions(+) create mode 100644 bombay-core/tests/invariants.rs diff --git a/bombay-core/tests/invariants.rs b/bombay-core/tests/invariants.rs new file mode 100644 index 0000000..8363f46 --- /dev/null +++ b/bombay-core/tests/invariants.rs @@ -0,0 +1,1186 @@ +//! Authoritative, DIRECT verification of the actor-system invariants (card #116). +//! +//! Each test asserts the invariant *itself* — the property an actor model must +//! carry — using the public `bombay_core` API only, with `oneshot` barriers to +//! force orderings and a 5 s `tokio::time::timeout` around every "must terminate" +//! await so a regression that hangs the loop FAILS FAST rather than stalling the +//! suite. Handler panics are caught by the loop and observed via `RunResult` + +//! spies (never `#[should_panic]`). Every assertion is a *specific* value (exact +//! counts, exact `RunResult` variant + `PanicReason`, exact ordered vectors). +//! +//! # Invariant map (I1–I23) +//! +//! Proven directly in THIS file: +//! I1 single-writer mutual exclusion (max concurrent handlers == 1) +//! -> i1_single_writer_mutual_exclusion +//! I3 macro-step atomicity (read-modify-write across an await is not torn) +//! -> i3_macro_step_atomicity +//! I4 message FIFO ordering -> covered by i5_fifo_exactly_once +//! I5 exactly-once-while-alive -> i5_fifo_exactly_once +//! I7 no reentrancy / self-send is enqueued, not re-entered +//! -> i7_no_reentrancy_self_send_is_enqueued +//! I8 lifecycle ordering (start < handle* < stop) +//! -> i8_i10_i11_lifecycle_order_normal / _panic +//! I9c on_stop is NOT run on startup failure -> i9c_on_stop_not_run_on_startup_failure +//! I10 exactly-once lifecycle hooks -> i8_i10_i11_lifecycle_order_normal +//! I11 nothing runs after on_stop -> i8_i10_i11_lifecycle_order_normal +//! I12 alive-window (pre-run buffered handled; post-stop send rejected) +//! -> i12_alive_window +//! I13 no loss / no duplication -> covered by i5_fifo_exactly_once +//! I14 stop-reason fidelity under on_stop failure (+ panic containment) +//! -> i14a_normal_stop_on_stop_panic_preserves_normal +//! i14b_normal_stop_on_stop_err_preserves_normal +//! i14c_handler_panic_then_on_stop_panic_preserves_original_cause +//! I15 fault isolation across actors -> i15_fault_isolation +//! I17 distinct actor ids -> i17_distinct_ids +//! I19 ref-send liveness / send-to-dead fails -> i19_send_and_weak_upgrade_after_termination +//! I20 backpressure: a full mailbox rejects (message handed back) +//! -> i20_i21_backpressure_and_capacity_freed_by_draining +//! I21 capacity is freed by the loop draining -> (same test) +//! +//! Proven elsewhere (listed here, not re-implemented): +//! I6 startup buffering (msgs during on_start handled after, in FIFO) +//! -> spawn.rs::messages_during_on_start_are_handled_after_in_order +//! I8 finish-current-then-stop, no drain +//! -> spawn.rs::cancel_finishes_in_flight_then_stops; dst_races.rs +//! I9 kill skips on_stop +//! -> spawn.rs::kill_skips_on_stop_and_drops_in_flight; dst_races.rs +//! I16 poison: on_stop observes the torn field (counter == 99) +//! -> spawn.rs::on_stop_after_panic_observes_torn_state +//! I18 weak-upgrade while open, None after the last strong sender drops +//! -> actor_ref.rs::weak_upgrades_while_open_then_none_after_drop +//! I23 no starvation (implied by FIFO + exactly-once, I4 + I5) +//! -> i5_fifo_exactly_once +//! +//! I2 and I22 are not individually enumerated by card #116's invariant set; the +//! one-message-at-a-time property they would name is exactly what I1 asserts here. + +use core::convert::Infallible; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, + time::Duration, +}; + +use tokio::{sync::oneshot, task::yield_now, time::timeout}; + +use bombay_core::{ + actor::{Actor, ActorRef, PreparedActor, RunResult, WeakActorRef}, + error::{ActorStopReason, PanicError, PanicReason}, + mailbox::{Capacity, Mailboxed, SendError, Signal, TrySendError}, + message::Msg, +}; + +/// The suite-wide fail-fast bound: any terminal await exceeding this is a hung +/// loop — a real bug — and the test fails here rather than stalling the run. +const TERMINATE: Duration = Duration::from_secs(5); + +fn cap(n: usize) -> Capacity { + Capacity::try_from(n).expect("valid test capacity") +} + +/// A stand-in domain error (any `Debug + Send + Sync + 'static` is a `ReplyError`). +#[derive(Debug)] +struct Boom; + +/// A trivial spy actor: counts handled messages. Reused by the alive-window, +/// distinct-id and send-to-dead invariants where no bespoke behaviour is needed. +struct Bank { + handled: Arc, +} +struct Poke; +impl Msg for Poke {} +impl Mailboxed for Bank { + type Msg = Poke; +} +impl Actor for Bank { + type Args = Arc; + type Error = Infallible; + async fn on_start(handled: Self::Args, _: ActorRef) -> Result { + Ok(Self { handled }) + } + async fn handle( + &mut self, + _: Poke, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// I1 — single-writer mutual exclusion: at most ONE handler runs at any instant. +// --------------------------------------------------------------------------- + +/// Eight senders race `PER_SENDER` messages each at one actor from the same +/// instant. The handler bumps a live `concurrent` counter, records the running +/// `max`, yields three times to open a real interleaving window, then drops the +/// counter. If the loop EVER ran two handlers at once, `max` would be >= 2. +/// ASSERT `max == 1` (mutual exclusion) AND every message handled exactly once. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn i1_single_writer_mutual_exclusion() { + use tokio::sync::Barrier; + + const SENDERS: u32 = 8; + const PER_SENDER: u32 = 25; + const TOTAL: u32 = SENDERS * PER_SENDER; + + struct Excl { + concurrent: Arc, + max: Arc, + handled: Arc, + done_at: u32, + } + struct Bump; + impl Msg for Bump {} + impl Mailboxed for Excl { + type Msg = Bump; + } + impl Actor for Excl { + type Args = (Arc, Arc, Arc, u32); + type Error = Infallible; + async fn on_start( + (concurrent, max, handled, done_at): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + concurrent, + max, + handled, + done_at, + }) + } + async fn handle( + &mut self, + _: Bump, + _: ActorRef, + stop: &mut bool, + ) -> Result<(), Self::Error> { + let n = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1; + self.max.fetch_max(n, Ordering::SeqCst); + yield_now().await; + yield_now().await; + yield_now().await; + self.concurrent.fetch_sub(1, Ordering::SeqCst); + let handled = self.handled.fetch_add(1, Ordering::SeqCst) + 1; + if handled == self.done_at { + *stop = true; + } + Ok(()) + } + } + + let concurrent = Arc::new(AtomicU32::new(0)); + let max = Arc::new(AtomicU32::new(0)); + let handled = Arc::new(AtomicU32::new(0)); + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn(( + Arc::clone(&concurrent), + Arc::clone(&max), + Arc::clone(&handled), + TOTAL, + )); + + let start = Arc::new(Barrier::new(SENDERS as usize)); + let mut tasks = Vec::new(); + for _ in 0..SENDERS { + let sender = actor_ref.mailbox_sender().clone(); + let start = Arc::clone(&start); + tasks.push(tokio::spawn(async move { + start.wait().await; + for _ in 0..PER_SENDER { + sender.send(Signal::Message(Bump)).await.expect("send"); + } + })); + } + for task in tasks { + task.await.expect("sender task"); + } + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop after the last message") + .expect("join"); + + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "clean normal stop, got {outcome:?}", + ); + assert_eq!( + max.load(Ordering::SeqCst), + 1, + "at most one handler ran at any instant — the single-writer invariant", + ); + assert_eq!( + handled.load(Ordering::SeqCst), + TOTAL, + "every message handled exactly once (none lost, none double-counted)", + ); +} + +// --------------------------------------------------------------------------- +// I3 — macro-step atomicity: a read-modify-write straddling an await is not torn. +// --------------------------------------------------------------------------- + +/// A single sender sends `Add(1..=10)`. Each handler reads `counter`, yields, then +/// writes `old + n` — a read-modify-write with an await in the middle. Because +/// handlers never overlap, no update is lost and no torn intermediate is observed +/// across the await. ASSERT the recovered `counter` equals the exact sum (55). +#[tokio::test] +async fn i3_macro_step_atomicity() { + struct Acc { + counter: u64, + } + struct Add(u64); + impl Msg for Add {} + impl Mailboxed for Acc { + type Msg = Add; + } + impl Actor for Acc { + type Args = (); + type Error = Infallible; + async fn on_start(_: (), _: ActorRef) -> Result { + Ok(Self { counter: 0 }) + } + async fn handle( + &mut self, + Add(n): Add, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + let old = self.counter; + yield_now().await; + self.counter = old + n; + Ok(()) + } + } + + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn(()); + for n in 1..=10u64 { + actor_ref + .mailbox_sender() + .send(Signal::Message(Add(n))) + .await + .expect("send add"); + } + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("send stop"); + + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop") + .expect("join"); + let RunResult::Stopped { actor, reason } = outcome else { + panic!("expected Stopped, got {outcome:?}"); + }; + assert_eq!( + actor.counter, 55, + "read-modify-write across the await accumulated exactly — no lost update", + ); + assert!(matches!(reason, ActorStopReason::Normal), "clean stop"); +} + +// --------------------------------------------------------------------------- +// I5 — message FIFO + exactly-once while alive (covers I4 FIFO + I13 no loss/dup). +// --------------------------------------------------------------------------- + +/// One sender sends `0..N`; the actor records each received value. ASSERT the +/// recorded vector equals `(0..N)` exactly — every message handled exactly once, +/// in order, none lost, none duplicated. +#[tokio::test] +async fn i5_fifo_exactly_once() { + const N: u64 = 100; + + struct Rec { + seen: Arc>>, + } + struct V(u64); + impl Msg for V {} + impl Mailboxed for Rec { + type Msg = V; + } + impl Actor for Rec { + type Args = Arc>>; + type Error = Infallible; + async fn on_start(seen: Self::Args, _: ActorRef) -> Result { + Ok(Self { seen }) + } + async fn handle( + &mut self, + V(v): V, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.seen.lock().expect("lock").push(v); + Ok(()) + } + } + + let seen = Arc::new(Mutex::new(Vec::new())); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn(Arc::clone(&seen)); + for v in 0..N { + actor_ref + .mailbox_sender() + .send(Signal::Message(V(v))) + .await + .expect("send"); + } + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop") + .expect("join"); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + assert_eq!( + *seen.lock().expect("lock"), + (0..N).collect::>(), + "every message handled exactly once, in FIFO order — no loss, no duplication", + ); +} + +// --------------------------------------------------------------------------- +// I7 — no reentrancy: a self-send is enqueued, never re-entered inside a handler. +// --------------------------------------------------------------------------- + +/// The `First` handler marks itself active, self-sends `Second`, then yields +/// (opening a reentrancy window) before clearing the flag. If the loop dispatched +/// `Second` reentrantly inside `First`, the `Second` handler would observe the +/// flag still set. ASSERT `Second` sees the flag clear (no reentrancy) and the +/// handled order is exactly `[First, Second]`. +#[tokio::test] +async fn i7_no_reentrancy_self_send_is_enqueued() { + enum M { + First, + Second, + } + impl Msg for M {} + struct Reentry { + in_handle: Arc, + handled: Arc>>, + } + impl Mailboxed for Reentry { + type Msg = M; + } + impl Actor for Reentry { + type Args = (Arc, Arc>>); + type Error = Infallible; + async fn on_start( + (in_handle, handled): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { in_handle, handled }) + } + async fn handle( + &mut self, + msg: M, + actor_ref: ActorRef, + stop: &mut bool, + ) -> Result<(), Self::Error> { + match msg { + M::First => { + assert!( + !self.in_handle.load(Ordering::SeqCst), + "no handler was active when First began", + ); + self.in_handle.store(true, Ordering::SeqCst); + self.handled.lock().expect("lock").push("First"); + actor_ref + .mailbox_sender() + .send(Signal::Message(M::Second)) + .await + .expect("self-send Second"); + yield_now().await; // a reentrant loop would run Second here + self.in_handle.store(false, Ordering::SeqCst); + } + M::Second => { + assert!( + !self.in_handle.load(Ordering::SeqCst), + "Second did NOT run reentrantly inside First", + ); + self.handled.lock().expect("lock").push("Second"); + *stop = true; + } + } + Ok(()) + } + } + + let in_handle = Arc::new(AtomicBool::new(false)); + let handled = Arc::new(Mutex::new(Vec::new())); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn((Arc::clone(&in_handle), Arc::clone(&handled))); + actor_ref + .mailbox_sender() + .send(Signal::Message(M::First)) + .await + .expect("send First"); + + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop") + .expect("join"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "no in-handler assert tripped (which would surface as Panicked), got {outcome:?}", + ); + assert_eq!( + *handled.lock().expect("lock"), + vec!["First", "Second"], + "self-sent Second was enqueued and handled after First returned", + ); +} + +// --------------------------------------------------------------------------- +// I8 / I10 / I11 — lifecycle ordering, exactly-once hooks, nothing after on_stop. +// --------------------------------------------------------------------------- + +/// One actor whose every lifecycle step appends to a shared log: `on_start` +/// pushes `"start"`, each `handle` pushes `"handle"`, `on_panic` pushes `"panic"`, +/// `on_stop` pushes `"stop"`. `panic_at` makes the handler panic on the Nth +/// message (torn-write-free: it panics before pushing). +struct Life { + count: u32, + panic_at: Option, + log: Arc>>, +} +struct Tick; +impl Msg for Tick {} +impl Mailboxed for Life { + type Msg = Tick; +} +impl Actor for Life { + type Args = (Option, Arc>>); + type Error = Infallible; + async fn on_start((panic_at, log): Self::Args, _: ActorRef) -> Result { + log.lock().expect("lock").push("start"); + Ok(Self { + count: 0, + panic_at, + log, + }) + } + async fn handle( + &mut self, + _: Tick, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.count += 1; + if self.panic_at == Some(self.count) { + panic!("handler boom"); + } + self.log.lock().expect("lock").push("handle"); + Ok(()) + } + async fn on_panic(&mut self, _: WeakActorRef, err: PanicError) -> ActorStopReason { + self.log.lock().expect("lock").push("panic"); + ActorStopReason::Panicked(err) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.log.lock().expect("lock").push("stop"); + Ok(()) + } +} + +/// Normal path: three messages then a graceful stop. ASSERT the log is exactly +/// `["start", "handle", "handle", "handle", "stop"]` — `on_start` first and once, +/// three handles in the middle, `on_stop` last and once (nothing after it). +#[tokio::test] +async fn i8_i10_i11_lifecycle_order_normal() { + let log = Arc::new(Mutex::new(Vec::new())); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn((None, Arc::clone(&log))); + for _ in 0..3 { + actor_ref + .mailbox_sender() + .send(Signal::Message(Tick)) + .await + .expect("send"); + } + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop") + .expect("join"); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + assert_eq!( + *log.lock().expect("lock"), + vec!["start", "handle", "handle", "handle", "stop"], + "start once & first; three handles; stop once & LAST", + ); +} + +/// Panic path: the handler panics on the 2nd message. ASSERT the log is exactly +/// `["start", "handle", "panic", "stop"]` — `on_panic` runs on the panic path, +/// once, before `on_stop`, and `on_stop` still runs and is last. +#[tokio::test] +async fn i8_i10_i11_lifecycle_order_panic() { + let log = Arc::new(Mutex::new(Vec::new())); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn((Some(2), Arc::clone(&log))); + actor_ref + .mailbox_sender() + .send(Signal::Message(Tick)) + .await + .expect("send 1"); + actor_ref + .mailbox_sender() + .send(Signal::Message(Tick)) + .await + .expect("send 2 (panics)"); + + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop") + .expect("join"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Panicked(_), + .. + } + ), + "handler panic → Stopped/Panicked, got {outcome:?}", + ); + assert_eq!( + *log.lock().expect("lock"), + vec!["start", "handle", "panic", "stop"], + "on_panic before on_stop, both once; on_stop last", + ); +} + +// --------------------------------------------------------------------------- +// I9c — on_stop is NOT run on startup failure (and IS run on a normal stop). +// --------------------------------------------------------------------------- + +/// `on_start` returning `Err` yields `StartupFailed` and never runs `on_stop` +/// (no state was built to clean up); a normal stop runs `on_stop` exactly once. +#[tokio::test] +async fn i9c_on_stop_not_run_on_startup_failure() { + struct MaybeStart { + spy: Arc, + } + struct Go; + impl Msg for Go {} + impl Mailboxed for MaybeStart { + type Msg = Go; + } + impl Actor for MaybeStart { + type Args = (bool, Arc); + type Error = Boom; + async fn on_start((fail, spy): Self::Args, _: ActorRef) -> Result { + if fail { Err(Boom) } else { Ok(Self { spy }) } + } + async fn handle( + &mut self, + _: Go, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + self.spy.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + // Startup failure → StartupFailed, on_stop never runs. + let spy = Arc::new(AtomicU32::new(0)); + let outcome = timeout( + TERMINATE, + PreparedActor::::new(cap(4)).run((true, Arc::clone(&spy))), + ) + .await + .expect("startup failure must terminate the run"); + assert!( + matches!(outcome, RunResult::StartupFailed(_)), + "on_start Err → StartupFailed, got {outcome:?}", + ); + assert_eq!( + spy.load(Ordering::SeqCst), + 0, + "on_stop NOT run when on_start failed", + ); + + // Normal stop → on_stop runs exactly once. + let spy2 = Arc::new(AtomicU32::new(0)); + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + let outcome2 = timeout(TERMINATE, prepared.run((false, Arc::clone(&spy2)))) + .await + .expect("normal stop must terminate the run"); + assert!(matches!( + outcome2, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + assert_eq!( + spy2.load(Ordering::SeqCst), + 1, + "on_stop runs exactly once on a normal stop", + ); +} + +// --------------------------------------------------------------------------- +// I12 — alive-window: pre-run buffered message IS handled; post-stop send fails. +// --------------------------------------------------------------------------- + +/// (a) A message enqueued BEFORE `run` starts (into the `PreparedActor`'s mailbox) +/// IS handled after start. (b) After a graceful stop completes, a `send` on a +/// retained sender is rejected with `SendError` (the message handed back), not +/// silently accepted. +#[tokio::test] +async fn i12_alive_window() { + let handled = Arc::new(AtomicU32::new(0)); + let prepared = PreparedActor::::new(cap(8)); + let actor_ref = prepared.actor_ref().clone(); + + // (a) enqueue before the loop starts, plus a Stop to end it. + actor_ref + .mailbox_sender() + .send(Signal::Message(Poke)) + .await + .expect("pre-run send"); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + + let outcome = timeout(TERMINATE, prepared.run(Arc::clone(&handled))) + .await + .expect("the actor must stop"); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + assert_eq!( + handled.load(Ordering::SeqCst), + 1, + "the message buffered before run was handled after start", + ); + + // (b) send-after-stop is rejected with the message handed back. + let resend = actor_ref.mailbox_sender().send(Signal::Message(Poke)).await; + assert!( + matches!(resend, Err(SendError(Signal::Message(Poke)))), + "send after a completed stop is rejected, not silently accepted", + ); +} + +// --------------------------------------------------------------------------- +// I14 — stop-reason fidelity under on_stop failure (+ panic containment, I5/I6). +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum StopMode { + Panic, + Err, +} + +/// Handler panics (or not) and `on_stop` panics / errors / succeeds, per config. +struct Cleanup { + handler_panics: bool, + on_stop_mode: StopMode, +} +struct Do; +impl Msg for Do {} +impl Mailboxed for Cleanup { + type Msg = Do; +} +impl Actor for Cleanup { + type Args = (bool, StopMode); + type Error = Boom; + async fn on_start( + (handler_panics, on_stop_mode): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + handler_panics, + on_stop_mode, + }) + } + async fn handle(&mut self, _: Do, _: ActorRef, _: &mut bool) -> Result<(), Self::Error> { + if self.handler_panics { + panic!("handler boom"); + } + Ok(()) + } + async fn on_stop( + &mut self, + _: WeakActorRef, + _: ActorStopReason, + ) -> Result<(), Self::Error> { + match self.on_stop_mode { + StopMode::Panic => panic!("cleanup boom"), + StopMode::Err => Err(Boom), + } + } +} + +/// (a) A normal stop whose `on_stop` PANICS is `Stopped { reason: Normal }` — the +/// panic is contained (the test completing proves it did not abort the process) +/// AND the reason is preserved, not rewritten to Panicked/OnStop. +#[tokio::test] +async fn i14a_normal_stop_on_stop_panic_preserves_normal() { + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + let outcome = timeout(TERMINATE, prepared.run((false, StopMode::Panic))) + .await + .expect("must terminate"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "on_stop panic contained; Normal preserved, got {outcome:?}", + ); +} + +/// (b) A normal stop whose `on_stop` returns `Err` is still `Stopped { Normal }` — +/// the error is logged, never unwrapped, and the reason is preserved. +#[tokio::test] +async fn i14b_normal_stop_on_stop_err_preserves_normal() { + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + let outcome = timeout(TERMINATE, prepared.run((false, StopMode::Err))) + .await + .expect("must terminate"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "on_stop Err surfaced but reason preserved as Normal, got {outcome:?}", + ); +} + +/// (c) A handler panic (→ Panicked/HandlerPanic) followed by an `on_stop` that +/// ALSO panics is `Stopped { reason: Panicked(pe) }` with `pe.reason() == +/// HandlerPanic` — the ORIGINAL cause survives; the on_stop panic is contained and +/// does not overwrite it with OnStop. +#[tokio::test] +async fn i14c_handler_panic_then_on_stop_panic_preserves_original_cause() { + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + actor_ref + .mailbox_sender() + .send(Signal::Message(Do)) + .await + .expect("send"); + let outcome = timeout(TERMINATE, prepared.run((true, StopMode::Panic))) + .await + .expect("must terminate"); + let RunResult::Stopped { + reason: ActorStopReason::Panicked(pe), + .. + } = outcome + else { + panic!("expected Stopped/Panicked, got {outcome:?}"); + }; + assert_eq!( + pe.reason(), + PanicReason::HandlerPanic, + "the original handler-panic cause is preserved, not overwritten by OnStop", + ); +} + +// --------------------------------------------------------------------------- +// I15 — fault isolation: one actor's crash does not affect another or the runtime. +// --------------------------------------------------------------------------- + +/// Two actors run on the same runtime. A's handler panics; B then handles a +/// message normally and stops. ASSERT A's outcome is Panicked(HandlerPanic), B +/// handled its message and stopped Normal — the crash is contained to A. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn i15_fault_isolation() { + struct Faulty; + struct Crash; + impl Msg for Crash {} + impl Mailboxed for Faulty { + type Msg = Crash; + } + impl Actor for Faulty { + type Args = (); + type Error = Infallible; + async fn on_start(_: (), _: ActorRef) -> Result { + Ok(Faulty) + } + async fn handle( + &mut self, + _: Crash, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + panic!("actor A handler boom"); + } + } + + struct Healthy { + handled: Arc, + } + struct Work; + impl Msg for Work {} + impl Mailboxed for Healthy { + type Msg = Work; + } + impl Actor for Healthy { + type Args = Arc; + type Error = Infallible; + async fn on_start(handled: Self::Args, _: ActorRef) -> Result { + Ok(Self { handled }) + } + async fn handle( + &mut self, + _: Work, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + self.handled.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let a_prepared = PreparedActor::::new(cap(4)); + let a_ref = a_prepared.actor_ref().clone(); + let a_run = a_prepared.spawn(()); + + let b_spy = Arc::new(AtomicU32::new(0)); + let b_prepared = PreparedActor::::new(cap(4)); + let b_ref = b_prepared.actor_ref().clone(); + let b_run = b_prepared.spawn(Arc::clone(&b_spy)); + + // A crashes. + a_ref + .mailbox_sender() + .send(Signal::Message(Crash)) + .await + .expect("send crash to A"); + // B keeps working after A's crash. + b_ref + .mailbox_sender() + .send(Signal::Message(Work)) + .await + .expect("send work to B"); + b_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop B"); + + let a_out = timeout(TERMINATE, a_run) + .await + .expect("A must terminate") + .expect("A join"); + let b_out = timeout(TERMINATE, b_run) + .await + .expect("B must terminate") + .expect("B join"); + + let RunResult::Stopped { + reason: ActorStopReason::Panicked(pe), + .. + } = a_out + else { + panic!("A should have panicked, got {a_out:?}"); + }; + assert_eq!( + pe.reason(), + PanicReason::HandlerPanic, + "A crashed via a handler panic", + ); + assert!( + matches!( + b_out, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "B is unaffected by A's crash and stops Normal, got {b_out:?}", + ); + assert_eq!( + b_spy.load(Ordering::SeqCst), + 1, + "B still handled its message despite A crashing", + ); +} + +// --------------------------------------------------------------------------- +// I17 — every actor gets a distinct id. +// --------------------------------------------------------------------------- + +/// Build 100 `PreparedActor`s and collect their ids. ASSERT all 100 are pairwise +/// distinct. (`ActorId` is `Eq` but not `Hash`, so distinctness is asserted by +/// pairwise `assert_ne!` rather than a `HashSet`.) +#[test] +fn i17_distinct_ids() { + let ids: Vec<_> = (0..100) + .map(|_| PreparedActor::::new(cap(1)).actor_ref().id()) + .collect(); + assert_eq!(ids.len(), 100, "built 100 actors"); + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!(ids[i], ids[j], "actor ids at {i} and {j} must be distinct"); + } + } +} + +// --------------------------------------------------------------------------- +// I19 — ref-send liveness + weak-ref liveness after termination. +// --------------------------------------------------------------------------- + +/// After a normal stop: a `send` on a retained strong sender fails (the receiver +/// is gone, message handed back), and — once the last strong sender is dropped — +/// a retained `WeakActorRef::upgrade()` returns `None`. (The weak handle tracks +/// strong senders, so the strong ref is dropped between the two checks.) +#[tokio::test] +async fn i19_send_and_weak_upgrade_after_termination() { + let handled = Arc::new(AtomicU32::new(0)); + let prepared = PreparedActor::::new(cap(4)); + let actor_ref = prepared.actor_ref().clone(); + let weak = actor_ref.downgrade(); + actor_ref + .mailbox_sender() + .send(Signal::Stop) + .await + .expect("stop"); + + let outcome = timeout(TERMINATE, prepared.run(Arc::clone(&handled))) + .await + .expect("the actor must stop"); + assert!(matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + )); + + // Ref-send liveness: the receiver is gone, so send fails with the message back. + let resend = actor_ref.mailbox_sender().send(Signal::Message(Poke)).await; + assert!( + matches!(resend, Err(SendError(Signal::Message(Poke)))), + "send to a terminated actor fails with the message handed back", + ); + + // Weak-ref liveness: drop the last strong sender, then upgrade must be None. + drop(actor_ref); + assert!( + weak.upgrade().is_none(), + "no strong sender remains → weak upgrade yields None", + ); +} + +// --------------------------------------------------------------------------- +// I20 / I21 — backpressure rejects (message returned) + capacity freed by drain. +// --------------------------------------------------------------------------- + +/// A capacity-1 mailbox with a handler that blocks on the first message. With the +/// slot then filled, a further `try_send` is rejected with `Full` and the message +/// handed back (no loss). Releasing the handler lets the loop drain the queued +/// message, freeing the slot; the SAME handed-back message then `try_send`s +/// successfully — backpressure is transient, capacity is freed by draining. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn i20_i21_backpressure_and_capacity_freed_by_draining() { + enum Cmd { + Block, + Drain, + } + impl Msg for Cmd {} + struct Gate { + entered: Option>, + release: Option>, + drained: Option>, + } + impl Mailboxed for Gate { + type Msg = Cmd; + } + impl Actor for Gate { + type Args = ( + oneshot::Sender<()>, + oneshot::Receiver<()>, + oneshot::Sender<()>, + ); + type Error = Infallible; + async fn on_start( + (entered, release, drained): Self::Args, + _: ActorRef, + ) -> Result { + Ok(Self { + entered: Some(entered), + release: Some(release), + drained: Some(drained), + }) + } + async fn handle( + &mut self, + msg: Cmd, + _: ActorRef, + _: &mut bool, + ) -> Result<(), Self::Error> { + match msg { + Cmd::Block => { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + if let Some(release) = self.release.take() { + let _ = release.await; + } + } + Cmd::Drain => { + // Fires as the queued message is dequeued for handling — i.e. + // once the slot it occupied has been freed. + if let Some(drained) = self.drained.take() { + let _ = drained.send(()); + } + } + } + Ok(()) + } + } + + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let (drained_tx, drained_rx) = oneshot::channel(); + + let prepared = PreparedActor::::new(cap(1)); + let actor_ref = prepared.actor_ref().clone(); + let run = prepared.spawn((entered_tx, release_rx, drained_tx)); + + // (1) The Block message enters the handler and parks; its slot is now free. + actor_ref + .mailbox_sender() + .send(Signal::Message(Cmd::Block)) + .await + .expect("send Block"); + entered_rx.await.expect("handler entered and parked"); + + // (2) Fill the single slot. + actor_ref + .mailbox_sender() + .try_send(Signal::Message(Cmd::Drain)) + .expect("the one free slot accepts Drain"); + + // (3) The mailbox is full: try_send is rejected and hands the message back. + let rejected = actor_ref + .mailbox_sender() + .try_send(Signal::Message(Cmd::Drain)); + let Err(TrySendError::Full(returned)) = rejected else { + panic!("expected Full rejection, got {rejected:?}"); + }; + + // (4) Release the handler; the loop drains the queued Drain, freeing the slot. + release_tx.send(()).expect("release the parked handler"); + drained_rx + .await + .expect("queued Drain dequeued (slot freed)"); + + // (5) The SAME handed-back message now fits — capacity was freed by draining. + actor_ref + .mailbox_sender() + .try_send(returned) + .expect("capacity freed by the loop draining; the returned message fits"); + + // Clean up: stop the actor and confirm a normal termination. + actor_ref.stop(); + let outcome = timeout(TERMINATE, run) + .await + .expect("the actor must stop") + .expect("join"); + assert!( + matches!( + outcome, + RunResult::Stopped { + reason: ActorStopReason::Normal, + .. + } + ), + "clean normal stop, got {outcome:?}", + ); +}