use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::releases::{Probe, ReleaseAnswer};
use crate::{git, home, ids};
const PASSED_THROUGH: &[&str] = &[
"PATH",
"HOME",
"SYSTEMROOT",
"USERPROFILE",
];
const POLL: Duration = Duration::from_millis(20);
pub struct Probed {
pub answer: ReleaseAnswer,
pub elapsed_ms: u128,
}
pub enum Checkout {
At(PathBuf),
None(String),
}
pub fn run(probe: &Probe, checkout: &Checkout) -> Probed {
let started = Instant::now();
let answer = match probe {
Probe::Script {
script,
args,
timeout_seconds,
} => match checkout {
Checkout::At(root) => script_form(root, script, args, *timeout_seconds),
Checkout::None(reason) => not_answered(reason.clone()),
},
Probe::Shell {
shell,
timeout_seconds,
} => shell_form(shell, *timeout_seconds),
};
Probed {
answer,
elapsed_ms: started.elapsed().as_millis(),
}
}
fn script_form(root: &Path, script: &Path, args: &[String], timeout_seconds: u64) -> ReleaseAnswer {
let executable = root.join(script);
if !executable.is_file() {
return not_answered(format!(
"the probe script {} is not a file in {}, so the repository being released does not \
carry it",
script.display(),
root.display()
));
}
let mut command = Command::new(&executable);
command.args(args).current_dir(root);
read(
command,
Duration::from_secs(timeout_seconds),
&script_label(script, args),
)
}
fn shell_form(shell: &str, timeout_seconds: u64) -> ReleaseAnswer {
let scratch = match scratch_directory() {
Ok(scratch) => scratch,
Err(failure) => {
return not_answered(format!("a shell probe has nowhere to run: {failure}"))
}
};
let mut command = Command::new("sh");
command.arg("-c").arg(shell).current_dir(&scratch);
let answer = read(
command,
Duration::from_secs(timeout_seconds),
&format!("the shell probe {shell:?}"),
);
let _ = std::fs::remove_dir_all(&scratch);
answer
}
fn scratch_directory() -> crate::Result<PathBuf> {
let directory = home::probes_dir()?.join(ids::unique());
home::ensure_dir(&directory)?;
Ok(directory)
}
fn script_label(script: &Path, args: &[String]) -> String {
let mut label = format!("the probe script {}", script.display());
for argument in args {
label.push(' ');
label.push_str(argument);
}
label
}
fn read(mut command: Command, bound: Duration, label: &str) -> ReleaseAnswer {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear();
for name in PASSED_THROUGH {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
git::detach_process_group(&mut command);
let mut child = match command.spawn() {
Ok(child) => child,
Err(failure) => return not_answered(format!("{label} could not be started: {failure}")),
};
let started = Instant::now();
let mut stdout = child.stdout.take().expect("stdout was piped");
let mut stderr = child.stderr.take().expect("stderr was piped");
let (sender, receiver) = mpsc::channel();
let out_sender = sender.clone();
let out_reader = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = stdout.read_to_end(&mut buffer);
let _ = out_sender.send(());
buffer
});
let err_reader = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = stderr.read_to_end(&mut buffer);
let _ = sender.send(());
buffer
});
let drained = drained_within(&receiver, started, bound);
let exited = if drained {
exited_within(&mut child, started, bound)
} else {
None
};
let Some(status) = exited else {
git::terminate_group(&child);
let _ = child.kill();
let _ = child.wait();
let _ = out_reader.join();
let _ = err_reader.join();
return not_answered(format!(
"{label} timed out after {bound}s",
bound = bound.as_secs()
));
};
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
let status = match status {
Ok(status) => status,
Err(failure) => return not_answered(format!("{label} could not be collected: {failure}")),
};
if !status.success() {
return not_answered(format!(
"{label} exited {code}{said}",
code = status
.code()
.map_or_else(|| "on a signal".to_owned(), |code| code.to_string()),
said = diagnosis(&stderr),
));
}
answer_from(&stdout, label)
}
fn answer_from(stdout: &[u8], label: &str) -> ReleaseAnswer {
let Ok(printed) = std::str::from_utf8(stdout) else {
return not_answered(format!("{label} printed bytes that are not text"));
};
if printed.trim().is_empty() {
return ReleaseAnswer::NoRelease;
}
let mut lines = printed
.lines()
.map(str::trim)
.filter(|line| !line.is_empty());
let first = lines.next().unwrap_or_default();
if lines.next().is_some() {
return not_answered(format!(
"{label} printed more than one line; a probe answers with the one version that is \
released right now"
));
}
if first.chars().any(|c| c.is_control()) {
return not_answered(format!(
"{label} printed control characters, which no version holds"
));
}
ReleaseAnswer::Released {
version: first.to_owned(),
}
}
fn diagnosis(stderr: &[u8]) -> String {
const LIMIT: usize = 400;
let said = String::from_utf8_lossy(stderr);
let said = said.trim();
if said.is_empty() {
return String::new();
}
let clipped: String = said.chars().take(LIMIT).collect();
format!(" and said {clipped:?}")
}
fn drained_within(receiver: &mpsc::Receiver<()>, started: Instant, bound: Duration) -> bool {
for _ in 0..2 {
if receiver
.recv_timeout(bound.saturating_sub(started.elapsed()))
.is_err()
{
return false;
}
}
true
}
fn exited_within(
child: &mut Child,
started: Instant,
bound: Duration,
) -> Option<std::io::Result<std::process::ExitStatus>> {
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(Ok(status)),
Err(failure) => return Some(Err(failure)),
Ok(None) if started.elapsed() >= bound => return None,
Ok(None) => std::thread::sleep(POLL),
}
}
}
fn not_answered(reason: String) -> ReleaseAnswer {
ReleaseAnswer::NotAnswered { reason }
}