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    any::Any,
18    collections::HashMap,
19    env, fs, io,
20    ops::{Deref, DerefMut},
21    panic::{AssertUnwindSafe, catch_unwind},
22    path::{Path, PathBuf},
23    process,
24    sync::{Mutex, MutexGuard},
25    thread,
26    time::{Duration, Instant},
27};
28
29mod standalone;
30
31const INSTALL_CYCLES: u128 = 500 * TC;
32const PIC_PROCESS_LOCK_DIR_NAME: &str = "canic-pocket-ic.lock";
33const PIC_PROCESS_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
34const PIC_PROCESS_LOCK_LOG_AFTER: Duration = Duration::from_secs(1);
35static PIC_PROCESS_LOCK_STATE: Mutex<ProcessLockState> = Mutex::new(ProcessLockState {
36    ref_count: 0,
37    process_lock: None,
38});
39
40struct ControllerSnapshot {
41    snapshot_id: Vec<u8>,
42    sender: Option<Principal>,
43}
44
45struct ProcessLockGuard {
46    path: PathBuf,
47}
48
49struct ProcessLockOwner {
50    pid: u32,
51    start_ticks: Option<u64>,
52}
53
54struct ProcessLockState {
55    ref_count: usize,
56    process_lock: Option<ProcessLockGuard>,
57}
58
59///
60/// PicStartError
61///
62
63#[derive(Debug, Eq, PartialEq)]
64pub enum PicStartError {
65    BinaryUnavailable { message: String },
66    BinaryInvalid { message: String },
67    DownloadFailed { message: String },
68    ServerStartFailed { message: String },
69    StartupTimedOut { message: String },
70    Panic { message: String },
71}
72
73///
74/// PicSerialGuardError
75///
76
77#[derive(Debug)]
78pub enum PicSerialGuardError {
79    LockParentUnavailable { path: PathBuf, source: io::Error },
80    LockUnavailable { path: PathBuf, source: io::Error },
81    LockOwnerRecordFailed { path: PathBuf, source: io::Error },
82}
83
84///
85/// PicInstallError
86///
87
88#[derive(Debug, Eq, PartialEq)]
89pub struct PicInstallError {
90    canister_id: Principal,
91    message: String,
92}
93
94///
95/// StandaloneCanisterFixtureError
96///
97
98#[derive(Debug)]
99pub enum StandaloneCanisterFixtureError {
100    SerialGuard(PicSerialGuardError),
101    Start(PicStartError),
102    Install(PicInstallError),
103}
104
105///
106/// ControllerSnapshots
107///
108
109pub struct ControllerSnapshots(HashMap<Principal, ControllerSnapshot>);
110
111///
112/// CachedPicBaseline
113///
114
115pub struct CachedPicBaseline<T> {
116    pub pic: Pic,
117    pub snapshots: ControllerSnapshots,
118    pub metadata: T,
119    _serial_guard: PicSerialGuard,
120}
121
122///
123/// CachedPicBaselineGuard
124///
125
126pub struct CachedPicBaselineGuard<'a, T> {
127    guard: MutexGuard<'a, Option<CachedPicBaseline<T>>>,
128}
129
130///
131/// PicSerialGuard
132///
133
134pub struct PicSerialGuard {
135    _private: (),
136}
137
138pub use standalone::{
139    StandaloneCanisterFixture, install_prebuilt_canister, install_prebuilt_canister_with_cycles,
140    install_standalone_canister, try_install_prebuilt_canister,
141    try_install_prebuilt_canister_with_cycles,
142};
143
144///
145/// Create a fresh PocketIC universe.
146///
147/// IMPORTANT:
148/// - Each call creates a new IC instance
149/// - WARNING: callers must hold a `PicSerialGuard` for the full `Pic` lifetime
150/// - Required to avoid PocketIC wasm chunk store exhaustion
151///
152#[must_use]
153pub fn pic() -> Pic {
154    try_pic().unwrap_or_else(|err| panic!("failed to start PocketIC: {err}"))
155}
156
157/// Create a fresh PocketIC universe without panicking on startup failures.
158pub fn try_pic() -> Result<Pic, PicStartError> {
159    PicBuilder::new().with_application_subnet().try_build()
160}
161
162/// Acquire the shared PocketIC serialization guard for the current process.
163#[must_use]
164pub fn acquire_pic_serial_guard() -> PicSerialGuard {
165    try_acquire_pic_serial_guard()
166        .unwrap_or_else(|err| panic!("failed to acquire PocketIC serial guard: {err}"))
167}
168
169/// Acquire the shared PocketIC serialization guard for the current process.
170pub fn try_acquire_pic_serial_guard() -> Result<PicSerialGuard, PicSerialGuardError> {
171    let mut state = PIC_PROCESS_LOCK_STATE
172        .lock()
173        .unwrap_or_else(std::sync::PoisonError::into_inner);
174
175    if state.ref_count == 0 {
176        state.process_lock = Some(acquire_process_lock()?);
177    }
178    state.ref_count += 1;
179
180    Ok(PicSerialGuard { _private: () })
181}
182
183/// Acquire one process-local cached PocketIC baseline, building it on first use.
184pub fn acquire_cached_pic_baseline<T, F>(
185    slot: &'static Mutex<Option<CachedPicBaseline<T>>>,
186    build: F,
187) -> (CachedPicBaselineGuard<'static, T>, bool)
188where
189    F: FnOnce() -> CachedPicBaseline<T>,
190{
191    let mut guard = slot
192        .lock()
193        .unwrap_or_else(std::sync::PoisonError::into_inner);
194    let cache_hit = guard.is_some();
195
196    if !cache_hit {
197        *guard = Some(build());
198    }
199
200    (CachedPicBaselineGuard { guard }, cache_hit)
201}
202
203/// Wait until a PocketIC canister reports `canic_ready`.
204pub fn wait_until_ready(pic: &PocketIc, canister_id: Principal, tick_limit: usize) {
205    let payload = encode_args(()).expect("encode empty args");
206
207    for _ in 0..tick_limit {
208        if let Ok(bytes) = pic.query_call(
209            canister_id,
210            Principal::anonymous(),
211            protocol::CANIC_READY,
212            payload.clone(),
213        ) && let Ok(ready) = decode_one::<bool>(&bytes)
214            && ready
215        {
216            return;
217        }
218        pic.tick();
219    }
220
221    panic!("canister did not report ready in time: {canister_id}");
222}
223
224/// Resolve one role principal from root's subnet registry, polling until present.
225#[must_use]
226pub fn role_pid(
227    pic: &PocketIc,
228    root_id: Principal,
229    role: &'static str,
230    tick_limit: usize,
231) -> Principal {
232    for _ in 0..tick_limit {
233        let registry: Result<Result<SubnetRegistryResponse, Error>, Error> = {
234            let payload = encode_args(()).expect("encode empty args");
235            pic.query_call(
236                root_id,
237                Principal::anonymous(),
238                protocol::CANIC_SUBNET_REGISTRY,
239                payload,
240            )
241            .map_err(|err| {
242                Error::internal(format!(
243                    "pocket_ic query_call failed (canister={root_id}, method={}): {err}",
244                    protocol::CANIC_SUBNET_REGISTRY
245                ))
246            })
247            .and_then(|bytes| {
248                decode_one(&bytes).map_err(|err| {
249                    Error::internal(format!("decode_one failed for subnet registry: {err}"))
250                })
251            })
252        };
253
254        if let Ok(Ok(registry)) = registry
255            && let Some(pid) = registry
256                .0
257                .into_iter()
258                .find(|entry| entry.role == CanisterRole::new(role))
259                .map(|entry| entry.pid)
260        {
261            return pid;
262        }
263
264        pic.tick();
265    }
266
267    panic!("{role} canister must be registered");
268}
269
270///
271/// PicBuilder
272/// Thin wrapper around the PocketIC builder.
273///
274/// This builder is only used to configure the singleton. It does not create
275/// additional IC instances beyond the global `Pic`.
276///
277/// Note: this file is test-only infrastructure; simplicity wins over abstraction.
278///
279
280pub struct PicBuilder(PocketIcBuilder);
281
282#[expect(clippy::new_without_default)]
283impl PicBuilder {
284    /// Start a new PicBuilder with sensible defaults.
285    #[must_use]
286    pub fn new() -> Self {
287        Self(PocketIcBuilder::new())
288    }
289
290    /// Include an application subnet in the PocketIC universe.
291    #[must_use]
292    pub fn with_application_subnet(mut self) -> Self {
293        self.0 = self.0.with_application_subnet();
294        self
295    }
296
297    /// Include an II subnet so threshold keys are available in the PocketIC universe.
298    #[must_use]
299    pub fn with_ii_subnet(mut self) -> Self {
300        self.0 = self.0.with_ii_subnet();
301        self
302    }
303
304    /// Include an NNS subnet in the PocketIC universe.
305    #[must_use]
306    pub fn with_nns_subnet(mut self) -> Self {
307        self.0 = self.0.with_nns_subnet();
308        self
309    }
310
311    /// Finish building the PocketIC instance and wrap it.
312    #[must_use]
313    pub fn build(self) -> Pic {
314        self.try_build()
315            .unwrap_or_else(|err| panic!("failed to start PocketIC: {err}"))
316    }
317
318    /// Finish building the PocketIC instance without panicking on startup failures.
319    pub fn try_build(self) -> Result<Pic, PicStartError> {
320        let build = catch_unwind(AssertUnwindSafe(|| self.0.build()));
321
322        match build {
323            Ok(inner) => Ok(Pic { inner }),
324            Err(payload) => Err(classify_pic_start_panic(payload)),
325        }
326    }
327}
328
329impl std::fmt::Display for PicStartError {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        match self {
332            Self::BinaryUnavailable { message }
333            | Self::BinaryInvalid { message }
334            | Self::DownloadFailed { message }
335            | Self::ServerStartFailed { message }
336            | Self::StartupTimedOut { message }
337            | Self::Panic { message } => f.write_str(message),
338        }
339    }
340}
341
342impl std::error::Error for PicStartError {}
343
344impl std::fmt::Display for PicSerialGuardError {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        match self {
347            Self::LockParentUnavailable { path, source } => write!(
348                f,
349                "failed to create PocketIC lock parent at {}: {source}",
350                path.display()
351            ),
352            Self::LockUnavailable { path, source } => write!(
353                f,
354                "failed to create PocketIC process lock dir at {}: {source}",
355                path.display()
356            ),
357            Self::LockOwnerRecordFailed { path, source } => write!(
358                f,
359                "failed to record PocketIC process lock owner at {}: {source}",
360                path.display()
361            ),
362        }
363    }
364}
365
366impl std::error::Error for PicSerialGuardError {
367    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
368        match self {
369            Self::LockParentUnavailable { source, .. }
370            | Self::LockUnavailable { source, .. }
371            | Self::LockOwnerRecordFailed { source, .. } => Some(source),
372        }
373    }
374}
375
376impl PicInstallError {
377    /// Capture one install failure for a specific canister id.
378    #[must_use]
379    pub const fn new(canister_id: Principal, message: String) -> Self {
380        Self {
381            canister_id,
382            message,
383        }
384    }
385
386    /// Read the canister id that failed to install.
387    #[must_use]
388    pub const fn canister_id(&self) -> Principal {
389        self.canister_id
390    }
391
392    /// Read the captured panic message from the install attempt.
393    #[must_use]
394    pub fn message(&self) -> &str {
395        &self.message
396    }
397}
398
399impl std::fmt::Display for PicInstallError {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        write!(
402            f,
403            "failed to install canister {}: {}",
404            self.canister_id, self.message
405        )
406    }
407}
408
409impl std::error::Error for PicInstallError {}
410
411impl std::fmt::Display for StandaloneCanisterFixtureError {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        match self {
414            Self::SerialGuard(err) => write!(f, "{err}"),
415            Self::Start(err) => write!(f, "{err}"),
416            Self::Install(err) => write!(f, "{err}"),
417        }
418    }
419}
420
421impl std::error::Error for StandaloneCanisterFixtureError {
422    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
423        match self {
424            Self::SerialGuard(err) => Some(err),
425            Self::Start(err) => Some(err),
426            Self::Install(err) => Some(err),
427        }
428    }
429}
430
431///
432/// Pic
433/// Thin wrapper around a PocketIC instance.
434///
435/// This type intentionally exposes only a minimal API surface; callers should
436/// use `pic()` to obtain an instance and then perform installs/calls.
437/// Callers must hold a `PicSerialGuard` for the full `Pic` lifetime.
438///
439
440pub struct Pic {
441    inner: PocketIc,
442}
443
444impl<T> Deref for CachedPicBaselineGuard<'_, T> {
445    type Target = CachedPicBaseline<T>;
446
447    fn deref(&self) -> &Self::Target {
448        self.guard
449            .as_ref()
450            .expect("cached PocketIC baseline must exist")
451    }
452}
453
454impl<T> DerefMut for CachedPicBaselineGuard<'_, T> {
455    fn deref_mut(&mut self) -> &mut Self::Target {
456        self.guard
457            .as_mut()
458            .expect("cached PocketIC baseline must exist")
459    }
460}
461
462impl<T> CachedPicBaseline<T> {
463    /// Capture one immutable cached baseline from the current PocketIC instance.
464    pub fn capture<I>(
465        pic: Pic,
466        controller_id: Principal,
467        canister_ids: I,
468        metadata: T,
469    ) -> Option<Self>
470    where
471        I: IntoIterator<Item = Principal>,
472    {
473        let snapshots = pic.capture_controller_snapshots(controller_id, canister_ids)?;
474
475        Some(Self {
476            pic,
477            snapshots,
478            metadata,
479            _serial_guard: acquire_pic_serial_guard(),
480        })
481    }
482
483    /// Restore the captured snapshot set back into the owned PocketIC instance.
484    pub fn restore(&self, controller_id: Principal) {
485        self.pic
486            .restore_controller_snapshots(controller_id, &self.snapshots);
487    }
488}
489
490impl Pic {
491    /// Capture the current PocketIC wall-clock time as nanoseconds since epoch.
492    #[must_use]
493    pub fn current_time_nanos(&self) -> u64 {
494        self.inner.get_time().as_nanos_since_unix_epoch()
495    }
496
497    /// Restore PocketIC wall-clock and certified time from a captured nanosecond value.
498    pub fn restore_time_nanos(&self, nanos_since_epoch: u64) {
499        let restored = pocket_ic::Time::from_nanos_since_unix_epoch(nanos_since_epoch);
500        self.inner.set_time(restored);
501        self.inner.set_certified_time(restored);
502    }
503
504    /// Install a root canister with the default root init arguments.
505    pub fn create_and_install_root_canister(&self, wasm: Vec<u8>) -> Result<Principal, Error> {
506        let init_bytes = install_root_args()?;
507
508        Ok(self.create_and_install_with_args(wasm, init_bytes, INSTALL_CYCLES))
509    }
510
511    /// Install a canister with the given type and wasm bytes.
512    ///
513    /// Install failures are treated as fatal in tests.
514    pub fn create_and_install_canister(
515        &self,
516        role: CanisterRole,
517        wasm: Vec<u8>,
518    ) -> Result<Principal, Error> {
519        let init_bytes = install_args(role)?;
520
521        Ok(self.create_and_install_with_args(wasm, init_bytes, INSTALL_CYCLES))
522    }
523
524    /// Install one arbitrary wasm module with caller-provided init bytes.
525    ///
526    /// This is the generic install path for downstreams that use `canic-testkit`
527    /// without depending on Canic canister init payload conventions.
528    #[must_use]
529    pub fn create_and_install_with_args(
530        &self,
531        wasm: Vec<u8>,
532        init_bytes: Vec<u8>,
533        install_cycles: u128,
534    ) -> Principal {
535        self.try_create_and_install_with_args(wasm, init_bytes, install_cycles)
536            .unwrap_or_else(|err| panic!("{err}"))
537    }
538
539    /// Install one arbitrary wasm module with caller-provided init bytes.
540    pub fn try_create_and_install_with_args(
541        &self,
542        wasm: Vec<u8>,
543        init_bytes: Vec<u8>,
544        install_cycles: u128,
545    ) -> Result<Principal, PicInstallError> {
546        self.try_create_funded_and_install(wasm, init_bytes, install_cycles)
547    }
548
549    /// Wait until one canister reports `canic_ready`.
550    pub fn wait_for_ready(&self, canister_id: Principal, tick_limit: usize, context: &str) {
551        for _ in 0..tick_limit {
552            self.tick();
553            if self.fetch_ready(canister_id) {
554                return;
555            }
556        }
557
558        self.dump_canister_debug(canister_id, context);
559        panic!("{context}: canister {canister_id} did not become ready after {tick_limit} ticks");
560    }
561
562    /// Wait until all provided canisters report `canic_ready`.
563    pub fn wait_for_all_ready<I>(&self, canister_ids: I, tick_limit: usize, context: &str)
564    where
565        I: IntoIterator<Item = Principal>,
566    {
567        let canister_ids = canister_ids.into_iter().collect::<Vec<_>>();
568
569        for _ in 0..tick_limit {
570            self.tick();
571            if canister_ids
572                .iter()
573                .copied()
574                .all(|canister_id| self.fetch_ready(canister_id))
575            {
576                return;
577            }
578        }
579
580        for canister_id in &canister_ids {
581            self.dump_canister_debug(*canister_id, context);
582        }
583        panic!("{context}: canisters did not become ready after {tick_limit} ticks");
584    }
585
586    /// Wait out the PocketIC `install_code` cooldown window inside the same instance.
587    pub fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
588        self.advance_time(cooldown);
589        self.tick_n(2);
590    }
591
592    /// Retry one install_code-like operation while PocketIC still reports rate limiting.
593    pub fn retry_install_code_ok<T, F>(
594        &self,
595        retry_limit: usize,
596        cooldown: Duration,
597        mut op: F,
598    ) -> Result<T, String>
599    where
600        F: FnMut() -> Result<T, String>,
601    {
602        let mut last_err = None;
603
604        for _ in 0..retry_limit {
605            match op() {
606                Ok(value) => return Ok(value),
607                Err(err) if is_install_code_rate_limited(&err) => {
608                    last_err = Some(err);
609                    self.wait_out_install_code_rate_limit(cooldown);
610                }
611                Err(err) => return Err(err),
612            }
613        }
614
615        Err(last_err.unwrap_or_else(|| "install_code retry loop exhausted".to_string()))
616    }
617
618    /// Retry one install_code-like failure path while PocketIC still reports rate limiting.
619    pub fn retry_install_code_err<F>(
620        &self,
621        retry_limit: usize,
622        cooldown: Duration,
623        first: Result<(), String>,
624        mut op: F,
625    ) -> Result<(), String>
626    where
627        F: FnMut() -> Result<(), String>,
628    {
629        match first {
630            Ok(()) => return Ok(()),
631            Err(err) if !is_install_code_rate_limited(&err) => return Err(err),
632            Err(_) => {}
633        }
634
635        self.wait_out_install_code_rate_limit(cooldown);
636
637        for _ in 1..retry_limit {
638            match op() {
639                Ok(()) => return Ok(()),
640                Err(err) if is_install_code_rate_limited(&err) => {
641                    self.wait_out_install_code_rate_limit(cooldown);
642                }
643                Err(err) => return Err(err),
644            }
645        }
646
647        op()
648    }
649
650    /// Dump basic PocketIC status and log context for one canister.
651    pub fn dump_canister_debug(&self, canister_id: Principal, context: &str) {
652        eprintln!("{context}: debug for canister {canister_id}");
653
654        match self.canister_status(canister_id, None) {
655            Ok(status) => eprintln!("canister_status: {status:?}"),
656            Err(err) => eprintln!("canister_status failed: {err:?}"),
657        }
658
659        match self.fetch_canister_logs(canister_id, Principal::anonymous()) {
660            Ok(records) => {
661                if records.is_empty() {
662                    eprintln!("canister logs: <empty>");
663                } else {
664                    for record in records {
665                        eprintln!("canister log: {record:?}");
666                    }
667                }
668            }
669            Err(err) => eprintln!("fetch_canister_logs failed: {err:?}"),
670        }
671    }
672
673    /// Capture one restorable snapshot per canister using a shared controller.
674    pub fn capture_controller_snapshots<I>(
675        &self,
676        controller_id: Principal,
677        canister_ids: I,
678    ) -> Option<ControllerSnapshots>
679    where
680        I: IntoIterator<Item = Principal>,
681    {
682        let mut snapshots = HashMap::new();
683
684        for canister_id in canister_ids {
685            let Some(snapshot) = self.try_take_controller_snapshot(controller_id, canister_id)
686            else {
687                eprintln!(
688                    "capture_controller_snapshots: snapshot capture unavailable for {canister_id}"
689                );
690                return None;
691            };
692            snapshots.insert(canister_id, snapshot);
693        }
694
695        Some(ControllerSnapshots(snapshots))
696    }
697
698    /// Restore a previously captured snapshot set using the same controller.
699    pub fn restore_controller_snapshots(
700        &self,
701        controller_id: Principal,
702        snapshots: &ControllerSnapshots,
703    ) {
704        for (canister_id, snapshot) in &snapshots.0 {
705            self.restore_controller_snapshot(controller_id, *canister_id, snapshot);
706        }
707    }
708
709    /// Generic update call helper (serializes args + decodes result).
710    pub fn update_call<T, A>(
711        &self,
712        canister_id: Principal,
713        method: &str,
714        args: A,
715    ) -> Result<T, Error>
716    where
717        T: CandidType + DeserializeOwned,
718        A: ArgumentEncoder,
719    {
720        let bytes: Vec<u8> = encode_args(args)
721            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
722        let result = self
723            .inner
724            .update_call(canister_id, Principal::anonymous(), method, bytes)
725            .map_err(|err| {
726                Error::internal(format!(
727                    "pocket_ic update_call failed (canister={canister_id}, method={method}): {err}"
728                ))
729            })?;
730
731        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
732    }
733
734    /// Generic update call helper with an explicit caller principal.
735    pub fn update_call_as<T, A>(
736        &self,
737        canister_id: Principal,
738        caller: Principal,
739        method: &str,
740        args: A,
741    ) -> Result<T, Error>
742    where
743        T: CandidType + DeserializeOwned,
744        A: ArgumentEncoder,
745    {
746        let bytes: Vec<u8> = encode_args(args)
747            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
748        let result = self
749            .inner
750            .update_call(canister_id, caller, method, bytes)
751            .map_err(|err| {
752                Error::internal(format!(
753                    "pocket_ic update_call failed (canister={canister_id}, method={method}): {err}"
754                ))
755            })?;
756
757        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
758    }
759
760    /// Generic query call helper.
761    pub fn query_call<T, A>(
762        &self,
763        canister_id: Principal,
764        method: &str,
765        args: A,
766    ) -> Result<T, Error>
767    where
768        T: CandidType + DeserializeOwned,
769        A: ArgumentEncoder,
770    {
771        let bytes: Vec<u8> = encode_args(args)
772            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
773        let result = self
774            .inner
775            .query_call(canister_id, Principal::anonymous(), method, bytes)
776            .map_err(|err| {
777                Error::internal(format!(
778                    "pocket_ic query_call failed (canister={canister_id}, method={method}): {err}"
779                ))
780            })?;
781
782        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
783    }
784
785    /// Generic query call helper with an explicit caller principal.
786    pub fn query_call_as<T, A>(
787        &self,
788        canister_id: Principal,
789        caller: Principal,
790        method: &str,
791        args: A,
792    ) -> Result<T, Error>
793    where
794        T: CandidType + DeserializeOwned,
795        A: ArgumentEncoder,
796    {
797        let bytes: Vec<u8> = encode_args(args)
798            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
799        let result = self
800            .inner
801            .query_call(canister_id, caller, method, bytes)
802            .map_err(|err| {
803                Error::internal(format!(
804                    "pocket_ic query_call failed (canister={canister_id}, method={method}): {err}"
805                ))
806            })?;
807
808        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
809    }
810
811    /// Advance PocketIC by a fixed number of ticks.
812    pub fn tick_n(&self, times: usize) {
813        for _ in 0..times {
814            self.tick();
815        }
816    }
817
818    // Install a canister after creating it and funding it with cycles.
819    fn try_create_funded_and_install(
820        &self,
821        wasm: Vec<u8>,
822        init_bytes: Vec<u8>,
823        install_cycles: u128,
824    ) -> Result<Principal, PicInstallError> {
825        let canister_id = self.create_canister();
826        self.add_cycles(canister_id, install_cycles);
827
828        let install = catch_unwind(AssertUnwindSafe(|| {
829            self.inner
830                .install_canister(canister_id, wasm, init_bytes, None);
831        }));
832        if let Err(payload) = install {
833            eprintln!("install_canister trapped for {canister_id}");
834            if let Ok(status) = self.inner.canister_status(canister_id, None) {
835                eprintln!("canister_status for {canister_id}: {status:?}");
836            }
837            if let Ok(logs) = self
838                .inner
839                .fetch_canister_logs(canister_id, Principal::anonymous())
840            {
841                for record in logs {
842                    eprintln!("canister_log {canister_id}: {record:?}");
843                }
844            }
845            return Err(PicInstallError::new(
846                canister_id,
847                panic_payload_to_string(payload.as_ref()),
848            ));
849        }
850
851        Ok(canister_id)
852    }
853
854    // Query `canic_ready` and panic with debug context on transport failures.
855    fn fetch_ready(&self, canister_id: Principal) -> bool {
856        match self.query_call(canister_id, protocol::CANIC_READY, ()) {
857            Ok(ready) => ready,
858            Err(err) => {
859                self.dump_canister_debug(canister_id, "query canic_ready failed");
860                panic!("query canic_ready failed: {err:?}");
861            }
862        }
863    }
864
865    // Capture one snapshot with sender fallbacks that match controller ownership.
866    fn try_take_controller_snapshot(
867        &self,
868        controller_id: Principal,
869        canister_id: Principal,
870    ) -> Option<ControllerSnapshot> {
871        let candidates = controller_sender_candidates(controller_id, canister_id);
872        let mut last_err = None;
873
874        for sender in candidates {
875            match self.take_canister_snapshot(canister_id, sender, None) {
876                Ok(snapshot) => {
877                    return Some(ControllerSnapshot {
878                        snapshot_id: snapshot.id,
879                        sender,
880                    });
881                }
882                Err(err) => last_err = Some((sender, err)),
883            }
884        }
885
886        if let Some((sender, err)) = last_err {
887            eprintln!(
888                "failed to capture canister snapshot for {canister_id} using sender {sender:?}: {err}"
889            );
890        }
891        None
892    }
893
894    // Restore one snapshot with sender fallbacks that match controller ownership.
895    fn restore_controller_snapshot(
896        &self,
897        controller_id: Principal,
898        canister_id: Principal,
899        snapshot: &ControllerSnapshot,
900    ) {
901        let fallback_sender = if snapshot.sender.is_some() {
902            None
903        } else {
904            Some(controller_id)
905        };
906        let candidates = [snapshot.sender, fallback_sender];
907        let mut last_err = None;
908
909        for sender in candidates {
910            match self.load_canister_snapshot(canister_id, sender, snapshot.snapshot_id.clone()) {
911                Ok(()) => return,
912                Err(err) => last_err = Some((sender, err)),
913            }
914        }
915
916        let (sender, err) =
917            last_err.expect("snapshot restore must have at least one sender attempt");
918        panic!(
919            "failed to restore canister snapshot for {canister_id} using sender {sender:?}: {err}"
920        );
921    }
922}
923
924fn is_install_code_rate_limited(message: &str) -> bool {
925    message.contains("CanisterInstallCodeRateLimited")
926}
927
928impl Drop for ProcessLockGuard {
929    fn drop(&mut self) {
930        let _ = fs::remove_dir_all(&self.path);
931    }
932}
933
934impl Drop for PicSerialGuard {
935    fn drop(&mut self) {
936        let mut state = PIC_PROCESS_LOCK_STATE
937            .lock()
938            .unwrap_or_else(std::sync::PoisonError::into_inner);
939
940        state.ref_count = state
941            .ref_count
942            .checked_sub(1)
943            .expect("PocketIC serial guard refcount underflow");
944        if state.ref_count == 0 {
945            state.process_lock.take();
946        }
947    }
948}
949
950impl Deref for Pic {
951    type Target = PocketIc;
952
953    fn deref(&self) -> &Self::Target {
954        &self.inner
955    }
956}
957
958impl DerefMut for Pic {
959    fn deref_mut(&mut self) -> &mut Self::Target {
960        &mut self.inner
961    }
962}
963
964/// --------------------------------------
965/// install_args helper
966/// --------------------------------------
967///
968/// Init semantics:
969/// - Root canisters receive a `SubnetIdentity` (direct root bootstrap).
970/// - Non-root canisters receive `EnvBootstrapArgs` + optional directory snapshots.
971///
972/// Directory handling:
973/// - By default, directory views are empty for standalone installs.
974/// - Directory-dependent logic is opt-in via `install_args_with_directories`.
975/// - Root-provisioned installs will populate directories via cascade.
976///
977
978fn install_args(role: CanisterRole) -> Result<Vec<u8>, Error> {
979    if role.is_root() {
980        install_root_args()
981    } else {
982        // Non-root standalone install.
983        // Provide only what is structurally known at install time.
984        let env = EnvBootstrapArgs {
985            prime_root_pid: None,
986            subnet_role: None,
987            subnet_pid: None,
988            root_pid: None,
989            canister_role: Some(role),
990            parent_pid: None,
991        };
992
993        // Intentional: standalone installs do not require directories unless
994        // a test explicitly exercises directory-dependent behavior.
995        let payload = CanisterInitPayload {
996            env,
997            app_directory: AppDirectoryArgs(Vec::new()),
998            subnet_directory: SubnetDirectoryArgs(Vec::new()),
999        };
1000
1001        encode_args::<(CanisterInitPayload, Option<Vec<u8>>)>((payload, None))
1002            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))
1003    }
1004}
1005
1006fn install_root_args() -> Result<Vec<u8>, Error> {
1007    encode_one(SubnetIdentity::Manual)
1008        .map_err(|err| Error::internal(format!("encode_one failed: {err}")))
1009}
1010
1011// Prefer the likely controller sender first to reduce noisy management-call failures.
1012fn controller_sender_candidates(
1013    controller_id: Principal,
1014    canister_id: Principal,
1015) -> [Option<Principal>; 2] {
1016    if canister_id == controller_id {
1017        [None, Some(controller_id)]
1018    } else {
1019        [Some(controller_id), None]
1020    }
1021}
1022
1023// Acquire the shared filesystem lock that serializes PocketIC usage per host.
1024fn acquire_process_lock() -> Result<ProcessLockGuard, PicSerialGuardError> {
1025    let lock_dir = process_lock_dir();
1026    ensure_process_lock_parent(&lock_dir)?;
1027    let started_waiting = Instant::now();
1028    let mut logged_wait = false;
1029
1030    loop {
1031        match fs::create_dir(&lock_dir) {
1032            Ok(()) => {
1033                if let Err(source) = fs::write(
1034                    process_lock_owner_path(&lock_dir),
1035                    render_process_lock_owner(),
1036                ) {
1037                    let _ = fs::remove_dir(&lock_dir);
1038                    return Err(PicSerialGuardError::LockOwnerRecordFailed {
1039                        path: lock_dir,
1040                        source,
1041                    });
1042                }
1043
1044                if logged_wait {
1045                    eprintln!(
1046                        "[canic_testkit::pic] acquired cross-process PocketIC lock at {}",
1047                        lock_dir.display()
1048                    );
1049                }
1050
1051                return Ok(ProcessLockGuard { path: lock_dir });
1052            }
1053            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
1054                if process_lock_is_stale(&lock_dir) && clear_stale_process_lock(&lock_dir).is_ok() {
1055                    continue;
1056                }
1057
1058                if !logged_wait && started_waiting.elapsed() >= PIC_PROCESS_LOCK_LOG_AFTER {
1059                    eprintln!(
1060                        "[canic_testkit::pic] waiting for cross-process PocketIC lock at {}",
1061                        lock_dir.display()
1062                    );
1063                    logged_wait = true;
1064                }
1065
1066                thread::sleep(PIC_PROCESS_LOCK_RETRY_DELAY);
1067            }
1068            Err(source) => {
1069                return Err(PicSerialGuardError::LockUnavailable {
1070                    path: lock_dir,
1071                    source,
1072                });
1073            }
1074        }
1075    }
1076}
1077
1078// Resolve the cross-process PocketIC lock path from the active temp root.
1079fn process_lock_dir() -> PathBuf {
1080    process_lock_dir_from_temp_root(&env::temp_dir())
1081}
1082
1083// Resolve the cross-process PocketIC lock path for one explicit temp root.
1084fn process_lock_dir_from_temp_root(temp_root: &Path) -> PathBuf {
1085    temp_root.join(PIC_PROCESS_LOCK_DIR_NAME)
1086}
1087
1088// Create the temp-root parent chain before trying to create the lock directory itself.
1089fn ensure_process_lock_parent(lock_dir: &Path) -> Result<(), PicSerialGuardError> {
1090    let parent = lock_dir.parent().unwrap_or_else(|| Path::new("."));
1091    fs::create_dir_all(parent).map_err(|source| PicSerialGuardError::LockParentUnavailable {
1092        path: parent.to_path_buf(),
1093        source,
1094    })
1095}
1096
1097// Extract a stable string message from one panic payload.
1098fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
1099    if let Some(message) = payload.downcast_ref::<String>() {
1100        return message.clone();
1101    }
1102    if let Some(message) = payload.downcast_ref::<&'static str>() {
1103        return (*message).to_string();
1104    }
1105
1106    "non-string panic payload".to_string()
1107}
1108
1109// Classify one PocketIC startup panic into a typed public error.
1110fn classify_pic_start_panic(payload: Box<dyn Any + Send>) -> PicStartError {
1111    let message = panic_payload_to_string(payload.as_ref());
1112
1113    if message.starts_with("Failed to validate PocketIC server binary") {
1114        if message.contains("No such file or directory") || message.contains("os error 2") {
1115            return PicStartError::BinaryUnavailable { message };
1116        }
1117
1118        return PicStartError::BinaryInvalid { message };
1119    }
1120
1121    if message.starts_with("Failed to download PocketIC server")
1122        || message.starts_with("Failed to write PocketIC server binary")
1123    {
1124        return PicStartError::DownloadFailed { message };
1125    }
1126
1127    if message.starts_with("Failed to start PocketIC binary")
1128        || message.starts_with("Failed to create PocketIC server directory")
1129    {
1130        return PicStartError::ServerStartFailed { message };
1131    }
1132
1133    if message.starts_with("Timed out waiting for PocketIC server being available") {
1134        return PicStartError::StartupTimedOut { message };
1135    }
1136
1137    PicStartError::Panic { message }
1138}
1139
1140fn process_lock_owner_path(lock_dir: &Path) -> PathBuf {
1141    lock_dir.join("owner")
1142}
1143
1144fn clear_stale_process_lock(lock_dir: &Path) -> io::Result<()> {
1145    match fs::remove_dir_all(lock_dir) {
1146        Ok(()) => Ok(()),
1147        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1148        Err(err) => Err(err),
1149    }
1150}
1151
1152fn process_lock_is_stale(lock_dir: &Path) -> bool {
1153    process_lock_is_stale_with_proc_root(lock_dir, Path::new("/proc"))
1154}
1155
1156fn process_lock_is_stale_with_proc_root(lock_dir: &Path, proc_root: &Path) -> bool {
1157    let Some(owner) = read_process_lock_owner(&process_lock_owner_path(lock_dir)) else {
1158        return true;
1159    };
1160
1161    let proc_dir = proc_root.join(owner.pid.to_string());
1162    if !proc_dir.exists() {
1163        return true;
1164    }
1165
1166    match owner.start_ticks {
1167        Some(expected_ticks) => {
1168            read_process_start_ticks(proc_root, owner.pid) != Some(expected_ticks)
1169        }
1170        None => false,
1171    }
1172}
1173
1174fn render_process_lock_owner() -> String {
1175    let owner = current_process_lock_owner();
1176    match owner.start_ticks {
1177        Some(start_ticks) => format!("pid={}\nstart_ticks={start_ticks}\n", owner.pid),
1178        None => format!("pid={}\n", owner.pid),
1179    }
1180}
1181
1182fn current_process_lock_owner() -> ProcessLockOwner {
1183    ProcessLockOwner {
1184        pid: process::id(),
1185        start_ticks: read_process_start_ticks(Path::new("/proc"), process::id()),
1186    }
1187}
1188
1189fn read_process_lock_owner(path: &Path) -> Option<ProcessLockOwner> {
1190    let text = fs::read_to_string(path).ok()?;
1191    parse_process_lock_owner(&text)
1192}
1193
1194fn parse_process_lock_owner(text: &str) -> Option<ProcessLockOwner> {
1195    let trimmed = text.trim();
1196    if trimmed.is_empty() {
1197        return None;
1198    }
1199
1200    if let Ok(pid) = trimmed.parse::<u32>() {
1201        return Some(ProcessLockOwner {
1202            pid,
1203            start_ticks: None,
1204        });
1205    }
1206
1207    let mut pid = None;
1208    let mut start_ticks = None;
1209    for line in trimmed.lines() {
1210        if let Some(value) = line.strip_prefix("pid=") {
1211            pid = value.trim().parse::<u32>().ok();
1212        } else if let Some(value) = line.strip_prefix("start_ticks=") {
1213            start_ticks = value.trim().parse::<u64>().ok();
1214        }
1215    }
1216
1217    Some(ProcessLockOwner {
1218        pid: pid?,
1219        start_ticks,
1220    })
1221}
1222
1223fn read_process_start_ticks(proc_root: &Path, pid: u32) -> Option<u64> {
1224    let stat_path = proc_root.join(pid.to_string()).join("stat");
1225    let stat = fs::read_to_string(stat_path).ok()?;
1226    let close_paren = stat.rfind(')')?;
1227    let rest = stat.get(close_paren + 2..)?;
1228    let fields = rest.split_whitespace().collect::<Vec<_>>();
1229    fields.get(19)?.parse::<u64>().ok()
1230}
1231
1232#[cfg(test)]
1233mod process_lock_tests {
1234    use super::{
1235        PicStartError, classify_pic_start_panic, clear_stale_process_lock,
1236        ensure_process_lock_parent, parse_process_lock_owner, process_lock_dir_from_temp_root,
1237        process_lock_is_stale_with_proc_root, process_lock_owner_path,
1238    };
1239    use std::{
1240        fs,
1241        path::PathBuf,
1242        time::{SystemTime, UNIX_EPOCH},
1243    };
1244
1245    fn unique_lock_dir() -> PathBuf {
1246        let nanos = SystemTime::now()
1247            .duration_since(UNIX_EPOCH)
1248            .expect("clock must be after unix epoch")
1249            .as_nanos();
1250        std::env::temp_dir().join(format!("canic-pocket-ic-test-lock-{nanos}"))
1251    }
1252
1253    #[test]
1254    fn stale_process_lock_is_detected_and_removed() {
1255        let lock_dir = unique_lock_dir();
1256        fs::create_dir(&lock_dir).expect("create lock dir");
1257        fs::write(process_lock_owner_path(&lock_dir), "999999").expect("write stale owner");
1258
1259        assert!(process_lock_is_stale_with_proc_root(
1260            &lock_dir,
1261            std::path::Path::new("/proc")
1262        ));
1263        clear_stale_process_lock(&lock_dir).expect("remove stale lock dir");
1264        assert!(!lock_dir.exists());
1265    }
1266
1267    #[test]
1268    fn owner_parser_accepts_legacy_pid_only_format() {
1269        let owner = parse_process_lock_owner("12345\n").expect("parse pid-only owner");
1270        assert_eq!(owner.pid, 12345);
1271        assert_eq!(owner.start_ticks, None);
1272    }
1273
1274    #[test]
1275    fn stale_process_lock_detects_pid_reuse_via_start_ticks() {
1276        let root = unique_lock_dir();
1277        let lock_dir = root.join("lock");
1278        let proc_root = root.join("proc");
1279        let proc_pid = proc_root.join("77");
1280        fs::create_dir_all(&lock_dir).expect("create lock dir");
1281        fs::create_dir_all(&proc_pid).expect("create proc pid dir");
1282        fs::write(
1283            process_lock_owner_path(&lock_dir),
1284            "pid=77\nstart_ticks=41\n",
1285        )
1286        .expect("write owner");
1287        fs::write(
1288            proc_pid.join("stat"),
1289            "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",
1290        )
1291        .expect("write proc stat");
1292
1293        assert!(process_lock_is_stale_with_proc_root(&lock_dir, &proc_root));
1294    }
1295
1296    #[test]
1297    fn ensure_process_lock_parent_creates_missing_temp_root_chain() {
1298        let root = unique_lock_dir();
1299        let temp_root = root.join("repo-local").join("tmp");
1300        let lock_dir = process_lock_dir_from_temp_root(&temp_root);
1301
1302        ensure_process_lock_parent(&lock_dir).expect("create temp-root parent chain");
1303
1304        assert!(temp_root.exists());
1305    }
1306
1307    #[test]
1308    fn pic_start_error_classifies_missing_binary() {
1309        let error = classify_pic_start_panic(Box::new(
1310            "Failed to validate PocketIC server binary `/tmp/pocket-ic`: `No such file or directory (os error 2)`.".to_string(),
1311        ));
1312
1313        assert!(matches!(error, PicStartError::BinaryUnavailable { .. }));
1314    }
1315
1316    #[test]
1317    fn pic_start_error_classifies_failed_spawn() {
1318        let error = classify_pic_start_panic(Box::new(
1319            "Failed to start PocketIC binary (/tmp/pocket-ic)".to_string(),
1320        ));
1321
1322        assert!(matches!(error, PicStartError::ServerStartFailed { .. }));
1323    }
1324}