pub(crate) const HEADLESS_CHILD_ENV: &str = "RHEI_HEADLESS_CHILD";
const LAUNCHER_ONLY_FLAGS: [&str; 3] = ["--headless", "--json", "--json-agent-output"];
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
const HANDSHAKE_POLL: Duration = Duration::from_millis(50);
const HEADLESS_LAUNCH_LOCK: &str = "headless-launch.lock";
pub(crate) fn is_headless_child() -> bool {
std::env::var_os(HEADLESS_CHILD_ENV).is_some()
}
pub(crate) fn run_lock_conflict(root: &Path) -> miette::Report {
match read_descriptor(&run_descriptor_path(root)).filter(|run| !run.liveness().has_ended()) {
Some(live) => miette!(
help = format!(
"watch it with `rhei attach {id}`, or stop it with `rhei stop {id}`",
id = live.id
),
"a run is already live on {}:\n {}",
root.display(),
live.summary_line()
),
None => miette!(
help = "see what is live on this machine with: rhei runs",
"a run is already live on {} and holds its .rhei/run.lock",
root.display()
),
}
}
pub(crate) fn launch_headless_run(
input: &Path,
json: bool,
announce_dashboard: bool,
) -> MietteResult<()> {
let workspace_root = execution_workspace_root(&normalize_workspace_input(input));
let _launch_lock = acquire_launch_lock(&workspace_root)?;
let occupied = read_descriptor(&run_descriptor_path(&workspace_root))
.is_some_and(|run| match run.liveness() {
Liveness::Live => true,
Liveness::Ended | Liveness::Gone | Liveness::Unknown(_) => false,
});
if occupied {
return Err(run_lock_conflict(&workspace_root));
}
if run_registry_dir().is_none() {
return Err(miette!(
help = "set HOME or XDG_STATE_HOME, or run in the foreground with `rhei run --no-tui`",
"a detached run needs a state directory to publish its id into, and neither \
XDG_STATE_HOME nor HOME is set"
));
}
let log_path = run_console_log_path(&workspace_root);
let mut child = spawn_detached_run(&log_path)?;
let pid = child.id();
match await_child_ready(&mut child, pid, &workspace_root) {
Ok(LaunchOutcome::Running(descriptor)) => {
report_launched(&descriptor, json, announce_dashboard);
warn_if_unregistered(&descriptor);
Ok(())
}
Ok(LaunchOutcome::FinishedEarly(descriptor)) => {
report_finished_early(&descriptor, json);
warn_if_unregistered(&descriptor);
Ok(())
}
Err(HandshakeFailure::Exited(status)) if status.success() => {
eprintln!(
"The run finished before it published a descriptor, so there is nothing to \
attach to.\n its console is at {}",
log_path.display()
);
Ok(())
}
Err(HandshakeFailure::Exited(status)) => Err(miette!(
help = format!("the run's console is at {}", log_path.display()),
"the run exited before it started ({}):\n{}",
exit_status_text(&status),
indent_block(&log_tail(&log_path, 20))
)),
Err(HandshakeFailure::TimedOut) => Err(miette!(
help = format!(
"it may still be starting: check `rhei runs`, or read {}",
log_path.display()
),
"the run (pid {pid}) did not report itself ready within {}s",
HANDSHAKE_TIMEOUT.as_secs()
)),
}
}
fn acquire_launch_lock(workspace_root: &Path) -> MietteResult<HeldRunLock> {
let rhei_dir = workspace_root.join(".rhei");
fs::create_dir_all(&rhei_dir)
.map_err(|err| file_io_report(&rhei_dir, "failed to create .rhei directory", err))?;
let path = rhei_dir.join(HEADLESS_LAUNCH_LOCK);
let file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|err| file_io_report(&path, "failed to open the headless launch lock", err))?;
match file.try_lock_exclusive() {
Ok(()) => Ok(HeldRunLock { file }),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
Err(concurrent_launch_report(workspace_root))
}
Err(err) => {
Err(file_io_report(&path, "failed to inspect the headless launch lock", err))
}
}
}
fn concurrent_launch_report(workspace_root: &Path) -> miette::Report {
match read_descriptor(&run_descriptor_path(workspace_root)).filter(|run| !run.liveness().has_ended())
{
Some(live) => miette!(
help = format!("watch it with `rhei attach {id}`", id = live.id),
"another `rhei run --headless` is starting a run on {}:\n {}",
workspace_root.display(),
live.summary_line()
),
None => miette!(
help = "wait for it to print its id, then see `rhei runs`",
"another `rhei run --headless` is already starting a run on {}",
workspace_root.display()
),
}
}
fn warn_if_unregistered(descriptor: &RunDescriptor) {
let registered =
run_registry_path(&descriptor.id).is_some_and(|entry| read_descriptor(&entry).is_some());
if registered {
return;
}
eprintln!(
"warning: run {} has no registry entry, so its id will not resolve from another \
directory.\n reach it by path instead: rhei attach {}",
descriptor.id,
shell_quote(&descriptor.workspace.display().to_string())
);
}
fn report_launched(descriptor: &RunDescriptor, json: bool, announce_dashboard: bool) {
if json {
println!("{}", descriptor_json(descriptor));
return;
}
println!("Run {} started headless (pid {}).", descriptor.id, descriptor.pid);
println!(" attach: rhei attach {}", descriptor.id);
println!(" stop: rhei stop {}", descriptor.id);
if let Some(log) = &descriptor.log {
println!(" log: {}", log.display());
}
if announce_dashboard {
if let Some(url) = &descriptor.control_url {
println!(" browser: {url}");
}
}
}
fn report_finished_early(descriptor: &RunDescriptor, json: bool) {
if json {
println!("{}", descriptor_json(descriptor));
eprintln!("Run {} finished before the launcher returned.", descriptor.id);
return;
}
println!("Run {} finished before the launcher returned.", descriptor.id);
match descriptor.exit_code {
Some(code) => println!(" It exited {code}."),
None => println!(" It recorded no exit status."),
}
println!(" attach: rhei attach {}", descriptor.id);
if let Some(log) = &descriptor.log {
println!(" log: {}", log.display());
}
}
fn descriptor_json(descriptor: &RunDescriptor) -> String {
serde_json::to_string(descriptor).unwrap_or_else(|_| format!(r#"{{"id":"{}"}}"#, descriptor.id))
}
enum HandshakeFailure {
Exited(std::process::ExitStatus),
TimedOut,
}
enum LaunchOutcome {
Running(RunDescriptor),
FinishedEarly(RunDescriptor),
}
fn exit_status_text(status: &std::process::ExitStatus) -> String {
if let Some(code) = status.code() {
return format!("exit status {code}");
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt as _;
if let Some(signal) = status.signal() {
return format!("killed by signal {signal}");
}
}
"no exit status".to_string()
}
fn await_child_ready(
child: &mut std::process::Child,
pid: u32,
workspace_root: &Path,
) -> Result<LaunchOutcome, HandshakeFailure> {
let descriptor_path = run_descriptor_path(workspace_root);
let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
loop {
if let Some(outcome) = child_outcome(&descriptor_path, pid) {
return Ok(outcome);
}
if let Ok(Some(status)) = child.try_wait() {
if let Some(outcome) = child_outcome(&descriptor_path, pid) {
return Ok(outcome);
}
return Err(HandshakeFailure::Exited(status));
}
if Instant::now() >= deadline {
return Err(HandshakeFailure::TimedOut);
}
std::thread::sleep(HANDSHAKE_POLL);
}
}
fn child_outcome(descriptor_path: &Path, pid: u32) -> Option<LaunchOutcome> {
let descriptor = read_descriptor(descriptor_path).filter(|run| run.pid == pid)?;
match descriptor.status {
RunStatus::Running => Some(LaunchOutcome::Running(descriptor)),
RunStatus::Finished if descriptor.exit_code == Some(0) => {
Some(LaunchOutcome::FinishedEarly(descriptor))
}
RunStatus::Finished | RunStatus::Failed => None,
}
}
fn spawn_detached_run(log_path: &Path) -> MietteResult<std::process::Child> {
if !cfg!(unix) {
return Err(miette!(
help = "run it in the foreground instead: rhei run --no-tui <plan>",
"`--headless` needs a POSIX session to detach into and is not supported on this \
platform yet"
));
}
let exe = std::env::current_exe().map_err(|err| {
miette!(
help = "a detached run re-executes this binary, so it must still be on disk",
"could not locate the rhei binary to re-execute: {err}"
)
})?;
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| file_io_report(parent, "failed to create the runtime directory", err))?;
}
let log = fs::File::create(log_path)
.map_err(|err| file_io_report(log_path, "failed to open the run console log", err))?;
let stderr = log
.try_clone()
.map_err(|err| file_io_report(log_path, "failed to open the run console log", err))?;
let mut command = std::process::Command::new(exe);
command.args(child_arguments());
command.env(HEADLESS_CHILD_ENV, "1");
command.stdin(std::process::Stdio::null());
command.stdout(std::process::Stdio::from(log));
command.stderr(std::process::Stdio::from(stderr));
detach_session(&mut command);
command.spawn().map_err(|err| {
miette!(
help = "check that the rhei binary is still on disk and executable",
"could not start the detached run: {err}"
)
})
}
#[cfg(unix)]
fn detach_session(command: &mut std::process::Command) {
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
nix::unistd::setsid()
.map(|_| ())
.map_err(|err| std::io::Error::from_raw_os_error(err as i32))
});
}
}
#[cfg(not(unix))]
fn detach_session(_command: &mut std::process::Command) {}
fn child_arguments() -> Vec<std::ffi::OsString> {
child_arguments_from(std::env::args_os().skip(1))
}
fn child_arguments_from(
given: impl IntoIterator<Item = std::ffi::OsString>,
) -> Vec<std::ffi::OsString> {
let separator = std::ffi::OsStr::new("--");
let mut arguments = Vec::new();
let mut past_separator = false;
for argument in given {
if past_separator {
arguments.push(argument);
continue;
}
if argument == separator {
past_separator = true;
} else if LAUNCHER_ONLY_FLAGS.iter().any(|flag| argument == std::ffi::OsStr::new(flag)) {
continue;
}
arguments.push(argument);
}
arguments
}
fn log_tail(path: &Path, lines: usize) -> String {
let Ok(contents) = fs::read_to_string(path) else {
return format!("(no console output at {})", path.display());
};
let tail: Vec<&str> = contents.lines().filter(|line| !line.trim().is_empty()).collect();
if tail.is_empty() {
return format!("(the run wrote nothing to {})", path.display());
}
tail[tail.len().saturating_sub(lines)..].join("\n")
}
fn indent_block(text: &str) -> String {
text.lines().map(|line| format!(" {line}")).collect::<Vec<_>>().join("\n")
}