canic_testkit/pic/
startup.rs1use std::{any::Any, panic::catch_unwind};
2
3use pocket_ic::PocketIcBuilder;
4
5use super::Pic;
6
7#[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
21#[derive(Debug, Eq, PartialEq)]
22pub(super) enum PicPanicKind {
23 DeadInstanceTransport { message: String },
24 Other { message: String },
25}
26
27pub(super) fn try_build_pic(builder: PocketIcBuilder) -> Result<Pic, PicStartError> {
28 let build = catch_unwind(|| builder.build());
29
30 match build {
31 Ok(inner) => Ok(Pic { inner }),
32 Err(payload) => Err(classify_pic_start_panic(payload)),
33 }
34}
35
36impl std::fmt::Display for PicStartError {
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 PicStartError {}
50
51pub(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
63pub(super) fn classify_pic_panic(payload: Box<dyn Any + Send>) -> PicPanicKind {
66 let message = panic_payload_to_string(payload.as_ref());
67
68 if is_dead_instance_transport_error(&message) {
69 return PicPanicKind::DeadInstanceTransport { message };
70 }
71
72 PicPanicKind::Other { message }
73}
74
75pub(super) fn panic_is_dead_instance_transport(payload: &(dyn Any + Send)) -> bool {
78 matches!(
79 classify_pic_panic(Box::new(panic_payload_to_string(payload))),
80 PicPanicKind::DeadInstanceTransport { .. }
81 )
82}
83
84pub(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
94fn classify_pic_start_panic(payload: Box<dyn Any + Send>) -> PicStartError {
96 let message = match classify_pic_panic(payload) {
97 PicPanicKind::DeadInstanceTransport { message } | PicPanicKind::Other { message } => {
98 message
99 }
100 };
101
102 if message.starts_with("Failed to validate PocketIC server binary") {
103 if message.contains("No such file or directory") || message.contains("os error 2") {
104 return PicStartError::BinaryUnavailable { message };
105 }
106
107 return PicStartError::BinaryInvalid { message };
108 }
109
110 if message.starts_with("Failed to download PocketIC server")
111 || message.starts_with("Failed to write PocketIC server binary")
112 {
113 return PicStartError::DownloadFailed { message };
114 }
115
116 if message.starts_with("Failed to start PocketIC binary")
117 || message.starts_with("Failed to create PocketIC server directory")
118 {
119 return PicStartError::ServerStartFailed { message };
120 }
121
122 if message.starts_with("Timed out waiting for PocketIC server being available") {
123 return PicStartError::StartupTimedOut { message };
124 }
125
126 PicStartError::Panic { message }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::{
132 PicPanicKind, PicStartError, classify_pic_panic, classify_pic_start_panic,
133 is_dead_instance_transport_error,
134 };
135
136 #[test]
137 fn pic_start_error_classifies_missing_binary() {
138 let error = classify_pic_start_panic(Box::new(
139 "Failed to validate PocketIC server binary `/tmp/pocket-ic`: `No such file or directory (os error 2)`.".to_string(),
140 ));
141
142 assert!(matches!(error, PicStartError::BinaryUnavailable { .. }));
143 }
144
145 #[test]
146 fn pic_start_error_classifies_failed_spawn() {
147 let error = classify_pic_start_panic(Box::new(
148 "Failed to start PocketIC binary (/tmp/pocket-ic)".to_string(),
149 ));
150
151 assert!(matches!(error, PicStartError::ServerStartFailed { .. }));
152 }
153
154 #[test]
155 fn dead_instance_transport_error_detects_connection_refused() {
156 assert!(is_dead_instance_transport_error(
157 "reqwest::Error { source: ConnectError(\"tcp connect error\", 127.0.0.1:1234, Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }"
158 ));
159 }
160
161 #[test]
162 fn dead_instance_transport_error_detects_incomplete_message() {
163 assert!(is_dead_instance_transport_error(
164 "reqwest::Error { source: hyper::Error(IncompleteMessage) }"
165 ));
166 }
167
168 #[test]
169 fn classify_pic_panic_marks_dead_instance_transport() {
170 let classified = classify_pic_panic(Box::new(
171 "reqwest::Error { source: hyper::Error(IncompleteMessage) }".to_string(),
172 ));
173
174 assert!(matches!(
175 classified,
176 PicPanicKind::DeadInstanceTransport { .. }
177 ));
178 }
179}