//! Picloud subprocess lifecycle for the CLI integration test binary. //! //! Mirrors what the original seed test did inline, lifted out so it can //! be shared across modules via `common::fixture()`. The Fixture lives //! in a `LazyLock` — and `LazyLock` never drops, so we register an //! `atexit` handler that SIGTERMs the child when the test binary exits //! normally (which is the common case under `cargo test`). //! //! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the //! creating thread dies, and cargo runs each `#[test]` on its own //! worker thread that exits as soon as the test returns — which would //! kill picloud after the first test that triggered the LazyLock, //! breaking every test after it. //! //! Abnormal exit (SIGKILL of the test binary) leaks the child; the //! dashboard Playwright suite accepts the same tradeoff. use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Child, Command as StdCommand, Stdio}; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; use serde_json::Value; pub fn pick_free_port() -> u16 { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port"); listener.local_addr().expect("local addr").port() } pub fn picloud_binary_path() -> PathBuf { // The integration test binary lives at // `/debug/deps/cli-`. Walk up two levels to reach // `/debug` and look for `picloud` next to ourselves. let exe = std::env::current_exe().expect("current_exe"); let debug_dir = exe .parent() .and_then(|p| p.parent()) .expect("test binary should live under target/debug/deps"); debug_dir.join(if cfg!(windows) { "picloud.exe" } else { "picloud" }) } pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child { let binary = picloud_binary_path(); assert!( binary.exists(), "expected picloud binary at {}. Run `cargo build -p picloud` first \ (or use `cargo test --workspace -- --include-ignored` which builds it)", binary.display() ); let mut child = StdCommand::new(&binary) .env("PICLOUD_BIND", format!("127.0.0.1:{port}")) .env("DATABASE_URL", database_url) .env("PICLOUD_ADMIN_USERNAME", admin_user) .env("PICLOUD_ADMIN_PASSWORD", admin_pass) .env("RUST_LOG", "warn") .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .expect("spawn picloud"); // Drain stderr in a side thread so the pipe buffer doesn't fill and // block the server. if let Some(err) = child.stderr.take().map(BufReader::new) { let (tx, _rx) = mpsc::channel::(); thread::spawn(move || { for line in err.lines().map_while(Result::ok) { let _ = tx.send(line); } }); } register_atexit_killer(child.id()); child } // -------------------------------------------------------------------- // atexit-based child cleanup // -------------------------------------------------------------------- static PICLOUD_PID: AtomicI32 = AtomicI32::new(0); fn register_atexit_killer(pid: u32) { // First spawn wins; subsequent spawns (none expected today) would // overwrite, but the previous child would already be tracked via // its Drop path on the Fixture. PICLOUD_PID.store(pid as i32, Ordering::SeqCst); #[cfg(unix)] { use std::sync::Once; static REGISTERED: Once = Once::new(); REGISTERED.call_once(|| { // SAFETY: atexit's contract is a `extern "C" fn()` callback; // ours signals a child PID we own. unsafe { libc::atexit(kill_picloud_at_exit); } }); } } #[cfg(unix)] extern "C" fn kill_picloud_at_exit() { let pid = PICLOUD_PID.swap(0, Ordering::SeqCst); if pid > 0 { // SAFETY: SIGTERM to a PID we recorded; if PID has been reused // we're killing the wrong process — accepted risk for a test // helper. unsafe { libc::kill(pid, libc::SIGTERM); } } } pub fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> { let deadline = Instant::now() + timeout; let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(2)) .build() .map_err(|e| e.to_string())?; while Instant::now() < deadline { if let Ok(resp) = client.get(format!("{url}/healthz")).send() { if resp.status().is_success() { return Ok(()); } } thread::sleep(Duration::from_millis(250)); } Err(format!("/healthz never returned 200 within {timeout:?}")) } pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String { let client = reqwest::blocking::Client::new(); let resp = client .post(format!("{url}/api/v1/admin/auth/login")) .json(&serde_json::json!({ "username": username, "password": password, })) .send() .expect("login request"); assert!( resp.status().is_success(), "login should succeed, got {}: {}", resp.status(), resp.text().unwrap_or_default() ); let v: Value = resp.json().expect("login json"); v["token"] .as_str() .expect("login returns token") .to_string() } pub fn kill_subprocess(child: &mut Child) { let _ = child.kill(); let _ = child.wait(); }