Introduce the swactor engine: a swactor-owned composite that retains a selected execution substrate, drives the core runtime, and hosts the async/blocking/timer work that backs actors. Integrations receive one cloneable EngineHandle and never construct or borrow a raw Tokio runtime/handle. Engine crate (crates/engine): - The contract: spawn / spawn_blocking / timer / interval / now, a per-implementation capability model with construction-time binding (require()), and engine-owned time. The engine owns all progression; actor handlers stay synchronous and never .await. - TokioBackend owns the Tokio runtime and schedules core ticks and supporting futures on it; SteppingBackend is a single-threaded deterministic scheduler with virtual time (the non-Tokio portability proof). Core is driven through its existing tick() surface; a self-rescheduling CoreDriver is installed at construction and is the sole place permitted to call try_tick. iroh-driver: - Receives an EngineHandle instead of a raw Tokio Handle. Accepts, reads, dials, writes, endpoint construction, and teardown schedule through it; required capabilities (tasks/timers/io) are validated before the endpoint binds. Engine-hosted interval pumps drive actor-bridge, datastream, and edge ingress. myelin: - One node/orchestrator engine owns core, protocol tick injection, and transport progression; the application loop only drains integration-owned queues. Stage-shard process readers, delayed actor messages, helper stdout/stderr, prompt RPC, and CPU sampling all schedule through the engine (spawn_blocking / engine tasks / timers). - Removed the split-engine APIs: install_actor_bridge_pump(period) and spawn_protocol_ticker(period) use each component's stored engine; deleted the no-op pump_network callback and its plumbing; deleted the dashboard raw-Tokio/standalone-runtime conveniences. Enforcement: - A clippy disallowed-methods boundary forbids direct runtime/scheduling/ time/core-driving bypasses, denied in swactor-engine, iroh-driver, and myelin. Retained excluded uses (VastAI provider, provider process supervision/log capture, OS-signal/stdin/process-control sequencing) carry narrow allowances with reasons. Verification: - Engine contract + unit tests (incl. the SteppingBackend portability proof), iroh integration tests (capability rejection before binding, multi-node actor behavior), and a production execution-composition smoke test that observes engine-driven actor progress with no ambient Tokio runtime and no manual tick/pump. Workspace all-target/all-feature clippy and tests are green. Specs co-located with their crates: ENGINE_SPEC.md in crates/engine, IROH_DRIVER_SPEC.md in crates/iroh-driver. VastAI remains explicitly out of scope pending its separate redesign.
187 lines
6.7 KiB
Rust
187 lines
6.7 KiB
Rust
//! Production execution-composition smoke test (ENGINE_SPEC.md).
|
|
//!
|
|
//! Verifies the real process-local execution composition for one Myelin node:
|
|
//! a swactor `Engine` over a Tokio substrate owns the core runtime and drives
|
|
//! it; the production distribution runtime/actors are constructed on that
|
|
//! engine; a real `IrohDriver` is bound through `EngineHandle` with
|
|
//! relay-disabled networking; actor-bridge and protocol-ticker progression are
|
|
//! installed on that same engine; and observable actor progress happens with no
|
|
//! ambient Tokio runtime and no application call to `tick`, `try_tick`,
|
|
//! `has_work`, or a manual network pump.
|
|
//!
|
|
//! The composition shares the production wiring (`DistributionRuntimeStack` +
|
|
//! `IrohDriver`); it does not duplicate a fake version of it. The only blocking
|
|
//! here is test-side observation polling — never engine work.
|
|
|
|
use std::time::{Duration, Instant};
|
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx, Message};
|
|
use swactor::runtime::Inbox;
|
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
|
|
|
use distribution::node::DistributedNodeConfig;
|
|
use iroh::RelayMode;
|
|
use iroh_driver::{IrohDriver, IrohDriverConfig};
|
|
|
|
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
|
|
|
const PROBE_TICK: Duration = Duration::from_millis(10);
|
|
const PROBE_DEADLINE: Duration = Duration::from_secs(8);
|
|
|
|
// ── Local probe actor ──────────────────────────────────────────────────────
|
|
|
|
/// Probe message: replies `ProbePong` to a captured address.
|
|
#[derive(Clone)]
|
|
struct ProbePing;
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
struct ProbePong;
|
|
|
|
struct EchoProbe {
|
|
reply_to: ActorAddress,
|
|
}
|
|
|
|
impl ActorInterface for EchoProbe {
|
|
type Incoming = ProbePing;
|
|
type Response = ();
|
|
fn handle(&mut self, ctx: &Ctx, _: ProbePing) {
|
|
let _ = ctx.send(self.reply_to, ProbePong);
|
|
}
|
|
}
|
|
|
|
// ── Production composition ─────────────────────────────────────────────────
|
|
|
|
/// Build the production composition for one node, mirroring the node boot
|
|
/// sequence: build the core runtime → the engine owns and drives it → the iroh
|
|
/// driver is bound through the engine handle → the distribution stack is built
|
|
/// 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) =
|
|
DistributionRuntimeStack::build_runtime(|_| {}, None);
|
|
let engine = Engine::new(
|
|
runtime.clone(),
|
|
TokioBackend::new(TokioConfig::default()).expect("build tokio backend"),
|
|
)
|
|
.expect("build engine");
|
|
|
|
let mut driver = IrohDriver::with_engine(
|
|
engine.handle(),
|
|
IrohDriverConfig {
|
|
secret_key: None,
|
|
relay_mode: RelayMode::Disabled,
|
|
node: DistributedNodeConfig::default(),
|
|
peer_auth: None,
|
|
additional_alpns: vec![],
|
|
},
|
|
)
|
|
.expect("build iroh driver with engine handle");
|
|
|
|
let stack = DistributionRuntimeStack::new_from_runtime(
|
|
runtime,
|
|
codec,
|
|
transport_router,
|
|
driver.node_id(),
|
|
DistributedNodeConfig::default(),
|
|
engine.handle(),
|
|
);
|
|
|
|
driver.enable_actor_bridge(
|
|
stack.runtime.clone(),
|
|
stack.codec.clone(),
|
|
stack.actor_bridge_routes(),
|
|
stack.actors.swim,
|
|
stack.relay_mirror.clone(),
|
|
stack.route_view.clone(),
|
|
stack.outbox.clone(),
|
|
);
|
|
// Engine-hosted protocol tick injection + adapter progression — no manual
|
|
// pump is wired anywhere.
|
|
stack.spawn_protocol_ticker(PROBE_TICK);
|
|
driver.install_actor_bridge_pump(PROBE_TICK);
|
|
|
|
(engine, driver, stack)
|
|
}
|
|
|
|
/// Poll an inbox until a value arrives or the deadline elapses. The only
|
|
/// `thread::sleep` in this module: test observation, not engine work.
|
|
#[allow(clippy::disallowed_methods)]
|
|
fn recv_within<T: Message>(inbox: &Inbox<T>, deadline: Duration) -> Option<T> {
|
|
let started = Instant::now();
|
|
loop {
|
|
if let Some(value) = inbox.try_recv() {
|
|
return Some(value);
|
|
}
|
|
if started.elapsed() >= deadline {
|
|
return None;
|
|
}
|
|
std::thread::sleep(PROBE_TICK);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn engine_drives_actor_progress_without_manual_tick() {
|
|
let (engine, _driver, stack) = build_composition();
|
|
|
|
// A probe actor plus an external inbox observe its reply. Delivery and the
|
|
// reply are processed entirely by engine-driven core progression — this
|
|
// test never calls tick / try_tick / has_work and pumps no network queue.
|
|
let pong_inbox = stack
|
|
.runtime
|
|
.new_inbox::<ProbePong>()
|
|
.expect("create pong inbox");
|
|
let echo = stack
|
|
.runtime
|
|
.spawn(EchoProbe {
|
|
reply_to: *pong_inbox.addr(),
|
|
})
|
|
.expect("spawn echo probe");
|
|
stack
|
|
.runtime
|
|
.send_to(echo, ProbePing)
|
|
.expect("send probe ping");
|
|
|
|
let pong = recv_within(&pong_inbox, PROBE_DEADLINE);
|
|
// Keep the engine alive until the observation completes.
|
|
drop(engine);
|
|
assert_eq!(pong, Some(ProbePong), "engine did not drive actor progress");
|
|
}
|
|
|
|
#[cfg(feature = "dashboard")]
|
|
#[test]
|
|
fn dashboard_server_is_scheduled_through_the_engine() {
|
|
// The dashboard server future is scheduled through the Myelin engine path
|
|
// (engine.spawn(handle.http_server())), exactly as in production, without
|
|
// constructing another runtime (ENGINE_SPEC.md).
|
|
let free_port = std::net::TcpListener::bind(("127.0.0.1", 0))
|
|
.expect("probe bind for free port")
|
|
.local_addr()
|
|
.expect("probe local addr")
|
|
.port();
|
|
|
|
let (engine, _driver, _stack) = build_composition();
|
|
let mut config = dashboard::DashboardConfig::default();
|
|
config.port = free_port;
|
|
let handle = dashboard::DashboardHandle::new(config);
|
|
engine.handle().spawn(handle.http_server());
|
|
|
|
// Behavioral proof the server future is actually running on the engine:
|
|
// the bound port accepts a TCP connection. No second runtime is involved.
|
|
let connected = poll_connect(("127.0.0.1", free_port), PROBE_DEADLINE);
|
|
drop(engine);
|
|
assert!(connected, "dashboard server did not accept connections");
|
|
}
|
|
|
|
#[cfg(feature = "dashboard")]
|
|
#[allow(clippy::disallowed_methods)]
|
|
fn poll_connect(addr: (&str, u16), deadline: Duration) -> bool {
|
|
use std::net::TcpStream;
|
|
let started = Instant::now();
|
|
loop {
|
|
if TcpStream::connect(addr).is_ok() {
|
|
return true;
|
|
}
|
|
if started.elapsed() >= deadline {
|
|
return false;
|
|
}
|
|
std::thread::sleep(PROBE_TICK);
|
|
}
|
|
}
|