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