Skip to main content

ados_protocol/
mavlink_ingest.rs

1//! Client for a node's MAVLink republish seam.
2//!
3//! A node's MAVLink router owns the frame fan-out that feeds its IPC socket and
4//! its direct-GCS transports. Normally that fan-out is fed by the router's own
5//! flight-controller link. A ground station has no flight controller: the
6//! vehicle's frames arrive over the radio's auxiliary lane, are decoded by the
7//! ground data plane, and then have to get into that same fan-out so a ground
8//! control station connected to the ground station sees the vehicle exactly as
9//! it would on a direct link.
10//!
11//! Nothing carried frames in that direction. The router's existing socket
12//! inbound path is the opposite direction — bytes a client sends there are
13//! written toward the flight controller — so reusing it would push the
14//! vehicle's own telemetry back at the autopilot. This module is the missing
15//! direction: a separate socket whose frames are published to the fan-out and
16//! nowhere else.
17//!
18//! ## Wire
19//!
20//! The [`crate::frame`] primitive: a 4-byte big-endian length followed by one
21//! raw MAVLink frame, identical to the router's other frame socket, so a reader
22//! written against one cannot misparse the other.
23//!
24//! ## What a successful send does and does not prove
25//!
26//! `Ok` means the framed bytes entered the socket. It does NOT mean the router
27//! accepted the frame, published it, or that any ground control station was
28//! connected to receive it. The proof of republish is the router's own accepted
29//! counter, on the far side of this socket.
30//!
31//! ## Bounded by construction
32//!
33//! Every operation is bounded by [`INGEST_WRITE_TIMEOUT`], so a wedged or
34//! stalled router costs a caller one timeout rather than parking it forever.
35//! Callers on a live data path are expected to drop the frame on a timeout, not
36//! queue it: this lane is a convenience over a radio, never a reason to stall.
37
38use std::path::{Path, PathBuf};
39use std::time::Duration;
40
41use tokio::io::AsyncWriteExt;
42use tokio::net::UnixStream;
43use tokio::sync::Mutex;
44
45use crate::frame::{encode_frame, FrameError, MAVLINK_MAX_FRAME};
46
47/// Socket file name under the agent run directory.
48pub const MAVLINK_INGEST_SOCK_NAME: &str = "mavlink-ingest.sock";
49
50/// Default agent run directory, overridden by `ADOS_RUN_DIR`.
51const DEFAULT_RUN_DIR: &str = "/run/ados";
52
53/// Bound on one connect or one framed write.
54///
55/// Short on purpose. The peer is a local process on the same box, so anything
56/// slower than this is a stall rather than latency, and a data-path caller must
57/// find that out quickly enough to drop the frame instead of backing up behind
58/// it.
59pub const INGEST_WRITE_TIMEOUT: Duration = Duration::from_millis(500);
60
61/// The republish socket path, honouring the `ADOS_RUN_DIR` override so a test
62/// or a second instance does not collide with the running service.
63pub fn ingest_socket_path() -> PathBuf {
64    let dir = std::env::var("ADOS_RUN_DIR").unwrap_or_else(|_| DEFAULT_RUN_DIR.to_string());
65    Path::new(&dir).join(MAVLINK_INGEST_SOCK_NAME)
66}
67
68/// Why a frame did not reach the republish seam.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum IngestError {
71    /// The frame is larger than the socket's frame cap. Never truncated: half a
72    /// MAVLink frame fails its checksum downstream and reads as link corruption
73    /// rather than as our bug. Carries the offending length.
74    TooLarge(usize),
75    /// The socket could not be reached. Retriable — the router may simply not
76    /// be up yet, or may not serve this seam on this node's profile.
77    Unavailable(String),
78    /// The connection was established but the write failed or timed out.
79    Send(String),
80}
81
82impl std::fmt::Display for IngestError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::TooLarge(n) => write!(f, "frame of {n} bytes exceeds the socket frame cap"),
86            Self::Unavailable(e) => write!(f, "republish seam unavailable: {e}"),
87            Self::Send(e) => write!(f, "republish write failed: {e}"),
88        }
89    }
90}
91
92impl std::error::Error for IngestError {}
93
94/// A producer's handle on a node's MAVLink republish seam.
95///
96/// Connects lazily on first send and keeps the connection for subsequent ones.
97/// A failed send drops the connection so the next send reconnects rather than
98/// writing into a dead handle forever.
99pub struct MavlinkIngest {
100    path: PathBuf,
101    timeout: Duration,
102    /// The connected stream, `None` until the first successful connect.
103    ///
104    /// The lock is held across the whole write. That is deliberate and load
105    /// bearing: this is a length-prefixed byte stream, so two interleaved
106    /// writes would splice one frame's payload into another's header and
107    /// desynchronise the reader permanently. Every path holding it is bounded
108    /// by `timeout`.
109    conn: Mutex<Option<UnixStream>>,
110}
111
112impl MavlinkIngest {
113    /// A client against the given socket path.
114    pub fn new(path: impl Into<PathBuf>) -> Self {
115        Self::with_timeout(path, INGEST_WRITE_TIMEOUT)
116    }
117
118    /// A client against the run directory's standard socket path.
119    pub fn at_default_path() -> Self {
120        Self::new(ingest_socket_path())
121    }
122
123    /// A client with an explicit bound on connect and write, so a test does not
124    /// pay the production timeout to prove the wedged-peer case.
125    pub fn with_timeout(path: impl Into<PathBuf>, timeout: Duration) -> Self {
126        Self {
127            path: path.into(),
128            timeout,
129            conn: Mutex::new(None),
130        }
131    }
132
133    /// Whether a connection is currently held open.
134    pub async fn is_connected(&self) -> bool {
135        self.conn.lock().await.is_some()
136    }
137
138    /// Send one raw MAVLink frame to the republish seam.
139    ///
140    /// An oversized frame is rejected before anything is connected, so a
141    /// producer emitting only oversized frames never opens the socket at all.
142    /// See the module note on what `Ok` does not prove.
143    pub async fn send_frame(&self, frame: &[u8]) -> Result<(), IngestError> {
144        let framed = encode_frame(frame, MAVLINK_MAX_FRAME).map_err(|e| match e {
145            FrameError::TooLarge { len, .. } | FrameError::PayloadTooBig(len) => {
146                IngestError::TooLarge(len)
147            }
148            // encode_frame never produces this variant; mapping it keeps the
149            // match total without inventing a category for an impossible case.
150            FrameError::ZeroLength => IngestError::TooLarge(0),
151        })?;
152
153        let mut guard = self.conn.lock().await;
154        if guard.is_none() {
155            let connect = tokio::time::timeout(self.timeout, UnixStream::connect(&self.path))
156                .await
157                .map_err(|_| IngestError::Unavailable("connect timed out".into()))?
158                .map_err(|e| IngestError::Unavailable(e.to_string()))?;
159            *guard = Some(connect);
160        }
161
162        let stream = guard.as_mut().expect("connected above");
163        let write = tokio::time::timeout(self.timeout, stream.write_all(&framed)).await;
164        match write {
165            Ok(Ok(())) => Ok(()),
166            Ok(Err(e)) => {
167                // Self-heal: a half-written frame has desynchronised the
168                // stream, so the connection is unusable and must be replaced,
169                // not reused.
170                *guard = None;
171                Err(IngestError::Send(e.to_string()))
172            }
173            Err(_) => {
174                *guard = None;
175                Err(IngestError::Send("write timed out".into()))
176            }
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use tokio::io::AsyncReadExt;
185    use tokio::net::UnixListener;
186    use tokio::sync::mpsc;
187
188    /// A stand-in router: accepts one connection and decodes the length-prefixed
189    /// frames off it onto a channel, exactly as the real seam's reader does.
190    fn fake_router(path: PathBuf) -> (mpsc::Receiver<Vec<u8>>, tokio::task::JoinHandle<()>) {
191        let (tx, rx) = mpsc::channel(16);
192        let handle = tokio::spawn(async move {
193            let listener = UnixListener::bind(&path).unwrap();
194            let Ok((mut stream, _)) = listener.accept().await else {
195                return;
196            };
197            loop {
198                let mut header = [0u8; crate::frame::HEADER_SIZE];
199                if stream.read_exact(&mut header).await.is_err() {
200                    return;
201                }
202                let len = u32::from_be_bytes(header) as usize;
203                let mut payload = vec![0u8; len];
204                if len > 0 && stream.read_exact(&mut payload).await.is_err() {
205                    return;
206                }
207                if tx.send(payload).await.is_err() {
208                    return;
209                }
210            }
211        });
212        (rx, handle)
213    }
214
215    #[tokio::test]
216    async fn a_frame_arrives_byte_for_byte() {
217        let dir = tempfile::tempdir().unwrap();
218        let path = dir.path().join(MAVLINK_INGEST_SOCK_NAME);
219        let (mut rx, router) = fake_router(path.clone());
220        tokio::time::sleep(Duration::from_millis(20)).await;
221
222        let client = MavlinkIngest::new(&path);
223        let frame = vec![0xFD, 0x09, 0x00, 0x00, 0x42, 0x01, 0x01, 0x00, 0x00, 0x00];
224        client.send_frame(&frame).await.unwrap();
225
226        assert_eq!(rx.recv().await.unwrap(), frame);
227        router.abort();
228    }
229
230    #[tokio::test]
231    async fn the_connection_is_opened_once_and_reused() {
232        // The fake accepts exactly one connection, so a client that reconnected
233        // per frame would lose the second frame.
234        let dir = tempfile::tempdir().unwrap();
235        let path = dir.path().join(MAVLINK_INGEST_SOCK_NAME);
236        let (mut rx, router) = fake_router(path.clone());
237        tokio::time::sleep(Duration::from_millis(20)).await;
238
239        let client = MavlinkIngest::new(&path);
240        client.send_frame(b"first").await.unwrap();
241        client.send_frame(b"second").await.unwrap();
242
243        assert_eq!(rx.recv().await.unwrap(), b"first");
244        assert_eq!(rx.recv().await.unwrap(), b"second");
245        assert!(client.is_connected().await);
246        router.abort();
247    }
248
249    #[tokio::test]
250    async fn an_absent_router_is_unavailable_not_a_panic() {
251        let client = MavlinkIngest::with_timeout(
252            "/nonexistent/mavlink-ingest.sock",
253            Duration::from_millis(50),
254        );
255        assert!(matches!(
256            client.send_frame(b"frame").await,
257            Err(IngestError::Unavailable(_))
258        ));
259        assert!(!client.is_connected().await);
260    }
261
262    #[tokio::test]
263    async fn an_oversized_frame_is_rejected_before_connecting() {
264        // The path does not exist, so any connect attempt would report
265        // Unavailable. TooLarge proves the size check ran first.
266        let client = MavlinkIngest::with_timeout(
267            "/nonexistent/mavlink-ingest.sock",
268            Duration::from_millis(50),
269        );
270        let too_big = vec![0u8; MAVLINK_MAX_FRAME + 1];
271        assert_eq!(
272            client.send_frame(&too_big).await,
273            Err(IngestError::TooLarge(MAVLINK_MAX_FRAME + 1))
274        );
275    }
276
277    #[tokio::test]
278    async fn a_dropped_router_is_reported_and_the_client_reconnects() {
279        let dir = tempfile::tempdir().unwrap();
280        let path = dir.path().join(MAVLINK_INGEST_SOCK_NAME);
281        let (mut rx, router) = fake_router(path.clone());
282        tokio::time::sleep(Duration::from_millis(20)).await;
283
284        let client = MavlinkIngest::with_timeout(&path, Duration::from_millis(200));
285        client.send_frame(b"before").await.unwrap();
286        assert_eq!(rx.recv().await.unwrap(), b"before");
287
288        // Router goes away, socket file with it.
289        router.abort();
290        drop(rx);
291        tokio::time::sleep(Duration::from_millis(20)).await;
292        std::fs::remove_file(&path).ok();
293
294        // Writes into the dead peer eventually fail. Either the write errors
295        // straight away or it lands in the socket buffer and the NEXT one
296        // fails; both must end with the client having dropped the connection
297        // rather than holding a corpse.
298        let mut reported = false;
299        for _ in 0..8 {
300            if client.send_frame(b"after").await.is_err() {
301                reported = true;
302                break;
303            }
304        }
305        assert!(reported, "a dead peer must surface as an error");
306        assert!(
307            !client.is_connected().await,
308            "the dead connection must be dropped so the next send reconnects"
309        );
310
311        // With the router back, the client recovers on its own.
312        let (mut rx2, router2) = fake_router(path.clone());
313        tokio::time::sleep(Duration::from_millis(20)).await;
314        client.send_frame(b"recovered").await.unwrap();
315        assert_eq!(rx2.recv().await.unwrap(), b"recovered");
316        router2.abort();
317    }
318
319    #[tokio::test]
320    async fn the_socket_path_follows_the_run_directory_override() {
321        // Guards the seam both processes must agree on: the ground data plane
322        // and the router derive this path independently.
323        assert!(ingest_socket_path().ends_with(MAVLINK_INGEST_SOCK_NAME));
324    }
325}