Skip to main content

ados_protocol/
armed.rs

1//! Whether the vehicle counts as armed, read from the router's state socket.
2//!
3//! Every consumer that must refuse a link-dropping action in flight (the
4//! control surface's interlock, the plugin host's unattended updates, the
5//! installer and the CLI) answers the same question the same way: the latest
6//! vehicle-state snapshot says `armed: false` and is younger than
7//! [`ARMED_STATE_MAX_AGE`], or the vehicle is treated as armed. No state, a
8//! stale state, or a state without an `armed` field proves nothing, so each of
9//! those fails closed.
10//!
11//! [`StateSocketProbe`] keeps a live snapshot for a long-running service;
12//! [`read_state_snapshot`] reads one snapshot for a one-shot caller.
13
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use serde_json::Value;
19use tokio::io::BufReader;
20use tokio::net::UnixStream;
21
22use crate::retry::{RetryPace, OS_STATE};
23use crate::state::read_state_value;
24
25/// How old the vehicle state may be before the vehicle is assumed armed.
26pub const ARMED_STATE_MAX_AGE: Duration = Duration::from_secs(3);
27
28/// Reconnect pacing for the probe: fixed, uncapped.
29const RECONNECT_PACE: RetryPace = OS_STATE;
30
31/// Whether the vehicle should be treated as armed: the latest state says so,
32/// or there is no state newer than [`ARMED_STATE_MAX_AGE`] to say otherwise.
33pub fn vehicle_treated_as_armed(snapshot: Option<(Instant, Value)>) -> bool {
34    match snapshot {
35        Some((at, state)) if at.elapsed() <= ARMED_STATE_MAX_AGE => {
36            state.get("armed").and_then(Value::as_bool) != Some(false)
37        }
38        _ => true,
39    }
40}
41
42/// A source of the armed verdict.
43pub trait ArmedProbe: Send + Sync {
44    /// Whether a link-dropping action must be refused right now.
45    fn treated_as_armed(&self) -> bool;
46}
47
48/// An [`ArmedProbe`] fed by a background reader of the router state socket.
49pub struct StateSocketProbe {
50    latest: Mutex<Option<(Instant, Value)>>,
51    vehicle_profile: bool,
52}
53
54impl StateSocketProbe {
55    /// Start the reader task on the current tokio runtime and return the probe.
56    /// A node whose profile carries no vehicle never counts as armed; the task
57    /// is not started for it. The task reconnects every 2 s, with no cap, after
58    /// a connect or read failure, and ends when the last probe handle drops.
59    pub fn spawn(state_socket: PathBuf, vehicle_profile: bool) -> Arc<Self> {
60        let probe = Arc::new(Self {
61            latest: Mutex::new(None),
62            vehicle_profile,
63        });
64        if vehicle_profile {
65            let weak = Arc::downgrade(&probe);
66            tokio::spawn(async move { read_loop(state_socket, weak).await });
67        }
68        probe
69    }
70
71    fn store(&self, value: Value) {
72        if let Ok(mut latest) = self.latest.lock() {
73            *latest = Some((Instant::now(), value));
74        }
75    }
76
77    fn latest(&self) -> Option<(Instant, Value)> {
78        self.latest.lock().ok().and_then(|latest| latest.clone())
79    }
80}
81
82impl ArmedProbe for StateSocketProbe {
83    fn treated_as_armed(&self) -> bool {
84        self.vehicle_profile && vehicle_treated_as_armed(self.latest())
85    }
86}
87
88async fn read_loop(state_socket: PathBuf, probe: std::sync::Weak<StateSocketProbe>) {
89    loop {
90        if probe.strong_count() == 0 {
91            return;
92        }
93        match UnixStream::connect(&state_socket).await {
94            Ok(stream) => {
95                let mut reader = BufReader::new(stream);
96                loop {
97                    match read_state_value(&mut reader).await {
98                        Ok(Some(value)) => match probe.upgrade() {
99                            Some(p) => p.store(value),
100                            None => return,
101                        },
102                        Ok(None) => break,
103                        Err(e) => {
104                            tracing::debug!(error = %e, "armed probe: state read failed");
105                            break;
106                        }
107                    }
108                }
109            }
110            Err(e) => {
111                tracing::debug!(
112                    path = %state_socket.display(),
113                    error = %e,
114                    "armed probe: state socket absent; will retry"
115                );
116            }
117        }
118        tokio::time::sleep(RECONNECT_PACE.wait()).await;
119    }
120}
121
122/// Read one vehicle-state snapshot from `state_socket`, giving up after
123/// `timeout`. `None` when the socket is absent, the stream ends, or no frame
124/// arrives in time; pass the result to [`vehicle_treated_as_armed`].
125pub async fn read_state_snapshot(
126    state_socket: &Path,
127    timeout: Duration,
128) -> Option<(Instant, Value)> {
129    let read = async {
130        let stream = UnixStream::connect(state_socket).await.ok()?;
131        let mut reader = BufReader::new(stream);
132        read_state_value(&mut reader).await.ok().flatten()
133    };
134    let value = tokio::time::timeout(timeout, read).await.ok()??;
135    Some((Instant::now(), value))
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use serde_json::json;
142
143    #[test]
144    fn an_armed_unknown_or_stale_vehicle_counts_as_armed() {
145        let now = Instant::now();
146        assert!(vehicle_treated_as_armed(None), "no state fails closed");
147        assert!(vehicle_treated_as_armed(Some((
148            now,
149            json!({"armed": true})
150        ))));
151        assert!(!vehicle_treated_as_armed(Some((
152            now,
153            json!({"armed": false})
154        ))));
155        assert!(vehicle_treated_as_armed(Some((now, json!({})))));
156        let stale = now - ARMED_STATE_MAX_AGE - Duration::from_secs(1);
157        assert!(
158            vehicle_treated_as_armed(Some((stale, json!({"armed": false})))),
159            "state older than the bound proves nothing"
160        );
161    }
162
163    #[tokio::test]
164    async fn a_probe_without_a_vehicle_never_counts_as_armed() {
165        let dir = tempfile::tempdir().unwrap();
166        let probe = StateSocketProbe::spawn(dir.path().join("state.sock"), false);
167        assert!(!probe.treated_as_armed());
168    }
169
170    #[tokio::test]
171    async fn a_vehicle_probe_with_no_state_counts_as_armed() {
172        let dir = tempfile::tempdir().unwrap();
173        let probe = StateSocketProbe::spawn(dir.path().join("state.sock"), true);
174        assert!(probe.treated_as_armed());
175        probe.store(json!({"armed": false}));
176        assert!(!probe.treated_as_armed());
177    }
178
179    #[tokio::test]
180    async fn a_missing_socket_reads_no_snapshot() {
181        let dir = tempfile::tempdir().unwrap();
182        let snap =
183            read_state_snapshot(&dir.path().join("state.sock"), Duration::from_millis(200)).await;
184        assert!(snap.is_none());
185    }
186}