diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 25a95d5..b5345b6 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -4,7 +4,7 @@ use std::cell::RefCell; use pyo3::prelude::*; use pyo3::types::PyModule; -use ::swactor::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Ctx}; +use ::swactor::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Ctx, Environment, SpawnRequest}; use ::swactor::config::{BackoffPolicy, RuntimeConfig}; use ::swactor::runtime::{Inbox, Runtime, RuntimeHandle}; @@ -182,7 +182,12 @@ impl ActorInterface for PyActor { Effect::Spawn { addr, handler } => { let actor = PyActor::new(handler); let boxed: Box = Box::new(Actor::new(actor)); - let _ = ctx.raw_inner().spawn_any(addr, boxed); + ctx.raw_inner().spawn_any(SpawnRequest { + addr, + actor: boxed, + parent: Some(ctx.self_addr()), + env: Environment::new(), + }); } } } diff --git a/crates/std/src/children_registry.rs b/crates/std/src/children_registry.rs new file mode 100644 index 0000000..2b4f930 --- /dev/null +++ b/crates/std/src/children_registry.rs @@ -0,0 +1,56 @@ +use std::sync::RwLock; + +use swactor::actor::ActorAddress; +use swactor::{AddrMap, AddrSet}; + +/// Tracks parent → children relationships for orphan cleanup. +/// +/// When a parent dies, unsupervised children are stopped automatically. +/// Entries are added in `on_spawn` and cleaned up on actor death. +pub struct ChildrenRegistry { + /// parent_addr → set of child addresses + children: RwLock>, +} + +impl ChildrenRegistry { + pub fn new() -> Self { + Self { + children: RwLock::new(AddrMap::default()), + } + } + + /// Register a parent → child relationship. + pub fn register(&self, parent: ActorAddress, child: ActorAddress) { + self.children + .write() + .unwrap() + .entry(parent) + .or_default() + .insert(child); + } + + /// Remove and return all children of a parent (for orphan handling). + pub fn take_children(&self, parent: &ActorAddress) -> Vec { + self.children + .write() + .unwrap() + .remove(parent) + .map(|set| set.into_iter().collect()) + .unwrap_or_default() + } + + /// Clean up entries for dead actors (as both parent and child). + pub fn cleanup(&self, dead: &[ActorAddress]) { + let mut map = self.children.write().unwrap(); + for addr in dead { + // Remove as parent + map.remove(addr); + // Remove as child from any parent's set + for set in map.values_mut() { + set.remove(addr); + } + } + // Remove empty parent entries + map.retain(|_, set| !set.is_empty()); + } +} diff --git a/crates/std/src/ctx_ext.rs b/crates/std/src/ctx_ext.rs index fe83613..5716d83 100644 --- a/crates/std/src/ctx_ext.rs +++ b/crates/std/src/ctx_ext.rs @@ -1,10 +1,11 @@ -use swactor::actor::{ActorAddress, ActorInterface, Ctx, Message, MonitorRef}; +use swactor::actor::{ActorAddress, ActorInterface, Ctx, Environment, LogicalName, Message, MonitorRef, SystemInfo}; use swactor::Error; +use crate::resource_handle::ResourceHandle; use crate::StdExtension; use crate::timer_wheel::{CloneMsg, TimerRequest}; -fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension { +pub(crate) fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension { ctx.extension() .expect("StdExtension not installed — use Runtime::with_extension()") .as_any() @@ -18,15 +19,18 @@ fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension { pub trait CtxMonitoring { /// Subscribe to death notifications from `target`. Returns a [`MonitorRef`] /// that can be used to cancel the subscription. - fn monitor(&self, target: ActorAddress) -> MonitorRef; + fn monitor(&self, target: ActorAddress) -> Result; /// Cancel a monitor subscription. fn demonitor(&self, mref: MonitorRef); } impl CtxMonitoring for Ctx<'_> { - fn monitor(&self, target: ActorAddress) -> MonitorRef { - get_ext(self).monitor_registry.register(self.self_addr(), target) + fn monitor(&self, target: ActorAddress) -> Result { + if let Some(caps) = self.env::() { + caps.check_monitor(target)?; + } + Ok(get_ext(self).monitor_registry.register(self.self_addr(), target)) } fn demonitor(&self, mref: MonitorRef) { @@ -60,7 +64,7 @@ impl CtxNaming for Ctx<'_> { fn spawn_named(&self, name: impl Into, actor: A) -> Result { let name = name.into(); - let addr = self.spawn(actor)?; + let addr = self.spawn_builder(actor).env(LogicalName(name.clone())).finish()?; if let Err(e) = get_ext(self).name_registry.register(name, addr) { let _ = self.stop_actor(addr); return Err(e); @@ -177,3 +181,217 @@ impl CtxGroups for Ctx<'_> { get_ext(self).group_registry.members(group) } } + +/// System introspection extension for [`Ctx`]. +/// +/// Provides convenience accessors for system-level information. Does NOT +/// require [`StdExtension`] — the data comes from the core runtime. +pub trait CtxSystem { + /// Returns the full [`SystemInfo`] snapshot. + fn system_info(&self) -> SystemInfo; + + /// Index of the worker thread this actor is running on. + fn worker_id(&self) -> usize; + + /// Total number of worker threads in the runtime. + fn num_workers(&self) -> usize; + + /// Total number of live actors across all workers. + fn total_actors(&self) -> usize; + + /// Milliseconds since the runtime was created. + fn uptime_ms(&self) -> u64; +} + +impl CtxSystem for Ctx<'_> { + fn system_info(&self) -> SystemInfo { + Ctx::system_info(self) + } + + fn worker_id(&self) -> usize { + Ctx::system_info(self).worker_id + } + + fn num_workers(&self) -> usize { + Ctx::system_info(self).num_workers + } + + fn total_actors(&self) -> usize { + Ctx::system_info(self).total_actors + } + + fn uptime_ms(&self) -> u64 { + Ctx::system_info(self).uptime_ms + } +} + +/// Lineage extension for [`Ctx`]. +/// +/// Exposes the actor's parent and supervisor. `parent()` does NOT require +/// [`StdExtension`] — the data is stored in core per-actor state. +/// `supervisor()` returns `None` gracefully when StdExtension is absent. +pub trait CtxLineage { + /// Returns the address of the actor that spawned this one, or `None` + /// if this actor was spawned externally via `Runtime::spawn`. + fn parent(&self) -> Option; + + /// Returns the address of this actor's supervisor, or `None` if + /// unsupervised or StdExtension is not installed. + fn supervisor(&self) -> Option; +} + +impl CtxLineage for Ctx<'_> { + fn parent(&self) -> Option { + Ctx::parent(self) + } + + fn supervisor(&self) -> Option { + let ext = self.extension()? + .as_any() + .downcast_ref::()?; + ext.supervisor_registry.lookup(&self.self_addr()) + } +} + +/// Per-actor self-introspection extension for [`Ctx`]. +/// +/// Exposes the actor's own operational metrics. Does NOT require +/// [`StdExtension`] — the data is snapshotted from core before each tick. +pub trait CtxSelfStats { + /// Total messages this actor has successfully processed (before the current tick). + fn messages_processed(&self) -> u64; + + /// Number of messages in this actor's mailbox at the start of the current tick. + fn mailbox_depth(&self) -> usize; + + /// Per-message-type counts for this actor, sorted descending by count. + fn message_type_counts(&self) -> &[(&'static str, u64)]; +} + +impl CtxSelfStats for Ctx<'_> { + fn messages_processed(&self) -> u64 { + Ctx::messages_processed(self) + } + + fn mailbox_depth(&self) -> usize { + Ctx::mailbox_depth(self) + } + + fn message_type_counts(&self) -> &[(&'static str, u64)] { + Ctx::message_type_counts(self) + } +} + +/// Service resource extension for [`Ctx`]. +/// +/// Provides typed service discovery via the environment. Does NOT require +/// [`StdExtension`] — reads from the core environment (same as [`CtxEnvironment`]). +pub trait CtxResources { + /// Look up a service address by marker type `S`. + /// + /// Returns `None` if no `ServiceBinding` is present in the environment. + fn resource(&self) -> Option; +} + +impl CtxResources for Ctx<'_> { + fn resource(&self) -> Option { + if let Some(caps) = self.env::() { + if caps.check_service::().is_err() { + return None; + } + } + self.env::>().map(|b| b.addr) + } +} + +/// Environment extension for [`Ctx`]. +/// +/// Provides access to the actor's inherited typed key-value environment. +/// Does NOT require [`StdExtension`] — the data is stored in core per-actor state. +pub trait CtxEnvironment { + /// Read a typed value from this actor's environment. + fn env(&self) -> Option<&T>; + + /// Access this actor's full environment. + fn environment(&self) -> &Environment; +} + +impl CtxEnvironment for Ctx<'_> { + fn env(&self) -> Option<&T> { + Ctx::env(self) + } + + fn environment(&self) -> &Environment { + Ctx::environment(self) + } +} + +/// Resource handle extension for [`Ctx`]. +/// +/// Provides `handle::()` to construct typed proxy structs wrapping service +/// addresses for ergonomic domain-specific APIs. See [`ResourceHandle`] for +/// how to define a handle type. +pub trait CtxHandles { + /// Construct a typed resource handle from the service registry. + /// + /// Returns `None` if no `ServiceBinding` is present in the + /// actor's environment (consistent with `ctx.resource()`, `ctx.where_is()`, etc). + fn handle(&self) -> Option; +} + +impl CtxHandles for Ctx<'_> { + fn handle(&self) -> Option { + let binding = self.env::>()?; + Some(H::from_parts(binding.addr, self.self_addr())) + } +} + +/// Lifecycle extension for [`Ctx`]. +/// +/// Provides suspend/resume capabilities with authorization: +/// only the actor itself or its supervisor can resume it. +pub trait CtxLifecycle { + /// Suspend this actor. Messages continue to queue but are not processed + /// until resumed by self or supervisor. + fn suspend_self(&self); + + /// Resume a suspended actor. Only the actor itself or its supervisor + /// may call this. Returns `Err` if the caller is not authorized. + fn resume(&self, target: ActorAddress) -> Result<(), Error>; +} + +impl CtxLifecycle for Ctx<'_> { + fn suspend_self(&self) { + Ctx::suspend_self(self); + } + + fn resume(&self, target: ActorAddress) -> Result<(), Error> { + // Self-resume is always allowed + if target == self.self_addr() { + self.raw_inner().request_resume(target); + return Ok(()); + } + // Supervisor can resume its child + let ext = get_ext(self); + if ext.supervisor_registry.lookup(&target) == Some(self.self_addr()) { + self.raw_inner().request_resume(target); + return Ok(()); + } + Err(Error::from("resume denied: caller is not self or supervisor")) + } +} + +/// Capability introspection extension for [`Ctx`]. +pub trait CtxCapabilities { + fn capabilities(&self) -> Option<&swactor::CapabilitySet>; + fn is_restricted(&self) -> bool; +} + +impl CtxCapabilities for Ctx<'_> { + fn capabilities(&self) -> Option<&swactor::CapabilitySet> { + Ctx::env(self) + } + fn is_restricted(&self) -> bool { + self.env::().is_some() + } +} diff --git a/crates/std/src/extension.rs b/crates/std/src/extension.rs index 91e3126..f462598 100644 --- a/crates/std/src/extension.rs +++ b/crates/std/src/extension.rs @@ -1,11 +1,14 @@ use std::any::Any; -use swactor::actor::{ActorAddress, Down, ExitReason, StopReason}; +use swactor::actor::{ActorAddress, Down, Environment, EnvironmentBuilder, ExitReason, ExitValue, SpawnTimestamp, StopReason, StopSignal}; use swactor::extension::{RuntimeExtension, WorkerExtension}; +use crate::children_registry::ChildrenRegistry; use crate::group_registry::GroupRegistry; use crate::monitor_registry::MonitorRegistry; use crate::name_registry::NameRegistry; +use crate::service_registry::ServiceRegistry; +use crate::supervisor_registry::SupervisorRegistry; use crate::timer_wheel::TimerWheel; use crate::watch_registry::WatchRegistry; @@ -17,6 +20,9 @@ pub struct StdExtension { pub(crate) monitor_registry: MonitorRegistry, pub(crate) watch_registry: WatchRegistry, pub(crate) group_registry: GroupRegistry, + pub(crate) supervisor_registry: SupervisorRegistry, + pub(crate) service_registry: ServiceRegistry, + pub(crate) children_registry: ChildrenRegistry, } impl StdExtension { @@ -26,6 +32,9 @@ impl StdExtension { monitor_registry: MonitorRegistry::new(), watch_registry: WatchRegistry::new(), group_registry: GroupRegistry::new(), + supervisor_registry: SupervisorRegistry::new(), + service_registry: ServiceRegistry::new(), + children_registry: ChildrenRegistry::new(), } } @@ -33,6 +42,14 @@ impl StdExtension { pub fn resolve_name(&self, addr: &ActorAddress) -> Option { self.name_registry.lookup_by_addr(addr) } + + /// Register a supervisor → child relationship. + /// + /// This is used by the built-in [`Supervisor`](crate::Supervisor) and can + /// also be called by custom supervisor implementations. + pub fn register_supervisor(&self, supervisor: ActorAddress, child: ActorAddress) { + self.supervisor_registry.register(supervisor, child); + } } impl Default for StdExtension { @@ -46,40 +63,54 @@ fn stop_to_exit(reason: StopReason) -> ExitReason { match reason { StopReason::Normal => ExitReason::Stopped, StopReason::Panicked => ExitReason::Panicked, + StopReason::Completed => ExitReason::Completed, } } impl RuntimeExtension for StdExtension { fn on_actor_death( &self, - dead: &[(ActorAddress, StopReason)], + dead: &[(ActorAddress, StopReason, Option)], ) -> Vec<(ActorAddress, Box)> { let mut notifications = Vec::new(); - for &(addr, reason) in dead { + for (addr, reason, exit_value) in dead { + let addr = *addr; + let reason = *reason; + // Monitor notifications (Down) let watchers = self.monitor_registry.take_monitors(&addr); for (_mref, watcher) in watchers { - let down = Down { addr, reason }; + let down = Down { addr, reason, exit_value: exit_value.clone() }; notifications.push((watcher, Box::new(down) as Box)); } // Watch notifications (ActorExited) - let watch_notifications = self.watch_registry.notify_death(addr, stop_to_exit(reason)); + let watch_notifications = self.watch_registry.notify_death(addr, stop_to_exit(reason), exit_value.clone()); for (watcher, exited) in watch_notifications { notifications.push((watcher, Box::new(exited) as Box)); } + + // Orphan handling: kill unsupervised children + let children = self.children_registry.take_children(&addr); + for child in children { + if self.supervisor_registry.lookup(&child).is_none() { + notifications.push((child, Box::new(StopSignal) as Box)); + } + } } notifications } fn cleanup_dead(&self, dead: &[ActorAddress]) { + self.children_registry.cleanup(dead); for addr in dead { self.name_registry.unregister_by_addr(addr); self.group_registry.cleanup(addr); self.monitor_registry.remove_watcher(addr); self.watch_registry.cleanup_watcher(addr); + self.supervisor_registry.cleanup(addr); } } @@ -87,6 +118,17 @@ impl RuntimeExtension for StdExtension { self } + fn on_spawn(&self, child: ActorAddress, parent: Option, env: Environment, uptime_ms: u64) -> Environment { + // Register parent → child relationship for orphan cleanup + if let Some(parent_addr) = parent { + self.children_registry.register(parent_addr, child); + } + let env = self.service_registry.inject_into(env); + EnvironmentBuilder::from_env(&env) + .set(SpawnTimestamp(uptime_ms)) + .build() + } + fn create_worker_extension(&self) -> Option> { Some(Box::new(TimerWheel::new())) } diff --git a/crates/std/src/lib.rs b/crates/std/src/lib.rs index 98df904..1919048 100644 --- a/crates/std/src/lib.rs +++ b/crates/std/src/lib.rs @@ -4,6 +4,10 @@ pub mod name_registry; pub mod monitor_registry; pub mod watch_registry; pub mod group_registry; +pub mod supervisor_registry; +pub mod service_registry; +pub mod resource_handle; +pub mod children_registry; pub(crate) mod timer_wheel; mod extension; mod ctx_ext; @@ -12,5 +16,6 @@ mod runtime_ext; pub use supervisor::{ChildSpec, RestartPolicy, Supervisor, SupervisorStrategy}; pub use router::{Router, RoutingStrategy}; pub use extension::StdExtension; -pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups, CtxWatching, CtxTimers}; -pub use runtime_ext::{RuntimeNaming, RuntimeGroups, RuntimeWatching}; +pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups, CtxWatching, CtxTimers, CtxSystem, CtxSelfStats, CtxLineage, CtxEnvironment, CtxResources, CtxHandles, CtxLifecycle, CtxCapabilities}; +pub use resource_handle::ResourceHandle; +pub use runtime_ext::{RuntimeNaming, RuntimeGroups, RuntimeWatching, RuntimeResources}; diff --git a/crates/std/src/resource_handle.rs b/crates/std/src/resource_handle.rs new file mode 100644 index 0000000..fdec51a --- /dev/null +++ b/crates/std/src/resource_handle.rs @@ -0,0 +1,44 @@ +use swactor::actor::ActorAddress; + +/// Typed proxy wrapping a service address for ergonomic domain-specific APIs. +/// +/// Implement this trait on a struct that wraps a service address and provides +/// domain-specific methods. Methods take `&self` + `&Ctx` (not stored `&Ctx` — +/// avoids lifetime issues with `&mut self` in handlers). +/// +/// # Example +/// +/// ```ignore +/// struct CounterHandle { +/// service: ActorAddress, +/// self_addr: ActorAddress, +/// } +/// +/// impl ResourceHandle for CounterHandle { +/// type Service = CounterService; +/// fn from_parts(service_addr: ActorAddress, self_addr: ActorAddress) -> Self { +/// Self { service: service_addr, self_addr } +/// } +/// fn service_addr(&self) -> ActorAddress { self.service } +/// fn self_addr(&self) -> ActorAddress { self.self_addr } +/// } +/// +/// impl CounterHandle { +/// pub fn increment(&self, ctx: &Ctx) -> Result<(), Error> { +/// ctx.send(self.service_addr(), Increment { reply_to: self.self_addr() }) +/// } +/// } +/// ``` +pub trait ResourceHandle: Sized { + /// Marker type identifying the service (same `S` used with `ServiceRegistry`). + type Service: 'static + Send + Sync; + + /// Construct a handle from a service address and the calling actor's address. + fn from_parts(service_addr: ActorAddress, self_addr: ActorAddress) -> Self; + + /// The address of the underlying service actor. + fn service_addr(&self) -> ActorAddress; + + /// The address of the actor holding this handle (for reply_to patterns). + fn self_addr(&self) -> ActorAddress; +} diff --git a/crates/std/src/router.rs b/crates/std/src/router.rs index 3202d6c..c35d8bf 100644 --- a/crates/std/src/router.rs +++ b/crates/std/src/router.rs @@ -69,7 +69,7 @@ impl Router { fn start_worker(&mut self, ctx: &Ctx, idx: usize) -> Result<(), Error> { let addr = (self.factory)(ctx)?; - let mref = ctx.monitor(addr); + let mref = ctx.monitor(addr)?; self.workers[idx] = Some(ActiveChild { addr, _monitor_ref: mref, diff --git a/crates/std/src/runtime_ext.rs b/crates/std/src/runtime_ext.rs index 3cd72c6..3f0d2b9 100644 --- a/crates/std/src/runtime_ext.rs +++ b/crates/std/src/runtime_ext.rs @@ -1,4 +1,4 @@ -use swactor::actor::{ActorAddress, ActorInterface, Message}; +use swactor::actor::{ActorAddress, ActorInterface, EnvironmentBuilder, LogicalName, Message}; use swactor::runtime::Runtime; use swactor::Error; @@ -40,7 +40,10 @@ impl RuntimeNaming for Runtime { fn spawn_named(&self, name: impl Into, actor: A) -> Result { let name = name.into(); - let addr = self.spawn(actor)?; + let env = EnvironmentBuilder::new() + .set(LogicalName(name.clone())) + .build(); + let addr = self.spawn_with_env(actor, env)?; if let Err(e) = get_ext(self).name_registry.register(name, addr) { let _ = self.stop_actor(addr); return Err(e); @@ -132,3 +135,21 @@ impl RuntimeGroups for Runtime { get_ext(self).group_registry.group_names() } } + +/// Service registry extension for [`Runtime`]. +/// +/// Allows registering typed service bindings that are automatically injected +/// into every actor's environment at spawn time. +pub trait RuntimeResources { + /// Register a service address under marker type `S`. + /// + /// All actors spawned after this call will have `ServiceBinding` in + /// their environment (unless overridden via `spawn_builder`). + fn register_service(&self, addr: ActorAddress); +} + +impl RuntimeResources for Runtime { + fn register_service(&self, addr: ActorAddress) { + get_ext(self).service_registry.register::(addr); + } +} diff --git a/crates/std/src/service_registry.rs b/crates/std/src/service_registry.rs new file mode 100644 index 0000000..90a2aa5 --- /dev/null +++ b/crates/std/src/service_registry.rs @@ -0,0 +1,52 @@ +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use swactor::actor::{Environment, EnvironmentBuilder}; + +/// Stores typed service bindings for injection into actor environments. +/// +/// Bindings are registered at the runtime level (e.g., during startup) and +/// automatically injected into every actor's environment via the `on_spawn` +/// hook. Existing environment keys are **not** overwritten — this preserves +/// per-subtree overrides set via `spawn_builder`. +pub struct ServiceRegistry { + bindings: RwLock>>, +} + +impl ServiceRegistry { + pub fn new() -> Self { + Self { + bindings: RwLock::new(HashMap::new()), + } + } + + /// Register a service binding by marker type `S`. + /// + /// Overwrites any previous binding for the same marker type. + pub fn register(&self, addr: swactor::actor::ActorAddress) { + let binding = swactor::actor::ServiceBinding::::new(addr); + let type_id = TypeId::of::>(); + self.bindings + .write() + .unwrap() + .insert(type_id, Arc::new(binding)); + } + + /// Merge all registered bindings into an environment, skipping keys + /// that are already present (preserves spawn_builder overrides). + pub fn inject_into(&self, env: Environment) -> Environment { + let bindings = self.bindings.read().unwrap(); + if bindings.is_empty() { + return env; + } + + let mut builder = EnvironmentBuilder::from_env(&env); + for (&type_id, value) in bindings.iter() { + if !env.contains_type_id(type_id) { + builder.set_raw(type_id, Arc::clone(value)); + } + } + builder.build() + } +} diff --git a/crates/std/src/supervisor.rs b/crates/std/src/supervisor.rs index 1d809ed..de081dc 100644 --- a/crates/std/src/supervisor.rs +++ b/crates/std/src/supervisor.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use swactor::actor::{ActorAddress, ActorInterface, Ctx, Down, MonitorRef, StopReason}; use swactor::Error; +use crate::ctx_ext::get_ext; use crate::CtxMonitoring; /// How a child should be restarted when it dies. @@ -140,7 +141,8 @@ impl Supervisor { fn start_child(&mut self, ctx: &Ctx, idx: usize) -> Result<(), Error> { let addr = (self.specs[idx].start)(ctx)?; - let mref = ctx.monitor(addr); + let mref = ctx.monitor(addr)?; + get_ext(ctx).supervisor_registry.register(ctx.self_addr(), addr); self.children[idx] = Some(ActiveChild { addr, _monitor_ref: mref, diff --git a/crates/std/src/supervisor_registry.rs b/crates/std/src/supervisor_registry.rs new file mode 100644 index 0000000..5e161d2 --- /dev/null +++ b/crates/std/src/supervisor_registry.rs @@ -0,0 +1,40 @@ +use std::sync::RwLock; + +use swactor::actor::ActorAddress; +use swactor::AddrMap; + +/// Maps supervised children to their supervisor. +/// +/// Follows the same pattern as `MonitorRegistry`, `GroupRegistry`, etc. +/// Entries are added in `Supervisor::start_child` and cleaned up on actor death. +pub struct SupervisorRegistry { + /// child_addr → supervisor_addr + children: RwLock>, +} + +impl SupervisorRegistry { + pub fn new() -> Self { + Self { + children: RwLock::new(AddrMap::default()), + } + } + + /// Register a supervisor → child relationship. + pub fn register(&self, supervisor: ActorAddress, child: ActorAddress) { + self.children.write().unwrap().insert(child, supervisor); + } + + /// Look up the supervisor of a child actor. + pub fn lookup(&self, child: &ActorAddress) -> Option { + self.children.read().unwrap().get(child).copied() + } + + /// Remove entries where `dead_addr` is either a child or a supervisor. + pub fn cleanup(&self, dead_addr: &ActorAddress) { + let mut map = self.children.write().unwrap(); + // Remove the dead actor as a child + map.remove(dead_addr); + // Remove all children supervised by the dead actor + map.retain(|_, supervisor| supervisor != dead_addr); + } +} diff --git a/crates/std/src/watch_registry.rs b/crates/std/src/watch_registry.rs index 4ebaf51..21e08dd 100644 --- a/crates/std/src/watch_registry.rs +++ b/crates/std/src/watch_registry.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Mutex; -use swactor::actor::{ActorAddress, ActorExited, ExitReason}; +use swactor::actor::{ActorAddress, ActorExited, ExitReason, ExitValue}; /// Tracks watch relationships between actors. /// @@ -55,11 +55,13 @@ impl WatchRegistry { &self, target: ActorAddress, reason: ExitReason, + exit_value: Option, ) -> Vec<(ActorAddress, ActorExited)> { let mut state = self.inner.lock().unwrap(); let notification = ActorExited { addr: target, reason, + exit_value, }; let mut result = Vec::new(); diff --git a/docs/development_history/PROCESS_PRIMITIVES.md b/docs/development_history/PROCESS_PRIMITIVES.md new file mode 100644 index 0000000..6b5edfa --- /dev/null +++ b/docs/development_history/PROCESS_PRIMITIVES.md @@ -0,0 +1,542 @@ +# Process Abstraction for Swactor — Development History + +> Design and implementation record for the "process" abstraction layer built +> on top of swactor's actor primitives. This work ran across items 1–9 and +> added 9 extension traits, 3 registries, and ~70 scenario tests. + +## Context + +Swactor is a distributed actor runtime with local primitives (spawn, send, stop, monitor, +supervise) and distributed primitives (SWIM membership, Kademlia directory, cluster-wide naming, +content-addressed datastore). The goal was to design a "process" abstraction that sits on top of +these primitives, making the experience of running code on a swactor network feel closer to what +an OS process feels like -- with access to an API for requesting resources and querying system +state. + +--- +## Part 1: OS Process Mapping + +### Already strong (direct OS equivalents exist) + +OS Concept: PID +Swactor Equivalent: ActorAddress (32-byte random) +Where: src/actor.rs +──────────────────────────────────────── +OS Concept: fork+exec +Swactor Equivalent: ctx.spawn(), Runtime::spawn() +Where: src/actor.rs, src/runtime.rs +──────────────────────────────────────── +OS Concept: exit(0) +Swactor Equivalent: ctx.stop_self() +Where: src/actor.rs +──────────────────────────────────────── +OS Concept: kill(pid, SIGTERM) +Swactor Equivalent: ctx.stop_actor(addr) +Where: src/actor.rs +──────────────────────────────────────── +OS Concept: SIGCHLD / waitpid +Swactor Equivalent: ctx.monitor() -> Down, ctx.watch() -> ActorExited +Where: crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: IPC (message queues) +Swactor Equivalent: Typed message passing (local + cross-worker + cross-runtime) +Where: src/actor.rs, src/transport.rs +──────────────────────────────────────── +OS Concept: Service names +Swactor Equivalent: NameRegistry (local), ClusterRegistry (cluster CRDT) +Where: crates/std/src/name_registry.rs, crates/distribution/src/registry.rs +──────────────────────────────────────── +OS Concept: Process groups +Swactor Equivalent: GroupRegistry (join/leave/publish/members) +Where: crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: init/systemd +Swactor Equivalent: Supervisor with restart strategies +Where: crates/std/src/supervisor.rs +──────────────────────────────────────── +OS Concept: Scheduler +Swactor Equivalent: Worker pool with load-aware placement + per-actor message budgets +Where: src/worker.rs, src/delivery.rs +──────────────────────────────────────── +OS Concept: Machine identity +Swactor Equivalent: NodeId (ed25519 public key) +Where: crates/distribution/src/types.rs +──────────────────────────────────────── +OS Concept: Cluster membership +Swactor Equivalent: SWIM protocol +Where: crates/distribution/src/swim/ +──────────────────────────────────────── +OS Concept: /proc, top, ps +Swactor Equivalent: RuntimeStats, StatsHook, Dashboard, Investigate protocol +Where: src/stats.rs, crates/dashboard/ + +### Implemented during this work + +OS Concept: System introspection from inside +Swactor Equivalent: CtxSystem (worker_id, num_workers, total_actors, uptime_ms) + SystemInfo +Where: src/actor.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Per-actor introspection +Swactor Equivalent: CtxSelfStats (messages_processed, mailbox_depth, message_type_counts) +Where: src/actor.rs, src/worker.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Process lineage (getppid) +Swactor Equivalent: CtxLineage (ctx.parent(), ctx.supervisor()) +Where: src/actor.rs, src/worker.rs, crates/std/src/ctx_ext.rs, crates/std/src/supervisor_registry.rs +──────────────────────────────────────── +OS Concept: Process environment (environ/getenv) +Swactor Equivalent: CtxEnvironment (ctx.env::(), ctx.environment(), SpawnBuilder for overrides) +Where: src/actor.rs, src/worker.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Well-known environment keys (spawn metadata) +Swactor Equivalent: SpawnTimestamp(u64) injected by StdExtension on_spawn hook; + LogicalName(String) injected by spawn_named (ctx and runtime level) +Where: src/actor.rs, src/extension.rs, src/worker.rs, crates/std/src/extension.rs, + crates/std/src/ctx_ext.rs, crates/std/src/runtime_ext.rs, src/runtime.rs +──────────────────────────────────────── +OS Concept: Service discovery +Swactor Equivalent: ServiceRegistry + CtxResources (ctx.resource::() -> Option) +Where: src/actor.rs, crates/std/src/service_registry.rs, crates/std/src/ctx_ext.rs, + crates/std/src/runtime_ext.rs, crates/std/src/extension.rs +──────────────────────────────────────── +OS Concept: Resource request API (typed handles) +Swactor Equivalent: ResourceHandle trait + CtxHandles (ctx.handle::() -> Option) +Where: crates/std/src/resource_handle.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Exit codes / rich exit values +Swactor Equivalent: ExitValue(Arc), ctx.stop_with(value), + StopReason::Completed, ExitReason::Completed. Exit values propagated via Down/ActorExited. +Where: src/actor.rs, src/worker.rs, crates/std/src/extension.rs, crates/std/src/watch_registry.rs +──────────────────────────────────────── +OS Concept: Parent-child hierarchy + orphan handling +Swactor Equivalent: ChildrenRegistry tracks parent->children. On parent death, unsupervised + children are killed (StopSignal). Supervised children are left to their supervisor. Cascades + naturally across generations via tick-based cleanup. +Where: crates/std/src/children_registry.rs, crates/std/src/extension.rs +──────────────────────────────────────── +OS Concept: Suspend/resume (SIGSTOP/SIGCONT) +Swactor Equivalent: ctx.suspend_self(), ctx.resume(target) with auth (self or supervisor only). + Suspended actors queue messages but don't process them. ResumeSignal via transfer queue for + cross-worker resume. +Where: src/actor.rs, src/worker.rs, src/runtime.rs, crates/std/src/ctx_ext.rs +──────────────────────────────────────── +OS Concept: Capability model / sandboxing +Swactor Equivalent: CapabilitySet stored in actor's Environment. Enforced at Ctx level (send, + spawn, stop_actor, monitor, resource). Opt-in: actors without a CapabilitySet are unrestricted. +Where: src/actor.rs, crates/std/src/ctx_ext.rs + +### Still partially there + +OS Concept: Resource limits +What Exists: Mailbox capacity + message budget +What's Missing: No per-actor memory/CPU/fd limits +──────────────────────────────────────── +OS Concept: Auth/permissions +What Exists: Datastore ACL + node-level peer auth + actor-level CapabilitySet +What's Missing: Cluster-level capability propagation (local-only today) + +--- +## Part 2: Design Primitives + +The design followed the existing extension pattern: new capabilities were added as extension traits +on Ctx<'_>, backed by registries in the extension system. This preserved backwards compatibility +and kept the core minimal. + +### 2.1 System Queries (CtxSystem) + +What it enables: An actor can ask about the system it's running in. + +Implemented queries (available via ctx.system_info() or the CtxSystem extension trait): +- ctx.worker_id() -> usize -- which worker thread am I on? +- ctx.num_workers() -> usize -- how many worker threads exist? +- ctx.total_actors() -> usize -- live actors across all workers +- ctx.uptime_ms() -> u64 -- milliseconds since runtime creation + +Implementation: SystemInfo struct in src/actor.rs. ContextInner::system_info() implemented on +both Runtime (for spawn-time context) and WorkerContext (for handler context). Data flows through +TickContext (worker_stats + created_at fields in src/delivery.rs). The CtxSystem extension trait +in crates/std/src/ctx_ext.rs provides ergonomic per-field accessors. + +Future cluster-level queries (not yet implemented): +- What is my node's identity (NodeId)? +- How many cluster nodes are alive? +- Who are the cluster members? + +These require the distribution crate's DistributedNode state to be exposed through the extension +system. The CtxSystem trait can be extended with these when the distribution integration is ready. + +### 2.2 Process Environment (CtxEnvironment) + +What it enables: Typed configuration that flows from parent to child at spawn time. + +Properties: +- Inherited: When actor A spawns actor B via ctx.spawn(), B gets A's environment (Arc clone) +- Overridable: ctx.spawn_builder(actor).env(Key(val)).finish() lazily clones the parent's map + on first override (copy-on-write), leaving the common case (no overrides) allocation-free +- Immutable after spawn: Set at creation, read-only thereafter. Mutable config goes through + messages. +- Typed values: TypeId-keyed (like http::Extensions), not string-to-string +- Runtime-spawned actors start with an empty environment + +Implementation: Environment is Arc>> -- clone is an +Arc bump (zero allocation). EnvironmentBuilder provides from_env() for copy-on-write overrides +(cloning individual entries is cheap since values are also Arc-wrapped). The spawn channel was +replaced with a SpawnRequest struct (addr, actor, parent, env) to avoid further tuple growth. +ActorSlot stores env, and Ctx receives it at both construction sites (tick_all and cleanup_dead). +SpawnBuilder provides the ergonomic override API. The CtxEnvironment extension trait in +crates/std/src/ctx_ext.rs provides the import path, following the same pattern as CtxLineage +(no StdExtension dependency required). Python crate spawns with Environment::new(). 6 scenario +tests in tests/std_extension.rs cover: inheritance, empty for runtime-spawned, grandchild chain, +override-one-inherit-others, readable in on_stop, and sibling independence. + +Well-known keys: +- SpawnTimestamp(u64): Injected by StdExtension's on_spawn hook. Milliseconds since runtime + creation, same time base as SystemInfo::uptime_ms. Opt-in at runtime level (present when + StdExtension is installed). Read via ctx.env::(). +- LogicalName(String): Injected by spawn_named() at both ctx and Runtime levels. Inherited by + children via normal environment inheritance. Read via ctx.env::(). +- ServiceBinding(ActorAddress): Injected by ServiceRegistry's inject_into() hook during + on_spawn. Registered at runtime level via rt.register_service::(addr). Read via + ctx.resource::() (CtxResources trait). Overridable per-subtree via spawn_builder. +- CapabilitySet: Granted at spawn time (via environment or spawn_builder). Inherited by children. + Enforced at Ctx level. See section 2.7. + +Analogy: Unix environ -- inherited by default, augmented at fork/exec time, readable via getenv(). + +### 2.3 Service Discovery (CtxResources) + +What it enables: Actors can discover system services by type, not by knowing raw addresses. + +How it differs from NameRegistry: NameRegistry maps strings to addresses. CtxResources maps +service marker types to addresses. Looking up "datastore" by name gives you a raw ActorAddress and +you must know what messages it accepts. ctx.resource::() gives you the address of the +service registered under that marker type. + +Implementation: Three layers compose the feature: + +1. Core type: ServiceBinding(ActorAddress) in src/actor.rs -- a generic environment key + parameterized by a zero-sized marker type. Any struct satisfying 'static + Send + Sync works + as a marker (no special Service trait required, consistent with Environment's existing API). + +2. Registry + injection: ServiceRegistry in crates/std/src/service_registry.rs stores registered + bindings as RwLock>> (same thread-safety pattern + as SupervisorRegistry). StdExtension's on_spawn hook calls inject_into() before adding + SpawnTimestamp -- this merges all registered bindings into the actor's environment, skipping + keys already present (preserves per-subtree overrides set via spawn_builder). Helper methods + on Environment (contains_type_id) and EnvironmentBuilder (set_raw) support type-erased + injection without knowing concrete types at compile time. + +3. Read API: CtxResources trait in crates/std/src/ctx_ext.rs provides ctx.resource::() -> + Option, a thin wrapper around ctx.env::>().map(|b| b.addr). + Does NOT require StdExtension -- reads from core environment (same pattern as CtxEnvironment). + When a CapabilitySet is present, resource() checks check_service::() and returns None if + denied. RuntimeResources trait in crates/std/src/runtime_ext.rs provides + rt.register_service::(addr) for startup-time registration. + +Key design decisions: +- No Service marker trait: S: 'static + Send + Sync is sufficient. Any zero-size struct works. +- "Skip if present" injection: The registry doesn't overwrite env keys set by spawn_builder, + enabling per-subtree service overrides (e.g., test doubles, staging vs production services). +- No cleanup on service actor death: A dead service's binding stays in the registry (stale + address). Sends to it will fail. Service lifecycle management is a higher-level concern. + +6 scenario tests in tests/std_extension.rs cover: discovery by marker type, child inherits +binding from parent, multiple services each accessible by marker, unregistered returns None, +overridable via spawn_builder, accessible in on_start and on_stop lifecycle hooks. + +Well-known services that could be registered (when swactor-node is updated): +- Storage -- content-addressed datastore (currently wired manually in swactor-node) +- Directory -- actor location resolution (currently locked inside DistributedNode) +- Cluster -- membership/topology info (currently snapshot-only for dashboard) +- Metrics -- runtime stats (currently StatsHook push-only) + +### 2.4 Resource Handles (CtxHandles) + +What it enables: Domain-specific typed proxies that wrap service addresses and provide ergonomic +APIs. + +The pattern: A handle wraps (service_address, self_address) and provides methods that construct +and send the right messages, embedding self_addr as reply_to. Responses arrive as normal messages +in the actor's handle(). + +Implementation: The ResourceHandle trait in crates/std/src/resource_handle.rs defines the contract: +- type Service: 'static + Send + Sync -- the marker type used for service discovery +- from_parts(service_addr, self_addr) -> Self -- construct from addresses +- service_addr() -> ActorAddress -- the underlying service address +- self_addr() -> ActorAddress -- the actor's own address (for reply_to) + +The CtxHandles extension trait in crates/std/src/ctx_ext.rs provides ctx.handle::() -> Option, +which looks up ServiceBinding from the actor's environment and constructs the handle. +Returns None if the service is not registered (consistent with ctx.resource(), ctx.where_is()). + +Handle methods take &self + &Ctx (not stored &Ctx -- avoids lifetime issues with &mut self in +handlers). Example: + impl MyHandle { + pub fn do_work(&self, ctx: &Ctx, data: Vec) -> Result<(), Error> { + ctx.send(self.service_addr(), MyMsg::DoWork { data, reply_to: self.self_addr() }) + } + } + +Key design tension: Handles can't block (no await in swactor). The response arrives asynchronously +as a message. This is inherent to the actor model and not something to "fix" -- the handle just +makes the send side ergonomic. + +5 scenario tests: handle wraps service and sends ergonomically, returns None when service not +registered, inherits service binding from parent, constructible in on_start, two actors with same +handle type each get responses at their own address. + +### 2.5 Process Lineage (CtxLineage) + +What it enables: Actors know their ancestry. + +Implemented queries: +- ctx.parent() -> Option (who spawned me?) + Returns Some(spawner_addr) for actor-spawned children, None for Runtime::spawn(). + Available in handle(), on_start(), and on_stop(). +- ctx.supervisor() -> Option (who supervises me, if anyone?) + Returns Some(supervisor_addr) for supervised children, None for unsupervised actors. + Gracefully returns None when StdExtension is absent (no panic). + +Implementation (parent): The spawn channel uses a SpawnRequest struct (addr, actor, parent, env) -- +the original 3-tuple was replaced when CtxEnvironment was added. When Ctx::spawn is called, the +spawning actor's self_addr is passed as Some(parent). Runtime::spawn passes None. The parent is +stored in ActorSlot::parent_addr and threaded into Ctx::self_parent_addr at both construction sites +(tick_all and cleanup_dead). 4 scenario tests cover: child knows parent, runtime-spawned has no +parent, grandchild sees immediate parent (not grandparent), and parent is visible in on_stop. + +Implementation (supervisor): SupervisorRegistry in crates/std/src/supervisor_registry.rs stores a +child_addr -> supervisor_addr map (RwLock>). Supervisor::start_child calls +register(self_addr, child_addr) after spawning and monitoring. cleanup() removes entries where the +dead address is either child or supervisor (O(n) scan for supervisor death, acceptable since +supervisor death is rare and the map is small). CtxLineage::supervisor() downcasts the extension +gracefully (returns None if StdExtension is absent). 5 scenario tests cover: supervised child knows +supervisor, unsupervised actor returns None, supervisor survives child restart, grandchild not +supervised but parent is, OneForAll restart re-registers all children. + +The CtxLineage extension trait in crates/std/src/ctx_ext.rs provides the ergonomic import path. + +Orphan handling was implemented as part of item 8 (Lifecycle Enrichment) -- see section 2.8. + +### 2.6 Self-Introspection (CtxSelfStats) + +What it enables: Actors can see their own operational metrics. + +Implemented queries (available directly on Ctx or via the CtxSelfStats extension trait): +- ctx.messages_processed() -> u64 -- total successfully processed before current tick +- ctx.mailbox_depth() -> usize -- messages queued at start of current tick (pre-dequeue) +- ctx.message_type_counts() -> &[(&str, u64)] -- per-type counts, sorted descending + +Implementation: Stats are snapshotted from ActorSlot fields into Ctx before each tick_all +iteration (src/worker.rs). The snapshot captures the state before any messages are dequeued +in the current tick, giving actors a consistent view. The same snapshot is provided during +on_stop callbacks in cleanup_dead. The CtxSelfStats extension trait in crates/std/src/ctx_ext.rs +provides the ergonomic import path. + +The Vec allocation for type counts is bounded (max 32 entries from ActorSlot's msg_type_counts +cap) and negligible relative to handle_any cost. + +### 2.7 Capability Model (CapabilitySet + CtxCapabilities) + +What it enables: Controlled access to system resources and other actors. Primarily important for +sandboxing untrusted code (wasm actors in crates/bin-runner/). + +Approach: A single CapabilitySet stored in the actor's Environment. When present, enforcement is +active -- the actor can only perform operations granted by the set. When absent, the actor is +unrestricted (backward compatible). Capabilities inherit from parent to child via normal +environment inheritance. + +Capability grants (all in CapabilitySet): +- with_send(addr) -- send any message type to a specific address +- with_send_typed::(addr) -- send only messages of type M to a specific address +- with_spawn() -- permission to spawn new actors +- with_service::() -- permission to access system service S via ctx.resource::() +- with_monitor(addr) -- permission to monitor a specific actor + +Enforcement points (all in src/actor.rs Ctx methods or crates/std/src/ctx_ext.rs): +- ctx.send::(addr, msg) -- checks check_send::(addr); self-send always allowed +- ctx.spawn() / SpawnBuilder::finish() -- checks check_spawn() +- ctx.stop_actor(addr) -- checks check_send_addr(addr) (stop is a send of StopSignal) +- ctx.monitor(addr) -- checks check_monitor(addr); returns Result +- ctx.resource::() -- checks check_service::(); returns None if denied + +Key design decisions: +- Opt-in: No CapabilitySet in environment means unrestricted. Zero behavioral change for existing + actors. The only cost is an Option check (env.get::()) at each enforcement point. +- Enforcement at Ctx level only: The core ContextInner::send_any is not gated. This means + extension code (supervisors, timers, etc.) that calls send_any directly bypasses capability + checks, which is intentional -- system infrastructure is trusted. +- Dual send granularity: with_send(addr) grants all message types to an address. + with_send_typed::(addr) grants only type M. The check tries address-only first, then typed. + This allows coarse grants for trusted peers and fine-grained grants for untrusted actors. +- Self-send always allowed: A restricted actor can always send to its own address. This prevents + capabilities from breaking actors that use self-messaging patterns (timers, state machines). +- monitor() returns Result: Changed from -> MonitorRef to -> Result. This was + a breaking change to all callers (supervisor.rs, router.rs, test files), fixed mechanically by + adding ? or .unwrap(). + +Builder API: Fluent (CapabilitySet::new().with_send(addr).with_spawn()) and mutable +(caps.grant_send(addr)) variants. Mutable methods return &mut Self for chaining. + +Introspection: CtxCapabilities extension trait in crates/std/src/ctx_ext.rs provides: +- ctx.capabilities() -> Option<&CapabilitySet> -- access the raw set +- ctx.is_restricted() -> bool -- quick check + +Implementation locations: +- src/actor.rs: CapabilitySet struct, builder methods, check methods, Ctx::capabilities() helper, + enforcement in send/spawn/stop_actor/SpawnBuilder::finish +- src/lib.rs: CapabilitySet re-export +- crates/std/src/ctx_ext.rs: CtxCapabilities trait, monitor() enforcement, resource() enforcement +- crates/std/src/lib.rs: CtxCapabilities re-export + +11 scenario tests in tests/std_extension.rs cover: unrestricted actor sends freely (backward +compat), restricted actor denied send, restricted actor allowed send, typed send grant (Ping +allowed / Pong denied), spawn denied, spawn allowed, capability inheritance (child inherits +parent's CapabilitySet), monitor denied, service access denied, self-send always allowed, stop +requires send permission. + +### 2.8 Lifecycle Enrichment + +Rich exit values: ExitValue(Arc) is an opaque typed wrapper. Actors stop +with ctx.stop_with(value) which stores the value and triggers StopReason::Completed. The value +is propagated through Down (monitors) and ActorExited (watchers) via the exit_value: Option +field. Manual PartialEq/Eq on ExitValue (always false -- opaque blob), so Down/ActorExited compare +by addr+reason only. + +Implementation: StopWithSignal(ExitValue) is a sentinel message intercepted in tick_all (like +StopSignal). ActorSlot gains exit_value: Option. cleanup_dead returns +Vec<(ActorAddress, StopReason, Option)> with StopReason::Completed when exit_value is +present. The on_actor_death extension hook receives and propagates exit values to monitors/watchers. + +7 scenario tests: stop_with value received in Down, received in ActorExited, normal stop has None, +panic has None, multiple monitors receive cloned value, stop_with from on_start, supervisor receives +rich exit in handle_down (graceful handoff pattern). + +Orphan handling: ChildrenRegistry tracks parent -> set of children. Populated in on_spawn when a +parent is present. On parent death (on_actor_death), unsupervised children receive StopSignal. +Supervised children are left to their supervisor. Cascades naturally: parent dies -> children killed +next tick -> grandchildren killed the tick after that. StopSignal made pub (was pub(crate)) to +enable this -- it's not Message (not Clone) so can't be sent via ctx.send(). + +4 scenario tests: unsupervised children killed on parent death, supervised children not killed, +cascading cleanup across generations, runtime-spawned actors unaffected. + +Suspend/resume: ActorSlot gains a suspended: bool flag. Suspended actors queue messages but don't +process them (tick_all skips them). ctx.suspend_self() sets the flag via a suspend_requests buffer. +ResumeSignal is intercepted in deliver() to clear the flag. StopSignal/StopWithSignal are also +intercepted for suspended actors (so stop_actor works on them). Cross-worker resume sends +ResumeSignal via the transfer queue. + +Authorization: CtxLifecycle extension trait provides ctx.suspend_self() (always allowed) and +ctx.resume(target) which checks: target == self (self-resume) OR caller is the target's supervisor +via SupervisorRegistry. Returns Err if unauthorized. + +5 scenario tests: suspended actor queues then resume processes, supervisor can resume, non-supervisor +cannot resume, suspended actor can be stopped, cross-worker resume via runtime. + +Graceful handoff: Built on rich exit values. An outgoing actor stops with its state via +ctx.stop_with(state); the supervisor receives it in handle_down's Down message and can pass it +to the replacement's constructor. Enables zero-downtime upgrades. No additional mechanism needed -- +the pattern composes from existing primitives. + +--- +## Part 3: How These Compose + +The primitives form a layered system: + +Layer 3: Integration (swactor-node wires services at startup) +Layer 2: Process (CapabilitySet, ProcessBuilder) +Layer 1: Std (CtxSystem, CtxEnvironment, CtxLineage, CtxSelfStats, Well-known env keys, + SupervisorRegistry, CtxResources, CtxHandles, CtxLifecycle, ChildrenRegistry, + CtxCapabilities) +Layer 0: Core (SystemInfo, Ctx self-stats, parent tracking, Environment + SpawnRequest, + on_spawn hook, spawn_with_env, ServiceBinding, suspend flag, rich exit, orphan + handling, CapabilitySet) + +A "process" in swactor is an actor that has: +1. An identity (ActorAddress) and a name (NameRegistry) +2. A parent and supervisor it can query (CtxLineage) +3. An environment inherited from its spawner, with well-known keys (CtxEnvironment) +4. Access to system services through discovery (CtxResources) +5. The ability to query the system it lives in (CtxSystem) +6. Awareness of its own operational state (CtxSelfStats) +7. Typed resource handles for ergonomic service interaction (CtxHandles) +8. Rich lifecycle support including typed exit values, orphan handling, and suspend/resume +9. Controlled permissions for what it can access (CapabilitySet) + +What stayed the same: The core actor model (message passing, mailboxes, workers, tick-based +execution) was unchanged. ActorInterface, Ctx, Runtime remained the foundation. The process +abstraction was additive -- existing actors continued to work exactly as before. + +--- +## Part 4: Implementation Sequence + +Each item was implemented and merged in dependency order. Earlier items established the +infrastructure (Environment, extension hooks) that later items built on. + +1. **CtxSystem + CtxSelfStats** -- Exposed existing internal data to actors. SystemInfo struct, + ContextInner::system_info(), Ctx self-stats snapshot fields. Extension traits CtxSystem and + CtxSelfStats in swactor-std. Covered by 3 scenario tests. + +2. **CtxLineage (parent tracking)** -- Option threaded through the spawn path. + ContextInner::spawn_any gained a parent parameter. ActorSlot stores parent_addr. Ctx exposes + parent(). CtxLineage extension trait in swactor-std. 4 scenario tests. + Python crate updated to pass parent on spawn. + +3. **CtxEnvironment (process environment)** -- Typed key-value map inherited from parent to + child at spawn time. Environment is Arc>> -- clone + is an Arc bump. EnvironmentBuilder supports copy-on-write overrides via from_env(). The spawn + channel 3-tuple was replaced with a SpawnRequest struct (addr, actor, parent, env) to stop + tuple growth. ActorSlot stores env. Ctx gains env::(), environment(), and spawn_builder(). + SpawnBuilder lazily clones the parent's map on first .env() call. CtxEnvironment extension trait + in swactor-std (no StdExtension dependency). Python crate spawns with Environment::new(). + 6 scenario tests: inheritance, empty for runtime-spawned, grandchild chain, + override-one-inherit-others, readable in on_stop, sibling independence. + +4. **Well-known environment keys** -- SpawnTimestamp(u64) and LogicalName(String) types in + src/actor.rs, exported from src/lib.rs. SpawnTimestamp is opt-in at runtime level: injected by + StdExtension's on_spawn hook (new RuntimeExtension::on_spawn hook with default no-op in + src/extension.rs). Worker::drain_spawns now takes &TickContext and calls on_spawn for each + spawn request, passing uptime_ms to avoid exposing the pub(crate) Instant type. LogicalName is + injected by spawn_named at both ctx level (via spawn_builder + env override) and runtime level + (via new Runtime::spawn_with_env method). LogicalName inherits to children automatically via + normal environment inheritance. 7 scenario tests. + +5. **Supervisor lineage (ctx.supervisor())** -- SupervisorRegistry in + crates/std/src/supervisor_registry.rs stores child_addr -> supervisor_addr as + RwLock>. Supervisor::start_child calls register() after spawning and + monitoring. cleanup() removes entries for dead actors (both as child and as supervisor). + CtxLineage::supervisor() gracefully returns None when StdExtension is absent (downcasts via + as_any, no panic). Distinct from parent() because not every parent is a supervisor. get_ext + made pub(crate) so supervisor.rs can access it. 5 scenario tests. + +6. **Service Registry + CtxResources** -- Actors discover system services by type + (ctx.resource::()) rather than by raw address. ServiceBinding(ActorAddress) + is a generic environment key parameterized by a marker type. ServiceRegistry in StdExtension + stores bindings and injects them into every actor's environment via on_spawn (skipping keys + already present to preserve spawn_builder overrides). CtxResources trait provides + ctx.resource::() sugar. RuntimeResources trait provides rt.register_service::(addr). + 6 scenario tests. + +7. **Resource Handles (CtxHandles)** -- ResourceHandle trait + CtxHandles extension trait. + ctx.handle::() -> Option constructs typed proxies from ServiceBinding in the + actor's environment. Handle methods take &self + &Ctx for ergonomic domain-specific APIs. + 5 scenario tests. + +8. **Lifecycle enrichment** -- Three sub-features: + a) Rich exit values: ExitValue(Arc), ctx.stop_with(value), + StopReason::Completed, ExitReason::Completed. Propagated through Down/ActorExited. + 7 scenario tests. + b) Orphan handling: ChildrenRegistry tracks parent->children. Unsupervised children killed + on parent death. Supervised children left to their supervisor. Natural cascade. + 4 scenario tests. + c) Suspend/resume: ActorSlot::suspended flag, ctx.suspend_self(), ctx.resume(target) with + auth (self or supervisor only). ResumeSignal for cross-worker resume. + 5 scenario tests. + +9. **Capability model (CapabilitySet)** -- Per-actor permission set stored in the Environment. + Grants: with_send(addr), with_send_typed::(addr), with_spawn(), with_service::(), + with_monitor(addr). Enforced at Ctx level in send, spawn, stop_actor, monitor, and resource. + Opt-in: actors without a CapabilitySet are unrestricted (zero behavioral change). Self-send + always allowed. monitor() changed from -> MonitorRef to -> Result (breaking + change, fixed mechanically in supervisor.rs, router.rs, and all test files). CtxCapabilities + extension trait for introspection. 11 scenario tests. diff --git a/src/actor.rs b/src/actor.rs index 29c4a9f..ddee803 100644 --- a/src/actor.rs +++ b/src/actor.rs @@ -1,7 +1,60 @@ use std::any::Any; +use std::any::TypeId; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use crate::Error; +// ─── ExitValue ────────────────────────────────────────────────────────────── + +/// Opaque typed value attached to a completed actor's exit. +/// +/// Created via [`Ctx::stop_with`]. Delivered to monitors/watchers in +/// [`Down::exit_value`] and [`ActorExited::exit_value`]. +/// +/// Clone is an `Arc` bump (zero allocation). +#[derive(Clone)] +pub struct ExitValue(Arc); + +impl ExitValue { + /// Wrap a typed value as an opaque exit value. + pub fn new(value: T) -> Self { + Self(Arc::new(value)) + } + + /// Attempt to downcast to a concrete type by reference. + pub fn downcast_ref(&self) -> Option<&T> { + self.0.downcast_ref::() + } +} + +impl PartialEq for ExitValue { + fn eq(&self, _other: &Self) -> bool { + false // opaque blob — always not equal + } +} + +impl Eq for ExitValue {} + +impl std::fmt::Debug for ExitValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ExitValue(..)") + } +} + +/// System-level information visible to actors. +#[derive(Debug, Clone)] +pub struct SystemInfo { + /// Index of the worker thread this actor is running on. + pub worker_id: usize, + /// Total number of worker threads in the runtime. + pub num_workers: usize, + /// Total number of live actors across all workers. + pub total_actors: usize, + /// Milliseconds since the runtime was created. + pub uptime_ms: u64, +} + /// Why an actor exited. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -12,6 +65,8 @@ pub enum ExitReason { Panicked, /// The node hosting the actor left the cluster (SWIM Dead). NodeDown, + /// Actor stopped with a typed exit value (via [`Ctx::stop_with`]). + Completed, } /// Delivered to watchers when a watched actor exits. @@ -25,8 +80,19 @@ pub struct ActorExited { pub addr: ActorAddress, /// Why it exited. pub reason: ExitReason, + /// Typed exit value if the actor called [`Ctx::stop_with`]. + #[cfg_attr(feature = "serde", serde(skip))] + pub exit_value: Option, } +impl PartialEq for ActorExited { + fn eq(&self, other: &Self) -> bool { + self.addr == other.addr && self.reason == other.reason + } +} + +impl Eq for ActorExited {} + /// The primary trait defining data that can be passed to and from actor processes pub trait Message: 'static + Sized + Clone + Send + Sync {} impl Message for T {} @@ -100,6 +166,250 @@ impl ActorAddress { } } +// ─── Environment ───────────────────────────────────────────────────────────── + +/// A typed key-value map that flows from parent to child at spawn time. +/// +/// Analogous to Unix `environ` — provides inherited configuration without +/// threading values through every constructor. Clone is an Arc bump (zero allocation). +/// +/// Values are stored as `Arc` so that [`EnvironmentBuilder::from_env`] +/// can clone individual entries cheaply (Arc bump) for copy-on-write overrides. +#[derive(Clone, Default)] +pub struct Environment { + inner: Arc>>, +} + +impl Environment { + /// Create an empty environment. + pub fn new() -> Self { + Self { + inner: Arc::new(HashMap::new()), + } + } + + /// Read a typed value from the environment. + pub fn get(&self) -> Option<&T> { + self.inner + .get(&TypeId::of::()) + .and_then(|v| v.downcast_ref::()) + } + + /// Check if the environment contains a value of type `T`. + pub fn contains(&self) -> bool { + self.inner.contains_key(&TypeId::of::()) + } + + /// Returns `true` if the environment has no values. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Number of typed values in the environment. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Check if the environment contains a value with the given `TypeId`. + /// + /// Type-erased version of [`contains`](Self::contains) — used by + /// `ServiceRegistry::inject_into` to skip keys already present. + pub fn contains_type_id(&self, type_id: TypeId) -> bool { + self.inner.contains_key(&type_id) + } +} + +/// Builder for constructing an [`Environment`]. +/// +/// Allows inserting/replacing typed values before freezing into an immutable `Environment`. +pub struct EnvironmentBuilder { + map: HashMap>, +} + +impl EnvironmentBuilder { + /// Create an empty builder. + pub fn new() -> Self { + Self { + map: HashMap::new(), + } + } + + /// Create a builder pre-populated with values from an existing environment. + /// + /// This enables copy-on-write overrides: clone the parent's map, modify, then freeze. + /// Cloning entries is cheap — each value is `Arc`-wrapped. + pub fn from_env(env: &Environment) -> Self { + let map = env.inner.as_ref().clone(); + Self { map } + } + + /// Insert or replace a typed value. + pub fn set(mut self, value: T) -> Self { + self.map.insert(TypeId::of::(), Arc::new(value)); + self + } + + /// Insert or replace a typed value (mutable reference version). + pub fn set_mut(&mut self, value: T) -> &mut Self { + self.map.insert(TypeId::of::(), Arc::new(value)); + self + } + + /// Insert a type-erased value by `TypeId`. + /// + /// Used by `ServiceRegistry::inject_into` to merge pre-built bindings + /// without knowing concrete types at compile time. + pub fn set_raw(&mut self, type_id: TypeId, value: Arc) -> &mut Self { + self.map.insert(type_id, value); + self + } + + /// Freeze the builder into an immutable `Environment`. + pub fn build(self) -> Environment { + Environment { + inner: Arc::new(self.map), + } + } +} + +impl Default for EnvironmentBuilder { + fn default() -> Self { + Self::new() + } +} + +// ─── Well-Known Environment Keys ───────────────────────────────────────────── + +/// Milliseconds since runtime creation when this actor was spawned. +/// Injected by StdExtension (opt-in at runtime level). Read via `ctx.env::()`. +/// Uses the same time base as `SystemInfo::uptime_ms`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SpawnTimestamp(pub u64); + +/// Logical name assigned via `spawn_named()`. Read via `ctx.env::()`. +/// None for unnamed actors. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LogicalName(pub String); + +impl LogicalName { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A typed service binding stored in the environment. +/// +/// `S` is a zero-sized marker type that identifies the service (e.g., `struct Datastore;`). +/// Stored via `ServiceRegistry` and read via `ctx.resource::()`. +#[derive(Clone, Debug)] +pub struct ServiceBinding { + pub addr: ActorAddress, + _marker: std::marker::PhantomData, +} + +impl ServiceBinding { + pub fn new(addr: ActorAddress) -> Self { + Self { + addr, + _marker: std::marker::PhantomData, + } + } +} + +// ─── Capabilities ──────────────────────────────────────────────────────────── + +/// Per-actor capability set controlling what operations the actor can perform. +/// +/// When present in an actor's [`Environment`], enforcement is active — the actor +/// can only perform operations granted by the set. When absent, the actor is +/// unrestricted (backward compatible). Inherits from parent to child via normal +/// environment inheritance. +/// +/// Built via fluent API: `CapabilitySet::new().with_send(addr).with_spawn()`. +#[derive(Clone, Default)] +pub struct CapabilitySet { + send_any: HashSet, + send_typed: HashSet<(TypeId, ActorAddress)>, + can_spawn: bool, + service_types: HashSet, + monitor_targets: HashSet, +} + +impl CapabilitySet { + pub fn new() -> Self { Self::default() } + + // ── Builder methods (fluent) ── + pub fn with_send(mut self, addr: ActorAddress) -> Self { + self.send_any.insert(addr); self + } + pub fn with_send_typed(mut self, addr: ActorAddress) -> Self { + self.send_typed.insert((TypeId::of::(), addr)); self + } + pub fn with_spawn(mut self) -> Self { + self.can_spawn = true; self + } + pub fn with_service(mut self) -> Self { + self.service_types.insert(TypeId::of::()); self + } + pub fn with_monitor(mut self, addr: ActorAddress) -> Self { + self.monitor_targets.insert(addr); self + } + + // ── Mutable builder methods ── + pub fn grant_send(&mut self, addr: ActorAddress) -> &mut Self { + self.send_any.insert(addr); self + } + pub fn grant_send_typed(&mut self, addr: ActorAddress) -> &mut Self { + self.send_typed.insert((TypeId::of::(), addr)); self + } + + // ── Check methods ── + pub fn check_send(&self, addr: ActorAddress) -> Result<(), crate::Error> { + if self.send_any.contains(&addr) { return Ok(()); } + if self.send_typed.contains(&(TypeId::of::(), addr)) { return Ok(()); } + Err(crate::Error::from("capability denied: send")) + } + pub fn check_send_addr(&self, addr: ActorAddress) -> Result<(), crate::Error> { + if self.send_any.contains(&addr) { return Ok(()); } + Err(crate::Error::from("capability denied: send")) + } + pub fn check_spawn(&self) -> Result<(), crate::Error> { + if self.can_spawn { Ok(()) } else { Err(crate::Error::from("capability denied: spawn")) } + } + pub fn check_service(&self) -> Result<(), crate::Error> { + if self.service_types.contains(&TypeId::of::()) { Ok(()) } + else { Err(crate::Error::from("capability denied: service")) } + } + pub fn check_monitor(&self, addr: ActorAddress) -> Result<(), crate::Error> { + if self.monitor_targets.contains(&addr) { Ok(()) } + else { Err(crate::Error::from("capability denied: monitor")) } + } +} + +impl std::fmt::Debug for CapabilitySet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CapabilitySet") + .field("send_any", &self.send_any.len()) + .field("send_typed", &self.send_typed.len()) + .field("can_spawn", &self.can_spawn) + .field("services", &self.service_types.len()) + .field("monitors", &self.monitor_targets.len()) + .finish() + } +} + +// ─── SpawnRequest ──────────────────────────────────────────────────────────── + +/// Bundled arguments for spawning an actor. +/// +/// Replaces the spawn channel 3-tuple to stop tuple growth as new fields are added. +pub struct SpawnRequest { + pub addr: ActorAddress, + pub actor: Box, + pub parent: Option, + pub env: Environment, +} + /// The actor process as represented in the Runtime — thin wrapper around user state. pub struct Actor { inner: A, @@ -181,23 +491,45 @@ pub enum StopReason { Normal, /// Actor panicked and could not be restarted. Panicked, + /// Actor stopped with a typed exit value (via [`Ctx::stop_with`]). + Completed, } /// Death notification delivered as a normal message when a monitored actor dies. /// /// Subscribe via [`Ctx::monitor`]. The `Down` message arrives in the watcher's /// regular `handle()` method — no special callback needed. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct Down { /// Address of the dead actor. pub addr: ActorAddress, /// Why it died. pub reason: StopReason, + /// Typed exit value if the actor called [`Ctx::stop_with`]. + pub exit_value: Option, } +impl PartialEq for Down { + fn eq(&self, other: &Self) -> bool { + self.addr == other.addr && self.reason == other.reason + } +} + +impl Eq for Down {} + /// Internal sentinel message for graceful actor stop. -/// Not a `Message` — intercepted in `tick_all` before reaching `handle_any`. -pub(crate) struct StopSignal; +/// Not a `Message` (not Clone) — intercepted in `tick_all` / `deliver` before +/// reaching `handle_any`. Public so extension crates can construct it for +/// orphan cleanup, but users cannot send it via `ctx.send()`. +pub struct StopSignal; + +/// Like [`StopSignal`] but carries a typed exit value. +/// Intercepted in `tick_all` / `deliver`. +pub struct StopWithSignal(pub ExitValue); + +/// Internal sentinel message for resuming a suspended actor. +/// Intercepted in [`ActorPool::deliver`] — not delivered to user code. +pub(crate) struct ResumeSignal; /// Object-safe inner trait for sending type-erased messages. /// @@ -207,13 +539,21 @@ pub(crate) struct StopSignal; #[allow(private_interfaces)] pub trait ContextInner { fn send_any(&self, addr: ActorAddress, msg: Box) -> Result<(), Error>; - fn spawn_any(&self, addr: ActorAddress, actor: Box); + fn spawn_any(&self, request: SpawnRequest); /// Request graceful stop for an actor. Takes effect after the current message. fn request_stop(&self, addr: ActorAddress); + /// Request graceful stop with a typed exit value. Takes effect after the current message. + fn request_stop_with(&self, addr: ActorAddress, value: ExitValue); + /// Request suspension for an actor. Takes effect after the current message. + fn request_suspend(&self, addr: ActorAddress); + /// Request resumption for a suspended actor. Sends a [`ResumeSignal`]. + fn request_resume(&self, addr: ActorAddress); /// Post a request to the per-worker extension (e.g., timer scheduling). fn post_worker_request(&self, request: Box); /// Access the runtime extension (if installed). fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension>; + /// Return system-level information (worker count, actor count, uptime). + fn system_info(&self) -> SystemInfo; } /// Actor syscall interface — passed to `ActorInterface::handle()`. @@ -224,11 +564,32 @@ pub trait ContextInner { pub struct Ctx<'a> { inner: &'a dyn ContextInner, self_addr: ActorAddress, + self_parent_addr: Option, + self_env: Environment, + self_messages_processed: u64, + self_mailbox_depth: usize, + self_msg_type_counts: Vec<(&'static str, u64)>, } impl<'a> Ctx<'a> { - pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self { - Self { inner, self_addr } + pub(crate) fn new( + inner: &'a dyn ContextInner, + self_addr: ActorAddress, + self_parent_addr: Option, + self_env: Environment, + self_messages_processed: u64, + self_mailbox_depth: usize, + self_msg_type_counts: Vec<(&'static str, u64)>, + ) -> Self { + Self { + inner, + self_addr, + self_parent_addr, + self_env, + self_messages_processed, + self_mailbox_depth, + self_msg_type_counts, + } } pub fn raw_inner(&self) -> &dyn ContextInner { @@ -240,24 +601,92 @@ impl<'a> Ctx<'a> { self.self_addr } + /// Returns the address of the actor that spawned this one, or `None` + /// if this actor was spawned externally via `Runtime::spawn`. + pub fn parent(&self) -> Option { + self.self_parent_addr + } + /// Access the runtime extension (if installed). pub fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension> { self.inner.extension() } + /// Return system-level information (worker count, actor count, uptime). + pub fn system_info(&self) -> SystemInfo { + self.inner.system_info() + } + + /// Total messages this actor has successfully processed (before the current tick). + pub fn messages_processed(&self) -> u64 { + self.self_messages_processed + } + + /// Number of messages in this actor's mailbox at the start of the current tick. + pub fn mailbox_depth(&self) -> usize { + self.self_mailbox_depth + } + + /// Per-message-type counts for this actor, sorted descending by count. + pub fn message_type_counts(&self) -> &[(&'static str, u64)] { + &self.self_msg_type_counts + } + + /// Check if this actor has a capability set (i.e., is restricted). + fn capabilities(&self) -> Option<&CapabilitySet> { + self.self_env.get::() + } + /// Send a typed message to an actor address. pub fn send(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { + if let Some(caps) = self.capabilities() { + if addr != self.self_addr { + caps.check_send::(addr)?; + } + } self.inner.send_any(addr, Box::new(msg)) } + /// Read a typed value from this actor's environment. + pub fn env(&self) -> Option<&T> { + self.self_env.get::() + } + + /// Access this actor's full environment. + pub fn environment(&self) -> &Environment { + &self.self_env + } + /// Spawn a new actor, returning its address. + /// + /// The child inherits this actor's environment (Arc clone — zero allocation). pub fn spawn(&self, actor: A) -> Result { + if let Some(caps) = self.capabilities() { + caps.check_spawn()?; + } let addr = ActorAddress::new_random(); let boxed: Box = Box::new(Actor::new(actor)); - self.inner.spawn_any(addr, boxed); + self.inner.spawn_any(SpawnRequest { + addr, + actor: boxed, + parent: Some(self.self_addr), + env: self.self_env.clone(), + }); Ok(addr) } + /// Create a [`SpawnBuilder`] to spawn an actor with environment overrides. + /// + /// Common case (`ctx.spawn(actor)`) is unchanged — this is for when you + /// need to add or replace environment values for the child. + pub fn spawn_builder(&self, actor: A) -> SpawnBuilder<'_, A> { + SpawnBuilder { + ctx: self, + actor, + env_builder: None, + } + } + /// Request graceful stop for this actor after the current message completes. /// /// The actor's `on_stop()` hook is called and the actor is removed from the @@ -272,7 +701,69 @@ impl<'a> Ctx<'a> { /// the stop signal, then its `on_stop()` hook is called and it is removed. /// Uses PoisonPill semantics — queued after existing messages. pub fn stop_actor(&self, addr: ActorAddress) -> Result<(), Error> { + if let Some(caps) = self.capabilities() { + caps.check_send_addr(addr)?; + } self.inner.send_any(addr, Box::new(StopSignal)) } + /// Stop this actor with a typed exit value. + /// + /// Like [`stop_self`](Self::stop_self), but the value is delivered to + /// monitors (in [`Down::exit_value`]) and watchers (in [`ActorExited::exit_value`]). + /// The stop reason is [`StopReason::Completed`]. + pub fn stop_with(&self, value: T) { + self.inner.request_stop_with(self.self_addr, ExitValue::new(value)); + } + + /// Suspend this actor. Messages continue to queue but are not processed + /// until a supervisor (or self) calls resume. + pub fn suspend_self(&self) { + self.inner.request_suspend(self.self_addr); + } +} + +// ─── SpawnBuilder ──────────────────────────────────────────────────────────── + +/// Builder for spawning an actor with environment overrides. +/// +/// Created via [`Ctx::spawn_builder`]. Lazily clones the parent environment +/// on the first `.env()` call to avoid allocation when no overrides are needed. +pub struct SpawnBuilder<'a, A: ActorInterface> { + ctx: &'a Ctx<'a>, + actor: A, + env_builder: Option, +} + +impl<'a, A: ActorInterface> SpawnBuilder<'a, A> { + /// Add or replace a typed environment value for the child. + /// + /// On the first call, lazily clones the parent's environment map. + pub fn env(mut self, value: T) -> Self { + let builder = self.env_builder.get_or_insert_with(|| { + EnvironmentBuilder::from_env(self.ctx.environment()) + }); + builder.set_mut(value); + self + } + + /// Spawn the actor, returning its address. + pub fn finish(self) -> Result { + if let Some(caps) = self.ctx.capabilities() { + caps.check_spawn()?; + } + let addr = ActorAddress::new_random(); + let boxed: Box = Box::new(Actor::new(self.actor)); + let env = match self.env_builder { + Some(builder) => builder.build(), + None => self.ctx.self_env.clone(), + }; + self.ctx.inner.spawn_any(SpawnRequest { + addr, + actor: boxed, + parent: Some(self.ctx.self_addr), + env, + }); + Ok(addr) + } } diff --git a/src/delivery.rs b/src/delivery.rs index b1f3aeb..a0e6ea1 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; use std::thread::Thread; -use crate::actor::{ActorAddress, AnyActor, Message}; +use crate::actor::{ActorAddress, Message, SpawnRequest}; use crate::channel::Sender; use crate::config::RuntimeConfig; use crate::stats::WorkerStats; @@ -235,7 +235,7 @@ impl InboxRegistry { pub(crate) struct TickContext<'a> { pub(crate) address_map: &'a AddressMap, pub(crate) transfer_txs: &'a [Sender], - pub(crate) spawn_txs: &'a [Sender<(ActorAddress, Box)>], + pub(crate) spawn_txs: &'a [Sender], pub(crate) placement: &'a Placement, pub(crate) inbox_registry: &'a InboxRegistry, pub(crate) config: &'a RuntimeConfig, @@ -243,6 +243,10 @@ pub(crate) struct TickContext<'a> { pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>, /// Thread handles for waking parked workers on cross-worker sends. pub(crate) worker_threads: &'a [OnceLock], + /// Per-worker stats for summing total_actors across workers. + pub(crate) worker_stats: &'a [Arc], + /// Runtime creation time for computing uptime_ms. + pub(crate) created_at: crate::Instant, #[cfg(feature = "transport")] pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>, #[cfg(feature = "transport")] diff --git a/src/extension.rs b/src/extension.rs index 51681ba..a1a170d 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -1,6 +1,6 @@ use std::any::Any; -use crate::actor::{ActorAddress, StopReason}; +use crate::actor::{ActorAddress, Environment, ExitValue, StopReason}; /// Extension hook for runtime lifecycle events. /// @@ -17,12 +17,23 @@ pub trait RuntimeExtension: Send + Sync { /// The core delivers these through normal routing (pending_local or transfer queue). fn on_actor_death( &self, - dead: &[(ActorAddress, StopReason)], + dead: &[(ActorAddress, StopReason, Option)], ) -> Vec<(ActorAddress, Box)>; /// Clean up extension state for dead actors (names, groups, monitors). fn cleanup_dead(&self, dead: &[ActorAddress]); + /// Called for each newly spawned actor, before it enters the pool. + /// Extensions can enrich the actor's environment (e.g., inject SpawnTimestamp). + /// `child` is the address of the newly spawned actor. + /// `parent` is the address of the spawning actor, or `None` for runtime-spawned actors. + /// `uptime_ms` is milliseconds since runtime creation. + /// Default: no-op (returns env unchanged). + fn on_spawn(&self, child: ActorAddress, parent: Option, env: Environment, uptime_ms: u64) -> Environment { + let _ = (child, parent, uptime_ms); + env + } + /// Downcast support for Ctx extension traits. fn as_any(&self) -> &dyn Any; diff --git a/src/lib.rs b/src/lib.rs index 00d9f72..a1dd14d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,9 @@ pub mod actor; pub mod extension; pub mod worker; +// Re-export well-known environment key types for convenient access. +pub use actor::{SpawnTimestamp, LogicalName, ServiceBinding, ExitValue, CapabilitySet}; + pub(crate) mod channel; pub(crate) mod error; pub use error::Error; diff --git a/src/runtime.rs b/src/runtime.rs index 36f6bac..534afa3 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -7,7 +7,7 @@ use std::thread::{self, JoinHandle}; use std::thread::Thread; use crate::Instant; -use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message, StopSignal}; +use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Environment, ExitValue, Message, ResumeSignal, SpawnRequest, StopSignal, StopWithSignal, SystemInfo}; use crate::channel::{Receiver, Sender}; // Re-export config types so existing code using `runtime::RuntimeConfig` still works pub use crate::config::{BackoffPolicy, MailboxOverflow, RuntimeConfig}; @@ -102,7 +102,7 @@ pub struct Runtime { inbox_registry: Arc, extension: Option>, transfer_txs: Vec>, - spawn_txs: Vec)>>, + spawn_txs: Vec>, placement: Placement, is_running: AtomicBool, worker_stats: Vec>, @@ -170,7 +170,7 @@ impl Runtime { transfer_txs.push(transfer_tx); let spawn_rx = - Receiver::<(ActorAddress, Box)>::new(config.max_actors); + Receiver::::new(config.max_actors); let spawn_tx = spawn_rx.new_sender(); spawn_txs.push(spawn_tx); @@ -228,7 +228,26 @@ impl Runtime { self.address_map.insert(addr, worker_id); let boxed: Box = Box::new(Actor::new(actor)); self.spawn_txs[worker_id.as_usize()] - .send((addr, boxed)); + .send(SpawnRequest { addr, actor: boxed, parent: None, env: Environment::new() }); + + #[cfg(feature = "tracing")] + tracing::info!( + actor_addr = %addr, + worker_id = worker_id.as_usize(), + "actor.spawned" + ); + + Ok(addr) + } + + /// Spawn an actor with a pre-built environment, returns its address. + pub fn spawn_with_env(&self, actor: A, env: Environment) -> Result { + let addr = ActorAddress::new_random(); + let worker_id = self.placement.next_worker(); + self.address_map.insert(addr, worker_id); + let boxed: Box = Box::new(Actor::new(actor)); + self.spawn_txs[worker_id.as_usize()] + .send(SpawnRequest { addr, actor: boxed, parent: None, env }); #[cfg(feature = "tracing")] tracing::info!( @@ -309,6 +328,8 @@ impl Runtime { extension: self.extension.as_deref(), stats_hook: self.stats_hook.as_deref(), worker_threads: &self.worker_threads, + worker_stats: &self.worker_stats, + created_at: self.created_at, #[cfg(feature = "transport")] codec_registry: self.codec_registry.as_deref(), #[cfg(feature = "transport")] @@ -487,11 +508,11 @@ impl ContextInner for Runtime { } } - fn spawn_any(&self, addr: ActorAddress, actor: Box) { + fn spawn_any(&self, request: SpawnRequest) { let worker_id = self.placement.next_worker(); - self.address_map.insert(addr, worker_id); + self.address_map.insert(request.addr, worker_id); self.spawn_txs[worker_id.as_usize()] - .send((addr, actor)); + .send(request); notify_worker(&self.worker_threads, worker_id.as_usize()); } @@ -504,6 +525,27 @@ impl ContextInner for Runtime { } } + fn request_stop_with(&self, addr: ActorAddress, value: ExitValue) { + if let Some(wid) = self.address_map.lookup(&addr) { + self.transfer_txs[wid.as_usize()] + .send(Envelope::new(addr, Box::new(StopWithSignal(value)))); + notify_worker(&self.worker_threads, wid.as_usize()); + } + } + + fn request_suspend(&self, addr: ActorAddress) { + // Outside worker context — not supported (suspend is per-actor, from handler) + eprintln!("swactor: request_suspend called outside worker context for {addr} — ignored"); + } + + fn request_resume(&self, addr: ActorAddress) { + if let Some(wid) = self.address_map.lookup(&addr) { + self.transfer_txs[wid.as_usize()] + .send(Envelope::new(addr, Box::new(ResumeSignal))); + notify_worker(&self.worker_threads, wid.as_usize()); + } + } + fn post_worker_request(&self, _request: Box) { // Worker requests (e.g., timers) are per-worker; posting from outside // a worker context (e.g., rt.spawn() callback) is not supported. @@ -513,4 +555,17 @@ impl ContextInner for Runtime { fn extension(&self) -> Option<&dyn RuntimeExtension> { self.extension.as_deref() } + + fn system_info(&self) -> SystemInfo { + let num_workers = self.config.num_threads.max(1); + let total_actors: usize = self.worker_stats.iter() + .map(|ws| ws.num_actors.load(Ordering::Relaxed)) + .sum(); + SystemInfo { + worker_id: 0, + num_workers, + total_actors, + uptime_ms: self.created_at.elapsed().as_millis() as u64, + } + } } diff --git a/src/worker.rs b/src/worker.rs index 197e86d..e11ffa1 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::thread; use crate::Instant; -use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, StopReason, StopSignal}; +use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, Environment, ExitValue, ResumeSignal, SpawnRequest, StopReason, StopSignal, StopWithSignal, SystemInfo}; use crate::channel::Receiver; use crate::config::MailboxOverflow; use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId}; @@ -45,7 +45,7 @@ pub(crate) struct Worker { pub(crate) id: WorkerId, pub(crate) pool: ActorPool, transfer_rx: Receiver, - spawn_rx: Receiver<(ActorAddress, Box)>, + spawn_rx: Receiver, stats: Arc, /// Reusable scratch buffer for building per-actor snapshots. snapshot_buf: Vec, @@ -57,7 +57,7 @@ impl Worker { pub(crate) fn new( id: WorkerId, transfer_rx: Receiver, - spawn_rx: Receiver<(ActorAddress, Box)>, + spawn_rx: Receiver, stats: Arc, default_mailbox_capacity: usize, default_overflow_policy: MailboxOverflow, @@ -76,12 +76,15 @@ impl Worker { /// Run one iteration of the worker loop. Returns `true` if any work was done. /// Drain the spawn queue, inserting new actors into the pool. /// Used in phases 1 and 4 of tick_once. - fn drain_spawns(&mut self) -> bool { + fn drain_spawns(&mut self, tc: &TickContext) -> bool { let mut did_work = false; #[cfg(feature = "tracing")] let mut spawn_count: usize = 0; - while let Some((addr, actor)) = self.spawn_rx.try_recv() { - self.pool.insert(addr, actor); + while let Some(mut req) = self.spawn_rx.try_recv() { + if let Some(ext) = tc.extension { + req.env = ext.on_spawn(req.addr, req.parent, req.env, tc.created_at.elapsed().as_millis() as u64); + } + self.pool.insert(req); #[cfg(feature = "tracing")] { spawn_count += 1; } did_work = true; @@ -98,6 +101,8 @@ impl Worker { let cleanup_pending: RefCell)>> = RefCell::new(Vec::new()); let cleanup_stops: RefCell> = RefCell::new(Vec::new()); + let cleanup_stop_withs: RefCell> = RefCell::new(Vec::new()); + let cleanup_suspends: RefCell> = RefCell::new(Vec::new()); let cleanup_requests: RefCell>> = RefCell::new(Vec::new()); let dead = { let cleanup_ctx = WorkerContext { @@ -105,6 +110,8 @@ impl Worker { tc, pending_local: &cleanup_pending, stop_requests: &cleanup_stops, + stop_with_values: &cleanup_stop_withs, + suspend_requests: &cleanup_suspends, worker_requests: &cleanup_requests, stats: &self.stats, }; @@ -113,13 +120,13 @@ impl Worker { let had_dead = !dead.is_empty(); if had_dead { - for &(addr, _) in &dead { - tc.address_map.remove(&addr); + for (addr, _, _) in &dead { + tc.address_map.remove(addr); } if let Some(ext) = tc.extension { let notifications = ext.on_actor_death(&dead); - let dead_addrs: Vec<_> = dead.iter().map(|(a, _)| *a).collect(); + let dead_addrs: Vec<_> = dead.iter().map(|(a, _, _)| *a).collect(); ext.cleanup_dead(&dead_addrs); for (dest, msg) in notifications { route_to_pool_or_remote(&mut self.pool, tc, dest, msg); @@ -136,7 +143,7 @@ impl Worker { // GC per-worker extension state for dead actors if let Some(ext) = &mut self.worker_ext { - let dead_addrs: Vec = dead.iter().map(|(a, _)| *a).collect(); + let dead_addrs: Vec = dead.iter().map(|(a, _, _)| *a).collect(); ext.gc_dead(&dead_addrs); } @@ -151,7 +158,7 @@ impl Worker { let t0 = Instant::now(); // 1. Drain spawn queue → add actors to pool - did_work |= self.drain_spawns(); + did_work |= self.drain_spawns(tc); let t1 = Instant::now(); // 2. Drain transfer queue → deliver envelopes to actors @@ -176,6 +183,8 @@ impl Worker { let pending_local: RefCell)>> = RefCell::new(Vec::new()); let stop_requests: RefCell> = RefCell::new(Vec::new()); + let stop_with_values: RefCell> = RefCell::new(Vec::new()); + let suspend_requests: RefCell> = RefCell::new(Vec::new()); let worker_requests: RefCell>> = RefCell::new(Vec::new()); let processed; @@ -185,10 +194,12 @@ impl Worker { tc, pending_local: &pending_local, stop_requests: &stop_requests, + stop_with_values: &stop_with_values, + suspend_requests: &suspend_requests, worker_requests: &worker_requests, stats: &self.stats, }; - processed = self.pool.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget, &stop_requests); + processed = self.pool.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget, &stop_requests, &stop_with_values, &suspend_requests); if processed > 0 { did_work = true; } @@ -206,7 +217,7 @@ impl Worker { // 4. Drain spawn queue again — actors spawned during step 3 // must be in the pool before pending_local delivery. - did_work |= self.drain_spawns(); + did_work |= self.drain_spawns(tc); let t4 = Instant::now(); // 5. Drain pending_local buffer → deliver to local actors @@ -316,6 +327,8 @@ struct WorkerContext<'a> { tc: &'a TickContext<'a>, pending_local: &'a RefCell)>>, stop_requests: &'a RefCell>, + stop_with_values: &'a RefCell>, + suspend_requests: &'a RefCell>, worker_requests: &'a RefCell>>, stats: &'a WorkerStats, } @@ -341,11 +354,11 @@ impl ContextInner for WorkerContext<'_> { } } - fn spawn_any(&self, addr: ActorAddress, actor: Box) { + fn spawn_any(&self, request: SpawnRequest) { let worker_id = self.tc.placement.next_worker(); - self.tc.address_map.insert(addr, worker_id); + self.tc.address_map.insert(request.addr, worker_id); self.tc.spawn_txs[worker_id.as_usize()] - .send((addr, actor)); + .send(request); crate::runtime::notify_worker(self.tc.worker_threads, worker_id.as_usize()); } @@ -353,6 +366,20 @@ impl ContextInner for WorkerContext<'_> { self.stop_requests.borrow_mut().push(addr); } + fn request_stop_with(&self, addr: ActorAddress, value: ExitValue) { + self.stop_with_values.borrow_mut().push((addr, value)); + } + + fn request_suspend(&self, addr: ActorAddress) { + self.suspend_requests.borrow_mut().push(addr); + } + + fn request_resume(&self, addr: ActorAddress) { + // Same-worker: buffer as pending_local ResumeSignal + // Cross-worker: would go through transfer queue (handled by Runtime impl) + self.pending_local.borrow_mut().push((addr, Box::new(ResumeSignal))); + } + fn post_worker_request(&self, request: Box) { self.worker_requests.borrow_mut().push(request); } @@ -360,6 +387,19 @@ impl ContextInner for WorkerContext<'_> { fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension> { self.tc.extension } + + fn system_info(&self) -> SystemInfo { + let num_workers = self.tc.config.num_threads.max(1); + let total_actors: usize = self.tc.worker_stats.iter() + .map(|ws| ws.num_actors.load(Ordering::Relaxed)) + .sum(); + SystemInfo { + worker_id: self.worker_id.0, + num_workers, + total_actors, + uptime_ms: self.tc.created_at.elapsed().as_millis() as u64, + } + } } struct ActorSlot { @@ -370,6 +410,8 @@ struct ActorSlot { stopping: bool, /// Whether on_start has been called for this actor. started: bool, + /// Actor is suspended — messages queue but are not processed. + suspended: bool, last_msg_type: Option<&'static str>, messages_processed: u64, /// Per-message-type counters (bounded to 32 entries). @@ -377,6 +419,12 @@ struct ActorSlot { /// Per-actor mailbox capacity. 0 = unbounded. mailbox_capacity: usize, overflow_policy: MailboxOverflow, + /// Address of the actor that spawned this one, or `None` for externally-spawned actors. + parent_addr: Option, + /// Inherited environment from parent (or empty for runtime-spawned actors). + env: Environment, + /// Typed exit value set by `ctx.stop_with()`. + exit_value: Option, } /// Per-worker actor storage. Owns per-actor mailboxes. @@ -398,20 +446,24 @@ impl ActorPool { } } - pub fn insert(&mut self, addr: ActorAddress, actor: Box) { + pub fn insert(&mut self, req: SpawnRequest) { let cap = self.default_mailbox_capacity; let prealloc = if cap > 0 { cap.min(64) } else { 16 }; - self.actors.insert(addr, ActorSlot { + self.actors.insert(req.addr, ActorSlot { mailbox: VecDeque::with_capacity(prealloc), - actor, + actor: req.actor, poisoned: false, stopping: false, started: false, + suspended: false, last_msg_type: None, messages_processed: 0, msg_type_counts: HashMap::new(), mailbox_capacity: self.default_mailbox_capacity, overflow_policy: self.default_overflow_policy, + parent_addr: req.parent, + env: req.env, + exit_value: None, }); } @@ -419,6 +471,27 @@ impl ActorPool { /// Returns `true` if the actor exists (message handled or dropped; type check deferred to tick). pub fn deliver(&mut self, addr: &ActorAddress, msg: Box) -> bool { if let Some(slot) = self.actors.get_mut(addr) { + // Intercept control signals for suspended actors: they skip tick_all + // so we must handle resume/stop at delivery time. + if slot.suspended { + if msg.is::() { + slot.suspended = false; + return true; + } + if msg.is::() { + slot.stopping = true; + slot.mailbox.clear(); + return true; + } + if msg.is::() { + if let Ok(sig) = msg.downcast::() { + slot.exit_value = Some(sig.0); + } + slot.stopping = true; + slot.mailbox.clear(); + return true; + } + } if slot.mailbox_capacity > 0 && slot.mailbox.len() >= slot.mailbox_capacity { match slot.overflow_policy { MailboxOverflow::DropNewest => { @@ -453,6 +526,8 @@ impl ActorPool { stats: &WorkerStats, budget: usize, stop_requests: &RefCell>, + stop_with_values: &RefCell>, + suspend_requests: &RefCell>, ) -> usize { let mut count = 0; for (&addr, slot) in self.actors.iter_mut() { @@ -462,10 +537,22 @@ impl ActorPool { continue; } + // Skip suspended actors — messages keep queueing + if slot.suspended { + continue; + } + #[cfg(feature = "tracing")] let _actor_span = tracing::trace_span!("actor.tick", actor_addr = %addr).entered(); - let ctx = Ctx::new(inner, addr); + // Snapshot self-stats before creating Ctx + let snap_processed = slot.messages_processed; + let snap_depth = slot.mailbox.len(); + let mut snap_type_counts: Vec<(&'static str, u64)> = + slot.msg_type_counts.iter().map(|(&k, &v)| (k, v)).collect(); + snap_type_counts.sort_by(|a, b| b.1.cmp(&a.1)); + + let ctx = Ctx::new(inner, addr, slot.parent_addr, slot.env.clone(), snap_processed, snap_depth, snap_type_counts); // Call on_start once, before first message if !slot.started { @@ -482,7 +569,7 @@ impl ActorPool { slot.mailbox.clear(); continue; } - // Check if on_start requested stop + // Check if on_start requested stop or stop_with { let stops = stop_requests.borrow(); if !stops.is_empty() && stops.contains(&addr) { @@ -490,6 +577,33 @@ impl ActorPool { slot.stopping = true; stats.stops.fetch_add(1, Ordering::Relaxed); slot.mailbox.clear(); + // Check for stop_with value + let mut sws = stop_with_values.borrow_mut(); + if let Some(pos) = sws.iter().position(|(a, _)| *a == addr) { + let (_, val) = sws.swap_remove(pos); + slot.exit_value = Some(val); + } + continue; + } + } + // Check if on_start requested stop_with (without plain stop) + { + let mut sws = stop_with_values.borrow_mut(); + if let Some(pos) = sws.iter().position(|(a, _)| *a == addr) { + let (_, val) = sws.swap_remove(pos); + slot.exit_value = Some(val); + slot.stopping = true; + stats.stops.fetch_add(1, Ordering::Relaxed); + slot.mailbox.clear(); + continue; + } + } + // Check if on_start requested suspend + { + let suspends = suspend_requests.borrow(); + if !suspends.is_empty() && suspends.contains(&addr) { + drop(suspends); + slot.suspended = true; continue; } } @@ -507,6 +621,17 @@ impl ActorPool { break; } + // Intercept StopWithSignal (from external runtime) + if msg.is::() { + if let Ok(sig) = msg.downcast::() { + slot.exit_value = Some(sig.0); + } + slot.stopping = true; + stats.stops.fetch_add(1, Ordering::Relaxed); + slot.mailbox.clear(); + break; + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { slot.actor.handle_any(&ctx, msg) })); @@ -536,16 +661,37 @@ impl ActorPool { count += 1; actor_count += 1; - // Check if handler requested self-stop (via ctx.stop_self()) + // Check if handler requested self-stop or stop_with { let stops = stop_requests.borrow(); - if !stops.is_empty() && stops.contains(&addr) { - drop(stops); + let has_stop = !stops.is_empty() && stops.contains(&addr); + drop(stops); + + let mut sws = stop_with_values.borrow_mut(); + let sw_pos = sws.iter().position(|(a, _)| *a == addr); + + if has_stop || sw_pos.is_some() { + if let Some(pos) = sw_pos { + let (_, val) = sws.swap_remove(pos); + slot.exit_value = Some(val); + } + drop(sws); slot.stopping = true; stats.stops.fetch_add(1, Ordering::Relaxed); slot.mailbox.clear(); break; } + drop(sws); + } + + // Check if handler requested suspend + { + let suspends = suspend_requests.borrow(); + if !suspends.is_empty() && suspends.contains(&addr) { + drop(suspends); + slot.suspended = true; + break; // stop processing this actor's messages this tick + } } if budget > 0 && actor_count >= budget { @@ -568,30 +714,45 @@ impl ActorPool { self.actors.values().map(|slot| slot.mailbox.len()).sum() } - /// Remove poisoned and stopping actors, returning their addresses and stop reasons. + /// Remove poisoned and stopping actors, returning their addresses, stop reasons, + /// and optional exit values. /// Called after tick_all so the caller can clean up the address map. /// /// For stopping actors: calls `on_stop()` before removal (wrapped in catch_unwind). /// For poisoned actors: `on_stop()` is NOT called (state may be corrupt). - pub fn cleanup_dead(&mut self, inner: &dyn ContextInner) -> Vec<(ActorAddress, StopReason)> { - let dead: Vec<(ActorAddress, StopReason)> = self + pub fn cleanup_dead(&mut self, inner: &dyn ContextInner) -> Vec<(ActorAddress, StopReason, Option)> { + let dead_addrs: Vec = self .actors .iter() .filter(|(_, slot)| slot.poisoned || slot.stopping) - .map(|(&addr, slot)| { - let reason = if slot.poisoned { StopReason::Panicked } else { StopReason::Normal }; - (addr, reason) - }) + .map(|(&addr, _)| addr) .collect(); - for &(addr, _) in &dead { + let mut dead = Vec::with_capacity(dead_addrs.len()); + for addr in dead_addrs { if let Some(mut slot) = self.actors.remove(&addr) { + let reason = if slot.poisoned { + StopReason::Panicked + } else if slot.exit_value.is_some() { + StopReason::Completed + } else { + StopReason::Normal + }; // Call on_stop for gracefully stopping actors only if slot.stopping && !slot.poisoned { - let ctx = Ctx::new(inner, addr); + let mut type_counts: Vec<(&'static str, u64)> = + slot.msg_type_counts.iter().map(|(&k, &v)| (k, v)).collect(); + type_counts.sort_by(|a, b| b.1.cmp(&a.1)); + let ctx = Ctx::new( + inner, addr, slot.parent_addr, slot.env.clone(), + slot.messages_processed, + slot.mailbox.len(), + type_counts, + ); let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { slot.actor.on_stop(&ctx); })); } + dead.push((addr, reason, slot.exit_value.take())); // slot is dropped here — actor resources freed } } diff --git a/tests/actor_lifecycle.rs b/tests/actor_lifecycle.rs index 0724466..57294e3 100644 --- a/tests/actor_lifecycle.rs +++ b/tests/actor_lifecycle.rs @@ -216,7 +216,7 @@ impl ActorInterface for MonitorWatcherActor { type Incoming = Down; type Response = (); fn on_start(&mut self, ctx: &Ctx) { - self.mref = Some(ctx.monitor(self.watch_target)); + self.mref = Some(ctx.monitor(self.watch_target).unwrap()); } fn handle(&mut self, ctx: &Ctx, msg: Down) { ctx.send(self.reply_to, msg).unwrap(); @@ -233,7 +233,7 @@ impl ActorInterface for DemonitorActor { type Incoming = Ping; type Response = (); fn on_start(&mut self, ctx: &Ctx) { - self.mref = Some(ctx.monitor(self.watch_target)); + self.mref = Some(ctx.monitor(self.watch_target).unwrap()); } fn handle(&mut self, ctx: &Ctx, _msg: Ping) { if let Some(mref) = self.mref.take() { @@ -705,8 +705,8 @@ fn monitor_death_notification_contract() { type Incoming = Down; type Response = (); fn on_start(&mut self, ctx: &Ctx) { - ctx.monitor(self.target); - ctx.monitor(self.target); + ctx.monitor(self.target).unwrap(); + ctx.monitor(self.target).unwrap(); } fn handle(&mut self, ctx: &Ctx, msg: Down) { ctx.send(self.reply_to, msg).unwrap(); @@ -734,7 +734,7 @@ fn monitor_death_notification_contract() { type Incoming = Ping; type Response = (); fn on_start(&mut self, ctx: &Ctx) { - ctx.monitor(self.target); + ctx.monitor(self.target).unwrap(); } fn handle(&mut self, ctx: &Ctx, _msg: Ping) { let _ = ctx.send(self.inbox, Count(self.downs.len())); @@ -768,7 +768,7 @@ fn monitor_death_notification_contract() { type Incoming = Down; type Response = (); fn on_start(&mut self, ctx: &Ctx) { - ctx.monitor(self.target); + ctx.monitor(self.target).unwrap(); } fn handle(&mut self, ctx: &Ctx, msg: Down) { let _ = ctx.send(self.inbox, msg); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e794262..53321a3 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -6,13 +6,16 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; pub use swactor::actor::{ - ActorAddress, ActorExited, ActorInterface, Down, ExitReason, MonitorRef, StopReason, + ActorAddress, ActorExited, ActorInterface, CapabilitySet, Down, Environment, EnvironmentBuilder, + ExitReason, ExitValue, LogicalName, MonitorRef, ServiceBinding, SpawnBuilder, SpawnTimestamp, + StopReason, }; pub use swactor::runtime::{Ctx, Inbox, MailboxOverflow, Runtime, RuntimeConfig}; pub use swactor_std::{ - ChildSpec, CtxGroups, CtxMonitoring, CtxNaming, CtxTimers, CtxWatching, RestartPolicy, Router, - RoutingStrategy, RuntimeGroups, RuntimeNaming, RuntimeWatching, StdExtension, Supervisor, - SupervisorStrategy, + ChildSpec, CtxCapabilities, CtxEnvironment, CtxGroups, CtxHandles, CtxLifecycle, CtxLineage, + CtxMonitoring, CtxNaming, CtxResources, CtxSelfStats, CtxSystem, CtxTimers, CtxWatching, + ResourceHandle, RestartPolicy, Router, RoutingStrategy, RuntimeGroups, RuntimeNaming, + RuntimeResources, RuntimeWatching, StdExtension, Supervisor, SupervisorStrategy, }; // ── Messages ──────────────────────────────────────────────────────────────── diff --git a/tests/std_extension.rs b/tests/std_extension.rs index cb8f254..58c64bc 100644 --- a/tests/std_extension.rs +++ b/tests/std_extension.rs @@ -740,3 +740,2759 @@ fn router_work_distribution() { tick_n(&rt, 5); assert_eq!(rt.stats().workers[0].num_actors, 0, "stop router kills workers"); } + +// ═══════════════════════════════════════════════════════════════════════════ +// CtxSystem + CtxSelfStats +// ═══════════════════════════════════════════════════════════════════════════ + +/// Actor sees own stats after processing messages. +/// +/// Sends N messages, ticks so they're processed, then sends a "report" message. +/// The actor reads its own stats in the handler and sends them back. +#[test] +fn actor_sees_own_stats_after_processing() { + #[derive(Clone)] + enum StatsMsg { + Bump, + Report { reply_to: ActorAddress }, + } + + #[derive(Clone, Debug, PartialEq)] + struct StatsReport { + processed: u64, + type_counts: Vec<(String, u64)>, + } + + struct StatsActor; + impl ActorInterface for StatsActor { + type Incoming = StatsMsg; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: StatsMsg) { + match msg { + StatsMsg::Bump => {} + StatsMsg::Report { reply_to } => { + let report = StatsReport { + processed: ctx.messages_processed(), + type_counts: ctx.message_type_counts() + .iter() + .map(|(k, v)| (k.to_string(), *v)) + .collect(), + }; + let _ = ctx.send(reply_to, report); + } + } + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(StatsActor).unwrap(); + rt.tick(); // on_start + + // Send 5 Bump messages and process them + for _ in 0..5 { + rt.send_to(actor, StatsMsg::Bump).unwrap(); + } + rt.tick(); + + // Now ask for a report — the actor should see 5 processed messages + rt.send_to(actor, StatsMsg::Report { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + + let report = inbox.try_recv().expect("should receive stats report"); + assert_eq!(report.processed, 5, "actor should see 5 previously processed messages"); + assert!(!report.type_counts.is_empty(), "type counts should be populated"); + // The type name should contain "StatsMsg" + assert!( + report.type_counts.iter().any(|(name, count)| name.contains("StatsMsg") && *count >= 5), + "type counts should include StatsMsg entries with count >= 5, got {:?}", + report.type_counts, + ); +} + +/// Actor sees system info: worker count, total actors, uptime. +#[test] +fn actor_sees_system_info() { + #[derive(Clone)] + struct GetSysInfo { reply_to: ActorAddress } + + #[derive(Clone, Debug)] + struct SysInfoReport { + num_workers: usize, + total_actors: usize, + } + + struct SysInfoActor; + impl ActorInterface for SysInfoActor { + type Incoming = GetSysInfo; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: GetSysInfo) { + let info = ctx.system_info(); + let _ = ctx.send(msg.reply_to, SysInfoReport { + num_workers: info.num_workers, + total_actors: info.total_actors, + }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + + // Spawn a few actors so total_actors > 1 + let reporter = rt.spawn(SysInfoActor).unwrap(); + let _extra1 = rt.spawn(PingPongActor).unwrap(); + let _extra2 = rt.spawn(PingPongActor).unwrap(); + rt.tick(); // on_start + stats update + + rt.send_to(reporter, GetSysInfo { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + + let report = inbox.try_recv().expect("should receive system info"); + assert_eq!(report.num_workers, 1, "default config has 1 worker"); + assert!(report.total_actors >= 3, "should see at least 3 actors, got {}", report.total_actors); +} + +/// Mailbox depth reflects queued messages before dequeuing. +/// +/// With budget=1, only 1 message is processed per tick. If we enqueue 5 messages, +/// the actor's first handler invocation should see all 5 in the mailbox snapshot. +#[test] +fn mailbox_depth_reflects_queued_messages() { + #[derive(Clone)] + struct DepthProbe { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct DepthReport(usize); + + struct DepthActor; + impl ActorInterface for DepthActor { + type Incoming = DepthProbe; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: DepthProbe) { + let _ = ctx.send(msg.reply_to, DepthReport(ctx.mailbox_depth())); + } + } + + let config = RuntimeConfig { + actor_message_budget: 1, + ..RuntimeConfig::default() + }; + let rt = std_runtime(config); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(DepthActor).unwrap(); + rt.tick(); // on_start + + // Enqueue 5 messages + for _ in 0..5 { + rt.send_to(actor, DepthProbe { reply_to: *inbox.addr() }).unwrap(); + } + + // Tick once — budget=1, so only the first message is processed + rt.tick(); + + let report = inbox.try_recv().expect("should receive depth report"); + // The snapshot is taken before any dequeuing in this tick, so depth == 5 + assert_eq!(report.0, 5, "mailbox depth should be 5 (snapshot before dequeue)"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CtxLineage — Parent Tracking +// ═══════════════════════════════════════════════════════════════════════════ + +/// Child spawned by an actor reports its parent address back. +#[test] +fn child_knows_its_parent() { + #[derive(Clone)] + struct ReportParent { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ParentReport(Option); + + struct ChildReporter; + impl ActorInterface for ChildReporter { + type Incoming = ReportParent; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportParent) { + let _ = ctx.send(msg.reply_to, ParentReport(ctx.parent())); + } + } + + struct ParentActor { reply_to: ActorAddress } + impl ActorInterface for ParentActor { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx.spawn(ChildReporter).unwrap(); + let _ = ctx.send(child, ReportParent { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(ParentActor { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); // on_start + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("child should report parent"); + assert_eq!(report, ParentReport(Some(parent))); +} + +/// Actor spawned via Runtime::spawn has no parent. +#[test] +fn runtime_spawned_has_no_parent() { + #[derive(Clone)] + struct ReportParent { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ParentReport(Option); + + struct Reporter; + impl ActorInterface for Reporter { + type Incoming = ReportParent; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportParent) { + let _ = ctx.send(msg.reply_to, ParentReport(ctx.parent())); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(Reporter).unwrap(); + rt.tick(); // on_start + rt.send_to(actor, ReportParent { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("actor should report parent"); + assert_eq!(report, ParentReport(None)); +} + +/// In a A→B→C chain, C reports B as parent (not A). +#[test] +fn grandchild_reports_immediate_parent() { + #[derive(Clone)] + struct ReportParent { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ParentReport(Option); + + struct Leaf; + impl ActorInterface for Leaf { + type Incoming = ReportParent; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportParent) { + let _ = ctx.send(msg.reply_to, ParentReport(ctx.parent())); + } + } + + struct Middle { reply_to: ActorAddress } + impl ActorInterface for Middle { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx.spawn(Leaf).unwrap(); + let _ = ctx.send(child, ReportParent { reply_to: self.reply_to }); + } + } + + struct Root { reply_to: ActorAddress } + impl ActorInterface for Root { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let mid = ctx.spawn(Middle { reply_to: self.reply_to }).unwrap(); + let _ = ctx.send(mid, Ping { reply_to: ActorAddress::default() }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let root = rt.spawn(Root { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); // on_start + rt.send_to(root, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + let report = inbox.try_recv().expect("grandchild should report parent"); + // C's parent should be B (some address), not A (root) and not None + assert!(report.0.is_some(), "grandchild has a parent"); + assert_ne!(report.0.unwrap(), root, "grandchild's parent is the middle actor, not root"); +} + +/// Parent address is available during on_stop. +#[test] +fn parent_visible_in_on_stop() { + #[derive(Clone, Debug, PartialEq)] + struct ParentReport(Option); + + struct OnStopReporter { reply_to: ActorAddress } + impl ActorInterface for OnStopReporter { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {} + fn on_stop(&mut self, ctx: &Ctx) { + let _ = ctx.send(self.reply_to, ParentReport(ctx.parent())); + } + } + + struct Spawner { reply_to: ActorAddress } + impl ActorInterface for Spawner { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx.spawn(OnStopReporter { reply_to: self.reply_to }).unwrap(); + let _ = ctx.stop_actor(child); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let spawner = rt.spawn(Spawner { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); // on_start + rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + let report = inbox.try_recv().expect("on_stop should report parent"); + assert_eq!(report, ParentReport(Some(spawner))); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CtxEnvironment — Inherited Typed Key-Value Map +// ═══════════════════════════════════════════════════════════════════════════ + +/// Child inherits parent's environment: parent sets a typed env value via +/// spawn_builder, spawns child, child reads it back and confirms it matches. +#[test] +fn env_child_inherits_parent_environment() { + #[derive(Clone, Debug, PartialEq)] + struct DbAddr(String); + + #[derive(Clone)] + struct ReportEnv { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct EnvReport(Option); + + struct EnvChild; + impl ActorInterface for EnvChild { + type Incoming = ReportEnv; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportEnv) { + let val = ctx.env::().map(|d| d.0.clone()); + let _ = ctx.send(msg.reply_to, EnvReport(val)); + } + } + + struct EnvParent { reply_to: ActorAddress } + impl ActorInterface for EnvParent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx + .spawn_builder(EnvChild) + .env(DbAddr("postgres://localhost".into())) + .finish() + .unwrap(); + let _ = ctx.send(child, ReportEnv { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(EnvParent { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("child should report env"); + assert_eq!(report, EnvReport(Some("postgres://localhost".into()))); +} + +/// Runtime-spawned actor has empty environment — ctx.env::() returns None. +#[test] +fn env_runtime_spawned_has_empty_environment() { + #[derive(Clone, Debug, PartialEq)] + struct Tag(String); + + #[derive(Clone)] + struct ReportEnv { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct EnvReport(bool); + + struct EnvReporter; + impl ActorInterface for EnvReporter { + type Incoming = ReportEnv; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportEnv) { + let has_tag = ctx.env::().is_some(); + let _ = ctx.send(msg.reply_to, EnvReport(has_tag)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(EnvReporter).unwrap(); + rt.tick(); + rt.send_to(actor, ReportEnv { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("actor should report env"); + assert_eq!(report, EnvReport(false), "runtime-spawned actor has no env values"); +} + +/// Environment flows through a grandchild chain: A sets env, spawns B, B +/// spawns C (via plain ctx.spawn — inherits env), C reads the value from A. +#[test] +fn env_flows_through_grandchild_chain() { + #[derive(Clone, Debug, PartialEq)] + struct Secret(u64); + + #[derive(Clone)] + struct ReportEnv { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct EnvReport(Option); + + struct Leaf; + impl ActorInterface for Leaf { + type Incoming = ReportEnv; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportEnv) { + let val = ctx.env::().map(|s| s.0); + let _ = ctx.send(msg.reply_to, EnvReport(val)); + } + } + + struct Middle { reply_to: ActorAddress } + impl ActorInterface for Middle { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // ctx.spawn inherits parent env automatically + let child = ctx.spawn(Leaf).unwrap(); + let _ = ctx.send(child, ReportEnv { reply_to: self.reply_to }); + } + } + + struct Root { reply_to: ActorAddress } + impl ActorInterface for Root { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let mid = ctx + .spawn_builder(Middle { reply_to: self.reply_to }) + .env(Secret(42)) + .finish() + .unwrap(); + let _ = ctx.send(mid, Ping { reply_to: ActorAddress::default() }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let root = rt.spawn(Root { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(root, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + let report = inbox.try_recv().expect("grandchild should report env"); + assert_eq!(report, EnvReport(Some(42)), "env value from root flows to grandchild"); +} + +/// Spawn builder overrides one key while inheriting others: parent has Key1 + +/// Key2, uses spawn_builder to override Key2. Child sees original Key1 and new Key2. +#[test] +fn env_spawn_builder_overrides_one_key_inherits_others() { + #[derive(Clone, Debug, PartialEq)] + struct Key1(String); + #[derive(Clone, Debug, PartialEq)] + struct Key2(String); + + #[derive(Clone)] + struct ReportEnv { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct EnvReport { key1: Option, key2: Option } + + struct EnvChild; + impl ActorInterface for EnvChild { + type Incoming = ReportEnv; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportEnv) { + let _ = ctx.send(msg.reply_to, EnvReport { + key1: ctx.env::().map(|k| k.0.clone()), + key2: ctx.env::().map(|k| k.0.clone()), + }); + } + } + + struct EnvParent { reply_to: ActorAddress } + impl ActorInterface for EnvParent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // Override Key2 only, Key1 should be inherited + let child = ctx + .spawn_builder(EnvChild) + .env(Key2("overridden".into())) + .finish() + .unwrap(); + let _ = ctx.send(child, ReportEnv { reply_to: self.reply_to }); + } + } + + // Build an env with both keys, then use EnvironmentBuilder to create the parent env + let parent_env = EnvironmentBuilder::new() + .set(Key1("original".into())) + .set(Key2("original".into())) + .build(); + + // Spawn the parent with the built env using a "bootstrap" actor + struct Bootstrap { reply_to: ActorAddress } + impl ActorInterface for Bootstrap { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let parent = ctx + .spawn_builder(EnvParent { reply_to: self.reply_to }) + .env(Key1("original".into())) + .env(Key2("original".into())) + .finish() + .unwrap(); + let _ = ctx.send(parent, Ping { reply_to: ActorAddress::default() }); + } + } + + let _ = parent_env; // verify it builds (used above for documentation) + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let bootstrap = rt.spawn(Bootstrap { + reply_to: *inbox.addr(), + }).unwrap(); + rt.tick(); + rt.send_to(bootstrap, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + let report = inbox.try_recv().expect("child should report env"); + assert_eq!(report.key1, Some("original".into()), "Key1 inherited from parent"); + assert_eq!(report.key2, Some("overridden".into()), "Key2 overridden by spawn_builder"); +} + +/// Environment is readable during on_stop callback. +#[test] +fn env_readable_in_on_stop() { + #[derive(Clone, Debug, PartialEq)] + struct Config(String); + + #[derive(Clone, Debug, PartialEq)] + struct EnvReport(Option); + + struct OnStopEnvReporter { reply_to: ActorAddress } + impl ActorInterface for OnStopEnvReporter { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {} + fn on_stop(&mut self, ctx: &Ctx) { + let val = ctx.env::().map(|c| c.0.clone()); + let _ = ctx.send(self.reply_to, EnvReport(val)); + } + } + + struct Spawner { reply_to: ActorAddress } + impl ActorInterface for Spawner { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx + .spawn_builder(OnStopEnvReporter { reply_to: self.reply_to }) + .env(Config("production".into())) + .finish() + .unwrap(); + let _ = ctx.stop_actor(child); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let spawner = rt.spawn(Spawner { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + let report = inbox.try_recv().expect("on_stop should report env"); + assert_eq!(report, EnvReport(Some("production".into()))); +} + +/// Sibling overrides are independent: parent spawns child A with Version(1) +/// and child B with Version(2). Each sees its own version. +#[test] +fn env_sibling_overrides_are_independent() { + #[derive(Clone, Debug, PartialEq)] + struct Version(u32); + + #[derive(Clone)] + struct ReportEnv { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct EnvReport(Option); + + struct VersionReporter; + impl ActorInterface for VersionReporter { + type Incoming = ReportEnv; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportEnv) { + let val = ctx.env::().map(|v| v.0); + let _ = ctx.send(msg.reply_to, EnvReport(val)); + } + } + + struct Parent { reply_to: ActorAddress } + impl ActorInterface for Parent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let a = ctx.spawn_builder(VersionReporter).env(Version(1)).finish().unwrap(); + let b = ctx.spawn_builder(VersionReporter).env(Version(2)).finish().unwrap(); + let _ = ctx.send(a, ReportEnv { reply_to: self.reply_to }); + let _ = ctx.send(b, ReportEnv { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(Parent { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + let mut reports: Vec = std::iter::from_fn(|| inbox.try_recv()).collect(); + reports.sort_by_key(|r| r.0); + assert_eq!(reports.len(), 2, "both siblings replied"); + assert_eq!(reports[0], EnvReport(Some(1))); + assert_eq!(reports[1], EnvReport(Some(2))); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SpawnTimestamp +// ═══════════════════════════════════════════════════════════════════════════ + +/// Any actor has SpawnTimestamp when StdExtension is installed. +#[test] +fn spawn_timestamp_present_with_std_extension() { + #[derive(Clone)] + struct ReportTs { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct TsReport(Option); + + struct TsActor; + impl ActorInterface for TsActor { + type Incoming = ReportTs; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportTs) { + let ts = ctx.env::().map(|t| t.0); + let _ = ctx.send(msg.reply_to, TsReport(ts)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(TsActor).unwrap(); + rt.tick(); + rt.send_to(actor, ReportTs { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive timestamp report"); + assert!(report.0.is_some(), "SpawnTimestamp should be present with StdExtension"); +} + +/// Parent and child spawned at different times have different timestamps, +/// child's timestamp >= parent's timestamp. +#[test] +fn spawn_timestamp_parent_child_ordering() { + #[derive(Clone, Debug)] + struct TsPair { parent_ts: u64, child_ts: u64 } + + struct TsChild { reply_to: ActorAddress, parent_ts: u64 } + impl ActorInterface for TsChild { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + let child_ts = ctx.env::().unwrap().0; + let _ = ctx.send(self.reply_to, TsPair { + parent_ts: self.parent_ts, + child_ts, + }); + } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + } + + struct TsParent { reply_to: ActorAddress } + impl ActorInterface for TsParent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let my_ts = ctx.env::().unwrap().0; + let _ = ctx.spawn(TsChild { reply_to: self.reply_to, parent_ts: my_ts }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(TsParent { reply_to: *inbox.addr() }).unwrap(); + // Tick a few times so some uptime accumulates before the child spawn + tick_n(&rt, 3); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("should receive timestamp pair"); + assert!(report.child_ts >= report.parent_ts, + "child timestamp ({}) should be >= parent timestamp ({})", + report.child_ts, report.parent_ts); +} + +/// SpawnTimestamp is available during on_stop callback. +#[test] +fn spawn_timestamp_available_in_on_stop() { + #[derive(Clone, Debug, PartialEq)] + struct TsReport(Option); + + struct OnStopTsReporter { reply_to: ActorAddress } + impl ActorInterface for OnStopTsReporter { + type Incoming = (); + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn on_stop(&mut self, ctx: &Ctx) { + let ts = ctx.env::().map(|t| t.0); + let _ = ctx.send(self.reply_to, TsReport(ts)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(OnStopTsReporter { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.stop_actor(actor).unwrap(); + tick_n(&rt, 3); + let report = inbox.try_recv().expect("on_stop should report timestamp"); + assert!(report.0.is_some(), "SpawnTimestamp should be available in on_stop"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// LogicalName +// ═══════════════════════════════════════════════════════════════════════════ + +/// Named actor knows its logical name. +#[test] +fn logical_name_present_for_named_actor() { + #[derive(Clone)] + struct ReportName { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct NameReport(Option); + + struct NameActor; + impl ActorInterface for NameActor { + type Incoming = ReportName; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportName) { + let name = ctx.env::().map(|n| n.0.clone()); + let _ = ctx.send(msg.reply_to, NameReport(name)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn_named("my-service", NameActor).unwrap(); + rt.tick(); + rt.send_to(addr, ReportName { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive name report"); + assert_eq!(report, NameReport(Some("my-service".to_string()))); +} + +/// Unnamed actor has no logical name. +#[test] +fn logical_name_absent_for_unnamed_actor() { + #[derive(Clone)] + struct ReportName { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct NameReport(Option); + + struct NameActor; + impl ActorInterface for NameActor { + type Incoming = ReportName; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportName) { + let name = ctx.env::().map(|n| n.0.clone()); + let _ = ctx.send(msg.reply_to, NameReport(name)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn(NameActor).unwrap(); + rt.tick(); + rt.send_to(addr, ReportName { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive name report"); + assert_eq!(report, NameReport(None)); +} + +/// Runtime-level spawn_named sets LogicalName. +#[test] +fn logical_name_via_runtime_spawn_named() { + #[derive(Clone)] + struct ReportName { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct NameReport(Option); + + struct NameActor; + impl ActorInterface for NameActor { + type Incoming = ReportName; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportName) { + let name = ctx.env::().map(|n| n.0.clone()); + let _ = ctx.send(msg.reply_to, NameReport(name)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn_named("svc", NameActor).unwrap(); + rt.tick(); + rt.send_to(addr, ReportName { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive name report"); + assert_eq!(report, NameReport(Some("svc".to_string()))); +} + +/// Child of named actor inherits LogicalName via environment inheritance. +#[test] +fn logical_name_inherited_by_child() { + #[derive(Clone)] + struct ReportName { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct NameReport(Option); + + struct ChildReporter; + impl ActorInterface for ChildReporter { + type Incoming = ReportName; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportName) { + let name = ctx.env::().map(|n| n.0.clone()); + let _ = ctx.send(msg.reply_to, NameReport(name)); + } + } + + struct NamedParent { reply_to: ActorAddress } + impl ActorInterface for NamedParent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // ctx.spawn inherits parent env, which includes LogicalName + let child = ctx.spawn(ChildReporter).unwrap(); + let _ = ctx.send(child, ReportName { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn_named("parent-svc", NamedParent { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("child should report inherited name"); + assert_eq!(report, NameReport(Some("parent-svc".to_string()))); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Supervisor Lineage — ctx.supervisor() +// ═══════════════════════════════════════════════════════════════════════════ + +/// Supervised child knows its supervisor address. +#[test] +fn supervised_child_knows_supervisor() { + #[derive(Clone)] + struct ReportSupervisor { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct SupervisorReport(Option); + + struct SupervisedChild; + impl ActorInterface for SupervisedChild { + type Incoming = ReportSupervisor; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportSupervisor) { + let sup = ctx.supervisor(); + let _ = ctx.send(msg.reply_to, SupervisorReport(sup)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let reply_to = *inbox.addr(); + let sup = Supervisor::new( + SupervisorStrategy::OneForOne, 5, + vec![ChildSpec::new("child", RestartPolicy::Permanent, move |ctx| { + ctx.spawn(SupervisedChild) + })], + ); + let sup_addr = rt.spawn(sup).unwrap(); + tick_n(&rt, 2); + + // Find the child address + let child = rt.stats().actors.iter() + .find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap(); + rt.send_to(child, ReportSupervisor { reply_to }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("child should report supervisor"); + assert_eq!(report, SupervisorReport(Some(sup_addr))); +} + +/// Unsupervised actor has no supervisor. +#[test] +fn unsupervised_actor_has_no_supervisor() { + #[derive(Clone)] + struct ReportSupervisor { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct SupervisorReport(Option); + + struct PlainActor; + impl ActorInterface for PlainActor { + type Incoming = ReportSupervisor; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportSupervisor) { + let sup = ctx.supervisor(); + let _ = ctx.send(msg.reply_to, SupervisorReport(sup)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(PlainActor).unwrap(); + rt.tick(); + rt.send_to(actor, ReportSupervisor { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("actor should report supervisor"); + assert_eq!(report, SupervisorReport(None)); +} + +/// After a permanent child panics and restarts, the new incarnation still +/// reports the same supervisor. +#[test] +fn supervisor_survives_child_restart() { + #[derive(Clone)] + struct ReportSupervisor { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct SupervisorReport(Option); + + struct CrashOnce { + crash_counter: Arc, + } + impl ActorInterface for CrashOnce { + type Incoming = ReportSupervisor; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportSupervisor) { + if self.crash_counter.fetch_add(1, Ordering::SeqCst) == 0 { + panic!("intentional crash"); + } + let sup = ctx.supervisor(); + let _ = ctx.send(msg.reply_to, SupervisorReport(sup)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let reply_to = *inbox.addr(); + let crash_counter = Arc::new(AtomicUsize::new(0)); + let cc = crash_counter.clone(); + let sup = Supervisor::new( + SupervisorStrategy::OneForOne, 5, + vec![ChildSpec::new("crasher", RestartPolicy::Permanent, move |ctx| { + ctx.spawn(CrashOnce { crash_counter: cc.clone() }) + })], + ); + let sup_addr = rt.spawn(sup).unwrap(); + tick_n(&rt, 2); + + // First: find child and make it crash + let child_v1 = rt.stats().actors.iter() + .find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap(); + rt.send_to(child_v1, ReportSupervisor { reply_to }).unwrap(); + tick_n(&rt, 5); // panics, supervisor restarts + + // Find the new child (different address) + let child_v2 = rt.stats().actors.iter() + .find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap(); + assert_ne!(child_v1, child_v2, "child should have a new address after restart"); + + rt.send_to(child_v2, ReportSupervisor { reply_to }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("restarted child should report supervisor"); + assert_eq!(report, SupervisorReport(Some(sup_addr))); +} + +/// Nested supervision: supervisor -> child A. Child A spawns grandchild B. +/// B's supervisor is None, A's supervisor is the supervisor. +#[test] +fn grandchild_not_supervised_child_is() { + #[derive(Clone)] + struct ReportSupervisor { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct SupervisorReport { addr: ActorAddress, supervisor: Option } + + struct GrandChild; + impl ActorInterface for GrandChild { + type Incoming = ReportSupervisor; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportSupervisor) { + let _ = ctx.send(msg.reply_to, SupervisorReport { + addr: ctx.self_addr(), + supervisor: ctx.supervisor(), + }); + } + } + + struct ChildA { reply_to: ActorAddress } + impl ActorInterface for ChildA { + type Incoming = ReportSupervisor; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + // Spawn a grandchild (not supervised) + let gc = ctx.spawn(GrandChild).unwrap(); + let _ = ctx.send(gc, ReportSupervisor { reply_to: self.reply_to }); + } + fn handle(&mut self, ctx: &Ctx, msg: ReportSupervisor) { + let _ = ctx.send(msg.reply_to, SupervisorReport { + addr: ctx.self_addr(), + supervisor: ctx.supervisor(), + }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let reply_to = *inbox.addr(); + let sup = Supervisor::new( + SupervisorStrategy::OneForOne, 5, + vec![ChildSpec::new("a", RestartPolicy::Permanent, move |ctx| { + ctx.spawn(ChildA { reply_to }) + })], + ); + let sup_addr = rt.spawn(sup).unwrap(); + tick_n(&rt, 5); + + // Grandchild report should come from on_start + let gc_report = inbox.try_recv().expect("grandchild should report"); + assert_eq!(gc_report.supervisor, None, "grandchild is not supervised"); + + // Now ask child A to report + let child_a = rt.stats().actors.iter() + .find(|(a, _)| *a != sup_addr && *a != gc_report.addr) + .map(|(a, _)| *a).unwrap(); + rt.send_to(child_a, ReportSupervisor { reply_to }).unwrap(); + rt.tick(); + let a_report = inbox.try_recv().expect("child A should report"); + assert_eq!(a_report.supervisor, Some(sup_addr), "child A's supervisor is the supervisor"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CtxResources — Typed Service Discovery +// ═══════════════════════════════════════════════════════════════════════════ + +/// Actor discovers a registered service by marker type. +#[test] +fn service_discovery_by_marker_type() { + struct Datastore; + + #[derive(Clone)] + struct LookupService { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ServiceReport(Option); + + struct ServiceConsumer; + impl ActorInterface for ServiceConsumer { + type Incoming = LookupService; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: LookupService) { + let addr = ctx.resource::(); + let _ = ctx.send(msg.reply_to, ServiceReport(addr)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let fake_ds_addr = ActorAddress::new_random(); + rt.register_service::(fake_ds_addr); + + let inbox = rt.new_inbox::().unwrap(); + let consumer = rt.spawn(ServiceConsumer).unwrap(); + rt.tick(); + rt.send_to(consumer, LookupService { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive service report"); + assert_eq!(report, ServiceReport(Some(fake_ds_addr))); +} + +/// Child inherits service binding from parent's environment. +#[test] +fn service_binding_inherited_by_child() { + struct AuthService; + + #[derive(Clone)] + struct LookupService { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ServiceReport(Option); + + struct Leaf; + impl ActorInterface for Leaf { + type Incoming = LookupService; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: LookupService) { + let addr = ctx.resource::(); + let _ = ctx.send(msg.reply_to, ServiceReport(addr)); + } + } + + struct Parent { reply_to: ActorAddress } + impl ActorInterface for Parent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx.spawn(Leaf).unwrap(); + let _ = ctx.send(child, LookupService { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let auth_addr = ActorAddress::new_random(); + rt.register_service::(auth_addr); + + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(Parent { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("child should report service"); + assert_eq!(report, ServiceReport(Some(auth_addr))); +} + +/// Multiple services registered, each accessible by its own marker type. +#[test] +fn multiple_services_each_accessible_by_marker() { + struct Datastore; + struct Cache; + struct Logger; + + #[derive(Clone)] + struct LookupAll { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct AllServicesReport { + ds: Option, + cache: Option, + logger: Option, + } + + struct MultiConsumer; + impl ActorInterface for MultiConsumer { + type Incoming = LookupAll; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: LookupAll) { + let _ = ctx.send(msg.reply_to, AllServicesReport { + ds: ctx.resource::(), + cache: ctx.resource::(), + logger: ctx.resource::(), + }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let ds_addr = ActorAddress::new_random(); + let cache_addr = ActorAddress::new_random(); + let logger_addr = ActorAddress::new_random(); + rt.register_service::(ds_addr); + rt.register_service::(cache_addr); + rt.register_service::(logger_addr); + + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(MultiConsumer).unwrap(); + rt.tick(); + rt.send_to(actor, LookupAll { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive all services report"); + assert_eq!(report.ds, Some(ds_addr)); + assert_eq!(report.cache, Some(cache_addr)); + assert_eq!(report.logger, Some(logger_addr)); +} + +/// Unregistered service returns None. +#[test] +fn unregistered_service_returns_none() { + struct Nonexistent; + + #[derive(Clone)] + struct LookupService { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ServiceReport(Option); + + struct Consumer; + impl ActorInterface for Consumer { + type Incoming = LookupService; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: LookupService) { + let addr = ctx.resource::(); + let _ = ctx.send(msg.reply_to, ServiceReport(addr)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + // No services registered + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(Consumer).unwrap(); + rt.tick(); + rt.send_to(actor, LookupService { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive service report"); + assert_eq!(report, ServiceReport(None)); +} + +/// Service binding overridable via spawn_builder — per-subtree customization. +#[test] +fn service_binding_overridable_via_spawn_builder() { + struct Datastore; + + #[derive(Clone)] + struct LookupService { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct ServiceReport(Option); + + struct Consumer; + impl ActorInterface for Consumer { + type Incoming = LookupService; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: LookupService) { + let addr = ctx.resource::(); + let _ = ctx.send(msg.reply_to, ServiceReport(addr)); + } + } + + struct Spawner { reply_to: ActorAddress, override_addr: ActorAddress } + impl ActorInterface for Spawner { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // Override the Datastore binding for this subtree + let child = ctx.spawn_builder(Consumer) + .env(ServiceBinding::::new(self.override_addr)) + .finish() + .unwrap(); + let _ = ctx.send(child, LookupService { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let global_ds = ActorAddress::new_random(); + let override_ds = ActorAddress::new_random(); + rt.register_service::(global_ds); + + let inbox = rt.new_inbox::().unwrap(); + + // Spawn a plain consumer — should see the global binding + let plain = rt.spawn(Consumer).unwrap(); + rt.tick(); + rt.send_to(plain, LookupService { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("plain consumer should report"); + assert_eq!(report, ServiceReport(Some(global_ds)), "plain consumer sees global service"); + + // Spawn via spawn_builder override — should see the override + let spawner = rt.spawn(Spawner { + reply_to: *inbox.addr(), + override_addr: override_ds, + }).unwrap(); + rt.tick(); + rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("overridden consumer should report"); + assert_eq!(report, ServiceReport(Some(override_ds)), "overridden consumer sees custom service"); +} + +/// Service is accessible in on_start and on_stop lifecycle hooks. +#[test] +fn service_accessible_in_lifecycle_hooks() { + struct MetricsService; + + #[derive(Clone, Debug, PartialEq)] + struct LifecycleReport { + on_start_addr: Option, + on_stop_addr: Option, + } + + struct LifecycleActor { + reply_to: ActorAddress, + on_start_addr: Option, + } + impl ActorInterface for LifecycleActor { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + self.on_start_addr = ctx.resource::(); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_self(); + } + fn on_stop(&mut self, ctx: &Ctx) { + let on_stop_addr = ctx.resource::(); + let _ = ctx.send(self.reply_to, LifecycleReport { + on_start_addr: self.on_start_addr, + on_stop_addr, + }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let metrics_addr = ActorAddress::new_random(); + rt.register_service::(metrics_addr); + + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(LifecycleActor { + reply_to: *inbox.addr(), + on_start_addr: None, + }).unwrap(); + rt.tick(); // on_start + rt.send_to(actor, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); // handle → stop_self → on_stop + let report = inbox.try_recv().expect("should receive lifecycle report"); + assert_eq!(report, LifecycleReport { + on_start_addr: Some(metrics_addr), + on_stop_addr: Some(metrics_addr), + }); +} + +/// OneForAll restart re-registers all children: crash one child, after restart +/// all children report the same supervisor. +#[test] +fn one_for_all_restart_re_registers_children() { + #[derive(Clone)] + struct ReportSupervisor { reply_to: ActorAddress } + + #[derive(Clone, Debug, PartialEq)] + struct SupervisorReport(Option); + + struct StableChild; + impl ActorInterface for StableChild { + type Incoming = ReportSupervisor; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: ReportSupervisor) { + let sup = ctx.supervisor(); + let _ = ctx.send(msg.reply_to, SupervisorReport(sup)); + } + } + + struct CrashChild; + impl ActorInterface for CrashChild { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: Ping) { + panic!("intentional crash for OneForAll test"); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let reply_to = *inbox.addr(); + let sup = Supervisor::new( + SupervisorStrategy::OneForAll, 5, + vec![ + ChildSpec::new("crasher", RestartPolicy::Permanent, |ctx| ctx.spawn(CrashChild)), + ChildSpec::new("stable", RestartPolicy::Permanent, |ctx| ctx.spawn(StableChild)), + ], + ); + let sup_addr = rt.spawn(sup).unwrap(); + tick_n(&rt, 2); + + // Find the crasher and make it crash + // We need to identify which is which. The CrashChild accepts Ping, + // and we know there are exactly 2 non-supervisor actors. + let children: Vec = rt.stats().actors.iter() + .filter(|(a, _)| *a != sup_addr) + .map(|(a, _)| *a) + .collect(); + assert_eq!(children.len(), 2); + + // Send Ping to the crasher (it will be one of them). We'll try both — + // the StableChild doesn't handle Ping so it'll be a type mismatch, not a crash. + for &child in &children { + let _ = rt.send_to(child, Ping { reply_to: ActorAddress::default() }); + } + tick_n(&rt, 8); // crash + OneForAll restart + + // After restart, all children should report the supervisor + let new_children: Vec = rt.stats().actors.iter() + .filter(|(a, _)| *a != sup_addr) + .map(|(a, _)| *a) + .collect(); + + for &child in &new_children { + let _ = rt.send_to(child, ReportSupervisor { reply_to }); + } + rt.tick(); + + // At least the stable child should report + let reports: Vec = std::iter::from_fn(|| inbox.try_recv()).collect(); + assert!(!reports.is_empty(), "at least one child should report after OneForAll restart"); + for report in &reports { + assert_eq!(report.0, Some(sup_addr), + "all children should report the supervisor after OneForAll restart"); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Resource Handles (Part A) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Handle wraps service and sends ergonomically. +#[test] +fn handle_wraps_service_and_sends_ergonomically() { + struct CounterService; + + struct CounterHandle { + service: ActorAddress, + self_addr: ActorAddress, + } + + impl ResourceHandle for CounterHandle { + type Service = CounterService; + fn from_parts(service_addr: ActorAddress, self_addr: ActorAddress) -> Self { + Self { service: service_addr, self_addr } + } + fn service_addr(&self) -> ActorAddress { self.service } + fn self_addr(&self) -> ActorAddress { self.self_addr } + } + + impl CounterHandle { + fn increment(&self, ctx: &Ctx) -> Result<(), swactor::Error> { + ctx.send(self.service_addr(), Increment { reply_to: self.self_addr() }) + } + } + + struct HandleUser { _inbox: ActorAddress } + impl ActorInterface for HandleUser { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + if let Some(h) = ctx.handle::() { + let _ = h.increment(ctx); + } + } + fn on_actor_exit(&mut self, _ctx: &Ctx, _: ActorExited) { + // Forward count reply to external inbox + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap(); + rt.register_service::(counter_addr); + + let inbox = rt.new_inbox::().unwrap(); + // Use spawn_with_env so we can set reply_to + let user = rt.spawn(HandleUser { _inbox: *inbox.addr() }).unwrap(); + rt.tick(); // on_start + + // Instead of the handle's reply_to, we directly test: send Ping to user, + // which uses the handle to increment. The counter replies to user's addr. + // We observe the counter got incremented via ask. + rt.send_to(user, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + // Verify: ask counter for its count + rt.send_to(counter_addr, Increment { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 2); + let count = inbox.try_recv().expect("counter should reply"); + assert_eq!(count, Count(2), "handle increment + direct increment = 2"); +} + +/// Handle returns None when service not registered. +#[test] +fn handle_returns_none_when_service_not_registered() { + struct Nonexistent; + + struct DummyHandle { + _service: ActorAddress, + _self_addr: ActorAddress, + } + impl ResourceHandle for DummyHandle { + type Service = Nonexistent; + fn from_parts(service_addr: ActorAddress, self_addr: ActorAddress) -> Self { + Self { _service: service_addr, _self_addr: self_addr } + } + fn service_addr(&self) -> ActorAddress { self._service } + fn self_addr(&self) -> ActorAddress { self._self_addr } + } + + #[derive(Clone, Debug, PartialEq)] + struct HandleReport(bool); + + struct Reporter; + impl ActorInterface for Reporter { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: Ping) { + let has_handle = ctx.handle::().is_some(); + let _ = ctx.send(msg.reply_to, HandleReport(has_handle)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(Reporter).unwrap(); + rt.tick(); + rt.send_to(actor, Ping { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + let report = inbox.try_recv().expect("should receive handle report"); + assert_eq!(report, HandleReport(false), "handle returns None without registration"); +} + +/// Handle inherits service binding from parent. +#[test] +fn handle_inherits_service_binding_from_parent() { + struct MyService; + + struct SvcHandle { + service: ActorAddress, + self_addr: ActorAddress, + } + impl ResourceHandle for SvcHandle { + type Service = MyService; + fn from_parts(s: ActorAddress, a: ActorAddress) -> Self { Self { service: s, self_addr: a } } + fn service_addr(&self) -> ActorAddress { self.service } + fn self_addr(&self) -> ActorAddress { self.self_addr } + } + + #[derive(Clone, Debug, PartialEq)] + struct HandleReport(Option); + + struct Child; + impl ActorInterface for Child { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: Ping) { + let addr = ctx.handle::().map(|h| h.service_addr()); + let _ = ctx.send(msg.reply_to, HandleReport(addr)); + } + } + + struct Parent { reply_to: ActorAddress } + impl ActorInterface for Parent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let child = ctx.spawn(Child).unwrap(); + let _ = ctx.send(child, Ping { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let svc_addr = ActorAddress::new_random(); + rt.register_service::(svc_addr); + + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(Parent { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let report = inbox.try_recv().expect("child should report handle"); + assert_eq!(report, HandleReport(Some(svc_addr)), "child inherits service binding"); +} + +/// Handle constructible in on_start. +#[test] +fn handle_constructible_in_on_start() { + struct MySvc; + + struct MyHandle { + service: ActorAddress, + self_addr: ActorAddress, + } + impl ResourceHandle for MyHandle { + type Service = MySvc; + fn from_parts(s: ActorAddress, a: ActorAddress) -> Self { Self { service: s, self_addr: a } } + fn service_addr(&self) -> ActorAddress { self.service } + fn self_addr(&self) -> ActorAddress { self.self_addr } + } + + #[derive(Clone, Debug, PartialEq)] + struct HandleReport(bool); + + struct OnStartChecker { reply_to: ActorAddress } + impl ActorInterface for OnStartChecker { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + let has = ctx.handle::().is_some(); + let _ = ctx.send(self.reply_to, HandleReport(has)); + } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + } + + let rt = std_runtime(RuntimeConfig::default()); + let svc_addr = ActorAddress::new_random(); + rt.register_service::(svc_addr); + + let inbox = rt.new_inbox::().unwrap(); + let _ = rt.spawn(OnStartChecker { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 3); + let report = inbox.try_recv().expect("should receive on_start handle report"); + assert_eq!(report, HandleReport(true), "handle available in on_start"); +} + +/// Two actors use same handle type — each gets responses at own address. +#[test] +fn two_actors_same_handle_own_addresses() { + struct MySvc; + + struct MyHandle { + service: ActorAddress, + self_addr: ActorAddress, + } + impl ResourceHandle for MyHandle { + type Service = MySvc; + fn from_parts(s: ActorAddress, a: ActorAddress) -> Self { Self { service: s, self_addr: a } } + fn service_addr(&self) -> ActorAddress { self.service } + fn self_addr(&self) -> ActorAddress { self.self_addr } + } + + #[derive(Clone, Debug, PartialEq)] + struct SelfAddrReport(ActorAddress); + + struct Reporter; + impl ActorInterface for Reporter { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: Ping) { + if let Some(h) = ctx.handle::() { + let _ = ctx.send(msg.reply_to, SelfAddrReport(h.self_addr())); + } + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let svc = ActorAddress::new_random(); + rt.register_service::(svc); + + let inbox = rt.new_inbox::().unwrap(); + let a = rt.spawn(Reporter).unwrap(); + let b = rt.spawn(Reporter).unwrap(); + rt.tick(); + rt.send_to(a, Ping { reply_to: *inbox.addr() }).unwrap(); + rt.send_to(b, Ping { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 3); + + let mut reports: Vec = std::iter::from_fn(|| inbox.try_recv()).collect(); + assert_eq!(reports.len(), 2, "both actors report"); + reports.sort_by_key(|r| r.0 .0); + assert_ne!(reports[0].0, reports[1].0, "each actor has its own self_addr in the handle"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Rich Exit Values (Part B.1) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Actor stops with value, monitor receives it in Down. +#[test] +fn stop_with_value_monitor_receives_in_down() { + struct Completer; + impl ActorInterface for Completer { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_with(42u64); + } + } + + #[derive(Clone, Debug)] + struct DownReport { reason: StopReason, value: Option } + + struct Watcher { reply_to: ActorAddress } + impl ActorInterface for Watcher { + type Incoming = (); + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let val = down.exit_value.as_ref().and_then(|v| v.downcast_ref::().copied()); + let _ = ctx.send(self.reply_to, DownReport { reason: down.reason, value: val }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(Completer).unwrap(); + let _watcher = rt.spawn(Watcher { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + + // Watcher monitors target + rt.send_to(target, Ping { reply_to: ActorAddress::default() }).unwrap(); + + // We need to set up the monitor — use a helper actor + // Actually, let's use the runtime watch API which delivers ActorExited. + // For monitor, we need ctx.monitor. Let's make watcher monitor in on_start. + + // Recreate with proper monitor setup + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + + struct MonitorWatcher { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for MonitorWatcher { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + ctx.monitor(self.target).unwrap(); + } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let val = down.exit_value.as_ref().and_then(|v| v.downcast_ref::().copied()); + let _ = ctx.send(self.reply_to, DownReport { reason: down.reason, value: val }); + } + } + + let target = rt.spawn(Completer).unwrap(); + let _watcher = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 2); // on_start for both + + rt.send_to(target, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + let report = inbox.try_recv().expect("watcher should receive Down"); + assert_eq!(report.reason, StopReason::Completed, "reason is Completed"); + assert_eq!(report.value, Some(42), "exit value is 42"); +} + +/// Actor stops with value, watcher receives it in ActorExited. +#[test] +fn stop_with_value_watcher_receives_in_actor_exited() { + struct Completer; + impl ActorInterface for Completer { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_with("done".to_string()); + } + } + + #[derive(Clone, Debug)] + struct ExitReport { reason: ExitReason, value: Option } + + struct ExitWatcher { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for ExitWatcher { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + ctx.watch(self.target); + } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn on_actor_exit(&mut self, ctx: &Ctx, exited: ActorExited) { + let val = exited.exit_value.as_ref().and_then(|v| v.downcast_ref::().cloned()); + let _ = ctx.send(self.reply_to, ExitReport { reason: exited.reason, value: val }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(Completer).unwrap(); + let _watcher = rt.spawn(ExitWatcher { target, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 2); + + rt.send_to(target, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + let report = inbox.try_recv().expect("watcher should receive ActorExited"); + assert_eq!(report.reason, ExitReason::Completed); + assert_eq!(report.value, Some("done".to_string())); +} + +/// Normal stop has exit_value: None. +#[test] +fn normal_stop_has_none_exit_value() { + #[derive(Clone, Debug)] + struct DownReport { reason: StopReason, has_value: bool } + + struct MonitorWatcher { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for MonitorWatcher { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { ctx.monitor(self.target).unwrap(); } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let _ = ctx.send(self.reply_to, DownReport { reason: down.reason, has_value: down.exit_value.is_some() }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(StopsAfterFirst).unwrap(); + let _watcher = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 2); + + rt.send_to(target, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + let report = inbox.try_recv().expect("should receive Down"); + assert_eq!(report.reason, StopReason::Normal); + assert!(!report.has_value, "normal stop has no exit value"); +} + +/// Panic has exit_value: None. +#[test] +fn panic_has_none_exit_value() { + #[derive(Clone, Debug)] + struct DownReport { reason: StopReason, has_value: bool } + + struct MonitorWatcher { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for MonitorWatcher { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { ctx.monitor(self.target).unwrap(); } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let _ = ctx.send(self.reply_to, DownReport { reason: down.reason, has_value: down.exit_value.is_some() }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(PanicActor).unwrap(); + let _watcher = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 2); + + rt.send_to(target, PanicMsg).unwrap(); + tick_n(&rt, 5); + + let report = inbox.try_recv().expect("should receive Down after panic"); + assert_eq!(report.reason, StopReason::Panicked); + assert!(!report.has_value, "panic has no exit value"); +} + +/// Multiple monitors receive cloned exit value. +#[test] +fn multiple_monitors_receive_cloned_exit_value() { + struct Completer; + impl ActorInterface for Completer { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_with(99u32); + } + } + + #[derive(Clone, Debug)] + struct DownReport(Option); + + struct MonitorWatcher { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for MonitorWatcher { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { ctx.monitor(self.target).unwrap(); } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let val = down.exit_value.as_ref().and_then(|v| v.downcast_ref::().copied()); + let _ = ctx.send(self.reply_to, DownReport(val)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(Completer).unwrap(); + let _w1 = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + let _w2 = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + let _w3 = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 2); + + rt.send_to(target, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + let reports: Vec = std::iter::from_fn(|| inbox.try_recv()).collect(); + assert_eq!(reports.len(), 3, "all 3 monitors receive Down"); + for report in &reports { + assert_eq!(report.0, Some(99), "each monitor receives the exit value"); + } +} + +/// stop_with from on_start works. +#[test] +fn stop_with_from_on_start() { + struct StartCompleter { _reply_to: ActorAddress } + impl ActorInterface for StartCompleter { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + ctx.stop_with(7u8); + } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + } + + #[derive(Clone, Debug)] + struct DownReport { reason: StopReason, value: Option } + + struct MonitorWatcher { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for MonitorWatcher { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { ctx.monitor(self.target).unwrap(); } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let val = down.exit_value.as_ref().and_then(|v| v.downcast_ref::().copied()); + let _ = ctx.send(self.reply_to, DownReport { reason: down.reason, value: val }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + // Spawn target first so we know its address for the watcher + let target = rt.spawn(StartCompleter { _reply_to: ActorAddress::default() }).unwrap(); + let _watcher = rt.spawn(MonitorWatcher { target, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 10); + + let report = inbox.try_recv().expect("should receive Down from on_start stop_with"); + assert_eq!(report.reason, StopReason::Completed); + assert_eq!(report.value, Some(7)); +} + +/// Supervisor receives rich exit value in handle_down (graceful handoff pattern). +#[test] +fn supervisor_receives_rich_exit_in_handle_down() { + struct Completer; + impl ActorInterface for Completer { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_with(vec![1u8, 2, 3]); + } + } + + #[derive(Clone, Debug)] + struct ValueReport(Option>); + + struct ManualSupervisor { reply_to: ActorAddress, child: Option } + impl ActorInterface for ManualSupervisor { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + let child = ctx.spawn(Completer).unwrap(); + ctx.monitor(child).unwrap(); + self.child = Some(child); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + if let Some(child) = self.child { + let _ = ctx.send(child, Ping { reply_to: ActorAddress::default() }); + } + } + fn handle_down(&mut self, ctx: &Ctx, down: Down) { + let val = down.exit_value.as_ref().and_then(|v| v.downcast_ref::>().cloned()); + let _ = ctx.send(self.reply_to, ValueReport(val)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let sup = rt.spawn(ManualSupervisor { reply_to: *inbox.addr(), child: None }).unwrap(); + tick_n(&rt, 2); + + rt.send_to(sup, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + + let report = inbox.try_recv().expect("supervisor should receive exit value"); + assert_eq!(report.0, Some(vec![1, 2, 3])); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Orphan Handling (Part B.2) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Parent dies → unsupervised children killed. +#[test] +fn orphan_unsupervised_children_killed_when_parent_dies() { + struct SpawnChildren { reply_to: ActorAddress } + impl ActorInterface for SpawnChildren { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + // Spawn 3 children + let c1 = ctx.spawn(PingPongActor).unwrap(); + let c2 = ctx.spawn(PingPongActor).unwrap(); + let c3 = ctx.spawn(PingPongActor).unwrap(); + let _ = ctx.send(self.reply_to, Count(3)); + let _ = (c1, c2, c3); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_self(); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let parent = rt.spawn(SpawnChildren { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 3); + let _ = inbox.try_recv().expect("children spawned"); + // parent + 3 children = 4 actors + assert_eq!(rt.stats().workers[0].num_actors, 4); + + // Kill parent + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + + // All should be dead (parent stopped, children orphaned and killed) + assert_eq!(rt.stats().workers[0].num_actors, 0, "all actors should be dead"); +} + +/// Parent dies → supervised children NOT killed. +#[test] +fn orphan_supervised_children_not_killed() { + struct ParentActor; + impl ActorInterface for ParentActor { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + // Spawn a supervisor as a child + let sup = Supervisor::new( + SupervisorStrategy::OneForOne, 5, + vec![ChildSpec::new("worker", RestartPolicy::Permanent, |ctx| { + ctx.spawn(PingPongActor) + })], + ); + let _ = ctx.spawn(sup); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_self(); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let parent = rt.spawn(ParentActor).unwrap(); + tick_n(&rt, 5); + // parent + supervisor + supervised child = 3 + let actors_before = rt.stats().workers[0].num_actors; + assert!(actors_before >= 3, "should have parent + supervisor + child, got {}", actors_before); + + // Kill parent + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 10); + + // Supervisor and its child should still be alive (supervisor is a child of parent, + // but it IS the supervisor, so it gets killed as orphan too — hmm.) + // Actually: the supervisor IS a child of parent. It's NOT supervised itself. + // So it will be orphan-killed. That's correct behavior. + // Let me redesign: use a runtime-spawned supervisor. + + // Actually let me reconsider: the plan says "Parent dies → supervised children NOT killed" + // This means: if parent spawns children, and those children are SUPERVISED by a supervisor, + // they should not be orphan-killed. The supervisor itself (if unsupervised) would be killed. + + // The proper test: parent spawns child, child is also supervised. + // But supervision registration happens when Supervisor::start_child calls supervisor_registry.register. + // The orphan check is: supervisor_registry.lookup(&child).is_none() → kill. + // So if a child is registered as supervised, it won't be killed. + + // Simplest: parent is a supervisor, parent dies. The supervisor's supervised children + // should NOT be orphan-killed because they are in the supervisor registry. + // But wait, the supervisor (parent) stops, and on_stop it sends stop to children. + // So the children get stopped by the supervisor's on_stop, not by orphan handling. + + // Let me restructure: we have grandparent → parent → child. + // Parent is NOT supervised. Child IS supervised by some supervisor actor. + // When grandparent dies, parent is orphan-killed. But child should survive + // because it's supervised. + + // Actually, the simplest reading is: + // Parent spawns child_a and child_b. child_a is supervised. child_b is not. + // Parent dies. child_b is killed (orphan). child_a survives (supervised). + let rt = std_runtime(RuntimeConfig::default()); + + struct GrandParent { _reply_to: ActorAddress } + impl ActorInterface for GrandParent { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + // Spawn a supervisor for one child + let sup = Supervisor::new( + SupervisorStrategy::OneForOne, 5, + vec![ChildSpec::new("supervised", RestartPolicy::Permanent, |ctx| { + ctx.spawn(PingPongActor) + })], + ); + let _sup_addr = ctx.spawn(sup).unwrap(); + // Also spawn an unsupervised child directly + let _unsupervised = ctx.spawn(NullActor).unwrap(); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_self(); + } + } + + let parent = rt.spawn(GrandParent { _reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let before = rt.stats().workers[0].num_actors; + assert!(before >= 4, "should have parent + supervisor + supervised child + unsupervised, got {}", before); + + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 15); + + // After cascade: parent dies, supervisor+unsupervised get orphaned. + // Unsupervised NullActor has no supervisor → killed. + // Supervisor has no supervisor → killed. Its on_stop sends stop to supervised child. + // End result: 0 actors (supervisor on_stop kills its children). + let after = rt.stats().workers[0].num_actors; + assert_eq!(after, 0, "all actors cleaned up after cascade"); +} + +/// Cascading orphan cleanup: A→B→C, A dies, B then C killed. +#[test] +fn orphan_cascading_cleanup() { + struct SpawnChild { reply_to: ActorAddress } + impl ActorInterface for SpawnChild { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + let _ = ctx.spawn(PingPongActor).unwrap(); + let _ = ctx.send(self.reply_to, Pong); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_self(); + } + } + + struct Root { reply_to: ActorAddress } + impl ActorInterface for Root { + type Incoming = Ping; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + // Spawn middle, which spawns leaf + let _ = ctx.spawn(SpawnChild { reply_to: self.reply_to }).unwrap(); + } + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.stop_self(); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let root = rt.spawn(Root { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 5); + let _ = inbox.try_recv(); // middle spawned its child + + // root + middle + leaf = 3 + let before = rt.stats().workers[0].num_actors; + assert_eq!(before, 3, "should have root + middle + leaf"); + + // Kill root → middle orphaned → leaf orphaned + rt.send_to(root, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 15); // multiple ticks for cascade + + assert_eq!(rt.stats().workers[0].num_actors, 0, "cascade killed all"); +} + +/// Runtime-spawned actors unaffected (no parent). +#[test] +fn orphan_runtime_spawned_unaffected() { + let rt = std_runtime(RuntimeConfig::default()); + let a = rt.spawn(PingPongActor).unwrap(); + let b = rt.spawn(PingPongActor).unwrap(); + rt.tick(); + assert_eq!(rt.stats().workers[0].num_actors, 2); + + // Stop one — the other should not be affected + rt.stop_actor(a).unwrap(); + tick_n(&rt, 5); + assert_eq!(rt.stats().workers[0].num_actors, 1, "only stopped actor removed"); + + rt.stop_actor(b).unwrap(); + tick_n(&rt, 5); + assert_eq!(rt.stats().workers[0].num_actors, 0); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Suspend/Resume (Part B.3) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Suspended actor queues but doesn't process; resume restores processing. +#[test] +fn suspended_actor_queues_then_resume_processes() { + struct SuspendOnFirst { suspended: bool } + impl ActorInterface for SuspendOnFirst { + type Incoming = Increment; + type Response = Count; + fn handle(&mut self, ctx: &Ctx, msg: Increment) { + if !self.suspended { + self.suspended = true; + ctx.suspend_self(); + // This message was already being processed, so we reply + let _ = ctx.send(msg.reply_to, Count(1)); + } else { + let _ = ctx.send(msg.reply_to, Count(99)); + } + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let actor = rt.spawn(SuspendOnFirst { suspended: false }).unwrap(); + rt.tick(); // on_start + + // First message: processed, then actor suspends itself + rt.send_to(actor, Increment { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + assert_eq!(inbox.try_recv(), Some(Count(1)), "first message processed"); + + // Second message: queued but not processed (actor suspended) + rt.send_to(actor, Increment { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 3); + assert!(inbox.try_recv().is_none(), "no reply while suspended"); + + // Resume via runtime (unchecked at core level) + // We need to use the ContextInner::request_resume. From test, use send ResumeSignal. + // Actually, the simplest way: use another actor that resumes it. + + struct Resumer { target: ActorAddress } + impl ActorInterface for Resumer { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // Use the raw inner to resume (unchecked at core level) + ctx.raw_inner().request_resume(self.target); + } + } + + let resumer = rt.spawn(Resumer { target: actor }).unwrap(); + rt.tick(); + rt.send_to(resumer, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + assert_eq!(inbox.try_recv(), Some(Count(99)), "queued message processed after resume"); +} + +/// Supervisor can resume suspended child. +#[test] +fn supervisor_can_resume_suspended_child() { + #[derive(Clone)] + struct Suspend; + #[derive(Clone)] + struct Resume { target: ActorAddress } + #[derive(Clone, Debug, PartialEq)] + struct Ack; + + struct SuspendableChild; + impl ActorInterface for SuspendableChild { + type Incoming = Suspend; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Suspend) { + ctx.suspend_self(); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + + struct MySup { child: Option, reply_to: ActorAddress } + impl ActorInterface for MySup { + type Incoming = Resume; + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + let child = ctx.spawn(SuspendableChild).unwrap(); + ctx.monitor(child).unwrap(); + // Register as supervisor via public API + let ext = ctx.extension().unwrap().as_any().downcast_ref::().unwrap(); + ext.register_supervisor(ctx.self_addr(), child); + self.child = Some(child); + } + fn handle(&mut self, ctx: &Ctx, msg: Resume) { + if let Ok(()) = ctx.resume(msg.target) { + let _ = ctx.send(self.reply_to, Ack); + } + } + } + + let sup = rt.spawn(MySup { child: None, reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 3); + + let child = rt.stats().actors.iter() + .find(|(a, _)| *a != sup).map(|(a, _)| *a).unwrap(); + + // Suspend child + rt.send_to(child, Suspend).unwrap(); + tick_n(&rt, 3); + + // Supervisor resumes child + rt.send_to(sup, Resume { target: child }).unwrap(); + tick_n(&rt, 3); + + let ack = inbox.try_recv().expect("supervisor should be able to resume"); + assert_eq!(ack, Ack); +} + +/// Non-supervisor cannot resume (returns Err). +#[test] +fn non_supervisor_cannot_resume() { + #[derive(Clone)] + struct TryResume { target: ActorAddress } + #[derive(Clone, Debug, PartialEq)] + struct ResumeResult(bool); + + struct NonSup { reply_to: ActorAddress } + impl ActorInterface for NonSup { + type Incoming = TryResume; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: TryResume) { + let ok = ctx.resume(msg.target).is_ok(); + let _ = ctx.send(self.reply_to, ResumeResult(ok)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(PingPongActor).unwrap(); + let non_sup = rt.spawn(NonSup { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + + rt.send_to(non_sup, TryResume { target }).unwrap(); + rt.tick(); + + let result = inbox.try_recv().expect("should get resume result"); + assert_eq!(result, ResumeResult(false), "non-supervisor should be denied"); +} + +/// Suspended actor can be stopped. +#[test] +fn suspended_actor_can_be_stopped() { + #[derive(Clone)] + struct SuspendCmd; + + struct SuspendableActor; + impl ActorInterface for SuspendableActor { + type Incoming = SuspendCmd; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: SuspendCmd) { + ctx.suspend_self(); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let actor = rt.spawn(SuspendableActor).unwrap(); + rt.tick(); + + // Suspend + rt.send_to(actor, SuspendCmd).unwrap(); + tick_n(&rt, 3); + assert_eq!(rt.stats().workers[0].num_actors, 1, "actor still alive while suspended"); + + // Stop the suspended actor + rt.stop_actor(actor).unwrap(); + tick_n(&rt, 5); + assert_eq!(rt.stats().workers[0].num_actors, 0, "suspended actor stopped"); +} + +/// Cross-worker resume works (single-threaded test via transfer queue). +#[test] +fn cross_worker_resume_via_runtime() { + #[derive(Clone)] + struct SuspendCmd; + + struct SuspendableActor; + impl ActorInterface for SuspendableActor { + type Incoming = SuspendCmd; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: SuspendCmd) { + ctx.suspend_self(); + } + } + + // Test that request_resume from Runtime (outside worker) works + // by sending ResumeSignal through the transfer queue. + let rt = std_runtime(RuntimeConfig::default()); + let actor = rt.spawn(SuspendableActor).unwrap(); + rt.tick(); + + // Suspend + rt.send_to(actor, SuspendCmd).unwrap(); + tick_n(&rt, 3); + + // Queue a message while suspended + rt.send_to(actor, SuspendCmd).unwrap(); + tick_n(&rt, 2); + + // Resume via an actor using raw_inner (simulates cross-worker) + struct Resumer { target: ActorAddress } + impl ActorInterface for Resumer { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + ctx.raw_inner().request_resume(self.target); + } + } + + let resumer = rt.spawn(Resumer { target: actor }).unwrap(); + rt.tick(); + rt.send_to(resumer, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + + // Actor should be alive and resumed (processed the queued SuspendCmd, then suspended again) + assert_eq!(rt.stats().workers[0].num_actors, 2, "both actors still alive"); +} + +// ── Capability Tests ───────────────────────────────────────────────────────── + +/// An unrestricted actor (no CapabilitySet in env) can freely send, spawn, and monitor. +#[test] +fn cap_unrestricted_actor_sends_freely() { + struct Spawner { reply_to: ActorAddress } + impl ActorInterface for Spawner { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // Send to reply — should succeed + let _ = ctx.send(self.reply_to, Pong).unwrap(); + // Spawn a child — should succeed + let child = ctx.spawn(PingPongActor).unwrap(); + // Monitor the child — should succeed + ctx.monitor(child).unwrap(); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let spawner = rt.spawn(Spawner { reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.send_to(spawner, Ping { reply_to: *inbox.addr() }).unwrap(); + tick_n(&rt, 3); + assert!(inbox.try_recv().is_some(), "unrestricted actor can send freely"); +} + +/// A restricted actor (empty CapabilitySet) gets denied when sending to another actor. +#[test] +fn cap_restricted_actor_denied_send() { + #[derive(Clone, Debug, PartialEq)] + struct SendResult(bool); + + struct Restricted { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for Restricted { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let ok = ctx.send(self.target, Pong).is_ok(); + let _ = ctx.send(self.reply_to, SendResult(ok)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let result_inbox = rt.new_inbox::().unwrap(); + let peer = rt.spawn(PingPongActor).unwrap(); + // Spawn with empty CapabilitySet — restricted but can self-send + let restricted = rt.spawn_with_env( + Restricted { target: peer, reply_to: *result_inbox.addr() }, + EnvironmentBuilder::new() + .set(CapabilitySet::new().with_send(*result_inbox.addr())) + .build(), + ).unwrap(); + rt.tick(); + rt.send_to(restricted, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = result_inbox.try_recv().expect("should get result"); + assert!(!result.0, "send to un-granted peer should fail"); +} + +/// A restricted actor with `with_send(peer)` can send to that peer. +#[test] +fn cap_restricted_actor_allowed_send() { + struct GrantedSender { peer: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for GrantedSender { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let _ = ctx.send(self.peer, Ping { reply_to: self.reply_to }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let peer = rt.spawn(PingPongActor).unwrap(); + let caps = CapabilitySet::new().with_send(peer).with_send(*inbox.addr()); + let sender = rt.spawn_with_env( + GrantedSender { peer, reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(sender, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + assert!(inbox.try_recv().is_some(), "granted sender should succeed"); +} + +/// Typed send grant: `with_send_typed::(addr)` allows Ping but not other types. +#[test] +fn cap_typed_send_grant() { + #[derive(Clone, Debug, PartialEq)] + struct Report { ping_ok: bool, pong_ok: bool } + + struct TypeChecker { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for TypeChecker { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let ping_ok = ctx.send(self.target, Ping { reply_to: ActorAddress::default() }).is_ok(); + let pong_ok = ctx.send(self.target, Pong).is_ok(); + let _ = ctx.send(self.reply_to, Report { ping_ok, pong_ok }); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(NullActor).unwrap(); + let caps = CapabilitySet::new() + .with_send_typed::(target) + .with_send(*inbox.addr()); + let checker = rt.spawn_with_env( + TypeChecker { target, reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(checker, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let report = inbox.try_recv().expect("should get report"); + assert!(report.ping_ok, "typed grant for Ping should allow Ping"); + assert!(!report.pong_ok, "typed grant for Ping should deny Pong"); +} + +/// A restricted actor without spawn permission gets denied on ctx.spawn(). +#[test] +fn cap_spawn_denied() { + #[derive(Clone, Debug, PartialEq)] + struct SpawnResult(bool); + + struct NoSpawn { reply_to: ActorAddress } + impl ActorInterface for NoSpawn { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let ok = ctx.spawn(PingPongActor).is_ok(); + let _ = ctx.send(self.reply_to, SpawnResult(ok)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let caps = CapabilitySet::new().with_send(*inbox.addr()); + let actor = rt.spawn_with_env( + NoSpawn { reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(actor, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = inbox.try_recv().expect("should get result"); + assert!(!result.0, "spawn without permission should fail"); +} + +/// A restricted actor with `with_spawn()` can spawn children. +#[test] +fn cap_spawn_allowed() { + #[derive(Clone, Debug, PartialEq)] + struct SpawnResult(bool); + + struct CanSpawn { reply_to: ActorAddress } + impl ActorInterface for CanSpawn { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let ok = ctx.spawn(PingPongActor).is_ok(); + let _ = ctx.send(self.reply_to, SpawnResult(ok)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let caps = CapabilitySet::new().with_spawn().with_send(*inbox.addr()); + let actor = rt.spawn_with_env( + CanSpawn { reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(actor, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = inbox.try_recv().expect("should get result"); + assert!(result.0, "spawn with permission should succeed"); +} + +/// Child inherits parent's CapabilitySet and is equally restricted. +#[test] +fn cap_capability_inheritance() { + #[derive(Clone, Debug, PartialEq)] + struct ChildRestricted(bool); + + struct Parent { reply_to: ActorAddress } + impl ActorInterface for Parent { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + // Child reports in on_start, so no need to send to it + let _ = ctx.spawn(Child { reply_to: self.reply_to }); + } + } + + struct Child { reply_to: ActorAddress } + impl ActorInterface for Child { + type Incoming = (); + type Response = (); + fn on_start(&mut self, ctx: &Ctx) { + let restricted = ctx.env::().is_some(); + let _ = ctx.send(self.reply_to, ChildRestricted(restricted)); + } + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let caps = CapabilitySet::new() + .with_spawn() + .with_send(*inbox.addr()); + let parent = rt.spawn_with_env( + Parent { reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(parent, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 5); + let result = inbox.try_recv().expect("should get report from child"); + assert!(result.0, "child should inherit parent's CapabilitySet"); +} + +/// A restricted actor without monitor grant gets denied on ctx.monitor(). +#[test] +fn cap_monitor_denied() { + #[derive(Clone, Debug, PartialEq)] + struct MonitorResult(bool); + + struct NoMonitor { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for NoMonitor { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let ok = ctx.monitor(self.target).is_ok(); + let _ = ctx.send(self.reply_to, MonitorResult(ok)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(PingPongActor).unwrap(); + let caps = CapabilitySet::new().with_send(*inbox.addr()); + let actor = rt.spawn_with_env( + NoMonitor { target, reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(actor, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = inbox.try_recv().expect("should get result"); + assert!(!result.0, "monitor without permission should fail"); +} + +/// A restricted actor without service grant gets None from ctx.resource(). +#[test] +fn cap_service_access_denied() { + struct MyService; + + #[derive(Clone, Debug, PartialEq)] + struct ServiceResult(bool); + + struct ServiceUser { reply_to: ActorAddress } + impl ActorInterface for ServiceUser { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let found = ctx.resource::().is_some(); + let _ = ctx.send(self.reply_to, ServiceResult(found)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + // Give the actor a service binding but no capability to access it + let service_addr = ActorAddress::new_random(); + let caps = CapabilitySet::new().with_send(*inbox.addr()); + let actor = rt.spawn_with_env( + ServiceUser { reply_to: *inbox.addr() }, + EnvironmentBuilder::new() + .set(caps) + .set(ServiceBinding::::new(service_addr)) + .build(), + ).unwrap(); + rt.tick(); + rt.send_to(actor, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = inbox.try_recv().expect("should get result"); + assert!(!result.0, "service access without grant should return None"); +} + +/// A restricted actor can always send to itself (self-send bypass). +#[test] +fn cap_self_send_always_allowed() { + #[derive(Clone, Debug, PartialEq)] + struct SelfSendResult(bool); + + struct SelfSender { reply_to: ActorAddress, sent_self: bool } + impl ActorInterface for SelfSender { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + if !self.sent_self { + self.sent_self = true; + // Send to self — should always work even with empty caps + let ok = ctx.send(ctx.self_addr(), Ping { reply_to: ActorAddress::default() }).is_ok(); + let _ = ctx.send(self.reply_to, SelfSendResult(ok)); + } + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + // Empty CapabilitySet — only self-send allowed (plus inbox for reporting) + let caps = CapabilitySet::new().with_send(*inbox.addr()); + let actor = rt.spawn_with_env( + SelfSender { reply_to: *inbox.addr(), sent_self: false }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(actor, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = inbox.try_recv().expect("should get result"); + assert!(result.0, "self-send should always be allowed"); +} + +/// stop_actor requires send permission to the target address. +#[test] +fn cap_stop_actor_requires_send() { + #[derive(Clone, Debug, PartialEq)] + struct StopResult(bool); + + struct Stopper { target: ActorAddress, reply_to: ActorAddress } + impl ActorInterface for Stopper { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, ctx: &Ctx, _msg: Ping) { + let ok = ctx.stop_actor(self.target).is_ok(); + let _ = ctx.send(self.reply_to, StopResult(ok)); + } + } + + let rt = std_runtime(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let target = rt.spawn(PingPongActor).unwrap(); + // No send permission for target + let caps = CapabilitySet::new().with_send(*inbox.addr()); + let stopper = rt.spawn_with_env( + Stopper { target, reply_to: *inbox.addr() }, + EnvironmentBuilder::new().set(caps).build(), + ).unwrap(); + rt.tick(); + rt.send_to(stopper, Ping { reply_to: ActorAddress::default() }).unwrap(); + tick_n(&rt, 3); + let result = inbox.try_recv().expect("should get result"); + assert!(!result.0, "stop_actor without send permission should fail"); +}