Fix native-v1 connection break, server panic, and resource leaks

The native-v1 work changed the UDP wire format (HEADER_LEN 42->58) without a
version bump, so a current client silently timed out against the older
still-deployed server. The server just needed redeploying; alongside that,
fix the robustness bugs surfaced while restoring the connection:

- Server: clamp terminal size to >=1 at every PTY/parser resize site. A client
  reporting a 0x0 terminal made vt100::Parser::set_size panic ("attempt to
  subtract with overflow") and took down the whole single-threaded daemon.
- Client: treat size() == Ok((0,0)) like a missing size and fall back to 80x24
  instead of sending 0x0 to the server.
- Reap abandoned sessions and their shells. PtyHandle now retains the child and
  kills it on drop, and the cleanup task removes clientless, non-prewarmed
  sessions after the grace period. Previously the shell leaked forever (one
  zsh per abandoned session); prewarmed sessions still stay hot.
- Evict half-finished native handshakes (pending_native) after 30s so an
  unauthenticated ClientHello flood can't grow the map without bound.
- Drop the per-client, per-packet vt100 Screen clone on the output hot path and
  remove the write-only last_screen field.
- Guard load_or_create_server_secret against a <32-byte secret file (was an
  index-out-of-range panic at startup).
- Reconcile stream flow-control credit (client and server) when StreamData hits
  a missing writer, so a closed/unknown stream can't wedge the peer's window.

Adds a unit test for session reaping. cargo fmt + cargo test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DuProcess
2026-06-14 10:06:27 -04:00
parent 6c14d669b8
commit 2a6f28d529
4 changed files with 201 additions and 71 deletions
+29 -10
View File
@@ -44,6 +44,20 @@ use tokio::sync::mpsc;
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// Current terminal size, with a sane fallback.
///
/// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it
/// can also return `Ok((0, 0))` for a real PTY that has not been sized yet
/// (e.g. launched under `script` with no controlling window). Sending `0` to
/// the server is meaningless and used to crash older servers, so treat any
/// zero dimension the same as a missing size and fall back to 80x24.
fn terminal_size() -> (u16, u16) {
match size() {
Ok((cols, rows)) if cols > 0 && rows > 0 => (cols, rows),
_ => (80, 24),
}
}
#[derive(Debug, Parser)]
#[command(name = "dosh-client")]
struct Args {
@@ -218,7 +232,7 @@ async fn main() -> Result<()> {
}
let dosh_port = args.dosh_port.or(host.port).unwrap_or(config.dosh_port);
let cache_path = cache_path(&config.credential_cache, &requested_server, &session, &mode);
let (cols, rows) = size().unwrap_or((80, 24));
let (cols, rows) = terminal_size();
let auth_preference = args
.auth
.clone()
@@ -2072,7 +2086,7 @@ async fn run_terminal(
let mut last_packet_at = Instant::now();
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
let mut last_size = size().unwrap_or((80, 24));
let mut last_size = terminal_size();
let mut frame_buffer = FrameBuffer::default();
let mut predictor = Predictor::new(predict && cred.mode != "view-only" && !forward_only);
let (forward_tx, mut forward_rx) = mpsc::channel::<ForwardEvent>(1024);
@@ -2151,6 +2165,8 @@ async fn run_terminal(
}
_ = resize_tick.tick() => {
if let Ok((cols, rows)) = size()
&& cols > 0
&& rows > 0
&& (cols, rows) != last_size
{
last_size = (cols, rows);
@@ -2337,17 +2353,20 @@ async fn run_terminal(
continue;
};
last_packet_at = Instant::now();
let len = data.bytes.len();
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).await;
send_stream_window_adjust(
&socket,
addr,
&cred,
&mut send_seq,
data.stream_id,
data.bytes.len(),
).await?;
}
// Always return flow-control credit so a stream whose local
// writer is already gone cannot wedge the server's send window.
send_stream_window_adjust(
&socket,
addr,
&cred,
&mut send_seq,
data.stream_id,
len,
).await?;
}
PacketKind::StreamWindowAdjust => {
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {