#![cfg(target_os = "linux")] use std::collections::{BTreeMap, BTreeSet}; use std::fs::OpenOptions; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::OnceLock; use std::time::{Duration, Instant}; use proptest::prelude::*; use proptest::test_runner::FileFailurePersistence; use serde_json::{Value, json}; use swactor_process::{ child_kill, child_try_wait, child_wait, command_spawn, find_process_identities_by_environment, request_child_termination, terminate_process_group, }; use swactor_vastai::test_http::{TestHttpRoute, TestHttpServer}; const CASE_DEADLINE: Duration = Duration::from_secs(10); const SUITE_DEADLINE: Duration = Duration::from_secs(30); const POLL: Duration = Duration::from_millis(25); const CENSUS_STABILITY: Duration = Duration::from_millis(25); const ACTORS_PER_NODE_LIMIT: u64 = 32; const OFFER_COUNT: usize = 8; static SUITE_STARTED: OnceLock = OnceLock::new(); std::thread_local! { static OBSERVED_HTTP_FAILURES: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; } fn http_failure_checkpoint() -> usize { OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow().len()) } fn discard_http_failures_since(checkpoint: usize) { OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow_mut().truncate(checkpoint)); } fn record_http_failure(failure: String) { OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow_mut().push(failure)); } fn observed_http_failures() -> Vec { OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow().clone()) } #[derive(Clone, Copy, Debug)] enum RestartMode { Graceful, FlushSafeAbrupt, } #[derive(Clone, Debug)] enum ExternalAction { Search { count: usize, }, Provision { command_slot: u8, use_searched_offers: bool, }, Query, Kill { node_slot: u8, command_slot: u8, }, Flush, Restart { mode: RestartMode, }, EndpointProbe { node_slot: u8, }, ConcurrentQueries, } #[derive(Clone, Debug)] struct E2eCase { seed: u64, node_seed: u8, kill_mask: u8, offer_offset: usize, actions: Vec, } fn e2e_case() -> impl Strategy { ( any::(), any::(), any::(), 0_usize..OFFER_COUNT, any::(), any::(), any::(), any::(), proptest::collection::vec(any::(), 11), ) .prop_map( |( seed, node_seed, kill_mask, offer_offset, invalid_search, use_searched_offers, node_slot, command_slot, ordering, )| { let mut keyed_actions = vec![ ExternalAction::Search { count: if invalid_search { 0 } else { 2 }, }, ExternalAction::Provision { command_slot, use_searched_offers, }, ExternalAction::Query, ExternalAction::Kill { node_slot, command_slot, }, ExternalAction::Flush, ExternalAction::Restart { mode: RestartMode::Graceful, }, ExternalAction::EndpointProbe { node_slot }, ExternalAction::ConcurrentQueries, ExternalAction::Query, ExternalAction::Kill { node_slot: node_slot.wrapping_add(1), command_slot, }, ExternalAction::Restart { mode: RestartMode::FlushSafeAbrupt, }, ] .into_iter() .enumerate() .map(|(index, action)| ((ordering[index], index), action)) .collect::>(); keyed_actions.sort_by_key(|(key, _)| *key); E2eCase { seed, node_seed, kill_mask, offer_offset, actions: keyed_actions .into_iter() .map(|(_, action)| action) .collect(), } }, ) } fn e2e_proptest_config() -> ProptestConfig { let has_case_override = std::env::var_os("PROPTEST_CASES").is_some(); let mut config = ProptestConfig::default(); if !has_case_override { config.cases = 4; } config.failure_persistence = Some(Box::new(FileFailurePersistence::Direct(concat!( env!("CARGO_MANIFEST_DIR"), "/proptest-regressions/tests/e2e_vastai.txt" )))); config.max_shrink_iters = 0; config } #[derive(Clone, Debug, Default, PartialEq, Eq)] struct ActorCensus { actors: u64, orchestrator_actors: u64, worker_actors: u64, poisoned: u64, type_mismatches: u64, mailbox_depth: u64, orchestrator_by_type: BTreeMap, workers_by_stream: BTreeMap>, } #[derive(Clone, Debug)] struct OracleObservation { expected_survivors: BTreeSet, expected_stopped: BTreeSet, running_after_restart: BTreeSet, stopped_after_restart: BTreeSet, reachable_nodes: BTreeSet, provider_resources: Vec, provider_created: BTreeSet, provider_destroyed: BTreeSet, selected_offers: BTreeMap, searched_offers: BTreeSet, actor_before_replays: ActorCensus, actor_after_replays: ActorCensus, actor_initial: ActorCensus, actor_after_teardown: ActorCensus, terminal_record_counts: BTreeMap, unexpected_process_exits: Vec, http_failures: Vec, teardown_resources: Vec, labeled_temp_resources: Vec, } fn validate_oracle(observation: &OracleObservation) -> Result<(), String> { if observation .terminal_record_counts .values() .any(|count| *count != 1) { return Err(format!( "accepted reply did not have exactly one terminal record: {:?}", observation.terminal_record_counts )); } if !observation.unexpected_process_exits.is_empty() { return Err(format!( "unexpected process exit: {:?}", observation.unexpected_process_exits )); } if !observation.http_failures.is_empty() { return Err(format!( "HTTP failure or disconnect: {:?}", observation.http_failures )); } if observation.running_after_restart != observation.expected_survivors { return Err(format!( "restart-state loss: expected running {:?}, observed {:?}", observation.expected_survivors, observation.running_after_restart )); } if observation.stopped_after_restart != observation.expected_stopped { return Err(format!( "destroy set mismatch: expected stopped {:?}, observed {:?}", observation.expected_stopped, observation.stopped_after_restart )); } if observation.reachable_nodes != observation.expected_survivors { return Err(format!( "survivor reachability mismatch: expected {:?}, observed {:?}", observation.expected_survivors, observation.reachable_nodes )); } let provider_set = observation .provider_resources .iter() .copied() .collect::>(); if provider_set.len() != observation.provider_resources.len() { return Err(format!( "duplicate provider resources: {:?}", observation.provider_resources )); } if provider_set != observation.expected_survivors { return Err(format!( "provider ledger mismatch: expected {:?}, observed {:?}", observation.expected_survivors, provider_set )); } let expected_all = observation .expected_survivors .union(&observation.expected_stopped) .copied() .collect::>(); if observation.provider_created != expected_all { return Err(format!( "provider create ledger mismatch: expected {:?}, observed {:?}", expected_all, observation.provider_created )); } if observation.provider_destroyed != observation.expected_stopped { return Err(format!( "provider destroy ledger mismatch: expected {:?}, observed {:?}", observation.expected_stopped, observation.provider_destroyed )); } if observation.selected_offers.len() != observation .expected_survivors .len() .saturating_add(observation.expected_stopped.len()) || observation .selected_offers .values() .any(|offer| !observation.searched_offers.contains(offer)) { return Err(format!( "selected offer did not come from search response: selected={:?}, searched={:?}", observation.selected_offers, observation.searched_offers )); } if observation.actor_initial.poisoned != 0 || observation.actor_before_replays.poisoned != 0 || observation.actor_after_replays.poisoned != 0 || observation.actor_after_teardown.poisoned != 0 { return Err(format!( "actor poisoning observed: initial={:?}, before={:?}, after={:?}, teardown={:?}", observation.actor_initial, observation.actor_before_replays, observation.actor_after_replays, observation.actor_after_teardown, )); } if observation.actor_initial.type_mismatches != 0 || observation.actor_before_replays.type_mismatches != 0 || observation.actor_after_replays.type_mismatches != 0 || observation.actor_after_teardown.type_mismatches != 0 { return Err(format!( "actor message type mismatch observed: initial={:?}, before={:?}, after={:?}, teardown={:?}", observation.actor_initial, observation.actor_before_replays, observation.actor_after_replays, observation.actor_after_teardown, )); } for (phase, census) in [ ("before replay", &observation.actor_before_replays), ("after replay", &observation.actor_after_replays), ] { let typed_worker_count = census .workers_by_stream .values() .flat_map(|types| types.iter()) .map(|(actor_type, count)| { if actor_type == "" { Err(format!( "{phase} worker census contains an unknown actor type" )) } else { Ok(*count) } }) .collect::, _>>()? .into_iter() .sum::(); if census.workers_by_stream.len() != observation.expected_survivors.len() || typed_worker_count != census.worker_actors { return Err(format!( "{phase} worker census is incomplete: expected_nodes={}, census={census:?}", observation.expected_survivors.len() )); } } let mut before_types = observation .actor_before_replays .orchestrator_by_type .clone(); let mut after_types = observation.actor_after_replays.orchestrator_by_type.clone(); before_types.remove("myelin::orchestration::control::ControlReplyObserver"); after_types.remove("myelin::orchestration::control::ControlReplyObserver"); if observation.actor_before_replays.actors != observation.actor_after_replays.actors || observation.actor_before_replays.orchestrator_actors != observation.actor_after_replays.orchestrator_actors || observation.actor_before_replays.worker_actors != observation.actor_after_replays.worker_actors || observation.actor_before_replays.mailbox_depth != observation.actor_after_replays.mailbox_depth || observation.actor_before_replays.workers_by_stream != observation.actor_after_replays.workers_by_stream || before_types != after_types { return Err(format!( "steady-state actor census changed under replay: before={:?}, after={:?}", observation.actor_before_replays, observation.actor_after_replays )); } let live_node_count = observation.expected_survivors.len() as u64; if observation.actor_before_replays.worker_actors > live_node_count.saturating_mul(ACTORS_PER_NODE_LIMIT) { return Err(format!( "per-node actor bound exceeded: live_nodes={live_node_count}, census={:?}", observation.actor_before_replays )); } if observation.actor_after_teardown.orchestrator_actors != observation.actor_initial.orchestrator_actors || observation.actor_after_teardown.mailbox_depth != 0 { return Err(format!( "actor census did not return to baseline: initial={:?}, teardown={:?}", observation.actor_initial, observation.actor_after_teardown )); } if !observation.teardown_resources.is_empty() || !observation.labeled_temp_resources.is_empty() { return Err(format!( "teardown leak: provider={:?}, labeled_temp={:?}", observation.teardown_resources, observation.labeled_temp_resources )); } Ok(()) } struct ScenarioHarness { state_dir: PathBuf, provider_url: String, dashboard_port: u16, run_id: u64, provider_ledger_path: PathBuf, restart: usize, child: Option, log_paths: Vec, unexpected_exits: Vec, } impl ScenarioHarness { fn new(state_dir: PathBuf, provider_url: String, dashboard_port: u16, run_id: u64) -> Self { let provider_ledger_path = state_dir.join("mock-vastai-lifecycle.jsonl"); Self { state_dir, provider_url, dashboard_port, run_id, provider_ledger_path, restart: 0, child: None, log_paths: Vec::new(), unexpected_exits: Vec::new(), } } fn start(&mut self) -> Result<(), String> { if self.child.is_some() { return Err("orchestrator is already running".to_owned()); } let log_path = self .state_dir .join(format!("orchestrator-{}.log", self.restart)); let stdout = OpenOptions::new() .create(true) .append(true) .open(&log_path) .map_err(|error| format!("open {}: {error}", log_path.display()))?; let stderr = stdout .try_clone() .map_err(|error| format!("clone {}: {error}", log_path.display()))?; let mut command = Command::new(env!("CARGO_BIN_EXE_myelin-orchestrator")); command .current_dir(&self.state_dir) .args([ "--provider", "vastai", "--vastai-provisioning", "mock", "--dashboard", "--state-dir", ]) .arg(&self.state_dir) .args(["--run-id", &self.run_id.to_string()]) .env("VASTAI_BASE_URL", &self.provider_url) .env("VAST_API_KEY", "stateful-e2e-secret") .env("MYELIN_DASHBOARD_PORT", self.dashboard_port.to_string()) .env("MYELIN_MOCK_VASTAI_LEDGER_PATH", &self.provider_ledger_path) .env("MYELIN_VASTAI_POLL_INTERVAL_SECS", "1") .env("MYELIN_IROH_RELAY_MODE", "disabled") .env("RUST_BACKTRACE", "1") .stdin(Stdio::null()) .stdout(Stdio::from(stdout)) .stderr(Stdio::from(stderr)); let child = command_spawn(&mut command) .map_err(|error| format!("spawn Cargo-built Myelin orchestrator: {error}"))?; self.child = Some(child); self.log_paths.push(log_path); self.restart += 1; Ok(()) } fn ensure_running(&mut self) -> Result<(), String> { let child = self .child .as_mut() .ok_or_else(|| "orchestrator process is absent".to_owned())?; if let Some(status) = child_try_wait(child) .map_err(|error| format!("inspect live orchestrator process: {error}"))? { let failure = format!("orchestrator exited unexpectedly with {status}"); self.unexpected_exits.push(failure.clone()); return Err(failure); } Ok(()) } fn restart_gracefully(&mut self, deadline: Instant) -> Result<(), String> { self.stop_child_gracefully(deadline)?; self.start() } fn restart_abruptly(&mut self) -> Result<(), String> { self.stop_child_abruptly()?; self.start() } fn stop_child_gracefully(&mut self, deadline: Instant) -> Result<(), String> { let Some(mut child) = self.child.take() else { return Ok(()); }; if child_try_wait(&mut child) .map_err(|error| format!("inspect orchestrator process: {error}"))? .is_none() { request_child_termination(&child) .map_err(|error| format!("request graceful orchestrator stop: {error}"))?; } loop { if let Some(status) = child_try_wait(&mut child) .map_err(|error| format!("inspect graceful orchestrator stop: {error}"))? { if status.success() { return Ok(()); } return Err(format!( "graceful orchestrator stop exited with {status}; logs={}", self.logs() )); } if Instant::now() >= deadline { let _ = child_kill(&mut child); let _ = child_wait(&mut child); return Err(format!( "graceful orchestrator stop exceeded case deadline; logs={}", self.logs() )); } std::thread::sleep(POLL); } } fn stop_child_abruptly(&mut self) -> Result<(), String> { let Some(mut child) = self.child.take() else { return Ok(()); }; if child_try_wait(&mut child) .map_err(|error| format!("inspect orchestrator process: {error}"))? .is_none() { child_kill(&mut child) .map_err(|error| format!("kill orchestrator process: {error}"))?; } child_wait(&mut child) .map_err(|error| format!("wait for orchestrator process: {error}"))?; Ok(()) } fn base_url(&self) -> String { format!("http://127.0.0.1:{}", self.dashboard_port) } fn logs(&self) -> String { let mut paths = self.log_paths.clone(); if let Ok(entries) = std::fs::read_dir(self.state_dir.join("process-output")) { paths.extend(entries.filter_map(|entry| entry.ok().map(|entry| entry.path()))); } paths.sort(); paths .iter() .map(|path| { std::fs::read_to_string(path) .map(|text| format!("\n--- {} ---\n{text}", path.display())) .unwrap_or_else(|error| { format!("\n--- {} unavailable: {error} ---", path.display()) }) }) .collect() } fn cleanup_workers(&self) { let environment = [("MYELIN_RUN_ID".to_owned(), self.run_id.to_string())]; let Ok(processes) = find_process_identities_by_environment(&environment, true) else { return; }; for process in processes { let _ = terminate_process_group( &process, Duration::from_secs(2), Duration::from_millis(20), ); } } fn remove_worker_sockets(&self, nodes: &BTreeSet) -> Result<(), String> { for node_id in nodes { let path = node_socket(self.run_id, *node_id); match std::fs::remove_file(&path) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(format!( "remove labeled worker socket {}: {error}", path.display() )); } } } Ok(()) } } impl Drop for ScenarioHarness { fn drop(&mut self) { let _ = self.stop_child_abruptly(); self.cleanup_workers(); let possible_nodes = BTreeSet::from([1, 2]); let _ = self.remove_worker_sockets(&possible_nodes); } } fn production_offers() -> Value { let offers = (0..OFFER_COUNT) .map(|index| { json!({ "id": 8_675_300_u64 + index as u64, "gpu_name": "RTX 4090", "num_gpus": 1, "gpu_ram": 24_576.0, "dph_total": 0.20 + index as f64 / 100.0, "host_id": 90_000_u64 + index as u64, "compute_cap": 890, "verification": "verified", "reliability2": 0.995, "inet_down": 1_000.0, "inet_up": 800.0, "internet_down_cost_per_tb": 1.5, "internet_up_cost_per_tb": 2.5, "geolocation": "US", "disk_bw": 2_000.0, "duration": 86_400.0, "rentable": true }) }) .collect::>(); json!({"offers": offers}) } fn reserve_port() -> Result { let listener = TcpListener::bind(("127.0.0.1", 0)) .map_err(|error| format!("reserve dashboard port: {error}"))?; listener .local_addr() .map(|address| address.port()) .map_err(|error| format!("read reserved dashboard address: {error}")) } fn check_deadlines(case_deadline: Instant, trace: &[String]) -> Result<(), String> { let now = Instant::now(); let suite_started = *SUITE_STARTED.get_or_init(Instant::now); if now >= case_deadline { return Err(format!( "{}-second E2E case deadline exceeded; trace={trace:#?}", CASE_DEADLINE.as_secs() )); } if now.duration_since(suite_started) >= SUITE_DEADLINE { return Err(format!( "{}-second E2E suite deadline exceeded; trace={trace:#?}", SUITE_DEADLINE.as_secs() )); } Ok(()) } fn wait_for( case_deadline: Instant, trace: &[String], description: &str, mut probe: impl FnMut() -> Result, String>, ) -> Result { let mut last_error = None; loop { check_deadlines(case_deadline, trace)?; match probe() { Ok(Some(value)) => return Ok(value), Ok(None) => {} Err(error) => last_error = Some(error), } std::thread::sleep(POLL); if Instant::now() >= case_deadline { return Err(format!( "timed out waiting for {description}; last_error={last_error:?}; trace={trace:#?}" )); } } } fn request_json( method: &str, url: &str, body: Option<&Value>, ) -> Result<(u16, Option), String> { let agent = ureq::AgentBuilder::new() .timeout_connect(Duration::from_millis(250)) .timeout_read(Duration::from_secs(3)) .timeout_write(Duration::from_secs(1)) .build(); let request = agent.request(method, url).set("Accept", "application/json"); let result = match body { Some(body) => request .set("Content-Type", "application/json") .send_string(&body.to_string()), None => request.call(), }; let response = match result { Ok(response) => response, Err(ureq::Error::Status(_, response)) => response, Err(error) => { let failure = format!("{method} {url} disconnected: {error}"); record_http_failure(failure.clone()); return Err(failure); } }; let status = response.status(); if status >= 500 { record_http_failure(format!("{method} {url} returned HTTP {status}")); } let raw = response.into_string().map_err(|error| { let failure = format!("read {method} {url} response: {error}"); record_http_failure(failure.clone()); failure })?; let json = if raw.trim().is_empty() { None } else { Some(serde_json::from_str(&raw).map_err(|error| { let failure = format!("parse {method} {url} response {raw:?}: {error}"); record_http_failure(failure.clone()); failure })?) }; Ok((status, json)) } fn get_status(base_url: &str) -> Result { let url = format!("{base_url}/api/control/status"); let (status, body) = request_json("GET", &url, None)?; if status != 200 { return Err(format!("status endpoint returned {status}: {body:?}")); } body.and_then(|body| body.get("Status").cloned()) .ok_or_else(|| "status response has no Status model".to_owned()) } fn search_offers(base_url: &str, count: usize) -> Result, String> { let url = format!("{base_url}/api/control/offers"); let request = json!({ "gpu_model": "RTX 4090", "min_gpu_ram_mb": 20_000, "min_compute_cap": 800, "min_reliability": 0.99, "require_verified": true, "min_download_mbps": 500.0, "min_upload_mbps": 400.0, "max_hourly_price": 1.0, "blacklist_hosts": [], "count": count }); let (status, body) = request_json("POST", &url, Some(&request))?; if status != 200 { return Err(format!("offer search returned {status}: {body:?}")); } body.and_then(|body| body.get("Offers").cloned()) .and_then(|offers| offers.as_array().cloned()) .ok_or_else(|| "offer response has no Offers array".to_owned())? .into_iter() .map(|offer| { offer .get("offer_id") .and_then(Value::as_u64) .ok_or_else(|| format!("offer has no offer_id: {offer}")) }) .collect() } fn assert_typed_prerequisite_rejection(base_url: &str) -> Result { match search_offers(base_url, 0) { Err(error) if error.contains("409") && error.contains("error") => Ok(error), result => Err(format!( "prerequisite-invalid action did not produce a typed rejection: {result:?}" )), } } #[derive(Clone, Debug)] struct AcceptedCommand { path: String, submissions: Vec, terminal: Option, } #[derive(Clone, Debug, Default)] struct ExternalReplyCollector { accepted: BTreeMap, public_replies: Vec, http_failures: Vec, } impl ExternalReplyCollector { fn submit(&mut self, base_url: &str, path: &str, body: &Value) -> Result<(), String> { let command_id = body .get("command_id") .and_then(Value::as_str) .ok_or_else(|| format!("external mutation has no command_id: {body}"))? .to_owned(); let url = format!("{base_url}{path}"); let response = request_json("POST", &url, Some(body)); let (status, response_body) = match response { Ok(response) => response, Err(error) => { self.http_failures .push(format!("POST {path} disconnected: {error}")); return Err(error); } }; if status >= 500 { self.http_failures .push(format!("POST {path} returned {status}: {response_body:?}")); } if status != 202 { return Err(format!("POST {path} returned {status}: {response_body:?}")); } self.public_replies .push(format!("POST {path} command_id={command_id} status=202")); self.accepted .entry(command_id) .or_insert_with(|| AcceptedCommand { path: path.to_owned(), submissions: Vec::new(), terminal: None, }) .submissions .push(body.clone()); Ok(()) } fn observe_status(&mut self, status: &Value) -> Result<(), String> { let commands = status .get("commands") .and_then(Value::as_array) .ok_or_else(|| format!("status has no command ledger: {status}"))?; for (command_id, accepted) in &mut self.accepted { let matches = commands .iter() .filter(|command| { command.get("command_id").and_then(Value::as_str) == Some(command_id.as_str()) }) .collect::>(); if matches.len() > 1 { return Err(format!( "accepted command {command_id} has {} public records: {matches:?}", matches.len() )); } let Some(record) = matches.first() else { continue; }; if record .get("state") .and_then(Value::as_str) .is_some_and(|state| matches!(state, "succeeded" | "failed")) { if accepted .terminal .as_ref() .is_some_and(|terminal| terminal != *record) { return Err(format!( "accepted command {command_id} changed terminal reply: before={:?}, after={record}", accepted.terminal )); } accepted.terminal = Some((*record).clone()); } } Ok(()) } fn all_terminal(&self) -> bool { self.accepted .values() .all(|accepted| accepted.terminal.is_some()) } fn terminal(&self, command_id: &str) -> Option<&Value> { self.accepted .get(command_id) .and_then(|accepted| accepted.terminal.as_ref()) } } fn flush_control(base_url: &str) -> Result<(), String> { let url = format!("{base_url}/api/control/flush"); let (status, response) = request_json("POST", &url, None)?; if status != 200 || response != Some(Value::String("Flushed".to_owned())) { return Err(format!("control flush returned {status}: {response:?}")); } Ok(()) } fn wait_for_accepted_replies( harness: &ScenarioHarness, case_deadline: Instant, trace: &[String], replies: &mut ExternalReplyCollector, ) -> Result { wait_for(case_deadline, trace, "all accepted command replies", || { let status = get_status(&harness.base_url())?; replies.observe_status(&status)?; Ok(replies.all_terminal().then_some(status)) }) } fn concurrent_status_requests(port: u16) -> Result<(), String> { let request = b"GET /api/control/status HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: application/json\r\nConnection: close\r\n\r\n"; let mut streams = Vec::new(); for _ in 0..2 { let mut stream = TcpStream::connect(("127.0.0.1", port)) .map_err(|error| format!("connect concurrent dashboard request: {error}"))?; stream .set_read_timeout(Some(Duration::from_secs(1))) .map_err(|error| format!("set concurrent dashboard read timeout: {error}"))?; stream .write_all(request) .map_err(|error| format!("write concurrent dashboard request: {error}"))?; stream .flush() .map_err(|error| format!("flush concurrent dashboard request: {error}"))?; streams.push(stream); } for mut stream in streams { let mut response = String::new(); stream .read_to_string(&mut response) .map_err(|error| format!("read concurrent dashboard response: {error}"))?; let status = response .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .and_then(|status| status.parse::().ok()) .ok_or_else(|| format!("malformed concurrent dashboard response: {response:?}"))?; if status != 200 { return Err(format!( "concurrent dashboard request returned {status}: {response:?}" )); } } Ok(()) } fn node_sets(status: &Value) -> Result<(BTreeSet, BTreeSet), String> { let nodes = status .get("nodes") .and_then(Value::as_array) .ok_or_else(|| format!("status has no nodes: {status}"))?; let mut running = BTreeSet::new(); let mut stopped = BTreeSet::new(); for node in nodes { let id = node .get("logical_node_id") .and_then(Value::as_u64) .ok_or_else(|| format!("node has no logical_node_id: {node}"))?; match node.get("phase").and_then(Value::as_str) { Some("running") => { if node.get("runtime").is_none_or(Value::is_null) { return Err(format!("running node {id} has no runtime readiness facts")); } running.insert(id); } Some("stopped") => { stopped.insert(id); } _ => {} } } Ok((running, stopped)) } fn selected_offer_map(status: &Value) -> Result, String> { status .get("nodes") .and_then(Value::as_array) .ok_or_else(|| format!("status has no nodes: {status}"))? .iter() .map(|node| { let node_id = node .get("logical_node_id") .and_then(Value::as_u64) .ok_or_else(|| format!("node has no logical_node_id: {node}"))?; let offer_id = node .get("selected_offer_id") .and_then(Value::as_u64) .ok_or_else(|| format!("node {node_id} has no selected_offer_id"))?; Ok((node_id, offer_id)) }) .collect() } #[derive(Clone, Debug, Default)] struct ProviderSnapshot { live: Vec, created: Vec, destroyed: Vec, events: Vec, } fn provider_ledger_snapshot(path: &PathBuf, run_id: u64) -> Result { let contents = match std::fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => { return Err(format!( "read independent provider ledger {}: {error}", path.display() )); } }; let mut resources = BTreeMap::::new(); let mut snapshot = ProviderSnapshot::default(); for (index, line) in contents.lines().enumerate() { let event: Value = serde_json::from_str(line).map_err(|error| { format!( "parse independent provider ledger {} line {}: {error}; line={line:?}", path.display(), index + 1 ) })?; if event.get("run_id").and_then(Value::as_u64) != Some(run_id) { return Err(format!( "independent provider ledger contains a foreign run: {event}" )); } let event_type = event .get("event") .and_then(Value::as_str) .ok_or_else(|| format!("provider lifecycle observation has no event: {event}"))?; let node_id = event .get("node_id") .and_then(Value::as_u64) .ok_or_else(|| format!("provider lifecycle observation has no node_id: {event}"))?; let provider_ref = event .get("provider_ref") .and_then(Value::as_str) .ok_or_else(|| { format!("provider lifecycle observation has no provider_ref: {event}") })?; match event_type { "created" => { if event .get("selected_offer_id") .and_then(Value::as_u64) .is_none_or(|offer_id| offer_id == 0) { return Err(format!( "provider create did not preserve a selected offer: {event}" )); } snapshot.created.push(node_id); if let Some(previous_node) = resources.insert(provider_ref.to_owned(), node_id) { return Err(format!( "duplicate provider resource {provider_ref}: previous node {previous_node}, event={event}" )); } } "adopted" | "recreated" => match resources.get(provider_ref) { Some(resource_node) if *resource_node == node_id => {} _ => { return Err(format!( "provider recovered a resource absent from the independent ledger: {event}; live={resources:?}" )); } }, "destroyed" => { snapshot.destroyed.push(node_id); if resources.remove(provider_ref) != Some(node_id) { return Err(format!( "provider destroyed a resource absent from the independent ledger: {event}; live={resources:?}" )); } } other => { return Err(format!( "unknown independent provider lifecycle event {other:?}: {event}" )); } } snapshot.events.push(event); } snapshot.live = resources.into_values().collect(); Ok(snapshot) } fn node_socket(run_id: u64, node_id: u64) -> PathBuf { std::env::temp_dir().join(format!("myelin-node-debug-join-{run_id}-{node_id}.sock")) } fn process_count(run_id: u64, node_id: u64) -> Result { find_process_identities_by_environment( &[ ("MYELIN_RUN_ID".to_owned(), run_id.to_string()), ("MYELIN_LOGICAL_NODE_ID".to_owned(), node_id.to_string()), ], true, ) .map(|matches| matches.len()) .map_err(|error| format!("find worker process for node {node_id}: {error}")) } fn reachable_nodes(run_id: u64, all_nodes: &BTreeSet) -> Result, String> { let mut reachable = BTreeSet::new(); for &node_id in all_nodes { let count = process_count(run_id, node_id)?; if count > 1 { return Err(format!("node {node_id} owns {count} worker processes")); } if count == 1 && UnixStream::connect(node_socket(run_id, node_id)).is_ok() { reachable.insert(node_id); } } Ok(reachable) } fn node_endpoint_request(run_id: u64, node_id: u64, request: &str) -> Result { let mut stream = UnixStream::connect(node_socket(run_id, node_id)) .map_err(|error| format!("connect node {node_id} debug endpoint: {error}"))?; stream .set_read_timeout(Some(Duration::from_secs(1))) .map_err(|error| format!("set node {node_id} endpoint read timeout: {error}"))?; stream .set_write_timeout(Some(Duration::from_secs(1))) .map_err(|error| format!("set node {node_id} endpoint write timeout: {error}"))?; stream .write_all(request.as_bytes()) .map_err(|error| format!("write node {node_id} endpoint request: {error}"))?; stream .flush() .map_err(|error| format!("flush node {node_id} endpoint request: {error}"))?; let mut response = String::new(); BufReader::new(stream) .read_line(&mut response) .map_err(|error| format!("read node {node_id} endpoint response: {error}"))?; serde_json::from_str(&response) .map_err(|error| format!("parse node {node_id} endpoint response: {error}")) } fn probe_node_endpoint(run_id: u64, node_id: u64, status: &Value) -> Result<(), String> { let node = status .get("nodes") .and_then(Value::as_array) .and_then(|nodes| { nodes .iter() .find(|node| node.get("logical_node_id").and_then(Value::as_u64) == Some(node_id)) }) .ok_or_else(|| format!("status has no node {node_id}: {status}"))?; let endpoint_json = node .pointer("/runtime/endpoint") .and_then(Value::as_str) .ok_or_else(|| format!("running node {node_id} has no endpoint: {node}"))?; let _: Value = serde_json::from_str(endpoint_json) .map_err(|error| format!("parse node {node_id} endpoint: {error}"))?; let response = node_endpoint_request(run_id, node_id, "{}\n")?; if response.get("type").and_then(Value::as_str) != Some("JoinRejected") || response.get("error").and_then(Value::as_str) != Some("MalformedCommand") { return Err(format!( "node {node_id} endpoint did not complete a typed readiness round trip: {response}" )); } Ok(()) } fn actor_census( harness: &ScenarioHarness, worker_nodes: &BTreeSet, ) -> Result { let base_url = harness.base_url(); let actor_url = format!("{base_url}/api/control/actors"); let (actor_status, actor_body) = request_json("GET", &actor_url, None)?; if actor_status != 200 { return Err(format!( "live orchestrator actor census returned {actor_status}: {actor_body:?}" )); } let actor_body = actor_body.ok_or_else(|| "live orchestrator actor census returned no body".to_owned())?; let mut census = ActorCensus::default(); census.orchestrator_actors = actor_body .get("actors") .and_then(Value::as_array) .map_or(0, |actors| actors.len() as u64); census.actors = census.orchestrator_actors; if let Some(workers) = actor_body.get("workers").and_then(Value::as_array) { for worker in workers { census.mailbox_depth = census.mailbox_depth.saturating_add( worker .get("mailbox_depth") .and_then(Value::as_u64) .unwrap_or(0), ); census.poisoned = census .poisoned .saturating_add(worker.get("panics").and_then(Value::as_u64).unwrap_or(0)); census.type_mismatches = census.type_mismatches.saturating_add( worker .get("type_mismatches") .and_then(Value::as_u64) .unwrap_or(0), ); } } let url = format!("{base_url}/api/view/fleet"); let (status, body) = request_json("GET", &url, None)?; if status != 200 { return Err(format!("fleet actor census returned {status}: {body:?}")); } let body = body.ok_or_else(|| "fleet actor census returned no body".to_owned())?; let live = body .get("live") .and_then(Value::as_array) .ok_or_else(|| format!("fleet actor census has no live array: {body}"))?; if live.is_empty() { return Err("fleet actor census has no live streams".to_owned()); } for node in live { let Some(summary) = node.get("actor_summary") else { continue; }; let _actors = summary.get("actors").and_then(Value::as_u64).unwrap_or(0); let orchestrator = node .pointer("/stream/origin") .and_then(Value::as_str) .is_some_and(|origin| origin == "orchestrator"); let mut by_type = BTreeMap::new(); if let Some(roster) = node.get("roster").and_then(Value::as_array) { for actor in roster { let actor_type = actor .get("actor_type") .and_then(Value::as_str) .unwrap_or("") .to_owned(); *by_type.entry(actor_type).or_insert(0) += 1; } } if orchestrator { for (actor_type, count) in by_type { *census.orchestrator_by_type.entry(actor_type).or_insert(0) += count; } } } for &node_id in worker_nodes { let response = node_endpoint_request(harness.run_id, node_id, "{\"type\":\"RuntimeStats\"}\n")?; if response.get("type").and_then(Value::as_str) != Some("RuntimeStats") { return Err(format!( "node {node_id} rejected runtime census request: {response}" )); } let stats = response .get("stats") .ok_or_else(|| format!("node {node_id} runtime census has no stats: {response}"))?; let details = stats .get("actors") .and_then(Value::as_array) .ok_or_else(|| format!("node {node_id} runtime census has no actors: {stats}"))?; if details.is_empty() { return Err(format!( "node {node_id} runtime census is not populated yet" )); } let actors = details.len() as u64; census.worker_actors = census.worker_actors.saturating_add(actors); census.actors = census.actors.saturating_add(actors); census.poisoned = census.poisoned.saturating_add( details .iter() .filter(|actor| actor.get("poisoned").and_then(Value::as_bool) == Some(true)) .count() as u64, ); census.type_mismatches = census.type_mismatches.saturating_add( stats .get("workers") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|worker| worker.get("type_mismatches").and_then(Value::as_u64)) .sum::(), ); census.mailbox_depth = census.mailbox_depth.saturating_add( details .iter() .filter_map(|actor| actor.get("mailbox_depth").and_then(Value::as_u64)) .sum(), ); let mut by_type = BTreeMap::new(); for actor in details { let actor_type = actor .get("actor_type") .and_then(Value::as_str) .unwrap_or("") .to_owned(); *by_type.entry(actor_type).or_insert(0) += 1; } census .workers_by_stream .insert(node_id.to_string(), by_type); } let typed_orchestrator_actors = census.orchestrator_by_type.values().sum::(); if typed_orchestrator_actors == 0 && !worker_nodes.is_empty() { return Err(format!( "live orchestrator roster is not populated yet: fleet={body}" )); } if typed_orchestrator_actors != 0 && typed_orchestrator_actors != census.orchestrator_actors { return Err(format!( "orchestrator actor roster disagrees with the live runtime: \ roster={typed_orchestrator_actors}, runtime={}; fleet={body}; runtime_stats={actor_body}", census.orchestrator_actors )); } if census.orchestrator_actors == 0 { return Err(format!( "live orchestrator actor census has no actors: {actor_body}" )); } Ok(census) } fn wait_for_stable_actor_census( harness: &ScenarioHarness, case_deadline: Instant, trace: &[String], worker_nodes: &BTreeSet, ) -> Result { let mut candidate: Option<(ActorCensus, Instant)> = None; wait_for(case_deadline, trace, "stable actor census", || { let census = actor_census(harness, worker_nodes)?; if census.mailbox_depth != 0 { candidate = None; return Ok(None); } match &candidate { Some((previous, since)) if previous == &census => { Ok((since.elapsed() >= CENSUS_STABILITY).then_some(census)) } _ => { candidate = Some((census, Instant::now())); Ok(None) } } }) } fn wait_for_baseline_actor_census( harness: &ScenarioHarness, case_deadline: Instant, trace: &[String], baseline: &ActorCensus, ) -> Result { wait_for(case_deadline, trace, "baseline actor census", || { let census = actor_census(harness, &BTreeSet::new())?; Ok((census.actors == baseline.actors && census.orchestrator_actors == baseline.orchestrator_actors && census.worker_actors == baseline.worker_actors && census.poisoned == baseline.poisoned && census.type_mismatches == baseline.type_mismatches && census.mailbox_depth == baseline.mailbox_depth) .then_some(census)) }) } fn wait_for_state( harness: &ScenarioHarness, case_deadline: Instant, trace: &[String], expected_running: &BTreeSet, expected_stopped: &BTreeSet, ) -> Result { wait_for(case_deadline, trace, "durable node state", || { let status = get_status(&harness.base_url())?; let (running, stopped) = node_sets(&status)?; Ok((running == *expected_running && stopped == *expected_stopped).then_some(status)) }) } fn wait_for_reachability( harness: &ScenarioHarness, case_deadline: Instant, trace: &[String], all_nodes: &BTreeSet, expected: &BTreeSet, ) -> Result, String> { wait_for(case_deadline, trace, "worker reachability", || { let reachable = reachable_nodes(harness.run_id, all_nodes)?; Ok((reachable == *expected).then_some(reachable)) }) } fn wait_for_provider_ledger( harness: &ScenarioHarness, case_deadline: Instant, trace: &[String], expected_created: &BTreeSet, expected_destroyed: &BTreeSet, expected_live: &BTreeSet, ) -> Result { wait_for( case_deadline, trace, "independent mock-provider lifecycle ledger", || { let snapshot = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id)?; let live_set = snapshot.live.iter().copied().collect::>(); let created_set = snapshot.created.iter().copied().collect::>(); let destroyed_set = snapshot.destroyed.iter().copied().collect::>(); Ok((created_set == *expected_created && snapshot.created.len() == expected_created.len() && destroyed_set == *expected_destroyed && snapshot.destroyed.len() == expected_destroyed.len() && live_set == *expected_live && snapshot.live.len() == expected_live.len()) .then_some(snapshot)) }, ) } #[derive(Clone, Debug)] struct LifecyclePlan { selected_offers: Vec, all_nodes: BTreeSet, stopped: BTreeSet, survivors: BTreeSet, } fn lifecycle_plan(case: &E2eCase, searched: &[u64]) -> Result { let usable = searched.len().min(2); if usable == 0 { return Err("cannot derive lifecycle plan without a usable search result".to_owned()); } let node_count = 1 + usize::from(case.node_seed) % usable; let selected_offers = (0..node_count) .map(|offset| searched[(case.offer_offset + offset) % searched.len()]) .collect::>(); let all_nodes = (1..=node_count as u64).collect::>(); let protected_survivor = 1 + case.seed % node_count as u64; let mut stopped = BTreeSet::new(); for node_id in &all_nodes { if *node_id != protected_survivor && case.kill_mask & (1_u8 << ((*node_id as usize - 1) % 8)) != 0 { stopped.insert(*node_id); } } let survivors = all_nodes .difference(&stopped) .copied() .collect::>(); Ok(LifecyclePlan { selected_offers, all_nodes, stopped, survivors, }) } fn labeled_temp_resources(harness: &ScenarioHarness, all_nodes: &BTreeSet) -> Vec { let mut leaked = all_nodes .iter() .map(|node_id| node_socket(harness.run_id, *node_id)) .filter(|path| path.exists()) .collect::>(); let registry_path = harness.state_dir.join("process-nodes.json"); let registry_leaked = match std::fs::read_to_string(®istry_path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, Ok(contents) => !matches!( serde_json::from_str::(&contents), Ok(Value::Object(registry)) if registry.is_empty() ), Err(_) => true, }; if registry_leaked { leaked.push(registry_path); } leaked } fn setup_failure_with_diagnostics(error: String, case: &E2eCase, trace: &[String]) -> String { format!( "{error}; seed={}; generated_actions={:#?}; trace={trace:#?}; \ public_replies=[]; http_failures=[]; process_logs=; \ census=; reachability=; provider_ledger=", case.seed, case.actions ) } fn failure_with_diagnostics( error: String, case: &E2eCase, trace: &[String], replies: &ExternalReplyCollector, census_snapshots: &[ActorCensus], harness: &ScenarioHarness, provider: &TestHttpServer, ) -> String { let status = get_status(&harness.base_url()); let node_sets = status .as_ref() .ok() .and_then(|status| node_sets(status).ok()); let observed_nodes = node_sets .as_ref() .map(|(running, stopped)| running.union(stopped).copied().collect()) .unwrap_or_default(); let reachability = reachable_nodes(harness.run_id, &observed_nodes); let census = node_sets .as_ref() .map(|(running, _)| actor_census(harness, running)) .transpose(); let provider_ledger = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id); let worker_processes = find_process_identities_by_environment( &[("MYELIN_RUN_ID".to_owned(), harness.run_id.to_string())], true, ); let worker_sockets = observed_nodes .iter() .map(|node_id| (*node_id, node_socket(harness.run_id, *node_id).exists())) .collect::>(); format!( "{error}; seed={}; generated_actions={:#?}; trace={trace:#?}; \ public_replies={:#?}; http_failures={:#?}; observed_http_failures={:#?}; \ unexpected_process_exits={:#?}; \ status={status:#?}; census_snapshots={census_snapshots:#?}; current_census={census:#?}; \ reachability={reachability:#?}; worker_processes={worker_processes:#?}; \ worker_sockets={worker_sockets:#?}; provider_ledger={provider_ledger:#?}; \ provider_http_requests={:#?}; process_logs={}", case.seed, case.actions, replies.public_replies, replies.http_failures, observed_http_failures(), harness.unexpected_exits, provider.requests(), harness.logs(), ) } fn run_stateful_case(case: E2eCase) -> Result<(), String> { let case_started = Instant::now(); let case_deadline = case_started + CASE_DEADLINE; let mut trace = vec![format!( "seed={} generated_actions={:#?}", case.seed, case.actions )]; discard_http_failures_since(0); check_deadlines(case_deadline, &trace) .map_err(|error| setup_failure_with_diagnostics(error, &case, &trace))?; let provider = TestHttpServer::start(vec![TestHttpRoute::json( "GET", "/api/v0/bundles/", 200, production_offers(), )]) .map_err(|error| setup_failure_with_diagnostics(error, &case, &trace))?; let temp = tempfile::tempdir().map_err(|error| { setup_failure_with_diagnostics(format!("create E2E state dir: {error}"), &case, &trace) })?; let run_id = 7_000_000_u64 + case.seed % 1_000_000; let dashboard_port = reserve_port().map_err(|error| setup_failure_with_diagnostics(error, &case, &trace))?; let mut harness = ScenarioHarness::new( temp.path().to_path_buf(), provider.uri(), dashboard_port, run_id, ); let mut replies = ExternalReplyCollector::default(); let mut census_snapshots = Vec::new(); let execution = (|| -> Result { harness.start()?; trace.push("launch direct Cargo-built Myelin orchestrator".to_owned()); let readiness_http_checkpoint = http_failure_checkpoint(); wait_for(case_deadline, &trace, "dashboard readiness", || { get_status(&harness.base_url()).map(Some) })?; discard_http_failures_since(readiness_http_checkpoint); harness.ensure_running()?; let mut generated_restart_mode = None; concurrent_status_requests(harness.dashboard_port)?; trace.push("two concurrent independent status requests completed".to_owned()); let actor_initial = wait_for_stable_actor_census(&harness, case_deadline, &trace, &BTreeSet::new())?; census_snapshots.push(actor_initial.clone()); let mut searched = Vec::new(); let mut generated_restarts = 0_usize; let mut typed_rejections = 0_usize; for (action_index, action) in case.actions.clone().into_iter().enumerate() { check_deadlines(case_deadline, &trace)?; harness.ensure_running()?; trace.push(format!("generated action {action_index}: {action:?}")); match action { ExternalAction::Search { count } => match search_offers(&harness.base_url(), count) { Ok(offers) if count > 0 => { searched = offers; trace.push(format!("search returned usable offers {searched:?}")); } Ok(offers) => { return Err(format!( "prerequisite-invalid search unexpectedly succeeded: {offers:?}" )); } Err(error) if count == 0 && error.contains("409") && error.contains("error") => { typed_rejections += 1; trace.push(format!("typed invalid-search rejection: {error}")); } Err(error) => { replies .http_failures .push(format!("offer search failed: {error}")); return Err(error); } }, ExternalAction::Provision { command_slot, use_searched_offers, } => { let Some(plan) = use_searched_offers .then(|| lifecycle_plan(&case, &searched).ok()) .flatten() else { let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?; typed_rejections += 1; trace.push(format!( "provision rejected before usable search selection: {rejection}" )); continue; }; let request = json!({ "command_id": format!("generated-provision-{command_slot}"), "count": plan.selected_offers.len(), "selected_offer_ids": plan.selected_offers, }); replies.submit(&harness.base_url(), "/api/control/provision", &request)?; } ExternalAction::Query => { let status = get_status(&harness.base_url()).map_err(|error| { replies .http_failures .push(format!("status query failed: {error}")); error })?; replies.observe_status(&status)?; } ExternalAction::Kill { node_slot, command_slot, } => { let Some(plan) = lifecycle_plan(&case, &searched).ok() else { let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?; typed_rejections += 1; trace.push(format!( "kill rejected before usable search selection: {rejection}" )); continue; }; let Some(target) = (!plan.stopped.is_empty()).then(|| { let index = usize::from(node_slot) % plan.stopped.len(); *plan.stopped.iter().nth(index).expect("bounded kill index") }) else { let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?; typed_rejections += 1; trace.push(format!( "kill rejected because its generated subset is empty: {rejection}" )); continue; }; let status = get_status(&harness.base_url())?; replies.observe_status(&status)?; let (running, stopped) = node_sets(&status)?; if !running.contains(&target) && !stopped.contains(&target) { let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?; typed_rejections += 1; trace.push(format!( "kill of node {target} rejected before provision: {rejection}" )); continue; } let request = json!({ "command_id": format!("generated-kill-{command_slot}"), "logical_node_id": target, }); replies.submit(&harness.base_url(), "/api/control/kill", &request)?; } ExternalAction::Flush => { flush_control(&harness.base_url()).map_err(|error| { replies .http_failures .push(format!("control flush failed: {error}")); error })?; replies .public_replies .push("POST /api/control/flush reply=Flushed".to_owned()); } ExternalAction::Restart { mode } => { if replies.accepted.is_empty() { trace.push(format!( "restart {mode:?} rejected by external model before any acknowledged mutation" )); continue; } if generated_restarts == 1 { trace.push(format!( "restart {mode:?} rejected by the bounded external model after one generated restart" )); continue; } flush_control(&harness.base_url())?; replies.public_replies.push(format!( "POST /api/control/flush reply=Flushed before {mode:?}" )); match mode { RestartMode::Graceful => { harness.restart_gracefully(case_deadline)?; } RestartMode::FlushSafeAbrupt => { harness.restart_abruptly()?; } } generated_restarts += 1; generated_restart_mode = Some(mode); let readiness_http_checkpoint = http_failure_checkpoint(); wait_for( case_deadline, &trace, "dashboard after generated restart", || get_status(&harness.base_url()).map(Some), )?; discard_http_failures_since(readiness_http_checkpoint); trace.push(format!( "completed generated {mode:?} restart between acknowledged operations" )); } ExternalAction::EndpointProbe { node_slot } => { let status = get_status(&harness.base_url())?; replies.observe_status(&status)?; let (running, _) = node_sets(&status)?; if !running.is_empty() { let index = usize::from(node_slot) % running.len(); let node_id = *running.iter().nth(index).expect("bounded probe index"); probe_node_endpoint(run_id, node_id, &status)?; typed_rejections += 1; trace.push(format!( "node {node_id} returned typed MalformedCommand rejection" )); } else { trace.push( "endpoint probe rejected by external model before provision".to_owned(), ); } } ExternalAction::ConcurrentQueries => { concurrent_status_requests(harness.dashboard_port).map_err(|error| { replies .http_failures .push(format!("concurrent status requests failed: {error}")); error })?; } } } if searched.is_empty() { searched = search_offers(&harness.base_url(), 2)?; trace.push(format!( "convergence search returned usable offers {searched:?}" )); } let plan = lifecycle_plan(&case, &searched)?; if plan.selected_offers.len() > 2 || plan.survivors.is_empty() { return Err(format!("invalid bounded lifecycle plan: {plan:?}")); } let mut status = if replies.accepted.is_empty() { get_status(&harness.base_url())? } else { wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)? }; for (command_id, accepted) in &replies.accepted { if let Some(terminal) = &accepted.terminal && terminal.get("state").and_then(Value::as_str) == Some("failed") { if terminal.get("error").and_then(Value::as_str).is_none() { return Err(format!( "failed command {command_id} has no typed rejection: {terminal}" )); } typed_rejections += 1; } } let (running, stopped) = node_sets(&status)?; let observed = running.union(&stopped).copied().collect::>(); if observed.is_empty() { let request = json!({ "command_id": format!("converge-provision-{}", case.seed), "count": plan.selected_offers.len(), "selected_offer_ids": plan.selected_offers, }); replies.submit(&harness.base_url(), "/api/control/provision", &request)?; wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?; let terminal = replies .terminal(request["command_id"].as_str().expect("command id")) .expect("accepted command has terminal reply"); if terminal.get("state").and_then(Value::as_str) != Some("succeeded") { return Err(format!("convergence provision failed: {terminal}")); } } else if observed != plan.all_nodes { return Err(format!( "accepted replies produced an unexpected node set: expected {:?}, observed {observed:?}, status={status}", plan.all_nodes )); } status = wait_for( case_deadline, &trace, "provisioned state from accepted replies", || { let status = get_status(&harness.base_url())?; let (running, stopped) = node_sets(&status)?; let observed = running.union(&stopped).copied().collect::>(); Ok((observed == plan.all_nodes).then_some(status)) }, )?; let (_, already_stopped) = node_sets(&status)?; if !already_stopped.is_subset(&plan.stopped) { return Err(format!( "generated successful kills exceeded the survivor-preserving subset: desired={:?}, observed={already_stopped:?}", plan.stopped )); } for node_id in plan.stopped.difference(&already_stopped) { let request = json!({ "command_id": format!("converge-kill-{}-{node_id}", case.seed), "logical_node_id": node_id, }); replies.submit(&harness.base_url(), "/api/control/kill", &request)?; } wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?; for command_id in replies .accepted .keys() .filter(|command_id| command_id.starts_with("converge-kill-")) { let terminal = replies .terminal(command_id) .expect("accepted kill has terminal reply"); if terminal.get("state").and_then(Value::as_str) != Some("succeeded") { return Err(format!("convergence kill failed: {terminal}")); } } status = wait_for_state( &harness, case_deadline, &trace, &plan.survivors, &plan.stopped, )?; wait_for_reachability( &harness, case_deadline, &trace, &plan.all_nodes, &plan.survivors, )?; wait_for_provider_ledger( &harness, case_deadline, &trace, &plan.all_nodes, &plan.stopped, &plan.survivors, )?; let selected_offer_by_node = selected_offer_map(&status)?; let expected_offer_by_node = plan .selected_offers .iter() .enumerate() .map(|(index, offer_id)| (index as u64 + 1, *offer_id)) .collect::>(); if selected_offer_by_node != expected_offer_by_node { return Err(format!( "public selected offers differ from searched selection: expected={expected_offer_by_node:?}, observed={selected_offer_by_node:?}" )); } if typed_rejections == 0 { match search_offers(&harness.base_url(), 0) { Err(error) if error.contains("409") && error.contains("error") => { typed_rejections += 1; trace.push(format!("typed convergence rejection: {error}")); } result => { return Err(format!( "could not establish a typed prerequisite rejection: {result:?}" )); } } } if typed_rejections == 0 { return Err("no typed rejection was observed".to_owned()); } let convergence_restarts = match generated_restart_mode { Some(RestartMode::Graceful) => vec![RestartMode::FlushSafeAbrupt], Some(RestartMode::FlushSafeAbrupt) => vec![RestartMode::Graceful], None if case.seed & 1 == 0 => { vec![RestartMode::Graceful, RestartMode::FlushSafeAbrupt] } None => vec![RestartMode::FlushSafeAbrupt, RestartMode::Graceful], }; for mode in convergence_restarts { flush_control(&harness.base_url())?; replies.public_replies.push(format!( "POST /api/control/flush reply=Flushed before convergence {mode:?}" )); match mode { RestartMode::Graceful => harness.restart_gracefully(case_deadline)?, RestartMode::FlushSafeAbrupt => harness.restart_abruptly()?, } let readiness_http_checkpoint = http_failure_checkpoint(); wait_for( case_deadline, &trace, "dashboard after convergence restart", || get_status(&harness.base_url()).map(Some), )?; discard_http_failures_since(readiness_http_checkpoint); trace.push(format!("convergence tail completed {mode:?} restart")); } status = wait_for_state( &harness, case_deadline, &trace, &plan.survivors, &plan.stopped, )?; wait_for_reachability( &harness, case_deadline, &trace, &plan.all_nodes, &plan.survivors, )?; wait_for_provider_ledger( &harness, case_deadline, &trace, &plan.all_nodes, &plan.stopped, &plan.survivors, )?; for node_id in &plan.survivors { probe_node_endpoint(run_id, *node_id, &status)?; } let actor_before_replays = wait_for_stable_actor_census(&harness, case_deadline, &trace, &plan.survivors)?; census_snapshots.push(actor_before_replays.clone()); let replay_requests = replies .accepted .values() .filter_map(|accepted| { accepted .submissions .first() .cloned() .map(|request| (accepted.path.clone(), request)) }) .collect::>(); for (path, request) in replay_requests { replies.submit(&harness.base_url(), &path, &request)?; } for _ in 0..2 { let read = get_status(&harness.base_url())?; replies.observe_status(&read)?; } concurrent_status_requests(harness.dashboard_port)?; status = wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?; let actor_after_replays = wait_for_stable_actor_census(&harness, case_deadline, &trace, &plan.survivors)?; census_snapshots.push(actor_after_replays.clone()); let (running_after_restart, stopped_after_restart) = node_sets(&status)?; let reachable = reachable_nodes(run_id, &plan.all_nodes)?; let provider_snapshot = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id)?; for node_id in &plan.survivors { let request = json!({ "command_id": format!("teardown-kill-{}-{node_id}", case.seed), "logical_node_id": node_id, }); replies.submit(&harness.base_url(), "/api/control/kill", &request)?; replies.submit(&harness.base_url(), "/api/control/kill", &request)?; } wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?; wait_for_state( &harness, case_deadline, &trace, &BTreeSet::new(), &plan.all_nodes, )?; wait_for_reachability( &harness, case_deadline, &trace, &plan.all_nodes, &BTreeSet::new(), )?; let final_provider = wait_for_provider_ledger( &harness, case_deadline, &trace, &plan.all_nodes, &plan.all_nodes, &BTreeSet::new(), )?; let actor_after_teardown = wait_for_baseline_actor_census(&harness, case_deadline, &trace, &actor_initial)?; census_snapshots.push(actor_after_teardown.clone()); let final_status = get_status(&harness.base_url())?; replies.observe_status(&final_status)?; if !replies.all_terminal() { return Err(format!( "accepted replies remained nonterminal: {:?}", replies.accepted )); } let commands = final_status .get("commands") .and_then(Value::as_array) .ok_or_else(|| format!("final status has no command ledger: {final_status}"))?; let terminal_record_counts = replies .accepted .keys() .map(|command_id| { let count = commands .iter() .filter(|command| { command.get("command_id").and_then(Value::as_str) == Some(command_id.as_str()) && command .get("state") .and_then(Value::as_str) .is_some_and(|state| matches!(state, "succeeded" | "failed")) }) .count(); (command_id.clone(), count) }) .collect::>(); flush_control(&harness.base_url())?; harness.stop_child_gracefully(case_deadline)?; wait_for( case_deadline, &trace, "all worker processes stopped", || { let live = plan .all_nodes .iter() .map(|node_id| process_count(run_id, *node_id)) .collect::, _>>()? .into_iter() .sum::(); Ok((live == 0).then_some(())) }, )?; let labeled_temp_resources = labeled_temp_resources(&harness, &plan.all_nodes); let mut http_failures = observed_http_failures(); http_failures.extend(replies.http_failures.clone()); Ok(OracleObservation { expected_survivors: plan.survivors, expected_stopped: plan.stopped, running_after_restart, stopped_after_restart, reachable_nodes: reachable, provider_resources: provider_snapshot.live, provider_created: provider_snapshot.created.into_iter().collect(), provider_destroyed: provider_snapshot.destroyed.into_iter().collect(), selected_offers: selected_offer_by_node, searched_offers: searched.into_iter().collect(), actor_before_replays, actor_after_replays, actor_initial, actor_after_teardown, terminal_record_counts, unexpected_process_exits: harness.unexpected_exits.clone(), http_failures, teardown_resources: final_provider.live, labeled_temp_resources, }) })(); let observation = execution.map_err(|error| { failure_with_diagnostics( error, &case, &trace, &replies, &census_snapshots, &harness, &provider, ) })?; validate_oracle(&observation).map_err(|error| { failure_with_diagnostics( format!("{error}; observation={observation:#?}"), &case, &trace, &replies, &census_snapshots, &harness, &provider, ) })?; if !provider .requests() .iter() .any(|request| request.method == "GET" && request.path == "/api/v0/bundles/") { return Err(failure_with_diagnostics( "provider search boundary was never exercised".to_owned(), &case, &trace, &replies, &census_snapshots, &harness, &provider, )); } let teardown_logs = harness.logs(); let teardown_ledger = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id); let provider_requests = provider.requests(); drop(harness); drop(provider); temp.close().map_err(|error| { format!( "remove labeled E2E state directory: {error}; seed={}; actions={:#?}; \ replies={:#?}; observation={observation:#?}; ledger={teardown_ledger:#?}; \ provider_requests={provider_requests:#?}; logs={teardown_logs}", case.seed, case.actions, replies.public_replies ) })?; Ok(()) } proptest! { #![proptest_config(e2e_proptest_config())] #[test] #[ignore = "nightly process E2E; run with --ignored"] fn stateful_vastai_dashboard_control_survives_restarts(case in e2e_case()) { if let Err(error) = run_stateful_case(case) { prop_assert!(false, "{error}"); } } } #[test] fn e2e_oracle_rejects_controlled_lifecycle_faults() { let baseline = OracleObservation { expected_survivors: BTreeSet::from([2]), expected_stopped: BTreeSet::from([1]), running_after_restart: BTreeSet::from([2]), stopped_after_restart: BTreeSet::from([1]), reachable_nodes: BTreeSet::from([2]), provider_resources: vec![2], provider_created: BTreeSet::from([1, 2]), provider_destroyed: BTreeSet::from([1]), selected_offers: BTreeMap::from([(1, 101), (2, 102)]), searched_offers: BTreeSet::from([101, 102]), actor_before_replays: ActorCensus { actors: 10, orchestrator_actors: 5, worker_actors: 5, orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]), workers_by_stream: BTreeMap::from([( "worker-2".to_owned(), BTreeMap::from([("worker".to_owned(), 5)]), )]), ..ActorCensus::default() }, actor_after_replays: ActorCensus { actors: 10, orchestrator_actors: 5, worker_actors: 5, orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]), workers_by_stream: BTreeMap::from([( "worker-2".to_owned(), BTreeMap::from([("worker".to_owned(), 5)]), )]), ..ActorCensus::default() }, actor_initial: ActorCensus { actors: 5, orchestrator_actors: 5, orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]), ..ActorCensus::default() }, actor_after_teardown: ActorCensus { actors: 5, orchestrator_actors: 5, orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]), ..ActorCensus::default() }, terminal_record_counts: BTreeMap::from([("command-1".to_owned(), 1)]), unexpected_process_exits: Vec::new(), http_failures: Vec::new(), teardown_resources: Vec::new(), labeled_temp_resources: Vec::new(), }; validate_oracle(&baseline).expect("baseline oracle observation"); let mut survivor_destroy = baseline.clone(); survivor_destroy.provider_resources.clear(); survivor_destroy.provider_destroyed.insert(2); assert!( validate_oracle(&survivor_destroy) .unwrap_err() .contains("provider ledger mismatch") ); let mut restart_loss = baseline.clone(); restart_loss.running_after_restart.clear(); assert!( validate_oracle(&restart_loss) .unwrap_err() .contains("restart-state loss") ); let mut duplicate_provider = baseline.clone(); duplicate_provider.provider_resources.push(2); assert!( validate_oracle(&duplicate_provider) .unwrap_err() .contains("duplicate provider resources") ); let mut actor_poison = baseline.clone(); actor_poison.actor_after_replays.poisoned = 1; assert!( validate_oracle(&actor_poison) .unwrap_err() .contains("actor poisoning") ); let mut unexpected_exit = baseline.clone(); unexpected_exit .unexpected_process_exits .push("exit status 1".to_owned()); assert!( validate_oracle(&unexpected_exit) .unwrap_err() .contains("unexpected process exit") ); let mut http_failure = baseline.clone(); http_failure .http_failures .push("HTTP 503 or disconnect".to_owned()); assert!( validate_oracle(&http_failure) .unwrap_err() .contains("HTTP failure or disconnect") ); let mut duplicate_terminal = baseline.clone(); duplicate_terminal .terminal_record_counts .insert("command-1".to_owned(), 2); assert!( validate_oracle(&duplicate_terminal) .unwrap_err() .contains("exactly one terminal record") ); let mut actor_leak = baseline.clone(); actor_leak.actor_after_replays.actors = 13; assert!( validate_oracle(&actor_leak) .unwrap_err() .contains("steady-state actor census") ); let mut teardown_leak = baseline; teardown_leak.teardown_resources.push(2); teardown_leak .labeled_temp_resources .push(PathBuf::from("process-nodes.json")); assert!( validate_oracle(&teardown_leak) .unwrap_err() .contains("teardown leak") ); }