Files
kaptan/firmware/tools/configurator/src/serial_io_tests.rs
T
Onur Özkan f9a61a086f feat(firmware): validate configuration and migrate sensor-driven flight APIs
Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
2026-11-07 18:12:11 +03:00

200 lines
7.3 KiB
Rust

use super::*;
use crate::{
config_client::{ClientEvent, tests::pending_close},
state::tests::unknown_board,
};
use std::{io, os::fd::FromRawFd, sync::mpsc};
fn pty() -> (File, File, String) {
let (mut master, mut slave) = (-1, -1);
// SAFETY: valid out-pointers receive two owned fds. Optional settings are null.
let opened = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
)
};
assert_eq!(opened, 0);
// SAFETY: openpty returned fresh fds whose ownership is transferred once.
let master = unsafe { File::from_raw_fd(master) };
// SAFETY: the second fresh fd is distinct and is transferred once.
let slave = unsafe { File::from_raw_fd(slave) };
// SAFETY: this live fd accepts the integer flags passed to fcntl.
let configured = unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK) };
assert_eq!(configured, 0);
let path = format!("/proc/self/fd/{}", slave.as_raw_fd());
(master, slave, path)
}
fn fill_transmit_queue(file: &mut File) {
for _ in 0..4096 {
match file.write(&[0; 4096]) {
Ok(count) => assert_ne!(count, 0),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return,
Err(error) => panic!("cannot fill PTY: {error}"),
}
}
panic!("PTY transmit queue did not reach its bound")
}
fn stall_frame(session: &mut Session, frame: MavlinkFrame) {
// PTY line-discipline work can free a little space after the first EAGAIN.
// Keep filling the unread peer until an actual frame write is backpressured.
for _ in 0..64 {
fill_transmit_queue(session.link.as_mut().unwrap());
write_raw(session, frame).unwrap();
if !flush_pending(session).unwrap() {
return;
}
}
panic!("PTY did not backpressure a configuration frame")
}
#[test]
fn queued_save_and_reset_recheck_connection_and_configuration_before_effects() {
for reconnect in [false, true] {
for action in [Action::Save, Action::ResetSaved] {
let state = SharedState::default();
let connection = state.connected("A".into());
state.configuration_event(ClientEvent::Configuration(unknown_board()));
state.edit(401, &serde_json::json!(30)).unwrap();
let intent = state
.begin_intent(state.snapshot().context, action.kind())
.unwrap();
let (_master, _slave, path) = pty();
let mut session = Session::new(Principal::new(255, 190));
session.link = Some(open_serial(&path, SerialBaud::DEFAULT).unwrap());
session.connection = Some(connection);
if reconnect {
state.disconnected(None);
session.connection = Some(state.connected("B".into()));
}
state.configuration_event(ClientEvent::Configuration(unknown_board()));
let before = state.snapshot();
handle_command(Command { intent, action }, &mut session, &state);
assert!(session.outbound.is_none());
assert!(session.client.target().is_err());
assert_eq!(state.snapshot().config_revision, before.config_revision);
assert_eq!(state.snapshot().context, before.context);
assert_eq!(state.snapshot().path, before.path);
}
}
}
#[test]
fn partial_writes_keep_exact_offsets_and_total_deadlines() {
struct ShortWriter {
bytes: Vec<u8>,
stalled: bool,
}
impl Write for ShortWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if self.stalled {
return Err(io::ErrorKind::WouldBlock.into());
}
let count = bytes.len().min(3);
self.bytes.extend_from_slice(&bytes[..count]);
Ok(count)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let (_, frame) = pending_close();
let mut pending = PendingWrite {
frame,
position: 0,
deadline: Instant::now() + WRITE_TIMEOUT,
};
let mut output = ShortWriter {
bytes: Vec::new(),
stalled: false,
};
assert!(!pending.advance(&mut output).unwrap());
output.stalled = true;
assert!(!pending.advance(&mut output).unwrap());
assert_eq!(pending.position, 3);
output.stalled = false;
for _ in 0..frame.raw_bytes().len() {
if pending.advance(&mut output).unwrap() {
break;
}
}
assert_eq!(output.bytes, frame.raw_bytes());
pending.position = 0;
pending.deadline = Instant::now();
assert!(
pending
.advance(&mut output)
.unwrap_err()
.contains("deadline")
);
assert_eq!(output.bytes, frame.raw_bytes());
}
#[test]
fn nonblocking_pty_backpressure_can_be_cancelled_and_shutdown_preserves_close_uncertainty() {
let (_master, _slave, path) = pty();
let mut file = open_serial(&path, SerialBaud::DEFAULT).unwrap();
// SAFETY: fcntl reads flags for this still-owned valid fd.
let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) };
assert_ne!(flags & libc::O_NONBLOCK, 0);
fill_transmit_queue(&mut file);
let state = Arc::new(SharedState::default());
let connection = state.connected(path);
state.configuration_event(ClientEvent::Configuration(unknown_board()));
let (client, frame) = pending_close();
let mut session = Session::new(Principal::new(255, 190));
session.client = client;
session.connection = Some(connection);
session.link = Some(file);
let started = Instant::now();
stall_frame(&mut session, frame);
assert!(started.elapsed() < Duration::from_millis(100));
let stopping = Arc::new(AtomicBool::new(false));
let (commands, receiver) = sync_channel(COMMAND_CAPACITY);
let worker_state = Arc::clone(&state);
let worker_stop = Arc::clone(&stopping);
let worker = thread::spawn(move || worker(receiver, worker_state, session, worker_stop));
let controller = SerialController {
commands,
worker: Mutex::new(Some(worker)),
baud: SerialBaud::DEFAULT,
state: Arc::clone(&state),
stopping,
};
let (done, completed) = mpsc::channel();
let shutdown = thread::spawn(move || {
drop(controller);
done.send(()).unwrap();
});
completed
.recv_timeout(Duration::from_millis(500))
.expect("shutdown must cancel a stalled write");
shutdown.join().unwrap();
assert!(state.snapshot().path.is_none());
assert!(state.snapshot().durable_state_unknown);
assert!(state.snapshot().error.unwrap().contains("unknown"));
}
#[test]
fn stalled_pty_write_hits_its_deadline_without_blocking_the_worker() {
let (_master, _slave, path) = pty();
let mut file = open_serial(&path, SerialBaud::DEFAULT).unwrap();
fill_transmit_queue(&mut file);
let mut session = Session::new(Principal::new(255, 190));
session.link = Some(file);
let (_, frame) = pending_close();
stall_frame(&mut session, frame);
session.outbound.as_mut().unwrap().deadline = Instant::now();
assert!(
flush_pending(&mut session)
.unwrap_err()
.contains("deadline")
);
}