use anyhow::{Context, Result};
use colored::Colorize;
use std::io::{self, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
pub fn log_info(msg: &str) {
println!("{} {}", "[INFO]".blue().bold(), msg);
}
pub fn log_success(msg: &str) {
println!("{} {}", "[OK]".green().bold(), msg);
}
pub fn log_warning(msg: &str) {
println!("{} {}", "[WARN]".yellow().bold(), msg);
}
pub fn log_error(msg: &str) {
eprintln!("{} {}", "[ERROR]".red().bold(), msg);
}
pub fn run_command(cmd: &mut Command) -> Result<()> {
let status = cmd
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.with_context(|| format!("Failed to execute: {:?}", cmd))?;
if !status.success() {
anyhow::bail!("Command failed with exit code: {:?}", status.code());
}
Ok(())
}
const DEFAULT_REBUILD_TIMEOUT_SECS: u64 = 5400;
pub fn rebuild_timeout() -> Option<Duration> {
match std::env::var("FLEET_REBUILD_TIMEOUT_SECS") {
Ok(v) => match v.trim().parse::<u64>() {
Ok(0) => None,
Ok(n) => Some(Duration::from_secs(n)),
Err(_) => {
log_warning(&format!(
"FLEET_REBUILD_TIMEOUT_SECS={v:?} is not a number — using default {DEFAULT_REBUILD_TIMEOUT_SECS}s"
));
Some(Duration::from_secs(DEFAULT_REBUILD_TIMEOUT_SECS))
}
},
Err(_) => Some(Duration::from_secs(DEFAULT_REBUILD_TIMEOUT_SECS)),
}
}
pub fn run_command_timed(cmd: &mut Command, timeout: Option<Duration>) -> Result<()> {
let Some(timeout) = timeout else {
return run_command(cmd);
};
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = cmd
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("Failed to execute: {cmd:?}"))?;
let pid = child.id();
let start = Instant::now();
loop {
match child.try_wait().context("waiting on child")? {
Some(status) if status.success() => return Ok(()),
Some(status) => {
anyhow::bail!("Command failed with exit code: {:?}", status.code())
}
None => {}
}
if start.elapsed() >= timeout {
log_error(&format!(
"no completion after {}s — treating as wedged and killing process group {pid}",
timeout.as_secs()
));
kill_group(pid);
std::thread::sleep(Duration::from_secs(3));
let _ = child.try_wait();
kill_group_hard(pid);
let _ = child.wait();
anyhow::bail!(
"rebuild exceeded {}s and was killed.\n\
\n\
This is usually a WEDGED flake-input fetch, not a slow build. nix\n\
fetches git inputs in-process via libgit2, which ignores\n\
connect-timeout / stalled-download-timeout / download-attempts, so a\n\
dropped connection parks forever instead of erroring.\n\
\n\
CONFIRM before retrying. The signal is whether the BUILD LOG is\n\
still advancing — NOT cpu%. The `nix build` client sits near 0%%\n\
CPU even on a perfectly healthy build, because the nix-daemon does\n\
the work in separate processes:\n\
a=$(wc -l < <logfile>); sleep 30; b=$(wc -l < <logfile>); echo $((b-a))\n\
Non-zero means it is building and you should raise the ceiling.\n\
Zero, plus no builder children, means it is genuinely wedged:\n\
pgrep -P $(pgrep -x nix-daemon | head -1) | wc -l\n\
\n\
If the build is genuinely this long, raise or disable the ceiling:\n\
FLEET_REBUILD_TIMEOUT_SECS=10800 fleet rebuild # 3h\n\
FLEET_REBUILD_TIMEOUT_SECS=0 fleet rebuild # no ceiling",
timeout.as_secs()
)
}
std::thread::sleep(Duration::from_millis(500));
}
}
fn kill_group(pid: u32) {
let _ = Command::new("kill")
.args(["-TERM", &format!("-{pid}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
fn kill_group_hard(pid: u32) {
let _ = Command::new("kill")
.args(["-KILL", &format!("-{pid}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
pub fn run_command_output(cmd: &mut Command) -> Result<String> {
let output = cmd
.output()
.with_context(|| format!("Failed to execute: {:?}", cmd))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Command failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn ssh_cmd_with_config(
user: &str,
host: &str,
ssh: &crate::config::ResolvedSsh,
) -> Command {
let mut cmd = Command::new("ssh");
cmd.arg("-o")
.arg(format!("ConnectTimeout={}", ssh.connect_timeout));
cmd.arg("-o")
.arg(format!("StrictHostKeyChecking={}", ssh.strict_host_key));
for (k, v) in &ssh.options {
cmd.arg("-o").arg(format!("{}={}", k, v));
}
cmd.arg(format!("{}@{}", user, host));
cmd
}
pub fn ssh_run_with_config(
user: &str,
host: &str,
ssh: &crate::config::ResolvedSsh,
remote_cmd: &str,
) -> Result<String> {
let mut cmd = ssh_cmd_with_config(user, host, ssh);
cmd.arg(remote_cmd);
run_command_output(&mut cmd)
}
pub fn node_label(name: &str) -> String {
format!("[{}]", name).cyan().bold().to_string()
}
pub fn confirm(msg: &str) -> Result<bool> {
print!("{} {} ", "[?]".yellow().bold(), msg);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let answer = input.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
pub fn flake_dir() -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Ok(root) = super::rebuild::find_flake_root(&cwd) {
return root.to_string_lossy().to_string();
}
}
std::env::var("FLEET_FLAKE_DIR").unwrap_or_else(|_| ".".to_string())
}
#[cfg(test)]
mod timeout_tests {
use super::*;
fn with_env<T>(val: Option<&str>, f: impl FnOnce() -> T) -> T {
let prev = std::env::var("FLEET_REBUILD_TIMEOUT_SECS").ok();
match val {
Some(v) => unsafe { std::env::set_var("FLEET_REBUILD_TIMEOUT_SECS", v) },
None => unsafe { std::env::remove_var("FLEET_REBUILD_TIMEOUT_SECS") },
}
let out = f();
match prev {
Some(p) => unsafe { std::env::set_var("FLEET_REBUILD_TIMEOUT_SECS", p) },
None => unsafe { std::env::remove_var("FLEET_REBUILD_TIMEOUT_SECS") },
}
out
}
#[test]
fn timeout_resolution_covers_every_env_case() {
with_env(None, || {
assert_eq!(
rebuild_timeout(),
Some(Duration::from_secs(DEFAULT_REBUILD_TIMEOUT_SECS)),
"default must be a finite ceiling"
);
});
with_env(Some("0"), || assert_eq!(rebuild_timeout(), None));
with_env(Some("120"), || {
assert_eq!(rebuild_timeout(), Some(Duration::from_secs(120)))
});
with_env(Some("banana"), || {
assert_eq!(
rebuild_timeout(),
Some(Duration::from_secs(DEFAULT_REBUILD_TIMEOUT_SECS))
)
});
}
#[test]
fn a_hanging_command_is_killed_and_reported() {
let mut c = Command::new("sleep");
c.arg("600");
let start = Instant::now();
let err = run_command_timed(&mut c, Some(Duration::from_secs(2)))
.expect_err("a hanging command must fail, not block");
assert!(start.elapsed() < Duration::from_secs(30), "must not have waited it out");
let msg = format!("{err}");
assert!(msg.contains("was killed"), "error must say it killed the process: {msg}");
assert!(msg.contains("FLEET_REBUILD_TIMEOUT_SECS"), "error must say how to raise it");
}
#[test]
fn the_bail_text_names_log_progress_not_cpu() {
let mut c = Command::new("sleep");
c.arg("600");
let err = run_command_timed(&mut c, Some(Duration::from_secs(2)))
.expect_err("must fail");
let msg = format!("{err}");
assert!(msg.contains("wc -l"), "must give the log-progress check: {msg}");
assert!(
msg.contains("NOT cpu%"),
"must explicitly warn cpu% is not the signal: {msg}"
);
}
#[test]
fn a_fast_command_still_succeeds() {
let mut c = Command::new("true");
assert!(run_command_timed(&mut c, Some(Duration::from_secs(30))).is_ok());
}
#[test]
fn a_failing_command_still_reports_its_exit_code() {
let mut c = Command::new("false");
assert!(run_command_timed(&mut c, Some(Duration::from_secs(30))).is_err());
}
}