1use candid::{CandidType, Principal, decode_one, encode_args, encode_one, utils::ArgumentEncoder};
2use canic::{
3 Error,
4 cdk::types::TC,
5 dto::{
6 abi::v1::CanisterInitPayload,
7 env::EnvBootstrapArgs,
8 subnet::SubnetIdentity,
9 topology::{AppDirectoryArgs, SubnetDirectoryArgs, SubnetRegistryResponse},
10 },
11 ids::CanisterRole,
12 protocol,
13};
14use pocket_ic::{PocketIc, PocketIcBuilder};
15use serde::de::DeserializeOwned;
16use std::{
17 collections::HashMap,
18 env, fs, io,
19 ops::{Deref, DerefMut},
20 panic::{AssertUnwindSafe, catch_unwind},
21 path::{Path, PathBuf},
22 process,
23 sync::{Mutex, MutexGuard},
24 thread,
25 time::{Duration, Instant},
26};
27
28mod standalone;
29
30const INSTALL_CYCLES: u128 = 500 * TC;
31const PIC_PROCESS_LOCK_DIR_NAME: &str = "canic-pocket-ic.lock";
32const PIC_PROCESS_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
33const PIC_PROCESS_LOCK_LOG_AFTER: Duration = Duration::from_secs(1);
34static PIC_PROCESS_LOCK_STATE: Mutex<ProcessLockState> = Mutex::new(ProcessLockState {
35 ref_count: 0,
36 process_lock: None,
37});
38
39struct ControllerSnapshot {
40 snapshot_id: Vec<u8>,
41 sender: Option<Principal>,
42}
43
44struct ProcessLockGuard {
45 path: PathBuf,
46}
47
48struct ProcessLockOwner {
49 pid: u32,
50 start_ticks: Option<u64>,
51}
52
53struct ProcessLockState {
54 ref_count: usize,
55 process_lock: Option<ProcessLockGuard>,
56}
57
58pub struct ControllerSnapshots(HashMap<Principal, ControllerSnapshot>);
63
64pub struct CachedPicBaseline<T> {
69 pub pic: Pic,
70 pub snapshots: ControllerSnapshots,
71 pub metadata: T,
72 _serial_guard: PicSerialGuard,
73}
74
75pub struct CachedPicBaselineGuard<'a, T> {
80 guard: MutexGuard<'a, Option<CachedPicBaseline<T>>>,
81}
82
83pub struct PicSerialGuard {
88 _private: (),
89}
90
91pub use standalone::{StandaloneCanisterFixture, install_standalone_canister};
92
93#[must_use]
102pub fn pic() -> Pic {
103 PicBuilder::new().with_application_subnet().build()
104}
105
106#[must_use]
108pub fn acquire_pic_serial_guard() -> PicSerialGuard {
109 let mut state = PIC_PROCESS_LOCK_STATE
110 .lock()
111 .unwrap_or_else(std::sync::PoisonError::into_inner);
112
113 if state.ref_count == 0 {
114 state.process_lock = Some(acquire_process_lock());
115 }
116 state.ref_count += 1;
117
118 PicSerialGuard { _private: () }
119}
120
121pub fn acquire_cached_pic_baseline<T, F>(
123 slot: &'static Mutex<Option<CachedPicBaseline<T>>>,
124 build: F,
125) -> (CachedPicBaselineGuard<'static, T>, bool)
126where
127 F: FnOnce() -> CachedPicBaseline<T>,
128{
129 let mut guard = slot
130 .lock()
131 .unwrap_or_else(std::sync::PoisonError::into_inner);
132 let cache_hit = guard.is_some();
133
134 if !cache_hit {
135 *guard = Some(build());
136 }
137
138 (CachedPicBaselineGuard { guard }, cache_hit)
139}
140
141pub fn wait_until_ready(pic: &PocketIc, canister_id: Principal, tick_limit: usize) {
143 let payload = encode_args(()).expect("encode empty args");
144
145 for _ in 0..tick_limit {
146 if let Ok(bytes) = pic.query_call(
147 canister_id,
148 Principal::anonymous(),
149 protocol::CANIC_READY,
150 payload.clone(),
151 ) && let Ok(ready) = decode_one::<bool>(&bytes)
152 && ready
153 {
154 return;
155 }
156 pic.tick();
157 }
158
159 panic!("canister did not report ready in time: {canister_id}");
160}
161
162#[must_use]
164pub fn role_pid(
165 pic: &PocketIc,
166 root_id: Principal,
167 role: &'static str,
168 tick_limit: usize,
169) -> Principal {
170 for _ in 0..tick_limit {
171 let registry: Result<Result<SubnetRegistryResponse, Error>, Error> = {
172 let payload = encode_args(()).expect("encode empty args");
173 pic.query_call(
174 root_id,
175 Principal::anonymous(),
176 protocol::CANIC_SUBNET_REGISTRY,
177 payload,
178 )
179 .map_err(|err| {
180 Error::internal(format!(
181 "pocket_ic query_call failed (canister={root_id}, method={}): {err}",
182 protocol::CANIC_SUBNET_REGISTRY
183 ))
184 })
185 .and_then(|bytes| {
186 decode_one(&bytes).map_err(|err| {
187 Error::internal(format!("decode_one failed for subnet registry: {err}"))
188 })
189 })
190 };
191
192 if let Ok(Ok(registry)) = registry
193 && let Some(pid) = registry
194 .0
195 .into_iter()
196 .find(|entry| entry.role == CanisterRole::new(role))
197 .map(|entry| entry.pid)
198 {
199 return pid;
200 }
201
202 pic.tick();
203 }
204
205 panic!("{role} canister must be registered");
206}
207
208pub struct PicBuilder(PocketIcBuilder);
219
220#[expect(clippy::new_without_default)]
221impl PicBuilder {
222 #[must_use]
224 pub fn new() -> Self {
225 Self(PocketIcBuilder::new())
226 }
227
228 #[must_use]
230 pub fn with_application_subnet(mut self) -> Self {
231 self.0 = self.0.with_application_subnet();
232 self
233 }
234
235 #[must_use]
237 pub fn with_ii_subnet(mut self) -> Self {
238 self.0 = self.0.with_ii_subnet();
239 self
240 }
241
242 #[must_use]
244 pub fn with_nns_subnet(mut self) -> Self {
245 self.0 = self.0.with_nns_subnet();
246 self
247 }
248
249 #[must_use]
251 pub fn build(self) -> Pic {
252 Pic {
253 inner: self.0.build(),
254 }
255 }
256}
257
258pub struct Pic {
268 inner: PocketIc,
269}
270
271impl<T> Deref for CachedPicBaselineGuard<'_, T> {
272 type Target = CachedPicBaseline<T>;
273
274 fn deref(&self) -> &Self::Target {
275 self.guard
276 .as_ref()
277 .expect("cached PocketIC baseline must exist")
278 }
279}
280
281impl<T> DerefMut for CachedPicBaselineGuard<'_, T> {
282 fn deref_mut(&mut self) -> &mut Self::Target {
283 self.guard
284 .as_mut()
285 .expect("cached PocketIC baseline must exist")
286 }
287}
288
289impl<T> CachedPicBaseline<T> {
290 pub fn capture<I>(
292 pic: Pic,
293 controller_id: Principal,
294 canister_ids: I,
295 metadata: T,
296 ) -> Option<Self>
297 where
298 I: IntoIterator<Item = Principal>,
299 {
300 let snapshots = pic.capture_controller_snapshots(controller_id, canister_ids)?;
301
302 Some(Self {
303 pic,
304 snapshots,
305 metadata,
306 _serial_guard: acquire_pic_serial_guard(),
307 })
308 }
309
310 pub fn restore(&self, controller_id: Principal) {
312 self.pic
313 .restore_controller_snapshots(controller_id, &self.snapshots);
314 }
315}
316
317impl Pic {
318 #[must_use]
320 pub fn current_time_nanos(&self) -> u64 {
321 self.inner.get_time().as_nanos_since_unix_epoch()
322 }
323
324 pub fn restore_time_nanos(&self, nanos_since_epoch: u64) {
326 let restored = pocket_ic::Time::from_nanos_since_unix_epoch(nanos_since_epoch);
327 self.inner.set_time(restored);
328 self.inner.set_certified_time(restored);
329 }
330
331 pub fn create_and_install_root_canister(&self, wasm: Vec<u8>) -> Result<Principal, Error> {
333 let init_bytes = install_root_args()?;
334
335 Ok(self.create_funded_and_install(wasm, init_bytes))
336 }
337
338 pub fn create_and_install_canister(
342 &self,
343 role: CanisterRole,
344 wasm: Vec<u8>,
345 ) -> Result<Principal, Error> {
346 let init_bytes = install_args(role)?;
347
348 Ok(self.create_funded_and_install(wasm, init_bytes))
349 }
350
351 pub fn wait_for_ready(&self, canister_id: Principal, tick_limit: usize, context: &str) {
353 for _ in 0..tick_limit {
354 self.tick();
355 if self.fetch_ready(canister_id) {
356 return;
357 }
358 }
359
360 self.dump_canister_debug(canister_id, context);
361 panic!("{context}: canister {canister_id} did not become ready after {tick_limit} ticks");
362 }
363
364 pub fn wait_for_all_ready<I>(&self, canister_ids: I, tick_limit: usize, context: &str)
366 where
367 I: IntoIterator<Item = Principal>,
368 {
369 let canister_ids = canister_ids.into_iter().collect::<Vec<_>>();
370
371 for _ in 0..tick_limit {
372 self.tick();
373 if canister_ids
374 .iter()
375 .copied()
376 .all(|canister_id| self.fetch_ready(canister_id))
377 {
378 return;
379 }
380 }
381
382 for canister_id in &canister_ids {
383 self.dump_canister_debug(*canister_id, context);
384 }
385 panic!("{context}: canisters did not become ready after {tick_limit} ticks");
386 }
387
388 pub fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
390 self.advance_time(cooldown);
391 self.tick_n(2);
392 }
393
394 pub fn retry_install_code_ok<T, F>(
396 &self,
397 retry_limit: usize,
398 cooldown: Duration,
399 mut op: F,
400 ) -> Result<T, String>
401 where
402 F: FnMut() -> Result<T, String>,
403 {
404 let mut last_err = None;
405
406 for _ in 0..retry_limit {
407 match op() {
408 Ok(value) => return Ok(value),
409 Err(err) if is_install_code_rate_limited(&err) => {
410 last_err = Some(err);
411 self.wait_out_install_code_rate_limit(cooldown);
412 }
413 Err(err) => return Err(err),
414 }
415 }
416
417 Err(last_err.unwrap_or_else(|| "install_code retry loop exhausted".to_string()))
418 }
419
420 pub fn retry_install_code_err<F>(
422 &self,
423 retry_limit: usize,
424 cooldown: Duration,
425 first: Result<(), String>,
426 mut op: F,
427 ) -> Result<(), String>
428 where
429 F: FnMut() -> Result<(), String>,
430 {
431 match first {
432 Ok(()) => return Ok(()),
433 Err(err) if !is_install_code_rate_limited(&err) => return Err(err),
434 Err(_) => {}
435 }
436
437 self.wait_out_install_code_rate_limit(cooldown);
438
439 for _ in 1..retry_limit {
440 match op() {
441 Ok(()) => return Ok(()),
442 Err(err) if is_install_code_rate_limited(&err) => {
443 self.wait_out_install_code_rate_limit(cooldown);
444 }
445 Err(err) => return Err(err),
446 }
447 }
448
449 op()
450 }
451
452 pub fn dump_canister_debug(&self, canister_id: Principal, context: &str) {
454 eprintln!("{context}: debug for canister {canister_id}");
455
456 match self.canister_status(canister_id, None) {
457 Ok(status) => eprintln!("canister_status: {status:?}"),
458 Err(err) => eprintln!("canister_status failed: {err:?}"),
459 }
460
461 match self.fetch_canister_logs(canister_id, Principal::anonymous()) {
462 Ok(records) => {
463 if records.is_empty() {
464 eprintln!("canister logs: <empty>");
465 } else {
466 for record in records {
467 eprintln!("canister log: {record:?}");
468 }
469 }
470 }
471 Err(err) => eprintln!("fetch_canister_logs failed: {err:?}"),
472 }
473 }
474
475 pub fn capture_controller_snapshots<I>(
477 &self,
478 controller_id: Principal,
479 canister_ids: I,
480 ) -> Option<ControllerSnapshots>
481 where
482 I: IntoIterator<Item = Principal>,
483 {
484 let mut snapshots = HashMap::new();
485
486 for canister_id in canister_ids {
487 let Some(snapshot) = self.try_take_controller_snapshot(controller_id, canister_id)
488 else {
489 eprintln!(
490 "capture_controller_snapshots: snapshot capture unavailable for {canister_id}"
491 );
492 return None;
493 };
494 snapshots.insert(canister_id, snapshot);
495 }
496
497 Some(ControllerSnapshots(snapshots))
498 }
499
500 pub fn restore_controller_snapshots(
502 &self,
503 controller_id: Principal,
504 snapshots: &ControllerSnapshots,
505 ) {
506 for (canister_id, snapshot) in &snapshots.0 {
507 self.restore_controller_snapshot(controller_id, *canister_id, snapshot);
508 }
509 }
510
511 pub fn update_call<T, A>(
513 &self,
514 canister_id: Principal,
515 method: &str,
516 args: A,
517 ) -> Result<T, Error>
518 where
519 T: CandidType + DeserializeOwned,
520 A: ArgumentEncoder,
521 {
522 let bytes: Vec<u8> = encode_args(args)
523 .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
524 let result = self
525 .inner
526 .update_call(canister_id, Principal::anonymous(), method, bytes)
527 .map_err(|err| {
528 Error::internal(format!(
529 "pocket_ic update_call failed (canister={canister_id}, method={method}): {err}"
530 ))
531 })?;
532
533 decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
534 }
535
536 pub fn update_call_as<T, A>(
538 &self,
539 canister_id: Principal,
540 caller: Principal,
541 method: &str,
542 args: A,
543 ) -> Result<T, Error>
544 where
545 T: CandidType + DeserializeOwned,
546 A: ArgumentEncoder,
547 {
548 let bytes: Vec<u8> = encode_args(args)
549 .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
550 let result = self
551 .inner
552 .update_call(canister_id, caller, method, bytes)
553 .map_err(|err| {
554 Error::internal(format!(
555 "pocket_ic update_call failed (canister={canister_id}, method={method}): {err}"
556 ))
557 })?;
558
559 decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
560 }
561
562 pub fn query_call<T, A>(
564 &self,
565 canister_id: Principal,
566 method: &str,
567 args: A,
568 ) -> Result<T, Error>
569 where
570 T: CandidType + DeserializeOwned,
571 A: ArgumentEncoder,
572 {
573 let bytes: Vec<u8> = encode_args(args)
574 .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
575 let result = self
576 .inner
577 .query_call(canister_id, Principal::anonymous(), method, bytes)
578 .map_err(|err| {
579 Error::internal(format!(
580 "pocket_ic query_call failed (canister={canister_id}, method={method}): {err}"
581 ))
582 })?;
583
584 decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
585 }
586
587 pub fn query_call_as<T, A>(
589 &self,
590 canister_id: Principal,
591 caller: Principal,
592 method: &str,
593 args: A,
594 ) -> Result<T, Error>
595 where
596 T: CandidType + DeserializeOwned,
597 A: ArgumentEncoder,
598 {
599 let bytes: Vec<u8> = encode_args(args)
600 .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
601 let result = self
602 .inner
603 .query_call(canister_id, caller, method, bytes)
604 .map_err(|err| {
605 Error::internal(format!(
606 "pocket_ic query_call failed (canister={canister_id}, method={method}): {err}"
607 ))
608 })?;
609
610 decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
611 }
612
613 pub fn tick_n(&self, times: usize) {
615 for _ in 0..times {
616 self.tick();
617 }
618 }
619
620 fn create_funded_and_install(&self, wasm: Vec<u8>, init_bytes: Vec<u8>) -> Principal {
622 let canister_id = self.create_canister();
623 self.add_cycles(canister_id, INSTALL_CYCLES);
624
625 let install = catch_unwind(AssertUnwindSafe(|| {
626 self.inner
627 .install_canister(canister_id, wasm, init_bytes, None);
628 }));
629 if let Err(err) = install {
630 eprintln!("install_canister trapped for {canister_id}");
631 if let Ok(status) = self.inner.canister_status(canister_id, None) {
632 eprintln!("canister_status for {canister_id}: {status:?}");
633 }
634 if let Ok(logs) = self
635 .inner
636 .fetch_canister_logs(canister_id, Principal::anonymous())
637 {
638 for record in logs {
639 eprintln!("canister_log {canister_id}: {record:?}");
640 }
641 }
642 std::panic::resume_unwind(err);
643 }
644
645 canister_id
646 }
647
648 fn fetch_ready(&self, canister_id: Principal) -> bool {
650 match self.query_call(canister_id, protocol::CANIC_READY, ()) {
651 Ok(ready) => ready,
652 Err(err) => {
653 self.dump_canister_debug(canister_id, "query canic_ready failed");
654 panic!("query canic_ready failed: {err:?}");
655 }
656 }
657 }
658
659 fn try_take_controller_snapshot(
661 &self,
662 controller_id: Principal,
663 canister_id: Principal,
664 ) -> Option<ControllerSnapshot> {
665 let candidates = controller_sender_candidates(controller_id, canister_id);
666 let mut last_err = None;
667
668 for sender in candidates {
669 match self.take_canister_snapshot(canister_id, sender, None) {
670 Ok(snapshot) => {
671 return Some(ControllerSnapshot {
672 snapshot_id: snapshot.id,
673 sender,
674 });
675 }
676 Err(err) => last_err = Some((sender, err)),
677 }
678 }
679
680 if let Some((sender, err)) = last_err {
681 eprintln!(
682 "failed to capture canister snapshot for {canister_id} using sender {sender:?}: {err}"
683 );
684 }
685 None
686 }
687
688 fn restore_controller_snapshot(
690 &self,
691 controller_id: Principal,
692 canister_id: Principal,
693 snapshot: &ControllerSnapshot,
694 ) {
695 let fallback_sender = if snapshot.sender.is_some() {
696 None
697 } else {
698 Some(controller_id)
699 };
700 let candidates = [snapshot.sender, fallback_sender];
701 let mut last_err = None;
702
703 for sender in candidates {
704 match self.load_canister_snapshot(canister_id, sender, snapshot.snapshot_id.clone()) {
705 Ok(()) => return,
706 Err(err) => last_err = Some((sender, err)),
707 }
708 }
709
710 let (sender, err) =
711 last_err.expect("snapshot restore must have at least one sender attempt");
712 panic!(
713 "failed to restore canister snapshot for {canister_id} using sender {sender:?}: {err}"
714 );
715 }
716}
717
718fn is_install_code_rate_limited(message: &str) -> bool {
719 message.contains("CanisterInstallCodeRateLimited")
720}
721
722impl Drop for ProcessLockGuard {
723 fn drop(&mut self) {
724 let _ = fs::remove_dir_all(&self.path);
725 }
726}
727
728impl Drop for PicSerialGuard {
729 fn drop(&mut self) {
730 let mut state = PIC_PROCESS_LOCK_STATE
731 .lock()
732 .unwrap_or_else(std::sync::PoisonError::into_inner);
733
734 state.ref_count = state
735 .ref_count
736 .checked_sub(1)
737 .expect("PocketIC serial guard refcount underflow");
738 if state.ref_count == 0 {
739 state.process_lock.take();
740 }
741 }
742}
743
744impl Deref for Pic {
745 type Target = PocketIc;
746
747 fn deref(&self) -> &Self::Target {
748 &self.inner
749 }
750}
751
752impl DerefMut for Pic {
753 fn deref_mut(&mut self) -> &mut Self::Target {
754 &mut self.inner
755 }
756}
757
758fn install_args(role: CanisterRole) -> Result<Vec<u8>, Error> {
773 if role.is_root() {
774 install_root_args()
775 } else {
776 let env = EnvBootstrapArgs {
779 prime_root_pid: None,
780 subnet_role: None,
781 subnet_pid: None,
782 root_pid: None,
783 canister_role: Some(role),
784 parent_pid: None,
785 };
786
787 let payload = CanisterInitPayload {
790 env,
791 app_directory: AppDirectoryArgs(Vec::new()),
792 subnet_directory: SubnetDirectoryArgs(Vec::new()),
793 };
794
795 encode_args::<(CanisterInitPayload, Option<Vec<u8>>)>((payload, None))
796 .map_err(|err| Error::internal(format!("encode_args failed: {err}")))
797 }
798}
799
800fn install_root_args() -> Result<Vec<u8>, Error> {
801 encode_one(SubnetIdentity::Manual)
802 .map_err(|err| Error::internal(format!("encode_one failed: {err}")))
803}
804
805fn controller_sender_candidates(
807 controller_id: Principal,
808 canister_id: Principal,
809) -> [Option<Principal>; 2] {
810 if canister_id == controller_id {
811 [None, Some(controller_id)]
812 } else {
813 [Some(controller_id), None]
814 }
815}
816
817fn acquire_process_lock() -> ProcessLockGuard {
818 let lock_dir = env::temp_dir().join(PIC_PROCESS_LOCK_DIR_NAME);
819 let started_waiting = Instant::now();
820 let mut logged_wait = false;
821
822 loop {
823 match fs::create_dir(&lock_dir) {
824 Ok(()) => {
825 fs::write(
826 process_lock_owner_path(&lock_dir),
827 render_process_lock_owner(),
828 )
829 .unwrap_or_else(|err| {
830 let _ = fs::remove_dir(&lock_dir);
831 panic!(
832 "failed to record PocketIC process lock owner at {}: {err}",
833 lock_dir.display()
834 );
835 });
836
837 if logged_wait {
838 eprintln!(
839 "[canic_testkit::pic] acquired cross-process PocketIC lock at {}",
840 lock_dir.display()
841 );
842 }
843
844 return ProcessLockGuard { path: lock_dir };
845 }
846 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
847 if process_lock_is_stale(&lock_dir) && clear_stale_process_lock(&lock_dir).is_ok() {
848 continue;
849 }
850
851 if !logged_wait && started_waiting.elapsed() >= PIC_PROCESS_LOCK_LOG_AFTER {
852 eprintln!(
853 "[canic_testkit::pic] waiting for cross-process PocketIC lock at {}",
854 lock_dir.display()
855 );
856 logged_wait = true;
857 }
858
859 thread::sleep(PIC_PROCESS_LOCK_RETRY_DELAY);
860 }
861 Err(err) => panic!(
862 "failed to create PocketIC process lock dir at {}: {err}",
863 lock_dir.display()
864 ),
865 }
866 }
867}
868
869fn process_lock_owner_path(lock_dir: &Path) -> PathBuf {
870 lock_dir.join("owner")
871}
872
873fn clear_stale_process_lock(lock_dir: &Path) -> io::Result<()> {
874 match fs::remove_dir_all(lock_dir) {
875 Ok(()) => Ok(()),
876 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
877 Err(err) => Err(err),
878 }
879}
880
881fn process_lock_is_stale(lock_dir: &Path) -> bool {
882 process_lock_is_stale_with_proc_root(lock_dir, Path::new("/proc"))
883}
884
885fn process_lock_is_stale_with_proc_root(lock_dir: &Path, proc_root: &Path) -> bool {
886 let Some(owner) = read_process_lock_owner(&process_lock_owner_path(lock_dir)) else {
887 return true;
888 };
889
890 let proc_dir = proc_root.join(owner.pid.to_string());
891 if !proc_dir.exists() {
892 return true;
893 }
894
895 match owner.start_ticks {
896 Some(expected_ticks) => {
897 read_process_start_ticks(proc_root, owner.pid) != Some(expected_ticks)
898 }
899 None => false,
900 }
901}
902
903fn render_process_lock_owner() -> String {
904 let owner = current_process_lock_owner();
905 match owner.start_ticks {
906 Some(start_ticks) => format!("pid={}\nstart_ticks={start_ticks}\n", owner.pid),
907 None => format!("pid={}\n", owner.pid),
908 }
909}
910
911fn current_process_lock_owner() -> ProcessLockOwner {
912 ProcessLockOwner {
913 pid: process::id(),
914 start_ticks: read_process_start_ticks(Path::new("/proc"), process::id()),
915 }
916}
917
918fn read_process_lock_owner(path: &Path) -> Option<ProcessLockOwner> {
919 let text = fs::read_to_string(path).ok()?;
920 parse_process_lock_owner(&text)
921}
922
923fn parse_process_lock_owner(text: &str) -> Option<ProcessLockOwner> {
924 let trimmed = text.trim();
925 if trimmed.is_empty() {
926 return None;
927 }
928
929 if let Ok(pid) = trimmed.parse::<u32>() {
930 return Some(ProcessLockOwner {
931 pid,
932 start_ticks: None,
933 });
934 }
935
936 let mut pid = None;
937 let mut start_ticks = None;
938 for line in trimmed.lines() {
939 if let Some(value) = line.strip_prefix("pid=") {
940 pid = value.trim().parse::<u32>().ok();
941 } else if let Some(value) = line.strip_prefix("start_ticks=") {
942 start_ticks = value.trim().parse::<u64>().ok();
943 }
944 }
945
946 Some(ProcessLockOwner {
947 pid: pid?,
948 start_ticks,
949 })
950}
951
952fn read_process_start_ticks(proc_root: &Path, pid: u32) -> Option<u64> {
953 let stat_path = proc_root.join(pid.to_string()).join("stat");
954 let stat = fs::read_to_string(stat_path).ok()?;
955 let close_paren = stat.rfind(')')?;
956 let rest = stat.get(close_paren + 2..)?;
957 let fields = rest.split_whitespace().collect::<Vec<_>>();
958 fields.get(19)?.parse::<u64>().ok()
959}
960
961#[cfg(test)]
962mod process_lock_tests {
963 use super::{
964 clear_stale_process_lock, parse_process_lock_owner, process_lock_is_stale_with_proc_root,
965 process_lock_owner_path,
966 };
967 use std::{
968 fs,
969 path::PathBuf,
970 time::{SystemTime, UNIX_EPOCH},
971 };
972
973 fn unique_lock_dir() -> PathBuf {
974 let nanos = SystemTime::now()
975 .duration_since(UNIX_EPOCH)
976 .expect("clock must be after unix epoch")
977 .as_nanos();
978 std::env::temp_dir().join(format!("canic-pocket-ic-test-lock-{nanos}"))
979 }
980
981 #[test]
982 fn stale_process_lock_is_detected_and_removed() {
983 let lock_dir = unique_lock_dir();
984 fs::create_dir(&lock_dir).expect("create lock dir");
985 fs::write(process_lock_owner_path(&lock_dir), "999999").expect("write stale owner");
986
987 assert!(process_lock_is_stale_with_proc_root(
988 &lock_dir,
989 std::path::Path::new("/proc")
990 ));
991 clear_stale_process_lock(&lock_dir).expect("remove stale lock dir");
992 assert!(!lock_dir.exists());
993 }
994
995 #[test]
996 fn owner_parser_accepts_legacy_pid_only_format() {
997 let owner = parse_process_lock_owner("12345\n").expect("parse pid-only owner");
998 assert_eq!(owner.pid, 12345);
999 assert_eq!(owner.start_ticks, None);
1000 }
1001
1002 #[test]
1003 fn stale_process_lock_detects_pid_reuse_via_start_ticks() {
1004 let root = unique_lock_dir();
1005 let lock_dir = root.join("lock");
1006 let proc_root = root.join("proc");
1007 let proc_pid = proc_root.join("77");
1008 fs::create_dir_all(&lock_dir).expect("create lock dir");
1009 fs::create_dir_all(&proc_pid).expect("create proc pid dir");
1010 fs::write(
1011 process_lock_owner_path(&lock_dir),
1012 "pid=77\nstart_ticks=41\n",
1013 )
1014 .expect("write owner");
1015 fs::write(
1016 proc_pid.join("stat"),
1017 "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",
1018 )
1019 .expect("write proc stat");
1020
1021 assert!(process_lock_is_stale_with_proc_root(&lock_dir, &proc_root));
1022 }
1023}