From 902471b1f4e64b51a60ab4923dd01d3ed69b794f Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 12 Feb 2026 13:26:59 +0000 Subject: [PATCH] feat: ask pattern for typed request-response (Cycle 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Runtime::ask() and Ask wrapper for convenient request-response. Creates a temporary inbox, sends the request (with reply address via closure), and provides recv_ticking() for automatic tick-until-response. Purely sugar over the existing inbox pattern — no implicit auto-reply. Co-Authored-By: Claude Opus 4.6 --- CLAUDE/notes/progress.md | 24 ++++++++++- src/runtime.rs | 54 ++++++++++++++++++++++++ tests/runtime_api.rs | 89 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/CLAUDE/notes/progress.md b/CLAUDE/notes/progress.md index 0d9feec..0cd61f0 100644 --- a/CLAUDE/notes/progress.md +++ b/CLAUDE/notes/progress.md @@ -2,7 +2,7 @@ ## Current Stage: Phase 1 — Research + First Improvement Cycle -### Status: Cycle 14 COMPLETE +### Status: Cycle 15 COMPLETE ## Plan Overview 1. **Phase 0**: Codebase audit — understand current swactor architecture, existing tests, benchmarks ✅ @@ -124,6 +124,28 @@ - `stop_nonexistent_actor_returns_error` — stop on bad address returns Err - **Result**: 82 tests pass, all workspace compiles +### Cycle 15: Ask Pattern (Request-Response) +- **Research**: Studied ask/call/request-response patterns across Erlang gen_server:call (From + reply), + Akka ask (temporary actor + Future), Ractor call (RpcReplyPort), Kameo ask (async + Reply trait), + xactor Handler (return value auto-routing) + - Key finding: swactor's synchronous tick model requires explicit reply_to, not implicit routing + - Decision: convenience wrapper over existing inbox pattern, not implicit auto-reply +- **Implementation**: `Ask` struct + `Runtime::ask()` method + - `Ask`: wraps `Inbox` with `try_recv()` and `recv_ticking(rt, max_ticks)` + - `rt.ask(addr, |reply_to| Msg { reply_to })` — creates inbox, builds message, sends, returns Ask + - `ask.recv_ticking(&rt, max_ticks)` — ticks until response or timeout (single-threaded only) + - `ask.try_recv()` — poll without ticking (works in both modes) + - `ask.reply_addr()` — access inbox address for manual use + - Purely sugar over `new_inbox → send_to → tick → try_recv` pattern + - Zero changes to ContextInner or ActorInterface — no implicit auto-reply magic +- **Tests**: 5 new behavioral tests + - `ask_recv_ticking_returns_response` — basic PingPong ask roundtrip + - `ask_multiple_times_tracks_state` — 3 sequential asks to CounterActor + - `ask_timeout_when_no_response` — ask dead actor → timeout error + - `ask_try_recv_returns_none_before_tick` — poll before tick → None, after tick → Some + - `ask_reply_addr_is_accessible` — reply address is valid +- **Result**: 127 tests pass (120 behavioral + 7 proptest), all workspace compiles, zero warnings + ### Cycle 14: Actor Groups (Pub-Sub) - **Research**: Studied group/pub-sub patterns across Erlang pg (scopes, join/leave/get_members), Akka DistributedPubSub (mediator, topics), Ractor pg (join/leave/broadcast), Bastion (Dispatcher), diff --git a/src/runtime.rs b/src/runtime.rs index ee7a3e4..6f0ad7c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -32,6 +32,39 @@ impl Inbox { } } +/// Pending ask response — wraps an inbox with convenience recv methods. +/// +/// Created by [`Runtime::ask`]. Provides `try_recv()` for polling and +/// `recv_ticking()` for automatic tick-until-response. +pub struct Ask { + inbox: Inbox, +} + +impl Ask { + /// Try to receive the response without ticking. + pub fn try_recv(&self) -> Option { + self.inbox.try_recv() + } + + /// Tick the runtime until a response arrives or `max_ticks` is exhausted. + /// + /// Only valid for single-threaded runtimes (panics if `num_threads >= 2`). + pub fn recv_ticking(&self, rt: &Runtime, max_ticks: usize) -> Result { + for _ in 0..max_ticks { + rt.tick(); + if let Some(resp) = self.inbox.try_recv() { + return Ok(resp); + } + } + Err(Error::from("ask timeout: no response within max_ticks")) + } + + /// Get the reply address (for manual message construction). + pub fn reply_addr(&self) -> &ActorAddress { + self.inbox.addr() + } +} + /// Handle for dealing with a runtime that has started via the `Runtime::run()` method. pub struct RuntimeHandle { pub runtime: Arc, @@ -301,6 +334,27 @@ impl Runtime { self.group_registry.group_names() } + /// Send a request and get a handle for the response. + /// + /// Creates a temporary inbox, calls `msg_builder` with the inbox's address + /// (so you can embed it as `reply_to`), sends the message, and returns an + /// [`Ask`] handle for receiving the response. + /// + /// ```ignore + /// let ask = rt.ask(actor, |reply_to| GetValue { reply_to })?; + /// let value = ask.recv_ticking(&rt, 10)?; + /// ``` + pub fn ask( + &self, + addr: ActorAddress, + msg_builder: impl FnOnce(ActorAddress) -> Req, + ) -> Result, Error> { + let inbox = self.new_inbox::()?; + let msg = msg_builder(*inbox.addr()); + self.send_to(addr, msg)?; + Ok(Ask { inbox }) + } + /// Send a message to an actor address pub fn send_to(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { let result = self.send_any(addr, Box::new(msg)); diff --git a/tests/runtime_api.rs b/tests/runtime_api.rs index 63d0f88..dc1fba5 100644 --- a/tests/runtime_api.rs +++ b/tests/runtime_api.rs @@ -3548,3 +3548,92 @@ fn ctx_publish_broadcasts_from_handler() { } assert!(pong_count >= 2, "at least 2 PingPong members should reply, got {pong_count}"); } + +// ── Ask Pattern ───────────────────────────────────────────────────────────── + +/// Given a PingPong actor, +/// when I ask with recv_ticking, +/// then I get the Pong response. +#[test] +fn ask_recv_ticking_returns_response() { + let rt = Runtime::new(RuntimeConfig::default()); + let actor = rt.spawn(PingPongActor).unwrap(); + rt.tick(); // on_start + + let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to }) + .unwrap() + .recv_ticking(&rt, 10) + .unwrap(); + assert_eq!(pong, Pong); +} + +/// Given a CounterActor, +/// when I ask multiple times, +/// then each response reflects the updated state. +#[test] +fn ask_multiple_times_tracks_state() { + let rt = Runtime::new(RuntimeConfig::default()); + let actor = rt.spawn(CounterActor { count: 0 }).unwrap(); + rt.tick(); // on_start + + let c1: Count = rt.ask(actor, |reply_to| Increment { reply_to }) + .unwrap().recv_ticking(&rt, 10).unwrap(); + let c2: Count = rt.ask(actor, |reply_to| Increment { reply_to }) + .unwrap().recv_ticking(&rt, 10).unwrap(); + let c3: Count = rt.ask(actor, |reply_to| Increment { reply_to }) + .unwrap().recv_ticking(&rt, 10).unwrap(); + + assert_eq!(c1, Count(1)); + assert_eq!(c2, Count(2)); + assert_eq!(c3, Count(3)); +} + +/// Given a dead actor, +/// when I ask and tick, +/// then recv_ticking returns a timeout error. +#[test] +fn ask_timeout_when_no_response() { + let rt = Runtime::new(RuntimeConfig::default()); + let actor = rt.spawn(PingPongActor).unwrap(); + rt.tick(); + rt.stop_actor(actor).unwrap(); + rt.tick(); // actor dies + + // Ask the dead actor — message is undeliverable, no response + let result = rt.ask::(actor, |reply_to| Ping { reply_to }); + // send_to may succeed (message goes to transfer queue) or fail (addr removed) + // Either way, no response will come + if let Ok(ask) = result { + let err = ask.recv_ticking(&rt, 5); + assert!(err.is_err(), "should timeout with no response"); + } +} + +/// Given an ask handle, +/// when I use try_recv before ticking, +/// then it returns None (response hasn't arrived yet). +#[test] +fn ask_try_recv_returns_none_before_tick() { + let rt = Runtime::new(RuntimeConfig::default()); + let actor = rt.spawn(PingPongActor).unwrap(); + rt.tick(); // on_start + + let ask = rt.ask::(actor, |reply_to| Ping { reply_to }).unwrap(); + assert!(ask.try_recv().is_none(), "no response before ticking"); + + rt.tick(); // process message + assert_eq!(ask.try_recv(), Some(Pong)); +} + +/// Given an ask, the reply_addr() returns the inbox address for manual use. +#[test] +fn ask_reply_addr_is_accessible() { + let rt = Runtime::new(RuntimeConfig::default()); + let actor = rt.spawn(PingPongActor).unwrap(); + rt.tick(); + + let ask = rt.ask::(actor, |reply_to| Ping { reply_to }).unwrap(); + let addr = *ask.reply_addr(); + // The address should be valid (non-zero) + assert_ne!(addr, ActorAddress::default()); +}