#[cfg(not(test))]
const SUPERVISED_TERMINATE_GRACE: Duration = Duration::from_secs(10);
#[cfg(test)]
const SUPERVISED_TERMINATE_GRACE: Duration = Duration::from_millis(50);
const SUPERVISED_POLL_MIN: Duration = Duration::from_millis(10);
const SUPERVISED_POLL_MAX: Duration = Duration::from_millis(200);
fn next_poll_interval(current: Duration) -> Duration {
(current * 2).min(SUPERVISED_POLL_MAX)
}
const SUPERVISED_GRACE_POLL_INTERVAL: Duration = Duration::from_millis(10);
const SUPERVISED_SLEEP_SLICE: Duration = Duration::from_millis(500);
struct StopToken {
stopping: std::sync::atomic::AtomicBool,
signals: std::sync::atomic::AtomicU8,
signal: std::sync::atomic::AtomicI32,
announced: std::sync::atomic::AtomicBool,
}
impl StopToken {
const fn new() -> Self {
Self {
stopping: std::sync::atomic::AtomicBool::new(false),
signals: std::sync::atomic::AtomicU8::new(0),
signal: std::sync::atomic::AtomicI32::new(0),
announced: std::sync::atomic::AtomicBool::new(false),
}
}
fn request(&self) {
self.stopping.store(true, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(unix)]
fn raise(&self, signum: i32) {
let _ = self.signal.compare_exchange(
0,
signum,
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
);
let _ = self.signals.fetch_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|count| Some(count.saturating_add(1)),
);
self.request();
}
fn is_set(&self) -> bool {
self.stopping.load(std::sync::atomic::Ordering::SeqCst)
}
fn signals_received(&self) -> u8 {
self.signals.load(std::sync::atomic::Ordering::SeqCst)
}
fn skip_grace(&self) -> bool {
self.signals_received() >= 2
}
fn signal_number(&self) -> Option<i32> {
match self.signal.load(std::sync::atomic::Ordering::SeqCst) {
0 => None,
signum => Some(signum),
}
}
fn exit_code(&self) -> Option<i32> {
self.signal_number().map(|signum| 128 + signum)
}
fn take_announcement(&self) -> Option<String> {
self.signal_number()?;
if self.announced.swap(true, std::sync::atomic::Ordering::SeqCst) {
return None;
}
let labels = live_invocation_labels();
Some(if labels.is_empty() {
"\nInterrupted — no subprocess in flight; stopping the run.".to_string()
} else {
format!(
"\nInterrupted — terminating {} invocation(s) ({}); \
press Ctrl+C again to kill immediately.",
labels.len(),
labels.join(", ")
)
})
}
}
static INTERRUPT: StopToken = StopToken::new();
static STOPPING_RUNS: Mutex<BTreeSet<u64>> = Mutex::new(BTreeSet::new());
fn mark_run_stopping(owner: u64) {
if owner == 0 {
return;
}
if let Ok(mut stopping) = STOPPING_RUNS.lock() {
stopping.insert(owner);
}
}
fn run_is_stopping(owner: u64) -> bool {
owner != 0
&& STOPPING_RUNS.lock().map(|stopping| stopping.contains(&owner)).unwrap_or(false)
}
fn run_shutdown_requested(owner: u64) -> bool {
INTERRUPT.is_set() || run_is_stopping(owner)
}
fn interrupt_requested() -> bool {
run_shutdown_requested(current_run_owner())
}
#[derive(Clone, Default)]
struct RunShutdown(Arc<std::sync::atomic::AtomicBool>);
impl RunShutdown {
fn raise(&self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
fn is_raised(&self) -> bool {
self.0.load(std::sync::atomic::Ordering::SeqCst) || INTERRUPT.is_set()
}
}
fn interrupted_by_signal() -> bool {
INTERRUPT.signal_number().is_some()
}
fn interrupt_exit_code() -> Option<i32> {
INTERRUPT.exit_code()
}
fn take_interruption_announcement() -> Option<String> {
INTERRUPT.take_announcement()
}
fn notify_through_sink(sink: &Arc<dyn rhei_tui::EventSink>) -> impl Fn(String) + '_ {
move |text| {
sink.emit(rhei_tui::RunEvent::Message { level: rhei_tui::MessageLevel::Warn, text })
}
}
#[cfg(all(unix, not(test)))]
extern "C" fn interrupt_signal_handler(signum: std::ffi::c_int) {
INTERRUPT.raise(signum);
}
#[cfg(all(unix, not(test)))]
fn install_interrupt_handlers() {
use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet};
let action = SigAction::new(
SigHandler::Handler(interrupt_signal_handler),
SaFlags::SA_RESTART,
SigSet::empty(),
);
for sig in [Signal::SIGINT, Signal::SIGTERM, Signal::SIGHUP] {
unsafe {
let _ = signal::sigaction(sig, &action);
}
}
}
#[cfg(any(not(unix), test))]
fn install_interrupt_handlers() {}
fn interruptible_sleep(total: Duration) {
let deadline = Instant::now() + total;
loop {
if interrupt_requested() {
return;
}
let now = Instant::now();
if now >= deadline {
return;
}
std::thread::sleep(SUPERVISED_SLEEP_SLICE.min(deadline - now));
}
}
#[cfg(unix)]
struct LiveGroup {
owner: u64,
label: String,
asked_to_stop: bool,
}
#[cfg(unix)]
static LIVE_GROUPS: Mutex<BTreeMap<i32, LiveGroup>> = Mutex::new(BTreeMap::new());
static NEXT_RUN_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
thread_local! {
static RUN_OWNER: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn current_run_owner() -> u64 {
RUN_OWNER.with(std::cell::Cell::get)
}
fn set_run_owner(owner: u64) {
RUN_OWNER.with(|cell| cell.set(owner));
}
fn inherit_run_owner(owner: u64) {
set_run_owner(owner);
}
#[cfg(unix)]
fn register_live_group(pgid: i32, label: &str) {
if let Ok(mut live) = LIVE_GROUPS.lock() {
live.insert(
pgid,
LiveGroup {
owner: current_run_owner(),
label: label.to_string(),
asked_to_stop: false,
},
);
}
}
#[cfg(unix)]
fn claim_group_termination(pgid: i32) -> bool {
LIVE_GROUPS
.lock()
.map(|mut live| {
live.get_mut(&pgid)
.is_some_and(|group| !std::mem::replace(&mut group.asked_to_stop, true))
})
.unwrap_or(false)
}
#[cfg(unix)]
fn unregister_live_group(pgid: i32) {
if let Ok(mut live) = LIVE_GROUPS.lock() {
live.remove(&pgid);
}
}
#[cfg(unix)]
fn live_group_ids(owner: Option<u64>) -> Vec<i32> {
LIVE_GROUPS
.lock()
.map(|live| {
live.iter()
.filter(|(_, group)| owner.is_none_or(|owner| group.owner == owner))
.map(|(pgid, _)| *pgid)
.collect()
})
.unwrap_or_default()
}
#[cfg(unix)]
fn live_invocation_labels() -> Vec<String> {
LIVE_GROUPS
.lock()
.map(|live| live.values().map(|group| group.label.clone()).collect())
.unwrap_or_default()
}
#[cfg(not(unix))]
fn live_invocation_labels() -> Vec<String> {
Vec::new()
}
trait TerminationTarget {
fn ask_to_stop(&mut self);
fn is_gone(&mut self) -> std::io::Result<bool>;
fn kill(&mut self);
}
fn run_termination_sequence(
target: &mut dyn TerminationTarget,
stop: &StopToken,
) -> std::io::Result<()> {
let mut failure = None;
if !stop.skip_grace() {
target.ask_to_stop();
let deadline = Instant::now() + SUPERVISED_TERMINATE_GRACE;
while Instant::now() < deadline {
match target.is_gone() {
Ok(true) => return Ok(()),
Ok(false) => {}
Err(err) => {
failure = Some(err);
break;
}
}
if stop.skip_grace() {
break;
}
std::thread::sleep(SUPERVISED_GRACE_POLL_INTERVAL);
}
}
target.kill();
match failure {
Some(err) => Err(err),
None => Ok(()),
}
}
#[cfg(unix)]
struct LiveGroupsTarget {
owner: Option<u64>,
}
#[cfg(unix)]
impl TerminationTarget for LiveGroupsTarget {
fn ask_to_stop(&mut self) {
for pgid in live_group_ids(self.owner) {
if claim_group_termination(pgid) {
let _ = signal::killpg(Pid::from_raw(pgid), Signal::SIGTERM);
}
}
}
fn is_gone(&mut self) -> std::io::Result<bool> {
Ok(live_group_ids(self.owner).is_empty())
}
fn kill(&mut self) {
for pgid in live_group_ids(self.owner) {
let _ = signal::killpg(Pid::from_raw(pgid), Signal::SIGKILL);
}
}
}
#[cfg(unix)]
fn terminate_live_groups(owner: Option<u64>) {
if live_group_ids(owner).is_empty() {
return;
}
let _ = run_termination_sequence(&mut LiveGroupsTarget { owner }, &INTERRUPT);
}
#[cfg(not(unix))]
fn terminate_live_groups(_owner: Option<u64>) {}
fn terminate_all_live_groups() {
INTERRUPT.request();
terminate_live_groups(None);
}
struct RunSubprocessGuard {
owner: u64,
shutdown: RunShutdown,
finished: bool,
}
impl RunSubprocessGuard {
fn install(shutdown: RunShutdown) -> Self {
let owner = NEXT_RUN_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
set_run_owner(owner);
Self { owner, shutdown, finished: false }
}
fn finished(&mut self) {
self.finished = true;
}
}
impl Drop for RunSubprocessGuard {
fn drop(&mut self) {
if !self.finished {
self.shutdown.raise();
}
mark_run_stopping(self.owner);
terminate_live_groups(Some(self.owner));
set_run_owner(0);
}
}
trait InvocationOutcome {
fn was_interrupted(&self) -> bool;
fn timed_out(&self) -> bool;
fn status(&self) -> std::process::ExitStatus;
}
fn slot_outcome<T: InvocationOutcome>(
result: &MietteResult<T>,
) -> (rhei_tui::TaskOutcome, Option<i32>) {
let outcome = match result {
Ok(outcome) => outcome,
Err(err) => return (rhei_tui::TaskOutcome::Failed(err.to_string()), None),
};
let code = outcome.status().code();
let reported = if outcome.was_interrupted() {
rhei_tui::TaskOutcome::Interrupted
} else if outcome.status().success() {
rhei_tui::TaskOutcome::Completed
} else if outcome.timed_out() {
rhei_tui::TaskOutcome::TimedOut
} else {
rhei_tui::TaskOutcome::Failed(format!("exit {}", code.unwrap_or(-1)))
};
(reported, code)
}
fn interrupted_task_warning(task_id: &str, state: &str, log: Option<&Path>) -> String {
let head = format!(" Task {task_id} interrupted in '{state}'; state unchanged.");
match log {
Some(log) => format!("{head} Log: {}", log.display()),
None => head,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EndCause {
Exited,
TimedOut,
Interrupted,
}
#[derive(Debug)]
struct Ended {
status: std::process::ExitStatus,
cause: EndCause,
}
fn announce_shutdown(stop: &StopToken, notify: &dyn Fn(String)) {
if let Some(notice) = stop.take_announcement() {
notify(notice);
}
}
fn spawn_was_interrupted(err: &std::io::Error) -> bool {
err.kind() == std::io::ErrorKind::Interrupted
}
fn never_started_status() -> std::process::ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt as _;
std::process::ExitStatus::from_raw(Signal::SIGTERM as i32)
}
#[cfg(not(unix))]
{
use std::os::windows::process::ExitStatusExt as _;
std::process::ExitStatus::from_raw(1)
}
}
struct Supervised {
child: std::process::Child,
#[cfg(unix)]
pgid: i32,
owner: u64,
reaped: bool,
}
impl Supervised {
fn spawn(cmd: &mut std::process::Command, label: &str) -> std::io::Result<Self> {
if interrupt_requested() {
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"the run was interrupted before this subprocess started",
));
}
let owner = current_run_owner();
cmd.env_remove(HEADLESS_CHILD_ENV);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt as _;
let supervisor = std::process::id();
unsafe {
cmd.pre_exec(move || {
let _ = nix::sys::prctl::set_pdeathsig(Some(Signal::SIGTERM));
if nix::unistd::getppid().as_raw() as u32 != supervisor {
return Err(std::io::Error::from_raw_os_error(
nix::errno::Errno::ESRCH as i32,
));
}
Ok(())
});
}
}
let child = cmd.spawn()?;
#[cfg(unix)]
let pgid = child.id() as i32;
#[cfg(unix)]
register_live_group(pgid, label);
#[cfg(not(unix))]
let _ = label;
Ok(Self {
child,
#[cfg(unix)]
pgid,
owner,
reaped: false,
})
}
fn shutdown_requested(&self, stop: &StopToken) -> bool {
stop.is_set() || run_is_stopping(self.owner)
}
fn wait(
&mut self,
timeout: Option<Duration>,
stop: &StopToken,
notify: &dyn Fn(String),
) -> std::io::Result<Ended> {
let start = Instant::now();
let mut poll = SUPERVISED_POLL_MIN;
loop {
if let Some(status) = self.child.try_wait()? {
self.finish();
return Ok(Ended { status, cause: EndCause::Exited });
}
let interrupted = self.shutdown_requested(stop);
let timed_out = !interrupted && timeout.is_some_and(|limit| start.elapsed() > limit);
if !interrupted && !timed_out {
std::thread::sleep(poll);
poll = next_poll_interval(poll);
continue;
}
if interrupted {
announce_shutdown(stop, notify);
}
let status = self.terminate_and_reap(stop)?;
let cause = if interrupted || self.shutdown_requested(stop) {
announce_shutdown(stop, notify);
EndCause::Interrupted
} else {
EndCause::TimedOut
};
return Ok(Ended { status, cause });
}
}
fn terminate_and_reap(
&mut self,
stop: &StopToken,
) -> std::io::Result<std::process::ExitStatus> {
let sequence = run_termination_sequence(self, stop);
let reaped = match self.child.try_wait() {
Ok(Some(status)) => Ok(status),
Ok(None) => self.child.wait(),
Err(err) => Err(err),
};
self.finish();
sequence.and(reaped)
}
fn finish(&mut self) {
self.reaped = true;
#[cfg(unix)]
unregister_live_group(self.pgid);
}
#[cfg(unix)]
fn terminate_group(&mut self) {
if claim_group_termination(self.pgid) {
let _ = signal::killpg(Pid::from_raw(self.pgid), Signal::SIGTERM);
}
}
#[cfg(not(unix))]
fn terminate_group(&mut self) {
let _ = self.child.kill();
}
#[cfg(unix)]
fn kill_group(&mut self) {
let _ = signal::killpg(Pid::from_raw(self.pgid), Signal::SIGKILL);
let _ = self.child.kill();
}
#[cfg(not(unix))]
fn kill_group(&mut self) {
let _ = self.child.kill();
}
}
impl TerminationTarget for Supervised {
fn ask_to_stop(&mut self) {
self.terminate_group();
}
fn is_gone(&mut self) -> std::io::Result<bool> {
Ok(self.child.try_wait()?.is_some())
}
fn kill(&mut self) {
self.kill_group();
}
}
impl Drop for Supervised {
fn drop(&mut self) {
if self.reaped {
return;
}
let _ = self.terminate_and_reap(&INTERRUPT);
#[cfg(unix)]
unregister_live_group(self.pgid);
}
}