# WebSocket Client Robustness: Parse Guard, Reconnect Backoff, Dead-Channel Fallback Priority: **High** · Effort: **Low–Medium** · Risk: **Low** ## Problem The WebSocket client in `public/channelSync.js` is brittle in three ways that can leave the UI silently desynced or hammering a dead server/channel: 1. **`JSON.parse(e.data)` has no try/catch** (`channelSync.js:262`). A single malformed or partial frame throws, the `onmessage` handler aborts, and the client stops processing all further state updates while the socket appears "open." 2. **Reconnect has no backoff, no jitter, no max-retries** (`channelSync.js:353-369`). A down server produces a fixed 2–3s reconnect storm with no cap. 3. **No fallback to the default channel**. If the current channel is deleted server-side while the client is disconnected, every reconnect 404s and the client loops forever against a dead channel ID. Separately, the message dispatcher falls through to `M.handleUpdate(data)` for any unknown `data.type` (`channelSync.js:346`), so a new server message type silently corrupts state. Worth hardening in the same pass. ## Affected Locations - `public/channelSync.js:262` — unguarded `JSON.parse` - `public/channelSync.js:346` — `handleUpdate` fallback for unknown types - `public/channelSync.js:353-369` — reconnect logic - `public/channelSync.js:245-261` — socket setup (reference for structure) ## Implementation Plan ### Step 1 — Wrap `JSON.parse` in try/catch ```js M.ws.onmessage = (e) => { let data; try { data = JSON.parse(e.data); } catch (err) { console.warn("[channelSync] Dropping malformed WS frame:", err); return; } // ... existing dispatch ... }; ``` Keep behavior: a bad frame is dropped, the socket stays open, subsequent good frames still process. ### Step 2 — Strict dispatch with explicit unknown-type handling Replace the implicit fall-through to `M.handleUpdate(data)`. Make the dispatch explicit: ```js switch (data.type) { case "channel_list": /* ... */ break; case "switched": /* ... */ break; case "track": /* fallthrough */ case "state": M.handleUpdate(data); break; // explicit state-shape types only // ... all other known types ... default: console.warn("[channelSync] Unknown WS message type:", data.type, data); } ``` (If the server currently sends `type` values other than the ones handled, gather the full set first via `grep` for `"type":` / `type: "` in the server broadcast paths and list them in this switch.) ### Step 3 — Reconnect with exponential backoff + jitter + max attempts Track reconnect state on `M` (e.g. `M.wsReconnect = { attempts: 0, timer: null }`). On close: ```js M.ws.onclose = () => { M.ws = null; // clear any user-facing "connected" state if (!M.wantSync) return; const recon = M.wsReconnect; recon.attempts++; const base = 1000; // 1s const cap = 30000; // 30s max const exp = Math.min(cap, base * 2 ** recon.attempts); const jitter = Math.random() * 500; // 0–500ms const delay = exp + jitter; recon.timer = setTimeout(() => { connectChannel(M.currentChannelId); }, delay); }; ``` Reset `recon.attempts = 0` on a successful `open`. Cancel `recon.timer` on any explicit/manual `connectChannel` call to avoid double-connects. **On max attempts**: rather than giving up entirely (a music app should keep trying), cap the delay at 30s but keep retrying. Optionally surface a "reconnecting…" indicator after the first few failures so the user knows the stream is stale. ### Step 4 — Dead-channel fallback In `connectChannel`, if the upgrade/open fails or the server responds with a channel-not-found error message (the server already sends `{ type: "error", message: "Channel not found" }` per `websocket.ts:50`), fall back to the default channel: ```js // inside onmessage error handler: if (data.type === "error" && /not found/i.test(data.message)) { console.warn(`[channelSync] Channel ${M.currentChannelId} not found, falling back to default`); // fetch default channel id from /api/channels (isDefault === true) const def = await fetchDefaultChannelId(); if (def && def !== M.currentChannelId) { M.currentChannelId = def; M.saveChannelId(def); connectChannel(def); M.showToast("This channel no longer exists — switched to the default channel."); return; } } ``` Add a small helper `fetchDefaultChannelId()` that GETs `/api/channels` and returns the `id` where `isDefault === true`. Cache it on `M.defaultChannelId` after first successful list load so we don't re-fetch on every failure. ## Validation - **Malformed frame**: from server console or a test, `ws.send("not json")` to a client — confirm the client logs a warning and continues processing subsequent valid frames (state still updates). - **Server kill / restart**: stop the server, confirm reconnect attempts grow with backoff (log each attempt + delay) rather than firing every 2s. Restart, confirm the client reconnects and `attempts` resets. - **Channel deletion while disconnected**: note a non-default channel ID, stop server, delete the channel row from `blastoise.db`, restart server, ensure the client reconnects → gets "not found" → falls back to the default channel and shows the toast. - **Dispatch**: inject an unknown `{ type: "future_thing" }` message server-side (or via a debug WS send) and confirm it logs a warning and does **not** call `handleUpdate`. ## Risk / Rollback - All changes are additive hardening; normal-path behavior is preserved. - Backoff introduces up to a 30s worst-case reconnect delay — acceptable and better than a tight loop. If a deployment wants faster recovery, tune `base`/`cap`. - Default-channel fallback adds one HTTP fetch on the error path only; cache the result to avoid repeats. - Rollback = revert the file; no schema or wire-protocol change.