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 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 02:27:19 +02:00
parent fe938a47b0
commit 9c8017cebd
8 changed files with 499 additions and 6 deletions

View File

@@ -80,6 +80,8 @@ class SessionDialog(QDialog):
layout.addLayout(form) layout.addLayout(form)
layout.addWidget(self._build_bastion_group()) 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)") self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)")
if self.quick_mode: if self.quick_mode:
@@ -260,6 +262,59 @@ class SessionDialog(QDialog):
bastion.key_path = self.bastion_key_combo.currentData() or "" bastion.key_path = self.bastion_key_combo.currentData() or ""
return bastion 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: def should_save(self) -> bool:
"""En connexion rapide, indique si la session doit aussi etre enregistree.""" """En connexion rapide, indique si la session doit aussi etre enregistree."""
return self.save_check.isChecked() if self.quick_mode else True 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.key_path = self.key_combo.currentData() or ""
self.session.notes = self.notes_edit.toPlainText() self.session.notes = self.notes_edit.toPlainText()
self.session.bastion = self._result_bastion() 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() self.accept()
def result_session(self) -> Session: def result_session(self) -> Session:

12
app/services/reconnect.py Normal file
View File

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

View File

@@ -66,6 +66,19 @@ class Session:
notes: str = "" notes: str = ""
bastion: BastionConfig = field(default_factory=BastionConfig) 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: def to_dict(self) -> dict:
return asdict(self) return asdict(self)

View File

@@ -57,6 +57,9 @@ class SSHConnection(QThread):
open_sftp_on_connect: bool = False, open_sftp_on_connect: bool = False,
bastion_target: BastionTarget | None = None, bastion_target: BastionTarget | None = None,
bastion_password: str = "", bastion_password: str = "",
keepalive_enabled: bool = False,
keepalive_interval: int = 30,
keepalive_count_max: int = 3,
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
@@ -74,11 +77,16 @@ class SSHConnection(QThread):
self.rows = rows self.rows = rows
self.bastion_target = bastion_target self.bastion_target = bastion_target
self.bastion_password = bastion_password 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.client: paramiko.SSHClient | None = None
self.channel: paramiko.Channel | None = None self.channel: paramiko.Channel | None = None
self._bastion_client: paramiko.SSHClient | None = None self._bastion_client: paramiko.SSHClient | None = None
self._stop = threading.Event() self._stop = threading.Event()
self._disconnect_reason = "Connexion fermee"
self._keepalive_thread: threading.Thread | None = None
self._trust_event = threading.Event() self._trust_event = threading.Event()
self._trust_result = False self._trust_result = False
@@ -259,6 +267,44 @@ class SSHConnection(QThread):
f"{self.host}:{self.port} ({exc})" f"{self.host}:{self.port} ({exc})"
) from 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 ---------- # ---------- cycle de vie du thread ----------
def run(self) -> None: def run(self) -> None:
try: try:
@@ -283,12 +329,14 @@ class SSHConnection(QThread):
if not self.open_shell: if not self.open_shell:
raise raise
self._start_keepalive()
if not self.open_shell: if not self.open_shell:
# Connexion dediee au SFTP : on garde la session vivante sans shell. # Connexion dediee au SFTP : on garde la session vivante sans shell.
self.connected.emit() self.connected.emit()
while not self._stop.is_set(): while not self._stop.is_set():
self._stop.wait(0.2) self._stop.wait(0.2)
self.disconnected.emit("Connexion fermee") self.disconnected.emit(self._disconnect_reason)
return return
self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows) self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows)
@@ -315,7 +363,7 @@ class SSHConnection(QThread):
except Exception: except Exception:
break break
self.disconnected.emit("Connexion fermee") self.disconnected.emit(self._disconnect_reason)
except paramiko.AuthenticationException: except paramiko.AuthenticationException:
self.error.emit("Authentification refusee (identifiants ou cle invalides).") self.error.emit("Authentification refusee (identifiants ou cle invalides).")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001

View File

@@ -11,7 +11,7 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path 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.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter, 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.tunnels import TunnelManager
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change 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.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 # sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
ANSI_ESCAPE_RE = re.compile( ANSI_ESCAPE_RE = re.compile(
@@ -104,6 +105,8 @@ class BaseTerminalTab(QWidget):
self.connection = None # assigne par les sous-classes self.connection = None # assigne par les sous-classes
self._log_file = None self._log_file = None
self._connection_generation = 0
self._closing = False
# ---------- journalisation ---------- # ---------- journalisation ----------
def toggle_logging(self) -> None: def toggle_logging(self) -> None:
@@ -147,20 +150,34 @@ class BaseTerminalTab(QWidget):
self.stop_logging() self.stop_logging()
def _wire_connection(self) -> None: 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.terminal.feed)
self.connection.data_received.connect(self._log_output) self.connection.data_received.connect(self._log_output)
self.connection.disconnected.connect(self._on_disconnected) self.connection.disconnected.connect(
self.connection.error.connect(self._on_error) 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.data_to_send.connect(self.connection.send)
self.terminal.resized.connect(self.connection.resize) 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): def _create_connection(self):
"""Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui """Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui
permet de la recreer a l'identique lors d'une reconnexion.""" permet de la recreer a l'identique lors d'une reconnexion."""
raise NotImplementedError raise NotImplementedError
def start_connection(self) -> None: def start_connection(self) -> None:
self._connection_generation += 1
self.connection = self._create_connection() self.connection = self._create_connection()
self._wire_connection() self._wire_connection()
self.connection.start() self.connection.start()
@@ -206,6 +223,7 @@ class BaseTerminalTab(QWidget):
self.search_status.setText("" if found else "aucun resultat") self.search_status.setText("" if found else "aucun resultat")
def close_connection(self) -> None: def close_connection(self) -> None:
self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue
self.stop_logging() self.stop_logging()
if self.connection: if self.connection:
self.connection.close_connection() self.connection.close_connection()
@@ -244,6 +262,19 @@ class SessionTabWidget(BaseTerminalTab):
self._secret = secret self._secret = secret
self.sftp_toggle.toggled.connect(self._toggle_sftp) 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() self.start_connection()
def _create_connection(self): def _create_connection(self):
@@ -261,6 +292,9 @@ class SessionTabWidget(BaseTerminalTab):
open_sftp_on_connect=True, open_sftp_on_connect=True,
bastion_target=self._bastion_target, bastion_target=self._bastion_target,
bastion_password=self._bastion_password, 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.sftp_ready.connect(self._on_sftp_ready)
connection.password_prompt.connect(self._on_password_prompt) 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") self.tunnel_btn.setText(f"🔀 Tunnels ({actifs})" if actifs else "🔀 Tunnels")
def _on_connected(self) -> None: 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}") 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 transport = self.connection.client.get_transport() if self.connection.client else None
if transport: if transport:
@@ -290,6 +325,57 @@ class SessionTabWidget(BaseTerminalTab):
def _on_disconnected(self, reason: str) -> None: def _on_disconnected(self, reason: str) -> None:
super()._on_disconnected(reason) super()._on_disconnected(reason)
self.sftp_toggle.setEnabled(False) 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: def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
accepted = confirm_host_key(self, hostname, key_type, fingerprint) 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()) return paramiko.SFTPClient.from_transport(self.connection.client.get_transport())
def close_connection(self) -> None: def close_connection(self) -> None:
self._cancel_reconnect_timers()
if self.sftp_panel: if self.sftp_panel:
self.sftp_panel.shutdown() self.sftp_panel.shutdown()
if self.tunnel_manager: if self.tunnel_manager:
@@ -380,6 +467,9 @@ class SFTPTabWidget(QWidget):
open_sftp_on_connect=True, open_sftp_on_connect=True,
bastion_target=bastion_target, bastion_target=bastion_target,
bastion_password=bastion_password, 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.sftp_ready.connect(self._on_sftp_ready)
self.connection.password_prompt.connect(self._on_password_prompt) self.connection.password_prompt.connect(self._on_password_prompt)

View File

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

112
tests/test_keepalive.py Normal file
View File

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

View File

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