//! Subprocess wrapper around `maestro daemon`. use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::Duration; use tempfile::TempDir; use crate::build::workspace_target_bin; use crate::util::{free_port, wait_for_async}; /// A live daemon running in a child process. Kill the child on drop. /// /// `home` and `tmux_tmpdir` are kept as `TempDir`s so they outlive the process /// and get cleaned up automatically. State lives under `home` via /// `XDG_STATE_HOME`. #[derive(Debug)] pub struct Daemon { pub bind_addr: SocketAddr, child: Option, home: TempDir, tmux: TempDir, } impl Daemon { /// Spawn a fresh daemon on a free localhost port. pub async fn spawn() -> Self { Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await } /// Spawn with a config file pre-seeded at the daemon's expected path /// (`$XDG_CONFIG_HOME/maestro/config.toml`). pub async fn spawn_with_config(toml: &str) -> Self { Self::spawn_config_at( SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())), Some(toml), ) .await } /// Spawn on a caller-supplied bind address. pub async fn spawn_at(bind: SocketAddr) -> Self { Self::spawn_config_at(bind, None).await } /// Spawn on a caller-supplied bind address, with an optional config file. async fn spawn_config_at(bind: SocketAddr, config: impl Into>) -> Self { let home = TempDir::new().expect("home tmpdir"); let tmux = TempDir::new().expect("tmux tmpdir"); if let Some(toml) = config.into() { let dir = home.path().join(".config").join("maestro"); std::fs::create_dir_all(&dir).expect("config dir"); std::fs::write(dir.join("config.toml"), toml).expect("write config.toml"); } let child = launch(bind, home.path(), tmux.path()); let me = Self { bind_addr: bind, child: Some(child), home, tmux, }; me.wait_until_ready().await; me } /// HTTP base URL of this daemon's API. pub fn http_base(&self) -> String { format!("http://{}", self.bind_addr) } /// Shared client bound to this daemon, with the harness's tighter budget. pub fn client(&self) -> maestro_protocol::DaemonClient { crate::client::TestClient::new(&self.http_base()) .daemon() .clone() } /// Stop the underlying process, but keep its state dirs around so we can /// `restart()` against the same `$HOME` / `state` / `TMUX_TMPDIR`. pub fn stop(&mut self) { if let Some(mut c) = self.child.take() { let _ = c.kill(); let _ = c.wait(); } } /// Re-spawn the daemon on the same port + state. The previous tmux /// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt /// its sessions. pub async fn restart(&mut self) { self.stop(); self.child = Some(launch(self.bind_addr, self.home.path(), self.tmux.path())); self.wait_until_ready().await; } async fn wait_until_ready(&self) { let addr = self.bind_addr; wait_for_async("daemon api port", Duration::from_secs(5), || async move { tokio::net::TcpStream::connect(addr).await.is_ok() }) .await; } pub fn home(&self) -> &std::path::Path { self.home.path() } pub fn state_dir(&self) -> PathBuf { // Matches maestro_daemon::default_state_dir which uses XDG_STATE_HOME. self.home.path().join(".local/state/maestro") } } impl Drop for Daemon { fn drop(&mut self) { if let Some(mut c) = self.child.take() { let _ = c.kill(); let _ = c.wait(); } } } fn launch(bind: SocketAddr, home: &std::path::Path, tmux_tmpdir: &std::path::Path) -> Child { let bin = workspace_target_bin("maestro", "maestro"); let mut cmd = Command::new(&bin); cmd.arg("daemon"); cmd.env_clear() .env( "PATH", std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()), ) .env("HOME", home) .env("XDG_CONFIG_HOME", home.join(".config")) .env("XDG_STATE_HOME", home.join(".local/state")) .env("TMUX_TMPDIR", tmux_tmpdir) .env("MAESTRO_DAEMON_BIND_ADDR", bind.to_string()) .env("RUST_LOG", "warn") .stdout(Stdio::null()) .stderr(Stdio::inherit()) .arg("run"); cmd.spawn() .unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display())) }