use std::collections::VecDeque; use std::error::Error; use std::ffi::OsStr; use std::fmt; use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(25); const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024; /// Build and start a child process with an explicit readiness contract. pub struct TestProcessBuilder { command: Command, label: String, readiness: Option, timeout: Duration, poll_interval: Duration, output_limit: usize, } impl fmt::Debug for TestProcessBuilder { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("TestProcessBuilder") .field("command", &self.command) .field("label", &self.label) .field("readiness", &self.readiness) .field("timeout", &self.timeout) .field("poll_interval", &self.poll_interval) .field("output_limit", &self.output_limit) .finish() } } impl TestProcessBuilder { /// Create a builder around a real process command. pub fn new(command: Command) -> Self { let label = command.get_program().to_string_lossy().into_owned(); Self { command, label, readiness: None, timeout: DEFAULT_TIMEOUT, poll_interval: DEFAULT_POLL_INTERVAL, output_limit: DEFAULT_OUTPUT_LIMIT, } } pub fn label(mut self, label: impl Into) -> Self { self.label = label.into(); self } pub fn arg(mut self, argument: impl AsRef) -> Self { self.command.arg(argument); self } pub fn args(mut self, arguments: I) -> Self where I: IntoIterator, S: AsRef, { self.command.args(arguments); self } pub fn env(mut self, key: impl AsRef, value: impl AsRef) -> Self { self.command.env(key, value); self } /// Wait until a TCP connection can be established. /// /// A listener already occupying the address also satisfies this probe. Use /// [`Self::http`] when readiness must identify an application endpoint. pub fn tcp(mut self, address: impl Into) -> Self { self.readiness = Some(Readiness::Tcp { address: address.into(), }); self } /// Wait until a plain HTTP endpoint returns a status from 200 through 399. pub fn http(mut self, address: impl Into, path: impl Into) -> Self { self.readiness = Some(Readiness::Http { address: address.into(), path: path.into(), }); self } pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } pub fn poll_interval(mut self, interval: Duration) -> Self { self.poll_interval = interval; self } /// Bound retained output per stream. Readers continue draining after the /// limit is reached so a noisy child cannot deadlock on a full pipe. pub fn output_limit(mut self, bytes: usize) -> Self { self.output_limit = bytes; self } /// Spawn the process and wait for its readiness contract. pub fn start(mut self) -> Result { if self.poll_interval.is_zero() { return Err(ProcessError::configuration( &self.label, "poll interval must be greater than zero", )); } let readiness = self.readiness.ok_or_else(|| { ProcessError::configuration(&self.label, "choose TCP or HTTP readiness before start") })?; let readiness = readiness.resolve(&self.label)?; let readiness_description = readiness.description(); self.command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = self.command.spawn().map_err(|source| ProcessError::Spawn { label: self.label.clone(), source, })?; let stdout = child .stdout .take() .map(|stream| CapturedOutput::spawn(stream, self.output_limit)); let stderr = child .stderr .take() .map(|stream| CapturedOutput::spawn(stream, self.output_limit)); let mut process = TestProcess { child: Some(child), label: self.label.clone(), stdout, stderr, exit_status: None, }; let started = Instant::now(); let deadline = started + self.timeout; let mut attempts = 0usize; loop { attempts += 1; let remaining = deadline.saturating_duration_since(Instant::now()); let probe_timeout = remaining.min(self.poll_interval); if readiness.probe(probe_timeout) { return Ok(process); } if let Some(status) = process.try_wait().map_err(|source| ProcessError::Monitor { label: self.label.clone(), source, })? { process.finish_capture(); return Err(ProcessError::EarlyExit { status, failure: Box::new(ProcessFailure { label: self.label, readiness: readiness_description, attempts, stdout: process.stdout(), stderr: process.stderr(), }), }); } if Instant::now() >= deadline { let cleanup_error = process.shutdown().err().map(|error| error.to_string()); return Err(ProcessError::TimedOut { timeout: self.timeout, failure: Box::new(ProcessFailure { label: self.label, readiness: readiness_description, attempts, stdout: process.stdout(), stderr: process.stderr(), }), cleanup_error, }); } thread::sleep(self.poll_interval.min(remaining)); } } } /// A real child process owned by an integration test. /// /// Explicit shutdown and `Drop` are idempotent. Both kill a running child, wait /// for it, drain captured output, and release the process handle. pub struct TestProcess { child: Option, label: String, stdout: Option, stderr: Option, exit_status: Option, } impl fmt::Debug for TestProcess { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("TestProcess") .field("label", &self.label) .field("id", &self.id()) .field("exit_status", &self.exit_status) .finish_non_exhaustive() } } impl TestProcess { pub fn builder(command: Command) -> TestProcessBuilder { TestProcessBuilder::new(command) } /// Compatibility entry point for TCP-ready processes. pub fn start( command: Command, label: impl Into, address: &str, timeout: Duration, ) -> io::Result { Self::builder(command) .label(label) .tcp(address) .timeout(timeout) .start() .map_err(ProcessError::into_io) } pub fn id(&self) -> Option { self.child.as_ref().map(Child::id) } pub fn exit_status(&self) -> Option { self.exit_status } /// Return the bounded, currently captured standard output. pub fn stdout(&self) -> String { self.stdout .as_ref() .map(CapturedOutput::snapshot) .unwrap_or_default() } /// Return the bounded, currently captured standard error. pub fn stderr(&self) -> String { self.stderr .as_ref() .map(CapturedOutput::snapshot) .unwrap_or_default() } pub fn try_wait(&mut self) -> io::Result> { if let Some(status) = self.exit_status { return Ok(Some(status)); } let Some(child) = self.child.as_mut() else { return Ok(self.exit_status); }; let status = child.try_wait()?; if let Some(status) = status { self.exit_status = Some(status); } Ok(status) } /// Kill a running child, wait for it, and drain output. Calling this more /// than once is harmless. pub fn shutdown(&mut self) -> io::Result<()> { if let Some(mut child) = self.child.take() { let status = match child.try_wait()? { Some(status) => status, None => { if let Err(error) = child.kill() { if error.kind() != io::ErrorKind::InvalidInput { return Err(error); } } child.wait()? } }; self.exit_status = Some(status); } self.finish_capture(); Ok(()) } fn finish_capture(&mut self) { if let Some(capture) = self.stdout.as_mut() { capture.finish(); } if let Some(capture) = self.stderr.as_mut() { capture.finish(); } } } impl Drop for TestProcess { fn drop(&mut self) { let _ = self.shutdown(); } } /// Captured context for an early exit or readiness timeout. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProcessFailure { pub label: String, pub readiness: String, pub attempts: usize, pub stdout: String, pub stderr: String, } /// Failure to configure, start, observe, or clean up a test process. #[derive(Debug)] pub enum ProcessError { Configuration { label: String, message: String, }, Spawn { label: String, source: io::Error, }, Monitor { label: String, source: io::Error, }, EarlyExit { status: ExitStatus, failure: Box, }, TimedOut { timeout: Duration, failure: Box, cleanup_error: Option, }, } impl ProcessError { fn configuration(label: &str, message: &str) -> Self { Self::Configuration { label: label.to_owned(), message: message.to_owned(), } } fn into_io(self) -> io::Error { let kind = match &self { Self::Spawn { source, .. } | Self::Monitor { source, .. } => source.kind(), Self::TimedOut { .. } => io::ErrorKind::TimedOut, Self::Configuration { .. } => io::ErrorKind::InvalidInput, Self::EarlyExit { .. } => io::ErrorKind::Other, }; io::Error::new(kind, self) } } impl fmt::Display for ProcessError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Configuration { label, message } => { write!(formatter, "invalid readiness for {label}: {message}") } Self::Spawn { label, source } => write!(formatter, "failed to spawn {label}: {source}"), Self::Monitor { label, source } => { write!(formatter, "failed to observe {label}: {source}") } Self::EarlyExit { status, failure } => write!( formatter, "{} exited with {status} before {} after {} readiness attempts{}{}", failure.label, failure.readiness, failure.attempts, output_section("stdout", &failure.stdout), output_section("stderr", &failure.stderr) ), Self::TimedOut { timeout, failure, cleanup_error, } => write!( formatter, "timed out after {timeout:?} waiting for {} to satisfy {} after {} readiness attempts{}{}{}", failure.label, failure.readiness, failure.attempts, output_section("stdout", &failure.stdout), output_section("stderr", &failure.stderr), cleanup_error .as_deref() .map(|error| format!("\ncleanup error:\n{error}")) .unwrap_or_default() ), } } } impl Error for ProcessError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Spawn { source, .. } | Self::Monitor { source, .. } => Some(source), _ => None, } } } #[derive(Clone, Debug)] enum Readiness { Tcp { address: String }, Http { address: String, path: String }, } impl Readiness { fn resolve(self, label: &str) -> Result { match self { Self::Tcp { address } => Ok(ResolvedReadiness::Tcp { socket: resolve_address(label, &address)?, address, }), Self::Http { address, path } => { if !path.starts_with('/') { return Err(ProcessError::configuration( label, "HTTP readiness path must start with '/'", )); } Ok(ResolvedReadiness::Http { socket: resolve_address(label, &address)?, address, path, }) } } } } #[derive(Clone, Debug)] enum ResolvedReadiness { Tcp { address: String, socket: SocketAddr, }, Http { address: String, socket: SocketAddr, path: String, }, } impl ResolvedReadiness { fn description(&self) -> String { match self { Self::Tcp { address, .. } => format!("TCP readiness on {address}"), Self::Http { address, path, .. } => { format!("HTTP readiness at http://{address}{path}") } } } fn probe(&self, timeout: Duration) -> bool { if timeout.is_zero() { return false; } match self { Self::Tcp { socket, .. } => TcpStream::connect_timeout(socket, timeout).is_ok(), Self::Http { address, socket, path, } => probe_http(*socket, address, path, timeout), } } } fn resolve_address(label: &str, address: &str) -> Result { address .to_socket_addrs() .map_err(|error| { ProcessError::configuration(label, &format!("could not resolve {address:?}: {error}")) })? .next() .ok_or_else(|| ProcessError::configuration(label, &format!("{address:?} resolved empty"))) } fn probe_http(socket: SocketAddr, host: &str, path: &str, timeout: Duration) -> bool { let Ok(mut stream) = TcpStream::connect_timeout(&socket, timeout) else { return false; }; let _ = stream.set_read_timeout(Some(timeout)); let _ = stream.set_write_timeout(Some(timeout)); let request = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"); if stream.write_all(request.as_bytes()).is_err() { return false; } let mut response = Vec::with_capacity(128); while response.len() < 128 && !response.contains(&b'\n') { let mut chunk = [0_u8; 32]; let Ok(read) = stream.read(&mut chunk) else { return false; }; if read == 0 { break; } response.extend_from_slice(&chunk[..read]); } let Ok(status_line) = std::str::from_utf8(&response) else { return false; }; status_line .split_whitespace() .nth(1) .and_then(|status| status.parse::().ok()) .is_some_and(|status| (200..400).contains(&status)) } struct CapturedOutput { buffer: Arc>, reader: Option>, } impl CapturedOutput { fn spawn(mut stream: impl Read + Send + 'static, limit: usize) -> Self { let buffer = Arc::new(Mutex::new(CapturedBuffer::new(limit))); let reader_buffer = Arc::clone(&buffer); let reader = thread::spawn(move || { let mut chunk = [0_u8; 4096]; loop { match stream.read(&mut chunk) { Ok(0) | Err(_) => break, Ok(read) => reader_buffer .lock() .unwrap_or_else(|error| error.into_inner()) .push(&chunk[..read]), } } }); Self { buffer, reader: Some(reader), } } fn snapshot(&self) -> String { self.buffer .lock() .unwrap_or_else(|error| error.into_inner()) .render() } fn finish(&mut self) { if let Some(reader) = self.reader.take() { let _ = reader.join(); } } } struct CapturedBuffer { bytes: VecDeque, limit: usize, omitted: usize, } impl CapturedBuffer { fn new(limit: usize) -> Self { Self { bytes: VecDeque::with_capacity(limit.min(4096)), limit, omitted: 0, } } fn push(&mut self, bytes: &[u8]) { self.bytes.extend(bytes); while self.bytes.len() > self.limit { self.bytes.pop_front(); self.omitted += 1; } } fn render(&self) -> String { let bytes = self.bytes.iter().copied().collect::>(); let output = String::from_utf8_lossy(&bytes); if self.omitted == 0 { output.into_owned() } else { format!("<{} earlier bytes omitted>\n{output}", self.omitted) } } } fn output_section(name: &str, output: &str) -> String { if output.is_empty() { String::new() } else { format!("\n{name}:\n{output}") } }