From 9c8017cebd74e1c6736d66fd7b974b0c2870430b Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 02:27:19 +0200 Subject: [PATCH] feat: ajouter le keepalive SSH et la reconnexion automatique - Session gagne keepalive_enabled/interval/count_max (actif par defaut : ne fait qu'envoyer des paquets de maintien, sans risque pour les sessions existantes) et reconnect_enabled/initial_delay/max_attempts (desactive par defaut : change reellement le comportement en cas de coupure, donc a activer explicitement). - SSHConnection._keepalive_loop() : thread dedie qui envoie "keepalive@openssh.com" (comme OpenSSH) avec accuse de reception toutes les `keepalive_interval` secondes. Un serveur conforme repond immediatement (meme en echec), ce qui confirme que le transport est vivant. Apres `keepalive_count_max` echecs consecutifs (aucune reponse), le canal est ferme pour que la boucle de lecture principale s'en apercoive et emette `disconnected` avec une raison explicite. - SessionTabWidget programme une reconnexion (QTimer, jamais de blocage de l'interface) quand la session l'autorise et que la deconnexion n'est pas volontaire (_closing) : delai en backoff exponentiel plafonne (app.services.reconnect.compute_backoff_delay), compte a rebours affiche ("Connexion perdue (...). Reconnexion dans Ns... (Tentative X/Y)"), abandon au-dela de reconnect_max_attempts (0 = illimite), compteur remis a zero des qu'une connexion reussit. Ne se declenche jamais sur une simple erreur d'authentification (seul un `disconnected` apres connexion reussie declenche une reconnexion, pas un `error` de connexion initiale - sinon un mot de passe errone entrainerait des tentatives repetees). - BaseTerminalTab : les signaux connected/disconnected/error d'une connexion sont desormais associes a la generation de connexion en cours au moment ou ils sont cables ; un signal parvenant apres qu'une reconnexion (manuelle ou automatique) ait deja demarre une nouvelle connexion est ignore. Necessaire ici : les signaux Qt entre threads sont mis en file et ne sont livres qu'apres le retour de reconnect() a la boucle d'evenements, alors qu'une nouvelle connexion est deja en place. - SessionDialog : sections "Keepalive" et "Reconnexion automatique". tests/test_keepalive.py (paramiko entierement mocke), test_reconnect_backoff.py (fonction pure) et test_auto_reconnect.py (SSHConnection.start() neutralisee : la programmation de la reconnexion ne depend d'aucune connexion reseau reelle). Co-Authored-By: Claude Sonnet 5 --- app/dialogs/session_dialog.py | 61 ++++++++++++++++ app/services/reconnect.py | 12 +++ app/session_manager.py | 13 ++++ app/ssh_connection.py | 52 ++++++++++++- app/ui/tabs.py | 98 ++++++++++++++++++++++++- tests/test_auto_reconnect.py | 125 ++++++++++++++++++++++++++++++++ tests/test_keepalive.py | 112 ++++++++++++++++++++++++++++ tests/test_reconnect_backoff.py | 32 ++++++++ 8 files changed, 499 insertions(+), 6 deletions(-) create mode 100644 app/services/reconnect.py create mode 100644 tests/test_auto_reconnect.py create mode 100644 tests/test_keepalive.py create mode 100644 tests/test_reconnect_backoff.py diff --git a/app/dialogs/session_dialog.py b/app/dialogs/session_dialog.py index f04f35d..b595f8c 100644 --- a/app/dialogs/session_dialog.py +++ b/app/dialogs/session_dialog.py @@ -80,6 +80,8 @@ class SessionDialog(QDialog): layout.addLayout(form) layout.addWidget(self._build_bastion_group()) + layout.addWidget(self._build_keepalive_group()) + layout.addWidget(self._build_reconnect_group()) self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)") if self.quick_mode: @@ -260,6 +262,59 @@ class SessionDialog(QDialog): bastion.key_path = self.bastion_key_combo.currentData() or "" return bastion + # ---------- Keepalive ---------- + def _build_keepalive_group(self) -> QGroupBox: + session = self.session + group = QGroupBox("Keepalive") + form = QFormLayout(group) + + self.keepalive_check = QCheckBox("Active") + self.keepalive_check.setChecked(session.keepalive_enabled) + self.keepalive_interval_spin = QSpinBox() + self.keepalive_interval_spin.setRange(5, 3600) + self.keepalive_interval_spin.setSuffix(" s") + self.keepalive_interval_spin.setValue(session.keepalive_interval or 30) + self.keepalive_count_spin = QSpinBox() + self.keepalive_count_spin.setRange(1, 20) + self.keepalive_count_spin.setValue(session.keepalive_count_max or 3) + + form.addRow(self.keepalive_check) + form.addRow("Intervalle :", self.keepalive_interval_spin) + form.addRow("Nombre d'echecs avant abandon :", self.keepalive_count_spin) + + self.keepalive_check.toggled.connect(self.keepalive_interval_spin.setEnabled) + self.keepalive_check.toggled.connect(self.keepalive_count_spin.setEnabled) + self.keepalive_interval_spin.setEnabled(session.keepalive_enabled) + self.keepalive_count_spin.setEnabled(session.keepalive_enabled) + return group + + # ---------- Reconnexion automatique ---------- + def _build_reconnect_group(self) -> QGroupBox: + session = self.session + group = QGroupBox("Reconnexion automatique") + form = QFormLayout(group) + + self.reconnect_check = QCheckBox("Reconnexion automatique") + self.reconnect_check.setChecked(session.reconnect_enabled) + self.reconnect_delay_spin = QSpinBox() + self.reconnect_delay_spin.setRange(1, 300) + self.reconnect_delay_spin.setSuffix(" s") + self.reconnect_delay_spin.setValue(session.reconnect_initial_delay or 5) + self.reconnect_attempts_spin = QSpinBox() + self.reconnect_attempts_spin.setRange(0, 1000) + self.reconnect_attempts_spin.setSpecialValueText("Illimite") + self.reconnect_attempts_spin.setValue(session.reconnect_max_attempts) + + form.addRow(self.reconnect_check) + form.addRow("Delai initial :", self.reconnect_delay_spin) + form.addRow("Tentatives maximum (0 = illimite) :", self.reconnect_attempts_spin) + + self.reconnect_check.toggled.connect(self.reconnect_delay_spin.setEnabled) + self.reconnect_check.toggled.connect(self.reconnect_attempts_spin.setEnabled) + self.reconnect_delay_spin.setEnabled(session.reconnect_enabled) + self.reconnect_attempts_spin.setEnabled(session.reconnect_enabled) + return group + def should_save(self) -> bool: """En connexion rapide, indique si la session doit aussi etre enregistree.""" return self.save_check.isChecked() if self.quick_mode else True @@ -305,6 +360,12 @@ class SessionDialog(QDialog): self.session.key_path = self.key_combo.currentData() or "" self.session.notes = self.notes_edit.toPlainText() self.session.bastion = self._result_bastion() + self.session.keepalive_enabled = self.keepalive_check.isChecked() + self.session.keepalive_interval = self.keepalive_interval_spin.value() + self.session.keepalive_count_max = self.keepalive_count_spin.value() + self.session.reconnect_enabled = self.reconnect_check.isChecked() + self.session.reconnect_initial_delay = self.reconnect_delay_spin.value() + self.session.reconnect_max_attempts = self.reconnect_attempts_spin.value() self.accept() def result_session(self) -> Session: diff --git a/app/services/reconnect.py b/app/services/reconnect.py new file mode 100644 index 0000000..fcb2300 --- /dev/null +++ b/app/services/reconnect.py @@ -0,0 +1,12 @@ +"""Calcul du delai de reconnexion automatique (backoff exponentiel plafonne).""" +from __future__ import annotations + + +def compute_backoff_delay(attempt: int, initial_delay: int, max_delay: int = 60) -> int: + """Delai (secondes) avant la tentative numero `attempt` (1, 2, 3, ...). + + Double a chaque tentative (5s, 10s, 20s, 40s, ...) et plafonne a `max_delay` + pour ne pas finir par attendre des heures apres de nombreux echecs.""" + if attempt < 1: + attempt = 1 + return min(initial_delay * (2 ** (attempt - 1)), max_delay) diff --git a/app/session_manager.py b/app/session_manager.py index 5238d66..6253e67 100644 --- a/app/session_manager.py +++ b/app/session_manager.py @@ -66,6 +66,19 @@ class Session: notes: str = "" bastion: BastionConfig = field(default_factory=BastionConfig) + # Keepalive : sans risque pour une session existante (n'envoie que des + # paquets de maintien de connexion), active par defaut. + keepalive_enabled: bool = True + keepalive_interval: int = 30 + keepalive_count_max: int = 3 + + # Reconnexion automatique : change reellement le comportement en cas de + # coupure, donc desactivee par defaut pour ne pas surprendre les sessions + # existantes ; a activer explicitement. + reconnect_enabled: bool = False + reconnect_initial_delay: int = 5 + reconnect_max_attempts: int = 10 # 0 = illimite + def to_dict(self) -> dict: return asdict(self) diff --git a/app/ssh_connection.py b/app/ssh_connection.py index d9fe140..2cc05dd 100644 --- a/app/ssh_connection.py +++ b/app/ssh_connection.py @@ -57,6 +57,9 @@ class SSHConnection(QThread): open_sftp_on_connect: bool = False, bastion_target: BastionTarget | None = None, bastion_password: str = "", + keepalive_enabled: bool = False, + keepalive_interval: int = 30, + keepalive_count_max: int = 3, parent=None, ): super().__init__(parent) @@ -74,11 +77,16 @@ class SSHConnection(QThread): self.rows = rows self.bastion_target = bastion_target self.bastion_password = bastion_password + self.keepalive_enabled = keepalive_enabled + self.keepalive_interval = max(1, keepalive_interval) + self.keepalive_count_max = max(1, keepalive_count_max) self.client: paramiko.SSHClient | None = None self.channel: paramiko.Channel | None = None self._bastion_client: paramiko.SSHClient | None = None self._stop = threading.Event() + self._disconnect_reason = "Connexion fermee" + self._keepalive_thread: threading.Thread | None = None self._trust_event = threading.Event() self._trust_result = False @@ -259,6 +267,44 @@ class SSHConnection(QThread): f"{self.host}:{self.port} ({exc})" ) from exc + # ---------- keepalive (ServerAliveInterval / ServerAliveCountMax) ---------- + def _start_keepalive(self) -> None: + if not self.keepalive_enabled: + return + self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True) + self._keepalive_thread.start() + + def _keepalive_loop(self) -> None: + """Envoie une requete keepalive avec accuse de reception toutes les + `keepalive_interval` secondes. Apres `keepalive_count_max` echecs + consecutifs (pas de reponse : transport mort), force la fermeture du + canal pour que la boucle de lecture principale s'en apercoive et + declenche la deconnexion (et, si activee, la reconnexion automatique).""" + failures = 0 + while not self._stop.wait(self.keepalive_interval): + transport = self.client.get_transport() if self.client else None + if not transport or not transport.is_active(): + return + try: + # Nom choisi comme celui d'OpenSSH : un serveur conforme repond + # "request failed" immediatement, ce qui suffit a confirmer que + # le transport est bien vivant (le contenu de la reponse importe peu). + transport.global_request("keepalive@openssh.com", wait=True) + failures = 0 + except Exception: # noqa: BLE001 + failures += 1 + if failures >= self.keepalive_count_max: + self._disconnect_reason = ( + f"keepalive : aucune reponse apres {failures} tentative(s)" + ) + self._stop.set() + try: + if self.channel: + self.channel.close() + except Exception: + pass + return + # ---------- cycle de vie du thread ---------- def run(self) -> None: try: @@ -283,12 +329,14 @@ class SSHConnection(QThread): if not self.open_shell: raise + self._start_keepalive() + if not self.open_shell: # Connexion dediee au SFTP : on garde la session vivante sans shell. self.connected.emit() while not self._stop.is_set(): self._stop.wait(0.2) - self.disconnected.emit("Connexion fermee") + self.disconnected.emit(self._disconnect_reason) return self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows) @@ -315,7 +363,7 @@ class SSHConnection(QThread): except Exception: break - self.disconnected.emit("Connexion fermee") + self.disconnected.emit(self._disconnect_reason) except paramiko.AuthenticationException: self.error.emit("Authentification refusee (identifiants ou cle invalides).") except Exception as exc: # noqa: BLE001 diff --git a/app/ui/tabs.py b/app/ui/tabs.py index 25e0681..9666661 100644 --- a/app/ui/tabs.py +++ b/app/ui/tabs.py @@ -11,7 +11,7 @@ import re from datetime import datetime from pathlib import Path -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter, @@ -27,6 +27,7 @@ from app.dialogs.tunnel_dialog import TunnelDialog from app.tunnels import TunnelManager from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change from app.services.ssh_target import detect_ssh_target +from app.services.reconnect import compute_backoff_delay # sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles ANSI_ESCAPE_RE = re.compile( @@ -104,6 +105,8 @@ class BaseTerminalTab(QWidget): self.connection = None # assigne par les sous-classes self._log_file = None + self._connection_generation = 0 + self._closing = False # ---------- journalisation ---------- def toggle_logging(self) -> None: @@ -147,20 +150,34 @@ class BaseTerminalTab(QWidget): self.stop_logging() def _wire_connection(self) -> None: - self.connection.connected.connect(self._on_connected) + # Un signal deconnexion/erreur/connexion emis par une connexion remplacee + # entre-temps (ex. reconnexion en cours) ne doit pas ecraser l'etat de la + # nouvelle : chaque connexion est associee a la generation en cours au + # moment ou elle est cablee, et un signal d'une generation perimee est ignore. + generation = self._connection_generation + self.connection.connected.connect(lambda: self._on_current_generation(generation, self._on_connected)) self.connection.data_received.connect(self.terminal.feed) self.connection.data_received.connect(self._log_output) - self.connection.disconnected.connect(self._on_disconnected) - self.connection.error.connect(self._on_error) + self.connection.disconnected.connect( + lambda reason: self._on_current_generation(generation, self._on_disconnected, reason) + ) + self.connection.error.connect( + lambda message: self._on_current_generation(generation, self._on_error, message) + ) self.terminal.data_to_send.connect(self.connection.send) self.terminal.resized.connect(self.connection.resize) + def _on_current_generation(self, generation: int, fn, *args) -> None: + if generation == self._connection_generation: + fn(*args) + def _create_connection(self): """Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui permet de la recreer a l'identique lors d'une reconnexion.""" raise NotImplementedError def start_connection(self) -> None: + self._connection_generation += 1 self.connection = self._create_connection() self._wire_connection() self.connection.start() @@ -206,6 +223,7 @@ class BaseTerminalTab(QWidget): self.search_status.setText("" if found else "aucun resultat") def close_connection(self) -> None: + self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue self.stop_logging() if self.connection: self.connection.close_connection() @@ -244,6 +262,19 @@ class SessionTabWidget(BaseTerminalTab): self._secret = secret self.sftp_toggle.toggled.connect(self._toggle_sftp) + + # Reconnexion automatique : un timer a declenchement unique pour la + # reconnexion elle-meme, et un second pour rafraichir le compte a + # rebours affiche a l'utilisateur pendant l'attente. + self._reconnect_attempt = 0 + self._reconnect_seconds_left = 0 + self._reconnect_last_reason = "" + self._reconnect_timer = QTimer(self) + self._reconnect_timer.setSingleShot(True) + self._reconnect_timer.timeout.connect(self._do_reconnect) + self._reconnect_countdown_timer = QTimer(self) + self._reconnect_countdown_timer.timeout.connect(self._tick_reconnect_countdown) + self.start_connection() def _create_connection(self): @@ -261,6 +292,9 @@ class SessionTabWidget(BaseTerminalTab): open_sftp_on_connect=True, bastion_target=self._bastion_target, bastion_password=self._bastion_password, + keepalive_enabled=session.keepalive_enabled, + keepalive_interval=session.keepalive_interval, + keepalive_count_max=session.keepalive_count_max, ) connection.sftp_ready.connect(self._on_sftp_ready) connection.password_prompt.connect(self._on_password_prompt) @@ -278,6 +312,7 @@ class SessionTabWidget(BaseTerminalTab): self.tunnel_btn.setText(f"🔀 Tunnels ({actifs})" if actifs else "🔀 Tunnels") def _on_connected(self) -> None: + self._reconnect_attempt = 0 # une connexion reussie remet le compteur a zero self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}") transport = self.connection.client.get_transport() if self.connection.client else None if transport: @@ -290,6 +325,57 @@ class SessionTabWidget(BaseTerminalTab): def _on_disconnected(self, reason: str) -> None: super()._on_disconnected(reason) self.sftp_toggle.setEnabled(False) + self._maybe_schedule_reconnect(reason) + + def reconnect(self) -> None: + self._cancel_reconnect_timers() + super().reconnect() + + # ---------- reconnexion automatique ---------- + def _cancel_reconnect_timers(self) -> None: + self._reconnect_timer.stop() + self._reconnect_countdown_timer.stop() + + def _maybe_schedule_reconnect(self, reason: str) -> None: + if self._closing or not self.session.reconnect_enabled: + return + max_attempts = self.session.reconnect_max_attempts + self._reconnect_attempt += 1 + if max_attempts and self._reconnect_attempt > max_attempts: + self.status_label.setText( + f"● Deconnecte ({reason}). Abandon apres {max_attempts} tentative(s) de reconnexion." + ) + self.reconnect_btn.show() + return + self._reconnect_last_reason = reason + self._reconnect_seconds_left = compute_backoff_delay( + self._reconnect_attempt, self.session.reconnect_initial_delay, + ) + self._update_reconnect_status() + self._reconnect_countdown_timer.start(1000) + self._reconnect_timer.start(self._reconnect_seconds_left * 1000) + + def _tick_reconnect_countdown(self) -> None: + self._reconnect_seconds_left -= 1 + if self._reconnect_seconds_left <= 0: + self._reconnect_countdown_timer.stop() + return + self._update_reconnect_status() + + def _update_reconnect_status(self) -> None: + max_attempts = self.session.reconnect_max_attempts + total = f"/{max_attempts}" if max_attempts else "" + self.status_label.setText( + f"● Connexion perdue ({self._reconnect_last_reason}). " + f"Reconnexion dans {self._reconnect_seconds_left} seconde(s)... " + f"(Tentative {self._reconnect_attempt}{total})" + ) + + def _do_reconnect(self) -> None: + self._reconnect_countdown_timer.stop() + if self._closing: + return + self.reconnect() def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None: accepted = confirm_host_key(self, hostname, key_type, fingerprint) @@ -326,6 +412,7 @@ class SessionTabWidget(BaseTerminalTab): return paramiko.SFTPClient.from_transport(self.connection.client.get_transport()) def close_connection(self) -> None: + self._cancel_reconnect_timers() if self.sftp_panel: self.sftp_panel.shutdown() if self.tunnel_manager: @@ -380,6 +467,9 @@ class SFTPTabWidget(QWidget): open_sftp_on_connect=True, bastion_target=bastion_target, bastion_password=bastion_password, + keepalive_enabled=session.keepalive_enabled, + keepalive_interval=session.keepalive_interval, + keepalive_count_max=session.keepalive_count_max, ) self.connection.sftp_ready.connect(self._on_sftp_ready) self.connection.password_prompt.connect(self._on_password_prompt) diff --git a/tests/test_auto_reconnect.py b/tests/test_auto_reconnect.py new file mode 100644 index 0000000..f92f138 --- /dev/null +++ b/tests/test_auto_reconnect.py @@ -0,0 +1,125 @@ +"""Reconnexion automatique de SessionTabWidget : programmation du delai (avec +backoff), plafond de tentatives, remise a zero apres succes, et absence de +reconnexion sur une fermeture volontaire de l'onglet. + +Pilote directement _on_disconnected()/_on_connected() plutot que d'attendre une +vraie coupure reseau (la detection elle-meme est testee dans test_keepalive.py). +SSHConnection.start() est neutralisee : ce module teste uniquement la logique +de programmation du cote de l'onglet, qui n'a besoin d'aucune connexion reseau +reelle - cela evite aussi qu'un thread reste bloque dans un connect() lent et +ne bloque la fin du script. +""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import time +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication + +from app.session_manager import Session +from app.ssh_connection import SSHConnection +from app.ui.tabs import SessionTabWidget + +app = QApplication.instance() or QApplication([]) + + +def pump(seconds: float) -> None: + deadline = time.time() + seconds + while time.time() < deadline: + app.processEvents() + time.sleep(0.01) + + +def make_session(**overrides) -> Session: + session = Session(name="t", host="10.0.0.1", port=22, username="root") + for key, value in overrides.items(): + setattr(session, key, value) + return session + + +no_real_thread = patch.object(SSHConnection, "start", lambda self: None) +no_real_thread.start() + +print("=== 1. Reconnexion desactivee par defaut : aucune programmation ===") +session = make_session() +assert session.reconnect_enabled is False +tab = SessionTabWidget(session, "") +tab._on_disconnected("test") +assert not tab._reconnect_timer.isActive() +tab.close_connection() +print(" OK") + +print() +print("=== 2. Reconnexion activee : programmation avec le delai initial exact ===") +session2 = make_session(reconnect_enabled=True, reconnect_initial_delay=1, reconnect_max_attempts=3) +tab2 = SessionTabWidget(session2, "") +tab2._on_disconnected("connexion perdue") +print(" statut:", tab2.status_label.text()) +assert tab2._reconnect_timer.isActive() +assert tab2._reconnect_attempt == 1 +assert tab2._reconnect_seconds_left == 1 # compute_backoff_delay(1, 1) == 1 +assert "Tentative 1/3" in tab2.status_label.text() +print(" OK") + +print() +print("=== 3. Le timer se declenche reellement et relance une connexion ===") +pump(1.5) +print(" countdown actif apres ~1.5s:", tab2._reconnect_countdown_timer.isActive()) +assert not tab2._reconnect_countdown_timer.isActive(), "le cycle doit s'etre declenche et avoir tout arrete" +tab2.close_connection() +print(" OK") + +print() +print("=== 4. Plafond de tentatives : abandon au-dela de max_attempts ===") +session3 = make_session(reconnect_enabled=True, reconnect_initial_delay=100, reconnect_max_attempts=2) +tab3 = SessionTabWidget(session3, "") +tab3._on_disconnected("r1") +assert tab3._reconnect_attempt == 1 and tab3._reconnect_timer.isActive() +tab3._cancel_reconnect_timers() # simule le declenchement du timer sans attendre 100s +tab3._on_disconnected("r2") +assert tab3._reconnect_attempt == 2 and tab3._reconnect_timer.isActive() +tab3._cancel_reconnect_timers() +tab3._on_disconnected("r3") +print(" apres 3 echecs (max=2):", tab3.status_label.text()) +assert tab3._reconnect_attempt == 3 +assert not tab3._reconnect_timer.isActive(), "au-dela du plafond, plus de nouvelle tentative programmee" +assert "Abandon" in tab3.status_label.text() +tab3.close_connection() +print(" OK") + +print() +print("=== 5. Une connexion reussie remet le compteur de tentatives a zero ===") +session4 = make_session(reconnect_enabled=True) +tab4 = SessionTabWidget(session4, "") +tab4._reconnect_attempt = 5 +tab4._on_connected() +assert tab4._reconnect_attempt == 0 +tab4.close_connection() +print(" OK") + +print() +print("=== 6. Fermeture volontaire de l'onglet : jamais de reconnexion programmee ===") +session5 = make_session(reconnect_enabled=True) +tab5 = SessionTabWidget(session5, "") +tab5.close_connection() # fermeture "pour de vrai" -> _closing = True +tab5._on_disconnected("apres fermeture") +assert not tab5._reconnect_timer.isActive() +print(" OK") + +print() +print("=== 7. Reconnexion manuelle (bouton) annule un compte a rebours en cours ===") +session6 = make_session(reconnect_enabled=True, reconnect_initial_delay=100) +tab6 = SessionTabWidget(session6, "") +tab6._on_disconnected("r1") +assert tab6._reconnect_timer.isActive() +tab6.reconnect() +assert not tab6._reconnect_timer.isActive() +assert not tab6._reconnect_countdown_timer.isActive() +tab6.close_connection() +print(" OK") + +no_real_thread.stop() + +print() +print("RECONNEXION AUTOMATIQUE : TOUS LES TESTS OK") diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py new file mode 100644 index 0000000..7656c23 --- /dev/null +++ b/tests/test_keepalive.py @@ -0,0 +1,112 @@ +"""Keepalive de SSHConnection : verifie l'envoi periodique et l'abandon apres +`keepalive_count_max` echecs consecutifs, avec un transport paramiko mocke +(aucune connexion reseau reelle).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import threading +import time +from unittest.mock import MagicMock + +from PySide6.QtWidgets import QApplication + +from app.ssh_connection import SSHConnection + +app = QApplication.instance() or QApplication([]) + + +def make_connection(interval=0.05, count_max=3): + conn = SSHConnection( + host="10.0.0.1", port=22, username="root", auth_type="auto", + keepalive_enabled=True, keepalive_interval=1, keepalive_count_max=count_max, + ) + # keepalive_interval est borne a >= 1 dans __init__ (secondes reelles) : pour un + # test rapide, on contourne ensuite l'attribut directement (le code ne relit + # l'intervalle qu'a chaque iteration, une valeur fractionnaire est acceptee par + # threading.Event.wait()). + conn.keepalive_interval = interval + return conn + + +print("=== 1. Requetes reussies : le compteur d'echecs ne progresse jamais ===") +conn = make_connection() +transport = MagicMock() +transport.is_active.return_value = True +conn.client = MagicMock() +conn.client.get_transport.return_value = transport + +thread = threading.Thread(target=conn._keepalive_loop, daemon=True) +thread.start() +time.sleep(0.35) +conn._stop.set() +thread.join(timeout=2) + +calls = transport.global_request.call_count +print(f" requetes envoyees en ~0.35s (intervalle 0.05s): {calls}") +assert calls >= 3, calls +assert conn._disconnect_reason == "Connexion fermee" # jamais modifie : aucun echec +print(" OK") + +print() +print("=== 2. N echecs consecutifs -> abandon, canal ferme, raison mise a jour ===") +conn2 = make_connection(count_max=3) +transport2 = MagicMock() +transport2.is_active.return_value = True +transport2.global_request.side_effect = OSError("reseau injoignable") +conn2.client = MagicMock() +conn2.client.get_transport.return_value = transport2 +fake_channel = MagicMock() +conn2.channel = fake_channel + +thread2 = threading.Thread(target=conn2._keepalive_loop, daemon=True) +thread2.start() +thread2.join(timeout=3) + +print(f" requetes tentees avant abandon: {transport2.global_request.call_count}") +assert transport2.global_request.call_count == 3, transport2.global_request.call_count +assert conn2._stop.is_set(), "la connexion doit etre marquee pour arret" +assert fake_channel.close.called, "le canal doit etre ferme pour reveiller la boucle de lecture" +print(" raison de deconnexion:", conn2._disconnect_reason) +assert "keepalive" in conn2._disconnect_reason.lower() +print(" OK") + +print() +print("=== 3. Une reponse reussie apres des echecs repart de zero ===") +conn3 = make_connection(count_max=3) +transport3 = MagicMock() +transport3.is_active.return_value = True +_calls = {"n": 0} + + +def _flaky_then_ok(*_a, **_k): + _calls["n"] += 1 + if _calls["n"] <= 2: + raise OSError("timeout") + return None + + +transport3.global_request.side_effect = _flaky_then_ok +conn3.client = MagicMock() +conn3.client.get_transport.return_value = transport3 + +thread3 = threading.Thread(target=conn3._keepalive_loop, daemon=True) +thread3.start() +time.sleep(0.35) +conn3._stop.set() +thread3.join(timeout=2) + +print(f" requetes envoyees: {transport3.global_request.call_count} (jamais 3 echecs consecutifs)") +# la connexion ne doit pas avoir ete abandonnee malgre 2 echecs, puisqu'un succes +# est intervenu avant le 3e (le compteur d'echecs consecutifs est remis a zero) : +assert conn3._disconnect_reason == "Connexion fermee" +print(" OK") + +print() +print("=== 4. Keepalive desactive : aucun thread demarre ===") +conn4 = SSHConnection(host="10.0.0.1", port=22, username="root", auth_type="auto", keepalive_enabled=False) +conn4._start_keepalive() +assert conn4._keepalive_thread is None +print(" OK") + +print() +print("KEEPALIVE : TOUS LES TESTS OK") diff --git a/tests/test_reconnect_backoff.py b/tests/test_reconnect_backoff.py new file mode 100644 index 0000000..70c8c28 --- /dev/null +++ b/tests/test_reconnect_backoff.py @@ -0,0 +1,32 @@ +"""Calcul du delai de reconnexion automatique (backoff exponentiel plafonne).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from app.services.reconnect import compute_backoff_delay + +print("=== 1. Progression exponentielle ===") +delays = [compute_backoff_delay(n, initial_delay=5) for n in range(1, 6)] +print(" delais:", delays) +assert delays == [5, 10, 20, 40, 60], delays # le 5e (80s) est plafonne a 60s +print(" OK") + +print() +print("=== 2. Plafond respecte au-dela ===") +assert compute_backoff_delay(10, initial_delay=5, max_delay=60) == 60 +assert compute_backoff_delay(20, initial_delay=5, max_delay=120) == 120 +print(" OK") + +print() +print("=== 3. Tentative < 1 traitee comme la premiere ===") +assert compute_backoff_delay(0, initial_delay=5) == 5 +assert compute_backoff_delay(-3, initial_delay=5) == 5 +print(" OK") + +print() +print("=== 4. Delai initial personnalise ===") +assert compute_backoff_delay(1, initial_delay=10) == 10 +assert compute_backoff_delay(2, initial_delay=10) == 20 +print(" OK") + +print() +print("BACKOFF DE RECONNEXION : TOUS LES TESTS OK")