llm: prevent orphaned subprocesses with PR_SET_PDEATHSIG

When the daemon is killed, spawned claude CLI processes survived as
orphans and burned CPU indefinitely. Use pre_exec to set
PR_SET_PDEATHSIG(SIGTERM) so children die with their parent.

Also fix byte-index truncation of stderr preview (same UTF-8 panic
pattern fixed in the daemon).

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-05 21:15:57 -05:00
parent 5eb8a4eb6a
commit d919bc3e51

View file

@ -1,27 +1,38 @@
// LLM utilities: model invocation and response parsing // LLM utilities: model invocation and response parsing
// //
// Shared by digest, audit, enrich, consolidate, knowledge, and fact_mine. // Calls claude CLI as a subprocess. Uses prctl(PR_SET_PDEATHSIG)
// so child processes die when the daemon exits, preventing orphans.
use crate::store::Store; use crate::store::Store;
use regex::Regex; use regex::Regex;
use std::fs; use std::fs;
use std::os::unix::process::CommandExt;
use std::process::Command; use std::process::Command;
/// Call a model via claude CLI. Returns the response text. /// Call a model via claude CLI. Returns the response text.
///
/// Sets PR_SET_PDEATHSIG on the child so it gets SIGTERM if the
/// parent daemon exits — no more orphaned claude processes.
fn call_model(model: &str, prompt: &str) -> Result<String, String> { fn call_model(model: &str, prompt: &str) -> Result<String, String> {
// Write prompt to temp file (claude CLI needs file input for large prompts) // Write prompt to temp file (claude CLI needs file input for large prompts)
// Use thread ID + PID to avoid collisions under parallel rayon calls
let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt", let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt",
std::process::id(), std::thread::current().id())); std::process::id(), std::thread::current().id()));
fs::write(&tmp, prompt) fs::write(&tmp, prompt)
.map_err(|e| format!("write temp prompt: {}", e))?; .map_err(|e| format!("write temp prompt: {}", e))?;
let result = Command::new("claude") let result = unsafe {
Command::new("claude")
.args(["-p", "--model", model, "--tools", "", "--no-session-persistence"]) .args(["-p", "--model", model, "--tools", "", "--no-session-persistence"])
.stdin(fs::File::open(&tmp).map_err(|e| format!("open temp: {}", e))?) .stdin(fs::File::open(&tmp).map_err(|e| format!("open temp: {}", e))?)
.env_remove("CLAUDECODE") .env_remove("CLAUDECODE")
.output(); .pre_exec(|| {
// Kill this child if the parent dies
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
Ok(())
})
.output()
};
fs::remove_file(&tmp).ok(); fs::remove_file(&tmp).ok();
@ -31,7 +42,8 @@ fn call_model(model: &str, prompt: &str) -> Result<String, String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else { } else {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("claude exited {}: {}", output.status, stderr.trim())) let preview: String = stderr.chars().take(500).collect();
Err(format!("claude exited {}: {}", output.status, preview.trim()))
} }
} }
Err(e) => Err(format!("spawn claude: {}", e)), Err(e) => Err(format!("spawn claude: {}", e)),
@ -75,7 +87,8 @@ pub(crate) fn parse_json_response(response: &str) -> Result<serde_json::Value, S
} }
} }
Err(format!("no valid JSON in response: {}...", &cleaned[..cleaned.len().min(200)])) let preview: String = cleaned.chars().take(200).collect();
Err(format!("no valid JSON in response: {preview}..."))
} }
/// Get semantic keys (non-journal, non-system) for prompt context. /// Get semantic keys (non-journal, non-system) for prompt context.