Add fresh default sessions and terminal status handling
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
Codex
2026-06-11 09:41:46 -04:00
parent 92f43df046
commit 0eca4f1cf4
10 changed files with 236 additions and 19 deletions
+7 -1
View File
@@ -118,13 +118,19 @@ Attach:
dosh palav
```
Use named sessions:
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want
to reattach to the same persistent terminal from multiple clients:
```bash
dosh --session work palav
dosh --session logs palav
```
Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh shows a blue status bar at the top of the
terminal with the disconnected duration and sends keepalive pings until traffic
returns.
If SSH and UDP use different public names, specify the UDP address:
+6 -9
View File
@@ -321,14 +321,9 @@ Reliability:
Named sessions:
```bash
dosh # attach default
dosh attach # attach default
dosh attach work
dosh attach work --view-only
dosh new work
dosh list
dosh list-clients [session]
dosh kill work
dosh host # open a fresh generated session
dosh --session work host # attach/create named persistent session
dosh --session work --view-only host
```
Session behavior:
@@ -336,6 +331,8 @@ Session behavior:
- One PTY per session.
- Sessions persist until killed or server exits.
- If a session has zero clients, the PTY keeps running.
- Plain client attaches use generated session names by default.
- `--session <name>` intentionally reuses or shares a persistent session.
- Configured sessions are prewarmed at daemon startup.
- If a requested session does not exist:
- `attach` creates it only when `create_on_attach = true`.
@@ -457,7 +454,7 @@ server = "user@example.com"
ssh_auth_command = "~/.local/bin/dosh-auth"
# ssh_port = 22
dosh_port = 50000
default_session = "default"
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
cache_attach_tickets = true
+1 -1
View File
@@ -54,7 +54,7 @@ $doshHostLine
ssh_auth_command = "~/.local/bin/dosh-auth"
# ssh_port = 22
dosh_port = $Port
default_session = "default"
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
cache_attach_tickets = true
+1 -1
View File
@@ -218,7 +218,7 @@ $dosh_host_line
ssh_auth_command = "~/.local/bin/dosh-auth"
# ssh_port = 22
dosh_port = $port
default_session = "default"
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
cache_attach_tickets = true
+80 -4
View File
@@ -18,7 +18,7 @@ use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, ToSocketAddrs};
use std::process::Command;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
@@ -27,8 +27,10 @@ use tokio::sync::mpsc;
struct Args {
#[arg()]
server: Option<String>,
#[arg(long, default_value = "default")]
session: String,
#[arg(long)]
session: Option<String>,
#[arg(long)]
new: bool,
#[arg(long)]
view_only: bool,
#[arg(long)]
@@ -73,7 +75,7 @@ async fn main() -> Result<()> {
let args = Args::parse();
let config = load_client_config(None).unwrap_or_default();
let server = args.server.unwrap_or(config.server);
let session = args.session;
let session = select_session(args.session.as_deref(), args.new, &config.default_session);
let mode = if args.view_only || config.view_only {
"view-only"
} else {
@@ -201,6 +203,7 @@ async fn main() -> Result<()> {
output_seq: ok.initial_seq,
bytes: ok.snapshot,
snapshot: true,
closed: false,
};
if args.attach_only {
render_frame(&first)?;
@@ -235,6 +238,24 @@ fn local_bootstrap(
)
}
fn select_session(requested: Option<&str>, force_new: bool, configured_default: &str) -> String {
if let Some(session) = requested {
return session.to_string();
}
if force_new || configured_default == "new" || configured_default.is_empty() {
return unique_session_name();
}
configured_default.to_string()
}
fn unique_session_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("term-{millis}-{}", std::process::id())
}
fn ssh_bootstrap(
server: &str,
ssh_port: Option<u16>,
@@ -426,6 +447,7 @@ async fn try_ticket_attach(
output_seq: ok.initial_seq,
bytes: ok.snapshot.clone(),
snapshot: true,
closed: false,
};
let mut next = cached.clone();
next.client_id = ok.client_id;
@@ -481,8 +503,14 @@ async fn run_terminal(
let _raw = RawMode::enter()?;
let addr = resolve_addr(&cred.udp_host, cred.udp_port)?;
let mut send_seq = 2u64;
let mut last_packet_at = Instant::now();
let mut status_visible = false;
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
if let Some(frame) = first_frame {
render_frame(&frame)?;
if frame.closed {
return Ok(());
}
cred.last_rendered_seq = frame.output_seq;
send_ack(&socket, addr, &cred, &mut send_seq).await?;
}
@@ -528,6 +556,11 @@ async fn run_terminal(
}
recv = socket.recv_from(&mut recv_buf) => {
let (n, _) = recv?;
last_packet_at = Instant::now();
if status_visible {
clear_status_bar()?;
status_visible = false;
}
let packet = protocol::decode(&recv_buf[..n])?;
match packet.header.kind {
PacketKind::Frame | PacketKind::ResumeOk => {
@@ -536,11 +569,36 @@ async fn run_terminal(
render_frame(&frame)?;
cred.last_rendered_seq = frame.output_seq;
send_ack(&socket, addr, &cred, &mut send_seq).await?;
if frame.closed {
break;
}
}
PacketKind::Pong => {}
_ => {}
}
}
_ = status_tick.tick() => {
let stale = last_packet_at.elapsed();
if stale >= Duration::from_secs(2) {
draw_status_bar(&format!(
"DOSH disconnected for {}s | session {} | trying UDP",
stale.as_secs(),
cred.session
))?;
status_visible = true;
let packet = protocol::encode_encrypted(
PacketKind::Ping,
cred.client_id,
send_seq,
cred.last_rendered_seq,
&cred.session_key,
CLIENT_TO_SERVER,
b"",
)?;
send_seq += 1;
socket.send_to(&packet, addr).await?;
}
}
}
}
Ok(())
@@ -588,6 +646,24 @@ fn render_frame(frame: &Frame) -> Result<()> {
Ok(())
}
fn draw_status_bar(message: &str) -> Result<()> {
let mut stdout = std::io::stdout();
write!(
stdout,
"\x1b7\x1b[1;1H\x1b[44;97m\x1b[K {} \x1b[0m\x1b8",
message
)?;
stdout.flush()?;
Ok(())
}
fn clear_status_bar() -> Result<()> {
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b7\x1b[1;1H\x1b[K\x1b8")?;
stdout.flush()?;
Ok(())
}
fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf {
let safe = format!("{server}_{session}_{mode}")
.chars()
+48
View File
@@ -465,6 +465,7 @@ async fn handle_resume(
output_seq,
bytes: snapshot,
snapshot: true,
closed: false,
};
let body = protocol::to_body(&frame)?;
let out = protocol::encode_encrypted(
@@ -614,6 +615,10 @@ async fn broadcast_output(
socket: &Arc<UdpSocket>,
output: PtyOutput,
) -> Result<()> {
if output.exited {
return broadcast_session_exit(state, socket, output.session).await;
}
let sends = {
let mut locked = state.lock().expect("server state poisoned");
let scrollback = locked.config.scrollback;
@@ -652,6 +657,7 @@ async fn broadcast_output(
output_seq,
bytes,
snapshot,
closed: false,
};
let body = protocol::to_body(&frame)?;
let packet = protocol::encode_encrypted(
@@ -683,6 +689,48 @@ async fn broadcast_output(
Ok(())
}
async fn broadcast_session_exit(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
session_name: String,
) -> Result<()> {
let sends = {
let mut locked = state.lock().expect("server state poisoned");
let Some(mut session) = locked.sessions.remove(&session_name) else {
return Ok(());
};
session.output_seq += 1;
let output_seq = session.output_seq;
let mut sends = Vec::new();
for (client_id, mut client) in session.clients.drain() {
client.send_seq += 1;
let frame = Frame {
session: session_name.clone(),
output_seq,
bytes: b"\r\n[dosh session exited]\r\n".to_vec(),
snapshot: false,
closed: true,
};
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,
)?;
sends.push((client.endpoint, packet));
}
sends
};
for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?;
}
Ok(())
}
async fn retransmit_pending(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
+1 -1
View File
@@ -64,7 +64,7 @@ impl Default for ClientConfig {
ssh_auth_command: Some("~/.local/bin/dosh-auth".to_string()),
ssh_port: None,
dosh_port: 50000,
default_session: "default".to_string(),
default_session: "new".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
cache_attach_tickets: true,
+1
View File
@@ -255,6 +255,7 @@ pub struct Frame {
pub output_seq: u64,
pub bytes: Vec<u8>,
pub snapshot: bool,
pub closed: bool,
}
pub fn to_body<T: Serialize>(value: &T) -> Result<Vec<u8>> {
+18 -2
View File
@@ -34,6 +34,7 @@ impl PtyHandle {
pub struct PtyOutput {
pub session: String,
pub bytes: Vec<u8>,
pub exited: bool,
}
pub fn spawn_pty_session(
@@ -75,14 +76,29 @@ pub fn spawn_pty_session(
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(0) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: Vec::new(),
exited: true,
});
break;
}
Ok(n) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: buf[..n].to_vec(),
exited: false,
});
}
Err(_) => break,
Err(_) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: Vec::new(),
exited: true,
});
break;
}
}
}
})
+73
View File
@@ -68,6 +68,8 @@ fn attach_once(dir: &tempfile::TempDir, port: u16, cache: bool) -> std::process:
let mut cmd = Command::new(client);
cmd.arg("--local-auth")
.arg("--attach-only")
.arg("--session")
.arg("default")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
@@ -388,6 +390,77 @@ fn resize_updates_pty_size() {
);
}
#[test]
fn pty_exit_sends_closed_frame() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"exit\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut closed = false;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.closed {
closed = true;
break;
}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(closed, "expected closed frame after shell exit");
}
#[test]
fn pty_sets_terminal_environment() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf \"$TERM\\n\"\n".to_vec(),
};
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, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("xterm-256color"),
"expected TERM=xterm-256color, got {text:?}"
);
}
#[test]
fn resume_updates_udp_endpoint_for_roaming() {
let dir = tempfile::tempdir().unwrap();