net: sync serverInfo version with web version and reconnect on first missed heartbeat
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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<void> {
|
||||
if (message.type === 'pong' && message.clientSentAt < 0) {
|
||||
heartbeatLastPongAt = Date.now();
|
||||
heartbeatAwaitingPong = false;
|
||||
return;
|
||||
}
|
||||
let restartAnnouncement: string | null = null;
|
||||
|
||||
@@ -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 <version>.`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user