diff --git a/CHANGELOG.md b/CHANGELOG.md index 651220e..83e991b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to PrismTTY are documented here. +## 1.0.11 - 2026-06-06 + +### Interactive Rendering + +- Surface delimiter-less raw-mode input echo without waiting for another + keystroke, covering ssh-style sessions where the local terminal ECHO flag is + off but the child still returns visible echo bytes. +- Keep the echo flush scoped to idle, byte-for-byte suffix matches, so ordinary + split program output remains buffered for cross-read highlighting unless it + exactly matches recent type-ahead. + +### Security and Reliability + +- Scope recent input tracking to the current line and clear it on submit, + interrupt, EOF, line kill, suspend, or quit controls so abandoned non-echoed + input does not linger. +- Preserve the display boundary that only child output bytes are emitted; recent + user input remains a bounded matching key and is never written directly. + +### Tests + +- Add raw-mode paste and typed-character integration coverage for ECHO-off + child sessions, plus unit coverage for current-line recent input clearing. + ## 1.0.10 - 2026-06-05 ### Interactive Rendering diff --git a/Cargo.lock b/Cargo.lock index e0e6561..fbc0044 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -590,7 +590,7 @@ dependencies = [ [[package]] name = "prismtty" -version = "1.0.10" +version = "1.0.11" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index ea4b809..79167c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "prismtty" -version = "1.0.10" +version = "1.0.11" edition = "2024" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index 58fd8c7..77e8b21 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ network-focused built-in profiles. Website: [prismtty.com](https://prismtty.com/). -Current version: `1.0.10`. +Current version: `1.0.11`. ## Quick Demo @@ -99,7 +99,7 @@ sudo apt-get install libpcre2-dev pkg-config ### GitHub Release Prebuilt release archives and checksums are available on the -[v1.0.10 release page](https://github.com/inxbit/prismtty/releases/tag/v1.0.10). +[v1.0.11 release page](https://github.com/inxbit/prismtty/releases/tag/v1.0.11). Each release archive contains the binaries, license/readme files, example profiles, shell completions, and a `.tar.gz.sha256` checksum. diff --git a/docs/index.html b/docs/index.html index 83c087f..dabe220 100644 --- a/docs/index.html +++ b/docs/index.html @@ -65,7 +65,7 @@

Readable output,
live in your
Latest
-
v1.0.10
+
v1.0.11
Engine
diff --git a/src/cli/pty.rs b/src/cli/pty.rs index 4fc5a6f..6526e35 100644 --- a/src/cli/pty.rs +++ b/src/cli/pty.rs @@ -22,7 +22,7 @@ use super::CliError; use super::args::Options; use super::profile_selection::dynamic_profile_enabled; use super::runtime::{ReloadWatcher, RuntimeRegistration}; -use super::stream::{InputSource, highlight_stream, terminal_echo_enabled}; +use super::stream::{InputSource, highlight_stream}; use super::trace::IoTrace; const STRIPPED_ITERM_ENV: [&str; 6] = [ @@ -160,8 +160,7 @@ pub(super) fn run_command(options: Options, command: Vec) -> Result) -> Result) -> Result( trace: IoTrace, profile_input: Option>>, recent_input: &Mutex>, - pty_fd: Option, ) -> io::Result<()> { let mut buffer = [0_u8; 1024]; let mut echo_state = LocalEchoState::default(); @@ -329,9 +326,11 @@ fn forward_stdin_to_pty( if let Some(sender) = &profile_input { let _ = sender.try_send(input.to_vec()); } - // Record before writing to the PTY so an immediate line-discipline echo - // cannot outrun the highlight loop's suffix match. - record_recent_input(recent_input, input, pty_fd); + // Record before the PTY write so an immediate line-discipline echo cannot + // outrun the highlight loop's suffix match. The match runs on the read-loop + // thread and `write_all` may block, so this is a cross-thread pre-write + // window; it is idle-gated and only ever surfaces the child's own output. + record_recent_input(recent_input, input); writer.write_all(input)?; writer.flush()?; @@ -348,20 +347,26 @@ fn forward_stdin_to_pty( /// Records forwarded input for the highlight loop's echo matching. /// -/// While the child's terminal has ECHO on, appends `input`, retaining only the -/// last [`RECENT_INPUT_WINDOW`] bytes so the buffer cannot grow without bound. -/// While ECHO is off (password prompts, raw-mode programs) the input never -/// echoes back, so it is not recorded; the buffer is cleared instead, so a typed -/// secret is not retained and cannot later be mistaken for program output. -fn record_recent_input(recent_input: &Mutex>, input: &[u8], pty_fd: Option) { +/// Appends `input`, then scopes the buffer to the in-progress line: everything up +/// to and including the last line-break/abort byte is dropped, so a line that is +/// submitted (CR/LF), interrupted (Ctrl-C), EOF'd (Ctrl-D), killed (Ctrl-U), +/// suspended (Ctrl-Z), or quit (Ctrl-\) does not linger. The retained tail is +/// bounded by [`RECENT_INPUT_WINDOW`]. This buffer is only ever compared against +/// the child's echoed output and is never emitted, so a non-echoed secret (e.g. a +/// password) is never drawn even while it is briefly held. Backspace and +/// word-erase are not normalised out — such bytes may remain until the next +/// break/abort byte, a window overflow, or a successful flush clears them. +fn record_recent_input(recent_input: &Mutex>, input: &[u8]) { + fn is_line_break(byte: &u8) -> bool { + matches!(byte, b'\r' | b'\n' | 0x03 | 0x04 | 0x15 | 0x1a | 0x1c) + } let mut recent = recent_input .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !terminal_echo_enabled(pty_fd) { - recent.clear(); - return; - } recent.extend_from_slice(input); + if let Some(idx) = recent.iter().rposition(is_line_break) { + recent.drain(..=idx); + } let overflow = recent.len().saturating_sub(RECENT_INPUT_WINDOW); if overflow > 0 { recent.drain(..overflow); @@ -910,8 +915,6 @@ mod tests { let trace = super::IoTrace::open(None).expect("trace disabled"); let recent_input = Mutex::new(Vec::new()); - // pty_fd: None makes terminal_echo_enabled default to true, so input is - // recorded (the ECHO-off skip path is covered by the integration tests). super::forward_stdin_to_pty( &mut input, &mut output, @@ -919,15 +922,15 @@ mod tests { trace, Some(tx), &recent_input, - None, ) .expect("stdin forwards even when profile input queue is full"); assert_eq!(output, b"show version\n"); - assert_eq!( - recent_input.lock().unwrap().as_slice(), - b"show version\n", - "forwarded input is recorded for echo matching by the highlight loop" + // The line ended with a newline, so recent_input is scoped back to empty + // (the submitted line is dropped); the forwarding itself is unaffected. + assert!( + recent_input.lock().unwrap().is_empty(), + "a submitted line is not retained in recent_input" ); } @@ -943,7 +946,7 @@ mod tests { .expect("function precedes tests"); let record_idx = function_source - .find("record_recent_input(recent_input, input,") + .find("record_recent_input(recent_input, input)") .expect("recent input is recorded"); let write_idx = function_source .find("writer.write_all(input)?") @@ -956,30 +959,39 @@ mod tests { } #[test] - fn record_recent_input_skips_non_echoed_input() { - use nix::pty::openpty; - use nix::sys::termios::{SetArg, tcgetattr, tcsetattr}; - use std::os::fd::AsRawFd; - - let pty = openpty(None, None).expect("openpty"); - let master_fd = pty.master.as_raw_fd(); + fn record_recent_input_scopes_to_current_line() { + // Recording no longer depends on ECHO state; instead the buffer is scoped + // to the in-progress line, so a submitted or aborted line (and any secret + // in it) does not linger. let recent = Mutex::new(Vec::new()); - // ECHO on: forwarded input is recorded for echo matching. - let mut attrs = tcgetattr(&pty.slave).expect("tcgetattr"); - attrs.local_flags.insert(LocalFlags::ECHO); - tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo on"); - super::record_recent_input(&recent, b"show version", Some(master_fd)); + // Plain typing accumulates for echo matching. + super::record_recent_input(&recent, b"show version"); assert_eq!(recent.lock().unwrap().as_slice(), b"show version"); - // ECHO off (e.g. a password prompt): the typed secret is never recorded, - // and any prior tail is cleared, so it cannot be retained or later matched. - attrs.local_flags.remove(LocalFlags::ECHO); - tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo off"); - super::record_recent_input(&recent, b"hunter2-secret-passphrase", Some(master_fd)); + // A submitted line (CR) drops; an embedded newline keeps only the tail. + super::record_recent_input(&recent, b"\r"); + assert!(recent.lock().unwrap().is_empty()); + super::record_recent_input(&recent, b"ab\ncd"); + assert_eq!(recent.lock().unwrap().as_slice(), b"cd"); + + // Abort/kill controls drop the in-progress line immediately, so a partial + // password cannot be retained. + super::record_recent_input(&recent, b"secret\x03"); // Ctrl-C + assert!( + recent.lock().unwrap().is_empty(), + "Ctrl-C abandons the line, dropping any partial secret" + ); + super::record_recent_input(&recent, b"secret\x1c"); // Ctrl-\ assert!( recent.lock().unwrap().is_empty(), - "non-echoed input (password) must not be retained in recent_input" + "Ctrl-\\ abandons the line, dropping any partial secret" + ); + super::record_recent_input(&recent, b"pw\x15retyped"); // Ctrl-U + assert_eq!( + recent.lock().unwrap().as_slice(), + b"retyped", + "Ctrl-U kills the line, keeping only what is typed after" ); } diff --git a/src/cli/stream.rs b/src/cli/stream.rs index 9c55802..1b773fd 100644 --- a/src/cli/stream.rs +++ b/src/cli/stream.rs @@ -1,13 +1,12 @@ use std::cmp::Reverse; use std::collections::HashMap; use std::io::{self, Read, Write}; -use std::os::fd::{BorrowedFd, RawFd}; +use std::os::fd::RawFd; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::Instant; use nix::libc; -use nix::sys::termios::{LocalFlags, tcgetattr}; use crate::highlight::{ AnsiChunk, BenchmarkReport, Highlighter, MAX_INCOMPLETE_ESCAPE_BYTES, StreamingHighlighter, @@ -35,10 +34,11 @@ const AUTO_DETECT_SAMPLE_LIMIT: usize = 64 * 1024; /// - `pty_fd`: the PTY master to poll so an echo burst's buffered trailing token /// is flushed once the child goes idle (a paste echoes back in one large read), /// - `recent_input`: the bytes the stdin forwarder recently sent to the child. -/// The loop flushes a buffered trailing token only when it is a suffix of this -/// (i.e. genuine input echo), so a speculatively-buffered *program-output* -/// token is never surfaced standalone — even when the child has echo off and -/// the user is typing while output streams. +/// The loop flushes a buffered trailing token only when it is a byte-for-byte +/// suffix of this (a heuristic for genuine input echo). The match is byte +/// equality, not provenance: in the rare case bulk program output coincides +/// with the user's exact type-ahead a program token may surface — but only the +/// child's own output bytes are ever emitted, never `recent_input`. pub(super) struct InputSource { pub(super) interactive: bool, pub(super) pty_fd: Option, @@ -407,26 +407,22 @@ fn prepare_chunk(input: &[u8], strip_existing_ansi: bool, strip_carry: &mut Vec< /// across reads still highlights as one unit. For interactive input echo that /// buffering must not strand the last token: a keystroke or pasted line echoes /// back and then the child goes idle, so the token would otherwise stay hidden -/// until the next byte. We flush it once four conditions hold: +/// until the next byte. We flush it once three conditions hold: /// - there is something buffered (`buffered_echo` non-empty), /// - the echo burst has drained ([`input_source_idle`]), so we do not split a /// multi-read echo mid-burst, and -/// - the child's terminal still has ECHO on, since echo only exists while ECHO -/// is on (so with it off a buffered token can only be program output), and -/// - the buffered token is a suffix of the recently forwarded input, i.e. it is -/// genuine echo of what the user typed/pasted — not a speculatively-buffered -/// token of bulk *program* output (which would lose its cross-read highlight -/// if flushed standalone). +/// - the buffered token is a byte-for-byte suffix of the recently forwarded +/// input ([`consume_echo_suffix`]) — a heuristic for "this is echo of what the +/// user typed/pasted," not a speculatively-buffered token of bulk *program* +/// output (which would lose its cross-read highlight if flushed standalone). /// -/// The ECHO check and the suffix match are complementary: the suffix match keeps -/// program output the user did not type from being flushed, and the ECHO check -/// covers the converse — a program-output token whose bytes happen to coincide -/// with non-echoing type-ahead (echo off) is still left buffered. +/// The suffix match is byte equality, not provenance: in the rare case where +/// bulk program output coincides with the user's exact non-echoed type-ahead a +/// program token can surface (and split its span). This is accepted — screen +/// safety does not rely on the heuristic, because the session only ever emits +/// the child's own output bytes, never `recent_input`. /// -/// Consumes the matched suffix from `recent_input` when it flushes. Also, when -/// the child's terminal ECHO is off, clears all of `recent_input` (without -/// flushing) — the forwarder is the primary guard there, this just drops any -/// stale tail the read loop observes during an ECHO-off stretch. +/// Consumes the matched suffix from `recent_input` when it flushes. fn should_flush_input_echo( interactive: bool, pty_fd: Option, @@ -439,13 +435,6 @@ fn should_flush_input_echo( let Some(recent_input) = recent_input else { return false; }; - if !terminal_echo_enabled(pty_fd) { - recent_input - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clear(); - return false; - } if !input_source_idle(pty_fd) { return false; } @@ -455,27 +444,6 @@ fn should_flush_input_echo( consume_echo_suffix(&mut recent_input, buffered_echo) } -/// Whether the child's terminal currently has ECHO enabled. Input is only -/// reflected back as echo while ECHO is on; with it off (password prompts, -/// raw-mode programs) a buffered token can only be program output, never input -/// echo — even if its bytes coincide with non-echoing type-ahead. The forwarder -/// uses the same check to avoid recording non-echoed input at all. Defaults to -/// `true` only when there is no descriptor; when a supplied descriptor cannot -/// report terminal state, fail closed. -pub(super) fn terminal_echo_enabled(pty_fd: Option) -> bool { - let Some(fd) = pty_fd else { - return true; - }; - // SAFETY: `fd` is an owned descriptor for the wrapped PTY (a dup of the - // master), kept open by its owner for the whole session; the borrow lasts - // only for this `tcgetattr` call. - let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; - match tcgetattr(borrowed) { - Ok(termios) => termios.local_flags.contains(LocalFlags::ECHO), - Err(_) => false, - } -} - /// If `recent_input` ends with `echo`, the buffered token is genuine input echo: /// consume that suffix (so it cannot be matched again by a later program token) /// and return true. Otherwise the token is program output — leave `recent_input` @@ -492,10 +460,9 @@ fn consume_echo_suffix(recent_input: &mut Vec, echo: &[u8]) -> bool { /// Reports whether the PTY master has no output readable at this instant — the /// cue that an interactive echo burst has drained, so buffered echo can surface /// without waiting for the next byte. A failed/interrupted poll is treated as -/// idle so echo still surfaces promptly; the ECHO-on check and the recent-input -/// suffix match in [`should_flush_input_echo`] keep that from disturbing -/// program-output buffering. Returns false when no descriptor is available -/// (stdin mode). +/// idle so echo still surfaces promptly; the recent-input suffix match in +/// [`should_flush_input_echo`] keeps that from disturbing program-output +/// buffering. Returns false when no descriptor is available (stdin mode). fn input_source_idle(pty_fd: Option) -> bool { let Some(fd) = pty_fd else { return false; @@ -630,8 +597,8 @@ mod tests { #[test] fn should_flush_input_echo_requires_interactive_buffered_and_recent_input() { - // No pty_fd: terminal_echo_enabled defaults true and input_source_idle - // returns false (stdin mode), so a non-interactive stream never flushes. + // Not interactive: the interactive guard short-circuits, so a + // non-interactive stream never flushes. assert!(!super::should_flush_input_echo(false, None, b"tok", None)); // Interactive but nothing buffered: never flushes. let recent = Mutex::new(b"tok".to_vec()); @@ -645,34 +612,18 @@ mod tests { assert!(!super::should_flush_input_echo(true, None, b"tok", None)); } - #[test] - fn terminal_echo_enabled_fails_closed_for_non_terminal_fd() { - let mut fds = [0 as nix::libc::c_int; 2]; - assert_eq!(unsafe { nix::libc::pipe(fds.as_mut_ptr()) }, 0); - assert!(!super::terminal_echo_enabled(Some(fds[0]))); - - unsafe { - nix::libc::close(fds[0]); - nix::libc::close(fds[1]); - } - } - #[test] fn should_flush_input_echo_flushes_only_matching_idle_echo() { use nix::pty::openpty; - use nix::sys::termios::{SetArg, tcgetattr, tcsetattr}; use std::os::fd::AsRawFd; let pty = openpty(None, None).expect("openpty"); let master_fd = pty.master.as_raw_fd(); let slave_fd = pty.slave.as_raw_fd(); - let mut attrs = tcgetattr(&pty.slave).expect("tcgetattr"); - attrs - .local_flags - .insert(nix::sys::termios::LocalFlags::ECHO); - tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo on"); // Buffered token is a suffix of recent input + idle: flush and clear it. + // ECHO state is irrelevant now — the suffix match is the sole + // discriminator, which is what lets raw-mode/ssh echo (ECHO off) surface. let recent = Mutex::new(b"router# show ".to_vec()); assert!(super::should_flush_input_echo( true, diff --git a/src/config.rs b/src/config.rs index d785845..cd853a8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -259,6 +259,7 @@ fn read_config_file(path: &Path) -> std::io::Result { .map_err(|source| std::io::Error::new(std::io::ErrorKind::InvalidData, source)) } +/// Loads and validates a native PrismTTY profile YAML file from disk. pub fn load_profile_file(path: impl AsRef) -> Result { let path = path.as_ref(); let input = read_config_file(path).map_err(|source| ConfigError::Read { diff --git a/src/highlight.rs b/src/highlight.rs index 5f335af..4191b52 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -628,9 +628,10 @@ impl StreamingHighlighter { /// The raw bytes that [`Self::flush_buffered_echo`] would surface right now: /// the buffered trailing token, minus any incomplete trailing escape. Empty /// when nothing would flush. The read loop matches these against recently - /// forwarded input to confirm the tail is genuine echo before flushing it, - /// so a speculatively-buffered *program-output* token is never surfaced - /// standalone (which would split its highlight span). + /// forwarded input (byte equality) to confirm the tail is echo before + /// flushing it, so a speculatively-buffered *program-output* token is + /// normally left buffered; the exception is the accepted case where program + /// output exactly coincides with the user's recent input. pub(crate) fn buffered_echo(&self) -> &[u8] { if !self.passthrough_single_byte_chunks || self.pending.is_empty() { return &[]; diff --git a/tests/interactive_paste.rs b/tests/interactive_paste.rs index 05a2b2d..f9ceaa7 100644 --- a/tests/interactive_paste.rs +++ b/tests/interactive_paste.rs @@ -331,12 +331,209 @@ fn split_program_output_token_survives_concurrent_nonechoing_input() { assert_spans_intact(&echo_off_split_stream_while_typing(b"x")); } -/// The adversarial case the suffix match alone cannot catch: with echo off the -/// user types the EXACT bytes of the streamed token. Content matching would see -/// recent input ending in those bytes and flush the *program* token, splitting -/// it. The ECHO-state gate closes this: with echo off there is no input echo at -/// all, so nothing is surfaced and the span stays intact. +// Accepted limitation (no test): when the child has ECHO off AND the user types +// the EXACT bytes of a concurrently-streamed program token, the byte-equality +// suffix match can surface that program token and split its span. This is the +// deliberate trade that lets raw-mode/ssh echo surface (see the raw_mode_* tests +// below); the `idle` gate prevents it during continuous output, and the +// non-matching guard above still holds. Screen-safety is unaffected — only the +// child's own output bytes are ever emitted, never recent_input. + +/// Raw-mode (ECHO-off) echo must also surface without extra input. This is the +/// nsupdate-over-ssh shape: the local PTY is raw with ECHO off, and the child +/// (here `cat` after `stty raw -echo`) re-emits forwarded bytes as program +/// output — exactly as a remote readline app's echo arrives back over ssh. The +/// buffered trailing token must surface on idle, not wait for a delimiter. #[test] -fn split_program_output_token_survives_concurrent_input_matching_the_token() { - assert_spans_intact(&echo_off_split_stream_while_typing(b"Vlan11")); +fn raw_mode_paste_line_is_visible_without_extra_input() { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty")); + builder.arg("sh"); + builder.arg("-c"); + builder.arg("stty raw -echo 2>/dev/null; printf 'READY\\n'; exec cat"); + + let mut child = pair + .slave + .spawn_command(builder) + .expect("spawn ptty raw cat"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let mut writer = pair.master.take_writer().expect("take writer"); + + let (tx, rx) = mpsc::channel::(); + thread::spawn(move || { + let mut acc = Vec::new(); + let mut buf = [0u8; 256]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + acc.extend_from_slice(&buf[..n]); + let visible = String::from_utf8_lossy(&strip_ansi(&acc)).into_owned(); + if tx.send(visible).is_err() { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let ready_deadline = Instant::now() + Duration::from_secs(5); + let mut visible = String::new(); + while Instant::now() < ready_deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + visible = latest; + if visible.contains("READY") { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + assert!( + visible.contains("READY"), + "raw-mode child was not ready before paste; saw: {visible:?}" + ); + + // A delimiter-less trailing token ("192.0.2.1") echoed back by `cat` while + // the tty has ECHO off. No newline: it can only surface via the idle flush. + let paste = b"update add test.example.com 3600 A 192.0.2.1"; + writer.write_all(paste).expect("write paste"); + writer.flush().expect("flush paste"); + + let target = "update add test.example.com 3600 A 192.0.2.1"; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + visible = latest; + if visible.contains(target) { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + visible.contains(target), + "raw-mode pasted line never fully surfaced without extra input; saw: {visible:?}" + ); +} + +/// The char-by-char mirror of the nsupdate report: in a raw/ECHO-off session the +/// running prefix of a typed token must surface at idle, before any delimiter. +/// Bytes are written one at a time with gaps, so each single-byte read is the +/// maximum split; without the idle flush the token stays invisible until Enter. +#[test] +fn raw_mode_typed_chars_are_visible_without_extra_input() { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty")); + builder.arg("sh"); + builder.arg("-c"); + builder.arg("stty raw -echo 2>/dev/null; printf 'READY\\n'; exec cat"); + + let mut child = pair + .slave + .spawn_command(builder) + .expect("spawn ptty raw cat"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let mut writer = pair.master.take_writer().expect("take writer"); + + let (tx, rx) = mpsc::channel::(); + thread::spawn(move || { + let mut acc = Vec::new(); + let mut buf = [0u8; 256]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + acc.extend_from_slice(&buf[..n]); + let visible = String::from_utf8_lossy(&strip_ansi(&acc)).into_owned(); + if tx.send(visible).is_err() { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let ready_deadline = Instant::now() + Duration::from_secs(5); + let mut visible = String::new(); + while Instant::now() < ready_deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + visible = latest; + if visible.contains("READY") { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + assert!( + visible.contains("READY"), + "raw-mode child was not ready before typing; saw: {visible:?}" + ); + + // A single delimiter-less token typed one byte at a time. With no space or + // newline ever following, the only path to visibility is the idle flush. + for byte in b"showversion" { + writer.write_all(&[*byte]).expect("write byte"); + writer.flush().expect("flush byte"); + thread::sleep(Duration::from_millis(40)); + } + + let target = "showversion"; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + visible = latest; + if visible.contains(target) { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + visible.contains(target), + "raw-mode typed token never surfaced without a delimiter; saw: {visible:?}" + ); }