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}