use crate::bounded_push::Patience;
use crate::push_progress::Clock;
use crate::output_capture::Capture;
use crate::quiet_ledger;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolCommand {
pub arguments: Vec<OsString>,
pub directory: PathBuf,
pub set: Vec<(OsString, OsString)>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ToolLimits {
pub window: Duration,
pub ceiling: Duration,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ToolEnd {
Succeeded,
Failed(String),
TimedOut,
CeilingReached(Duration),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolRun {
pub end: ToolEnd,
pub millis: u128,
pub stdout: Vec<u8>,
pub notes: Vec<String>,
}
const OUTPUT_TAIL_BYTES: usize = 600;
const POLL_INTERVAL: Duration = Duration::from_millis(10);
pub fn find_program(name: &str, search_path: Option<&OsStr>) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let search: &OsStr = search_path?;
for directory in std::env::split_paths(search) {
if directory.as_os_str().is_empty() {
continue;
}
let candidate: PathBuf = directory.join(name);
let runnable: bool = match std::fs::metadata(&candidate) {
Ok(found) => found.is_file() && found.permissions().mode() & 0o111 != 0,
Err(_) => false,
};
if runnable {
return Some(candidate);
}
}
return None;
}
fn tail_of(bytes: &[u8]) -> String {
let cut: usize = bytes.len().saturating_sub(OUTPUT_TAIL_BYTES);
let text: String = String::from_utf8_lossy(&bytes[cut..]).into_owned();
let mut informative_lines: Vec<&str> = Vec::new();
for line in text.lines() {
if !line.trim_start().starts_with("Blocking waiting for file lock") {
informative_lines.push(line);
}
}
let informative: String = informative_lines.join("\n");
let trimmed: &str = informative.trim();
if cut == 0 {
return trimmed.to_owned();
}
let whole_lines: &str = match trimmed.split_once('\n') {
Some((_ragged, rest)) => rest,
None => trimmed,
};
return whole_lines.trim().to_owned();
}
fn describe_status(status: std::process::ExitStatus) -> String {
use std::os::unix::process::ExitStatusExt;
return match (status.code(), status.signal()) {
(Some(code), _) => format!("exit status {code}"),
(None, Some(signal)) => format!("stopped by signal {signal}"),
(None, None) => String::from("ended without a status"),
};
}
pub fn run_tool(program: &Path, command: &ToolCommand, limits: ToolLimits) -> ToolRun {
return run_tool_reaping(program, command, limits, &reap_within_stop_grace);
}
fn reap_within_stop_grace(leader: Child, _captured: &Capture) -> bool {
return reap_within(leader, crate::bounded_push::STOP_GRACE);
}
fn no_place_for_output(program: &Path, started: Instant, error: &std::io::Error) -> ToolRun {
return ToolRun { end: ToolEnd::Failed(format!("{} could not be started: no place for its output ({error})", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new(), notes: Vec::new() };
}
pub fn run_tool_reaping(program: &Path, command: &ToolCommand, limits: ToolLimits, reap: &dyn Fn(Child, &Capture) -> bool) -> ToolRun {
use std::os::unix::process::CommandExt;
let started: Instant = Instant::now();
let mut starting: Command = Command::new(program);
let captured: Capture = match Capture::new() {
Ok(files) => match files.stdio() {
Ok((out, err)) => {
starting.stdout(out).stderr(err);
files
}
Err(error) => return no_place_for_output(program, started, &error),
},
Err(error) => return no_place_for_output(program, started, &error),
};
starting.args(&command.arguments).current_dir(&command.directory).stdin(Stdio::null()).process_group(0);
for (name, value) in &command.set {
starting.env(name, value);
}
let mut child: Child = match starting.spawn() {
Ok(running) => running,
Err(error) => return ToolRun { end: ToolEnd::Failed(format!("{} could not start: {error}", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new(), notes: Vec::new() },
};
let mut clock: Clock = Clock::start(child.id(), Patience::Progress { stall: limits.window }).without_wall_fallback();
let mut at_ceiling: bool = false;
let mut wait_failure: Option<std::io::Error> = None;
let status: Option<std::process::ExitStatus> = loop {
match wait_status(&mut child) {
Ok(Some(done)) => break Some(done),
Ok(None) if started.elapsed() >= limits.ceiling => {
at_ceiling = true;
break None;
}
Ok(None) if clock.check().is_some() => break None,
Ok(None) => std::thread::sleep(POLL_INTERVAL),
Err(error) => {
wait_failure = Some(error);
break None;
}
}
};
if clock.measures() {
quiet_ledger::note(tool_step(program, command).as_str(), clock.longest_quiet(), status.is_none() && !at_ceiling && wait_failure.is_none());
}
let Some(finished) = status else {
stop_group(&child);
captured.discard();
let _reaped_in_time: bool = reap(child, &captured);
let end: ToolEnd = match wait_failure {
Some(error) => ToolEnd::Failed(format!("{} could not be waited for: {error}", program.display())),
None if at_ceiling => ToolEnd::CeilingReached(limits.ceiling),
None => ToolEnd::TimedOut,
};
return ToolRun { end, millis: started.elapsed().as_millis(), stdout: Vec::new(), notes: Vec::new() };
};
let shown: crate::output_capture::Captured = captured.snapshot(crate::output_capture::STEP_OUTPUT_LIMIT);
captured.discard();
let (stdout, errors): (Vec<u8>, Vec<u8>) = (shown.stdout, shown.stderr);
let millis: u128 = started.elapsed().as_millis();
if finished.success() {
return ToolRun { end: ToolEnd::Succeeded, millis, stdout, notes: shown.notes };
}
let combined: Vec<u8> = [stdout.as_slice(), errors.as_slice()].concat();
let text: String = tail_of(combined.as_slice());
let disclosed: String = if shown.notes.is_empty() { String::new() } else { format!(" ({})", shown.notes.join("; ")) };
return ToolRun { end: ToolEnd::Failed(format!("{}: {text}{disclosed}", describe_status(finished))), millis, stdout: Vec::new(), notes: shown.notes };
}
fn wait_status(child: &mut Child) -> std::io::Result<Option<std::process::ExitStatus>> {
#[cfg(test)]
if WAIT_FAILS_ONCE_THIS_FILE_EXISTS.with(|seam| { return seam.borrow().as_ref().is_some_and(|path| { return path.exists(); }); }) {
return Err(std::io::Error::other("the wait failed"));
}
return child.try_wait();
}
#[cfg(test)]
thread_local! {
pub(crate) static WAIT_FAILS_ONCE_THIS_FILE_EXISTS: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
fn reap_within(mut child: Child, budget: Duration) -> bool {
let began: Instant = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) if began.elapsed() < budget => std::thread::sleep(POLL_INTERVAL),
Ok(None) | Err(_) => break,
}
}
let _handed = std::thread::Builder::new().name(String::from("cli-git-leader-reaper")).spawn(move || {
let _reaped = child.wait();
});
return false;
}
fn tool_step(program: &Path, command: &ToolCommand) -> String {
let name: String = match program.file_name() {
Some(file) => file.to_string_lossy().into_owned(),
None => String::new(),
};
return match command.arguments.first() {
Some(first) => format!("{name} {}", first.to_string_lossy()),
None => name,
};
}
fn stop_group(child: &Child) {
let Ok(leader) = libc::pid_t::try_from(child.id()) else { return };
let _stopped = unsafe { libc::kill(-leader, libc::SIGKILL) };
}
#[cfg(test)]
#[path = "package_repair_command_tests.rs"]
mod tests;