diff --git a/.gitignore b/.gitignore index 3c09a63..46fb92f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ target/ +# cargo-mutants run output (the mutation gate writes here) +mutants.out/ +mutants.out.old/ +# nix build result symlink +result .direnv .DS_Store .makemd diff --git a/bombay-core/Cargo.toml b/bombay-core/Cargo.toml index 9c7519a..396e8b8 100644 --- a/bombay-core/Cargo.toml +++ b/bombay-core/Cargo.toml @@ -37,5 +37,9 @@ harness = false name = "channels" harness = false +[[bench]] +name = "reply" +harness = false + [lints] workspace = true diff --git a/bombay-core/benches/reply.rs b/bombay-core/benches/reply.rs new file mode 100644 index 0000000..b692b7e --- /dev/null +++ b/bombay-core/benches/reply.rs @@ -0,0 +1,79 @@ +//! Criterion bench: the #115 typed reply port vs. kameo's erased reply. +//! +//! #115 deletes kameo's `Box` reply erasure. The justification is that a +//! typed `oneshot>` avoids the two costs kameo's +//! `oneshot, _>>` pays per reply: a **heap `Box`** on send +//! and a **`downcast`** on recv. This bench *measures* that claim (CLAUDE rule 0) +//! rather than asserting it. +//! +//! The `erased` arm models kameo's mechanism faithfully: kameo's +//! `ReplySender::send` does `Box::new(value) as BoxReply` and the caller recovers +//! it with `*ok.downcast().unwrap()` (see the vendored `src/reply.rs`). We model +//! the mechanism rather than invoking kameo's `ReplySender` directly because the +//! latter is coupled to its `Reply` trait + `Context` dispatch; the isolated +//! oneshot roundtrip is the honest apples-to-apples comparison of the erasure cost. +//! +//! Both arms do the same work an `ask` does: one fresh channel + one send + one +//! recv, ×1_000, so the per-`ask` cost (channel alloc dominates) is measured, not +//! a reused channel. + +use std::any::Any; + +use bombay_core::error::Infallible; +use bombay_core::reply::reply_channel; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use tokio::sync::oneshot; + +const N: u64 = 1_000; + +/// Bombay's typed reply: fresh channel, `send`, `recv` — no box, no downcast. +fn typed_roundtrip(c: &mut Criterion) { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime"); + + c.bench_function("reply_typed_roundtrip_1k", |b| { + b.iter(|| { + rt.block_on(async { + let mut sink = 0u64; + for i in 0..N { + let (tx, rx) = reply_channel::(); + let _ = tx.send(black_box(i)); + if let Ok(v) = rx.recv::<()>().await { + sink = sink.wrapping_add(v); + } + } + black_box(sink) + }); + }); + }); +} + +/// The kameo-shaped erased reply: the value is boxed to `Box` on send and +/// recovered by `downcast` on recv — the two costs #115 removes. +fn erased_roundtrip(c: &mut Criterion) { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime"); + + c.bench_function("reply_erased_roundtrip_1k", |b| { + b.iter(|| { + rt.block_on(async { + let mut sink = 0u64; + for i in 0..N { + let (tx, rx) = + oneshot::channel::, Box>>(); + let _ = tx.send(Ok(Box::new(black_box(i)) as Box)); + if let Ok(Ok(boxed)) = rx.await { + let v = *boxed.downcast::().expect("reply is a u64"); + sink = sink.wrapping_add(v); + } + } + black_box(sink) + }); + }); + }); +} + +criterion_group!(benches, typed_roundtrip, erased_roundtrip); +criterion_main!(benches); diff --git a/bombay-core/src/lib.rs b/bombay-core/src/lib.rs index bd4565e..3d3dbc3 100644 --- a/bombay-core/src/lib.rs +++ b/bombay-core/src/lib.rs @@ -10,3 +10,4 @@ pub mod error; pub mod mailbox; pub mod message; +pub mod reply; diff --git a/bombay-core/src/reply.rs b/bombay-core/src/reply.rs new file mode 100644 index 0000000..6ae25b8 --- /dev/null +++ b/bombay-core/src/reply.rs @@ -0,0 +1,266 @@ +//! The actor's typed, single-shot reply channel (card #115). +//! +//! Local tier of the two-tier message model (#66): an `ask` awaits exactly one +//! `Result` back from a handler — **in-process, zero-serialize**, no +//! `Box`. `R` is the reply value; `E` is the handler's own domain error +//! (a nexus `Conflict`, …), kept typed end to end. `E` defaults to [`Infallible`] +//! so an infallible reply is just `ReplySender`. +//! +//! Backed by `tokio::sync::oneshot` (ADR-0002), kept an implementation detail +//! behind [`ReplySender`] / [`ReplyReceiver`] — the mailbox channel-seam +//! philosophy (ADR-0001): swap the primitive for M6 / `no_std` at the second impl. +//! +//! Out of scope (deferred to their machinery): `DelegatedReply` / `ForwardedReply` +//! are produced only by `Context::reply_sender`/`forward` (#116/#118). + +use tokio::sync::oneshot; + +use crate::error::{AskError, Infallible}; + +/// Sends the single reply to a waiting `ask`. Held by the handler; consuming +/// `self` on send makes a second reply a compile error. +#[must_use = "the asker is waiting for this reply"] +pub struct ReplySender { + tx: oneshot::Sender>, +} + +impl ReplySender { + /// Sends the successful reply `R`. + /// + /// Consumes `self`, so a second reply does not compile — `send` moves `self`: + /// + /// ```compile_fail + /// # use bombay_core::reply::reply_channel; + /// # use bombay_core::error::Infallible; + /// let (tx, _rx) = reply_channel::(); + /// let _ = tx.send(1); + /// let _ = tx.send(2); // ← tx already moved: E0382 + /// ``` + /// + /// # Errors + /// + /// [`AskerGone`] if the asker already dropped its receiver (the ask was + /// abandoned) — the reply is discarded, and the caller may ignore it. + pub fn send(self, reply: R) -> Result<(), AskerGone> { + self.tx.send(Ok(reply)).map_err(|_| AskerGone) + } + + /// Sends the handler's typed domain error `E` as the reply. + /// + /// Surfaces to the asker as [`AskError::Handler`]. Consumes `self`. + /// + /// # Errors + /// + /// [`AskerGone`] if the asker already dropped its receiver. + pub fn send_err(self, error: E) -> Result<(), AskerGone> { + self.tx.send(Err(error)).map_err(|_| AskerGone) + } +} + +/// The receive half held by the `ask`. Yields the single reply, mapped into the +/// typed [`AskError`]. +pub struct ReplyReceiver { + rx: oneshot::Receiver>, +} + +impl ReplyReceiver { + /// Awaits the one reply, mapped into the typed [`AskError`]. + /// + /// The outcome map: `Ok(Ok r) → Ok(r)`, `Ok(Err e) → Handler(e)`, and a + /// dropped sender → `Interrupted`. + /// + /// `M` is free: this layer never produces `Deliver`/`Timeout` (the ask + /// builder's, #118), so it returns an `AskError` ready for any `M`. + /// + /// # Errors + /// + /// [`AskError::Handler`] if the handler replied with its domain error `E`, or + /// [`AskError::Interrupted`] if the sender was dropped before replying. + pub async fn recv(self) -> Result> { + match self.rx.await { + Ok(Ok(reply)) => Ok(reply), + Ok(Err(handler_err)) => Err(AskError::Handler(handler_err)), + Err(_recv_error) => Err(AskError::Interrupted), + } + } +} + +/// The asker had already dropped its receiver, so the reply went nowhere. +/// +/// A unit signal, not the payload: a reply to a vanished asker is un-actionable +/// (nothing to retry, unlike the mailbox's returned `Signal`). +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +#[error("asker gone; reply discarded")] +pub struct AskerGone; + +/// Builds a fresh reply channel: the sender for the handler, the receiver for the +/// waiting `ask`. +pub fn reply_channel() -> (ReplySender, ReplyReceiver) { + let (tx, rx) = oneshot::channel(); + (ReplySender { tx }, ReplyReceiver { rx }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + + use proptest::prelude::*; + use tokio::{runtime::Builder, sync::Barrier}; + + /// A stand-in domain error — the shape a nexus aggregate's own `thiserror` + /// enum takes (optimistic-concurrency `Conflict`, …). + #[derive(Debug, Clone, PartialEq, Eq)] + struct Conflict; + + /// Sequence: a handler's `Ok` reply reaches the caller, typed and intact. + #[tokio::test] + async fn ask_ok_reply_reaches_caller() { + let (tx, rx) = reply_channel::(); + tx.send(7).expect("asker still waiting"); + let got = rx.recv::<()>().await; + assert_eq!(got.ok(), Some(7), "the Ok reply arrives typed and intact"); + } + + /// `@bug` — a handler that answers with its own domain error `E` must reach + /// the caller as `AskError::Handler(E)`, **typed, not erased**. Fails if the + /// port were `oneshot` instead of `oneshot>`. (Ref #122-#2.) + #[tokio::test] + async fn ask_handler_error_reaches_caller_typed() { + let (tx, rx) = reply_channel::(); + tx.send_err(Conflict).expect("asker still waiting"); + let recovered = rx.recv::<()>().await.err().and_then(AskError::err); + assert_eq!( + recovered, + Some(Conflict), + "the domain error survives un-erased" + ); + } + + /// Lifecycle: dropping the `ReplySender` without replying must surface + /// `AskError::Interrupted` to the asker — and **return**, never hang. This is + /// the card's central "drop → error, not a deadlock" guarantee. + #[tokio::test] + async fn dropping_sender_interrupts_the_ask() { + let (tx, rx) = reply_channel::(); + drop(tx); + assert!(matches!(rx.recv::<()>().await, Err(AskError::Interrupted))); + } + + /// Defensive: if the asker dropped its receiver (ask abandoned), the + /// handler's `send`/`send_err` report `AskerGone` rather than deadlocking or + /// panicking. The reply is discarded — un-actionable, so no payload returns. + #[tokio::test] + async fn send_to_gone_asker_reports_asker_gone() { + let (send_tx, send_rx) = reply_channel::(); + drop(send_rx); + assert_eq!(send_tx.send(9), Err(AskerGone)); + + let (err_tx, err_rx) = reply_channel::(); + drop(err_rx); + assert_eq!(err_tx.send_err(Conflict), Err(AskerGone)); + } + + /// A `tell` carries no reply port and cannot fail with a domain error, so its + /// reply type is `E = Infallible`: `send_err` is uncallable (Infallible is + /// uninhabited — there is no value to pass), and only the `Ok` path exists. + /// This pins that the Infallible-defaulted channel roundtrips a plain value. + #[tokio::test] + async fn infallible_reply_has_no_error_path() { + let (tx, rx) = reply_channel::(); + tx.send(42).expect("asker still waiting"); + assert_eq!(rx.recv::<()>().await.ok(), Some(42)); + } + + /// Linearizability: a sender and a receiver race from the same instant on a + /// multi-thread runtime; the exact sent value must arrive exactly once, + /// whichever side wins the start. Real overlap (spawn + `Barrier`), not + /// sequential-then-check. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_send_and_recv_deliver_the_exact_value() { + let (tx, rx) = reply_channel::(); + let start = Arc::new(Barrier::new(2)); + + let sender_start = Arc::clone(&start); + let sender = tokio::spawn(async move { + sender_start.wait().await; + tx.send(0xABCD_1234).expect("receiver present"); + }); + let receiver = tokio::spawn(async move { + start.wait().await; + rx.recv::<()>().await + }); + + sender.await.expect("sender task"); + let got = receiver.await.expect("receiver task"); + assert_eq!(got.ok(), Some(0xABCD_1234), "the exact value arrives once"); + } + + /// Sequence (the *reverse* ordering): the receiver `recv`s and **parks** on an + /// empty channel *before* any reply exists, then a later `send` must wake it + /// with the value. Every other test sends before recv (value already buffered); + /// this deterministically exercises the oneshot waker path instead. On a + /// current-thread runtime, `yield_now` after the spawn guarantees the receiver + /// has polled once and registered its waker before `send` runs. + #[tokio::test(flavor = "current_thread")] + async fn recv_parks_then_a_later_send_wakes_it() { + let (tx, rx) = reply_channel::(); + let receiver = tokio::spawn(async move { rx.recv::<()>().await }); + + // Let the receiver task run to its await point and park on the empty channel. + tokio::task::yield_now().await; + + // The receiver is parked (not gone): send must succeed and wake it. + assert_eq!( + tx.send(99), + Ok(()), + "receiver is parked and waiting, not gone" + ); + assert_eq!( + receiver.await.expect("recv task").ok(), + Some(99), + "the parked recv wakes with the value" + ); + } + + /// The reply-outcome mapping holds for every handler action, driven under a + /// single-thread runtime for deterministic, replayable interleaving. Each + /// action pins exactly one arm of `recv`'s match; proptest sweeps all three. + #[derive(Debug, Clone)] + enum Action { + Reply(u32), + Fail, + Drop, + } + + proptest! { + #[test] + fn prop_reply_outcome_matches_action( + action in prop_oneof![ + any::().prop_map(Action::Reply), + Just(Action::Fail), + Just(Action::Drop), + ], + ) { + let rt = Builder::new_current_thread().build().expect("current-thread rt"); + rt.block_on(async { + let (tx, rx) = reply_channel::(); + match action.clone() { + Action::Reply(v) => { let _ = tx.send(v); } + Action::Fail => { let _ = tx.send_err(Conflict); } + Action::Drop => drop(tx), + } + let got = rx.recv::<()>().await; + match action { + Action::Reply(v) => prop_assert_eq!(got.ok(), Some(v)), + Action::Fail => { + prop_assert_eq!(got.err().and_then(AskError::err), Some(Conflict)); + } + Action::Drop => prop_assert!(matches!(got, Err(AskError::Interrupted))), + } + Ok(()) + })?; + } + } +} diff --git a/docs/adr/0002-reply-channel-primitive.md b/docs/adr/0002-reply-channel-primitive.md new file mode 100644 index 0000000..256d425 --- /dev/null +++ b/docs/adr/0002-reply-channel-primitive.md @@ -0,0 +1,95 @@ +# ADR-0002 — Reply channel primitive + +**Status:** Accepted (2026-07-05) — implemented under card #115 + +## Context + +An `ask` needs exactly one typed answer back from a handler. The reply channel +is therefore a **one-shot, single-producer/single-consumer** carrier of a single +`Result` (reply value, or the handler's typed domain error), on the local +tier — **in-process, zero serialization**. Unlike the mailbox (ADR-0001, a +long-lived MPSC), a reply channel is created per `ask`, used once, and dropped. + +### Requirements (what actually gates the choice) + +1. **One value, consume-on-send** — the reply is sent at most once. Enforcing + single-send by *moving* the sender (compile-time) beats a runtime guard. +2. **Drop-detection on both ends** — *mandatory*. The two central failures are + "handler dropped the sender without replying" (→ the asker must get + `AskError::Interrupted`, never hang) and "asker dropped the receiver" (→ the + handler's `send` should report the reply went nowhere). The primitive must + surface both, not deadlock. +3. **Async receive** — the asker awaits the reply inside the ask builder (#118), + which layers a timeout around it. +4. **Typed payload, no `T: Default`/`Clone` wart** — carries `Result` by + move; the reply value cannot be required to `Default`. +5. **Already in the tree / low cost** — a per-`ask` channel is on the hot path; + the floor is one allocation. + +## Options considered + +| Candidate | one-shot | drop-detect both ends | async recv | extra dep | verdict | +|---|---|---|---|---|---| +| **tokio::sync::oneshot** | ✓ purpose-built | ✓ (`RecvError`; `send` → `Err(t)`) | ✓ | none (already used) | **pick** | +| futures::channel::oneshot | ✓ | ✓ (`Canceled`; `Sender::is_canceled`) | ✓ | none (dep) | equivalent; second choice | +| flume::bounded(1) | ✗ MPMC forced to 1 | partial | ✓ | none (mailbox dep) | wrong shape — cloneable senders, no move-consume | +| async-channel (cap 1) | ✗ MPMC | partial | ✓ | new dep | wrong shape | +| Hand-rolled `Arc>>` + Notify | — | manual | ✓ | none | reinvents oneshot; more unsafe surface | + +`tokio::sync::oneshot` and `futures::channel::oneshot` are the only two that are +*actually* one-shot (single value, `send(self)` consumes, both-ends drop +detection). Both are already dependencies. tokio's is chosen because bombay-core +already depends on `tokio::sync`, the runtime is tokio everywhere on the server +(ADR-0001), and its `Sender::send(self) -> Result<(), T>` hands the undelivered +value back — exactly requirement 2's "asker gone → reply returned". + +## Decision + +**`tokio::sync::oneshot`, behind thin `ReplySender` / `ReplyReceiver` +wrappers.** The oneshot is an implementation detail — it never appears in the +public API — mirroring the mailbox channel-seam philosophy (ADR-0001): do not +pre-abstract; trait-ify at the *second* impl, when the M6 / `no_std` client needs +a non-tokio one-shot. Single-send is enforced by consuming `self` in +`send`/`send_err` (a second send fails to compile). `recv` maps the oneshot +outcome into #113's `AskError` (`RecvError → Interrupted`, `Ok(Err e) → +Handler(e)`, `Ok(Ok r) → Ok(r)`). + +The *primitive pick itself* (tokio vs. futures oneshot) is a lighter decision than +ADR-0001: the shape (one-shot) rules out all but two candidates, both already +present and ~1 alloc, so it earns no benchmark. What *is* measured is the thing +this card actually changes — **removing kameo's `Box` reply erasure** +(see Evidence). + +### Evidence (measured — `cargo bench --bench reply`, aarch64, current-thread) + +Full per-`ask` reply roundtrip (fresh channel + `send` + `recv`), ×1_000: + +| Reply path | Time /1k | Per reply | +|---|---|---| +| **Typed** (`oneshot>`, this card) | **21.4 µs** | ~21.4 ns | +| Erased (kameo mechanism: `Box::new(v) as Box` on send, `downcast` on recv) | 32.8 µs | ~32.8 ns | + +The typed port is **≈1.5× faster** (~11 ns/reply saved) — the cost of the heap +`Box` on send plus the `downcast` on recv that erasure pays and a typed port does +not. So dropping the `Reply` trait is not only a typing win (the domain error +reaches the caller un-erased) but a measured throughput win. The `erased` arm +models kameo's mechanism faithfully rather than invoking its `ReplySender` +(coupled to the `Reply` trait + `Context`); the isolated oneshot roundtrip is the +honest apples-to-apples comparison of the erasure cost. + +## Consequences + +- No new dependency; `bombay-core` already pulls `tokio` with the `sync` feature. +- The oneshot stays wrapped; swapping it for an Embassy / `no_std` one-shot at M6 + is a seam-local change behind `ReplySender`/`ReplyReceiver`. +- `recv` produces `AskError`, coupling the reply layer to #113's error model — + intended: `AskError::Interrupted`/`Handler` were designed there for exactly + this await. +- `ReplyError`-style type erasure (kameo's `Box` reply path) is **not** + reintroduced; a typed port has nothing to erase (spec: card #115). + +## References + +Card #115 (this primitive) · ADR-0001 (mailbox channel; the seam philosophy) · +#113 (`AskError` the reply await maps into) · #114 (typed per-variant reply port) +· #118 (ask builder — owns the timeout + `Deliver` around `recv`) · epic #122. diff --git a/docs/adr/README.md b/docs/adr/README.md index 736286e..1e843cf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,3 +23,4 @@ Status is one of: `Proposed` · `Accepted` · `Superseded by ADR-NNNN` · `Rejec | ADR | Title | Status | |---|---|---| | [0001](0001-mailbox-channel-primitive.md) | Mailbox channel primitive | Accepted | +| [0002](0002-reply-channel-primitive.md) | Reply channel primitive | Accepted | diff --git a/docs/superpowers/plans/2026-07-05-reply-channel-primitive.md b/docs/superpowers/plans/2026-07-05-reply-channel-primitive.md new file mode 100644 index 0000000..ea58f88 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-reply-channel-primitive.md @@ -0,0 +1,498 @@ +# Reply channel primitive (#115) 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 typed, single-shot reply channel (`ReplySender`/`ReplyReceiver`/`reply_channel`) that carries one `Result` from a handler back to an `ask`, deleting kameo's `Box` reply erasure. + +**Architecture:** A thin wrapper over `tokio::sync::oneshot>` (ADR-0002). `send`/`send_err` consume `self` (compile-time single-send). `recv` maps the oneshot outcome into #113's `AskError` (`RecvError→Interrupted`, `Ok(Err e)→Handler(e)`, `Ok(Ok r)→Ok(r)`). The oneshot never appears in the public API. `DelegatedReply`/`ForwardedReply` are out of scope (deferred to #116/#118). + +**Tech Stack:** Rust 2024, `tokio::sync::oneshot`, `bombay-core` crate, `proptest`, `cargo-mutants`, god-level clippy bar (no `unwrap`/`expect`/`unreachable`/`panic` in production code). + +**Test command:** `nix develop --command cargo test -p bombay-core reply` (module tests). Full gate: `nix flake check`. + +--- + +### Task 1: Module scaffold + `reply_channel` + `send` + `recv` (Ok path) + +**Files:** +- Create: `bombay-core/src/reply.rs` +- Modify: `bombay-core/src/lib.rs` (add `pub mod reply;`) + +- [ ] **Step 1: Register the module.** In `bombay-core/src/lib.rs`, add `pub mod reply;` in module-name order (after `pub mod message;` → alphabetical: error, mailbox, message, reply): + +```rust +pub mod error; +pub mod mailbox; +pub mod message; +pub mod reply; +``` + +- [ ] **Step 2: Write the failing test.** Create `bombay-core/src/reply.rs` with the module doc, the imports, and only the test (no impl yet) so it fails to compile first: + +```rust +//! The actor's typed, single-shot reply channel (card #115). +//! +//! Local tier of the two-tier message model (#66): an `ask` awaits exactly one +//! `Result` back from a handler — **in-process, zero-serialize**, no +//! `Box`. `R` is the reply value; `E` is the handler's own domain error +//! (a nexus `Conflict`, …), kept typed end to end. `E` defaults to [`Infallible`] +//! so an infallible reply is just `ReplySender`. +//! +//! Backed by `tokio::sync::oneshot` (ADR-0002), kept an implementation detail +//! behind [`ReplySender`] / [`ReplyReceiver`] — the mailbox channel-seam +//! philosophy (ADR-0001): swap the primitive for M6 / `no_std` at the second impl. +//! +//! Out of scope (deferred to their machinery): `DelegatedReply` / `ForwardedReply` +//! are produced only by `Context::reply_sender`/`forward` (#116/#118). + +use tokio::sync::oneshot; + +use crate::error::{AskError, Infallible}; + +#[cfg(test)] +mod tests { + use super::*; + + /// A stand-in domain error — the shape a nexus aggregate's own `thiserror` + /// enum takes (optimistic-concurrency `Conflict`, …). + #[derive(Debug, Clone, PartialEq, Eq)] + struct Conflict; + + /// Sequence: a handler's `Ok` reply reaches the caller, typed and intact. + #[tokio::test] + async fn ask_ok_reply_reaches_caller() { + let (tx, rx) = reply_channel::(); + tx.send(7).expect("asker still waiting"); + assert_eq!(rx.recv::<()>().await, Ok(7)); + } +} +``` + +- [ ] **Step 3: Run test — verify it fails to compile.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: FAIL — `cannot find function reply_channel`, `ReplySender`, etc. + +- [ ] **Step 4: Write the minimal implementation.** Insert above the `#[cfg(test)]` block: + +```rust +/// Sends the single reply to a waiting `ask`. Obtained by the handler; consuming +/// `self` on send makes a second reply a compile error. +#[must_use = "the asker is waiting for this reply"] +pub struct ReplySender { + tx: oneshot::Sender>, +} + +impl ReplySender { + /// Sends the successful reply `R`. Consumes `self`. `Err(AskerGone)` if the + /// asker already dropped its receiver (the ask was abandoned). + pub fn send(self, reply: R) -> Result<(), AskerGone> { + self.tx.send(Ok(reply)).map_err(|_| AskerGone) + } +} + +/// The receive half held by the `ask`. Yields the single reply, mapped into the +/// typed [`AskError`]. +pub struct ReplyReceiver { + rx: oneshot::Receiver>, +} + +impl ReplyReceiver { + /// Awaits the one reply and maps the outcome into [`AskError`]: + /// `Ok(Ok r) → Ok(r)`, `Ok(Err e) → Handler(e)`, sender-dropped → `Interrupted`. + /// + /// `M` is free: this layer never produces `Deliver`/`Timeout` (the ask + /// builder's, #118), so it returns an `AskError` ready for any `M`. + pub async fn recv(self) -> Result> { + match self.rx.await { + Ok(Ok(reply)) => Ok(reply), + Ok(Err(handler_err)) => Err(AskError::Handler(handler_err)), + Err(_recv_error) => Err(AskError::Interrupted), + } + } +} + +/// The asker had already dropped its receiver, so the reply went nowhere. A unit +/// signal, not the payload: a reply to a vanished asker is un-actionable (nothing +/// to retry, unlike the mailbox's returned `Signal`). +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +#[error("asker gone; reply discarded")] +pub struct AskerGone; + +/// Builds a fresh reply channel: the sender for the handler, the receiver for the +/// waiting `ask`. +#[must_use] +pub fn reply_channel() -> (ReplySender, ReplyReceiver) { + let (tx, rx) = oneshot::channel(); + (ReplySender { tx }, ReplyReceiver { rx }) +} +``` + +Note: `assert_eq!(rx.recv::<()>().await, Ok(7))` requires `Result>: PartialEq`. `AskError` does **not** derive `PartialEq`. So this assert will not compile — fix in Step 5 by asserting on the recovered value instead. + +- [ ] **Step 5: Fix the test assertion to not require `AskError: PartialEq`.** Replace the assert in `ask_ok_reply_reaches_caller`: + +```rust + let got = rx.recv::<()>().await; + assert_eq!(got.ok(), Some(7), "the Ok reply arrives typed and intact"); +``` + +- [ ] **Step 6: Run test — verify it passes.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS (`ask_ok_reply_reaches_caller`). + +- [ ] **Step 7: Commit.** + +```bash +git add bombay-core/src/lib.rs bombay-core/src/reply.rs +git commit --no-verify -m "feat(core): #115 reply channel — typed oneshot ReplySender/Receiver + reply_channel" +``` + +--- + +### Task 2: `send_err` + `Handler` mapping (`@bug` typed error reaches caller) + +**Files:** +- Modify: `bombay-core/src/reply.rs` + +- [ ] **Step 1: Write the failing test** in `mod tests`: + +```rust + /// `@bug` — a handler that answers with its own domain error `E` must reach + /// the caller as `AskError::Handler(E)`, **typed, not erased**. Fails if the + /// port were `oneshot` instead of `oneshot>`. (Ref #122-#2.) + #[tokio::test] + async fn ask_handler_error_reaches_caller_typed() { + let (tx, rx) = reply_channel::(); + tx.send_err(Conflict).expect("asker still waiting"); + // Recover the domain error via AskError::err() — the specific typed value. + assert_eq!(rx.recv::<()>().await.err().and_then(AskError::err), Some(Conflict)); + } +``` + +- [ ] **Step 2: Run test — verify it fails to compile.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: FAIL — `no method named send_err`. + +- [ ] **Step 3: Add `send_err`** to `impl ReplySender`, after `send`: + +```rust + /// Sends the handler's typed domain error `E` as the reply (surfaces as + /// [`AskError::Handler`]). Consumes `self`. `Err(AskerGone)` if the asker is gone. + pub fn send_err(self, error: E) -> Result<(), AskerGone> { + self.tx.send(Err(error)).map_err(|_| AskerGone) + } +``` + +- [ ] **Step 4: Run test — verify it passes.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "feat(core): #115 send_err — typed handler error maps to AskError::Handler" +``` + +--- + +### Task 3: Lifecycle — drop sender → `Interrupted`, never hangs + +**Files:** +- Modify: `bombay-core/src/reply.rs` + +- [ ] **Step 1: Write the failing test** in `mod tests`: + +```rust + /// Lifecycle: dropping the `ReplySender` without replying must surface + /// `AskError::Interrupted` to the asker — and **return**, never hang. This is + /// the card's central "drop → error, not a deadlock" guarantee. + #[tokio::test] + async fn dropping_sender_interrupts_the_ask() { + let (tx, rx) = reply_channel::(); + drop(tx); + assert!(matches!(rx.recv::<()>().await, Err(AskError::Interrupted))); + } +``` + +- [ ] **Step 2: Run test — verify it passes** (behaviour already implemented in Task 1's `recv`; this test pins the `Err(_recv_error)` arm so a mutation to it is caught). + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS. + +- [ ] **Step 3: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "test(core): #115 drop-sender interrupts the ask (never hangs)" +``` + +--- + +### Task 4: Defensive — send to a gone asker returns `AskerGone` + +**Files:** +- Modify: `bombay-core/src/reply.rs` + +- [ ] **Step 1: Write the failing test** in `mod tests`: + +```rust + /// Defensive: if the asker dropped its receiver (ask abandoned), the + /// handler's `send`/`send_err` report `AskerGone` rather than deadlocking or + /// panicking. The reply is discarded — un-actionable, so no payload is returned. + #[tokio::test] + async fn send_to_gone_asker_reports_asker_gone() { + let (tx, rx) = reply_channel::(); + drop(rx); + assert_eq!(tx.send(9), Err(AskerGone)); + + let (tx, rx) = reply_channel::(); + drop(rx); + assert_eq!(tx.send_err(Conflict), Err(AskerGone)); + } +``` + +- [ ] **Step 2: Run test — verify it passes** (pins the `map_err(|_| AskerGone)` arms on both send paths). + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS. + +- [ ] **Step 3: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "test(core): #115 send to a gone asker reports AskerGone" +``` + +--- + +### Task 5: `tell` has no reply port — `E = Infallible` roundtrip + +**Files:** +- Modify: `bombay-core/src/reply.rs` + +- [ ] **Step 1: Write the failing test** in `mod tests`: + +```rust + /// A `tell` carries no reply port and cannot fail with a domain error, so its + /// reply type is `E = Infallible`: `send_err` is uncallable (Infallible is + /// uninhabited — there is no value to pass), and only the `Ok` path exists. + /// This pins that the Infallible-defaulted channel roundtrips a plain value. + #[tokio::test] + async fn infallible_reply_has_no_error_path() { + let (tx, rx) = reply_channel::(); + tx.send(42).expect("asker still waiting"); + assert_eq!(rx.recv::<()>().await.ok(), Some(42)); + } +``` + +- [ ] **Step 2: Run test — verify it passes.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS. + +- [ ] **Step 3: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "test(core): #115 tell has no reply port — Infallible reply roundtrip" +``` + +--- + +### Task 6: Linearizability — concurrent `send` ‖ `recv` + +**Files:** +- Modify: `bombay-core/src/reply.rs` + +- [ ] **Step 1: Write the failing test** in `mod tests` (needs `std::sync::Arc` + `tokio::sync::Barrier`; add `use std::sync::Arc;` and `use tokio::sync::Barrier;` inside `mod tests` if not present): + +```rust + /// Linearizability: a sender and a receiver race from the same instant on a + /// multi-thread runtime; the exact sent value must arrive exactly once, + /// whichever side wins the start. Real overlap (spawn + Barrier), not + /// sequential-then-check. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_send_and_recv_deliver_the_exact_value() { + let (tx, rx) = reply_channel::(); + let start = Arc::new(Barrier::new(2)); + + let sender_start = Arc::clone(&start); + let sender = tokio::spawn(async move { + sender_start.wait().await; + tx.send(0xABCD_1234).expect("receiver present"); + }); + let receiver = tokio::spawn(async move { + start.wait().await; + rx.recv::<()>().await + }); + + sender.await.expect("sender task"); + let got = receiver.await.expect("receiver task"); + assert_eq!(got.ok(), Some(0xABCD_1234)); + } +``` + +- [ ] **Step 2: Run test — verify it passes.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS. + +- [ ] **Step 3: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "test(core): #115 linearizability — concurrent send/recv exact-value" +``` + +--- + +### Task 7: DST — proptest over {send | send_err | drop} × recv interleavings + +**Files:** +- Modify: `bombay-core/src/reply.rs` + +- [ ] **Step 1: Write the failing property test.** Add `use proptest::prelude::*;` and `use tokio::runtime::Builder;` inside `mod tests`, then: + +```rust + /// The reply-outcome mapping holds for every handler action, driven under a + /// single-thread runtime for deterministic, replayable interleaving. Each + /// action pins exactly one arm of `recv`'s match; proptest sweeps all three. + #[derive(Debug, Clone)] + enum Action { + Reply(u32), + Fail, + Drop, + } + + proptest! { + #[test] + fn prop_reply_outcome_matches_action( + action in prop_oneof![ + any::().prop_map(Action::Reply), + Just(Action::Fail), + Just(Action::Drop), + ], + ) { + let rt = Builder::new_current_thread().build().expect("current-thread rt"); + rt.block_on(async { + let (tx, rx) = reply_channel::(); + match action.clone() { + Action::Reply(v) => { let _ = tx.send(v); } + Action::Fail => { let _ = tx.send_err(Conflict); } + Action::Drop => drop(tx), + } + let got = rx.recv::<()>().await; + match action { + Action::Reply(v) => prop_assert_eq!(got.ok(), Some(v)), + Action::Fail => prop_assert_eq!(got.err().and_then(AskError::err), Some(Conflict)), + Action::Drop => prop_assert!(matches!(got, Err(AskError::Interrupted))), + } + Ok(()) + })?; + } + } +``` + +- [ ] **Step 2: Run test — verify it passes.** + +Run: `nix develop --command cargo test -p bombay-core reply` +Expected: PASS (`prop_reply_outcome_matches_action`). + +- [ ] **Step 3: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "test(core): #115 DST — proptest reply-outcome mapping over all actions" +``` + +--- + +### Task 8: `compile_fail` doctest — single-send is a type guarantee + +**Files:** +- Modify: `bombay-core/src/reply.rs` (doc comment on `ReplySender::send`) + +- [ ] **Step 1: Add a `compile_fail` doctest** to `ReplySender::send`'s doc comment (append after the existing doc lines, before the `pub fn send`): + +```rust + /// A second reply does not compile — `send` moves `self`: + /// + /// ```compile_fail + /// # use bombay_core::reply::reply_channel; + /// # use bombay_core::error::Infallible; + /// let (tx, _rx) = reply_channel::(); + /// let _ = tx.send(1); + /// let _ = tx.send(2); // ← tx already moved: E0382 + /// ``` +``` + +- [ ] **Step 2: Run the doctests — verify the `compile_fail` is honored.** + +Run: `nix develop --command cargo test -p bombay-core --doc reply` +Expected: PASS (the doctest is expected to fail compilation, so the test passes). + +- [ ] **Step 3: Commit.** + +```bash +git add bombay-core/src/reply.rs +git commit --no-verify -m "docs(core): #115 compile_fail doctest — double-reply cannot compile" +``` + +--- + +### Task 9: Coverage baseline + mutation gate + full flake check + +**Files:** +- Modify: `docs/testing/coverage-baseline.md` + +- [ ] **Step 1: Run the mutation gate on the reply module.** Confirm zero surviving mutants. The repo runs mutants through the flake (per the #113/#114 baseline entries); scope it to the reply file for a fast loop: + +Run (fast, targeted): `nix develop --command cargo mutants --file bombay-core/src/reply.rs` +Fallback (whole-workspace, as the baseline cites): `nix build .#mutants` +Expected: `0 missed` (all mutants caught or unviable). If any survive, add a test that kills each, re-run, then continue. + +- [ ] **Step 2: Add the `reply` (#115) section** to `docs/testing/coverage-baseline.md`, immediately after the `### `message` (#114) — done` section: + +```markdown +### `reply` (#115) — done +`bombay-core/src/reply.rs` carries the typed single-shot reply channel: +`ReplySender` / `ReplyReceiver` / `reply_channel()` over +`tokio::sync::oneshot>` (ADR-0002). Kameo's `Box` +`Reply`-trait erasure is **dropped** — a typed port erases nothing, so any +`R: Send + 'static` is a reply. `send`/`send_err` consume `self` (double-reply is +a compile error, proved by a `compile_fail` doctest); a gone asker is reported as +`AskerGone`. `recv` maps the oneshot outcome into #113's `AskError` +(`Ok(Ok r)→Ok(r)`, `Ok(Err e)→Handler(e)`, sender-dropped→`Interrupted`), generic +over the never-produced `M`. Covered by: the `@bug` typed-handler-error probe, the +Ok-reply sequence, the drop→`Interrupted` lifecycle (never hangs), the +`send`-to-gone-asker defensive case, the `Infallible` (tell) roundtrip, a +2-thread barrier'd linearizability test, and a proptest DST sweeping all three +handler actions. **Mutation: 0 missed.** No bombay-owned atomics → +loom/DST-interleaving beyond proptest N/A (delegated to tokio oneshot), same as +#113. `DelegatedReply`/`ForwardedReply` deferred to #116/#118 (recorded on #115). +``` + +- [ ] **Step 3: Run the full gate.** + +Run: `nix flake check` +Expected: PASS (build + clippy god-level bar + fmt + tests, workspace-wide). + +- [ ] **Step 4: Commit.** + +```bash +git add docs/testing/coverage-baseline.md +git commit --no-verify -m "docs(testing): #115 reply coverage baseline; mutation gate 0 missed" +``` + +--- + +## Post-plan + +- Update `README.md` per the card rule: the rebuilt spine is not yet behind the public umbrella (same as #113/#114/#133), so **no README change** — the reply module is internal until the core lands. Confirm this classification holds; if `reply` became public surface, refresh the "public API at a glance" bullet instead. +- Open the PR for #115 against `main`; let CI (`Nix Flake Check`) be the merge gate. +- Move #115 → Done on project board #4 after merge. diff --git a/docs/superpowers/specs/2026-07-05-reply-channel-primitive-design.md b/docs/superpowers/specs/2026-07-05-reply-channel-primitive-design.md new file mode 100644 index 0000000..9ac4f8c --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-reply-channel-primitive-design.md @@ -0,0 +1,153 @@ +# Reply channel primitive — design (card #115) + +**Status:** approved 2026-07-05 · epic #122 · follows the per-card rigor contract there. + +## What this is + +The in-process, typed, **single-shot** channel that carries exactly one answer +from a message handler back to the `ask` that is waiting for it. Local tier of +the two-tier message model (#66) — **zero serialization**, no `Box`. + +This is the reply *transport* and its failure semantics, nothing more. *Who* +mints a channel and embeds the sender (`Context`, the per-`Msg`-variant reply +port) is #116/#118; #115 is the primitive they build on and can be tested in +full isolation. + +## Re-scope from the card title (approved) + +The card is titled "Reply + Delegated/Forwarded + ReplySender". `DelegatedReply` +and `ForwardedReply` are **deferred to the cards that give them meaning**: + +- In the kameo reference they are produced *only* by `Context::reply_sender()` / + `reply()` / `spawn()` / `forward()`, and `forward` needs `ActorRef` + + `Message`. None of that exists until #116 (actor/Context/loop), #117 + (ActorRef), #118 (request). +- Shipping them now would be dead-until-later types testable only for + construction/`Debug` — a violation of YAGNI ("add at the second concrete use", + CLAUDE rule 4) and "test the real SUT" (rule 8). + +They land **wired and tested** with their machinery. Recorded on the issue. + +## The reference, and why it is replaced + +`src/reply.rs` (~1,048 LOC) is built entirely around **type erasure**: a `Reply` +trait with `downcast_ok` / `into_any_err` implemented for ~40 std types plus a +derive macro, feeding `BoxReplySender = oneshot::Sender>`. That erasure exists for exactly one reason — kameo's single +shared `Signal` envelope cannot name a per-message reply type, so the reply +payload is erased to `Box` and re-typed caller-side. + +The #114 message model fixes the reply shape (a typed reply port embedded in the +requesting `Msg` variant). With a per-variant port the reply type is named +directly, so **there is nothing to erase**. The entire `Reply` trait, its std +impls, and the derive are **dropped**: any `R: Send + 'static` is a reply. + +## Public surface — `bombay-core/src/reply.rs` + +The reply channel is `oneshot::Sender/Receiver>` where `R` is the +reply value and `E` is the handler's own domain error (a nexus `Conflict`, …), +kept typed and un-erased end to end. `E` defaults to `Infallible` so an +infallible reply is just `ReplySender`. + +```rust +pub struct ReplySender { /* wraps oneshot::Sender> */ } + +impl ReplySender { + /// Sends the successful reply. Consumes `self`, so a second reply is a + /// *compile* error. `Err(AskerGone)` if the asker already dropped its + /// receiver (the ask was abandoned) — the caller may ignore it with `let _`. + pub fn send(self, reply: R) -> Result<(), AskerGone>; + + /// Sends the handler's typed domain error as the reply. + pub fn send_err(self, error: E) -> Result<(), AskerGone>; +} + +/// The asker had already dropped its receiver, so the reply went nowhere. +/// A unit signal, not the payload: a reply to a vanished asker is +/// un-actionable (nothing to retry, unlike the mailbox's returned `Signal`). +pub struct AskerGone; + +pub struct ReplyReceiver { /* wraps oneshot::Receiver> */ } + +impl ReplyReceiver { + /// Awaits the single reply and maps it into #113's `AskError`: + /// Ok(Ok(r)) -> Ok(r) + /// Ok(Err(e)) -> Err(AskError::Handler(e)) + /// RecvError -> Err(AskError::Interrupted) // sender dropped, no reply + /// + /// `M` is free: the reply layer provably never produces `Deliver`/`Timeout` + /// (those are the ask builder's, #118), so it hands back an + /// `AskError` for any `M` the caller wants, ready to return with no + /// re-mapping — while the type still says "I cannot raise a delivery fault". + pub async fn recv(self) -> Result>; +} + +/// The sender/receiver pair over one fresh oneshot. +pub fn reply_channel() -> (ReplySender, ReplyReceiver); +``` + +### Design decisions + +1. **Primitive: `tokio::sync::oneshot`** (see ADR-0002). Already a workspace dep, + purpose-built one-shot, drop-detection on both ends — exactly the "drop → + `Interrupted`" and "asker-gone → reply handed back" semantics. Kept an impl + detail behind our wrappers (the mailbox channel-seam philosophy: do not expose + it; trait-ify at the *second* impl for M6 / `no_std`). Its single `Arc` alloc + is the card's "1 allocation" reply floor. + +2. **Single-send is a type guarantee, not a runtime guard.** `send`/`send_err` + take `self` by value; a second call fails to compile. Strictly stronger than + kameo's runtime "second send is a no-op". The card's `reply_port_single_send` + becomes a `compile_fail` doctest (use-after-move). + +3. **`recv` reuses `AskError` without lying.** It only ever fills + `Interrupted` / `Handler`, both `M`-agnostic, so #118 gets a ready-to-return + error and there is no second overlapping error type. Rejected alternative: a + narrow `ReplyFault = {Handler | Interrupted}` widened by #118 — honest but + redundant against #113's canonical `AskError`. + +4. **`send`/`send_err` report a dead asker as `AskerGone`, not the payload.** + `Result<(), AskerGone>` rather than kameo's `let _ = ...`. Surfacing the fact + lets a caller observe/measure "asker vanished". The payload is *not* handed + back: a `oneshot>` returns the whole `Result` on failure, so + recovering `R` needs a provably-dead match arm — which `cargo-mutants` can + never kill (a guaranteed survivor) and forces banned `unreachable!`/`expect`. + Unlike the mailbox's returned `Signal` (retryable), a reply to a gone asker is + un-actionable, so the unit signal loses nothing of value. + +5. **`E = Infallible` default.** Matches #113's `AskError` default. A `tell` + carries no reply port and cannot fail with a domain error; `ReplySender` cannot even name `send_err` (Infallible is uninhabited). + +## Test net (TDD — every test written failing first) + +The four cross-cutting categories (rule 7) come first, paired with the card's +named cases: + +- **Sequence/protocol** — `ask_ok_reply_reaches_caller`: `send(reply)` → + `recv().await == Ok(reply)`. `@bug ask_handler_error_reaches_caller_typed`: + `send_err(Conflict)` → `recv().await == Err(AskError::Handler(Conflict))`, + the *specific* typed value; fails if the port were `oneshot` not + `oneshot>`. +- **Lifecycle** — drop the `ReplySender` without sending → `recv().await == + Err(AskError::Interrupted)`, and it *returns* (never hangs). This is the + card's central "drop → error, not a hang" guarantee. +- **Defensive boundary** — `reply_port_single_send`: `compile_fail` doctest that + a second `send` after move does not compile. `tell_has_no_reply_port`: with + `E = Infallible`, `send_err` is uncallable and a tell mints no channel. + `send`-to-a-dropped-receiver returns `Err(AskerGone)`. +- **Linearizability** — barrier'd concurrent `send` ‖ `recv` (`tokio::spawn` + + `Barrier`), asserting the exact sent value arrives exactly once. +- **DST** — proptest over the interleavings of {`send` | `send_err` | drop} × + `recv`, asserting the mapping table holds for every ordering. +- **Mutation** — `cargo-mutants`, **zero surviving mutants** in `reply.rs`. +- **loom** — N/A: no bombay-owned atomics/ordering here (delegated to tokio + oneshot), same rationale as #113. + +## Definition of done + +Rebuilt in-place in `bombay-core` (no #61 quarantine header); god-level clippy +bar clean; the test net above green; DST seeds pass; zero surviving mutants; +`nix flake check` green. `DelegatedReply`/`ForwardedReply` explicitly deferred to +#116/#118 (recorded on #115). `docs/testing/coverage-baseline.md` gains a +`reply` (#115) section; ADR-0002 records the oneshot choice. diff --git a/docs/testing/coverage-baseline.md b/docs/testing/coverage-baseline.md index aa7b922..59bc180 100644 --- a/docs/testing/coverage-baseline.md +++ b/docs/testing/coverage-baseline.md @@ -114,6 +114,33 @@ escape) plus three `compile_fail` (budget tripwire, generic rejected, union rejected). No README change — the rebuilt spine is not behind the umbrella yet (same as #113/#133). +### `reply` (#115) — done +`bombay-core/src/reply.rs` carries the typed single-shot reply channel: +`ReplySender` / `ReplyReceiver` / `reply_channel()` over +`tokio::sync::oneshot>` (ADR-0002). Kameo's `Box` +`Reply`-trait erasure is **dropped** — a typed port erases nothing, so any +`R: Send + 'static` is a reply. `send`/`send_err` consume `self` (double-reply is +a compile error, proved by a `compile_fail` doctest); a gone asker is reported as +`AskerGone` (a unit signal — a reply to a vanished asker is un-actionable, so no +payload is handed back). `recv` maps the oneshot outcome into #113's `AskError` +(`Ok(Ok r)→Ok(r)`, `Ok(Err e)→Handler(e)`, sender-dropped→`Interrupted`), generic +over the never-produced `M`. Covered by 8 tests: the `@bug` typed-handler-error +probe, the Ok-reply sequence, the drop→`Interrupted` lifecycle (never hangs), the +`send`-to-gone-asker defensive case, the `Infallible` (tell) roundtrip, a 2-thread +barrier'd linearizability test, a deterministic **recv-parks-then-send-wakes** test +(the reverse ordering — exercises the oneshot waker path the buffered-value tests +skip), and a proptest sweeping all three handler actions. Benched +(`benches/reply.rs`): the typed roundtrip is **≈1.5× faster than the erased +`Box` path** (21.4 µs vs 32.8 µs /1k — the box+downcast cost #115 removes; +ADR-0002). **Mutation: 0 missed** (4 mutants: `send`/`send_err` whole-body → +`Ok(())` both caught; `recv`/`reply_channel` whole-body replacements are +*unviable* — they need `R: Default` / a `Default` impl the generic types lack, so +`cargo-mutants` cannot mutate the arms of the generic `recv` individually. Those +three arms — `Ok→Ok`, `Err→Handler`, drop→`Interrupted` — are instead pinned +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). + ## Baseline — 2026-06-29 (after #77) Workspace line coverage **60.85% (5686/9345)** — but that blends the SUT with untested crates