swactor/crates/iroh-driver/tests/iroh_driver.rs

298 lines
10 KiB
Rust
Raw Permalink Normal View History

//! Integration tests for the iroh-based P2P driver.
//!
//! These tests verify that IrohDriver can:
//! - Create endpoints with matching identities
//! - Form clusters via join
//! - Detect membership changes through SWIM
//! - Reject unauthorized peers
//!
//! Runs in the `iroh-driver` crate, where iroh support is always available.
pub mod common;
use std::sync::Arc;
use std::time::{Duration, Instant};
use common::iroh::*;
use distribution::peer_auth::PeerAllowList;
use iroh::PublicKey;
use iroh_driver::{EndpointAddrMask, advertised_endpoint};
use parking_lot::Mutex;
// ─── Identity tests ─────────────────────────────────────────────────────
#[test]
fn iroh_driver_creates_with_unique_identity() {
let mut d1 = make_driver();
let mut d2 = make_driver();
assert_ne!(d1.node_id(), d2.node_id());
d1.shutdown();
d2.shutdown();
}
#[test]
fn iroh_driver_reports_listen_addr_and_no_routes() {
// A freshly created driver knows where it listens and, having learned no
// peers, has converged on an empty directory route view.
let mut driver = make_driver();
assert!(!driver.listen_addr().is_empty());
assert_eq!(driver.directory_route_count(), 0);
driver.shutdown();
}
#[test]
fn endpoint_addr_includes_home_relay() {
let (relay_url, _relay_guard) = spawn_test_relay();
let expected_relay_url = relay_url.to_string();
let mut node = make_driver_with_relay(relay_url.clone());
let start = Instant::now();
let (relay_advertised, observed_relay_url) = loop {
let endpoint = node.endpoint_addr();
let current_relay_url = endpoint.relay_urls().next().map(|url| url.to_string());
if current_relay_url.as_deref() == Some(expected_relay_url.as_str()) {
break (true, current_relay_url);
}
if start.elapsed() >= Duration::from_secs(5) {
break (false, current_relay_url);
}
std::thread::sleep(Duration::from_millis(10));
};
node.shutdown();
assert!(
relay_advertised,
"advertised endpoint did not include home relay {expected_relay_url} within timeout; last relay URL: {observed_relay_url:?}"
);
}
#[test]
fn relay_masked_local_nodes_join_through_only_the_relay() {
let (relay_url, _relay_guard) = spawn_test_relay();
let mut node_a = make_driver_with_relay(relay_url.clone());
let mut node_b = make_driver_with_relay(relay_url);
node_a
.driver
.wait_for_relay_endpoint()
.expect("node A relay ready");
node_b
.driver
.wait_for_relay_endpoint()
.expect("node B relay ready");
let a_addr = advertised_endpoint(node_a.endpoint_addr(), EndpointAddrMask::RelayOnly)
.expect("mask node A endpoint");
let b_addr = advertised_endpoint(node_b.endpoint_addr(), EndpointAddrMask::RelayOnly)
.expect("mask node B endpoint");
assert_eq!(a_addr.ip_addrs().count(), 0);
assert_eq!(b_addr.ip_addrs().count(), 0);
assert_eq!(a_addr.relay_urls().count(), 1);
assert_eq!(b_addr.relay_urls().count(), 1);
node_a.join(std::slice::from_ref(&b_addr));
node_b.join(std::slice::from_ref(&a_addr));
let converged = pump_until_pair(&mut node_a, &mut node_b, Duration::from_secs(5), |a, b| {
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
sees_alive(a, &b_key) && sees_alive(b, &a_key)
});
node_a.shutdown();
node_b.shutdown();
assert!(
converged,
"relay-only masked nodes did not converge through the relay"
);
}
// ─── Join integration tests ─────────────────────────────────────────────
#[test]
fn two_nodes_form_cluster_via_join() {
let mut node_a = make_driver();
let mut node_b = make_driver();
let b_addr = node_b.endpoint_addr();
node_a.join(&[b_addr]);
let converged = pump_until_pair(&mut node_a, &mut node_b, Duration::from_secs(5), |a, b| {
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
sees_alive(a, &b_key) && sees_alive(b, &a_key)
});
assert!(converged, "nodes did not converge within timeout");
assert_eq!(node_a.alive_count(), 1, "node_a should see 1 alive peer");
assert_eq!(node_b.alive_count(), 1, "node_b should see 1 alive peer");
node_a.shutdown();
node_b.shutdown();
}
#[test]
fn two_nodes_form_cluster_via_mutual_join() {
let mut node_a = make_driver();
let mut node_b = make_driver();
let b_addr = node_b.endpoint_addr();
let a_addr = node_a.endpoint_addr();
node_a.join(&[b_addr]);
node_b.join(&[a_addr]);
let converged = pump_until_pair(&mut node_a, &mut node_b, Duration::from_secs(5), |a, b| {
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
sees_alive(a, &b_key) && sees_alive(b, &a_key)
});
assert!(
converged,
"nodes did not converge within timeout (mutual join)"
);
assert_eq!(node_a.alive_count(), 1);
assert_eq!(node_b.alive_count(), 1);
node_a.shutdown();
node_b.shutdown();
}
#[test]
fn two_nodes_form_cluster_with_peer_auth() {
let auth_a = Arc::new(Mutex::new(PeerAllowList::open()));
let auth_b = Arc::new(Mutex::new(PeerAllowList::open()));
let mut node_a = make_driver_with_auth(auth_a.clone());
let mut node_b = make_driver_with_auth(auth_b.clone());
let a_id = node_a.node_id();
let b_id = node_b.node_id();
let b_addr = node_b.endpoint_addr();
// Switch to restrictive mode by adding each other
auth_a.lock().add_peer(b_id, "node-b".into());
auth_b.lock().add_peer(a_id, "node-a".into());
node_a.join(&[b_addr]);
let converged = pump_until_pair(&mut node_a, &mut node_b, Duration::from_secs(5), |a, b| {
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
sees_alive(a, &b_key) && sees_alive(b, &a_key)
});
assert!(
converged,
"nodes with peer auth did not converge within timeout"
);
assert_eq!(node_a.alive_count(), 1);
assert_eq!(node_b.alive_count(), 1);
node_a.shutdown();
node_b.shutdown();
}
#[test]
fn peer_auth_prevents_unauthorized_join() {
let mut node_a = make_driver();
// Node B has auth with only a dummy peer — node_a is NOT authorized
let auth_b = Arc::new(Mutex::new(PeerAllowList::open()));
let dummy_id = distribution::types::NodeId([0xAA; 32]);
auth_b.lock().add_peer(dummy_id, "dummy".into());
let mut node_b = make_driver_with_auth(auth_b);
let b_addr = node_b.endpoint_addr();
node_a.join(&[b_addr]);
let converged = pump_until_pair(&mut node_a, &mut node_b, Duration::from_secs(3), |_a, b| {
b.alive_count() > 0
});
assert!(!converged, "unauthorized peer should NOT have joined");
assert_eq!(node_b.alive_count(), 0, "node_b should have no alive peers");
node_a.shutdown();
node_b.shutdown();
}
// ─── Multi-node tests ───────────────────────────────────────────────────
#[test]
fn three_nodes_converge_via_star_join() {
let mut cluster = IrohTestCluster::star(3);
let converged = cluster.pump_until(Duration::from_secs(10), |nodes| {
nodes.iter().all(|d| d.alive_count() == 2)
});
assert!(converged, "3-node star did not converge");
cluster.shutdown();
}
// ─── Goal 2 (real-QUIC) — genuine death is detected ──────────────────────
#[test]
fn goal2_shutdown_node_is_detected_dead_by_survivors() {
// BEHAVIORAL_TEST_SPEC Goal 2 over real QUIC: shut one node down for real and
// poll until the survivors converge on it being Dead — a genuine probe
// timeout, not an injected death. Observed only through the membership view.
let mut cluster = IrohTestCluster::star(3);
let converged = cluster.pump_until(Duration::from_secs(10), |nodes| {
nodes.iter().all(|d| d.alive_count() == 2)
});
assert!(converged, "precondition: 3-node star must converge");
let dead = 2usize;
let dead_key = cluster.key(dead);
cluster.shutdown_one(dead);
feat(engine): substrate-neutral execution engine abstraction 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.
2026-08-10 20:23:03 +00:00
// The survivors' probes to the dead node now truly fail; poll until both
// converge on it being Dead.
let detected = cluster.pump_until(Duration::from_secs(30), |drivers| {
drivers
.iter()
.enumerate()
.all(|(i, d)| i == dead || sees_dead(d, &dead_key))
});
assert!(detected, "survivors must detect the shut-down node as Dead");
// Tear down the two survivors (the third is already shut down).
cluster[0].shutdown();
cluster[1].shutdown();
}
feat(engine): substrate-neutral execution engine abstraction 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.
2026-08-10 20:23:03 +00:00
// ─── Capability binding (ENGINE_SPEC.md) ──────────────────────
#[test]
fn driver_rejects_engine_without_io() {
// The SteppingBackend advertises tasks + timers + blocking but NOT io.
// The driver requires tasks + timers + io, so construction must fail
// before any endpoint is bound or background work starts.
use distribution::node::DistributedNodeConfig;
use iroh::RelayMode;
use iroh_driver::{IrohDriver, IrohDriverConfig};
use swactor::config::RuntimeConfig;
use swactor::runtime::RuntimeParts;
feat(engine): substrate-neutral execution engine abstraction 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.
2026-08-10 20:23:03 +00:00
use swactor_engine::{Engine, SteppingBackend};
let parts = RuntimeParts::new(RuntimeConfig::default());
let engine = Engine::new(parts, SteppingBackend::default()).expect("stepping engine");
feat(engine): substrate-neutral execution engine abstraction 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.
2026-08-10 20:23:03 +00:00
let result = IrohDriver::with_engine(
engine.handle(),
IrohDriverConfig {
secret_key: None,
relay_mode: RelayMode::Disabled,
bind_port: None,
feat(engine): substrate-neutral execution engine abstraction 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.
2026-08-10 20:23:03 +00:00
node: DistributedNodeConfig::default(),
peer_auth: None,
additional_alpns: vec![],
},
);
assert!(
result.is_err(),
"driver must reject an engine that lacks the io capability"
);
}