From a05e4f4a5f1aa4f0db0ba22c673bb5edbf4d6628 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:13:13 -0700 Subject: [PATCH 1/2] Fix Windows backspace and initial prompt Two fixes for connecting to a Unix etrs from a Windows etr client, both invisible over plain ssh from the same machine: - Backspace behaved like a stray/delete key. The Windows console hands raw byte reads legacy key codes (Backspace as 0x08), whereas a Unix PTY expects the xterm conventions (Backspace as 0x7f/DEL, matching stty erase). The client now switches the console into virtual-terminal mode after enabling raw mode (ENABLE_VIRTUAL_TERMINAL_INPUT on stdin, plus ENABLE_VIRTUAL_TERMINAL_PROCESSING on stdout for ANSI rendering), so it emits the same key bytes a real terminal does. No-op on Unix. New Windows-only dependency: windows-sys (Console). - Blank screen until the first Enter. etrs started reading the shell's PTY at session start, before any client connected, so the initial prompt could be produced in the window between snapshotting replay data and installing the live PTY channel: it was recorded to history but neither replayed nor sent on the first connection. The PTY reader task is now started lazily on the first connection, after the client's PTY channel is live (mirrors how ssh emits shell output only once the channel exists). Replay-on-reconnect is unchanged. Validated: clippy -D warnings + cargo test green on both Windows (x86_64-pc-windows-msvc) and Linux (WSL Fedora); man pages rebuilt at 0.6.3. Assisted-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 3 +- Cargo.toml | 10 ++++++- NOTES.md | 31 +++++++++++++++++++- src/bin/etr.rs | 45 ++++++++++++++++++++++++++++ src/bin/etrs.rs | 78 +++++++++++++++++++++++++++++++------------------ 5 files changed, 136 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03a1a56..eb4660e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,7 +585,7 @@ dependencies = [ [[package]] name = "etr" -version = "0.6.2" +version = "0.6.3" dependencies = [ "clap", "clap_complete", @@ -604,6 +604,7 @@ dependencies = [ "serde", "tokio", "toml", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index db7b840..9979bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "etr" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "A Rust implementation of Eternal Terminal (et)" license = "GPL-3.0-only" @@ -36,6 +36,14 @@ libc = "0.2" serde = { version = "1", features = ["derive"] } toml = "1" +# Windows-only: put the console into virtual-terminal mode so the client speaks +# the same key/ANSI byte conventions a Unix PTY expects (Backspace → DEL, etc.). +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Console", +] } + [dev-dependencies] criterion = { version = "0.8", features = ["async_tokio"] } diff --git a/NOTES.md b/NOTES.md index 9152132..9ef5523 100644 --- a/NOTES.md +++ b/NOTES.md @@ -9,7 +9,36 @@ the link drops. This project uses **QUIC** (via the `quinn` crate) for the tran layer, which provides reliable, ordered, multiplexed streams with congestion control and TLS 1.3 built-in. -## Current state: v0.6.2 — CI mirrors release's target matrix +## Current state: v0.6.3 — Windows terminal fidelity (backspace + initial prompt) + +New in v0.6.3 — two fixes for connecting to a Unix `etrs` from a Windows `etr` +client (both were invisible over plain `ssh` from the same machine): +- **Backspace behaved like a stray/delete key.** The Windows console delivers + legacy key codes to raw byte reads — Backspace as `0x08` (`^H`), no ESC + sequences for arrows/function keys — whereas a Unix PTY expects the xterm + conventions, notably Backspace → `0x7f` (DEL) to match the default + `stty erase`. The client now switches the console into virtual-terminal mode + after enabling raw mode (`ENABLE_VIRTUAL_TERMINAL_INPUT` on stdin, + `ENABLE_VIRTUAL_TERMINAL_PROCESSING` on stdout, via `windows-sys`), so it + emits the same key bytes a real terminal does and renders the remote's ANSI + output. No-op on Unix. New Windows-only dependency: `windows-sys` (Console). +- **Blank screen until the first Enter.** `etrs` spawned the shell and started + reading its PTY at session start — before any client connected. The shell's + initial prompt could be produced in the window between the server snapshotting + replay data and installing the live PTY channel, so on the first connection it + was recorded to history but neither replayed nor sent; pressing Enter forced a + fresh prompt. The PTY *reader* task is now started lazily on the first + connection, after the client's PTY channel is live, so the prompt is delivered + immediately (mirrors how SSH only emits shell output once the channel exists). + Replay-on-reconnect is unchanged (the reader persists across reconnects). One + consequence: a shell/command producing more than the kernel PTY buffer before + the first client attaches will block on write until connect (~1 RTT) — + bounded, and analogous to SSH channel flow control. +- Test count: 110 (unchanged; the fixes are in integration paths — the client + console setup is Windows-runtime behaviour and the reader deferral is covered + by the `e2e-local`/`e2e-cmd-local` live tests rather than unit tests). + +## Previous: v0.6.2 — CI mirrors release's target matrix New in v0.6.2: - `ci.yml`'s `lints` and `test` matrices gained `ubuntu-24.04-arm` (the same diff --git a/src/bin/etr.rs b/src/bin/etr.rs index cdc0df8..58d0946 100644 --- a/src/bin/etr.rs +++ b/src/bin/etr.rs @@ -28,6 +28,47 @@ static IN_RAW_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBoo /// prevents false triggers from `~` in shell paths or git refs. const ESCAPE_CHAR: u8 = b'~'; +/// Put the console into virtual-terminal mode. +/// +/// On Windows the console, by default, hands raw byte reads the legacy key +/// codes (Backspace → `0x08`, no ESC sequences for arrows/function keys) and +/// does not interpret ANSI output. A Unix PTY on the far end expects the +/// xterm conventions instead — notably Backspace → `0x7f` (DEL) — so without +/// this the remote `stty erase` (DEL) never matches what we send and Backspace +/// misbehaves. Enabling `ENABLE_VIRTUAL_TERMINAL_INPUT` makes the console +/// translate keys into the same VT byte sequences a real terminal emits, and +/// `ENABLE_VIRTUAL_TERMINAL_PROCESSING` makes it render the remote's ANSI +/// output. Call this after raw mode is enabled, on every (re)connect. +/// +/// No-op on Unix, where the terminal already speaks VT natively. +#[cfg(windows)] +fn enable_vt_console() { + use windows_sys::Win32::System::Console::{ + CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, + GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + }; + // SAFETY: standard Win32 console calls. `GetStdHandle` returns a process + // std handle; `GetConsoleMode` fails (returns 0) for non-console handles + // (e.g. redirected stdio), so we only call `SetConsoleMode` on a handle it + // confirmed is a console. All pointers point to locals that outlive the call. + unsafe { + let h_in = GetStdHandle(STD_INPUT_HANDLE); + let mut mode: CONSOLE_MODE = 0; + if GetConsoleMode(h_in, &mut mode) != 0 { + SetConsoleMode(h_in, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + } + let h_out = GetStdHandle(STD_OUTPUT_HANDLE); + let mut out_mode: CONSOLE_MODE = 0; + if GetConsoleMode(h_out, &mut out_mode) != 0 { + SetConsoleMode(h_out, out_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING); + } + } +} + +/// No-op on Unix: the terminal already delivers VT key sequences and renders ANSI. +#[cfg(not(windows))] +fn enable_vt_console() {} + macro_rules! vlog { ($verbose:expr, $level:expr, $($arg:tt)*) => { if $verbose >= $level { @@ -760,6 +801,10 @@ async fn run_connection_loop( vlog!(verbose, 2, "[etr] {}", quic::tls_info()); enable_raw_mode().unwrap(); + // On Windows, raw mode alone still leaves the console emitting legacy key + // codes (Backspace → 0x08) and not rendering ANSI; switch it to VT mode so + // we speak the same conventions as the remote Unix PTY. No-op on Unix. + enable_vt_console(); IN_RAW_MODE.store(true, std::sync::atomic::Ordering::Relaxed); in_raw = true; let result = tokio::select! { diff --git a/src/bin/etrs.rs b/src/bin/etrs.rs index 1300409..041b021 100644 --- a/src/bin/etrs.rs +++ b/src/bin/etrs.rs @@ -502,7 +502,7 @@ async fn run_session( let mut child = pair.slave.spawn_command(cmd).map_err(io::Error::other)?; let master_fd = pair.master.as_raw_fd(); - let mut pty_reader = pair.master.try_clone_reader().map_err(io::Error::other)?; + let pty_reader = pair.master.try_clone_reader().map_err(io::Error::other)?; let mut pty_writer = pair.master.take_writer().map_err(io::Error::other)?; let master = Arc::new(Mutex::new(pair.master)); @@ -525,33 +525,14 @@ async fn run_session( // outbound_ctrl_tx: current connection's control envelope channel. let outbound_ctrl_tx: CtrlTx = Arc::new(std::sync::Mutex::new(None)); - // PTY reader: forwards PTY output into the session and the active connection. - { - let outbound_pty_tx = Arc::clone(&outbound_pty_tx); - let session_state = Arc::clone(&session_state); - tokio::task::spawn_blocking(move || { - let mut buf = [0u8; 4096]; - loop { - match pty_reader.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - let data = buf[..n].to_vec(); - let seq = { - let mut s = session_state.blocking_lock(); - let st = s.stream_mut(0).expect("stream 0 always exists"); - let seq = st.next_out_seq; - st.next_out_seq += 1; - st.record_send(seq, data.clone()); - seq - }; - if let Some(tx) = outbound_pty_tx.lock().unwrap().clone() { - let _ = tx.blocking_send((seq, data)); - } - } - } - } - }); - } + // The PTY reader (which forwards shell output to the client and records it + // for replay) is NOT started here. It is handed to the first client + // connection and started only once the client's PTY channel is live — see + // `handle_connection`. Starting it eagerly meant the shell's initial prompt + // could be produced before anything was listening: it was recorded to + // history but neither replayed nor sent on that first connection, so the + // user saw a blank screen until they pressed Enter to force a fresh prompt. + let pty_reader_holder: PtyReaderHolder = Arc::new(std::sync::Mutex::new(Some(pty_reader))); // Shell-exit signal: set to true when the child shell exits. let (shell_exit_tx, shell_exit_rx) = tokio::sync::watch::channel(false); @@ -682,6 +663,7 @@ async fn run_session( Arc::clone(&active_reverse_listeners), shell_exit_rx.clone(), Arc::clone(&x11_real_cookie), + Arc::clone(&pty_reader_holder), ) => (r, false), _ = sigterm_conn.recv() => { vlog!(1, "[etrs] SIGTERM during active session, closing connection"); @@ -722,6 +704,11 @@ async fn run_session( type PtyTx = Arc)>>>>; type CtrlTx = Arc>>>; +/// One-shot hand-off of the PTY master reader to the first connection that +/// establishes a live PTY channel. `Some` until the reader task is started; +/// `None` thereafter (the running task persists across reconnects). +#[cfg(unix)] +type PtyReaderHolder = Arc>>>; /// Handle one QUIC connection. Returns `true` on clean Disconnect. #[cfg(unix)] @@ -740,6 +727,7 @@ async fn handle_connection( active_reverse_listeners: Arc>>, shell_exit_rx: tokio::sync::watch::Receiver, x11_real_cookie: Arc>>>, + pty_reader_holder: PtyReaderHolder, ) -> bool { let peer = conn.remote_address(); vlog!( @@ -965,6 +953,40 @@ async fn handle_connection( } }); + // ── PTY master reader (started once, on the first connection) ───────── + // Deferred to here — after `outbound_pty_tx` is installed and the PTY send + // stream is live — so the shell's very first output (its prompt) is + // delivered on this connection instead of being produced before any + // listener exists. `take()` ensures it starts exactly once; the running + // task persists across reconnects and picks up each new `outbound_pty_tx` + // via the shared holder, so replay-on-reconnect is unchanged. + if let Some(mut pty_reader) = pty_reader_holder.lock().unwrap().take() { + let outbound_pty_tx = Arc::clone(&outbound_pty_tx); + let session_state = Arc::clone(&session_state); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 4096]; + loop { + match pty_reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let data = buf[..n].to_vec(); + let seq = { + let mut s = session_state.blocking_lock(); + let st = s.stream_mut(0).expect("stream 0 always exists"); + let seq = st.next_out_seq; + st.next_out_seq += 1; + st.record_send(seq, data.clone()); + seq + }; + if let Some(tx) = outbound_pty_tx.lock().unwrap().clone() { + let _ = tx.blocking_send((seq, data)); + } + } + } + } + }); + } + // ── PTY input reader: QUIC PTY recv stream → pty_in_tx ─────────────── let session_r = Arc::clone(&session_state); let pty_in_tx2 = pty_in_tx.clone(); From 55434fb455b6c0823cd8a7a2eeb3c93d265482f6 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:24:08 -0700 Subject: [PATCH 2/2] Clear crossbeam-epoch and anyhow advisories Bump crossbeam-epoch 0.9.18->0.9.20 (RUSTSEC-2026-0204, dev-only via criterion) and anyhow 1.0.102->1.0.103 (RUSTSEC-2026-0190 unsoundness) so the CI security audit passes. Both advisories were published against pre-existing transitive deps while this branch was in flight; neither was introduced by this work. Assisted-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 8 ++++---- NOTES.md | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb4660e..6509c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "asn1-rs" @@ -414,9 +414,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/NOTES.md b/NOTES.md index 9ef5523..ad02350 100644 --- a/NOTES.md +++ b/NOTES.md @@ -37,6 +37,11 @@ client (both were invisible over plain `ssh` from the same machine): - Test count: 110 (unchanged; the fixes are in integration paths — the client console setup is Windows-runtime behaviour and the reader deferral is covered by the `e2e-local`/`e2e-cmd-local` live tests rather than unit tests). +- Also cleared two RustSec advisories published while this work was in flight + (both against pre-existing transitive deps, neither introduced here): + `crossbeam-epoch` 0.9.18→0.9.20 (RUSTSEC-2026-0204, dev-only via `criterion`; + not in the shipped binaries) and `anyhow` 1.0.102→1.0.103 (RUSTSEC-2026-0190 + unsoundness warning). ## Previous: v0.6.2 — CI mirrors release's target matrix