Skip to main content

ic_testkit/pic/
calls.rs

1use candid::{
2    CandidType, Principal, decode_args, decode_one, encode_args,
3    utils::{ArgumentDecoder, ArgumentEncoder},
4};
5use pocket_ic::{PocketIc, RejectResponse};
6use serde::de::DeserializeOwned;
7use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
8
9use super::{CandidCallContext, CandidCallError, PocketIcOperationError, transport};
10
11#[derive(Clone, Copy)]
12struct CallContext<'a> {
13    operation: &'static str,
14    canister_id: Principal,
15    caller: Principal,
16    method: &'a str,
17}
18
19impl CallContext<'_> {
20    fn to_error_context(self) -> CandidCallContext {
21        CandidCallContext::new(self.operation, self.canister_id, self.caller, self.method)
22    }
23}
24
25/// Typed Candid calls with contextual encoding, rejection, and decoding errors.
26pub trait CandidCallExt {
27    /// Encode and execute an anonymous update call, then decode its result.
28    fn update_candid<T, A>(
29        &self,
30        canister_id: Principal,
31        method: &str,
32        args: A,
33    ) -> Result<T, CandidCallError>
34    where
35        T: CandidType + DeserializeOwned,
36        A: ArgumentEncoder,
37    {
38        self.update_candid_as(canister_id, Principal::anonymous(), method, args)
39    }
40
41    /// Encode and execute an update call as an explicit caller.
42    fn update_candid_as<T, A>(
43        &self,
44        canister_id: Principal,
45        caller: Principal,
46        method: &str,
47        args: A,
48    ) -> Result<T, CandidCallError>
49    where
50        T: CandidType + DeserializeOwned,
51        A: ArgumentEncoder;
52
53    /// Encode and execute an anonymous query call, then decode its result.
54    fn query_candid<T, A>(
55        &self,
56        canister_id: Principal,
57        method: &str,
58        args: A,
59    ) -> Result<T, CandidCallError>
60    where
61        T: CandidType + DeserializeOwned,
62        A: ArgumentEncoder,
63    {
64        self.query_candid_as(canister_id, Principal::anonymous(), method, args)
65    }
66
67    /// Encode and execute a query call as an explicit caller.
68    fn query_candid_as<T, A>(
69        &self,
70        canister_id: Principal,
71        caller: Principal,
72        method: &str,
73        args: A,
74    ) -> Result<T, CandidCallError>
75    where
76        T: CandidType + DeserializeOwned,
77        A: ArgumentEncoder;
78}
79
80impl CandidCallExt for PocketIc {
81    /// Generic update call helper with an explicit caller principal.
82    fn update_candid_as<T, A>(
83        &self,
84        canister_id: Principal,
85        caller: Principal,
86        method: &str,
87        args: A,
88    ) -> Result<T, CandidCallError>
89    where
90        T: CandidType + DeserializeOwned,
91        A: ArgumentEncoder,
92    {
93        let context = CallContext {
94            operation: "update_call",
95            canister_id,
96            caller,
97            method,
98        };
99        call_candid(
100            context,
101            args,
102            |bytes| Self::update_call(self, canister_id, caller, method, bytes),
103            |bytes| decode_one(bytes),
104        )
105    }
106
107    /// Generic query call helper with an explicit caller principal.
108    fn query_candid_as<T, A>(
109        &self,
110        canister_id: Principal,
111        caller: Principal,
112        method: &str,
113        args: A,
114    ) -> Result<T, CandidCallError>
115    where
116        T: CandidType + DeserializeOwned,
117        A: ArgumentEncoder,
118    {
119        let context = CallContext {
120            operation: "query_call",
121            canister_id,
122            caller,
123            method,
124        };
125        call_candid(
126            context,
127            args,
128            |bytes| Self::query_call(self, canister_id, caller, method, bytes),
129            |bytes| decode_one(bytes),
130        )
131    }
132}
133
134/// Execute an update as `caller`, decoding the top-level Candid reply arguments.
135///
136/// Unlike [`CandidCallExt::update_candid_as`], `(u64, bool)` means two reply
137/// arguments rather than one tuple-valued reply. Use `()` for no replies or
138/// `(T,)` for one reply. Pass [`Principal::anonymous()`] for an anonymous caller.
139/// Application-level `Result<T, E>` values are returned without unwrapping.
140pub fn update_candid_args<T, A>(
141    pic: &PocketIc,
142    canister_id: Principal,
143    caller: Principal,
144    method: &str,
145    args: A,
146) -> Result<T, CandidCallError>
147where
148    T: for<'de> ArgumentDecoder<'de>,
149    A: ArgumentEncoder,
150{
151    let context = CallContext {
152        operation: "update_call",
153        canister_id,
154        caller,
155        method,
156    };
157    call_candid(
158        context,
159        args,
160        |bytes| pic.update_call(canister_id, caller, method, bytes),
161        |bytes| decode_args(bytes),
162    )
163}
164
165/// Execute a query as `caller`, decoding the top-level Candid reply arguments.
166///
167/// Unlike [`CandidCallExt::query_candid_as`], `(u64, bool)` means two reply
168/// arguments rather than one tuple-valued reply. Use `()` for no replies or
169/// `(T,)` for one reply. Pass [`Principal::anonymous()`] for an anonymous caller.
170/// Application-level `Result<T, E>` values are returned without unwrapping.
171pub fn query_candid_args<T, A>(
172    pic: &PocketIc,
173    canister_id: Principal,
174    caller: Principal,
175    method: &str,
176    args: A,
177) -> Result<T, CandidCallError>
178where
179    T: for<'de> ArgumentDecoder<'de>,
180    A: ArgumentEncoder,
181{
182    let context = CallContext {
183        operation: "query_call",
184        canister_id,
185        caller,
186        method,
187    };
188    call_candid(
189        context,
190        args,
191        |bytes| pic.query_call(canister_id, caller, method, bytes),
192        |bytes| decode_args(bytes),
193    )
194}
195
196fn call_candid<T, A, F, D>(
197    context: CallContext<'_>,
198    args: A,
199    call: F,
200    decode: D,
201) -> Result<T, CandidCallError>
202where
203    A: ArgumentEncoder,
204    F: FnOnce(Vec<u8>) -> Result<Vec<u8>, RejectResponse>,
205    D: FnOnce(&[u8]) -> Result<T, candid::Error>,
206{
207    let bytes = encode_args(args).map_err(|err| CandidCallError::Encode {
208        context: Box::new(context.to_error_context()),
209        message: err.to_string(),
210    })?;
211    let result = run_raw_call(context, || call(bytes))?;
212    decode(&result).map_err(|err| CandidCallError::Decode {
213        context: Box::new(context.to_error_context()),
214        bytes: result.len(),
215        message: err.to_string(),
216    })
217}
218
219fn run_raw_call<F>(context: CallContext<'_>, call: F) -> Result<Vec<u8>, CandidCallError>
220where
221    F: FnOnce() -> Result<Vec<u8>, RejectResponse>,
222{
223    match catch_unwind(AssertUnwindSafe(call)) {
224        Ok(Ok(bytes)) => Ok(bytes),
225        Ok(Err(response)) => Err(CandidCallError::CanisterReject {
226            context: Box::new(context.to_error_context()),
227            response: Box::new(response),
228        }),
229        Err(payload) => {
230            let source =
231                PocketIcOperationError::new(transport::panic_payload_to_string(payload.as_ref()));
232            if source.is_transport() {
233                Err(CandidCallError::Transport {
234                    context: Box::new(context.to_error_context()),
235                    source,
236                })
237            } else {
238                resume_unwind(payload)
239            }
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use candid::Principal;
247
248    use super::{CallContext, CandidCallError, call_candid, run_raw_call};
249
250    #[test]
251    fn decode_error_includes_call_context() {
252        let context = CallContext {
253            operation: "query_call",
254            canister_id: Principal::anonymous(),
255            caller: Principal::management_canister(),
256            method: "get",
257        };
258
259        let err = call_candid(
260            context,
261            (),
262            |_| Ok(vec![0xde, 0xad]),
263            |bytes| candid::decode_one::<u64>(bytes),
264        )
265        .expect_err("decode fails");
266
267        assert!(err.to_string().contains("candid decode failed"));
268        assert!(err.to_string().contains("operation=query_call"));
269        assert!(err.to_string().contains("method=get"));
270        assert!(err.to_string().contains("bytes=2"));
271        let CandidCallError::Decode { context, bytes, .. } = err else {
272            panic!("expected decode failure");
273        };
274        assert_eq!(bytes, 2);
275        assert_eq!(context.method, "get");
276    }
277
278    #[test]
279    fn reply_arguments_preserve_top_level_arity_and_wide_values() {
280        let context = CallContext {
281            operation: "update_call",
282            canister_id: Principal::anonymous(),
283            caller: Principal::management_canister(),
284            method: "measure",
285        };
286        let values = (u128::MAX, u128::from(u64::MAX) + 1);
287        let bytes = candid::encode_args(values).unwrap();
288        assert_eq!(candid::decode_args::<(u128, u128)>(&bytes).unwrap(), values);
289        assert!(candid::decode_one::<(u128, u128)>(&bytes).is_err());
290        let tuple = candid::encode_one(values).unwrap();
291        assert!(candid::decode_args::<(u128, u128)>(&tuple).is_err());
292        assert_eq!(candid::decode_one::<(u128, u128)>(&tuple).unwrap(), values);
293        let no_replies = candid::encode_args(()).unwrap();
294        candid::decode_args::<()>(&no_replies).unwrap();
295        let one_reply = candid::encode_args((7_u64,)).unwrap();
296        assert_eq!(candid::decode_args::<(u64,)>(&one_reply).unwrap(), (7,));
297        let result: (u64, bool, String) = call_candid(
298            context,
299            (42_u64, false),
300            |args| {
301                assert_eq!(
302                    candid::decode_args::<(u64, bool)>(&args).unwrap(),
303                    (42, false)
304                );
305                Ok(candid::encode_args((9_u64, true, "ready")).unwrap())
306            },
307            |bytes| candid::decode_args(bytes),
308        )
309        .unwrap();
310        assert_eq!(result, (9, true, "ready".into()));
311    }
312
313    #[test]
314    fn invalid_reply_arguments_keep_decode_context() {
315        let context = CallContext {
316            operation: "query_call",
317            canister_id: Principal::anonymous(),
318            caller: Principal::management_canister(),
319            method: "measure",
320        };
321        for bytes in [vec![0xde, 0xad], candid::encode_args((7_u64,)).unwrap()] {
322            let length = bytes.len();
323            let err = call_candid(
324                context,
325                (),
326                |_| Ok(bytes),
327                |bytes| candid::decode_args::<(u64, bool)>(bytes),
328            )
329            .unwrap_err();
330            assert!(err.to_string().contains(&format!("bytes={length}")));
331            let CandidCallError::Decode {
332                context: observed,
333                bytes,
334                ..
335            } = err
336            else {
337                panic!("expected decode failure");
338            };
339            assert_eq!(bytes, length);
340            assert_eq!(observed.operation, "query_call");
341            assert_eq!(observed.canister_id, context.canister_id);
342            assert_eq!(observed.caller, context.caller);
343            assert_eq!(observed.method, "measure");
344        }
345    }
346
347    #[test]
348    fn dead_instance_panic_is_classified_as_transport() {
349        let context = CallContext {
350            operation: "query_call",
351            canister_id: Principal::anonymous(),
352            caller: Principal::management_canister(),
353            method: "get",
354        };
355        let error = run_raw_call(context, || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
356            panic!("HTTP failure: reqwest::Error {{ kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/execute_ingress_message\", source: ConnectError(\"tcp connect error\", Os {{ code: 111, kind: ConnectionRefused, message: \"Connection refused\" }}) }}");
357        })
358        .unwrap_err();
359
360        let source = std::error::Error::source(&error)
361            .unwrap()
362            .downcast_ref::<super::PocketIcOperationError>()
363            .unwrap();
364        assert!(source.is_transport());
365        assert!(source.message().starts_with("HTTP failure: "));
366        assert!(error.to_string().contains("operation=query_call"));
367        assert!(super::super::is_dead_pocket_ic_transport_error(&error));
368        let CandidCallError::Transport { source: owned, .. } = error else {
369            panic!("expected transport failure");
370        };
371        assert!(owned.is_transport());
372    }
373
374    #[test]
375    fn unrelated_panics_resume_with_the_original_payload() {
376        for message in [
377            "application worker channel closed",
378            "fixture expected ConnectionRefused but observed another result",
379        ] {
380            let context = CallContext {
381                operation: "query_call",
382                canister_id: Principal::anonymous(),
383                caller: Principal::management_canister(),
384                method: "get",
385            };
386            let payload = message.to_owned();
387            let original_allocation = payload.as_ptr() as usize;
388            let result = std::panic::catch_unwind(|| {
389                run_raw_call(context, || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
390                    std::panic::panic_any(payload);
391                })
392            });
393            let resumed = result
394                .expect_err("application panic must resume")
395                .downcast::<String>()
396                .expect("original panic type must be preserved");
397            assert_eq!(resumed.as_str(), message);
398            assert_eq!(resumed.as_ptr() as usize, original_allocation);
399        }
400        let payload = Box::new(7_u8);
401        let original = std::ptr::from_ref(payload.as_ref());
402        let result = std::panic::catch_unwind(|| {
403            run_raw_call(
404                CallContext {
405                    operation: "query_call",
406                    canister_id: Principal::anonymous(),
407                    caller: Principal::anonymous(),
408                    method: "get",
409                },
410                || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
411                    std::panic::panic_any(payload);
412                },
413            )
414        });
415        let resumed = result.unwrap_err().downcast::<Box<u8>>().unwrap();
416        assert_eq!(std::ptr::from_ref(resumed.as_ref().as_ref()), original);
417    }
418}