Skip to main content

canic_testkit/pic/
mod.rs

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
58///
59/// ControllerSnapshots
60///
61
62pub struct ControllerSnapshots(HashMap<Principal, ControllerSnapshot>);
63
64///
65/// CachedPicBaseline
66///
67
68pub struct CachedPicBaseline<T> {
69    pub pic: Pic,
70    pub snapshots: ControllerSnapshots,
71    pub metadata: T,
72    _serial_guard: PicSerialGuard,
73}
74
75///
76/// CachedPicBaselineGuard
77///
78
79pub struct CachedPicBaselineGuard<'a, T> {
80    guard: MutexGuard<'a, Option<CachedPicBaseline<T>>>,
81}
82
83///
84/// PicSerialGuard
85///
86
87pub struct PicSerialGuard {
88    _private: (),
89}
90
91pub use standalone::{StandaloneCanisterFixture, install_standalone_canister};
92
93///
94/// Create a fresh PocketIC universe.
95///
96/// IMPORTANT:
97/// - Each call creates a new IC instance
98/// - WARNING: callers must hold a `PicSerialGuard` for the full `Pic` lifetime
99/// - Required to avoid PocketIC wasm chunk store exhaustion
100///
101#[must_use]
102pub fn pic() -> Pic {
103    PicBuilder::new().with_application_subnet().build()
104}
105
106/// Acquire the shared PocketIC serialization guard for the current process.
107#[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
121/// Acquire one process-local cached PocketIC baseline, building it on first use.
122pub 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
141/// Wait until a PocketIC canister reports `canic_ready`.
142pub 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/// Resolve one role principal from root's subnet registry, polling until present.
163#[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
208///
209/// PicBuilder
210/// Thin wrapper around the PocketIC builder.
211///
212/// This builder is only used to configure the singleton. It does not create
213/// additional IC instances beyond the global `Pic`.
214///
215/// Note: this file is test-only infrastructure; simplicity wins over abstraction.
216///
217
218pub struct PicBuilder(PocketIcBuilder);
219
220#[expect(clippy::new_without_default)]
221impl PicBuilder {
222    /// Start a new PicBuilder with sensible defaults.
223    #[must_use]
224    pub fn new() -> Self {
225        Self(PocketIcBuilder::new())
226    }
227
228    /// Include an application subnet in the PocketIC universe.
229    #[must_use]
230    pub fn with_application_subnet(mut self) -> Self {
231        self.0 = self.0.with_application_subnet();
232        self
233    }
234
235    /// Include an II subnet so threshold keys are available in the PocketIC universe.
236    #[must_use]
237    pub fn with_ii_subnet(mut self) -> Self {
238        self.0 = self.0.with_ii_subnet();
239        self
240    }
241
242    /// Include an NNS subnet in the PocketIC universe.
243    #[must_use]
244    pub fn with_nns_subnet(mut self) -> Self {
245        self.0 = self.0.with_nns_subnet();
246        self
247    }
248
249    /// Finish building the PocketIC instance and wrap it.
250    #[must_use]
251    pub fn build(self) -> Pic {
252        Pic {
253            inner: self.0.build(),
254        }
255    }
256}
257
258///
259/// Pic
260/// Thin wrapper around a PocketIC instance.
261///
262/// This type intentionally exposes only a minimal API surface; callers should
263/// use `pic()` to obtain an instance and then perform installs/calls.
264/// Callers must hold a `PicSerialGuard` for the full `Pic` lifetime.
265///
266
267pub 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    /// Capture one immutable cached baseline from the current PocketIC instance.
291    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    /// Restore the captured snapshot set back into the owned PocketIC instance.
311    pub fn restore(&self, controller_id: Principal) {
312        self.pic
313            .restore_controller_snapshots(controller_id, &self.snapshots);
314    }
315}
316
317impl Pic {
318    /// Capture the current PocketIC wall-clock time as nanoseconds since epoch.
319    #[must_use]
320    pub fn current_time_nanos(&self) -> u64 {
321        self.inner.get_time().as_nanos_since_unix_epoch()
322    }
323
324    /// Restore PocketIC wall-clock and certified time from a captured nanosecond value.
325    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    /// Install a root canister with the default root init arguments.
332    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    /// Install a canister with the given type and wasm bytes.
339    ///
340    /// Install failures are treated as fatal in tests.
341    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    /// Wait until one canister reports `canic_ready`.
352    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    /// Wait until all provided canisters report `canic_ready`.
365    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    /// Wait out the PocketIC `install_code` cooldown window inside the same instance.
389    pub fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
390        self.advance_time(cooldown);
391        self.tick_n(2);
392    }
393
394    /// Retry one install_code-like operation while PocketIC still reports rate limiting.
395    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    /// Retry one install_code-like failure path while PocketIC still reports rate limiting.
421    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    /// Dump basic PocketIC status and log context for one canister.
453    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    /// Capture one restorable snapshot per canister using a shared controller.
476    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    /// Restore a previously captured snapshot set using the same controller.
501    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    /// Generic update call helper (serializes args + decodes result).
512    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    /// Generic update call helper with an explicit caller principal.
537    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    /// Generic query call helper.
563    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    /// Generic query call helper with an explicit caller principal.
588    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    /// Advance PocketIC by a fixed number of ticks.
614    pub fn tick_n(&self, times: usize) {
615        for _ in 0..times {
616            self.tick();
617        }
618    }
619
620    // Install a canister after creating it and funding it with cycles.
621    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    // Query `canic_ready` and panic with debug context on transport failures.
649    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    // Capture one snapshot with sender fallbacks that match controller ownership.
660    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    // Restore one snapshot with sender fallbacks that match controller ownership.
689    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
758/// --------------------------------------
759/// install_args helper
760/// --------------------------------------
761///
762/// Init semantics:
763/// - Root canisters receive a `SubnetIdentity` (direct root bootstrap).
764/// - Non-root canisters receive `EnvBootstrapArgs` + optional directory snapshots.
765///
766/// Directory handling:
767/// - By default, directory views are empty for standalone installs.
768/// - Directory-dependent logic is opt-in via `install_args_with_directories`.
769/// - Root-provisioned installs will populate directories via cascade.
770///
771
772fn install_args(role: CanisterRole) -> Result<Vec<u8>, Error> {
773    if role.is_root() {
774        install_root_args()
775    } else {
776        // Non-root standalone install.
777        // Provide only what is structurally known at install time.
778        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        // Intentional: standalone installs do not require directories unless
788        // a test explicitly exercises directory-dependent behavior.
789        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
805// Prefer the likely controller sender first to reduce noisy management-call failures.
806fn 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}