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 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}