Skip to main content

canic_testkit/pic/
mod.rs

1mod root;
2
3use candid::{CandidType, Principal, decode_one, encode_args, encode_one, utils::ArgumentEncoder};
4use canic::{
5    Error,
6    cdk::types::TC,
7    dto::{
8        abi::v1::CanisterInitPayload,
9        env::EnvBootstrapArgs,
10        subnet::SubnetIdentity,
11        topology::{AppDirectoryArgs, SubnetDirectoryArgs},
12    },
13    ids::CanisterRole,
14    protocol,
15};
16use pocket_ic::{PocketIc, PocketIcBuilder};
17use serde::de::DeserializeOwned;
18use std::{
19    collections::HashMap,
20    env, fs, io,
21    ops::{Deref, DerefMut},
22    panic::{AssertUnwindSafe, catch_unwind},
23    path::{Path, PathBuf},
24    process,
25    sync::{Mutex, MutexGuard},
26    thread,
27    time::{Duration, Instant},
28};
29
30pub use root::{
31    RootBaselineMetadata, RootBaselineSpec, build_root_cached_baseline,
32    ensure_root_release_artifacts_built, load_root_wasm, restore_root_cached_baseline,
33    setup_root_topology,
34};
35
36const INSTALL_CYCLES: u128 = 500 * TC;
37const PIC_PROCESS_LOCK_DIR_NAME: &str = "canic-pocket-ic.lock";
38const PIC_PROCESS_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
39const PIC_PROCESS_LOCK_LOG_AFTER: Duration = Duration::from_secs(1);
40
41struct ControllerSnapshot {
42    snapshot_id: Vec<u8>,
43    sender: Option<Principal>,
44}
45
46struct ProcessLockGuard {
47    path: PathBuf,
48}
49
50///
51/// ControllerSnapshots
52///
53
54pub struct ControllerSnapshots(HashMap<Principal, ControllerSnapshot>);
55
56///
57/// CachedPicBaseline
58///
59
60pub struct CachedPicBaseline<T> {
61    pub pic: Pic,
62    pub snapshots: ControllerSnapshots,
63    pub metadata: T,
64}
65
66///
67/// CachedPicBaselineGuard
68///
69
70pub struct CachedPicBaselineGuard<'a, T> {
71    guard: MutexGuard<'a, Option<CachedPicBaseline<T>>>,
72}
73
74///
75/// PicSerialGuard
76///
77
78pub struct PicSerialGuard {
79    _process_lock: ProcessLockGuard,
80}
81
82///
83/// Create a fresh PocketIC universe.
84///
85/// IMPORTANT:
86/// - Each call creates a new IC instance
87/// - WARNING: callers must hold a `PicSerialGuard` for the full `Pic` lifetime
88/// - Required to avoid PocketIC wasm chunk store exhaustion
89///
90#[must_use]
91pub fn pic() -> Pic {
92    PicBuilder::new().with_application_subnet().build()
93}
94
95/// Acquire the shared PocketIC serialization guard for the current process.
96#[must_use]
97pub fn acquire_pic_serial_guard() -> PicSerialGuard {
98    PicSerialGuard {
99        _process_lock: acquire_process_lock(),
100    }
101}
102
103/// Acquire one process-local cached PocketIC baseline, building it on first use.
104pub fn acquire_cached_pic_baseline<T, F>(
105    slot: &'static Mutex<Option<CachedPicBaseline<T>>>,
106    build: F,
107) -> (CachedPicBaselineGuard<'static, T>, bool)
108where
109    F: FnOnce() -> CachedPicBaseline<T>,
110{
111    let mut guard = slot
112        .lock()
113        .unwrap_or_else(std::sync::PoisonError::into_inner);
114    let cache_hit = guard.is_some();
115
116    if !cache_hit {
117        *guard = Some(build());
118    }
119
120    (CachedPicBaselineGuard { guard }, cache_hit)
121}
122
123///
124/// PicBuilder
125/// Thin wrapper around the PocketIC builder.
126///
127/// This builder is only used to configure the singleton. It does not create
128/// additional IC instances beyond the global `Pic`.
129///
130/// Note: this file is test-only infrastructure; simplicity wins over abstraction.
131///
132
133pub struct PicBuilder(PocketIcBuilder);
134
135#[expect(clippy::new_without_default)]
136impl PicBuilder {
137    /// Start a new PicBuilder with sensible defaults.
138    #[must_use]
139    pub fn new() -> Self {
140        Self(PocketIcBuilder::new())
141    }
142
143    /// Include an application subnet in the PocketIC universe.
144    #[must_use]
145    pub fn with_application_subnet(mut self) -> Self {
146        self.0 = self.0.with_application_subnet();
147        self
148    }
149
150    /// Include an NNS subnet in the PocketIC universe.
151    #[must_use]
152    pub fn with_nns_subnet(mut self) -> Self {
153        self.0 = self.0.with_nns_subnet();
154        self
155    }
156
157    /// Finish building the PocketIC instance and wrap it.
158    #[must_use]
159    pub fn build(self) -> Pic {
160        Pic {
161            inner: self.0.build(),
162        }
163    }
164}
165
166///
167/// Pic
168/// Thin wrapper around a PocketIC instance.
169///
170/// This type intentionally exposes only a minimal API surface; callers should
171/// use `pic()` to obtain an instance and then perform installs/calls.
172/// Callers must hold a `PicSerialGuard` for the full `Pic` lifetime.
173///
174
175pub struct Pic {
176    inner: PocketIc,
177}
178
179impl<T> Deref for CachedPicBaselineGuard<'_, T> {
180    type Target = CachedPicBaseline<T>;
181
182    fn deref(&self) -> &Self::Target {
183        self.guard
184            .as_ref()
185            .expect("cached PocketIC baseline must exist")
186    }
187}
188
189impl<T> DerefMut for CachedPicBaselineGuard<'_, T> {
190    fn deref_mut(&mut self) -> &mut Self::Target {
191        self.guard
192            .as_mut()
193            .expect("cached PocketIC baseline must exist")
194    }
195}
196
197impl<T> CachedPicBaseline<T> {
198    /// Capture one immutable cached baseline from the current PocketIC instance.
199    pub fn capture<I>(
200        pic: Pic,
201        controller_id: Principal,
202        canister_ids: I,
203        metadata: T,
204    ) -> Option<Self>
205    where
206        I: IntoIterator<Item = Principal>,
207    {
208        let snapshots = pic.capture_controller_snapshots(controller_id, canister_ids)?;
209
210        Some(Self {
211            pic,
212            snapshots,
213            metadata,
214        })
215    }
216
217    /// Restore the captured snapshot set back into the owned PocketIC instance.
218    pub fn restore(&self, controller_id: Principal) {
219        self.pic
220            .restore_controller_snapshots(controller_id, &self.snapshots);
221    }
222}
223
224impl Pic {
225    /// Capture the current PocketIC wall-clock time as nanoseconds since epoch.
226    #[must_use]
227    pub fn current_time_nanos(&self) -> u64 {
228        self.inner.get_time().as_nanos_since_unix_epoch()
229    }
230
231    /// Restore PocketIC wall-clock and certified time from a captured nanosecond value.
232    pub fn restore_time_nanos(&self, nanos_since_epoch: u64) {
233        let restored = pocket_ic::Time::from_nanos_since_unix_epoch(nanos_since_epoch);
234        self.inner.set_time(restored);
235        self.inner.set_certified_time(restored);
236    }
237
238    /// Install a root canister with the default root init arguments.
239    pub fn create_and_install_root_canister(&self, wasm: Vec<u8>) -> Result<Principal, Error> {
240        let init_bytes = install_root_args()?;
241
242        Ok(self.create_funded_and_install(wasm, init_bytes))
243    }
244
245    /// Install a canister with the given type and wasm bytes.
246    ///
247    /// Install failures are treated as fatal in tests.
248    pub fn create_and_install_canister(
249        &self,
250        role: CanisterRole,
251        wasm: Vec<u8>,
252    ) -> Result<Principal, Error> {
253        let init_bytes = install_args(role)?;
254
255        Ok(self.create_funded_and_install(wasm, init_bytes))
256    }
257
258    /// Wait until one canister reports `canic_ready`.
259    pub fn wait_for_ready(&self, canister_id: Principal, tick_limit: usize, context: &str) {
260        for _ in 0..tick_limit {
261            self.tick();
262            if self.fetch_ready(canister_id) {
263                return;
264            }
265        }
266
267        self.dump_canister_debug(canister_id, context);
268        panic!("{context}: canister {canister_id} did not become ready after {tick_limit} ticks");
269    }
270
271    /// Wait until all provided canisters report `canic_ready`.
272    pub fn wait_for_all_ready<I>(&self, canister_ids: I, tick_limit: usize, context: &str)
273    where
274        I: IntoIterator<Item = Principal>,
275    {
276        let canister_ids = canister_ids.into_iter().collect::<Vec<_>>();
277
278        for _ in 0..tick_limit {
279            self.tick();
280            if canister_ids
281                .iter()
282                .copied()
283                .all(|canister_id| self.fetch_ready(canister_id))
284            {
285                return;
286            }
287        }
288
289        for canister_id in &canister_ids {
290            self.dump_canister_debug(*canister_id, context);
291        }
292        panic!("{context}: canisters did not become ready after {tick_limit} ticks");
293    }
294
295    /// Dump basic PocketIC status and log context for one canister.
296    pub fn dump_canister_debug(&self, canister_id: Principal, context: &str) {
297        eprintln!("{context}: debug for canister {canister_id}");
298
299        match self.canister_status(canister_id, None) {
300            Ok(status) => eprintln!("canister_status: {status:?}"),
301            Err(err) => eprintln!("canister_status failed: {err:?}"),
302        }
303
304        match self.fetch_canister_logs(canister_id, Principal::anonymous()) {
305            Ok(records) => {
306                if records.is_empty() {
307                    eprintln!("canister logs: <empty>");
308                } else {
309                    for record in records {
310                        eprintln!("canister log: {record:?}");
311                    }
312                }
313            }
314            Err(err) => eprintln!("fetch_canister_logs failed: {err:?}"),
315        }
316    }
317
318    /// Capture one restorable snapshot per canister using a shared controller.
319    pub fn capture_controller_snapshots<I>(
320        &self,
321        controller_id: Principal,
322        canister_ids: I,
323    ) -> Option<ControllerSnapshots>
324    where
325        I: IntoIterator<Item = Principal>,
326    {
327        let mut snapshots = HashMap::new();
328
329        for canister_id in canister_ids {
330            let Some(snapshot) = self.try_take_controller_snapshot(controller_id, canister_id)
331            else {
332                eprintln!(
333                    "capture_controller_snapshots: snapshot capture unavailable for {canister_id}"
334                );
335                return None;
336            };
337            snapshots.insert(canister_id, snapshot);
338        }
339
340        Some(ControllerSnapshots(snapshots))
341    }
342
343    /// Restore a previously captured snapshot set using the same controller.
344    pub fn restore_controller_snapshots(
345        &self,
346        controller_id: Principal,
347        snapshots: &ControllerSnapshots,
348    ) {
349        for (canister_id, snapshot) in &snapshots.0 {
350            self.restore_controller_snapshot(controller_id, *canister_id, snapshot);
351        }
352    }
353
354    /// Generic update call helper (serializes args + decodes result).
355    pub fn update_call<T, A>(
356        &self,
357        canister_id: Principal,
358        method: &str,
359        args: A,
360    ) -> Result<T, Error>
361    where
362        T: CandidType + DeserializeOwned,
363        A: ArgumentEncoder,
364    {
365        let bytes: Vec<u8> = encode_args(args)
366            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
367        let result = self
368            .inner
369            .update_call(canister_id, Principal::anonymous(), method, bytes)
370            .map_err(|err| {
371                Error::internal(format!(
372                    "pocket_ic update_call failed (canister={canister_id}, method={method}): {err}"
373                ))
374            })?;
375
376        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
377    }
378
379    /// Generic update call helper with an explicit caller principal.
380    pub fn update_call_as<T, A>(
381        &self,
382        canister_id: Principal,
383        caller: Principal,
384        method: &str,
385        args: A,
386    ) -> Result<T, Error>
387    where
388        T: CandidType + DeserializeOwned,
389        A: ArgumentEncoder,
390    {
391        let bytes: Vec<u8> = encode_args(args)
392            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
393        let result = self
394            .inner
395            .update_call(canister_id, caller, method, bytes)
396            .map_err(|err| {
397                Error::internal(format!(
398                    "pocket_ic update_call failed (canister={canister_id}, method={method}): {err}"
399                ))
400            })?;
401
402        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
403    }
404
405    /// Generic query call helper.
406    pub fn query_call<T, A>(
407        &self,
408        canister_id: Principal,
409        method: &str,
410        args: A,
411    ) -> Result<T, Error>
412    where
413        T: CandidType + DeserializeOwned,
414        A: ArgumentEncoder,
415    {
416        let bytes: Vec<u8> = encode_args(args)
417            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
418        let result = self
419            .inner
420            .query_call(canister_id, Principal::anonymous(), method, bytes)
421            .map_err(|err| {
422                Error::internal(format!(
423                    "pocket_ic query_call failed (canister={canister_id}, method={method}): {err}"
424                ))
425            })?;
426
427        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
428    }
429
430    /// Generic query call helper with an explicit caller principal.
431    pub fn query_call_as<T, A>(
432        &self,
433        canister_id: Principal,
434        caller: Principal,
435        method: &str,
436        args: A,
437    ) -> Result<T, Error>
438    where
439        T: CandidType + DeserializeOwned,
440        A: ArgumentEncoder,
441    {
442        let bytes: Vec<u8> = encode_args(args)
443            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))?;
444        let result = self
445            .inner
446            .query_call(canister_id, caller, method, bytes)
447            .map_err(|err| {
448                Error::internal(format!(
449                    "pocket_ic query_call failed (canister={canister_id}, method={method}): {err}"
450                ))
451            })?;
452
453        decode_one(&result).map_err(|err| Error::internal(format!("decode_one failed: {err}")))
454    }
455
456    /// Advance PocketIC by a fixed number of ticks.
457    pub fn tick_n(&self, times: usize) {
458        for _ in 0..times {
459            self.tick();
460        }
461    }
462
463    // Install a canister after creating it and funding it with cycles.
464    fn create_funded_and_install(&self, wasm: Vec<u8>, init_bytes: Vec<u8>) -> Principal {
465        let canister_id = self.create_canister();
466        self.add_cycles(canister_id, INSTALL_CYCLES);
467
468        let install = catch_unwind(AssertUnwindSafe(|| {
469            self.inner
470                .install_canister(canister_id, wasm, init_bytes, None);
471        }));
472        if let Err(err) = install {
473            eprintln!("install_canister trapped for {canister_id}");
474            if let Ok(status) = self.inner.canister_status(canister_id, None) {
475                eprintln!("canister_status for {canister_id}: {status:?}");
476            }
477            if let Ok(logs) = self
478                .inner
479                .fetch_canister_logs(canister_id, Principal::anonymous())
480            {
481                for record in logs {
482                    eprintln!("canister_log {canister_id}: {record:?}");
483                }
484            }
485            std::panic::resume_unwind(err);
486        }
487
488        canister_id
489    }
490
491    // Query `canic_ready` and panic with debug context on transport failures.
492    fn fetch_ready(&self, canister_id: Principal) -> bool {
493        match self.query_call(canister_id, protocol::CANIC_READY, ()) {
494            Ok(ready) => ready,
495            Err(err) => {
496                self.dump_canister_debug(canister_id, "query canic_ready failed");
497                panic!("query canic_ready failed: {err:?}");
498            }
499        }
500    }
501
502    // Capture one snapshot with sender fallbacks that match controller ownership.
503    fn try_take_controller_snapshot(
504        &self,
505        controller_id: Principal,
506        canister_id: Principal,
507    ) -> Option<ControllerSnapshot> {
508        let candidates = controller_sender_candidates(controller_id, canister_id);
509        let mut last_err = None;
510
511        for sender in candidates {
512            match self.take_canister_snapshot(canister_id, sender, None) {
513                Ok(snapshot) => {
514                    return Some(ControllerSnapshot {
515                        snapshot_id: snapshot.id,
516                        sender,
517                    });
518                }
519                Err(err) => last_err = Some((sender, err)),
520            }
521        }
522
523        if let Some((sender, err)) = last_err {
524            eprintln!(
525                "failed to capture canister snapshot for {canister_id} using sender {sender:?}: {err}"
526            );
527        }
528        None
529    }
530
531    // Restore one snapshot with sender fallbacks that match controller ownership.
532    fn restore_controller_snapshot(
533        &self,
534        controller_id: Principal,
535        canister_id: Principal,
536        snapshot: &ControllerSnapshot,
537    ) {
538        let fallback_sender = if snapshot.sender.is_some() {
539            None
540        } else {
541            Some(controller_id)
542        };
543        let candidates = [snapshot.sender, fallback_sender];
544        let mut last_err = None;
545
546        for sender in candidates {
547            match self.load_canister_snapshot(canister_id, sender, snapshot.snapshot_id.clone()) {
548                Ok(()) => return,
549                Err(err) => last_err = Some((sender, err)),
550            }
551        }
552
553        let (sender, err) =
554            last_err.expect("snapshot restore must have at least one sender attempt");
555        panic!(
556            "failed to restore canister snapshot for {canister_id} using sender {sender:?}: {err}"
557        );
558    }
559}
560
561impl Drop for ProcessLockGuard {
562    fn drop(&mut self) {
563        let _ = fs::remove_file(process_lock_owner_path(&self.path));
564        let _ = fs::remove_dir(&self.path);
565    }
566}
567
568impl Deref for Pic {
569    type Target = PocketIc;
570
571    fn deref(&self) -> &Self::Target {
572        &self.inner
573    }
574}
575
576impl DerefMut for Pic {
577    fn deref_mut(&mut self) -> &mut Self::Target {
578        &mut self.inner
579    }
580}
581
582/// --------------------------------------
583/// install_args helper
584/// --------------------------------------
585///
586/// Init semantics:
587/// - Root canisters receive a `SubnetIdentity` (direct root bootstrap).
588/// - Non-root canisters receive `EnvBootstrapArgs` + optional directory snapshots.
589///
590/// Directory handling:
591/// - By default, directory views are empty for standalone installs.
592/// - Directory-dependent logic is opt-in via `install_args_with_directories`.
593/// - Root-provisioned installs will populate directories via cascade.
594///
595
596fn install_args(role: CanisterRole) -> Result<Vec<u8>, Error> {
597    if role.is_root() {
598        install_root_args()
599    } else {
600        // Non-root standalone install.
601        // Provide only what is structurally known at install time.
602        let env = EnvBootstrapArgs {
603            prime_root_pid: None,
604            subnet_role: None,
605            subnet_pid: None,
606            root_pid: None,
607            canister_role: Some(role),
608            parent_pid: None,
609        };
610
611        // Intentional: standalone installs do not require directories unless
612        // a test explicitly exercises directory-dependent behavior.
613        let payload = CanisterInitPayload {
614            env,
615            app_directory: AppDirectoryArgs(Vec::new()),
616            subnet_directory: SubnetDirectoryArgs(Vec::new()),
617        };
618
619        encode_args::<(CanisterInitPayload, Option<Vec<u8>>)>((payload, None))
620            .map_err(|err| Error::internal(format!("encode_args failed: {err}")))
621    }
622}
623
624fn install_root_args() -> Result<Vec<u8>, Error> {
625    encode_one(SubnetIdentity::Manual)
626        .map_err(|err| Error::internal(format!("encode_one failed: {err}")))
627}
628
629// Prefer the likely controller sender first to reduce noisy management-call failures.
630fn controller_sender_candidates(
631    controller_id: Principal,
632    canister_id: Principal,
633) -> [Option<Principal>; 2] {
634    if canister_id == controller_id {
635        [None, Some(controller_id)]
636    } else {
637        [Some(controller_id), None]
638    }
639}
640
641fn acquire_process_lock() -> ProcessLockGuard {
642    let lock_dir = env::temp_dir().join(PIC_PROCESS_LOCK_DIR_NAME);
643    let started_waiting = Instant::now();
644    let mut logged_wait = false;
645
646    loop {
647        match fs::create_dir(&lock_dir) {
648            Ok(()) => {
649                fs::write(
650                    process_lock_owner_path(&lock_dir),
651                    process::id().to_string(),
652                )
653                .unwrap_or_else(|err| {
654                    let _ = fs::remove_dir(&lock_dir);
655                    panic!(
656                        "failed to record PocketIC process lock owner at {}: {err}",
657                        lock_dir.display()
658                    );
659                });
660
661                if logged_wait {
662                    eprintln!(
663                        "[canic_testkit::pic] acquired cross-process PocketIC lock at {}",
664                        lock_dir.display()
665                    );
666                }
667
668                return ProcessLockGuard { path: lock_dir };
669            }
670            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
671                if process_lock_is_stale(&lock_dir) {
672                    let _ = fs::remove_file(process_lock_owner_path(&lock_dir));
673                    let _ = fs::remove_dir(&lock_dir);
674                    continue;
675                }
676
677                if !logged_wait && started_waiting.elapsed() >= PIC_PROCESS_LOCK_LOG_AFTER {
678                    eprintln!(
679                        "[canic_testkit::pic] waiting for cross-process PocketIC lock at {}",
680                        lock_dir.display()
681                    );
682                    logged_wait = true;
683                }
684
685                thread::sleep(PIC_PROCESS_LOCK_RETRY_DELAY);
686            }
687            Err(err) => panic!(
688                "failed to create PocketIC process lock dir at {}: {err}",
689                lock_dir.display()
690            ),
691        }
692    }
693}
694
695fn process_lock_owner_path(lock_dir: &Path) -> PathBuf {
696    lock_dir.join("owner")
697}
698
699fn process_lock_is_stale(lock_dir: &Path) -> bool {
700    let Ok(pid_text) = fs::read_to_string(process_lock_owner_path(lock_dir)) else {
701        return true;
702    };
703    let Ok(pid) = pid_text.trim().parse::<u32>() else {
704        return true;
705    };
706
707    !Path::new("/proc").join(pid.to_string()).exists()
708}