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