//! T-orchestrator: TEST_SPEC §9 — the seed spawn chain, the vast.ai //! lease chain (mocked HTTP), and the convergence-wait helper. //! //! None of these tests touch real iroh, real vast.ai, or the //! `pp-gpu-node` binary. The spawn chain is exercised with a `sh -c` //! "fake child" that prints `PP_GPU_NODE_ADDR ...` and then sleeps; the //! vast.ai chain uses `wiremock`; the convergence-wait helper is a //! pure function fed a closure. use std::path::PathBuf; use std::process::Command; use std::sync::{Arc, Mutex}; use std::time::Duration; use pipeline_parallel_inference::orchestrator::{ await_convergence, spawn_chain, ConvergeError, SpawnChainError, StageSpawnCtx, }; use pipeline_parallel_inference::vastai; use reqwest::Client; use wiremock::matchers::{header, method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; // ─── helpers for spawn_chain tests ──────────────────────────────────── /// A unique tempdir per test, so parallel tests do not collide and /// teardown is automatic on drop. struct TempDir { path: PathBuf, } impl TempDir { fn new(tag: &str) -> Self { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let pid = std::process::id(); let path = std::env::temp_dir().join(format!("pp-t-orch-{tag}-{pid}-{nanos}")); std::fs::create_dir_all(&path).expect("create tempdir"); Self { path } } fn child(&self, name: &str) -> PathBuf { self.path.join(name) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); } } /// Build a `sh -c` command for a fake child that /// /// 1. Records its OS pid to `pids_file` (newline-terminated). /// 2. Prints a deterministic `PP_GPU_NODE_ADDR ` /// line so [`spawn_chain`] can parse it. /// 3. Sleeps for `sleep_secs` so it remains alive until killed. /// /// `fake_hex` / `fake_direct` are derived from `ctx.stage` so each /// stage's announcement is unique and predictable from the test side. fn fake_child_command( ctx: &StageSpawnCtx, pids_file: &PathBuf, sleep_secs: u32, ) -> Command { let hex = fake_hex(ctx.stage); let direct = fake_direct(ctx.stage); let script = format!( "echo $$ >> {pids}; echo 'PP_GPU_NODE_ADDR {hex} {direct}'; exec sleep {sleep_secs}", pids = shell_escape(pids_file), ); let mut cmd = Command::new("sh"); cmd.arg("-c").arg(script); cmd } fn shell_escape(p: &PathBuf) -> String { // The tempdir paths are tag-pid-nanos — no shell-special characters // expected. Wrap in single quotes anyway so a stray dot or dash never // turns into a glob. format!("'{}'", p.display()) } fn fake_hex(stage: u32) -> String { // 64 hex chars total, with the stage index baked into the last byte // so each is distinguishable but every entry is structurally valid // for downstream parsers that expect 32-byte hex. let prefix: String = std::iter::repeat('a').take(62).collect(); format!("{prefix}{stage:02x}") } fn fake_direct(stage: u32) -> String { format!("127.0.0.1:{}", 40000 + stage) } /// Read every PID line out of `pids_file` (one per line). fn read_pids(pids_file: &PathBuf) -> Vec { let contents = std::fs::read_to_string(pids_file).unwrap_or_default(); contents .lines() .filter_map(|l| l.trim().parse::().ok()) .collect() } /// Returns true if `/proc/` exists, i.e. the kernel still has an /// entry for that pid. After a child has been `wait()`ed on, the entry /// disappears almost immediately. fn pid_is_alive(pid: u32) -> bool { std::path::Path::new(&format!("/proc/{pid}")).exists() } // ─── §9.1 spawn chain ───────────────────────────────────────────────── #[test] fn spawn_chain_propagates_each_stage_peer_direct_to_successor() { let tmp = TempDir::new("propagates"); let pids_file = tmp.child("pids"); let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); let cap = captured.clone(); let pf = pids_file.clone(); let guard = spawn_chain(4, Duration::from_secs(5), move |ctx| { cap.lock().unwrap().push(ctx.clone()); fake_child_command(&ctx, &pf, 30) }) .expect("spawn_chain must succeed"); let calls = captured.lock().unwrap(); assert_eq!(calls.len(), 4); assert!(calls[0].peer.is_none(), "stage 0 must have no PEER_DIRECT"); for i in 1..calls.len() { let peer = calls[i].peer.as_ref().unwrap_or_else(|| { panic!("stage {i} must have peer set from stage {}", i - 1) }); assert_eq!( peer.hex, fake_hex((i - 1) as u32), "stage {i} PEER_NODE_ID must match stage {}'s announcement", i - 1, ); assert_eq!( peer.direct, fake_direct((i - 1) as u32), "stage {i} PEER_DIRECT must match stage {}'s announcement", i - 1, ); } drop(guard); // tears down the sleep children } #[test] fn spawn_chain_reads_pp_gpu_node_addr_in_order() { let tmp = TempDir::new("reads-in-order"); let pids_file = tmp.child("pids"); let pf = pids_file.clone(); let guard = spawn_chain(3, Duration::from_secs(5), move |ctx| { fake_child_command(&ctx, &pf, 30) }) .expect("spawn_chain must succeed"); let stages = guard.stages(); assert_eq!(stages.len(), 3); for (i, spawned) in stages.iter().enumerate() { assert_eq!(spawned.stage, i as u32, "stage ordering"); assert_eq!( spawned.addr.hex, fake_hex(i as u32), "stage {i} announcement", ); assert_eq!(spawned.addr.direct, fake_direct(i as u32)); } } #[test] fn spawn_chain_kills_already_spawned_on_failure() { let tmp = TempDir::new("kill-on-spawn-fail"); let pids_file = tmp.child("pids"); let pf = pids_file.clone(); let result = spawn_chain(4, Duration::from_secs(5), move |ctx| { if ctx.stage == 2 { // Stage 2's binary does not exist; cmd.spawn() will fail. return Command::new("/this/path/intentionally/does/not/exist"); } fake_child_command(&ctx, &pf, 60) }); match result { Err(SpawnChainError::Spawn { stage: 2, .. }) => {} other => panic!("expected Spawn error for stage 2, got {other:?}"), } // Stages 0 and 1 were spawned and then dropped by the guard on // error. Drop ran kill+wait synchronously, so the pids should no // longer have /proc entries. Give the kernel a moment to reap. std::thread::sleep(Duration::from_millis(100)); let pids = read_pids(&pids_file); assert_eq!(pids.len(), 2, "stages 0 and 1 should have started"); for pid in pids { assert!( !pid_is_alive(pid), "child pid {pid} should have been killed after spawn_chain failure", ); } } #[test] fn spawn_chain_kills_already_spawned_on_addr_timeout() { let tmp = TempDir::new("kill-on-timeout"); let pids_file = tmp.child("pids"); let pf = pids_file.clone(); let result = spawn_chain(3, Duration::from_millis(400), move |ctx| { if ctx.stage == 1 { // Stage 1 starts but never announces PP_GPU_NODE_ADDR. // Record its pid so we can verify the kill happened, then // sleep so we exceed the timeout. let script = format!( "echo $$ >> {pids}; exec sleep 30", pids = shell_escape(&pf), ); let mut cmd = Command::new("sh"); cmd.arg("-c").arg(script); return cmd; } fake_child_command(&ctx, &pf, 30) }); match result { Err(SpawnChainError::AddressTimeout { stage: 1, .. }) => {} other => panic!("expected AddressTimeout for stage 1, got {other:?}"), } std::thread::sleep(Duration::from_millis(100)); let pids = read_pids(&pids_file); assert_eq!( pids.len(), 2, "stages 0 and 1 should each have written a pid", ); for pid in pids { assert!( !pid_is_alive(pid), "child pid {pid} should have been killed after addr timeout", ); } } #[test] fn spawn_chain_supports_num_stages_two_through_eight() { for n in 2u32..=8 { let tag = format!("n-{n}"); let tmp = TempDir::new(&tag); let pids_file = tmp.child("pids"); let pf = pids_file.clone(); let guard = spawn_chain(n, Duration::from_secs(5), move |ctx| { fake_child_command(&ctx, &pf, 30) }) .unwrap_or_else(|e| panic!("spawn_chain N={n} must succeed: {e}")); assert_eq!(guard.len(), n as usize, "all {n} stages must be spawned"); for (i, s) in guard.stages().iter().enumerate() { assert_eq!(s.stage, i as u32); assert_eq!(s.addr.hex, fake_hex(i as u32)); } drop(guard); } } // ─── §9.2 vast.ai lease chain ───────────────────────────────────────── const SEED_ADDR: &str = "iroh://pp-seed-abc123"; const IMAGE: &str = "swactor-pp-gpu:latest"; const API_KEY: &str = "lease-test-key"; /// Build a JSON `offers` body with `count` synthetic offers, each cheaper /// than the next so the find-offer client's price-sort is deterministic. fn offers_json(count: u32) -> serde_json::Value { let offers: Vec<_> = (0..count) .map(|i| { serde_json::json!({ "id": 1000 + i, "gpu_name": "RTX 4090", "dph_total": 0.10 + (i as f64) * 0.01, "geolocation": "US", }) }) .collect(); serde_json::json!({ "offers": offers }) } /// Mount the `/api/v0/bundles/?q=...` GET so every find_offer call gets /// the same fixed candidate list. The lease chain's client-side filter /// is what enforces distinctness via `exclude_ids`. async fn mount_offers(server: &MockServer, count: u32) { Mock::given(method("GET")) .and(path_regex(r"^/api/v0/bundles/")) .respond_with(ResponseTemplate::new(200).set_body_json(offers_json(count))) .mount(server) .await; } /// Mount per-offer create endpoints that succeed and return distinct /// contract ids `(1000 + offer_id_offset, …)`. async fn mount_creates_ok(server: &MockServer, num_stages: u32) { for i in 0..num_stages { let offer_id = 1000 + i; let contract_id = 9000 + i; Mock::given(method("PUT")) .and(path_regex(format!("^/api/v0/asks/{offer_id}/$").as_str())) .and(header("Authorization", format!("Bearer {API_KEY}").as_str())) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({ "new_contract": contract_id, })), ) .expect(1) .mount(server) .await; } } /// Mount per-contract `running` status responses so `wait_for_running` /// resolves in one poll for each. `expected` is enforced so the test /// would fail if a contract was skipped. async fn mount_status_running(server: &MockServer, contract_ids: &[u64]) { for &id in contract_ids { Mock::given(method("GET")) .and(path_regex(format!("^/api/v0/instances/{id}/$").as_str())) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({ "instances": { "actual_status": "running", "intended_status": "running", "public_ipaddr": "203.0.113.10", "ssh_port": 22, } })), ) .expect(1..) .mount(server) .await; } } #[tokio::test] async fn lease_chain_finds_n_distinct_offers() { let server = MockServer::start().await; mount_offers(&server, 6).await; mount_creates_ok(&server, 4).await; mount_status_running(&server, &[9000, 9001, 9002, 9003]).await; let client = Client::new(); let infos = vastai::lease_chain( &client, &server.uri(), API_KEY, "RTX 4090", 4, SEED_ADDR, None, IMAGE, None, None, Duration::from_millis(10), 3, None, ) .await .expect("lease_chain must succeed when N distinct offers exist"); // Contract: N pairwise-distinct instances, each drawn from the catalog. // Which offer a given stage lands on is an implementation detail // (find_offer takes the median-priced candidate, and a create that misses // an unmounted offer falls back to another), so we assert distinctness + // membership rather than a fixed id order. let ids: Vec = infos.iter().map(|i| i.contract_id).collect(); assert_eq!(ids.len(), 4, "must lease N=4 instances"); let mut unique = ids.clone(); unique.sort(); unique.dedup(); assert_eq!(unique.len(), 4, "the 4 leased contracts must be distinct"); assert!( ids.iter().all(|c| (9000..=9003).contains(c)), "every leased contract must come from the mounted catalog, got {ids:?}", ); } #[tokio::test] async fn lease_chain_creates_n_instances_with_distinct_stage_env() { let server = MockServer::start().await; // Exactly N offers, all creatable, so the median-priced selection never // has to fall back to an unmounted offer — keeping the create count at one // PUT per stage regardless of which offer each stage picks. mount_offers(&server, 3).await; mount_creates_ok(&server, 3).await; mount_status_running(&server, &[9000, 9001, 9002]).await; let client = Client::new(); vastai::lease_chain( &client, &server.uri(), API_KEY, "RTX 4090", 3, SEED_ADDR, None, IMAGE, None, None, Duration::from_millis(10), 3, None, ) .await .expect("lease_chain must succeed"); let reqs = server.received_requests().await.unwrap(); let puts: Vec<_> = reqs .iter() .filter(|r| r.method.as_ref() == "PUT") .collect(); assert_eq!(puts.len(), 3, "exactly one create (PUT) per stage"); // Contract: the three creates collectively cover STAGE 0/1/2 exactly once // each, and every one carries NUM_STAGES=3. We assert STAGE as a set // rather than tying it to a specific offer id, since which offer hosts a // given stage is up to the (median-priced) selector. let mut stages: Vec = Vec::new(); for r in &puts { let body: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); assert_eq!( body["env"]["NUM_STAGES"], "3", "every create must carry NUM_STAGES=3", ); stages.push( body["env"]["STAGE"] .as_str() .expect("STAGE env must be a string") .to_string(), ); } stages.sort(); assert_eq!( stages, vec!["0", "1", "2"], "the creates must cover STAGE 0,1,2 exactly once each", ); } #[tokio::test] async fn lease_chain_rolls_back_on_partial_creation() { let server = MockServer::start().await; mount_offers(&server, 3).await; // Stages 0 and 1 create OK; stage 2 fails. for i in 0..2u32 { Mock::given(method("PUT")) .and(path_regex(format!("^/api/v0/asks/{}/$", 1000 + i).as_str())) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({ "new_contract": 9000 + i, })), ) .expect(1) .mount(&server) .await; } Mock::given(method("PUT")) .and(path_regex("^/api/v0/asks/1002/$")) .respond_with(ResponseTemplate::new(500).set_body_string("boom")) .expect(1) .mount(&server) .await; // Rollback should DELETE 9000 and 9001. for &id in &[9000u64, 9001] { Mock::given(method("DELETE")) .and(path_regex(format!("^/api/v0/instances/{id}/$").as_str())) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({"success": true})), ) .expect(1) .mount(&server) .await; } let client = Client::new(); let result = vastai::lease_chain( &client, &server.uri(), API_KEY, "RTX 4090", 3, SEED_ADDR, None, IMAGE, None, None, Duration::from_millis(10), 3, None, ) .await; assert!(result.is_err(), "partial create must surface an error"); // Drop of server verifies the DELETE expectations. } #[tokio::test] async fn lease_chain_waits_for_running_per_contract() { let server = MockServer::start().await; mount_offers(&server, 4).await; mount_creates_ok(&server, 3).await; mount_status_running(&server, &[9000, 9001, 9002]).await; let client = Client::new(); vastai::lease_chain( &client, &server.uri(), API_KEY, "RTX 4090", 3, SEED_ADDR, None, IMAGE, None, None, Duration::from_millis(10), 3, None, ) .await .expect("lease_chain must succeed"); let reqs = server.received_requests().await.unwrap(); let gets_per_contract: std::collections::HashMap = { let mut map = std::collections::HashMap::new(); for r in &reqs { if r.method.as_ref() != "GET" { continue; } for id in [9000u64, 9001, 9002] { if r.url.path() == format!("/api/v0/instances/{id}/") { *map.entry(id).or_insert(0) += 1; } } } map }; for id in [9000u64, 9001, 9002] { assert!( gets_per_contract.get(&id).copied().unwrap_or(0) >= 1, "wait_for_running must poll contract {id} at least once", ); } } /// A host that loads the image then stops (never reaching `running`) must not /// sink the whole lease: the stage it was filling is destroyed and /// re-provisioned on a fresh offer, and `lease_chain` still returns N distinct /// running contracts — none of them the dead one. (This is the real failure /// that aborted a 12-node lease: one instance reported "stopped: Successfully /// loaded ".) #[tokio::test] async fn lease_chain_replaces_a_stage_that_stops_before_running() { let server = MockServer::start().await; // Four offers (1000..1003) at ascending price; find_offer takes the // median, so stage 0 lands on offer 1002, stage 1 on 1001, and the // replacement for stage 0 on 1003. mount_offers(&server, 4).await; for (offer, contract) in [(1002u32, 9002u64), (1001, 9001), (1003, 9003)] { Mock::given(method("PUT")) .and(path_regex(format!("^/api/v0/asks/{offer}/$").as_str())) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({ "new_contract": contract })), ) .expect(1) .mount(&server) .await; } // 9002 stops after loading the image (the failure we are guarding against). Mock::given(method("GET")) .and(path_regex("^/api/v0/instances/9002/$")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "instances": { "actual_status": "created", "intended_status": "stopped", "status_msg": "Successfully loaded zacheryasc/swactor-pp-gpu:latest", } }))) .mount(&server) .await; // The replacement (9003) and the healthy stage 1 (9001) both come up. mount_status_running(&server, &[9003, 9001]).await; // The dead instance must be torn down so it stops billing. Mock::given(method("DELETE")) .and(path_regex("^/api/v0/instances/9002/$")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), ) .expect(1) .mount(&server) .await; let client = Client::new(); let infos = vastai::lease_chain( &client, &server.uri(), API_KEY, "RTX 4090", 2, SEED_ADDR, None, IMAGE, None, None, Duration::from_millis(10), 3, None, ) .await .expect("lease_chain must recover by replacing the stopped stage"); let ids: Vec = infos.iter().map(|i| i.contract_id).collect(); assert_eq!(ids.len(), 2, "lease must still yield N=2 running instances"); assert!( !ids.contains(&9002), "the stopped instance must not appear in the lease, got {ids:?}", ); let mut unique = ids.clone(); unique.sort(); unique.dedup(); assert_eq!(unique.len(), 2, "leased contracts must be distinct"); // The DELETE + create expectations are verified on server drop. } // ─── §9.3 convergence wait ──────────────────────────────────────────── #[test] fn await_convergence_returns_when_all_stages_alive() { let mut polls = 0usize; let res = await_convergence( 3, // 3 stages Duration::from_secs(2), Duration::from_millis(1), || { polls += 1; // 0, 1, 2 on the first three polls; reaches 3 on the fourth. (polls - 1).min(3) }, ); assert!(res.is_ok(), "should converge once alive >= 3, got {res:?}"); assert!(polls >= 4, "should have polled until threshold met"); } #[test] fn await_convergence_returns_error_after_timeout() { let res = await_convergence( 5, // expects 5 alive peers Duration::from_millis(150), Duration::from_millis(10), || 3, // never converges ); match res { Err(ConvergeError::Timeout { expected, last_seen, .. }) => { assert_eq!(expected, 5); assert_eq!(last_seen, 3); } other => panic!("expected Timeout, got {other:?}"), } }