nixfleet_agent/activation/
verify_poll.rs

1//! Post-switch verify: poll `/run/current-system` until expected, or terminal.
2
3use std::time::Duration;
4
5use anyhow::{Context, Result, anyhow};
6
7use super::types::{POLL_BUDGET, POLL_INTERVAL};
8
9pub async fn read_current_system_basename() -> Result<String> {
10    let target = tokio::fs::read_link("/run/current-system")
11        .await
12        .with_context(|| "readlink /run/current-system")?;
13    let basename = target
14        .file_name()
15        .and_then(|n| n.to_str())
16        .ok_or_else(|| {
17            anyhow!(
18                "/run/current-system target has no utf-8 basename: {}",
19                target.display()
20            )
21        })?
22        .to_string();
23    Ok(basename)
24}
25
26#[derive(Debug, Clone)]
27pub enum PollOutcome {
28    Settled,
29    /// `last_observed` distinguishes "still running" from "switch died".
30    Timeout {
31        last_observed: String,
32    },
33    /// Symlink at a third basename - caller must roll back.
34    /// Only produced when caller set `previous_basename = Some(_)`.
35    FlippedToUnexpected {
36        observed: String,
37    },
38}
39
40/// `previous_basename = Some(p)` enables hard-mismatch detection: any third
41/// basename -> `FlippedToUnexpected` immediately. Rollback path leaves it None
42/// (no meaningful pre-state). Read errors during polling are non-fatal.
43pub struct VerifyPoll<'a> {
44    pub expected_basename: &'a str,
45    pub previous_basename: Option<&'a str>,
46    pub interval: Duration,
47    pub budget: Duration,
48}
49
50impl<'a> VerifyPoll<'a> {
51    pub fn new(expected_basename: &'a str) -> Self {
52        Self {
53            expected_basename,
54            previous_basename: None,
55            interval: POLL_INTERVAL,
56            budget: POLL_BUDGET,
57        }
58    }
59
60    pub fn with_previous(mut self, previous: &'a str) -> Self {
61        self.previous_basename = Some(previous);
62        self
63    }
64
65    /// Pure: no logging, deterministic timing.
66    pub async fn until_settled(&self) -> PollOutcome {
67        let deadline = tokio::time::Instant::now() + self.budget;
68        // unwrap_or_else fallback covers the budget=0 edge where no read runs.
69        #[allow(unused_assignments)]
70        let mut last_observed: Option<String> = None;
71
72        loop {
73            match read_current_system_basename().await {
74                Ok(basename) => {
75                    if basename == self.expected_basename {
76                        return PollOutcome::Settled;
77                    }
78                    if let Some(prev) = self.previous_basename
79                        && basename != prev
80                    {
81                        return PollOutcome::FlippedToUnexpected { observed: basename };
82                    }
83                    last_observed = Some(basename);
84                }
85                Err(err) => {
86                    // GOTCHA: symlink briefly absent mid-activation - read errors are non-fatal.
87                    last_observed = Some(format!("<read-error: {err}>"));
88                }
89            }
90
91            if tokio::time::Instant::now() >= deadline {
92                return PollOutcome::Timeout {
93                    last_observed: last_observed
94                        .unwrap_or_else(|| String::from("<no-reads-completed>")),
95                };
96            }
97            tokio::time::sleep(self.interval).await;
98        }
99    }
100}