Skip to main content

aster_forge_test/
state.rs

1//! Shared container state: lock files, per-process resource registry, and stale-process pruning.
2//!
3//! Test binaries from several processes may share one reusable container. The state file records
4//! which process created which resources (for example per-test databases), so a later run can
5//! clean up resources whose owner process already exited.
6
7use 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/// Serializable registry of live test processes and the resources they created.
15#[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    /// Registers a live process id.
25    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    /// Records a resource (for example a per-test database name) owned by `pid`.
33    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    /// Removes one resource from `pid` after the owning test cleaned it up successfully.
43    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    /// Returns the resources currently attributed to live processes.
57    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    /// Removes entries whose process no longer exists and returns the orphaned resources.
66    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
91/// Exclusive filesystem lock guarding one service's state file.
92///
93/// Hold the lock for the whole read-modify-write cycle. The lock is released when the guard
94/// drops.
95pub struct ContainerStateLock {
96    _file: File,
97    state_path: std::path::PathBuf,
98}
99
100impl ContainerStateLock {
101    /// Acquires the exclusive lock for `service` within `suite`, blocking until available.
102    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    /// Loads the state file, tolerating a missing or empty file.
129    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    /// Persists the state file atomically enough for test purposes (write + flush).
159    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
185/// Lease that prunes dead-process entries from a service's state file on drop.
186///
187/// Test containers hold the lease so abnormal test binary exits still let the next run reclaim
188/// orphaned resources.
189pub struct ContainerLease {
190    suite: TestContainerSuite,
191    service: String,
192}
193
194impl ContainerLease {
195    /// Creates a lease for `service` within `suite`.
196    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    // `kill` reserves zero and negative values for process-group or broadcast semantics. Some
219    // implementations parse values above `i32::MAX` into a signed `pid_t` (for example,
220    // `u32::MAX` becomes `-1`), which can make an invalid state-file entry look alive.
221    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    // Without a portable liveness probe, assume processes are alive so entries are kept.
241    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        // The state file survives across test runs, so drop entries left by previous
293        // (already exited) processes before asserting on what this run sees.
294        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}