use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::net::UnixStream;
use tokio::sync::Mutex;
use crate::frame::{encode_frame, FrameError, MAVLINK_MAX_FRAME};
pub const MAVLINK_INGEST_SOCK_NAME: &str = "mavlink-ingest.sock";
const DEFAULT_RUN_DIR: &str = "/run/ados";
pub const INGEST_WRITE_TIMEOUT: Duration = Duration::from_millis(500);
pub fn ingest_socket_path() -> PathBuf {
let dir = std::env::var("ADOS_RUN_DIR").unwrap_or_else(|_| DEFAULT_RUN_DIR.to_string());
Path::new(&dir).join(MAVLINK_INGEST_SOCK_NAME)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IngestError {
TooLarge(usize),
Unavailable(String),
Send(String),
}
impl std::fmt::Display for IngestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLarge(n) => write!(f, "frame of {n} bytes exceeds the socket frame cap"),
Self::Unavailable(e) => write!(f, "republish seam unavailable: {e}"),
Self::Send(e) => write!(f, "republish write failed: {e}"),
}
}
}
impl std::error::Error for IngestError {}
pub struct MavlinkIngest {
path: PathBuf,
timeout: Duration,
conn: Mutex<Option<UnixStream>>,
}
impl MavlinkIngest {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self::with_timeout(path, INGEST_WRITE_TIMEOUT)
}
pub fn at_default_path() -> Self {
Self::new(ingest_socket_path())
}
pub fn with_timeout(path: impl Into<PathBuf>, timeout: Duration) -> Self {
Self {
path: path.into(),
timeout,
conn: Mutex::new(None),
}
}
pub async fn is_connected(&self) -> bool {
self.conn.lock().await.is_some()
}
pub async fn send_frame(&self, frame: &[u8]) -> Result<(), IngestError> {
let framed = encode_frame(frame, MAVLINK_MAX_FRAME).map_err(|e| match e {
FrameError::TooLarge { len, .. } | FrameError::PayloadTooBig(len) => {
IngestError::TooLarge(len)
}
FrameError::ZeroLength => IngestError::TooLarge(0),
})?;
let mut guard = self.conn.lock().await;
if guard.is_none() {
let connect = tokio::time::timeout(self.timeout, UnixStream::connect(&self.path))
.await
.map_err(|_| IngestError::Unavailable("connect timed out".into()))?
.map_err(|e| IngestError::Unavailable(e.to_string()))?;
*guard = Some(connect);
}
let stream = guard.as_mut().expect("connected above");
let write = tokio::time::timeout(self.timeout, stream.write_all(&framed)).await;
match write {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => {
*guard = None;
Err(IngestError::Send(e.to_string()))
}
Err(_) => {
*guard = None;
Err(IngestError::Send("write timed out".into()))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
use tokio::net::UnixListener;
use tokio::sync::mpsc;
fn fake_router(path: PathBuf) -> (mpsc::Receiver<Vec<u8>>, tokio::task::JoinHandle<()>) {
let (tx, rx) = mpsc::channel(16);
let handle = tokio::spawn(async move {
let listener = UnixListener::bind(&path).unwrap();
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
loop {
let mut header = [0u8; crate::frame::HEADER_SIZE];
if stream.read_exact(&mut header).await.is_err() {
return;
}
let len = u32::from_be_bytes(header) as usize;
let mut payload = vec![0u8; len];
if len > 0 && stream.read_exact(&mut payload).await.is_err() {
return;
}
if tx.send(payload).await.is_err() {
return;
}
}
});
(rx, handle)
}
#[tokio::test]
async fn a_frame_arrives_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(MAVLINK_INGEST_SOCK_NAME);
let (mut rx, router) = fake_router(path.clone());
tokio::time::sleep(Duration::from_millis(20)).await;
let client = MavlinkIngest::new(&path);
let frame = vec![0xFD, 0x09, 0x00, 0x00, 0x42, 0x01, 0x01, 0x00, 0x00, 0x00];
client.send_frame(&frame).await.unwrap();
assert_eq!(rx.recv().await.unwrap(), frame);
router.abort();
}
#[tokio::test]
async fn the_connection_is_opened_once_and_reused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(MAVLINK_INGEST_SOCK_NAME);
let (mut rx, router) = fake_router(path.clone());
tokio::time::sleep(Duration::from_millis(20)).await;
let client = MavlinkIngest::new(&path);
client.send_frame(b"first").await.unwrap();
client.send_frame(b"second").await.unwrap();
assert_eq!(rx.recv().await.unwrap(), b"first");
assert_eq!(rx.recv().await.unwrap(), b"second");
assert!(client.is_connected().await);
router.abort();
}
#[tokio::test]
async fn an_absent_router_is_unavailable_not_a_panic() {
let client = MavlinkIngest::with_timeout(
"/nonexistent/mavlink-ingest.sock",
Duration::from_millis(50),
);
assert!(matches!(
client.send_frame(b"frame").await,
Err(IngestError::Unavailable(_))
));
assert!(!client.is_connected().await);
}
#[tokio::test]
async fn an_oversized_frame_is_rejected_before_connecting() {
let client = MavlinkIngest::with_timeout(
"/nonexistent/mavlink-ingest.sock",
Duration::from_millis(50),
);
let too_big = vec![0u8; MAVLINK_MAX_FRAME + 1];
assert_eq!(
client.send_frame(&too_big).await,
Err(IngestError::TooLarge(MAVLINK_MAX_FRAME + 1))
);
}
#[tokio::test]
async fn a_dropped_router_is_reported_and_the_client_reconnects() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(MAVLINK_INGEST_SOCK_NAME);
let (mut rx, router) = fake_router(path.clone());
tokio::time::sleep(Duration::from_millis(20)).await;
let client = MavlinkIngest::with_timeout(&path, Duration::from_millis(200));
client.send_frame(b"before").await.unwrap();
assert_eq!(rx.recv().await.unwrap(), b"before");
router.abort();
drop(rx);
tokio::time::sleep(Duration::from_millis(20)).await;
std::fs::remove_file(&path).ok();
let mut reported = false;
for _ in 0..8 {
if client.send_frame(b"after").await.is_err() {
reported = true;
break;
}
}
assert!(reported, "a dead peer must surface as an error");
assert!(
!client.is_connected().await,
"the dead connection must be dropped so the next send reconnects"
);
let (mut rx2, router2) = fake_router(path.clone());
tokio::time::sleep(Duration::from_millis(20)).await;
client.send_frame(b"recovered").await.unwrap();
assert_eq!(rx2.recv().await.unwrap(), b"recovered");
router2.abort();
}
#[tokio::test]
async fn the_socket_path_follows_the_run_directory_override() {
assert!(ingest_socket_path().ends_with(MAVLINK_INGEST_SOCK_NAME));
}
}