diff --git a/Cargo.lock b/Cargo.lock index 5b48f41..7e99746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index be43ca2..4feaa1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "0.1.9" +version = "0.1.10" edition = "2024" license = "MIT" diff --git a/scripts/tui-harness.sh b/scripts/tui-harness.sh index a001f6d..4827b84 100755 --- a/scripts/tui-harness.sh +++ b/scripts/tui-harness.sh @@ -9,6 +9,7 @@ for test_name in \ tui_control_sequences_survive_transport_verbatim \ unicode_output_survives_transport \ tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution \ + btop_style_graph_output_stays_raw_when_retransmit_history_overflows \ large_tui_paint_is_delivered_in_mtu_safe_frames \ resume_snapshot_preserves_alternate_screen_mode do diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 64200c8..22c32fa 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -3336,7 +3336,7 @@ async fn broadcast_output( let sends = { let mut locked = state.lock().expect("server state poisoned"); let scrollback = locked.config.scrollback; - let retransmit_window = locked.config.retransmit_window; + let retransmit_window = locked.config.retransmit_window.max(1); let session = locked .sessions .get_mut(&output.session) @@ -3374,20 +3374,18 @@ async fn broadcast_output( client.send_seq += 1; // Count traffic toward the packet-count rekey trigger (spec §11). client.epoch_packets = client.epoch_packets.saturating_add(1); - // Only materialize a screen snapshot when this client has fallen too - // far behind; the common case sends the raw output bytes and must not - // clone the whole vt100 grid per client per packet. - let (bytes, snapshot) = if client.pending.len() >= retransmit_window { - client.pending.clear(); - (screen_snapshot(session.parser.screen()), true) - } else { - (output.bytes.clone(), false) - }; + // Live terminal bytes are never rewritten into vt100 snapshots. A + // snapshot is useful for attach/resume, but substituting it during a + // running TUI can lose graph/background-cell details that apps like + // btop rely on. If retransmit history is full, prune only history. + while client.pending.len() >= retransmit_window { + client.pending.pop_front(); + } let frame = Frame { session: output.session.clone(), output_seq, - bytes, - snapshot, + bytes: output.bytes.clone(), + snapshot: false, closed: false, }; let body = protocol::to_body(&frame)?; @@ -3400,9 +3398,6 @@ async fn broadcast_output( SERVER_TO_CLIENT, &body, )?; - while client.pending.len() >= retransmit_window { - client.pending.pop_front(); - } client.pending.push_back(PendingFrame { output_seq, packet: packet.clone(), diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index bf2979c..d162418 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -112,6 +112,21 @@ fn write_server_config_with_output_interval( config } +fn write_server_config_with_retransmit_window( + dir: &tempfile::TempDir, + port: u16, + retransmit_window: u64, +) -> std::path::PathBuf { + let config = write_server_config(dir, port); + let mut raw = fs::read_to_string(&config).unwrap(); + raw = raw.replace( + "retransmit_window = 256\n", + &format!("retransmit_window = {retransmit_window}\n"), + ); + fs::write(&config, raw).unwrap(); + config +} + fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { let server = env!("CARGO_BIN_EXE_dosh-server"); let child = Command::new(server) @@ -1694,6 +1709,69 @@ fn tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution() { ); } +#[test] +fn btop_style_graph_output_stays_raw_when_retransmit_history_overflows() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config_with_retransmit_window(&dir, port, 3); + let mut server = start_server(&dir, &config); + let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + let graph = "DOSH_BTOP_NET ⠀⠁⠃⠇⡇⣇⣧⣷⣿"; + let input = Input { + bytes: format!( + "stty -echo; \ + printf '\\033[?1049h\\033[?2026h\\033[?25l'; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + printf '\\033[6;4H\\033[38;2;80;220;140m{} %s\\033[0m' \"$i\"; \ + done; \ + printf '\\033[?25h\\033[?2026l\\033[?1049l'\n", + graph + ) + .into_bytes(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + + let mut text = String::new(); + let mut snapshots = 0usize; + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while std::time::Instant::now() < deadline + && !(text.matches("DOSH_BTOP_NET").count() >= 10 && text.contains("\x1b[?2026l")) + { + if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) { + if frame.snapshot { + snapshots += 1; + } + text.push_str(&String::from_utf8_lossy(&frame.bytes)); + } + } + + let _ = server.kill(); + let _ = server.wait(); + + assert_eq!( + snapshots, 0, + "live btop-style graph output must not be replaced by snapshots; got {snapshots}" + ); + assert!( + text.matches("DOSH_BTOP_NET").count() >= 10 + && text.contains("⣿") + && text.contains("\x1b[38;2;80;220;140m") + && text.contains("\x1b[?2026h") + && text.contains("\x1b[?2026l"), + "expected raw btop-style graph output to survive under retransmit pressure, got {text:?}" + ); +} + #[test] fn large_tui_paint_is_delivered_in_mtu_safe_frames() { let dir = tempfile::tempdir().unwrap();