Preserve raw terminal output for TUIs
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
This commit is contained in:
Generated
+1
-1
@@ -436,7 +436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ for test_name in \
|
|||||||
live_output_forwards_terminal_control_sequences \
|
live_output_forwards_terminal_control_sequences \
|
||||||
tui_control_sequences_survive_transport_verbatim \
|
tui_control_sequences_survive_transport_verbatim \
|
||||||
unicode_output_survives_transport \
|
unicode_output_survives_transport \
|
||||||
|
tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution \
|
||||||
large_tui_paint_is_delivered_in_mtu_safe_frames \
|
large_tui_paint_is_delivered_in_mtu_safe_frames \
|
||||||
resume_snapshot_preserves_alternate_screen_mode
|
resume_snapshot_preserves_alternate_screen_mode
|
||||||
do
|
do
|
||||||
|
|||||||
+3
-131
@@ -232,19 +232,6 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let pacing_state = Arc::clone(&state);
|
|
||||||
let pacing_socket = Arc::clone(&socket);
|
|
||||||
let pacing_interval_ms = config.output_frame_interval_ms.max(1);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_millis(pacing_interval_ms));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if let Err(err) = flush_paced_snapshots(&pacing_state, &pacing_socket).await {
|
|
||||||
eprintln!("paced snapshot error: {err:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let cleanup_state = Arc::clone(&state);
|
let cleanup_state = Arc::clone(&state);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||||
@@ -400,8 +387,6 @@ struct ClientState {
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
last_seen: Instant,
|
last_seen: Instant,
|
||||||
pending: VecDeque<PendingFrame>,
|
pending: VecDeque<PendingFrame>,
|
||||||
last_frame_sent: Instant,
|
|
||||||
paced_snapshot_due: bool,
|
|
||||||
allowed_forwardings: Vec<ForwardingRequest>,
|
allowed_forwardings: Vec<ForwardingRequest>,
|
||||||
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
|
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
|
||||||
opened_streams: HashSet<u64>,
|
opened_streams: HashSet<u64>,
|
||||||
@@ -1167,8 +1152,6 @@ async fn handle_native_user_auth(
|
|||||||
rows,
|
rows,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
|
||||||
paced_snapshot_due: false,
|
|
||||||
allowed_forwardings: req.auth.requested_forwardings.clone(),
|
allowed_forwardings: req.auth.requested_forwardings.clone(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -1311,8 +1294,6 @@ async fn handle_bootstrap_attach(
|
|||||||
rows: req.rows,
|
rows: req.rows,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
|
||||||
paced_snapshot_due: false,
|
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -1447,8 +1428,6 @@ async fn handle_ticket_attach(
|
|||||||
rows: req.rows,
|
rows: req.rows,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
|
||||||
paced_snapshot_due: false,
|
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -3358,8 +3337,6 @@ async fn broadcast_output(
|
|||||||
let mut locked = state.lock().expect("server state poisoned");
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
let scrollback = locked.config.scrollback;
|
let scrollback = locked.config.scrollback;
|
||||||
let retransmit_window = locked.config.retransmit_window;
|
let retransmit_window = locked.config.retransmit_window;
|
||||||
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
|
|
||||||
let now = Instant::now();
|
|
||||||
let session = locked
|
let session = locked
|
||||||
.sessions
|
.sessions
|
||||||
.get_mut(&output.session)
|
.get_mut(&output.session)
|
||||||
@@ -3394,15 +3371,6 @@ async fn broadcast_output(
|
|||||||
}
|
}
|
||||||
let mut sends = Vec::new();
|
let mut sends = Vec::new();
|
||||||
for (client_id, client) in session.clients.iter_mut() {
|
for (client_id, client) in session.clients.iter_mut() {
|
||||||
let terminal_control =
|
|
||||||
output.bytes.contains(&0x1b) || session.parser.screen().alternate_screen();
|
|
||||||
if !terminal_control
|
|
||||||
&& !frame_interval.is_zero()
|
|
||||||
&& now.duration_since(client.last_frame_sent) < frame_interval
|
|
||||||
{
|
|
||||||
client.paced_snapshot_due = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
client.send_seq += 1;
|
client.send_seq += 1;
|
||||||
// Count traffic toward the packet-count rekey trigger (spec §11).
|
// Count traffic toward the packet-count rekey trigger (spec §11).
|
||||||
client.epoch_packets = client.epoch_packets.saturating_add(1);
|
client.epoch_packets = client.epoch_packets.saturating_add(1);
|
||||||
@@ -3441,8 +3409,6 @@ async fn broadcast_output(
|
|||||||
last_sent: Instant::now(),
|
last_sent: Instant::now(),
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
});
|
});
|
||||||
client.last_frame_sent = now;
|
|
||||||
client.paced_snapshot_due = false;
|
|
||||||
sends.push((client.endpoint, packet));
|
sends.push((client.endpoint, packet));
|
||||||
}
|
}
|
||||||
sends
|
sends
|
||||||
@@ -3626,76 +3592,6 @@ async fn retransmit_pending(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn flush_paced_snapshots(
|
|
||||||
state: &Arc<Mutex<ServerState>>,
|
|
||||||
socket: &Arc<UdpSocket>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let sends = {
|
|
||||||
let mut locked = state.lock().expect("server state poisoned");
|
|
||||||
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
|
|
||||||
if frame_interval.is_zero() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let retransmit_window = locked.config.retransmit_window;
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut sends = Vec::new();
|
|
||||||
for (session_name, session) in locked.sessions.iter_mut() {
|
|
||||||
let due = session.clients.values().any(|client| {
|
|
||||||
client.paced_snapshot_due
|
|
||||||
&& now.duration_since(client.last_frame_sent) >= frame_interval
|
|
||||||
});
|
|
||||||
if !due {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let snapshot = screen_snapshot(session.parser.screen());
|
|
||||||
let output_seq = session.output_seq;
|
|
||||||
for (client_id, client) in session.clients.iter_mut() {
|
|
||||||
if !client.paced_snapshot_due
|
|
||||||
|| now.duration_since(client.last_frame_sent) < frame_interval
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
client.send_seq += 1;
|
|
||||||
client.epoch_packets = client.epoch_packets.saturating_add(1);
|
|
||||||
let frame = Frame {
|
|
||||||
session: session_name.clone(),
|
|
||||||
output_seq,
|
|
||||||
bytes: snapshot.clone(),
|
|
||||||
snapshot: true,
|
|
||||||
closed: false,
|
|
||||||
};
|
|
||||||
let body = protocol::to_body(&frame)?;
|
|
||||||
let packet = protocol::encode_encrypted(
|
|
||||||
PacketKind::Frame,
|
|
||||||
*client_id,
|
|
||||||
client.send_seq,
|
|
||||||
client.last_acked,
|
|
||||||
&client.session_key,
|
|
||||||
SERVER_TO_CLIENT,
|
|
||||||
&body,
|
|
||||||
)?;
|
|
||||||
while client.pending.len() >= retransmit_window {
|
|
||||||
client.pending.pop_front();
|
|
||||||
}
|
|
||||||
client.pending.push_back(PendingFrame {
|
|
||||||
output_seq,
|
|
||||||
packet: packet.clone(),
|
|
||||||
last_sent: now,
|
|
||||||
attempts: 0,
|
|
||||||
});
|
|
||||||
client.last_frame_sent = now;
|
|
||||||
client.paced_snapshot_due = false;
|
|
||||||
sends.push((client.endpoint, packet));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sends
|
|
||||||
};
|
|
||||||
for (endpoint, packet) in sends {
|
|
||||||
socket.send_to(&packet, endpoint).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long the previous epoch's key is retained after a rekey so in-flight
|
/// How long the previous epoch's key is retained after a rekey so in-flight
|
||||||
/// pre-rekey packets still decrypt instead of being dropped as fatal.
|
/// pre-rekey packets still decrypt instead of being dropped as fatal.
|
||||||
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
|
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
|
||||||
@@ -4077,8 +3973,6 @@ mod tests {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
|
||||||
paced_snapshot_due: false,
|
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -4395,8 +4289,6 @@ mod tests {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
|
||||||
paced_snapshot_due: false,
|
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -4491,8 +4383,6 @@ mod tests {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
|
||||||
paced_snapshot_due: false,
|
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::from([42]),
|
opened_streams: HashSet::from([42]),
|
||||||
@@ -4562,7 +4452,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn output_pacing_coalesces_fast_frames_into_snapshot() {
|
async fn live_terminal_output_is_never_replaced_by_paced_snapshots() {
|
||||||
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
|
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
|
||||||
let config = ServerConfig {
|
let config = ServerConfig {
|
||||||
output_frame_interval_ms: 1000,
|
output_frame_interval_ms: 1000,
|
||||||
@@ -4614,25 +4504,6 @@ mod tests {
|
|||||||
let frame: Frame = protocol::from_body(&plain).unwrap();
|
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||||
assert!(!frame.snapshot);
|
assert!(!frame.snapshot);
|
||||||
assert_eq!(frame.bytes, b"first");
|
assert_eq!(frame.bytes, b"first");
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(30), receiver.recv_from(&mut buf))
|
|
||||||
.await
|
|
||||||
.is_err(),
|
|
||||||
"second frame should be coalesced"
|
|
||||||
);
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut locked = state.lock().unwrap();
|
|
||||||
let client = locked
|
|
||||||
.sessions
|
|
||||||
.get_mut("test")
|
|
||||||
.unwrap()
|
|
||||||
.clients
|
|
||||||
.get_mut(&client_id)
|
|
||||||
.unwrap();
|
|
||||||
client.last_frame_sent = Instant::now() - Duration::from_secs(2);
|
|
||||||
}
|
|
||||||
flush_paced_snapshots(&state, &sender).await.unwrap();
|
|
||||||
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
|
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -4640,8 +4511,9 @@ mod tests {
|
|||||||
let packet = protocol::decode(&buf[..n]).unwrap();
|
let packet = protocol::decode(&buf[..n]).unwrap();
|
||||||
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
|
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
|
||||||
let frame: Frame = protocol::from_body(&plain).unwrap();
|
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||||
assert!(frame.snapshot);
|
assert!(!frame.snapshot);
|
||||||
assert_eq!(frame.output_seq, 2);
|
assert_eq!(frame.output_seq, 2);
|
||||||
|
assert_eq!(frame.bytes, b"second");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ persist_sessions = false
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_server_config_with_output_interval(
|
||||||
|
dir: &tempfile::TempDir,
|
||||||
|
port: u16,
|
||||||
|
output_frame_interval_ms: u64,
|
||||||
|
) -> std::path::PathBuf {
|
||||||
|
let config = write_server_config(dir, port);
|
||||||
|
let mut raw = fs::read_to_string(&config).unwrap();
|
||||||
|
raw.push_str(&format!(
|
||||||
|
"output_frame_interval_ms = {output_frame_interval_ms}\n"
|
||||||
|
));
|
||||||
|
fs::write(&config, raw).unwrap();
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
|
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
|
||||||
let server = env!("CARGO_BIN_EXE_dosh-server");
|
let server = env!("CARGO_BIN_EXE_dosh-server");
|
||||||
let child = Command::new(server)
|
let child = Command::new(server)
|
||||||
@@ -1639,6 +1653,47 @@ fn unicode_output_survives_transport() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config_with_output_interval(&dir, port, 1000);
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
|
||||||
|
|
||||||
|
let graph = "DOSH_GRAPH ⣀⣤⣶⣿ █▇▆▅▄▃▂▁ ╭╮╯╰";
|
||||||
|
let input = Input {
|
||||||
|
bytes: format!(
|
||||||
|
"printf '\\033[?1049h\\033[?25l'; for i in 1 2 3 4 5; do printf '\\033[H{} %s\\n' \"$i\"; done; printf '\\033[?25h\\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 text = collect_frame_text(&socket, &bootstrap.session_key, 3000);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
text.contains(graph) && text.matches("DOSH_GRAPH").count() >= 5,
|
||||||
|
"expected repeated raw graph glyph frames, got {text:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("\x1b[?25l") && text.contains("\x1b[?25h"),
|
||||||
|
"expected cursor visibility controls to survive around graph repaint, got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
|
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user