Skip to main content

ic_testkit/pic/
calls.rs

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