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 crate::port_reservation::PortReservation;
6use std::path::PathBuf;
7use std::process::{Child, Command, Stdio};
8use tracing::{debug, info, warn};
9
10/// Managed MockForge process
11pub struct ManagedProcess {
12    child: Child,
13    http_port: u16,
14    pid: u32,
15    port_reservations: Vec<PortReservation>,
16}
17
18impl ManagedProcess {
19    /// Spawn a new MockForge server process
20    pub fn spawn(config: &ServerConfig) -> Result<Self> {
21        Self::spawn_reserved(config, Vec::new())
22    }
23
24    pub(crate) fn spawn_reserved(
25        config: &ServerConfig,
26        mut port_reservations: Vec<PortReservation>,
27    ) -> Result<Self> {
28        let binary_path = find_mockforge_binary(config)?;
29        debug!("Using MockForge binary at: {:?}", binary_path);
30
31        let mut cmd = Command::new(&binary_path);
32        cmd.arg("serve");
33
34        // Configure ports
35        cmd.arg("--http-port").arg(config.http_port.to_string());
36
37        if let Some(ws_port) = config.ws_port {
38            cmd.arg("--ws-port").arg(ws_port.to_string());
39        }
40
41        if let Some(grpc_port) = config.grpc_port {
42            cmd.arg("--grpc-port").arg(grpc_port.to_string());
43        }
44
45        if let Some(admin_port) = config.admin_port {
46            cmd.arg("--admin-port").arg(admin_port.to_string());
47        }
48
49        if let Some(metrics_port) = config.metrics_port {
50            cmd.arg("--metrics-port").arg(metrics_port.to_string());
51        }
52
53        // Configure admin UI
54        if config.enable_admin {
55            cmd.arg("--admin");
56        }
57
58        // Configure metrics
59        if config.enable_metrics {
60            cmd.arg("--metrics");
61        }
62
63        // Configure spec file
64        if let Some(spec_file) = &config.spec_file {
65            cmd.arg("--spec").arg(spec_file);
66        }
67
68        // Configure workspace
69        if let Some(workspace_dir) = &config.workspace_dir {
70            cmd.arg("--workspace-dir").arg(workspace_dir);
71        }
72
73        // Configure profile
74        if let Some(profile) = &config.profile {
75            cmd.arg("--profile").arg(profile);
76        }
77
78        // Add extra arguments
79        for arg in &config.extra_args {
80            cmd.arg(arg);
81        }
82
83        // Set working directory
84        if let Some(working_dir) = &config.working_dir {
85            cmd.current_dir(working_dir);
86        }
87
88        // Set environment variables
89        for (key, value) in &config.env_vars {
90            cmd.env(key, value);
91        }
92
93        // Configure stdio - use inherit() for testing to see actual output
94        cmd.stdout(Stdio::inherit());
95        cmd.stderr(Stdio::inherit());
96
97        debug!("Spawning MockForge process: {:?}", cmd);
98
99        // The CLI binds its own sockets. Release the temporary listeners just
100        // before spawn, retaining process-local claims until the child is reaped.
101        for reservation in &mut port_reservations {
102            reservation.handoff();
103        }
104        let child = cmd
105            .spawn()
106            .map_err(|e| Error::ServerStartFailed(format!("Failed to spawn process: {}", e)))?;
107
108        let pid = child.id();
109        info!("Spawned MockForge process with PID: {}", pid);
110
111        Ok(Self {
112            child,
113            http_port: config.http_port,
114            pid,
115            port_reservations,
116        })
117    }
118
119    /// Get the HTTP port the server is running on
120    pub fn http_port(&self) -> u16 {
121        self.http_port
122    }
123
124    /// Get the process ID
125    pub fn pid(&self) -> u32 {
126        self.pid
127    }
128
129    /// Check if the process is still running
130    pub fn is_running(&mut self) -> bool {
131        matches!(self.child.try_wait(), Ok(None))
132    }
133
134    /// Kill the process
135    pub fn kill(&mut self) -> Result<()> {
136        let status = self.child.try_wait().map_err(|e| {
137            Error::ProcessError(format!("Failed to inspect process {}: {e}", self.pid))
138        })?;
139        if status.is_none() {
140            debug!("Killing MockForge process (PID: {})", self.pid);
141            self.child
142                .kill()
143                .map_err(|e| Error::ProcessError(format!("Failed to kill process: {e}")))?;
144            self.child
145                .wait()
146                .map_err(|e| Error::ProcessError(format!("Failed to reap process: {e}")))?;
147            info!("MockForge process (PID: {}) terminated", self.pid);
148        } else {
149            debug!("Process (PID: {}) already exited", self.pid);
150        }
151        // Never make a port available to a sibling until its owner is reaped.
152        self.port_reservations.clear();
153        Ok(())
154    }
155}
156
157impl Drop for ManagedProcess {
158    fn drop(&mut self) {
159        if let Err(e) = self.kill() {
160            // If cleanup cannot prove exit, conservatively keep the claims for
161            // the rest of this test process. Do not hand them to another child.
162            for reservation in &mut self.port_reservations {
163                reservation.retain_claim();
164            }
165            warn!("Failed to kill process on drop: {}", e);
166        }
167    }
168}
169
170/// Find the MockForge binary.
171///
172/// Resolution order:
173/// 1. Explicit `config.binary_path` (when the test set one).
174/// 2. `MOCKFORGE_TEST_BINARY` env var (lets `cargo test` point at the
175///    freshly-built `target/debug/mockforge` instead of an older
176///    `mockforge` on PATH).
177/// 3. The workspace's `target/debug/mockforge` and `target/release/mockforge`,
178///    if either exists — auto-detected via `CARGO_TARGET_DIR` or by walking
179///    up from `CARGO_MANIFEST_DIR`. This makes `cargo test` "just work" on
180///    a fresh checkout without having to `cargo install --path`.
181/// 4. `mockforge` on `$PATH` as a last resort.
182fn find_mockforge_binary(config: &ServerConfig) -> Result<PathBuf> {
183    if let Some(binary_path) = &config.binary_path {
184        if binary_path.exists() {
185            return Ok(binary_path.clone());
186        }
187        return Err(Error::BinaryNotFound);
188    }
189
190    if let Ok(env_path) = std::env::var("MOCKFORGE_TEST_BINARY") {
191        let p = PathBuf::from(env_path);
192        if p.exists() {
193            return Ok(p);
194        }
195    }
196
197    if let Some(p) = workspace_target_binary() {
198        return Ok(p);
199    }
200
201    which::which("mockforge")
202        .map_err(|_| Error::BinaryNotFound)
203        .map(|p| p.to_path_buf())
204}
205
206/// Look for a freshly-built `mockforge` binary under the workspace's
207/// `target/{debug,release}` directory, preferring debug. Returns `None`
208/// when neither candidate exists.
209///
210/// Resolution order for the target dir:
211/// 1. `CARGO_TARGET_DIR` env var
212/// 2. Walk up from `CARGO_MANIFEST_DIR` looking for a `target/` sibling
213///    (set by cargo when the test runs under `cargo test`)
214/// 3. Walk up from `std::env::current_exe()` — this lets us locate the
215///    workspace target even when the test binary is invoked directly
216///    (e.g. by a debugger), so we don't silently fall through to a
217///    stale `mockforge` on `$PATH` whose schema may differ from the
218///    workspace.
219fn workspace_target_binary() -> Option<PathBuf> {
220    let target_dir = std::env::var_os("CARGO_TARGET_DIR")
221        .map(PathBuf::from)
222        .or_else(target_dir_from_manifest)
223        .or_else(target_dir_from_current_exe)?;
224
225    let debug = target_dir.join("debug").join("mockforge");
226    if debug.exists() {
227        return Some(debug);
228    }
229    let release = target_dir.join("release").join("mockforge");
230    if release.exists() {
231        return Some(release);
232    }
233    None
234}
235
236fn target_dir_from_manifest() -> Option<PathBuf> {
237    let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)?;
238    let mut dir: &std::path::Path = &manifest_dir;
239    loop {
240        let candidate = dir.join("target");
241        if candidate.is_dir() {
242            return Some(candidate);
243        }
244        dir = dir.parent()?;
245    }
246}
247
248fn target_dir_from_current_exe() -> Option<PathBuf> {
249    let exe = std::env::current_exe().ok()?;
250    let mut dir = exe.parent()?;
251    loop {
252        if dir.file_name() == Some(std::ffi::OsStr::new("target")) {
253            return Some(dir.to_path_buf());
254        }
255        dir = dir.parent()?;
256    }
257}
258
259/// Check if a port is available
260pub fn is_port_available(port: u16) -> bool {
261    use std::net::TcpListener;
262    TcpListener::bind(("127.0.0.1", port)).is_ok()
263}
264
265/// Find an available port.
266///
267/// Always asks the OS for an ephemeral port (binds to `127.0.0.1:0` and
268/// reads back the assigned port). The `start_port` argument is accepted
269/// for API compatibility but ignored — the old "sequentially probe
270/// `start_port..start_port+100`" strategy raced badly under parallel
271/// nextest runs, where two tests would both see the same port as free
272/// and both subprocesses would try to bind it. On macOS the loser's
273/// `bind` returns `EADDRINUSE` (errno 48) and the losing subprocess's
274/// `tokio::select!` over its admin handle would then short-circuit the
275/// entire mock server, taking HTTP + WS down with it. The test's
276/// `connect_async` saw `Connection refused` and failed.
277///
278/// The OS-assigned ephemeral range is ~30k ports wide, so the same race
279/// is still technically possible but astronomically unlikely in practice.
280///
281/// A tiny TOCTOU window remains — the listener drops before the caller's
282/// subprocess binds the same port — but in practice this hasn't been
283/// observed because the ephemeral range is large and no other process is
284/// typically aggressively snapping ports in that window.
285pub fn find_available_port(_start_port: u16) -> Result<u16> {
286    use std::net::TcpListener;
287    let listener = TcpListener::bind("127.0.0.1:0")
288        .map_err(|e| Error::ConfigError(format!("OS-assigned port bind failed: {}", e)))?;
289    let port = listener
290        .local_addr()
291        .map_err(|e| Error::ConfigError(format!("Failed to read OS-assigned port: {}", e)))?
292        .port();
293    drop(listener);
294    Ok(port)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn failed_binary_lookup_releases_reservations() {
303        let _guard = crate::port_reservation::test_guard();
304        let directory = tempfile::tempdir().expect("temporary directory");
305        let config = ServerConfig::builder()
306            .binary_path(directory.path().join("missing-mockforge"))
307            .build();
308        let reservation = PortReservation::new().expect("reserve port");
309        let port = reservation.port();
310        assert!(ManagedProcess::spawn_reserved(&config, vec![reservation]).is_err());
311        assert!(!crate::port_reservation::is_claimed(port));
312        assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok());
313    }
314
315    #[test]
316    fn failed_spawn_releases_handed_off_reservations() {
317        let _guard = crate::port_reservation::test_guard();
318        let directory = tempfile::tempdir().expect("temporary directory");
319        // A directory exists but cannot be executed as the mockforge binary.
320        let config = ServerConfig::builder().binary_path(directory.path()).build();
321        let reservation = PortReservation::new().expect("reserve port");
322        let port = reservation.port();
323        assert!(ManagedProcess::spawn_reserved(&config, vec![reservation]).is_err());
324        assert!(!crate::port_reservation::is_claimed(port));
325        assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok());
326    }
327
328    // An owned child fixture for kill/drop ordering. Only the parent tests
329    // invoke this ignored test, with an isolated temporary ready-file path.
330    #[test]
331    #[ignore = "owned subprocess fixture, invoked by lifecycle tests"]
332    fn owned_port_child_fixture() {
333        let port: u16 = std::env::var("MOCKFORGE_LEASE_FIXTURE_PORT")
334            .expect("fixture port")
335            .parse()
336            .expect("numeric port");
337        let _listener = std::net::TcpListener::bind(("0.0.0.0", port)).expect("child binds");
338        std::fs::write(
339            std::env::var_os("MOCKFORGE_LEASE_FIXTURE_READY").expect("ready path"),
340            b"ready",
341        )
342        .expect("write ready file");
343        loop {
344            std::thread::park();
345        }
346    }
347
348    fn owned_port_child() -> (ManagedProcess, u16, tempfile::TempDir) {
349        let directory = tempfile::tempdir().expect("temporary directory");
350        let ready = directory.path().join("ready");
351        let mut reservation = PortReservation::new().expect("reserve port");
352        let port = reservation.port();
353        reservation.handoff();
354        let child = Command::new(std::env::current_exe().expect("current test binary"))
355            .args([
356                "--exact",
357                "process::tests::owned_port_child_fixture",
358                "--ignored",
359            ])
360            .env("MOCKFORGE_LEASE_FIXTURE_PORT", port.to_string())
361            .env("MOCKFORGE_LEASE_FIXTURE_READY", &ready)
362            .spawn()
363            .expect("spawn owned child");
364        let pid = child.id();
365        let process = ManagedProcess {
366            child,
367            pid,
368            http_port: port,
369            port_reservations: vec![reservation],
370        };
371        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
372        while !ready.exists() && std::time::Instant::now() < deadline {
373            std::thread::sleep(std::time::Duration::from_millis(10));
374        }
375        assert!(ready.exists(), "owned child must confirm its listener");
376        assert!(crate::port_reservation::is_claimed(port));
377        assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_err());
378        (process, port, directory)
379    }
380
381    #[test]
382    fn kill_reaps_owned_child_before_releasing_claim() {
383        let _guard = crate::port_reservation::test_guard();
384        let (mut process, port, _directory) = owned_port_child();
385        process.kill().expect("kill and reap child");
386        assert!(process.child.try_wait().expect("child status").is_some());
387        assert!(!crate::port_reservation::is_claimed(port));
388        assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok());
389    }
390
391    #[test]
392    fn drop_releases_claim_only_after_owned_child_cleanup() {
393        let _guard = crate::port_reservation::test_guard();
394        let (process, port, _directory) = owned_port_child();
395        drop(process);
396        assert!(!crate::port_reservation::is_claimed(port));
397        assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok());
398    }
399
400    #[test]
401    fn test_is_port_available() {
402        // Port 0 should always be available (it means "assign any port")
403        assert!(is_port_available(0));
404    }
405
406    #[test]
407    fn test_find_available_port_returns_nonzero() {
408        // `find_available_port` always asks the OS for an ephemeral port.
409        // The `start_port` hint is ignored (see the function's doc comment
410        // for why), so the assigned port is from the OS ephemeral range
411        // rather than the argument's range.
412        let port = find_available_port(30000).expect("Failed to find available port");
413        assert!(port > 0);
414    }
415
416    #[test]
417    fn test_find_available_port_ignores_hint() {
418        // Two calls with the same hint should hand back distinct ports
419        // (ephemeral allocation is effectively never reused back-to-back).
420        let port1 = find_available_port(30000).expect("Failed to find port 1");
421        let port2 = find_available_port(30000).expect("Failed to find port 2");
422        assert_ne!(port1, port2, "ephemeral allocator handed back the same port twice");
423    }
424
425    #[test]
426    fn test_is_port_available_high_port() {
427        // High ports are usually available
428        let available = is_port_available(59999);
429        // This might be true or false depending on system state
430        // Just ensure it doesn't panic
431        let _ = available;
432    }
433
434    #[test]
435    fn test_multiple_port_allocations() {
436        // Get three ports; they should all be nonzero and distinct.
437        let port1 = find_available_port(31000).expect("Failed to find port 1");
438        let port2 = find_available_port(32000).expect("Failed to find port 2");
439        let port3 = find_available_port(33000).expect("Failed to find port 3");
440
441        assert!(port1 > 0);
442        assert!(port2 > 0);
443        assert!(port3 > 0);
444        assert_ne!(port1, port2);
445        assert_ne!(port1, port3);
446        assert_ne!(port2, port3);
447    }
448}