Replace UDP transport with QUIC; add port forwarding (v0.3.0) - #2
Merged
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Assisted-By: Claude Sonnet 4.6
Retire custom UDP/KEM/AEAD stack in favour of quinn 0.11 QUIC with TLS 1.3 and rcgen self-signed cert pinning. Stream tags route control (0x01), PTY (0x02), and forward (0x03) streams over multiplexed QUIC bidi streams. Session persistence (send_history, replay_from) is unchanged — QUIC does not replay application data across connections. Remove --cipher flag, ML-KEM, X25519, AES-GCM, ChaCha20, and HKDF. 57 tests pass; cargo clippy -D warnings clean. Assisted-By: Claude Sonnet 4.6
quinn::Endpoint::server() requires an active tokio runtime, so it cannot be called before fork(). Use a pipe to let the child create the quinn endpoint (inside tokio), write the assigned port back to the parent, and have the parent print PORT/CERT to SSH stdout before exiting. Assisted-By: Claude Sonnet 4.6
Heartbeat messages now carry last_received_seq so each side can continuously trim its send_history as the peer acks data, rather than waiting until a reconnect. Previously the server's PTY history grew to max_history (10k entries × 4KB = ~40MB) and stayed there for the duration of the session. Also add level-3 (-vvv) traces for PTY chunk sends/receives and heartbeat ack maps on both client and server, replacing the packet traces that existed in the old UDP transport. Assisted-By: Claude Sonnet 4.6
Add quic::tls_info() and log it at verbosity level 2 on both client (after QUIC handshake) and server (after session verification), replacing the cipher-selection log that existed in the old UDP transport. Quinn does not expose the negotiated cipher suite, so we describe the full configured set (TLS 1.3 only; three suites). Assisted-By: Claude Sonnet 4.6
- Replace count-based send_history cap (10,000 entries ≈ 40 MB max) with a 4 MB byte-based cap in StreamState::record_send; oldest entries are evicted immediately regardless of heartbeat-ack interval. - Fix test-local reconnect PID lookup to use tmux pane_pid + pgrep -f fallback instead of pgrep -x (which fails in non-interactive shells). - Add stress-local justfile recipe: 1 PTY + 2 -L forward streams (TCP echo + UDP echo), data pumped hard in both directions for 30 s while sampling etrs RSS every 2 s; fails if peak growth > 20 MB. Assisted-By: Claude Sonnet 4.6
justfile's parser tokenizes all recipe body lines (including
heredoc content) for {{ }} interpolation, so attribute-access dots
in Python code trigger parse errors. Move the four helper scripts
to scripts/stress/{tcp,udp}_{echo,pump}.py and call them by path
from the recipe.
Assisted-By: Claude Sonnet 4.6
Three fixes accumulated during testing: - Run etr as the direct tmux session command (not via send-keys to a shell) so .zshrc/neofetch can't race with our command injection. - Detect session readiness by watching the log file for the 'Forwarding:' line rather than pane prompt matching (which fired on the local shell prompt before the session was up). - Get etrs PID via 'awk /PPid/ /proc/$$/status' inside the remote shell — /proc/self/status gives the PPid of the subshell running awk, not of the login shell; $$ gives the login shell's own PID so PPid is correctly etrs, not the remote shell itself. Stress test now passes: etrs peak RSS growth 4.6 MB over 30 s of full-throttle PTY + TCP echo + UDP echo traffic, within the 20 MB limit set by the 4 MB byte-based send-history cap. Assisted-By: Claude Sonnet 4.6
Run etr as the tmux session command instead of via
send-keys so #{pane_pid} == etr's PID directly and
the .zshrc startup race is eliminated. Wait for
"[etr] Connected." in the client log file before
sending the PTY sentinel, then for the sentinel in
the pane before running happy-path and reconnect
sub-tests. Both sub-tests now pass reliably.
Assisted-By: Claude Sonnet 4.6
The recipe tests both the happy-path PTY session and reconnect, not just one thing; e2e-local better reflects its scope. Assisted-By: Claude Sonnet 4.6
- Cargo.toml: 0.2.0 → 0.3.0 - README.md: full rewrite for QUIC transport — removes UDP/ML-KEM/cipher-table content, adds -L forwarding, QUIC security description, config section - PROTOCOL.md: replace old UDP/KEM spec entirely with QUIC wire format (stream tags, framing, bootstrap, reconnect flow, protobuf definitions) - NOTES.md: minor cleanup (test count phrasing) Assisted-By: Claude Sonnet 4.6
Assisted-By: Claude Sonnet 4.6
Assisted-By: Claude Sonnet 4.6
l1a
added a commit
that referenced
this pull request
Jul 21, 2026
* 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 * 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 * 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 * 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 * 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 * Fix Windows first-line echo (#54) via reader gate 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 * Fix local shell Enter broken after etr exits (Windows) 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
-Llocal port forwarding (TCP + UDP): each TCP connection gets its own QUIC stream; UDP uses a dedicated stream per spec with last-sender reply routing. Runs without a PTY session if no terminal is attached.rustls-post-quantumhybrid.Heartbeatcarrieslast_received_seqso the send-history replay buffer is trimmed continuously, not just on reconnect.#{pane_pid}directly for SIGSTOP/SIGCONT.-Lforward streams pumped hard for 30 s; asserts etrs RSS growth < 20 MB.Deleted
src/crypto/— entire module (aead, kdf, kyber, x25519, mod)src/handshake.rssrc/transport.rs--cipherCLI flag andciphersconfig fieldTest plan
just check— fmt + clippy cleanjust test— 57 unit/integration tests passjust e2e-local— happy path + reconnect pass on localhostjust stress-local— etrs RSS growth < 20 MB over 30 s with 3 concurrent streamsetr -L 8080:example.com:80 localhost— TCP forward smoke testetr -vvv localhost— verify QUIC log lines appear🤖 Generated with Claude Code