use super::*;
#[derive(Debug, Clone, Copy)]
pub struct InterruptSignal {
pub name: &'static str,
pub signum: i32,
}
impl InterruptSignal {
pub fn exit_code(&self) -> i32 {
128 + self.signum
}
}
pub(crate) async fn wait_for_termination_signal() -> Option<InterruptSignal> {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigint = signal(SignalKind::interrupt()).ok()?;
let mut sigterm = signal(SignalKind::terminate()).ok()?;
let mut sighup = signal(SignalKind::hangup()).ok()?;
tokio::select! {
_ = sigint.recv() => Some(InterruptSignal { name: "SIGINT", signum: 2 }),
_ = sigterm.recv() => Some(InterruptSignal { name: "SIGTERM", signum: 15 }),
_ = sighup.recv() => Some(InterruptSignal { name: "SIGHUP", signum: 1 }),
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await.ok()?;
Some(InterruptSignal {
name: "SIGINT",
signum: 2,
})
}
}
#[allow(clippy::too_many_arguments)] pub(crate) async fn terminate_on_signal(
signal: InterruptSignal,
executor: &LocalExecutor,
join_set: &mut tokio::task::JoinSet<(
String,
oxo_flow_core::executor::JobStatus,
oxo_flow_core::executor::JobRecord,
)>,
in_flight: impl Iterator<Item = &String>,
checkpoint: &Arc<Mutex<CheckpointState>>,
checkpoint_path: &Path,
json_summary: &mut RunJsonSummary,
config: &WorkflowConfig,
) -> ! {
eprintln!(
"\n{} {} received — stopping in-flight rules and saving the checkpoint",
"Interrupted:".yellow().bold(),
signal.name
);
executor.clear_resource_waiters().await;
let snapshot = executor.active_pids();
tokio::task::spawn_blocking(move || {
for (rule, pid) in snapshot {
if let Err(e) = oxo_flow_core::executor::timeout::kill_process_tree(pid) {
tracing::warn!(rule = %rule, pid, error = %e, "failed to signal in-flight rule on {}", signal.name);
}
}
})
.await
.ok();
join_set.abort_all();
let late = executor.active_pids();
if !late.is_empty() {
tokio::task::spawn_blocking(move || {
for (rule, pid) in late {
if let Err(e) = oxo_flow_core::executor::timeout::kill_process_tree(pid) {
tracing::warn!(rule = %rule, pid, error = %e, "failed to signal late-spawned rule on {}", signal.name);
}
}
})
.await
.ok();
}
let mut ck = checkpoint.lock().await;
for rule in in_flight {
let record = oxo_flow_core::executor::JobRecord {
signal: Some(15), rule: rule.clone(),
status: oxo_flow_core::executor::JobStatus::Failed,
enqueued_at: None,
started_at: None,
finished_at: Some(chrono::Utc::now()),
exit_code: None,
stdout: None,
stderr: None,
command: None,
retries: 0,
skip_reason: Some(format!("interrupted by {}", signal.name)),
max_rss_mb: None,
cpu_seconds: None,
caption: None,
};
ck.record_run(&record, output_tail_bytes_from_env());
ck.mark_failed(rule);
}
if let Err(e) = ck.save_to_file(checkpoint_path) {
tracing::warn!(error = %e, "failed to save checkpoint after interruption");
}
drop(ck);
json_summary.emit("failed", &RunCounts::default(), vec![]);
notify_webhook(
config,
oxo_flow_core::webhook::WebhookEvent::WorkflowFailed,
None,
)
.await;
std::process::exit(signal.exit_code());
}
pub(crate) async fn reclaim_rule_outputs(
candidate: &oxo_flow_core::scheduler::ReclaimCandidate,
workdir: &std::path::Path,
checkpoint: &Arc<Mutex<CheckpointState>>,
checkpoint_path: &Path,
run_log: &SharedRunLog,
) -> anyhow::Result<usize> {
let mut paths = Vec::new();
{
let mut ck = checkpoint.lock().await;
for path in &candidate.paths {
let resolved = workdir.join(path);
if !resolved.exists() {
continue;
}
if let Some(sha) = ck.checksums.remove(path) {
ck.record_cleaned_checksum(path, sha);
}
paths.push(path.clone());
}
if paths.is_empty() {
return Ok(0);
}
ck.tombstones.insert(candidate.rule.clone(), paths.clone());
if let Err(e) = ck.save_to_file_async(checkpoint_path).await {
ck.tombstones.remove(&candidate.rule);
for path in &paths {
if let Some(sha) = ck.cleaned_checksums.remove(path) {
ck.checksums.insert(path.clone(), sha);
}
}
return Err(anyhow::anyhow!(
"persisting tombstone failed: {e} — outputs kept"
));
}
}
let mut unlinked = 0usize;
for path in &paths {
let resolved = workdir.join(path);
let ok = match std::fs::symlink_metadata(&resolved) {
Ok(md) if md.file_type().is_symlink() => std::fs::remove_file(&resolved).is_ok(),
Ok(md) if md.is_dir() => std::fs::remove_dir_all(&resolved).is_ok(),
Ok(_) => std::fs::remove_file(&resolved).is_ok(),
Err(_) => false,
};
if ok {
unlinked += 1;
} else {
diagnostic_narrate(
format_args!(
" {} reclaim unlink failed for '{}' path '{path}'",
"!".yellow(),
candidate.rule
),
Some(run_log),
);
}
}
diagnostic_narrate(
format_args!(
" {} reclaimed temporary outputs of '{}' ({}) — regenerated on demand via cascade-up",
"⊘".dimmed(),
candidate.rule,
oxo_flow_core::plural(unlinked, "file", "files")
),
Some(run_log),
);
Ok(unlinked)
}