Files
nexus/rust/core/src/clients/serial.rs
T
Andy Dixon f09300fd95 fix: a panic on one thread no longer takes the whole app down
Every shared lock was std::sync::Mutex with lock().unwrap() (~150 sites),
so a panic in any session's reader or callback poisoned the lock and the
next UI frame panicked too, dropping every open session. Locks are now
parking_lot::Mutex (no poisoning; already in the dependency tree), and a
panic hook reports worker panics as an error toast instead of the session
silently stopping (F3, F4).
2026-10-12 09:07:32 +01:00

371 lines
14 KiB
Rust

//! Serial client (new protocol, no Go original) on the `serialport` crate. The device path is
//! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other
//! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included).
use parking_lot::Mutex;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits};
use super::Callbacks;
use crate::models::{self, LineEnding, Profile, SerialConfig};
/// The reader polls this often, so `close` stops it within this long.
const READ_POLL: Duration = Duration::from_millis(100);
const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
const BREAK_DURATION: Duration = Duration::from_millis(250);
type OpenPort = (Box<dyn SerialPort>, Arc<AtomicBool>);
pub struct SerialClient {
inner: Arc<Inner>,
}
struct Inner {
path: String,
cfg: SerialConfig,
cb: Callbacks,
/// The writer handle plus this connection's stop flag (the reader owns a clone of the port).
port: Mutex<Option<OpenPort>>,
}
/// Device paths of the serial ports on this machine (empty if enumeration fails).
pub fn list_ports() -> Vec<String> {
serialport::available_ports().map(|v| v.into_iter().map(|p| p.port_name).collect()).unwrap_or_default()
}
impl SerialClient {
pub fn new(profile: &Profile, cb: Callbacks) -> SerialClient {
SerialClient {
inner: Arc::new(Inner { path: profile.host.trim().to_string(), cfg: profile.serial.clone().unwrap_or_default(), cb, port: Mutex::new(None) }),
}
}
/// Opens and configures the port, applies DTR/RTS, and starts the reader thread.
pub fn connect(&self) -> crate::Result<()> {
let inner = &self.inner;
if inner.port.lock().is_some() {
return Err("already connected".into());
}
if inner.path.is_empty() {
return Err("no serial device specified".into());
}
let c = &inner.cfg;
let mut port = serialport::new(&inner.path, c.baud_rate)
.data_bits(match c.data_bits {
5 => DataBits::Five,
6 => DataBits::Six,
7 => DataBits::Seven,
_ => DataBits::Eight,
})
.parity(match c.parity {
models::Parity::None => Parity::None,
models::Parity::Odd => Parity::Odd,
models::Parity::Even => Parity::Even,
})
.stop_bits(if c.stop_bits == 2 { StopBits::Two } else { StopBits::One })
.flow_control(match c.flow_control {
models::FlowControl::None => FlowControl::None,
models::FlowControl::Xonxoff => FlowControl::Software,
models::FlowControl::Rtscts => FlowControl::Hardware,
})
.timeout(WRITE_TIMEOUT)
.open()
.map_err(|e| open_error(&inner.path, &e))?;
// Opening asserts both lines on most OSes, so set them either way. Ptys and some adapters
// have no modem lines; that's not fatal.
let _ = port.write_data_terminal_ready(c.dtr_on_connect);
let _ = port.write_request_to_send(c.rts_on_connect);
let mut reader = port.try_clone()?;
reader.set_timeout(READ_POLL)?;
let stop = Arc::new(AtomicBool::new(false));
{
let mut guard = inner.port.lock();
if guard.is_some() {
return Err("already connected".into());
}
*guard = Some((port, stop.clone()));
}
let me = inner.clone();
std::thread::spawn(move || me.read_loop(reader, stop));
Ok(())
}
/// Sends user input: the Enter key's `\r` becomes the configured line ending, text is encoded,
/// and with local echo on the input is fed back through `on_data` (Enter shown as CRLF).
pub fn write(&self, data: &[u8]) -> crate::Result<()> {
let out = translate_enter(data, self.inner.cfg.line_ending);
let out = if super::is_latin1(&self.inner.cfg.encoding) { super::utf8_to_latin1(&out) } else { out };
self.with_port(|p| Ok(p.write_all(&out)?))?;
if self.inner.cfg.local_echo {
(self.inner.cb.on_data)(&translate_enter(data, LineEnding::Crlf));
}
Ok(())
}
pub fn set_lines(&self, dtr: bool, rts: bool) -> crate::Result<()> {
self.with_port(|p| {
p.write_data_terminal_ready(dtr)?;
p.write_request_to_send(rts)?;
Ok(())
})
}
/// Holds the line in break for ~250 ms (blocks the caller, and other writes, meanwhile).
pub fn send_break(&self) -> crate::Result<()> {
self.with_port(|p| {
p.set_break()?;
std::thread::sleep(BREAK_DURATION);
p.clear_break()?;
Ok(())
})
}
pub fn close(&self) {
self.inner.close();
}
pub fn is_connected(&self) -> bool {
self.inner.port.lock().is_some()
}
fn with_port(&self, f: impl FnOnce(&mut Box<dyn SerialPort>) -> crate::Result<()>) -> crate::Result<()> {
let mut guard = self.inner.port.lock();
let (port, _) = guard.as_mut().ok_or("not connected")?;
f(port)
}
}
impl Inner {
fn read_loop(&self, mut reader: Box<dyn SerialPort>, stop: Arc<AtomicBool>) {
let mut buf = [0u8; 4096];
let latin1 = super::is_latin1(&self.cfg.encoding);
while !stop.load(Ordering::SeqCst) {
let err = match reader.read(&mut buf) {
Ok(0) => "device disconnected".to_string(),
Ok(n) if latin1 => {
(self.cb.on_data)(super::latin1_to_utf8(&buf[..n]).as_bytes());
continue;
}
Ok(n) => {
(self.cb.on_data)(&buf[..n]);
continue;
}
Err(e) if matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted) => continue,
Err(e) => format!("device disconnected: {e}"),
};
if !stop.load(Ordering::SeqCst) {
(self.cb.on_error)(format!("read error: {err}"));
self.close();
}
return;
}
}
fn close(&self) {
let taken = self.port.lock().take();
if let Some((port, stop)) = taken {
stop.store(true, Ordering::SeqCst);
drop(port);
(self.cb.on_close)();
}
}
}
/// Replaces each `\r` (the Enter key) with `le`; `None` leaves it alone.
fn translate_enter(data: &[u8], le: LineEnding) -> Vec<u8> {
if le == LineEnding::None {
return data.to_vec();
}
let mut out = Vec::with_capacity(data.len() + 4);
for &b in data {
if b == b'\r' { out.extend_from_slice(le.bytes()) } else { out.push(b) }
}
out
}
fn open_error(path: &str, e: &serialport::Error) -> crate::Error {
let desc = e.description.to_lowercase();
let busy = ["busy", "lock", "access is denied", "in use"].iter().any(|s| desc.contains(s));
let hint = match e.kind {
serialport::ErrorKind::NoDevice if busy => " (the port is in use by another program)",
serialport::ErrorKind::Io(std::io::ErrorKind::PermissionDenied) => {
" (permission denied: add your user to the dialout (Linux) / uucp group, then log in again)"
}
_ => "",
};
format!("cannot open serial port {path}: {}{hint}", e.description).into()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn enter_translation() {
assert_eq!(translate_enter(b"AT\r", LineEnding::Crlf), b"AT\r\n");
assert_eq!(translate_enter(b"AT\r", LineEnding::Lf), b"AT\n");
assert_eq!(translate_enter(b"AT\r", LineEnding::Cr), b"AT\r");
assert_eq!(translate_enter(b"AT\r", LineEnding::None), b"AT\r");
}
#[test]
fn error_hints() {
let perm = serialport::Error::new(serialport::ErrorKind::Io(std::io::ErrorKind::PermissionDenied), "Permission denied");
assert!(open_error("/dev/ttyUSB0", &perm).to_string().contains("dialout"));
let busy = serialport::Error::new(serialport::ErrorKind::NoDevice, "Device or resource busy");
assert!(open_error("/dev/ttyUSB0", &busy).to_string().contains("in use by another program"));
let missing = serialport::Error::new(serialport::ErrorKind::Io(std::io::ErrorKind::NotFound), "No such file or directory");
assert_eq!(open_error("/dev/x", &missing).to_string(), "cannot open serial port /dev/x: No such file or directory");
}
#[test]
fn not_connected() {
let c = SerialClient::new(&Profile::new(models::Protocol::Serial), Callbacks::noop());
assert!(c.write(b"x").is_err());
assert_eq!(c.connect().unwrap_err().to_string(), "no serial device specified");
let _ = list_ports();
}
/// A pty pair: the client opens the slave as its "device", the test plays the far end on the master.
#[cfg(target_os = "linux")]
mod pty {
use super::*;
use std::fs::File;
use std::io::Write;
use std::os::fd::{AsRawFd, FromRawFd};
struct Pty {
master: File,
slave: File,
path: String,
}
fn open_pty() -> Pty {
let (mut m, mut s) = (0, 0);
let mut name = [0 as libc::c_char; 128];
let rc = unsafe { libc::openpty(&mut m, &mut s, name.as_mut_ptr(), std::ptr::null(), std::ptr::null()) };
assert_eq!(rc, 0);
let path = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) }.to_string_lossy().into_owned();
Pty { master: unsafe { File::from_raw_fd(m) }, slave: unsafe { File::from_raw_fd(s) }, path }
}
/// Reads from the master until something arrives or `timeout` passes.
fn read_master(master: &File, timeout: Duration) -> Vec<u8> {
let mut pfd = libc::pollfd { fd: master.as_raw_fd(), events: libc::POLLIN, revents: 0 };
if unsafe { libc::poll(&mut pfd, 1, timeout.as_millis() as i32) } != 1 {
return Vec::new();
}
let mut buf = [0u8; 256];
let n = std::io::Read::read(&mut &*master, &mut buf).unwrap();
buf[..n].to_vec()
}
fn master_n(master: &File, n: usize) -> Vec<u8> {
let mut got = Vec::new();
while got.len() < n {
let chunk = read_master(master, Duration::from_secs(5));
assert!(!chunk.is_empty(), "timed out");
got.extend(chunk);
}
got
}
fn recv_n(rx: &mpsc::Receiver<Vec<u8>>, n: usize) -> Vec<u8> {
let mut got = Vec::new();
while got.len() < n {
got.extend(rx.recv_timeout(Duration::from_secs(5)).expect("timed out"));
}
got
}
fn client(pty: &Pty, cfg: SerialConfig) -> (SerialClient, mpsc::Receiver<Vec<u8>>, mpsc::Receiver<String>, mpsc::Receiver<()>) {
let (dtx, drx) = mpsc::channel();
let (etx, erx) = mpsc::channel();
let (ctx, crx) = mpsc::channel();
let cb = Callbacks {
on_data: Arc::new(move |d| {
let _ = dtx.send(d.to_vec());
}),
on_error: Arc::new(move |e| {
let _ = etx.send(e);
}),
on_close: Arc::new(move || {
let _ = ctx.send(());
}),
};
let profile = Profile { host: pty.path.clone(), serial: Some(cfg), ..Profile::new(models::Protocol::Serial) };
(SerialClient::new(&profile, cb), drx, erx, crx)
}
#[test]
fn data_echo_line_endings_and_close() {
let mut pty = open_pty();
let cfg = SerialConfig { local_echo: true, line_ending: LineEnding::Crlf, ..Default::default() };
let (c, data, errors, closed) = client(&pty, cfg);
c.connect().unwrap();
assert!(c.connect().is_err());
c.write(b"AT\r").unwrap();
assert_eq!(master_n(&pty.master, 4), b"AT\r\n");
assert_eq!(recv_n(&data, 4), b"AT\r\n"); // local echo
pty.master.write_all(b"OK\r\n").unwrap();
assert_eq!(recv_n(&data, 4), b"OK\r\n");
c.close();
c.close();
closed.recv_timeout(Duration::from_secs(1)).unwrap();
assert!(closed.recv_timeout(Duration::from_millis(300)).is_err());
assert!(errors.try_recv().is_err());
assert!(c.write(b"x").is_err());
}
#[test]
fn latin1_encoding() {
let mut pty = open_pty();
let cfg = SerialConfig { encoding: "latin1".into(), line_ending: LineEnding::Lf, ..Default::default() };
let (c, data, _e, _c) = client(&pty, cfg);
c.connect().unwrap();
c.write("é\r".as_bytes()).unwrap();
assert_eq!(master_n(&pty.master, 2), [0xe9, b'\n']);
pty.master.write_all(&[0xe9]).unwrap();
assert_eq!(recv_n(&data, 2), "é".as_bytes());
}
#[test]
fn xon_xoff_flow_control() {
let mut pty = open_pty();
let cfg = SerialConfig { flow_control: models::FlowControl::Xonxoff, line_ending: LineEnding::None, ..Default::default() };
let (c, _d, _e, _c) = client(&pty, cfg);
c.connect().unwrap();
pty.master.write_all(&[0x13]).unwrap(); // XOFF
std::thread::sleep(Duration::from_millis(100));
let c = Arc::new(c);
let w = c.clone();
let writer = std::thread::spawn(move || w.write(b"x"));
assert!(read_master(&pty.master, Duration::from_millis(300)).is_empty(), "output not paused by XOFF");
pty.master.write_all(&[0x11]).unwrap(); // XON
assert_eq!(master_n(&pty.master, 1), b"x");
writer.join().unwrap().unwrap();
}
#[test]
fn unplug_reports_error_and_closes() {
let pty = open_pty();
let (c, _d, errors, closed) = client(&pty, SerialConfig::default());
c.connect().unwrap();
drop(pty.master);
drop(pty.slave);
let err = errors.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(err.starts_with("read error: device disconnected"), "{err}");
closed.recv_timeout(Duration::from_secs(1)).unwrap();
assert!(!c.is_connected());
}
}
}