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    if let Ok(pid) = trimmed.parse::<u32>() {
261        return Some(ProcessLockOwner {
262            pid,
263            start_ticks: None,
264        });
265    }
266
267    let mut pid = None;
268    let mut start_ticks = None;
269    for line in trimmed.lines() {
270        if let Some(value) = line.strip_prefix("pid=") {
271            pid = value.trim().parse::<u32>().ok();
272        } else if let Some(value) = line.strip_prefix("start_ticks=") {
273            start_ticks = value.trim().parse::<u64>().ok();
274        }
275    }
276
277    Some(ProcessLockOwner {
278        pid: pid?,
279        start_ticks,
280    })
281}
282
283fn read_process_start_ticks(proc_root: &Path, pid: u32) -> Option<u64> {
284    let stat_path = proc_root.join(pid.to_string()).join("stat");
285    let stat = fs::read_to_string(stat_path).ok()?;
286    let close_paren = stat.rfind(')')?;
287    let rest = stat.get(close_paren + 2..)?;
288    let fields = rest.split_whitespace().collect::<Vec<_>>();
289    fields.get(19)?.parse::<u64>().ok()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::{
295        clear_stale_process_lock, ensure_process_lock_parent, parse_process_lock_owner,
296        process_lock_dir_from_temp_root, process_lock_is_stale_with_proc_root,
297        process_lock_owner_path,
298    };
299    use std::{
300        fs,
301        path::PathBuf,
302        time::{SystemTime, UNIX_EPOCH},
303    };
304
305    fn unique_lock_dir() -> PathBuf {
306        let nanos = SystemTime::now()
307            .duration_since(UNIX_EPOCH)
308            .expect("clock must be after unix epoch")
309            .as_nanos();
310        std::env::temp_dir().join(format!("canic-pocket-ic-test-lock-{nanos}"))
311    }
312
313    #[test]
314    fn stale_process_lock_is_detected_and_removed() {
315        let lock_dir = unique_lock_dir();
316        fs::create_dir(&lock_dir).expect("create lock dir");
317        fs::write(process_lock_owner_path(&lock_dir), "999999").expect("write stale owner");
318
319        assert!(process_lock_is_stale_with_proc_root(
320            &lock_dir,
321            std::path::Path::new("/proc")
322        ));
323        clear_stale_process_lock(&lock_dir).expect("remove stale lock dir");
324        assert!(!lock_dir.exists());
325    }
326
327    #[test]
328    fn owner_parser_accepts_legacy_pid_only_format() {
329        let owner = parse_process_lock_owner("12345\n").expect("parse pid-only owner");
330        assert_eq!(owner.pid, 12345);
331        assert_eq!(owner.start_ticks, None);
332    }
333
334    #[test]
335    fn stale_process_lock_detects_pid_reuse_via_start_ticks() {
336        let root = unique_lock_dir();
337        let lock_dir = root.join("lock");
338        let proc_root = root.join("proc");
339        let proc_pid = proc_root.join("77");
340        fs::create_dir_all(&lock_dir).expect("create lock dir");
341        fs::create_dir_all(&proc_pid).expect("create proc pid dir");
342        fs::write(
343            process_lock_owner_path(&lock_dir),
344            "pid=77\nstart_ticks=41\n",
345        )
346        .expect("write owner");
347        fs::write(
348            proc_pid.join("stat"),
349            "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",
350        )
351        .expect("write proc stat");
352
353        assert!(process_lock_is_stale_with_proc_root(&lock_dir, &proc_root));
354    }
355
356    #[test]
357    fn ensure_process_lock_parent_creates_missing_temp_root_chain() {
358        let root = unique_lock_dir();
359        let temp_root = root.join("repo-local").join("tmp");
360        let lock_dir = process_lock_dir_from_temp_root(&temp_root);
361
362        ensure_process_lock_parent(&lock_dir).expect("create temp-root parent chain");
363
364        assert!(temp_root.exists());
365    }
366}