feat(core): add process-local multicore runtime

Drive owned workers through RuntimeParts, SingleThreadRuntime, and engine worker drivers. Update bindings, Myelin, transport/driver tests, specs, and archive the multicore draft spec.
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-08-11 16:08:06 +04:00
parent b887e941cb
commit af49ba5c2c
49 changed files with 2609 additions and 1277 deletions

View file

@ -23,12 +23,12 @@ pub trait ActorInterface: 'static + Send {
} }
``` ```
You drive the runtime single-threaded with `tick()` (this is what compiles to You can drive core explicitly with `SingleThreadRuntime` (the WASM-friendly
WASM) or multi-threaded with `run()`: host) or hand `RuntimeParts` to an engine substrate:
```rust ```rust
use swactor::actor::{ActorInterface, Ctx}; use swactor::actor::{ActorInterface, Ctx};
use swactor::runtime::{Runtime, RuntimeConfig}; use swactor::runtime::{RuntimeConfig, RuntimeParts, SingleThreadRuntime};
struct Counter { count: u64 } struct Counter { count: u64 }
@ -42,17 +42,19 @@ impl ActorInterface for Counter {
} }
fn main() -> Result<(), swactor::Error> { fn main() -> Result<(), swactor::Error> {
let rt = Runtime::new(RuntimeConfig::default()); // 1 worker → drive with tick() let parts = RuntimeParts::new(RuntimeConfig::default()); // 1 worker by default
let rt = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let addr = rt.spawn(Counter { count: 0 })?; let addr = rt.spawn(Counter { count: 0 })?;
rt.send_to(addr, ())?; rt.send_to(addr, ())?;
for _ in 0..3 { rt.tick(); } // spawn → handle → done for _ in 0..3 { host.tick(); } // spawn → handle → done
Ok(()) Ok(())
} }
``` ```
`RuntimeConfig` is three knobs — `max_actors`, `channel_buffer_size`, `RuntimeConfig` exposes worker count, actor/worker ingress budgets, actor
and a per-tick `actor_message_budget` (inspired by BEAM's capacity, and channel buffer sizing. The per-tick actor budget is inspired by
reduction count, so one chatty mailbox can't starve the rest). BEAM's reduction count, so one chatty mailbox can't starve the rest.
## Features ## Features

View file

@ -2,8 +2,9 @@
***STALE! FOR HISTORICAL REFERENCE ONLY*** ***STALE! FOR HISTORICAL REFERENCE ONLY***
Id: 9 Id: 9
Last modified: Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
This document is the single Myelin reference for the swactor GGUF pipeline system. This document is the single Myelin reference for the swactor GGUF pipeline system.
It folds the system behavior previously split across the orchestration, It folds the system behavior previously split across the orchestration,

View file

@ -1720,13 +1720,12 @@ fn run() -> Result<(), String> {
// during runtime construction. // during runtime construction.
let mut datastream = NodeDatastream::new(&config); let mut datastream = NodeDatastream::new(&config);
// Build the core swactor runtime, then hand it to the engine. The engine // Build the core swactor runtime parts, clone the routing handle needed by
// owns both the runtime (it drives actor progression) and the Tokio // integrations, then hand the workers to the engine. The engine owns both
// substrate (it schedules all background work). After this point the engine // core progression and the Tokio substrate (it schedules all background
// is the sole owner of Tokio and core progression — no raw handles are // work); components retain only cheap Runtime handles (ENGINE_SPEC.md).
// passed to components (ENGINE_SPEC.md).
let worker_stats_hook = datastream.producer.stats_hook(); let worker_stats_hook = datastream.producer.stats_hook();
let (runtime, codec, transport_router) = DistributionRuntimeStack::build_runtime( let (parts, runtime, codec, transport_router) = DistributionRuntimeStack::build_runtime(
|registry| { |registry| {
register_myelin_actor_codecs(registry); register_myelin_actor_codecs(registry);
datastream::wire::register_datastream_codec(registry); datastream::wire::register_datastream_codec(registry);
@ -1734,7 +1733,7 @@ fn run() -> Result<(), String> {
Some(worker_stats_hook), Some(worker_stats_hook),
); );
let engine = match TokioBackend::new(TokioConfig::default()) let engine = match TokioBackend::new(TokioConfig::default())
.and_then(|backend| Engine::new(runtime.clone(), backend)) .and_then(|backend| Engine::new(parts, backend))
{ {
Ok(engine) => { Ok(engine) => {
boot("engine", "ready", json!({"backend":"tokio","owns":"core+substrate"}))?; boot("engine", "ready", json!({"backend":"tokio","owns":"core+substrate"}))?;

View file

@ -219,12 +219,11 @@ where
let actors_channel = orch_datastream.channel_by_name("runtime.actors"); let actors_channel = orch_datastream.channel_by_name("runtime.actors");
let orch_stats_hook = orch_datastream.producer.stats_hook_on(actors_channel); let orch_stats_hook = orch_datastream.producer.stats_hook_on(actors_channel);
// Build the core swactor runtime, then hand it to the engine. The engine // Build the core swactor runtime parts, clone the routing handle needed by
// owns both the runtime (it drives actor progression) and the Tokio // integrations, then hand the workers to the engine. The engine owns both
// substrate (it schedules all background work). After this point the engine // core progression and the Tokio substrate (it schedules all background
// is the sole owner of Tokio and core progression — no raw handles are // work); components retain only cheap Runtime handles (ENGINE_SPEC.md).
// passed to components (ENGINE_SPEC.md). let (parts, runtime, codec, transport_router) = DistributionRuntimeStack::build_runtime(
let (runtime, codec, transport_router) = DistributionRuntimeStack::build_runtime(
|registry| { |registry| {
register_myelin_actor_codecs(registry); register_myelin_actor_codecs(registry);
datastream::wire::register_datastream_codec(registry); datastream::wire::register_datastream_codec(registry);
@ -232,7 +231,7 @@ where
Some(orch_stats_hook), Some(orch_stats_hook),
); );
let engine = match TokioBackend::new(TokioConfig::default()) let engine = match TokioBackend::new(TokioConfig::default())
.and_then(|backend| Engine::new(runtime.clone(), backend)) .and_then(|backend| Engine::new(parts, backend))
{ {
Ok(engine) => { Ok(engine) => {
bootstrap( bootstrap(
@ -461,7 +460,7 @@ where
let (work_tx, work_rx) = mpsc::channel::<PromptWork>(); let (work_tx, work_rx) = mpsc::channel::<PromptWork>();
let stop_rx = stop_rx.unwrap_or_else(spawn_stop_listener); let stop_rx = stop_rx.unwrap_or_else(spawn_stop_listener);
let provisioner = config.build_provisioner(Arc::clone(&stack.runtime))?; let provisioner = config.build_provisioner(stack.runtime.clone())?;
bootstrap( bootstrap(
&mut orch_datastream, &mut orch_datastream,
dashboard.as_ref(), dashboard.as_ref(),
@ -1765,7 +1764,7 @@ impl Config {
fn build_provisioner( fn build_provisioner(
&self, &self,
bootstrap_runtime: Arc<swactor::runtime::Runtime>, bootstrap_runtime: swactor::runtime::Runtime,
) -> Result<Box<dyn ProvisionPlugin>, String> { ) -> Result<Box<dyn ProvisionPlugin>, String> {
match self.provider.as_str() { match self.provider.as_str() {
"process" => { "process" => {
@ -4476,7 +4475,7 @@ impl PipelinePromptRuntime {
&mut self, &mut self,
request: SubmitPrompt, request: SubmitPrompt,
events: mpsc::Sender<PromptEvent>, events: mpsc::Sender<PromptEvent>,
runtime: &Arc<swactor::runtime::Runtime>, runtime: &swactor::runtime::Runtime,
dashboard: Option<&DashboardSupport>, dashboard: Option<&DashboardSupport>,
orch_datastream: &mut OrchDatastream, orch_datastream: &mut OrchDatastream,
run_id: u64, run_id: u64,
@ -4583,7 +4582,7 @@ impl PipelinePromptRuntime {
fn drain_tokenizer_events( fn drain_tokenizer_events(
&mut self, &mut self,
runtime: &Arc<swactor::runtime::Runtime>, runtime: &swactor::runtime::Runtime,
tokenizer_events: &swactor::runtime::Inbox<TokenizerEvent>, tokenizer_events: &swactor::runtime::Inbox<TokenizerEvent>,
dashboard: Option<&DashboardSupport>, dashboard: Option<&DashboardSupport>,
orch_datastream: &mut OrchDatastream, orch_datastream: &mut OrchDatastream,
@ -4676,7 +4675,7 @@ impl PipelinePromptRuntime {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_decoded_tokens( fn handle_decoded_tokens(
&mut self, &mut self,
_runtime: &Arc<swactor::runtime::Runtime>, _runtime: &swactor::runtime::Runtime,
request_id: u64, request_id: u64,
text: String, text: String,
dashboard: Option<&DashboardSupport>, dashboard: Option<&DashboardSupport>,
@ -4791,7 +4790,7 @@ impl PipelinePromptRuntime {
fn request_decode( fn request_decode(
&mut self, &mut self,
runtime: &Arc<swactor::runtime::Runtime>, runtime: &swactor::runtime::Runtime,
request_id: u64, request_id: u64,
token_id: u32, token_id: u32,
eos: bool, eos: bool,
@ -4863,7 +4862,7 @@ impl PipelinePromptRuntime {
fn drain_tokens( fn drain_tokens(
&mut self, &mut self,
runtime: &Arc<swactor::runtime::Runtime>, runtime: &swactor::runtime::Runtime,
dashboard: Option<&DashboardSupport>, dashboard: Option<&DashboardSupport>,
orch_datastream: &mut OrchDatastream, orch_datastream: &mut OrchDatastream,
run_id: u64, run_id: u64,

View file

@ -14,9 +14,9 @@ use std::time::{Duration, Instant};
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::{Ctx, Runtime}; use swactor::runtime::{Ctx, Runtime, RuntimeParts};
use swactor_engine::EngineHandle;
use swactor::stats::StatsHook; use swactor::stats::StatsHook;
use swactor_engine::EngineHandle;
use swactor::std::StdExtension; use swactor::std::StdExtension;
use swactor_transport::{CodecRegistry, CodecRemoteSink, NetworkMessage, TransportRouter}; use swactor_transport::{CodecRegistry, CodecRemoteSink, NetworkMessage, TransportRouter};
@ -48,7 +48,7 @@ pub(crate) struct DistributionActorAddrs {
} }
pub(crate) struct DistributionRuntimeStack { pub(crate) struct DistributionRuntimeStack {
pub runtime: Arc<Runtime>, pub runtime: Runtime,
/// The node engine this stack is bound to. Protocol ticking and all /// The node engine this stack is bound to. Protocol ticking and all
/// supporting work schedule on this stored handle; the stack does not /// supporting work schedule on this stored handle; the stack does not
/// accept an unrelated engine at each call (ENGINE_SPEC.md). /// accept an unrelated engine at each call (ENGINE_SPEC.md).
@ -64,39 +64,41 @@ pub(crate) struct DistributionRuntimeStack {
} }
impl DistributionRuntimeStack { impl DistributionRuntimeStack {
/// Build and configure the core swactor runtime + codec, returning the /// Build and configure the core swactor runtime parts + codec, returning the
/// shared transport router needed by [`new_from_runtime`]. The runtime is /// cloned runtime handle and shared transport router needed by
/// fully configured — extension, remote sink, statistics hook — but no /// [`new_from_runtime`]. The parts are fully configured — extension, remote
/// actors are spawned yet. /// sink, statistics hook — but no actors are spawned yet.
/// ///
/// This split lets the engine own the runtime before the driver exists: /// This split lets the engine own the runtime workers before the driver
/// construct the runtime, hand it to [`Engine::new`](swactor_engine::Engine), /// exists: construct the parts, clone the runtime handle, hand the parts to
/// create the driver (which needs the engine handle), then spawn actors via /// [`Engine::new`](swactor_engine::Engine), create the driver (which needs
/// [`new_from_runtime`] using `driver.node_id()`. /// the engine handle), then spawn actors via [`new_from_runtime`] using
/// `driver.node_id()`.
pub(crate) fn build_runtime( pub(crate) fn build_runtime(
extend_codecs: impl FnOnce(&mut CodecRegistry), extend_codecs: impl FnOnce(&mut CodecRegistry),
stats_hook: Option<Arc<dyn StatsHook>>, stats_hook: Option<Arc<dyn StatsHook>>,
) -> (Arc<Runtime>, Arc<CodecRegistry>, Arc<TransportRouter>) { ) -> (RuntimeParts, Runtime, Arc<CodecRegistry>, Arc<TransportRouter>) {
let mut runtime = let mut parts = RuntimeParts::new(RuntimeConfig::default())
Runtime::new(RuntimeConfig::default()).with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let mut codec = actor_codec_registry(); let mut codec = actor_codec_registry();
extend_codecs(&mut codec); extend_codecs(&mut codec);
let codec = Arc::new(codec); let codec = Arc::new(codec);
let transport_router = Arc::new(TransportRouter::new()); let transport_router = Arc::new(TransportRouter::new());
let runtime = parts.runtime().clone();
runtime.set_remote_sink(Arc::new(CodecRemoteSink::new( runtime.set_remote_sink(Arc::new(CodecRemoteSink::new(
Arc::clone(&codec), Arc::clone(&codec),
Arc::clone(&transport_router), Arc::clone(&transport_router),
))); )));
if let Some(hook) = stats_hook { if let Some(hook) = stats_hook {
runtime.set_stats_hook(hook); parts = parts.with_stats_hook(hook);
} }
(Arc::new(runtime), codec, transport_router) (parts, runtime, codec, transport_router)
} }
/// Spawn the four distribution protocol actors on a pre-built runtime. /// Spawn the four distribution protocol actors on a pre-built runtime.
/// Used after [`build_runtime`] when the engine already owns the runtime. /// Used after [`build_runtime`] when the engine already owns the workers.
pub(crate) fn new_from_runtime( pub(crate) fn new_from_runtime(
runtime: Arc<Runtime>, runtime: Runtime,
codec: Arc<CodecRegistry>, codec: Arc<CodecRegistry>,
transport_router: Arc<TransportRouter>, transport_router: Arc<TransportRouter>,
node_id: NodeId, node_id: NodeId,
@ -194,20 +196,6 @@ impl DistributionRuntimeStack {
} }
} }
/// Convenience: build the runtime and spawn actors in one step. Use
/// [`build_runtime`] + [`new_from_runtime`] when the engine must own the
/// runtime before the driver is constructed.
pub(crate) fn new_with_codecs(
node_id: NodeId,
config: DistributedNodeConfig,
extend_codecs: impl FnOnce(&mut CodecRegistry),
stats_hook: Option<Arc<dyn StatsHook>>,
engine: EngineHandle,
) -> Self {
let (runtime, codec, transport_router) = Self::build_runtime(extend_codecs, stats_hook);
Self::new_from_runtime(runtime, codec, transport_router, node_id, config, engine)
}
pub(crate) fn actor_bridge_routes(&self) -> HashMap<String, ActorAddress> { pub(crate) fn actor_bridge_routes(&self) -> HashMap<String, ActorAddress> {
let mut routes = HashMap::new(); let mut routes = HashMap::new();
for tag in [ for tag in [

View file

@ -1,8 +1,7 @@
// VastAI provider adapter: temporarily exempt from the engine disallowed-methods // VastAI provider adapter: owns private blocking facades and a legacy provider
// policy. This module owns private Tokio runtimes, blocking facades, and a // monitor thread outside the orchestration engine. The monitor's swactor core
// polling thread because it predates the engine and is explicitly OUT of engine // is driven by an explicit SingleThreadRuntime owned by that thread; the main
// scope (ENGINE_SPEC.md §2). It will be redesigned independently; until then it // orchestration engine owns all bootstrap actors spawned on its runtime handle.
// carries this narrow allowance rather than being migrated piecemeal.
#![allow(clippy::disallowed_methods)] #![allow(clippy::disallowed_methods)]
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashSet};
@ -16,7 +15,9 @@ use std::time::{Duration, Instant};
use datastream::DatastreamProducer; use datastream::DatastreamProducer;
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, ExternalSender, Runtime, RuntimeConfig}; use swactor::runtime::{
Ctx, ExternalSender, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime,
};
use swactor_vastai::{ use swactor_vastai::{
CreateInstanceRequest, LifecyclePolicy, Offer, ProvisionRequest, ProvisionedInstance, CreateInstanceRequest, LifecyclePolicy, Offer, ProvisionRequest, ProvisionedInstance,
SelectionPolicy, classify_vastai_error, create_instance, SelectionPolicy, classify_vastai_error, create_instance,
@ -62,23 +63,21 @@ pub(crate) struct VastAiSshEndpoint {
} }
pub(crate) struct VastAiProviderMonitor { pub(crate) struct VastAiProviderMonitor {
runtime: Arc<Runtime>, runtime: Runtime,
actor: ActorAddress, actor: ActorAddress,
tick_thread: Option<JoinHandle<()>>, tick_thread: Option<JoinHandle<()>>,
stop_flag: Arc<AtomicBool>, stop_flag: Arc<AtomicBool>,
} }
impl VastAiProviderMonitor { impl VastAiProviderMonitor {
fn new(runtime: Runtime, actor: ActorAddress) -> Self { fn new(runtime: Runtime, mut host: SingleThreadRuntime, actor: ActorAddress) -> Self {
let runtime = Arc::new(runtime);
let stop_flag = Arc::new(AtomicBool::new(false)); let stop_flag = Arc::new(AtomicBool::new(false));
let rt = Arc::clone(&runtime);
let flag = Arc::clone(&stop_flag); let flag = Arc::clone(&stop_flag);
let tick_thread = thread::spawn(move || { let tick_thread = thread::spawn(move || {
while !flag.load(Ordering::Relaxed) || rt.has_work() { while !flag.load(Ordering::Relaxed) || host.has_work() {
if rt.has_work() { if host.has_work() {
rt.tick(); host.tick();
} else { } else {
thread::sleep(Duration::from_millis(10)); thread::sleep(Duration::from_millis(10));
} }
@ -565,7 +564,8 @@ impl VastAiLeaseClient for ToolsVastAiLeaseClient {
spec: NodeProvisionSpec, spec: NodeProvisionSpec,
sink: PluginSink, sink: PluginSink,
) -> Option<VastAiProviderMonitor> { ) -> Option<VastAiProviderMonitor> {
let runtime = Runtime::new(RuntimeConfig::default()); let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let sender = runtime.create_sender(); let sender = runtime.create_sender();
let actor = runtime let actor = runtime
.spawn(VastAiProviderMonitorActor::new( .spawn(VastAiProviderMonitorActor::new(
@ -578,7 +578,11 @@ impl VastAiLeaseClient for ToolsVastAiLeaseClient {
sender, sender,
)) ))
.ok()?; .ok()?;
Some(VastAiProviderMonitor::new(runtime, actor)) Some(VastAiProviderMonitor::new(
runtime,
SingleThreadRuntime::new(parts),
actor,
))
} }
fn destroy_contract(&mut self, contract_id: u64) -> Result<(), String> { fn destroy_contract(&mut self, contract_id: u64) -> Result<(), String> {
@ -951,15 +955,15 @@ fn stop_ssh_child(child: &mut Option<Child>) {
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct SshCommandBootstrapLauncher { pub(crate) struct SshCommandBootstrapLauncher {
ssh_identity: Option<PathBuf>, ssh_identity: Option<PathBuf>,
runtime: Arc<Runtime>, runtime: Runtime,
} }
pub(crate) struct SshCommandBootstrapHandle { pub(crate) struct SshCommandBootstrapHandle {
actor: ActorAddress, actor: ActorAddress,
runtime: Arc<Runtime>, runtime: Runtime,
} }
impl SshCommandBootstrapLauncher { impl SshCommandBootstrapLauncher {
pub(crate) fn new(ssh_identity: Option<PathBuf>, runtime: Arc<Runtime>) -> Self { pub(crate) fn new(ssh_identity: Option<PathBuf>, runtime: Runtime) -> Self {
Self { Self {
ssh_identity, ssh_identity,
runtime, runtime,
@ -999,13 +1003,12 @@ impl VastAiBootstrapLauncher for SshCommandBootstrapLauncher {
Ok(SshCommandBootstrapHandle { Ok(SshCommandBootstrapHandle {
actor, actor,
runtime: Arc::clone(&self.runtime), runtime: self.runtime.clone(),
}) })
} }
fn stop_bootstrap(&mut self, handle: &mut Self::Handle) { fn stop_bootstrap(&mut self, handle: &mut Self::Handle) {
let _ = handle.runtime.send_to(handle.actor, SshBootstrapMsg::Stop); let _ = handle.runtime.send_to(handle.actor, SshBootstrapMsg::Stop);
handle.runtime.tick();
} }
} }

View file

@ -55,10 +55,10 @@ impl ActorInterface for EchoProbe {
/// on the same runtime → actor bridge, protocol ticker, and adapter pump are /// on the same runtime → actor bridge, protocol ticker, and adapter pump are
/// installed on that one engine. /// installed on that one engine.
fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) { fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
let (runtime, codec, transport_router) = let (parts, runtime, codec, transport_router) =
DistributionRuntimeStack::build_runtime(|_| {}, None); DistributionRuntimeStack::build_runtime(|_| {}, None);
let engine = Engine::new( let engine = Engine::new(
runtime.clone(), parts,
TokioBackend::new(TokioConfig::default()).expect("build tokio backend"), TokioBackend::new(TokioConfig::default()).expect("build tokio backend"),
) )
.expect("build engine"); .expect("build engine");
@ -76,7 +76,7 @@ fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
.expect("build iroh driver with engine handle"); .expect("build iroh driver with engine handle");
let stack = DistributionRuntimeStack::new_from_runtime( let stack = DistributionRuntimeStack::new_from_runtime(
runtime, runtime.clone(),
codec, codec,
transport_router, transport_router,
driver.node_id(), driver.node_id(),

View file

@ -1,9 +1,7 @@
//! Behavior guarantees for the `node` module. //! Behavior guarantees for the `node` module.
//! //!
//! These unit tests drive a raw `Runtime` in isolation to verify actor message //! These unit tests drive a manual `SingleThreadRuntime` host in isolation to verify actor
//! routing — they are not engine integration tests and are exempt from the //! message routing — they are not engine integration tests.
//! disallowed-methods policy (ENGINE_SPEC.md §2).
#![allow(clippy::disallowed_methods)]
use crate::node_actor::{NodeAgentActor, NodeAgentMsg, NodeAgentReport}; use crate::node_actor::{NodeAgentActor, NodeAgentMsg, NodeAgentReport};
use crate::orchestration::actor::OrchestratorMsg; use crate::orchestration::actor::OrchestratorMsg;
@ -11,11 +9,13 @@ use iroh::{EndpointAddr, SecretKey};
use myelin::staging as stage; use myelin::staging as stage;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime; use swactor::runtime::{RuntimeParts, SingleThreadRuntime};
#[test] #[test]
fn node_agent_runtime_loaded_reports_orchestrator() { fn node_agent_runtime_loaded_reports_orchestrator() {
let runtime = Runtime::new(RuntimeConfig::default()); let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let orchestrator_inbox = runtime let orchestrator_inbox = runtime
.new_inbox::<OrchestratorMsg>() .new_inbox::<OrchestratorMsg>()
.expect("orchestrator inbox"); .expect("orchestrator inbox");
@ -41,7 +41,7 @@ fn node_agent_runtime_loaded_reports_orchestrator() {
}, },
) )
.expect("send runtime loaded"); .expect("send runtime loaded");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
orchestrator_inbox.try_recv(), orchestrator_inbox.try_recv(),
@ -59,7 +59,9 @@ fn node_agent_runtime_loaded_reports_orchestrator() {
#[test] #[test]
fn node_agent_runtime_ready_ack_reports_worker_loop() { fn node_agent_runtime_ready_ack_reports_worker_loop() {
let runtime = Runtime::new(RuntimeConfig::default()); let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let orchestrator_inbox = runtime let orchestrator_inbox = runtime
.new_inbox::<OrchestratorMsg>() .new_inbox::<OrchestratorMsg>()
.expect("orchestrator inbox"); .expect("orchestrator inbox");
@ -85,7 +87,7 @@ fn node_agent_runtime_ready_ack_reports_worker_loop() {
}, },
) )
.expect("send runtime ready ack"); .expect("send runtime ready ack");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
reports.try_recv(), reports.try_recv(),

View file

@ -8,7 +8,9 @@ use ::swactor::actor::{
Actor, ActorAddress, ActorInterface, AnyActor, Ctx, Environment, SpawnRequest, Actor, ActorAddress, ActorInterface, AnyActor, Ctx, Environment, SpawnRequest,
}; };
use ::swactor::config::RuntimeConfig; use ::swactor::config::RuntimeConfig;
use ::swactor::runtime::{Inbox, Runtime}; use ::swactor::runtime::{
Inbox, Runtime, RuntimeParts, SingleThreadRuntime as SingleThreadRuntimeHost,
};
// ─── PyMsg newtype ─────────────────────────────────────────────────────────── // ─── PyMsg newtype ───────────────────────────────────────────────────────────
@ -224,6 +226,12 @@ pub struct PyRuntimeConfig {
max_actors: usize, max_actors: usize,
#[pyo3(get, set)] #[pyo3(get, set)]
channel_buffer_size: usize, channel_buffer_size: usize,
#[pyo3(get, set)]
actor_message_budget: usize,
#[pyo3(get, set)]
worker_count: usize,
#[pyo3(get, set)]
worker_ingress_budget: usize,
} }
#[pymethods] #[pymethods]
@ -233,11 +241,23 @@ impl PyRuntimeConfig {
*, *,
max_actors = 1_000, max_actors = 1_000,
channel_buffer_size = 1_000, channel_buffer_size = 1_000,
actor_message_budget = 64,
worker_count = 1,
worker_ingress_budget = 1_024,
))] ))]
fn new(max_actors: usize, channel_buffer_size: usize) -> Self { fn new(
max_actors: usize,
channel_buffer_size: usize,
actor_message_budget: usize,
worker_count: usize,
worker_ingress_budget: usize,
) -> Self {
Self { Self {
max_actors, max_actors,
channel_buffer_size, channel_buffer_size,
actor_message_budget,
worker_count,
worker_ingress_budget,
} }
} }
} }
@ -247,16 +267,19 @@ impl From<PyRuntimeConfig> for RuntimeConfig {
RuntimeConfig { RuntimeConfig {
max_actors: py.max_actors, max_actors: py.max_actors,
channel_buffer_size: py.channel_buffer_size, channel_buffer_size: py.channel_buffer_size,
..Default::default() actor_message_budget: py.actor_message_budget,
worker_count: py.worker_count,
worker_ingress_budget: py.worker_ingress_budget,
} }
} }
} }
// ─── PyRuntime ─────────────────────────────────────────────────────────────── // ─── PyRuntime ───────────────────────────────────────────────────────────────
#[pyclass(name = "Runtime")] #[pyclass(name = "Runtime", unsendable)]
pub struct PyRuntime { pub struct PyRuntime {
inner: Option<Runtime>, runtime: Runtime,
host: SingleThreadRuntimeHost,
} }
#[pymethods] #[pymethods]
@ -268,47 +291,37 @@ impl PyRuntime {
Some(c) => c.into(), Some(c) => c.into(),
None => RuntimeConfig::default(), None => RuntimeConfig::default(),
}; };
Self { let parts = RuntimeParts::new(config);
inner: Some(Runtime::new(config)), let runtime = parts.runtime().clone();
} let host = SingleThreadRuntimeHost::new(parts);
Self { runtime, host }
} }
fn spawn(&self, handler: PyObject) -> PyResult<PyActorAddress> { fn spawn(&self, handler: PyObject) -> PyResult<PyActorAddress> {
let rt = self.inner.as_ref().ok_or_else(|| { let rt = &self.runtime;
pyo3::exceptions::PyRuntimeError::new_err("Runtime not available")
})?;
let actor = PyActor::new(handler); let actor = PyActor::new(handler);
let addr = rt.spawn(actor).map_err(to_py_err)?; let addr = rt.spawn(actor).map_err(to_py_err)?;
Ok(PyActorAddress::from(addr)) Ok(PyActorAddress::from(addr))
} }
fn send(&self, addr: &PyActorAddress, msg: PyObject) -> PyResult<()> { fn send(&self, addr: &PyActorAddress, msg: PyObject) -> PyResult<()> {
let rt = self.inner.as_ref().ok_or_else(|| { let rt = &self.runtime;
pyo3::exceptions::PyRuntimeError::new_err("Runtime not available")
})?;
rt.send_to(addr.inner, PyMsg(msg)).map_err(to_py_err) rt.send_to(addr.inner, PyMsg(msg)).map_err(to_py_err)
} }
fn inbox(&self) -> PyResult<PyInbox> { fn inbox(&self) -> PyResult<PyInbox> {
let rt = self.inner.as_ref().ok_or_else(|| { let rt = &self.runtime;
pyo3::exceptions::PyRuntimeError::new_err("Runtime not available")
})?;
let inbox: Inbox<PyMsg> = rt.new_inbox().map_err(to_py_err)?; let inbox: Inbox<PyMsg> = rt.new_inbox().map_err(to_py_err)?;
Ok(PyInbox { inner: inbox }) Ok(PyInbox { inner: inbox })
} }
fn tick(&self) -> PyResult<()> { fn tick(&mut self) -> PyResult<()> {
let rt = self.inner.as_ref().ok_or_else(|| { self.host.tick();
pyo3::exceptions::PyRuntimeError::new_err("Runtime not available")
})?;
rt.tick();
Ok(()) Ok(())
} }
fn stats(&self) -> PyResult<PyRuntimeStats> { fn stats(&self) -> PyResult<PyRuntimeStats> {
let rt = self.inner.as_ref().ok_or_else(|| { let rt = &self.runtime;
pyo3::exceptions::PyRuntimeError::new_err("Runtime not available")
})?;
Ok(build_stats(rt)) Ok(build_stats(rt))
} }
} }

View file

@ -3,7 +3,10 @@ use std::sync::Arc;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorExited, ActorInterface}; use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{
Ctx, Inbox, Runtime, RuntimeConfig, RuntimeParts,
SingleThreadRuntime as SingleThreadRuntimeHost,
};
use swactor::std::{CtxGroups, CtxWatching, RuntimeGroups, RuntimeNaming, StdExtension}; use swactor::std::{CtxGroups, CtxWatching, RuntimeGroups, RuntimeNaming, StdExtension};
// ─── Core JS-facing types ─────────────────────────────────────────────────── // ─── Core JS-facing types ───────────────────────────────────────────────────
@ -91,26 +94,30 @@ impl WasmInboxString {
/// The browser-facing swactor runtime. /// The browser-facing swactor runtime.
/// ///
/// Wraps `swactor::Runtime` in single-threaded mode with StdExtension installed /// Owns a cloneable `swactor::Runtime` handle plus the single-threaded host that
/// (naming, monitoring, groups). Actors are spawned via dedicated spawn functions /// drives its workers, with StdExtension installed (naming, monitoring, groups).
/// (one per actor type). The runtime is driven by calling `tick()`. /// Actors are spawned via dedicated spawn functions (one per actor type). The
/// runtime is driven by calling `tick()`.
#[wasm_bindgen] #[wasm_bindgen]
pub struct WasmRuntime { pub struct WasmRuntime {
rt: Runtime, rt: Runtime,
host: SingleThreadRuntimeHost,
} }
#[wasm_bindgen] #[wasm_bindgen]
impl WasmRuntime { impl WasmRuntime {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new() -> Self { pub fn new() -> Self {
let rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
Self { rt } let rt = parts.runtime().clone();
let host = SingleThreadRuntimeHost::new(parts);
Self { rt, host }
} }
/// Drive one tick of the runtime. /// Drive one tick of the runtime.
pub fn tick(&self) { pub fn tick(&mut self) {
self.rt.tick(); self.host.tick();
} }
/// Number of actors currently alive. /// Number of actors currently alive.

View file

@ -1,7 +1,7 @@
use data_plane::actor as dp; use data_plane::actor as dp;
use data_plane::object_record; use data_plane::object_record;
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime; use swactor::runtime::{RuntimeParts, SingleThreadRuntime};
fn object_spec() -> dp::ObjectSpec { fn object_spec() -> dp::ObjectSpec {
dp::ObjectSpec { dp::ObjectSpec {
@ -60,7 +60,9 @@ fn inbound_endpoint() -> dp::WireEdgeEndpoint {
#[test] #[test]
fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_ready() { fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_ready() {
let runtime = Runtime::new(RuntimeConfig::default()); let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let arena = runtime let arena = runtime
.new_inbox::<dp::DataPlaneArenaMsg>() .new_inbox::<dp::DataPlaneArenaMsg>()
.expect("arena inbox"); .expect("arena inbox");
@ -96,7 +98,7 @@ fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_rea
dp::DataPlaneNodeMsg::ProvisionWireEdgeEndpoint(inbound_endpoint()), dp::DataPlaneNodeMsg::ProvisionWireEdgeEndpoint(inbound_endpoint()),
) )
.expect("provision inbound"); .expect("provision inbound");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
arena.try_recv(), arena.try_recv(),
@ -118,7 +120,7 @@ fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_rea
}), }),
) )
.expect("ring leased"); .expect("ring leased");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
worker.try_recv(), worker.try_recv(),
@ -142,7 +144,7 @@ fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_rea
}), }),
) )
.expect("worker installed"); .expect("worker installed");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
transport.try_recv(), transport.try_recv(),
@ -161,7 +163,7 @@ fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_rea
}), }),
) )
.expect("transport ready"); .expect("transport ready");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
reports.try_recv(), reports.try_recv(),
@ -173,7 +175,9 @@ fn inbound_wire_edge_establishes_through_arena_worker_transport_then_reports_rea
#[test] #[test]
fn object_loaded_observation_is_reported_as_coarse_data_plane_outcome() { fn object_loaded_observation_is_reported_as_coarse_data_plane_outcome() {
let runtime = Runtime::new(RuntimeConfig::default()); let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let arena = runtime let arena = runtime
.new_inbox::<dp::DataPlaneArenaMsg>() .new_inbox::<dp::DataPlaneArenaMsg>()
.expect("arena inbox"); .expect("arena inbox");
@ -216,7 +220,7 @@ fn object_loaded_observation_is_reported_as_coarse_data_plane_outcome() {
}), }),
) )
.expect("object loaded"); .expect("object loaded");
runtime.tick(); host.tick();
assert_eq!( assert_eq!(
reports.try_recv(), reports.try_recv(),

View file

@ -1,8 +1,9 @@
# The Datastream — Specification # The Datastream — Specification
Id: 7 Id: 7
Last modified: Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
--- ---

View file

@ -146,12 +146,12 @@ impl FrameSink for NoopSink {
/// Legacy swactor frame sink retained until MVP runtime cutover removes it. /// Legacy swactor frame sink retained until MVP runtime cutover removes it.
pub struct ClusterFrameSink { pub struct ClusterFrameSink {
rt: Arc<Runtime>, rt: Runtime,
sink: Arc<OnceLock<ActorAddress>>, sink: Arc<OnceLock<ActorAddress>>,
} }
impl ClusterFrameSink { impl ClusterFrameSink {
pub fn new(rt: Arc<Runtime>, sink: Arc<OnceLock<ActorAddress>>) -> Self { pub fn new(rt: Runtime, sink: Arc<OnceLock<ActorAddress>>) -> Self {
Self { rt, sink } Self { rt, sink }
} }
} }

View file

@ -228,12 +228,13 @@ mod standalone_gossip_transport {
//! Actorized registry/metadata/directory gossip over one codec and transport, proving //! Actorized registry/metadata/directory gossip over one codec and transport, proving
//! standalone frames converge without piggybacking on SWIM. //! standalone frames converge without piggybacking on SWIM.
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use swactor::Error; use swactor::Error;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope}; use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope};
@ -251,7 +252,7 @@ mod standalone_gossip_transport {
/// production ingress — decode, then `deliver_raw` to the local actor that owns /// production ingress — decode, then `deliver_raw` to the local actor that owns
/// the frame's `type_tag` (the tag→actor routing the real driver does). /// the frame's `type_tag` (the tag→actor routing the real driver does).
struct Link { struct Link {
dst_rt: Arc<Runtime>, dst_rt: Runtime,
routes: HashMap<String, ActorAddress>, routes: HashMap<String, ActorAddress>,
codec: Arc<CodecRegistry>, codec: Arc<CodecRegistry>,
} }
@ -270,7 +271,8 @@ mod standalone_gossip_transport {
/// One node: a runtime hosting a RegistryActor + MetadataActor + DirectoryActor, /// One node: a runtime hosting a RegistryActor + MetadataActor + DirectoryActor,
/// plus the shared state needed to wire it into a mesh. /// plus the shared state needed to wire it into a mesh.
struct Node { struct Node {
rt: Arc<Runtime>, rt: Runtime,
host: RefCell<SingleThreadRuntime>,
registry: ActorAddress, registry: ActorAddress,
metadata: ActorAddress, metadata: ActorAddress,
directory: ActorAddress, directory: ActorAddress,
@ -295,14 +297,15 @@ mod standalone_gossip_transport {
// Phase 1: per-node runtime + actors. // Phase 1: per-node runtime + actors.
let mut nodes = Vec::new(); let mut nodes = Vec::new();
for &nid in &ids { for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new()); let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new( rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(), codec.clone(),
router.clone(), router.clone(),
))); )));
let rt = Arc::new(rt); let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new(); let dir = SharedPeerDirectory::new();
let relay_mirror: RelayMirror = Arc::new(RwLock::new(HashMap::new())); let relay_mirror: RelayMirror = Arc::new(RwLock::new(HashMap::new()));
@ -333,6 +336,7 @@ mod standalone_gossip_transport {
nodes.push(Node { nodes.push(Node {
rt, rt,
host,
registry, registry,
metadata, metadata,
directory, directory,
@ -403,7 +407,7 @@ mod standalone_gossip_transport {
fn pump(&self, k: usize) { fn pump(&self, k: usize) {
for _ in 0..k { for _ in 0..k {
for node in &self.nodes { for node in &self.nodes {
node.rt.tick(); node.host.borrow_mut().tick();
} }
} }
} }
@ -444,7 +448,7 @@ mod standalone_gossip_transport {
}, },
) )
.unwrap(); .unwrap();
self.nodes[observer].rt.tick(); self.nodes[observer].host.borrow_mut().tick();
inbox.try_recv().and_then(|r| r.binding) inbox.try_recv().and_then(|r| r.binding)
} }
@ -461,7 +465,7 @@ mod standalone_gossip_transport {
}, },
) )
.unwrap(); .unwrap();
self.nodes[observer].rt.tick(); self.nodes[observer].host.borrow_mut().tick();
inbox.try_recv().and_then(|r| r.relay_url) inbox.try_recv().and_then(|r| r.relay_url)
} }
@ -478,7 +482,7 @@ mod standalone_gossip_transport {
}, },
) )
.unwrap(); .unwrap();
self.nodes[observer].rt.tick(); self.nodes[observer].host.borrow_mut().tick();
inbox.try_recv().and_then(|located| located.host) inbox.try_recv().and_then(|located| located.host)
} }
} }

View file

@ -16,13 +16,14 @@ mod directory_actor {
//! DirectoryActor convergence and safety: signed claims, supersede, deterministic conflict //! DirectoryActor convergence and safety: signed claims, supersede, deterministic conflict
//! resolution, dead-host hiding, catch-up, quieting, and retained recovery claims. //! resolution, dead-host hiding, catch-up, quieting, and retained recovery claims.
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use swactor::Error; use swactor::Error;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope}; use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope};
@ -40,7 +41,7 @@ mod directory_actor {
/// frame's `type_tag`. Counts every frame it carries, so a test can prove a /// frame's `type_tag`. Counts every frame it carries, so a test can prove a
/// settled cluster has gone quiet. /// settled cluster has gone quiet.
struct Link { struct Link {
dst_rt: Arc<Runtime>, dst_rt: Runtime,
routes: HashMap<String, ActorAddress>, routes: HashMap<String, ActorAddress>,
codec: Arc<CodecRegistry>, codec: Arc<CodecRegistry>,
frames: Arc<AtomicUsize>, frames: Arc<AtomicUsize>,
@ -61,7 +62,8 @@ mod directory_actor {
/// One node: a runtime hosting a `DirectoryActor`, plus the shared state needed to /// One node: a runtime hosting a `DirectoryActor`, plus the shared state needed to
/// wire it into a mesh and observe it. /// wire it into a mesh and observe it.
struct Node { struct Node {
rt: Arc<Runtime>, rt: Runtime,
host: RefCell<SingleThreadRuntime>,
directory: ActorAddress, directory: ActorAddress,
dir: SharedPeerDirectory, dir: SharedPeerDirectory,
router: Arc<TransportRouter>, router: Arc<TransportRouter>,
@ -84,14 +86,15 @@ mod directory_actor {
let mut nodes = Vec::new(); let mut nodes = Vec::new();
for &nid in &ids { for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new()); let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new( rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(), codec.clone(),
router.clone(), router.clone(),
))); )));
let rt = Arc::new(rt); let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new(); let dir = SharedPeerDirectory::new();
let route_view: RouteView = Arc::new(RwLock::new(HashMap::new())); let route_view: RouteView = Arc::new(RwLock::new(HashMap::new()));
@ -106,6 +109,7 @@ mod directory_actor {
nodes.push(Node { nodes.push(Node {
rt, rt,
host,
directory, directory,
dir, dir,
router, router,
@ -177,7 +181,7 @@ mod directory_actor {
fn pump(&self, k: usize) { fn pump(&self, k: usize) {
for _ in 0..k { for _ in 0..k {
for node in &self.nodes { for node in &self.nodes {
node.rt.tick(); node.host.borrow_mut().tick();
} }
} }
} }
@ -242,7 +246,7 @@ mod directory_actor {
}, },
) )
.unwrap(); .unwrap();
self.nodes[observer].rt.tick(); self.nodes[observer].host.borrow_mut().tick();
inbox.try_recv().and_then(|located| located.host) inbox.try_recv().and_then(|located| located.host)
} }
@ -581,13 +585,14 @@ mod directory_route_path {
//! Application delivery through the directory route view: address-only sends, supersede, and //! Application delivery through the directory route view: address-only sends, supersede, and
//! best-effort drops. //! best-effort drops.
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swactor::Error; use swactor::Error;
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Runtime, RuntimeConfig}; use swactor::runtime::{Ctx, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use swactor_transport::{CodecRegistry, NetworkMessage, TransportRouter}; use swactor_transport::{CodecRegistry, NetworkMessage, TransportRouter};
@ -653,7 +658,8 @@ mod directory_route_path {
// ── The harness ───────────────────────────────────────────────────────────── // ── The harness ─────────────────────────────────────────────────────────────
struct RouteNode { struct RouteNode {
rt: Arc<Runtime>, rt: Runtime,
host: RefCell<SingleThreadRuntime>,
outbox: Outbox, outbox: Outbox,
route_view: RouteView, route_view: RouteView,
directory: ActorAddress, directory: ActorAddress,
@ -688,14 +694,15 @@ mod directory_route_path {
let mut nodes = Vec::new(); let mut nodes = Vec::new();
for &nid in &ids { for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new()); let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new( rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(), codec.clone(),
router.clone(), router.clone(),
))); )));
let rt = Arc::new(rt); let host = RefCell::new(SingleThreadRuntime::new(parts));
let outbox: Outbox = Arc::new(Mutex::new(Vec::new())); let outbox: Outbox = Arc::new(Mutex::new(Vec::new()));
let route_view: RouteView = Arc::new(RwLock::new(HashMap::new())); let route_view: RouteView = Arc::new(RwLock::new(HashMap::new()));
@ -724,6 +731,7 @@ mod directory_route_path {
nodes.push(RouteNode { nodes.push(RouteNode {
rt, rt,
host,
outbox, outbox,
route_view, route_view,
directory, directory,
@ -793,7 +801,7 @@ mod directory_route_path {
fn settle(&self, k: usize) { fn settle(&self, k: usize) {
for _ in 0..k { for _ in 0..k {
for node in &self.nodes { for node in &self.nodes {
node.rt.tick(); node.host.borrow_mut().tick();
} }
self.deliver_wire(); self.deliver_wire();
} }

View file

@ -16,11 +16,12 @@ mod single_runtime_actor {
//! SwimActor behavior in one runtime: subscription stream convergence and genuine unreachable- //! SwimActor behavior in one runtime: subscription stream convergence and genuine unreachable-
//! peer death detection. //! peer death detection.
use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use swactor::runtime::{Runtime, RuntimeConfig}; use swactor::runtime::{Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use distribution::swim::actor::{ use distribution::swim::actor::{
@ -54,6 +55,7 @@ mod single_runtime_actor {
/// `MembershipChanged` subscriber inbox, and a shared Binding. /// `MembershipChanged` subscriber inbox, and a shared Binding.
struct ActorCluster { struct ActorCluster {
rt: Runtime, rt: Runtime,
host: RefCell<SingleThreadRuntime>,
ids: Vec<NodeId>, ids: Vec<NodeId>,
addrs: Vec<swactor::actor::ActorAddress>, addrs: Vec<swactor::actor::ActorAddress>,
inboxes: Vec<swactor::runtime::Inbox<MembershipChanged>>, inboxes: Vec<swactor::runtime::Inbox<MembershipChanged>>,
@ -67,8 +69,10 @@ mod single_runtime_actor {
/// Spawn `n` actors. Nodes `1..n` join via node 0 (the seed). Returns once /// Spawn `n` actors. Nodes `1..n` join via node 0 (the seed). Returns once
/// the join requests have been issued (not yet converged). /// the join requests have been issued (not yet converged).
fn new(n: usize) -> Self { fn new(n: usize) -> Self {
let rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new(); let dir = SharedPeerDirectory::new();
let now = Instant::now(); let now = Instant::now();
let ids: Vec<NodeId> = (0..n).map(|i| id(i as u8)).collect(); let ids: Vec<NodeId> = (0..n).map(|i| id(i as u8)).collect();
@ -99,6 +103,7 @@ mod single_runtime_actor {
} }
let mut c = ActorCluster { let mut c = ActorCluster {
rt, rt,
host,
ids, ids,
addrs, addrs,
inboxes, inboxes,
@ -123,7 +128,7 @@ mod single_runtime_actor {
fn pump(&self, n: usize) { fn pump(&self, n: usize) {
for _ in 0..n { for _ in 0..n {
self.rt.tick(); self.host.borrow_mut().tick();
} }
} }
@ -237,13 +242,14 @@ mod transport_runtime_actor {
//! SwimActor behavior across separate runtimes through codec, TransportRouter, deliver_raw, and //! SwimActor behavior across separate runtimes through codec, TransportRouter, deliver_raw, and
//! transport send failure. //! transport send failure.
use std::cell::RefCell;
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use swactor::Error; use swactor::Error;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope}; use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope};
@ -289,7 +295,7 @@ mod transport_runtime_actor {
struct Link { struct Link {
src: usize, src: usize,
dst: usize, dst: usize,
dst_rt: Arc<Runtime>, dst_rt: Runtime,
dst_swim: ActorAddress, dst_swim: ActorAddress,
codec: Arc<CodecRegistry>, codec: Arc<CodecRegistry>,
partition: Arc<Mutex<HashSet<usize>>>, partition: Arc<Mutex<HashSet<usize>>>,
@ -314,7 +320,8 @@ mod transport_runtime_actor {
/// A cluster of `n` `SwimActor`s, each on its **own** runtime, meshed through /// A cluster of `n` `SwimActor`s, each on its **own** runtime, meshed through
/// `Link` transports — the multi-runtime analog of `swim_actor.rs::ActorCluster`. /// `Link` transports — the multi-runtime analog of `swim_actor.rs::ActorCluster`.
struct TransportCluster { struct TransportCluster {
rts: Vec<Arc<Runtime>>, rts: Vec<Runtime>,
hosts: Vec<RefCell<SingleThreadRuntime>>,
swims: Vec<ActorAddress>, swims: Vec<ActorAddress>,
inboxes: Vec<Inbox<MembershipChanged>>, inboxes: Vec<Inbox<MembershipChanged>>,
streams: Vec<Vec<MembershipChanged>>, streams: Vec<Vec<MembershipChanged>>,
@ -331,6 +338,7 @@ mod transport_runtime_actor {
let partition = Arc::new(Mutex::new(HashSet::new())); let partition = Arc::new(Mutex::new(HashSet::new()));
let mut rts = Vec::new(); let mut rts = Vec::new();
let mut hosts = Vec::new();
let mut swims = Vec::new(); let mut swims = Vec::new();
let mut dirs = Vec::new(); let mut dirs = Vec::new();
let mut routers = Vec::new(); let mut routers = Vec::new();
@ -339,14 +347,15 @@ mod transport_runtime_actor {
// Phase 1: one runtime per node, each with the actor codec registry + an // Phase 1: one runtime per node, each with the actor codec registry + an
// (initially empty) transport router. Spawn the SwimActor and subscribe. // (initially empty) transport router. Spawn the SwimActor and subscribe.
for &nid in &ids { for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new()); let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new( rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(), codec.clone(),
router.clone(), router.clone(),
))); )));
let rt = Arc::new(rt); let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new(); let dir = SharedPeerDirectory::new();
let swim = rt let swim = rt
@ -369,6 +378,7 @@ mod transport_runtime_actor {
.unwrap(); .unwrap();
rts.push(rt); rts.push(rt);
hosts.push(host);
swims.push(swim); swims.push(swim);
dirs.push(dir); dirs.push(dir);
routers.push(router); routers.push(router);
@ -377,7 +387,8 @@ mod transport_runtime_actor {
// Phase 2: mesh. Each node resolves every peer's NodeId to its synthetic // Phase 2: mesh. Each node resolves every peer's NodeId to its synthetic
// peer address and routes that address through a Link into the peer's // peer address and routes that address through a Link into the peer's
// runtime. Now both Arcs exist, so the mutual references close cleanly. // runtime handle. Now every cloned handle exists, so the mutual references
// close cleanly.
for i in 0..n { for i in 0..n {
for j in 0..n { for j in 0..n {
if i == j { if i == j {
@ -401,6 +412,7 @@ mod transport_runtime_actor {
let mut c = TransportCluster { let mut c = TransportCluster {
rts, rts,
hosts,
swims, swims,
inboxes, inboxes,
streams: vec![Vec::new(); n], streams: vec![Vec::new(); n],
@ -431,8 +443,8 @@ mod transport_runtime_actor {
/// iterations; `k` is sized so a probe + its notification settle per round. /// iterations; `k` is sized so a probe + its notification settle per round.
fn pump(&self, k: usize) { fn pump(&self, k: usize) {
for _ in 0..k { for _ in 0..k {
for rt in &self.rts { for host in &self.hosts {
rt.tick(); host.borrow_mut().tick();
} }
} }
} }
@ -535,11 +547,12 @@ mod actor_membership_safety_edges {
//! Actor-observable safety edges: resurrection after silence, multi-hop death dissemination, //! Actor-observable safety edges: resurrection after silence, multi-hop death dissemination,
//! and bounded stale-refute behavior. //! and bounded stale-refute behavior.
use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use swactor::runtime::{Runtime, RuntimeConfig}; use swactor::runtime::{Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use distribution::swim::actor::{ use distribution::swim::actor::{
@ -576,6 +589,7 @@ mod actor_membership_safety_edges {
/// but lets the test choose the config and rebind a dropped node. /// but lets the test choose the config and rebind a dropped node.
struct Cluster { struct Cluster {
rt: Runtime, rt: Runtime,
host: RefCell<SingleThreadRuntime>,
ids: Vec<NodeId>, ids: Vec<NodeId>,
addrs: Vec<swactor::actor::ActorAddress>, addrs: Vec<swactor::actor::ActorAddress>,
inboxes: Vec<swactor::runtime::Inbox<MembershipChanged>>, inboxes: Vec<swactor::runtime::Inbox<MembershipChanged>>,
@ -586,8 +600,10 @@ mod actor_membership_safety_edges {
impl Cluster { impl Cluster {
fn new(n: usize, config: SwimConfig) -> Self { fn new(n: usize, config: SwimConfig) -> Self {
let rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new(); let dir = SharedPeerDirectory::new();
let now = Instant::now(); let now = Instant::now();
let ids: Vec<NodeId> = (0..n).map(|i| id(i as u8)).collect(); let ids: Vec<NodeId> = (0..n).map(|i| id(i as u8)).collect();
@ -617,6 +633,7 @@ mod actor_membership_safety_edges {
} }
let mut c = Cluster { let mut c = Cluster {
rt, rt,
host,
ids, ids,
addrs, addrs,
inboxes, inboxes,
@ -638,7 +655,7 @@ mod actor_membership_safety_edges {
fn pump(&self, n: usize) { fn pump(&self, n: usize) {
for _ in 0..n { for _ in 0..n {
self.rt.tick(); self.host.borrow_mut().tick();
} }
} }

View file

@ -1,8 +1,9 @@
# swactor engine — specification # swactor engine — specification
Id: 1 Id: 1
Last modified: f8fc594b95871813a890b5d60f60dee505ef93bc Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: f8fc594b95871813a890b5d60f60dee505ef93bc Last reviewed: f8fc594b95871813a890b5d60f60dee505ef93bc
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
**Scope:** the execution substrate that drives swactor workers and hosts its async side-work, defined as an interface implemented per environment. **Scope:** the execution substrate that drives swactor workers and hosts its async side-work, defined as an interface implemented per environment.
@ -46,9 +47,9 @@ This spec defines the **engine**: a swactor-owned composite that retains a selec
## 3. Model ## 3. Model
- The **core runtime** owns its actor-workers, pools, mailboxes, and routing. Actor execution remains synchronous and single-writer; core exposes tick semantics that advance those state machines and return immediately. - The **core runtime** is constructed as `RuntimeParts`: a cloneable runtime handle plus a fixed set of owned workers. The handle owns shared routing, inboxes, and producer queues; each worker owns its actor pool and mailbox drains. Actor execution remains synchronous and single-writer; each worker exposes a synchronous transition that advances its state machine and returns immediately.
- The **engine** is a swactor-owned composite. It retains the selected execution substrate and the core runtime, and it does exactly two things: - The **engine** is a swactor-owned composite. It retains the selected execution substrate, retains the core runtime handle, moves every worker into a core driver, and does exactly two things:
1. **Drives actor execution** — schedules core ticks without application involvement. 1. **Drives actor execution** — schedules worker transitions without application involvement.
2. **Runs supporting work** — schedules the I/O, blocking calls, retries, and other long-lived flows that back actors. 2. **Runs supporting work** — schedules the I/O, blocking calls, retries, and other long-lived flows that back actors.
- **The engine owns all progression.** Actor handlers never `.await`. Every handler is a synchronous transition that returns control immediately; the engine carries control flow across time. - **The engine owns all progression.** Actor handlers never `.await`. Every handler is a synchronous transition that returns control immediately; the engine carries control flow across time.
- Actor execution and supporting work are not independently driven systems. They share one engine, one execution policy, and one lifecycle. An engine may use multiple internal pools, threads, scheduler domains, or substrate-native facilities to meet its progression and performance requirements. - Actor execution and supporting work are not independently driven systems. They share one engine, one execution policy, and one lifecycle. An engine may use multiple internal pools, threads, scheduler domains, or substrate-native facilities to meet its progression and performance requirements.
@ -58,7 +59,7 @@ This spec defines the **engine**: a swactor-owned composite that retains a selec
The interface is authored from swactor's needs. It is a **contract** — operations plus their semantics and invariants. A Rust trait is its canonical Rust binding; Go, JS, and other hosts implement the same contract natively. This spec defines the contract, not the Rust signature. The interface is authored from swactor's needs. It is a **contract** — operations plus their semantics and invariants. A Rust trait is its canonical Rust binding; Go, JS, and other hosts implement the same contract natively. This spec defines the contract, not the Rust signature.
Constructing a swactor engine consumes or retains the selected execution substrate and establishes core driving for the engine's lifetime. Worker installation is internal engine behavior: applications and integrations do not register workers or receive core routing handles. Constructing a swactor engine consumes configured `RuntimeParts` and the selected execution substrate, then establishes core driving for the engine's lifetime. Worker installation is internal engine behavior: applications and integrations do not register workers or receive core routing handles.
**The engine handle provides:** **The engine handle provides:**
@ -71,13 +72,13 @@ Constructing a swactor engine consumes or retains the selected execution substra
Time belongs to the engine rather than actor core. Engine-hosted work needs delays, intervals, retry deadlines, and timeouts; leaving those operations outside the contract would keep integrations such as Iroh and Myelin coupled to `tokio::time` or `std::thread::sleep`. An engine-owned monotonic clock also gives all hosted work one time source and allows a deterministic engine to substitute virtual time without changing integration code. Time belongs to the engine rather than actor core. Engine-hosted work needs delays, intervals, retry deadlines, and timeouts; leaving those operations outside the contract would keep integrations such as Iroh and Myelin coupled to `tokio::time` or `std::thread::sleep`. An engine-owned monotonic clock also gives all hosted work one time source and allows a deterministic engine to substitute virtual time without changing integration code.
**Existing core integration.** The engine wraps and drives core without redefining it. Actor progression uses the existing `Runtime::tick()` / `Runtime::try_tick()` surface, and message delivery continues through existing runtime and sender APIs. Core implements no engine trait, exposes no worker callback, and receives no engine-specific routing handle. Core owns actor logic, routing, inboxes, and delivery; the engine owns when core transitions run and schedules all supporting work on the same substrate. **Core only transitions. The engine drives.** **Existing core integration.** The engine wraps and drives core without redefining it. Actor progression uses one core driver per worker; each driver calls that worker's synchronous transition and never touches any other worker. Message delivery continues through existing runtime and sender APIs. Core implements no engine trait, exposes no worker callback, and receives no engine-specific routing handle. Core owns actor logic, routing, inboxes, and delivery; the engine owns when core transitions run and schedules all supporting work on the same substrate. **Core only transitions. The engine drives.**
## 5. Driving workers ## 5. Driving workers
- Driving core is intrinsic to the engine and is established during engine construction. Application code and integrations never register or manually drive workers. - Driving core is intrinsic to the engine and is established during engine construction. Application code and integrations never register or manually drive workers.
- The engine advances core through its existing synchronous tick semantics. Each tick runs to completion and returns control to the engine scheduler. - The engine advances core through one long-lived driver task per worker. Each poll runs one worker transition to completion and returns control to the engine scheduler.
- **Non-reentrancy.** The engine must never invoke the same worker concurrently. Actor state is live only for the duration of a synchronous tick. - **Non-reentrancy.** The engine must never invoke the same worker concurrently. Moving each worker into exactly one driver is the native Rust implementation's non-reentrancy proof.
- **Scheduling strategy is the engine's choice.** Tick cadence, batching, thread placement, and cooperative scheduling are implementation decisions, subject to the progress guarantees in §8. - **Scheduling strategy is the engine's choice.** Tick cadence, batching, thread placement, and cooperative scheduling are implementation decisions, subject to the progress guarantees in §8.
## 6. Capability surface ## 6. Capability surface

View file

@ -1,22 +1,24 @@
//! Core driving loop. //! Core driving loop.
//! //!
//! Substrate-neutral: the driver is one allocated task that runs one //! Substrate-neutral: each driver owns one core worker. A poll runs one
//! [`Runtime::try_tick`] per poll — synchronous, returns immediately — then //! [`Worker::try_tick`] — synchronous, returns immediately — then re-schedules
//! re-schedules itself by waking its own waker. There is no backend reference //! itself by waking its own waker. There is no backend reference on the hot path,
//! on the hot path, no per-turn boxed yield, no inbox-wake or readiness //! no per-turn boxed yield, no inbox-wake or readiness mechanism, and no
//! mechanism, and no `has_work()` gate; later polls observe newly delivered //! `has_work()` gate; later polls observe newly delivered messages
//! messages (ENGINE_SPEC.md). //! (ENGINE_SPEC.md).
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use crate::backend::{BoxTask, ExecutionBackend}; use crate::backend::{BoxTask, ExecutionBackend};
use swactor::worker::Worker;
/// The sole core-driving loop, installed once per engine.
/// One core-driving loop for one worker.
/// ///
/// Each poll runs one [`Runtime::try_tick`] — synchronous, returns immediately /// Each poll runs one [`Worker::try_tick`] — synchronous, returns immediately —
/// — then re-arms itself via `cx.waker().wake_by_ref()` and returns `Pending`. /// then re-arms itself via `cx.waker().wake_by_ref()` and returns `Pending`.
/// Rescheduling through the waker hands control back to the substrate /// Rescheduling through the waker hands control back to the substrate
/// scheduler between ticks, so other engine work progresses. The driver holds /// scheduler between ticks, so other engine work progresses. The driver holds
/// no backend reference and allocates nothing per turn (ENGINE_SPEC.md). /// no backend reference and allocates nothing per turn (ENGINE_SPEC.md).
@ -27,27 +29,26 @@ use crate::backend::{BoxTask, ExecutionBackend};
/// task, so one `step` still advances the driver by exactly one tick. /// task, so one `step` still advances the driver by exactly one tick.
/// ///
/// # Non-reentrancy /// # Non-reentrancy
/// Only one driver is installed per engine, and `try_tick` runs to completion /// Each worker is moved into exactly one driver, and `try_tick` runs to
/// within a poll, so the runtime's worker is never borrowed concurrently. /// completion within a poll, so the same worker is never borrowed concurrently
/// This is what makes `unsafe impl Sync` on [`Runtime`] sound under a single /// by the engine.
/// driver (see ENGINE_SPEC.md §5/§8).
/// ///
/// [`Runtime::try_tick`]: swactor::runtime::Runtime::try_tick /// [`Worker::try_tick`]: swactor::worker::Worker::try_tick
/// [`Runtime`]: swactor::runtime::Runtime /// [`Worker`]: swactor::worker::Worker
struct CoreDriver { struct CoreDriver {
runtime: Arc<swactor::runtime::Runtime>, worker: Worker,
} }
// The core driver is the engine's sole core-progression path; it is the one // Core drivers are the engine's sole core-progression path; this is the one
// place permitted to call `Runtime::try_tick` (ENGINE_SPEC.md §2). // place permitted to call `Worker::try_tick` (ENGINE_SPEC.md §2).
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_methods)]
impl Future for CoreDriver { impl Future for CoreDriver {
type Output = (); type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// `CoreDriver` is `Unpin` (`Arc<…>` is `Unpin`), so field access // `CoreDriver` is `Unpin`; it contains no self-referential state.
// through `Pin<&mut Self>` is sound without projection. let this = self.get_mut();
self.runtime.try_tick(); this.worker.try_tick();
// Re-arm immediately: the substrate scheduler redispatches this task, // Re-arm immediately: the substrate scheduler redispatches this task,
// yielding to other engine work between ticks. No boxed yield is // yielding to other engine work between ticks. No boxed yield is
// allocated per turn and no backend reference is retained. // allocated per turn and no backend reference is retained.
@ -56,11 +57,13 @@ impl Future for CoreDriver {
} }
} }
/// Install the sole core-driving loop for `runtime` onto `backend`. /// Install one core-driving loop per worker onto `backend`.
/// ///
/// One task is allocated at engine construction and runs for the engine's /// One task is allocated per worker at engine construction and runs for the
/// lifetime; the substrate cancels it when the backend is dropped. /// engine's lifetime; the substrate cancels it when the backend is dropped.
pub(crate) fn install(runtime: Arc<swactor::runtime::Runtime>, backend: &Arc<dyn ExecutionBackend>) { pub(crate) fn install(workers: Vec<Worker>, backend: &Arc<dyn ExecutionBackend>) {
let driver: BoxTask = Box::pin(CoreDriver { runtime }); for worker in workers {
backend.spawn(driver); let driver: BoxTask = Box::pin(CoreDriver { worker });
backend.spawn(driver);
}
} }

View file

@ -5,40 +5,43 @@ use std::time::Duration;
use crate::backend::{Capabilities, EngineError, ExecutionBackend}; use crate::backend::{Capabilities, EngineError, ExecutionBackend};
use crate::time::{EngineInstant, Interval, Timer, Timeout}; use crate::time::{EngineInstant, Interval, Timer, Timeout};
use swactor::runtime::{Runtime, RuntimeParts};
/// The composite engine: retains a configured core runtime and its execution
/// backend, and owns the sole core-driving loop for that runtime. /// The composite engine: retains a configured core runtime handle and its
/// execution backend, and owns one core-driving loop per worker.
/// ///
/// Construct with [`Engine::new`]; obtain a scheduler handle with /// Construct with [`Engine::new`]; obtain a scheduler handle with
/// [`Engine::handle`]. /// [`Engine::handle`].
pub struct Engine { pub struct Engine {
/// Retained so the engine owns the runtime it drives for its full lifetime. /// Retained so the engine owns the runtime handle it drives for its full
/// The core driver holds its own clone; this field anchors ownership (and /// lifetime. Core workers are moved into substrate tasks at construction.
/// future admin/shutdown surfaces) even though it is not read directly.
#[allow(dead_code)] #[allow(dead_code)]
runtime: Arc<swactor::runtime::Runtime>, runtime: Runtime,
backend: Arc<dyn ExecutionBackend>, backend: Arc<dyn ExecutionBackend>,
} }
impl Engine { impl Engine {
/// Construct an engine over `runtime` driven by `backend`. /// Construct an engine over `parts` driven by `backend`.
/// ///
/// The runtime must be fully configured beforehand; after construction the /// The runtime parts must be fully configured beforehand; after construction
/// engine is its sole driver. Construction fails if `backend` does not /// the engine owns every worker and is their sole driver. Construction fails
/// advertise a capability the engine requires (at minimum, `tasks`). /// if `backend` does not advertise a capability the engine requires (at
/// minimum, `tasks`).
pub fn new( pub fn new(
runtime: Arc<swactor::runtime::Runtime>, parts: RuntimeParts,
backend: impl ExecutionBackend, backend: impl ExecutionBackend,
) -> Result<Self, EngineError> { ) -> Result<Self, EngineError> {
let backend: Arc<dyn ExecutionBackend> = Arc::new(backend); let backend: Arc<dyn ExecutionBackend> = Arc::new(backend);
if !backend.capabilities().tasks { if !backend.capabilities().tasks {
return Err(EngineError::MissingRequiredCapability); return Err(EngineError::MissingRequiredCapability);
} }
// Install exactly one core-driving loop; the engine is now the sole let runtime = parts.runtime().clone();
// driver of `runtime`. This is substrate-neutral — no Tokio feature let workers = parts.into_workers();
// gate — so core progression does not silently disappear when an // Install one core-driving loop per worker. This is substrate-neutral —
// alternate backend is used (ENGINE_SPEC.md). // no Tokio feature gate — so core progression does not silently
crate::core_driver::install(runtime.clone(), &backend); // disappear when an alternate backend is used (ENGINE_SPEC.md).
crate::core_driver::install(workers, &backend);
Ok(Engine { runtime, backend }) Ok(Engine { runtime, backend })
} }

View file

@ -10,6 +10,28 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use swactor::actor::{ActorInterface, Ctx}; use swactor::actor::{ActorInterface, Ctx};
use swactor::runtime::{Runtime, RuntimeConfig, RuntimeParts};
pub fn runtime_parts(config: RuntimeConfig) -> (RuntimeParts, Runtime) {
let parts = RuntimeParts::new(config);
let runtime = parts.runtime().clone();
(parts, runtime)
}
pub fn default_runtime_parts() -> (RuntimeParts, Runtime) {
runtime_parts(RuntimeConfig::default())
}
pub fn runtime_parts_with_workers(worker_count: usize) -> (RuntimeParts, Runtime) {
let mut config = RuntimeConfig::default();
config.worker_count = worker_count;
runtime_parts(config)
}
pub fn default_parts() -> RuntimeParts {
RuntimeParts::new(RuntimeConfig::default())
}
// ── Probe message ─────────────────────────────────────────────────────────── // ── Probe message ───────────────────────────────────────────────────────────

View file

@ -18,8 +18,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::atomic::Ordering::SeqCst; use std::sync::atomic::Ordering::SeqCst;
use std::time::Duration; use std::time::Duration;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use swactor_engine::{Engine, TokioBackend, TokioConfig}; use swactor_engine::{Engine, TokioBackend, TokioConfig};
@ -32,9 +30,9 @@ const DEADLINE: Duration = Duration::from_secs(5);
#[test] #[test]
fn engine_runs_without_an_ambient_tokio_runtime() { fn engine_runs_without_an_ambient_tokio_runtime() {
// No outer Tokio runtime, no `#[tokio::test]`. The engine owns its runtime. // No outer Tokio runtime, no `#[tokio::test]`. The engine owns its runtime.
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime, backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1); let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -50,7 +48,7 @@ fn engine_runs_without_an_ambient_tokio_runtime() {
#[test] #[test]
fn engine_drives_core_without_application_ticks() { fn engine_drives_core_without_application_ticks() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let received = Arc::new(AtomicUsize::new(0)); let received = Arc::new(AtomicUsize::new(0));
let addr = runtime let addr = runtime
.spawn(RecordingProbe { .spawn(RecordingProbe {
@ -59,7 +57,7 @@ fn engine_drives_core_without_application_ticks() {
.expect("spawn probe actor"); .expect("spawn probe actor");
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let _engine = Engine::new(runtime.clone(), backend).expect("construct engine"); let _engine = Engine::new(parts, backend).expect("construct engine");
// Deliver AFTER engine construction: a later tick must observe it. // Deliver AFTER engine construction: a later tick must observe it.
runtime runtime
@ -76,9 +74,9 @@ fn engine_drives_core_without_application_ticks() {
#[test] #[test]
fn spawned_supporting_work_runs() { fn spawned_supporting_work_runs() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime, backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1); let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -94,7 +92,7 @@ fn spawned_supporting_work_runs() {
#[test] #[test]
fn actor_ticks_and_supporting_work_both_progress() { fn actor_ticks_and_supporting_work_both_progress() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let received = Arc::new(AtomicUsize::new(0)); let received = Arc::new(AtomicUsize::new(0));
let addr = runtime let addr = runtime
.spawn(RecordingProbe { .spawn(RecordingProbe {
@ -103,7 +101,7 @@ fn actor_ticks_and_supporting_work_both_progress() {
.expect("spawn probe actor"); .expect("spawn probe actor");
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime.clone(), backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
// Long-lived cooperative supporting work that yields between steps so it // Long-lived cooperative supporting work that yields between steps so it
@ -135,7 +133,7 @@ fn actor_ticks_and_supporting_work_both_progress() {
#[test] #[test]
fn runtime_ticks_are_never_concurrent() { fn runtime_ticks_are_never_concurrent() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let entered = Arc::new(AtomicBool::new(false)); let entered = Arc::new(AtomicBool::new(false));
let violations = Arc::new(AtomicUsize::new(0)); let violations = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
@ -148,7 +146,7 @@ fn runtime_ticks_are_never_concurrent() {
.expect("spawn reentrancy probe"); .expect("spawn reentrancy probe");
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let _engine = Engine::new(runtime.clone(), backend).expect("construct engine"); let _engine = Engine::new(parts, backend).expect("construct engine");
let sender = runtime.create_sender(); let sender = runtime.create_sender();
const SENDERS: usize = 4; const SENDERS: usize = 4;
@ -198,7 +196,7 @@ fn blocking_work_does_not_stop_actor_ticks() {
// A blocking-capability test, not part of the baseline tasks-plus-time // A blocking-capability test, not part of the baseline tasks-plus-time
// contract. Configure a small async worker pool so passing cannot be an // contract. Configure a small async worker pool so passing cannot be an
// accident of excessive worker count. // accident of excessive worker count.
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let received = Arc::new(AtomicUsize::new(0)); let received = Arc::new(AtomicUsize::new(0));
let addr = runtime let addr = runtime
.spawn(RecordingProbe { .spawn(RecordingProbe {
@ -208,7 +206,7 @@ fn blocking_work_does_not_stop_actor_ticks() {
let backend = TokioBackend::new(TokioConfig { worker_threads: 1 }) let backend = TokioBackend::new(TokioConfig { worker_threads: 1 })
.expect("build tokio backend"); .expect("build tokio backend");
let engine = Engine::new(runtime.clone(), backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
// Blocking work that waits on a barrier; it stays stuck for the whole test // Blocking work that waits on a barrier; it stays stuck for the whole test
@ -238,9 +236,9 @@ fn blocking_work_does_not_stop_actor_ticks() {
#[test] #[test]
fn engine_clock_is_monotonic() { fn engine_clock_is_monotonic() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime, backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let mut prev = handle.now(); let mut prev = handle.now();
@ -255,9 +253,9 @@ fn engine_clock_is_monotonic() {
#[test] #[test]
fn engine_timer_fires() { fn engine_timer_fires() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime, backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1); let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -275,9 +273,9 @@ fn engine_timer_fires() {
#[test] #[test]
fn engine_interval_recurs() { fn engine_interval_recurs() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime, backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1); let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -305,9 +303,9 @@ fn engine_timer_can_be_created_off_runtime() {
// Construct the timer directly in the test body — no spawned task, no ambient // Construct the timer directly in the test body — no spawned task, no ambient
// runtime — then await it on an engine task. If the tokio backend's timer // runtime — then await it on an engine task. If the tokio backend's timer
// needed runtime context at construction, this would panic. // needed runtime context at construction, this would panic.
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend"); let backend = TokioBackend::new(TokioConfig::default()).expect("build tokio backend");
let engine = Engine::new(runtime, backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
// Constructed off-runtime: must not panic. // Constructed off-runtime: must not panic.
@ -342,7 +340,7 @@ fn engine_adopts_caller_tuned_tokio_runtime() {
.expect("build tuned tokio runtime"); .expect("build tuned tokio runtime");
let backend = TokioBackend::from_runtime(tuned); let backend = TokioBackend::from_runtime(tuned);
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let received = Arc::new(AtomicUsize::new(0)); let received = Arc::new(AtomicUsize::new(0));
let addr = runtime let addr = runtime
.spawn(RecordingProbe { .spawn(RecordingProbe {
@ -350,7 +348,7 @@ fn engine_adopts_caller_tuned_tokio_runtime() {
}) })
.expect("spawn probe actor"); .expect("spawn probe actor");
let engine = Engine::new(runtime.clone(), backend).expect("construct engine"); let engine = Engine::new(parts, backend).expect("construct engine");
// The adopted substrate still exposes every native capability (§9). // The adopted substrate still exposes every native capability (§9).
assert_eq!( assert_eq!(
engine.handle().capabilities(), engine.handle().capabilities(),

View file

@ -13,8 +13,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::atomic::Ordering::SeqCst; use std::sync::atomic::Ordering::SeqCst;
use std::time::Duration; use std::time::Duration;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use swactor_engine::{ use swactor_engine::{
Capabilities, Engine, EngineError, ExecutionBackend, SteppingBackend, Capabilities, Engine, EngineError, ExecutionBackend, SteppingBackend,
@ -133,8 +131,8 @@ impl swactor_engine::ExecutionBackend for NoCapBackend {
#[test] #[test]
fn engine_new_rejects_backend_without_tasks() { fn engine_new_rejects_backend_without_tasks() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let result = Engine::new(runtime, NoCapBackend); let result = Engine::new(parts, NoCapBackend);
assert!( assert!(
matches!(result, Err(EngineError::MissingRequiredCapability)), matches!(result, Err(EngineError::MissingRequiredCapability)),
"Engine::new must reject a backend that cannot schedule tasks" "Engine::new must reject a backend that cannot schedule tasks"
@ -143,9 +141,9 @@ fn engine_new_rejects_backend_without_tasks() {
#[test] #[test]
fn engine_new_accepts_stepping_backend() { fn engine_new_accepts_stepping_backend() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).expect("stepping backend has tasks"); let engine = Engine::new(parts, backend).expect("stepping backend has tasks");
drop(engine); drop(engine);
} }
@ -155,9 +153,9 @@ fn engine_new_accepts_stepping_backend() {
#[test] #[test]
fn require_accepts_when_all_capabilities_present() { fn require_accepts_when_all_capabilities_present() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).unwrap(); let engine = Engine::new(parts, backend).unwrap();
let handle = engine.handle(); let handle = engine.handle();
// Stepping provides tasks + timers + blocking. // Stepping provides tasks + timers + blocking.
@ -172,9 +170,9 @@ fn require_accepts_when_all_capabilities_present() {
#[test] #[test]
fn require_rejects_when_io_missing() { fn require_rejects_when_io_missing() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).unwrap(); let engine = Engine::new(parts, backend).unwrap();
let handle = engine.handle(); let handle = engine.handle();
// Stepping does NOT provide io. // Stepping does NOT provide io.
@ -198,8 +196,8 @@ fn require_rejects_when_timers_missing() {
} }
} }
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let engine = Engine::new(runtime, TaskOnlyBackend).unwrap(); let engine = Engine::new(parts, TaskOnlyBackend).unwrap();
let handle = engine.handle(); let handle = engine.handle();
assert!( assert!(
@ -218,9 +216,9 @@ fn require_rejects_when_timers_missing() {
#[test] #[test]
fn require_can_be_called_multiple_times() { fn require_can_be_called_multiple_times() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).unwrap(); let engine = Engine::new(parts, backend).unwrap();
let handle = engine.handle(); let handle = engine.handle();
assert!(handle.require(Capabilities::TASKS_ONLY).is_ok()); assert!(handle.require(Capabilities::TASKS_ONLY).is_ok());
@ -266,7 +264,7 @@ const STEPS: usize = 30;
#[test] #[test]
fn stepping_core_progresses_without_tokio() { fn stepping_core_progresses_without_tokio() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let received = Arc::new(AtomicUsize::new(0)); let received = Arc::new(AtomicUsize::new(0));
let addr = runtime let addr = runtime
.spawn(RecordingProbe { .spawn(RecordingProbe {
@ -275,7 +273,7 @@ fn stepping_core_progresses_without_tokio() {
.expect("spawn probe"); .expect("spawn probe");
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let _engine = Engine::new(runtime.clone(), backend.clone()).expect("construct engine"); let _engine = Engine::new(parts, backend.clone()).expect("construct engine");
// Deliver AFTER engine construction — a later tick must observe it. // Deliver AFTER engine construction — a later tick must observe it.
runtime.send_to(addr, Probe).expect("deliver probe"); runtime.send_to(addr, Probe).expect("deliver probe");
@ -291,10 +289,60 @@ fn stepping_core_progresses_without_tokio() {
} }
#[test] #[test]
fn stepping_supporting_work_progresses() { fn stepping_engine_installs_one_driver_per_worker() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, _runtime) = runtime_parts_with_workers(3);
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).expect("construct engine");
let _engine = Engine::new(parts, backend.clone()).expect("construct engine");
assert_eq!(
backend.pending_task_count(),
3,
"one core-driving task is installed per worker"
);
}
#[test]
fn stepping_engine_drives_every_worker() {
let (parts, runtime) = runtime_parts_with_workers(3);
let counters: Vec<_> = (0..3)
.map(|_| Arc::new(AtomicUsize::new(0)))
.collect();
let addrs: Vec<_> = counters
.iter()
.map(|received| {
runtime
.spawn(RecordingProbe {
received: received.clone(),
})
.expect("spawn probe")
})
.collect();
let backend = SteppingBackend::new();
let _engine = Engine::new(parts, backend.clone()).expect("construct engine");
for addr in addrs {
runtime.send_to(addr, Probe).expect("deliver probe");
}
for _ in 0..STEPS {
backend.step();
}
for (worker, received) in counters.iter().enumerate() {
assert_eq!(
received.load(SeqCst),
1,
"worker {worker} must be driven by its own core driver"
);
}
}
#[test]
fn stepping_supporting_work_progresses() {
let parts = default_parts();
let backend = SteppingBackend::new();
let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let done = Arc::new(AtomicBool::new(false)); let done = Arc::new(AtomicBool::new(false));
@ -315,7 +363,7 @@ fn stepping_supporting_work_progresses() {
#[test] #[test]
fn stepping_core_and_supporting_work_both_progress() { fn stepping_core_and_supporting_work_both_progress() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let (parts, runtime) = default_runtime_parts();
let received = Arc::new(AtomicUsize::new(0)); let received = Arc::new(AtomicUsize::new(0));
let addr = runtime let addr = runtime
.spawn(RecordingProbe { .spawn(RecordingProbe {
@ -324,7 +372,7 @@ fn stepping_core_and_supporting_work_both_progress() {
.expect("spawn probe"); .expect("spawn probe");
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime.clone(), backend.clone()).expect("construct engine"); let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
// Long-lived cooperative supporting work that yields between steps. // Long-lived cooperative supporting work that yields between steps.
@ -367,9 +415,9 @@ fn stepping_virtual_time_is_monotonic() {
#[test] #[test]
fn stepping_virtual_now_matches_engine_now() { fn stepping_virtual_now_matches_engine_now() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).unwrap(); let engine = Engine::new(parts, backend.clone()).unwrap();
let handle = engine.handle(); let handle = engine.handle();
assert_eq!(handle.now(), backend.virtual_now()); assert_eq!(handle.now(), backend.virtual_now());
@ -380,9 +428,9 @@ fn stepping_virtual_now_matches_engine_now() {
#[test] #[test]
fn stepping_timer_does_not_fire_before_advance() { fn stepping_timer_does_not_fire_before_advance() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).expect("construct engine"); let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let fired = Arc::new(AtomicBool::new(false)); let fired = Arc::new(AtomicBool::new(false));
@ -404,9 +452,9 @@ fn stepping_timer_does_not_fire_before_advance() {
#[test] #[test]
fn stepping_timer_fires_after_virtual_time_advance() { fn stepping_timer_fires_after_virtual_time_advance() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).expect("construct engine"); let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let fired = Arc::new(AtomicBool::new(false)); let fired = Arc::new(AtomicBool::new(false));
@ -435,9 +483,9 @@ fn stepping_timer_fires_after_virtual_time_advance() {
#[test] #[test]
fn stepping_blocking_work_runs_isolated() { fn stepping_blocking_work_runs_isolated() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).expect("construct engine"); let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
let done = Arc::new(AtomicBool::new(false)); let done = Arc::new(AtomicBool::new(false));
@ -461,9 +509,9 @@ fn stepping_blocking_work_runs_isolated() {
#[test] #[test]
fn stepping_spawned_task_completing_is_removed_from_queue() { fn stepping_spawned_task_completing_is_removed_from_queue() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let backend = SteppingBackend::new(); let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).expect("construct engine"); let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle(); let handle = engine.handle();
handle.spawn(async {}); handle.spawn(async {});
@ -529,9 +577,9 @@ fn dropping_engine_releases_backend_even_with_live_handles() {
// core-driver task it owns) is released once the engine drops — even while // core-driver task it owns) is released once the engine drops — even while
// handles remain alive (ENGINE_SPEC.md). // handles remain alive (ENGINE_SPEC.md).
let sentinel = Arc::new(()); let sentinel = Arc::new(());
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let engine = Engine::new( let engine = Engine::new(
runtime, parts,
SentinelBackend { sentinel: sentinel.clone() }, SentinelBackend { sentinel: sentinel.clone() },
) )
.expect("tasks capability present"); .expect("tasks capability present");
@ -562,8 +610,8 @@ fn handle_used_after_engine_drop_degrades_gracefully() {
// Behavior beyond the engine's lifetime is out of spec, but a handle must // Behavior beyond the engine's lifetime is out of spec, but a handle must
// not retain the backend and should degrade through the smallest practical // not retain the backend and should degrade through the smallest practical
// API rather than panic (ENGINE_SPEC.md). // API rather than panic (ENGINE_SPEC.md).
let runtime = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = default_parts();
let engine = Engine::new(runtime, SteppingBackend::default()).unwrap(); let engine = Engine::new(parts, SteppingBackend::default()).unwrap();
let handle = engine.handle(); let handle = engine.handle();
// Live handle reports the stepping backend's capabilities. // Live handle reports the stepping backend's capabilities.
assert!(handle.capabilities().tasks); assert!(handle.capabilities().tasks);

View file

@ -1,8 +1,9 @@
# Iroh Driver Fixed Specification # Iroh Driver Fixed Specification
Id: 5 Id: 5
Last modified: f8fc594b95871813a890b5d60f60dee505ef93bc Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
> Review checkpoint: reviewed through Section 3.2; resume with Section 3.3 Accepted Connection Output. > Review checkpoint: reviewed through Section 3.2; resume with Section 3.3 Accepted Connection Output.
@ -537,7 +538,7 @@ datastream_transport
```text ```text
keypair keypair
endpoint endpoint
Tokio handle engine handle
connection cache connection cache
pending joins pending joins
accepted actor connections accepted actor connections
@ -548,10 +549,9 @@ eviction queue
peer relay cache peer relay cache
join status map join status map
actor bridge actor bridge
optional owned Tokio runtime for legacy construction
``` ```
The driver core is intentionally pumpable. Network I/O runs in background Tokio tasks. Pump methods drain in-memory queues and do not perform long blocking network operations. The driver core is intentionally pumpable. Network I/O runs in engine-hosted tasks. Pump methods drain in-memory queues and do not perform long blocking network operations.
### 5.2 Actor Bridge ### 5.2 Actor Bridge
@ -632,7 +632,7 @@ Boot begins when a caller constructs the driver.
Construction: Construction:
```text ```text
resolve Tokio handle resolve swactor engine handle and required capabilities
build iroh endpoint with actor ALPN and additional protocol wire ALPNs build iroh endpoint with actor ALPN and additional protocol wire ALPNs
apply relay mode apply relay mode
apply secret key when configured apply secret key when configured
@ -675,7 +675,7 @@ The engine-hosted pump cycle advances the following per interval:
```text ```text
send protocol actor ticks send protocol actor ticks
drain inbound iroh actor frames into Swactor drain inbound iroh actor frames into Swactor
run Swactor runtime work actor work progresses through the engine-owned worker drivers
drain actor egress channel to iroh drain actor egress channel to iroh
drain protocol adapter ingress/status queues drain protocol adapter ingress/status queues
wake or drain ring-backed protocol pumps as needed wake or drain ring-backed protocol pumps as needed
@ -847,7 +847,7 @@ shutdown check:
close stops endpoint accept, protocol runners, and pump work without caller-held task handles close stops endpoint accept, protocol runners, and pump work without caller-held task handles
``` ```
Legacy APIs that expose endpoint clones, Tokio handles, raw accepted connections, raw streams, or transport task handles are compatibility surfaces only. They are not part of the target behavioral contract. Legacy APIs that expose endpoint clones, raw accepted connections, raw streams, or transport task handles are compatibility surfaces only. They are not part of the target behavioral contract.
--- ---
@ -855,9 +855,9 @@ Legacy APIs that expose endpoint clones, Tokio handles, raw accepted connections
The driver is only one part of a running distributed actor node. Several components must be established around it. The driver is only one part of a running distributed actor node. Several components must be established around it.
### 8.1 Tokio Runtime ### 8.1 Engine Substrate
The driver requires a Tokio runtime for: The driver requires a swactor engine handle with task, timer, and I/O capability for:
- endpoint bind; - endpoint bind;
- endpoint accept; - endpoint accept;
@ -867,9 +867,9 @@ The driver requires a Tokio runtime for:
- retry timers; - retry timers;
- async close. - async close.
New production code must pass this runtime explicitly with `with_handle`. Production code passes this substrate explicitly with `with_engine`.
The driver must not create or own another Tokio runtime. The caller keeps the single process runtime alive for at least as long as the driver is alive. The driver must not create, discover, or store a raw Tokio runtime. The caller keeps the owning swactor engine alive for at least as long as the driver is alive.
### 8.2 Iroh Endpoint ### 8.2 Iroh Endpoint
@ -881,13 +881,13 @@ The endpoint must register the actor ALPN and every internal wire ALPN required
### 8.3 Swactor Runtime ### 8.3 Swactor Runtime
The Swactor runtime owns local actor mailboxes and actor execution. The Swactor runtime handle owns local actor mailboxes and actor delivery APIs.
The driver needs an `Arc<Runtime>` only after the actor bridge is installed. The driver stores a cloned `Runtime` handle only after the actor bridge is installed.
The driver delivers decoded inbound actor messages into this runtime with `deliver_raw`. The driver delivers decoded inbound actor messages into this runtime with `deliver_raw`.
The Swactor runtime still must be ticked by the caller. The driver does not schedule actors by itself. Actor execution is driven by the swactor engine that consumed `RuntimeParts`; the driver does not tick actors itself.
### 8.4 Distribution Actors ### 8.4 Distribution Actors
@ -902,7 +902,7 @@ DirectoryActor
The driver transports their messages but does not implement their state machines. The driver transports their messages but does not implement their state machines.
The caller must create these actors, route their message type tags, inject protocol ticks, and subscribe/fan out membership changes as required by the distribution stack. The caller must create these actors, route their message type tags, install the engine-hosted protocol tick pump, and subscribe/fan out membership changes as required by the distribution stack.
### 8.5 Codec Registry, Transport Router, and Actor Egress Channel ### 8.5 Codec Registry, Transport Router, and Actor Egress Channel
@ -942,7 +942,7 @@ Driver errors are localized to the operation that observes them. Higher-level li
Construction can fail while building or binding the iroh endpoint. Construction can fail while building or binding the iroh endpoint.
Construction errors return from `with_handle`. Construction errors return from `with_engine`.
No runtime pump contract exists for a driver that failed construction. No runtime pump contract exists for a driver that failed construction.

View file

@ -317,8 +317,8 @@ pub struct IrohDriver {
/// State the driver needs to shuttle frames between iroh and the swactor runtime /// State the driver needs to shuttle frames between iroh and the swactor runtime
/// once the protocol runs as actors (see [`IrohDriver::enable_actor_bridge`]). /// once the protocol runs as actors (see [`IrohDriver::enable_actor_bridge`]).
struct ActorBridge { struct ActorBridge {
/// The swactor runtime, for `deliver_raw` of decoded inbound + `SendFailed`. /// The swactor runtime handle, for `deliver_raw` of decoded inbound + `SendFailed`.
rt: Arc<Runtime>, rt: Runtime,
/// Actor codec: wire `type_tag` → the actor `Incoming` variant and back. /// Actor codec: wire `type_tag` → the actor `Incoming` variant and back.
codec: Arc<CodecRegistry>, codec: Arc<CodecRegistry>,
/// `type_tag` → the local actor mailbox that owns it (ingress routing table). /// `type_tag` → the local actor mailbox that owns it (ingress routing table).
@ -934,7 +934,7 @@ impl IrohDriver {
/// adapter pump ([`Self::install_actor_bridge_pump`]). /// adapter pump ([`Self::install_actor_bridge_pump`]).
pub fn enable_actor_bridge( pub fn enable_actor_bridge(
&mut self, &mut self,
rt: Arc<Runtime>, rt: Runtime,
codec: Arc<CodecRegistry>, codec: Arc<CodecRegistry>,
routes: HashMap<String, ActorAddress>, routes: HashMap<String, ActorAddress>,
swim_addr: ActorAddress, swim_addr: ActorAddress,

View file

@ -29,7 +29,7 @@ use swactor_engine::{Engine, TokioBackend, TokioConfig};
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::{Ctx, Runtime}; use swactor::runtime::{Ctx, Runtime, RuntimeParts};
use swactor::std::StdExtension; use swactor::std::StdExtension;
use swactor_transport::TransportRouter; use swactor_transport::TransportRouter;
@ -81,7 +81,7 @@ impl ActorInterface for MembershipFanout {
/// the four protocol actors. Owns everything that must stay alive and be pumped. /// the four protocol actors. Owns everything that must stay alive and be pumped.
pub struct IrohNode { pub struct IrohNode {
pub driver: IrohDriver, pub driver: IrohDriver,
rt: Arc<Runtime>, rt: Runtime,
outbox: Outbox, outbox: Outbox,
swim_addr: ActorAddress, swim_addr: ActorAddress,
registry_addr: ActorAddress, registry_addr: ActorAddress,
@ -104,20 +104,21 @@ impl IrohNode {
// Per-node swactor runtime + codec + transport router. The runtime is // Per-node swactor runtime + codec + transport router. The runtime is
// created before the driver so the engine can own it; the driver needs // created before the driver so the engine can own it; the driver needs
// the engine handle, and actors need the driver's node_id. // the engine handle, and actors need the driver's node_id.
let mut swactor_rt = let parts = RuntimeParts::new(RuntimeConfig::default())
Runtime::new(RuntimeConfig::default()).with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let actor_codec = Arc::new(actor_codec_registry()); let actor_codec = Arc::new(actor_codec_registry());
let transport_router = Arc::new(TransportRouter::new()); let transport_router = Arc::new(TransportRouter::new());
swactor_rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new( rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
Arc::clone(&actor_codec), Arc::clone(&actor_codec),
Arc::clone(&transport_router), Arc::clone(&transport_router),
))); )));
let rt: Arc<Runtime> = Arc::new(swactor_rt);
// The engine owns the runtime (drives actor progression) and the Tokio // The engine owns the runtime workers (drives actor progression) and the
// substrate (schedules all iroh background work). // Tokio substrate (schedules all iroh background work). The cloned
// `Runtime` handle remains available for actor spawning and sends.
let engine = Engine::new( let engine = Engine::new(
Arc::clone(&rt), parts,
TokioBackend::new(TokioConfig::default()).expect("build test tokio backend"), TokioBackend::new(TokioConfig::default()).expect("build test tokio backend"),
) )
.expect("build test engine"); .expect("build test engine");
@ -216,7 +217,7 @@ impl IrohNode {
routes.insert("swactor_dist::MetadataGossip".to_string(), metadata_addr); routes.insert("swactor_dist::MetadataGossip".to_string(), metadata_addr);
routes.insert("swactor_dist::DirectoryGossip".to_string(), directory_addr); routes.insert("swactor_dist::DirectoryGossip".to_string(), directory_addr);
driver.enable_actor_bridge( driver.enable_actor_bridge(
Arc::clone(&rt), rt.clone(),
Arc::clone(&actor_codec), Arc::clone(&actor_codec),
routes, routes,
swim_addr, swim_addr,
@ -233,7 +234,7 @@ impl IrohNode {
// protocol injection (ENGINE_SPEC.md). // protocol injection (ENGINE_SPEC.md).
let ticker_handle = engine.handle(); let ticker_handle = engine.handle();
let ticker_inner = ticker_handle.clone(); let ticker_inner = ticker_handle.clone();
let ticker_rt = Arc::clone(&rt); let ticker_rt = rt.clone();
ticker_handle.spawn(async move { ticker_handle.spawn(async move {
let mut interval = ticker_inner.interval(Duration::from_millis(10)); let mut interval = ticker_inner.interval(Duration::from_millis(10));
loop { loop {

View file

@ -1,5 +1,4 @@
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use datastream::{ use datastream::{
ChannelContent, DatastreamEndpoint, DatastreamEvent, Lifetime, NodeId, Position, StreamId, ChannelContent, DatastreamEndpoint, DatastreamEvent, Lifetime, NodeId, Position, StreamId,
@ -10,16 +9,16 @@ use iroh_driver::{
write_available_subscription, write_available_subscription,
}; };
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime; use swactor::runtime::RuntimeParts;
use swactor_engine::{Engine, TokioBackend, TokioConfig}; use swactor_engine::{Engine, TokioBackend, TokioConfig};
/// Datastream transport test scheduled through `EngineHandle`, not an ambient /// Datastream transport test scheduled through `EngineHandle`, not an ambient
/// `#[tokio::test]` runtime (ENGINE_SPEC.md). /// `#[tokio::test]` runtime (ENGINE_SPEC.md).
#[test] #[test]
fn iroh_datastream_alpn_carries_catalog_and_numeric_frames() { fn iroh_datastream_alpn_carries_catalog_and_numeric_frames() {
let runtime = Runtime::new(RuntimeConfig::default()); let parts = RuntimeParts::new(RuntimeConfig::default());
let engine = Engine::new( let engine = Engine::new(
Arc::new(runtime), parts,
TokioBackend::new(TokioConfig::default()).expect("test backend"), TokioBackend::new(TokioConfig::default()).expect("test backend"),
) )
.expect("test engine"); .expect("test engine");

View file

@ -233,11 +233,11 @@ fn driver_rejects_engine_without_io() {
use iroh::RelayMode; use iroh::RelayMode;
use iroh_driver::{IrohDriver, IrohDriverConfig}; use iroh_driver::{IrohDriver, IrohDriverConfig};
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime; use swactor::runtime::RuntimeParts;
use swactor_engine::{Engine, SteppingBackend}; use swactor_engine::{Engine, SteppingBackend};
let rt = Arc::new(Runtime::new(RuntimeConfig::default())); let parts = RuntimeParts::new(RuntimeConfig::default());
let engine = Engine::new(rt, SteppingBackend::default()).expect("stepping engine"); let engine = Engine::new(parts, SteppingBackend::default()).expect("stepping engine");
let result = IrohDriver::with_engine( let result = IrohDriver::with_engine(
engine.handle(), engine.handle(),
IrohDriverConfig { IrohDriverConfig {

View file

@ -1,8 +1,9 @@
# Swactor Managed Process Specification # Swactor Managed Process Specification
Id: 8 Id: 8
Last modified: Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
`swactor-process` provides a Swactor actor interface for launching, supervising, `swactor-process` provides a Swactor actor interface for launching, supervising,
stopping, and observing one operating-system child process per process actor. stopping, and observing one operating-system child process per process actor.

View file

@ -5,7 +5,9 @@ use std::time::{Duration, Instant};
use datastream::{DatastreamEndpoint, DatastreamEvent, Lifetime, NodeId, StreamId}; use datastream::{DatastreamEndpoint, DatastreamEvent, Lifetime, NodeId, StreamId};
use serde_json::{Value, json}; use serde_json::{Value, json};
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::runtime::{ExternalSender, Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{
ExternalSender, Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime,
};
use swactor_process::{ use swactor_process::{
ExitStatus, ProcessCommand, ProcessOutput, ProcessOutputConfig, ProcessSpec, ExitStatus, ProcessCommand, ProcessOutput, ProcessOutputConfig, ProcessSpec,
send_process_command, spawn_local_process, send_process_command, spawn_local_process,
@ -63,13 +65,20 @@ fn drain_outputs(inbox: &Inbox<ProcessOutput>, outputs: &mut Vec<ProcessOutput>)
} }
} }
fn runtime_host() -> (Runtime, SingleThreadRuntime) {
let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let host = SingleThreadRuntime::new(parts);
(runtime, host)
}
fn drive_once( fn drive_once(
rt: &Runtime, host: &mut SingleThreadRuntime,
endpoint: Option<&DatastreamEndpoint>, endpoint: Option<&DatastreamEndpoint>,
upstream: &Inbox<ProcessOutput>, upstream: &Inbox<ProcessOutput>,
outputs: &mut Vec<ProcessOutput>, outputs: &mut Vec<ProcessOutput>,
) { ) {
rt.tick(); host.tick();
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));
if let Some(endpoint) = endpoint { if let Some(endpoint) = endpoint {
endpoint.tick(); endpoint.tick();
@ -98,14 +107,14 @@ fn send_spawn(
} }
fn drive_until_spawn_reply( fn drive_until_spawn_reply(
rt: &Runtime, host: &mut SingleThreadRuntime,
endpoint: Option<&DatastreamEndpoint>, endpoint: Option<&DatastreamEndpoint>,
upstream: &Inbox<ProcessOutput>, upstream: &Inbox<ProcessOutput>,
outputs: &mut Vec<ProcessOutput>, outputs: &mut Vec<ProcessOutput>,
reply: &Inbox<SpawnReply>, reply: &Inbox<SpawnReply>,
) -> SpawnReply { ) -> SpawnReply {
for _ in 0..400 { for _ in 0..400 {
drive_once(rt, endpoint, upstream, outputs); drive_once(host, endpoint, upstream, outputs);
if let Some(reply) = reply.try_recv() { if let Some(reply) = reply.try_recv() {
return reply; return reply;
} }
@ -114,14 +123,14 @@ fn drive_until_spawn_reply(
} }
fn drive_until( fn drive_until(
rt: &Runtime, host: &mut SingleThreadRuntime,
endpoint: Option<&DatastreamEndpoint>, endpoint: Option<&DatastreamEndpoint>,
upstream: &Inbox<ProcessOutput>, upstream: &Inbox<ProcessOutput>,
outputs: &mut Vec<ProcessOutput>, outputs: &mut Vec<ProcessOutput>,
mut done: impl FnMut(&[ProcessOutput]) -> bool, mut done: impl FnMut(&[ProcessOutput]) -> bool,
) { ) {
for _ in 0..800 { for _ in 0..800 {
drive_once(rt, endpoint, upstream, outputs); drive_once(host, endpoint, upstream, outputs);
if done(outputs) { if done(outputs) {
return; return;
} }
@ -221,7 +230,7 @@ fn channel_names(endpoint: &DatastreamEndpoint) -> Vec<String> {
#[test] #[test]
fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() { fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -244,7 +253,7 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
Some(&endpoint), Some(&endpoint),
&upstream, &upstream,
&mut outputs, &mut outputs,
@ -253,11 +262,11 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() {
assert_ne!(process_addr, ActorAddress::default()); assert_ne!(process_addr, ActorAddress::default());
let mut datastream_events = subscription.drain_available(); let mut datastream_events = subscription.drain_available();
drive_until(&rt, Some(&endpoint), &upstream, &mut outputs, |outputs| { drive_until(&mut host, Some(&endpoint), &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });
for _ in 0..5 { for _ in 0..5 {
drive_once(&rt, Some(&endpoint), &upstream, &mut outputs); drive_once(&mut host, Some(&endpoint), &upstream, &mut outputs);
datastream_events.extend(subscription.drain_available()); datastream_events.extend(subscription.drain_available());
} }
@ -319,7 +328,7 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() {
#[test] #[test]
fn spawn_failure_maps_to_public_spawn_failed_output() { fn spawn_failure_maps_to_public_spawn_failed_output() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -340,13 +349,13 @@ fn spawn_failure_maps_to_public_spawn_failed_output() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let _process_addr = expect_spawned(drive_until_spawn_reply( let _process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, has_spawn_failed); drive_until(&mut host, None, &upstream, &mut outputs, has_spawn_failed);
let spawn_failures: Vec<&String> = outputs let spawn_failures: Vec<&String> = outputs
.iter() .iter()
@ -381,7 +390,7 @@ fn spawn_failure_maps_to_public_spawn_failed_output() {
#[test] #[test]
fn command_basename_is_default_lifecycle_label_source() { fn command_basename_is_default_lifecycle_label_source() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -399,13 +408,13 @@ fn command_basename_is_default_lifecycle_label_source() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let _process_addr = expect_spawned(drive_until_spawn_reply( let _process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
Some(&endpoint), Some(&endpoint),
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, Some(&endpoint), &upstream, &mut outputs, |outputs| { drive_until(&mut host, Some(&endpoint), &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });
@ -421,7 +430,7 @@ fn command_basename_is_default_lifecycle_label_source() {
#[test] #[test]
fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() { fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -439,7 +448,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let first_addr = expect_spawned(drive_until_spawn_reply( let first_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
Some(&endpoint), Some(&endpoint),
&upstream, &upstream,
&mut outputs, &mut outputs,
@ -455,7 +464,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
&reply, &reply,
); );
let error = expect_failed(drive_until_spawn_reply( let error = expect_failed(drive_until_spawn_reply(
&rt, &mut host,
Some(&endpoint), Some(&endpoint),
&upstream, &upstream,
&mut outputs, &mut outputs,
@ -476,7 +485,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
}, },
) )
.unwrap(); .unwrap();
drive_until(&rt, Some(&endpoint), &upstream, &mut outputs, |outputs| { drive_until(&mut host, Some(&endpoint), &upstream, &mut outputs, |outputs| {
outputs outputs
.iter() .iter()
.any(|output| matches!(output, ProcessOutput::Exited { .. })) .any(|output| matches!(output, ProcessOutput::Exited { .. }))
@ -486,7 +495,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
#[test] #[test]
fn stop_before_spawn_success_reports_started_then_exited() { fn stop_before_spawn_success_reports_started_then_exited() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -503,7 +512,7 @@ fn stop_before_spawn_success_reports_started_then_exited() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
@ -517,7 +526,7 @@ fn stop_before_spawn_success_reports_started_then_exited() {
}, },
) )
.unwrap(); .unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
outputs outputs
.iter() .iter()
.any(|output| matches!(output, ProcessOutput::Exited { .. })) .any(|output| matches!(output, ProcessOutput::Exited { .. }))
@ -543,7 +552,7 @@ fn stop_before_spawn_success_reports_started_then_exited() {
#[test] #[test]
fn stop_before_spawn_failure_reports_only_spawn_failed() { fn stop_before_spawn_failure_reports_only_spawn_failed() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -564,7 +573,7 @@ fn stop_before_spawn_failure_reports_only_spawn_failed() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
@ -578,7 +587,7 @@ fn stop_before_spawn_failure_reports_only_spawn_failed() {
}, },
) )
.unwrap(); .unwrap();
drive_until(&rt, None, &upstream, &mut outputs, has_spawn_failed); drive_until(&mut host, None, &upstream, &mut outputs, has_spawn_failed);
assert_eq!( assert_eq!(
terminal_count(&outputs), terminal_count(&outputs),
@ -600,7 +609,7 @@ fn stop_before_spawn_failure_reports_only_spawn_failed() {
#[test] #[test]
fn stop_running_with_kill_after_escalates_to_kill() { fn stop_running_with_kill_after_escalates_to_kill() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -621,13 +630,13 @@ fn stop_running_with_kill_after_escalates_to_kill() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, has_started); drive_until(&mut host, None, &upstream, &mut outputs, has_started);
send_process_command( send_process_command(
&sender, &sender,
process_addr, process_addr,
@ -636,7 +645,7 @@ fn stop_running_with_kill_after_escalates_to_kill() {
}, },
) )
.unwrap(); .unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Signal(9)) has_exited(outputs, ExitStatus::Signal(9))
}); });
@ -649,7 +658,7 @@ fn stop_running_with_kill_after_escalates_to_kill() {
#[test] #[test]
fn stop_running_without_kill_after_terminates_without_kill_escalation() { fn stop_running_without_kill_after_terminates_without_kill_escalation() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -670,20 +679,20 @@ fn stop_running_without_kill_after_terminates_without_kill_escalation() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, has_started); drive_until(&mut host, None, &upstream, &mut outputs, has_started);
send_process_command( send_process_command(
&sender, &sender,
process_addr, process_addr,
ProcessCommand::Stop { kill_after: None }, ProcessCommand::Stop { kill_after: None },
) )
.unwrap(); .unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });
@ -700,7 +709,7 @@ fn stop_running_without_kill_after_terminates_without_kill_escalation() {
#[test] #[test]
fn child_exit_before_kill_deadline_suppresses_kill_escalation() { fn child_exit_before_kill_deadline_suppresses_kill_escalation() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -721,13 +730,13 @@ fn child_exit_before_kill_deadline_suppresses_kill_escalation() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, has_started); drive_until(&mut host, None, &upstream, &mut outputs, has_started);
send_process_command( send_process_command(
&sender, &sender,
process_addr, process_addr,
@ -736,11 +745,11 @@ fn child_exit_before_kill_deadline_suppresses_kill_escalation() {
}, },
) )
.unwrap(); .unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });
for _ in 0..5 { for _ in 0..5 {
drive_once(&rt, None, &upstream, &mut outputs); drive_once(&mut host, None, &upstream, &mut outputs);
} }
assert!( assert!(
@ -756,7 +765,7 @@ fn child_exit_before_kill_deadline_suppresses_kill_escalation() {
#[test] #[test]
fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() { fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -777,13 +786,13 @@ fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, has_started); drive_until(&mut host, None, &upstream, &mut outputs, has_started);
let first_stop = Instant::now(); let first_stop = Instant::now();
send_process_command( send_process_command(
@ -804,7 +813,7 @@ fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() {
.unwrap(); .unwrap();
while first_stop.elapsed() < Duration::from_secs(2) { while first_stop.elapsed() < Duration::from_secs(2) {
drive_once(&rt, None, &upstream, &mut outputs); drive_once(&mut host, None, &upstream, &mut outputs);
if has_exited(&outputs, ExitStatus::Signal(9)) { if has_exited(&outputs, ExitStatus::Signal(9)) {
break; break;
} }
@ -829,7 +838,7 @@ fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() {
#[test] #[test]
fn stop_after_terminal_output_emits_no_additional_output() { fn stop_after_terminal_output_emits_no_additional_output() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -846,13 +855,13 @@ fn stop_after_terminal_output_emits_no_additional_output() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let process_addr = expect_spawned(drive_until_spawn_reply( let process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });
let output_len = outputs.len(); let output_len = outputs.len();
@ -865,7 +874,7 @@ fn stop_after_terminal_output_emits_no_additional_output() {
}, },
); );
for _ in 0..10 { for _ in 0..10 {
drive_once(&rt, None, &upstream, &mut outputs); drive_once(&mut host, None, &upstream, &mut outputs);
} }
assert_eq!( assert_eq!(
@ -878,7 +887,7 @@ fn stop_after_terminal_output_emits_no_additional_output() {
#[test] #[test]
fn lifecycle_mirror_submit_failure_does_not_suppress_upstream_or_emit_error() { fn lifecycle_mirror_submit_failure_does_not_suppress_upstream_or_emit_error() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -896,13 +905,13 @@ fn lifecycle_mirror_submit_failure_does_not_suppress_upstream_or_emit_error() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let _process_addr = expect_spawned(drive_until_spawn_reply( let _process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });
let dropped = endpoint.mux().dropped(); let dropped = endpoint.mux().dropped();
@ -925,7 +934,7 @@ fn lifecycle_mirror_submit_failure_does_not_suppress_upstream_or_emit_error() {
#[test] #[test]
fn stdout_and_stderr_writes_do_not_affect_lifecycle() { fn stdout_and_stderr_writes_do_not_affect_lifecycle() {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
let sender = rt.create_sender(); let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap(); let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
let reply = rt.new_inbox::<SpawnReply>().unwrap(); let reply = rt.new_inbox::<SpawnReply>().unwrap();
@ -949,13 +958,13 @@ fn stdout_and_stderr_writes_do_not_affect_lifecycle() {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let _process_addr = expect_spawned(drive_until_spawn_reply( let _process_addr = expect_spawned(drive_until_spawn_reply(
&rt, &mut host,
None, None,
&upstream, &upstream,
&mut outputs, &mut outputs,
&reply, &reply,
)); ));
drive_until(&rt, None, &upstream, &mut outputs, |outputs| { drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0)) has_exited(outputs, ExitStatus::Code(0))
}); });

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use swactor::{ use swactor::{
actor::{ActorAddress, ActorInterface}, actor::{ActorAddress, ActorInterface},
runtime::{Ctx, Runtime, RuntimeConfig}, runtime::{Ctx, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime},
Error, Error,
}; };
use swactor_transport::{ use swactor_transport::{
@ -111,9 +111,16 @@ fn build_codec_registry() -> CodecRegistry {
cr cr
} }
fn tick_n(rt: &Runtime, n: usize) { fn runtime_host() -> (Runtime, SingleThreadRuntime) {
let parts = RuntimeParts::new(RuntimeConfig::default());
let runtime = parts.runtime().clone();
let host = SingleThreadRuntime::new(parts);
(runtime, host)
}
fn tick_n(host: &mut SingleThreadRuntime, n: usize) {
for _ in 0..n { for _ in 0..n {
rt.tick(); host.tick();
} }
} }
@ -141,18 +148,17 @@ fn two_runtimes_communicate_via_in_memory_transport() {
let codecs = Arc::new(build_codec_registry()); let codecs = Arc::new(build_codec_registry());
// Runtime A — the sender // Runtime A — the sender
let mut rt_a = Runtime::new(RuntimeConfig::default()); let (rt_a, _host_a) = runtime_host();
let (transport_a_to_b, rx_b) = InMemoryTransport::pair(); let (transport_a_to_b, rx_b) = InMemoryTransport::pair();
let router_a = TransportRouter::new(); let router_a = TransportRouter::new();
// Runtime B — has the PongActor // Runtime B — has the PongActor
let mut rt_b = Runtime::new(RuntimeConfig::default()); let (rt_b, mut host_b) = runtime_host();
let (transport_b_to_a, rx_a) = InMemoryTransport::pair(); let (transport_b_to_a, rx_a) = InMemoryTransport::pair();
let router_b = TransportRouter::new(); let router_b = TransportRouter::new();
// Spawn PongActor on B, drain spawn queue // Spawn PongActor on B; drain spawn queue after remote sinks are installed.
let pong_addr = rt_b.spawn(PongActor).unwrap(); let pong_addr = rt_b.spawn(PongActor).unwrap();
tick_n(&rt_b, 1);
// Create inbox on A to receive the reply // Create inbox on A to receive the reply
let inbox_a = rt_a.new_inbox::<Pong>().unwrap(); let inbox_a = rt_a.new_inbox::<Pong>().unwrap();
@ -171,6 +177,7 @@ fn two_runtimes_communicate_via_in_memory_transport() {
codecs.clone(), codecs.clone(),
Arc::new(router_b), Arc::new(router_b),
))); )));
tick_n(&mut host_b, 1);
// A sends Ping to pong_addr — this goes via transport // A sends Ping to pong_addr — this goes via transport
rt_a.send_to( rt_a.send_to(
@ -184,7 +191,7 @@ fn two_runtimes_communicate_via_in_memory_transport() {
// Deliver from A→B transport, tick B to process // Deliver from A→B transport, tick B to process
drain_transport(&rx_b, &codecs, &rt_b); drain_transport(&rx_b, &codecs, &rt_b);
tick_n(&rt_b, 1); tick_n(&mut host_b, 1);
// Deliver reply from B→A transport // Deliver reply from B→A transport
drain_transport(&rx_a, &codecs, &rt_a); drain_transport(&rx_a, &codecs, &rt_a);
@ -207,7 +214,7 @@ fn unregistered_type_produces_clear_error() {
let fake_addr = ActorAddress::new_random(); let fake_addr = ActorAddress::new_random();
router.add_route(fake_addr, transport); router.add_route(fake_addr, transport);
let mut rt = Runtime::new(RuntimeConfig::default()); let (rt, _host) = runtime_host();
rt.set_remote_sink(Arc::new(CodecRemoteSink::new(codecs, Arc::new(router)))); rt.set_remote_sink(Arc::new(CodecRemoteSink::new(codecs, Arc::new(router))));
let result = rt.send_to( let result = rt.send_to(
@ -258,7 +265,7 @@ fn local_send_still_bypasses_transport() {
let remote_addr = ActorAddress::new_random(); let remote_addr = ActorAddress::new_random();
router.add_route(remote_addr, transport); router.add_route(remote_addr, transport);
let mut rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = runtime_host();
rt.set_remote_sink(Arc::new(CodecRemoteSink::new(codecs, Arc::new(router)))); rt.set_remote_sink(Arc::new(CodecRemoteSink::new(codecs, Arc::new(router))));
// Spawn a local PongActor + inbox // Spawn a local PongActor + inbox
@ -275,7 +282,7 @@ fn local_send_still_bypasses_transport() {
) )
.unwrap(); .unwrap();
tick_n(&rt, 2); tick_n(&mut host, 2);
// Verify local delivery worked // Verify local delivery worked
let pong = inbox.try_recv().expect("should receive Pong locally"); let pong = inbox.try_recv().expect("should receive Pong locally");
@ -295,8 +302,8 @@ fn round_trip_across_two_runtimes() {
let codecs = Arc::new(build_codec_registry()); let codecs = Arc::new(build_codec_registry());
// Set up two runtimes with bidirectional transports // Set up two runtimes with bidirectional transports
let mut rt_a = Runtime::new(RuntimeConfig::default()); let (rt_a, _host_a) = runtime_host();
let mut rt_b = Runtime::new(RuntimeConfig::default()); let (rt_b, mut host_b) = runtime_host();
let (transport_a2b, rx_b) = InMemoryTransport::pair(); let (transport_a2b, rx_b) = InMemoryTransport::pair();
let (transport_b2a, rx_a) = InMemoryTransport::pair(); let (transport_b2a, rx_a) = InMemoryTransport::pair();
@ -304,9 +311,8 @@ fn round_trip_across_two_runtimes() {
let router_a = TransportRouter::new(); let router_a = TransportRouter::new();
let router_b = TransportRouter::new(); let router_b = TransportRouter::new();
// Spawn actors // Spawn actors; drain spawn queue after remote sinks are installed.
let pong_addr = rt_b.spawn(PongActor).unwrap(); let pong_addr = rt_b.spawn(PongActor).unwrap();
tick_n(&rt_b, 1);
let inbox_a = rt_a.new_inbox::<Pong>().unwrap(); let inbox_a = rt_a.new_inbox::<Pong>().unwrap();
let inbox_addr = *inbox_a.addr(); let inbox_addr = *inbox_a.addr();
@ -323,6 +329,7 @@ fn round_trip_across_two_runtimes() {
codecs.clone(), codecs.clone(),
Arc::new(router_b), Arc::new(router_b),
))); )));
tick_n(&mut host_b, 1);
// Send 3 pings and verify 3 pongs come back // Send 3 pings and verify 3 pongs come back
for i in 0..3u32 { for i in 0..3u32 {
@ -338,7 +345,7 @@ fn round_trip_across_two_runtimes() {
// Flush A→B // Flush A→B
drain_transport(&rx_b, &codecs, &rt_b); drain_transport(&rx_b, &codecs, &rt_b);
tick_n(&rt_b, 1); tick_n(&mut host_b, 1);
// Flush B→A // Flush B→A
drain_transport(&rx_a, &codecs, &rt_a); drain_transport(&rx_a, &codecs, &rt_a);

View file

@ -0,0 +1,194 @@
# swactor process-local multicore runtime — specification
Id: 2
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
**Scope:** multicore (multi-worker) execution and message delivery within a single process (shared address space).
## 1. Scope
**In scope**
- How N logical workers execute concurrently within one process.
- How messages are routed between actors on different workers through shared memory.
- How messages are delivered between actors on the same worker.
- The ownership seam between core, the hosting engine, and the explicit single-thread adapter.
**Out of scope (deferred to separate specs)**
- Cross-isolation delivery (JS web workers) — no shared heap; requires serialization.
- Cross-process / WAN delivery — owned by the `transport` and `distribution` crates.
- Actor migration, work-stealing, and load balancing beyond spawn-time worker selection.
- Ready-queue and readiness-driven idle optimizations.
**Constraint.** Core (`src/`) stays free of any specific engine: no tokio dependency and no owned thread pool. Core exposes synchronous worker transitions; the selected host decides when and where to call them.
## 2. Model
- A **worker** owns a disjoint set of actors and processes them one at a time. It is the logical unit of parallelism. The hosting engine may run different workers concurrently.
- A worker is not an OS thread or physical core. The engine may move its driver between substrate threads; no affinity is guaranteed.
- An actor is **pinned** to one logical worker at spawn and never moved. `ctx.spawn` selects the caller's worker. Spawns through the runtime handle are assigned round-robin.
- Multicore parallelizes different actors. One actor's work is never split across workers.
- Workers are autonomous: each has its own state and transition loop. There is no global tick, barrier, or per-pass cross-worker synchronization.
- Each engine-owned worker driver remains schedulable and invokes one worker pass per scheduling turn. Readiness-driven idling may replace this policy after measurement without changing actor semantics.
## 3. Ownership and the core / engine seam
- **`RuntimeConfig`** selects `worker_count` before routing begins and configures `worker_ingress_budget`.
- **`Runtime`** is a cloneable shared handle. It owns routing state, per-worker producer handles, process-local inbox routing, configuration, extensions, and statistics. It routes and spawns; it has no `tick()` or `try_tick()`.
- **`Worker`** owns one actor pool and the consumer sides of its transfer, spawn, and admin queues. `Worker::try_tick(&mut self)` performs one synchronous pass and returns immediately.
- **`RuntimeParts`** linearly owns one `Runtime` handle and its `Vec<Worker>`. It is the construction bundle consumed by exactly one execution host.
- **`Engine`** consumes `RuntimeParts` and installs one driver task per worker. Each task directly owns its `Worker`; no runtime lock or shared worker borrow is required on the hot path.
- **`SingleThreadRuntime`** is the explicit manual adapter. It consumes `RuntimeParts` and sequentially calls each worker through `&mut self`.
Linear ownership prevents two engines, or an engine and the single-thread adapter, from driving the same workers. **Core only transitions. The selected host drives.**
## 4. Delivery regimes
| target lives on… | delivery |
| ----------------------------------- | --------------------------------------------------------------- |
| the sender's worker | append to `pending_local`; eligible on the next worker pass |
| another worker in the same process | move an `Envelope` into that worker's transfer queue |
| a process-local external `Inbox` | deliver through the existing `InboxRegistry` |
| another process / isolated / remote | out of scope — handled by `transport` / `distribution` |
## 5. Routing and delivery
The runtime resolves actor addresses to logical workers. Engine drivers poll workers continuously, so depositing work requires no separate engine wake operation in the initial implementation.
### 5.1 Send path
For a send of `M` to `addr`:
1. Resolve `addr` in `address_map`.
2. If it names an actor:
- From that actor's owning worker to the same worker: type-erase the payload and append `(addr, payload)` to `pending_local`.
- From another worker: type-erase the payload, create `Envelope { dest, payload }`, and deposit it into the target's transfer queue.
- From the runtime handle or an `ExternalSender`: there is no source worker identity, so deposit into the target's transfer queue.
3. If it is not an actor address, try the process-local `InboxRegistry` before the non-local transport seam.
The payload is moved and type-erased, not cloned. Same-worker delivery bypasses the concurrent transfer queue but does not recursively run another actor in the current pass.
### 5.2 Per-worker ingress
Each worker initially retains the existing three MPSC inputs:
- transfer envelopes;
- spawn requests;
- admin commands.
`Worker::try_tick` checks all three, so no queue is solely responsible for waking an idle worker. The initial queue implementation may remain non-blocking and unbounded, but those properties are not permanent public guarantees; later bounded queues or backpressure may change them.
### 5.3 Shared-memory crossing
Cross-worker delivery moves an `Envelope` containing the boxed payload through shared memory. It performs no serialization or payload clone. Because workers are logical, this may or may not cross an OS-thread boundary on a particular scheduling turn.
## 6. Worker progression and budgets
`Worker::try_tick(&mut self)` performs one worker pass:
1. Drain bounded batches from spawn, transfer, and admin ingress.
2. Run per-worker extension work.
3. Process each runnable actor up to `actor_message_budget`.
4. Install actors spawned during handlers before delivering messages staged for them.
5. Append `pending_local` messages to target mailboxes.
6. Publish statistics and clean up stopped or poisoned actors.
`worker_ingress_budget` is the maximum number of items consumed by one drain operation for each ingress queue; `0` means unlimited. A pass may perform a second bounded spawn drain after handlers so children exist before local delivery. The budget counts ingress items, not distinct target actors.
Messages appended from `pending_local` become eligible on the next pass. This keeps a pass finite under local send chains and preserves the actor message budget across self-sends.
`try_tick` returns whether the pass did work. An engine driver calls it once per future poll, re-arms its own waker, and yields to the substrate. `SingleThreadRuntime::try_tick(&mut self)` calls each owned worker once sequentially and combines their results.
## 7. Guarantees
- **Single-writer.** Each `Worker` has one owning driver, so at most one message is handled per actor at any instant.
- **Stable placement.** An actor remains on its assigned logical worker for its lifetime.
- **Per-(sender, target) FIFO.** Sequential sends from one sender to one target are delivered in send order. Cross-sender ordering is unspecified.
- **Acceptance, not processing.** A successful send means the runtime accepted the message for routing. Actor stop, panic, type mismatch, or later queue policy may prevent processing.
- **Fairness.** A nonzero actor budget bounds one actor's work per pass; a nonzero ingress budget bounds each ingress drain.
- **Panic isolation.** A panicking actor is poisoned and removed without taking down its worker or other actors.
## 8. Data structures
**Configuration**
- `worker_count: usize` — number of logical workers created with the runtime.
- `worker_ingress_budget: usize` — maximum items per ingress drain; `0` is unlimited.
- Existing actor and channel capacity settings remain.
**Runtime-wide shared state**
- `address_map: RwLock<HashMap<ActorAddress, WorkerId, identity-hash>>`.
- `transfer_txs`, `spawn_txs`, and `admin_txs`, indexed by `WorkerId`.
- `worker_stats`, indexed by `WorkerId`.
- `rr_worker: AtomicUsize` for runtime-handle spawns.
- Existing `InboxRegistry`, runtime extension, observers, and remote sink.
`WorkerId` is an opaque internal newtype created during runtime construction. It indexes only arrays belonging to that same runtime.
**Linear construction ownership**
- `RuntimeParts { runtime: Runtime, workers: Vec<Worker> }`.
- `Runtime { shared: Arc<RuntimeShared> }`.
- `SingleThreadRuntime { runtime: Runtime, workers: Vec<Worker> }`.
**Per worker**
- `id: WorkerId`.
- Consumer sides of the transfer, spawn, and admin queues.
- `pool: HashMap<ActorAddress, ActorSlot>`.
- `ActorSlot { mailbox: VecDeque<Box<dyn Any + Send>>, actor, lifecycle flags }`.
- Worker-local staging including `pending_local`.
- One `WorkerStats` and optional `WorkerExtension`.
**Envelope**
- `{ dest: ActorAddress, payload: Box<dyn Any + Send> }`.
## 9. Worked example
X on logical worker 0 sends `M` to Y on logical worker 1:
1. `ctx.send(addr, M)` resolves `addr` to worker 1.
2. The payload is boxed and moved into `transfer_txs[1]` as an `Envelope`.
3. Worker 1's engine driver receives a scheduling turn and calls `worker.try_tick()`.
4. The transfer drain appends the payload to Y's mailbox.
5. The actor pass pops the message and calls `Y.handle(ctx, M)`.
The engine may execute these worker drivers on different OS threads, the same OS thread at different times, or different threads on later turns. Actor placement remains worker-stable either way.
If Y is on worker 0, the send appends to `pending_local`. At the end of the pass it enters Y's mailbox and becomes eligible on worker 0's next pass. No concurrent transfer queue is used.
## 10. Changes required in current `src/` and `crates/engine`
**Core**
- Add `worker_count` and `worker_ingress_budget` to `RuntimeConfig`.
- Replace the address set with `ActorAddress → WorkerId` routing.
- Replace the single transfer, spawn, admin, stats, and worker fields with per-worker construction.
- Split the shared `Runtime` handle from linearly owned `Worker` values assembled in `RuntimeParts`.
- Remove `Runtime::tick()` / `Runtime::try_tick()` and the `RefCell<Worker>` / unsafe `Runtime: Sync` arrangement.
- Give `Worker` its real `WorkerId`; update `SystemInfo`, admin responses, tracing, hooks, and stats.
- Route targeted admin commands to the owning worker and broadcast aggregate commands such as actor listing.
- Create one `WorkerExtension` per worker.
**Engine**
- Change engine construction to consume `RuntimeParts`.
- Replace the single `Runtime::try_tick` driver with one self-polling driver future per worker.
- Each driver owns its `Worker` and invokes `Worker::try_tick(&mut self)` directly.
**Single-thread execution**
- Add `SingleThreadRuntime`, which consumes `RuntimeParts` and sequentially advances all workers without locks.
- Move manual ticking helpers such as `recv_ticking` to this explicit adapter.
**Retained**
- `ActorPool`, `ActorSlot`, mailboxes, next-pass `pending_local`, actor message budgets, and panic isolation.
- `ExternalSender`, process-local `Inbox` / `Ask`, runtime extensions, administration, statistics, and transport routing.
- The current queue implementation as the initial policy, without making it a permanent API guarantee.
## 11. Deferred
- Readiness-driven idle workers and other polling optimizations, pending performance evidence.
- Bounded ingress and explicit backpressure policy.
- Actor migration, work-stealing, and load balancing beyond spawn-time selection.
- Ready-queue optimization.
- Physical thread/core affinity.
- Cross-isolation delivery and cross-process / WAN delivery.
- Address-encoded worker routing.

View file

@ -1,8 +1,9 @@
# Synaptic Job Runner Specification # Synaptic Job Runner Specification
Id: 4 Id: 4
Last modified: Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
--- ---

View file

@ -1,145 +0,0 @@
# swactor process-local multicore runtime — specification
Id: 2
Last modified:
Last reviewed:
**Scope:** multicore (multi-worker) execution and message delivery within a single process (shared address space).
## 1. Scope
**In scope**
- How N workers run concurrently on N cores within one process.
- How a message is routed and delivered between actors on different workers (foreign-thread, shared memory).
- How a message is delivered between actors on the same worker.
- The seam between core and the hosting engine.
**Out of scope (deferred to separate specs)**
- Cross-isolation delivery (JS web workers) — no shared heap; requires serialization.
- Cross-process / WAN delivery — owned by the `transport` and `distribution` crates.
- Actor migration, work-stealing, and load balancing beyond spawn-time worker selection.
- A ready-queue / runnable-set optimization.
**Constraint.** Core (`src/`) stays free of any specific engine: no tokio dependency, no owned thread pool. Any engine that can host a blocking or async receiver can host a worker.
## 2. Model
- A **worker** owns a disjoint set of actors and processes them one at a time. It is the unit of parallelism: N workers on N cores run up to N actors concurrently.
- An actor is **pinned**: assigned to one worker at spawn, never moved. Worker selection at spawn is deliberately simple: an actor spawned from within a worker (`ctx.spawn`) pins to that same worker; an actor spawned from outside the runtime (via the runtime handle) is assigned round-robin across workers. A side effect is that a parent and the children it talks to stay co-located, so their traffic stays on the same-worker fast path.
- Multicore parallelizes *different actors*. One actor's work is never split across cores. This preserves the single-writer invariant: at most one message is handled per actor at any instant, across all workers.
- Workers are **autonomous and independent**: each runs its own loop. There is no global tick, no barrier, no per-step cross-worker synchronization.
- Workers are **reactive**: when idle they wait; when work arrives they run a pass.
## 3. Responsibilities (the core / engine seam)
- **Runtime** (core): owns the actor address space, the `address → worker` routing map, the per-worker inbox deposit handles, and spawn-time worker selection (same-worker for in-runtime spawns, round-robin for external spawns). It routes. It does not execute and does not own threads.
- **Worker** (core logic, engine-driven): owns its pinned actor pool. Each pass drains its inbox into actor mailboxes and processes non-empty mailboxes up to a fairness budget.
- **Engine** (integrator-supplied — std::thread, tokio, …): decides how many workers to create, hosts each worker's loop, and owns the inbox's consumer side (how the worker idles and how often it drains). **Core only transitions. The engine drives.**
## 4. Delivery regimes (this spec)
| target lives on… | delivery |
| ------------------------------------ | ------------------------------------------------- |
| the same worker | inline, within the current pass (no queue) |
| another worker, same process | pointer-move into that worker's MPSC inbox |
| another process / isolated / remote | out of scope — `transport` / `distribution` |
## 5. Routing and delivery mechanism
A send resolves the target actor to its owning worker and deposits the message. There is **no wake step**. The receiving worker idles on its own inbox, so depositing into it is what makes the next transition runnable.
### 5.1 Send path
For a send of `M` to `addr` from any in-process sender (an actor handler, or an external thread holding a sender handle):
1. Box `M` once → `Box<dyn Any + Send>` (a heap pointer). The payload is never copied again.
2. Look up `addr` in the routing map → `WorkerId`.
3. Branch:
- **Same worker** as sender → append `(addr, M)` to the worker's local `pending_local` buffer. Delivered within the current pass. No queue, no cross-thread.
- **Different worker** → wrap as `Envelope { dest: addr, payload: M }` and deposit into that worker's inbox (a pointer-move into shared memory). Return. No signal is sent to the receiver.
- **Not in the map** → defer to the non-local seam (`transport` / `distribution`). Out of scope here.
### 5.2 The inbox
- One MPSC queue per worker. Many producers (any foreign thread); one consumer (the owning worker).
- **Producer side** (the deposit): non-blocking, unbounded, loss-free, FIFO. Core holds this handle per worker, indexed by `WorkerId`.
- **Consumer side** (the worker's idle point): engine-chosen. A blocking channel under std::thread; an async channel under tokio. Receiving *is* the idle point, so depositing makes the next transition runnable with no separate wake primitive. The engine owns this side and the drain cadence.
### 5.3 The crossing
The message crosses the thread boundary exactly once, inside the inbox queue. The producer writes a pointer into a slot in shared memory; the consumer, blocked or awaiting on that queue, returns it. No serialization, no copy of the payload, no inter-thread signal beyond the queue's own readiness.
## 6. Guarantees
- **Single-writer.** At most one message handled per actor at any instant, across all workers.
- **Per-(sender, target) FIFO.** Messages from one sender to one target are delivered in send order. Cross-sender ordering to the same target is not guaranteed.
- **Loss-free / non-blocking producer.** The inbox never drops and never blocks the sender (unbounded). Mailboxes likewise.
- **Fairness.** No actor processes more than `budget` messages per pass, so one actor cannot starve the others on its worker.
- **Panic isolation.** A panicking actor is poisoned and skipped; it does not take down its worker or other actors. (Existing behavior, retained.)
## 7. Data structures
**Runtime-wide (shared, read-mostly)**
- `address_map`: `RwLock<HashMap<ActorAddress, WorkerId, identity-hash>>` — the routing table; read on send, written at spawn.
- `inbox_txs`: per-worker inbox deposit handles, indexed by `WorkerId`.
- `rr_worker`: `AtomicUsize` round-robin counter, used only for external (out-of-runtime) spawns. In-runtime spawns (`ctx.spawn`) need no counter — the child pins to the caller's worker.
**Per-worker inbox (cross-thread)**
- MPSC queue. Producer = deposit (pointer-move; lock-free ring + overflow). Consumer = the worker's wait point (engine-typed).
**Per-worker, worker-local (single-threaded)**
- `pool`: `HashMap<ActorAddress, ActorSlot>`.
- `ActorSlot { mailbox: VecDeque<Box<dyn Any + Send>>, actor, lifecycle flags }`.
- `pending_local`: `Vec<(ActorAddress, Box<dyn Any + Send>)>` — same-worker buffer.
**Envelope**: `{ dest: ActorAddress, payload: Box<dyn Any + Send> }`.
## 9. Worked example
**Note on `WorkerId` indexing.** `WorkerId` is an opaque internal newtype — minted only by the runtime at spawn and used only to index that same runtime's own `inbox_txs` / `spawn_txs` slices. It never crosses the public API as a raw index, so misuse is bounded to internal code. The per-message cost on the cross-worker path is the routing-map lookup (§11 defers eliminating it via address-encoded routing), not the slice index that follows — the latter is a single pointer-add. The fast path is the same-worker arm (`pending_local`), which bypasses the inbox, the `Envelope`, and the second thread entirely.
X on worker 0 (thread T0) sends `M` to `addr`, which is Y on worker 1 (thread T1):
1. `ctx.send(addr, M)` → `Box::new(M)` (one allocation).
2. `send_any`: `address_map.lookup(addr)` → worker 1; not self → `inbox_txs[1].send(Envelope { addr, M })`. Pointer into worker 1's inbox ring. Return. No signal.
3. T1 was blocked on `inbox.recv()`; the deposit unblocks it and returns the `Envelope`.
4. T1 drains: `pool[addr].mailbox.push_back(M)`.
5. Pass walks the pool, finds Y's mailbox non-empty, pops, `Y.handle(ctx, M)`.
X learned nothing about threads. The only thread-aware steps were the one map read and the queue the pointer sat in.
Had `addr` been on worker 0: step 2 takes the same-worker arm, `M` goes to `pending_local`, and Y handles it later in this same pass — no `Envelope`, no ring, no second thread.
## 10. Changes vs current `src/`
**Removed**
- `Runtime::run()` spawning owned OS threads.
- `thread::park()` / `thread::unpark()` wakeup.
- `notify_worker()` and the `worker_threads: Vec<OnceLock<Thread>>` plumbing (including inside `ExternalSender`).
- `Placement` (the load-aware selector) and its `WorkerStats`-driven `next_worker()` scan; replaced by same-worker pinning for in-runtime spawns and a single round-robin counter (`rr_worker`) for external spawns.
**Changed**
- The per-worker transfer queue becomes the worker **inbox**, and its consumer side becomes the worker's idle point (engine-supplied). Deposit no longer signals the engine.
**Retained unchanged**
- `tick()` / `try_tick()` inline all-workers mode (deterministic, wasm, tests).
- `ActorPool`, `ActorSlot`, mailboxes, `pending_local`, budget, panic isolation, `ExternalSender` / `Inbox` / `Ask` (minus the removed wake).
**Added**
- The engine seam: a way for an integrator to create and register workers, supply each worker's inbox consumer and wait, and drive each worker's loop. Exact API is defined per engine in follow-on integration notes.
## 11. Deferred
- Actor migration, work-stealing, and load balancing beyond spawn-time worker selection.
- Ready-queue optimization.
- Cross-isolation delivery (web workers) and cross-process / WAN delivery (`transport`, `distribution`).
- Address-encoded worker routing (eliminating the routing-map lookup).

View file

@ -1,8 +1,9 @@
# cluster reconciler — specification # cluster reconciler — specification
Id: 3 Id: 3
Last modified: Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
Last reviewed: Last reviewed:
> Any edit to this spec must update `Last modified` above to the current `git HEAD` commit.
**Scope:** a level-triggered reconciler that drives a declared cluster shape toward **Scope:** a level-triggered reconciler that drives a declared cluster shape toward
convergence over the existing node lifecycle, living in `crates/provisioning` convergence over the existing node lifecycle, living in `crates/provisioning`

View file

@ -17,7 +17,9 @@ use std::sync::Arc;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorExited, ActorInterface}; use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
use swactor::runtime::{Ctx, Inbox, Runtime as SwactorRuntime, RuntimeConfig}; use swactor::runtime::{
Ctx, Inbox, Runtime as SwactorRuntime, RuntimeConfig, RuntimeParts, SingleThreadRuntime,
};
use swactor::std::{CtxWatching, StdExtension}; use swactor::std::{CtxWatching, StdExtension};
// ─── Host-facing bindings ─────────────────────────────────────────────────── // ─── Host-facing bindings ───────────────────────────────────────────────────
@ -51,23 +53,26 @@ impl LogInbox {
#[wasm_bindgen] #[wasm_bindgen]
pub struct App { pub struct App {
rt: SwactorRuntime, rt: SwactorRuntime,
host: SingleThreadRuntime,
} }
#[wasm_bindgen] #[wasm_bindgen]
impl App { impl App {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new() -> App { pub fn new() -> App {
let rt = SwactorRuntime::new(RuntimeConfig { let parts = RuntimeParts::new(RuntimeConfig {
num_threads: 1, worker_count: 1,
..RuntimeConfig::default() ..RuntimeConfig::default()
}) })
.with_extension(Arc::new(StdExtension::new())); .with_extension(Arc::new(StdExtension::new()));
App { rt } let rt = parts.runtime().clone();
let host = SingleThreadRuntime::new(parts);
App { rt, host }
} }
/// Advance the runtime one tick. /// Advance the runtime one tick.
pub fn tick(&self) { pub fn tick(&mut self) {
self.rt.tick(); self.host.tick();
} }
/// Actors currently alive. /// Actors currently alive.

View file

@ -1,5 +1,5 @@
use crate::actor::{ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Message}; use crate::actor::{ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Message};
use crate::runtime::{Inbox, Runtime}; use crate::runtime::{Inbox, Runtime, SingleThreadRuntime};
use parking_lot::Mutex; use parking_lot::Mutex;
use std::any::Any; use std::any::Any;
use std::sync::Arc; use std::sync::Arc;
@ -102,9 +102,9 @@ impl<T: Message> Admin<T> {
self.inbox.try_recv() self.inbox.try_recv()
} }
pub fn recv_ticking(&self, rt: &Runtime, max_ticks: usize) -> AdminResult<T> { pub fn recv_ticking(&self, host: &mut SingleThreadRuntime, max_ticks: usize) -> AdminResult<T> {
for _ in 0..max_ticks { for _ in 0..max_ticks {
rt.tick(); host.try_tick();
if let Some(resp) = self.inbox.try_recv() { if let Some(resp) = self.inbox.try_recv() {
return resp; return resp;
} }

View file

@ -6,6 +6,18 @@ pub struct RuntimeConfig {
/// Prevents a single actor with a large mailbox from starving others. /// Prevents a single actor with a large mailbox from starving others.
/// `0` means unlimited (drain entire mailbox). /// `0` means unlimited (drain entire mailbox).
pub actor_message_budget: usize, pub actor_message_budget: usize,
/// Number of logical workers created with the runtime.
///
/// Actors are pinned to one logical worker at spawn and never moved.
/// Defaults to `1` to preserve single-worker behavior unless a caller opts
/// into more workers. `0` is rejected during runtime construction.
pub worker_count: usize,
/// Maximum number of items consumed by one ingress drain (transfer, spawn,
/// admin) per worker pass. `0` means unlimited.
///
/// Each drain operation is bounded independently, so a backlog in one queue
/// cannot permanently block another. Defaults to `1024`.
pub worker_ingress_budget: usize,
} }
/// 8kB for the `Box<..>` before counting the rest of the memory /// 8kB for the `Box<..>` before counting the rest of the memory
@ -20,12 +32,22 @@ const DEFAULT_CHANNEL_BUFFER_SIZE: usize = 1_000;
/// 64 is a good default: high enough for throughput, low enough for fairness. /// 64 is a good default: high enough for throughput, low enough for fairness.
const DEFAULT_ACTOR_MESSAGE_BUDGET: usize = 64; const DEFAULT_ACTOR_MESSAGE_BUDGET: usize = 64;
/// Default logical worker count. Preserves the historic single-worker runtime
/// unless a caller explicitly opts into more workers.
const DEFAULT_WORKER_COUNT: usize = 1;
/// Default per-drain ingress budget. `0` would mean unlimited, so the default
/// is a finite, generous cap that keeps every worker pass bounded.
const DEFAULT_WORKER_INGRESS_BUDGET: usize = 1_024;
impl Default for RuntimeConfig { impl Default for RuntimeConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
channel_buffer_size: DEFAULT_CHANNEL_BUFFER_SIZE, channel_buffer_size: DEFAULT_CHANNEL_BUFFER_SIZE,
actor_message_budget: DEFAULT_ACTOR_MESSAGE_BUDGET, actor_message_budget: DEFAULT_ACTOR_MESSAGE_BUDGET,
worker_count: DEFAULT_WORKER_COUNT,
worker_ingress_budget: DEFAULT_WORKER_INGRESS_BUDGET,
} }
} }
} }

View file

@ -7,7 +7,7 @@ use crate::Error;
use crate::actor::{ActorAddress, Message, SpawnRequest}; use crate::actor::{ActorAddress, Message, SpawnRequest};
use crate::channel::Sender; use crate::channel::Sender;
use crate::config::RuntimeConfig; use crate::config::RuntimeConfig;
use crate::stats::WorkerStats; use crate::stats::{StatsHook, WorkerStats};
// ─── Identity Hasher for ActorAddress ─────────────────────────────────────── // ─── Identity Hasher for ActorAddress ───────────────────────────────────────
@ -54,45 +54,69 @@ pub type AddrMap<V> = HashMap<ActorAddress, V, AddrBuildHasher>;
/// HashSet optimized for ActorAddress keys. /// HashSet optimized for ActorAddress keys.
pub type AddrSet = HashSet<ActorAddress, AddrBuildHasher>; pub type AddrSet = HashSet<ActorAddress, AddrBuildHasher>;
// ─── Worker identity ────────────────────────────────────────────────────────
/// Opaque internal identity of a logical worker within one runtime.
///
/// Created during runtime construction and used only to index the arrays
/// (transfer/spawn/admin producers and per-worker stats) that belong to that
/// same runtime. It is intentionally `pub(crate)`: no code outside core
/// constructs or compares `WorkerId` values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct WorkerId(pub(crate) usize);
impl WorkerId {
pub(crate) fn index(self) -> usize {
self.0
}
}
// ─── Address Registry ─────────────────────────────────────────────────────── // ─── Address Registry ───────────────────────────────────────────────────────
/// Tracks which actor addresses belong to this runtime. /// Routes actor addresses to their owning logical worker.
/// ///
/// `RwLock<AddrSet>` — zero contention for parallel reads, write-rare (only on spawn). /// `RwLock<AddrMap<WorkerId>>` — zero contention for parallel reads; writes
/// happen only at spawn (insert) and cleanup (remove).
pub(crate) struct AddressMap { pub(crate) struct AddressMap {
inner: RwLock<AddrSet>, inner: RwLock<AddrMap<WorkerId>>,
} }
impl AddressMap { impl AddressMap {
pub fn with_capacity(capacity: usize) -> Self { pub fn with_capacity(capacity: usize) -> Self {
Self { Self {
inner: RwLock::new(HashSet::with_capacity_and_hasher( inner: RwLock::new(HashMap::with_capacity_and_hasher(capacity, AddrBuildHasher)),
capacity,
AddrBuildHasher,
)),
} }
} }
pub fn insert(&self, addr: ActorAddress) {
self.inner.write().insert(addr); /// Number of routed actor addresses (runtime-wide actor count).
pub fn len(&self) -> usize {
self.inner.read().len()
}
/// Record that `addr` lives on `worker`.
pub fn insert(&self, addr: ActorAddress, worker: WorkerId) {
self.inner.write().insert(addr, worker);
} }
pub fn contains(&self, addr: &ActorAddress) -> bool { /// Resolve the owning worker for `addr`, if it is a local actor.
self.inner.read().contains(addr) pub fn worker_of(&self, addr: &ActorAddress) -> Option<WorkerId> {
self.inner.read().get(addr).copied()
} }
/// Remove the routing entry for `addr` (called when the actor terminates).
pub fn remove(&self, addr: &ActorAddress) { pub fn remove(&self, addr: &ActorAddress) {
self.inner.write().remove(addr); self.inner.write().remove(addr);
} }
pub fn addresses(&self) -> Vec<ActorAddress> { /// Iterate `(address, worker)` pairs for stats reporting.
self.inner.read().iter().copied().collect() pub fn placements(&self) -> Vec<(ActorAddress, WorkerId)> {
self.inner.read().iter().map(|(&a, &w)| (a, w)).collect()
} }
} }
// ─── Delivery Types ────────────────────────────────────────────────────────── // ─── Delivery Types ──────────────────────────────────────────────────────────
/// A type-erased message envelope for depositing into the worker's inbox. /// A type-erased message envelope for depositing into a worker's transfer queue.
/// ///
/// Uses `Box` (no atomic refcount) and move semantics (no clone). /// Uses `Box` (no atomic refcount) and move semantics (no clone).
pub(crate) struct Envelope { pub(crate) struct Envelope {
@ -142,7 +166,6 @@ impl InboxRegistry {
pub fn register(&self, addr: ActorAddress, sender: Arc<dyn SenderT>) { pub fn register(&self, addr: ActorAddress, sender: Arc<dyn SenderT>) {
self.senders.write().insert(addr, sender); self.senders.write().insert(addr, sender);
} }
/// Check if an address is registered without consuming a message. /// Check if an address is registered without consuming a message.
#[cfg(feature = "transport")] #[cfg(feature = "transport")]
pub fn contains(&self, addr: &ActorAddress) -> bool { pub fn contains(&self, addr: &ActorAddress) -> bool {
@ -160,26 +183,53 @@ impl InboxRegistry {
} }
} }
/// Shared state passed to tick_once — single thin pointer avoids register spill. /// Shared routing context passed into a worker pass.
///
/// Borrows the runtime-wide shared state plus the per-worker slices needed to
/// route messages. The current worker's identity (`worker_id`) selects its own
/// producer handles; cross-worker sends index `transfer_txs` by the target's
/// `WorkerId`.
pub(crate) struct TickContext<'a> { pub(crate) struct TickContext<'a> {
pub(crate) address_map: &'a AddressMap, pub(crate) address_map: &'a AddressMap,
pub(crate) spawn_tx: &'a Sender<SpawnRequest>, pub(crate) spawn_txs: &'a [Sender<SpawnRequest>],
pub(crate) transfer_tx: &'a Sender<Envelope>, pub(crate) transfer_txs: &'a [Sender<Envelope>],
pub(crate) inbox_registry: &'a InboxRegistry, pub(crate) inbox_registry: &'a InboxRegistry,
pub(crate) config: &'a RuntimeConfig, pub(crate) config: &'a RuntimeConfig,
pub(crate) extension: Option<&'a dyn crate::extension::RuntimeExtension>, pub(crate) extension: Option<&'a dyn crate::extension::RuntimeExtension>,
pub(crate) process_output_observer: pub(crate) process_output_observer:
Option<&'a Arc<dyn crate::process_observer::ProcessOutputObserver>>, Option<&'a Arc<dyn crate::process_observer::ProcessOutputObserver>>,
pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>, pub(crate) stats_hook: Option<&'a dyn StatsHook>,
pub(crate) worker_stats: &'a WorkerStats, pub(crate) worker_stats: &'a WorkerStats,
pub(crate) num_workers: usize,
pub(crate) worker_id: WorkerId,
pub(crate) created_at: crate::Instant, pub(crate) created_at: crate::Instant,
#[cfg(feature = "transport")] #[cfg(feature = "transport")]
pub(crate) remote_sink: Option<&'a dyn crate::runtime::RemoteSink>, pub(crate) remote_sink: Option<&'a dyn crate::runtime::RemoteSink>,
} }
impl<'a> TickContext<'a> { impl<'a> TickContext<'a> {
/// Route a message whose destination is not in the local address map. pub(crate) fn worker_id(&self) -> WorkerId {
/// Tries inbox registry, then remote transport, then falls back to inbox error. self.worker_id
}
/// Resolve the owning worker for `addr`, if it is a local actor.
pub(crate) fn worker_of(&self, addr: &ActorAddress) -> Option<WorkerId> {
self.address_map.worker_of(addr)
}
/// Borrow the transfer producer for `worker`.
pub(crate) fn transfer_tx(&self, worker: WorkerId) -> &'a Sender<Envelope> {
&self.transfer_txs[worker.index()]
}
/// Borrow the spawn producer for `worker`.
pub(crate) fn spawn_tx(&self, worker: WorkerId) -> &'a Sender<SpawnRequest> {
&self.spawn_txs[worker.index()]
}
/// Route a message whose destination is not a local actor address.
/// Tries the process-local inbox registry, then remote transport, then
/// falls back to an inbox error.
pub(crate) fn route_nonlocal( pub(crate) fn route_nonlocal(
&self, &self,
addr: ActorAddress, addr: ActorAddress,

View file

@ -1,12 +1,11 @@
use crate::Instant; use crate::Instant;
use std::any::{Any, TypeId}; use std::any::{Any, TypeId};
use std::cell::RefCell;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use crate::actor::{ use crate::actor::{
Actor, ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Environment, ExitValue, Actor, ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Environment,
Message, ResumeSignal, SpawnRequest, StopSignal, StopWithSignal, SystemInfo, Message, SpawnRequest, StopSignal,
}; };
use crate::admin::{ use crate::admin::{
ActorStateSnapshot, Admin, AdminCommand, AdminError, AdminResult, GetActorStateResponse, ActorStateSnapshot, Admin, AdminCommand, AdminError, AdminResult, GetActorStateResponse,
@ -15,12 +14,11 @@ use crate::admin::{
use crate::channel::{Receiver, Sender}; use crate::channel::{Receiver, Sender};
// Re-export config types so existing code using `runtime::RuntimeConfig` still works // Re-export config types so existing code using `runtime::RuntimeConfig` still works
pub use crate::config::RuntimeConfig; pub use crate::config::RuntimeConfig;
use crate::delivery::{AddressMap, Envelope, InboxRegistry, TickContext}; use crate::delivery::{AddressMap, Envelope, InboxRegistry, WorkerId};
use crate::extension::RuntimeExtension; use crate::extension::RuntimeExtension;
use crate::stats::{StatsHook, WorkerStats}; use crate::stats::{RuntimeStats, StatsHook, WorkerInfo, WorkerStats};
// Re-export stats types so existing code using `runtime::*` still works // Re-export stats types so existing code using `runtime::*` still works
use crate::Error; use crate::Error;
pub use crate::stats::{RuntimeStats, WorkerInfo};
use crate::worker::Worker; use crate::worker::Worker;
/// Generic message inbox for receiving messages outside of the runtime. /// Generic message inbox for receiving messages outside of the runtime.
@ -38,9 +36,11 @@ impl<M: Message> Inbox<M> {
self.inner.try_recv() self.inner.try_recv()
} }
pub fn recv_ticking(&self, rt: &Runtime, max_ticks: usize) -> Option<M> { /// Poll up to `max_ticks` times, driving `host` once per attempt, returning
/// the first received message or `None` on timeout.
pub fn recv_ticking(&self, host: &mut SingleThreadRuntime, max_ticks: usize) -> Option<M> {
for _ in 0..max_ticks { for _ in 0..max_ticks {
rt.tick(); host.try_tick();
if let Some(msg) = self.inner.try_recv() { if let Some(msg) = self.inner.try_recv() {
return Some(msg); return Some(msg);
} }
@ -62,49 +62,91 @@ impl<R: Message> Ask<R> {
self.inbox.try_recv() self.inbox.try_recv()
} }
pub fn recv_ticking(&self, rt: &Runtime, max_ticks: usize) -> Option<R> { pub fn recv_ticking(&self, host: &mut SingleThreadRuntime, max_ticks: usize) -> Option<R> {
self.inbox.recv_ticking(rt, max_ticks) self.inbox.recv_ticking(host, max_ticks)
} }
} }
// Re-export Ctx for backwards compatibility // Re-export Ctx for backwards compatibility
use crate::actor::ContextInner;
pub use crate::actor::Ctx; pub use crate::actor::Ctx;
// ─── RuntimeShared ──────────────────────────────────────────────────────────
/// Runtime-wide shared state, owned via `Arc` by the [`Runtime`] handle and
/// every [`Worker`]. Contains only `Sync` data: routing tables, per-worker
/// producer handles, atomics, and the shared extension/observer hooks.
///
/// `Runtime` holds no worker state and needs no `unsafe Sync` justification.
pub(crate) struct RuntimeShared {
pub(crate) config: RuntimeConfig,
pub(crate) address_map: AddressMap,
pub(crate) inbox_registry: InboxRegistry,
pub(crate) extension: OnceLock<Arc<dyn RuntimeExtension>>,
/// Per-worker transfer/spawn/admin producers, indexed by `WorkerId`.
pub(crate) transfer_txs: Vec<Sender<Envelope>>,
pub(crate) spawn_txs: Vec<Sender<SpawnRequest>>,
pub(crate) admin_txs: Vec<Sender<AdminCommand>>,
pub(crate) worker_stats: Vec<Arc<WorkerStats>>,
/// Round-robin cursor for runtime-handle spawns.
pub(crate) rr_worker: AtomicUsize,
pub(crate) stats_hook: OnceLock<Arc<dyn StatsHook>>,
pub(crate) process_output_observer: OnceLock<Arc<dyn crate::process_observer::ProcessOutputObserver>>,
pub(crate) created_at: Instant,
#[cfg(feature = "transport")]
pub(crate) remote_sink: OnceLock<Arc<dyn RemoteSink>>,
}
impl RuntimeShared {
/// Number of logical workers in this runtime.
pub(crate) fn worker_count(&self) -> usize {
self.worker_stats.len()
}
/// Pick the next worker for a runtime-handle spawn (round-robin).
pub(crate) fn next_worker(&self) -> WorkerId {
let n = self.worker_count();
// fetch_add grows monotonically; wrap with modular arithmetic. `n >= 1`
// is guaranteed at construction, so this never divides by zero.
let idx = self.rr_worker.fetch_add(1, Ordering::Relaxed);
WorkerId(idx % n)
}
/// Route a message whose destination is not a local actor address:
/// process-local inbox registry first, then the remote transport seam.
pub(crate) fn route_nonlocal(
&self,
addr: ActorAddress,
msg: Box<dyn Any + Send>,
) -> Result<(), Error> {
#[cfg(feature = "transport")]
{
if self.inbox_registry.contains(&addr) {
return self.inbox_registry.try_deliver(addr, msg);
}
if let Some(sink) = self.remote_sink.get() {
return sink.send(addr, msg);
}
}
self.inbox_registry.try_deliver(addr, msg)
}
}
// ─── Runtime ───────────────────────────────────────────────────────────────── // ─── Runtime ─────────────────────────────────────────────────────────────────
/// The `Runtime` struct is the primary gateway for interacting with the framework. /// The cloneable shared handle for a swactor runtime.
/// ///
/// Owns a single `Worker` advanced by the caller via `tick()` / `try_tick()`. /// `Runtime` routes and spawns; it owns no executable worker state and has no
/// `tick()` / `try_tick()`. Worker progression is owned by exactly one
/// execution host ([`SingleThreadRuntime`] or an engine that consumes
/// [`RuntimeParts`]).
/// ///
/// Core is a transition-only state machine. Each call mutates state and /// Core is a transition-only state machine. Each call mutates shared routing
/// returns immediately, holding no control flow between calls. When to take /// state and returns immediately, holding no control flow between calls.
/// the next step is the engine's decision, not core's. Any driver that can #[derive(Clone)]
/// call `tick` (tokio, std-thread, a test stepper) can host it.
pub struct Runtime { pub struct Runtime {
config: RuntimeConfig, pub(crate) shared: Arc<RuntimeShared>,
address_map: Arc<AddressMap>,
inbox_registry: Arc<InboxRegistry>,
extension: Option<Arc<dyn RuntimeExtension>>,
transfer_tx: Sender<Envelope>,
spawn_tx: Sender<SpawnRequest>,
admin_tx: Sender<AdminCommand>,
worker_stats: Arc<WorkerStats>,
stats_hook: Option<Arc<dyn StatsHook>>,
process_output_observer: OnceLock<Arc<dyn crate::process_observer::ProcessOutputObserver>>,
worker: RefCell<Worker>,
created_at: Instant,
#[cfg(feature = "transport")]
remote_sink: Option<Arc<dyn RemoteSink>>,
} }
// Safety: `RefCell<Worker>` is only borrowed from the owning thread in
// `tick()` / `try_tick()` / `has_work()` / `with_extension()`. All `&self`
// methods callable through `Arc<Runtime>` from other threads (`send_to`,
// `spawn`, `deliver_raw`, `stats`, `create_sender`) access only `Sync` fields
// (Arcs, atomics, channels) — never the `RefCell`. No worker threads exist.
unsafe impl Sync for Runtime {}
/// Core's only hook for delivering a message to a **non-local** address. /// Core's only hook for delivering a message to a **non-local** address.
/// ///
/// Implemented outside core (e.g., `swactor-transport`'s `CodecRemoteSink`), /// Implemented outside core (e.g., `swactor-transport`'s `CodecRemoteSink`),
@ -144,18 +186,15 @@ impl RuntimeAddress {
/// from any thread — including non-actor I/O threads. /// from any thread — including non-actor I/O threads.
/// ///
/// Created via [`Runtime::create_sender`]. The primary use case is bridging /// Created via [`Runtime::create_sender`]. The primary use case is bridging
/// background I/O (e.g., pipe readers, network listeners) with the tick-based /// background I/O (e.g., pipe readers, network listeners) with the actor system.
/// actor system.
pub struct ExternalSender { pub struct ExternalSender {
address_map: Arc<AddressMap>, shared: Arc<RuntimeShared>,
transfer_tx: Sender<Envelope>,
} }
impl Clone for ExternalSender { impl Clone for ExternalSender {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
address_map: self.address_map.clone(), shared: self.shared.clone(),
transfer_tx: self.transfer_tx.clone(),
} }
} }
} }
@ -169,8 +208,8 @@ impl ExternalSender {
/// ///
/// Returns `Err` if the address is not found in the runtime's address map. /// Returns `Err` if the address is not found in the runtime's address map.
pub fn send_to<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { pub fn send_to<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
if self.address_map.contains(&addr) { if let Some(w) = self.shared.address_map.worker_of(&addr) {
self.transfer_tx.send(Envelope::new(addr, Box::new(msg))); self.shared.transfer_txs[w.index()].send(Envelope::new(addr, Box::new(msg)));
Ok(()) Ok(())
} else { } else {
Err(Error::from("Address not found")) Err(Error::from("Address not found"))
@ -178,58 +217,142 @@ impl ExternalSender {
} }
} }
impl Runtime { // ─── RuntimeParts ────────────────────────────────────────────────────────────
/// Builds a new `Runtime` struct, but does not yet run anything.
/// Drive via `tick()` / `try_tick()`. /// The linear construction bundle: one [`Runtime`] handle and the [`Worker`]
/// values it routes to. Consumed by exactly one execution host.
///
/// Configuration that creates per-worker state — especially
/// [`RuntimeExtension::create_worker_extension`] — must be installed (via
/// [`RuntimeParts::with_extension`]) before the parts are consumed by a host.
pub struct RuntimeParts {
runtime: Runtime,
workers: Vec<Worker>,
}
impl RuntimeParts {
/// Build a runtime with `config.worker_count` logical workers, each with its
/// own transfer/spawn/admin queues and `WorkerStats`.
///
/// Panics if `config.worker_count == 0`.
pub fn new(config: RuntimeConfig) -> Self { pub fn new(config: RuntimeConfig) -> Self {
let address_map = Arc::new(AddressMap::with_capacity(config.max_actors)); assert!(
let inbox_registry = Arc::new(InboxRegistry::new()); config.worker_count >= 1,
"swactor: RuntimeConfig.worker_count must be >= 1"
);
let n = config.worker_count;
let max_actors = config.max_actors;
let channel_buffer_size = config.channel_buffer_size;
let transfer_rx = Receiver::<Envelope>::new(config.channel_buffer_size); let mut transfer_rxs = Vec::with_capacity(n);
let transfer_tx = transfer_rx.new_sender(); let mut transfer_txs = Vec::with_capacity(n);
let mut spawn_rxs = Vec::with_capacity(n);
let mut spawn_txs = Vec::with_capacity(n);
let mut admin_rxs = Vec::with_capacity(n);
let mut admin_txs = Vec::with_capacity(n);
let mut worker_stats = Vec::with_capacity(n);
for _ in 0..n {
let transfer_rx = Receiver::<Envelope>::new(channel_buffer_size);
transfer_txs.push(transfer_rx.new_sender());
transfer_rxs.push(transfer_rx);
let spawn_rx = Receiver::<SpawnRequest>::new(config.max_actors); let spawn_rx = Receiver::<SpawnRequest>::new(max_actors);
let spawn_tx = spawn_rx.new_sender(); spawn_txs.push(spawn_rx.new_sender());
spawn_rxs.push(spawn_rx);
let admin_rx = Receiver::<AdminCommand>::new(config.channel_buffer_size); let admin_rx = Receiver::<AdminCommand>::new(channel_buffer_size);
let admin_tx = admin_rx.new_sender(); admin_txs.push(admin_rx.new_sender());
admin_rxs.push(admin_rx);
let worker_stats = Arc::new(WorkerStats::new()); worker_stats.push(Arc::new(WorkerStats::new()));
}
let worker = Worker::new(transfer_rx, spawn_rx, admin_rx, worker_stats.clone()); let shared = Arc::new(RuntimeShared {
let rt = Self {
config, config,
address_map, address_map: AddressMap::with_capacity(max_actors),
inbox_registry, inbox_registry: InboxRegistry::new(),
extension: None, extension: OnceLock::new(),
transfer_tx, transfer_txs,
spawn_tx, spawn_txs,
admin_tx, admin_txs,
worker_stats, worker_stats,
stats_hook: None, rr_worker: AtomicUsize::new(0),
stats_hook: OnceLock::new(),
process_output_observer: OnceLock::new(), process_output_observer: OnceLock::new(),
worker: RefCell::new(worker),
created_at: Instant::now(), created_at: Instant::now(),
#[cfg(feature = "transport")] #[cfg(feature = "transport")]
remote_sink: None, remote_sink: OnceLock::new(),
}; });
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
tracing::info!( tracing::info!(max_actors, worker_count = n, "runtime.created");
max_actors = rt.config.max_actors,
"runtime.created"
);
rt let workers: Vec<Worker> = transfer_rxs
.into_iter()
.zip(spawn_rxs)
.zip(admin_rxs)
.enumerate()
.map(|(i, ((trx, srx), arx))| {
Worker::new(
WorkerId(i),
shared.clone(),
trx,
srx,
arx,
shared.worker_stats[i].clone(),
)
})
.collect();
Self {
runtime: Runtime { shared },
workers,
}
} }
/// Spawn an actor, returns its address /// Borrow the cloneable runtime handle.
pub fn runtime(&self) -> &Runtime {
&self.runtime
}
/// Install a runtime extension and create one `WorkerExtension` per worker.
///
/// Must be called before the parts are consumed by a host.
pub fn with_extension(mut self, ext: Arc<dyn RuntimeExtension>) -> Self {
for w in &mut self.workers {
if let Some(wext) = ext.create_worker_extension() {
w.worker_ext = Some(wext);
}
}
let _ = self.runtime.shared.extension.set(ext);
self
}
/// Install a stats hook across all workers. Must be called before the parts
/// are consumed by a host.
pub fn with_stats_hook(self, hook: Arc<dyn StatsHook>) -> Self {
let _ = self.runtime.shared.stats_hook.set(hook);
self
}
/// Consume the parts, returning the workers for an execution host to own.
///
/// The runtime handle must be cloned beforehand via [`runtime`](Self::runtime).
pub fn into_workers(self) -> Vec<Worker> {
self.workers
}
}
impl Runtime {
/// Spawn an actor, returns its address.
///
/// Runtime-handle spawns are assigned round-robin across workers.
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> { pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
let addr = ActorAddress::new_random(); let addr = ActorAddress::new_random();
self.address_map.insert(addr); let worker = self.shared.next_worker();
self.shared.address_map.insert(addr, worker);
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor)); let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
self.spawn_tx.send(SpawnRequest { self.shared.spawn_txs[worker.index()].send(SpawnRequest {
addr, addr,
actor: boxed, actor: boxed,
parent: None, parent: None,
@ -237,10 +360,7 @@ impl Runtime {
}); });
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
tracing::info!( tracing::info!(actor_addr = %addr, "actor.spawned");
actor_addr = %addr,
"actor.spawned"
);
Ok(addr) Ok(addr)
} }
@ -252,9 +372,10 @@ impl Runtime {
env: Environment, env: Environment,
) -> Result<ActorAddress, Error> { ) -> Result<ActorAddress, Error> {
let addr = ActorAddress::new_random(); let addr = ActorAddress::new_random();
self.address_map.insert(addr); let worker = self.shared.next_worker();
self.shared.address_map.insert(addr, worker);
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor)); let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
self.spawn_tx.send(SpawnRequest { self.shared.spawn_txs[worker.index()].send(SpawnRequest {
addr, addr,
actor: boxed, actor: boxed,
parent: None, parent: None,
@ -262,29 +383,14 @@ impl Runtime {
}); });
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
tracing::info!( tracing::info!(actor_addr = %addr, "actor.spawned");
actor_addr = %addr,
"actor.spawned"
);
Ok(addr) Ok(addr)
} }
/// Install a runtime extension. Extensions provide higher-level features
/// (naming, monitoring, groups) via lifecycle hooks.
///
/// Must be called before `tick()`.
pub fn with_extension(mut self, ext: Arc<dyn RuntimeExtension>) -> Self {
if let Some(wext) = ext.create_worker_extension() {
self.worker.get_mut().worker_ext = Some(wext);
}
self.extension = Some(ext);
self
}
/// Access the installed runtime extension (if any). /// Access the installed runtime extension (if any).
pub fn extension(&self) -> Option<&dyn RuntimeExtension> { pub fn extension(&self) -> Option<&dyn RuntimeExtension> {
self.extension.as_deref() self.shared.extension.get().map(|a| a.as_ref())
} }
pub fn admin(&self) -> RuntimeAdmin<'_> { pub fn admin(&self) -> RuntimeAdmin<'_> {
@ -315,7 +421,14 @@ impl Runtime {
/// ///
/// Returns `Err` if the address is unknown to the runtime. /// Returns `Err` if the address is unknown to the runtime.
pub fn send_to<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { pub fn send_to<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
let result = self.send_any(addr, Box::new(msg)); let boxed: Box<dyn Any + Send> = Box::new(msg);
let result = match self.shared.address_map.worker_of(&addr) {
Some(w) => {
self.shared.transfer_txs[w.index()].send(Envelope::new(addr, boxed));
Ok(())
}
None => self.shared.route_nonlocal(addr, boxed),
};
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
tracing::trace!(dest = %addr, "message.sent"); tracing::trace!(dest = %addr, "message.sent");
@ -326,9 +439,9 @@ impl Runtime {
/// Create an external inbox for receiving messages in the outer process containing the runtime /// Create an external inbox for receiving messages in the outer process containing the runtime
pub fn new_inbox<M: Message>(&self) -> Result<Inbox<M>, Error> { pub fn new_inbox<M: Message>(&self) -> Result<Inbox<M>, Error> {
let addr = ActorAddress::new_random(); let addr = ActorAddress::new_random();
let receiver = Receiver::<M>::new(self.config.channel_buffer_size); let receiver = Receiver::<M>::new(self.shared.config.channel_buffer_size);
let sender = receiver.new_sender(); let sender = receiver.new_sender();
self.inbox_registry.register(addr, Arc::new(sender)); self.shared.inbox_registry.register(addr, Arc::new(sender));
Ok(Inbox { Ok(Inbox {
addr, addr,
inner: receiver, inner: receiver,
@ -341,64 +454,35 @@ impl Runtime {
/// background I/O threads to bridge external events into the actor system. /// background I/O threads to bridge external events into the actor system.
pub fn create_sender(&self) -> ExternalSender { pub fn create_sender(&self) -> ExternalSender {
ExternalSender { ExternalSender {
address_map: self.address_map.clone(), shared: self.shared.clone(),
transfer_tx: self.transfer_tx.clone(),
} }
} }
fn make_tick_context(&self) -> TickContext<'_> {
TickContext {
address_map: &self.address_map,
spawn_tx: &self.spawn_tx,
transfer_tx: &self.transfer_tx,
inbox_registry: &self.inbox_registry,
config: &self.config,
extension: self.extension.as_deref(),
process_output_observer: self.process_output_observer.get(),
stats_hook: self.stats_hook.as_deref(),
worker_stats: &self.worker_stats,
created_at: self.created_at,
#[cfg(feature = "transport")]
remote_sink: self.remote_sink.as_deref(),
}
}
/// Return whether the runtime currently has schedulable work.
pub fn has_work(&self) -> bool {
self.worker.borrow().has_work()
}
/// Try to drive one tick of the runtime.
///
/// Returns `false` if no work was performed.
/// Returns `true` if at least one actor was processed.
pub fn try_tick(&self) -> bool {
let tc = self.make_tick_context();
self.worker.borrow_mut().tick_once(&tc)
}
/// Drive one tick of the runtime.
pub fn tick(&self) {
let _ = self.try_tick();
}
/// Returns a snapshot of runtime stats: actor placements and per-worker info. /// Returns a snapshot of runtime stats: actor placements and per-worker info.
pub fn stats(&self) -> RuntimeStats { pub fn stats(&self) -> RuntimeStats {
let workers = vec![self.worker_stats.snapshot(0)]; let s = &self.shared;
let num_workers = s.worker_count();
let actors = self let workers: Vec<WorkerInfo> = s
.address_map .worker_stats
.addresses() .iter()
.into_iter() .enumerate()
.map(|addr| (addr, 0)) .map(|(i, ws)| ws.snapshot(i))
.collect(); .collect();
let actors = s
let tick_timings = vec![self.worker_stats.drain_tick_timings()]; .address_map
.placements()
let uptime_ms = self.created_at.elapsed().as_millis() as u64; .into_iter()
.map(|(a, w)| (a, w.index()))
.collect();
let tick_timings: Vec<_> = s
.worker_stats
.iter()
.map(|ws| ws.drain_tick_timings())
.collect();
let uptime_ms = s.created_at.elapsed().as_millis() as u64;
RuntimeStats { RuntimeStats {
num_workers: 1, num_workers,
uptime_ms, uptime_ms,
actors, actors,
workers, workers,
@ -410,13 +494,13 @@ impl Runtime {
/// Request an actor to stop gracefully. /// Request an actor to stop gracefully.
/// ///
/// The actor's `on_stop()` hook is called before removal. Pending messages /// The actor's `on_stop()` hook is called before removal. Pending messages
/// in the mailbox are discarded. The stop takes effect on the next tick. /// in the mailbox are discarded. The stop takes effect on the owning
/// worker's next pass.
/// ///
/// Returns `Err` if the actor address is not found in the runtime. /// Returns `Err` if the actor address is not found in the runtime.
pub fn stop_actor(&self, addr: ActorAddress) -> Result<(), Error> { pub fn stop_actor(&self, addr: ActorAddress) -> Result<(), Error> {
if self.address_map.contains(&addr) { if let Some(w) = self.shared.address_map.worker_of(&addr) {
self.transfer_tx self.shared.transfer_txs[w.index()].send(Envelope::new(addr, Box::new(StopSignal)));
.send(Envelope::new(addr, Box::new(StopSignal)));
Ok(()) Ok(())
} else { } else {
Err(Error::from("Actor not found")) Err(Error::from("Actor not found"))
@ -425,9 +509,9 @@ impl Runtime {
/// Set a stats hook to receive per-actor snapshots from workers. /// Set a stats hook to receive per-actor snapshots from workers.
/// ///
/// Must be called before [`tick()`](Self::tick). /// Must be called before the runtime is driven.
pub fn set_stats_hook(&mut self, hook: Arc<dyn StatsHook>) { pub fn set_stats_hook(&self, hook: Arc<dyn StatsHook>) {
self.stats_hook = Some(hook); let _ = self.shared.stats_hook.set(hook);
} }
/// Set the sink for non-local (remote) message delivery. /// Set the sink for non-local (remote) message delivery.
@ -435,8 +519,8 @@ impl Runtime {
/// The sink owns all codec/transport concerns; core only knows how to hand /// The sink owns all codec/transport concerns; core only knows how to hand
/// it a type-erased message destined for a non-local address. /// it a type-erased message destined for a non-local address.
#[cfg(feature = "transport")] #[cfg(feature = "transport")]
pub fn set_remote_sink(&mut self, sink: Arc<dyn RemoteSink>) { pub fn set_remote_sink(&self, sink: Arc<dyn RemoteSink>) {
self.remote_sink = Some(sink); let _ = self.shared.remote_sink.set(sink);
} }
/// Deliver a raw deserialized message into the runtime. /// Deliver a raw deserialized message into the runtime.
@ -445,11 +529,11 @@ impl Runtime {
/// this to inject the resulting message for a local actor or inbox. /// this to inject the resulting message for a local actor or inbox.
#[cfg(feature = "transport")] #[cfg(feature = "transport")]
pub fn deliver_raw(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> { pub fn deliver_raw(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
if self.address_map.contains(&addr) { if let Some(w) = self.shared.address_map.worker_of(&addr) {
self.transfer_tx.send(Envelope::new(addr, msg)); self.shared.transfer_txs[w.index()].send(Envelope::new(addr, msg));
Ok(()) Ok(())
} else { } else {
self.inbox_registry.try_deliver(addr, msg) self.shared.inbox_registry.try_deliver(addr, msg)
} }
} }
} }
@ -465,34 +549,44 @@ impl RuntimeAdmin<'_> {
let (admin, reply_to) = self.new_admin::<T>()?; let (admin, reply_to) = self.new_admin::<T>()?;
let _ = self let _ = self
.runtime .runtime
.shared
.inbox_registry .inbox_registry
.try_deliver(reply_to, Box::new(result)); .try_deliver(reply_to, Box::new(result));
Ok(admin) Ok(admin)
} }
/// Borrow the producer that owns `actor`'s worker, or `None` if unknown.
fn admin_tx_for(&self, actor: ActorAddress) -> Option<&Sender<AdminCommand>> {
self.runtime
.shared
.address_map
.worker_of(&actor)
.map(|w| &self.runtime.shared.admin_txs[w.index()])
}
pub fn list_actors(&self) -> Result<Admin<ListActorsResponse>, Error> { pub fn list_actors(&self) -> Result<Admin<ListActorsResponse>, Error> {
let (admin, reply_to) = self.new_admin::<ListActorsResponse>()?; let (admin, reply_to) = self.new_admin::<ListActorsResponse>()?;
let n = self.runtime.shared.worker_count();
let acc = Arc::new(ListActorsAccumulator { let acc = Arc::new(ListActorsAccumulator {
remaining: AtomicUsize::new(1), remaining: AtomicUsize::new(n),
summaries: parking_lot::Mutex::new(Vec::new()), summaries: parking_lot::Mutex::new(Vec::new()),
reply_to, reply_to,
}); });
self.runtime // Broadcast to every worker; the last to finish aggregates and replies.
.admin_tx for tx in &self.runtime.shared.admin_txs {
.send(AdminCommand::ListActors { acc: acc.clone() }); tx.send(AdminCommand::ListActors { acc: acc.clone() });
}
Ok(admin) Ok(admin)
} }
pub fn inspect_actor(&self, actor: ActorAddress) -> Result<Admin<InspectActorResponse>, Error> { pub fn inspect_actor(&self, actor: ActorAddress) -> Result<Admin<InspectActorResponse>, Error> {
if !self.runtime.address_map.contains(&actor) { let Some(tx) = self.admin_tx_for(actor) else {
return self.ready::<InspectActorResponse>(Err(AdminError::ActorNotFound { actor })); return self.ready::<InspectActorResponse>(Err(AdminError::ActorNotFound { actor }));
} };
let (admin, reply_to) = self.new_admin::<InspectActorResponse>()?; let (admin, reply_to) = self.new_admin::<InspectActorResponse>()?;
self.runtime tx.send(AdminCommand::InspectActor { actor, reply_to });
.admin_tx
.send(AdminCommand::InspectActor { actor, reply_to });
Ok(admin) Ok(admin)
} }
@ -503,10 +597,10 @@ impl RuntimeAdmin<'_> {
where where
A: ActorInterface + Clone + Sync, A: ActorInterface + Clone + Sync,
{ {
if !self.runtime.address_map.contains(&actor) { let Some(tx) = self.admin_tx_for(actor) else {
return self return self
.ready::<GetActorStateResponse<A>>(Err(AdminError::ActorNotFound { actor })); .ready::<GetActorStateResponse<A>>(Err(AdminError::ActorNotFound { actor }));
} };
let (admin, reply_to) = self.new_admin::<GetActorStateResponse<A>>()?; let (admin, reply_to) = self.new_admin::<GetActorStateResponse<A>>()?;
let get = Box::new( let get = Box::new(
@ -558,7 +652,7 @@ impl RuntimeAdmin<'_> {
)) as Box<dyn Any + Send> )) as Box<dyn Any + Send>
}); });
self.runtime.admin_tx.send(AdminCommand::GetActorState { tx.send(AdminCommand::GetActorState {
actor, actor,
reply_to, reply_to,
get, get,
@ -582,9 +676,9 @@ impl RuntimeAdmin<'_> {
})); }));
} }
if !self.runtime.address_map.contains(&actor) { let Some(tx) = self.admin_tx_for(actor) else {
return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor })); return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor }));
} };
let (admin, reply_to) = self.new_admin::<OperationResult>()?; let (admin, reply_to) = self.new_admin::<OperationResult>()?;
let actor_instance = state.actor_instance; let actor_instance = state.actor_instance;
@ -619,114 +713,88 @@ impl RuntimeAdmin<'_> {
}, },
); );
self.runtime tx.send(AdminCommand::ReplaceActorState {
.admin_tx actor,
.send(AdminCommand::ReplaceActorState { reply_to,
actor, replace,
reply_to, });
replace,
});
Ok(admin) Ok(admin)
} }
pub fn stop_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> { pub fn stop_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> {
if !self.runtime.address_map.contains(&actor) { let Some(tx) = self.admin_tx_for(actor) else {
return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor })); return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor }));
} };
let (admin, reply_to) = self.new_admin::<OperationResult>()?; let (admin, reply_to) = self.new_admin::<OperationResult>()?;
self.runtime tx.send(AdminCommand::StopActor { actor, reply_to });
.admin_tx
.send(AdminCommand::StopActor { actor, reply_to });
Ok(admin) Ok(admin)
} }
pub fn suspend_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> { pub fn suspend_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> {
if !self.runtime.address_map.contains(&actor) { let Some(tx) = self.admin_tx_for(actor) else {
return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor })); return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor }));
} };
let (admin, reply_to) = self.new_admin::<OperationResult>()?; let (admin, reply_to) = self.new_admin::<OperationResult>()?;
self.runtime tx.send(AdminCommand::SuspendActor { actor, reply_to });
.admin_tx
.send(AdminCommand::SuspendActor { actor, reply_to });
Ok(admin) Ok(admin)
} }
pub fn resume_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> { pub fn resume_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> {
if !self.runtime.address_map.contains(&actor) { let Some(tx) = self.admin_tx_for(actor) else {
return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor })); return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor }));
} };
let (admin, reply_to) = self.new_admin::<OperationResult>()?; let (admin, reply_to) = self.new_admin::<OperationResult>()?;
self.runtime tx.send(AdminCommand::ResumeActor { actor, reply_to });
.admin_tx Ok(admin)
.send(AdminCommand::ResumeActor { actor, reply_to }); }
Ok(admin)
}
} }
#[allow(private_interfaces)] // ─── SingleThreadRuntime ────────────────────────────────────────────────────
impl ContextInner for Runtime {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> { /// The explicit manual host: consumes [`RuntimeParts`] and sequentially advances
if self.address_map.contains(&addr) { /// every worker once per [`try_tick`](Self::try_tick), without locks.
self.transfer_tx.send(Envelope::new(addr, msg)); ///
Ok(()) /// External handles retain only a cloned [`Runtime`]; the host owns the workers.
} else { pub struct SingleThreadRuntime {
self.make_tick_context().route_nonlocal(addr, msg) runtime: Runtime,
workers: Vec<Worker>,
}
impl SingleThreadRuntime {
/// Consume `parts` and own its workers for manual progression.
pub fn new(parts: RuntimeParts) -> Self {
Self {
runtime: parts.runtime,
workers: parts.workers,
} }
} }
fn spawn_any(&self, request: SpawnRequest) { /// Borrow the cloneable runtime handle.
self.address_map.insert(request.addr); pub fn runtime(&self) -> &Runtime {
self.spawn_tx.send(request); &self.runtime
} }
fn request_stop(&self, addr: ActorAddress) { /// Return whether any owned worker currently has schedulable work.
if self.address_map.contains(&addr) { pub fn has_work(&self) -> bool {
self.transfer_tx self.workers.iter().any(|w| w.has_work())
.send(Envelope::new(addr, Box::new(StopSignal))); }
/// Advance every owned worker exactly once, combining their results.
///
/// Does not short-circuit: later workers are still ticked after an earlier
/// productive one, so cross-worker backlogs drain on the same call.
pub fn try_tick(&mut self) -> bool {
let mut did_work = false;
for w in &mut self.workers {
if w.try_tick() {
did_work = true;
}
} }
did_work
} }
fn request_stop_with(&self, addr: ActorAddress, value: ExitValue) { /// Drive one pass of every worker (ignoring whether work was done).
if self.address_map.contains(&addr) { pub fn tick(&mut self) {
self.transfer_tx let _ = self.try_tick();
.send(Envelope::new(addr, Box::new(StopWithSignal(value))));
}
}
fn request_suspend(&self, addr: ActorAddress) {
// From spawn context (outside worker), 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 self.address_map.contains(&addr) {
self.transfer_tx
.send(Envelope::new(addr, Box::new(ResumeSignal)));
}
}
fn post_worker_request(&self, _request: Box<dyn Any + Send>) {
// Worker requests (e.g., timers) are per-worker; posting from outside
// a worker context (e.g., rt.spawn() callback) is not supported.
eprintln!("swactor: post_worker_request called outside worker context — ignored");
}
fn extension(&self) -> Option<&dyn RuntimeExtension> {
self.extension.as_deref()
}
fn process_output_observer(
&self,
) -> Option<Arc<dyn crate::process_observer::ProcessOutputObserver>> {
self.process_output_observer.get().cloned()
}
fn system_info(&self) -> SystemInfo {
SystemInfo {
worker_id: 0,
num_workers: 1,
total_actors: self.worker_stats.num_actors.load(Ordering::Relaxed),
uptime_ms: self.created_at.elapsed().as_millis() as u64,
}
} }
} }

View file

@ -15,10 +15,11 @@ use crate::admin::{
ListActorsResponse, OperationResult, ListActorsResponse, OperationResult,
}; };
use crate::channel::Receiver; use crate::channel::Receiver;
use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext}; use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId};
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats}; use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
use crate::extension::WorkerExtension; use crate::extension::WorkerExtension;
use crate::runtime::RuntimeShared;
/// Whether an actor should be skipped during `tick_all`. /// Whether an actor should be skipped during `tick_all`.
pub(crate) fn should_skip_actor(poisoned: bool, stopping: bool, suspended: bool) -> bool { pub(crate) fn should_skip_actor(poisoned: bool, stopping: bool, suspended: bool) -> bool {
@ -41,9 +42,10 @@ pub(crate) fn determine_stop_reason(poisoned: bool, has_exit_value: bool) -> Sto
} }
} }
/// Route a message: try local pool first, then deposit for pending-spawn actors, /// Route a runtime-injected message (extension output, death notification) to
/// then inbox_registry for external receivers. /// its destination: same-worker pool first, then the owning worker's transfer
fn route_to_pool_or_remote( /// queue, then the non-local (inbox/transport) seam.
fn route_runtime_message(
pool: &mut ActorPool, pool: &mut ActorPool,
tc: &TickContext, tc: &TickContext,
dest: ActorAddress, dest: ActorAddress,
@ -51,23 +53,28 @@ fn route_to_pool_or_remote(
) { ) {
if pool.contains(&dest) { if pool.contains(&dest) {
pool.deliver(&dest, msg); pool.deliver(&dest, msg);
} else if tc.address_map.contains(&dest) {
// Actor exists but not yet in pool (pending spawn) — deposit for next tick
tc.transfer_tx.send(Envelope::new(dest, msg));
} else { } else {
let _ = tc.route_nonlocal(dest, msg); match tc.worker_of(&dest) {
Some(w) => tc.transfer_tx(w).send(Envelope::new(dest, msg)),
None => {
let _ = tc.route_nonlocal(dest, msg);
}
}
} }
} }
// ─── Worker ───────────────────────────────────────────────────────────────── /// A worker owns a disjoint set of actors and processes them via [`Worker::try_tick`].
///
/// A worker owns a set of actors and processes them via tick_once. /// Each worker is owned by exactly one execution host (a [`SingleThreadRuntime`]
pub(crate) struct Worker { /// or an engine driver) and requires only `Send`, not `Sync`.
pub struct Worker {
pub(crate) id: WorkerId,
pub(crate) shared: Arc<RuntimeShared>,
pub(crate) pool: ActorPool, pub(crate) pool: ActorPool,
transfer_rx: Receiver<Envelope>, transfer_rx: Receiver<Envelope>,
spawn_rx: Receiver<SpawnRequest>, spawn_rx: Receiver<SpawnRequest>,
admin_rx: Receiver<AdminCommand>, admin_rx: Receiver<AdminCommand>,
stats: Arc<WorkerStats>, pub(crate) stats: Arc<WorkerStats>,
/// Reusable scratch buffer for building per-actor snapshots. /// Reusable scratch buffer for building per-actor snapshots.
snapshot_buf: Vec<ActorSnapshot>, snapshot_buf: Vec<ActorSnapshot>,
/// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory. /// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory.
@ -75,16 +82,25 @@ pub(crate) struct Worker {
/// True if the previous tick did work — ensures one full tick follows a productive /// True if the previous tick did work — ensures one full tick follows a productive
/// tick so pending_local messages delivered to mailboxes get drained. /// tick so pending_local messages delivered to mailboxes get drained.
has_backlog: bool, has_backlog: bool,
/// Transfers whose address is mapped to this worker but whose spawn request
/// has not reached the pool yet.
deferred_transfers: VecDeque<Envelope>,
/// Targeted admin commands waiting for their mapped spawn to be installed.
deferred_admin: VecDeque<AdminCommand>,
} }
impl Worker { impl Worker {
pub(crate) fn new( pub(crate) fn new(
id: WorkerId,
shared: Arc<RuntimeShared>,
transfer_rx: Receiver<Envelope>, transfer_rx: Receiver<Envelope>,
spawn_rx: Receiver<SpawnRequest>, spawn_rx: Receiver<SpawnRequest>,
admin_rx: Receiver<AdminCommand>, admin_rx: Receiver<AdminCommand>,
stats: Arc<WorkerStats>, stats: Arc<WorkerStats>,
) -> Self { ) -> Self {
Self { Self {
id,
shared,
pool: ActorPool::new(), pool: ActorPool::new(),
transfer_rx, transfer_rx,
spawn_rx, spawn_rx,
@ -93,6 +109,8 @@ impl Worker {
snapshot_buf: Vec::new(), snapshot_buf: Vec::new(),
worker_ext: None, worker_ext: None,
has_backlog: false, has_backlog: false,
deferred_transfers: VecDeque::new(),
deferred_admin: VecDeque::new(),
} }
} }
@ -100,187 +118,21 @@ impl Worker {
self.has_backlog self.has_backlog
|| !self.spawn_rx.is_empty() || !self.spawn_rx.is_empty()
|| !self.transfer_rx.is_empty() || !self.transfer_rx.is_empty()
|| !self.deferred_transfers.is_empty()
|| !self.admin_rx.is_empty() || !self.admin_rx.is_empty()
|| !self.deferred_admin.is_empty()
|| self || self
.worker_ext .worker_ext
.as_ref() .as_ref()
.map_or(false, |e| e.has_pending_work()) .map_or(false, |e| e.has_pending_work())
} }
/// Run one iteration of the worker loop. Returns `true` if any work was done. /// Run one synchronous worker pass. 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. /// This is the only core worker transition. The owning host calls it; the
fn drain_spawns(&mut self, tc: &TickContext) -> bool { /// worker never drives itself.
let mut did_work = false; pub fn try_tick(&mut self) -> bool {
#[cfg(feature = "tracing")] let wid = self.id;
let mut spawn_count: usize = 0;
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;
}
#[cfg(feature = "tracing")]
if spawn_count > 0 {
tracing::debug!(
worker_id = 0,
count = spawn_count,
"worker.spawns_drained"
);
}
did_work
}
fn drain_admin(&mut self, tc: &TickContext) -> bool {
let mut did_work = false;
while let Some(cmd) = self.admin_rx.try_recv() {
did_work = true;
self.apply_admin_command(tc, cmd);
}
did_work
}
fn send_admin_reply<T: crate::actor::Message>(
tc: &TickContext,
reply_to: ActorAddress,
result: AdminResult<T>,
) {
let _ = tc.inbox_registry.try_deliver(reply_to, Box::new(result));
}
fn apply_admin_command(&mut self, tc: &TickContext, cmd: AdminCommand) {
match cmd {
AdminCommand::ListActors { acc } => {
let mut local = Vec::new();
self.pool.actor_summaries_into(&mut local);
{
let mut summaries = acc.summaries.lock();
summaries.extend(local);
}
if acc.remaining.fetch_sub(1, Ordering::AcqRel) == 1 {
let actors = {
let mut summaries = acc.summaries.lock();
std::mem::take(&mut *summaries)
};
Self::send_admin_reply(tc, acc.reply_to, Ok(ListActorsResponse { actors }));
}
}
AdminCommand::InspectActor { actor, reply_to } => {
let result = self
.pool
.actor_summary(actor)
.map(|summary| InspectActorResponse { summary });
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::GetActorState {
actor,
reply_to,
get,
not_found,
} => {
let boxed = match self.pool.get_actor_erased(actor) {
Some(erased) => get(actor, erased, erased.metadata()),
None => not_found(actor),
};
let _ = tc.inbox_registry.try_deliver(reply_to, boxed);
}
AdminCommand::ReplaceActorState {
actor,
reply_to,
replace,
} => {
let result = match self.pool.get_actor_erased_mut(actor) {
Some(erased) => {
let metadata = erased.metadata();
replace(erased, metadata)
}
None => Err(AdminError::ActorNotFound { actor }),
};
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::StopActor { actor, reply_to } => {
let result = self.pool.stop_actor_admin(actor, &self.stats);
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::SuspendActor { actor, reply_to } => {
let result = self.pool.suspend_actor_admin(actor);
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::ResumeActor { actor, reply_to } => {
let result = self.pool.resume_actor_admin(actor);
Self::send_admin_reply(tc, reply_to, result);
}
}
}
/// Phase 7: clean up dead actors, deliver death notifications, GC extension state.
fn cleanup_dead_actors(&mut self, tc: &TickContext) -> bool {
let cleanup_pending: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
RefCell::new(Vec::new());
let cleanup_stops: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let cleanup_stop_withs: RefCell<Vec<(ActorAddress, ExitValue)>> = RefCell::new(Vec::new());
let cleanup_suspends: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let cleanup_requests: RefCell<Vec<Box<dyn Any + Send>>> = RefCell::new(Vec::new());
let dead = {
let cleanup_ctx = WorkerContext {
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,
};
self.pool.cleanup_dead(&cleanup_ctx)
};
let had_dead = !dead.is_empty();
if had_dead {
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();
ext.cleanup_dead(&dead_addrs);
for (dest, msg) in notifications {
route_to_pool_or_remote(&mut self.pool, tc, dest, msg);
}
}
self.stats
.num_actors
.store(self.pool.len(), Ordering::Relaxed);
}
// Deliver any messages sent during on_stop callbacks
for (addr, msg) in cleanup_pending.into_inner() {
self.pool.deliver(&addr, msg);
}
// GC per-worker extension state for dead actors
if let Some(ext) = &mut self.worker_ext {
let dead_addrs: Vec<ActorAddress> = dead.iter().map(|(a, _, _)| *a).collect();
ext.gc_dead(&dead_addrs);
}
had_dead
}
pub(crate) fn tick_once(&mut self, tc: &TickContext) -> bool {
#[cfg(feature = "tracing")]
let _span = tracing::trace_span!("worker.tick", worker_id = 0).entered();
// Fast idle path: skip the entire tick when nothing could have changed. // Fast idle path: skip the entire tick when nothing could have changed.
// Cost: ~3 atomic loads, zero syscalls, zero actor iteration. // Cost: ~3 atomic loads, zero syscalls, zero actor iteration.
@ -288,24 +140,60 @@ impl Worker {
return false; return false;
} }
// Build the routing context from disjoint fields so the mutable
// per-pass state (pool, queues, extension) can still be borrowed below.
let shared = &self.shared;
let tc = TickContext {
address_map: &shared.address_map,
spawn_txs: &shared.spawn_txs,
transfer_txs: &shared.transfer_txs,
inbox_registry: &shared.inbox_registry,
config: &shared.config,
extension: shared.extension.get().map(|a| a.as_ref()),
process_output_observer: shared.process_output_observer.get(),
stats_hook: shared.stats_hook.get().map(|a| a.as_ref()),
worker_stats: &self.stats,
num_workers: shared.worker_stats.len(),
worker_id: wid,
created_at: shared.created_at,
#[cfg(feature = "transport")]
remote_sink: shared.remote_sink.get().map(|a| a.as_ref()),
};
#[cfg(feature = "tracing")]
let _span = tracing::trace_span!("worker.tick", worker_id = wid.index()).entered();
let mut did_work = false; let mut did_work = false;
let t0 = Instant::now(); let t0 = Instant::now();
// 1. Drain spawn queue → add actors to pool // 1. Drain spawn queue → add actors to pool
did_work |= self.drain_spawns(tc); did_work |= Self::drain_spawns(
&mut self.pool,
&self.spawn_rx,
&tc,
tc.config.worker_ingress_budget,
);
let t1 = Instant::now(); let t1 = Instant::now();
// 2. Drain transfer queue → deliver envelopes to actors // 2. Drain retained transfers first, then the transfer queue → deliver
while let Some(envelope) = self.transfer_rx.try_recv() { // envelopes to actors or retain them until their mapped spawn installs.
let dest = envelope.dest(); did_work |= Self::drain_transfers(
let payload = envelope.into_payload(); &mut self.pool,
self.pool.deliver(&dest, payload); &self.transfer_rx,
did_work = true; &mut self.deferred_transfers,
} &tc,
tc.config.worker_ingress_budget,
);
let t2 = Instant::now(); let t2 = Instant::now();
// 3. Drain admin queue → inspect or mutate worker-owned slots before handlers // 3. Drain admin queue → inspect or mutate worker-owned slots before handlers
did_work |= self.drain_admin(tc); did_work |= Self::drain_admin(
&mut self.pool,
&self.admin_rx,
&mut self.deferred_admin,
&tc,
tc.config.worker_ingress_budget,
);
// 4. Fire per-worker extension (e.g., timers) → deliver before tick_all // 4. Fire per-worker extension (e.g., timers) → deliver before tick_all
let ext_msgs: Vec<_> = self let ext_msgs: Vec<_> = self
@ -314,7 +202,7 @@ impl Worker {
.map(|ext| ext.on_tick()) .map(|ext| ext.on_tick())
.unwrap_or_default(); .unwrap_or_default();
for (dest, msg) in ext_msgs { for (dest, msg) in ext_msgs {
route_to_pool_or_remote(&mut self.pool, tc, dest, msg); route_runtime_message(&mut self.pool, &tc, dest, msg);
did_work = true; did_work = true;
} }
@ -329,7 +217,7 @@ impl Worker {
let processed; let processed;
{ {
let worker_ctx = WorkerContext { let worker_ctx = WorkerContext {
tc, tc: &tc,
pending_local: &pending_local, pending_local: &pending_local,
stop_requests: &stop_requests, stop_requests: &stop_requests,
stop_with_values: &stop_with_values, stop_with_values: &stop_with_values,
@ -337,11 +225,9 @@ impl Worker {
worker_requests: &worker_requests, worker_requests: &worker_requests,
stats: &self.stats, stats: &self.stats,
}; };
processed = self.pool.tick_all( processed = self
&worker_ctx, .pool
&self.stats, .tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget);
tc.config.actor_message_budget,
);
if processed > 0 { if processed > 0 {
did_work = true; did_work = true;
} }
@ -351,7 +237,7 @@ impl Worker {
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
if processed > 0 { if processed > 0 {
tracing::debug!( tracing::debug!(
worker_id = 0, worker_id = wid.index(),
messages_processed = processed, messages_processed = processed,
"worker.tick_all" "worker.tick_all"
); );
@ -359,7 +245,12 @@ impl Worker {
// 6. Drain spawn queue again — actors spawned during step 5 // 6. Drain spawn queue again — actors spawned during step 5
// must be in the pool before pending_local delivery. // must be in the pool before pending_local delivery.
did_work |= self.drain_spawns(tc); did_work |= Self::drain_spawns(
&mut self.pool,
&self.spawn_rx,
&tc,
tc.config.worker_ingress_budget,
);
let t4 = Instant::now(); let t4 = Instant::now();
// 7. Drain pending_local buffer → deliver to local actors // 7. Drain pending_local buffer → deliver to local actors
@ -368,7 +259,12 @@ impl Worker {
did_work = true; did_work = true;
} }
for (addr, msg) in pending { for (addr, msg) in pending {
self.pool.deliver(&addr, msg); Self::deliver_or_defer_transfer(
&mut self.pool,
&tc,
&mut self.deferred_transfers,
Envelope::new(addr, msg),
);
} }
// 7.5. Process worker extension requests from handlers (e.g., timer scheduling) // 7.5. Process worker extension requests from handlers (e.g., timer scheduling)
@ -394,7 +290,7 @@ impl Worker {
if let Some(hook) = tc.stats_hook { if let Some(hook) = tc.stats_hook {
self.pool.mailbox_depths_into(&mut self.snapshot_buf); self.pool.mailbox_depths_into(&mut self.snapshot_buf);
hook.on_tick(0, &self.snapshot_buf); hook.on_tick(wid.index(), &self.snapshot_buf);
} }
} }
@ -418,7 +314,7 @@ impl Worker {
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
if did_work { if did_work {
tracing::debug!( tracing::debug!(
worker_id = 0, worker_id = wid.index(),
num_actors = self.pool.len(), num_actors = self.pool.len(),
mailbox_depth = self.pool.total_mailbox_depth(), mailbox_depth = self.pool.total_mailbox_depth(),
messages_processed = processed, messages_processed = processed,
@ -427,18 +323,318 @@ impl Worker {
} }
// 9. Clean up poisoned and stopping actors // 9. Clean up poisoned and stopping actors
did_work |= self.cleanup_dead_actors(tc); did_work |= Self::cleanup_dead_actors(
&mut self.pool,
&mut self.worker_ext,
&mut self.deferred_transfers,
&tc,
);
self.has_backlog = did_work; self.has_backlog = did_work;
did_work did_work
} }
fn drain_spawns(
pool: &mut ActorPool,
spawn_rx: &Receiver<SpawnRequest>,
tc: &TickContext,
budget: usize,
) -> bool {
let mut did_work = false;
let mut count = 0usize;
#[cfg(feature = "tracing")]
let mut spawn_count: usize = 0;
while let Some(mut req) = 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,
);
}
pool.insert(req);
#[cfg(feature = "tracing")]
{
spawn_count += 1;
}
did_work = true;
count += 1;
if budget != 0 && count >= budget {
break;
}
}
#[cfg(feature = "tracing")]
if spawn_count > 0 {
tracing::debug!(
worker_id = tc.worker_id().index(),
count = spawn_count,
"worker.spawns_drained"
);
}
did_work
}
fn deliver_or_defer_transfer(
pool: &mut ActorPool,
tc: &TickContext,
deferred: &mut VecDeque<Envelope>,
envelope: Envelope,
) -> bool {
let dest = envelope.dest();
if pool.contains(&dest) {
pool.deliver(&dest, envelope.into_payload());
true
} else if tc.worker_of(&dest) == Some(tc.worker_id()) {
deferred.push_back(envelope);
false
} else {
true
}
}
fn drain_transfers(
pool: &mut ActorPool,
transfer_rx: &Receiver<Envelope>,
deferred: &mut VecDeque<Envelope>,
tc: &TickContext,
budget: usize,
) -> bool {
let mut did_work = false;
let mut count = 0usize;
while budget == 0 || count < budget {
let Some(envelope) = deferred.pop_front() else {
break;
};
if Self::deliver_or_defer_transfer(pool, tc, deferred, envelope) {
did_work = true;
}
count += 1;
}
while budget == 0 || count < budget {
let Some(envelope) = transfer_rx.try_recv() else {
break;
};
did_work = true;
Self::deliver_or_defer_transfer(pool, tc, deferred, envelope);
count += 1;
}
did_work
}
fn admin_target(cmd: &AdminCommand) -> Option<ActorAddress> {
match cmd {
AdminCommand::ListActors { .. } => None,
AdminCommand::InspectActor { actor, .. }
| AdminCommand::GetActorState { actor, .. }
| AdminCommand::ReplaceActorState { actor, .. }
| AdminCommand::StopActor { actor, .. }
| AdminCommand::SuspendActor { actor, .. }
| AdminCommand::ResumeActor { actor, .. } => Some(*actor),
}
}
fn should_defer_admin_command(
pool: &ActorPool,
tc: &TickContext,
cmd: &AdminCommand,
) -> bool {
Self::admin_target(cmd).is_some_and(|actor| {
!pool.contains(&actor) && tc.worker_of(&actor) == Some(tc.worker_id())
})
}
fn apply_or_defer_admin_command(
pool: &mut ActorPool,
tc: &TickContext,
deferred: &mut VecDeque<AdminCommand>,
cmd: AdminCommand,
) -> bool {
if Self::should_defer_admin_command(pool, tc, &cmd) {
deferred.push_back(cmd);
false
} else {
Self::apply_admin_command(pool, tc, cmd);
true
}
}
fn drain_admin(
pool: &mut ActorPool,
admin_rx: &Receiver<AdminCommand>,
deferred: &mut VecDeque<AdminCommand>,
tc: &TickContext,
budget: usize,
) -> bool {
let mut did_work = false;
let mut count = 0usize;
while budget == 0 || count < budget {
let Some(cmd) = deferred.pop_front() else {
break;
};
if Self::apply_or_defer_admin_command(pool, tc, deferred, cmd) {
did_work = true;
}
count += 1;
}
while budget == 0 || count < budget {
let Some(cmd) = admin_rx.try_recv() else {
break;
};
did_work = true;
Self::apply_or_defer_admin_command(pool, tc, deferred, cmd);
count += 1;
}
did_work
}
fn send_admin_reply<T: crate::actor::Message>(
tc: &TickContext,
reply_to: ActorAddress,
result: AdminResult<T>,
) {
let _ = tc.inbox_registry.try_deliver(reply_to, Box::new(result));
}
fn apply_admin_command(pool: &mut ActorPool, tc: &TickContext, cmd: AdminCommand) {
match cmd {
AdminCommand::ListActors { acc } => {
let mut local = Vec::new();
pool.actor_summaries_into(&mut local, tc.worker_id);
{
let mut summaries = acc.summaries.lock();
summaries.extend(local);
}
if acc.remaining.fetch_sub(1, Ordering::AcqRel) == 1 {
let actors = {
let mut summaries = acc.summaries.lock();
std::mem::take(&mut *summaries)
};
Self::send_admin_reply(tc, acc.reply_to, Ok(ListActorsResponse { actors }));
}
}
AdminCommand::InspectActor { actor, reply_to } => {
let result = pool
.actor_summary(actor, tc.worker_id)
.map(|summary| InspectActorResponse { summary });
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::GetActorState {
actor,
reply_to,
get,
not_found,
} => {
let boxed = match pool.get_actor_erased(actor) {
Some(erased) => get(actor, erased, erased.metadata()),
None => not_found(actor),
};
let _ = tc.inbox_registry.try_deliver(reply_to, boxed);
}
AdminCommand::ReplaceActorState {
actor,
reply_to,
replace,
} => {
let result = match pool.get_actor_erased_mut(actor) {
Some(erased) => {
let metadata = erased.metadata();
replace(erased, metadata)
}
None => Err(AdminError::ActorNotFound { actor }),
};
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::StopActor { actor, reply_to } => {
let result = pool.stop_actor_admin(actor, tc.worker_stats);
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::SuspendActor { actor, reply_to } => {
let result = pool.suspend_actor_admin(actor);
Self::send_admin_reply(tc, reply_to, result);
}
AdminCommand::ResumeActor { actor, reply_to } => {
let result = pool.resume_actor_admin(actor);
Self::send_admin_reply(tc, reply_to, result);
}
}
}
/// Phase 9: clean up dead actors, deliver death notifications, GC extension state.
fn cleanup_dead_actors(
pool: &mut ActorPool,
worker_ext: &mut Option<Box<dyn WorkerExtension>>,
deferred_transfers: &mut VecDeque<Envelope>,
tc: &TickContext,
) -> bool {
let cleanup_pending: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
RefCell::new(Vec::new());
let cleanup_stops: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let cleanup_stop_withs: RefCell<Vec<(ActorAddress, ExitValue)>> = RefCell::new(Vec::new());
let cleanup_suspends: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let cleanup_requests: RefCell<Vec<Box<dyn Any + Send>>> = RefCell::new(Vec::new());
let dead = {
let cleanup_ctx = WorkerContext {
tc,
pending_local: &cleanup_pending,
stop_requests: &cleanup_stops,
stop_with_values: &cleanup_stop_withs,
suspend_requests: &cleanup_suspends,
worker_requests: &cleanup_requests,
stats: tc.worker_stats,
};
pool.cleanup_dead(&cleanup_ctx)
};
let had_dead = !dead.is_empty();
if had_dead {
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();
ext.cleanup_dead(&dead_addrs);
for (dest, msg) in notifications {
route_runtime_message(pool, tc, dest, msg);
}
}
tc.worker_stats.num_actors.store(pool.len(), Ordering::Relaxed);
}
// Deliver any messages sent during on_stop callbacks.
for (addr, msg) in cleanup_pending.into_inner() {
Self::deliver_or_defer_transfer(
pool,
tc,
deferred_transfers,
Envelope::new(addr, msg),
);
}
// GC per-worker extension state for dead actors
if let Some(ext) = worker_ext {
let dead_addrs: Vec<ActorAddress> = dead.iter().map(|(a, _, _)| *a).collect();
ext.gc_dead(&dead_addrs);
}
had_dead
}
} }
/// The `ContextInner` impl for in-worker sends. /// The `ContextInner` impl for in-worker sends.
/// ///
/// All sends to local actors are buffered in `pending_local` (delivered after /// Same-worker sends are staged in `pending_local` (eligible next pass).
/// the current tick round). Non-local addresses route to inbox_registry or remote. /// Cross-worker sends move an `Envelope` into the target worker's transfer
/// queue. Non-actor addresses route to the inbox registry / transport seam.
struct WorkerContext<'a> { struct WorkerContext<'a> {
tc: &'a TickContext<'a>, tc: &'a TickContext<'a>,
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>, pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
@ -451,19 +647,29 @@ struct WorkerContext<'a> {
impl ContextInner for WorkerContext<'_> { impl ContextInner for WorkerContext<'_> {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> { fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
if self.tc.address_map.contains(&addr) { match self.tc.worker_of(&addr) {
self.stats.local_sends.fetch_add(1, Ordering::Relaxed); Some(w) if w == self.tc.worker_id() => {
self.pending_local.borrow_mut().push((addr, msg)); self.stats.local_sends.fetch_add(1, Ordering::Relaxed);
Ok(()) self.pending_local.borrow_mut().push((addr, msg));
} else { Ok(())
self.stats.inbox_sends.fetch_add(1, Ordering::Relaxed); }
self.tc.route_nonlocal(addr, msg) Some(w) => {
// Cross-worker: move the payload through shared memory.
self.stats.local_sends.fetch_add(1, Ordering::Relaxed);
self.tc.transfer_tx(w).send(Envelope::new(addr, msg));
Ok(())
}
None => {
self.stats.inbox_sends.fetch_add(1, Ordering::Relaxed);
self.tc.route_nonlocal(addr, msg)
}
} }
} }
fn spawn_any(&self, request: SpawnRequest) { fn spawn_any(&self, request: SpawnRequest) {
self.tc.address_map.insert(request.addr); // ctx.spawn pins the child to the current worker.
self.tc.spawn_tx.send(request); self.tc.address_map.insert(request.addr, self.tc.worker_id());
self.tc.spawn_tx(self.tc.worker_id()).send(request);
} }
fn request_stop(&self, addr: ActorAddress) { fn request_stop(&self, addr: ActorAddress) {
@ -500,9 +706,9 @@ impl ContextInner for WorkerContext<'_> {
fn system_info(&self) -> SystemInfo { fn system_info(&self) -> SystemInfo {
SystemInfo { SystemInfo {
worker_id: 0, worker_id: self.tc.worker_id().index(),
num_workers: 1, num_workers: self.tc.num_workers,
total_actors: self.tc.worker_stats.num_actors.load(Ordering::Relaxed), total_actors: self.tc.address_map.len(),
uptime_ms: self.tc.created_at.elapsed().as_millis() as u64, uptime_ms: self.tc.created_at.elapsed().as_millis() as u64,
} }
} }
@ -605,13 +811,13 @@ impl ActorPool {
self.actors.get_mut(&addr).map(|slot| slot.actor.as_mut()) self.actors.get_mut(&addr).map(|slot| slot.actor.as_mut())
} }
fn actor_summary_from_slot(address: ActorAddress, slot: &ActorSlot) -> ActorSummary { fn actor_summary_from_slot(address: ActorAddress, slot: &ActorSlot, worker: WorkerId) -> ActorSummary {
let metadata = slot.actor.metadata(); let metadata = slot.actor.metadata();
ActorSummary { ActorSummary {
address, address,
actor_type: metadata.actor_type_name, actor_type: metadata.actor_type_name,
message_type: metadata.message_type_name, message_type: metadata.message_type_name,
worker_id: 0, worker_id: worker.index(),
parent: slot.parent_addr, parent: slot.parent_addr,
mailbox_depth: slot.mailbox.len(), mailbox_depth: slot.mailbox.len(),
status: ActorStatus { status: ActorStatus {
@ -625,19 +831,19 @@ impl ActorPool {
} }
} }
fn actor_summary(&self, addr: ActorAddress) -> AdminResult<ActorSummary> { fn actor_summary(&self, addr: ActorAddress, worker: WorkerId) -> AdminResult<ActorSummary> {
self.actors self.actors
.get(&addr) .get(&addr)
.map(|slot| Self::actor_summary_from_slot(addr, slot)) .map(|slot| Self::actor_summary_from_slot(addr, slot, worker))
.ok_or(AdminError::ActorNotFound { actor: addr }) .ok_or(AdminError::ActorNotFound { actor: addr })
} }
fn actor_summaries_into(&self, out: &mut Vec<ActorSummary>) { fn actor_summaries_into(&self, out: &mut Vec<ActorSummary>, worker: WorkerId) {
out.clear(); out.clear();
out.extend( out.extend(
self.actors self.actors
.iter() .iter()
.map(|(&addr, slot)| Self::actor_summary_from_slot(addr, slot)), .map(|(&addr, slot)| Self::actor_summary_from_slot(addr, slot, worker)),
); );
} }

View file

@ -345,7 +345,7 @@ fn actor_from_birth_to_first_message() {
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
// Spawn one tracked actor + 4 more sharing the same counters // Spawn one tracked actor + 4 more sharing the same counters
@ -366,7 +366,7 @@ fn actor_from_birth_to_first_message() {
} }
// First tick: all 5 on_start fire, no messages processed yet // First tick: all 5 on_start fire, no messages processed yet
rt.tick(); host.try_tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start per instance"); assert_eq!(started.load(Ordering::Relaxed), 5, "on_start per instance");
assert_eq!( assert_eq!(
handled.load(Ordering::Relaxed), handled.load(Ordering::Relaxed),
@ -386,7 +386,7 @@ fn actor_from_birth_to_first_message() {
) )
.unwrap(); .unwrap();
} }
let replies = tick_and_drain(&rt, &count_inbox, 10); let replies = tick_and_drain(&mut host, &count_inbox, 10);
assert_eq!( assert_eq!(
replies, replies,
vec![Count(1), Count(2), Count(3)], vec![Count(1), Count(2), Count(3)],
@ -394,8 +394,8 @@ fn actor_from_birth_to_first_message() {
); );
// on_start must not fire again on subsequent ticks // on_start must not fire again on subsequent ticks
rt.tick(); host.try_tick();
rt.tick(); host.try_tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start not repeated"); assert_eq!(started.load(Ordering::Relaxed), 5, "on_start not repeated");
// Verify the first actor still responds normally // Verify the first actor still responds normally
@ -406,7 +406,7 @@ fn actor_from_birth_to_first_message() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 10); let reply = tick_until_recv(&mut host, &inbox, 10);
assert!(reply.is_some(), "actor handles messages after on_start"); assert!(reply.is_some(), "actor handles messages after on_start");
} }
@ -414,7 +414,7 @@ fn actor_from_birth_to_first_message() {
/// distributes work. Spawn+send interleaving in a single handler works. /// distributes work. Spawn+send interleaving in a single handler works.
#[test] #[test]
fn parent_child_delegation_and_spawn_chains() { fn parent_child_delegation_and_spawn_chains() {
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 2000, max_actors: 2000,
..Default::default() ..Default::default()
}); });
@ -430,7 +430,7 @@ fn parent_child_delegation_and_spawn_chains() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 20); let reply = tick_until_recv(&mut host, &inbox, 20);
assert_eq!(reply, Some(Done(14)), "delegator child doubles value"); assert_eq!(reply, Some(Done(14)), "delegator child doubles value");
// Act 2: Chain of depth 20 // Act 2: Chain of depth 20
@ -444,7 +444,7 @@ fn parent_child_delegation_and_spawn_chains() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 200); let reply = tick_until_recv(&mut host, &inbox, 200);
assert_eq!(reply, Some(Done(20)), "chain reaches depth 20"); assert_eq!(reply, Some(Done(20)), "chain reaches depth 20");
// Act 3: Fan-out to 20 children // Act 3: Fan-out to 20 children
@ -457,7 +457,7 @@ fn parent_child_delegation_and_spawn_chains() {
}, },
) )
.unwrap(); .unwrap();
let replies = tick_and_drain(&rt, &inbox, 50); let replies = tick_and_drain(&mut host, &inbox, 50);
assert_eq!(replies.len(), 20, "all 20 fan-out children reply"); assert_eq!(replies.len(), 20, "all 20 fan-out children reply");
} }
@ -467,7 +467,7 @@ fn parent_child_delegation_and_spawn_chains() {
fn graceful_stop_lifecycle() { fn graceful_stop_lifecycle() {
// --- Part A: SelfStopActor --- // --- Part A: SelfStopActor ---
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap(); let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt let addr = rt
@ -487,7 +487,7 @@ fn graceful_stop_lifecycle() {
}, },
); );
} }
tick_n(&rt, 10); tick_n(&mut host, 10);
let mut replies = Vec::new(); let mut replies = Vec::new();
while let Some(Done(v)) = inbox.try_recv() { while let Some(Done(v)) = inbox.try_recv() {
@ -512,16 +512,16 @@ fn graceful_stop_lifecycle() {
); );
// --- Part B: FarewellActor sends farewell in on_stop --- // --- Part B: FarewellActor sends farewell in on_stop ---
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt let addr = rt
.spawn(FarewellActor { .spawn(FarewellActor {
farewell_to: *inbox.addr(), farewell_to: *inbox.addr(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!( assert_eq!(
inbox.try_recv(), inbox.try_recv(),
Some(Pong), Some(Pong),
@ -531,7 +531,7 @@ fn graceful_stop_lifecycle() {
// --- Part C: External stop after pending messages --- // --- Part C: External stop after pending messages ---
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt let addr = rt
.spawn(LifecycleActor { .spawn(LifecycleActor {
@ -549,7 +549,7 @@ fn graceful_stop_lifecycle() {
); );
} }
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
tick_n(&rt, 10); tick_n(&mut host, 10);
assert_eq!( assert_eq!(
handled.load(Ordering::Relaxed), handled.load(Ordering::Relaxed),
10, 10,
@ -563,7 +563,7 @@ fn graceful_stop_lifecycle() {
// --- Part D: External stop before messages → 0 processed --- // --- Part D: External stop before messages → 0 processed ---
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt let addr = rt
.spawn(LifecycleActor { .spawn(LifecycleActor {
@ -572,7 +572,7 @@ fn graceful_stop_lifecycle() {
handled: handled.clone(), handled: handled.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); // on_start host.try_tick(); // on_start
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
for _ in 0..5 { for _ in 0..5 {
let _ = rt.send_to( let _ = rt.send_to(
@ -582,7 +582,7 @@ fn graceful_stop_lifecycle() {
}, },
); );
} }
tick_n(&rt, 10); tick_n(&mut host, 10);
assert_eq!( assert_eq!(
handled.load(Ordering::Relaxed), handled.load(Ordering::Relaxed),
0, 0,
@ -591,15 +591,15 @@ fn graceful_stop_lifecycle() {
// --- Part E: Mid-mailbox stop trigger --- // --- Part E: Mid-mailbox stop trigger ---
let processed = Arc::new(AtomicUsize::new(0)); let processed = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let addr = rt.spawn(StopOnTrigger(processed.clone())).unwrap(); let addr = rt.spawn(StopOnTrigger(processed.clone())).unwrap();
rt.tick(); host.try_tick();
rt.send_to(addr, Trigger(false)).unwrap(); rt.send_to(addr, Trigger(false)).unwrap();
rt.send_to(addr, Trigger(false)).unwrap(); rt.send_to(addr, Trigger(false)).unwrap();
rt.send_to(addr, Trigger(true)).unwrap(); // stop trigger rt.send_to(addr, Trigger(true)).unwrap(); // stop trigger
rt.send_to(addr, Trigger(false)).unwrap(); rt.send_to(addr, Trigger(false)).unwrap();
rt.send_to(addr, Trigger(false)).unwrap(); rt.send_to(addr, Trigger(false)).unwrap();
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!( assert_eq!(
processed.load(Ordering::Relaxed), processed.load(Ordering::Relaxed),
3, 3,
@ -614,7 +614,7 @@ fn graceful_stop_lifecycle() {
/// delivered, bulk cleanup, on_start panic also poisons. /// delivered, bulk cleanup, on_start panic also poisons.
#[test] #[test]
fn panic_isolation_and_cleanup() { fn panic_isolation_and_cleanup() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let count_inbox = rt.new_inbox::<Count>().unwrap(); let count_inbox = rt.new_inbox::<Count>().unwrap();
@ -636,7 +636,7 @@ fn panic_isolation_and_cleanup() {
reply_to: *inbox.addr(), reply_to: *inbox.addr(),
}, },
); );
tick_n(&rt, 10); tick_n(&mut host, 10);
// Healthy actor still works // Healthy actor still works
rt.send_to( rt.send_to(
@ -653,7 +653,7 @@ fn panic_isolation_and_cleanup() {
}, },
) )
.unwrap(); .unwrap();
let replies = tick_and_drain(&rt, &count_inbox, 10); let replies = tick_and_drain(&mut host, &count_inbox, 10);
assert_eq!( assert_eq!(
replies, replies,
vec![Count(1), Count(2)], vec![Count(1), Count(2)],
@ -681,7 +681,7 @@ fn panic_isolation_and_cleanup() {
// --- Mid-batch panic discards remaining --- // --- Mid-batch panic discards remaining ---
let counter = Arc::new(AtomicUsize::new(0)); let counter = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let dummy = rt.new_inbox::<Pong>().unwrap(); let dummy = rt.new_inbox::<Pong>().unwrap();
let addr = rt let addr = rt
.spawn(PanicAfterNActor { .spawn(PanicAfterNActor {
@ -698,7 +698,7 @@ fn panic_isolation_and_cleanup() {
) )
.unwrap(); .unwrap();
} }
tick_n(&rt, 20); tick_n(&mut host, 20);
assert_eq!( assert_eq!(
counter.load(Ordering::SeqCst), counter.load(Ordering::SeqCst),
2, 2,
@ -706,7 +706,7 @@ fn panic_isolation_and_cleanup() {
); );
// --- Child spawned before parent panic survives --- // --- Child spawned before parent panic survives ---
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap(); let inbox = rt.new_inbox::<Done>().unwrap();
let parent = rt.spawn(SpawnThenPanicActor).unwrap(); let parent = rt.spawn(SpawnThenPanicActor).unwrap();
rt.send_to( rt.send_to(
@ -717,11 +717,11 @@ fn panic_isolation_and_cleanup() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 30); let reply = tick_until_recv(&mut host, &inbox, 30);
assert_eq!(reply, Some(Done(10)), "child survives parent panic"); assert_eq!(reply, Some(Done(10)), "child survives parent panic");
// --- Message sent before panic is delivered --- // --- Message sent before panic is delivered ---
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(SendThenPanicActor).unwrap(); let addr = rt.spawn(SendThenPanicActor).unwrap();
rt.send_to( rt.send_to(
@ -731,11 +731,11 @@ fn panic_isolation_and_cleanup() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 20); let reply = tick_until_recv(&mut host, &inbox, 20);
assert!(reply.is_some(), "message sent before panic still delivered"); assert!(reply.is_some(), "message sent before panic still delivered");
// --- Bulk cleanup: 20 panicking actors all cleaned --- // --- Bulk cleanup: 20 panicking actors all cleaned ---
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let mut addrs = Vec::new(); let mut addrs = Vec::new();
for _ in 0..20 { for _ in 0..20 {
addrs.push(rt.spawn(PanicActor).unwrap()); addrs.push(rt.spawn(PanicActor).unwrap());
@ -743,7 +743,7 @@ fn panic_isolation_and_cleanup() {
for &addr in &addrs { for &addr in &addrs {
let _ = rt.send_to(addr, PanicMsg); let _ = rt.send_to(addr, PanicMsg);
} }
tick_n(&rt, 10); tick_n(&mut host, 10);
let stats = rt.stats(); let stats = rt.stats();
assert_eq!( assert_eq!(
stats.workers[0].num_actors, 0, stats.workers[0].num_actors, 0,
@ -756,7 +756,7 @@ fn panic_isolation_and_cleanup() {
/// runtime-level watch works. /// runtime-level watch works.
#[test] #[test]
fn watch_notification_contract() { fn watch_notification_contract() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let target = rt.spawn(PanicActor).unwrap(); let target = rt.spawn(PanicActor).unwrap();
let (w1, s1) = new_exit_watcher(); let (w1, s1) = new_exit_watcher();
@ -770,14 +770,14 @@ fn watch_notification_contract() {
rt.send_to(w1_addr, WatcherCmd::WatchThis(target)).unwrap(); rt.send_to(w1_addr, WatcherCmd::WatchThis(target)).unwrap();
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap(); rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
rt.send_to(w3_addr, WatcherCmd::WatchThis(target)).unwrap(); rt.send_to(w3_addr, WatcherCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
// w2 double-watches: registration is idempotent. // w2 double-watches: registration is idempotent.
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap(); rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
rt.send_to(target, PanicMsg).unwrap(); rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!(s1.count(), 1, "watcher 1 notified"); assert_eq!(s1.count(), 1, "watcher 1 notified");
assert_eq!(s2.count(), 1, "double-watch still only one notification"); assert_eq!(s2.count(), 1, "double-watch still only one notification");
@ -790,14 +790,14 @@ fn watch_notification_contract() {
#[test] #[test]
fn watch_edge_cases() { fn watch_edge_cases() {
// Self-watch — no crash // Self-watch — no crash
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let (w, _s) = new_exit_watcher(); let (w, _s) = new_exit_watcher();
let addr = rt.spawn(w).unwrap(); let addr = rt.spawn(w).unwrap();
rt.send_to(addr, WatcherCmd::WatchThis(addr)).unwrap(); rt.send_to(addr, WatcherCmd::WatchThis(addr)).unwrap();
tick_n(&rt, 5); tick_n(&mut host, 5);
// Watcher reacts to death by spawning replacement // Watcher reacts to death by spawning replacement
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let spawned = Arc::new(AtomicUsize::new(0)); let spawned = Arc::new(AtomicUsize::new(0));
struct SupervisorWatcher { struct SupervisorWatcher {
@ -830,9 +830,9 @@ fn watch_edge_cases() {
}) })
.unwrap(); .unwrap();
rt.send_to(sup, SupCmd::WatchThis(target)).unwrap(); rt.send_to(sup, SupCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
rt.send_to(target, PanicMsg).unwrap(); rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!( assert_eq!(
spawned.load(Ordering::SeqCst), spawned.load(Ordering::SeqCst),
1, 1,
@ -846,7 +846,7 @@ fn watch_edge_cases() {
#[test] #[test]
fn lifecycle_decision_paths_match_runtime_behavior() { fn lifecycle_decision_paths_match_runtime_behavior() {
for msg_count in 1..=5 { for msg_count in 1..=5 {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0)); let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
@ -858,22 +858,22 @@ fn lifecycle_decision_paths_match_runtime_behavior() {
handled: handled.clone(), handled: handled.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
for _ in 0..msg_count { for _ in 0..msg_count {
rt.send_to(addr, Work).unwrap(); rt.send_to(addr, Work).unwrap();
} }
tick_n(&rt, msg_count + 3); tick_n(&mut host, msg_count + 3);
assert_eq!(started.load(Ordering::SeqCst), 1); assert_eq!(started.load(Ordering::SeqCst), 1);
assert_eq!(handled.load(Ordering::SeqCst), msg_count); assert_eq!(handled.load(Ordering::SeqCst), msg_count);
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
assert_eq!(stopped.load(Ordering::SeqCst), 1); assert_eq!(stopped.load(Ordering::SeqCst), 1);
} }
for msg_count in 1..=5 { for msg_count in 1..=5 {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0)); let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
@ -885,19 +885,19 @@ fn lifecycle_decision_paths_match_runtime_behavior() {
handled: handled.clone(), handled: handled.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
for _ in 0..msg_count { for _ in 0..msg_count {
let _ = rt.send_to(addr, Work); let _ = rt.send_to(addr, Work);
} }
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!(handled.load(Ordering::SeqCst), 0); assert_eq!(handled.load(Ordering::SeqCst), 0);
assert_eq!(stopped.load(Ordering::SeqCst), 1); assert_eq!(stopped.load(Ordering::SeqCst), 1);
} }
for msg_count in 1..=5 { for msg_count in 1..=5 {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
@ -907,27 +907,27 @@ fn lifecycle_decision_paths_match_runtime_behavior() {
stopped: stopped.clone(), stopped: stopped.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
for _ in 0..msg_count { for _ in 0..msg_count {
let _ = rt.send_to(addr, Work); let _ = rt.send_to(addr, Work);
} }
tick_n(&rt, msg_count + 3); tick_n(&mut host, msg_count + 3);
assert_eq!(handled.load(Ordering::SeqCst), 0); assert_eq!(handled.load(Ordering::SeqCst), 0);
assert_eq!(stopped.load(Ordering::SeqCst), 0); assert_eq!(stopped.load(Ordering::SeqCst), 0);
} }
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let addr = rt let addr = rt
.spawn(PanicOnHandleWithStopReport { .spawn(PanicOnHandleWithStopReport {
stopped: stopped.clone(), stopped: stopped.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
rt.send_to(addr, Work).unwrap(); rt.send_to(addr, Work).unwrap();
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!(stopped.load(Ordering::SeqCst), 0); assert_eq!(stopped.load(Ordering::SeqCst), 0);
} }
@ -936,7 +936,7 @@ fn lifecycle_decision_paths_match_runtime_behavior() {
#[test] #[test]
fn panicking_actors_do_not_affect_sibling_progress() { fn panicking_actors_do_not_affect_sibling_progress() {
for (healthy_count, msg_count, panic_at) in [(2, 1, 1), (4, 65, 17), (8, 130, 1)] { for (healthy_count, msg_count, panic_at) in [(2, 1, 1), (4, 65, 17), (8, 130, 1)] {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let report_inbox = rt.new_inbox::<WorkCount>().unwrap(); let report_inbox = rt.new_inbox::<WorkCount>().unwrap();
let report_to = *report_inbox.addr(); let report_to = *report_inbox.addr();
@ -956,7 +956,7 @@ fn panicking_actors_do_not_affect_sibling_progress() {
panic_at, panic_at,
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
for _ in 0..msg_count { for _ in 0..msg_count {
for &addr in &healthy { for &addr in &healthy {
@ -964,12 +964,12 @@ fn panicking_actors_do_not_affect_sibling_progress() {
} }
rt.send_to(panicker, Work).unwrap(); rt.send_to(panicker, Work).unwrap();
} }
tick_n(&rt, (msg_count / 64) + 10); tick_n(&mut host, (msg_count / 64) + 10);
for &addr in &healthy { for &addr in &healthy {
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
} }
tick_n(&rt, 3); tick_n(&mut host, 3);
let reports = drain_work_counts(&report_inbox); let reports = drain_work_counts(&report_inbox);
assert_eq!(reports.len(), healthy_count); assert_eq!(reports.len(), healthy_count);
@ -983,7 +983,7 @@ fn panicking_actors_do_not_affect_sibling_progress() {
#[test] #[test]
fn on_start_panic_does_not_block_siblings() { fn on_start_panic_does_not_block_siblings() {
for (before_count, after_count, msgs_each) in [(1, 1, 1), (4, 4, 25), (8, 3, 70)] { for (before_count, after_count, msgs_each) in [(1, 1, 1), (4, 4, 25), (8, 3, 70)] {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0)); let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
@ -1019,7 +1019,7 @@ fn on_start_panic_does_not_block_siblings() {
.unwrap(), .unwrap(),
); );
} }
tick_n(&rt, 3); tick_n(&mut host, 3);
assert_eq!(started.load(Ordering::SeqCst), siblings.len()); assert_eq!(started.load(Ordering::SeqCst), siblings.len());
assert_eq!(panic_handled.load(Ordering::SeqCst), 0); assert_eq!(panic_handled.load(Ordering::SeqCst), 0);
@ -1030,13 +1030,13 @@ fn on_start_panic_does_not_block_siblings() {
rt.send_to(addr, Work).unwrap(); rt.send_to(addr, Work).unwrap();
} }
} }
tick_n(&rt, (msgs_each / 64) + 5); tick_n(&mut host, (msgs_each / 64) + 5);
assert_eq!(handled.load(Ordering::SeqCst), siblings.len() * msgs_each); assert_eq!(handled.load(Ordering::SeqCst), siblings.len() * msgs_each);
for &addr in &siblings { for &addr in &siblings {
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
} }
tick_n(&rt, 3); tick_n(&mut host, 3);
assert_eq!(stopped.load(Ordering::SeqCst), siblings.len()); assert_eq!(stopped.load(Ordering::SeqCst), siblings.len());
} }
} }
@ -1044,7 +1044,7 @@ fn on_start_panic_does_not_block_siblings() {
#[test] #[test]
fn multiple_panics_in_same_tick_preserve_healthy_actors() { fn multiple_panics_in_same_tick_preserve_healthy_actors() {
for (healthy_count, panic_count, msgs_each) in [(2, 2, 1), (6, 4, 70)] { for (healthy_count, panic_count, msgs_each) in [(2, 2, 1), (6, 4, 70)] {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let report_inbox = rt.new_inbox::<WorkCount>().unwrap(); let report_inbox = rt.new_inbox::<WorkCount>().unwrap();
let report_to = *report_inbox.addr(); let report_to = *report_inbox.addr();
@ -1069,7 +1069,7 @@ fn multiple_panics_in_same_tick_preserve_healthy_actors() {
.unwrap(), .unwrap(),
); );
} }
rt.tick(); host.try_tick();
for _ in 0..msgs_each { for _ in 0..msgs_each {
for &addr in &healthy { for &addr in &healthy {
@ -1079,12 +1079,12 @@ fn multiple_panics_in_same_tick_preserve_healthy_actors() {
rt.send_to(addr, Work).unwrap(); rt.send_to(addr, Work).unwrap();
} }
} }
tick_n(&rt, (msgs_each / 64) + 10); tick_n(&mut host, (msgs_each / 64) + 10);
for &addr in &healthy { for &addr in &healthy {
rt.stop_actor(addr).unwrap(); rt.stop_actor(addr).unwrap();
} }
tick_n(&rt, 3); tick_n(&mut host, 3);
let reports = drain_work_counts(&report_inbox); let reports = drain_work_counts(&report_inbox);
assert_eq!(reports.len(), healthy_count); assert_eq!(reports.len(), healthy_count);

View file

@ -10,7 +10,7 @@ pub use swactor::actor::{
EnvironmentBuilder, ExitReason, ExitValue, LogicalName, MonitorRef, ServiceBinding, EnvironmentBuilder, ExitReason, ExitValue, LogicalName, MonitorRef, ServiceBinding,
SpawnBuilder, SpawnTimestamp, StopReason, SpawnBuilder, SpawnTimestamp, StopReason,
}; };
pub use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; pub use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
pub use swactor::std::{CtxGroups, CtxWatching, RuntimeGroups, RuntimeNaming, StdExtension}; pub use swactor::std::{CtxGroups, CtxWatching, RuntimeGroups, RuntimeNaming, StdExtension};
// ── Messages ──────────────────────────────────────────────────────────────── // ── Messages ────────────────────────────────────────────────────────────────
@ -225,19 +225,31 @@ impl ActorInterface for InboxReplyActor {
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
/// Helper: construct a Runtime with StdExtension installed. /// Helper: build a Runtime handle + single-thread host with StdExtension installed.
pub fn std_runtime(config: RuntimeConfig) -> Runtime { /// Returns `(rt, host)`: use `rt` for spawn/send/inbox and `host` for ticking.
Runtime::new(config).with_extension(Arc::new(StdExtension::new())) pub fn std_host(config: RuntimeConfig) -> (Runtime, SingleThreadRuntime) {
let parts = RuntimeParts::new(config).with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let host = SingleThreadRuntime::new(parts);
(rt, host)
}
/// Helper: build a Runtime handle + single-thread host with no extension.
pub fn plain_host(config: RuntimeConfig) -> (Runtime, SingleThreadRuntime) {
let parts = RuntimeParts::new(config);
let rt = parts.runtime().clone();
let host = SingleThreadRuntime::new(parts);
(rt, host)
} }
/// Tick up to `max` times, returning as soon as `inbox` has a message. /// Tick up to `max` times, returning as soon as `inbox` has a message.
pub fn tick_until_recv<M: swactor::actor::Message>( pub fn tick_until_recv<M: swactor::actor::Message>(
rt: &Runtime, host: &mut SingleThreadRuntime,
inbox: &Inbox<M>, inbox: &Inbox<M>,
max: usize, max: usize,
) -> Option<M> { ) -> Option<M> {
for _ in 0..max { for _ in 0..max {
rt.tick(); host.try_tick();
if let Some(msg) = inbox.try_recv() { if let Some(msg) = inbox.try_recv() {
return Some(msg); return Some(msg);
} }
@ -246,20 +258,20 @@ pub fn tick_until_recv<M: swactor::actor::Message>(
} }
/// Tick exactly `n` times (no inbox polling). /// Tick exactly `n` times (no inbox polling).
pub fn tick_n(rt: &Runtime, n: usize) { pub fn tick_n(host: &mut SingleThreadRuntime, n: usize) {
for _ in 0..n { for _ in 0..n {
rt.tick(); host.try_tick();
} }
} }
/// Tick `n` times, then drain all messages from the inbox. /// Tick `n` times, then drain all messages from the inbox.
pub fn tick_and_drain<M: swactor::actor::Message>( pub fn tick_and_drain<M: swactor::actor::Message>(
rt: &Runtime, host: &mut SingleThreadRuntime,
inbox: &Inbox<M>, inbox: &Inbox<M>,
ticks: usize, ticks: usize,
) -> Vec<M> { ) -> Vec<M> {
for _ in 0..ticks { for _ in 0..ticks {
rt.tick(); host.try_tick();
} }
std::iter::from_fn(|| inbox.try_recv()).collect() std::iter::from_fn(|| inbox.try_recv()).collect()
} }

View file

@ -8,7 +8,7 @@ use swactor::actor::{
}; };
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::extension::{RuntimeExtension, WorkerExtension}; use swactor::extension::{RuntimeExtension, WorkerExtension};
use swactor::runtime::Runtime; use swactor::runtime::{RuntimeParts, SingleThreadRuntime};
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
struct SpawnMarker(&'static str); struct SpawnMarker(&'static str);
@ -143,9 +143,9 @@ impl WorkerExtension for SeamWorkerExtension {
} }
} }
fn tick_n(rt: &Runtime, n: usize) { fn tick_n(host: &mut SingleThreadRuntime, n: usize) {
for _ in 0..n { for _ in 0..n {
rt.tick(); host.try_tick();
} }
} }
@ -168,16 +168,19 @@ impl ActorInterface for MarkerReporter {
#[test] #[test]
fn on_spawn_environment_mutation_is_visible_to_actor() { fn on_spawn_environment_mutation_is_visible_to_actor() {
let state = Arc::new(SeamState::default()); let state = Arc::new(SeamState::default());
let rt = Runtime::new(RuntimeConfig::default()).with_extension(Arc::new( let parts = RuntimeParts::new(RuntimeConfig::default())
SeamExtension::new(Arc::clone(&state)).with_spawn_marker(), .with_extension(Arc::new(
)); SeamExtension::new(Arc::clone(&state)).with_spawn_marker(),
));
let rt = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let inbox = rt.new_inbox::<SpawnMarkerSeen>().unwrap(); let inbox = rt.new_inbox::<SpawnMarkerSeen>().unwrap();
rt.spawn(MarkerReporter { rt.spawn(MarkerReporter {
report_to: *inbox.addr(), report_to: *inbox.addr(),
}) })
.unwrap(); .unwrap();
tick_n(&rt, 2); tick_n(&mut host, 2);
assert_eq!( assert_eq!(
inbox.try_recv(), inbox.try_recv(),
@ -199,14 +202,16 @@ impl ActorInterface for PanicOnPing {
#[test] #[test]
fn on_actor_death_messages_are_routed() { fn on_actor_death_messages_are_routed() {
let state = Arc::new(SeamState::default()); let state = Arc::new(SeamState::default());
let rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(SeamExtension::new(Arc::clone(&state)))); .with_extension(Arc::new(SeamExtension::new(Arc::clone(&state))));
let rt = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let inbox = rt.new_inbox::<DeathSeen>().unwrap(); let inbox = rt.new_inbox::<DeathSeen>().unwrap();
*state.death_report_to.lock() = Some(*inbox.addr()); *state.death_report_to.lock() = Some(*inbox.addr());
let target = rt.spawn(PanicOnPing).unwrap(); let target = rt.spawn(PanicOnPing).unwrap();
rt.send_to(target, ()).unwrap(); rt.send_to(target, ()).unwrap();
tick_n(&rt, 4); tick_n(&mut host, 4);
assert_eq!(inbox.try_recv(), Some(DeathSeen(target))); assert_eq!(inbox.try_recv(), Some(DeathSeen(target)));
} }
@ -214,12 +219,14 @@ fn on_actor_death_messages_are_routed() {
#[test] #[test]
fn cleanup_dead_receives_dead_actor_batch() { fn cleanup_dead_receives_dead_actor_batch() {
let state = Arc::new(SeamState::default()); let state = Arc::new(SeamState::default());
let rt = Runtime::new(RuntimeConfig::default()) let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(SeamExtension::new(Arc::clone(&state)))); .with_extension(Arc::new(SeamExtension::new(Arc::clone(&state))));
let rt = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let target = rt.spawn(PanicOnPing).unwrap(); let target = rt.spawn(PanicOnPing).unwrap();
rt.send_to(target, ()).unwrap(); rt.send_to(target, ()).unwrap();
tick_n(&rt, 4); tick_n(&mut host, 4);
assert!(state.cleaned.lock().contains(&target)); assert!(state.cleaned.lock().contains(&target));
} }
@ -242,9 +249,11 @@ impl ActorInterface for WorkerRequestActor {
#[test] #[test]
fn worker_extension_request_is_handled_and_emits_message() { fn worker_extension_request_is_handled_and_emits_message() {
let state = Arc::new(SeamState::default()); let state = Arc::new(SeamState::default());
let rt = Runtime::new(RuntimeConfig::default()).with_extension(Arc::new( let parts = RuntimeParts::new(RuntimeConfig::default()).with_extension(Arc::new(
SeamExtension::new(Arc::clone(&state)).with_worker_extension(), SeamExtension::new(Arc::clone(&state)).with_worker_extension(),
)); ));
let rt = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let inbox = rt.new_inbox::<WorkerExtFired>().unwrap(); let inbox = rt.new_inbox::<WorkerExtFired>().unwrap();
let actor = rt let actor = rt
.spawn(WorkerRequestActor { .spawn(WorkerRequestActor {
@ -253,7 +262,7 @@ fn worker_extension_request_is_handled_and_emits_message() {
.unwrap(); .unwrap();
rt.send_to(actor, ()).unwrap(); rt.send_to(actor, ()).unwrap();
tick_n(&rt, 4); tick_n(&mut host, 4);
assert_eq!(inbox.try_recv(), Some(WorkerExtFired)); assert_eq!(inbox.try_recv(), Some(WorkerExtFired));
} }
@ -261,13 +270,15 @@ fn worker_extension_request_is_handled_and_emits_message() {
#[test] #[test]
fn worker_extension_pending_work_keeps_runtime_progressing() { fn worker_extension_pending_work_keeps_runtime_progressing() {
let state = Arc::new(SeamState::default()); let state = Arc::new(SeamState::default());
let rt = Runtime::new(RuntimeConfig::default()).with_extension(Arc::new( let parts = RuntimeParts::new(RuntimeConfig::default()).with_extension(Arc::new(
SeamExtension::new(Arc::clone(&state)).with_worker_extension(), SeamExtension::new(Arc::clone(&state)).with_worker_extension(),
)); ));
let rt = parts.runtime().clone();
let mut host = SingleThreadRuntime::new(parts);
let inbox = rt.new_inbox::<WorkerExtFired>().unwrap(); let inbox = rt.new_inbox::<WorkerExtFired>().unwrap();
state.worker_pending.lock().push(*inbox.addr()); state.worker_pending.lock().push(*inbox.addr());
rt.tick(); host.try_tick();
assert_eq!(inbox.try_recv(), Some(WorkerExtFired)); assert_eq!(inbox.try_recv(), Some(WorkerExtFired));
} }

View file

@ -108,7 +108,7 @@ impl ActorInterface for RingNode {
#[test] #[test]
fn message_routing_at_scale() { fn message_routing_at_scale() {
// 200-actor numbered routing // 200-actor numbered routing
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 300, max_actors: 300,
channel_buffer_size: 1024, channel_buffer_size: 1024,
..Default::default() ..Default::default()
@ -119,7 +119,7 @@ fn message_routing_at_scale() {
for _ in 0..200 { for _ in 0..200 {
addrs.push(rt.spawn(NumberedActor).unwrap()); addrs.push(rt.spawn(NumberedActor).unwrap());
} }
rt.tick(); host.try_tick();
for (i, addr) in addrs.iter().enumerate() { for (i, addr) in addrs.iter().enumerate() {
rt.send_to( rt.send_to(
*addr, *addr,
@ -130,7 +130,7 @@ fn message_routing_at_scale() {
) )
.unwrap(); .unwrap();
} }
tick_n(&rt, 3); tick_n(&mut host, 3);
let replies: Vec<NumberedReply> = std::iter::from_fn(|| inbox.try_recv()).collect(); let replies: Vec<NumberedReply> = std::iter::from_fn(|| inbox.try_recv()).collect();
assert_eq!(replies.len(), 200, "all 200 actors replied"); assert_eq!(replies.len(), 200, "all 200 actors replied");
for (i, addr) in addrs.iter().enumerate() { for (i, addr) in addrs.iter().enumerate() {
@ -144,7 +144,7 @@ fn message_routing_at_scale() {
} }
// 100-hop ring // 100-hop ring
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 200, max_actors: 200,
channel_buffer_size: 1024, channel_buffer_size: 1024,
..Default::default() ..Default::default()
@ -159,7 +159,7 @@ fn message_routing_at_scale() {
next = addr; next = addr;
} }
ring_addrs.reverse(); ring_addrs.reverse();
rt.tick(); host.try_tick();
rt.send_to( rt.send_to(
ring_addrs[0], ring_addrs[0],
RingHop { RingHop {
@ -168,7 +168,7 @@ fn message_routing_at_scale() {
}, },
) )
.unwrap(); .unwrap();
let result = tick_until_recv(&rt, &inbox, 110); let result = tick_until_recv(&mut host, &inbox, 110);
assert_eq!( assert_eq!(
result, result,
Some(RingDone(100)), Some(RingDone(100)),
@ -180,7 +180,7 @@ fn message_routing_at_scale() {
/// rapid spawn+immediate-send, multiple inbox types coexist. /// rapid spawn+immediate-send, multiple inbox types coexist.
#[test] #[test]
fn delivery_from_within_handlers() { fn delivery_from_within_handlers() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
// Delegation: spawn+send in handler // Delegation: spawn+send in handler
let delegator = rt.spawn(DelegatorActor).unwrap(); let delegator = rt.spawn(DelegatorActor).unwrap();
@ -193,7 +193,7 @@ fn delivery_from_within_handlers() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 20); let reply = tick_until_recv(&mut host, &inbox, 20);
assert_eq!( assert_eq!(
reply, reply,
Some(Done(10)), Some(Done(10)),
@ -210,7 +210,7 @@ fn delivery_from_within_handlers() {
}, },
) )
.unwrap(); .unwrap();
let reply = tick_until_recv(&rt, &inbox, 50); let reply = tick_until_recv(&mut host, &inbox, 50);
assert_eq!(reply, Some(Done(0)), "self-send chain completes"); assert_eq!(reply, Some(Done(0)), "self-send chain completes");
// Multiple senders reach same actor // Multiple senders reach same actor
@ -231,7 +231,7 @@ fn delivery_from_within_handlers() {
}, },
) )
.unwrap(); .unwrap();
tick_n(&rt, 10); tick_n(&mut host, 10);
assert!(inbox_a.try_recv().is_some()); assert!(inbox_a.try_recv().is_some());
assert_eq!( assert_eq!(
inbox_b.try_recv(), inbox_b.try_recv(),
@ -240,7 +240,7 @@ fn delivery_from_within_handlers() {
); );
// 50 rapid spawn+immediate-send pairs // 50 rapid spawn+immediate-send pairs
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let pong_inbox = rt.new_inbox::<Pong>().unwrap(); let pong_inbox = rt.new_inbox::<Pong>().unwrap();
for _ in 0..50 { for _ in 0..50 {
let addr = rt.spawn(PingPongActor).unwrap(); let addr = rt.spawn(PingPongActor).unwrap();
@ -252,11 +252,11 @@ fn delivery_from_within_handlers() {
) )
.unwrap(); .unwrap();
} }
let replies = tick_and_drain(&rt, &pong_inbox, 50); let replies = tick_and_drain(&mut host, &pong_inbox, 50);
assert_eq!(replies.len(), 50, "all spawn+send pairs complete"); assert_eq!(replies.len(), 50, "all spawn+send pairs complete");
// Multiple inbox types coexist // Multiple inbox types coexist
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap(); let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
let pinger_addr = rt.spawn(PingPongActor).unwrap(); let pinger_addr = rt.spawn(PingPongActor).unwrap();
let count_inbox = rt.new_inbox::<Count>().unwrap(); let count_inbox = rt.new_inbox::<Count>().unwrap();
@ -275,7 +275,7 @@ fn delivery_from_within_handlers() {
}, },
) )
.unwrap(); .unwrap();
tick_n(&rt, 10); tick_n(&mut host, 10);
assert_eq!(count_inbox.try_recv(), Some(Count(1))); assert_eq!(count_inbox.try_recv(), Some(Count(1)));
assert_eq!(pong_inbox.try_recv(), Some(Pong)); assert_eq!(pong_inbox.try_recv(), Some(Pong));
} }
@ -284,7 +284,7 @@ fn delivery_from_within_handlers() {
/// type_mismatch counter. /// type_mismatch counter.
#[test] #[test]
fn address_error_handling() { fn address_error_handling() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
// Nonexistent address // Nonexistent address
let bogus = ActorAddress::new_random(); let bogus = ActorAddress::new_random();
@ -298,7 +298,7 @@ fn address_error_handling() {
rt.send_to(addr, Count(42)).unwrap(); // Count instead of Ping rt.send_to(addr, Count(42)).unwrap(); // Count instead of Ping
rt.send_to(addr, Count(0)).unwrap(); rt.send_to(addr, Count(0)).unwrap();
rt.send_to(addr, Count(0)).unwrap(); rt.send_to(addr, Count(0)).unwrap();
tick_n(&rt, 10); tick_n(&mut host, 10);
let stats = rt.stats(); let stats = rt.stats();
let mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum(); let mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
assert_eq!(mismatches, 3, "3 wrong-type messages counted as mismatches"); assert_eq!(mismatches, 3, "3 wrong-type messages counted as mismatches");
@ -309,7 +309,7 @@ fn address_error_handling() {
#[test] #[test]
fn fairness_budget_prevents_starvation() { fn fairness_budget_prevents_starvation() {
// Hot (1000 msgs) vs cold (1 msg), budget=64 // Hot (1000 msgs) vs cold (1 msg), budget=64
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let hot_counter = Arc::new(AtomicUsize::new(0)); let hot_counter = Arc::new(AtomicUsize::new(0));
let cold_inbox = rt.new_inbox::<Pong>().unwrap(); let cold_inbox = rt.new_inbox::<Pong>().unwrap();
let hot = rt let hot = rt
@ -335,7 +335,7 @@ fn fairness_budget_prevents_starvation() {
}, },
) )
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
assert!( assert!(
cold_inbox.try_recv().is_some(), cold_inbox.try_recv().is_some(),
"cold actor not starved by hot actor" "cold actor not starved by hot actor"
@ -346,7 +346,7 @@ fn fairness_budget_prevents_starvation() {
); );
// Budget=4 with self-send chain of 20 → completes across multiple ticks // Budget=4 with self-send chain of 20 → completes across multiple ticks
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
actor_message_budget: 4, actor_message_budget: 4,
..Default::default() ..Default::default()
}); });
@ -360,7 +360,7 @@ fn fairness_budget_prevents_starvation() {
}, },
) )
.unwrap(); .unwrap();
tick_n(&rt, 30); tick_n(&mut host, 30);
assert_eq!( assert_eq!(
inbox.try_recv(), inbox.try_recv(),
Some(Done(0)), Some(Done(0)),
@ -368,7 +368,7 @@ fn fairness_budget_prevents_starvation() {
); );
// Unlimited budget (0) drains all // Unlimited budget (0) drains all
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
actor_message_budget: 0, actor_message_budget: 0,
..Default::default() ..Default::default()
}); });
@ -388,8 +388,8 @@ fn fairness_budget_prevents_starvation() {
) )
.unwrap(); .unwrap();
} }
rt.tick(); host.try_tick();
rt.tick(); host.try_tick();
assert_eq!( assert_eq!(
counter.load(Ordering::SeqCst), counter.load(Ordering::SeqCst),
500, 500,

776
tests/multicore.rs Normal file
View file

@ -0,0 +1,776 @@
//! Multicore runtime contract tests.
//!
//! These tests exercise the multi-worker ownership and routing model defined in
//! `docs/specs/drafts/MULTICORE_SPEC.md`. They observe behavior through public
//! APIs only — never inspecting source layout.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use parking_lot::Mutex;
use common::*;
use swactor::admin::OperationResult;
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::runtime::RuntimeConfig;
/// A minimal message delivered to probe actors.
#[derive(Clone, Debug)]
pub struct Probe;
/// A sequenced message used to verify delivery order.
#[derive(Clone, Debug)]
pub struct Seq(pub usize);
/// Report sent by a spawning parent: its own worker id and the child address.
#[derive(Clone)]
struct ParentReport {
parent_worker: usize,
child_addr: ActorAddress,
}
/// Records every `Probe` it handles into its own shared counter.
struct CountingProbe(Arc<AtomicUsize>);
impl ActorInterface for CountingProbe {
type Incoming = Probe;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Probe) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
struct StopCountingProbe {
stopped: Arc<AtomicUsize>,
}
impl ActorInterface for StopCountingProbe {
type Incoming = Probe;
type Response = ();
fn on_stop(&mut self, _ctx: &Ctx) {
self.stopped.fetch_add(1, Ordering::SeqCst);
}
fn handle(&mut self, _ctx: &Ctx, _msg: Probe) {}
}
struct SpawnTwoAndSendSecond {
second_count: Arc<AtomicUsize>,
}
impl ActorInterface for SpawnTwoAndSendSecond {
type Incoming = Probe;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Probe) {
let _first = ctx
.spawn(CountingProbe(Arc::new(AtomicUsize::new(0))))
.expect("spawn first child");
let second = ctx
.spawn(CountingProbe(self.second_count.clone()))
.expect("spawn second child");
ctx.send(second, Probe).expect("send second child");
}
}
/// Records every `Seq` value it handles, preserving arrival order.
struct Recorder {
out: Arc<Mutex<Vec<usize>>>,
}
impl ActorInterface for Recorder {
type Incoming = Seq;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, msg: Seq) {
self.out.lock().push(msg.0);
}
}
/// On a `Probe`, sends a burst of `Seq` values to a target address.
struct BurstSender {
target: ActorAddress,
values: Vec<usize>,
}
impl ActorInterface for BurstSender {
type Incoming = Probe;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Probe) {
for &v in &self.values {
let _ = ctx.send(self.target, Seq(v));
}
}
}
/// Records each `Seq` and, while below `limit`, sends itself the next value
/// (a same-worker self-send).
struct ChainSelf {
out: Arc<Mutex<Vec<usize>>>,
limit: usize,
}
impl ActorInterface for ChainSelf {
type Incoming = Seq;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Seq) {
self.out.lock().push(msg.0);
if msg.0 < self.limit {
let _ = ctx.send(ctx.self_addr(), Seq(msg.0 + 1));
}
}
}
/// On a `Probe`, spawns a `CountingProbe` child and reports its own worker id
/// plus the child address.
struct SpawningParent {
reply_to: ActorAddress,
}
impl ActorInterface for SpawningParent {
type Incoming = Probe;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Probe) {
let child = ctx
.spawn(CountingProbe(Arc::new(AtomicUsize::new(0))))
.expect("spawn child");
let _ = ctx.send(
self.reply_to,
ParentReport {
parent_worker: ctx.system_info().worker_id,
child_addr: child,
},
);
}
}
/// Look up the worker id for `addr` in a stats snapshot.
fn worker_of(stats: &swactor::stats::RuntimeStats, addr: ActorAddress) -> usize {
stats
.actors
.iter()
.find(|(a, _)| *a == addr)
.map(|(_, w)| *w)
.expect("address placed")
}
fn config_with(workers: usize) -> RuntimeConfig {
let mut c = RuntimeConfig::default();
c.worker_count = workers;
c
}
// ─── Phase 1: single-thread host advances every worker once ─────────────────
#[test]
fn single_thread_host_advances_every_worker_once() {
// Three workers; round-robin runtime spawns place one actor on each.
let (rt, mut host) = std_host(config_with(3));
let c0 = Arc::new(AtomicUsize::new(0));
let c1 = Arc::new(AtomicUsize::new(0));
let c2 = Arc::new(AtomicUsize::new(0));
let a = rt.spawn(CountingProbe(c0.clone())).expect("spawn a");
let b = rt.spawn(CountingProbe(c1.clone())).expect("spawn b");
let c = rt.spawn(CountingProbe(c2.clone())).expect("spawn c");
rt.send_to(a, Probe).expect("send a");
rt.send_to(b, Probe).expect("send b");
rt.send_to(c, Probe).expect("send c");
// A single pass must tick every worker — not stop after the first
// productive one. If any worker were skipped, its actor would not have
// processed its probe.
let did_work = host.try_tick();
assert!(did_work, "try_tick must report work when workers produced");
assert_eq!(c0.load(Ordering::SeqCst), 1, "worker 0 actor processed its probe");
assert_eq!(c1.load(Ordering::SeqCst), 1, "worker 1 actor processed its probe");
assert_eq!(c2.load(Ordering::SeqCst), 1, "worker 2 actor processed its probe");
}
#[test]
fn single_worker_runtime_remains_equivalent() {
let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().expect("inbox");
let addr = rt.spawn(PingPongActor).expect("spawn ping-pong");
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).expect("send ping");
let pong = tick_until_recv(&mut host, &inbox, 16);
assert_eq!(pong, Some(Pong), "single-worker delivery still works");
}
// ─── Phase 2: worker-aware routing and bounded passes ───────────────────────
#[test]
fn external_spawns_distribute_round_robin() {
let (rt, _host) = std_host(config_with(4));
let mut addrs = Vec::new();
for _ in 0..8 {
addrs.push(
rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0))))
.expect("spawn"),
);
}
// Placement is recorded in the address map at spawn time.
let stats = rt.stats();
let workers: Vec<usize> = addrs.iter().map(|a| worker_of(&stats, *a)).collect();
assert_eq!(
workers,
vec![0, 1, 2, 3, 0, 1, 2, 3],
"runtime-handle spawns are round-robin across workers"
);
}
#[test]
fn ctx_spawn_places_child_on_parents_worker() {
let (rt, mut host) = std_host(config_with(2));
let report = rt.new_inbox::<ParentReport>().expect("inbox");
// First runtime spawn → worker 0; the parent reports its own worker id.
let parent = rt.spawn(SpawningParent { reply_to: *report.addr() }).expect("spawn parent");
let stats = rt.stats();
assert_eq!(worker_of(&stats, parent), 0, "parent placed on worker 0");
rt.send_to(parent, Probe).expect("probe parent");
let msg = tick_until_recv(&mut host, &report, 16).expect("parent reported");
assert_eq!(msg.parent_worker, 0, "parent handler observes worker 0");
let stats = rt.stats();
assert_eq!(
worker_of(&stats, msg.child_addr),
0,
"ctx.spawn pins the child to the parent's worker"
);
}
#[test]
fn cross_worker_delivery_and_fifo_hold() {
// worker 0: Recorder. worker 1: BurstSender targeting the Recorder.
let (rt, mut host) = std_host(config_with(2));
let recorded = Arc::new(Mutex::new(Vec::new()));
let recorder = rt.spawn(Recorder { out: recorded.clone() }).expect("spawn recorder");
let sender = rt
.spawn(BurstSender { target: recorder, values: vec![1, 2, 3] })
.expect("spawn sender");
let stats = rt.stats();
assert_eq!(worker_of(&stats, recorder), 0, "recorder on worker 0");
assert_eq!(worker_of(&stats, sender), 1, "sender on worker 1");
rt.send_to(sender, Probe).expect("trigger sender");
// Drive enough passes for the cross-worker transfer + handler round trips.
tick_n(&mut host, 8);
let got = recorded.lock().clone();
assert_eq!(got, vec![1, 2, 3], "cross-worker delivery preserves per-(sender,target) FIFO");
}
#[test]
fn same_worker_sends_are_not_recursive_in_the_current_pass() {
let (rt, mut host) = std_host(config_with(1));
let out = Arc::new(Mutex::new(Vec::new()));
let addr = rt.spawn(ChainSelf { out: out.clone(), limit: 5 }).expect("spawn chain");
rt.send_to(addr, Seq(1)).expect("seed");
// One pass: the seed is handled and the self-send is staged for next pass.
host.try_tick();
assert_eq!(
out.lock().len(),
1,
"same-worker self-send must not be handled recursively this pass"
);
// Subsequent passes drain the self-chain one value per pass.
tick_n(&mut host, 8);
assert_eq!(*out.lock(), vec![1, 2, 3, 4, 5], "chain completes across passes");
}
#[test]
fn transfer_backlog_is_consumed_across_multiple_passes() {
// Ingress budget is the limiter; the actor message budget stays independent.
let mut config = config_with(1);
config.worker_ingress_budget = 4;
let (rt, mut host) = std_host(config);
let recorded = Arc::new(Mutex::new(Vec::new()));
let recorder = rt.spawn(Recorder { out: recorded.clone() }).expect("spawn recorder");
host.try_tick(); // install recorder
for v in 1..=10u32 {
rt.send_to(recorder, Seq(v as usize)).expect("send");
}
host.try_tick();
assert_eq!(
recorded.lock().len(),
4,
"a single transfer drain is bounded by worker_ingress_budget"
);
// The remaining backlog drains over further passes.
tick_n(&mut host, 8);
assert_eq!(
recorded.lock().len(),
10,
"the full backlog is eventually consumed across passes"
);
}
#[test]
fn messages_to_budget_delayed_runtime_spawn_are_retained() {
let mut config = config_with(1);
config.worker_ingress_budget = 1;
let (rt, mut host) = std_host(config);
let first_count = Arc::new(AtomicUsize::new(0));
let second_count = Arc::new(AtomicUsize::new(0));
let _first = rt
.spawn(CountingProbe(first_count))
.expect("spawn first actor");
let second = rt
.spawn(CountingProbe(second_count.clone()))
.expect("spawn second actor");
rt.send_to(second, Probe).expect("send to second actor");
tick_n(&mut host, 8);
assert_eq!(
second_count.load(Ordering::SeqCst),
1,
"message to mapped but budget-delayed spawn is delivered after install"
);
}
#[test]
fn stop_signal_to_budget_delayed_runtime_spawn_is_retained() {
let mut config = config_with(1);
config.worker_ingress_budget = 1;
let (rt, mut host) = std_host(config);
let stopped = Arc::new(AtomicUsize::new(0));
let _first = rt
.spawn(CountingProbe(Arc::new(AtomicUsize::new(0))))
.expect("spawn first actor");
let second = rt
.spawn(StopCountingProbe {
stopped: stopped.clone(),
})
.expect("spawn second actor");
rt.stop_actor(second).expect("request stop");
tick_n(&mut host, 8);
assert_eq!(
stopped.load(Ordering::SeqCst),
1,
"stop signal waits for the delayed spawn instead of being dropped"
);
assert!(
rt.send_to(second, Probe).is_err(),
"stopped actor is removed from routing"
);
}
#[test]
fn targeted_admin_to_budget_delayed_runtime_spawn_is_retained() {
let mut config = config_with(1);
config.worker_ingress_budget = 1;
let (rt, mut host) = std_host(config);
let stopped = Arc::new(AtomicUsize::new(0));
let _first = rt
.spawn(CountingProbe(Arc::new(AtomicUsize::new(0))))
.expect("spawn first actor");
let second = rt
.spawn(StopCountingProbe {
stopped: stopped.clone(),
})
.expect("spawn second actor");
let admin = rt.admin().stop_actor(second).expect("admin stop");
let result = admin.recv_ticking(&mut host, 8);
assert_eq!(result, Ok(OperationResult { applied: true }));
assert_eq!(
stopped.load(Ordering::SeqCst),
1,
"admin stop waits for the delayed spawn instead of returning ActorNotFound"
);
}
#[test]
fn local_messages_to_budget_delayed_handler_spawn_are_retained() {
let mut config = config_with(1);
config.worker_ingress_budget = 1;
let (rt, mut host) = std_host(config);
let second_count = Arc::new(AtomicUsize::new(0));
let parent = rt
.spawn(SpawnTwoAndSendSecond {
second_count: second_count.clone(),
})
.expect("spawn parent");
rt.send_to(parent, Probe).expect("trigger parent");
tick_n(&mut host, 8);
assert_eq!(
second_count.load(Ordering::SeqCst),
1,
"staged local message waits for handler-spawned child install"
);
}
#[test]
fn spawn_and_admin_drains_are_not_blocked_by_transfer_backlog() {
let mut config = config_with(1);
config.worker_ingress_budget = 4;
let (rt, mut host) = std_host(config);
let recorded = Arc::new(Mutex::new(Vec::new()));
let recorder = rt.spawn(Recorder { out: recorded.clone() }).expect("spawn recorder");
host.try_tick(); // install recorder
// Build a transfer backlog that exceeds the ingress budget.
for v in 1..=10u32 {
rt.send_to(recorder, Seq(v as usize)).expect("send");
}
// Queue a spawn and an admin command alongside the backlog.
let probe_addr = rt
.spawn(CountingProbe(Arc::new(AtomicUsize::new(0))))
.expect("spawn probe");
let admin_handle = rt.admin().list_actors().expect("list_actors");
// A single pass: the spawn drain installs the new actor, the admin drain
// answers list_actors, and the transfer drain consumes only its own budget.
host.try_tick();
let stats = rt.stats();
assert!(
stats.actors.iter().any(|(a, _)| *a == probe_addr),
"spawn drain is not blocked by the transfer backlog"
);
// The admin reply was produced during that same pass — no extra ticking.
let resp = admin_handle
.try_recv()
.expect("admin reply delivered in the first pass")
.expect("list_actors ok");
assert!(
resp.actors.iter().any(|a| a.address == probe_addr),
"admin drain observes the newly spawned actor"
);
assert_eq!(
recorded.lock().len(),
4,
"transfer drain still bounded while spawn/admin progress"
);
}
#[test]
fn process_local_inbox_routing_precedes_remote_transport() {
// A non-actor address resolves through the process-local inbox registry,
// which route_nonlocal consults before any remote transport seam.
let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Probe>().expect("inbox");
rt.send_to(*inbox.addr(), Probe).expect("send to inbox address");
// Inbox delivery is synchronous through the registry; one tick suffices to
// also prove no actor path captured it.
host.try_tick();
assert!(inbox.try_recv().is_some(), "non-actor address delivered to the local inbox");
}
// ─── Phase 3: multicore admin, stats, lifecycle, extensions ─────────────────
use std::any::Any;
use swactor::actor::ActorExited;
use swactor::extension::{RuntimeExtension, WorkerExtension};
use swactor::runtime::{RuntimeParts, SingleThreadRuntime};
/// Reports `system_info()` observed from inside a handler.
#[derive(Clone)]
struct SystemReport {
num_workers: usize,
total_actors: usize,
worker_id: usize,
}
struct SystemReporter {
reply_to: ActorAddress,
}
impl ActorInterface for SystemReporter {
type Incoming = Probe;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Probe) {
let si = ctx.system_info();
let _ = ctx.send(
self.reply_to,
SystemReport {
num_workers: si.num_workers,
total_actors: si.total_actors,
worker_id: si.worker_id,
},
);
}
}
/// Panics on every message — used to prove panic isolation across workers.
struct PanicOnProbe;
impl ActorInterface for PanicOnProbe {
type Incoming = Probe;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Probe) {
panic!("boom");
}
}
/// Watches a target on start and records an `ActorExited` notification.
struct CrossWorkerWatcher {
target: ActorAddress,
got: Arc<AtomicUsize>,
}
impl ActorInterface for CrossWorkerWatcher {
type Incoming = Probe;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.watch(self.target);
}
fn on_actor_exit(&mut self, _ctx: &Ctx, _exited: ActorExited) {
self.got.fetch_add(1, Ordering::SeqCst);
}
fn handle(&mut self, _ctx: &Ctx, _msg: Probe) {}
}
/// Marker fired once by each per-worker extension instance.
#[derive(Clone)]
struct WorkerExtFired(usize);
struct DistinctWorkerExt {
fired: bool,
id: usize,
report: ActorAddress,
}
impl WorkerExtension for DistinctWorkerExt {
fn has_pending_work(&self) -> bool {
!self.fired
}
fn on_tick(&mut self) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
if self.fired {
return Vec::new();
}
self.fired = true;
vec![(self.report, Box::new(WorkerExtFired(self.id)))]
}
fn handle_request(&mut self, _request: Box<dyn Any + Send>) {}
fn gc_dead(&mut self, _dead: &[ActorAddress]) {}
}
struct DistinctExt {
report: ActorAddress,
next: AtomicUsize,
}
impl RuntimeExtension for DistinctExt {
fn on_actor_death(
&self,
_dead: &[(ActorAddress, swactor::actor::StopReason, Option<swactor::actor::ExitValue>)],
) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
Vec::new()
}
fn cleanup_dead(&self, _dead: &[ActorAddress]) {}
fn on_spawn(
&self,
_child: ActorAddress,
_parent: Option<ActorAddress>,
env: swactor::actor::Environment,
_uptime_ms: u64,
) -> swactor::actor::Environment {
env
}
fn as_any(&self) -> &dyn Any {
self
}
fn create_worker_extension(&self) -> Option<Box<dyn WorkerExtension>> {
Some(Box::new(DistinctWorkerExt {
fired: false,
id: self.next.fetch_add(1, Ordering::SeqCst),
report: self.report,
}))
}
}
#[test]
fn targeted_admin_mutates_only_owning_worker() {
let (rt, mut host) = std_host(config_with(2));
let a = rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn a");
let b = rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn b");
// a → worker 0, b → worker 1 (round-robin).
// Suspend only a; b on the other worker must be untouched.
rt.admin()
.suspend_actor(a)
.expect("suspend")
.recv_ticking(&mut host, 8)
.expect("suspend ok");
let summary_a = rt
.admin()
.inspect_actor(a)
.expect("inspect")
.recv_ticking(&mut host, 8)
.expect("inspect a ok");
let summary_b = rt
.admin()
.inspect_actor(b)
.expect("inspect")
.recv_ticking(&mut host, 8)
.expect("inspect b ok");
assert!(summary_a.summary.status.suspended, "a suspended");
assert!(
!summary_b.summary.status.suspended,
"b on another worker is not affected by a's targeted admin"
);
}
#[test]
fn list_actors_spans_every_worker() {
let (rt, mut host) = std_host(config_with(3));
let mut addrs = Vec::new();
for _ in 0..3 {
addrs.push(rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn"));
}
host.try_tick(); // ensure actors are installed before listing
let resp = rt
.admin()
.list_actors()
.expect("list")
.recv_ticking(&mut host, 8)
.expect("list ok");
// Exactly one summary per actor, spanning all three workers exactly once.
assert_eq!(resp.actors.len(), 3, "list_actors completes once with every actor");
let mut workers: Vec<usize> = resp.actors.iter().map(|s| s.worker_id).collect();
workers.sort();
assert_eq!(workers, vec![0, 1, 2], "actors from every worker are represented");
}
#[test]
fn stats_report_real_worker_ids_and_runtime_width() {
let (rt, _host) = std_host(config_with(3));
let mut addrs = Vec::new();
for _ in 0..5 {
addrs.push(rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn"));
}
let stats = rt.stats();
assert_eq!(stats.num_workers, 3, "num_workers reflects worker_count");
assert_eq!(stats.workers.len(), 3, "one WorkerInfo per worker");
let workers: Vec<usize> = addrs.iter().map(|a| worker_of(&stats, *a)).collect();
assert_eq!(workers, vec![0, 1, 2, 0, 1], "placements use real worker ids");
}
#[test]
fn system_info_reflects_runtime_width() {
let (rt, mut host) = std_host(config_with(3));
let report = rt.new_inbox::<SystemReport>().expect("inbox");
// First spawn → worker 0.
let reporter = rt.spawn(SystemReporter { reply_to: *report.addr() }).expect("spawn reporter");
// Spawn two more so the runtime-wide actor count is observably > 1.
let _ = rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn extra");
let _ = rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn extra");
rt.send_to(reporter, Probe).expect("trigger");
let msg = tick_until_recv(&mut host, &report, 16).expect("system report");
assert_eq!(msg.worker_id, 0, "reporter observes its own worker");
assert_eq!(msg.num_workers, 3, "system_info reports runtime-wide worker count");
assert!(msg.total_actors >= 3, "total_actors is runtime-wide, not per-worker");
}
#[test]
fn per_worker_extensions_are_distinct() {
let parts = RuntimeParts::new(config_with(3));
let rt = parts.runtime().clone();
let inbox = rt.new_inbox::<WorkerExtFired>().expect("inbox");
let ext = Arc::new(DistinctExt {
report: *inbox.addr(),
next: AtomicUsize::new(0),
});
let parts = parts.with_extension(ext);
let mut host = SingleThreadRuntime::new(parts);
host.try_tick();
let mut ids = Vec::new();
while let Some(m) = inbox.try_recv() {
ids.push(m.0);
}
ids.sort();
ids.dedup();
assert_eq!(ids, vec![0, 1, 2], "three distinct per-worker extension instances fired");
}
#[test]
fn panic_on_one_worker_does_not_stop_another() {
let (rt, mut host) = std_host(config_with(2));
let healthy_counter = Arc::new(AtomicUsize::new(0));
// Round-robin: panicker → worker 0, healthy → worker 1.
let _panicker = rt.spawn(PanicOnProbe).expect("spawn panicker");
let healthy = rt.spawn(CountingProbe(healthy_counter.clone())).expect("spawn healthy");
rt.send_to(_panicker, Probe).expect("trigger panic");
rt.send_to(healthy, Probe).expect("trigger healthy");
tick_n(&mut host, 8);
assert_eq!(
healthy_counter.load(Ordering::SeqCst),
1,
"worker 1 keeps processing after worker 0's actor panicked"
);
// The panicked actor is gone from the runtime's address map.
let stats = rt.stats();
assert!(
!stats.actors.iter().any(|(a, _)| *a == _panicker),
"panicked actor is cleaned up"
);
}
#[test]
fn death_notification_crosses_workers() {
let (rt, mut host) = std_host(config_with(2));
let got = Arc::new(AtomicUsize::new(0));
// watched → worker 0; watcher → worker 1.
let watched = rt.spawn(CountingProbe(Arc::new(AtomicUsize::new(0)))).expect("spawn watched");
let watcher = rt
.spawn(CrossWorkerWatcher { target: watched, got: got.clone() })
.expect("spawn watcher");
let _ = watcher;
// Install both and run on_start (which registers the watch).
tick_n(&mut host, 4);
rt.stop_actor(watched).expect("stop watched");
tick_n(&mut host, 12);
assert_eq!(
got.load(Ordering::SeqCst),
1,
"watcher on worker 1 received the exit notification from worker 0"
);
}

View file

@ -3,6 +3,9 @@
//! Uses proptest for randomized testing and proptest-state-machine for //! Uses proptest for randomized testing and proptest-state-machine for
//! stateful property testing with automatic shrinking of failing sequences. //! stateful property testing with automatic shrinking of failing sequences.
mod common;
use common::plain_host;
use std::collections::HashMap; use std::collections::HashMap;
use proptest::prelude::*; use proptest::prelude::*;
@ -10,7 +13,7 @@ use proptest_state_machine::{ReferenceStateMachine, StateMachineTest, prop_state
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::config::RuntimeConfig; use swactor::config::RuntimeConfig;
use swactor::runtime::{Ctx, Inbox, Runtime}; use swactor::runtime::{Ctx, Inbox, Runtime, SingleThreadRuntime};
// ─── Shared Actor Types ──────────────────────────────────────────────────── // ─── Shared Actor Types ────────────────────────────────────────────────────
@ -76,7 +79,7 @@ proptest! {
actor_message_budget: budget, actor_message_budget: budget,
..Default::default() ..Default::default()
}; };
let rt = Runtime::new(config); let (rt, mut host) = plain_host(config);
let inbox = rt.new_inbox::<Ping>().unwrap(); let inbox = rt.new_inbox::<Ping>().unwrap();
let mut addrs = Vec::new(); let mut addrs = Vec::new();
@ -84,7 +87,7 @@ proptest! {
addrs.push(rt.spawn(CounterActor { count: 0, reply_to: *inbox.addr() }).unwrap()); addrs.push(rt.spawn(CounterActor { count: 0, reply_to: *inbox.addr() }).unwrap());
} }
rt.tick(); // on_start host.try_tick(); // on_start
// Send msgs_per messages to each actor // Send msgs_per messages to each actor
for addr in &addrs { for addr in &addrs {
@ -94,7 +97,7 @@ proptest! {
} }
// Single tick — each actor should process at most `budget` messages // Single tick — each actor should process at most `budget` messages
rt.tick(); host.try_tick();
// Drain inbox to count replies per actor // Drain inbox to count replies per actor
// CounterActor replies with incrementing count, so max reply value = messages processed // CounterActor replies with incrementing count, so max reply value = messages processed
@ -114,12 +117,12 @@ proptest! {
/// Spawn N actors and verify all get unique addresses and appear in stats. /// Spawn N actors and verify all get unique addresses and appear in stats.
#[test] #[test]
fn spawn_n_actors_all_tracked(n in 1usize..50) { fn spawn_n_actors_all_tracked(n in 1usize..50) {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, mut host) = plain_host(RuntimeConfig::default());
let mut addrs = Vec::new(); let mut addrs = Vec::new();
for _ in 0..n { for _ in 0..n {
addrs.push(rt.spawn(NoopActor).unwrap()); addrs.push(rt.spawn(NoopActor).unwrap());
} }
rt.tick(); // process spawns host.try_tick(); // process spawns
let stats = rt.stats(); let stats = rt.stats();
prop_assert_eq!(stats.actors.len(), n, "Expected {} actors in stats", n); prop_assert_eq!(stats.actors.len(), n, "Expected {} actors in stats", n);
@ -286,6 +289,8 @@ impl ReferenceStateMachine for SwactorModel {
struct SutState { struct SutState {
runtime: Runtime, runtime: Runtime,
/// Single-thread host that owns the workers; ticking lives here.
host: SingleThreadRuntime,
inbox: Inbox<Ping>, inbox: Inbox<Ping>,
/// Maps reference actor_id to actual ActorAddress /// Maps reference actor_id to actual ActorAddress
actor_map: HashMap<usize, ActorAddress>, actor_map: HashMap<usize, ActorAddress>,
@ -304,10 +309,11 @@ impl StateMachineTest for SwactorTest {
type Reference = SwactorModel; type Reference = SwactorModel;
fn init_test(_ref_state: &RefState) -> Self::SystemUnderTest { fn init_test(_ref_state: &RefState) -> Self::SystemUnderTest {
let rt = Runtime::new(RuntimeConfig::default()); let (rt, host) = plain_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap(); let inbox = rt.new_inbox::<Ping>().unwrap();
SutState { SutState {
runtime: rt, runtime: rt,
host,
inbox, inbox,
actor_map: HashMap::new(), actor_map: HashMap::new(),
panic_ids: Vec::new(), panic_ids: Vec::new(),
@ -357,11 +363,11 @@ impl StateMachineTest for SwactorTest {
} }
} }
Transition::Tick => { Transition::Tick => {
sut.runtime.tick(); sut.host.try_tick();
} }
Transition::TickN(n) => { Transition::TickN(n) => {
for _ in 0..n { for _ in 0..n {
sut.runtime.tick(); sut.host.try_tick();
} }
} }
Transition::StopActor(idx) => { Transition::StopActor(idx) => {

View file

@ -81,7 +81,7 @@ fn operation_applied() -> OperationResult {
#[test] #[test]
fn ask_recv_ticking_delivers_reply_through_runtime_inbox() { fn ask_recv_ticking_delivers_reply_through_runtime_inbox() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let actor = rt.spawn(SelfAddrActor).unwrap(); let actor = rt.spawn(SelfAddrActor).unwrap();
let ask = rt let ask = rt
@ -94,7 +94,7 @@ fn ask_recv_ticking_delivers_reply_through_runtime_inbox() {
"ask reply is not available before ticking" "ask reply is not available before ticking"
); );
assert_eq!( assert_eq!(
ask.recv_ticking(&rt, 5).unwrap(), ask.recv_ticking(&mut host, 5).unwrap(),
MyAddr(actor), MyAddr(actor),
"recv_ticking drives the runtime inbox reply path" "recv_ticking drives the runtime inbox reply path"
); );
@ -102,10 +102,10 @@ fn ask_recv_ticking_delivers_reply_through_runtime_inbox() {
#[test] #[test]
fn admin_list_and_inspect_report_actor_slot_metadata() { fn admin_list_and_inspect_report_actor_slot_metadata() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let ping_pong = rt.spawn(PingPongActor).unwrap(); let ping_pong = rt.spawn(PingPongActor).unwrap();
let counter = rt.spawn(CounterActor { count: 0 }).unwrap(); let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
rt.tick(); host.try_tick();
let count_inbox = rt.new_inbox::<Count>().unwrap(); let count_inbox = rt.new_inbox::<Count>().unwrap();
rt.send_to( rt.send_to(
@ -123,14 +123,13 @@ fn admin_list_and_inspect_report_actor_slot_metadata() {
) )
.unwrap(); .unwrap();
assert_eq!(tick_until_recv(&rt, &count_inbox, 5), Some(Count(1))); assert_eq!(tick_until_recv(&mut host, &count_inbox, 5), Some(Count(1)));
assert_eq!(tick_until_recv(&rt, &count_inbox, 5), Some(Count(2))); assert_eq!(tick_until_recv(&mut host, &count_inbox, 5), Some(Count(2)));
let response = rt let response = rt
.admin() .admin()
.list_actors() .list_actors()
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -172,8 +171,7 @@ fn admin_list_and_inspect_report_actor_slot_metadata() {
let inspect = rt let inspect = rt
.admin() .admin()
.inspect_actor(counter) .inspect_actor(counter)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!(inspect.summary, *counter_summary); assert_eq!(inspect.summary, *counter_summary);
@ -181,8 +179,7 @@ fn admin_list_and_inspect_report_actor_slot_metadata() {
let missing_result = rt let missing_result = rt
.admin() .admin()
.inspect_actor(missing) .inspect_actor(missing)
.unwrap() .unwrap().recv_ticking(&mut host, 5);
.recv_ticking(&rt, 5);
assert!( assert!(
matches!(missing_result, Err(AdminError::ActorNotFound { actor }) if actor == missing), matches!(missing_result, Err(AdminError::ActorNotFound { actor }) if actor == missing),
"missing actor is reported through AdminResult" "missing actor is reported through AdminResult"
@ -191,7 +188,7 @@ fn admin_list_and_inspect_report_actor_slot_metadata() {
#[test] #[test]
fn admin_get_and_replace_actor_state_preserves_slot_metadata() { fn admin_get_and_replace_actor_state_preserves_slot_metadata() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0)); let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let addr = rt let addr = rt
@ -201,7 +198,7 @@ fn admin_get_and_replace_actor_state_preserves_slot_metadata() {
stopped: stopped.clone(), stopped: stopped.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
assert_eq!(started.load(Ordering::SeqCst), 1); assert_eq!(started.load(Ordering::SeqCst), 1);
assert_eq!(stopped.load(Ordering::SeqCst), 0); assert_eq!(stopped.load(Ordering::SeqCst), 0);
@ -215,13 +212,12 @@ fn admin_get_and_replace_actor_state_preserves_slot_metadata() {
}, },
) )
.unwrap(); .unwrap();
assert_eq!(tick_until_recv(&rt, &count_inbox, 5), Some(Count(2))); assert_eq!(tick_until_recv(&mut host, &count_inbox, 5), Some(Count(2)));
let state = rt let state = rt
.admin() .admin()
.get_actor_state::<ReplaceProbe>(addr) .get_actor_state::<ReplaceProbe>(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap() .unwrap()
.state; .state;
assert_eq!(state.actor, addr); assert_eq!(state.actor, addr);
@ -240,8 +236,7 @@ fn admin_get_and_replace_actor_state_preserves_slot_metadata() {
let replace_result = rt let replace_result = rt
.admin() .admin()
.replace_actor_state::<ReplaceProbe>(addr, replacement) .replace_actor_state::<ReplaceProbe>(addr, replacement)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!(replace_result, operation_applied()); assert_eq!(replace_result, operation_applied());
assert_eq!( assert_eq!(
@ -263,13 +258,12 @@ fn admin_get_and_replace_actor_state_preserves_slot_metadata() {
}, },
) )
.unwrap(); .unwrap();
assert_eq!(tick_until_recv(&rt, &count_inbox, 5), Some(Count(101))); assert_eq!(tick_until_recv(&mut host, &count_inbox, 5), Some(Count(101)));
let summary = rt let summary = rt
.admin() .admin()
.inspect_actor(addr) .inspect_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap() .unwrap()
.summary; .summary;
assert_eq!(summary.address, addr); assert_eq!(summary.address, addr);
@ -282,17 +276,16 @@ fn admin_get_and_replace_actor_state_preserves_slot_metadata() {
let stop_result = rt let stop_result = rt
.admin() .admin()
.stop_actor(addr) .stop_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!(stop_result, operation_applied()); assert_eq!(stop_result, operation_applied());
rt.tick(); host.try_tick();
assert_eq!(stopped.load(Ordering::SeqCst), 1); assert_eq!(stopped.load(Ordering::SeqCst), 1);
} }
#[test] #[test]
fn admin_replace_rejects_wrong_actor_type_and_wrong_snapshot_address() { fn admin_replace_rejects_wrong_actor_type_and_wrong_snapshot_address() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0)); let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
let addr = rt let addr = rt
@ -302,14 +295,13 @@ fn admin_replace_rejects_wrong_actor_type_and_wrong_snapshot_address() {
stopped: stopped.clone(), stopped: stopped.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
let wrong_type_snapshot = ActorStateSnapshot::new(addr, WrongProbe); let wrong_type_snapshot = ActorStateSnapshot::new(addr, WrongProbe);
let wrong_type = rt let wrong_type = rt
.admin() .admin()
.replace_actor_state::<WrongProbe>(addr, wrong_type_snapshot) .replace_actor_state::<WrongProbe>(addr, wrong_type_snapshot)
.unwrap() .unwrap().recv_ticking(&mut host, 5);
.recv_ticking(&rt, 5);
assert!( assert!(
matches!(wrong_type, Err(AdminError::TypeMismatch { .. })), matches!(wrong_type, Err(AdminError::TypeMismatch { .. })),
"wrong concrete actor type is rejected" "wrong concrete actor type is rejected"
@ -327,8 +319,7 @@ fn admin_replace_rejects_wrong_actor_type_and_wrong_snapshot_address() {
let wrong_address = rt let wrong_address = rt
.admin() .admin()
.replace_actor_state::<ReplaceProbe>(addr, wrong_addr_snapshot) .replace_actor_state::<ReplaceProbe>(addr, wrong_addr_snapshot)
.unwrap() .unwrap().recv_ticking(&mut host, 5);
.recv_ticking(&rt, 5);
assert!( assert!(
matches!(wrong_address, Err(AdminError::AddressMismatch { requested, snapshot }) if requested == addr && snapshot == wrong_addr), matches!(wrong_address, Err(AdminError::AddressMismatch { requested, snapshot }) if requested == addr && snapshot == wrong_addr),
"snapshot address must match the target address" "snapshot address must match the target address"
@ -344,7 +335,7 @@ fn admin_replace_rejects_wrong_actor_type_and_wrong_snapshot_address() {
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
tick_until_recv(&rt, &count_inbox, 5), tick_until_recv(&mut host, &count_inbox, 5),
Some(Count(11)), Some(Count(11)),
"failed replacements do not mutate the original actor state" "failed replacements do not mutate the original actor state"
); );
@ -352,20 +343,19 @@ fn admin_replace_rejects_wrong_actor_type_and_wrong_snapshot_address() {
#[test] #[test]
fn admin_suspend_queues_messages_until_resume() { fn admin_suspend_queues_messages_until_resume() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0)); let counter = Arc::new(AtomicUsize::new(0));
let addr = rt let addr = rt
.spawn(CountingPingActor { .spawn(CountingPingActor {
counter: counter.clone(), counter: counter.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
let suspend_result = rt let suspend_result = rt
.admin() .admin()
.suspend_actor(addr) .suspend_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!(suspend_result, operation_applied()); assert_eq!(suspend_result, operation_applied());
@ -379,15 +369,14 @@ fn admin_suspend_queues_messages_until_resume() {
) )
.unwrap(); .unwrap();
} }
tick_n(&rt, 5); tick_n(&mut host, 5);
assert_eq!(counter.load(Ordering::SeqCst), 0); assert_eq!(counter.load(Ordering::SeqCst), 0);
assert_eq!(pong_inbox.try_recv(), None); assert_eq!(pong_inbox.try_recv(), None);
let suspended = rt let suspended = rt
.admin() .admin()
.inspect_actor(addr) .inspect_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap() .unwrap()
.summary; .summary;
assert!(suspended.status.suspended); assert!(suspended.status.suspended);
@ -396,20 +385,18 @@ fn admin_suspend_queues_messages_until_resume() {
let resume_result = rt let resume_result = rt
.admin() .admin()
.resume_actor(addr) .resume_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!(resume_result, operation_applied()); assert_eq!(resume_result, operation_applied());
for _ in 0..3 { for _ in 0..3 {
assert_eq!(tick_until_recv(&rt, &pong_inbox, 5), Some(Pong)); assert_eq!(tick_until_recv(&mut host, &pong_inbox, 5), Some(Pong));
} }
assert_eq!(counter.load(Ordering::SeqCst), 3); assert_eq!(counter.load(Ordering::SeqCst), 3);
let resumed = rt let resumed = rt
.admin() .admin()
.inspect_actor(addr) .inspect_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap() .unwrap()
.summary; .summary;
assert!(!resumed.status.suspended); assert!(!resumed.status.suspended);
@ -419,7 +406,7 @@ fn admin_suspend_queues_messages_until_resume() {
#[test] #[test]
fn admin_stop_clears_pending_mailbox_without_calling_handle() { fn admin_stop_clears_pending_mailbox_without_calling_handle() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0)); let started = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0)); let handled = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0)); let stopped = Arc::new(AtomicUsize::new(0));
@ -430,7 +417,7 @@ fn admin_stop_clears_pending_mailbox_without_calling_handle() {
stopped: stopped.clone(), stopped: stopped.clone(),
}) })
.unwrap(); .unwrap();
rt.tick(); host.try_tick();
assert_eq!(started.load(Ordering::SeqCst), 1); assert_eq!(started.load(Ordering::SeqCst), 1);
let pong_inbox = rt.new_inbox::<Pong>().unwrap(); let pong_inbox = rt.new_inbox::<Pong>().unwrap();
@ -447,11 +434,10 @@ fn admin_stop_clears_pending_mailbox_without_calling_handle() {
let stop_result = rt let stop_result = rt
.admin() .admin()
.stop_actor(addr) .stop_actor(addr)
.unwrap() .unwrap().recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.unwrap(); .unwrap();
assert_eq!(stop_result, operation_applied()); assert_eq!(stop_result, operation_applied());
rt.tick(); host.try_tick();
assert_eq!(handled.load(Ordering::SeqCst), 0); assert_eq!(handled.load(Ordering::SeqCst), 0);
assert_eq!(stopped.load(Ordering::SeqCst), 1); assert_eq!(stopped.load(Ordering::SeqCst), 1);
@ -467,7 +453,7 @@ fn admin_stop_clears_pending_mailbox_without_calling_handle() {
"admin-stopped actor is removed from normal send routing" "admin-stopped actor is removed from normal send routing"
); );
let inspect = rt.admin().inspect_actor(addr).unwrap().recv_ticking(&rt, 5); let inspect = rt.admin().inspect_actor(addr).unwrap().recv_ticking(&mut host, 5);
assert!( assert!(
matches!(inspect, Err(AdminError::ActorNotFound { actor }) if actor == addr), matches!(inspect, Err(AdminError::ActorNotFound { actor }) if actor == addr),
"admin-stopped actor is no longer inspectable" "admin-stopped actor is no longer inspectable"
@ -476,7 +462,7 @@ fn admin_stop_clears_pending_mailbox_without_calling_handle() {
#[test] #[test]
fn admin_suspend_resume() { fn admin_suspend_resume() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0)); let counter = Arc::new(AtomicUsize::new(0));
let addr = rt let addr = rt
.spawn(CountingPingActor { .spawn(CountingPingActor {
@ -484,11 +470,11 @@ fn admin_suspend_resume() {
}) })
.unwrap(); .unwrap();
let pong_inbox = rt.new_inbox::<Pong>().unwrap(); let pong_inbox = rt.new_inbox::<Pong>().unwrap();
rt.tick(); // process on_start host.try_tick(); // process on_start
// Suspend // Suspend
let suspended = rt.admin().suspend_actor(addr).unwrap(); let suspended = rt.admin().suspend_actor(addr).unwrap();
let suspended = suspended.recv_ticking(&rt, 5); let suspended = suspended.recv_ticking(&mut host, 5);
assert_eq!(suspended, Ok(operation_applied())); assert_eq!(suspended, Ok(operation_applied()));
// Send while suspended — should not process // Send while suspended — should not process
@ -499,7 +485,7 @@ fn admin_suspend_resume() {
}, },
) )
.unwrap(); .unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
assert!( assert!(
pong_inbox.try_recv().is_none(), pong_inbox.try_recv().is_none(),
"no pong while suspended" "no pong while suspended"
@ -508,11 +494,11 @@ fn admin_suspend_resume() {
// Resume // Resume
let resumed = rt.admin().resume_actor(addr).unwrap(); let resumed = rt.admin().resume_actor(addr).unwrap();
let resumed = resumed.recv_ticking(&rt, 5); let resumed = resumed.recv_ticking(&mut host, 5);
assert_eq!(resumed, Ok(operation_applied())); assert_eq!(resumed, Ok(operation_applied()));
// Tick — message should now be processed // Tick — message should now be processed
tick_n(&rt, 3); tick_n(&mut host, 3);
assert_eq!( assert_eq!(
pong_inbox.try_recv(), pong_inbox.try_recv(),
Some(Pong), Some(Pong),
@ -523,7 +509,7 @@ fn admin_suspend_resume() {
#[test] #[test]
fn admin_list_actors() { fn admin_list_actors() {
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 100, max_actors: 100,
..Default::default() ..Default::default()
}); });
@ -531,11 +517,10 @@ fn admin_list_actors() {
for _ in 0..16 { for _ in 0..16 {
addrs.push(rt.spawn(CounterActor { count: 0 }).unwrap()); addrs.push(rt.spawn(CounterActor { count: 0 }).unwrap());
} }
rt.tick(); // process spawns host.try_tick(); // process spawns
let admin = rt.admin().list_actors().unwrap(); let admin = rt.admin().list_actors().unwrap();
let list = admin let list = admin.recv_ticking(&mut host, 5)
.recv_ticking(&rt, 5)
.expect("list_actors timed out"); .expect("list_actors timed out");
let expected: HashSet<_> = addrs.iter().copied().collect(); let expected: HashSet<_> = addrs.iter().copied().collect();

View file

@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
/// then processes messages correctly. /// then processes messages correctly.
#[test] #[test]
fn runtime_basics() { fn runtime_basics() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let addr = rt.spawn(PingPongActor).unwrap(); let addr = rt.spawn(PingPongActor).unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to( rt.send_to(
@ -29,7 +29,7 @@ fn runtime_basics() {
.unwrap(); .unwrap();
assert!(inbox.try_recv().is_none(), "no processing before tick"); assert!(inbox.try_recv().is_none(), "no processing before tick");
tick_n(&rt, 2); tick_n(&mut host, 2);
assert!( assert!(
inbox.try_recv().is_some(), inbox.try_recv().is_some(),
"tick() drives processing" "tick() drives processing"
@ -47,7 +47,7 @@ fn runtime_basics() {
) )
.unwrap(); .unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
assert_eq!( assert_eq!(
done_inbox.try_recv(), done_inbox.try_recv(),
Some(Done(6)), Some(Done(6)),
@ -70,7 +70,7 @@ fn high_volume_delivery() {
// ── Part A: 50 senders × 100 messages → one receiver ── // ── Part A: 50 senders × 100 messages → one receiver ──
{ {
let rt = std_runtime(cfg()); let (rt, mut host) = std_host(cfg());
let counter = Arc::new(AtomicUsize::new(0)); let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap(); let dummy = rt.new_inbox::<Pong>().unwrap();
let receiver = rt let receiver = rt
@ -92,7 +92,7 @@ fn high_volume_delivery() {
} }
} }
tick_n(&rt, 200); tick_n(&mut host, 200);
let processed = counter.load(Ordering::SeqCst); let processed = counter.load(Ordering::SeqCst);
assert_eq!( assert_eq!(
processed, total_expected, processed, total_expected,
@ -102,7 +102,7 @@ fn high_volume_delivery() {
// ── Part B: 200 concurrent spawn+send pairs ── // ── Part B: 200 concurrent spawn+send pairs ──
{ {
let rt = std_runtime(cfg()); let (rt, mut host) = std_host(cfg());
let counter = Arc::new(AtomicUsize::new(0)); let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap(); let dummy = rt.new_inbox::<Pong>().unwrap();
for _ in 0..200 { for _ in 0..200 {
@ -120,14 +120,14 @@ fn high_volume_delivery() {
.unwrap(); .unwrap();
} }
tick_n(&rt, 50); tick_n(&mut host, 50);
let received = counter.load(Ordering::SeqCst); let received = counter.load(Ordering::SeqCst);
assert_eq!(received, 200, "all 200 spawn+send pairs complete"); assert_eq!(received, 200, "all 200 spawn+send pairs complete");
} }
// ── Part C: 50-level chain ── // ── Part C: 50-level chain ──
{ {
let rt = std_runtime(cfg()); let (rt, mut host) = std_host(cfg());
let addr = rt.spawn(ChainActor).unwrap(); let addr = rt.spawn(ChainActor).unwrap();
let inbox = rt.new_inbox::<Done>().unwrap(); let inbox = rt.new_inbox::<Done>().unwrap();
rt.send_to( rt.send_to(
@ -140,7 +140,7 @@ fn high_volume_delivery() {
) )
.unwrap(); .unwrap();
tick_n(&rt, 100); tick_n(&mut host, 100);
assert_eq!( assert_eq!(
inbox.try_recv(), inbox.try_recv(),
Some(Done(50)), Some(Done(50)),
@ -155,7 +155,7 @@ fn high_volume_delivery() {
/// and all 1000 healthy messages are still processed. /// and all 1000 healthy messages are still processed.
#[test] #[test]
fn panic_isolation_under_load() { fn panic_isolation_under_load() {
let rt = std_runtime(RuntimeConfig { let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 5_000, max_actors: 5_000,
channel_buffer_size: 10_000, channel_buffer_size: 10_000,
..Default::default() ..Default::default()
@ -192,7 +192,7 @@ fn panic_isolation_under_load() {
} }
} }
tick_n(&rt, 200); tick_n(&mut host, 200);
let expected = 10 * 100; let expected = 10 * 100;
let processed = counter.load(Ordering::SeqCst); let processed = counter.load(Ordering::SeqCst);
@ -209,7 +209,7 @@ fn panic_isolation_under_load() {
/// messages are accounted for. /// messages are accounted for.
#[test] #[test]
fn sustained_throughput_no_message_loss() { fn sustained_throughput_no_message_loss() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0)); let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap(); let dummy = rt.new_inbox::<Pong>().unwrap();
let addr = rt let addr = rt
@ -228,7 +228,7 @@ fn sustained_throughput_no_message_loss() {
) )
.unwrap(); .unwrap();
} }
tick_n(&rt, 5); tick_n(&mut host, 5);
let processed = counter.load(Ordering::SeqCst); let processed = counter.load(Ordering::SeqCst);
assert!( assert!(
processed > batch * 50, processed > batch * 50,
@ -237,7 +237,7 @@ fn sustained_throughput_no_message_loss() {
} }
// Drain remaining. // Drain remaining.
tick_n(&rt, 100); tick_n(&mut host, 100);
let total = counter.load(Ordering::SeqCst); let total = counter.load(Ordering::SeqCst);
assert_eq!(total, 1000, "sustained load should not drop any messages"); assert_eq!(total, 1000, "sustained load should not drop any messages");
} }

View file

@ -46,7 +46,7 @@ impl ActorInterface for JoinOnStart {
#[test] #[test]
fn std_extension_installs() { fn std_extension_installs() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let actor = rt.spawn(PingPongActor).unwrap(); let actor = rt.spawn(PingPongActor).unwrap();
@ -57,14 +57,14 @@ fn std_extension_installs() {
}, },
) )
.unwrap(); .unwrap();
tick_n(&rt, 2); tick_n(&mut host, 2);
assert_eq!(inbox.try_recv(), Some(Pong)); assert_eq!(inbox.try_recv(), Some(Pong));
} }
#[test] #[test]
fn runtime_naming_lifecycle() { fn runtime_naming_lifecycle() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let actor = rt.spawn(PingPongActor).unwrap(); let actor = rt.spawn(PingPongActor).unwrap();
@ -79,7 +79,7 @@ fn runtime_naming_lifecycle() {
}, },
) )
.unwrap(); .unwrap();
tick_n(&rt, 2); tick_n(&mut host, 2);
assert_eq!(inbox.try_recv(), Some(Pong)); assert_eq!(inbox.try_recv(), Some(Pong));
assert!( assert!(
@ -96,13 +96,13 @@ fn runtime_naming_lifecycle() {
rt.register_name("worker", actor).unwrap(); rt.register_name("worker", actor).unwrap();
rt.stop_actor(actor).unwrap(); rt.stop_actor(actor).unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
assert_eq!(rt.where_is("worker"), None, "dead actors are unregistered"); assert_eq!(rt.where_is("worker"), None, "dead actors are unregistered");
} }
#[test] #[test]
fn runtime_groups_lifecycle() { fn runtime_groups_lifecycle() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
let a = rt.spawn(PingPongActor).unwrap(); let a = rt.spawn(PingPongActor).unwrap();
let b = rt.spawn(PingPongActor).unwrap(); let b = rt.spawn(PingPongActor).unwrap();
@ -126,21 +126,21 @@ fn runtime_groups_lifecycle() {
), ),
2 2
); );
tick_n(&rt, 2); tick_n(&mut host, 2);
assert_eq!(tick_and_drain(&rt, &inbox, 0), vec![Pong, Pong]); assert_eq!(tick_and_drain(&mut host, &inbox, 0), vec![Pong, Pong]);
rt.leave_group(a, "workers"); rt.leave_group(a, "workers");
assert_eq!(rt.group_members("workers"), vec![b]); assert_eq!(rt.group_members("workers"), vec![b]);
rt.stop_actor(b).unwrap(); rt.stop_actor(b).unwrap();
tick_n(&rt, 3); tick_n(&mut host, 3);
assert!(rt.group_members("workers").is_empty()); assert!(rt.group_members("workers").is_empty());
assert!(rt.groups().is_empty()); assert!(rt.groups().is_empty());
} }
#[test] #[test]
fn ctx_watch_delivers_actor_exited() { fn ctx_watch_delivers_actor_exited() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<ActorExited>().unwrap(); let inbox = rt.new_inbox::<ActorExited>().unwrap();
let target = rt.spawn(PanicActor).unwrap(); let target = rt.spawn(PanicActor).unwrap();
rt.spawn(ReportExitTo { rt.spawn(ReportExitTo {
@ -148,10 +148,10 @@ fn ctx_watch_delivers_actor_exited() {
report_to: *inbox.addr(), report_to: *inbox.addr(),
}) })
.unwrap(); .unwrap();
tick_n(&rt, 2); tick_n(&mut host, 2);
rt.send_to(target, PanicMsg).unwrap(); rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 4); tick_n(&mut host, 4);
let exited = inbox.try_recv().expect("watch notification"); let exited = inbox.try_recv().expect("watch notification");
assert_eq!(exited.addr, target); assert_eq!(exited.addr, target);
@ -160,10 +160,10 @@ fn ctx_watch_delivers_actor_exited() {
#[test] #[test]
fn ctx_join_group_receives_runtime_publish() { fn ctx_join_group_receives_runtime_publish() {
let rt = std_runtime(RuntimeConfig::default()); let (rt, mut host) = std_host(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap(); let inbox = rt.new_inbox::<Pong>().unwrap();
rt.spawn(JoinOnStart { group: "joined" }).unwrap(); rt.spawn(JoinOnStart { group: "joined" }).unwrap();
tick_n(&rt, 2); tick_n(&mut host, 2);
assert_eq!(rt.group_members("joined").len(), 1); assert_eq!(rt.group_members("joined").len(), 1);
assert_eq!( assert_eq!(
@ -175,7 +175,7 @@ fn ctx_join_group_receives_runtime_publish() {
), ),
1 1
); );
tick_n(&rt, 2); tick_n(&mut host, 2);
assert_eq!(inbox.try_recv(), Some(Pong)); assert_eq!(inbox.try_recv(), Some(Pong));
} }