diff --git a/client/public/version.js b/client/public/version.js index fd02007..36a1176 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.22 R165"; +window.CHGRID_WEB_VERSION = "2026.02.22 R166"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index a4301ae..07ad8e1 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -78,6 +78,8 @@ const MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD = 0.003; const MIC_INPUT_GAIN_SCALE_MULTIPLIER = 2; const MIC_INPUT_GAIN_STEP = 0.05; const HEARTBEAT_INTERVAL_MS = 10_000; +const RECONNECT_DELAY_MS = 2_000; +const RECONNECT_MAX_ATTEMPTS = 3; declare global { interface Window { @@ -512,6 +514,10 @@ function handleSignalingStatus(message: string): void { if (message === 'Connected.') { return; } + if (message === 'Disconnected.' && state.running && !reconnectInFlight) { + void reconnectAfterSocketClose(); + return; + } pushChatMessage(message); } @@ -1109,16 +1115,37 @@ function startHeartbeat(): void { /** Performs one reconnect attempt when heartbeat timeout indicates stale signaling. */ async function reconnectAfterHeartbeatTimeout(): Promise { + await reconnectWithRetry('heartbeat'); +} + +/** Performs immediate reconnect when websocket closes unexpectedly. */ +async function reconnectAfterSocketClose(): Promise { + await reconnectWithRetry('socketClose'); +} + +/** Reconnects after disconnect with delay and bounded retry attempts. */ +async function reconnectWithRetry(reason: 'heartbeat' | 'socketClose'): Promise { if (reconnectInFlight || !state.running) return; reconnectInFlight = true; stopHeartbeat(); - pushChatMessage('Connection stale. Reconnecting...'); - disconnect(); - try { - await connect(); - } finally { - reconnectInFlight = false; + if (reason === 'heartbeat') { + pushChatMessage('Connection stale. Reconnecting...'); } + disconnect(); + for (let attempt = 1; attempt <= RECONNECT_MAX_ATTEMPTS; attempt += 1) { + await new Promise((resolve) => window.setTimeout(resolve, RECONNECT_DELAY_MS)); + await connect(); + if (state.running) { + reconnectInFlight = false; + return; + } + if (attempt < RECONNECT_MAX_ATTEMPTS) { + pushChatMessage(`Reconnect attempt ${attempt} failed. Retrying...`); + } + } + pushChatMessage('Reconnect failed after 3 attempts. Press Connect to retry.'); + audio.sfxUiCancel(); + reconnectInFlight = false; } /** Builds dependencies shared by connect/disconnect flow helpers. */ diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 251f6ee..4620baa 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -61,7 +61,9 @@ This is a behavior guide for packet semantics beyond raw schemas. - Client sends automatic heartbeat `ping` packets every 10 seconds while connected. - Heartbeat pings use negative `clientSentAt` ids and are internal (not user-visible ping status). -- If a heartbeat `pong` is missed for one interval (10 seconds), client force-disconnects and reconnects. +- If websocket close is observed unexpectedly, client starts reconnect flow. +- If a heartbeat `pong` is missed for one interval (10 seconds), client also starts reconnect flow. +- Reconnect flow waits 2 seconds and retries up to 3 times before stopping. - After reconnect, if `welcome.serverInfo.instanceId` changed, client announces `Server restarted.` - Client emits `Connected. Version .` after each `welcome`. - If `welcome.serverInfo.version` differs from running client version, client auto-reloads. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 50ce0ad..b7e449a 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -47,8 +47,10 @@ Core incoming message effects: ## Stale Connection Recovery -- While running, client sends heartbeat `ping` every 10 seconds. -- If one heartbeat `pong` is missed (10-second interval), client auto-reconnects. +- If websocket closes unexpectedly, client starts reconnect flow immediately. +- While running, client also sends heartbeat `ping` every 10 seconds (fallback for silent half-open cases). +- If one heartbeat `pong` is missed (10-second interval), client starts reconnect flow. +- Reconnect flow waits 2 seconds and retries up to 3 times. - If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart. - Connect/reconnect status message is emitted from `welcome` and includes server version.