use std::collections::HashMap;
use std::fmt;
use ppe::praxis_policy_core::error::PluginError;
const STDERR_EXCERPT_BYTES: usize = 4096;
const DETAIL_EXIT_CODE: &str = "exit_code";
const DETAIL_STDERR: &str = "stderr_excerpt";
const DETAIL_STDERR_TRUNCATED: &str = "stderr_truncated";
const DETAIL_TIMEOUT_SECS: &str = "timeout_secs";
const DETAIL_SIZE: &str = "size_bytes";
const DETAIL_LIMIT: &str = "limit_bytes";
#[derive(Debug, Clone, Default)]
pub struct ProcessOutput {
pub exit_code: Option<i32>,
pub stderr: String,
}
impl ProcessOutput {
pub fn from_output(output: &std::process::Output) -> Self {
Self {
exit_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}
}
fn stderr_excerpt(&self) -> (&str, bool) {
if self.stderr.len() <= STDERR_EXCERPT_BYTES {
return (self.stderr.as_str(), false);
}
let mut start = self.stderr.len() - STDERR_EXCERPT_BYTES;
while start < self.stderr.len() && !self.stderr.is_char_boundary(start) {
start += 1;
}
(&self.stderr[start..], true)
}
fn describe(&self) -> String {
let status = match self.exit_code {
Some(code) => format!("exited {code}"),
None => "was killed by a signal".to_string(),
};
let (excerpt, _) = self.stderr_excerpt();
if excerpt.is_empty() {
status
} else {
format!("{status}: {excerpt}")
}
}
fn write_details(&self, details: &mut HashMap<String, serde_json::Value>) {
details.insert(
DETAIL_EXIT_CODE.to_string(),
match self.exit_code {
Some(code) => serde_json::Value::from(code),
None => serde_json::Value::Null,
},
);
let (excerpt, truncated) = self.stderr_excerpt();
details.insert(DETAIL_STDERR.to_string(), serde_json::Value::from(excerpt));
if truncated {
details.insert(
DETAIL_STDERR_TRUNCATED.to_string(),
serde_json::Value::Bool(true),
);
}
}
}
#[derive(Debug)]
pub enum HostError {
Config { message: String },
VenvBuild { message: String },
VenvCommand {
step: &'static str,
context: String,
output: ProcessOutput,
},
WorkerStart { message: String },
WorkerDied { message: String },
Timeout { timeout_secs: u64 },
TaskTooLarge { size: usize, limit: usize },
ResponseTooLarge { size: usize, limit: usize },
WorkerError { message: String },
Protocol { message: String },
Credential { message: String },
LegacyShim { hook: String, message: String },
}
impl fmt::Display for HostError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Config { message } => write!(f, "configuration error: {message}"),
Self::VenvBuild { message } => write!(f, "venv build failed: {message}"),
Self::VenvCommand {
step,
context,
output,
} => write!(
f,
"venv build failed: `{step}` on {context} {}",
output.describe()
),
Self::WorkerStart { message } => write!(f, "worker failed to start: {message}"),
Self::WorkerDied { message } => write!(f, "worker process died: {message}"),
Self::Timeout { timeout_secs } => {
write!(f, "worker did not respond within {timeout_secs}s")
},
Self::TaskTooLarge { size, limit } => write!(
f,
"serialized task is {size} bytes, over the {limit}-byte max_content_size"
),
Self::ResponseTooLarge { size, limit } => write!(
f,
"worker response frame is at least {size} bytes, over the \
{limit}-byte max_content_size; the frame was discarded rather \
than buffered"
),
Self::WorkerError { message } => write!(f, "worker returned an error: {message}"),
Self::Protocol { message } => write!(f, "protocol error: {message}"),
Self::Credential { message } => write!(f, "credential error: {message}"),
Self::LegacyShim { hook, message } => write!(
f,
"hook '{hook}' cannot be served by this worker, which predates cmf hook \
support: {message}"
),
}
}
}
impl std::error::Error for HostError {}
impl HostError {
pub fn code(&self) -> &'static str {
match self {
Self::Config { .. } => "config",
Self::VenvBuild { .. } | Self::VenvCommand { .. } => "venv_build_failed",
Self::WorkerStart { .. } => "worker_start_failed",
Self::WorkerDied { .. } => "worker_died",
Self::Timeout { .. } => "timeout",
Self::TaskTooLarge { .. } => "task_too_large",
Self::ResponseTooLarge { .. } => "response_too_large",
Self::WorkerError { .. } => "worker_error",
Self::Protocol { .. } => "protocol_error",
Self::Credential { .. } => "credential_error",
Self::LegacyShim { .. } => "legacy_shim_unsupported",
}
}
pub fn details(&self) -> HashMap<String, serde_json::Value> {
let mut details = HashMap::new();
match self {
Self::VenvCommand { step, output, .. } => {
details.insert("step".to_string(), serde_json::Value::from(*step));
output.write_details(&mut details);
},
Self::Timeout { timeout_secs } => {
details.insert(
DETAIL_TIMEOUT_SECS.to_string(),
serde_json::Value::from(*timeout_secs),
);
},
Self::TaskTooLarge { size, limit } | Self::ResponseTooLarge { size, limit } => {
details.insert(DETAIL_SIZE.to_string(), serde_json::Value::from(*size));
details.insert(DETAIL_LIMIT.to_string(), serde_json::Value::from(*limit));
},
Self::Config { .. }
| Self::VenvBuild { .. }
| Self::WorkerStart { .. }
| Self::WorkerDied { .. }
| Self::WorkerError { .. }
| Self::Protocol { .. }
| Self::Credential { .. }
| Self::LegacyShim { .. } => {},
}
details
}
pub fn into_plugin_error(self, plugin_name: &str) -> Box<PluginError> {
match self {
Self::Timeout { timeout_secs } => PluginError::Timeout {
plugin_name: plugin_name.to_string(),
timeout_ms: timeout_secs.saturating_mul(1000),
proto_error_code: None,
}
.boxed(),
Self::Config { ref message } => PluginError::Config {
message: format!("plugin '{plugin_name}' (isolated_venv): {message}"),
}
.boxed(),
other => {
let code = other.code();
PluginError::Execution {
plugin_name: plugin_name.to_string(),
message: other.to_string(),
source: None,
code: Some(code.to_string()),
details: other.details(),
proto_error_code: None,
}
.boxed()
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn details_of(err: HostError) -> HashMap<String, serde_json::Value> {
err.details()
}
fn execution_details(err: HostError) -> HashMap<String, serde_json::Value> {
match *err.into_plugin_error("p") {
PluginError::Execution { details, .. } => details,
other => panic!("expected Execution, got {other:?}"),
}
}
fn pip_failure(stderr: &str, exit_code: Option<i32>) -> HostError {
HostError::VenvCommand {
step: "pip install",
context: "requirements.txt".to_string(),
output: ProcessOutput {
exit_code,
stderr: stderr.to_string(),
},
}
}
#[test]
fn a_failed_pip_install_carries_its_exit_code_and_stderr_in_details() {
let details = execution_details(pip_failure("ERROR: No matching distribution", Some(1)));
assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::from(1));
assert_eq!(
details[DETAIL_STDERR],
serde_json::Value::from("ERROR: No matching distribution")
);
assert_eq!(details["step"], serde_json::Value::from("pip install"));
assert!(!details.contains_key(DETAIL_STDERR_TRUNCATED));
}
#[test]
fn the_new_variant_shares_the_venv_build_failed_code() {
assert_eq!(pip_failure("boom", Some(1)).code(), "venv_build_failed");
assert_eq!(
HostError::VenvBuild {
message: "boom".into()
}
.code(),
"venv_build_failed"
);
}
#[test]
fn a_signal_killed_child_reports_a_null_exit_code() {
let details = execution_details(pip_failure("", None));
assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::Null);
assert!(
pip_failure("", None)
.to_string()
.contains("was killed by a signal")
);
}
#[test]
fn an_oversized_stderr_is_truncated_to_its_tail_and_flagged() {
let stderr = format!("{}TAIL-MARKER", "x".repeat(STDERR_EXCERPT_BYTES * 2));
let details = execution_details(pip_failure(&stderr, Some(2)));
let excerpt = details[DETAIL_STDERR].as_str().unwrap();
assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
assert!(
excerpt.ends_with("TAIL-MARKER"),
"the tail holds the real error, so it must survive truncation"
);
assert_eq!(
details[DETAIL_STDERR_TRUNCATED],
serde_json::Value::Bool(true)
);
}
#[test]
fn truncation_respects_char_boundaries() {
let stderr = "é".repeat(STDERR_EXCERPT_BYTES);
let details = execution_details(pip_failure(&stderr, Some(1)));
let excerpt = details[DETAIL_STDERR].as_str().unwrap();
assert!(excerpt.chars().all(|c| c == 'é'));
assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
}
#[test]
fn a_timeout_still_maps_to_the_timeout_variant() {
let err = HostError::Timeout { timeout_secs: 30 };
match *err.into_plugin_error("p") {
PluginError::Timeout { timeout_ms, .. } => assert_eq!(timeout_ms, 30_000),
other => panic!("expected Timeout, got {other:?}"),
}
}
#[test]
fn a_size_cap_breach_reports_both_the_size_and_the_limit() {
let details = execution_details(HostError::TaskTooLarge {
size: 2048,
limit: 1024,
});
assert_eq!(details[DETAIL_SIZE], serde_json::Value::from(2048));
assert_eq!(details[DETAIL_LIMIT], serde_json::Value::from(1024));
}
#[test]
fn message_only_variants_contribute_no_details() {
for err in [
HostError::VenvBuild {
message: "m".into(),
},
HostError::WorkerStart {
message: "m".into(),
},
HostError::WorkerDied {
message: "m".into(),
},
HostError::WorkerError {
message: "m".into(),
},
HostError::Protocol {
message: "m".into(),
},
HostError::Credential {
message: "m".into(),
},
] {
assert!(details_of(err).is_empty());
}
}
#[test]
fn the_display_message_still_names_the_step_context_and_failure() {
let message = pip_failure("ERROR: could not build wheel", Some(1)).to_string();
assert!(message.contains("pip install"));
assert!(message.contains("requirements.txt"));
assert!(message.contains("exited 1"));
assert!(message.contains("ERROR: could not build wheel"));
}
}