1use std::{
2 any::Any,
3 panic::{AssertUnwindSafe, catch_unwind},
4};
5
6use 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
54#[non_exhaustive]
56#[derive(Debug)]
57pub enum PocketIcCallFailure {
58 Rejected(RejectResponse),
60 Operation(PocketIcOperationError),
62}
63
64impl std::fmt::Display for PocketIcCallFailure {
65 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
68 Self::Operation(source) if source.is_transport() => {
69 write!(formatter, "PocketIC instance unavailable: {source}")
70 }
71 Self::Operation(source) => write!(formatter, "panicked: {source}"),
72 }
73 }
74}
75
76impl std::error::Error for PocketIcCallFailure {
77 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78 match self {
79 Self::Rejected(_) => None,
80 Self::Operation(source) => Some(source),
81 }
82 }
83}
84
85pub(super) fn capture_call<T>(
86 call: impl FnOnce() -> Result<T, RejectResponse>,
87) -> Result<T, PocketIcCallFailure> {
88 match capture_operation(call) {
89 Ok(Ok(value)) => Ok(value),
90 Ok(Err(response)) => Err(PocketIcCallFailure::Rejected(response)),
91 Err(source) => Err(PocketIcCallFailure::Operation(source)),
92 }
93}
94
95pub(super) fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
97 if let Some(message) = payload.downcast_ref::<String>() {
98 return message.clone();
99 }
100 if let Some(message) = payload.downcast_ref::<&'static str>() {
101 return (*message).to_string();
102 }
103
104 "non-string panic payload".to_string()
105}
106
107#[must_use]
129pub fn is_dead_pocket_ic_transport_error(error: &(dyn std::error::Error + 'static)) -> bool {
130 let mut current = Some(error);
131 while let Some(candidate) = current {
132 if candidate
133 .downcast_ref::<PocketIcOperationError>()
134 .is_some_and(PocketIcOperationError::is_transport)
135 {
136 return true;
137 }
138 if is_dead_instance_transport_error(&candidate.to_string()) {
139 return true;
140 }
141 current = if let Some(inner) = candidate
145 .downcast_ref::<std::io::Error>()
146 .and_then(std::io::Error::get_ref)
147 {
148 Some(inner)
149 } else {
150 candidate.source()
151 };
152 }
153 false
154}
155
156pub(super) fn is_dead_instance_transport_error(message: &str) -> bool {
158 let message = message
159 .strip_prefix("HTTP failure: ")
160 .or_else(|| message.strip_prefix("called `Result::unwrap()` on an `Err` value: "))
161 .unwrap_or(message);
162 let Some(error) = message.strip_prefix("reqwest::Error { kind: ") else {
163 return false;
164 };
165 let Some((kind, request)) = error.split_once(", url: \"") else {
166 return false;
167 };
168 if !matches!(kind, "Request" | "Body" | "Decode") {
169 return false;
170 }
171 let Some((url, source)) = request.split_once("\", source: ") else {
172 return false;
173 };
174 let Some(source) = source.strip_suffix(" }") else {
175 return false;
176 };
177 let Some(authority_and_path) = url
178 .strip_prefix("http://")
179 .or_else(|| url.strip_prefix("https://"))
180 else {
181 return false;
182 };
183 let Some((authority, path)) = authority_and_path.split_once('/') else {
184 return false;
185 };
186 let Some(instance_path) = path.strip_prefix("instances/") else {
187 return false;
188 };
189 let instance_id = instance_path.split('/').next().unwrap_or_default();
190 if authority.is_empty()
191 || instance_id.is_empty()
192 || !instance_id.bytes().all(|byte| byte.is_ascii_digit())
193 {
194 return false;
195 }
196 let source = source
197 .strip_prefix("hyper_util::client::legacy::Error(Connect, ")
198 .or_else(|| source.strip_prefix("hyper_util::client::legacy::Error(SendRequest, "))
199 .and_then(|source| source.strip_suffix(')'))
200 .unwrap_or(source);
201 let reset = source
202 .strip_prefix("hyper::Error(Io, Os { code: ")
203 .and_then(|source| source.split_once(", kind: ConnectionReset, message: \""))
204 .is_some_and(|(code, message)| code.parse::<i32>().is_ok() && message.ends_with("\" })"));
205 (source.starts_with("ConnectError(") && source.contains("kind: ConnectionRefused,"))
206 || reset
207 || matches!(
208 source,
209 "hyper::Error(IncompleteMessage)"
210 | "hyper::Error(ChannelClosed)"
211 | "hyper::Error(Canceled, \"connection closed before message completed\")"
212 )
213}
214
215#[cfg(test)]
216mod tests {
217 use super::super::{CanisterInstallError, ControllerSnapshotError};
218 use super::{
219 PocketIcOperationError, capture_operation, is_dead_instance_transport_error,
220 is_dead_pocket_ic_transport_error,
221 };
222
223 #[derive(Debug)]
224 struct WrapperError(std::io::Error);
225
226 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\" })) }";
227 const INCOMPLETE: &str = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: hyper::Error(IncompleteMessage) }";
228 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\" })) }";
229
230 impl std::fmt::Display for WrapperError {
231 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 formatter.write_str("wrapped PocketIC request failed")
233 }
234 }
235
236 impl std::error::Error for WrapperError {
237 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
238 Some(&self.0)
239 }
240 }
241
242 #[test]
243 fn dead_instance_transport_error_detects_connection_refused() {
244 assert!(is_dead_instance_transport_error(REFUSED));
245 assert!(is_dead_instance_transport_error(&format!(
246 "HTTP failure: {REFUSED}"
247 )));
248 }
249
250 #[test]
251 fn dead_instance_transport_error_detects_incomplete_message() {
252 assert!(is_dead_instance_transport_error(INCOMPLETE));
253 assert!(is_dead_instance_transport_error(&format!(
254 "called `Result::unwrap()` on an `Err` value: {INCOMPLETE}"
255 )));
256 }
257
258 #[test]
259 fn dead_instance_transport_error_detects_native_connection_reset() {
260 for message in [RESET.to_owned(), RESET.replace("code: 54", "code: 104")] {
261 assert!(is_dead_instance_transport_error(&message));
262 assert!(PocketIcOperationError::new(format!("HTTP failure: {message}")).is_transport());
263 assert!(is_dead_instance_transport_error(&format!(
264 "called `Result::unwrap()` on an `Err` value: {message}"
265 )));
266 }
267 }
268
269 #[test]
270 fn classify_pocket_ic_panic_marks_dead_instance_transport() {
271 let classified = capture_operation::<()>(|| panic!("{INCOMPLETE}"))
272 .expect_err("operation panic must be captured");
273
274 assert_eq!(classified.message(), INCOMPLETE);
275 assert!(classified.is_transport());
276 let non_string = capture_operation::<()>(|| std::panic::panic_any(7_u8))
277 .expect_err("non-string panic must be captured");
278 assert_eq!(non_string.message(), "non-string panic payload");
279 assert!(!non_string.is_transport());
280 }
281
282 #[test]
283 fn public_classifier_inspects_the_error_source_chain() {
284 let dead = WrapperError(std::io::Error::other(REFUSED));
285 assert!(is_dead_pocket_ic_transport_error(&dead));
286
287 let unrelated = WrapperError(std::io::Error::other("request rejected"));
288 assert!(!is_dead_pocket_ic_transport_error(&unrelated));
289 }
290
291 #[test]
292 fn snapshot_and_install_wrappers_preserve_transport_causes() {
293 let canister_id = candid::Principal::anonymous();
294 for message in [REFUSED, RESET, "unrelated application panic"] {
295 let expected = message != "unrelated application panic";
296 let capture = ControllerSnapshotError::CapturePanicked {
297 canister_id,
298 source: PocketIcOperationError::new(message),
299 cleanup_failures: vec![],
300 };
301 let restore = ControllerSnapshotError::RestorePanicked {
302 canister_id,
303 source: PocketIcOperationError::new(message),
304 };
305 for snapshot in [capture, restore] {
306 assert_eq!(is_dead_pocket_ic_transport_error(&snapshot), expected);
307 let wrapper = WrapperError(std::io::Error::other(snapshot));
308 assert_eq!(is_dead_pocket_ic_transport_error(&wrapper), expected);
309 }
310 for install in [
311 CanisterInstallError::CreateCanister {
312 label: Some("fixture".into()),
313 source: PocketIcOperationError::new(message),
314 },
315 CanisterInstallError::AddCycles {
316 canister_id,
317 label: Some("fixture".into()),
318 source: PocketIcOperationError::new(message),
319 },
320 CanisterInstallError::InstallCode {
321 canister_id,
322 label: Some("fixture".into()),
323 source: PocketIcOperationError::new(message),
324 },
325 ] {
326 assert_eq!(is_dead_pocket_ic_transport_error(&install), expected);
327 let wrapper = WrapperError(std::io::Error::other(install));
328 assert_eq!(is_dead_pocket_ic_transport_error(&wrapper), expected);
329 }
330 }
331 }
332
333 #[test]
334 fn unrelated_and_quoted_transport_text_does_not_qualify() {
335 for message in [
336 "application worker channel closed",
337 "fixture expected ConnectionRefused but observed another result",
338 "tcp connect error",
339 "connection closed before message completed",
340 "IncompleteMessage",
341 &format!("fixture quoted {REFUSED}"),
342 &REFUSED.replace("/instances/0/update/tick", "/application/worker"),
343 &REFUSED.replace("/instances/0/", "/instances/quoted/"),
344 &INCOMPLETE.replace(
345 "hyper::Error(IncompleteMessage)",
346 "Custom(\"channel closed\")",
347 ),
348 &INCOMPLETE.replace(
349 "hyper::Error(IncompleteMessage)",
350 "Custom(\"hyper::Error(IncompleteMessage)\")",
351 ),
352 &format!("fixture quoted {RESET}"),
353 &RESET.replace("/instances/0/", "/application/worker/"),
354 &RESET.replace("/instances/0/", "/instances/quoted/"),
355 &RESET.replace("kind: ConnectionReset,", "kind: ConnectionAborted,"),
356 &RESET.replace("code: 54", "code: quoted"),
357 &RESET.replace("hyper::Error(Io, Os {", "Custom(Os {"),
358 ] {
359 let error = WrapperError(std::io::Error::other(message.to_owned()));
360 assert!(!is_dead_pocket_ic_transport_error(&error), "{message}");
361 assert!(!PocketIcOperationError::new(message).is_transport());
362 }
363 let refused = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
364 assert!(!is_dead_pocket_ic_transport_error(&refused));
365 assert!(!is_dead_pocket_ic_transport_error(&WrapperError(refused)));
366 let reset = std::io::Error::from(std::io::ErrorKind::ConnectionReset);
367 assert!(!is_dead_pocket_ic_transport_error(&reset));
368 assert!(!is_dead_pocket_ic_transport_error(&WrapperError(reset)));
369 }
370
371 #[test]
372 fn structured_channel_closure_on_an_instance_request_qualifies() {
373 let message = INCOMPLETE.replace("IncompleteMessage", "ChannelClosed");
374 assert!(is_dead_instance_transport_error(&message));
375 }
376
377 #[test]
378 fn contextual_call_errors_use_their_structured_kind() {
379 let context = super::super::CandidCallContext::new(
380 "query_call",
381 candid::Principal::anonymous(),
382 candid::Principal::anonymous(),
383 "get",
384 );
385 let error = super::super::CandidCallError::Transport {
386 context: Box::new(context.clone()),
387 source: PocketIcOperationError::new(REFUSED),
388 };
389 assert!(is_dead_pocket_ic_transport_error(&error));
390 let error = super::super::CandidCallError::Decode {
391 context: Box::new(context),
392 bytes: 0,
393 message: "channel closed".into(),
394 };
395 assert!(!is_dead_pocket_ic_transport_error(&error));
396 }
397
398 #[test]
399 fn io_wrappers_preserve_contextual_call_error_kinds() {
400 let context = super::super::CandidCallContext::new(
401 "query_call",
402 candid::Principal::anonymous(),
403 candid::Principal::anonymous(),
404 "get",
405 );
406 for (error, expected) in [
407 (
408 super::super::CandidCallError::Transport {
409 context: Box::new(context.clone()),
410 source: PocketIcOperationError::new(REFUSED),
411 },
412 true,
413 ),
414 (
415 super::super::CandidCallError::Decode {
416 context: Box::new(context),
417 bytes: 0,
418 message: REFUSED.into(),
419 },
420 false,
421 ),
422 ] {
423 let inner = std::io::Error::other(error);
424 assert_eq!(is_dead_pocket_ic_transport_error(&inner), expected);
425 let wrapped = WrapperError(std::io::Error::other(inner));
426 assert_eq!(is_dead_pocket_ic_transport_error(&wrapped), expected);
427 }
428 }
429}