-
Notifications
You must be signed in to change notification settings - Fork 1
Description
Problem Summary
When running tmux inside a bssh PTY session, terminal escape sequence responses appear as raw text on the first prompt:
64;2500;0c>|iTerm2 3.6.7beta1
This does NOT occur with OpenSSH - the issue is specific to bssh's input handling.
Root Cause
bssh uses crossterm::event::read() which parses escape sequences into Event objects. This parsing consumes the ESC byte (0x1b), corrupting terminal responses that should pass through transparently.
Location: src/pty/session/session_manager.rs lines 237-238
if crossterm::event::poll(poll_timeout).unwrap_or(false) {
match crossterm::event::read() { // ← Problem: parses escape sequencesSolution: crossterm + Raw stdin
Use crossterm only for terminal mode management (enable_raw_mode()), and read input as raw bytes using std::io::stdin().read() without event parsing.
Key Insight: Two Independent Layers
┌─────────────────────────────────────────────────────────────┐
│ crossterm::event::read() ← Escape sequence parsing │ HIGH LEVEL (REMOVE)
├─────────────────────────────────────────────────────────────┤
│ std::io::stdin().read() ← Raw bytes reading │ (USE THIS)
├─────────────────────────────────────────────────────────────┤
│ crossterm::terminal::enable_raw_mode() ← termios settings │ LOW LEVEL (KEEP)
└─────────────────────────────────────────────────────────────┘
enable_raw_mode(): Configures terminal driver (disables line buffering, echo, signal generation)stdin.read(): Reads raw bytes directly (works correctly AFTERenable_raw_mode()is called)event::read(): Parses escape sequences into Event objects (THIS IS THE PROBLEM)
The fix: Keep using enable_raw_mode() for terminal configuration, but replace event::read() with direct stdin.read().
Architecture Change
Current (problematic):
stdin → crossterm::event::read() → Event parsing → bytes reconversion → SSH channel
↑
ESC consumed, terminal responses corrupted
After fix:
┌──────────────────────────────────────────────────────────────────────────┐
│ crossterm::terminal::enable_raw_mode() // Terminal configuration │
└──────────────────────────────────────────────────────────────────────────┘
↓
stdin → std::io::stdin().read() → raw bytes as-is → SSH channel
↑
Transparent passthrough (same as OpenSSH)
crossterm Role Change
| Function | Before | After | Purpose |
|---|---|---|---|
terminal::enable_raw_mode() |
✅ Used | ✅ Keep | Required for raw byte input |
terminal::disable_raw_mode() |
✅ Used | ✅ Keep | Restore terminal on exit |
terminal::size() |
✅ Used | ✅ Keep | Get terminal dimensions |
event::read() |
✅ Used | ❌ Remove | Causes ESC byte consumption |
event::poll() |
✅ Used | ❌ Remove | Replaced by nix::poll |
Implementation Details
1. New Raw Input Reader Module
File: src/pty/session/raw_input.rs (new)
//! Raw byte input reader for PTY sessions.
//!
//! Reads stdin as raw bytes without escape sequence parsing,
//! providing transparent passthrough like OpenSSH.
//!
//! IMPORTANT: This module requires `crossterm::terminal::enable_raw_mode()`
//! to be called before reading. The raw mode ensures:
//! - No line buffering (bytes available immediately)
//! - No echo (typed characters not displayed by terminal)
//! - No signal generation (Ctrl+C doesn't generate SIGINT)
use std::io::{self, Read};
use std::os::unix::io::AsRawFd;
use std::time::Duration;
/// Raw input reader that provides transparent byte passthrough.
///
/// # Prerequisites
/// - `crossterm::terminal::enable_raw_mode()` must be called before use
/// - Terminal must be in raw mode for proper operation
pub struct RawInputReader {
stdin: io::Stdin,
}
impl RawInputReader {
pub fn new() -> Self {
Self {
stdin: io::stdin(),
}
}
/// Poll for available input with timeout.
/// Returns true if data is available to read.
pub fn poll(&self, timeout: Duration) -> io::Result<bool> {
use nix::poll::{poll, PollFd, PollFlags};
let fd = self.stdin.as_raw_fd();
let mut poll_fds = [PollFd::new(fd, PollFlags::POLLIN)];
let timeout_ms = timeout.as_millis() as i32;
let result = poll(&mut poll_fds, timeout_ms)?;
Ok(result > 0)
}
/// Read available bytes from stdin.
/// Returns the number of bytes read.
///
/// When terminal is in raw mode (via `enable_raw_mode()`),
/// this returns raw bytes including escape sequences like:
/// - Arrow keys: `\x1b[A`, `\x1b[B`, `\x1b[C`, `\x1b[D`
/// - Function keys: `\x1bOP`, `\x1bOQ`, etc.
/// - Terminal responses: `\x1b[>64;2500;0c`, etc.
///
/// All bytes are passed through as-is without interpretation.
pub fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
self.stdin.read(buffer)
}
}2. Session Manager Modification
File: src/pty/session/session_manager.rs
The key change is that enable_raw_mode() is still called (via TerminalStateGuard::new()), but the input reading switches from crossterm::event::read() to direct stdin.read().
// Before
use crossterm::event::{self, Event};
// After
use super::raw_input::RawInputReader;// The terminal is already in raw mode here (TerminalStateGuard::new() calls enable_raw_mode())
// So stdin.read() will return raw bytes without line buffering
// Before: input task using crossterm event parsing
let input_task = tokio::task::spawn_blocking(move || {
loop {
if *cancel_for_input.borrow() {
break;
}
let poll_timeout = Duration::from_millis(INPUT_POLL_TIMEOUT_MS);
if crossterm::event::poll(poll_timeout).unwrap_or(false) {
match crossterm::event::read() {
Ok(event) => {
if let Some(data) = handle_input_event(event) {
if input_tx.try_send(PtyMessage::LocalInput(data)).is_err() {
break;
}
}
}
Err(e) => {
let _ = input_tx.try_send(PtyMessage::Error(format!("Input error: {e}")));
break;
}
}
}
}
});
// After: input task using raw stdin reading
// NOTE: enable_raw_mode() is already called by TerminalStateGuard before this point
let input_task = tokio::task::spawn_blocking(move || {
let mut reader = RawInputReader::new();
let mut buffer = [0u8; 1024];
loop {
if *cancel_for_input.borrow() {
break;
}
let poll_timeout = Duration::from_millis(INPUT_POLL_TIMEOUT_MS);
match reader.poll(poll_timeout) {
Ok(true) => {
match reader.read(&mut buffer) {
Ok(0) => break, // EOF
Ok(n) => {
// Handle local escape sequences (e.g., ~. for disconnect)
if let Some(action) = check_local_escape(&buffer[..n]) {
match action {
LocalAction::Disconnect => break,
LocalAction::Passthrough(data) => {
if input_tx.try_send(PtyMessage::LocalInput(data)).is_err() {
break;
}
}
}
} else {
// Pass raw bytes through as-is (arrow keys, function keys, etc.)
let data = SmallVec::from_slice(&buffer[..n]);
if input_tx.try_send(PtyMessage::LocalInput(data)).is_err() {
break;
}
}
}
Err(e) => {
let _ = input_tx.try_send(PtyMessage::Error(format!("Input error: {e}")));
break;
}
}
}
Ok(false) => continue, // Timeout, poll again
Err(e) => {
let _ = input_tx.try_send(PtyMessage::Error(format!("Poll error: {e}")));
break;
}
}
}
});3. Local Escape Sequence Handling (Optional)
File: src/pty/session/local_escape.rs (new)
//! Local escape sequence handling (OpenSSH-style).
//!
//! Handles sequences like `~.` for disconnect without sending to remote.
use smallvec::SmallVec;
pub enum LocalAction {
/// Disconnect the session
Disconnect,
/// Pass data through to remote
Passthrough(SmallVec<[u8; 8]>),
}
/// State machine for detecting `~.` after newline
pub struct LocalEscapeDetector {
after_newline: bool,
saw_tilde: bool,
}
impl LocalEscapeDetector {
pub fn new() -> Self {
Self {
after_newline: true, // Start as if after newline
saw_tilde: false,
}
}
/// Process input and check for local escape sequences.
/// Returns None if data should pass through unchanged.
pub fn process(&mut self, data: &[u8]) -> Option<LocalAction> {
for &byte in data {
match byte {
b'\r' | b'\n' => {
self.after_newline = true;
self.saw_tilde = false;
}
b'~' if self.after_newline => {
self.saw_tilde = true;
self.after_newline = false;
}
b'.' if self.saw_tilde => {
return Some(LocalAction::Disconnect);
}
_ => {
self.after_newline = false;
self.saw_tilde = false;
}
}
}
None // Pass through
}
}4. Remove Unnecessary Code
File: src/pty/session/input.rs
The handle_input_event() and key_event_to_bytes() functions in this file are no longer needed.
- Remove Event → bytes conversion logic
- Delete the entire file or replace with
local_escape.rs
5. Update Module Structure
File: src/pty/session/mod.rs
// Before
mod input;
// After
mod raw_input;
mod local_escape; // OptionalFiles to Modify
| File | Changes |
|---|---|
src/pty/session/session_manager.rs |
Change input task to raw bytes reading |
src/pty/session/raw_input.rs |
New: Raw input reader |
src/pty/session/local_escape.rs |
New: Local escape handling (optional) |
src/pty/session/input.rs |
Delete or reduce |
src/pty/session/mod.rs |
Update module structure |
Cargo.toml |
Add nix crate (for poll) |
Dependencies
[dependencies]
nix = { version = "0.29", features = ["poll"] }Testing Plan
-
Basic Connection Test
bssh user@host # Verify prompt displays cleanly -
tmux Test
bssh user@host tmux # Verify no escape sequence garbage on first prompt -
Arrow Keys / Special Keys Test
bssh user@host # Verify ↑↓←→ arrow keys work # Verify Ctrl+C, Ctrl+D, Ctrl+Z work # Verify Tab auto-completion works
-
vim/neovim Test
bssh user@host nvim # Verify normal operation, no DA response garbage -
Local Escape Test (if implemented)
bssh user@host # Press Enter then ~. to verify disconnect works
Notes
- Windows support is out of scope for this issue (bssh currently supports Linux/macOS only)
- crossterm is used only for terminal mode management (
enable_raw_mode,disable_raw_mode,size()) escape_filter.rsremains unchanged for output-side filtering- The
TerminalStateGuardalready handlesenable_raw_mode()/disable_raw_mode()lifecycle
Acceptance Criteria
- No escape sequence garbage displayed on first prompt when running tmux
- Arrow keys and special keys work correctly
- Control characters (Ctrl+C, Ctrl+D, etc.) work correctly
- Terminal applications like vim/neovim work correctly
- All existing tests pass