CreaRack-SL

Servicio Connector WebSocket — Diagnóstico del Flapping (terminal/agent/core/connector.py)

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 nombre WebSocketConnector usado 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ípicamente wss://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

IndicadorUmbralAcción
reconnect_count > 100/díaAltoInvestigar causa raíz (red, cloud, servidor)
last_session_secs < 30CríticoConexión muere casi inmediatamente — fallo de handshake o timeout
last_close_code = 1006 (abnormal)AnómaloRed inestable o cierre por timeout (no es limpio)
last_close_code = 1011 (server error)Error del serverSaaS devuelve error interno
last_close_code = 1000 (normal)OKCierre limpio (raro en reconexión, common en shutdown)
queued_commands alto y sostenidoAnó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ódigoNombreCausa típica
1000Normal ClosureCierre limpio (raramente observado en flapping)
1006Abnormal ClosureRed inestable, timeout TCP, no-response
1011Server ErrorSaaS devolvió error interno (5xx)
4000-4999CustomErrores 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() en deep_discovery.py falla 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 instancia self._commands = CommandQueue().
  • _receive_loop() ya no hace await self._dispatch_command(...); hace self._commands.enqueue(lambda: self._dispatch_command(...)) y vuelve enseguida a receive().
  • CommandQueue corre 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() y force_reset() llaman a self._commands.stop(), que cancela el comando en curso (su resultado, si viajaba por send(), ya se descartaba antes igual al caerse el socket).
  • get_status() expone queued_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 /info expone 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_message vuelve sin esperar al handler, orden y exclusividad de la cola, un handler roto no la para, force_reset cancela 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 en get_status() como queued_commands).

Véase también

  • [[feature—agent—resync-throttle-v2160]]
  • [[entity—terminal—service—sync-throttle]]
  • [[incident—20260907—ccib-comando-bloqueante-websocket]]