use std::path::PathBuf;
use chrono::Utc;
use serde_json::Value;
use crate::cgroup_memory::{cgroup_shell_vars, parse_cgroup_memory_sample};
use crate::docker_post_mortem::{normalize_docker_timestamp, shell_vars};
use crate::execution_attempt::{append_lifecycle, read_attempt_activity, read_attempt_log_tail};
use crate::execution_store::{
ExecutionRecord, ExecutionStatus, ExecutionStore, ExecutionStoreOptions,
};
use crate::exit_reason::{describe_exit_code_str, resolve_exit_reason};
use crate::isolation::isolation_log::{shell_quote, FATAL_MARKER_TAIL_BYTES};
pub const INTERNAL_FINALIZE_FLAG: &str = "--internal-finalize-detached-docker";
pub const END_TIME_SOURCE_DOCKER_FINISHED_AT: &str = "docker-finished-at";
pub const END_TIME_SOURCE_LOG_FOOTER: &str = "log-footer";
pub const END_TIME_SOURCE_OBSERVED_AT: &str = "observed-at";
pub const WATCHER_LOST_CONTAINER: &str = "watcher-lost-container";
#[derive(Debug, Clone, Default)]
pub struct DetachedFinalizeFacts {
pub exit_code: String,
pub oom_killed: String,
pub started_at: String,
pub finished_at: String,
pub container_error: String,
pub running: String,
pub cgroup_memory: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FinalizeOutcome {
pub updated: bool,
pub reason: String,
}
pub(crate) fn normalize_bool(value: &str) -> Option<bool> {
match value.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
pub(crate) fn normalize_container_error(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "<no value>" || trimmed == "(none)" {
None
} else {
Some(trimmed.to_string())
}
}
pub fn finalize_detached_execution(
store: &ExecutionStore,
execution_id: &str,
facts: &DetachedFinalizeFacts,
) -> FinalizeOutcome {
finalize_detached_execution_with_attempt(store, execution_id, facts, None)
}
pub fn finalize_detached_execution_with_attempt(
store: &ExecutionStore,
execution_id: &str,
facts: &DetachedFinalizeFacts,
attempt_number: Option<u64>,
) -> FinalizeOutcome {
if execution_id.is_empty() {
return FinalizeOutcome {
updated: false,
reason: "missing-arguments".to_string(),
};
}
if normalize_bool(&facts.running) == Some(true) {
return FinalizeOutcome {
updated: false,
reason: "still-running".to_string(),
};
}
let mut record = match store.get(execution_id) {
Some(record) => record,
None => {
return FinalizeOutcome {
updated: false,
reason: "record-not-found".to_string(),
}
}
};
if let Some(attempt) = &record.attempt {
let started = normalize_docker_timestamp(Some(&facts.started_at));
let older = started
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.zip(chrono::DateTime::parse_from_rfc3339(&attempt.started_at).ok())
.is_some_and(|(started, since)| started < since);
if attempt_number.is_some_and(|number| number != attempt.number)
|| older
|| (attempt_number.is_none() && started.is_none())
{
return FinalizeOutcome {
updated: false,
reason: "stale-attempt".into(),
};
}
}
if record.status == ExecutionStatus::Executed && record.end_time.is_some() {
return FinalizeOutcome {
updated: false,
reason: "already-final".to_string(),
};
}
let finished_at = normalize_docker_timestamp(Some(&facts.finished_at));
let described = describe_exit_code_str(&facts.exit_code);
let exit_code = match described.code {
Some(0) if finished_at.is_none() => None,
code => code,
};
record.status = ExecutionStatus::Executed;
match exit_code {
Some(code) => record.exit_code = Some(code),
None => {
if record.exit_code.is_none() || finished_at.is_none() {
record.exit_code = Some(-1);
}
}
}
let lost = finished_at.is_none();
match finished_at {
Some(finished_at) => {
record.end_time = Some(finished_at);
record.end_time_source = Some(END_TIME_SOURCE_DOCKER_FINISHED_AT.to_string());
}
None => {
let now = Utc::now().to_rfc3339();
record.end_time = Some(now.clone());
record.end_time_source = Some(END_TIME_SOURCE_OBSERVED_AT.to_string());
record.observed_at = Some(now);
}
}
if let Some(started_at) = normalize_docker_timestamp(Some(&facts.started_at)) {
record.container_started_at = Some(started_at);
}
if let Some(oom_killed) = normalize_bool(&facts.oom_killed) {
record.oom_killed = Some(oom_killed);
}
if let Some(cgroup_memory) = parse_cgroup_memory_sample(&facts.cgroup_memory) {
record.cgroup_memory = Some(cgroup_memory);
}
let tail = read_attempt_log_tail(&record, FATAL_MARKER_TAIL_BYTES);
let (logged_main, daemon) = crate::exit_evidence::from_log(tail.as_deref());
let main = !daemon
&& (logged_main
|| (record.oom_killed == Some(true)
&& record.exit_code.is_some_and(|code| code == 137 || code < 0)
&& crate::exit_evidence::recent_oom_delta(
&facts.cgroup_memory,
&facts.finished_at,
)));
record.options.insert(
"exitEvidence".into(),
serde_json::json!({"mainOom": main, "daemonRestart": daemon}),
);
let reason =
resolve_reason(&record).or_else(|| lost.then(|| WATCHER_LOST_CONTAINER.to_string()));
if let Some(reason) = reason {
record.exit_reason = Some(reason);
}
if let Some(error) = normalize_container_error(&facts.container_error) {
record
.options
.insert("containerError".to_string(), Value::String(error));
}
read_attempt_activity(&mut record);
match store.save(&record) {
Ok(()) => {
append_lifecycle(
&record,
"terminal",
serde_json::json!({ "status": record.status, "exitCode": record.exit_code,
"endTime": record.end_time, "exitReason": record.exit_reason, "oomKilled": record.oom_killed, "cgroupMemory": record.cgroup_memory }),
);
FinalizeOutcome {
updated: true,
reason: "finalized".to_string(),
}
}
Err(error) => FinalizeOutcome {
updated: false,
reason: format!("save-failed: {}", error),
},
}
}
fn resolve_reason(record: &ExecutionRecord) -> Option<String> {
let tail = if record.log_path.is_empty() {
None
} else {
read_attempt_log_tail(record, FATAL_MARKER_TAIL_BYTES)
};
let tail = crate::exit_evidence::record_tail(record, tail);
resolve_exit_reason(
record.exit_code,
tail.as_deref(),
record.oom_killed,
record.cgroup_memory.and_then(|memory| memory.oom_kills),
)
}
pub fn reconcile_finalized_record(
store: &ExecutionStore,
record: &ExecutionRecord,
) -> ExecutionRecord {
let stored = match store.get(&record.uuid) {
Some(stored) => stored,
None => return record.clone(),
};
if stored.status != ExecutionStatus::Executed || stored.end_time.is_none() {
return record.clone();
}
let mut merged = stored;
for (key, value) in &record.options {
merged.options.insert(key.clone(), value.clone());
}
merged
}
pub fn build_detached_finalize_snippet(execution_id: &str) -> String {
build_detached_finalize_snippet_with_attempt(execution_id, None)
}
pub fn build_detached_finalize_snippet_with_attempt(
execution_id: &str,
attempt_number: Option<u64>,
) -> String {
let executable = std::env::current_exe()
.map(|path| path.to_string_lossy().to_string())
.unwrap_or_else(|_| "start".to_string());
format!(
"{} {} {} \"${}\" \"${}\" \"${}\" \"${}\" \"${}\" \"${}\" \"${}\"{} >/dev/null 2>&1 || true",
shell_quote(&executable),
INTERNAL_FINALIZE_FLAG,
shell_quote(execution_id),
shell_vars::EXIT,
shell_vars::OOM,
shell_vars::STARTED,
shell_vars::FINISHED,
shell_vars::ERROR,
shell_vars::RUNNING,
cgroup_shell_vars::SAMPLE,
attempt_number.map(|number| format!(" {}", shell_quote(&number.to_string()))).unwrap_or_default(),
)
}
pub fn run_internal_finalize(args: &[String]) {
let execution_id = match args.first() {
Some(value) if !value.is_empty() => value.clone(),
_ => return,
};
if std::env::var("START_DISABLE_TRACKING").as_deref() == Ok("true") {
return;
}
let field = |index: usize| args.get(index).cloned().unwrap_or_default();
let store = ExecutionStore::with_options(ExecutionStoreOptions {
app_folder: std::env::var("START_APP_FOLDER").ok().map(PathBuf::from),
..ExecutionStoreOptions::default()
});
finalize_detached_execution_with_attempt(
&store,
&execution_id,
&DetachedFinalizeFacts {
exit_code: field(1),
oom_killed: field(2),
started_at: field(3),
finished_at: field(4),
container_error: field(5),
running: field(6),
cgroup_memory: field(7),
},
field(8).parse().ok(),
);
}