1use candid::{
2 Principal, encode_args,
3 utils::{ArgumentDecoder, ArgumentEncoder},
4};
5use ic_testkit_candid::{DecodeError, DecodeLimits, decode_args};
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
25pub 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
57pub 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 candid::Principal;
140 use ic_testkit_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 = candid::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 let bytes = candid::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 = candid::encode_args(values).unwrap();
256 assert_eq!(candid::decode_args::<(u128, u128)>(&bytes).unwrap(), values);
257 assert!(candid::decode_one::<(u128, u128)>(&bytes).is_err());
258 let tuple = candid::encode_one(values).unwrap();
259 assert!(candid::decode_args::<(u128, u128)>(&tuple).is_err());
260 assert_eq!(candid::decode_one::<(u128, u128)>(&tuple).unwrap(), values);
261 let no_replies = candid::encode_args(()).unwrap();
262 candid::decode_args::<()>(&no_replies).unwrap();
263 let one_reply = candid::encode_args((7_u64,)).unwrap();
264 assert_eq!(candid::decode_args::<(u64,)>(&one_reply).unwrap(), (7,));
265 let result: (u64, bool, String) = call_candid(
266 context,
267 (42_u64, false),
268 |args| {
269 assert_eq!(
270 candid::decode_args::<(u64, bool)>(&args).unwrap(),
271 (42, false)
272 );
273 Ok(candid::encode_args((9_u64, true, "ready")).unwrap())
274 },
275 |bytes| decode_args(bytes, LIMITS),
276 )
277 .unwrap();
278 assert_eq!(result, (9, true, "ready".into()));
279 }
280
281 #[test]
282 fn invalid_reply_arguments_keep_decode_context() {
283 let context = CallContext {
284 operation: "query_call",
285 canister_id: Principal::anonymous(),
286 caller: Principal::management_canister(),
287 method: "measure",
288 };
289 for bytes in [vec![0xde, 0xad], candid::encode_args((7_u64,)).unwrap()] {
290 let length = bytes.len();
291 let err = call_candid(
292 context,
293 (),
294 |_| Ok(bytes),
295 |bytes| decode_args::<(u64, bool)>(bytes, LIMITS),
296 )
297 .unwrap_err();
298 assert!(err.to_string().contains(&format!("bytes={length}")));
299 let CandidCallError::Decode {
300 context: observed,
301 bytes,
302 ..
303 } = err
304 else {
305 panic!("expected decode failure");
306 };
307 assert_eq!(bytes, length);
308 assert_eq!(observed.operation, "query_call");
309 assert_eq!(observed.canister_id, context.canister_id);
310 assert_eq!(observed.caller, context.caller);
311 assert_eq!(observed.method, "measure");
312 }
313 }
314
315 #[test]
316 fn dead_instance_panic_is_classified_as_transport() {
317 let context = CallContext {
318 operation: "query_call",
319 canister_id: Principal::anonymous(),
320 caller: Principal::management_canister(),
321 method: "get",
322 };
323 let error = run_raw_call(context, || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
324 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\" }}) }}");
325 })
326 .unwrap_err();
327
328 let source = std::error::Error::source(&error)
329 .unwrap()
330 .downcast_ref::<super::PocketIcOperationError>()
331 .unwrap();
332 assert!(source.is_transport());
333 assert!(source.message().starts_with("HTTP failure: "));
334 assert!(error.to_string().contains("operation=query_call"));
335 assert!(super::super::is_dead_pocket_ic_transport_error(&error));
336 let CandidCallError::Transport { source: owned, .. } = error else {
337 panic!("expected transport failure");
338 };
339 assert!(owned.is_transport());
340 }
341
342 #[test]
343 fn unrelated_panics_resume_with_the_original_payload() {
344 for message in [
345 "application worker channel closed",
346 "fixture expected ConnectionRefused but observed another result",
347 ] {
348 let context = CallContext {
349 operation: "query_call",
350 canister_id: Principal::anonymous(),
351 caller: Principal::management_canister(),
352 method: "get",
353 };
354 let payload = message.to_owned();
355 let original_allocation = payload.as_ptr() as usize;
356 let result = std::panic::catch_unwind(|| {
357 run_raw_call(context, || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
358 std::panic::panic_any(payload);
359 })
360 });
361 let resumed = result
362 .expect_err("application panic must resume")
363 .downcast::<String>()
364 .expect("original panic type must be preserved");
365 assert_eq!(resumed.as_str(), message);
366 assert_eq!(resumed.as_ptr() as usize, original_allocation);
367 }
368 let payload = Box::new(7_u8);
369 let original = std::ptr::from_ref(payload.as_ref());
370 let result = std::panic::catch_unwind(|| {
371 run_raw_call(
372 CallContext {
373 operation: "query_call",
374 canister_id: Principal::anonymous(),
375 caller: Principal::anonymous(),
376 method: "get",
377 },
378 || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
379 std::panic::panic_any(payload);
380 },
381 )
382 });
383 let resumed = result.unwrap_err().downcast::<Box<u8>>().unwrap();
384 assert_eq!(std::ptr::from_ref(resumed.as_ref().as_ref()), original);
385 }
386}