Files
OpenFrontIO/src/client/LobbySocket.ts
T
FloPinguin 5e84227b54 Fix: Lobby websocket reconnects after stop() in singleplayer 🔧 (#3407)
## Description:

Calling `lobbySocket.stop()` closes the WebSocket, but the async `close`
event fires afterward and triggers `handleClose()` →
`scheduleReconnect()`, reopening the connection. In singleplayer, both
`onPrestart` and `onJoin` callbacks fire synchronously in a single
stack, so the second `stop()` call has nothing left to clean up - the
reconnect timeout gets set after both calls complete.

Adds a `stopped` flag that skips reconnection in `handleClose()` when
the socket was intentionally closed.

Surely this PR description is not AI generated.

Should reduce network load of the server because all the singleplayer
gamers no longer stay connected to the lobbies websocket forever. They
also no longer load all the map mainfest.jsons and thumbnails while they
are playing alone (rotation running in the background).

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-03-11 14:36:32 -07:00

151 lines
4.2 KiB
TypeScript

import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { PublicGames, PublicGamesSchema } from "../core/Schemas";
interface LobbySocketOptions {
reconnectDelay?: number;
maxWsAttempts?: number;
pollIntervalMs?: number;
}
function getRandomWorkerPath(numWorkers: number): string {
const workerIndex = Math.floor(Math.random() * numWorkers);
return `/w${workerIndex}`;
}
export class PublicLobbySocket {
private ws: WebSocket | null = null;
private wsReconnectTimeout: number | null = null;
private wsConnectionAttempts = 0;
private wsAttemptCounted = false;
private workerPath: string = "";
private stopped = true;
private readonly reconnectDelay: number;
private readonly maxWsAttempts: number;
constructor(
private onLobbiesUpdate: (data: PublicGames) => void,
options?: LobbySocketOptions,
) {
this.reconnectDelay = options?.reconnectDelay ?? 3000;
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
}
async start() {
this.stopped = false;
this.wsConnectionAttempts = 0;
// Get config to determine number of workers, then pick a random one
const config = await getServerConfigFromClient();
this.workerPath = getRandomWorkerPath(config.numWorkers());
this.connectWebSocket();
}
stop() {
this.stopped = true;
this.disconnectWebSocket();
}
private connectWebSocket() {
try {
// Clean up existing WebSocket before creating a new one
if (this.ws) {
this.ws.close();
this.ws = null;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}${this.workerPath}/lobbies`;
this.ws = new WebSocket(wsUrl);
this.wsAttemptCounted = false;
this.ws.addEventListener("open", () => this.handleOpen());
this.ws.addEventListener("message", (event) => this.handleMessage(event));
this.ws.addEventListener("close", () => this.handleClose());
this.ws.addEventListener("error", (error) => this.handleError(error));
} catch (error) {
this.handleConnectError(error);
}
}
private handleOpen() {
console.log("WebSocket connected: lobby updating");
this.wsConnectionAttempts = 0;
if (this.wsReconnectTimeout !== null) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
}
private handleMessage(event: MessageEvent) {
try {
const publicGames = PublicGamesSchema.parse(
JSON.parse(event.data as string),
);
this.onLobbiesUpdate(publicGames);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.close();
} catch (closeError) {
console.error(
"Error closing WebSocket after parse failure:",
closeError,
);
}
}
}
}
private handleClose() {
if (this.stopped) return;
console.log("WebSocket disconnected, attempting to reconnect...");
if (!this.wsAttemptCounted) {
this.wsAttemptCounted = true;
this.wsConnectionAttempts++;
}
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
console.error("Max WebSocket attempts reached");
} else {
this.scheduleReconnect();
}
}
private handleError(error: Event) {
console.error("WebSocket error:", error);
}
private handleConnectError(error: unknown) {
console.error("Error connecting WebSocket:", error);
if (!this.wsAttemptCounted) {
this.wsAttemptCounted = true;
this.wsConnectionAttempts++;
}
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
alert("error connecting to game service");
} else {
this.scheduleReconnect();
}
}
private scheduleReconnect() {
if (this.wsReconnectTimeout !== null) return;
this.wsReconnectTimeout = window.setTimeout(() => {
this.wsReconnectTimeout = null;
this.connectWebSocket();
}, this.reconnectDelay);
}
private disconnectWebSocket() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
if (this.wsReconnectTimeout !== null) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
}
}