Skip to main content

canic_testkit/pic/
process_lock.rs

1use std::{
2    env, fs, io,
3    path::{Path, PathBuf},
4    process,
5    sync::Mutex,
6    thread,
7    time::{Duration, Instant},
8};
9
10const PIC_PROCESS_LOCK_DIR_NAME: &str = "canic-pocket-ic.lock";
11const PIC_PROCESS_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
12const PIC_PROCESS_LOCK_LOG_AFTER: Duration = Duration::from_secs(1);
13static PIC_PROCESS_LOCK_STATE: Mutex<ProcessLockState> = Mutex::new(ProcessLockState {
14    ref_count: 0,
15    process_lock: None,
16});
17
18struct ProcessLockGuard {
19    path: PathBuf,
20}
21
22struct ProcessLockOwner {
23    pid: u32,
24    start_ticks: Option<u64>,
25}
26
27struct ProcessLockState {
28    ref_count: usize,
29    process_lock: Option<ProcessLockGuard>,
30}
31
32///
33/// PicSerialGuardError
34///
35
36#[derive(Debug)]
37pub enum PicSerialGuardError {
38    LockParentUnavailable { path: PathBuf, source: io::Error },
39    LockUnavailable { path: PathBuf, source: io::Error },
40    LockOwnerRecordFailed { path: PathBuf, source: io::Error },
41}
42
43///
44/// PicSerialGuard
45///
46
47pub struct PicSerialGuard {
48    _private: (),
49}
50
51/// Acquire the shared PocketIC serialization guard for the current process.
52#[must_use]
53pub fn acquire_pic_serial_guard() -> PicSerialGuard {
54    try_acquire_pic_serial_guard()
55        .unwrap_or_else(|err| panic!("failed to acquire PocketIC serial guard: {err}"))
56}
57
58/// Acquire the shared PocketIC serialization guard for the current process.
59pub fn try_acquire_pic_serial_guard() -> Result<PicSerialGuard, PicSerialGuardError> {
60    let mut state = PIC_PROCESS_LOCK_STATE
61        .lock()
62        .unwrap_or_else(std::sync::PoisonError::into_inner);
63
64    if state.ref_count == 0 {
65        state.process_lock = Some(acquire_process_lock()?);
66    }
67    state.ref_count += 1;
68
69    Ok(PicSerialGuard { _private: () })
70}
71
72impl std::fmt::Display for PicSerialGuardError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Self::LockParentUnavailable { path, source } => write!(
76                f,
77                "failed to create PocketIC lock parent at {}: {source}",
78                path.display()
79            ),
80            Self::LockUnavailable { path, source } => write!(
81                f,
82                "failed to create PocketIC process lock dir at {}: {source}",
83                path.display()
84            ),
85            Self::LockOwnerRecordFailed { path, source } => write!(
86                f,
87                "failed to record PocketIC process lock owner at {}: {source}",
88                path.display()
89            ),
90        }
91    }
92}
93
94impl std::error::Error for PicSerialGuardError {
95    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96        match self {
97            Self::LockParentUnavailable { source, .. }
98            | Self::LockUnavailable { source, .. }
99            | Self::LockOwnerRecordFailed { source, .. } => Some(source),
100        }
101    }
102}
103
104impl Drop for ProcessLockGuard {
105    fn drop(&mut self) {
106        let _ = fs::remove_dir_all(&self.path);
107    }
108}
109
110impl Drop for PicSerialGuard {
111    fn drop(&mut self) {
112        let mut state = PIC_PROCESS_LOCK_STATE
113            .lock()
114            .unwrap_or_else(std::sync::PoisonError::into_inner);
115
116        state.ref_count = state
117            .ref_count
118            .checked_sub(1)
119            .expect("PocketIC serial guard refcount underflow");
120        if state.ref_count == 0 {
121            state.process_lock.take();
122        }
123    }
124}
125
126// Acquire the shared filesystem lock that serializes PocketIC usage per host.
127fn acquire_process_lock() -> Result<ProcessLockGuard, PicSerialGuardError> {
128    let lock_dir = process_lock_dir();
129    ensure_process_lock_parent(&lock_dir)?;
130    let started_waiting = Instant::now();
131    let mut logged_wait = false;
132
133    loop {
134        match fs::create_dir(&lock_dir) {
135            Ok(()) => {
136                if let Err(source) = fs::write(
137                    process_lock_owner_path(&lock_dir),
138                    render_process_lock_owner(),
139                ) {
140                    let _ = fs::remove_dir(&lock_dir);
141                    return Err(PicSerialGuardError::LockOwnerRecordFailed {
142                        path: lock_dir,
143                        source,
144                    });
145                }
146
147                if logged_wait {
148                    eprintln!(
149                        "[canic_testkit::pic] acquired cross-process PocketIC lock at {}",
150                        lock_dir.display()
151                    );
152                }
153
154                return Ok(ProcessLockGuard { path: lock_dir });
155            }
156            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
157                if process_lock_is_stale(&lock_dir) && clear_stale_process_lock(&lock_dir).is_ok() {
158                    continue;
159                }
160
161                if !logged_wait && started_waiting.elapsed() >= PIC_PROCESS_LOCK_LOG_AFTER {
162                    eprintln!(
163                        "[canic_testkit::pic] waiting for cross-process PocketIC lock at {}",
164                        lock_dir.display()
165                    );
166                    logged_wait = true;
167                }
168
169                thread::sleep(PIC_PROCESS_LOCK_RETRY_DELAY);
170            }
171            Err(source) => {
172                return Err(PicSerialGuardError::LockUnavailable {
173                    path: lock_dir,
174                    source,
175                });
176            }
177        }
178    }
179}
180
181// Resolve the cross-process PocketIC lock path from the active temp root.
182fn process_lock_dir() -> PathBuf {
183    process_lock_dir_from_temp_root(&env::temp_dir())
184}
185
186// Resolve the cross-process PocketIC lock path for one explicit temp root.
187fn process_lock_dir_from_temp_root(temp_root: &Path) -> PathBuf {
188    temp_root.join(PIC_PROCESS_LOCK_DIR_NAME)
189}
190
191// Create the temp-root parent chain before trying to create the lock directory itself.
192fn ensure_process_lock_parent(lock_dir: &Path) -> Result<(), PicSerialGuardError> {
193    let parent = lock_dir.parent().unwrap_or_else(|| Path::new("."));
194    fs::create_dir_all(parent).map_err(|source| PicSerialGuardError::LockParentUnavailable {
195        path: parent.to_path_buf(),
196        source,
197    })
198}
199
200fn process_lock_owner_path(lock_dir: &Path) -> PathBuf {
201    lock_dir.join("owner")
202}
203
204fn clear_stale_process_lock(lock_dir: &Path) -> io::Result<()> {
205    match fs::remove_dir_all(lock_dir) {
206        Ok(()) => Ok(()),
207        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
208        Err(err) => Err(err),
209    }
210}
211
212fn process_lock_is_stale(lock_dir: &Path) -> bool {
213    process_lock_is_stale_with_proc_root(lock_dir, Path::new("/proc"))
214}
215
216fn process_lock_is_stale_with_proc_root(lock_dir: &Path, proc_root: &Path) -> bool {
217    let Some(owner) = read_process_lock_owner(&process_lock_owner_path(lock_dir)) else {
218        return true;
219    };
220
221    let proc_dir = proc_root.join(owner.pid.to_string());
222    if !proc_dir.exists() {
223        return true;
224    }
225
226    match owner.start_ticks {
227        Some(expected_ticks) => {
228            read_process_start_ticks(proc_root, owner.pid) != Some(expected_ticks)
229        }
230        None => false,
231    }
232}
233
234fn render_process_lock_owner() -> String {
235    let owner = current_process_lock_owner();
236    match owner.start_ticks {
237        Some(start_ticks) => format!("pid={}\nstart_ticks={start_ticks}\n", owner.pid),
238        None => format!("pid={}\n", owner.pid),
239    }
240}
241
242fn current_process_lock_owner() -> ProcessLockOwner {
243    ProcessLockOwner {
244        pid: process::id(),
245        start_ticks: read_process_start_ticks(Path::new("/proc"), process::id()),
246    }
247}
248
249fn read_process_lock_owner(path: &Path) -> Option<ProcessLockOwner> {
250    let text = fs::read_to_string(path).ok()?;
251    parse_process_lock_owner(&text)
252}
253
254fn parse_process_lock_owner(text: &str) -> Option<ProcessLockOwner> {
255    let trimmed = text.trim();
256    if trimmed.is_empty() {
257        return None;
258    }
259
260    let mut pid = None;
261    let mut start_ticks = None;
262    for line in trimmed.lines() {
263        if let Some(value) = line.strip_prefix("pid=") {
264            pid = value.trim().parse::<u32>().ok();
265        } else if let Some(value) = line.strip_prefix("start_ticks=") {
266            start_ticks = value.trim().parse::<u64>().ok();
267        }
268    }
269
270    Some(ProcessLockOwner {
271        pid: pid?,
272        start_ticks,
273    })
274}
275
276fn read_process_start_ticks(proc_root: &Path, pid: u32) -> Option<u64> {
277    let stat_path = proc_root.join(pid.to_string()).join("stat");
278    let stat = fs::read_to_string(stat_path).ok()?;
279    let close_paren = stat.rfind(')')?;
280    let rest = stat.get(close_paren + 2..)?;
281    let fields = rest.split_whitespace().collect::<Vec<_>>();
282    fields.get(19)?.parse::<u64>().ok()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::{
288        clear_stale_process_lock, ensure_process_lock_parent, parse_process_lock_owner,
289        process_lock_dir_from_temp_root, process_lock_is_stale_with_proc_root,
290        process_lock_owner_path,
291    };
292    use std::{
293        fs,
294        path::PathBuf,
295        time::{SystemTime, UNIX_EPOCH},
296    };
297
298    fn unique_lock_dir() -> PathBuf {
299        let nanos = SystemTime::now()
300            .duration_since(UNIX_EPOCH)
301            .expect("clock must be after unix epoch")
302            .as_nanos();
303        std::env::temp_dir().join(format!("canic-pocket-ic-test-lock-{nanos}"))
304    }
305
306    #[test]
307    fn stale_process_lock_is_detected_and_removed() {
308        let lock_dir = unique_lock_dir();
309        fs::create_dir(&lock_dir).expect("create lock dir");
310        fs::write(process_lock_owner_path(&lock_dir), "999999").expect("write stale owner");
311
312        assert!(process_lock_is_stale_with_proc_root(
313            &lock_dir,
314            std::path::Path::new("/proc")
315        ));
316        clear_stale_process_lock(&lock_dir).expect("remove stale lock dir");
317        assert!(!lock_dir.exists());
318    }
319
320    #[test]
321    fn owner_parser_rejects_pid_only_format() {
322        assert!(parse_process_lock_owner("12345\n").is_none());
323    }
324
325    #[test]
326    fn stale_process_lock_detects_pid_reuse_via_start_ticks() {
327        let root = unique_lock_dir();
328        let lock_dir = root.join("lock");
329        let proc_root = root.join("proc");
330        let proc_pid = proc_root.join("77");
331        fs::create_dir_all(&lock_dir).expect("create lock dir");
332        fs::create_dir_all(&proc_pid).expect("create proc pid dir");
333        fs::write(
334            process_lock_owner_path(&lock_dir),
335            "pid=77\nstart_ticks=41\n",
336        )
337        .expect("write owner");
338        fs::write(
339            proc_pid.join("stat"),
340            "77 (cargo) S 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 99 0 0\n",
341        )
342        .expect("write proc stat");
343
344        assert!(process_lock_is_stale_with_proc_root(&lock_dir, &proc_root));
345    }
346
347    #[test]
348    fn ensure_process_lock_parent_creates_missing_temp_root_chain() {
349        let root = unique_lock_dir();
350        let temp_root = root.join("repo-local").join("tmp");
351        let lock_dir = process_lock_dir_from_temp_root(&temp_root);
352
353        ensure_process_lock_parent(&lock_dir).expect("create temp-root parent chain");
354
355        assert!(temp_root.exists());
356    }
357}