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 near `start_port`.
241///
242/// Sequentially probes `start_port..start_port+100`, returning the first
243/// port that isn't currently bound. If every port in that range is taken
244/// (which happens under heavy parallel test load), falls back to asking
245/// the OS for an ephemeral port via binding to port 0 — the test will get
246/// *some* free port even when its preferred range is saturated.
247pub fn find_available_port(start_port: u16) -> Result<u16> {
248    for port in start_port..start_port.saturating_add(100) {
249        if is_port_available(port) {
250            return Ok(port);
251        }
252    }
253    // Fallback: ask the OS to assign an ephemeral port. Without this,
254    // running many integration tests in parallel could exhaust the
255    // sequential range and surface a confusing "no available ports"
256    // error even though the machine had thousands of free ports.
257    use std::net::TcpListener;
258    let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| {
259        Error::ConfigError(format!(
260            "No available ports in {}-{} and OS-assigned fallback failed: {}",
261            start_port,
262            start_port.saturating_add(100),
263            e
264        ))
265    })?;
266    let port = listener
267        .local_addr()
268        .map_err(|e| Error::ConfigError(format!("Failed to read OS-assigned port: {}", e)))?
269        .port();
270    drop(listener);
271    Ok(port)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_is_port_available() {
280        // Port 0 should always be available (it means "assign any port")
281        assert!(is_port_available(0));
282    }
283
284    #[test]
285    fn test_find_available_port() {
286        // Should find a port starting from 30000
287        let port = find_available_port(30000).expect("Failed to find available port");
288        assert!(port >= 30000);
289        assert!(port < 30100);
290    }
291
292    #[test]
293    fn test_find_available_port_from_different_start() {
294        let port = find_available_port(40000).expect("Failed to find available port");
295        assert!(port >= 40000);
296        assert!(port < 40100);
297    }
298
299    #[test]
300    fn test_find_available_port_high_range() {
301        let port = find_available_port(60000).expect("Failed to find available port");
302        assert!(port >= 60000);
303        assert!(port < 60100);
304    }
305
306    #[test]
307    fn test_is_port_available_high_port() {
308        // High ports are usually available
309        let available = is_port_available(59999);
310        // This might be true or false depending on system state
311        // Just ensure it doesn't panic
312        let _ = available;
313    }
314
315    #[test]
316    fn test_multiple_port_allocations() {
317        // Find multiple available ports
318        let port1 = find_available_port(31000).expect("Failed to find port 1");
319        let port2 = find_available_port(32000).expect("Failed to find port 2");
320        let port3 = find_available_port(33000).expect("Failed to find port 3");
321
322        // Ports should be in their respective ranges
323        assert!((31000..31100).contains(&port1));
324        assert!((32000..32100).contains(&port2));
325        assert!((33000..33100).contains(&port3));
326    }
327}