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
WASM) or multi-threaded with `run()`:
You can drive core explicitly with `SingleThreadRuntime` (the WASM-friendly
host) or hand `RuntimeParts` to an engine substrate:
```rust
use swactor::actor::{ActorInterface, Ctx};
use swactor::runtime::{Runtime, RuntimeConfig};
use swactor::runtime::{RuntimeConfig, RuntimeParts, SingleThreadRuntime};
struct Counter { count: u64 }
@ -42,17 +42,19 @@ impl ActorInterface for Counter {
}
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 })?;
rt.send_to(addr, ())?;
for _ in 0..3 { rt.tick(); } // spawn → handle → done
for _ in 0..3 { host.tick(); } // spawn → handle → done
Ok(())
}
```
`RuntimeConfig` is three knobs — `max_actors`, `channel_buffer_size`,
and a per-tick `actor_message_budget` (inspired by BEAM's
reduction count, so one chatty mailbox can't starve the rest).
`RuntimeConfig` exposes worker count, actor/worker ingress budgets, actor
capacity, and channel buffer sizing. The per-tick actor budget is inspired by
BEAM's reduction count, so one chatty mailbox can't starve the rest.
## Features

View file

@ -2,8 +2,9 @@
***STALE! FOR HISTORICAL REFERENCE ONLY***
Id: 9
Last modified:
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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.
It folds the system behavior previously split across the orchestration,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,9 @@
# The Datastream — Specification
Id: 7
Last modified:
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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.
pub struct ClusterFrameSink {
rt: Arc<Runtime>,
rt: Runtime,
sink: Arc<OnceLock<ActorAddress>>,
}
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 }
}
}

View file

@ -228,12 +228,13 @@ mod standalone_gossip_transport {
//! Actorized registry/metadata/directory gossip over one codec and transport, proving
//! standalone frames converge without piggybacking on SWIM.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use swactor::Error;
use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime, RuntimeConfig};
use swactor::runtime::{Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension;
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
/// the frame's `type_tag` (the tag→actor routing the real driver does).
struct Link {
dst_rt: Arc<Runtime>,
dst_rt: Runtime,
routes: HashMap<String, ActorAddress>,
codec: Arc<CodecRegistry>,
}
@ -270,7 +271,8 @@ mod standalone_gossip_transport {
/// One node: a runtime hosting a RegistryActor + MetadataActor + DirectoryActor,
/// plus the shared state needed to wire it into a mesh.
struct Node {
rt: Arc<Runtime>,
rt: Runtime,
host: RefCell<SingleThreadRuntime>,
registry: ActorAddress,
metadata: ActorAddress,
directory: ActorAddress,
@ -295,14 +297,15 @@ mod standalone_gossip_transport {
// Phase 1: per-node runtime + actors.
let mut nodes = Vec::new();
for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default())
let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(),
router.clone(),
)));
let rt = Arc::new(rt);
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new();
let relay_mirror: RelayMirror = Arc::new(RwLock::new(HashMap::new()));
@ -333,6 +336,7 @@ mod standalone_gossip_transport {
nodes.push(Node {
rt,
host,
registry,
metadata,
directory,
@ -403,7 +407,7 @@ mod standalone_gossip_transport {
fn pump(&self, k: usize) {
for _ in 0..k {
for node in &self.nodes {
node.rt.tick();
node.host.borrow_mut().tick();
}
}
}
@ -444,7 +448,7 @@ mod standalone_gossip_transport {
},
)
.unwrap();
self.nodes[observer].rt.tick();
self.nodes[observer].host.borrow_mut().tick();
inbox.try_recv().and_then(|r| r.binding)
}
@ -461,7 +465,7 @@ mod standalone_gossip_transport {
},
)
.unwrap();
self.nodes[observer].rt.tick();
self.nodes[observer].host.borrow_mut().tick();
inbox.try_recv().and_then(|r| r.relay_url)
}
@ -478,7 +482,7 @@ mod standalone_gossip_transport {
},
)
.unwrap();
self.nodes[observer].rt.tick();
self.nodes[observer].host.borrow_mut().tick();
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
//! resolution, dead-host hiding, catch-up, quieting, and retained recovery claims.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use swactor::Error;
use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime, RuntimeConfig};
use swactor::runtime::{Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension;
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
/// settled cluster has gone quiet.
struct Link {
dst_rt: Arc<Runtime>,
dst_rt: Runtime,
routes: HashMap<String, ActorAddress>,
codec: Arc<CodecRegistry>,
frames: Arc<AtomicUsize>,
@ -61,7 +62,8 @@ mod directory_actor {
/// One node: a runtime hosting a `DirectoryActor`, plus the shared state needed to
/// wire it into a mesh and observe it.
struct Node {
rt: Arc<Runtime>,
rt: Runtime,
host: RefCell<SingleThreadRuntime>,
directory: ActorAddress,
dir: SharedPeerDirectory,
router: Arc<TransportRouter>,
@ -84,14 +86,15 @@ mod directory_actor {
let mut nodes = Vec::new();
for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default())
let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(),
router.clone(),
)));
let rt = Arc::new(rt);
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new();
let route_view: RouteView = Arc::new(RwLock::new(HashMap::new()));
@ -106,6 +109,7 @@ mod directory_actor {
nodes.push(Node {
rt,
host,
directory,
dir,
router,
@ -177,7 +181,7 @@ mod directory_actor {
fn pump(&self, k: usize) {
for _ in 0..k {
for node in &self.nodes {
node.rt.tick();
node.host.borrow_mut().tick();
}
}
}
@ -242,7 +246,7 @@ mod directory_actor {
},
)
.unwrap();
self.nodes[observer].rt.tick();
self.nodes[observer].host.borrow_mut().tick();
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
//! best-effort drops.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use serde::{Deserialize, Serialize};
use swactor::Error;
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_transport::{CodecRegistry, NetworkMessage, TransportRouter};
@ -653,7 +658,8 @@ mod directory_route_path {
// ── The harness ─────────────────────────────────────────────────────────────
struct RouteNode {
rt: Arc<Runtime>,
rt: Runtime,
host: RefCell<SingleThreadRuntime>,
outbox: Outbox,
route_view: RouteView,
directory: ActorAddress,
@ -688,14 +694,15 @@ mod directory_route_path {
let mut nodes = Vec::new();
for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default())
let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.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 route_view: RouteView = Arc::new(RwLock::new(HashMap::new()));
@ -724,6 +731,7 @@ mod directory_route_path {
nodes.push(RouteNode {
rt,
host,
outbox,
route_view,
directory,
@ -793,7 +801,7 @@ mod directory_route_path {
fn settle(&self, k: usize) {
for _ in 0..k {
for node in &self.nodes {
node.rt.tick();
node.host.borrow_mut().tick();
}
self.deliver_wire();
}

View file

@ -16,11 +16,12 @@ mod single_runtime_actor {
//! SwimActor behavior in one runtime: subscription stream convergence and genuine unreachable-
//! peer death detection.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use swactor::runtime::{Runtime, RuntimeConfig};
use swactor::runtime::{Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension;
use distribution::swim::actor::{
@ -54,6 +55,7 @@ mod single_runtime_actor {
/// `MembershipChanged` subscriber inbox, and a shared Binding.
struct ActorCluster {
rt: Runtime,
host: RefCell<SingleThreadRuntime>,
ids: Vec<NodeId>,
addrs: Vec<swactor::actor::ActorAddress>,
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
/// the join requests have been issued (not yet converged).
fn new(n: usize) -> Self {
let rt = Runtime::new(RuntimeConfig::default())
let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new();
let now = Instant::now();
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 {
rt,
host,
ids,
addrs,
inboxes,
@ -123,7 +128,7 @@ mod single_runtime_actor {
fn pump(&self, n: usize) {
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
//! transport send failure.
use std::cell::RefCell;
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use swactor::Error;
use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime, RuntimeConfig};
use swactor::runtime::{Inbox, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension;
use swactor_transport::{CodecRegistry, Transport, TransportRouter, WireEnvelope};
@ -289,7 +295,7 @@ mod transport_runtime_actor {
struct Link {
src: usize,
dst: usize,
dst_rt: Arc<Runtime>,
dst_rt: Runtime,
dst_swim: ActorAddress,
codec: Arc<CodecRegistry>,
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
/// `Link` transports — the multi-runtime analog of `swim_actor.rs::ActorCluster`.
struct TransportCluster {
rts: Vec<Arc<Runtime>>,
rts: Vec<Runtime>,
hosts: Vec<RefCell<SingleThreadRuntime>>,
swims: Vec<ActorAddress>,
inboxes: Vec<Inbox<MembershipChanged>>,
streams: Vec<Vec<MembershipChanged>>,
@ -331,6 +338,7 @@ mod transport_runtime_actor {
let partition = Arc::new(Mutex::new(HashSet::new()));
let mut rts = Vec::new();
let mut hosts = Vec::new();
let mut swims = Vec::new();
let mut dirs = 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
// (initially empty) transport router. Spawn the SwimActor and subscribe.
for &nid in &ids {
let mut rt = Runtime::new(RuntimeConfig::default())
let parts = RuntimeParts::new(RuntimeConfig::default())
.with_extension(Arc::new(StdExtension::new()));
let rt = parts.runtime().clone();
let router = Arc::new(TransportRouter::new());
rt.set_remote_sink(Arc::new(swactor_transport::CodecRemoteSink::new(
codec.clone(),
router.clone(),
)));
let rt = Arc::new(rt);
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new();
let swim = rt
@ -369,6 +378,7 @@ mod transport_runtime_actor {
.unwrap();
rts.push(rt);
hosts.push(host);
swims.push(swim);
dirs.push(dir);
routers.push(router);
@ -377,7 +387,8 @@ mod transport_runtime_actor {
// 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
// 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 j in 0..n {
if i == j {
@ -401,6 +412,7 @@ mod transport_runtime_actor {
let mut c = TransportCluster {
rts,
hosts,
swims,
inboxes,
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.
fn pump(&self, k: usize) {
for _ in 0..k {
for rt in &self.rts {
rt.tick();
for host in &self.hosts {
host.borrow_mut().tick();
}
}
}
@ -535,11 +547,12 @@ mod actor_membership_safety_edges {
//! Actor-observable safety edges: resurrection after silence, multi-hop death dissemination,
//! and bounded stale-refute behavior.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use swactor::runtime::{Runtime, RuntimeConfig};
use swactor::runtime::{Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime};
use swactor::std::StdExtension;
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.
struct Cluster {
rt: Runtime,
host: RefCell<SingleThreadRuntime>,
ids: Vec<NodeId>,
addrs: Vec<swactor::actor::ActorAddress>,
inboxes: Vec<swactor::runtime::Inbox<MembershipChanged>>,
@ -586,8 +600,10 @@ mod actor_membership_safety_edges {
impl Cluster {
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()));
let rt = parts.runtime().clone();
let host = RefCell::new(SingleThreadRuntime::new(parts));
let dir = SharedPeerDirectory::new();
let now = Instant::now();
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 {
rt,
host,
ids,
addrs,
inboxes,
@ -638,7 +655,7 @@ mod actor_membership_safety_edges {
fn pump(&self, n: usize) {
for _ in 0..n {
self.rt.tick();
self.host.borrow_mut().tick();
}
}

View file

@ -1,8 +1,9 @@
# swactor engine — specification
Id: 1
Last modified: f8fc594b95871813a890b5d60f60dee505ef93bc
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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.
@ -46,9 +47,9 @@ This spec defines the **engine**: a swactor-owned composite that retains a selec
## 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 **engine** is a swactor-owned composite. It retains the selected execution substrate and the core runtime, and it does exactly two things:
1. **Drives actor execution** — schedules core ticks without application involvement.
- 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, retains the core runtime handle, moves every worker into a core driver, and does exactly two things:
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.
- **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.
@ -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.
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:**
@ -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.
**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
- 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.
- **Non-reentrancy.** The engine must never invoke the same worker concurrently. Actor state is live only for the duration of a synchronous tick.
- 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. 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.
## 6. Capability surface

View file

@ -1,22 +1,24 @@
//! Core driving loop.
//!
//! Substrate-neutral: the driver is one allocated task that runs one
//! [`Runtime::try_tick`] per poll — synchronous, returns immediately — then
//! re-schedules itself by waking its own waker. There is no backend reference
//! on the hot path, no per-turn boxed yield, no inbox-wake or readiness
//! mechanism, and no `has_work()` gate; later polls observe newly delivered
//! messages (ENGINE_SPEC.md).
//! Substrate-neutral: each driver owns one core worker. A poll runs one
//! [`Worker::try_tick`] — synchronous, returns immediately — then re-schedules
//! itself by waking its own waker. There is no backend reference on the hot path,
//! no per-turn boxed yield, no inbox-wake or readiness mechanism, and no
//! `has_work()` gate; later polls observe newly delivered messages
//! (ENGINE_SPEC.md).
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
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
/// — then re-arms itself via `cx.waker().wake_by_ref()` and returns `Pending`.
/// Each poll runs one [`Worker::try_tick`] — synchronous, returns immediately —
/// then re-arms itself via `cx.waker().wake_by_ref()` and returns `Pending`.
/// Rescheduling through the waker hands control back to the substrate
/// scheduler between ticks, so other engine work progresses. The driver holds
/// 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.
///
/// # Non-reentrancy
/// Only one driver is installed per engine, and `try_tick` runs to completion
/// within a poll, so the runtime's worker is never borrowed concurrently.
/// This is what makes `unsafe impl Sync` on [`Runtime`] sound under a single
/// driver (see ENGINE_SPEC.md §5/§8).
/// Each worker is moved into exactly one driver, and `try_tick` runs to
/// completion within a poll, so the same worker is never borrowed concurrently
/// by the engine.
///
/// [`Runtime::try_tick`]: swactor::runtime::Runtime::try_tick
/// [`Runtime`]: swactor::runtime::Runtime
/// [`Worker::try_tick`]: swactor::worker::Worker::try_tick
/// [`Worker`]: swactor::worker::Worker
struct CoreDriver {
runtime: Arc<swactor::runtime::Runtime>,
worker: Worker,
}
// The core driver is the engine's sole core-progression path; it is the one
// place permitted to call `Runtime::try_tick` (ENGINE_SPEC.md §2).
// Core drivers are the engine's sole core-progression path; this is the one
// place permitted to call `Worker::try_tick` (ENGINE_SPEC.md §2).
#[allow(clippy::disallowed_methods)]
impl Future for CoreDriver {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// `CoreDriver` is `Unpin` (`Arc<…>` is `Unpin`), so field access
// through `Pin<&mut Self>` is sound without projection.
self.runtime.try_tick();
// `CoreDriver` is `Unpin`; it contains no self-referential state.
let this = self.get_mut();
this.worker.try_tick();
// Re-arm immediately: the substrate scheduler redispatches this task,
// yielding to other engine work between ticks. No boxed yield is
// 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
/// lifetime; the substrate cancels it when the backend is dropped.
pub(crate) fn install(runtime: Arc<swactor::runtime::Runtime>, backend: &Arc<dyn ExecutionBackend>) {
let driver: BoxTask = Box::pin(CoreDriver { runtime });
backend.spawn(driver);
/// One task is allocated per worker at engine construction and runs for the
/// engine's lifetime; the substrate cancels it when the backend is dropped.
pub(crate) fn install(workers: Vec<Worker>, backend: &Arc<dyn ExecutionBackend>) {
for worker in workers {
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::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
/// [`Engine::handle`].
pub struct Engine {
/// Retained so the engine owns the runtime it drives for its full lifetime.
/// The core driver holds its own clone; this field anchors ownership (and
/// future admin/shutdown surfaces) even though it is not read directly.
/// Retained so the engine owns the runtime handle it drives for its full
/// lifetime. Core workers are moved into substrate tasks at construction.
#[allow(dead_code)]
runtime: Arc<swactor::runtime::Runtime>,
runtime: Runtime,
backend: Arc<dyn ExecutionBackend>,
}
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
/// engine is its sole driver. Construction fails if `backend` does not
/// advertise a capability the engine requires (at minimum, `tasks`).
/// The runtime parts must be fully configured beforehand; after construction
/// the engine owns every worker and is their sole driver. Construction fails
/// if `backend` does not advertise a capability the engine requires (at
/// minimum, `tasks`).
pub fn new(
runtime: Arc<swactor::runtime::Runtime>,
parts: RuntimeParts,
backend: impl ExecutionBackend,
) -> Result<Self, EngineError> {
let backend: Arc<dyn ExecutionBackend> = Arc::new(backend);
if !backend.capabilities().tasks {
return Err(EngineError::MissingRequiredCapability);
}
// Install exactly one core-driving loop; the engine is now the sole
// driver of `runtime`. This is substrate-neutral — no Tokio feature
// gate — so core progression does not silently disappear when an
// alternate backend is used (ENGINE_SPEC.md).
crate::core_driver::install(runtime.clone(), &backend);
let runtime = parts.runtime().clone();
let workers = parts.into_workers();
// Install one core-driving loop per worker. This is substrate-neutral —
// no Tokio feature gate — so core progression does not silently
// disappear when an alternate backend is used (ENGINE_SPEC.md).
crate::core_driver::install(workers, &backend);
Ok(Engine { runtime, backend })
}

View file

@ -10,6 +10,28 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
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 ───────────────────────────────────────────────────────────

View file

@ -18,8 +18,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::atomic::Ordering::SeqCst;
use std::time::Duration;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use swactor_engine::{Engine, TokioBackend, TokioConfig};
@ -32,9 +30,9 @@ const DEADLINE: Duration = Duration::from_secs(5);
#[test]
fn engine_runs_without_an_ambient_tokio_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 engine = Engine::new(runtime, backend).expect("construct engine");
let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -50,7 +48,7 @@ fn engine_runs_without_an_ambient_tokio_runtime() {
#[test]
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 addr = runtime
.spawn(RecordingProbe {
@ -59,7 +57,7 @@ fn engine_drives_core_without_application_ticks() {
.expect("spawn probe actor");
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.
runtime
@ -76,9 +74,9 @@ fn engine_drives_core_without_application_ticks() {
#[test]
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 engine = Engine::new(runtime, backend).expect("construct engine");
let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -94,7 +92,7 @@ fn spawned_supporting_work_runs() {
#[test]
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 addr = runtime
.spawn(RecordingProbe {
@ -103,7 +101,7 @@ fn actor_ticks_and_supporting_work_both_progress() {
.expect("spawn probe actor");
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();
// Long-lived cooperative supporting work that yields between steps so it
@ -135,7 +133,7 @@ fn actor_ticks_and_supporting_work_both_progress() {
#[test]
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 violations = 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");
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();
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
// contract. Configure a small async worker pool so passing cannot be an
// 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 addr = runtime
.spawn(RecordingProbe {
@ -208,7 +206,7 @@ fn blocking_work_does_not_stop_actor_ticks() {
let backend = TokioBackend::new(TokioConfig { worker_threads: 1 })
.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();
// 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]
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 engine = Engine::new(runtime, backend).expect("construct engine");
let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle();
let mut prev = handle.now();
@ -255,9 +253,9 @@ fn engine_clock_is_monotonic() {
#[test]
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 engine = Engine::new(runtime, backend).expect("construct engine");
let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
@ -275,9 +273,9 @@ fn engine_timer_fires() {
#[test]
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 engine = Engine::new(runtime, backend).expect("construct engine");
let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle();
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
// runtime — then await it on an engine task. If the tokio backend's timer
// 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 engine = Engine::new(runtime, backend).expect("construct engine");
let engine = Engine::new(parts, backend).expect("construct engine");
let handle = engine.handle();
// Constructed off-runtime: must not panic.
@ -342,7 +340,7 @@ fn engine_adopts_caller_tuned_tokio_runtime() {
.expect("build tuned tokio runtime");
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 addr = runtime
.spawn(RecordingProbe {
@ -350,7 +348,7 @@ fn engine_adopts_caller_tuned_tokio_runtime() {
})
.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).
assert_eq!(
engine.handle().capabilities(),

View file

@ -13,8 +13,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::atomic::Ordering::SeqCst;
use std::time::Duration;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use swactor_engine::{
Capabilities, Engine, EngineError, ExecutionBackend, SteppingBackend,
@ -133,8 +131,8 @@ impl swactor_engine::ExecutionBackend for NoCapBackend {
#[test]
fn engine_new_rejects_backend_without_tasks() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let result = Engine::new(runtime, NoCapBackend);
let parts = default_parts();
let result = Engine::new(parts, NoCapBackend);
assert!(
matches!(result, Err(EngineError::MissingRequiredCapability)),
"Engine::new must reject a backend that cannot schedule tasks"
@ -143,9 +141,9 @@ fn engine_new_rejects_backend_without_tasks() {
#[test]
fn engine_new_accepts_stepping_backend() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
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);
}
@ -155,9 +153,9 @@ fn engine_new_accepts_stepping_backend() {
#[test]
fn require_accepts_when_all_capabilities_present() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).unwrap();
let engine = Engine::new(parts, backend).unwrap();
let handle = engine.handle();
// Stepping provides tasks + timers + blocking.
@ -172,9 +170,9 @@ fn require_accepts_when_all_capabilities_present() {
#[test]
fn require_rejects_when_io_missing() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).unwrap();
let engine = Engine::new(parts, backend).unwrap();
let handle = engine.handle();
// Stepping does NOT provide io.
@ -198,8 +196,8 @@ fn require_rejects_when_timers_missing() {
}
}
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let engine = Engine::new(runtime, TaskOnlyBackend).unwrap();
let parts = default_parts();
let engine = Engine::new(parts, TaskOnlyBackend).unwrap();
let handle = engine.handle();
assert!(
@ -218,9 +216,9 @@ fn require_rejects_when_timers_missing() {
#[test]
fn require_can_be_called_multiple_times() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend).unwrap();
let engine = Engine::new(parts, backend).unwrap();
let handle = engine.handle();
assert!(handle.require(Capabilities::TASKS_ONLY).is_ok());
@ -266,7 +264,7 @@ const STEPS: usize = 30;
#[test]
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 addr = runtime
.spawn(RecordingProbe {
@ -275,7 +273,7 @@ fn stepping_core_progresses_without_tokio() {
.expect("spawn probe");
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.
runtime.send_to(addr, Probe).expect("deliver probe");
@ -291,10 +289,60 @@ fn stepping_core_progresses_without_tokio() {
}
#[test]
fn stepping_supporting_work_progresses() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
fn stepping_engine_installs_one_driver_per_worker() {
let (parts, _runtime) = runtime_parts_with_workers(3);
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 done = Arc::new(AtomicBool::new(false));
@ -315,7 +363,7 @@ fn stepping_supporting_work_progresses() {
#[test]
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 addr = runtime
.spawn(RecordingProbe {
@ -324,7 +372,7 @@ fn stepping_core_and_supporting_work_both_progress() {
.expect("spawn probe");
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();
// Long-lived cooperative supporting work that yields between steps.
@ -367,9 +415,9 @@ fn stepping_virtual_time_is_monotonic() {
#[test]
fn stepping_virtual_now_matches_engine_now() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
let backend = SteppingBackend::new();
let engine = Engine::new(runtime, backend.clone()).unwrap();
let engine = Engine::new(parts, backend.clone()).unwrap();
let handle = engine.handle();
assert_eq!(handle.now(), backend.virtual_now());
@ -380,9 +428,9 @@ fn stepping_virtual_now_matches_engine_now() {
#[test]
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 engine = Engine::new(runtime, backend.clone()).expect("construct engine");
let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle();
let fired = Arc::new(AtomicBool::new(false));
@ -404,9 +452,9 @@ fn stepping_timer_does_not_fire_before_advance() {
#[test]
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 engine = Engine::new(runtime, backend.clone()).expect("construct engine");
let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle();
let fired = Arc::new(AtomicBool::new(false));
@ -435,9 +483,9 @@ fn stepping_timer_fires_after_virtual_time_advance() {
#[test]
fn stepping_blocking_work_runs_isolated() {
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
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 done = Arc::new(AtomicBool::new(false));
@ -461,9 +509,9 @@ fn stepping_blocking_work_runs_isolated() {
#[test]
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 engine = Engine::new(runtime, backend.clone()).expect("construct engine");
let engine = Engine::new(parts, backend.clone()).expect("construct engine");
let handle = engine.handle();
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
// handles remain alive (ENGINE_SPEC.md).
let sentinel = Arc::new(());
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let parts = default_parts();
let engine = Engine::new(
runtime,
parts,
SentinelBackend { sentinel: sentinel.clone() },
)
.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
// not retain the backend and should degrade through the smallest practical
// API rather than panic (ENGINE_SPEC.md).
let runtime = Arc::new(Runtime::new(RuntimeConfig::default()));
let engine = Engine::new(runtime, SteppingBackend::default()).unwrap();
let parts = default_parts();
let engine = Engine::new(parts, SteppingBackend::default()).unwrap();
let handle = engine.handle();
// Live handle reports the stepping backend's capabilities.
assert!(handle.capabilities().tasks);

View file

@ -1,8 +1,9 @@
# Iroh Driver Fixed Specification
Id: 5
Last modified: f8fc594b95871813a890b5d60f60dee505ef93bc
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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.
@ -537,7 +538,7 @@ datastream_transport
```text
keypair
endpoint
Tokio handle
engine handle
connection cache
pending joins
accepted actor connections
@ -548,10 +549,9 @@ eviction queue
peer relay cache
join status map
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
@ -632,7 +632,7 @@ Boot begins when a caller constructs the driver.
Construction:
```text
resolve Tokio handle
resolve swactor engine handle and required capabilities
build iroh endpoint with actor ALPN and additional protocol wire ALPNs
apply relay mode
apply secret key when configured
@ -675,7 +675,7 @@ The engine-hosted pump cycle advances the following per interval:
```text
send protocol actor ticks
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 protocol adapter ingress/status queues
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
```
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.
### 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 accept;
@ -867,9 +867,9 @@ The driver requires a Tokio runtime for:
- retry timers;
- 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
@ -881,13 +881,13 @@ The endpoint must register the actor ALPN and every internal wire ALPN required
### 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 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
@ -902,7 +902,7 @@ DirectoryActor
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
@ -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 errors return from `with_handle`.
Construction errors return from `with_engine`.
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
/// once the protocol runs as actors (see [`IrohDriver::enable_actor_bridge`]).
struct ActorBridge {
/// The swactor runtime, for `deliver_raw` of decoded inbound + `SendFailed`.
rt: Arc<Runtime>,
/// The swactor runtime handle, for `deliver_raw` of decoded inbound + `SendFailed`.
rt: Runtime,
/// Actor codec: wire `type_tag` → the actor `Incoming` variant and back.
codec: Arc<CodecRegistry>,
/// `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`]).
pub fn enable_actor_bridge(
&mut self,
rt: Arc<Runtime>,
rt: Runtime,
codec: Arc<CodecRegistry>,
routes: HashMap<String, ActorAddress>,
swim_addr: ActorAddress,

View file

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

View file

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

View file

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

View file

@ -1,8 +1,9 @@
# Swactor Managed Process Specification
Id: 8
Last modified:
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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,
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 serde_json::{Value, json};
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::{
ExitStatus, ProcessCommand, ProcessOutput, ProcessOutputConfig, ProcessSpec,
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(
rt: &Runtime,
host: &mut SingleThreadRuntime,
endpoint: Option<&DatastreamEndpoint>,
upstream: &Inbox<ProcessOutput>,
outputs: &mut Vec<ProcessOutput>,
) {
rt.tick();
host.tick();
std::thread::sleep(Duration::from_millis(5));
if let Some(endpoint) = endpoint {
endpoint.tick();
@ -98,14 +107,14 @@ fn send_spawn(
}
fn drive_until_spawn_reply(
rt: &Runtime,
host: &mut SingleThreadRuntime,
endpoint: Option<&DatastreamEndpoint>,
upstream: &Inbox<ProcessOutput>,
outputs: &mut Vec<ProcessOutput>,
reply: &Inbox<SpawnReply>,
) -> SpawnReply {
for _ in 0..400 {
drive_once(rt, endpoint, upstream, outputs);
drive_once(host, endpoint, upstream, outputs);
if let Some(reply) = reply.try_recv() {
return reply;
}
@ -114,14 +123,14 @@ fn drive_until_spawn_reply(
}
fn drive_until(
rt: &Runtime,
host: &mut SingleThreadRuntime,
endpoint: Option<&DatastreamEndpoint>,
upstream: &Inbox<ProcessOutput>,
outputs: &mut Vec<ProcessOutput>,
mut done: impl FnMut(&[ProcessOutput]) -> bool,
) {
for _ in 0..800 {
drive_once(rt, endpoint, upstream, outputs);
drive_once(host, endpoint, upstream, outputs);
if done(outputs) {
return;
}
@ -221,7 +230,7 @@ fn channel_names(endpoint: &DatastreamEndpoint) -> Vec<String> {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
Some(&endpoint),
&upstream,
&mut outputs,
@ -253,11 +262,11 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() {
assert_ne!(process_addr, ActorAddress::default());
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))
});
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());
}
@ -319,7 +328,7 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_datastream() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 _process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&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
.iter()
@ -381,7 +390,7 @@ fn spawn_failure_maps_to_public_spawn_failed_output() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 _process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
Some(&endpoint),
&upstream,
&mut outputs,
&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))
});
@ -421,7 +430,7 @@ fn command_basename_is_default_lifecycle_label_source() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 first_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
Some(&endpoint),
&upstream,
&mut outputs,
@ -455,7 +464,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
&reply,
);
let error = expect_failed(drive_until_spawn_reply(
&rt,
&mut host,
Some(&endpoint),
&upstream,
&mut outputs,
@ -476,7 +485,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
},
)
.unwrap();
drive_until(&rt, Some(&endpoint), &upstream, &mut outputs, |outputs| {
drive_until(&mut host, Some(&endpoint), &upstream, &mut outputs, |outputs| {
outputs
.iter()
.any(|output| matches!(output, ProcessOutput::Exited { .. }))
@ -486,7 +495,7 @@ fn explicit_label_overrides_basename_and_duplicate_labels_are_rejected() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
@ -517,7 +526,7 @@ fn stop_before_spawn_success_reports_started_then_exited() {
},
)
.unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
outputs
.iter()
.any(|output| matches!(output, ProcessOutput::Exited { .. }))
@ -543,7 +552,7 @@ fn stop_before_spawn_success_reports_started_then_exited() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
@ -578,7 +587,7 @@ fn stop_before_spawn_failure_reports_only_spawn_failed() {
},
)
.unwrap();
drive_until(&rt, None, &upstream, &mut outputs, has_spawn_failed);
drive_until(&mut host, None, &upstream, &mut outputs, has_spawn_failed);
assert_eq!(
terminal_count(&outputs),
@ -600,7 +609,7 @@ fn stop_before_spawn_failure_reports_only_spawn_failed() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&reply,
));
drive_until(&rt, None, &upstream, &mut outputs, has_started);
drive_until(&mut host, None, &upstream, &mut outputs, has_started);
send_process_command(
&sender,
process_addr,
@ -636,7 +645,7 @@ fn stop_running_with_kill_after_escalates_to_kill() {
},
)
.unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Signal(9))
});
@ -649,7 +658,7 @@ fn stop_running_with_kill_after_escalates_to_kill() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&reply,
));
drive_until(&rt, None, &upstream, &mut outputs, has_started);
drive_until(&mut host, None, &upstream, &mut outputs, has_started);
send_process_command(
&sender,
process_addr,
ProcessCommand::Stop { kill_after: None },
)
.unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0))
});
@ -700,7 +709,7 @@ fn stop_running_without_kill_after_terminates_without_kill_escalation() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&reply,
));
drive_until(&rt, None, &upstream, &mut outputs, has_started);
drive_until(&mut host, None, &upstream, &mut outputs, has_started);
send_process_command(
&sender,
process_addr,
@ -736,11 +745,11 @@ fn child_exit_before_kill_deadline_suppresses_kill_escalation() {
},
)
.unwrap();
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0))
});
for _ in 0..5 {
drive_once(&rt, None, &upstream, &mut outputs);
drive_once(&mut host, None, &upstream, &mut outputs);
}
assert!(
@ -756,7 +765,7 @@ fn child_exit_before_kill_deadline_suppresses_kill_escalation() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&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();
send_process_command(
@ -804,7 +813,7 @@ fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() {
.unwrap();
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)) {
break;
}
@ -829,7 +838,7 @@ fn duplicate_stop_while_stopping_is_noop_and_keeps_original_deadline() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&reply,
));
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0))
});
let output_len = outputs.len();
@ -865,7 +874,7 @@ fn stop_after_terminal_output_emits_no_additional_output() {
},
);
for _ in 0..10 {
drive_once(&rt, None, &upstream, &mut outputs);
drive_once(&mut host, None, &upstream, &mut outputs);
}
assert_eq!(
@ -878,7 +887,7 @@ fn stop_after_terminal_output_emits_no_additional_output() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 _process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&reply,
));
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0))
});
let dropped = endpoint.mux().dropped();
@ -925,7 +934,7 @@ fn lifecycle_mirror_submit_failure_does_not_suppress_upstream_or_emit_error() {
#[test]
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 upstream = rt.new_inbox::<ProcessOutput>().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 _process_addr = expect_spawned(drive_until_spawn_reply(
&rt,
&mut host,
None,
&upstream,
&mut outputs,
&reply,
));
drive_until(&rt, None, &upstream, &mut outputs, |outputs| {
drive_until(&mut host, None, &upstream, &mut outputs, |outputs| {
has_exited(outputs, ExitStatus::Code(0))
});

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use swactor::{
actor::{ActorAddress, ActorInterface},
runtime::{Ctx, Runtime, RuntimeConfig},
runtime::{Ctx, Runtime, RuntimeConfig, RuntimeParts, SingleThreadRuntime},
Error,
};
use swactor_transport::{
@ -111,9 +111,16 @@ fn build_codec_registry() -> CodecRegistry {
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 {
rt.tick();
host.tick();
}
}
@ -141,18 +148,17 @@ fn two_runtimes_communicate_via_in_memory_transport() {
let codecs = Arc::new(build_codec_registry());
// 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 router_a = TransportRouter::new();
// 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 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();
tick_n(&rt_b, 1);
// Create inbox on A to receive the reply
let inbox_a = rt_a.new_inbox::<Pong>().unwrap();
@ -171,6 +177,7 @@ fn two_runtimes_communicate_via_in_memory_transport() {
codecs.clone(),
Arc::new(router_b),
)));
tick_n(&mut host_b, 1);
// A sends Ping to pong_addr — this goes via transport
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
drain_transport(&rx_b, &codecs, &rt_b);
tick_n(&rt_b, 1);
tick_n(&mut host_b, 1);
// Deliver reply from B→A transport
drain_transport(&rx_a, &codecs, &rt_a);
@ -207,7 +214,7 @@ fn unregistered_type_produces_clear_error() {
let fake_addr = ActorAddress::new_random();
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))));
let result = rt.send_to(
@ -258,7 +265,7 @@ fn local_send_still_bypasses_transport() {
let remote_addr = ActorAddress::new_random();
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))));
// Spawn a local PongActor + inbox
@ -275,7 +282,7 @@ fn local_send_still_bypasses_transport() {
)
.unwrap();
tick_n(&rt, 2);
tick_n(&mut host, 2);
// Verify local delivery worked
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());
// Set up two runtimes with bidirectional transports
let mut rt_a = Runtime::new(RuntimeConfig::default());
let mut rt_b = Runtime::new(RuntimeConfig::default());
let (rt_a, _host_a) = runtime_host();
let (rt_b, mut host_b) = runtime_host();
let (transport_a2b, rx_b) = 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_b = TransportRouter::new();
// Spawn actors
// Spawn actors; drain spawn queue after remote sinks are installed.
let pong_addr = rt_b.spawn(PongActor).unwrap();
tick_n(&rt_b, 1);
let inbox_a = rt_a.new_inbox::<Pong>().unwrap();
let inbox_addr = *inbox_a.addr();
@ -323,6 +329,7 @@ fn round_trip_across_two_runtimes() {
codecs.clone(),
Arc::new(router_b),
)));
tick_n(&mut host_b, 1);
// Send 3 pings and verify 3 pongs come back
for i in 0..3u32 {
@ -338,7 +345,7 @@ fn round_trip_across_two_runtimes() {
// Flush A→B
drain_transport(&rx_b, &codecs, &rt_b);
tick_n(&rt_b, 1);
tick_n(&mut host_b, 1);
// Flush B→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
Id: 4
Last modified:
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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
Id: 3
Last modified:
Last modified: b887e941cbe6f1e209339abd0375507aca9bfe52
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
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 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};
// ─── Host-facing bindings ───────────────────────────────────────────────────
@ -51,23 +53,26 @@ impl LogInbox {
#[wasm_bindgen]
pub struct App {
rt: SwactorRuntime,
host: SingleThreadRuntime,
}
#[wasm_bindgen]
impl App {
#[wasm_bindgen(constructor)]
pub fn new() -> App {
let rt = SwactorRuntime::new(RuntimeConfig {
num_threads: 1,
let parts = RuntimeParts::new(RuntimeConfig {
worker_count: 1,
..RuntimeConfig::default()
})
.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.
pub fn tick(&self) {
self.rt.tick();
pub fn tick(&mut self) {
self.host.tick();
}
/// Actors currently alive.

View file

@ -1,5 +1,5 @@
use crate::actor::{ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Message};
use crate::runtime::{Inbox, Runtime};
use crate::runtime::{Inbox, Runtime, SingleThreadRuntime};
use parking_lot::Mutex;
use std::any::Any;
use std::sync::Arc;
@ -102,9 +102,9 @@ impl<T: Message> Admin<T> {
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 {
rt.tick();
host.try_tick();
if let Some(resp) = self.inbox.try_recv() {
return resp;
}

View file

@ -6,6 +6,18 @@ pub struct RuntimeConfig {
/// Prevents a single actor with a large mailbox from starving others.
/// `0` means unlimited (drain entire mailbox).
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
@ -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.
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 {
fn default() -> Self {
Self {
max_actors: DEFAULT_MAX_ACTORS,
channel_buffer_size: DEFAULT_CHANNEL_BUFFER_SIZE,
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::channel::Sender;
use crate::config::RuntimeConfig;
use crate::stats::WorkerStats;
use crate::stats::{StatsHook, WorkerStats};
// ─── Identity Hasher for ActorAddress ───────────────────────────────────────
@ -54,45 +54,69 @@ pub type AddrMap<V> = HashMap<ActorAddress, V, AddrBuildHasher>;
/// HashSet optimized for ActorAddress keys.
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 ───────────────────────────────────────────────────────
/// 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 {
inner: RwLock<AddrSet>,
inner: RwLock<AddrMap<WorkerId>>,
}
impl AddressMap {
pub fn with_capacity(capacity: usize) -> Self {
Self {
inner: RwLock::new(HashSet::with_capacity_and_hasher(
capacity,
AddrBuildHasher,
)),
inner: RwLock::new(HashMap::with_capacity_and_hasher(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 {
self.inner.read().contains(addr)
/// Resolve the owning worker for `addr`, if it is a local actor.
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) {
self.inner.write().remove(addr);
}
pub fn addresses(&self) -> Vec<ActorAddress> {
self.inner.read().iter().copied().collect()
/// Iterate `(address, worker)` pairs for stats reporting.
pub fn placements(&self) -> Vec<(ActorAddress, WorkerId)> {
self.inner.read().iter().map(|(&a, &w)| (a, w)).collect()
}
}
// ─── 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).
pub(crate) struct Envelope {
@ -142,7 +166,6 @@ impl InboxRegistry {
pub fn register(&self, addr: ActorAddress, sender: Arc<dyn SenderT>) {
self.senders.write().insert(addr, sender);
}
/// Check if an address is registered without consuming a message.
#[cfg(feature = "transport")]
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) address_map: &'a AddressMap,
pub(crate) spawn_tx: &'a Sender<SpawnRequest>,
pub(crate) transfer_tx: &'a Sender<Envelope>,
pub(crate) spawn_txs: &'a [Sender<SpawnRequest>],
pub(crate) transfer_txs: &'a [Sender<Envelope>],
pub(crate) inbox_registry: &'a InboxRegistry,
pub(crate) config: &'a RuntimeConfig,
pub(crate) extension: Option<&'a dyn crate::extension::RuntimeExtension>,
pub(crate) process_output_observer:
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) num_workers: usize,
pub(crate) worker_id: WorkerId,
pub(crate) created_at: crate::Instant,
#[cfg(feature = "transport")]
pub(crate) remote_sink: Option<&'a dyn crate::runtime::RemoteSink>,
}
impl<'a> TickContext<'a> {
/// Route a message whose destination is not in the local address map.
/// Tries inbox registry, then remote transport, then falls back to inbox error.
pub(crate) fn worker_id(&self) -> WorkerId {
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(
&self,
addr: ActorAddress,

View file

@ -1,12 +1,11 @@
use crate::Instant;
use std::any::{Any, TypeId};
use std::cell::RefCell;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use crate::actor::{
Actor, ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Environment, ExitValue,
Message, ResumeSignal, SpawnRequest, StopSignal, StopWithSignal, SystemInfo,
Actor, ActorAddress, ActorInterface, ActorTypeMetadata, AnyActor, Environment,
Message, SpawnRequest, StopSignal,
};
use crate::admin::{
ActorStateSnapshot, Admin, AdminCommand, AdminError, AdminResult, GetActorStateResponse,
@ -15,12 +14,11 @@ use crate::admin::{
use crate::channel::{Receiver, Sender};
// Re-export config types so existing code using `runtime::RuntimeConfig` still works
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::stats::{StatsHook, WorkerStats};
use crate::stats::{RuntimeStats, StatsHook, WorkerInfo, WorkerStats};
// Re-export stats types so existing code using `runtime::*` still works
use crate::Error;
pub use crate::stats::{RuntimeStats, WorkerInfo};
use crate::worker::Worker;
/// Generic message inbox for receiving messages outside of the runtime.
@ -38,9 +36,11 @@ impl<M: Message> Inbox<M> {
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 {
rt.tick();
host.try_tick();
if let Some(msg) = self.inner.try_recv() {
return Some(msg);
}
@ -62,49 +62,91 @@ impl<R: Message> Ask<R> {
self.inbox.try_recv()
}
pub fn recv_ticking(&self, rt: &Runtime, max_ticks: usize) -> Option<R> {
self.inbox.recv_ticking(rt, max_ticks)
pub fn recv_ticking(&self, host: &mut SingleThreadRuntime, max_ticks: usize) -> Option<R> {
self.inbox.recv_ticking(host, max_ticks)
}
}
// Re-export Ctx for backwards compatibility
use crate::actor::ContextInner;
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 ─────────────────────────────────────────────────────────────────
/// 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
/// returns immediately, holding no control flow between calls. When to take
/// the next step is the engine's decision, not core's. Any driver that can
/// call `tick` (tokio, std-thread, a test stepper) can host it.
/// Core is a transition-only state machine. Each call mutates shared routing
/// state and returns immediately, holding no control flow between calls.
#[derive(Clone)]
pub struct Runtime {
config: RuntimeConfig,
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>>,
pub(crate) shared: Arc<RuntimeShared>,
}
// 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.
///
/// Implemented outside core (e.g., `swactor-transport`'s `CodecRemoteSink`),
@ -144,18 +186,15 @@ impl RuntimeAddress {
/// from any thread — including non-actor I/O threads.
///
/// Created via [`Runtime::create_sender`]. The primary use case is bridging
/// background I/O (e.g., pipe readers, network listeners) with the tick-based
/// actor system.
/// background I/O (e.g., pipe readers, network listeners) with the actor system.
pub struct ExternalSender {
address_map: Arc<AddressMap>,
transfer_tx: Sender<Envelope>,
shared: Arc<RuntimeShared>,
}
impl Clone for ExternalSender {
fn clone(&self) -> Self {
Self {
address_map: self.address_map.clone(),
transfer_tx: self.transfer_tx.clone(),
shared: self.shared.clone(),
}
}
}
@ -169,8 +208,8 @@ impl ExternalSender {
///
/// 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> {
if self.address_map.contains(&addr) {
self.transfer_tx.send(Envelope::new(addr, Box::new(msg)));
if let Some(w) = self.shared.address_map.worker_of(&addr) {
self.shared.transfer_txs[w.index()].send(Envelope::new(addr, Box::new(msg)));
Ok(())
} else {
Err(Error::from("Address not found"))
@ -178,58 +217,142 @@ impl ExternalSender {
}
}
impl Runtime {
/// Builds a new `Runtime` struct, but does not yet run anything.
/// Drive via `tick()` / `try_tick()`.
// ─── RuntimeParts ────────────────────────────────────────────────────────────
/// 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 {
let address_map = Arc::new(AddressMap::with_capacity(config.max_actors));
let inbox_registry = Arc::new(InboxRegistry::new());
assert!(
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 transfer_tx = transfer_rx.new_sender();
let mut transfer_rxs = Vec::with_capacity(n);
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_tx = spawn_rx.new_sender();
let spawn_rx = Receiver::<SpawnRequest>::new(max_actors);
spawn_txs.push(spawn_rx.new_sender());
spawn_rxs.push(spawn_rx);
let admin_rx = Receiver::<AdminCommand>::new(config.channel_buffer_size);
let admin_tx = admin_rx.new_sender();
let admin_rx = Receiver::<AdminCommand>::new(channel_buffer_size);
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 rt = Self {
let shared = Arc::new(RuntimeShared {
config,
address_map,
inbox_registry,
extension: None,
transfer_tx,
spawn_tx,
admin_tx,
address_map: AddressMap::with_capacity(max_actors),
inbox_registry: InboxRegistry::new(),
extension: OnceLock::new(),
transfer_txs,
spawn_txs,
admin_txs,
worker_stats,
stats_hook: None,
rr_worker: AtomicUsize::new(0),
stats_hook: OnceLock::new(),
process_output_observer: OnceLock::new(),
worker: RefCell::new(worker),
created_at: Instant::now(),
#[cfg(feature = "transport")]
remote_sink: None,
};
remote_sink: OnceLock::new(),
});
#[cfg(feature = "tracing")]
tracing::info!(
max_actors = rt.config.max_actors,
"runtime.created"
);
tracing::info!(max_actors, worker_count = n, "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> {
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));
self.spawn_tx.send(SpawnRequest {
self.shared.spawn_txs[worker.index()].send(SpawnRequest {
addr,
actor: boxed,
parent: None,
@ -237,10 +360,7 @@ impl Runtime {
});
#[cfg(feature = "tracing")]
tracing::info!(
actor_addr = %addr,
"actor.spawned"
);
tracing::info!(actor_addr = %addr, "actor.spawned");
Ok(addr)
}
@ -252,9 +372,10 @@ impl Runtime {
env: Environment,
) -> Result<ActorAddress, Error> {
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));
self.spawn_tx.send(SpawnRequest {
self.shared.spawn_txs[worker.index()].send(SpawnRequest {
addr,
actor: boxed,
parent: None,
@ -262,29 +383,14 @@ impl Runtime {
});
#[cfg(feature = "tracing")]
tracing::info!(
actor_addr = %addr,
"actor.spawned"
);
tracing::info!(actor_addr = %addr, "actor.spawned");
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).
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<'_> {
@ -315,7 +421,14 @@ impl Runtime {
///
/// Returns `Err` if the address is unknown to the runtime.
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")]
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
pub fn new_inbox<M: Message>(&self) -> Result<Inbox<M>, Error> {
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();
self.inbox_registry.register(addr, Arc::new(sender));
self.shared.inbox_registry.register(addr, Arc::new(sender));
Ok(Inbox {
addr,
inner: receiver,
@ -341,64 +454,35 @@ impl Runtime {
/// background I/O threads to bridge external events into the actor system.
pub fn create_sender(&self) -> ExternalSender {
ExternalSender {
address_map: self.address_map.clone(),
transfer_tx: self.transfer_tx.clone(),
shared: self.shared.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.
pub fn stats(&self) -> RuntimeStats {
let workers = vec![self.worker_stats.snapshot(0)];
let actors = self
.address_map
.addresses()
.into_iter()
.map(|addr| (addr, 0))
let s = &self.shared;
let num_workers = s.worker_count();
let workers: Vec<WorkerInfo> = s
.worker_stats
.iter()
.enumerate()
.map(|(i, ws)| ws.snapshot(i))
.collect();
let tick_timings = vec![self.worker_stats.drain_tick_timings()];
let uptime_ms = self.created_at.elapsed().as_millis() as u64;
let actors = s
.address_map
.placements()
.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 {
num_workers: 1,
num_workers,
uptime_ms,
actors,
workers,
@ -410,13 +494,13 @@ impl Runtime {
/// Request an actor to stop gracefully.
///
/// 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.
pub fn stop_actor(&self, addr: ActorAddress) -> Result<(), Error> {
if self.address_map.contains(&addr) {
self.transfer_tx
.send(Envelope::new(addr, Box::new(StopSignal)));
if let Some(w) = self.shared.address_map.worker_of(&addr) {
self.shared.transfer_txs[w.index()].send(Envelope::new(addr, Box::new(StopSignal)));
Ok(())
} else {
Err(Error::from("Actor not found"))
@ -425,9 +509,9 @@ impl Runtime {
/// Set a stats hook to receive per-actor snapshots from workers.
///
/// Must be called before [`tick()`](Self::tick).
pub fn set_stats_hook(&mut self, hook: Arc<dyn StatsHook>) {
self.stats_hook = Some(hook);
/// Must be called before the runtime is driven.
pub fn set_stats_hook(&self, hook: Arc<dyn StatsHook>) {
let _ = self.shared.stats_hook.set(hook);
}
/// 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
/// it a type-erased message destined for a non-local address.
#[cfg(feature = "transport")]
pub fn set_remote_sink(&mut self, sink: Arc<dyn RemoteSink>) {
self.remote_sink = Some(sink);
pub fn set_remote_sink(&self, sink: Arc<dyn RemoteSink>) {
let _ = self.shared.remote_sink.set(sink);
}
/// 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.
#[cfg(feature = "transport")]
pub fn deliver_raw(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
if self.address_map.contains(&addr) {
self.transfer_tx.send(Envelope::new(addr, msg));
if let Some(w) = self.shared.address_map.worker_of(&addr) {
self.shared.transfer_txs[w.index()].send(Envelope::new(addr, msg));
Ok(())
} 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 _ = self
.runtime
.shared
.inbox_registry
.try_deliver(reply_to, Box::new(result));
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> {
let (admin, reply_to) = self.new_admin::<ListActorsResponse>()?;
let n = self.runtime.shared.worker_count();
let acc = Arc::new(ListActorsAccumulator {
remaining: AtomicUsize::new(1),
remaining: AtomicUsize::new(n),
summaries: parking_lot::Mutex::new(Vec::new()),
reply_to,
});
self.runtime
.admin_tx
.send(AdminCommand::ListActors { acc: acc.clone() });
// Broadcast to every worker; the last to finish aggregates and replies.
for tx in &self.runtime.shared.admin_txs {
tx.send(AdminCommand::ListActors { acc: acc.clone() });
}
Ok(admin)
}
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 }));
}
};
let (admin, reply_to) = self.new_admin::<InspectActorResponse>()?;
self.runtime
.admin_tx
.send(AdminCommand::InspectActor { actor, reply_to });
tx.send(AdminCommand::InspectActor { actor, reply_to });
Ok(admin)
}
@ -503,10 +597,10 @@ impl RuntimeAdmin<'_> {
where
A: ActorInterface + Clone + Sync,
{
if !self.runtime.address_map.contains(&actor) {
let Some(tx) = self.admin_tx_for(actor) else {
return self
.ready::<GetActorStateResponse<A>>(Err(AdminError::ActorNotFound { actor }));
}
};
let (admin, reply_to) = self.new_admin::<GetActorStateResponse<A>>()?;
let get = Box::new(
@ -558,7 +652,7 @@ impl RuntimeAdmin<'_> {
)) as Box<dyn Any + Send>
});
self.runtime.admin_tx.send(AdminCommand::GetActorState {
tx.send(AdminCommand::GetActorState {
actor,
reply_to,
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 }));
}
};
let (admin, reply_to) = self.new_admin::<OperationResult>()?;
let actor_instance = state.actor_instance;
@ -619,114 +713,88 @@ impl RuntimeAdmin<'_> {
},
);
self.runtime
.admin_tx
.send(AdminCommand::ReplaceActorState {
actor,
reply_to,
replace,
});
tx.send(AdminCommand::ReplaceActorState {
actor,
reply_to,
replace,
});
Ok(admin)
}
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 }));
}
};
let (admin, reply_to) = self.new_admin::<OperationResult>()?;
self.runtime
.admin_tx
.send(AdminCommand::StopActor { actor, reply_to });
tx.send(AdminCommand::StopActor { actor, reply_to });
Ok(admin)
}
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 }));
}
};
let (admin, reply_to) = self.new_admin::<OperationResult>()?;
self.runtime
.admin_tx
.send(AdminCommand::SuspendActor { actor, reply_to });
tx.send(AdminCommand::SuspendActor { actor, reply_to });
Ok(admin)
}
pub fn resume_actor(&self, actor: ActorAddress) -> Result<Admin<OperationResult>, Error> {
if !self.runtime.address_map.contains(&actor) {
return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor }));
}
let (admin, reply_to) = self.new_admin::<OperationResult>()?;
self.runtime
.admin_tx
.send(AdminCommand::ResumeActor { actor, reply_to });
Ok(admin)
}
let Some(tx) = self.admin_tx_for(actor) else {
return self.ready::<OperationResult>(Err(AdminError::ActorNotFound { actor }));
};
let (admin, reply_to) = self.new_admin::<OperationResult>()?;
tx.send(AdminCommand::ResumeActor { actor, reply_to });
Ok(admin)
}
}
#[allow(private_interfaces)]
impl ContextInner for Runtime {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
if self.address_map.contains(&addr) {
self.transfer_tx.send(Envelope::new(addr, msg));
Ok(())
} else {
self.make_tick_context().route_nonlocal(addr, msg)
// ─── SingleThreadRuntime ────────────────────────────────────────────────────
/// The explicit manual host: consumes [`RuntimeParts`] and sequentially advances
/// every worker once per [`try_tick`](Self::try_tick), without locks.
///
/// External handles retain only a cloned [`Runtime`]; the host owns the workers.
pub struct SingleThreadRuntime {
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) {
self.address_map.insert(request.addr);
self.spawn_tx.send(request);
/// Borrow the cloneable runtime handle.
pub fn runtime(&self) -> &Runtime {
&self.runtime
}
fn request_stop(&self, addr: ActorAddress) {
if self.address_map.contains(&addr) {
self.transfer_tx
.send(Envelope::new(addr, Box::new(StopSignal)));
/// Return whether any owned worker currently has schedulable work.
pub fn has_work(&self) -> bool {
self.workers.iter().any(|w| w.has_work())
}
/// 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) {
if self.address_map.contains(&addr) {
self.transfer_tx
.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,
}
/// Drive one pass of every worker (ignoring whether work was done).
pub fn tick(&mut self) {
let _ = self.try_tick();
}
}

View file

@ -15,10 +15,11 @@ use crate::admin::{
ListActorsResponse, OperationResult,
};
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::extension::WorkerExtension;
use crate::runtime::RuntimeShared;
/// Whether an actor should be skipped during `tick_all`.
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,
/// then inbox_registry for external receivers.
fn route_to_pool_or_remote(
/// Route a runtime-injected message (extension output, death notification) to
/// its destination: same-worker pool first, then the owning worker's transfer
/// queue, then the non-local (inbox/transport) seam.
fn route_runtime_message(
pool: &mut ActorPool,
tc: &TickContext,
dest: ActorAddress,
@ -51,23 +53,28 @@ fn route_to_pool_or_remote(
) {
if pool.contains(&dest) {
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 {
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 set of actors and processes them via tick_once.
pub(crate) struct Worker {
/// A worker owns a disjoint set of actors and processes them via [`Worker::try_tick`].
///
/// Each worker is owned by exactly one execution host (a [`SingleThreadRuntime`]
/// 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,
transfer_rx: Receiver<Envelope>,
spawn_rx: Receiver<SpawnRequest>,
admin_rx: Receiver<AdminCommand>,
stats: Arc<WorkerStats>,
pub(crate) stats: Arc<WorkerStats>,
/// Reusable scratch buffer for building per-actor snapshots.
snapshot_buf: Vec<ActorSnapshot>,
/// 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
/// tick so pending_local messages delivered to mailboxes get drained.
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 {
pub(crate) fn new(
id: WorkerId,
shared: Arc<RuntimeShared>,
transfer_rx: Receiver<Envelope>,
spawn_rx: Receiver<SpawnRequest>,
admin_rx: Receiver<AdminCommand>,
stats: Arc<WorkerStats>,
) -> Self {
Self {
id,
shared,
pool: ActorPool::new(),
transfer_rx,
spawn_rx,
@ -93,6 +109,8 @@ impl Worker {
snapshot_buf: Vec::new(),
worker_ext: None,
has_backlog: false,
deferred_transfers: VecDeque::new(),
deferred_admin: VecDeque::new(),
}
}
@ -100,187 +118,21 @@ impl Worker {
self.has_backlog
|| !self.spawn_rx.is_empty()
|| !self.transfer_rx.is_empty()
|| !self.deferred_transfers.is_empty()
|| !self.admin_rx.is_empty()
|| !self.deferred_admin.is_empty()
|| self
.worker_ext
.as_ref()
.map_or(false, |e| e.has_pending_work())
}
/// Run one iteration of the worker loop. Returns `true` if any work was done.
/// Drain the spawn queue, inserting new actors into the pool.
/// Used in phases 1 and 4 of tick_once.
fn drain_spawns(&mut self, tc: &TickContext) -> bool {
let mut did_work = false;
#[cfg(feature = "tracing")]
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();
/// Run one synchronous worker pass. Returns `true` if any work was done.
///
/// This is the only core worker transition. The owning host calls it; the
/// worker never drives itself.
pub fn try_tick(&mut self) -> bool {
let wid = self.id;
// Fast idle path: skip the entire tick when nothing could have changed.
// Cost: ~3 atomic loads, zero syscalls, zero actor iteration.
@ -288,24 +140,60 @@ impl Worker {
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 t0 = Instant::now();
// 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();
// 2. Drain transfer queue → deliver envelopes to actors
while let Some(envelope) = self.transfer_rx.try_recv() {
let dest = envelope.dest();
let payload = envelope.into_payload();
self.pool.deliver(&dest, payload);
did_work = true;
}
// 2. Drain retained transfers first, then the transfer queue → deliver
// envelopes to actors or retain them until their mapped spawn installs.
did_work |= Self::drain_transfers(
&mut self.pool,
&self.transfer_rx,
&mut self.deferred_transfers,
&tc,
tc.config.worker_ingress_budget,
);
let t2 = Instant::now();
// 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
let ext_msgs: Vec<_> = self
@ -314,7 +202,7 @@ impl Worker {
.map(|ext| ext.on_tick())
.unwrap_or_default();
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;
}
@ -329,7 +217,7 @@ impl Worker {
let processed;
{
let worker_ctx = WorkerContext {
tc,
tc: &tc,
pending_local: &pending_local,
stop_requests: &stop_requests,
stop_with_values: &stop_with_values,
@ -337,11 +225,9 @@ impl Worker {
worker_requests: &worker_requests,
stats: &self.stats,
};
processed = self.pool.tick_all(
&worker_ctx,
&self.stats,
tc.config.actor_message_budget,
);
processed = self
.pool
.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget);
if processed > 0 {
did_work = true;
}
@ -351,7 +237,7 @@ impl Worker {
#[cfg(feature = "tracing")]
if processed > 0 {
tracing::debug!(
worker_id = 0,
worker_id = wid.index(),
messages_processed = processed,
"worker.tick_all"
);
@ -359,7 +245,12 @@ impl Worker {
// 6. Drain spawn queue again — actors spawned during step 5
// 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();
// 7. Drain pending_local buffer → deliver to local actors
@ -368,7 +259,12 @@ impl Worker {
did_work = true;
}
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)
@ -394,7 +290,7 @@ impl Worker {
if let Some(hook) = tc.stats_hook {
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")]
if did_work {
tracing::debug!(
worker_id = 0,
worker_id = wid.index(),
num_actors = self.pool.len(),
mailbox_depth = self.pool.total_mailbox_depth(),
messages_processed = processed,
@ -427,18 +323,318 @@ impl Worker {
}
// 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;
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.
///
/// All sends to local actors are buffered in `pending_local` (delivered after
/// the current tick round). Non-local addresses route to inbox_registry or remote.
/// Same-worker sends are staged in `pending_local` (eligible next pass).
/// 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> {
tc: &'a TickContext<'a>,
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
@ -451,19 +647,29 @@ struct WorkerContext<'a> {
impl ContextInner for WorkerContext<'_> {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
if self.tc.address_map.contains(&addr) {
self.stats.local_sends.fetch_add(1, Ordering::Relaxed);
self.pending_local.borrow_mut().push((addr, msg));
Ok(())
} else {
self.stats.inbox_sends.fetch_add(1, Ordering::Relaxed);
self.tc.route_nonlocal(addr, msg)
match self.tc.worker_of(&addr) {
Some(w) if w == self.tc.worker_id() => {
self.stats.local_sends.fetch_add(1, Ordering::Relaxed);
self.pending_local.borrow_mut().push((addr, msg));
Ok(())
}
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) {
self.tc.address_map.insert(request.addr);
self.tc.spawn_tx.send(request);
// ctx.spawn pins the child to the current worker.
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) {
@ -500,9 +706,9 @@ impl ContextInner for WorkerContext<'_> {
fn system_info(&self) -> SystemInfo {
SystemInfo {
worker_id: 0,
num_workers: 1,
total_actors: self.tc.worker_stats.num_actors.load(Ordering::Relaxed),
worker_id: self.tc.worker_id().index(),
num_workers: self.tc.num_workers,
total_actors: self.tc.address_map.len(),
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())
}
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();
ActorSummary {
address,
actor_type: metadata.actor_type_name,
message_type: metadata.message_type_name,
worker_id: 0,
worker_id: worker.index(),
parent: slot.parent_addr,
mailbox_depth: slot.mailbox.len(),
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
.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 })
}
fn actor_summaries_into(&self, out: &mut Vec<ActorSummary>) {
fn actor_summaries_into(&self, out: &mut Vec<ActorSummary>, worker: WorkerId) {
out.clear();
out.extend(
self.actors
.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 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();
// 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
rt.tick();
host.try_tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start per instance");
assert_eq!(
handled.load(Ordering::Relaxed),
@ -386,7 +386,7 @@ fn actor_from_birth_to_first_message() {
)
.unwrap();
}
let replies = tick_and_drain(&rt, &count_inbox, 10);
let replies = tick_and_drain(&mut host, &count_inbox, 10);
assert_eq!(
replies,
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
rt.tick();
rt.tick();
host.try_tick();
host.try_tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start not repeated");
// Verify the first actor still responds normally
@ -406,7 +406,7 @@ fn actor_from_birth_to_first_message() {
},
)
.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");
}
@ -414,7 +414,7 @@ fn actor_from_birth_to_first_message() {
/// distributes work. Spawn+send interleaving in a single handler works.
#[test]
fn parent_child_delegation_and_spawn_chains() {
let rt = std_runtime(RuntimeConfig {
let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 2000,
..Default::default()
});
@ -430,7 +430,7 @@ fn parent_child_delegation_and_spawn_chains() {
},
)
.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");
// Act 2: Chain of depth 20
@ -444,7 +444,7 @@ fn parent_child_delegation_and_spawn_chains() {
},
)
.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");
// Act 3: Fan-out to 20 children
@ -457,7 +457,7 @@ fn parent_child_delegation_and_spawn_chains() {
},
)
.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");
}
@ -467,7 +467,7 @@ fn parent_child_delegation_and_spawn_chains() {
fn graceful_stop_lifecycle() {
// --- Part A: SelfStopActor ---
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 addr = rt
@ -487,7 +487,7 @@ fn graceful_stop_lifecycle() {
},
);
}
tick_n(&rt, 10);
tick_n(&mut host, 10);
let mut replies = Vec::new();
while let Some(Done(v)) = inbox.try_recv() {
@ -512,16 +512,16 @@ fn graceful_stop_lifecycle() {
);
// --- 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 addr = rt
.spawn(FarewellActor {
farewell_to: *inbox.addr(),
})
.unwrap();
rt.tick();
host.try_tick();
rt.stop_actor(addr).unwrap();
tick_n(&rt, 5);
tick_n(&mut host, 5);
assert_eq!(
inbox.try_recv(),
Some(Pong),
@ -531,7 +531,7 @@ fn graceful_stop_lifecycle() {
// --- Part C: External stop after pending messages ---
let stopped = 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 addr = rt
.spawn(LifecycleActor {
@ -549,7 +549,7 @@ fn graceful_stop_lifecycle() {
);
}
rt.stop_actor(addr).unwrap();
tick_n(&rt, 10);
tick_n(&mut host, 10);
assert_eq!(
handled.load(Ordering::Relaxed),
10,
@ -563,7 +563,7 @@ fn graceful_stop_lifecycle() {
// --- Part D: External stop before messages → 0 processed ---
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 addr = rt
.spawn(LifecycleActor {
@ -572,7 +572,7 @@ fn graceful_stop_lifecycle() {
handled: handled.clone(),
})
.unwrap();
rt.tick(); // on_start
host.try_tick(); // on_start
rt.stop_actor(addr).unwrap();
for _ in 0..5 {
let _ = rt.send_to(
@ -582,7 +582,7 @@ fn graceful_stop_lifecycle() {
},
);
}
tick_n(&rt, 10);
tick_n(&mut host, 10);
assert_eq!(
handled.load(Ordering::Relaxed),
0,
@ -591,15 +591,15 @@ fn graceful_stop_lifecycle() {
// --- Part E: Mid-mailbox stop trigger ---
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();
rt.tick();
host.try_tick();
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(false)).unwrap();
rt.send_to(addr, Trigger(false)).unwrap();
tick_n(&rt, 5);
tick_n(&mut host, 5);
assert_eq!(
processed.load(Ordering::Relaxed),
3,
@ -614,7 +614,7 @@ fn graceful_stop_lifecycle() {
/// delivered, bulk cleanup, on_start panic also poisons.
#[test]
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 count_inbox = rt.new_inbox::<Count>().unwrap();
@ -636,7 +636,7 @@ fn panic_isolation_and_cleanup() {
reply_to: *inbox.addr(),
},
);
tick_n(&rt, 10);
tick_n(&mut host, 10);
// Healthy actor still works
rt.send_to(
@ -653,7 +653,7 @@ fn panic_isolation_and_cleanup() {
},
)
.unwrap();
let replies = tick_and_drain(&rt, &count_inbox, 10);
let replies = tick_and_drain(&mut host, &count_inbox, 10);
assert_eq!(
replies,
vec![Count(1), Count(2)],
@ -681,7 +681,7 @@ fn panic_isolation_and_cleanup() {
// --- Mid-batch panic discards remaining ---
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 addr = rt
.spawn(PanicAfterNActor {
@ -698,7 +698,7 @@ fn panic_isolation_and_cleanup() {
)
.unwrap();
}
tick_n(&rt, 20);
tick_n(&mut host, 20);
assert_eq!(
counter.load(Ordering::SeqCst),
2,
@ -706,7 +706,7 @@ fn panic_isolation_and_cleanup() {
);
// --- 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 parent = rt.spawn(SpawnThenPanicActor).unwrap();
rt.send_to(
@ -717,11 +717,11 @@ fn panic_isolation_and_cleanup() {
},
)
.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");
// --- 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 addr = rt.spawn(SendThenPanicActor).unwrap();
rt.send_to(
@ -731,11 +731,11 @@ fn panic_isolation_and_cleanup() {
},
)
.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");
// --- 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();
for _ in 0..20 {
addrs.push(rt.spawn(PanicActor).unwrap());
@ -743,7 +743,7 @@ fn panic_isolation_and_cleanup() {
for &addr in &addrs {
let _ = rt.send_to(addr, PanicMsg);
}
tick_n(&rt, 10);
tick_n(&mut host, 10);
let stats = rt.stats();
assert_eq!(
stats.workers[0].num_actors, 0,
@ -756,7 +756,7 @@ fn panic_isolation_and_cleanup() {
/// runtime-level watch works.
#[test]
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 (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(w2_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.
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3);
tick_n(&mut host, 3);
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!(s2.count(), 1, "double-watch still only one notification");
@ -790,14 +790,14 @@ fn watch_notification_contract() {
#[test]
fn watch_edge_cases() {
// 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 addr = rt.spawn(w).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
let rt = std_runtime(RuntimeConfig::default());
let (rt, mut host) = std_host(RuntimeConfig::default());
let spawned = Arc::new(AtomicUsize::new(0));
struct SupervisorWatcher {
@ -830,9 +830,9 @@ fn watch_edge_cases() {
})
.unwrap();
rt.send_to(sup, SupCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3);
tick_n(&mut host, 3);
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5);
tick_n(&mut host, 5);
assert_eq!(
spawned.load(Ordering::SeqCst),
1,
@ -846,7 +846,7 @@ fn watch_edge_cases() {
#[test]
fn lifecycle_decision_paths_match_runtime_behavior() {
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 stopped = 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(),
})
.unwrap();
rt.tick();
host.try_tick();
for _ in 0..msg_count {
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!(handled.load(Ordering::SeqCst), msg_count);
rt.stop_actor(addr).unwrap();
tick_n(&rt, 3);
tick_n(&mut host, 3);
assert_eq!(stopped.load(Ordering::SeqCst), 1);
}
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 stopped = 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(),
})
.unwrap();
rt.tick();
host.try_tick();
rt.stop_actor(addr).unwrap();
for _ in 0..msg_count {
let _ = rt.send_to(addr, Work);
}
tick_n(&rt, 5);
tick_n(&mut host, 5);
assert_eq!(handled.load(Ordering::SeqCst), 0);
assert_eq!(stopped.load(Ordering::SeqCst), 1);
}
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 stopped = Arc::new(AtomicUsize::new(0));
@ -907,27 +907,27 @@ fn lifecycle_decision_paths_match_runtime_behavior() {
stopped: stopped.clone(),
})
.unwrap();
rt.tick();
host.try_tick();
for _ in 0..msg_count {
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!(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 addr = rt
.spawn(PanicOnHandleWithStopReport {
stopped: stopped.clone(),
})
.unwrap();
rt.tick();
host.try_tick();
rt.send_to(addr, Work).unwrap();
tick_n(&rt, 5);
tick_n(&mut host, 5);
assert_eq!(stopped.load(Ordering::SeqCst), 0);
}
@ -936,7 +936,7 @@ fn lifecycle_decision_paths_match_runtime_behavior() {
#[test]
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)] {
let rt = Runtime::new(RuntimeConfig::default());
let (rt, mut host) = plain_host(RuntimeConfig::default());
let report_inbox = rt.new_inbox::<WorkCount>().unwrap();
let report_to = *report_inbox.addr();
@ -956,7 +956,7 @@ fn panicking_actors_do_not_affect_sibling_progress() {
panic_at,
})
.unwrap();
rt.tick();
host.try_tick();
for _ in 0..msg_count {
for &addr in &healthy {
@ -964,12 +964,12 @@ fn panicking_actors_do_not_affect_sibling_progress() {
}
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 {
rt.stop_actor(addr).unwrap();
}
tick_n(&rt, 3);
tick_n(&mut host, 3);
let reports = drain_work_counts(&report_inbox);
assert_eq!(reports.len(), healthy_count);
@ -983,7 +983,7 @@ fn panicking_actors_do_not_affect_sibling_progress() {
#[test]
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)] {
let rt = Runtime::new(RuntimeConfig::default());
let (rt, mut host) = plain_host(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0));
let stopped = 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(),
);
}
tick_n(&rt, 3);
tick_n(&mut host, 3);
assert_eq!(started.load(Ordering::SeqCst), siblings.len());
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();
}
}
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);
for &addr in &siblings {
rt.stop_actor(addr).unwrap();
}
tick_n(&rt, 3);
tick_n(&mut host, 3);
assert_eq!(stopped.load(Ordering::SeqCst), siblings.len());
}
}
@ -1044,7 +1044,7 @@ fn on_start_panic_does_not_block_siblings() {
#[test]
fn multiple_panics_in_same_tick_preserve_healthy_actors() {
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_to = *report_inbox.addr();
@ -1069,7 +1069,7 @@ fn multiple_panics_in_same_tick_preserve_healthy_actors() {
.unwrap(),
);
}
rt.tick();
host.try_tick();
for _ in 0..msgs_each {
for &addr in &healthy {
@ -1079,12 +1079,12 @@ fn multiple_panics_in_same_tick_preserve_healthy_actors() {
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 {
rt.stop_actor(addr).unwrap();
}
tick_n(&rt, 3);
tick_n(&mut host, 3);
let reports = drain_work_counts(&report_inbox);
assert_eq!(reports.len(), healthy_count);

View file

@ -10,7 +10,7 @@ pub use swactor::actor::{
EnvironmentBuilder, ExitReason, ExitValue, LogicalName, MonitorRef, ServiceBinding,
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};
// ── Messages ────────────────────────────────────────────────────────────────
@ -225,19 +225,31 @@ impl ActorInterface for InboxReplyActor {
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Helper: construct a Runtime with StdExtension installed.
pub fn std_runtime(config: RuntimeConfig) -> Runtime {
Runtime::new(config).with_extension(Arc::new(StdExtension::new()))
/// Helper: build a Runtime handle + single-thread host with StdExtension installed.
/// Returns `(rt, host)`: use `rt` for spawn/send/inbox and `host` for ticking.
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.
pub fn tick_until_recv<M: swactor::actor::Message>(
rt: &Runtime,
host: &mut SingleThreadRuntime,
inbox: &Inbox<M>,
max: usize,
) -> Option<M> {
for _ in 0..max {
rt.tick();
host.try_tick();
if let Some(msg) = inbox.try_recv() {
return Some(msg);
}
@ -246,20 +258,20 @@ pub fn tick_until_recv<M: swactor::actor::Message>(
}
/// 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 {
rt.tick();
host.try_tick();
}
}
/// Tick `n` times, then drain all messages from the inbox.
pub fn tick_and_drain<M: swactor::actor::Message>(
rt: &Runtime,
host: &mut SingleThreadRuntime,
inbox: &Inbox<M>,
ticks: usize,
) -> Vec<M> {
for _ in 0..ticks {
rt.tick();
host.try_tick();
}
std::iter::from_fn(|| inbox.try_recv()).collect()
}

View file

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

View file

@ -108,7 +108,7 @@ impl ActorInterface for RingNode {
#[test]
fn message_routing_at_scale() {
// 200-actor numbered routing
let rt = std_runtime(RuntimeConfig {
let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 300,
channel_buffer_size: 1024,
..Default::default()
@ -119,7 +119,7 @@ fn message_routing_at_scale() {
for _ in 0..200 {
addrs.push(rt.spawn(NumberedActor).unwrap());
}
rt.tick();
host.try_tick();
for (i, addr) in addrs.iter().enumerate() {
rt.send_to(
*addr,
@ -130,7 +130,7 @@ fn message_routing_at_scale() {
)
.unwrap();
}
tick_n(&rt, 3);
tick_n(&mut host, 3);
let replies: Vec<NumberedReply> = std::iter::from_fn(|| inbox.try_recv()).collect();
assert_eq!(replies.len(), 200, "all 200 actors replied");
for (i, addr) in addrs.iter().enumerate() {
@ -144,7 +144,7 @@ fn message_routing_at_scale() {
}
// 100-hop ring
let rt = std_runtime(RuntimeConfig {
let (rt, mut host) = std_host(RuntimeConfig {
max_actors: 200,
channel_buffer_size: 1024,
..Default::default()
@ -159,7 +159,7 @@ fn message_routing_at_scale() {
next = addr;
}
ring_addrs.reverse();
rt.tick();
host.try_tick();
rt.send_to(
ring_addrs[0],
RingHop {
@ -168,7 +168,7 @@ fn message_routing_at_scale() {
},
)
.unwrap();
let result = tick_until_recv(&rt, &inbox, 110);
let result = tick_until_recv(&mut host, &inbox, 110);
assert_eq!(
result,
Some(RingDone(100)),
@ -180,7 +180,7 @@ fn message_routing_at_scale() {
/// rapid spawn+immediate-send, multiple inbox types coexist.
#[test]
fn delivery_from_within_handlers() {
let rt = std_runtime(RuntimeConfig::default());
let (rt, mut host) = std_host(RuntimeConfig::default());
// Delegation: spawn+send in handler
let delegator = rt.spawn(DelegatorActor).unwrap();
@ -193,7 +193,7 @@ fn delivery_from_within_handlers() {
},
)
.unwrap();
let reply = tick_until_recv(&rt, &inbox, 20);
let reply = tick_until_recv(&mut host, &inbox, 20);
assert_eq!(
reply,
Some(Done(10)),
@ -210,7 +210,7 @@ fn delivery_from_within_handlers() {
},
)
.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");
// Multiple senders reach same actor
@ -231,7 +231,7 @@ fn delivery_from_within_handlers() {
},
)
.unwrap();
tick_n(&rt, 10);
tick_n(&mut host, 10);
assert!(inbox_a.try_recv().is_some());
assert_eq!(
inbox_b.try_recv(),
@ -240,7 +240,7 @@ fn delivery_from_within_handlers() {
);
// 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();
for _ in 0..50 {
let addr = rt.spawn(PingPongActor).unwrap();
@ -252,11 +252,11 @@ fn delivery_from_within_handlers() {
)
.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");
// 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 pinger_addr = rt.spawn(PingPongActor).unwrap();
let count_inbox = rt.new_inbox::<Count>().unwrap();
@ -275,7 +275,7 @@ fn delivery_from_within_handlers() {
},
)
.unwrap();
tick_n(&rt, 10);
tick_n(&mut host, 10);
assert_eq!(count_inbox.try_recv(), Some(Count(1)));
assert_eq!(pong_inbox.try_recv(), Some(Pong));
}
@ -284,7 +284,7 @@ fn delivery_from_within_handlers() {
/// type_mismatch counter.
#[test]
fn address_error_handling() {
let rt = std_runtime(RuntimeConfig::default());
let (rt, mut host) = std_host(RuntimeConfig::default());
// Nonexistent address
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(0)).unwrap();
rt.send_to(addr, Count(0)).unwrap();
tick_n(&rt, 10);
tick_n(&mut host, 10);
let stats = rt.stats();
let mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
assert_eq!(mismatches, 3, "3 wrong-type messages counted as mismatches");
@ -309,7 +309,7 @@ fn address_error_handling() {
#[test]
fn fairness_budget_prevents_starvation() {
// 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 cold_inbox = rt.new_inbox::<Pong>().unwrap();
let hot = rt
@ -335,7 +335,7 @@ fn fairness_budget_prevents_starvation() {
},
)
.unwrap();
rt.tick();
host.try_tick();
assert!(
cold_inbox.try_recv().is_some(),
"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
let rt = std_runtime(RuntimeConfig {
let (rt, mut host) = std_host(RuntimeConfig {
actor_message_budget: 4,
..Default::default()
});
@ -360,7 +360,7 @@ fn fairness_budget_prevents_starvation() {
},
)
.unwrap();
tick_n(&rt, 30);
tick_n(&mut host, 30);
assert_eq!(
inbox.try_recv(),
Some(Done(0)),
@ -368,7 +368,7 @@ fn fairness_budget_prevents_starvation() {
);
// Unlimited budget (0) drains all
let rt = std_runtime(RuntimeConfig {
let (rt, mut host) = std_host(RuntimeConfig {
actor_message_budget: 0,
..Default::default()
});
@ -388,8 +388,8 @@ fn fairness_budget_prevents_starvation() {
)
.unwrap();
}
rt.tick();
rt.tick();
host.try_tick();
host.try_tick();
assert_eq!(
counter.load(Ordering::SeqCst),
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
//! stateful property testing with automatic shrinking of failing sequences.
mod common;
use common::plain_host;
use std::collections::HashMap;
use proptest::prelude::*;
@ -10,7 +13,7 @@ use proptest_state_machine::{ReferenceStateMachine, StateMachineTest, prop_state
use swactor::actor::{ActorAddress, ActorInterface};
use swactor::config::RuntimeConfig;
use swactor::runtime::{Ctx, Inbox, Runtime};
use swactor::runtime::{Ctx, Inbox, Runtime, SingleThreadRuntime};
// ─── Shared Actor Types ────────────────────────────────────────────────────
@ -76,7 +79,7 @@ proptest! {
actor_message_budget: budget,
..Default::default()
};
let rt = Runtime::new(config);
let (rt, mut host) = plain_host(config);
let inbox = rt.new_inbox::<Ping>().unwrap();
let mut addrs = Vec::new();
@ -84,7 +87,7 @@ proptest! {
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
for addr in &addrs {
@ -94,7 +97,7 @@ proptest! {
}
// Single tick — each actor should process at most `budget` messages
rt.tick();
host.try_tick();
// Drain inbox to count replies per actor
// 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.
#[test]
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();
for _ in 0..n {
addrs.push(rt.spawn(NoopActor).unwrap());
}
rt.tick(); // process spawns
host.try_tick(); // process spawns
let stats = rt.stats();
prop_assert_eq!(stats.actors.len(), n, "Expected {} actors in stats", n);
@ -286,6 +289,8 @@ impl ReferenceStateMachine for SwactorModel {
struct SutState {
runtime: Runtime,
/// Single-thread host that owns the workers; ticking lives here.
host: SingleThreadRuntime,
inbox: Inbox<Ping>,
/// Maps reference actor_id to actual ActorAddress
actor_map: HashMap<usize, ActorAddress>,
@ -304,10 +309,11 @@ impl StateMachineTest for SwactorTest {
type Reference = SwactorModel;
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();
SutState {
runtime: rt,
host,
inbox,
actor_map: HashMap::new(),
panic_ids: Vec::new(),
@ -357,11 +363,11 @@ impl StateMachineTest for SwactorTest {
}
}
Transition::Tick => {
sut.runtime.tick();
sut.host.try_tick();
}
Transition::TickN(n) => {
for _ in 0..n {
sut.runtime.tick();
sut.host.try_tick();
}
}
Transition::StopActor(idx) => {

View file

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

View file

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

View file

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