Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Roughly double bssh-server single-connection SFTP write throughput by fixing channel fragmentation and the serial write path** (#187). Three independent bottlenecks identified in the issue's code audit are addressed. First, `build_russh_config` now advertises a 65535-byte `maximum_packet_size` (the russh cap; library default 32768) and an 8 MiB `window_size` (library default 2 MiB), both configurable via `server.maximum_packet_size` / `server.window_size` (env: `BSSH_MAX_PACKET_SIZE` / `BSSH_WINDOW_SIZE`), so a 256 KiB SFTP write is no longer chopped into 8 CHANNEL_DATA packets that each pay the full cipher/copy/scheduling cost. Second, the SFTP server loop in `bssh-russh-sftp` is restructured from a strict read-process-write-flush cycle into a reader task with a bounded read-ahead queue plus an in-order processor: responses are flushed once per burst instead of once per request, and consecutive queued `SSH_FXP_WRITE` requests to the same handle at sequential offsets are coalesced into a single handler call (bounded by `max_write_coalesce_len`, default 256 KiB) while every merged request id still receives its own status reply, preserving SFTP response ordering and error semantics. The bssh write/read handlers additionally track each open file's cursor and elide the per-chunk `seek` for sequential transfers (append handles are exempt since O_APPEND ignores the cursor). Third, the workspace gains a tuned `[profile.release]` (`lto = "fat"`, `codegen-units = 1`) and documented `RUSTFLAGS` target-CPU guidance for self-builds. Local before/after benchmark (loopback, OpenSSH sftp client, 1 GiB random file, 3-run averages, 20-core Linux box): upload 71 MiB/s to 437 MiB/s (6.2x), download 73 MiB/s to 282 MiB/s (3.9x); an ablation with the code changes but library-default channel sizing lands at 333 / 291 MiB/s, attributing most of the gain to the pipelined write path and the rest of the upload gain to the larger packets. Numbers on the reporter's NIC-limited Xeon will differ; the methodology is documented in the PR. Remaining plan items (per-packet copies inside russh, AES-CTR internals) live in upstream russh since #212 removed the fork and are out of scope here.

### Added
- **Preserve an outer TUI's enhanced-keyboard state across interactive PTY sessions** (#236). Before the local input task starts, bssh performs DA-delimited, 100 ms-per-screen bounded queries for raw Kitty progressive-enhancement flags on both main and alternate screens, plus xterm `modifyOtherKeys` and the screen initially selected according to DEC private mode 1049. It uses non-clearing DEC mode 47 transitions to inspect the hidden screen, then normalizes both screens to the legacy keyboard baseline without popping the outer application's Kitty stacks, so the remote shell is isolated from the outer TUI's protocol. Strictly matched replies before each transaction's first primary DA are consumed while every post-DA, unrelated, malformed, partial, or concurrently typed byte is retained in order and forwarded through the normal PTY input path. Teardown resets leaked stacks and restores each screen's captured Kitty flags plus the global xterm value. Fragmented remote 1049 transitions are tracked only after the bytes survive filtering and are successfully written; the streaming parser skips OSC/DCS/APC/PM/SOS payloads with their protocol-specific terminators and validates CSI parameters so escape-looking data is not mistaken for an executed transition. A session that began on main can therefore safely use `1049l` to restore the remote-saved cursor after a disconnect; when a session began in an alternate screen, cleanup intentionally uses non-clearing mode 47 instead of a destructive `1049h`, because the terminal protocol cannot restore the 1049 bit and its single cursor-save slot without clearing or overwriting outer state. Failed or incomplete queries retain #234's ordinary-shell baseline fallback, hidden-screen setup failures make a best-effort return to the initial screen, the same guaranteed snapshot is reused by normal, forced, and repeated cleanup paths, and an atomic RAII ownership token covers both raw and deferred-raw guards.
- **Make server-side SSH compression configurable instead of hard-disabled** (#220). The #215 workaround forced the server to advertise only `none` compression for every deployment, with no way for operators to opt back in. A new `server.compression` option (YAML `server.compression: true`, env `BSSH_COMPRESSION`, builder `ServerConfigBuilder::compression`) now controls whether `build_russh_config` advertises russh's default compression list (`none`, `zlib`, `zlib@openssh.com`) or only `none`. The default stays off, so behavior is unchanged unless explicitly enabled, and enabling it logs a warning: russh's delayed-zlib (`zlib@openssh.com`) transport still desyncs a few packets after compression activates post-auth (reproduced on russh 0.61.1 and 0.62.1), dropping clients that negotiate it mid-session. Both settings are covered by unit tests, and the option plus the desync caveat are documented in the man page and config docs.

### Fixed
Expand Down
13 changes: 12 additions & 1 deletion docs/architecture/interactive-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,18 @@ Run this check in Ghostty and at least one other Kitty-keyboard-compatible termi
3. At the restored local prompt, verify that Space, Enter, arrow keys, and modified keys behave normally and do not print CSI-u fragments such as `;1:3u`, `:1C`, or `:3C`.
4. Repeat with a Kitty-keyboard-aware full-screen application on the remote host and terminate the SSH transport before the application exits cleanly.

The cleanup deliberately restores the ordinary shell baseline. Exact preservation of an enhanced keyboard mode owned by an outer local TUI is not currently available because crossterm exposes protocol support detection but not the active flag value, while issuing a separate `/dev/tty` query would race bssh's PTY input reader.
Before starting the PTY input task, bssh uses a DA-delimited query transaction of at most 100 ms per screen to capture the Kitty enhancement flags independently for the main and alternate screens, together with the global xterm `modifyOtherKeys` value and the screen initially selected according to DEC private mode 1049. It uses DEC mode 47 to inspect the hidden buffer without the clear/initialize behavior of mode 1049, sets both screens to the legacy keyboard baseline without popping the outer application's Kitty stacks, and returns to the original screen before remote input starts. Only strict replies before the first primary DA in each transaction are consumed; post-DA bytes and user input, malformed replies, or unrelated escape sequences are retained in byte order and forwarded to the remote session. A failed hidden-screen write or flush makes a best-effort mode-47 return before capture falls back.

Teardown resets leaked stacks on both buffers and restores both captured Kitty values plus `modifyOtherKeys`. It also tracks fragmented remote `1049h`/`1049l` transitions, but commits a transition only after the bytes survive response filtering and `write_all` succeeds. Its streaming parser skips 7-bit OSC, DCS, APC, PM, and SOS payloads through fragmented terminators—BEL is accepted only for OSC compatibility, while every string type accepts `ESC \` ST—and accepts only validated private CSI parameters, preventing embedded escape-looking data from changing cleanup state. Raw 8-bit C1 controls are not interpreted because their byte values can also occur as UTF-8 continuation bytes. If bssh began on main and the connection dies in a remote-owned 1049 alternate screen, cleanup uses `1049l` to restore the remote-saved cursor before restoring both screens' keyboard state. The reverse correction is intentionally non-destructive: if bssh began inside an outer alternate screen and the remote reset 1049, cleanup selects the original buffer with mode 47 instead of forcing `1049h`. Generic terminal protocols expose only one cursor-save slot, and `1049h` clears the alternate buffer, so the 1049 mode bit, saved cursor, and existing outer contents cannot all be reconstructed exactly in that case. Keyboard state and screen contents take priority over claiming an impossible exact mode-bit restoration.

If a DA transaction or the screen-state query is unsupported, malformed, or times out, cleanup falls back to the ordinary-shell baseline from #234 rather than claiming partial preservation. The cleanup snapshot is published with poison recovery and reused by normal, forced, and repeated cleanup paths. A single-owner RAII token covers raw and deferred-raw guards alike so they cannot race the process-global snapshot, while a deferred guard that never enters raw mode performs no terminal cleanup when dropped.

To validate outer-TUI preservation in Ghostty and Kitty or WezTerm:

1. Start a Kitty-keyboard-aware TUI that can launch a child command while remaining in its alternate screen, and record its active enhancement flags with `printf '\033[?u'`.
2. Launch `bssh` from that TUI, enable different remote flags with `printf '\033[=31u'`, and exit normally.
3. Verify the outer TUI receives the same keyboard encoding it used before launching bssh and that its alternate-screen contents were not cleared by an unnecessary `1049l`/`1049h` round trip.
4. Repeat after abrupt transport loss and local `~.`. From an ordinary main-screen shell, disconnect after remote `printf '\033[?1049h'` and verify bssh safely returns with `1049l`; from an outer alternate-screen TUI, use remote `printf '\033[?1049l'` and verify cleanup returns with mode 47 without clearing the outer buffer.

### Performance Characteristics

Expand Down
5 changes: 5 additions & 0 deletions src/pty/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ use tokio::time::Duration;

pub mod session;
pub mod terminal;
mod terminal_protocol;
#[cfg(test)]
mod terminal_protocol_boundary_tests;
mod terminal_protocol_restore;
mod terminal_screen;

pub use session::PtySession;
pub use terminal::{TerminalState, TerminalStateGuard, force_terminal_cleanup};
Expand Down
4 changes: 3 additions & 1 deletion src/pty/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ mod constants;
mod escape_filter;
mod input;
mod local_escape;
mod raw_input;
mod output_delivery;
pub(crate) mod raw_input;
mod raw_input_task;
mod session_manager;
mod terminal_modes;

Expand Down
110 changes: 110 additions & 0 deletions src/pty/session/output_delivery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Ordered filtering, terminal delivery, and observation of remote output.

use std::io::{self, Write};

use super::escape_filter::EscapeSequenceFilter;

/// Filter remote bytes, write the surviving bytes, then commit only bytes the
/// terminal actually accepted to the protocol observer.
pub(super) fn deliver_filtered_output(
filter: &mut EscapeSequenceFilter,
writer: &mut impl Write,
input: &[u8],
commit: impl FnOnce(&[u8]),
) -> io::Result<()> {
let output = filter.filter(input);
if output.is_empty() {
return Ok(());
}

writer.write_all(&output)?;
commit(&output);
let _ = writer.flush();
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::pty::terminal_screen::ScreenModeTracker;

struct FailedWriter;

impl Write for FailedWriter {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
Err(io::Error::other("injected delivery failure"))
}

fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

#[test]
fn failed_delivery_does_not_commit_screen_transition() {
let mut filter = EscapeSequenceFilter::new();
let mut tracker = ScreenModeTracker::new();
let mut committed = false;

let result =
deliver_filtered_output(&mut filter, &mut FailedWriter, b"\x1b[?1049h", |output| {
committed = true;
tracker.observe(output);
});

assert!(result.is_err());
assert!(!committed);
}

#[test]
fn filtered_payload_does_not_commit_screen_transition() {
let mut filter = EscapeSequenceFilter::new();
let mut tracker = ScreenModeTracker::new();
let mut output = Vec::new();
let mut committed = false;

deliver_filtered_output(
&mut filter,
&mut output,
b"\x1b]10;payload\x1b[?1049h\x07",
|delivered| {
committed = true;
tracker.observe(delivered);
},
)
.unwrap();

assert!(output.is_empty());
assert!(!committed);
}

#[test]
fn successful_delivery_commits_screen_transition() {
let mut filter = EscapeSequenceFilter::new();
let mut tracker = ScreenModeTracker::new();
let mut output = Vec::new();
let mut observed = None;

deliver_filtered_output(&mut filter, &mut output, b"\x1b[?1049h", |delivered| {
observed = tracker.observe(delivered);
})
.unwrap();

assert_eq!(output, b"\x1b[?1049h");
assert_eq!(observed, Some(true));
}
}
90 changes: 90 additions & 0 deletions src/pty/session/raw_input_task.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Blocking local-terminal input forwarding for an active PTY session.

use std::time::Duration;

use tokio::sync::{mpsc, watch};

use super::constants::INPUT_POLL_TIMEOUT_MS;
use super::local_escape::{LocalAction, LocalEscapeDetector};
use super::raw_input::RawInputReader;
use crate::pty::PtyMessage;

pub(crate) fn run(
input_tx: mpsc::Sender<PtyMessage>,
cancel_rx: watch::Receiver<bool>,
pending_input: Vec<u8>,
) {
let mut reader = RawInputReader::new();
let mut buffer = [0_u8; 1024];
let mut escape_detector = LocalEscapeDetector::new();

if !pending_input.is_empty() && !forward_input(&input_tx, &mut escape_detector, &pending_input)
{
return;
}

loop {
if *cancel_rx.borrow() {
break;
}

match reader.poll(Duration::from_millis(INPUT_POLL_TIMEOUT_MS)) {
Ok(true) => match reader.read(&mut buffer) {
Ok(0) => {
tracing::debug!("EOF received on stdin");
break;
}
Ok(read) => {
if !forward_input(&input_tx, &mut escape_detector, &buffer[..read]) {
break;
}
}
Err(error) => {
let _ = input_tx.try_send(PtyMessage::Error(format!("Input error: {error}")));
break;
}
},
Ok(false) => continue,
Err(error) => {
let _ = input_tx.try_send(PtyMessage::Error(format!("Poll error: {error}")));
break;
}
}
}
}

fn forward_input(
input_tx: &mpsc::Sender<PtyMessage>,
detector: &mut LocalEscapeDetector,
bytes: &[u8],
) -> bool {
if let Some(action) = detector.process(bytes) {
match action {
LocalAction::Disconnect => {
tracing::debug!("Disconnect escape sequence detected");
let _ = input_tx.try_send(PtyMessage::Terminate);
false
}
LocalAction::Passthrough(data) => {
input_tx.try_send(PtyMessage::LocalInput(data)).is_ok()
}
}
} else {
let data = smallvec::SmallVec::from_slice(bytes);
input_tx.try_send(PtyMessage::LocalInput(data)).is_ok()
}
}
Loading