Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"] }

Expand Down
36 changes: 35 additions & 1 deletion NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/bin/etr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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! {
Expand Down
78 changes: 50 additions & 28 deletions src/bin/etrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -722,6 +704,11 @@ async fn run_session(

type PtyTx = Arc<std::sync::Mutex<Option<mpsc::Sender<(u64, Vec<u8>)>>>>;
type CtrlTx = Arc<std::sync::Mutex<Option<mpsc::Sender<Envelope>>>>;
/// 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<std::sync::Mutex<Option<Box<dyn std::io::Read + Send>>>>;

/// Handle one QUIC connection. Returns `true` on clean Disconnect.
#[cfg(unix)]
Expand All @@ -740,6 +727,7 @@ async fn handle_connection(
active_reverse_listeners: Arc<Mutex<std::collections::HashSet<String>>>,
shell_exit_rx: tokio::sync::watch::Receiver<bool>,
x11_real_cookie: Arc<Mutex<Option<Vec<u8>>>>,
pty_reader_holder: PtyReaderHolder,
) -> bool {
let peer = conn.remote_address();
vlog!(
Expand Down Expand Up @@ -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();
Expand Down
Loading