fix: correctly handle PTY exit as graceful and add SSH keepalives for fast disconnect detection

This commit is contained in:
TomZz
2026-06-14 00:16:56 +08:00
parent 227966319c
commit 0e4c4cdc69
4 changed files with 106 additions and 61 deletions
+27 -11
View File
@@ -28,10 +28,9 @@ use rust_i18n::t;
use tokio::runtime::Runtime;
use crate::{
sftp::SftpHandle,
session::config::{AuthMethod, ConfigStore},
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendCommand, BackendEvent, TabKind, TerminalTab},
terminal::{self, BackendEvent, TabKind, TerminalTab},
backend::ssh,
};
@@ -558,7 +557,7 @@ impl Ashell {
if self
.connection_progress
.as_ref()
.is_some_and(|progress| progress.tab_id == tab_id)
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
{
self.connection_progress = None;
}
@@ -644,6 +643,7 @@ impl Ashell {
self.remote_sample_in_flight = false;
return changed;
}
let mut needs_new_progress = false;
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
@@ -653,16 +653,32 @@ impl Ashell {
let _ = tab_title;
progress.title = t!("connection_failed").into();
progress.failed = true;
} else if !progress.failed {
// We were showing connecting progress, but another tab dropped!
// Switch to failed state so the user can see it and retry.
progress.tab_id = tab_id.clone();
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else {
// Already showing a failure dialog, just append the new failure
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
}
} else if let Some(_) = session_label {
if !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
needs_new_progress = true;
}
if needs_new_progress && !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
self.status = reason.into();
}
+9 -3
View File
@@ -115,7 +115,7 @@ async fn run_ssh(
tab_id: tab_id.clone(),
});
let mut exit_reason = "ssh connection lost (unknown)".to_string();
let exit_reason;
let mut is_graceful_close = false;
loop {
@@ -146,7 +146,7 @@ async fn run_ssh(
bytes: data.to_vec(),
});
}
Some(ChannelMsg::ExitStatus { exit_status: _ }) => {
Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => {
is_graceful_close = true;
}
Some(ChannelMsg::Close) => {
@@ -158,7 +158,11 @@ async fn run_ssh(
break;
}
None => {
exit_reason = "ssh connection lost (network drop)".to_string();
if is_graceful_close {
exit_reason = "ssh session closed".to_string();
} else {
exit_reason = "ssh connection lost (network drop)".to_string();
}
break;
}
_ => {}
@@ -184,6 +188,8 @@ async fn connect_and_authenticate(
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(10)),
keepalive_max: 3,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
+70 -46
View File
@@ -16,7 +16,6 @@ use self::config::{AuthMethod, Session};
use crate::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup,
backend::{local, ssh},
sftp,
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
app::constants::{DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, TERMINAL_PADDING_Y},
};
@@ -420,53 +419,71 @@ impl Ashell {
let Some(progress) = self.connection_progress.clone() else {
return;
};
let Some(ix) = self.tabs.iter().position(|tab| tab.id == progress.tab_id) else {
self.connection_progress = None;
cx.notify();
return;
};
let Some(session) = self.tabs[ix].session.clone() else {
self.connection_progress = None;
cx.notify();
return;
};
self.connection_progress = None;
let mut groups_to_restart_sftp = std::collections::HashSet::new();
// Close old backend
self.tabs[ix].backend.send(BackendCommand::Close);
// Spawn new backend
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
progress.tab_id.clone(),
session.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
self.events_tx.clone(),
);
// Replace tab state in-place to reuse the UI component
self.tabs[ix] = TerminalTab::new_ssh(
progress.tab_id.clone(),
&session,
backend,
self.events_tx.clone(),
);
// Restart SFTP if applicable
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&progress.tab_id)) {
if let Some(old_handle) = self.sftp_handles.remove(&group.id) {
old_handle.close();
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
}
let sftp_handle = crate::sftp::spawn_sftp(
}
if retry_tabs.is_empty() {
cx.notify();
return;
}
for (ix, tab_id, session) in retry_tabs {
// Close old backend
self.tabs[ix].backend.send(BackendCommand::Close);
// Spawn new backend
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
group.id.clone(),
tab_id.clone(),
session.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
self.events_tx.clone(),
);
self.sftp_handles.insert(group.id.clone(), sftp_handle);
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
// Replace tab state in-place to reuse the UI component
self.tabs[ix] = TerminalTab::new_ssh(
tab_id.clone(),
&session,
backend,
self.events_tx.clone(),
);
// Find group to restart SFTP
if let Some(group) = self.tab_groups.iter().find(|g| g.pane_root.contains(&tab_id)) {
groups_to_restart_sftp.insert(group.id.clone());
}
}
// Restart SFTP for affected groups
for group_id in groups_to_restart_sftp {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
// Use the session of any tab in that group
let group_session = self.tabs.iter().find(|t| group.pane_root.contains(&t.id) && t.session.is_some()).and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group.id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group.id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group.id.clone(), sftp_handle);
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
@@ -476,16 +493,23 @@ impl Ashell {
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tab retrying".into();
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
if self.connection_progress.is_none() {
return;
};
}
self.connection_progress = None;
self.close_tab(progress.tab_id, cx);
let tabs_to_close: Vec<_> = self.tabs.iter()
.filter(|tab| !tab.connected && tab.session.is_some())
.map(|tab| tab.id.clone())
.collect();
for id in tabs_to_close {
self.handle_tab_close(id);
}
cx.notify();
}
#[allow(dead_code)]
-1
View File
@@ -435,7 +435,6 @@ impl EventListener for TerminalListener {
}
}
use rust_i18n::t;
fn new_term(
cols: u16,
rows: u16,