aster_forge_test/
state.rs1use crate::suite::TestContainerSuite;
8use fs2::FileExt;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs::{File, OpenOptions};
12use std::io::{Read, Write};
13
14#[derive(Debug, Default, Deserialize, Serialize)]
16pub struct SharedContainerState {
17 #[serde(default)]
18 pids: Vec<u32>,
19 #[serde(default)]
20 resources_by_pid: HashMap<u32, Vec<String>>,
21}
22
23impl SharedContainerState {
24 pub fn register_pid(&mut self, pid: u32) {
26 if !self.pids.contains(&pid) {
27 self.pids.push(pid);
28 }
29 self.normalize();
30 }
31
32 pub fn remember_resource(&mut self, pid: u32, resource: &str) {
34 self.register_pid(pid);
35 let resources = self.resources_by_pid.entry(pid).or_default();
36 if !resources.iter().any(|name| name == resource) {
37 resources.push(resource.to_string());
38 }
39 resources.sort_unstable();
40 }
41
42 pub fn forget_resource(&mut self, pid: u32, resource: &str) {
44 let remove_owner = if let Some(resources) = self.resources_by_pid.get_mut(&pid) {
45 resources.retain(|name| name != resource);
46 resources.is_empty()
47 } else {
48 false
49 };
50 if remove_owner {
51 self.resources_by_pid.remove(&pid);
52 }
53 self.normalize();
54 }
55
56 pub fn live_resources(&self) -> Vec<&str> {
58 self.resources_by_pid
59 .values()
60 .flatten()
61 .map(String::as_str)
62 .collect()
63 }
64
65 pub fn prune_stale(&mut self) -> Vec<String> {
67 let stale_pids = self
68 .pids
69 .iter()
70 .copied()
71 .filter(|pid| !process_is_running(*pid))
72 .collect::<Vec<_>>();
73 let orphaned = stale_pids
74 .iter()
75 .flat_map(|pid| self.resources_by_pid.remove(pid).unwrap_or_default())
76 .collect::<Vec<_>>();
77
78 self.pids.retain(|pid| !stale_pids.contains(pid));
79 self.normalize();
80 orphaned
81 }
82
83 fn normalize(&mut self) {
84 self.pids.sort_unstable();
85 self.pids.dedup();
86 self.resources_by_pid
87 .retain(|pid, _| self.pids.binary_search(pid).is_ok());
88 }
89}
90
91pub struct ContainerStateLock {
96 _file: File,
97 state_path: std::path::PathBuf,
98}
99
100impl ContainerStateLock {
101 pub fn acquire(suite: &TestContainerSuite, service: &str) -> Self {
103 let lock_path = suite.lock_path(service);
104 let file = OpenOptions::new()
105 .create(true)
106 .truncate(true)
107 .read(true)
108 .write(true)
109 .open(&lock_path)
110 .unwrap_or_else(|error| {
111 panic!(
112 "failed to open test container lock {}: {error}",
113 lock_path.display()
114 )
115 });
116 file.lock_exclusive().unwrap_or_else(|error| {
117 panic!(
118 "failed to lock test container state {}: {error}",
119 lock_path.display()
120 )
121 });
122 Self {
123 _file: file,
124 state_path: suite.state_path(service),
125 }
126 }
127
128 pub fn load(&self) -> SharedContainerState {
130 if !self.state_path.exists() {
131 return SharedContainerState::default();
132 }
133
134 let mut raw = String::new();
135 File::open(&self.state_path)
136 .and_then(|mut file| file.read_to_string(&mut raw))
137 .unwrap_or_else(|error| {
138 panic!(
139 "failed to read test container state {}: {error}",
140 self.state_path.display()
141 )
142 });
143
144 let mut state = if raw.trim().is_empty() {
145 SharedContainerState::default()
146 } else {
147 serde_json::from_str(&raw).unwrap_or_else(|error| {
148 panic!(
149 "failed to parse test container state {}: {error}",
150 self.state_path.display()
151 )
152 })
153 };
154 state.normalize();
155 state
156 }
157
158 pub fn save(&self, state: &SharedContainerState) {
160 let json = serde_json::to_vec(state)
161 .unwrap_or_else(|error| panic!("failed to serialize test container state: {error}"));
162 let mut file = OpenOptions::new()
163 .create(true)
164 .write(true)
165 .truncate(true)
166 .open(&self.state_path)
167 .unwrap_or_else(|error| {
168 panic!(
169 "failed to open test container state {}: {error}",
170 self.state_path.display()
171 )
172 });
173 file.write_all(&json)
174 .and_then(|()| file.write_all(b"\n"))
175 .and_then(|()| file.flush())
176 .unwrap_or_else(|error| {
177 panic!(
178 "failed to write test container state {}: {error}",
179 self.state_path.display()
180 )
181 });
182 }
183}
184
185pub struct ContainerLease {
190 suite: TestContainerSuite,
191 service: String,
192}
193
194impl ContainerLease {
195 pub fn new(suite: TestContainerSuite, service: impl Into<String>) -> Self {
197 Self {
198 suite,
199 service: service.into(),
200 }
201 }
202}
203
204impl Drop for ContainerLease {
205 fn drop(&mut self) {
206 let lock = ContainerStateLock::acquire(&self.suite, &self.service);
207 let mut state = lock.load();
208 let _ = state.prune_stale();
209 lock.save(&state);
210 }
211}
212
213fn process_is_running(pid: u32) -> bool {
214 if pid == std::process::id() {
215 return true;
216 }
217
218 if pid == 0 || i32::try_from(pid).is_err() {
222 return false;
223 }
224
225 platform_process_is_running(pid)
226}
227
228#[cfg(unix)]
229fn platform_process_is_running(pid: u32) -> bool {
230 std::process::Command::new("/bin/kill")
231 .arg("-0")
232 .arg(pid.to_string())
233 .output()
234 .map(|output| output.status.success())
235 .unwrap_or(false)
236}
237
238#[cfg(not(unix))]
239fn platform_process_is_running(pid: u32) -> bool {
240 let _ = pid;
242 true
243}
244
245#[cfg(test)]
246mod tests {
247 use super::{ContainerLease, ContainerStateLock, SharedContainerState};
248 use crate::suite::TestContainerSuite;
249
250 #[test]
251 fn state_registry_tracks_resources_per_process() {
252 let mut state = SharedContainerState::default();
253 state.remember_resource(42, "db_a");
254 state.remember_resource(42, "db_b");
255 state.remember_resource(42, "db_a");
256 state.remember_resource(7, "db_c");
257
258 let mut live = state.live_resources();
259 live.sort_unstable();
260 assert_eq!(live, vec!["db_a", "db_b", "db_c"]);
261 }
262
263 #[test]
264 fn state_registry_forgets_cleaned_resources() {
265 let mut state = SharedContainerState::default();
266 state.remember_resource(42, "db_a");
267 state.remember_resource(42, "db_b");
268
269 state.forget_resource(42, "db_a");
270 assert_eq!(state.live_resources(), vec!["db_b"]);
271 state.forget_resource(42, "db_b");
272 assert!(state.live_resources().is_empty());
273 }
274
275 #[test]
276 fn prune_removes_dead_processes_and_returns_orphans() {
277 let mut state = SharedContainerState::default();
278 state.remember_resource(std::process::id(), "db_live");
279 state.remember_resource(u32::MAX, "db_dead");
280
281 let orphaned = state.prune_stale();
282
283 assert_eq!(orphaned, vec!["db_dead".to_string()]);
284 assert_eq!(state.live_resources(), vec!["db_live"]);
285 }
286
287 #[test]
288 fn lock_round_trips_state_through_json_file() {
289 let suite = TestContainerSuite::new("forge-state-test");
290 let lock = ContainerStateLock::acquire(&suite, "roundtrip");
291
292 let mut state = lock.load();
295 let _ = state.prune_stale();
296 state.remember_resource(std::process::id(), "db_persisted");
297 lock.save(&state);
298
299 let loaded = lock.load();
300 assert_eq!(loaded.live_resources(), vec!["db_persisted"]);
301 }
302
303 #[test]
304 fn lease_drop_preserves_live_entries() {
305 let suite = TestContainerSuite::new("forge-lease-test");
306 {
307 let lock = ContainerStateLock::acquire(&suite, "leased");
308 let mut state = lock.load();
309 state.remember_resource(std::process::id(), "db_live");
310 lock.save(&state);
311 }
312
313 drop(ContainerLease::new(suite.clone(), "leased"));
314
315 let lock = ContainerStateLock::acquire(&suite, "leased");
316 assert_eq!(lock.load().live_resources(), vec!["db_live"]);
317 }
318}