Skip to main content

ic_testkit/pic/
startup.rs

1use std::{any::Any, panic::catch_unwind};
2
3use pocket_ic::{PocketIc, PocketIcBuilder};
4
5///
6/// PocketIcStartError
7///
8
9#[derive(Debug, Eq, PartialEq)]
10pub enum PocketIcStartError {
11    BinaryUnavailable { message: String },
12    BinaryInvalid { message: String },
13    DownloadFailed { message: String },
14    ServerStartFailed { message: String },
15    StartupTimedOut { message: String },
16    Panic { message: String },
17}
18
19#[derive(Debug, Eq, PartialEq)]
20pub(super) enum PocketIcPanicKind {
21    DeadInstanceTransport { message: String },
22    Other { message: String },
23}
24
25pub(super) fn try_build_pocket_ic(
26    builder: PocketIcBuilder,
27) -> Result<PocketIc, PocketIcStartError> {
28    let build = catch_unwind(|| builder.build());
29
30    match build {
31        Ok(pocket_ic) => Ok(pocket_ic),
32        Err(payload) => Err(classify_pocket_ic_start_panic(payload)),
33    }
34}
35
36impl std::fmt::Display for PocketIcStartError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::BinaryUnavailable { message }
40            | Self::BinaryInvalid { message }
41            | Self::DownloadFailed { message }
42            | Self::ServerStartFailed { message }
43            | Self::StartupTimedOut { message }
44            | Self::Panic { message } => f.write_str(message),
45        }
46    }
47}
48
49impl std::error::Error for PocketIcStartError {}
50
51// Extract a stable string message from one panic payload.
52pub(super) fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
53    if let Some(message) = payload.downcast_ref::<String>() {
54        return message.clone();
55    }
56    if let Some(message) = payload.downcast_ref::<&'static str>() {
57        return (*message).to_string();
58    }
59
60    "non-string panic payload".to_string()
61}
62
63// Classify one panic payload so callers can recover dead-instance restores
64// without repeating transport-string matching at each call site.
65pub(super) fn classify_pocket_ic_panic(payload: Box<dyn Any + Send>) -> PocketIcPanicKind {
66    let message = panic_payload_to_string(payload.as_ref());
67
68    if is_dead_instance_transport_error(&message) {
69        return PocketIcPanicKind::DeadInstanceTransport { message };
70    }
71
72    PocketIcPanicKind::Other { message }
73}
74
75// Check whether one panic payload belongs to the dead-instance transport class
76// without consuming it, so callers can still resume the original panic.
77pub(super) fn panic_is_dead_instance_transport(payload: &(dyn Any + Send)) -> bool {
78    matches!(
79        classify_pocket_ic_panic(Box::new(panic_payload_to_string(payload))),
80        PocketIcPanicKind::DeadInstanceTransport { .. }
81    )
82}
83
84// Detect the PocketIC transport failure class that means the owned instance
85// has already died and cached snapshot restore should rebuild from scratch.
86pub(super) fn is_dead_instance_transport_error(message: &str) -> bool {
87    message.contains("ConnectionRefused")
88        || message.contains("tcp connect error")
89        || message.contains("IncompleteMessage")
90        || message.contains("connection closed before message completed")
91        || message.contains("channel closed")
92}
93
94// Classify one PocketIC startup panic into a typed public error.
95fn classify_pocket_ic_start_panic(payload: Box<dyn Any + Send>) -> PocketIcStartError {
96    let message = match classify_pocket_ic_panic(payload) {
97        PocketIcPanicKind::DeadInstanceTransport { message }
98        | PocketIcPanicKind::Other { message } => message,
99    };
100
101    if message.starts_with("Failed to validate PocketIC server binary") {
102        if message.contains("No such file or directory") || message.contains("os error 2") {
103            return PocketIcStartError::BinaryUnavailable { message };
104        }
105
106        return PocketIcStartError::BinaryInvalid { message };
107    }
108
109    if message.starts_with("Failed to download PocketIC server")
110        || message.starts_with("Failed to write PocketIC server binary")
111    {
112        return PocketIcStartError::DownloadFailed { message };
113    }
114
115    if message.starts_with("Failed to start PocketIC binary")
116        || message.starts_with("Failed to create PocketIC server directory")
117    {
118        return PocketIcStartError::ServerStartFailed { message };
119    }
120
121    if message.starts_with("Timed out waiting for PocketIC server being available") {
122        return PocketIcStartError::StartupTimedOut { message };
123    }
124
125    PocketIcStartError::Panic { message }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::{
131        PocketIcPanicKind, PocketIcStartError, classify_pocket_ic_panic,
132        classify_pocket_ic_start_panic, is_dead_instance_transport_error,
133    };
134
135    #[test]
136    fn pocket_ic_start_error_classifies_missing_binary() {
137        let error = classify_pocket_ic_start_panic(Box::new(
138            "Failed to validate PocketIC server binary `/tmp/pocket-ic`: `No such file or directory (os error 2)`.".to_string(),
139        ));
140
141        assert!(matches!(
142            error,
143            PocketIcStartError::BinaryUnavailable { .. }
144        ));
145    }
146
147    #[test]
148    fn pocket_ic_start_error_classifies_failed_spawn() {
149        let error = classify_pocket_ic_start_panic(Box::new(
150            "Failed to start PocketIC binary (/tmp/pocket-ic)".to_string(),
151        ));
152
153        assert!(matches!(
154            error,
155            PocketIcStartError::ServerStartFailed { .. }
156        ));
157    }
158
159    #[test]
160    fn dead_instance_transport_error_detects_connection_refused() {
161        assert!(is_dead_instance_transport_error(
162            "reqwest::Error { source: ConnectError(\"tcp connect error\", 127.0.0.1:1234, Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }"
163        ));
164    }
165
166    #[test]
167    fn dead_instance_transport_error_detects_incomplete_message() {
168        assert!(is_dead_instance_transport_error(
169            "reqwest::Error { source: hyper::Error(IncompleteMessage) }"
170        ));
171    }
172
173    #[test]
174    fn classify_pocket_ic_panic_marks_dead_instance_transport() {
175        let classified = classify_pocket_ic_panic(Box::new(
176            "reqwest::Error { source: hyper::Error(IncompleteMessage) }".to_string(),
177        ));
178
179        assert!(matches!(
180            classified,
181            PocketIcPanicKind::DeadInstanceTransport { .. }
182        ));
183    }
184}