From 81ff4e9134426b91670e3e52ca7d835d2f4ec049 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Tue, 7 Jul 2026 12:27:31 +0900 Subject: [PATCH 1/2] serial: add pty sink (mode = "pty") Allocate a host pseudo-terminal and bridge Paula's serial port to it, so a terminal program (minicom/screen/picocom) attaches to the slave path directly, with no TCP in the loop. Unix only; opt-in via [serial] mode = "pty" or --serial pty. A background reader thread stages input, and the sink holds its own slave fd open so the master never EIO-spins while nothing is attached. Co-Authored-By: Claude Opus 4.8 --- copperline.example.toml | 11 ++- src/config.rs | 8 +- src/emulator.rs | 6 ++ src/main.rs | 9 +- src/serial.rs | 190 ++++++++++++++++++++++++++++++++++++++++ src/video/launcher.rs | 4 +- 6 files changed, 220 insertions(+), 8 deletions(-) diff --git a/copperline.example.toml b/copperline.example.toml index 1e549aa..b73a38a 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -290,10 +290,19 @@ floppy_sounds_volume = 100 # "midi" serial in/out is bridged to host MIDI endpoints. Needs a build # with the `midi` feature; midi_out/midi_in name the endpoints # (substring match, e.g. a USB interface or a virtual port). -# --serial MODE overrides this per run; --midi-out/--midi-in NAME imply "midi". +# "tcp" serial in/out is bridged to a host TCP port, like UAE's "TCP:" +# device. listen sets the bind address (default 127.0.0.1:1234); +# connect with e.g. `nc`, `socat`, or a raw-mode telnet client. +# "pty" serial in/out is bridged to a host pseudo-terminal (Unix only). +# The slave path (/dev/pts/N) is logged at startup; attach a +# terminal with e.g. `minicom -D`, `screen`, or `cu -l`. +# With an AUX: shell on the Amiga side, "tcp"/"pty" give a remote AmigaDOS +# console. --serial MODE overrides this per run; --midi-out/--midi-in NAME +# imply "midi". # mode = "stdout" # midi_out = "USB MIDI Interface" # midi_in = "USB MIDI Interface" +# listen = "127.0.0.1:1234" # Optional floppy drives. DF0 is always connected; set [floppy] drives diff --git a/src/config.rs b/src/config.rs index 4c71bca..10bb7f0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -245,6 +245,10 @@ pub enum SerialMode { /// serial device. With an `AUX:` shell on the Amiga side, a connected /// client gets a remote AmigaDOS console. Tcp, + /// Serial in/out is bridged to a host pseudo-terminal. The emulator + /// allocates a pty and logs the slave path (`/dev/pts/N`); a terminal + /// program (`minicom`, `screen`, `cu`) attaches to it. Unix hosts only. + Pty, } impl SerialMode { @@ -255,6 +259,7 @@ impl SerialMode { Self::Stdout => "stdout", Self::Midi => "midi", Self::Tcp => "tcp", + Self::Pty => "pty", } } } @@ -1994,8 +1999,9 @@ pub(crate) fn parse_serial_mode(s: &str) -> Result { "stdout" | "terminal" => Ok(SerialMode::Stdout), "midi" => Ok(SerialMode::Midi), "tcp" => Ok(SerialMode::Tcp), + "pty" => Ok(SerialMode::Pty), _ => Err(anyhow!( - "unknown [serial] mode {:?}: expected \"off\", \"stdout\", \"midi\", or \"tcp\"", + "unknown [serial] mode {:?}: expected \"off\", \"stdout\", \"midi\", \"tcp\", or \"pty\"", s )), } diff --git a/src/emulator.rs b/src/emulator.rs index 0e05cb5..ec35dcf 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -1757,6 +1757,12 @@ fn build_serial_sink(cfg: &Config) -> Result> SerialMode::Tcp => Ok(Box::new(crate::serial::TcpSerialSink::listen( cfg.serial.listen.as_deref().unwrap_or("127.0.0.1:1234"), )?)), + #[cfg(unix)] + SerialMode::Pty => Ok(Box::new(crate::serial::PtySerialSink::open()?)), + #[cfg(not(unix))] + SerialMode::Pty => Err(anyhow!( + "[serial] mode = \"pty\" is only available on Unix hosts" + )), } } diff --git a/src/main.rs b/src/main.rs index 9c62513..2f72835 100644 --- a/src/main.rs +++ b/src/main.rs @@ -334,10 +334,9 @@ where ); } "--serial" => { - overrides.serial = Some( - args.next() - .ok_or_else(|| anyhow!("--serial requires a mode (off/stdout/midi)"))?, - ); + overrides.serial = Some(args.next().ok_or_else(|| { + anyhow!("--serial requires a mode (off/stdout/midi/tcp/pty)") + })?); } "--midi-out" => { overrides.midi_out = Some( @@ -699,7 +698,7 @@ fn print_help() { \x20 instead of live output\n \ --profile-live-audio SECS run a no-window Paula-to-cpal profile workload;\n \ \x20 combine with COPPERLINE_AUDIO_PROFILE=1 for counters\n \ - --serial MODE Paula serial port: off, stdout, or midi\n \ + --serial MODE Paula serial port: off, stdout, midi, tcp, or pty\n \ {midi}--calibrate-gamepad interactively bind a USB gamepad to the port-2\n \ \x20 joystick, save the calibration, then exit\n \ -h, --help show this help and exit\n \ diff --git a/src/serial.rs b/src/serial.rs index 8af33c9..21a23aa 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -189,6 +189,162 @@ impl SerialSink for TcpSerialSink { fn flush(&mut self) {} } +/// Bidirectional pseudo-terminal bridge. Allocates a pty pair, prints the +/// slave device path, and wires Paula's serial port to it -- so a host +/// terminal (`minicom -D `, `screen `, `cu -l `) talks to +/// the Amiga serial port directly, with no network in the loop. With an +/// `AUX:` shell on the Amiga side, that terminal is a local AmigaDOS console. +/// +/// The sink holds its own slave fd open for its whole lifetime. That keeps +/// the pty alive across terminal programs attaching and detaching, and stops +/// the master read from returning `EIO` (the "no slave attached" error a naive +/// blocking reader would hot-spin on) while nothing is attached. A background +/// thread owns the read half, pushing bytes into a channel, so Paula's idle +/// fast path polls a counter, never a syscall -- exactly like [`TcpSerialSink`]. +#[cfg(unix)] +pub struct PtySerialSink { + /// Master fd; the Amiga's output is written here and reaches the terminal. + master: std::fs::File, + rx: std::sync::mpsc::Receiver, + /// Bytes staged in `rx`; signed for the same reason as + /// [`TcpSerialSink::buffered`] (a read racing ahead of the reader thread's + /// `fetch_add` dips to a transient -1 rather than wrapping huge). + buffered: std::sync::Arc, + /// The `/dev/pts/N` slave path terminal programs connect to. + slave_path: String, + /// Held open for the sink's lifetime; see the type docs. + _slave_keepalive: std::fs::File, +} + +#[cfg(unix)] +impl PtySerialSink { + pub fn open() -> anyhow::Result { + use std::os::fd::AsRawFd; + let last_err = || std::io::Error::last_os_error(); + // SAFETY: posix_openpt with a valid flag set. The fd it returns is + // wrapped in a File immediately below so it is closed on any early + // return from this function. + let master_fd = unsafe { libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY) }; + if master_fd < 0 { + return Err(anyhow::anyhow!( + "[serial] pty: posix_openpt: {}", + last_err() + )); + } + // SAFETY: master_fd is a fresh owned fd from posix_openpt. + let master = unsafe { ::from_raw_fd(master_fd) }; + // SAFETY: master_fd is a valid pty master for both of these calls. + if unsafe { libc::grantpt(master_fd) } != 0 { + return Err(anyhow::anyhow!("[serial] pty: grantpt: {}", last_err())); + } + if unsafe { libc::unlockpt(master_fd) } != 0 { + return Err(anyhow::anyhow!("[serial] pty: unlockpt: {}", last_err())); + } + // ptsname's static-buffer non-reentrancy is fine here: open() runs + // single-threaded before the reader thread is spawned, and the path is + // copied out immediately. + // SAFETY: master_fd is a valid pty master; the returned pointer is + // owned by libc and only read (before any further libc call). + let name_ptr = unsafe { libc::ptsname(master_fd) }; + if name_ptr.is_null() { + return Err(anyhow::anyhow!("[serial] pty: ptsname: {}", last_err())); + } + // SAFETY: name_ptr is a valid NUL-terminated C string from ptsname. + let slave_path = unsafe { std::ffi::CStr::from_ptr(name_ptr) } + .to_string_lossy() + .into_owned(); + + // Hold the slave open for our lifetime (keepalive) and put the shared + // line discipline in raw mode so it neither echoes nor rewrites CR/LF: + // the Amiga wants the bytes verbatim. + let slave = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&slave_path)?; + // SAFETY: slave.as_raw_fd() is a valid tty fd; termios is fully + // initialised by tcgetattr before use and only read by cfmakeraw. + unsafe { + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(slave.as_raw_fd(), &mut termios) == 0 { + libc::cfmakeraw(&mut termios); + let _ = libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &termios); + } + } + + log::info!( + "serial: pty at {slave_path} (connect with e.g. \"minicom -D {slave_path}\", \ + \"screen {slave_path}\" or \"cu -l {slave_path}\")" + ); + + let (tx, rx) = std::sync::mpsc::channel(); + let buffered = std::sync::Arc::new(std::sync::atomic::AtomicIsize::new(0)); + let reader_buffered = std::sync::Arc::clone(&buffered); + let mut reader = master.try_clone()?; + std::thread::Builder::new() + .name("serial-pty".into()) + .spawn(move || { + use std::io::Read; + let mut buf = [0u8; 512]; + loop { + match reader.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + for &b in &buf[..n] { + if tx.send(b).is_err() { + return; + } + reader_buffered.fetch_add(1, std::sync::atomic::Ordering::Release); + } + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + // Anything else is teardown (the sink dropped, closing + // the last slave -> EIO): let the thread exit instead + // of spinning. + Err(_) => return, + } + } + })?; + + Ok(Self { + master, + rx, + buffered, + slave_path, + _slave_keepalive: slave, + }) + } + + /// The `/dev/pts/N` path terminal programs attach to. + #[cfg_attr(not(test), allow(dead_code))] + pub fn slave_path(&self) -> &str { + &self.slave_path + } +} + +#[cfg(unix)] +impl SerialSink for PtySerialSink { + fn write_byte(&mut self, b: u8, _at_cck: u64) { + let _ = self.master.write_all(&[b]); + } + + fn read_byte(&mut self) -> Option { + let b = self.rx.try_recv().ok(); + if b.is_some() { + self.buffered + .fetch_sub(1, std::sync::atomic::Ordering::Release); + } + b + } + + fn has_pending_input(&self) -> bool { + self.buffered.load(std::sync::atomic::Ordering::Acquire) > 0 + } + + fn flush(&mut self) { + let _ = self.master.flush(); + } +} + /// Inert sink: discards output and never produces input. Placeholder used /// where a `Box` must exist before the host wires the real /// one (serde-skipped fields during save-state deserialization). @@ -272,4 +428,38 @@ mod tests { client.read_exact(&mut got).unwrap(); assert_eq!(&got, b"x"); } + + #[cfg(unix)] + #[test] + fn pty_sink_round_trips_input_and_output() { + let mut sink = PtySerialSink::open().unwrap(); + let mut term = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(sink.slave_path()) + .unwrap(); + + // Input: bytes the terminal writes reach the sink. + term.write_all(b"ab").unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !sink.has_pending_input() { + assert!(std::time::Instant::now() < deadline, "input never arrived"); + std::thread::yield_now(); + } + assert_eq!(sink.read_byte(), Some(b'a')); + while !sink.has_pending_input() { + assert!(std::time::Instant::now() < deadline, "second byte lost"); + std::thread::yield_now(); + } + assert_eq!(sink.read_byte(), Some(b'b')); + assert!(!sink.has_pending_input()); + + // Output: bytes written to the sink arrive at the terminal. Raw mode + // (VMIN=1) makes the read block until the byte lands. + sink.write_byte(b'x', 0); + sink.flush(); + let mut got = [0u8; 1]; + term.read_exact(&mut got).unwrap(); + assert_eq!(&got, b"x"); + } } diff --git a/src/video/launcher.rs b/src/video/launcher.rs index e95ad12..2b301eb 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -498,11 +498,12 @@ const WARPS: [WarpSpeed; 5] = [ const JOYSTICK_MODES: [JoystickInputMode; 2] = [JoystickInputMode::Gamepad, JoystickInputMode::Keyboard]; #[cfg(feature = "midi")] -const SERIAL_MODES: [SerialMode; 4] = [ +const SERIAL_MODES: [SerialMode; 5] = [ SerialMode::Off, SerialMode::Stdout, SerialMode::Midi, SerialMode::Tcp, + SerialMode::Pty, ]; /// Stereo-separation presets the picker steps through (percent), ascending so @@ -1220,6 +1221,7 @@ impl MachineSetup { SerialMode::Stdout => "Stdout".to_string(), SerialMode::Midi => "MIDI".to_string(), SerialMode::Tcp => "TCP".to_string(), + SerialMode::Pty => "PTY".to_string(), }, #[cfg(feature = "midi")] F::MidiOut => self.midi_out.clone().unwrap_or_else(|| "None".to_string()), From 0fb3b0fdfb7551883894d578e9d989305aeb0574 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 8 Jul 2026 00:58:00 +0900 Subject: [PATCH 2/2] serial: harden pty sink per review Warn when tcgetattr/tcsetattr fail instead of silently dropping raw mode, so a terminal that ends up echoing or rewriting CR/LF is diagnosable. Poll the pty with a 5s deadline in the round-trip test so an output regression fails instead of wedging CI on a blocking tty read. Co-Authored-By: Claude Opus 4.8 --- src/serial.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/serial.rs b/src/serial.rs index 21a23aa..7b480f9 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -265,9 +265,21 @@ impl PtySerialSink { // initialised by tcgetattr before use and only read by cfmakeraw. unsafe { let mut termios: libc::termios = std::mem::zeroed(); - if libc::tcgetattr(slave.as_raw_fd(), &mut termios) == 0 { + if libc::tcgetattr(slave.as_raw_fd(), &mut termios) != 0 { + log::warn!( + "serial: pty: tcgetattr failed ({}); the terminal may echo \ + or rewrite CR/LF", + io::Error::last_os_error() + ); + } else { libc::cfmakeraw(&mut termios); - let _ = libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &termios); + if libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &termios) != 0 { + log::warn!( + "serial: pty: tcsetattr(raw) failed ({}); the terminal \ + may echo or rewrite CR/LF", + io::Error::last_os_error() + ); + } } } @@ -454,10 +466,24 @@ mod tests { assert_eq!(sink.read_byte(), Some(b'b')); assert!(!sink.has_pending_input()); - // Output: bytes written to the sink arrive at the terminal. Raw mode - // (VMIN=1) makes the read block until the byte lands. + // Output: bytes written to the sink arrive at the terminal. Poll with a + // deadline first, so a delivery (or raw-mode) regression fails the test + // instead of wedging CI on a blocking tty read. + use std::os::fd::AsRawFd; sink.write_byte(b'x', 0); sink.flush(); + let mut pfd = libc::pollfd { + fd: term.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: polling a single valid fd, owned by `term`, for 5000 ms. + let n = unsafe { libc::poll(&mut pfd, 1, 5000) }; + assert!( + n == 1 && pfd.revents & libc::POLLIN != 0, + "output byte never arrived (poll returned {n}, revents {})", + pfd.revents + ); let mut got = [0u8; 1]; term.read_exact(&mut got).unwrap(); assert_eq!(&got, b"x");