use super::Doctor;
use crate::service::Ran;
use hennery_host::AgentCommand;
use std::collections::BTreeSet;
use std::io::{BufRead, Read, Write};
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
pub const MAX_OUTPUT: u64 = 4096;
#[cfg(not(test))]
pub const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(20);
#[cfg(test)]
pub const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(1);
pub const CLI_TIMEOUT: Duration = Duration::from_secs(20);
const DRAIN: Duration = Duration::from_secs(5);
pub fn agent_env(doctor: &Doctor) -> Vec<(String, String)> {
let cx = doctor.cx;
let linux = cx.platform == crate::service::Platform::Linux;
let kept: &[&str] = if linux {
&["HOME", "USER", "LOGNAME", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]
} else {
&["HOME", "USER", "LOGNAME"]
};
let mut env: Vec<(String, String)> = kept
.iter()
.filter_map(|name| cx.env.get(*name).map(|v| (name.to_string(), v.clone())))
.collect();
let shell = super::service::service_shell_of(doctor).unwrap_or_else(|| cx.shell.display().to_string());
env.push(("SHELL".to_string(), shell));
let path = super::service::service_path_of(doctor)
.or_else(|| cx.env.get("PATH").cloned())
.unwrap_or_else(|| "/usr/bin:/bin".to_string());
env.push(("PATH".to_string(), path));
env
}
fn stripped(name: &str) -> bool {
use hennery_host::adapter::{HOST_LOG_VARS, HOST_SECRET_VARS, NESTING_VARS};
NESTING_VARS
.iter()
.chain(HOST_SECRET_VARS)
.chain(HOST_LOG_VARS)
.any(|v| *v == name)
}
pub struct Groups(Mutex<Option<BTreeSet<libc::pid_t>>>);
impl Groups {
pub const fn new() -> Self {
Self(Mutex::new(Some(BTreeSet::new())))
}
pub fn spawn(&self, cmd: &mut Command) -> std::io::Result<Child> {
let mut groups = self.0.lock().unwrap_or_else(|e| e.into_inner());
let Some(groups) = groups.as_mut() else {
return Err(std::io::ErrorKind::Interrupted.into());
};
let child = cmd.spawn()?;
if let Ok(pgid) = libc::pid_t::try_from(child.id()) {
groups.insert(pgid);
}
Ok(child)
}
pub fn kill(&self, child: &mut Child) {
if let Ok(pgid) = libc::pid_t::try_from(child.id()) {
unsafe { libc::kill(-pgid, libc::SIGKILL) };
if let Some(groups) = self.0.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
groups.remove(&pgid);
}
}
let _ = child.wait();
}
pub fn kill_all(&self) {
let groups = self.0.lock().unwrap_or_else(|e| e.into_inner()).take();
for pgid in groups.into_iter().flatten() {
unsafe { libc::kill(-pgid, libc::SIGKILL) };
}
}
}
static GROUPS: Groups = Groups::new();
pub fn kill_all() {
GROUPS.kill_all();
}
fn spawn(cmd: &mut Command) -> std::io::Result<Child> {
GROUPS.spawn(cmd)
}
fn kill_group(child: &mut Child) {
GROUPS.kill(child)
}
fn command(program: &Path, args: &[String], env: &[(String, String)]) -> Command {
let home = env
.iter()
.find(|(k, _)| k == "HOME")
.map(|(_, v)| v.as_str())
.unwrap_or("/");
let mut cmd = Command::new(program);
cmd.args(args)
.env_clear()
.envs(env.iter().filter(|(k, _)| !stripped(k)).cloned())
.current_dir(home)
.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd
}
fn reader(from: impl Read + Send + 'static) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = from.take(MAX_OUTPUT).read_to_end(&mut bytes);
let _ = tx.send(bytes);
});
rx
}
pub fn run(program: &Path, args: &[&str], env: &[(String, String)], timeout: Duration, output: bool) -> Option<Ran> {
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
let mut cmd = command(program, &args, env);
if output {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
}
let mut child = spawn(&mut cmd).ok()?;
let streams = output.then(|| {
(
reader(child.stdout.take().expect("piped")),
reader(child.stderr.take().expect("piped")),
)
});
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
_ => break None,
}
};
kill_group(&mut child);
let status = status?;
let (stdout, stderr) = match streams {
Some((out, err)) => (
out.recv_timeout(DRAIN).unwrap_or_default(),
err.recv_timeout(DRAIN).unwrap_or_default(),
),
None => (Vec::new(), Vec::new()),
};
Some(Ran {
ok: status.success(),
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Started {
Answered(Option<String>),
Failed(String),
}
const INITIALIZE: &str =
r#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"#;
pub const NO_ANSWER: &str = "it did not answer `initialize`";
const MAX_VERSION: usize = 64;
pub fn readable_version(version: &str) -> String {
let readable = !version.is_empty()
&& version.len() <= MAX_VERSION
&& version.chars().all(|c| c.is_ascii_alphanumeric() || ".+-".contains(c));
if readable {
version.to_string()
} else {
"an unreadable version".to_string()
}
}
const MAX_LINES: usize = 1000;
pub fn initialize(agent: &AgentCommand, env: &[(String, String)], timeout: Duration) -> Started {
let mut all = env.to_vec();
all.extend(agent.env.iter().cloned());
let mut cmd = command(Path::new(&agent.program), &agent.args, &all);
cmd.stdin(Stdio::piped()).stdout(Stdio::piped());
let mut child = match spawn(&mut cmd) {
Ok(child) => child,
Err(err) => return Started::Failed(format!("it cannot be started: {}", err.kind())),
};
let (tx, rx) = mpsc::channel();
let stdout = child.stdout.take().expect("piped");
let lines = std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout.take(MAX_OUTPUT * 256))
.lines()
.take(MAX_LINES)
{
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut stdin = child.stdin.take().expect("piped");
let sent = stdin
.write_all(format!("{INITIALIZE}\n").as_bytes())
.and_then(|()| stdin.flush());
let deadline = Instant::now() + timeout;
let started = if sent.is_err() {
Started::Failed("it exited before it read `initialize`".to_string())
} else {
loop {
let left = deadline.saturating_duration_since(Instant::now());
match rx.recv_timeout(left) {
Ok(line) => {
let Ok(message) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if message.get("id") != Some(&serde_json::json!(0)) {
continue;
}
break match message.get("result") {
Some(result) => Started::Answered(
result
.pointer("/agentInfo/version")
.and_then(|v| v.as_str())
.map(readable_version),
),
None => Started::Failed("it answered `initialize` with an error".to_string()),
};
}
Err(mpsc::RecvTimeoutError::Timeout) => {
break Started::Failed(format!("{NO_ANSWER} within {timeout:?}"));
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break Started::Failed("it exited without answering `initialize`".to_string());
}
}
}
};
drop(stdin);
kill_group(&mut child);
drop(lines);
started
}
pub fn version_in(text: &str) -> Option<(u64, u64, u64)> {
text.split(|c: char| !(c.is_ascii_digit() || c == '.'))
.find_map(|word| {
let mut parts = word.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
})
}
pub fn far_apart(a: (u64, u64, u64), b: (u64, u64, u64)) -> bool {
a.0 != b.0 || a.1 != b.1
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cli {
pub program: std::path::PathBuf,
pub args: Vec<String>,
}
impl Cli {
pub fn plain(program: impl Into<std::path::PathBuf>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
}
}
pub fn run(&self, extra: &[&str], env: &[(String, String)], output: bool) -> Option<Ran> {
let mut args: Vec<&str> = self.args.iter().map(String::as_str).collect();
args.extend_from_slice(extra);
let mut env = env.to_vec();
env.push(("DISABLE_AUTOUPDATER".to_string(), "1".to_string()));
run(&self.program, &args, &env, CLI_TIMEOUT, output)
}
pub fn shown(&self) -> String {
std::iter::once(self.program.display().to_string())
.chain(self.args.iter().cloned())
.collect::<Vec<_>>()
.join(" ")
}
}
pub fn cli_version(cli: &Cli, env: &[(String, String)]) -> Option<(u64, u64, u64)> {
let ran = cli.run(&["--version"], env, true)?;
if !ran.ok {
return None;
}
version_in(&ran.stdout)
}