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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to SSHub are documented in this file.

## [Unreleased]

### Fixed

- **Tunnel no longer panics on non-ASCII ssh errors** - trimming the kept ssh
stderr of a tunnel cut at a byte offset, so a multibyte character on the
boundary panicked the reader thread once the output passed 8 KB. It now cuts
on a character boundary.

## [0.21.0] - 2026-10-28

### Added
Expand Down
40 changes: 34 additions & 6 deletions src/tunnel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ impl TunnelStatus {
}
}

/// Cap on the stderr kept per tunnel process (bytes); past it the oldest
/// output is dropped down to [`STDERR_TAIL_KEEP`].
const STDERR_TAIL_CAP: usize = 8192;
const STDERR_TAIL_KEEP: usize = 4096;

/// Append one stderr line to `buf`, trimming old output from the front once
/// `buf` passes [`STDERR_TAIL_CAP`].
fn push_stderr_line(buf: &mut String, line: &str) {
buf.push_str(line);
buf.push('\n');
if buf.len() > STDERR_TAIL_CAP {
// `cut` is a byte offset: step back to a char boundary so a multibyte
// character is never split.
let mut cut = buf.len() - STDERR_TAIL_KEEP;
while !buf.is_char_boundary(cut) {
cut -= 1;
}
buf.drain(..cut);
}
}

struct TunnelProcess {
child: Child,
started_at: Instant,
Expand Down Expand Up @@ -149,12 +170,7 @@ impl TunnelManager {
use std::io::{BufRead, BufReader};
for line in BufReader::new(err).lines().map_while(Result::ok) {
if let Ok(mut s) = buf.lock() {
s.push_str(&line);
s.push('\n');
if s.len() > 8192 {
let cut = s.len() - 4096;
s.drain(..cut);
}
push_stderr_line(&mut s, &line);
}
}
});
Expand Down Expand Up @@ -559,6 +575,18 @@ mod tests {
use super::*;
use std::time::Duration;

#[test]
fn stderr_tail_trim_does_not_split_a_multibyte_char() {
let mut buf = String::new();
// Two-byte chars after a one-byte prefix, so the trim point lands
// inside a char whenever the buffer crosses the cap on an odd length.
for i in 0..200 {
push_stderr_line(&mut buf, &format!("x{}", "\u{e9}".repeat(50 + i % 7)));
}
assert!(buf.len() <= STDERR_TAIL_CAP);
assert!(buf.ends_with('\n'));
}

#[test]
fn active_work_excludes_exhausted_reconnects() {
let mut mgr = TunnelManager::new();
Expand Down
Loading