ref:main
//! Detach a `runner start` invocation into the background.
//!
//! We avoid the classic double-fork dance — by the time we reach this
//! point we're already inside a tokio runtime, and `fork()` while tokio
//! has spawned worker threads is undefined behavior. Instead we re-exec
//! ourselves with a hidden `--detach-child` flag, redirecting the
//! child's stdio to a rotated log file and placing it in a new process
//! group so terminal-close signals don't reach it. The parent then exits
//! with the child PID printed for the operator.
//!
//! The child runs the normal `runner start` path; the `--detach-child`
//! flag exists only to prevent infinite re-detach recursion if the
//! daemon ever re-invokes itself.
use crate::runner::log_rotate::{self, RotationPolicy};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Default location for the detached daemon's stdout/stderr log.
pub fn default_log_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
.join("runner.log")
}
/// Spawn the current process tree as a detached daemon, redirecting
/// stdout+stderr to `log_path` and giving it a new process group.
/// Returns the spawned child's PID. The caller is expected to exit
/// after this returns.
///
/// `forwarded_args` are the original `runner start ...` flags minus
/// `--detach` (so the child doesn't try to detach again). Caller adds
/// the `--detach-child` hidden flag as a marker.
pub fn spawn_daemon(
log_path: &Path,
forwarded_args: &[String],
) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
// Rotate the existing log if it's grown beyond the threshold. This
// bounds disk usage across restarts; long-running detached runners
// that need stricter rotation should pair with logrotate / newsyslog.
let _ = log_rotate::maybe_rotate(log_path, RotationPolicy::default())?;
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Open the log file once and dup it to both stdout and stderr so the
// ordering of stdout/stderr writes from the daemon matches the wall
// clock. APPEND so concurrent runners (e.g. user accidentally starts
// two) don't clobber each other's tails.
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
let log_clone = log_file.try_clone()?;
let exe = std::env::current_exe()?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("runner")
.arg("start")
.arg("--detach-child")
.args(forwarded_args)
.stdin(std::process::Stdio::null())
.stdout(log_file)
.stderr(log_clone);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
// setsid() detaches the child from the parent's terminal session
// BEFORE our tokio SIGHUP handler arms; without this, a shell
// with `huponexit` set can kill the daemon during its startup
// window. pre_exec runs in the forked child after fork() but
// before execvp(), so it's safe to call libc::setsid directly.
unsafe {
cmd.pre_exec(|| {
// setsid creates a new session AND process group, making
// the child the leader of both. Best-effort: if the
// child is already a process group leader,
// process_group(0) above already covered that.
if libc::setsid() == -1 {
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
// EPERM means the caller is already a session leader
// (extremely unlikely in our spawn path, but harmless).
if errno != libc::EPERM {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
});
}
}
let child = cmd.spawn()?;
Ok(child.id())
}
/// Outcome of waiting for the detached daemon to come up.
pub enum DaemonReady {
/// Daemon wrote its PID file; the daemon's real PID is enclosed
/// (typically the same as the spawn'd PID unless the daemon
/// double-forks itself in the future).
Live(u32),
/// Daemon process exited during startup. The string is the tail of
/// its log file for the operator to triage.
DiedDuringStartup(String),
/// Daemon process is still alive but hasn't acquired its PID file
/// within the startup window; could be a slow heartbeat call.
Timeout,
}
const STARTUP_TIMEOUT_SECS: u64 = 10;
const POLL_INTERVAL_MS: u64 = 100;
/// Block the parent until the spawned daemon either:
/// 1. acquires its PID file (success — return Live with the PID
/// read from the file, which may differ from spawn_pid if the
/// daemon double-forks later),
/// 2. dies entirely (return DiedDuringStartup with the log tail), or
/// 3. exhausts the startup budget (return Timeout — caller decides
/// whether to fail or warn).
pub fn wait_for_daemon_ready(
spawn_pid: u32,
pid_path: &Path,
log_path: &Path,
) -> std::io::Result<DaemonReady> {
let deadline = Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS);
let interval = Duration::from_millis(POLL_INTERVAL_MS);
while Instant::now() < deadline {
// (1) PID file present?
if let Ok(contents) = std::fs::read_to_string(pid_path) {
if let Ok(pid) = contents.trim().parse::<u32>() {
return Ok(DaemonReady::Live(pid));
}
}
// (2) Did the spawned process die?
#[cfg(unix)]
{
let alive = unsafe { libc::kill(spawn_pid as i32, 0) } == 0;
if !alive {
let tail = log_tail(log_path).unwrap_or_else(|| "(log unavailable)".into());
return Ok(DaemonReady::DiedDuringStartup(tail));
}
}
std::thread::sleep(interval);
}
Ok(DaemonReady::Timeout)
}
fn log_tail(path: &Path) -> Option<String> {
use std::io::Read;
let mut file = std::fs::File::open(path).ok()?;
let len = file.metadata().ok()?.len();
// Read the last ~4 KB so we capture a reasonable amount of context
// without slurping a huge log file into memory.
let chunk = 4096u64.min(len);
let seek_from = std::io::SeekFrom::End(-(chunk as i64));
use std::io::Seek;
file.seek(seek_from).ok()?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).ok()?;
let s = String::from_utf8_lossy(&buf).into_owned();
let tail: String = s
.lines()
.rev()
.take(20)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
Some(tail)
}