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
59pub struct ControllerSnapshots(HashMap<Principal, ControllerSnapshot>);
64
65pub struct CachedPicBaseline<T> {
70 pub pic: Pic,
71 pub snapshots: ControllerSnapshots,
72 pub metadata: T,
73 _serial_guard: PicSerialGuard,
74}
75
76pub struct CachedPicBaselineGuard<'a, T> {
81 guard: MutexGuard<'a, Option<CachedPicBaseline<T>>>,
82}
83
84pub struct PicSerialGuard {
89 _private: (),
90}
91
92#[must_use]
101pub fn pic() -> Pic {
102 PicBuilder::new().with_application_subnet().build()
103}
104
105#[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
120pub 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
140pub struct PicBuilder(PocketIcBuilder);
151
152#[expect(clippy::new_without_default)]
153impl PicBuilder {
154 #[must_use]
156 pub fn new() -> Self {
157 Self(PocketIcBuilder::new())
158 }
159
160 #[must_use]
162 pub fn with_application_subnet(mut self) -> Self {
163 self.0 = self.0.with_application_subnet();
164 self
165 }
166
167 #[must_use]
169 pub fn with_nns_subnet(mut self) -> Self {
170 self.0 = self.0.with_nns_subnet();
171 self
172 }
173
174 #[must_use]
176 pub fn build(self) -> Pic {
177 Pic {
178 inner: self.0.build(),
179 }
180 }
181}
182
183pub 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 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 pub fn restore(&self, controller_id: Principal) {
237 self.pic
238 .restore_controller_snapshots(controller_id, &self.snapshots);
239 }
240}
241
242impl Pic {
243 #[must_use]
245 pub fn current_time_nanos(&self) -> u64 {
246 self.inner.get_time().as_nanos_since_unix_epoch()
247 }
248
249 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 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 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 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 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 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 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 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 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 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 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 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 pub fn tick_n(&self, times: usize) {
476 for _ in 0..times {
477 self.tick();
478 }
479 }
480
481 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 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 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 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
616fn install_args(role: CanisterRole) -> Result<Vec<u8>, Error> {
631 if role.is_root() {
632 install_root_args()
633 } else {
634 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 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
663fn 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}