1use std::collections::HashMap;
15use std::fmt;
16
17use ppe::praxis_policy_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 LegacyShim { hook: String, message: String },
181}
182
183impl fmt::Display for HostError {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 match self {
186 Self::Config { message } => write!(f, "configuration error: {message}"),
187 Self::VenvBuild { message } => write!(f, "venv build failed: {message}"),
188 Self::VenvCommand {
189 step,
190 context,
191 output,
192 } => write!(
193 f,
194 "venv build failed: `{step}` on {context} {}",
195 output.describe()
196 ),
197 Self::WorkerStart { message } => write!(f, "worker failed to start: {message}"),
198 Self::WorkerDied { message } => write!(f, "worker process died: {message}"),
199 Self::Timeout { timeout_secs } => {
200 write!(f, "worker did not respond within {timeout_secs}s")
201 },
202 Self::TaskTooLarge { size, limit } => write!(
203 f,
204 "serialized task is {size} bytes, over the {limit}-byte max_content_size"
205 ),
206 Self::ResponseTooLarge { size, limit } => write!(
207 f,
208 "worker response frame is at least {size} bytes, over the \
209 {limit}-byte max_content_size; the frame was discarded rather \
210 than buffered"
211 ),
212 Self::WorkerError { message } => write!(f, "worker returned an error: {message}"),
213 Self::Protocol { message } => write!(f, "protocol error: {message}"),
214 Self::Credential { message } => write!(f, "credential error: {message}"),
215 Self::LegacyShim { hook, message } => write!(
216 f,
217 "hook '{hook}' cannot be served by this worker, which predates cmf hook \
218 support: {message}"
219 ),
220 }
221 }
222}
223
224impl std::error::Error for HostError {}
225
226impl HostError {
227 pub fn code(&self) -> &'static str {
230 match self {
231 Self::Config { .. } => "config",
232 Self::VenvBuild { .. } | Self::VenvCommand { .. } => "venv_build_failed",
233 Self::WorkerStart { .. } => "worker_start_failed",
234 Self::WorkerDied { .. } => "worker_died",
235 Self::Timeout { .. } => "timeout",
236 Self::TaskTooLarge { .. } => "task_too_large",
237 Self::ResponseTooLarge { .. } => "response_too_large",
238 Self::WorkerError { .. } => "worker_error",
239 Self::Protocol { .. } => "protocol_error",
240 Self::Credential { .. } => "credential_error",
241 Self::LegacyShim { .. } => "legacy_shim_unsupported",
242 }
243 }
244
245 pub fn details(&self) -> HashMap<String, serde_json::Value> {
261 let mut details = HashMap::new();
262 match self {
263 Self::VenvCommand { step, output, .. } => {
264 details.insert("step".to_string(), serde_json::Value::from(*step));
265 output.write_details(&mut details);
266 },
267 Self::Timeout { timeout_secs } => {
268 details.insert(
269 DETAIL_TIMEOUT_SECS.to_string(),
270 serde_json::Value::from(*timeout_secs),
271 );
272 },
273 Self::TaskTooLarge { size, limit } | Self::ResponseTooLarge { size, limit } => {
274 details.insert(DETAIL_SIZE.to_string(), serde_json::Value::from(*size));
275 details.insert(DETAIL_LIMIT.to_string(), serde_json::Value::from(*limit));
276 },
277 Self::Config { .. }
279 | Self::VenvBuild { .. }
280 | Self::WorkerStart { .. }
281 | Self::WorkerDied { .. }
282 | Self::WorkerError { .. }
283 | Self::Protocol { .. }
284 | Self::Credential { .. }
285 | Self::LegacyShim { .. } => {},
286 }
287 details
288 }
289
290 pub fn into_plugin_error(self, plugin_name: &str) -> Box<PluginError> {
297 match self {
298 Self::Timeout { timeout_secs } => PluginError::Timeout {
299 plugin_name: plugin_name.to_string(),
300 timeout_ms: timeout_secs.saturating_mul(1000),
301 proto_error_code: None,
302 }
303 .boxed(),
304
305 Self::Config { ref message } => PluginError::Config {
306 message: format!("plugin '{plugin_name}' (isolated_venv): {message}"),
307 }
308 .boxed(),
309
310 other => {
311 let code = other.code();
312 PluginError::Execution {
313 plugin_name: plugin_name.to_string(),
314 message: other.to_string(),
315 source: None,
316 code: Some(code.to_string()),
317 details: other.details(),
318 proto_error_code: None,
319 }
320 .boxed()
321 },
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn details_of(err: HostError) -> HashMap<String, serde_json::Value> {
332 err.details()
333 }
334
335 fn execution_details(err: HostError) -> HashMap<String, serde_json::Value> {
337 match *err.into_plugin_error("p") {
338 PluginError::Execution { details, .. } => details,
339 other => panic!("expected Execution, got {other:?}"),
340 }
341 }
342
343 fn pip_failure(stderr: &str, exit_code: Option<i32>) -> HostError {
344 HostError::VenvCommand {
345 step: "pip install",
346 context: "requirements.txt".to_string(),
347 output: ProcessOutput {
348 exit_code,
349 stderr: stderr.to_string(),
350 },
351 }
352 }
353
354 #[test]
358 fn a_failed_pip_install_carries_its_exit_code_and_stderr_in_details() {
359 let details = execution_details(pip_failure("ERROR: No matching distribution", Some(1)));
360
361 assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::from(1));
362 assert_eq!(
363 details[DETAIL_STDERR],
364 serde_json::Value::from("ERROR: No matching distribution")
365 );
366 assert_eq!(details["step"], serde_json::Value::from("pip install"));
367 assert!(!details.contains_key(DETAIL_STDERR_TRUNCATED));
368 }
369
370 #[test]
372 fn the_new_variant_shares_the_venv_build_failed_code() {
373 assert_eq!(pip_failure("boom", Some(1)).code(), "venv_build_failed");
374 assert_eq!(
375 HostError::VenvBuild {
376 message: "boom".into()
377 }
378 .code(),
379 "venv_build_failed"
380 );
381 }
382
383 #[test]
387 fn a_signal_killed_child_reports_a_null_exit_code() {
388 let details = execution_details(pip_failure("", None));
389
390 assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::Null);
391 assert!(
392 pip_failure("", None)
393 .to_string()
394 .contains("was killed by a signal")
395 );
396 }
397
398 #[test]
401 fn an_oversized_stderr_is_truncated_to_its_tail_and_flagged() {
402 let stderr = format!("{}TAIL-MARKER", "x".repeat(STDERR_EXCERPT_BYTES * 2));
403 let details = execution_details(pip_failure(&stderr, Some(2)));
404
405 let excerpt = details[DETAIL_STDERR].as_str().unwrap();
406 assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
407 assert!(
408 excerpt.ends_with("TAIL-MARKER"),
409 "the tail holds the real error, so it must survive truncation"
410 );
411 assert_eq!(
412 details[DETAIL_STDERR_TRUNCATED],
413 serde_json::Value::Bool(true)
414 );
415 }
416
417 #[test]
419 fn truncation_respects_char_boundaries() {
420 let stderr = "é".repeat(STDERR_EXCERPT_BYTES);
422 let details = execution_details(pip_failure(&stderr, Some(1)));
423
424 let excerpt = details[DETAIL_STDERR].as_str().unwrap();
425 assert!(excerpt.chars().all(|c| c == 'é'));
426 assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
427 }
428
429 #[test]
432 fn a_timeout_still_maps_to_the_timeout_variant() {
433 let err = HostError::Timeout { timeout_secs: 30 };
434 match *err.into_plugin_error("p") {
435 PluginError::Timeout { timeout_ms, .. } => assert_eq!(timeout_ms, 30_000),
436 other => panic!("expected Timeout, got {other:?}"),
437 }
438 }
439
440 #[test]
441 fn a_size_cap_breach_reports_both_the_size_and_the_limit() {
442 let details = execution_details(HostError::TaskTooLarge {
443 size: 2048,
444 limit: 1024,
445 });
446
447 assert_eq!(details[DETAIL_SIZE], serde_json::Value::from(2048));
448 assert_eq!(details[DETAIL_LIMIT], serde_json::Value::from(1024));
449 }
450
451 #[test]
454 fn message_only_variants_contribute_no_details() {
455 for err in [
456 HostError::VenvBuild {
457 message: "m".into(),
458 },
459 HostError::WorkerStart {
460 message: "m".into(),
461 },
462 HostError::WorkerDied {
463 message: "m".into(),
464 },
465 HostError::WorkerError {
466 message: "m".into(),
467 },
468 HostError::Protocol {
469 message: "m".into(),
470 },
471 HostError::Credential {
472 message: "m".into(),
473 },
474 ] {
475 assert!(details_of(err).is_empty());
476 }
477 }
478
479 #[test]
483 fn the_display_message_still_names_the_step_context_and_failure() {
484 let message = pip_failure("ERROR: could not build wheel", Some(1)).to_string();
485
486 assert!(message.contains("pip install"));
487 assert!(message.contains("requirements.txt"));
488 assert!(message.contains("exited 1"));
489 assert!(message.contains("ERROR: could not build wheel"));
490 }
491}