aster_forge_test/
process.rs1use std::fs::File;
4use std::io::Read;
5use std::net::TcpListener;
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Stdio};
8use std::time::{Duration, Instant};
9
10use crate::temp::TestTempDir;
11
12pub fn available_loopback_port() -> u16 {
14 TcpListener::bind(("127.0.0.1", 0))
15 .expect("failed to reserve local test port")
16 .local_addr()
17 .expect("failed to resolve local test port")
18 .port()
19}
20
21pub struct TestProcess {
23 name: String,
24 child: Option<Child>,
25 runtime_dir: TestTempDir,
26 stdout_log: PathBuf,
27 stderr_log: PathBuf,
28}
29
30impl TestProcess {
31 pub fn spawn(name: &str, command: &mut Command) -> Self {
36 assert_valid_process_name(name);
37 let runtime_dir = TestTempDir::new(&format!("process-{name}"));
38 let stdout_log = runtime_dir.join("stdout.log");
39 let stderr_log = runtime_dir.join("stderr.log");
40 let stdout = File::create(&stdout_log)
41 .unwrap_or_else(|error| panic!("failed to create {}: {error}", stdout_log.display()));
42 let stderr = File::create(&stderr_log)
43 .unwrap_or_else(|error| panic!("failed to create {}: {error}", stderr_log.display()));
44
45 let child = command
46 .current_dir(runtime_dir.path())
47 .stdin(Stdio::null())
48 .stdout(Stdio::from(stdout))
49 .stderr(Stdio::from(stderr))
50 .spawn()
51 .unwrap_or_else(|error| panic!("failed to spawn test process {name}: {error}"));
52
53 Self {
54 name: name.to_string(),
55 child: Some(child),
56 runtime_dir,
57 stdout_log,
58 stderr_log,
59 }
60 }
61
62 pub fn name(&self) -> &str {
64 &self.name
65 }
66
67 pub fn runtime_dir(&self) -> &Path {
69 self.runtime_dir.path()
70 }
71
72 pub fn terminate(&mut self) {
74 let Some(mut child) = self.child.take() else {
75 return;
76 };
77 let _ = child.kill();
78 let _ = child.wait();
79 }
80
81 #[cfg(unix)]
83 pub fn terminate_gracefully(&mut self, timeout: Duration) -> bool {
84 let Some(child) = self.child.as_mut() else {
85 return true;
86 };
87 let status = Command::new("/bin/kill")
88 .args(["-TERM", &child.id().to_string()])
89 .status()
90 .unwrap_or_else(|error| panic!("failed to send SIGTERM to {}: {error}", self.name));
91 assert!(
92 status.success(),
93 "failed to send SIGTERM to {}: {status}",
94 self.name
95 );
96
97 let deadline = Instant::now() + timeout;
98 loop {
99 match child.try_wait() {
100 Ok(Some(_)) => {
101 self.child.take();
102 return true;
103 }
104 Ok(None) if Instant::now() < deadline => {
105 std::thread::sleep(Duration::from_millis(25));
106 }
107 Ok(None) => return false,
108 Err(error) => panic!("failed to wait for {} after SIGTERM: {error}", self.name),
109 }
110 }
111 }
112
113 pub fn assert_running(&mut self) {
115 let Some(child) = self.child.as_mut() else {
116 panic!("test process {} has already stopped", self.name);
117 };
118 if let Some(status) = child
119 .try_wait()
120 .unwrap_or_else(|error| panic!("failed to query process {}: {error}", self.name))
121 {
122 panic!(
123 "test process {} exited with {status}\n{}",
124 self.name,
125 self.diagnostics()
126 );
127 }
128 }
129
130 pub fn diagnostics(&self) -> String {
132 format!(
133 "--- {} stdout ---\n{}\n--- {} stderr ---\n{}",
134 self.name,
135 read_log_tail(&self.stdout_log),
136 self.name,
137 read_log_tail(&self.stderr_log)
138 )
139 }
140}
141
142impl Drop for TestProcess {
143 fn drop(&mut self) {
144 self.terminate();
145 }
146}
147
148fn assert_valid_process_name(name: &str) {
149 assert!(
150 !name.is_empty()
151 && name
152 .bytes()
153 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'),
154 "test process name must be non-empty ascii alphanumeric or '-': {name:?}"
155 );
156}
157
158fn read_log_tail(path: &Path) -> String {
159 let mut file = match File::open(path) {
160 Ok(file) => file,
161 Err(error) => return format!("<failed to open log: {error}>"),
162 };
163 let mut bytes = Vec::new();
164 if let Err(error) = file.read_to_end(&mut bytes) {
165 return format!("<failed to read log: {error}>");
166 }
167 let start = bytes.len().saturating_sub(16 * 1024);
168 String::from_utf8_lossy(&bytes[start..]).into_owned()
169}
170
171#[cfg(test)]
172mod tests {
173 use super::{TestProcess, available_loopback_port};
174 use std::process::Command;
175 use std::time::Duration;
176
177 #[test]
178 fn available_port_is_nonzero() {
179 assert_ne!(available_loopback_port(), 0);
180 }
181
182 #[cfg(unix)]
183 #[test]
184 fn process_captures_logs_and_terminates_on_request() {
185 let mut command = Command::new("/bin/sh");
186 command.args(["-c", "echo ready; echo warning >&2; sleep 30"]);
187 let mut process = TestProcess::spawn("capture", &mut command);
188
189 process.assert_running();
190 std::thread::sleep(std::time::Duration::from_millis(50));
191 let diagnostics = process.diagnostics();
192 assert!(diagnostics.contains("ready"));
193 assert!(diagnostics.contains("warning"));
194
195 process.terminate();
196 }
197
198 #[test]
199 fn process_rejects_unsafe_fixture_names() {
200 let result = std::panic::catch_unwind(|| {
201 let mut command = Command::new("unused");
202 TestProcess::spawn("../escape", &mut command)
203 });
204 assert!(result.is_err());
205 }
206
207 #[cfg(unix)]
208 #[test]
209 fn process_supports_graceful_termination() {
210 let mut command = Command::new("/bin/sh");
211 command.args(["-c", "trap 'exit 0' TERM; while true; do sleep 1; done"]);
212 let mut process = TestProcess::spawn("graceful", &mut command);
213
214 assert!(process.terminate_gracefully(Duration::from_secs(2)));
215 }
216}