screentinker/docs/148-connection-lifecycle-analysis.md

9.4 KiB
Raw Blame History

#148 — WebSocket connection-lifecycle analysis (idle-reap / half-open)

Status: investigation + mitigation spec. No code changed, nothing deployed.

Headline

Idle-reap by Bold's edge is a plausible trigger, but it is not the whole story, and a keepalive alone will not fix it. Two facts constrain the diagnosis:

  1. The client already sends traffic every 15s (device:heartbeat) plus receives a Socket.IO ping every 30s — so a pure idle-timer above ~1530s should never reap this socket. If Bold's firewall still reaps it, the cause is DPI/SSL-inspection or a sub-15s timeout, not simple idleness — and more keepalive won't help.
  2. A Fire TV Stick survives on the SAME network + SAME server + SAME APK (#148). Whatever Bold's edge does, a well-behaved client rides through it. So the decisive difference is the client's ability to detect and recover from a silent (half-open) drop — and the MAXHUB build has a real gap there.

The fix is client self-heal + server half-open detection; the firewall config is a secondary trigger-reducer. None of this is fixed by v1.9.2 (the APK socket code is unchanged), so this needs a new player build.


Phase 0 — the keepalive stack (exact values, current main / 1.9.2)

Server (server/config.js, server/server.js):

  • Socket.IO pingInterval = 30000, pingTimeout = 30000server→client ping every 30s; worst-case dead-socket detection = 60s. (Engine.IO v4: the server pings, the client pongs; these are real WebSocket frames, so a firewall that counts frames sees traffic.)
  • heartbeatTimeout = 45000 → server marks a device offline after 45s with no device:heartbeat. Marking offline only flips DB status; it does NOT close the socket.
  • No setKeepAlive/SO_KEEPALIVE anywhere → the OS never independently detects a dead peer; the server relies solely on the 60s Engine.IO ping-timeout.
  • Transports: Socket.IO default (polling→websocket upgrade allowed). No websocket-only lock.

Client (Android, WebSocketService.kt):

  • reconnection = true, reconnectionAttempts = MAX_VALUE (infinite), delay 1s→60s, jitter 0.5, connect timeout = 20000. Configured to retry forever.
  • App-level device:heartbeat every 15s (every 4th → playlist pull). Fire-and-forget, no ACK, and guarded by if (socket.connected() != true) return.
  • Runs as a foreground service (mediaPlayback) + PARTIAL_WAKE_LOCK → not subject to Doze.
  • No app-level liveness watchdog — no last-server-message tracking, no forced reconnect; it trusts socket.connected() and the socket.io-client-java Engine.IO ping-timeout.

Phase 1 — the half-open / silent-death crux

A firewall idle-reap (or SSL-inspection session drop) typically kills the TCP silently — no FIN — leaving both ends half-open.

Server: holds the socket "connected" until its next ping gets no pong → closes on pingTimeout (~3060s), then marks offline at 45s. With no SO_KEEPALIVE, 60s is the best it can do. Acceptable, but slow, and it never proactively probes TCP health.

Client — the gap: it has no independent liveness check. On a half-open socket, socket.connected() still returns true (the lib hasn't noticed), so the 15s heartbeat keeps firing into the void and EVENT_DISCONNECT may never fire → the infinite-reconnect logic never triggers → the device sits on a dead socket = "no retry." Recovery depends entirely on the socket.io-client-java Engine.IO ping-timeout firing; if it's slow or missed on the MAXHUB's OkHttp/network stack, the client is stuck until the app/service restarts.

This exactly matches #148: server sees a clean/eventual disconnect, then nothing; the client loops or stops retrying. The two sub-cases (both consistent, distinguished only by MAXHUB logcat):

  • (a) half-open undetected → client never reconnects ("no retry").
  • (b) client does reconnect but a rapid reap→reconnect cycle trips the server flap-limiter → 30-min quarantine → reconnects refused → "loops connecting/waiting, then offline."

Fire TV reconciliation: on the same network + server + APK, the Fire TV survives — so its network stack detects the half-open (or keeps the socket warm) and reconnects cleanly, riding through the same edge behavior the MAXHUB can't. The "1.9.2 staging, days" row adds a second reason (a single idle device with no fleet, no flapping, and possibly no aggressive firewall in staging) — but the clean same-network comparison isolates the difference to the client's half-open handling.


Phase 2 — server-side contributor ruling

Candidate Verdict
Per-IP / total connection cap (SNAT'd fleet = one IP) CLEARED. No maxConnections, no per-IP handshake limit. The flap-limiter keys on identity (device_id→fingerprint→token→anon), never IP — SNAT-safe.
ScreenTinker's own proxy (nginx/short WS read timeout) CLEARED. The image runs CMD ["node","server.js"] — no proxy layer. Any WS timeout is on Bold's edge.
Anon-bucket collapse under SNAT CLEARED for reconnects. A reconnect carries the saved device_id → per-device bucket, not anon:global.
Flap-limiter / reconnect-throttle IMPLICATED as an AMPLIFIER. A reconnect on a fresh socket has currentDeviceId=nullisRefreshConnect=false → it counts toward the 20-connects/5-min limit. A repeated reap→reconnect cycle trips it → 30-min in-memory quarantine → a recoverable blip becomes a 30-min offline. Because every device behind the edge gets reaped together, each trips its own limit at ~the same time → a fleet-wide, synchronized-looking quarantine (not via a shared bucket — via synchronized independent tripping). This is 1.9.2-only (beta5 has no flap-limiter).

Phase 3 — mitigation spec (propose; do NOT implement yet)

Fixes from OUR side (make it self-heal regardless of Bold's edge)

  1. Client half-open watchdog (the primary fix). Add an app-level liveness probe: send device:heartbeat with an ACK callback + a timeout (e.g. ack expected within 10s); on a missed ack, socket.disconnect(); socket.connect() (force a fresh reconnect). Equivalently, track the last inbound server message and force-reconnect if none for >45s (> the 30s pingInterval). This detects half-open death in ~1525s regardless of the lib, and doubles as the keepalive. Requires the server to ACK the heartbeat (or emit device:heartbeat-ack). This is an Android-APK change — it is NOT in v1.9.2.
  2. Server SO_KEEPALIVE. socket.setKeepAlive(true, ~30000) on accepted connections so the OS surfaces a dead peer independently of the 60s Engine.IO ping-timeout — faster, more reliable server-side half-open detection.
  3. Don't let the flap-limiter amplify a reap. For an already-paired device_id, don't escalate to the 30-min quarantine on reconnect churn (keep the soft reconnect-throttle for loop protection, but a known device with a flaky link must keep being let back in). Scope the exemption to paired device_ids so genuine anon/unidentified flappers are still limited — do not defeat the flap-limiter globally.

Keepalive cadence (workaround — reduces the trigger, doesn't fix recovery)

  • The client already emits every 15s; the server pings every 30s. If Bold's timeout is a plain idle-timer, this already defeats it. If reaping persists, it's DPI/SSL-inspection — a keepalive can't fix that. Optionally tighten server pingInterval to ~25s as cheap insurance (still far below the flap threshold, no load concern). The heartbeat-with-ack in (1) is the real keepalive+detector; a bare keepalive is not sufficient.

Fix vs. workaround

  • Fixes it (our side): client heartbeat-with-ack + force-reconnect (1), server SO_KEEPALIVE (2), flap-limiter paired-device exemption (3). With these, any silent drop self-heals in ~1560s and is never amplified into a 30-min lockout — independent of the firewall.
  • Bold should still set (their side): raise/disable the Sophos WebSocket idle/session timeout for the ScreenTinker host; disable SSL/DPI inspection for that host (DPI is the most likely reason a 15s-active socket still gets reaped); confirm no reverse proxy with a short WS read timeout. These reduce how often the reap fires, but are not a substitute for client self-heal.

Questions for Dan to send Bold

  1. Disconnect interval during quiet periods (from #148): is it a round number (60/120/300s)? A fixed round interval ⇒ idle/session-timeout confirmed. Irregular ⇒ half-open/other.
  2. Sophos model + config: the WebSocket/idle/session-timeout value on the ScreenTinker host; is SSL/deep-packet inspection enabled for that host (this is the prime suspect for reaping a socket that already has 15s traffic)?
  3. Reverse proxy? Any nginx / HAProxy / Cloudflare Tunnel / load balancer in front of the ScreenTinker Docker container, and its WebSocket read/idle timeout?
  4. MAXHUB logcat at the moment of a drop (the decisive missing evidence): does it log EVENT_DISCONNECT (client detected the drop → recovery is the throttle/quarantine story) or nothing (half-open undetected → the client-watchdog fix is required)?
  5. Does the same MAXHUB survive when pointed at a server with no firewall in the path (e.g. direct/LAN)? Confirms edge involvement vs. a pure client timer bug.