screentinker/docs/148-half-open-fix.md

143 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# #148 — half-open death: mechanism, synchronization, and fix ownership
**Status: investigation + fix spec. No code changed, nothing deployed.**
## Headline (corrects the leading assumption)
The premise "the SERVER sits on a half-open socket and never detects it" is **empirically
false.** The Socket.IO server **actively pings and closes dead peers** — I confirmed it closes
a non-ponging socket in `pingInterval + pingTimeout` (**1005ms** in a 500/500 test → **~60s in
prod at 30/30**). And the server **hands those ping values to the client** in the Engine.IO
handshake, so a compliant client runs the *same* ~60s dead-server detector.
Therefore:
- **The per-device fix is NOT "make the server detect half-open" — it already does.**
- The MAXHUB "no retry" is a **client-side recovery failure** (its Engine.IO ping-timeout
isn't recovering the socket) that a server change **cannot fully fix over a severed TCP**
the server's close frame can't reach a client whose TCP is dead.
- The **simultaneity is Bold-edge or a reporting artifact**, not a 1.9.2 server mechanism.
**A server-only 1.9.2.x patch can HELP (tighten detection via lower ping values, add
SO_KEEPALIVE, stop the flap-limiter amplifying, make offline↔socket consistent) but is NOT
proven sufficient alone.** Full resolution likely needs the client watchdog + Bold's edge fix.
This is stated against the Phase-B gate below.
---
## Phase A — server-side detection (confirmed by test)
| Behavior | Finding |
|---|---|
| Socket.IO `pingInterval`/`pingTimeout` | **30000 / 30000** (set, not default-off). Server sends a ping every 30s and **closes on missed pong** → dead-peer detection in **≤60s**. **Empirically verified** (`/tmp/halfopen.cjs`: 500/500 → closed at 1005ms). |
| Client parameterization | The handshake open-packet carries `pingInterval`/`pingTimeout`; `socket.io-client:2.1.0` (EIO4) uses them for its **own** ping-timeout → the client also has a ~60s dead-server detector, **driven by the server's values**. |
| SO_KEEPALIVE | **NOT set** anywhere. The OS never independently probes; Engine.IO ping is the only detector. |
| `mark-offline` vs socket close | The heartbeat checker flips DB status to `offline` at **45s** and **does NOT close the socket**; Engine.IO closes it separately at **≤60s**. So the "offline-but-open" divergence is **bounded to ~15s**, not indefinite — but the two are not explicitly consistent. |
| Server "sits forever" theory | **REFUTED.** The socket is gone within ~60s. |
**Consequence:** because the client's detector is *parameterized by the server*, lowering the
server's ping values **tightens the client's half-open detection without an APK update** — the
one genuine server-only lever, and only useful **if the client's timer fires at all**.
---
## Phase B — the synchronization GATE (explicitly addressed)
Half-open explains the *per-device* hang. For fleet-wide simultaneity, the candidates:
1. **Server tick / event-loop freeze** — a pause > pingTimeout would make **every** client's
ping-timeout fire at once (true simultaneous mass disconnect). **RULED OUT on 1.9.2:** the
chunked prune removed the freeze; alpha runs clean for days; #148 logs show no freeze/
restart. (This *was* the beta5 mechanism — the 4048s `ROW_NUMBER` prune freeze paused the
loop past pingTimeout → the whole fleet ping-timed-out together. It is the link between the
old "death spiral" and a genuine mass disconnect, and 1.9.2 fixed it.)
2. **Bold's edge — conntrack / session-table flush** behind the single SNAT IP: a periodic
firewall event (NAT table timeout, policy reload, session flush) severs **all flows behind
that IP simultaneously** → every device goes half-open at the same instant. True
simultaneity, edge-owned.
3. **Reporting artifact** — staggered client half-open deaths, then the **10s heartbeat checker
marks a batch offline on one tick** (plus the 5s deferral), so the CMS shows a synchronized
*offline wave* that does **not** correspond to a synchronized *sever*.
**Verdict on the gate:** the per-device mechanism is real, and simultaneity is explainable —
but I **cannot yet discriminate (2) a true edge-sever from (3) a reporting artifact** without
Bold's data. What I *can* state firmly: **on 1.9.2 there is no server-side synchronizer** (the
freeze is gone), so a true simultaneous *sever* would have to be edge (2). The disconnect-
interval from #148 and whether the Fire TV *also* momentarily blips are the discriminators
(questions below). **This is the honest "not fully proven" the gate asks for — the fix must not
pretend the simultaneity is ours to fix.**
**Fire TV reconciliation:** same network + server + APK, survives for days → its stack's
client-side ping-timeout fires and it reconnects cleanly (self-heals within ~60s; a brief blip
goes unnoticed). The MAXHUB on the identical path does not recover — the difference is
**client-side recovery**, not the server or the edge (both hit the Fire TV too).
---
## Phase C — the reconnect amplifier (ours, under SNAT)
- A **one-shot mass reconnect** from the single SNAT IP is **NOT refused**: each device carries
its own `device_id` → per-device flap bucket → one connect each, well under 20/5min. The
flap-limiter is identity-keyed (SNAT-safe), and reconnects carry `device_id` (not `anon`).
✅ recovery from a single flush is not blocked.
- **BUT** a *repeated* flush→reconnect cycle (or a reconnect that immediately re-drops) makes a
device accumulate connects → trips its own **20/5min → 30-min quarantine** → reconnects
refused → **sustained offline**. Because all devices trip ~together, this reads as a
synchronized fleet-wide lockout. **This is 1.9.2-only** (beta5 has no flap-limiter) and turns
a recoverable blip into a long outage. It must be fixed (Phase D-4).
---
## Does it exist in 1.9.2 as cut? / is the fix server-only?
- The **server-detection gap does not exist** in 1.9.2 (it closes dead peers in ≤60s).
- What **does** exist in 1.9.2 and is worth patching: (a) no SO_KEEPALIVE; (b) 60s detection is
slower than it needs to be, and the *client* window is server-parameterized so we can tighten
it without an APK; (c) `mark-offline` and socket-close aren't explicitly consistent; (d) the
**flap-limiter can amplify** a reconnect storm into a fleet-wide quarantine.
- **Server-only reach:** items (a)(d) are a legitimate **1.9.2.x server patch, no fleet APK
update** — and (b) tightens the *client's* detector for free. **But** if the MAXHUB's
client-side timer genuinely doesn't fire (true "no retry"), no server change wakes it over a
dead TCP → a **client watchdog is still required**, and the **edge synchronizer is Bold's**.
So: **ship the server patch (it strictly helps and de-risks the rollout), but do not tell
Bold it is guaranteed to fix #148 until the MAXHUB logcat shows the client recovers.**
---
## Fix spec (propose; do NOT implement until green-lit)
**Server (1.9.2.x, no APK):**
1. **Tighten ping cadence** — e.g. `PING_INTERVAL=20000`, `PING_TIMEOUT=20000` (→ ~40s
detection on both server AND client, since the client reads these). Do not go so low that
transient loop-lag false-disconnects healthy clients; gate/adjust with the existing loop-lag
band if lag is elevated. Env-driven, so tunable per deployment.
2. **SO_KEEPALIVE**`httpServer.on('connection', s => s.setKeepAlive(true, 20000))` (or on
engine.io's transport) as OS-level defense-in-depth.
3. **Make mark-offline consistent with socket state** — when the heartbeat checker marks a
device offline, also `disconnect(true)` any lingering socket for it, so DB-offline can never
diverge into a silent half-open (and, on a one-directional break, the client is signalled).
4. **Flap-limiter amplifier fix** — do NOT escalate a **paired `device_id`** to the 30-min
quarantine on reconnect churn (keep the soft reconnect-throttle for loop protection; a known
device with a flaky link must keep being let back in). Scope strictly to paired devices so
genuine anon/unidentified flappers are still limited — do **not** weaken the flap-limiter
globally or reintroduce the load risk it exists to prevent.
**Client (Android APK — the real per-device fix, separate release):**
5. **App-level liveness watchdog:** `device:heartbeat` with an **ACK + timeout**; on a missed
ack force `socket.disconnect(); socket.connect()`. Detects half-open in ~1525s regardless
of the lib/OEM timer behavior. (Requires a server `device:heartbeat` ack.)
**Bold (edge):** raise/disable the Sophos WS idle/session timeout for the host; **disable
SSL/DPI inspection** for it; confirm no reverse proxy with a short WS read timeout.
### Tests
- **Server closes a dead peer within pingTimeout** — a client that completes the handshake then
stops ponging is disconnected within `pingInterval+pingTimeout` (already demonstrated:
`/tmp/halfopen.cjs`, 500/500 → 1005ms). Add as a regression at a short env timeout.
- **offline ⇒ closed** — after the heartbeat checker marks a device offline, assert no open
socket remains for it (Phase D-3).
- **SNAT mass reconnect not refused** — N distinct `device_id`s reconnecting once each from the
same IP are all admitted (flap-limiter identity-keyed); and a *repeated* cycle for a **paired**
device is not quarantined (Phase D-4).
- **Client (instrumented/emulator):** after a silent transport kill, the watchdog forces a
reconnect within the ack-timeout window.