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

@@ -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)