From 73dbd7e14abdae0c3101d704a13e325a4be5270f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:34:29 +0800 Subject: [PATCH] fix(ssh): tell a client that attaches late what the connection reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ssh phase is an event, and `replay_state` — which catches a new subscriber up on the cwd, the prompt, the remote context and the exit — did not carry it. A GUI restarted while a session was up therefore learned nothing about it: the tab fell through to the grey "nobody said" dot, on a pane the user could type into and get output back from. The daemon keeps the last phase per pane and replays it on attach. It records it even with no client listening, which is exactly the case that matters: the phase a connection reached while nobody was attached is the one the next client needs. --- crates/tty7-core/src/daemon/pane.rs | 86 ++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 67a05d1b..1fa13dd5 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -11,6 +11,7 @@ use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system} use crate::core::kitty_graphics::{GraphicsSniffer, Segment, Sniffed}; use crate::core::osc::OscTokenizer; +use crate::daemon::protocol::SshPhase; use crate::daemon::protocol::{ AuthResponse, DaemonMsg, MAX_FRAME, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, WinSize, @@ -684,6 +685,13 @@ struct PaneState { /// `shell` above, which is the shell-integration state. shell_spec: Option, remote: Option, + /// The last phase a native ssh connection reported. + /// + /// Kept so a client that attaches later is told: the phase is an event, and + /// a GUI restarted while the session was up got no answer at all — every + /// live connection came back wearing the grey "nobody said" dot instead of + /// the green one, on a pane the user could type into. + ssh_phase: Option, agent: Option, agent_argv: Option>, agent_session: Option, @@ -1330,6 +1338,7 @@ impl DaemonPane { shell: ShellState::default(), shell_spec: spawn.shell.clone(), remote: spawn.remote.clone(), + ssh_phase: None, agent: None, agent_session: None, agent_argv: None, @@ -1549,6 +1558,7 @@ impl DaemonPane { command: None, }, remote: carried.remote, + ssh_phase: None, agent: carried.agent, agent_session: carried.agent_session, agent_argv: carried.agent_argv, @@ -1598,6 +1608,7 @@ impl DaemonPane { osc_title: None, shell: ShellState::default(), remote: Some(remote), + ssh_phase: None, agent: None, agent_session: None, agent_argv: None, @@ -1610,7 +1621,14 @@ impl DaemonPane { let broker = { let state = state.clone(); crate::daemon::ssh::PromptBroker::new(Box::new(move |msg: DaemonMsg| { - match &state.lock().unwrap().subscriber { + let mut st = state.lock().unwrap(); + // Remembered before it is sent, and remembered even when there + // is no client listening: the phase a connection reached while + // nobody was attached is exactly the one the next client needs. + if let DaemonMsg::SshStatus { phase } = &msg { + st.ssh_phase = Some(phase.clone()); + } + match &st.subscriber { Some(sub) => sub.send(msg).is_ok(), None => false, } @@ -2401,6 +2419,11 @@ fn replay_state(st: &PaneState, subscriber: &Sender) { if st.remote.is_some() { let _ = subscriber.send(DaemonMsg::RemoteContext(st.remote.clone())); } + if let Some(phase) = &st.ssh_phase { + let _ = subscriber.send(DaemonMsg::SshStatus { + phase: phase.clone(), + }); + } if st.agent.is_some() { let _ = subscriber.send(DaemonMsg::Agent(st.agent)); } @@ -3042,6 +3065,66 @@ mod tests { use crate::core::kitty_graphics::ImageDelete; use std::path::Path; + /// A client that attaches after the fact is told what the connection + /// reached. The phase is an event, so a GUI restarted while an ssh session + /// was up got no answer at all — every live connection came back wearing + /// the grey "nobody said" dot on a pane the user could type into. + #[test] + fn attaching_late_is_told_the_phase_the_connection_reached() { + let mut st = PaneState { + id: 7, + ring: ReplayRing::new(crate::daemon::protocol::WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 16, + }), + subscriber: None, + subscriber_epoch: 0, + observers: Vec::new(), + observer_seq: 0, + cwd: None, + osc_title: None, + shell: ShellState::default(), + shell_spec: None, + remote: Some(RemoteContext { + kind: crate::daemon::protocol::RemoteKind::NativeSsh, + argv: Vec::new(), + target: "java-box".into(), + }), + ssh_phase: Some(SshPhase::Connected), + agent: None, + agent_argv: None, + agent_session: None, + alive: true, + exit_code: None, + }; + + let (tx, rx) = mpsc::channel(); + replay_state(&st, &tx); + drop(tx); + let replayed: Vec = rx.into_iter().collect(); + assert!( + replayed.iter().any(|m| matches!( + m, + DaemonMsg::SshStatus { + phase: SshPhase::Connected + } + )), + "a late client has no other way to learn the connection is up: {replayed:?}" + ); + + // A pane that never had a connection says nothing about one. + st.ssh_phase = None; + let (tx, rx) = mpsc::channel(); + replay_state(&st, &tx); + drop(tx); + assert!( + !rx.into_iter() + .any(|m| matches!(m, DaemonMsg::SshStatus { .. })) + ); + } + #[test] fn a_shell_that_cannot_run_is_named_once_not_wrapped_four_deep() { let dir = tempfile::tempdir().expect("tempdir"); @@ -4154,6 +4237,7 @@ mod tests { osc_title: None, shell: ShellState::default(), remote: None, + ssh_phase: None, agent: None, agent_session: None, agent_argv: None,