Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b393c0e5d5 | |||
| d5bc8e3c64 | |||
| c40f5459ba | |||
| 601510687e | |||
| 60387e9222 | |||
| a48aa15308 | |||
| 691b58e531 |
Generated
+1
-1
@@ -436,7 +436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dosh"
|
||||
version = "0.1.7"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dosh"
|
||||
version = "0.1.7"
|
||||
version = "0.1.12"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
|
||||
|
||||
+19
-5
@@ -141,6 +141,20 @@ function Verify-ArchiveVersion($ExtractDir, $Url) {
|
||||
}
|
||||
}
|
||||
|
||||
function Install-Binary($Source, $Destination) {
|
||||
$dir = Split-Path -Parent $Destination
|
||||
$name = Split-Path -Leaf $Destination
|
||||
$tmp = Join-Path $dir ".$name.tmp.$PID"
|
||||
Copy-Item $Source $tmp -Force
|
||||
try {
|
||||
Move-Item $tmp $Destination -Force
|
||||
}
|
||||
catch {
|
||||
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
$bindir = Join-Path $Prefix "bin"
|
||||
$configDir = Join-Path $HOME ".config\dosh"
|
||||
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
||||
@@ -168,9 +182,9 @@ function Install-Prebuilt {
|
||||
if (-not $found) {
|
||||
throw "prebuilt archive missing $bin"
|
||||
}
|
||||
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
|
||||
Install-Binary $found.FullName (Join-Path $bindir $bin)
|
||||
}
|
||||
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
|
||||
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
@@ -202,9 +216,9 @@ function Install-FromSource {
|
||||
try {
|
||||
Push-Location $src
|
||||
cargo build --release --bin dosh-client --bin dosh-bench
|
||||
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
|
||||
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
|
||||
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
|
||||
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
|
||||
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
|
||||
Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
|
||||
+21
-12
@@ -12,11 +12,7 @@ start_server=1
|
||||
force_config=0
|
||||
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
|
||||
quiet="${DOSH_UPDATE_QUIET:-0}"
|
||||
if [ "${DOSH_UPDATE_QUIET:-0}" = "1" ] && [ "${DOSH_BINARY_REQUIRED:-0}" != "1" ]; then
|
||||
use_prebuilt=0
|
||||
else
|
||||
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
|
||||
fi
|
||||
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
|
||||
binary_url="${DOSH_BINARY_URL:-}"
|
||||
binary_base="${DOSH_BINARY_BASE:-}"
|
||||
binary_name="${DOSH_BINARY_NAME:-}"
|
||||
@@ -230,13 +226,26 @@ find_extracted_binary() {
|
||||
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
src="$1"
|
||||
dst="$2"
|
||||
dst_dir="$(dirname "$dst")"
|
||||
dst_base="$(basename "$dst")"
|
||||
tmp="$dst_dir/.$dst_base.tmp.$$"
|
||||
install -m 0755 "$src" "$tmp"
|
||||
if ! mv -f "$tmp" "$dst"; then
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_extracted_binary() {
|
||||
found="$(find_extracted_binary "$1" "$2")"
|
||||
if [ -z "$found" ]; then
|
||||
echo "prebuilt archive missing $2" >&2
|
||||
return 1
|
||||
fi
|
||||
install -m 0755 "$found" "$3"
|
||||
install_binary "$found" "$3"
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
@@ -333,7 +342,7 @@ try_install_prebuilt() {
|
||||
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
|
||||
fi
|
||||
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
|
||||
install -m 0755 "$found_bench" "$bindir/dosh-bench"
|
||||
install_binary "$found_bench" "$bindir/dosh-bench"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@@ -392,14 +401,14 @@ install_from_source() {
|
||||
fi
|
||||
fi
|
||||
|
||||
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
|
||||
install_binary target/release/dosh-client "$bindir/dosh-client"
|
||||
ln -sf dosh-client "$bindir/dosh"
|
||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
|
||||
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
|
||||
install_binary target/release/dosh-server "$bindir/dosh-server"
|
||||
install_binary target/release/dosh-auth "$bindir/dosh-auth"
|
||||
fi
|
||||
if [ -f target/release/dosh-bench ]; then
|
||||
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
|
||||
install_binary target/release/dosh-bench "$bindir/dosh-bench"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -425,7 +434,7 @@ PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=$config_dir $data_dir
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
|
||||
@@ -14,7 +14,7 @@ PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
|
||||
@@ -99,6 +99,21 @@ fi
|
||||
|
||||
web_repo="${base%/}/${repo}"
|
||||
failed=0
|
||||
artifact_version() {
|
||||
artifact="$1"
|
||||
case "$artifact" in
|
||||
*.tar.gz)
|
||||
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||
;;
|
||||
*.zip)
|
||||
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh-macos-*.tar.gz target/dosh-release/dosh-windows-*.zip; do
|
||||
[ -f "$artifact" ] || continue
|
||||
name="$(basename "$artifact")"
|
||||
@@ -107,6 +122,11 @@ for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
actual="$(artifact_version "$artifact" || true)"
|
||||
if [ "$actual" != "$version" ]; then
|
||||
echo "skipping stale artifact $name: version ${actual:-unknown}, expected $version" >&2
|
||||
continue
|
||||
fi
|
||||
url="$web_repo/releases/download/$tag/$name"
|
||||
if curl -fsSI "$url" >/dev/null; then
|
||||
echo "verified $url"
|
||||
|
||||
@@ -8,6 +8,8 @@ for test_name in \
|
||||
live_output_forwards_terminal_control_sequences \
|
||||
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
|
||||
|
||||
@@ -32,6 +32,21 @@ token="${GITEA_TOKEN:-}"
|
||||
title="${GITEA_TITLE:-$tag}"
|
||||
expected_version="${tag#v}"
|
||||
|
||||
artifact_version() {
|
||||
artifact="$1"
|
||||
case "$artifact" in
|
||||
*.tar.gz)
|
||||
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||
;;
|
||||
*.zip)
|
||||
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
|
||||
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
|
||||
fi
|
||||
@@ -52,6 +67,11 @@ if [ "$#" -eq 0 ]; then
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
actual="$(artifact_version "$artifact" || true)"
|
||||
if [ "$actual" != "$expected_version" ]; then
|
||||
echo "skipping $artifact: version ${actual:-unknown}, expected $expected_version" >&2
|
||||
continue
|
||||
fi
|
||||
if [ -f "$artifact.sha256" ]; then
|
||||
set -- "$@" "$artifact" "$artifact.sha256"
|
||||
else
|
||||
@@ -103,21 +123,6 @@ delete_existing_asset() {
|
||||
done
|
||||
}
|
||||
|
||||
artifact_version() {
|
||||
artifact="$1"
|
||||
case "$artifact" in
|
||||
*.tar.gz)
|
||||
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||
;;
|
||||
*.zip)
|
||||
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_release_artifact_version() {
|
||||
artifact="$1"
|
||||
name="$(basename "$artifact")"
|
||||
|
||||
+80
-4
@@ -4271,6 +4271,7 @@ async fn run_terminal(
|
||||
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
|
||||
let mut stream_retransmit_tick = tokio::time::interval(Duration::from_millis(200));
|
||||
let mut frame_gap_tick = tokio::time::interval(Duration::from_millis(100));
|
||||
let mut last_size = terminal_size();
|
||||
// React to terminal resize the instant it happens via SIGWINCH (mosh-style),
|
||||
// instead of waiting up to one `resize_tick`. The 250ms poll below stays as a
|
||||
@@ -4498,6 +4499,36 @@ async fn run_terminal(
|
||||
_ = resize_tick.tick() => {
|
||||
maybe_send_resize(&socket, addr, &cred, &mut send_seq, &mut last_size).await?;
|
||||
}
|
||||
_ = frame_gap_tick.tick() => {
|
||||
if frame_buffer.gap_wait_elapsed(Duration::from_millis(FRAME_GAP_RESYNC_AFTER_MS))
|
||||
&& let Some(frame) = reconnect(
|
||||
&socket,
|
||||
&mut cred,
|
||||
&mut send_seq,
|
||||
last_size,
|
||||
&mut frame_buffer,
|
||||
&mut predictor,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
refresh_live_addr(&mut addr, &cred)?;
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
predictor.observe_output(&frame.bytes);
|
||||
}
|
||||
last_packet_at = Instant::now();
|
||||
flush_pending_user_input(
|
||||
&socket,
|
||||
addr,
|
||||
&cred,
|
||||
&mut send_seq,
|
||||
&mut predictor,
|
||||
&mut pending_user_input,
|
||||
&mut pending_user_input_bytes,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
recv = socket.recv_from(&mut recv_buf) => {
|
||||
let (n, _) = recv?;
|
||||
let Ok(packet) = protocol::decode(&recv_buf[..n]) else {
|
||||
@@ -6561,14 +6592,18 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
|
||||
const FRAME_GAP_RESYNC_AFTER_MS: u64 = 500;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FrameBuffer {
|
||||
pending: BTreeMap<u64, Frame>,
|
||||
gap_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl FrameBuffer {
|
||||
fn clear(&mut self) {
|
||||
self.pending.clear();
|
||||
self.gap_since = None;
|
||||
}
|
||||
|
||||
fn accept(&mut self, frame: Frame, last_rendered_seq: &mut u64) -> Vec<Frame> {
|
||||
@@ -6577,6 +6612,7 @@ impl FrameBuffer {
|
||||
return Vec::new();
|
||||
}
|
||||
self.pending.clear();
|
||||
self.gap_since = None;
|
||||
*last_rendered_seq = frame.output_seq;
|
||||
return vec![frame];
|
||||
}
|
||||
@@ -6590,8 +6626,30 @@ impl FrameBuffer {
|
||||
*last_rendered_seq = frame.output_seq;
|
||||
ready.push(frame);
|
||||
}
|
||||
self.refresh_gap(*last_rendered_seq);
|
||||
ready
|
||||
}
|
||||
|
||||
fn refresh_gap(&mut self, last_rendered_seq: u64) {
|
||||
let waiting_on_missing = self
|
||||
.pending
|
||||
.first_key_value()
|
||||
.is_some_and(|(&seq, _)| seq > last_rendered_seq.saturating_add(1));
|
||||
if waiting_on_missing {
|
||||
self.gap_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
self.gap_since = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn gap_wait_elapsed(&self, wait: Duration) -> bool {
|
||||
self.gap_since.is_some_and(|since| since.elapsed() >= wait)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn backdate_gap_for_test(&mut self, elapsed: Duration) {
|
||||
self.gap_since = Some(Instant::now() - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current terminal size and, if it changed since `last_size`, send a
|
||||
@@ -7003,10 +7061,10 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CachedCredential, DisconnectStatus, DynamicForward, FrameBuffer, LocalForward,
|
||||
MAX_PENDING_USER_INPUT_BYTES, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode,
|
||||
Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig,
|
||||
StartupGateMode, StatusAction, auth_allows, cache_key, cache_server_prefix,
|
||||
CachedCredential, DisconnectStatus, DynamicForward, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer,
|
||||
LocalForward, MAX_PENDING_USER_INPUT_BYTES, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen,
|
||||
PredictMode, Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD,
|
||||
SshConfig, StartupGateMode, StatusAction, auth_allows, cache_key, cache_server_prefix,
|
||||
clear_cached_credentials, ensure_tui_safe_status_overlay, input_matches_escape,
|
||||
is_local_status_target, is_resume_response_for_client, latest_release_download_url,
|
||||
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key,
|
||||
@@ -7425,12 +7483,14 @@ mod tests {
|
||||
|
||||
assert!(buffer.accept(test_frame(12, false), &mut last).is_empty());
|
||||
assert_eq!(last, 10);
|
||||
assert!(!buffer.gap_wait_elapsed(Duration::from_secs(60)));
|
||||
|
||||
let ready = buffer.accept(test_frame(11, false), &mut last);
|
||||
assert_eq!(ready.len(), 2);
|
||||
assert_eq!(ready[0].output_seq, 11);
|
||||
assert_eq!(ready[1].output_seq, 12);
|
||||
assert_eq!(last, 12);
|
||||
assert!(!buffer.gap_wait_elapsed(Duration::from_secs(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7449,14 +7509,30 @@ mod tests {
|
||||
let mut last = 10;
|
||||
|
||||
assert!(buffer.accept(test_frame(12, false), &mut last).is_empty());
|
||||
buffer.backdate_gap_for_test(Duration::from_secs(10));
|
||||
assert!(buffer.gap_wait_elapsed(Duration::from_millis(1)));
|
||||
let ready = buffer.accept(test_frame(20, true), &mut last);
|
||||
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].output_seq, 20);
|
||||
assert_eq!(last, 20);
|
||||
assert!(!buffer.gap_wait_elapsed(Duration::from_millis(1)));
|
||||
assert!(buffer.accept(test_frame(12, false), &mut last).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_buffer_marks_gap_ready_for_resync_after_wait() {
|
||||
let mut buffer = FrameBuffer::default();
|
||||
let mut last = 10;
|
||||
|
||||
assert!(buffer.accept(test_frame(13, false), &mut last).is_empty());
|
||||
assert_eq!(last, 10);
|
||||
assert!(!buffer.gap_wait_elapsed(Duration::from_secs(60)));
|
||||
|
||||
buffer.backdate_gap_for_test(Duration::from_secs(1));
|
||||
assert!(buffer.gap_wait_elapsed(Duration::from_millis(FRAME_GAP_RESYNC_AFTER_MS)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_buffer_ignores_stale_snapshot() {
|
||||
let mut buffer = FrameBuffer::default();
|
||||
|
||||
+13
-146
@@ -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);
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
@@ -400,8 +387,6 @@ struct ClientState {
|
||||
rows: u16,
|
||||
last_seen: Instant,
|
||||
pending: VecDeque<PendingFrame>,
|
||||
last_frame_sent: Instant,
|
||||
paced_snapshot_due: bool,
|
||||
allowed_forwardings: Vec<ForwardingRequest>,
|
||||
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
|
||||
opened_streams: HashSet<u64>,
|
||||
@@ -1167,8 +1152,6 @@ async fn handle_native_user_auth(
|
||||
rows,
|
||||
last_seen: Instant::now(),
|
||||
pending: VecDeque::new(),
|
||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||
paced_snapshot_due: false,
|
||||
allowed_forwardings: req.auth.requested_forwardings.clone(),
|
||||
stream_writers: HashMap::new(),
|
||||
opened_streams: HashSet::new(),
|
||||
@@ -1311,8 +1294,6 @@ async fn handle_bootstrap_attach(
|
||||
rows: req.rows,
|
||||
last_seen: Instant::now(),
|
||||
pending: VecDeque::new(),
|
||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||
paced_snapshot_due: false,
|
||||
allowed_forwardings: Vec::new(),
|
||||
stream_writers: HashMap::new(),
|
||||
opened_streams: HashSet::new(),
|
||||
@@ -1447,8 +1428,6 @@ async fn handle_ticket_attach(
|
||||
rows: req.rows,
|
||||
last_seen: Instant::now(),
|
||||
pending: VecDeque::new(),
|
||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||
paced_snapshot_due: false,
|
||||
allowed_forwardings: Vec::new(),
|
||||
stream_writers: HashMap::new(),
|
||||
opened_streams: HashSet::new(),
|
||||
@@ -3357,9 +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 frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
|
||||
let now = Instant::now();
|
||||
let retransmit_window = locked.config.retransmit_window.max(1);
|
||||
let session = locked
|
||||
.sessions
|
||||
.get_mut(&output.session)
|
||||
@@ -3394,32 +3371,21 @@ async fn broadcast_output(
|
||||
}
|
||||
let mut sends = Vec::new();
|
||||
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;
|
||||
// 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)?;
|
||||
@@ -3432,17 +3398,12 @@ 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(),
|
||||
last_sent: Instant::now(),
|
||||
attempts: 0,
|
||||
});
|
||||
client.last_frame_sent = now;
|
||||
client.paced_snapshot_due = false;
|
||||
sends.push((client.endpoint, packet));
|
||||
}
|
||||
sends
|
||||
@@ -3626,76 +3587,6 @@ async fn retransmit_pending(
|
||||
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
|
||||
/// pre-rekey packets still decrypt instead of being dropped as fatal.
|
||||
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
|
||||
@@ -4077,8 +3968,6 @@ mod tests {
|
||||
rows: 24,
|
||||
last_seen: Instant::now(),
|
||||
pending: VecDeque::new(),
|
||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||
paced_snapshot_due: false,
|
||||
allowed_forwardings: Vec::new(),
|
||||
stream_writers: HashMap::new(),
|
||||
opened_streams: HashSet::new(),
|
||||
@@ -4395,8 +4284,6 @@ mod tests {
|
||||
rows: 24,
|
||||
last_seen: Instant::now(),
|
||||
pending: VecDeque::new(),
|
||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||
paced_snapshot_due: false,
|
||||
allowed_forwardings: Vec::new(),
|
||||
stream_writers: HashMap::new(),
|
||||
opened_streams: HashSet::new(),
|
||||
@@ -4491,8 +4378,6 @@ mod tests {
|
||||
rows: 24,
|
||||
last_seen: Instant::now(),
|
||||
pending: VecDeque::new(),
|
||||
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||
paced_snapshot_due: false,
|
||||
allowed_forwardings: Vec::new(),
|
||||
stream_writers: HashMap::new(),
|
||||
opened_streams: HashSet::from([42]),
|
||||
@@ -4562,7 +4447,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 config = ServerConfig {
|
||||
output_frame_interval_ms: 1000,
|
||||
@@ -4614,25 +4499,6 @@ mod tests {
|
||||
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||
assert!(!frame.snapshot);
|
||||
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))
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -4640,8 +4506,9 @@ mod tests {
|
||||
let packet = protocol::decode(&buf[..n]).unwrap();
|
||||
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
|
||||
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||
assert!(frame.snapshot);
|
||||
assert!(!frame.snapshot);
|
||||
assert_eq!(frame.output_seq, 2);
|
||||
assert_eq!(frame.bytes, b"second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -98,6 +98,35 @@ persist_sessions = false
|
||||
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 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)
|
||||
@@ -1639,6 +1668,110 @@ 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]
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#[test]
|
||||
fn quiet_update_keeps_prebuilt_enabled_by_default() {
|
||||
let install = include_str!("../install.sh");
|
||||
assert!(install.contains("use_prebuilt=\"${DOSH_USE_PREBUILT:-1}\""));
|
||||
assert!(
|
||||
!install.contains("use_prebuilt=0"),
|
||||
"quiet updates must not force source builds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_scripts_skip_stale_artifacts_when_auto_discovering() {
|
||||
let upload = include_str!("../scripts/upload-gitea-release.sh");
|
||||
assert!(upload.contains("skipping $artifact: version"));
|
||||
assert!(upload.contains("expected_version"));
|
||||
|
||||
let publish = include_str!("../scripts/publish-gitea-release.sh");
|
||||
assert!(publish.contains("skipping stale artifact"));
|
||||
assert!(publish.contains("actual=\"$(artifact_version \"$artifact\" || true)\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemd_sandbox_allows_netlink_for_terminal_tools() {
|
||||
let service = include_str!("../packaging/systemd/dosh-server.service");
|
||||
let install = include_str!("../install.sh");
|
||||
for raw in [service, install] {
|
||||
assert!(
|
||||
raw.contains("RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK"),
|
||||
"dosh sessions must allow AF_NETLINK so tools like btop can enumerate network interfaces"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user