1use std::collections::HashMap;
4use std::sync::OnceLock;
5
6pub static EXIT_CODE_HINTS: OnceLock<HashMap<i32, &'static str>> = OnceLock::new();
8
9fn exit_code_hints_map() -> &'static HashMap<i32, &'static str> {
10 EXIT_CODE_HINTS.get_or_init(|| {
13 let mut m = HashMap::new();
14 m.insert(1, "subprocess returned a generic error; check logs under the XDG data dir (llm-backend.log)");
15 m.insert(2, "incorrect subprocess CLI usage; review flags passed to the binary");
16 m.insert(101, "kernel SIGABRT; possible panic inside the subprocess");
17 m.insert(126, "binary not executable; run chmod +x on the binary");
18 m.insert(127, "binary not found on PATH; verify which codex / which claude");
19 m.insert(134, "SIGABRT; internal subprocess abort — report upstream");
20 m.insert(137, "SIGKILL from OOM killer or external signal; check dmesg and lower --llm-parallelism");
21 m.insert(139, "SIGSEGV; report upstream with preserved stderr");
22 m.insert(143, "external SIGTERM; PreToolUse hook or timeout cascaded");
23 m
24 })
25}
26
27pub fn diagnose_exit_code(code: Option<i32>, signal: Option<i32>) -> String {
29 if let Some(sig) = signal {
30 return match sig {
31 2 => "SIGINT received; user cancelled the operation".to_string(),
32 9 => "external SIGKILL; kernel OOM killer".to_string(),
33 15 => "external SIGTERM; PreToolUse hook or timeout cascaded".to_string(),
34 other => format!("unmapped Unix signal {other}; see `kill -l`"),
35 };
36 }
37 let code = code.unwrap_or(-1);
38 exit_code_hints_map()
39 .get(&code)
40 .map(|s| s.to_string())
41 .unwrap_or_else(|| {
42 format!("unknown exit code {code}; consult upstream docs for the binary")
43 })
44}
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49
50 #[test]
51 fn oom_killer_hint_contains_oom() {
52 let hint = diagnose_exit_code(Some(137), None);
53 assert!(hint.contains("OOM"), "expected OOM in: {hint}");
54 }
55
56 #[test]
57 fn not_found_hint_contains_path() {
58 let hint = diagnose_exit_code(Some(127), None);
59 assert!(hint.contains("PATH"), "expected PATH in: {hint}");
60 }
61
62 #[test]
63 fn sigterm_signal_hint() {
64 let hint = diagnose_exit_code(None, Some(15));
65 assert!(hint.contains("SIGTERM"), "expected SIGTERM in: {hint}");
66 }
67
68 #[test]
69 fn unknown_code_returns_generic() {
70 let hint = diagnose_exit_code(Some(42), None);
71 assert!(hint.contains("42"), "expected 42 in: {hint}");
72 }
73
74 #[test]
75 fn nine_exit_codes_mapped() {
76 assert_eq!(exit_code_hints_map().len(), 9);
77 }
78}
79
80pub const DIAG_TAIL_BYTES: usize = 1024;
89
90#[derive(Debug, Clone, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum LlmBackendError {
105 NonZeroExit {
110 exit_code: Option<i32>,
112 signal: Option<i32>,
115 stdout_tail: String,
117 stderr_tail: String,
119 binary: String,
121 hint: String,
123 },
124 SpawnFailed {
129 binary: String,
131 source: String,
133 },
134 Timeout {
137 secs: u64,
139 binary: String,
141 },
142 NoBackendsAvailable,
146}
147
148impl LlmBackendError {
149 pub fn hint(&self) -> String {
151 match self {
152 Self::NonZeroExit { hint, .. } => hint.clone(),
153 Self::SpawnFailed { binary, source } => {
154 format!(
155 "spawn of '{binary}' failed: {source}; check that the binary exists, is executable, and required env vars (PATH, HOME, ...) are set"
156 )
157 }
158 Self::Timeout { secs, binary } => {
159 format!(
160 "subprocess '{binary}' exceeded the {secs}s timeout; \
161 override via `config set embedding.timeout_secs <secs>`"
162 )
163 }
164 Self::NoBackendsAvailable => "no backends succeeded and no fallback was configured; \
165 pass --llm-fallback=codex,claude or --skip-embedding-on-failure"
166 .to_string(),
167 }
168 }
169
170 pub fn truncate_tail(raw: &[u8], max_bytes: usize) -> String {
173 if raw.len() <= max_bytes {
174 return String::from_utf8_lossy(raw).into_owned();
175 }
176 let mut cut = max_bytes.min(raw.len());
182 while cut > 0 && (raw[cut] >= 0x80 && raw[cut] < 0xC0) {
183 cut -= 1;
184 }
185 let mut s = String::from_utf8_lossy(&raw[..cut]).into_owned();
186 s.push_str("...[truncated]");
187 s
188 }
189}
190
191impl std::fmt::Display for LlmBackendError {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 match self {
194 Self::NonZeroExit {
195 exit_code,
196 signal,
197 stdout_tail,
198 stderr_tail,
199 binary,
200 ..
201 } => {
202 let code_repr = match (exit_code, signal) {
203 (Some(c), _) => format!("exit {c}"),
204 (None, Some(s)) => format!("signal {s}"),
205 _ => "unknown status".to_string(),
206 };
207 write!(
208 f,
209 "{binary} subprocess failed: {code_repr}; \
210 stdout_tail={stdout_tail:?}; stderr_tail={stderr_tail:?}"
211 )
212 }
213 Self::SpawnFailed { binary, source } => {
214 write!(f, "{binary} spawn failed: {source}")
215 }
216 Self::Timeout { secs, binary } => {
217 write!(f, "{binary} timed out after {secs}s")
218 }
219 Self::NoBackendsAvailable => {
220 write!(f, "no LLM backends available; fallback chain exhausted")
221 }
222 }
223 }
224}
225
226impl std::error::Error for LlmBackendError {}
227
228pub fn into_legacy_embedding(err: &LlmBackendError) -> crate::errors::AppError {
233 crate::errors::AppError::Embedding(crate::i18n::validation::embedding_detail(err))
234}
235
236#[cfg(test)]
237mod llm_backend_error_tests {
238 use super::*;
239
240 #[test]
241 fn truncate_tail_short_returns_input() {
242 let s = LlmBackendError::truncate_tail(b"hello", 1024);
243 assert_eq!(s, "hello");
244 }
245
246 #[test]
247 fn truncate_tail_long_appends_marker() {
248 let raw = vec![b'a'; 2048];
249 let s = LlmBackendError::truncate_tail(&raw, 1024);
250 assert!(s.ends_with("...[truncated]"));
251 assert!(s.starts_with(&"a".repeat(1024)));
253 }
254
255 #[test]
256 fn truncate_tail_respects_utf8_boundary() {
257 let raw = "é".repeat(600).into_bytes(); let s = LlmBackendError::truncate_tail(&raw, 1023);
263 assert_eq!(s.len(), 1022 + "...[truncated]".len());
264 assert!(s.ends_with("...[truncated]"));
265 let cut = s.trim_end_matches("...[truncated]").len();
268 let prefix = &s[..cut];
269 assert!(std::str::from_utf8(prefix.as_bytes()).is_ok());
270 }
271
272 #[test]
273 fn no_backends_hint_mentions_fallback() {
274 let err = LlmBackendError::NoBackendsAvailable;
275 assert!(err.hint().contains("--llm-fallback"));
276 }
277
278 #[test]
279 fn spawn_failed_hint_mentions_binary() {
280 let err = LlmBackendError::SpawnFailed {
281 binary: "claude".into(),
282 source: "No such file or directory".into(),
283 };
284 let h = err.hint();
285 assert!(h.contains("claude"));
286 assert!(h.contains("No such file or directory"));
287 }
288
289 #[test]
290 fn timeout_hint_mentions_config_key() {
291 let err = LlmBackendError::Timeout {
292 secs: 300,
293 binary: "codex".into(),
294 };
295 assert!(err.hint().contains("embedding.timeout_secs"));
296 }
297
298 #[test]
299 fn non_zero_exit_display_includes_stderr_tail() {
300 let err = LlmBackendError::NonZeroExit {
301 exit_code: Some(1),
302 signal: None,
303 stdout_tail: "out-1k".into(),
304 stderr_tail: "err-1k".into(),
305 binary: "codex".into(),
306 hint: "diagnostic".into(),
307 };
308 let s = err.to_string();
309 assert!(s.contains("codex"));
310 assert!(s.contains("exit 1"));
311 assert!(s.contains("err-1k"));
312 }
313}