Skip to content

Commit 1c21fbb

Browse files
authored
feat: persist daemon termination evidence (#8170)
1 parent f0710a3 commit 1c21fbb

11 files changed

Lines changed: 291 additions & 3 deletions

File tree

src/commands/daemon.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ enum DaemonCommand {
7373
#[arg(long, default_value = daemon::DEFAULT_ADDR)]
7474
addr: String,
7575
},
76+
/// Supervise one daemon child and persist its termination evidence.
77+
#[command(hide = true)]
78+
Supervise {
79+
#[arg(long, default_value = daemon::DEFAULT_ADDR)]
80+
addr: String,
81+
#[arg(long)]
82+
startup_token: String,
83+
},
7684
/// Stop the background daemon recorded in the state file
7785
Stop,
7886
/// Show daemon state and selected local address
@@ -163,6 +171,10 @@ pub fn run(args: DaemonArgs, _global: &crate::commands::GlobalArgs) -> CmdResult
163171
0,
164172
)),
165173
DaemonCommand::Serve { addr } => serve(&addr),
174+
DaemonCommand::Supervise { addr, startup_token } => {
175+
daemon::supervise(&addr, &startup_token)?;
176+
Ok((DaemonOutput::Serve(DaemonStartResult { pid: std::process::id(), address: addr, state_path: String::new(), lease_id: String::new() }), 0))
177+
}
166178
DaemonCommand::Stop => Ok((DaemonOutput::Stop(daemon::stop()?), 0)),
167179
DaemonCommand::Status => Ok((DaemonOutput::Status(daemon::read_status()?), 0)),
168180
DaemonCommand::BrokerConfig {

src/commands/runner/doctor/remote.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,13 @@ pub(super) fn disconnected_report(
283283
if let Some(evidence) = &recovery.ownership_evidence {
284284
details.insert("ownership_evidence".to_string(), evidence.clone());
285285
}
286+
if let Some(evidence) = &recovery.termination_evidence {
287+
details.insert(
288+
"termination_evidence".to_string(),
289+
serde_json::to_string(evidence)
290+
.unwrap_or_else(|_| "unavailable: serialization failed".to_string()),
291+
);
292+
}
286293
(
287294
"Disconnected runner was checked through bounded remote lease recovery".to_string(),
288295
recovery.adoption_command.clone(),

src/commands/runner/doctor/tests/daemon.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ fn disconnected_lab_doctor_reuses_daemon_recovery_envelope() {
127127
daemon_build_identity: Some("homeboy 0.284.0+live".to_string()),
128128
runtime_paths: None,
129129
active_jobs: 1,
130+
termination_evidence: None,
130131
repair_plan: Vec::new(),
131132
};
132133
let runner = Runner {

src/core/daemon.rs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub use artifact_download::ArtifactDownload;
3737
pub use broker_config::{render_broker_config, BrokerConfig, BrokerConfigOptions, ServiceIdentity};
3838
pub use control::{
3939
adopt_orphaned_lease, artifact_content_url, ensure_running, fetch_artifact_to_path,
40-
reconcile_leaseless_orphans, recover_missing_lease_state, start_background,
40+
reconcile_leaseless_orphans, recover_missing_lease_state, start_background, supervise,
4141
ArtifactFetchOutcome,
4242
};
4343
use patch_capture::{capture_baseline, capture_patch_report};
@@ -88,6 +88,43 @@ pub struct DaemonStatus {
8888
/// operator can distinguish another runner from an attributable owner.
8989
#[serde(default, skip_serializing_if = "Vec::is_empty")]
9090
pub process_candidates: Vec<DaemonProcessCandidate>,
91+
/// Launcher-owned evidence is best effort. A missing record explicitly does
92+
/// not imply an OS-level cause for a dead daemon.
93+
#[serde(skip_serializing_if = "Option::is_none")]
94+
pub termination_evidence: Option<DaemonTerminationEvidence>,
95+
}
96+
97+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
98+
#[serde(rename_all = "snake_case")]
99+
pub enum DaemonTerminationClassification {
100+
CleanStop,
101+
UnexpectedExit,
102+
}
103+
104+
/// Durable, bounded evidence from the process that launched the daemon.
105+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
106+
pub struct DaemonTerminationEvidence {
107+
pub classification: DaemonTerminationClassification,
108+
pub observed_at: String,
109+
#[serde(skip_serializing_if = "Option::is_none")]
110+
pub lease_id: Option<String>,
111+
#[serde(skip_serializing_if = "Option::is_none")]
112+
pub pid: Option<u32>,
113+
#[serde(skip_serializing_if = "Option::is_none")]
114+
pub binary_identity: Option<String>,
115+
pub active_jobs: usize,
116+
pub resource_evidence: String,
117+
pub os_evidence: String,
118+
#[serde(skip_serializing_if = "Option::is_none")]
119+
pub exit_code: Option<i32>,
120+
#[serde(skip_serializing_if = "Option::is_none")]
121+
pub signal: Option<i32>,
122+
#[serde(skip_serializing_if = "Option::is_none")]
123+
pub stdout: Option<String>,
124+
#[serde(skip_serializing_if = "Option::is_none")]
125+
pub stderr: Option<String>,
126+
#[serde(default)]
127+
pub stop_requested: bool,
91128
}
92129

93130
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
@@ -168,6 +205,8 @@ pub struct DaemonFreshnessReport {
168205
#[serde(skip_serializing_if = "Option::is_none")]
169206
pub runtime_paths: Option<DaemonRuntimeSnapshot>,
170207
pub active_jobs: usize,
208+
#[serde(skip_serializing_if = "Option::is_none")]
209+
pub termination_evidence: Option<DaemonTerminationEvidence>,
171210
#[serde(default, skip_serializing_if = "Vec::is_empty")]
172211
pub repair_plan: Vec<DaemonRepairStep>,
173212
}
@@ -497,6 +536,44 @@ pub fn read_status() -> Result<DaemonStatus> {
497536
state_path,
498537
state_identity,
499538
process_candidates: control::daemon_process_candidates(&jobs_path)?,
539+
termination_evidence: (!validation.running)
540+
.then(read_termination_evidence)
541+
.transpose()?
542+
.flatten(),
543+
})
544+
}
545+
546+
fn read_termination_evidence() -> Result<Option<DaemonTerminationEvidence>> {
547+
let path = paths::daemon_termination_file()?;
548+
match fs::read_to_string(&path) {
549+
Ok(body) => serde_json::from_str(&body).map(Some).map_err(|error| {
550+
Error::internal_json(error.to_string(), Some(format!("parse {}", path.display())))
551+
}),
552+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
553+
Err(error) => Err(Error::internal_io(
554+
error.to_string(),
555+
Some(format!("read {}", path.display())),
556+
)),
557+
}
558+
}
559+
560+
pub(crate) fn write_termination_evidence(evidence: &DaemonTerminationEvidence) -> Result<()> {
561+
let path = paths::daemon_termination_file()?;
562+
let parent = path.parent().expect("daemon termination path has parent");
563+
fs::create_dir_all(parent).map_err(|error| {
564+
Error::internal_io(
565+
error.to_string(),
566+
Some(format!("create {}", parent.display())),
567+
)
568+
})?;
569+
let body = serde_json::to_vec_pretty(evidence).map_err(|error| {
570+
Error::internal_json(
571+
error.to_string(),
572+
Some("serialize daemon termination evidence".to_string()),
573+
)
574+
})?;
575+
fs::write(&path, body).map_err(|error| {
576+
Error::internal_io(error.to_string(), Some(format!("write {}", path.display())))
500577
})
501578
}
502579

@@ -672,6 +749,12 @@ fn freshness_report_from_validation(
672749
daemon_build_identity: state.map(|state| state.build_identity.display.clone()),
673750
runtime_paths: state.map(|state| state.runtime_paths.clone()),
674751
active_jobs,
752+
termination_evidence: (!validation.running)
753+
.then(read_termination_evidence)
754+
.transpose()
755+
.ok()
756+
.flatten()
757+
.flatten(),
675758
repair_plan,
676759
}
677760
}
@@ -757,6 +840,26 @@ fn stop_unlocked() -> Result<DaemonStopResult> {
757840
)));
758841
}
759842

843+
if pid_is_running(pid) {
844+
write_termination_evidence(&DaemonTerminationEvidence {
845+
classification: DaemonTerminationClassification::CleanStop,
846+
observed_at: chrono::Utc::now().to_rfc3339(),
847+
lease_id: Some(state.lease_id.clone()),
848+
pid: Some(pid),
849+
binary_identity: Some(state.build_identity.display.clone()),
850+
active_jobs: JobStore::active_count_at_path(paths::daemon_jobs_file()?)?,
851+
resource_evidence: "unavailable: launcher does not collect OS resource snapshots"
852+
.to_string(),
853+
os_evidence:
854+
"unavailable: no OS termination evidence collected before operator-requested stop"
855+
.to_string(),
856+
exit_code: None,
857+
signal: None,
858+
stdout: None,
859+
stderr: None,
860+
stop_requested: true,
861+
})?;
862+
}
760863
let stopped = if pid_is_running(pid) {
761864
terminate_pid(pid)?;
762865
true

src/core/daemon/control.rs

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ use super::{
2222
read_status, repair_legacy_lease_for_start, stop_unlocked, try_acquire_daemon_owner_lock,
2323
DaemonLeaselessOrphanReconciliationResult, DaemonLeaselessRecoveryResult,
2424
DaemonOrphanAdoptionResult, DaemonProcessCandidate, DaemonProcessOwnership,
25-
DaemonStaleReasonCode, DaemonStartResult, DAEMON_STARTUP_TOKEN_ENV,
25+
DaemonStaleReasonCode, DaemonStartResult, DaemonTerminationClassification,
26+
DaemonTerminationEvidence, DAEMON_STARTUP_TOKEN_ENV,
2627
};
2728

2829
/// Enumerate foreground daemon processes without inferring ownership from a
@@ -53,6 +54,81 @@ pub(super) fn daemon_process_candidates(jobs_path: &Path) -> Result<Vec<DaemonPr
5354
.collect())
5455
}
5556

57+
/// Supervise one daemon child and persist its bounded termination evidence.
58+
/// This is shared by local and SSH launches because SSH invokes the same CLI.
59+
pub fn supervise(addr: &str, startup_token: &str) -> Result<()> {
60+
let exe = std::env::current_exe().map_err(|error| {
61+
Error::internal_io(
62+
error.to_string(),
63+
Some("resolve current executable".to_string()),
64+
)
65+
})?;
66+
let child = Command::new(exe)
67+
.args(["daemon", "serve", "--addr", addr])
68+
.env(DAEMON_STARTUP_TOKEN_ENV, startup_token)
69+
.stdin(Stdio::null())
70+
.stdout(Stdio::piped())
71+
.stderr(Stdio::piped())
72+
.spawn()
73+
.map_err(|error| {
74+
Error::internal_io(
75+
error.to_string(),
76+
Some("spawn supervised daemon".to_string()),
77+
)
78+
})?;
79+
let pid = child.id();
80+
let output = child.wait_with_output().map_err(|error| {
81+
Error::internal_io(
82+
error.to_string(),
83+
Some("wait for supervised daemon".to_string()),
84+
)
85+
})?;
86+
let state = super::validate_lease_file(&crate::core::paths::daemon_state_file()?)
87+
.ok()
88+
.and_then(|validation| validation.state);
89+
let prior = super::read_termination_evidence()?;
90+
let stop_requested = prior
91+
.as_ref()
92+
.is_some_and(|evidence| evidence.stop_requested && evidence.pid == Some(pid));
93+
let (exit_code, signal) = exit_details(&output.status);
94+
let evidence = DaemonTerminationEvidence {
95+
classification: if stop_requested { DaemonTerminationClassification::CleanStop } else { DaemonTerminationClassification::UnexpectedExit },
96+
observed_at: chrono::Utc::now().to_rfc3339(),
97+
lease_id: state.as_ref().map(|state| state.lease_id.clone()).or_else(|| prior.and_then(|evidence| evidence.lease_id)),
98+
pid: Some(pid),
99+
binary_identity: state.as_ref().map(|state| state.build_identity.display.clone()),
100+
active_jobs: super::JobStore::active_count_at_path(crate::core::paths::daemon_jobs_file()?)?,
101+
resource_evidence: "unavailable: launcher does not collect OS resource snapshots".to_string(),
102+
os_evidence: "unavailable: no OS evidence collected; exit status and signal are launcher observations only".to_string(),
103+
exit_code, signal,
104+
stdout: bounded_redacted(&output.stdout), stderr: bounded_redacted(&output.stderr), stop_requested,
105+
};
106+
super::write_termination_evidence(&evidence)
107+
}
108+
109+
fn bounded_redacted(bytes: &[u8]) -> Option<String> {
110+
const LIMIT: usize = 4096;
111+
if bytes.is_empty() {
112+
return None;
113+
}
114+
let mut text = String::from_utf8_lossy(&bytes[..bytes.len().min(LIMIT)]).to_string();
115+
if bytes.len() > LIMIT {
116+
text.push_str("\n[truncated]");
117+
}
118+
Some(crate::core::redaction::redact_string(&text))
119+
}
120+
121+
#[cfg(unix)]
122+
fn exit_details(status: &std::process::ExitStatus) -> (Option<i32>, Option<i32>) {
123+
use std::os::unix::process::ExitStatusExt;
124+
(status.code(), status.signal())
125+
}
126+
127+
#[cfg(not(unix))]
128+
fn exit_details(status: &std::process::ExitStatus) -> (Option<i32>, Option<i32>) {
129+
(status.code(), None)
130+
}
131+
56132
fn parse_daemon_process_candidate(
57133
line: &str,
58134
jobs_path: &Path,
@@ -1199,7 +1275,14 @@ fn spawn_and_wait_for_lease(addr: &str, startup_token: &str) -> Result<DaemonSta
11991275
)
12001276
})?;
12011277
let child = Command::new(exe)
1202-
.args(["daemon", "serve", "--addr", addr])
1278+
.args([
1279+
"daemon",
1280+
"supervise",
1281+
"--addr",
1282+
addr,
1283+
"--startup-token",
1284+
startup_token,
1285+
])
12031286
.env(DAEMON_STARTUP_TOKEN_ENV, startup_token)
12041287
.stdin(Stdio::null())
12051288
.stdout(Stdio::null())
@@ -1343,6 +1426,59 @@ fn default_artifact_output_path(artifact_id: &str) -> PathBuf {
13431426
.unwrap_or_else(|| PathBuf::from("artifact.bin"))
13441427
}
13451428

1429+
#[cfg(test)]
1430+
mod termination_tests {
1431+
use super::*;
1432+
1433+
#[test]
1434+
fn termination_output_is_bounded_and_redacted() {
1435+
let output =
1436+
bounded_redacted(format!("token=super-secret\n{}", "x".repeat(5_000)).as_bytes())
1437+
.expect("output");
1438+
assert!(output.contains("[REDACTED]"));
1439+
assert!(output.contains("[truncated]"));
1440+
assert!(output.len() < 4_200);
1441+
}
1442+
1443+
#[cfg(unix)]
1444+
#[test]
1445+
fn nonzero_fixture_exit_preserves_exit_status_without_os_cause_inference() {
1446+
let status = Command::new("sh")
1447+
.args(["-c", "exit 23"])
1448+
.status()
1449+
.expect("fixture process");
1450+
assert_eq!(exit_details(&status), (Some(23), None));
1451+
let evidence = DaemonTerminationEvidence {
1452+
classification: DaemonTerminationClassification::UnexpectedExit,
1453+
observed_at: "2026-01-01T00:00:00Z".to_string(),
1454+
lease_id: Some("lease".to_string()),
1455+
pid: Some(1),
1456+
binary_identity: Some("fixture".to_string()),
1457+
active_jobs: 1,
1458+
resource_evidence: "unavailable: fixture has no OS resource snapshot".to_string(),
1459+
os_evidence: "unavailable: fixture has no OS evidence".to_string(),
1460+
exit_code: Some(23),
1461+
signal: None,
1462+
stdout: None,
1463+
stderr: Some("panic: fixture".to_string()),
1464+
stop_requested: false,
1465+
};
1466+
assert_eq!(
1467+
evidence.classification,
1468+
DaemonTerminationClassification::UnexpectedExit
1469+
);
1470+
assert!(evidence.os_evidence.starts_with("unavailable:"));
1471+
}
1472+
1473+
#[test]
1474+
fn requested_stop_is_distinct_from_unexpected_exit() {
1475+
assert_ne!(
1476+
DaemonTerminationClassification::CleanStop,
1477+
DaemonTerminationClassification::UnexpectedExit
1478+
);
1479+
}
1480+
}
1481+
13461482
fn header_value(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
13471483
headers
13481484
.get(name)

src/core/paths/runtime.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ pub fn daemon_jobs_file() -> Result<PathBuf> {
2424
Ok(daemon_state_dir()?.join("jobs.json"))
2525
}
2626

27+
/// Latest bounded launcher-owned termination evidence for this daemon store.
28+
pub fn daemon_termination_file() -> Result<PathBuf> {
29+
Ok(daemon_state_dir()?.join("termination.json"))
30+
}
31+
2732
/// Exact state-loss recovery receipt keyed by the operator-supplied lease.
2833
pub fn daemon_state_loss_recovery_receipt_file(lease_id: &str) -> Result<PathBuf> {
2934
Ok(daemon_state_dir()?

0 commit comments

Comments
 (0)