From 787a7f1f9938e26d099b1c17a19bb4f6ca24f2d6 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:43:55 -0700 Subject: [PATCH 1/7] Fix Windows input path and terminal restore Two independent native-Windows parity fixes in the etr client. 1. Special characters no longer "eaten" (issue #54): the stdin reader used std::io::stdin().read(), which on Windows goes through Rust std's ReadConsoleW shim (UTF-16->UTF-8 + line cooking). Even in raw mode it batches input and drops non-UTF-8 bytes, which made special characters vanish (zellij keybindings needing ^g) and caused the first-line-not- echoed bug. It now reads the console input handle directly with ReadFile (read_stdin); with ENABLE_VIRTUAL_TERMINAL_INPUT on this returns the same unbatched, per-keystroke VT byte stream a Unix terminal emits. enable_vt_console also sets the console input codepage to UTF-8 (65001), saved/restored on exit, so typed multi-byte input reaches the remote as UTF-8. Adds windows-sys feature Win32_Storage_FileSystem for ReadFile. 2. Local terminal restored on exit: a remote full-screen app leaves the local terminal in alternate-screen/mouse/paste/hidden-cursor modes that disable_raw_mode does not undo, so after a hard drop or ~. the mouse wheel spewed escapes and the terminal was unusable. restore_terminal() now emits VT resets on every final-exit path: a cursor-safe part (TERM_RESET_MODES) on every exit and a screen-restoring part (TERM_RESET_SCREEN, which homes the cursor) only on unclean exits. Avoids a full RIS so scrollback is kept. Version 0.6.4 -> 0.6.5. Test count 110 -> 112 (reset-sequence regressions). Assisted-By: Claude Opus 4.8 --- Cargo.lock | 2 +- Cargo.toml | 7 +- NOTES.md | 56 ++++++++++++- src/bin/etr.rs | 214 +++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 248 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d950809..e976fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "etr" -version = "0.6.4" +version = "0.6.5" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 2dd6332..0548d48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "etr" -version = "0.6.4" +version = "0.6.5" edition = "2024" description = "A Rust implementation of Eternal Terminal (et)" license = "GPL-3.0-only" @@ -37,11 +37,14 @@ 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.). +# the same key/ANSI byte conventions a Unix PTY expects (Backspace → DEL, etc.), +# and read the console input handle directly with `ReadFile` +# (Win32_Storage_FileSystem) to get an unbatched, per-keystroke VT byte stream. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_Console", + "Win32_Storage_FileSystem", ] } [dev-dependencies] diff --git a/NOTES.md b/NOTES.md index 1789b46..edd3450 100644 --- a/NOTES.md +++ b/NOTES.md @@ -9,7 +9,51 @@ 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.4 — Windows Backspace fix +## Current state: v0.6.5 — Windows input path + terminal restore on exit + +New in v0.6.5 (two independent Windows parity fixes): + +- **Special characters no longer "eaten"; input is now per-keystroke.** The + client's stdin reader previously used `std::io::stdin().read()`, which on + Windows goes through Rust std's `ReadConsoleW` shim (UTF-16→UTF-8 + internal + line cooking). Even in raw mode that shim **batches** input and **drops bytes + that aren't clean UTF-8**, which is what made special characters disappear + (e.g. zellij keybindings not registering, needing `^g`) and caused the + first-line-not-echoed bug (#54). The reader now reads the console input handle + directly with `ReadFile` (new `read_stdin`, Windows path). With + `ENABLE_VIRTUAL_TERMINAL_INPUT` already on, `ReadFile` returns the same + per-keystroke VT byte stream a Unix terminal emits — no batching, no UTF-8 + mangling. `enable_vt_console` also now sets the console **input codepage to + UTF-8 (65001)** (saved and restored on exit) so typed multi-byte characters + reach the remote as UTF-8, matching what the std path produced. No-op on Unix. +- **Local terminal is restored on exit.** A remote full-screen app + (zellij/vim/less) puts the *local* terminal into alternate-screen, + mouse-reporting, bracketed-paste, hidden-cursor, application-keypad and + scroll-region modes via escapes we relay. On a hard drop (remote reboot) or a + forced `~.` the remote never sends its cleanup, so those modes were left set: + the mouse wheel spewed escape sequences and the terminal was unusable. + `disable_raw_mode` only restores console line/echo flags, not these + emulator modes. The client now emits an explicit VT reset (`restore_terminal`) + on every final-exit path. It is split in two: a **cursor-safe** part + (`TERM_RESET_MODES` — disable mouse/paste/app-keys, show cursor, reset SGR) + emitted on every exit, and a **screen-restoring** part (`TERM_RESET_SCREEN` — + leave alternate screen, reset scroll region; both move the cursor to home) + emitted **only** on unclean exits (`~.`, abandoned hard drop, remote command + whose TUI may still be up). Clean shell exits skip the screen reset so the + cursor is left untouched. Cross-platform (Unix terminals honour the same + resets); deliberately avoids a full RIS so scrollback is preserved. +- Test count: 110 → 112 (two regression tests asserting the reset sequences + cover the critical modes and never move the cursor on the safe path / never + clear scrollback). + +### ~~Known issue — Windows: first line of input not echoed until Enter~~ (fixed in v0.6.5) + +Fixed by the `ReadFile`-based input path above: reading the console handle +directly delivers bytes per-keystroke, so the first line echoes as it is typed +rather than only on Enter. Tracked in +[GitHub issue #54](https://github.com/l1a/etr/issues/54). + +## Previous: v0.6.4 — Windows Backspace fix New in v0.6.4: - **Windows client Backspace fixed.** The Windows console delivers legacy key @@ -26,7 +70,11 @@ New in v0.6.4: already bumped to 0.9.20 in v0.6.3 for RUSTSEC-2026-0204.) - Test count: 110 (unchanged). -### Known issue — Windows: first line of input not echoed until Enter +### Known issue (as of v0.6.4; resolved in v0.6.5) — Windows: first line of input not echoed until Enter + +Resolved in v0.6.5 by reading the console input handle directly with `ReadFile` +instead of `std::io::stdin().read()` (see the v0.6.5 section above). The +historical diagnosis is kept below for context. When connecting from a Windows `etr` client to a Unix host, the shell prompt renders correctly, but the **first line** the user types is not echoed until @@ -727,7 +775,7 @@ By default, remote listeners are bound to both `127.0.0.1` and `[::1]` loopbacks --- -## Test coverage (110 tests) +## Test coverage (112 tests) | Module | What's tested | |--------|--------------| @@ -737,6 +785,6 @@ By default, remote listeners are bound to both `127.0.0.1` and `[::1]` loopbacks | `session/mod` | Close/ack unknown stream, `last_received_map` semantics, collect_replays, `open_stream` idempotence | | `bin/etrs` | CLI defaults, verbose count, custom port, subcommand parsing, hex_decode, custom --log-path override, `ETRX11` bootstrap line parsing | | `login` | no-panic checks for record_login / record_logout with invalid fd | -| `bin/etr` | CLI defaults, port parsing, target parsing, no --cipher flag, custom --log-path and --server-log-path overrides, config fallback for log paths | +| `bin/etr` | CLI defaults, port parsing, target parsing, no --cipher flag, custom --log-path and --server-log-path overrides, config fallback for log paths, terminal-restore sequences (cursor-safe modes cover mouse/paste/cursor and never move the cursor; screen reset leaves alt-screen without clearing scrollback) | | `config` | TOML parse (full section, partial, empty), default values, `gateway_ports` / `forward` / `reverse_forward` / `x11` / `x11_trusted` config keys | | `forward` | `-L`/`-R` spec parsing: TCP/UDP/IPv6, explicit proto, bad port, empty host, Display; bind address parsing (explicit IP, `[::1]`, wildcard `*`); `get_bind_addresses` with and without gateway flag; `resolve_udp_target`: localhost prefers IPv6, explicit IPv4, unresolvable host; `X11Display` parsing | diff --git a/src/bin/etr.rs b/src/bin/etr.rs index 58d0946..8fec3d5 100644 --- a/src/bin/etr.rs +++ b/src/bin/etr.rs @@ -23,6 +23,34 @@ use etr::session::SessionState; static LOG_FILE: std::sync::OnceLock> = std::sync::OnceLock::new(); static IN_RAW_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +/// The console input codepage in effect before we switched it to UTF-8, saved so +/// it can be restored on exit. 0 means "not yet captured". Windows-only. +#[cfg(windows)] +static ORIG_INPUT_CP: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Cursor-safe VT resets emitted on *every* session exit. +/// +/// A remote full-screen program (zellij, vim, less, …) switches the *local* +/// terminal into mouse-reporting, bracketed-paste, application-cursor-key, +/// application-keypad and hidden-cursor modes via escape sequences we relay to +/// stdout. If the session dies before that program emits its own cleanup — a +/// hard drop (remote reboot) or a forced `~.` — those modes stay set and the +/// terminal is left unusable (mouse wheel spews escape sequences, cursor +/// hidden). These are terminal-*emulator* modes, not console line/echo flags, +/// so `disable_raw_mode` does not undo them; we emit the resets ourselves. +/// +/// Every reset here is idempotent *and* leaves the cursor where it is, so it is +/// safe to send even on a clean exit where nothing was left set. +const TERM_RESET_MODES: &[u8] = b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b[?1l\x1b>\x1b[?7h\x1b[?25h\x1b[0m"; + +/// Screen-restoring VT resets: leave the alternate screen buffer and reset the +/// scrolling region. Both move the cursor to home per the VT spec, so these are +/// emitted *only* when the session ended uncleanly (hard drop / forced `~.`) and +/// a full-screen app may still hold the alternate screen — never on a clean exit +/// where the remote app already switched back (re-emitting them would reposition +/// the cursor). Deliberately avoids a full RIS (`\x1bc`) so scrollback is kept. +const TERM_RESET_SCREEN: &[u8] = b"\x1b[?1049l\x1b[r"; + /// Escape character for the client: `~` (0x7E), SSH-style. Type it at the /// start of a line followed by `.` to force-disconnect. The line-start guard /// prevents false triggers from `~` in shell paths or git refs. @@ -43,10 +71,18 @@ const ESCAPE_CHAR: u8 = b'~'; /// No-op on Unix, where the terminal already speaks VT natively. #[cfg(windows)] fn enable_vt_console() { + use std::sync::atomic::Ordering; 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, + GetConsoleCP, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + SetConsoleCP, SetConsoleMode, }; + // The UTF-8 codepage. We read the console input handle directly with + // `ReadFile` (see `read_stdin`), which returns typed characters encoded in + // the console *input* codepage. Set it to UTF-8 so multi-byte input reaches + // the remote as the UTF-8 the Unix PTY expects, rather than the legacy + // OEM/ANSI codepage. + const CP_UTF8: u32 = 65001; // 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 @@ -56,6 +92,14 @@ fn enable_vt_console() { let mut mode: CONSOLE_MODE = 0; if GetConsoleMode(h_in, &mut mode) != 0 { SetConsoleMode(h_in, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + // Capture the original input codepage exactly once (before we change + // it), so reconnects — which call this again — don't overwrite the + // saved value with our own UTF-8 setting. + let cp = GetConsoleCP(); + if cp != 0 { + let _ = ORIG_INPUT_CP.compare_exchange(0, cp, Ordering::Relaxed, Ordering::Relaxed); + } + SetConsoleCP(CP_UTF8); } let h_out = GetStdHandle(STD_OUTPUT_HANDLE); let mut out_mode: CONSOLE_MODE = 0; @@ -69,6 +113,95 @@ fn enable_vt_console() { #[cfg(not(windows))] fn enable_vt_console() {} +/// Restore the console input codepage saved by `enable_vt_console`. No-op on +/// Unix and if the codepage was never changed. +#[cfg(windows)] +fn restore_console_cp() { + use std::sync::atomic::Ordering; + use windows_sys::Win32::System::Console::SetConsoleCP; + let cp = ORIG_INPUT_CP.load(Ordering::Relaxed); + if cp != 0 { + // SAFETY: `SetConsoleCP` takes a codepage id by value; `cp` is the value + // `GetConsoleCP` returned earlier, so it is a valid codepage. + unsafe { + SetConsoleCP(cp); + } + } +} + +#[cfg(not(windows))] +fn restore_console_cp() {} + +/// Read a chunk of stdin bytes into `buf`, returning the number read (0 = EOF). +/// +/// On Unix this is a plain `stdin().read`. On Windows it reads the console +/// input handle directly with `ReadFile`, bypassing Rust std's `ReadConsoleW` +/// shim: with `ENABLE_VIRTUAL_TERMINAL_INPUT` on (set by `enable_vt_console`) +/// the console hands `ReadFile` the same per-keystroke VT byte stream a Unix +/// terminal emits. The std shim instead batches input and drops bytes that +/// aren't valid UTF-8, which manifested as "special characters eaten" and the +/// "first line not echoed until Enter" bug (#54). +#[cfg(not(windows))] +fn read_stdin(buf: &mut [u8]) -> io::Result { + use std::io::Read; + std::io::stdin().read(buf) +} + +#[cfg(windows)] +fn read_stdin(buf: &mut [u8]) -> io::Result { + use windows_sys::Win32::Storage::FileSystem::ReadFile; + use windows_sys::Win32::System::Console::{GetStdHandle, STD_INPUT_HANDLE}; + // SAFETY: `GetStdHandle` returns the process stdin handle, valid for the + // process lifetime. `ReadFile` writes at most `buf.len()` bytes into `buf` + // and reports the count via `read`; both pointers reference locals/`buf` + // that outlive the call. A null overlapped pointer requests a synchronous + // read, which is correct for the (synchronous) console handle. + unsafe { + let h = GetStdHandle(STD_INPUT_HANDLE); + let mut read: u32 = 0; + let ok = ReadFile( + h, + buf.as_mut_ptr(), + buf.len() as u32, + &mut read, + std::ptr::null_mut(), + ); + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(read as usize) + } +} + +/// Return the local terminal to a sane state after a session ends for good +/// (clean exit, `~.`, or a hard drop we give up on). Emits the VT resets while +/// output VT processing is still enabled, restores the console input codepage, +/// then drops raw mode. +/// +/// `reset_screen` should be `true` only for unclean endings (forced `~.`, a hard +/// drop we abandon, a remote command whose TUI may still be up): it additionally +/// leaves the alternate screen and resets the scroll region ([`TERM_RESET_SCREEN`]), +/// which move the cursor. On a clean exit pass `false` so the cursor is left +/// untouched, since the remote already restored the screen. +/// +/// Must only be called when raw/VT mode was actually entered — otherwise the +/// escape bytes would print literally on a console without VT processing enabled. +fn restore_terminal(reset_screen: bool) { + IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); + { + let mut out = io::stdout(); + // Leave the alternate screen first (back to the normal buffer), then + // reset the cursor-safe modes on the buffer the user will actually see. + if reset_screen { + let _ = out.write_all(TERM_RESET_SCREEN); + } + let _ = out.write_all(TERM_RESET_MODES); + let _ = out.flush(); + } + restore_console_cp(); + let _ = disable_raw_mode(); +} + macro_rules! vlog { ($verbose:expr, $level:expr, $($arg:tt)*) => { if $verbose >= $level { @@ -659,13 +792,12 @@ async fn run_connection_loop( let (escape_tx, escape_rx) = tokio::sync::watch::channel(false); let _stdin_reader = tokio::task::spawn_blocking(move || { - use std::io::Read; let mut buf = [0u8; 1024]; // `~` is common in shell input, so only recognise it at line-start // (mirrors ssh ~. behaviour). let mut at_line_start = true; let mut escape_pending = false; - while let Ok(n) = std::io::stdin().read(&mut buf) { + while let Ok(n) = read_stdin(&mut buf) { if n == 0 { break; } @@ -733,13 +865,13 @@ async fn run_connection_loop( tokio::select! { _ = tokio::time::sleep(Duration::from_secs(2)) => {} Ok(_) = escape_rx.wait_for(|&v| v) => { + // Only restore (emit the VT reset) if we ever entered raw/VT + // mode — otherwise the escape bytes would print literally. + // Unclean ending: reset the screen too. if in_raw { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprint!("\r\n[etr] Disconnected (~.).\r\n"); - } else { - eprintln!("[etr] Disconnected (~.)."); + restore_terminal(true); } + eprintln!("[etr] Disconnected (~.)."); return Ok(()); } } @@ -787,12 +919,9 @@ async fn run_connection_loop( }, Ok(_) = escape_rx.wait_for(|&v| v) => { if in_raw { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprint!("\r\n[etr] Disconnected (~.).\r\n"); - } else { - eprintln!("[etr] Disconnected (~.)."); + restore_terminal(true); } + eprintln!("[etr] Disconnected (~.)."); return Ok(()); } }; @@ -823,34 +952,33 @@ async fn run_connection_loop( verbose, ) => r, Ok(_) = escape_rx.wait_for(|&v| v) => { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprint!("\r\n[etr] Disconnected (~.).\r\n"); + restore_terminal(true); + eprintln!("[etr] Disconnected (~.)."); std::process::exit(0); } }; match result { Ok(_) => { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); + // Clean exit: the remote shell already restored the screen, so + // don't touch the cursor — just reset emulator modes. + restore_terminal(false); vlog!(verbose, 1, "[etr] Connection closed cleanly."); std::process::exit(0); } Err(e) if e.kind() == io::ErrorKind::ConnectionAborted => { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); + restore_terminal(false); vlog!(verbose, 1, "[etr] Connection closed cleanly."); std::process::exit(0); } Err(e) => { // For remote commands: exit rather than reconnect. The command // has finished (or the server is gone), so there is nothing to - // reconnect to. Restore the terminal before printing. + // reconnect to. A full-screen command (btop, …) may have been + // left holding the alternate screen, so reset it too. if has_remote_command { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprintln!("\n[etr] Session ended: {e}"); + restore_terminal(true); + eprintln!("[etr] Session ended: {e}"); std::process::exit(1); } // Keep raw mode ON during reconnect so ~. fires immediately. @@ -1914,6 +2042,44 @@ mod tests { assert_eq!(ESCAPE_CHAR, b'~'); } + #[test] + fn test_term_reset_modes_covers_critical_modes() { + // Regression guard: the cursor-safe reset emitted on every session exit + // must undo the emulator modes a remote full-screen app leaves set, or + // the local terminal is unusable after a hard drop / `~.` quit. Bytes + // are checked so an accidental edit that drops one is caught. + let seq = TERM_RESET_MODES; + let contains = |needle: &[u8]| seq.windows(needle.len()).any(|w| w == needle); + // Disable every mouse-reporting mode (mouse wheel spewing escapes). + assert!(contains(b"\x1b[?1000l")); + assert!(contains(b"\x1b[?1002l")); + assert!(contains(b"\x1b[?1003l")); + assert!(contains(b"\x1b[?1006l")); + // Disable bracketed paste. + assert!(contains(b"\x1b[?2004l")); + // Show the cursor again. + assert!(contains(b"\x1b[?25h")); + // Reset SGR attributes. + assert!(seq.ends_with(b"\x1b[0m")); + // The cursor-safe reset must NOT move the cursor: no alternate-screen + // switch (`?1049l`), no scroll-region reset (`\x1b[r`), no full RIS. + assert!(!contains(b"\x1b[?1049l")); + assert!(!contains(b"\x1b[r")); + assert!(!contains(b"\x1bc")); + } + + #[test] + fn test_term_reset_screen_leaves_alt_screen() { + // The screen-restoring reset (unclean exits only) must leave the + // alternate screen and reset the scroll region, but never clear + // scrollback with a full RIS. + let seq = TERM_RESET_SCREEN; + let contains = |needle: &[u8]| seq.windows(needle.len()).any(|w| w == needle); + assert!(contains(b"\x1b[?1049l")); + assert!(contains(b"\x1b[r")); + assert!(!contains(b"\x1bc")); + } + #[test] fn test_log_paths_fallback_to_config() { let toml = "[client]\nlog_path = \"/config/client.log\"\nserver_log_path = \"/config/server.log\"\n"; From 9a188a6b904e43499ee7fa9c2c0527fcb0565883 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:04:50 -0700 Subject: [PATCH 2/7] docs: record live Windows->WSL verification of v0.6.5 Verified end-to-end against a real Unix etrs (WSL Fedora 44): remote prompt renders, PTY command round-trips, and the client emits the cursor-safe terminal-restore sequence on clean exit (fix #2 confirmed in the live byte stream). The console input-VT path (fix #1) needs interactive keystrokes and is flagged for manual confirmation. Also notes an adjacent pre-existing gap: redirected stdin ends a remote-command session on EOF before output arrives. Assisted-By: Claude Opus 4.8 --- NOTES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/NOTES.md b/NOTES.md index edd3450..5f2cb14 100644 --- a/NOTES.md +++ b/NOTES.md @@ -46,6 +46,20 @@ New in v0.6.5 (two independent Windows parity fixes): cover the critical modes and never move the cursor on the safe path / never clear scrollback). +**Live verification (Windows → WSL Fedora 44 etrs, 2026-07-21):** a full +Windows→Unix session was exercised end-to-end with the rebuilt v0.6.5 client: +the remote zsh/starship prompt rendered with correct ANSI, a command typed into +the PTY round-tripped (executed remotely, output returned), and on clean shell +exit the client emitted exactly the 70-byte cursor-safe `TERM_RESET_MODES` with +no screen reset — confirming fix #2 in the live byte stream. The console-side +input-VT translation of fix #1 (the `ReadFile` path) can only be exercised with +real interactive console keystrokes and so must be confirmed by hand: +zellij keybindings should work without `^g` and the first typed line should echo +per-keystroke. Note (adjacent, pre-existing): running `etr host 'cmd'` with +redirected/` Date: Tue, 21 Jul 2026 14:20:46 -0700 Subject: [PATCH 3/7] docs: verify v0.6.5 fixes via synthesized console keystrokes Drove the live etr client with real console key events (WriteConsoleInputW) against a WSL Fedora 44 etrs. Confirms fix #1 end-to-end: Ctrl+G->0x07, arrow keys, rapid bursts and Unicode all survive the raw+VT-input ReadFile path un-eaten, and injected keystrokes reach the remote per-keystroke (remote zsh-syntax-highlighting recolours char-by-char). Confirms fix #2: cursor-safe terminal-restore sequence emitted on clean exit. Updates NOTES accordingly. Assisted-By: Claude Opus 4.8 --- NOTES.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/NOTES.md b/NOTES.md index 5f2cb14..52947da 100644 --- a/NOTES.md +++ b/NOTES.md @@ -46,16 +46,25 @@ New in v0.6.5 (two independent Windows parity fixes): cover the critical modes and never move the cursor on the safe path / never clear scrollback). -**Live verification (Windows → WSL Fedora 44 etrs, 2026-07-21):** a full -Windows→Unix session was exercised end-to-end with the rebuilt v0.6.5 client: -the remote zsh/starship prompt rendered with correct ANSI, a command typed into -the PTY round-tripped (executed remotely, output returned), and on clean shell -exit the client emitted exactly the 70-byte cursor-safe `TERM_RESET_MODES` with -no screen reset — confirming fix #2 in the live byte stream. The console-side -input-VT translation of fix #1 (the `ReadFile` path) can only be exercised with -real interactive console keystrokes and so must be confirmed by hand: -zellij keybindings should work without `^g` and the first typed line should echo -per-keystroke. Note (adjacent, pre-existing): running `etr host 'cmd'` with +**Live verification (Windows → WSL Fedora 44 etrs, 2026-07-21):** both fixes were +verified end-to-end against a real Unix `etrs`, driving the rebuilt v0.6.5 client +with *synthesized real console key events* (`WriteConsoleInputW` into the +client's own console — the same INPUT_RECORDs a physical keyboard produces): + +- *Fix #1 (input not eaten, per-keystroke):* An isolated harness confirmed the + exact path `read_stdin` uses (console in raw + `ENABLE_VIRTUAL_TERMINAL_INPUT`, + read via `ReadFile`) delivers every key intact and unbatched: `a`, **Ctrl+G → + `0x07`**, Up-arrow → `ESC [ A`, a rapid 5-key burst, and `é` → UTF-8 `c3 a9`. + A full-composition harness then injected keystrokes into a live `etr` session; + the remote `zsh-syntax-highlighting` re-coloured the command **character by + character** as it arrived (proof of per-keystroke delivery, not batching) and + the typed command executed and round-tripped. This is the root cause of the + "characters eaten / zellij needs `^g`" report. +- *Fix #2 (terminal restore):* On clean shell exit the client emitted exactly the + 70-byte cursor-safe `TERM_RESET_MODES` (no cursor-moving screen reset), + confirmed in the live output byte stream. + +Note (adjacent, pre-existing, out of scope): running `etr host 'cmd'` with redirected/` Date: Tue, 21 Jul 2026 14:25:27 -0700 Subject: [PATCH 4/7] docs: add stdin-EOF remote-command gap to Known gaps Promote the redirected-stdin truncation note from the v0.6.5 verification footnote into the Known gaps / next steps list so it is discoverable as tracked open work, with a sketch of the ssh-parity fix (half-close stdin, keep draining PTY output). Assisted-By: Claude Opus 4.8 --- NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NOTES.md b/NOTES.md index 52947da..fab1046 100644 --- a/NOTES.md +++ b/NOTES.md @@ -748,6 +748,15 @@ By default, remote listeners are bound to both `127.0.0.1` and `[::1]` loopbacks ## Known gaps / next steps +- **Remote command truncated with redirected stdin**: `etr host 'cmd'` with + ` Date: Tue, 21 Jul 2026 14:29:36 -0700 Subject: [PATCH 5/7] docs: note just recipes fail on native Windows shells `just install` (and other bash-shebang recipes) fail from PowerShell/nushell with "could not find cygpath": just tries to translate the shebang interpreter path via cygpath, absent without Git Bash on PATH. Recorded in Known gaps with the cargo-install workaround and a sketch of a cross-shell fix. Assisted-By: Claude Opus 4.8 --- NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NOTES.md b/NOTES.md index fab1046..13b7cc7 100644 --- a/NOTES.md +++ b/NOTES.md @@ -748,6 +748,15 @@ By default, remote listeners are bound to both `127.0.0.1` and `[::1]` loopbacks ## Known gaps / next steps +- **`just` recipes unusable from native Windows shells**: the `justfile` recipes + use `#!/usr/bin/env bash` shebangs, so on Windows `just` tries to translate the + interpreter path with `cygpath`. From PowerShell/nushell (no Git-Bash `cygpath` + on PATH) recipes like `just install` fail with "could not find `cygpath` + executable". Workaround for the client build/install on Windows: + `cargo install --path . --bin etr --force` (or `cargo build --release --bin etr` + then copy `target\release\etr.exe` to `~/.cargo/bin`). A real fix would make the + common recipes cross-shell — e.g. plain (non-shebang) recipes that shell out to + `cargo` directly, or documenting that `just` needs Git Bash on PATH on Windows. - **Remote command truncated with redirected stdin**: `etr host 'cmd'` with ` Date: Tue, 21 Jul 2026 14:39:39 -0700 Subject: [PATCH 6/7] Fix Windows first-line echo (#54) via reader gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single stdin reader thread is spawned before the QUIC connect, but raw + VT-input mode is only enabled after connect. On Windows a ReadFile issued while the console is still in cooked/line mode stays line-buffered for that whole read, so the first line was held client-side until Enter ("no echo until first Enter"). The v0.6.5 ReadFile change did not fix this — it is a timing problem, not a read-mechanism one. Gate the Windows reader on a one-shot signal fired right after the first enable_raw_mode + enable_vt_console, so its first read happens in raw + VT mode and is per-keystroke. Unix is unaffected (ungated, never had the bug). Verified with an A/B console-keystroke harness that snapshots the client's stdout before Enter: pre-fix the typed first line is absent (line-buffered); with the gate it appears, echoed back per-keystroke. NOTES corrected (the earlier claim that ReadFile alone fixed #54 was wrong). Assisted-By: Claude Opus 4.8 --- NOTES.md | 54 +++++++++++++++++++++++++++++++++----------------- src/bin/etr.rs | 24 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/NOTES.md b/NOTES.md index 13b7cc7..23a5ac8 100644 --- a/NOTES.md +++ b/NOTES.md @@ -11,21 +11,30 @@ and TLS 1.3 built-in. ## Current state: v0.6.5 — Windows input path + terminal restore on exit -New in v0.6.5 (two independent Windows parity fixes): - -- **Special characters no longer "eaten"; input is now per-keystroke.** The - client's stdin reader previously used `std::io::stdin().read()`, which on - Windows goes through Rust std's `ReadConsoleW` shim (UTF-16→UTF-8 + internal - line cooking). Even in raw mode that shim **batches** input and **drops bytes - that aren't clean UTF-8**, which is what made special characters disappear - (e.g. zellij keybindings not registering, needing `^g`) and caused the - first-line-not-echoed bug (#54). The reader now reads the console input handle - directly with `ReadFile` (new `read_stdin`, Windows path). With - `ENABLE_VIRTUAL_TERMINAL_INPUT` already on, `ReadFile` returns the same - per-keystroke VT byte stream a Unix terminal emits — no batching, no UTF-8 - mangling. `enable_vt_console` also now sets the console **input codepage to - UTF-8 (65001)** (saved and restored on exit) so typed multi-byte characters - reach the remote as UTF-8, matching what the std path produced. No-op on Unix. +New in v0.6.5 (three independent Windows parity fixes): + +- **Special characters no longer "eaten".** The client's stdin reader previously + used `std::io::stdin().read()`, which on Windows goes through Rust std's + `ReadConsoleW` shim (UTF-16→UTF-8 + internal line cooking). That shim **drops + bytes that aren't clean UTF-8**, which is what made special characters + disappear (e.g. zellij keybindings not registering, needing `^g`). The reader + now reads the console input handle directly with `ReadFile` (new `read_stdin`, + Windows path). With `ENABLE_VIRTUAL_TERMINAL_INPUT` already on, `ReadFile` + returns the same VT byte stream a Unix terminal emits — no UTF-8 mangling. + `enable_vt_console` also now sets the console **input codepage to UTF-8 + (65001)** (saved and restored on exit) so typed multi-byte characters reach + the remote as UTF-8, matching what the std path produced. No-op on Unix. +- **First line of input now echoes as typed (issue #54).** The single stdin + reader thread is spawned before the QUIC connect, but raw + VT-input mode is + only enabled *after* the connect. On Windows a `ReadFile` issued while the + console is still in cooked/line mode stays line-buffered for that whole read, + so the first line was held client-side until Enter (the reported "no echo + until first Enter"; the `ReadFile` change above did **not** fix this on its + own — it is a timing problem, not a read-mechanism one). The Windows reader + now waits on a one-shot signal fired immediately after the first + `enable_raw_mode` + `enable_vt_console`, so its very first read happens in raw + + VT mode and is per-keystroke. Unix has no such coupling (and never showed the + bug), so its reader is ungated and starts immediately as before. - **Local terminal is restored on exit.** A remote full-screen app (zellij/vim/less) puts the *local* terminal into alternate-screen, mouse-reporting, bracketed-paste, hidden-cursor, application-keypad and @@ -60,6 +69,12 @@ client's own console — the same INPUT_RECORDs a physical keyboard produces): character** as it arrived (proof of per-keystroke delivery, not batching) and the typed command executed and round-tripped. This is the root cause of the "characters eaten / zellij needs `^g`" report. +- *First-line echo (#54):* A/B harness that types the first line and snapshots + the client's stdout **before** sending Enter. Pre-fix binary: the typed + command is absent from the snapshot (held client-side until Enter). With the + reader gate: the command appears in the pre-Enter snapshot, echoed back + per-keystroke (remote syntax-highlighting recolours char-by-char) — first line + now echoes as typed. - *Fix #2 (terminal restore):* On clean shell exit the client emitted exactly the 70-byte cursor-safe `TERM_RESET_MODES` (no cursor-moving screen reset), confirmed in the live output byte stream. @@ -71,9 +86,12 @@ interactive console stdin never EOFs so this does not affect normal use. ### ~~Known issue — Windows: first line of input not echoed until Enter~~ (fixed in v0.6.5) -Fixed by the `ReadFile`-based input path above: reading the console handle -directly delivers bytes per-keystroke, so the first line echoes as it is typed -rather than only on Enter. Tracked in +Fixed in v0.6.5 by gating the Windows stdin reader until raw + VT-input mode is +enabled (see the "First line of input now echoes as typed" bullet above). The +`ReadFile`-based input path was necessary but **not sufficient** on its own — the +first line was line-buffered because the first read was *issued* before raw mode, +which is a timing problem the reader gate solves. Verified with an A/B harness +that snapshots the client's stdout before Enter is sent. Tracked in [GitHub issue #54](https://github.com/l1a/etr/issues/54). ## Previous: v0.6.4 — Windows Backspace fix diff --git a/src/bin/etr.rs b/src/bin/etr.rs index 8fec3d5..4a93f93 100644 --- a/src/bin/etr.rs +++ b/src/bin/etr.rs @@ -791,7 +791,21 @@ async fn run_connection_loop( // ~. triggers this to exit the reconnect loop. let (escape_tx, escape_rx) = tokio::sync::watch::channel(false); + // Windows only: the reader must not issue its first `ReadFile` until raw + + // VT-input mode is enabled. A `ReadFile` issued while the console is still + // in cooked/line mode stays line-buffered for that whole read, so the first + // line would be held until Enter (issue #54 — "first line not echoed until + // Enter"). Raw mode is enabled per-connect (after the QUIC handshake), so + // we gate the reader on a one-shot signal fired right after the first + // `enable_raw_mode` + `enable_vt_console`. Unix has no such coupling (and + // never exhibited the bug), so its reader starts immediately as before. + #[cfg(windows)] + let (raw_ready_tx, raw_ready_rx) = std::sync::mpsc::channel::<()>(); + let _stdin_reader = tokio::task::spawn_blocking(move || { + // Wait until the console is in raw + VT-input mode before the first read. + #[cfg(windows)] + let _ = raw_ready_rx.recv(); let mut buf = [0u8; 1024]; // `~` is common in shell input, so only recognise it at line-start // (mirrors ssh ~. behaviour). @@ -851,6 +865,10 @@ async fn run_connection_loop( // Track whether the terminal is currently in raw mode so reconnect messages // can use \r\n (raw) vs \n (cooked) and so we don't over-call disable_raw_mode. let mut in_raw = false; + // Windows only: fired once, right after raw + VT mode is first enabled, to + // release the gated stdin reader (see the reader spawn above). + #[cfg(windows)] + let mut raw_ready_tx = Some(raw_ready_tx); 'reconnect: loop { if !first { @@ -936,6 +954,12 @@ async fn run_connection_loop( enable_vt_console(); IN_RAW_MODE.store(true, std::sync::atomic::Ordering::Relaxed); in_raw = true; + // Release the stdin reader now that raw + VT mode is active, so its first + // read is per-keystroke rather than a line-buffered cooked read (#54). + #[cfg(windows)] + if let Some(tx) = raw_ready_tx.take() { + let _ = tx.send(()); + } let result = tokio::select! { r = run_session( conn, From 20c1f856040469e5a44776c688559e13370888bc Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:17:13 -0700 Subject: [PATCH 7/7] Fix local shell Enter broken after etr exits (Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enable_vt_console sets ENABLE_VIRTUAL_TERMINAL_INPUT, but crossterm's disable_raw_mode only ORs the line/echo/processed-input bits back — it never clears the VT-input flag. So after etr exited, the console was left with VT-input enabled and the local shell echoed typed characters but would not accept Enter (the VT-translated Enter wasn't seen as line submission). Capture the console's exact original input/output modes + input codepage once (capture_console_originals, before raw mode is first enabled) and restore them verbatim on every exit path (restore_console_state), which clears the leftover VT-input flag. Verified with a harness: input mode restored byte-identical (0x01f7 -> 0x01f7), VT_INPUT not left set. Pre-existing since v0.6.4; no-op on Unix. NOTES also records a related server-side gap (clean shell `exit` sometimes reconnects instead of quitting because the Disconnect races the connection close). Assisted-By: Claude Opus 4.8 --- NOTES.md | 30 ++++++++++++- src/bin/etr.rs | 117 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/NOTES.md b/NOTES.md index 23a5ac8..5adaadf 100644 --- a/NOTES.md +++ b/NOTES.md @@ -11,7 +11,7 @@ and TLS 1.3 built-in. ## Current state: v0.6.5 — Windows input path + terminal restore on exit -New in v0.6.5 (three independent Windows parity fixes): +New in v0.6.5 (four independent Windows parity fixes): - **Special characters no longer "eaten".** The client's stdin reader previously used `std::io::stdin().read()`, which on Windows goes through Rust std's @@ -51,6 +51,17 @@ New in v0.6.5 (three independent Windows parity fixes): whose TUI may still be up). Clean shell exits skip the screen reset so the cursor is left untouched. Cross-platform (Unix terminals honour the same resets); deliberately avoids a full RIS so scrollback is preserved. +- **Local shell's Enter works again after etr exits.** `enable_vt_console` sets + `ENABLE_VIRTUAL_TERMINAL_INPUT` on the console, but crossterm's + `disable_raw_mode` only ORs the line/echo/processed-input bits back — it never + clears that VT-input flag. So after etr exited, the console was left with + VT-input still enabled, and the *local* shell echoed typed characters but did + not accept Enter (the VT-translated Enter wasn't recognised as line + submission). etr now captures the console's exact original input/output modes + and input codepage once (`capture_console_originals`, before raw mode is first + enabled) and restores them verbatim on exit (`restore_console_state`), which + clears the leftover VT-input flag. Pre-existing since VT-input was introduced + in v0.6.4. No-op on Unix (crossterm fully restores termios there). - Test count: 110 → 112 (two regression tests asserting the reset sequences cover the critical modes and never move the cursor on the safe path / never clear scrollback). @@ -78,6 +89,10 @@ client's own console — the same INPUT_RECORDs a physical keyboard produces): - *Fix #2 (terminal restore):* On clean shell exit the client emitted exactly the 70-byte cursor-safe `TERM_RESET_MODES` (no cursor-moving screen reset), confirmed in the live output byte stream. +- *Console-mode restore:* A harness recorded the console input mode before + launching etr and again after etr exited via `~.`. Result: the mode was + restored byte-identical (`0x01f7` → `0x01f7`) and `ENABLE_VIRTUAL_TERMINAL_INPUT` + was not left set — the local shell's line input (Enter) works after exit. Note (adjacent, pre-existing, out of scope): running `etr host 'cmd'` with redirected/`> = std::sync::OnceLock::new(); static IN_RAW_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -/// The console input codepage in effect before we switched it to UTF-8, saved so -/// it can be restored on exit. 0 means "not yet captured". Windows-only. +// The console input/output modes and input codepage in effect before etr +// touched anything, captured once by `capture_console_originals` so they can be +// restored verbatim on exit. Restoring the exact modes is essential: crossterm's +// `disable_raw_mode` only ORs the line/echo/processed-input bits back, so the +// `ENABLE_VIRTUAL_TERMINAL_INPUT` we add in `enable_vt_console` would otherwise +// be left set — which breaks the *local* shell's Enter handling after etr exits. +#[cfg(windows)] +static CONSOLE_CAPTURED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(windows)] +static ORIG_INPUT_MODE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +#[cfg(windows)] +static OUTPUT_CAPTURED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(windows)] +static ORIG_OUTPUT_MODE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); #[cfg(windows)] static ORIG_INPUT_CP: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); @@ -71,11 +83,10 @@ const ESCAPE_CHAR: u8 = b'~'; /// No-op on Unix, where the terminal already speaks VT natively. #[cfg(windows)] fn enable_vt_console() { - use std::sync::atomic::Ordering; use windows_sys::Win32::System::Console::{ CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, - GetConsoleCP, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, - SetConsoleCP, SetConsoleMode, + GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCP, + SetConsoleMode, }; // The UTF-8 codepage. We read the console input handle directly with // `ReadFile` (see `read_stdin`), which returns typed characters encoded in @@ -92,13 +103,6 @@ fn enable_vt_console() { let mut mode: CONSOLE_MODE = 0; if GetConsoleMode(h_in, &mut mode) != 0 { SetConsoleMode(h_in, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); - // Capture the original input codepage exactly once (before we change - // it), so reconnects — which call this again — don't overwrite the - // saved value with our own UTF-8 setting. - let cp = GetConsoleCP(); - if cp != 0 { - let _ = ORIG_INPUT_CP.compare_exchange(0, cp, Ordering::Relaxed, Ordering::Relaxed); - } SetConsoleCP(CP_UTF8); } let h_out = GetStdHandle(STD_OUTPUT_HANDLE); @@ -113,24 +117,78 @@ fn enable_vt_console() { #[cfg(not(windows))] fn enable_vt_console() {} -/// Restore the console input codepage saved by `enable_vt_console`. No-op on -/// Unix and if the codepage was never changed. +/// Capture the console's original input/output modes and input codepage exactly +/// once, before etr changes any of them, so they can be restored verbatim on +/// exit. Must be called before the first `enable_raw_mode`/`enable_vt_console`. +/// No-op on Unix (crossterm's `disable_raw_mode` fully restores termios there). +#[cfg(windows)] +fn capture_console_originals() { + use std::sync::atomic::Ordering; + use windows_sys::Win32::System::Console::{ + CONSOLE_MODE, GetConsoleCP, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, + }; + if CONSOLE_CAPTURED.load(Ordering::Relaxed) { + return; + } + // SAFETY: standard Win32 console queries. `GetConsoleMode` fails (returns 0) + // for non-console handles, in which case we capture nothing and restore is a + // no-op. All pointers reference 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 { + return; // no real console (e.g. redirected stdin); nothing to restore + } + ORIG_INPUT_MODE.store(mode, Ordering::Relaxed); + let cp = GetConsoleCP(); + if cp != 0 { + ORIG_INPUT_CP.store(cp, Ordering::Relaxed); + } + let h_out = GetStdHandle(STD_OUTPUT_HANDLE); + let mut out_mode: CONSOLE_MODE = 0; + if GetConsoleMode(h_out, &mut out_mode) != 0 { + ORIG_OUTPUT_MODE.store(out_mode, Ordering::Relaxed); + OUTPUT_CAPTURED.store(true, Ordering::Relaxed); + } + CONSOLE_CAPTURED.store(true, Ordering::Relaxed); + } +} + +#[cfg(not(windows))] +fn capture_console_originals() {} + +/// Restore the console modes and input codepage captured by +/// `capture_console_originals`. This is what actually clears the +/// `ENABLE_VIRTUAL_TERMINAL_INPUT` flag `enable_vt_console` set — crossterm's +/// `disable_raw_mode` does not — so the *local* shell's line input (Enter) works +/// again after etr exits. No-op on Unix and if nothing was captured. #[cfg(windows)] -fn restore_console_cp() { +fn restore_console_state() { use std::sync::atomic::Ordering; - use windows_sys::Win32::System::Console::SetConsoleCP; - let cp = ORIG_INPUT_CP.load(Ordering::Relaxed); - if cp != 0 { - // SAFETY: `SetConsoleCP` takes a codepage id by value; `cp` is the value - // `GetConsoleCP` returned earlier, so it is a valid codepage. - unsafe { + use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCP, SetConsoleMode, + }; + if !CONSOLE_CAPTURED.load(Ordering::Relaxed) { + return; + } + // SAFETY: restoring modes/codepage captured earlier from the same handles. + unsafe { + let h_in = GetStdHandle(STD_INPUT_HANDLE); + SetConsoleMode(h_in, ORIG_INPUT_MODE.load(Ordering::Relaxed)); + let cp = ORIG_INPUT_CP.load(Ordering::Relaxed); + if cp != 0 { SetConsoleCP(cp); } + if OUTPUT_CAPTURED.load(Ordering::Relaxed) { + let h_out = GetStdHandle(STD_OUTPUT_HANDLE); + SetConsoleMode(h_out, ORIG_OUTPUT_MODE.load(Ordering::Relaxed)); + } } } #[cfg(not(windows))] -fn restore_console_cp() {} +fn restore_console_state() {} /// Read a chunk of stdin bytes into `buf`, returning the number read (0 = EOF). /// @@ -175,8 +233,9 @@ fn read_stdin(buf: &mut [u8]) -> io::Result { /// Return the local terminal to a sane state after a session ends for good /// (clean exit, `~.`, or a hard drop we give up on). Emits the VT resets while -/// output VT processing is still enabled, restores the console input codepage, -/// then drops raw mode. +/// output VT processing is still enabled, drops raw mode, then restores the +/// console's exact original modes + codepage (which is what clears the +/// `ENABLE_VIRTUAL_TERMINAL_INPUT` flag so the local shell's Enter works again). /// /// `reset_screen` should be `true` only for unclean endings (forced `~.`, a hard /// drop we abandon, a remote command whose TUI may still be up): it additionally @@ -198,8 +257,12 @@ fn restore_terminal(reset_screen: bool) { let _ = out.write_all(TERM_RESET_MODES); let _ = out.flush(); } - restore_console_cp(); + // Drop crossterm's raw mode first (keeps its internal state consistent), then + // restore the exact original console modes — the latter wins, and crucially + // clears ENABLE_VIRTUAL_TERMINAL_INPUT, which crossterm's disable_raw_mode + // leaves set. let _ = disable_raw_mode(); + restore_console_state(); } macro_rules! vlog { @@ -757,6 +820,10 @@ async fn run_connection_loop( x11_auth_cookie: Vec, verbose: u8, ) -> io::Result<()> { + // Snapshot the console's original modes/codepage before anything (raw mode, + // VT-input) changes them, so `restore_terminal` can put it back exactly. + capture_console_originals(); + let host = if let Some(idx) = target.find('@') { &target[idx + 1..] } else {