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#[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
43pub struct PicSerialGuard {
48 _private: (),
49}
50
51#[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
58pub 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
126fn 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
181fn process_lock_dir() -> PathBuf {
183 process_lock_dir_from_temp_root(&env::temp_dir())
184}
185
186fn process_lock_dir_from_temp_root(temp_root: &Path) -> PathBuf {
188 temp_root.join(PIC_PROCESS_LOCK_DIR_NAME)
189}
190
191fn 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}