1use std::collections::HashMap;
15use std::fmt;
16
17use cpex_core::error::PluginError;
18
19const STDERR_EXCERPT_BYTES: usize = 4096;
25
26const DETAIL_EXIT_CODE: &str = "exit_code";
28const DETAIL_STDERR: &str = "stderr_excerpt";
29const DETAIL_STDERR_TRUNCATED: &str = "stderr_truncated";
30const DETAIL_TIMEOUT_SECS: &str = "timeout_secs";
31const DETAIL_SIZE: &str = "size_bytes";
32const DETAIL_LIMIT: &str = "limit_bytes";
33
34#[derive(Debug, Clone, Default)]
47pub struct ProcessOutput {
48 pub exit_code: Option<i32>,
50
51 pub stderr: String,
53}
54
55impl ProcessOutput {
56 pub fn from_output(output: &std::process::Output) -> Self {
61 Self {
62 exit_code: output.status.code(),
63 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
64 }
65 }
66
67 fn stderr_excerpt(&self) -> (&str, bool) {
71 if self.stderr.len() <= STDERR_EXCERPT_BYTES {
72 return (self.stderr.as_str(), false);
73 }
74 let mut start = self.stderr.len() - STDERR_EXCERPT_BYTES;
75 while start < self.stderr.len() && !self.stderr.is_char_boundary(start) {
76 start += 1;
77 }
78 (&self.stderr[start..], true)
79 }
80
81 fn describe(&self) -> String {
83 let status = match self.exit_code {
84 Some(code) => format!("exited {code}"),
85 None => "was killed by a signal".to_string(),
86 };
87 let (excerpt, _) = self.stderr_excerpt();
88 if excerpt.is_empty() {
89 status
90 } else {
91 format!("{status}: {excerpt}")
92 }
93 }
94
95 fn write_details(&self, details: &mut HashMap<String, serde_json::Value>) {
97 details.insert(
98 DETAIL_EXIT_CODE.to_string(),
99 match self.exit_code {
100 Some(code) => serde_json::Value::from(code),
101 None => serde_json::Value::Null,
102 },
103 );
104 let (excerpt, truncated) = self.stderr_excerpt();
105 details.insert(DETAIL_STDERR.to_string(), serde_json::Value::from(excerpt));
106 if truncated {
107 details.insert(
108 DETAIL_STDERR_TRUNCATED.to_string(),
109 serde_json::Value::Bool(true),
110 );
111 }
112 }
113}
114
115#[derive(Debug)]
117pub enum HostError {
118 Config { message: String },
121
122 VenvBuild { message: String },
124
125 VenvCommand {
135 step: &'static str,
136 context: String,
137 output: ProcessOutput,
138 },
139
140 WorkerStart { message: String },
142
143 WorkerDied { message: String },
147
148 Timeout { timeout_secs: u64 },
150
151 TaskTooLarge { size: usize, limit: usize },
154
155 ResponseTooLarge { size: usize, limit: usize },
158
159 WorkerError { message: String },
161
162 Protocol { message: String },
167
168 Credential { message: String },
173}
174
175impl fmt::Display for HostError {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 Self::Config { message } => write!(f, "configuration error: {message}"),
179 Self::VenvBuild { message } => write!(f, "venv build failed: {message}"),
180 Self::VenvCommand {
181 step,
182 context,
183 output,
184 } => write!(
185 f,
186 "venv build failed: `{step}` on {context} {}",
187 output.describe()
188 ),
189 Self::WorkerStart { message } => write!(f, "worker failed to start: {message}"),
190 Self::WorkerDied { message } => write!(f, "worker process died: {message}"),
191 Self::Timeout { timeout_secs } => {
192 write!(f, "worker did not respond within {timeout_secs}s")
193 },
194 Self::TaskTooLarge { size, limit } => write!(
195 f,
196 "serialized task is {size} bytes, over the {limit}-byte max_content_size"
197 ),
198 Self::ResponseTooLarge { size, limit } => write!(
199 f,
200 "worker response frame is at least {size} bytes, over the \
201 {limit}-byte max_content_size; the frame was discarded rather \
202 than buffered"
203 ),
204 Self::WorkerError { message } => write!(f, "worker returned an error: {message}"),
205 Self::Protocol { message } => write!(f, "protocol error: {message}"),
206 Self::Credential { message } => write!(f, "credential error: {message}"),
207 }
208 }
209}
210
211impl std::error::Error for HostError {}
212
213impl HostError {
214 pub fn code(&self) -> &'static str {
217 match self {
218 Self::Config { .. } => "config",
219 Self::VenvBuild { .. } | Self::VenvCommand { .. } => "venv_build_failed",
220 Self::WorkerStart { .. } => "worker_start_failed",
221 Self::WorkerDied { .. } => "worker_died",
222 Self::Timeout { .. } => "timeout",
223 Self::TaskTooLarge { .. } => "task_too_large",
224 Self::ResponseTooLarge { .. } => "response_too_large",
225 Self::WorkerError { .. } => "worker_error",
226 Self::Protocol { .. } => "protocol_error",
227 Self::Credential { .. } => "credential_error",
228 }
229 }
230
231 pub fn details(&self) -> HashMap<String, serde_json::Value> {
247 let mut details = HashMap::new();
248 match self {
249 Self::VenvCommand { step, output, .. } => {
250 details.insert("step".to_string(), serde_json::Value::from(*step));
251 output.write_details(&mut details);
252 },
253 Self::Timeout { timeout_secs } => {
254 details.insert(
255 DETAIL_TIMEOUT_SECS.to_string(),
256 serde_json::Value::from(*timeout_secs),
257 );
258 },
259 Self::TaskTooLarge { size, limit } | Self::ResponseTooLarge { size, limit } => {
260 details.insert(DETAIL_SIZE.to_string(), serde_json::Value::from(*size));
261 details.insert(DETAIL_LIMIT.to_string(), serde_json::Value::from(*limit));
262 },
263 Self::Config { .. }
265 | Self::VenvBuild { .. }
266 | Self::WorkerStart { .. }
267 | Self::WorkerDied { .. }
268 | Self::WorkerError { .. }
269 | Self::Protocol { .. }
270 | Self::Credential { .. } => {},
271 }
272 details
273 }
274
275 pub fn into_plugin_error(self, plugin_name: &str) -> Box<PluginError> {
282 match self {
283 Self::Timeout { timeout_secs } => PluginError::Timeout {
284 plugin_name: plugin_name.to_string(),
285 timeout_ms: timeout_secs.saturating_mul(1000),
286 proto_error_code: None,
287 }
288 .boxed(),
289
290 Self::Config { ref message } => PluginError::Config {
291 message: format!("plugin '{plugin_name}' (isolated_venv): {message}"),
292 }
293 .boxed(),
294
295 other => {
296 let code = other.code();
297 PluginError::Execution {
298 plugin_name: plugin_name.to_string(),
299 message: other.to_string(),
300 source: None,
301 code: Some(code.to_string()),
302 details: other.details(),
303 proto_error_code: None,
304 }
305 .boxed()
306 },
307 }
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn details_of(err: HostError) -> HashMap<String, serde_json::Value> {
317 err.details()
318 }
319
320 fn execution_details(err: HostError) -> HashMap<String, serde_json::Value> {
322 match *err.into_plugin_error("p") {
323 PluginError::Execution { details, .. } => details,
324 other => panic!("expected Execution, got {other:?}"),
325 }
326 }
327
328 fn pip_failure(stderr: &str, exit_code: Option<i32>) -> HostError {
329 HostError::VenvCommand {
330 step: "pip install",
331 context: "requirements.txt".to_string(),
332 output: ProcessOutput {
333 exit_code,
334 stderr: stderr.to_string(),
335 },
336 }
337 }
338
339 #[test]
343 fn a_failed_pip_install_carries_its_exit_code_and_stderr_in_details() {
344 let details = execution_details(pip_failure("ERROR: No matching distribution", Some(1)));
345
346 assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::from(1));
347 assert_eq!(
348 details[DETAIL_STDERR],
349 serde_json::Value::from("ERROR: No matching distribution")
350 );
351 assert_eq!(details["step"], serde_json::Value::from("pip install"));
352 assert!(!details.contains_key(DETAIL_STDERR_TRUNCATED));
353 }
354
355 #[test]
357 fn the_new_variant_shares_the_venv_build_failed_code() {
358 assert_eq!(pip_failure("boom", Some(1)).code(), "venv_build_failed");
359 assert_eq!(
360 HostError::VenvBuild {
361 message: "boom".into()
362 }
363 .code(),
364 "venv_build_failed"
365 );
366 }
367
368 #[test]
372 fn a_signal_killed_child_reports_a_null_exit_code() {
373 let details = execution_details(pip_failure("", None));
374
375 assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::Null);
376 assert!(pip_failure("", None)
377 .to_string()
378 .contains("was killed by a signal"));
379 }
380
381 #[test]
384 fn an_oversized_stderr_is_truncated_to_its_tail_and_flagged() {
385 let stderr = format!("{}TAIL-MARKER", "x".repeat(STDERR_EXCERPT_BYTES * 2));
386 let details = execution_details(pip_failure(&stderr, Some(2)));
387
388 let excerpt = details[DETAIL_STDERR].as_str().unwrap();
389 assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
390 assert!(
391 excerpt.ends_with("TAIL-MARKER"),
392 "the tail holds the real error, so it must survive truncation"
393 );
394 assert_eq!(
395 details[DETAIL_STDERR_TRUNCATED],
396 serde_json::Value::Bool(true)
397 );
398 }
399
400 #[test]
402 fn truncation_respects_char_boundaries() {
403 let stderr = "é".repeat(STDERR_EXCERPT_BYTES);
405 let details = execution_details(pip_failure(&stderr, Some(1)));
406
407 let excerpt = details[DETAIL_STDERR].as_str().unwrap();
408 assert!(excerpt.chars().all(|c| c == 'é'));
409 assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
410 }
411
412 #[test]
415 fn a_timeout_still_maps_to_the_timeout_variant() {
416 let err = HostError::Timeout { timeout_secs: 30 };
417 match *err.into_plugin_error("p") {
418 PluginError::Timeout { timeout_ms, .. } => assert_eq!(timeout_ms, 30_000),
419 other => panic!("expected Timeout, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn a_size_cap_breach_reports_both_the_size_and_the_limit() {
425 let details = execution_details(HostError::TaskTooLarge {
426 size: 2048,
427 limit: 1024,
428 });
429
430 assert_eq!(details[DETAIL_SIZE], serde_json::Value::from(2048));
431 assert_eq!(details[DETAIL_LIMIT], serde_json::Value::from(1024));
432 }
433
434 #[test]
437 fn message_only_variants_contribute_no_details() {
438 for err in [
439 HostError::VenvBuild {
440 message: "m".into(),
441 },
442 HostError::WorkerStart {
443 message: "m".into(),
444 },
445 HostError::WorkerDied {
446 message: "m".into(),
447 },
448 HostError::WorkerError {
449 message: "m".into(),
450 },
451 HostError::Protocol {
452 message: "m".into(),
453 },
454 HostError::Credential {
455 message: "m".into(),
456 },
457 ] {
458 assert!(details_of(err).is_empty());
459 }
460 }
461
462 #[test]
466 fn the_display_message_still_names_the_step_context_and_failure() {
467 let message = pip_failure("ERROR: could not build wheel", Some(1)).to_string();
468
469 assert!(message.contains("pip install"));
470 assert!(message.contains("requirements.txt"));
471 assert!(message.contains("exited 1"));
472 assert!(message.contains("ERROR: could not build wheel"));
473 }
474}