198 lines
8.7 KiB
Python
198 lines
8.7 KiB
Python
# agent_v1/ui/session_watchdog.py
|
|
"""Watchdog de session interactive Windows — résilience RDP/Citrix.
|
|
|
|
Problème résolu (preuve poste clinique Émilie, 01/07) :
|
|
09:46:28 [MAIN] agent.run() est sorti mais agent.running=True — probablement
|
|
pystray sans session interactive (SSH)
|
|
09:46:28 [MAIN] Keepalive headless actif — main thread bloque...
|
|
|
|
Sur les postes cliniques (tous RDP/Citrix), la session interactive
|
|
disparaît quand l'utilisateur se déconnecte / la session bascule en
|
|
verrouillage. `pystray.Icon.run()` sort alors immédiatement (plus de
|
|
bureau interactif `WinSta0\\Default` pour recevoir les entrées et afficher
|
|
l'icône). L'ancien `_headless_keepalive` bloquait le main thread *pour
|
|
toujours* : l'icône tray + la fenêtre chat DISPARAISSAIENT et ne
|
|
revenaient JAMAIS, même après reconnexion RDP. Les soignants croyaient
|
|
que Léa avait planté (la capture continuait pourtant en fond).
|
|
|
|
Solution : un watchdog qui surveille la disponibilité du bureau
|
|
interactif via `OpenInputDesktop()` (signal Win32 canonique — échoue quand
|
|
la session est déconnectée/verrouillée, réussit à la reconnexion) et
|
|
(re)lance l'UI tray dès qu'une session redevient disponible. Les threads
|
|
de fond (heartbeat, replay poll, capture_server) NE SONT JAMAIS touchés :
|
|
ils tournent contre `agent.running` et restent uniques. On ne relance
|
|
JAMAIS un second `AgentV1` — seulement la couche UI (tray + chat).
|
|
|
|
État de l'art (recherche 01/07) :
|
|
- `OpenInputDesktop()` échoue (ERROR_ACCESS_DENIED / ERROR_INVALID_...)
|
|
quand le processus n'est pas rattaché au windowstation interactif
|
|
`WinSta0` — c'est exactement le cas quand la session RDP est
|
|
déconnectée. C'est la méthode fiable recommandée (comparer les
|
|
*noms* de bureau via GetUserObjectInformation n'apporte rien de plus
|
|
ici : on a juste besoin d'un booléen « input desktop dispo ? »).
|
|
- `WTSGetActiveConsoleSessionId` renvoie une pseudo-session même sans
|
|
login → PAS fiable pour ce besoin.
|
|
- `pystray.Icon.run()` ne sort jamais en session interactive normale ;
|
|
il sort immédiatement sinon → c'est notre signal de « session perdue ».
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import platform
|
|
import threading
|
|
from typing import Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Intervalle de sondage du bureau interactif (secondes).
|
|
# 3s = compromis : réactif à la reconnexion sans marteler l'API Win32.
|
|
POLL_INTERVAL_S = 3.0
|
|
|
|
|
|
def is_interactive_desktop_available() -> bool:
|
|
"""Retourne True si un bureau interactif Windows est disponible.
|
|
|
|
Utilise `OpenInputDesktop()` : succès => le windowstation interactif
|
|
(`WinSta0\\Default`) est accessible et peut afficher un tray. Échec =>
|
|
session RDP/Citrix déconnectée ou verrouillée sans bureau d'entrée.
|
|
|
|
Hors Windows (Linux/dev/tests) : renvoie toujours True (pas de notion
|
|
de bureau interactif verrouillable ici — on laisse l'UI tourner).
|
|
Toute erreur d'appel Win32 est traitée comme « indisponible » (prudent)
|
|
SAUF l'indisponibilité de l'API elle-même (pywin32 absent) → True pour
|
|
ne pas priver un poste de son tray à cause d'une dépendance manquante.
|
|
"""
|
|
if platform.system() != "Windows":
|
|
return True
|
|
|
|
try:
|
|
import win32con # type: ignore
|
|
import win32service # type: ignore
|
|
except Exception:
|
|
# pywin32 indisponible : on ne peut pas sonder → on suppose dispo
|
|
# (comportement historique : tenter l'UI plutôt que la bloquer).
|
|
logger.debug("pywin32 indisponible — sondage bureau interactif ignoré")
|
|
return True
|
|
|
|
hdesk = None
|
|
try:
|
|
# DESKTOP_SWITCHDESKTOP (0x0100) = droit minimal, aligné sur l'usage
|
|
# documenté pour tester la présence du bureau d'entrée.
|
|
hdesk = win32service.OpenInputDesktop(0, False, win32con.DESKTOP_SWITCHDESKTOP)
|
|
return hdesk is not None
|
|
except Exception:
|
|
# OpenInputDesktop lève quand aucun bureau d'entrée n'est accessible
|
|
# (session déconnectée / verrouillée). C'est le cas « indisponible ».
|
|
return False
|
|
finally:
|
|
if hdesk is not None:
|
|
try:
|
|
# PyHANDLE se ferme via .Close() (pywin32) ; fallback silencieux.
|
|
hdesk.Close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class InteractiveSessionWatchdog:
|
|
"""Surveille la session interactive et (re)lance l'UI tray à la reconnexion.
|
|
|
|
Ne détient AUCUN état de capture. Sa seule responsabilité : garantir
|
|
qu'il existe au plus UN tray vivant à la fois, et le ressusciter quand
|
|
une session interactive redevient disponible. Les daemon threads de
|
|
l'agent (heartbeat/replay/capture) sont indépendants et intacts.
|
|
|
|
Paramètres :
|
|
run_ui : callable bloquant qui lance le tray (typiquement
|
|
``agent.ui.run`` / ``agent.run``). Retourne quand le
|
|
tray sort (normal en fin de session interactive).
|
|
is_running : callable -> bool ; True tant que l'agent doit vivre
|
|
(typiquement ``lambda: agent.running``).
|
|
is_available : callable -> bool de détection de session (injectable
|
|
pour les tests). Défaut = is_interactive_desktop_available.
|
|
poll_interval_s : période de sondage quand la session est absente.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
run_ui: Callable[[], None],
|
|
is_running: Callable[[], bool],
|
|
is_available: Optional[Callable[[], bool]] = None,
|
|
poll_interval_s: float = POLL_INTERVAL_S,
|
|
) -> None:
|
|
self._run_ui = run_ui
|
|
self._is_running = is_running
|
|
self._is_available = is_available or is_interactive_desktop_available
|
|
self._poll_interval_s = poll_interval_s
|
|
self._wake = threading.Event()
|
|
# Sérialise le lancement de l'UI : jamais deux trays en parallèle.
|
|
self._ui_lock = threading.Lock()
|
|
|
|
def stop(self) -> None:
|
|
"""Réveille le watchdog pour qu'il réévalue ``is_running`` et sorte."""
|
|
self._wake.set()
|
|
|
|
def _run_ui_once(self) -> None:
|
|
"""Lance l'UI tray une fois (bloquant) sous verrou, avec garde d'erreur.
|
|
|
|
Le verrou empêche formellement qu'un second appel démarre un tray
|
|
alors qu'un premier tourne encore (invariant « un seul tray »).
|
|
"""
|
|
with self._ui_lock:
|
|
try:
|
|
self._run_ui()
|
|
except Exception:
|
|
# Un crash du tray ne doit jamais tuer le watchdog : on log et
|
|
# on laisse la boucle décider (retry ou sortie selon is_running).
|
|
logger.exception("[WATCHDOG] Le tray UI a levé une exception")
|
|
|
|
def run(self) -> None:
|
|
"""Boucle principale (bloque le main thread à la place du keepalive).
|
|
|
|
Cycle :
|
|
1. Attendre qu'un bureau interactif soit disponible.
|
|
2. (Re)lancer le tray — bloque jusqu'à sa sortie (déconnexion RDP).
|
|
3. Recommencer tant que ``is_running`` est vrai.
|
|
|
|
Ne consomme pas de CPU en boucle serrée : sonde toutes les
|
|
``poll_interval_s`` via un Event interruptible (réveil immédiat au stop).
|
|
"""
|
|
logger.info(
|
|
"[WATCHDOG] Surveillance session interactive active "
|
|
"(re-affichage auto du tray + chat à la reconnexion RDP/Citrix)."
|
|
)
|
|
first_cycle = True
|
|
|
|
while self._is_running():
|
|
if not self._is_available():
|
|
# Session absente : sonder périodiquement sans brûler le CPU.
|
|
if first_cycle:
|
|
logger.warning(
|
|
"[WATCHDOG] Aucune session interactive — Léa reste active "
|
|
"en fond (capture/heartbeat), tray masqué. En attente de "
|
|
"reconnexion RDP/Citrix pour ré-afficher l'interface."
|
|
)
|
|
# Event.wait renvoie True si stop() a été appelé → on sort.
|
|
if self._wake.wait(timeout=self._poll_interval_s):
|
|
break
|
|
first_cycle = False
|
|
continue
|
|
|
|
# Session disponible : (re)lancer le tray.
|
|
if not first_cycle:
|
|
logger.info(
|
|
"[WATCHDOG] Session interactive détectée — ré-affichage du "
|
|
"tray et de la fenêtre chat de Léa."
|
|
)
|
|
first_cycle = False
|
|
|
|
# Bloque jusqu'à la sortie du tray (fin de session interactive).
|
|
self._run_ui_once()
|
|
|
|
# Le tray est sorti. Si l'agent doit vivre, on reboucle (le
|
|
# prochain tour re-sondera la session et re-affichera le tray).
|
|
if not self._is_running():
|
|
break
|
|
|
|
logger.info("[WATCHDOG] Arrêt de la surveillance de session interactive.")
|