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.
200 lines
7.3 KiB
Rust
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")
|
|
);
|
|
}
|