From 80166eae1538a0c809eb2f391b19b4c1e683fec8 Mon Sep 17 00:00:00 2001
From: DuProcess <273172371+DuProcess@users.noreply.github.com>
Date: Sat, 13 Jun 2026 10:20:03 -0400
Subject: [PATCH] Keep Dosh alive across stale UDP reconnects
---
README.md | 4 +
SPEC.md | 4 +-
install.sh | 2 +-
src/bin/dosh-client.rs | 216 +++++++++++++++++++++++++++++------------
src/config.rs | 2 +-
5 files changed, 161 insertions(+), 67 deletions(-)
diff --git a/README.md b/README.md
index 3b9bedb..2d03663 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,10 @@ first usable terminal screen.
measured UDP RTT.
- New session: measured separately because it may need PTY and shell creation.
+Server-side client resume state is kept for a day by default, so a sleeping laptop
+can resume the same encrypted client association without depending on a still-valid
+attach ticket. Attach tickets remain the fast path for fresh client processes.
+
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
UDP ticket attach, and terminal-ready time.
diff --git a/SPEC.md b/SPEC.md
index 1390a1b..dc82a69 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -391,6 +391,8 @@ View-only mode:
Client timeout:
- Clients are removed after `client_timeout_secs` without ack/ping.
+- The default is intentionally long enough for laptop sleep/resume, currently one
+ day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep.
- Removing a client never kills the session.
---
@@ -437,7 +439,7 @@ scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
-client_timeout_secs = 30
+client_timeout_secs = 86400
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
diff --git a/install.sh b/install.sh
index d4cead1..5e99b6b 100755
--- a/install.sh
+++ b/install.sh
@@ -206,7 +206,7 @@ scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
-client_timeout_secs = 30
+client_timeout_secs = 86400
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs
index 64ccd4b..a054e62 100644
--- a/src/bin/dosh-client.rs
+++ b/src/bin/dosh-client.rs
@@ -164,63 +164,64 @@ async fn main() -> Result<()> {
cached.udp_host = target_udp_host.clone();
cached.udp_port = dosh_port;
log_timing(args.verbose, "credential_lookup_end", started.elapsed());
- match try_resume(&socket, &cached, cols, rows).await {
- Ok((frame, cred)) => {
- log_timing(args.verbose, "udp_resume_ready", started.elapsed());
- if !args.no_cache {
+ if config.cache_attach_tickets && !args.no_cache {
+ match try_ticket_attach(&socket, &cached, cols, rows).await {
+ Ok((frame, cred)) => {
+ log_timing(args.verbose, "udp_ticket_attach_ready", started.elapsed());
save_cache(&cache_path, &cred)?;
- }
- if args.attach_only {
- render_frame(&frame)?;
- detach_once(&socket, &cred, 2).await?;
- return Ok(());
- }
- return run_terminal(
- socket,
- cred,
- Some(frame),
- predict,
- startup_command,
- config.reconnect_timeout_secs,
- )
- .await;
- }
- Err(err) => {
- log_debug(
- args.verbose,
- 2,
- &format!("dosh resume failed, trying ticket attach before SSH: {err:#}"),
- );
- if config.cache_attach_tickets && !args.no_cache {
- match try_ticket_attach(&socket, &cached, cols, rows).await {
- Ok((frame, cred)) => {
- log_timing(args.verbose, "udp_ticket_attach_ready", started.elapsed());
- save_cache(&cache_path, &cred)?;
- if args.attach_only {
- render_frame(&frame)?;
- detach_once(&socket, &cred, 2).await?;
- return Ok(());
- }
- return run_terminal(
- socket,
- cred,
- Some(frame),
- predict,
- startup_command,
- config.reconnect_timeout_secs,
- )
- .await;
- }
- Err(err) => {
- log_debug(
- args.verbose,
- 2,
- &format!(
- "dosh ticket attach failed, falling back to SSH bootstrap: {err:#}"
- ),
- );
- }
+ if args.attach_only {
+ render_frame(&frame)?;
+ detach_once(&socket, &cred, 2).await?;
+ return Ok(());
}
+ return run_terminal(
+ socket,
+ cred,
+ Some(frame),
+ predict,
+ startup_command,
+ config.reconnect_timeout_secs,
+ )
+ .await;
+ }
+ Err(err) => {
+ log_debug(
+ args.verbose,
+ 2,
+ &format!(
+ "dosh ticket attach failed, falling back to SSH bootstrap: {err:#}"
+ ),
+ );
+ }
+ }
+ } else {
+ match try_resume_fresh_process(&socket, &cached, cols, rows).await {
+ Ok((frame, cred)) => {
+ log_timing(args.verbose, "udp_resume_ready", started.elapsed());
+ if !args.no_cache {
+ save_cache(&cache_path, &cred)?;
+ }
+ if args.attach_only {
+ render_frame(&frame)?;
+ detach_once(&socket, &cred, 2).await?;
+ return Ok(());
+ }
+ return run_terminal(
+ socket,
+ cred,
+ Some(frame),
+ predict,
+ startup_command,
+ config.reconnect_timeout_secs,
+ )
+ .await;
+ }
+ Err(err) => {
+ log_debug(
+ args.verbose,
+ 2,
+ &format!("dosh resume failed, falling back to SSH bootstrap: {err:#}"),
+ );
}
}
}
@@ -745,7 +746,7 @@ async fn try_ticket_attach(
Ok((frame, next))
}
-async fn try_resume(
+async fn try_resume_fresh_process(
socket: &UdpSocket,
cached: &CachedCredential,
cols: u16,
@@ -787,6 +788,53 @@ async fn try_resume(
Ok((frame, next))
}
+async fn try_live_resume(
+ socket: &UdpSocket,
+ cached: &CachedCredential,
+ send_seq: &mut u64,
+ cols: u16,
+ rows: u16,
+) -> Result<(Frame, CachedCredential)> {
+ let addr = resolve_addr(&cached.udp_host, cached.udp_port)?;
+ let req = ResumeRequest {
+ session: cached.session.clone(),
+ last_rendered_seq: cached.last_rendered_seq,
+ cols,
+ rows,
+ };
+ let body = protocol::to_body(&req)?;
+ let packet = protocol::encode_encrypted(
+ PacketKind::ResumeRequest,
+ cached.client_id,
+ *send_seq,
+ cached.last_rendered_seq,
+ &cached.session_key,
+ CLIENT_TO_SERVER,
+ &body,
+ )?;
+ *send_seq += 1;
+ socket.send_to(&packet, addr).await?;
+ let mut buf = vec![0u8; 65535];
+ let (n, _) =
+ tokio::time::timeout(Duration::from_millis(700), socket.recv_from(&mut buf)).await??;
+ let packet = protocol::decode(&buf[..n])?;
+ if packet.header.conn_id != cached.client_id {
+ return Err(anyhow!("resume received stale client packet"));
+ }
+ if packet.header.kind != PacketKind::ResumeOk {
+ if packet.header.kind == PacketKind::AttachReject {
+ let reject: AttachReject = protocol::from_body(&packet.body)?;
+ return Err(anyhow!("resume rejected: {}", reject.reason));
+ }
+ return Err(anyhow!("resume rejected"));
+ }
+ let plain = protocol::decrypt_body(&packet, &cached.session_key, SERVER_TO_CLIENT)?;
+ let frame: Frame = protocol::from_body(&plain)?;
+ let mut next = cached.clone();
+ next.last_rendered_seq = frame.output_seq;
+ Ok((frame, next))
+}
+
async fn run_terminal(
socket: UdpSocket,
mut cred: CachedCredential,
@@ -865,12 +913,35 @@ async fn run_terminal(
}
recv = socket.recv_from(&mut recv_buf) => {
let (n, _) = recv?;
- last_packet_at = Instant::now();
- let packet = protocol::decode(&recv_buf[..n])?;
+ let Ok(packet) = protocol::decode(&recv_buf[..n]) else {
+ continue;
+ };
+ if packet.header.conn_id != [0u8; 16] && packet.header.conn_id != cred.client_id {
+ continue;
+ }
match packet.header.kind {
PacketKind::Frame | PacketKind::ResumeOk => {
- let plain = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)?;
- let frame: Frame = protocol::from_body(&plain)?;
+ let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
+ if let Some(frame) = reconnect(
+ &socket,
+ &mut cred,
+ &mut send_seq,
+ last_size,
+ &mut frame_buffer,
+ &mut predictor,
+ )
+ .await?
+ {
+ render_frame(&frame)?;
+ predictor.observe_output(&frame.bytes);
+ last_packet_at = Instant::now();
+ }
+ continue;
+ };
+ let Ok(frame) = protocol::from_body::(&plain) else {
+ continue;
+ };
+ last_packet_at = Instant::now();
let frames = frame_buffer.accept(frame, &mut cred.last_rendered_seq);
for frame in frames {
predictor.clear_pending()?;
@@ -883,11 +954,13 @@ async fn run_terminal(
}
send_ack(&socket, addr, &cred, &mut send_seq).await?;
}
- PacketKind::Pong => {}
+ PacketKind::Pong => {
+ last_packet_at = Instant::now();
+ }
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body)?;
if reject.reason == "unknown client" {
- if let Some(frame) = reconnect_with_ticket(
+ if let Some(frame) = reconnect(
&socket,
&mut cred,
&mut send_seq,
@@ -911,7 +984,7 @@ async fn run_terminal(
_ = status_tick.tick() => {
let stale = last_packet_at.elapsed();
if stale >= Duration::from_secs(reconnect_timeout_secs.max(1)) {
- if let Some(frame) = reconnect_with_ticket(
+ if let Some(frame) = reconnect(
&socket,
&mut cred,
&mut send_seq,
@@ -945,7 +1018,7 @@ async fn run_terminal(
Ok(())
}
-async fn reconnect_with_ticket(
+async fn reconnect(
socket: &UdpSocket,
cred: &mut CachedCredential,
send_seq: &mut u64,
@@ -953,6 +1026,21 @@ async fn reconnect_with_ticket(
frame_buffer: &mut FrameBuffer,
predictor: &mut Predictor,
) -> Result