diff --git a/Cargo.lock b/Cargo.lock index 03a1a56..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", ] @@ -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..ad02350 100644 --- a/NOTES.md +++ b/NOTES.md @@ -9,7 +9,41 @@ 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). +- 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 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();