//! T-docker: Stage 11 pre-deploy gate — bring an N-stage cluster up //! inside Docker containers, drive one inference request through it, //! and verify clean teardown. //! //! TEST_SPEC §13b. Mirrors §13's happy path and at least one failure //! scenario, but with each `pp-gpu-node` running inside its own //! container instead of as a host process. //! //! Every test in this file is `#[ignore]`d and requires a working //! Docker daemon plus the ability to build a small CPU image. Run //! with: //! //! ```text //! cargo test -p pipeline-parallel-inference --test t_docker -- --ignored //! ``` //! //! Each test uses a unique container-name prefix so concurrent //! invocations cannot collide on container names. The happy-path test //! is the canonical gate; the idempotency test runs the harness twice //! to surface state leaked across runs; the failure test kills a //! container mid-decode to prove the harness fails fast. use std::io::Read; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant}; /// Process-wide lock that serialises every Docker-touching test. `cargo /// test --test t_docker -- --ignored` is invoked with `--test-threads 1` /// in the documented gate, but a developer may forget and the shared /// docker daemon does not survive overlapping image builds + container /// spawns. Holding this mutex around each test keeps the gate /// reproducible regardless of test-thread count. fn docker_serial_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poison| poison.into_inner()) } fn crate_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } fn docker_e2e_script() -> PathBuf { crate_dir().join("scripts").join("docker-e2e.sh") } fn docker_gpu_node_shim() -> PathBuf { crate_dir().join("scripts").join("docker-gpu-node.sh") } /// Skip the test (with a printed reason) when docker is unreachable. /// Returns `true` if the caller should proceed, `false` to early-return. fn require_docker() -> bool { match Command::new("docker").arg("info").output() { Ok(out) if out.status.success() => true, Ok(_) => { eprintln!("t_docker: skipping — `docker info` failed (no daemon?)"); false } Err(e) => { eprintln!("t_docker: skipping — docker not available: {e}"); false } } } fn remove_containers_with_prefix(prefix: &str) { let filter = format!("name=^{prefix}-[0-9]+$"); let listing = Command::new("docker") .args(["ps", "-aq", "--filter", &filter]) .output(); let ids = match listing { Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(), Err(_) => return, }; if ids.is_empty() { return; } for id in ids.split_whitespace() { let _ = Command::new("docker") .args(["rm", "-f", id]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } } /// Block until every container matching the prefix is gone from /// `docker ps -a`. `docker run --rm` removes its container /// asynchronously after the process exits, so a prior test's last /// container can linger for hundreds of milliseconds; a leftover-check /// that fires before the daemon catches up sees a false positive. /// On timeout, force-remove and continue rather than panicking — the /// test that called us still has to make its own assertion, and a /// stuck container in `docker ps` is more useful as a delete + warn /// than as a swallowed test failure. fn wait_until_prefix_drains(prefix: &str, timeout: Duration) { let start = Instant::now(); loop { if list_containers_with_prefix(prefix).is_empty() { return; } if start.elapsed() >= timeout { eprintln!( "t_docker: containers with prefix {prefix:?} did not drain within {:?}; \ force-removing", timeout ); remove_containers_with_prefix(prefix); return; } thread::sleep(Duration::from_millis(150)); } } /// Poll for "no leftover stage containers" with a short grace window. /// A clean `pp-smoke-run` exit triggers `docker run --rm` teardown on /// each shim, but the daemon-side delete is not synchronous with the /// CLI's exit, so we give the daemon a moment to catch up before we /// call the run dirty. fn assert_no_leftovers_eventually(prefix: &str, timeout: Duration) { let start = Instant::now(); loop { let leftover = list_containers_with_prefix(prefix); if leftover.is_empty() { return; } if start.elapsed() >= timeout { panic!("stage containers remained after run: {leftover:?}"); } thread::sleep(Duration::from_millis(150)); } } fn list_containers_with_prefix(prefix: &str) -> Vec { let filter = format!("name=^{prefix}-[0-9]+$"); let out = match Command::new("docker") .args(["ps", "-aq", "--filter", &filter]) .output() { Ok(o) => o, Err(_) => return Vec::new(), }; String::from_utf8_lossy(&out.stdout) .split_whitespace() .map(|s| s.to_string()) .collect() } /// Wait for at least `expected` containers matching `prefix` to be /// `running`. Returns the running container names on success, or /// `None` on timeout. fn wait_for_running_containers( prefix: &str, expected: usize, timeout: Duration, ) -> Option> { let filter_running = format!("name=^{prefix}-[0-9]+$"); let start = Instant::now(); loop { let out = Command::new("docker") .args([ "ps", "--filter", &filter_running, "--filter", "status=running", "--format", "{{.Names}}", ]) .output() .ok()?; let names: Vec = String::from_utf8_lossy(&out.stdout) .lines() .map(|s| s.to_string()) .collect(); if names.len() >= expected { return Some(names); } if start.elapsed() >= timeout { return None; } std::thread::sleep(Duration::from_millis(200)); } } /// Run `docker-e2e.sh N` and return its `(status, stdout, stderr)`. /// /// Stdout and stderr are written to files in a unique tempdir rather than /// captured in-process via `Command::output`. Direct pipe capture forces /// the entire script's output (including every container's chatty /// per-tick log line) through a kernel pipe whose write side is held by /// the orchestrator and a transitive `docker run` CLI per container. /// Under back-to-back load the cumulative pipe pressure intermittently /// stalls the SWIM gossip-piggyback registration of `pp-stage-0` long /// enough for the second run to time out resolving it (observed even /// with a 120s timeout). Redirecting to files makes the second run as /// reliable as the manual `scripts/docker-e2e.sh` invocation — neither /// holds the pipe back. fn run_docker_e2e(num_stages: u32, prefix: &str, skip_image_build: bool) -> ScriptOutcome { cargo_build_release_once(); let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let logdir = std::env::temp_dir().join(format!("pp-e2e-{prefix}-{stamp}")); std::fs::create_dir_all(&logdir).expect("create temp log dir"); let stdout_path = logdir.join("stdout.log"); let stderr_path = logdir.join("stderr.log"); let stdout_file = std::fs::File::create(&stdout_path).expect("create stdout log"); let stderr_file = std::fs::File::create(&stderr_path).expect("create stderr log"); let mut cmd = Command::new(docker_e2e_script()); cmd.arg(num_stages.to_string()) .env("PP_CONTAINER_PREFIX", prefix) .env("PP_SKIP_BUILD", "1") .stdout(Stdio::from(stdout_file)) .stderr(Stdio::from(stderr_file)); if skip_image_build { cmd.env("PP_SKIP_IMAGE_BUILD", "1"); } let status = cmd.status().expect("run docker-e2e.sh"); let stdout = std::fs::read_to_string(&stdout_path).unwrap_or_default(); let stderr = std::fs::read_to_string(&stderr_path).unwrap_or_default(); let _ = std::fs::remove_dir_all(&logdir); ScriptOutcome { status, stdout, stderr, } } struct ScriptOutcome { status: std::process::ExitStatus, stdout: String, stderr: String, } impl ScriptOutcome { fn require_success(&self, context: &str) { if self.status.success() { return; } // On failure, surface every diagnostic the orchestrator and // its container children emitted: name-registration lines, // resolution attempts, cluster convergence, premature exits. // Without these the reader gets just the test-side panic text // and nothing about what went wrong in the cluster. let registry: Vec<&str> = self .stderr .lines() .filter(|l| { l.contains("registered ") || l.contains("resolved ") || l.contains("failed to resolve") || l.contains("cluster did not converge") || l.contains("cluster converged") || l.contains("exited prematurely") }) .collect(); panic!( "docker-e2e.sh ({context}) exited {:?}\n\ --- stdout ---\n{}\n\ --- stderr registry events ---\n{}\n\ --- stderr (tail 120) ---\n{}", self.status, self.stdout, registry.join("\n"), tail_lines(&self.stderr, 120), ); } fn require_response(&self) -> String { let header = "=== pipeline-parallel Inference Response ==="; let footer = "============================================"; let mut lines = self.stdout.lines(); let hdr_idx = lines.position(|l| l == header).unwrap_or_else(|| { panic!( "stdout missing response header banner\n--- stdout ---\n{}", self.stdout ) }); let _ = hdr_idx; let body: Vec<&str> = self .stdout .lines() .skip_while(|l| *l != header) .skip(1) .take_while(|l| *l != footer) .collect(); assert!( !body.is_empty() && body.iter().any(|l| !l.is_empty()), "response banner present but body empty\n--- stdout ---\n{}", self.stdout ); body.join("\n") } } fn tail_lines(s: &str, n: usize) -> String { let v: Vec<&str> = s.lines().collect(); let start = v.len().saturating_sub(n); v[start..].join("\n") } /// Build the release binaries exactly once per test process. Each `#[ignore]` /// test in this file goes through `docker-e2e.sh` with `PP_SKIP_BUILD=1`, so /// without this the artefacts would be missing. Using a `OnceLock` keeps the /// tests independent of run order: whichever test fires first does the /// `cargo build`, the rest reuse the artefacts. fn cargo_build_release_once() { use std::sync::OnceLock; static BUILT: OnceLock<()> = OnceLock::new(); BUILT.get_or_init(|| { let status = Command::new("cargo") .arg("build") .arg("--manifest-path") .arg(crate_dir().join("Cargo.toml")) .arg("--release") .arg("--bin") .arg("pp-gpu-node") .arg("--bin") .arg("pp-smoke-run") .status() .expect("invoke cargo build"); assert!(status.success(), "cargo build --release failed"); }); } /// RAII guard that removes every container whose name starts with the /// configured prefix when dropped. Belt-and-suspenders: even if a test /// panics mid-run, we never leave containers behind to fail the next /// test or annoy the developer running the suite. /// /// `new` blocks until the prefix is drained — a previous test or a /// developer's manual run can leave a container that is still in /// `Created` / `Removal` and would otherwise haunt this test. struct PrefixCleanup<'a> { prefix: &'a str, } impl<'a> PrefixCleanup<'a> { fn new(prefix: &'a str) -> Self { remove_containers_with_prefix(prefix); wait_until_prefix_drains(prefix, Duration::from_secs(15)); Self { prefix } } } impl Drop for PrefixCleanup<'_> { fn drop(&mut self) { remove_containers_with_prefix(self.prefix); wait_until_prefix_drains(self.prefix, Duration::from_secs(10)); } } // ─── §13b.1 happy path ─────────────────────────────────────────────── #[test] #[ignore] fn docker_e2e_three_stage_cluster_returns_response() { let _serial = docker_serial_lock(); if !require_docker() { return; } let prefix = "pp-e2e-happy"; let _cleanup = PrefixCleanup::new(prefix); let outcome = run_docker_e2e(3, prefix, false); outcome.require_success("happy-path N=3"); let response = outcome.require_response(); assert!( !response.trim().is_empty(), "expected non-empty response text" ); assert_no_leftovers_eventually(prefix, Duration::from_secs(10)); } #[test] #[ignore] fn docker_e2e_re_running_command_twice_both_pass() { let _serial = docker_serial_lock(); if !require_docker() { return; } let prefix = "pp-e2e-idem"; let _cleanup = PrefixCleanup::new(prefix); let first = run_docker_e2e(3, prefix, false); first.require_success("first run"); let _ = first.require_response(); assert_no_leftovers_eventually(prefix, Duration::from_secs(10)); // Small settle between runs. `assert_no_leftovers_eventually` // already waits for docker to remove the prior containers, but // the kernel keeps the prior orchestrator's UDP sockets around // for a moment after the process exits; giving them time to clear // keeps the second run from racing the kernel for ephemeral ports // when iroh re-opens its endpoint. thread::sleep(Duration::from_secs(2)); // Second run reuses the image and the cargo artefacts. let second = run_docker_e2e(3, prefix, true); second.require_success("second run"); let _ = second.require_response(); assert_no_leftovers_eventually(prefix, Duration::from_secs(10)); } // ─── §13b.2 failure path ───────────────────────────────────────────── #[test] #[ignore] fn docker_e2e_premature_container_exit_fails_fast() { let _serial = docker_serial_lock(); if !require_docker() { return; } let prefix = "pp-e2e-fail"; let _cleanup = PrefixCleanup::new(prefix); cargo_build_release_once(); // Build the stub image inline so the failure test does not depend // on a prior happy-path run having already built it. let workspace = crate_dir() .parent() .and_then(|p| p.parent()) .expect("workspace root") .to_path_buf(); let image_tag = "pp-gpu-node-stub:t_docker-fail"; let dockerfile = crate_dir().join("Dockerfile.stub"); let build = Command::new("docker") .args(["build", "-f"]) .arg(&dockerfile) .arg("-t") .arg(image_tag) .arg(&workspace) .status() .expect("docker build"); assert!(build.success(), "docker build failed"); let smoke_bin = crate_dir().join("target/release/pp-smoke-run"); let worker_py = crate_dir().join("pp_tinygrad_worker.py"); assert!(smoke_bin.exists() && worker_py.exists()); let mut child = Command::new(&smoke_bin) .arg("--seed") .arg("--num-stages") .arg("3") .arg("--gpu-node") .arg(docker_gpu_node_shim()) .arg("--worker") .arg(&worker_py) .arg("--prompt") .arg("Say hello") .arg("--max-tokens") .arg("64") .env("PP_WORKER_STUB", "1") .env("PP_IMAGE", image_tag) .env("PP_CONTAINER_PREFIX", prefix) .env("PP_DEV", "CPU") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn pp-smoke-run with docker shim"); // Drain the child's stderr (and stdout) into in-memory buffers via // background reader threads. Without this any pp-smoke-run / shim // diagnostic message is swallowed and the test gives the reader // nothing actionable on failure. let stderr_buf = spawn_stream_collector(child.stderr.take().expect("child stderr piped")); let stdout_buf = spawn_stream_collector(child.stdout.take().expect("child stdout piped")); // Wait for all 3 stage containers to be running, then kill the middle one. let running = match wait_for_running_containers(prefix, 3, Duration::from_secs(180)) { Some(running) => running, None => { let _ = child.kill(); let _ = child.wait(); panic!( "3 stage containers did not start within 180s\n\ --- pp-smoke-run stderr (tail) ---\n{}\n\ --- pp-smoke-run stdout (tail) ---\n{}", tail_lines(&stderr_buf.snapshot(), 80), tail_lines(&stdout_buf.snapshot(), 40), ); } }; // Container names are pp--{stage}; pick the middle one (-1). let victim = format!("{prefix}-1"); assert!( running.iter().any(|n| n == &victim), "expected {victim} in running set, got {running:?}" ); let kill = Command::new("docker") .args(["kill", &victim]) .status() .expect("docker kill"); assert!(kill.success(), "docker kill {victim} failed"); // The orchestrator must surface this as a non-zero exit within // the kill detection window (try_wait inside await_response sees // the docker-shim child exit promptly). let deadline = Instant::now() + Duration::from_secs(60); let status = loop { match child.try_wait().expect("try_wait pp-smoke-run") { Some(s) => break s, None if Instant::now() >= deadline => { let _ = child.kill(); let _ = child.wait(); panic!( "pp-smoke-run did not exit within 60s of killing a container\n\ --- pp-smoke-run stderr (tail) ---\n{}\n\ --- pp-smoke-run stdout (tail) ---\n{}", tail_lines(&stderr_buf.snapshot(), 80), tail_lines(&stdout_buf.snapshot(), 40), ); } None => std::thread::sleep(Duration::from_millis(100)), } }; assert!( !status.success(), "expected non-zero exit after container kill, got {status:?}\n\ --- pp-smoke-run stderr (tail) ---\n{}", tail_lines(&stderr_buf.snapshot(), 80), ); // No stage container may survive the failure path. The shim's // `docker run --rm` cleans up the victim and the surviving // containers exit when their parent shim process dies. Poll for a // few seconds because `--rm` removes asynchronously after exit. assert_no_leftovers_eventually(prefix, Duration::from_secs(15)); } /// Background reader for a child's stdout/stderr pipe. Writes the /// stream to a thread-safe buffer in 4 KiB chunks so we can dump the /// tail on a test panic without blocking on the read. struct StreamBuf { inner: Arc>>, } impl StreamBuf { fn snapshot(&self) -> String { let guard = self.inner.lock().unwrap_or_else(|p| p.into_inner()); String::from_utf8_lossy(&guard).to_string() } } fn spawn_stream_collector(mut reader: R) -> StreamBuf { let inner = Arc::new(Mutex::new(Vec::::new())); let inner_for_thread = Arc::clone(&inner); thread::spawn(move || { let mut chunk = [0u8; 4096]; loop { match reader.read(&mut chunk) { Ok(0) => break, Ok(n) => { let mut guard = inner_for_thread .lock() .unwrap_or_else(|p| p.into_inner()); guard.extend_from_slice(&chunk[..n]); } Err(_) => break, } } }); StreamBuf { inner } }