Skip to main content

canic_testkit/pic/
startup.rs

1use std::{any::Any, panic::catch_unwind};
2
3use pocket_ic::PocketIcBuilder;
4
5use super::Pic;
6
7///
8/// PicStartError
9///
10
11#[derive(Debug, Eq, PartialEq)]
12pub enum PicStartError {
13    BinaryUnavailable { message: String },
14    BinaryInvalid { message: String },
15    DownloadFailed { message: String },
16    ServerStartFailed { message: String },
17    StartupTimedOut { message: String },
18    Panic { message: String },
19}
20
21pub(super) fn try_build_pic(builder: PocketIcBuilder) -> Result<Pic, PicStartError> {
22    let build = catch_unwind(|| builder.build());
23
24    match build {
25        Ok(inner) => Ok(Pic { inner }),
26        Err(payload) => Err(classify_pic_start_panic(payload)),
27    }
28}
29
30impl std::fmt::Display for PicStartError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::BinaryUnavailable { message }
34            | Self::BinaryInvalid { message }
35            | Self::DownloadFailed { message }
36            | Self::ServerStartFailed { message }
37            | Self::StartupTimedOut { message }
38            | Self::Panic { message } => f.write_str(message),
39        }
40    }
41}
42
43impl std::error::Error for PicStartError {}
44
45// Extract a stable string message from one panic payload.
46pub(super) fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
47    if let Some(message) = payload.downcast_ref::<String>() {
48        return message.clone();
49    }
50    if let Some(message) = payload.downcast_ref::<&'static str>() {
51        return (*message).to_string();
52    }
53
54    "non-string panic payload".to_string()
55}
56
57// Detect the PocketIC transport failure class that means the owned instance
58// has already died and cached snapshot restore should rebuild from scratch.
59pub(super) fn is_dead_instance_transport_error(message: &str) -> bool {
60    message.contains("ConnectionRefused")
61        || message.contains("tcp connect error")
62        || message.contains("IncompleteMessage")
63        || message.contains("connection closed before message completed")
64        || message.contains("channel closed")
65}
66
67// Classify one PocketIC startup panic into a typed public error.
68fn classify_pic_start_panic(payload: Box<dyn Any + Send>) -> PicStartError {
69    let message = panic_payload_to_string(payload.as_ref());
70
71    if message.starts_with("Failed to validate PocketIC server binary") {
72        if message.contains("No such file or directory") || message.contains("os error 2") {
73            return PicStartError::BinaryUnavailable { message };
74        }
75
76        return PicStartError::BinaryInvalid { message };
77    }
78
79    if message.starts_with("Failed to download PocketIC server")
80        || message.starts_with("Failed to write PocketIC server binary")
81    {
82        return PicStartError::DownloadFailed { message };
83    }
84
85    if message.starts_with("Failed to start PocketIC binary")
86        || message.starts_with("Failed to create PocketIC server directory")
87    {
88        return PicStartError::ServerStartFailed { message };
89    }
90
91    if message.starts_with("Timed out waiting for PocketIC server being available") {
92        return PicStartError::StartupTimedOut { message };
93    }
94
95    PicStartError::Panic { message }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::{PicStartError, classify_pic_start_panic, is_dead_instance_transport_error};
101
102    #[test]
103    fn pic_start_error_classifies_missing_binary() {
104        let error = classify_pic_start_panic(Box::new(
105            "Failed to validate PocketIC server binary `/tmp/pocket-ic`: `No such file or directory (os error 2)`.".to_string(),
106        ));
107
108        assert!(matches!(error, PicStartError::BinaryUnavailable { .. }));
109    }
110
111    #[test]
112    fn pic_start_error_classifies_failed_spawn() {
113        let error = classify_pic_start_panic(Box::new(
114            "Failed to start PocketIC binary (/tmp/pocket-ic)".to_string(),
115        ));
116
117        assert!(matches!(error, PicStartError::ServerStartFailed { .. }));
118    }
119
120    #[test]
121    fn dead_instance_transport_error_detects_connection_refused() {
122        assert!(is_dead_instance_transport_error(
123            "reqwest::Error { source: ConnectError(\"tcp connect error\", 127.0.0.1:1234, Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }"
124        ));
125    }
126
127    #[test]
128    fn dead_instance_transport_error_detects_incomplete_message() {
129        assert!(is_dead_instance_transport_error(
130            "reqwest::Error { source: hyper::Error(IncompleteMessage) }"
131        ));
132    }
133}