fix(maestro): make attach exit cleanly when session ends (NAP-226)
ci / rust (push) Successful in 2m15s

When a session terminates (the user runs `exit`, tmux is killed, or
`maestro kill` is called) the daemon's per-session broadcast channel
closes and `serve_attach` breaks out of its loop. The socket was then
dropped without a Close frame, so the client's `stream.next()` would
hang and the terminal stayed stuck in raw mode.

- daemon: send `Message::Close(None)` before returning from
  `serve_attach` whenever the session itself ended (broadcast closed,
  or session was killed between auth and subscribe).
- client: track remote-close, restore the TTY first, then print
  `[maestro] session ended` so the message lands in cooked mode.
- tests: cover the kill path and the liveness-watcher reap path on the
  daemon side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 08:49:00 +00:00
co-authored by Claude Opus 4.7
parent 91f252b4e2
commit c1b4fe0bb6
3 changed files with 112 additions and 6 deletions
+18 -3
View File
@@ -134,7 +134,7 @@ pub async fn run_attach(
let (ws, _resp) = tokio_tungstenite::connect_async(&url).await?;
let (mut sink, mut stream) = ws.split();
let _raw = RawTty::enable()?;
let raw = RawTty::enable()?;
// Send initial window size so the remote PTY matches our terminal.
if let Some((cols, rows)) = current_winsize() {
@@ -156,12 +156,17 @@ pub async fn run_attach(
"\r\n[maestro] detached (key 0x{detach_byte:02x})\r\n"
));
let mut session_ended = false;
loop {
tokio::select! {
// Server → local stdout
msg = stream.next() => {
match msg {
None => break,
None => {
session_ended = true;
break;
}
Some(Err(e)) => return Err(AttachError::WebSocket(e)),
Some(Ok(Message::Binary(bytes))) => {
stdout.write_all(&bytes).await?;
@@ -177,7 +182,10 @@ pub async fn run_attach(
Some(Ok(Message::Ping(p))) => {
sink.send(Message::Pong(p)).await?;
}
Some(Ok(Message::Close(_))) => break,
Some(Ok(Message::Close(_))) => {
session_ended = true;
break;
}
Some(Ok(_)) => {}
}
}
@@ -212,6 +220,13 @@ pub async fn run_attach(
}
}
// Restore the TTY before printing so the message lands in cooked mode.
drop(raw);
if session_ended {
let _ = stdout.write_all(b"[maestro] session ended\n").await;
let _ = stdout.flush().await;
}
Ok(())
}