Skip to content

fix: Terminal escape sequence responses displayed on first prompt when starting tmux #87

@inureyes

Description

@inureyes

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 sequences

Solution: 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 AFTER enable_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;  // Optional

Files 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

  1. Basic Connection Test

    bssh user@host
    # Verify prompt displays cleanly
  2. tmux Test

    bssh user@host
    tmux
    # Verify no escape sequence garbage on first prompt
  3. 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
  4. vim/neovim Test

    bssh user@host
    nvim
    # Verify normal operation, no DA response garbage
  5. 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.rs remains unchanged for output-side filtering
  • The TerminalStateGuard already handles enable_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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions