Skip to main content

mockforge_test/
process.rs

1//! Process management for MockForge servers
2
3use crate::config::ServerConfig;
4use crate::error::{Error, Result};
5use std::path::PathBuf;
6use std::process::{Child, Command, Stdio};
7use tracing::{debug, info, warn};
8
9/// Managed MockForge process
10pub struct ManagedProcess {
11    child: Child,
12    http_port: u16,
13    pid: u32,
14}
15
16impl ManagedProcess {
17    /// Spawn a new MockForge server process
18    pub fn spawn(config: &ServerConfig) -> Result<Self> {
19        let binary_path = find_mockforge_binary(config)?;
20        debug!("Using MockForge binary at: {:?}", binary_path);
21
22        let mut cmd = Command::new(&binary_path);
23        cmd.arg("serve");
24
25        // Configure ports
26        cmd.arg("--http-port").arg(config.http_port.to_string());
27
28        if let Some(ws_port) = config.ws_port {
29            cmd.arg("--ws-port").arg(ws_port.to_string());
30        }
31
32        if let Some(grpc_port) = config.grpc_port {
33            cmd.arg("--grpc-port").arg(grpc_port.to_string());
34        }
35
36        if let Some(admin_port) = config.admin_port {
37            cmd.arg("--admin-port").arg(admin_port.to_string());
38        }
39
40        if let Some(metrics_port) = config.metrics_port {
41            cmd.arg("--metrics-port").arg(metrics_port.to_string());
42        }
43
44        // Configure admin UI
45        if config.enable_admin {
46            cmd.arg("--admin");
47        }
48
49        // Configure metrics
50        if config.enable_metrics {
51            cmd.arg("--metrics");
52        }
53
54        // Configure spec file
55        if let Some(spec_file) = &config.spec_file {
56            cmd.arg("--spec").arg(spec_file);
57        }
58
59        // Configure workspace
60        if let Some(workspace_dir) = &config.workspace_dir {
61            cmd.arg("--workspace-dir").arg(workspace_dir);
62        }
63
64        // Configure profile
65        if let Some(profile) = &config.profile {
66            cmd.arg("--profile").arg(profile);
67        }
68
69        // Add extra arguments
70        for arg in &config.extra_args {
71            cmd.arg(arg);
72        }
73
74        // Set working directory
75        if let Some(working_dir) = &config.working_dir {
76            cmd.current_dir(working_dir);
77        }
78
79        // Set environment variables
80        for (key, value) in &config.env_vars {
81            cmd.env(key, value);
82        }
83
84        // Configure stdio - use inherit() for testing to see actual output
85        cmd.stdout(Stdio::inherit());
86        cmd.stderr(Stdio::inherit());
87
88        debug!("Spawning MockForge process: {:?}", cmd);
89
90        let child = cmd
91            .spawn()
92            .map_err(|e| Error::ServerStartFailed(format!("Failed to spawn process: {}", e)))?;
93
94        let pid = child.id();
95        info!("Spawned MockForge process with PID: {}", pid);
96
97        Ok(Self {
98            child,
99            http_port: config.http_port,
100            pid,
101        })
102    }
103
104    /// Get the HTTP port the server is running on
105    pub fn http_port(&self) -> u16 {
106        self.http_port
107    }
108
109    /// Get the process ID
110    pub fn pid(&self) -> u32 {
111        self.pid
112    }
113
114    /// Check if the process is still running
115    pub fn is_running(&mut self) -> bool {
116        matches!(self.child.try_wait(), Ok(None))
117    }
118
119    /// Kill the process
120    pub fn kill(&mut self) -> Result<()> {
121        if self.is_running() {
122            debug!("Killing MockForge process (PID: {})", self.pid);
123            self.child
124                .kill()
125                .map_err(|e| Error::ProcessError(format!("Failed to kill process: {}", e)))?;
126
127            // Wait for the process to exit
128            let _ = self.child.wait();
129            info!("MockForge process (PID: {}) terminated", self.pid);
130        } else {
131            debug!("Process (PID: {}) already exited", self.pid);
132        }
133        Ok(())
134    }
135}
136
137impl Drop for ManagedProcess {
138    fn drop(&mut self) {
139        if let Err(e) = self.kill() {
140            warn!("Failed to kill process on drop: {}", e);
141        }
142    }
143}
144
145/// Find the MockForge binary.
146///
147/// Resolution order:
148/// 1. Explicit `config.binary_path` (when the test set one).
149/// 2. `MOCKFORGE_TEST_BINARY` env var (lets `cargo test` point at the
150///    freshly-built `target/debug/mockforge` instead of an older
151///    `mockforge` on PATH).
152/// 3. The workspace's `target/debug/mockforge` and `target/release/mockforge`,
153///    if either exists — auto-detected via `CARGO_TARGET_DIR` or by walking
154///    up from `CARGO_MANIFEST_DIR`. This makes `cargo test` "just work" on
155///    a fresh checkout without having to `cargo install --path`.
156/// 4. `mockforge` on `$PATH` as a last resort.
157fn find_mockforge_binary(config: &ServerConfig) -> Result<PathBuf> {
158    if let Some(binary_path) = &config.binary_path {
159        if binary_path.exists() {
160            return Ok(binary_path.clone());
161        }
162        return Err(Error::BinaryNotFound);
163    }
164
165    if let Ok(env_path) = std::env::var("MOCKFORGE_TEST_BINARY") {
166        let p = PathBuf::from(env_path);
167        if p.exists() {
168            return Ok(p);
169        }
170    }
171
172    if let Some(p) = workspace_target_binary() {
173        return Ok(p);
174    }
175
176    which::which("mockforge")
177        .map_err(|_| Error::BinaryNotFound)
178        .map(|p| p.to_path_buf())
179}
180
181/// Look for a freshly-built `mockforge` binary under the workspace's
182/// `target/{debug,release}` directory, preferring debug. Returns `None`
183/// when neither candidate exists.
184///
185/// Resolution order for the target dir:
186/// 1. `CARGO_TARGET_DIR` env var
187/// 2. Walk up from `CARGO_MANIFEST_DIR` looking for a `target/` sibling
188///    (set by cargo when the test runs under `cargo test`)
189/// 3. Walk up from `std::env::current_exe()` — this lets us locate the
190///    workspace target even when the test binary is invoked directly
191///    (e.g. by a debugger), so we don't silently fall through to a
192///    stale `mockforge` on `$PATH` whose schema may differ from the
193///    workspace.
194fn workspace_target_binary() -> Option<PathBuf> {
195    let target_dir = std::env::var_os("CARGO_TARGET_DIR")
196        .map(PathBuf::from)
197        .or_else(target_dir_from_manifest)
198        .or_else(target_dir_from_current_exe)?;
199
200    let debug = target_dir.join("debug").join("mockforge");
201    if debug.exists() {
202        return Some(debug);
203    }
204    let release = target_dir.join("release").join("mockforge");
205    if release.exists() {
206        return Some(release);
207    }
208    None
209}
210
211fn target_dir_from_manifest() -> Option<PathBuf> {
212    let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)?;
213    let mut dir: &std::path::Path = &manifest_dir;
214    loop {
215        let candidate = dir.join("target");
216        if candidate.is_dir() {
217            return Some(candidate);
218        }
219        dir = dir.parent()?;
220    }
221}
222
223fn target_dir_from_current_exe() -> Option<PathBuf> {
224    let exe = std::env::current_exe().ok()?;
225    let mut dir = exe.parent()?;
226    loop {
227        if dir.file_name() == Some(std::ffi::OsStr::new("target")) {
228            return Some(dir.to_path_buf());
229        }
230        dir = dir.parent()?;
231    }
232}
233
234/// Check if a port is available
235pub fn is_port_available(port: u16) -> bool {
236    use std::net::TcpListener;
237    TcpListener::bind(("127.0.0.1", port)).is_ok()
238}
239
240/// Find an available port.
241///
242/// Always asks the OS for an ephemeral port (binds to `127.0.0.1:0` and
243/// reads back the assigned port). The `start_port` argument is accepted
244/// for API compatibility but ignored — the old "sequentially probe
245/// `start_port..start_port+100`" strategy raced badly under parallel
246/// nextest runs, where two tests would both see the same port as free
247/// and both subprocesses would try to bind it. On macOS the loser's
248/// `bind` returns `EADDRINUSE` (errno 48) and the losing subprocess's
249/// `tokio::select!` over its admin handle would then short-circuit the
250/// entire mock server, taking HTTP + WS down with it. The test's
251/// `connect_async` saw `Connection refused` and failed.
252///
253/// The OS-assigned ephemeral range is ~30k ports wide, so the same race
254/// is still technically possible but astronomically unlikely in practice.
255///
256/// A tiny TOCTOU window remains — the listener drops before the caller's
257/// subprocess binds the same port — but in practice this hasn't been
258/// observed because the ephemeral range is large and no other process is
259/// typically aggressively snapping ports in that window.
260pub fn find_available_port(_start_port: u16) -> Result<u16> {
261    use std::net::TcpListener;
262    let listener = TcpListener::bind("127.0.0.1:0")
263        .map_err(|e| Error::ConfigError(format!("OS-assigned port bind failed: {}", e)))?;
264    let port = listener
265        .local_addr()
266        .map_err(|e| Error::ConfigError(format!("Failed to read OS-assigned port: {}", e)))?
267        .port();
268    drop(listener);
269    Ok(port)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_is_port_available() {
278        // Port 0 should always be available (it means "assign any port")
279        assert!(is_port_available(0));
280    }
281
282    #[test]
283    fn test_find_available_port_returns_nonzero() {
284        // `find_available_port` always asks the OS for an ephemeral port.
285        // The `start_port` hint is ignored (see the function's doc comment
286        // for why), so the assigned port is from the OS ephemeral range
287        // rather than the argument's range.
288        let port = find_available_port(30000).expect("Failed to find available port");
289        assert!(port > 0);
290    }
291
292    #[test]
293    fn test_find_available_port_ignores_hint() {
294        // Two calls with the same hint should hand back distinct ports
295        // (ephemeral allocation is effectively never reused back-to-back).
296        let port1 = find_available_port(30000).expect("Failed to find port 1");
297        let port2 = find_available_port(30000).expect("Failed to find port 2");
298        assert_ne!(port1, port2, "ephemeral allocator handed back the same port twice");
299    }
300
301    #[test]
302    fn test_is_port_available_high_port() {
303        // High ports are usually available
304        let available = is_port_available(59999);
305        // This might be true or false depending on system state
306        // Just ensure it doesn't panic
307        let _ = available;
308    }
309
310    #[test]
311    fn test_multiple_port_allocations() {
312        // Get three ports; they should all be nonzero and distinct.
313        let port1 = find_available_port(31000).expect("Failed to find port 1");
314        let port2 = find_available_port(32000).expect("Failed to find port 2");
315        let port3 = find_available_port(33000).expect("Failed to find port 3");
316
317        assert!(port1 > 0);
318        assert!(port2 > 0);
319        assert!(port3 > 0);
320        assert_ne!(port1, port2);
321        assert_ne!(port1, port3);
322        assert_ne!(port2, port3);
323    }
324}