1use std::{
2 any::Any,
3 panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
4};
5
6use ic_platform::pocket_ic::RejectResponse;
7
8#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
10#[error("{message}")]
11pub struct PocketIcOperationError {
12 message: String,
13 transport: bool,
14}
15
16impl PocketIcOperationError {
17 #[must_use]
19 pub fn new(message: impl Into<String>) -> Self {
20 let message = message.into();
21 let transport = is_dead_instance_transport_error(&message);
22 Self { message, transport }
23 }
24
25 #[must_use]
27 pub fn message(&self) -> &str {
28 &self.message
29 }
30
31 #[must_use]
33 pub const fn is_transport(&self) -> bool {
34 self.transport
35 }
36}
37
38pub(super) fn capture_operation<T>(
41 operation: impl FnOnce() -> T,
42) -> Result<T, PocketIcOperationError> {
43 catch_unwind(AssertUnwindSafe(operation))
44 .map_err(|payload| PocketIcOperationError::new(panic_payload_to_string(payload.as_ref())))
45}
46
47pub fn try_pocket_ic_operation<T>(
74 operation: impl FnOnce() -> T,
75) -> Result<T, PocketIcOperationError> {
76 match catch_unwind(AssertUnwindSafe(operation)) {
77 Ok(value) => Ok(value),
78 Err(payload) => {
79 let source = PocketIcOperationError::new(panic_payload_to_string(payload.as_ref()));
80 if source.is_transport() {
81 Err(source)
82 } else {
83 resume_unwind(payload)
84 }
85 }
86 }
87}
88
89#[non_exhaustive]
91#[derive(Debug)]
92pub enum PocketIcCallFailure {
93 Rejected(RejectResponse),
95 Operation(PocketIcOperationError),
97}
98
99impl std::fmt::Display for PocketIcCallFailure {
100 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
103 Self::Operation(source) if source.is_transport() => {
104 write!(formatter, "PocketIC instance unavailable: {source}")
105 }
106 Self::Operation(source) => write!(formatter, "panicked: {source}"),
107 }
108 }
109}
110
111impl std::error::Error for PocketIcCallFailure {
112 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
113 match self {
114 Self::Rejected(_) => None,
115 Self::Operation(source) => Some(source),
116 }
117 }
118}
119
120pub(super) fn capture_call<T>(
121 call: impl FnOnce() -> Result<T, RejectResponse>,
122) -> Result<T, PocketIcCallFailure> {
123 match capture_operation(call) {
124 Ok(Ok(value)) => Ok(value),
125 Ok(Err(response)) => Err(PocketIcCallFailure::Rejected(response)),
126 Err(source) => Err(PocketIcCallFailure::Operation(source)),
127 }
128}
129
130pub(super) fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
132 if let Some(message) = payload.downcast_ref::<String>() {
133 return message.clone();
134 }
135 if let Some(message) = payload.downcast_ref::<&'static str>() {
136 return (*message).to_string();
137 }
138
139 "non-string panic payload".to_string()
140}
141
142#[must_use]
164pub fn is_dead_pocket_ic_transport_error(error: &(dyn std::error::Error + 'static)) -> bool {
165 let mut current = Some(error);
166 while let Some(candidate) = current {
167 #[cfg(feature = "wire")]
169 if let Some(error) = candidate.downcast_ref::<super::WireCallError>() {
170 return match error {
171 super::WireCallError::Encode { .. } | super::WireCallError::Decode { .. } => false,
172 super::WireCallError::Call { source, .. } => matches!(
173 source.as_ref(),
174 PocketIcCallFailure::Operation(error) if error.is_transport()
175 ),
176 };
177 }
178 if let Some(failure) = candidate.downcast_ref::<PocketIcCallFailure>() {
179 return matches!(failure, PocketIcCallFailure::Operation(error) if error.is_transport());
180 }
181 if candidate
182 .downcast_ref::<PocketIcOperationError>()
183 .is_some_and(PocketIcOperationError::is_transport)
184 {
185 return true;
186 }
187 if is_dead_instance_transport_error(&candidate.to_string()) {
188 return true;
189 }
190 current = candidate
194 .downcast_ref::<std::io::Error>()
195 .and_then(std::io::Error::get_ref)
196 .map(|inner| inner as &dyn std::error::Error)
197 .or_else(|| candidate.source());
198 }
199 false
200}
201
202pub(super) fn is_dead_instance_transport_error(message: &str) -> bool {
204 let message = message
205 .strip_prefix("HTTP failure: ")
206 .or_else(|| message.strip_prefix("called `Result::unwrap()` on an `Err` value: "))
207 .unwrap_or(message);
208 let Some(error) = message.strip_prefix("reqwest::Error { kind: ") else {
209 return false;
210 };
211 let Some((kind, request)) = error.split_once(", url: \"") else {
212 return false;
213 };
214 if !matches!(kind, "Request" | "Body" | "Decode") {
215 return false;
216 }
217 let Some((url, source)) = request.split_once("\", source: ") else {
218 return false;
219 };
220 let Some(source) = source.strip_suffix(" }") else {
221 return false;
222 };
223 let Some(authority_and_path) = url
224 .strip_prefix("http://")
225 .or_else(|| url.strip_prefix("https://"))
226 else {
227 return false;
228 };
229 let Some((authority, path)) = authority_and_path.split_once('/') else {
230 return false;
231 };
232 let Some(instance_path) = path.strip_prefix("instances/") else {
233 return false;
234 };
235 let instance_id = instance_path.split('/').next().unwrap_or_default();
236 if authority.is_empty()
237 || instance_id.is_empty()
238 || !instance_id.bytes().all(|byte| byte.is_ascii_digit())
239 {
240 return false;
241 }
242 let source = source
243 .strip_prefix("hyper_util::client::legacy::Error(Connect, ")
244 .or_else(|| source.strip_prefix("hyper_util::client::legacy::Error(SendRequest, "))
245 .and_then(|source| source.strip_suffix(')'))
246 .unwrap_or(source);
247 let reset = source
248 .strip_prefix("hyper::Error(Io, Os { code: ")
249 .and_then(|source| source.split_once(", kind: ConnectionReset, message: \""))
250 .is_some_and(|(code, message)| code.parse::<i32>().is_ok() && message.ends_with("\" })"));
251 (source.starts_with("ConnectError(") && source.contains("kind: ConnectionRefused,"))
252 || reset
253 || matches!(
254 source,
255 "hyper::Error(IncompleteMessage)"
256 | "hyper::Error(ChannelClosed)"
257 | "hyper::Error(Canceled, \"connection closed before message completed\")"
258 )
259}
260
261#[cfg(test)]
262mod tests {
263 use super::super::{CanisterInstallError, ControllerSnapshotError};
264 use super::{
265 PocketIcCallFailure, PocketIcOperationError, capture_operation,
266 is_dead_instance_transport_error, is_dead_pocket_ic_transport_error,
267 try_pocket_ic_operation,
268 };
269
270 #[derive(Debug)]
271 struct WrapperError(std::io::Error);
272
273 const REFUSED: &str = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/tick\", source: hyper_util::client::legacy::Error(Connect, ConnectError(\"tcp connect error\", 127.0.0.1:1234, Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" })) }";
274 const INCOMPLETE: &str = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: hyper::Error(IncompleteMessage) }";
275 const RESET: &str = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:49238/instances/0/update/submit_ingress_message\", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Os { code: 54, kind: ConnectionReset, message: \"Connection reset by peer\" })) }";
276
277 #[test]
278 fn raw_operations_preserve_values_and_rejections() {
279 assert_eq!(try_pocket_ic_operation(|| 42), Ok(42));
280 let rejection = ic_platform::pocket_ic::RejectResponse {
281 reject_code: ic_platform::pocket_ic::RejectCode::CanisterReject,
282 reject_message: "application rejection".into(),
283 error_code: ic_platform::pocket_ic::ErrorCode::CanisterRejectedMessage,
284 certified: false,
285 };
286 let result = try_pocket_ic_operation(|| Err::<(), _>(rejection))
287 .expect("returned rejection is not a transport panic");
288 let rejection = result.expect_err("retain the original rejection");
289 assert_eq!(rejection.reject_message, "application rejection");
290 assert_eq!(
291 rejection.reject_code,
292 ic_platform::pocket_ic::RejectCode::CanisterReject
293 );
294 }
295
296 #[test]
297 fn raw_transport_failure_is_returned_after_one_attempt() {
298 let mut attempts = 0;
299 let error = try_pocket_ic_operation::<()>(|| {
300 attempts += 1;
301 panic!("{REFUSED}");
302 })
303 .expect_err("transport panic must return an error");
304 assert_eq!(attempts, 1);
305 assert_eq!(error.message(), REFUSED);
306 assert!(error.is_transport());
307 }
308
309 impl std::fmt::Display for WrapperError {
310 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 formatter.write_str("wrapped PocketIC request failed")
312 }
313 }
314
315 impl std::error::Error for WrapperError {
316 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
317 Some(&self.0)
318 }
319 }
320
321 #[test]
322 fn dead_instance_transport_error_detects_connection_refused() {
323 assert!(is_dead_instance_transport_error(REFUSED));
324 assert!(is_dead_instance_transport_error(&format!(
325 "HTTP failure: {REFUSED}"
326 )));
327 }
328
329 #[test]
330 fn dead_instance_transport_error_detects_incomplete_message() {
331 assert!(is_dead_instance_transport_error(INCOMPLETE));
332 assert!(is_dead_instance_transport_error(&format!(
333 "called `Result::unwrap()` on an `Err` value: {INCOMPLETE}"
334 )));
335 }
336
337 #[test]
338 fn dead_instance_transport_error_detects_native_connection_reset() {
339 for message in [RESET.to_owned(), RESET.replace("code: 54", "code: 104")] {
340 assert!(is_dead_instance_transport_error(&message));
341 assert!(PocketIcOperationError::new(format!("HTTP failure: {message}")).is_transport());
342 assert!(is_dead_instance_transport_error(&format!(
343 "called `Result::unwrap()` on an `Err` value: {message}"
344 )));
345 }
346 }
347
348 #[test]
349 fn classify_pocket_ic_panic_marks_dead_instance_transport() {
350 let classified = capture_operation::<()>(|| panic!("{INCOMPLETE}"))
351 .expect_err("operation panic must be captured");
352
353 assert_eq!(classified.message(), INCOMPLETE);
354 assert!(classified.is_transport());
355 let non_string = capture_operation::<()>(|| std::panic::panic_any(7_u8))
356 .expect_err("non-string panic must be captured");
357 assert_eq!(non_string.message(), "non-string panic payload");
358 assert!(!non_string.is_transport());
359 }
360
361 #[test]
362 fn public_classifier_inspects_the_error_source_chain() {
363 let dead = WrapperError(std::io::Error::other(REFUSED));
364 assert!(is_dead_pocket_ic_transport_error(&dead));
365
366 let unrelated = WrapperError(std::io::Error::other("request rejected"));
367 assert!(!is_dead_pocket_ic_transport_error(&unrelated));
368 }
369
370 #[test]
371 fn snapshot_and_install_wrappers_preserve_transport_causes() {
372 let canister_id = ic_platform::principal::ExternalPrincipal::anonymous();
373 for message in [REFUSED, RESET, "unrelated application panic"] {
374 let expected = message != "unrelated application panic";
375 let capture = ControllerSnapshotError::CaptureFailed {
376 canister_id,
377 sender: None,
378 source: Box::new(PocketIcCallFailure::Operation(PocketIcOperationError::new(
379 message,
380 ))),
381 cleanup_failures: vec![],
382 };
383 let restore = ControllerSnapshotError::RestoreFailed {
384 canister_id,
385 sender: None,
386 source: Box::new(PocketIcCallFailure::Operation(PocketIcOperationError::new(
387 message,
388 ))),
389 };
390 for snapshot in [capture, restore] {
391 assert_eq!(is_dead_pocket_ic_transport_error(&snapshot), expected);
392 let wrapper = WrapperError(std::io::Error::other(snapshot));
393 assert_eq!(is_dead_pocket_ic_transport_error(&wrapper), expected);
394 }
395 for install in [
396 CanisterInstallError::CreateCanister {
397 label: Some("fixture".into()),
398 source: PocketIcOperationError::new(message),
399 },
400 CanisterInstallError::AddCycles {
401 canister_id,
402 label: Some("fixture".into()),
403 source: PocketIcOperationError::new(message),
404 },
405 CanisterInstallError::InstallCode {
406 canister_id,
407 label: Some("fixture".into()),
408 source: PocketIcOperationError::new(message),
409 },
410 ] {
411 assert_eq!(is_dead_pocket_ic_transport_error(&install), expected);
412 let wrapper = WrapperError(std::io::Error::other(install));
413 assert_eq!(is_dead_pocket_ic_transport_error(&wrapper), expected);
414 }
415 }
416 }
417
418 #[test]
419 fn unrelated_and_quoted_transport_text_does_not_qualify() {
420 for message in [
421 "application worker channel closed",
422 "fixture expected ConnectionRefused but observed another result",
423 "tcp connect error",
424 "connection closed before message completed",
425 "IncompleteMessage",
426 &format!("fixture quoted {REFUSED}"),
427 &REFUSED.replace("/instances/0/update/tick", "/application/worker"),
428 &REFUSED.replace("/instances/0/", "/instances/quoted/"),
429 &INCOMPLETE.replace(
430 "hyper::Error(IncompleteMessage)",
431 "Custom(\"channel closed\")",
432 ),
433 &INCOMPLETE.replace(
434 "hyper::Error(IncompleteMessage)",
435 "Custom(\"hyper::Error(IncompleteMessage)\")",
436 ),
437 &format!("fixture quoted {RESET}"),
438 &RESET.replace("/instances/0/", "/application/worker/"),
439 &RESET.replace("/instances/0/", "/instances/quoted/"),
440 &RESET.replace("kind: ConnectionReset,", "kind: ConnectionAborted,"),
441 &RESET.replace("code: 54", "code: quoted"),
442 &RESET.replace("hyper::Error(Io, Os {", "Custom(Os {"),
443 ] {
444 let error = WrapperError(std::io::Error::other(message.to_owned()));
445 assert!(!is_dead_pocket_ic_transport_error(&error), "{message}");
446 assert!(!PocketIcOperationError::new(message).is_transport());
447 }
448 let refused = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
449 assert!(!is_dead_pocket_ic_transport_error(&refused));
450 assert!(!is_dead_pocket_ic_transport_error(&WrapperError(refused)));
451 let reset = std::io::Error::from(std::io::ErrorKind::ConnectionReset);
452 assert!(!is_dead_pocket_ic_transport_error(&reset));
453 assert!(!is_dead_pocket_ic_transport_error(&WrapperError(reset)));
454 }
455
456 #[test]
457 fn structured_channel_closure_on_an_instance_request_qualifies() {
458 let message = INCOMPLETE.replace("IncompleteMessage", "ChannelClosed");
459 assert!(is_dead_instance_transport_error(&message));
460 }
461
462 #[cfg(feature = "wire")]
463 #[test]
464 fn contextual_call_errors_use_their_structured_kind() {
465 use super::super::{WireCallContext, WireCallError};
466 let context = WireCallContext {
467 operation: "query_call",
468 canister_id: ic_platform::principal::ExternalPrincipal::anonymous(),
469 caller: ic_platform::principal::ExternalPrincipal::anonymous(),
470 method: REFUSED,
471 method_id: 1,
472 };
473 for (error, expected) in [
474 (
475 WireCallError::Call {
476 context: Box::new(context.clone()),
477 source: Box::new(PocketIcCallFailure::Operation(PocketIcOperationError::new(
478 REFUSED,
479 ))),
480 },
481 true,
482 ),
483 (
484 WireCallError::Decode {
485 context: Box::new(context.clone()),
486 bytes: 0,
487 source: ic_wire::Error::Malformed,
488 },
489 false,
490 ),
491 (
492 WireCallError::Encode {
493 context: Box::new(context.clone()),
494 source: ic_wire::Error::Limit,
495 },
496 false,
497 ),
498 (
499 WireCallError::Call {
500 context: Box::new(context),
501 source: Box::new(PocketIcCallFailure::Rejected(
502 ic_platform::pocket_ic::RejectResponse {
503 reject_code: ic_platform::pocket_ic::RejectCode::CanisterReject,
504 reject_message: REFUSED.into(),
505 error_code: ic_platform::pocket_ic::ErrorCode::CanisterRejectedMessage,
506 certified: false,
507 },
508 )),
509 },
510 false,
511 ),
512 ] {
513 assert_eq!(is_dead_pocket_ic_transport_error(&error), expected);
514 let wrapped = WrapperError(std::io::Error::other(std::io::Error::other(error)));
515 assert_eq!(is_dead_pocket_ic_transport_error(&wrapped), expected);
516 }
517 }
518}