fix: SSH keepalive + auto-reconnect for SFTP and terminal

- Add transport.set_keepalive(30) to all SSH connections
- Add SFTPSession.reconnect() method
- Auto-reconnect in _refresh_remote with retry on failure
- Auto-reconnect before upload/download operations
- Guard all remote navigation methods with connection checks
- ShellSession.connected wrapped in try/except
- Terminal: reset reconnect counter on send failure, increase max to 5
- Terminal: trigger reconnect on send to dead session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 02:25:40 -05:00
parent 3e9aeababe
commit 85a8c3ffc6
4 changed files with 85 additions and 34 deletions

View File

@@ -45,6 +45,9 @@ def _connect_client(server: dict, key_path: str, timeout: int = 15) -> paramiko.
try:
kwargs["key_filename"] = key_path
client.connect(**kwargs)
transport = client.get_transport()
if transport is not None:
transport.set_keepalive(30)
return client
except paramiko.AuthenticationException:
log.debug(f"Key auth failed for {server.get('alias', '?')}, trying password")
@@ -68,6 +71,9 @@ def _connect_client(server: dict, key_path: str, timeout: int = 15) -> paramiko.
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
transport = client.get_transport()
if transport is not None:
transport.set_keepalive(30)
return client
raise Exception(f"No auth method for {server.get('alias', 'unknown')}")
@@ -92,11 +98,14 @@ class ShellSession:
@property
def connected(self) -> bool:
return (
self._channel is not None
and self._channel.get_transport() is not None
and self._channel.get_transport().is_active()
)
try:
return (
self._channel is not None
and self._channel.get_transport() is not None
and self._channel.get_transport().is_active()
)
except Exception:
return False
def connect(self):
self._client = _connect_client(self.server, self.key_path)
@@ -334,6 +343,12 @@ class SFTPSession:
self._client = _connect_client(self.server, self.key_path)
self._sftp = self._client.open_sftp()
def reconnect(self):
"""Disconnect and re-establish SFTP session."""
self.disconnect()
time.sleep(0.2)
self.connect()
def disconnect(self):
if self._sftp:
try:

View File

@@ -419,41 +419,53 @@ class FilesTab(ctk.CTkFrame):
# ── Remote navigation ──
def _refresh_remote(self):
if not self._sftp or not self._sftp.connected:
if not self._sftp:
return
self._remote_path_entry.delete(0, "end")
self._remote_path_entry.insert(0, self._remote_path)
def _list_remote():
"""Fetch remote listing, returns items list."""
if self._sftp.sudo_mode:
try:
attrs = self._sftp.listdir_attr_sudo(self._remote_path)
except Exception:
attrs = self._sftp.listdir_attr(self._remote_path)
else:
attrs = self._sftp.listdir_attr(self._remote_path)
items = []
if self._remote_path != "/":
items.append({
"name": "..", "size": "", "date": "", "perm": "", "is_dir": True,
})
for a in attrs:
is_dir = stat.S_ISDIR(a.st_mode) if a.st_mode else False
items.append({
"name": a.filename,
"size": "" if is_dir else _format_size(a.st_size or 0),
"date": _format_date(a.st_mtime or 0),
"perm": _format_perm(a.st_mode & 0o777) if a.st_mode else "",
"is_dir": is_dir,
})
return items
def _do():
try:
if self._sftp.sudo_mode:
try:
attrs = self._sftp.listdir_attr_sudo(self._remote_path)
except Exception:
attrs = self._sftp.listdir_attr(self._remote_path)
else:
attrs = self._sftp.listdir_attr(self._remote_path)
items = []
# Parent dir
if self._remote_path != "/":
items.append({
"name": "..", "size": "", "date": "", "perm": "", "is_dir": True,
})
for a in attrs:
is_dir = stat.S_ISDIR(a.st_mode) if a.st_mode else False
items.append({
"name": a.filename,
"size": "" if is_dir else _format_size(a.st_size or 0),
"date": _format_date(a.st_mtime or 0),
"perm": _format_perm(a.st_mode & 0o777) if a.st_mode else "",
"is_dir": is_dir,
})
if not self._sftp.connected:
self._sftp.reconnect()
items = _list_remote()
self.after(0, lambda: self._populate_remote(items))
except PermissionError as e:
hint = f"\n{t('try_sudo_hint')}" if not self._sftp.sudo_mode else ""
self.after(0, lambda: self._on_sftp_error(str(e) + hint))
except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e)))
except Exception:
# Connection likely dead — try reconnect once
try:
self._sftp.reconnect()
items = _list_remote()
self.after(0, lambda: self._populate_remote(items))
except Exception as e2:
self.after(0, lambda: self._on_sftp_error(str(e2)))
threading.Thread(target=_do, daemon=True).start()
@@ -463,6 +475,8 @@ class FilesTab(ctk.CTkFrame):
self._remote_status.configure(text=t("items_count").format(count=count))
def _navigate_remote(self, name: str):
if not self._sftp:
return
if name == "..":
self._remote_go_up()
return
@@ -477,18 +491,22 @@ class FilesTab(ctk.CTkFrame):
def _remote_go_up(self):
if self._remote_path == "/":
return
if not self._sftp:
return
parent = "/".join(self._remote_path.rstrip("/").split("/")[:-1]) or "/"
self._remote_history.append(self._remote_path)
self._remote_path = parent
self._refresh_remote()
def _remote_go_back(self):
if not self._sftp:
return
if self._remote_history:
self._remote_path = self._remote_history.pop()
self._refresh_remote()
def _remote_go_to_path(self):
if not self._sftp or not self._sftp.connected:
if not self._sftp:
return
path = self._remote_path_entry.get().strip()
if path:
@@ -507,7 +525,7 @@ class FilesTab(ctk.CTkFrame):
def _do_upload(self, items: list[dict]):
"""Upload files and folders from local to remote. Runs in background thread."""
if not self._sftp or not self._sftp.connected or self._transferring:
if not self._sftp or self._transferring:
return
items = [s for s in items if s["name"] != ".."]
if not items:
@@ -518,6 +536,13 @@ class FilesTab(ctk.CTkFrame):
self._download_btn.configure(state="disabled")
def _do():
if not self._sftp.connected:
try:
self._sftp.reconnect()
except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e)))
self.after(0, self._on_transfer_done)
return
for item in items:
name = item["name"]
local = os.path.join(self._local_path, name)
@@ -579,7 +604,7 @@ class FilesTab(ctk.CTkFrame):
def _do_download(self, items: list[dict]):
"""Download files and folders from remote to local. Runs in background thread."""
if not self._sftp or not self._sftp.connected or self._transferring:
if not self._sftp or self._transferring:
return
items = [s for s in items if s["name"] != ".."]
if not items:
@@ -590,6 +615,13 @@ class FilesTab(ctk.CTkFrame):
self._download_btn.configure(state="disabled")
def _do():
if not self._sftp.connected:
try:
self._sftp.reconnect()
except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e)))
self.after(0, self._on_transfer_done)
return
for item in items:
name = item["name"]
remote = self._remote_join(name)

View File

@@ -17,7 +17,7 @@ class TerminalTab(ctk.CTkFrame):
self._current_alias: str | None = None
self._session: ShellSession | None = None
self._reconnect_count = 0
self._max_reconnect = 3
self._max_reconnect = 5
self._intentional_disconnect = False
# Import here to avoid circular issues
@@ -142,6 +142,10 @@ class TerminalTab(ctk.CTkFrame):
session = self._session # local ref for thread safety
if session and session.connected:
session.send(data)
elif self._current_alias and not self._intentional_disconnect:
# Session dead — trigger reconnect
self._reconnect_count = 0
self._on_disconnected()
def _on_resize(self, cols: int, rows: int):
session = self._session # local ref for thread safety