Compare commits

...

2 Commits

Author SHA1 Message Date
DuProcess a48aa15308 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
2026-07-02 18:34:09 -04:00
DuProcess 691b58e531 Harden update and release tooling
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
2026-07-01 19:48:46 -04:00
9 changed files with 122 additions and 153 deletions
Generated
+1 -1
View File
@@ -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
View File
@@ -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"
+1 -5
View File
@@ -12,11 +12,7 @@ start_server=1
force_config=0 force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}" update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}" quiet="${DOSH_UPDATE_QUIET:-0}"
if [ "${DOSH_UPDATE_QUIET:-0}" = "1" ] && [ "${DOSH_BINARY_REQUIRED:-0}" != "1" ]; then use_prebuilt="${DOSH_USE_PREBUILT:-1}"
use_prebuilt=0
else
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
fi
binary_url="${DOSH_BINARY_URL:-}" binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}" binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}" binary_name="${DOSH_BINARY_NAME:-}"
+20
View File
@@ -99,6 +99,21 @@ fi
web_repo="${base%/}/${repo}" web_repo="${base%/}/${repo}"
failed=0 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 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 [ -f "$artifact" ] || continue
name="$(basename "$artifact")" name="$(basename "$artifact")"
@@ -107,6 +122,11 @@ for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh
continue continue
;; ;;
esac 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" url="$web_repo/releases/download/$tag/$name"
if curl -fsSI "$url" >/dev/null; then if curl -fsSI "$url" >/dev/null; then
echo "verified $url" echo "verified $url"
+1
View File
@@ -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
+20 -15
View File
@@ -32,6 +32,21 @@ token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}" title="${GITEA_TITLE:-$tag}"
expected_version="${tag#v}" 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 if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")" token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
fi fi
@@ -52,6 +67,11 @@ if [ "$#" -eq 0 ]; then
continue continue
;; ;;
esac 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 if [ -f "$artifact.sha256" ]; then
set -- "$@" "$artifact" "$artifact.sha256" set -- "$@" "$artifact" "$artifact.sha256"
else else
@@ -103,21 +123,6 @@ delete_existing_asset() {
done 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() { verify_release_artifact_version() {
artifact="$1" artifact="$1"
name="$(basename "$artifact")" name="$(basename "$artifact")"
+3 -131
View File
@@ -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]
+55
View File
@@ -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();
+20
View File
@@ -0,0 +1,20 @@
#[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)\""));
}