Skip to main content

ic_testkit/pic/
calls.rs

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