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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user