Propósito
El módulo connector.py del Local Agent gestiona la conexión WebSocket con el servidor SaaS. Desde v2.16.0, expone diagnostico del flapping mediante logging detallado de cada cierre y stats públicas de reconexión.
Nota de nomenclatura: en el código actual la clase se llama
SaaSConnector(terminal/agent/core/connector.py). El nombreWebSocketConnectorusado en las secciones históricas de abajo es el que tenía cuando se escribió este documento (v2.16.0) — el contrato descrito (connect/send/stats) sigue siendo el mismo.
Funciones principales
WebSocketConnector (clase principal)
Gestiona la conexión, reconexiones, buffers de entrada/salida.
__init__(url, token, handlers)
- Inicializa la conexión hacia
url(típicamentewss://server:port/api/agent/ws). - Token JWT del Agent (validado server-side).
- Handlers: callbacks para
on_connected,on_disconnected,on_message.
connect()
- Establece la conexión WebSocket.
- Inicia el receive loop (
_receive_loop). - Maneja reintentos con backoff exponencial (base 1.0s).
_receive_loop()
- Novedad v2.16.0: loguea SIEMPRE al salir:
close_code: código HTTP/WS del cierre (p.ej.1000 = cierre normal,1006 = abrupt,1011 = server error).duration_secs: segundos que la conexión estuvo viva.reason: string descriptivo (si aplica).- Ejemplo:
[CONNECTOR] WS closed: code=1006 (abnormal closure), duration=6.67s, reconnecting in 1.0s...
- Desde 2.27.1: este bucle ya NO ejecuta comandos en línea (ver sección CommandQueue abajo) — siempre está en
receive(), sea cual sea la duración del comando que esté procesando el trabajador.
send(message)
- Envía un mensaje JSON al servidor (coroutine async).
- Guarda en buffer si no hay conexión (se envía al reconectar).
ConnectionStats (dataclass)
Estructura que acumula stats de la conexión:
@dataclass
class ConnectionStats:
started_at: datetime
last_connected_at: Optional[datetime] = None
reconnect_count: int = 0
last_close_code: Optional[int] = None
last_error: Optional[str] = None
last_session_secs: float = 0.0
close_codes_recent: List[Tuple[int, datetime]] = field(default_factory=list) # últimas 10
Actualización:
- Cada reconexión incremental
reconnect_count. - Cada cierre registra
last_close_code+last_session_secs. - Últimos 10 close_codes guardados en
close_codes_recent(para diagnóstico post-mortem).
Endpoint /info — Sección saas.ws
Antes (v2.15.1): no exponía stats.
Desde v2.16.0: sección saas.ws incluye:
{
"saas": {
"ws": {
"connected": true,
"reconnect_count": 3539,
"last_close_code": 1006,
"last_session_secs": 6.67,
"close_codes_recent": [
{"code": 1006, "timestamp": "2026-07-21T14:23:45Z"},
{"code": 1006, "timestamp": "2026-07-21T14:23:52Z"},
...
],
"last_error": "ConnectionResetError: [WinError 10054] ...",
"uptime_secs": 123456
}
}
}
Desde 2.27.1: get_status() añade queued_commands (tamaño de la cola de CommandQueue en ese instante) — un valor alto y sostenido delata un comando lento monopolizando el trabajador.
Diagnóstico del flapping
Indicadores de inestabilidad
| Indicador | Umbral | Acción |
|---|---|---|
reconnect_count > 100/día | Alto | Investigar causa raíz (red, cloud, servidor) |
last_session_secs < 30 | Crítico | Conexión muere casi inmediatamente — fallo de handshake o timeout |
last_close_code = 1006 (abnormal) | Anómalo | Red inestable o cierre por timeout (no es limpio) |
last_close_code = 1011 (server error) | Error del server | SaaS devuelve error interno |
last_close_code = 1000 (normal) | OK | Cierre limpio (raro en reconexión, common en shutdown) |
queued_commands alto y sostenido | Anómalo (2.27.1+) | Un comando (deep discovery contra equipo mudo) monopoliza el trabajador — no bloquea ya el receive loop, pero sí retrasa el resto de encargos |
Interpretación de close_codes
| Código | Nombre | Causa típica |
|---|---|---|
| 1000 | Normal Closure | Cierre limpio (raramente observado en flapping) |
| 1006 | Abnormal Closure | Red inestable, timeout TCP, no-response |
| 1011 | Server Error | SaaS devolvió error interno (5xx) |
| 4000-4999 | Custom | Errores de aplicación (p.ej. auth fallida, tenant inválido) |
Post-mortem de YogaEdu (21-07-2026)
- Síntomas: 3.539 reconexiones en 24h.
- close_codes_recent: mayormente
1006(abnormal). - last_session_secs: 6.67s (consistente, no random).
- Correlación: cada cierre correlaciona con POST de 1000 métricas en tránsito.
- Hipótesis: saturación de uplink (office VPN) + event loop server bloqueado.
- Solución: throttle del sync (v2.16.0) para reducir bombardeo de POST.
Post-mortem CCIB (07-09-2026)
Segundo incidente de flapping en el mismo portátil (YogaEdu), causa raíz distinta a la de julio — detalle completo en [[incident—20260907—ccib-comando-bloqueante-websocket]]:
- Síntoma: desconexión de 3 min (
close_code=1006) sin fallo de red (los POST de métricas del mismo minuto respondían 200) ni del servidor. - Causa raíz: un deep discovery contra un equipo mudo (
172.25.10.201, aislado por una ACL del switch) se ejecutaba EN LÍNEA dentro del bucle de lectura del websocket — ~4 min de timeouts SNMP dejaban el socket sin leer, aiohttp llenaba su buffer de entrada (128 KB) con los siguientes encargos, pausaba la lectura TCP y con ella el PONG del latido (heartbeat=30, pong esperado en 15s) → conexión declarada muerta mientras el Agente seguía procesando lo ya buffereado. - Fix:
CommandQueue(ver sección siguiente) saca la ejecución de comandos del bucle de lectura;_probe_alive()endeep_discovery.pyfalla en ~10s contra un equipo mudo en vez de ~4 min.
CommandQueue — cola de comandos con un solo trabajador (2.27.1)
Módulo nuevo terminal/agent/core/command_queue.py::CommandQueue. Antes de 2.27.1, cada comando que llegaba por el websocket (command, device_op, handlers registrados) se ejecutaba awaiteado dentro de _receive_loop() — un comando lento (deep discovery, ver post-mortem CCIB arriba) dejaba el socket sin leer.
SaaSConnector.__init__crea una instanciaself._commands = CommandQueue()._receive_loop()ya no haceawait self._dispatch_command(...); haceself._commands.enqueue(lambda: self._dispatch_command(...))y vuelve enseguida areceive().CommandQueuecorre un único_worker_loop()en una tarea propia: los comandos se siguen ejecutando uno detrás de otro, en el mismo orden de llegada — el cambio es que ya no comparten tarea con la lectura del socket.- Un comando que lanza excepción no para el trabajador (se loguea y sigue con el siguiente).
- La cola sobrevive a reconexiones del websocket:
disconnect()yforce_reset()llaman aself._commands.stop(), que cancela el comando en curso (su resultado, si viajaba porsend(), ya se descartaba antes igual al caerse el socket). get_status()exponequeued_commands(ver tabla de indicadores arriba).
Logging detallado
Cada operación clave se loguea en agent.log:
[2026-07-21 14:23:45] [connector.py] INFO: WS connected (attempt 1, 0 reconnections)
[2026-07-21 14:23:52] [connector.py] INFO: WS closed: code=1006, duration=6.67s, reason=abnormal_closure
[2026-07-21 14:23:52] [connector.py] INFO: Reconnecting in 1.0s... (attempt 2)
[2026-07-21 14:23:53] [connector.py] INFO: WS connected (attempt 2, 1 reconnection)
...
Antes (v2.15.1): solo había INFO: connected y nada más.
Integración con sync.py
El connector dispara callbacks:
on_connected(): sync.py evalúa si necesita resync (desde v2.16.0, basado en downtime).on_disconnected(): sync.py registra timestamp de desconexión.on_message(msg): sync.py procesa comandos del servidor (p.ej. device_op, config_change).
Tests
- Test de logging:
test_connector_logs_close_code(verifica que cada cierre se loguea). - Test de stats:
test_connector_stats_reconnect_count(counter incremental). - Test de info endpoint:
test_info_saas_ws_section(verifica que/infoexpone stats). - Coverage: 3 tests nuevos en v2.16.0 (parte de los 8 del throttle).
tests/agent/test_agent_2271.py(2.27.1, 7 tests):_handle_messagevuelve sin esperar al handler, orden y exclusividad de la cola, un handler roto no la para,force_resetcancela el comando en curso.
Referencias de código
- Archivo:
terminal/agent/core/connector.py+terminal/agent/core/command_queue.py(nuevo, 2.27.1). - Última actualización: 2026-09-07 (commit 22604b8, Agente 2.27.1).
- Clase pública:
SaaSConnector → connect(), send(), disconnect(), force_reset(), get_status(). - Stats:
ConnectionStats(expuesta en/info);CommandQueue.pending(expuesta enget_status()comoqueued_commands).
Véase también
- [[feature—agent—resync-throttle-v2160]]
- [[entity—terminal—service—sync-throttle]]
- [[incident—20260907—ccib-comando-bloqueante-websocket]]
Referenciado desde
- Agent 2.16.0 — Throttle del resync histórico + diagnóstico del flapping WS + aviso de portátil
- CCIB 07-09-2026 — un deep discovery contra un equipo mudo bloqueó el websocket y tiró al Agente 3 minutos
- Servicio SaaS-Commands del Local Agent (terminal/agent/core/saas_commands.py)
- Servicio Sentinel-Runtime del Local Agent (terminal/agent/core/sentinel_runtime.py)
- Servicio Sync-Throttle del Local Agent (terminal/agent/core/sync.py)