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:?}"
+ );
}