diff --git a/client/public/version.js b/client/public/version.js index 175da18..6941f1b 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 R159"; +window.CHGRID_WEB_VERSION = "2026.02.22 R160"; // 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 b4b1aa6..f12a5b6 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -77,6 +77,8 @@ const MIC_CALIBRATION_TARGET_RMS = 0.12; 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 { @@ -194,6 +196,11 @@ let pendingEscapeDisconnect = false; let micGainLoopbackRestoreState: boolean | null = null; let helpViewerLines: string[] = []; let helpViewerIndex = 0; +let heartbeatTimerId: number | null = null; +let heartbeatLastPongAt = 0; +let heartbeatNextPingId = -1; +let reconnectInFlight = false; +let activeServerInstanceId: string | null = null; let audioLayers: AudioLayerState = { voice: true, item: true, @@ -1036,6 +1043,51 @@ function restoreLoopbackAfterMicGainEdit(): void { micGainLoopbackRestoreState = null; } +/** Stops heartbeat timer and clears in-memory heartbeat state. */ +function stopHeartbeat(): void { + if (heartbeatTimerId !== null) { + window.clearInterval(heartbeatTimerId); + heartbeatTimerId = null; + } + heartbeatLastPongAt = 0; +} + +/** Sends one heartbeat ping packet using reserved negative ids. */ +function sendHeartbeatPing(): void { + signaling.send({ type: 'ping', clientSentAt: heartbeatNextPingId }); + heartbeatNextPingId -= 1; +} + +/** Starts heartbeat timer for stale-connection detection. */ +function startHeartbeat(): void { + stopHeartbeat(); + heartbeatLastPongAt = Date.now(); + sendHeartbeatPing(); + heartbeatTimerId = window.setInterval(() => { + if (!state.running) return; + const now = Date.now(); + if (now - heartbeatLastPongAt > HEARTBEAT_TIMEOUT_MS) { + void reconnectAfterHeartbeatTimeout(); + return; + } + sendHeartbeatPing(); + }, HEARTBEAT_INTERVAL_MS); +} + +/** Performs one reconnect attempt when heartbeat timeout indicates stale signaling. */ +async function reconnectAfterHeartbeatTimeout(): Promise { + if (reconnectInFlight || !state.running) return; + reconnectInFlight = true; + stopHeartbeat(); + updateStatus('Connection stale. Reconnecting...'); + disconnect(); + try { + await connect(); + } finally { + reconnectInFlight = false; + } +} + /** Builds dependencies shared by connect/disconnect flow helpers. */ function getConnectionFlowDeps(): ConnectFlowDeps { return { @@ -1055,7 +1107,7 @@ function getConnectionFlowDeps(): ConnectFlowDeps { mediaStopLocalMedia: () => stopLocalMedia(), signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise), signalingDisconnect: () => signaling.disconnect(), - onMessage: (message) => onMessage(message as IncomingMessage), + onMessage: (message) => onSignalingMessage(message as IncomingMessage), worldGridSize, persistPlayerPosition, peerManagerCleanupAll: () => peerManager.cleanupAll(), @@ -1074,12 +1126,13 @@ async function connect(): Promise { /** Tears down active session state, media, peers, and UI back to pre-connect mode. */ function disconnect(): void { + stopHeartbeat(); runDisconnectFlow(getConnectionFlowDeps()); pendingEscapeDisconnect = false; restoreLoopbackAfterMicGainEdit(); } -const onMessage = createOnMessageHandler({ +const onAppMessage = createOnMessageHandler({ getWorldGridSize: () => worldGridSize, setWorldGridSize: (size) => { worldGridSize = size; @@ -1131,6 +1184,29 @@ const onMessage = createOnMessageHandler({ }, }); +/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */ +async function onSignalingMessage(message: IncomingMessage): Promise { + if (message.type === 'pong' && message.clientSentAt < 0) { + heartbeatLastPongAt = Date.now(); + return; + } + let restartAnnouncement: string | null = null; + if (message.type === 'welcome') { + const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; + const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; + if (activeServerInstanceId && incomingInstanceId && activeServerInstanceId !== incomingInstanceId) { + restartAnnouncement = `Server restarted, version ${incomingVersion}.`; + } + activeServerInstanceId = incomingInstanceId; + startHeartbeat(); + } + await onAppMessage(message); + if (restartAnnouncement) { + updateStatus(restartAnnouncement); + audio.sfxUiConfirm(); + } +} + /** Toggles local microphone track mute state. */ function toggleMute(): void { state.isMuted = !state.isMuted; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 81ddaf0..4be201e 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -34,6 +34,12 @@ export const welcomeMessageSchema = z.object({ gridSize: z.number().int().positive(), }) .optional(), + serverInfo: z + .object({ + instanceId: z.string(), + version: z.string().optional(), + }) + .optional(), uiDefinitions: z .object({ itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget'])), diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 13755f8..9668646 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -38,6 +38,9 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Welcome Metadata - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. +- `welcome.serverInfo`: server process identity/version metadata: + - `instanceId`: unique id generated at server startup + - `version`: server package version (or `unknown` fallback) - `welcome.uiDefinitions`: server-provided item UI definitions: - `itemTypeOrder`: add-item menu order - `itemTypes[].tooltip`: item-level tooltip/help text @@ -53,3 +56,11 @@ This is a behavior guide for packet semantics beyond raw schemas. - Server is authoritative for all action validation and normalization. - Client validates incoming packet shapes and applies runtime behavior. - Client-side item edit validation is convenience only; server remains source of truth. + +## Heartbeat/Stale Recovery + +- 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. +- 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 cfcb87e..9097a57 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -8,12 +8,14 @@ 4. Server sends `welcome` with users/items snapshot. 5. Client: - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering + - records `welcome.serverInfo` (`instanceId`, `version`) for restart detection - applies `welcome.uiDefinitions` for item menus/properties/options - sends initial `update_position` - sends initial `update_nickname` - creates peer runtimes for known users - syncs item runtimes (`radio`, `emit`) - applies audio layer state + - starts signaling heartbeat monitor - starts game loop ## Main Loop @@ -38,12 +40,22 @@ Core incoming message effects: - `item_remove`: remove item and cleanup runtimes. - `item_action_result`: success/error status for actions. - `item_use_sound`: play one-shot spatial sample (world layer gated). +- `pong`: + - positive `clientSentAt`: user ping response (`P` command) + - negative `clientSentAt`: internal heartbeat response + +## 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 reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart/version. ## Disconnect/Cleanup On disconnect: - Close signaling. +- Stop heartbeat monitor. - Stop local media tracks. - Cleanup peers and all audio runtimes. - Reset UI/mode state and lists. diff --git a/server/app/models.py b/server/app/models.py index dab2945..d133a3f 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -103,6 +103,7 @@ class WelcomePacket(BasePacket): items: list[dict] | None = None worldConfig: dict | None = None uiDefinitions: dict | None = None + serverInfo: dict | None = None class UserLeftPacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 893a2b3..f41ced5 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import asyncio from datetime import datetime +from importlib.metadata import PackageNotFoundError, version as package_version import json import logging import ssl @@ -88,6 +89,11 @@ class SignalingServer: self.item_service = ItemService(state_file=state_file) self.item_last_use_ms: dict[str, int] = {} self.grid_size = max(1, grid_size) + self.instance_id = str(uuid.uuid4()) + try: + self.server_version = package_version("chgrid-server") + except PackageNotFoundError: + self.server_version = "unknown" @property def items(self) -> dict[str, WorldItem]: @@ -263,6 +269,7 @@ class SignalingServer: items=[item.model_dump(exclude_none=True) for item in self.items.values()], worldConfig={"gridSize": self.grid_size}, uiDefinitions=self._build_ui_definitions(), + serverInfo={"instanceId": self.instance_id, "version": self.server_version}, ) await self._send(client.websocket, packet)