net: sync serverInfo version with web version and reconnect on first missed heartbeat

This commit is contained in:
Jage9
2026-02-22 18:24:53 -05:00
parent c7c30f234d
commit 0cfba9f1a7
5 changed files with 34 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -78,7 +78,6 @@ const MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD = 0.003;
const MIC_INPUT_GAIN_SCALE_MULTIPLIER = 2; const MIC_INPUT_GAIN_SCALE_MULTIPLIER = 2;
const MIC_INPUT_GAIN_STEP = 0.05; const MIC_INPUT_GAIN_STEP = 0.05;
const HEARTBEAT_INTERVAL_MS = 10_000; const HEARTBEAT_INTERVAL_MS = 10_000;
const HEARTBEAT_TIMEOUT_MS = 25_000;
declare global { declare global {
interface Window { interface Window {
@@ -199,6 +198,7 @@ let helpViewerIndex = 0;
let heartbeatTimerId: number | null = null; let heartbeatTimerId: number | null = null;
let heartbeatLastPongAt = 0; let heartbeatLastPongAt = 0;
let heartbeatNextPingId = -1; let heartbeatNextPingId = -1;
let heartbeatAwaitingPong = false;
let reconnectInFlight = false; let reconnectInFlight = false;
let activeServerInstanceId: string | null = null; let activeServerInstanceId: string | null = null;
let audioLayers: AudioLayerState = { let audioLayers: AudioLayerState = {
@@ -1050,23 +1050,25 @@ function stopHeartbeat(): void {
heartbeatTimerId = null; heartbeatTimerId = null;
} }
heartbeatLastPongAt = 0; heartbeatLastPongAt = 0;
heartbeatAwaitingPong = false;
} }
/** Sends one heartbeat ping packet using reserved negative ids. */ /** Sends one heartbeat ping packet using reserved negative ids. */
function sendHeartbeatPing(): void { function sendHeartbeatPing(): void {
signaling.send({ type: 'ping', clientSentAt: heartbeatNextPingId }); signaling.send({ type: 'ping', clientSentAt: heartbeatNextPingId });
heartbeatNextPingId -= 1; heartbeatNextPingId -= 1;
heartbeatAwaitingPong = true;
} }
/** Starts heartbeat timer for stale-connection detection. */ /** Starts heartbeat timer for stale-connection detection. */
function startHeartbeat(): void { function startHeartbeat(): void {
stopHeartbeat(); stopHeartbeat();
heartbeatLastPongAt = Date.now(); heartbeatLastPongAt = Date.now();
heartbeatAwaitingPong = false;
sendHeartbeatPing(); sendHeartbeatPing();
heartbeatTimerId = window.setInterval(() => { heartbeatTimerId = window.setInterval(() => {
if (!state.running) return; if (!state.running) return;
const now = Date.now(); if (heartbeatAwaitingPong) {
if (now - heartbeatLastPongAt > HEARTBEAT_TIMEOUT_MS) {
void reconnectAfterHeartbeatTimeout(); void reconnectAfterHeartbeatTimeout();
return; return;
} }
@@ -1188,6 +1190,7 @@ const onAppMessage = createOnMessageHandler({
async function onSignalingMessage(message: IncomingMessage): Promise<void> { async function onSignalingMessage(message: IncomingMessage): Promise<void> {
if (message.type === 'pong' && message.clientSentAt < 0) { if (message.type === 'pong' && message.clientSentAt < 0) {
heartbeatLastPongAt = Date.now(); heartbeatLastPongAt = Date.now();
heartbeatAwaitingPong = false;
return; return;
} }
let restartAnnouncement: string | null = null; let restartAnnouncement: string | null = null;

View File

@@ -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. - 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). - 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: - After reconnect, if `welcome.serverInfo.instanceId` changed, client announces:
`Server restarted, version <version>.` `Server restarted, version <version>.`

View File

@@ -47,7 +47,7 @@ Core incoming message effects:
## Stale Connection Recovery ## Stale Connection Recovery
- While running, client sends heartbeat `ping` every 10 seconds. - 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. - If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart/version.
## Disconnect/Cleanup ## Disconnect/Cleanup

View File

@@ -8,6 +8,8 @@ from datetime import datetime
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
import json import json
import logging import logging
import os
import re
import ssl import ssl
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -90,10 +92,31 @@ class SignalingServer:
self.item_last_use_ms: dict[str, int] = {} self.item_last_use_ms: dict[str, int] = {}
self.grid_size = max(1, grid_size) self.grid_size = max(1, grid_size)
self.instance_id = str(uuid.uuid4()) 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: 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: except PackageNotFoundError:
self.server_version = "unknown" return "unknown"
@property @property
def items(self) -> dict[str, WorldItem]: def items(self) -> dict[str, WorldItem]: