From 0cfba9f1a7d964f639f3b8ae643c8f82b9cf68e1 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 22 Feb 2026 18:24:53 -0500 Subject: [PATCH] net: sync serverInfo version with web version and reconnect on first missed heartbeat --- client/public/version.js | 2 +- client/src/main.ts | 9 ++++++--- docs/protocol-notes.md | 2 +- docs/runtime-flow.md | 2 +- server/app/server.py | 27 +++++++++++++++++++++++++-- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 6941f1b..9612169 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 R160"; +window.CHGRID_WEB_VERSION = "2026.02.22 R161"; // 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 f12a5b6..f092a3c 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -78,7 +78,6 @@ 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 HEARTBEAT_TIMEOUT_MS = 25_000; declare global { interface Window { @@ -199,6 +198,7 @@ let helpViewerIndex = 0; let heartbeatTimerId: number | null = null; let heartbeatLastPongAt = 0; let heartbeatNextPingId = -1; +let heartbeatAwaitingPong = false; let reconnectInFlight = false; let activeServerInstanceId: string | null = null; let audioLayers: AudioLayerState = { @@ -1050,23 +1050,25 @@ function stopHeartbeat(): void { heartbeatTimerId = null; } heartbeatLastPongAt = 0; + heartbeatAwaitingPong = false; } /** Sends one heartbeat ping packet using reserved negative ids. */ function sendHeartbeatPing(): void { signaling.send({ type: 'ping', clientSentAt: heartbeatNextPingId }); heartbeatNextPingId -= 1; + heartbeatAwaitingPong = true; } /** Starts heartbeat timer for stale-connection detection. */ function startHeartbeat(): void { stopHeartbeat(); heartbeatLastPongAt = Date.now(); + heartbeatAwaitingPong = false; sendHeartbeatPing(); heartbeatTimerId = window.setInterval(() => { if (!state.running) return; - const now = Date.now(); - if (now - heartbeatLastPongAt > HEARTBEAT_TIMEOUT_MS) { + if (heartbeatAwaitingPong) { void reconnectAfterHeartbeatTimeout(); return; } @@ -1188,6 +1190,7 @@ const onAppMessage = createOnMessageHandler({ async function onSignalingMessage(message: IncomingMessage): Promise { if (message.type === 'pong' && message.clientSentAt < 0) { heartbeatLastPongAt = Date.now(); + heartbeatAwaitingPong = false; return; } let restartAnnouncement: string | null = null; diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 9668646..cebaea6 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -61,6 +61,6 @@ 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 no heartbeat `pong` arrives within 25 seconds, client force-disconnects and reconnects. +- If a heartbeat `pong` is missed for one interval (10 seconds), client force-disconnects and reconnects. - After reconnect, if `welcome.serverInfo.instanceId` changed, client announces: `Server restarted, version .` diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 9097a57..8d602f5 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -47,7 +47,7 @@ Core incoming message effects: ## Stale Connection Recovery - While running, client sends heartbeat `ping` every 10 seconds. -- If no heartbeat `pong` is observed for 25 seconds, client auto-reconnects. +- If one heartbeat `pong` is missed (10-second interval), client auto-reconnects. - If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart/version. ## Disconnect/Cleanup diff --git a/server/app/server.py b/server/app/server.py index f41ced5..a6a017f 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -8,6 +8,8 @@ from datetime import datetime from importlib.metadata import PackageNotFoundError, version as package_version import json import logging +import os +import re import ssl import uuid from pathlib import Path @@ -90,10 +92,31 @@ class SignalingServer: self.item_last_use_ms: dict[str, int] = {} self.grid_size = max(1, grid_size) self.instance_id = str(uuid.uuid4()) + self.server_version = self._resolve_server_version() + + @staticmethod + def _resolve_server_version() -> str: + """Resolve serverInfo version, preferring synced web version when available.""" + + env_override = os.getenv("CHGRID_SERVER_VERSION", "").strip() + if env_override: + return env_override + try: - self.server_version = package_version("chgrid-server") + version_file = Path(__file__).resolve().parents[2] / "client" / "public" / "version.js" + text = version_file.read_text(encoding="utf-8") + match = re.search(r'CHGRID_WEB_VERSION\s*=\s*"([^"]+)"', text) + if match: + token = match.group(1).strip() + if token: + return token + except OSError: + pass + + try: + return package_version("chgrid-server") except PackageNotFoundError: - self.server_version = "unknown" + return "unknown" @property def items(self) -> dict[str, WorldItem]: