mockforge_test/
process.rs1use 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
10pub struct ManagedProcess {
12 child: Child,
13 http_port: u16,
14 pid: u32,
15 port_reservations: Vec<PortReservation>,
16}
17
18impl ManagedProcess {
19 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 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 if config.enable_admin {
55 cmd.arg("--admin");
56 }
57
58 if config.enable_metrics {
60 cmd.arg("--metrics");
61 }
62
63 if let Some(spec_file) = &config.spec_file {
65 cmd.arg("--spec").arg(spec_file);
66 }
67
68 if let Some(workspace_dir) = &config.workspace_dir {
70 cmd.arg("--workspace-dir").arg(workspace_dir);
71 }
72
73 if let Some(profile) = &config.profile {
75 cmd.arg("--profile").arg(profile);
76 }
77
78 for arg in &config.extra_args {
80 cmd.arg(arg);
81 }
82
83 if let Some(working_dir) = &config.working_dir {
85 cmd.current_dir(working_dir);
86 }
87
88 for (key, value) in &config.env_vars {
90 cmd.env(key, value);
91 }
92
93 cmd.stdout(Stdio::inherit());
95 cmd.stderr(Stdio::inherit());
96
97 debug!("Spawning MockForge process: {:?}", cmd);
98
99 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 pub fn http_port(&self) -> u16 {
121 self.http_port
122 }
123
124 pub fn pid(&self) -> u32 {
126 self.pid
127 }
128
129 pub fn is_running(&mut self) -> bool {
131 matches!(self.child.try_wait(), Ok(None))
132 }
133
134 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 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 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
170fn 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
206fn 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
259pub fn is_port_available(port: u16) -> bool {
261 use std::net::TcpListener;
262 TcpListener::bind(("127.0.0.1", port)).is_ok()
263}
264
265pub 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 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 #[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 assert!(is_port_available(0));
404 }
405
406 #[test]
407 fn test_find_available_port_returns_nonzero() {
408 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 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 let available = is_port_available(59999);
429 let _ = available;
432 }
433
434 #[test]
435 fn test_multiple_port_allocations() {
436 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}