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:
|
try:
|
||||||
kwargs["key_filename"] = key_path
|
kwargs["key_filename"] = key_path
|
||||||
client.connect(**kwargs)
|
client.connect(**kwargs)
|
||||||
|
transport = client.get_transport()
|
||||||
|
if transport is not None:
|
||||||
|
transport.set_keepalive(30)
|
||||||
return client
|
return client
|
||||||
except paramiko.AuthenticationException:
|
except paramiko.AuthenticationException:
|
||||||
log.debug(f"Key auth failed for {server.get('alias', '?')}, trying password")
|
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["look_for_keys"] = False
|
||||||
kwargs["allow_agent"] = False
|
kwargs["allow_agent"] = False
|
||||||
client.connect(**kwargs)
|
client.connect(**kwargs)
|
||||||
|
transport = client.get_transport()
|
||||||
|
if transport is not None:
|
||||||
|
transport.set_keepalive(30)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
raise Exception(f"No auth method for {server.get('alias', 'unknown')}")
|
raise Exception(f"No auth method for {server.get('alias', 'unknown')}")
|
||||||
@@ -92,11 +98,14 @@ class ShellSession:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return (
|
try:
|
||||||
self._channel is not None
|
return (
|
||||||
and self._channel.get_transport() is not None
|
self._channel is not None
|
||||||
and self._channel.get_transport().is_active()
|
and self._channel.get_transport() is not None
|
||||||
)
|
and self._channel.get_transport().is_active()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
self._client = _connect_client(self.server, self.key_path)
|
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._client = _connect_client(self.server, self.key_path)
|
||||||
self._sftp = self._client.open_sftp()
|
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):
|
def disconnect(self):
|
||||||
if self._sftp:
|
if self._sftp:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -419,41 +419,53 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
# ── Remote navigation ──
|
# ── Remote navigation ──
|
||||||
|
|
||||||
def _refresh_remote(self):
|
def _refresh_remote(self):
|
||||||
if not self._sftp or not self._sftp.connected:
|
if not self._sftp:
|
||||||
return
|
return
|
||||||
self._remote_path_entry.delete(0, "end")
|
self._remote_path_entry.delete(0, "end")
|
||||||
self._remote_path_entry.insert(0, self._remote_path)
|
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():
|
def _do():
|
||||||
try:
|
try:
|
||||||
if self._sftp.sudo_mode:
|
if not self._sftp.connected:
|
||||||
try:
|
self._sftp.reconnect()
|
||||||
attrs = self._sftp.listdir_attr_sudo(self._remote_path)
|
items = _list_remote()
|
||||||
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,
|
|
||||||
})
|
|
||||||
self.after(0, lambda: self._populate_remote(items))
|
self.after(0, lambda: self._populate_remote(items))
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
hint = f"\n{t('try_sudo_hint')}" if not self._sftp.sudo_mode else ""
|
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))
|
self.after(0, lambda: self._on_sftp_error(str(e) + hint))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
self.after(0, lambda: self._on_sftp_error(str(e)))
|
# 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()
|
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))
|
self._remote_status.configure(text=t("items_count").format(count=count))
|
||||||
|
|
||||||
def _navigate_remote(self, name: str):
|
def _navigate_remote(self, name: str):
|
||||||
|
if not self._sftp:
|
||||||
|
return
|
||||||
if name == "..":
|
if name == "..":
|
||||||
self._remote_go_up()
|
self._remote_go_up()
|
||||||
return
|
return
|
||||||
@@ -477,18 +491,22 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
def _remote_go_up(self):
|
def _remote_go_up(self):
|
||||||
if self._remote_path == "/":
|
if self._remote_path == "/":
|
||||||
return
|
return
|
||||||
|
if not self._sftp:
|
||||||
|
return
|
||||||
parent = "/".join(self._remote_path.rstrip("/").split("/")[:-1]) or "/"
|
parent = "/".join(self._remote_path.rstrip("/").split("/")[:-1]) or "/"
|
||||||
self._remote_history.append(self._remote_path)
|
self._remote_history.append(self._remote_path)
|
||||||
self._remote_path = parent
|
self._remote_path = parent
|
||||||
self._refresh_remote()
|
self._refresh_remote()
|
||||||
|
|
||||||
def _remote_go_back(self):
|
def _remote_go_back(self):
|
||||||
|
if not self._sftp:
|
||||||
|
return
|
||||||
if self._remote_history:
|
if self._remote_history:
|
||||||
self._remote_path = self._remote_history.pop()
|
self._remote_path = self._remote_history.pop()
|
||||||
self._refresh_remote()
|
self._refresh_remote()
|
||||||
|
|
||||||
def _remote_go_to_path(self):
|
def _remote_go_to_path(self):
|
||||||
if not self._sftp or not self._sftp.connected:
|
if not self._sftp:
|
||||||
return
|
return
|
||||||
path = self._remote_path_entry.get().strip()
|
path = self._remote_path_entry.get().strip()
|
||||||
if path:
|
if path:
|
||||||
@@ -507,7 +525,7 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
def _do_upload(self, items: list[dict]):
|
def _do_upload(self, items: list[dict]):
|
||||||
"""Upload files and folders from local to remote. Runs in background thread."""
|
"""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
|
return
|
||||||
items = [s for s in items if s["name"] != ".."]
|
items = [s for s in items if s["name"] != ".."]
|
||||||
if not items:
|
if not items:
|
||||||
@@ -518,6 +536,13 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
self._download_btn.configure(state="disabled")
|
self._download_btn.configure(state="disabled")
|
||||||
|
|
||||||
def _do():
|
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:
|
for item in items:
|
||||||
name = item["name"]
|
name = item["name"]
|
||||||
local = os.path.join(self._local_path, name)
|
local = os.path.join(self._local_path, name)
|
||||||
@@ -579,7 +604,7 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
def _do_download(self, items: list[dict]):
|
def _do_download(self, items: list[dict]):
|
||||||
"""Download files and folders from remote to local. Runs in background thread."""
|
"""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
|
return
|
||||||
items = [s for s in items if s["name"] != ".."]
|
items = [s for s in items if s["name"] != ".."]
|
||||||
if not items:
|
if not items:
|
||||||
@@ -590,6 +615,13 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
self._download_btn.configure(state="disabled")
|
self._download_btn.configure(state="disabled")
|
||||||
|
|
||||||
def _do():
|
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:
|
for item in items:
|
||||||
name = item["name"]
|
name = item["name"]
|
||||||
remote = self._remote_join(name)
|
remote = self._remote_join(name)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class TerminalTab(ctk.CTkFrame):
|
|||||||
self._current_alias: str | None = None
|
self._current_alias: str | None = None
|
||||||
self._session: ShellSession | None = None
|
self._session: ShellSession | None = None
|
||||||
self._reconnect_count = 0
|
self._reconnect_count = 0
|
||||||
self._max_reconnect = 3
|
self._max_reconnect = 5
|
||||||
self._intentional_disconnect = False
|
self._intentional_disconnect = False
|
||||||
|
|
||||||
# Import here to avoid circular issues
|
# Import here to avoid circular issues
|
||||||
@@ -142,6 +142,10 @@ class TerminalTab(ctk.CTkFrame):
|
|||||||
session = self._session # local ref for thread safety
|
session = self._session # local ref for thread safety
|
||||||
if session and session.connected:
|
if session and session.connected:
|
||||||
session.send(data)
|
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):
|
def _on_resize(self, cols: int, rows: int):
|
||||||
session = self._session # local ref for thread safety
|
session = self._session # local ref for thread safety
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user