Skip to main content

ic_testkit/pic/
transport.rs

1use std::{
2    any::Any,
3    panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
4};
5
6use ic_platform::pocket_ic::RejectResponse;
7
8/// Captured `PocketIC` operation failure, retaining transport classification.
9#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
10#[error("{message}")]
11pub struct PocketIcOperationError {
12    message: String,
13    transport: bool,
14}
15
16impl PocketIcOperationError {
17    /// Capture an upstream `PocketIC` panic message and classify its transport.
18    #[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    /// Read the original upstream message without contextual wrappers.
26    #[must_use]
27    pub fn message(&self) -> &str {
28        &self.message
29    }
30
31    /// Whether the message matches a recognized `PocketIC` transport failure.
32    #[must_use]
33    pub const fn is_transport(&self) -> bool {
34        self.transport
35    }
36}
37
38// Only callers that intentionally capture every unwind use this boundary.
39// Typed calls retain their separate policy of resuming unrelated panics.
40pub(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
47/// Execute one synchronous `PocketIC` operation, returning recognized transport panics.
48///
49/// Successful values are returned unchanged, including any upstream `Result` and
50/// its original rejection. Unrelated panics resume with their original payload.
51/// The closure is invoked once: this helper never retries or replays an operation.
52/// A transport failure does not establish whether an update was accepted or
53/// whether an instance died; callers retain recovery and reconciliation policy.
54///
55/// Recognition uses the maintained panic shapes of the selected `PocketIC` client.
56/// It cannot recover the typed cause discarded upstream, suppress the panic hook,
57/// bound a blocked request, or catch errors while polling a returned future.
58///
59/// ```no_run
60/// use ic_platform::pocket_ic::PocketIc;
61/// use ic_testkit::pic::{PocketIcOperationError, try_pocket_ic_operation};
62///
63/// fn tick(pic: &PocketIc) -> Result<(), PocketIcOperationError> {
64///     try_pocket_ic_operation(|| pic.tick())
65/// }
66/// ```
67///
68/// # Panics
69/// Unrecognized panic payloads continue unwinding.
70///
71/// # Errors
72/// Returns recognized upstream operation failures; unrelated panics continue unwinding.
73pub 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/// A rejected management call or a captured `PocketIC` operation panic.
90#[non_exhaustive]
91#[derive(Debug)]
92pub enum PocketIcCallFailure {
93    /// `PocketIC` returned a structured management-canister rejection.
94    Rejected(RejectResponse),
95    /// Captured `PocketIC` panic, retaining its original transport classification.
96    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
130// Extract a stable string message from one panic payload.
131pub(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/// Recognize `PocketIC` transport failures in typed causes and request-error text.
143///
144/// The complete error source chain is inspected so a recipe can classify its
145/// own wrapper error as [`super::RebuildReason::DeadPocketIcTransport`]. This
146/// preserves the classification already captured by testkit's structured errors.
147/// Testkit's optional Wire call errors, [`super::PocketIcCallFailure`], and
148/// [`PocketIcOperationError`] causes retain their transport classification through
149/// contextual display wrappers, including nested [`std::io::Error`] values.
150/// Snapshot and installation failures expose their operation cause through
151/// the error source chain.
152///
153/// For other errors, message recognition requires a reqwest debug error with a
154/// `PocketIC` instance URL and a recognized transport source, optionally prefixed
155/// by `PocketIC`'s HTTP panic context. Generic application messages and bare I/O
156/// error kinds do not qualify.
157/// Recognized sources include OS connection resets during an HTTP request.
158///
159/// Use this only for errors originating in a `PocketIC` operation. This is a
160/// message-based heuristic, not proof that the server or instance has died;
161/// another service or application can reproduce the same text. `PocketIC` does
162/// not yet expose a structured transport error.
163#[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        // Owned codec errors and canister rejections are not transport failures.
168        #[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        // io::Error::source delegates to the contained error's source, skipping
191        // the contained error itself. Inspect it before following its causes so
192        // contextual errors keep their own structured classification.
193        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
202// Recognize maintained PocketIC request-error shapes for restore recovery.
203pub(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}