handle websocket errors

This commit is contained in:
Evan
2025-02-12 13:23:31 -08:00
parent 07be6a4ff6
commit a6328f3522
4 changed files with 313 additions and 243 deletions
+24
View File
@@ -0,0 +1,24 @@
# Dependencies
node_modules
out
# Build outputs
dist
build
# Generated files
*.generated.*
.next
# Config files that shouldn't be formatted
.husky
*.lock
package-lock.json
# Client resources
resources
# Optional: Specific file types you don't want formatted
*.min.js
*.svg
*.ico
+1 -1
View File
@@ -35,7 +35,7 @@
OpenFront.io
</h1>
<h2 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl mb-4">
(v0.14.3)
(v0.14.4)
</h2>
<div class="flex justify-center items-start">
<div class="w-full max-w-3xl p-4 space-y-4">
+281 -242
View File
@@ -1,275 +1,314 @@
import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameRecordSchema, Intent, PlayerRecord, ServerPingMessageSchema, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameRecordSchema,
Intent,
PlayerRecord,
ServerPingMessageSchema,
ServerStartGameMessage,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { Config, ServerConfig } from "../core/configuration/Config";
import { Client } from "./Client";
import WebSocket from 'ws';
import WebSocket from "ws";
import { slog } from "./StructuredLog";
import { CreateGameRecord } from "../core/Util";
import { archive } from "./Archive";
import { arc } from "d3";
export enum GamePhase {
Lobby = 'LOBBY',
Active = 'ACTIVE',
Finished = 'FINISHED'
Lobby = "LOBBY",
Active = "ACTIVE",
Finished = "FINISHED",
}
export class GameServer {
private maxGameDuration = 5 * 60 * 60 * 1000; // 5 hours
private turns: Turn[] = [];
private intents: Intent[] = [];
public activeClients: Client[] = [];
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map();
private _hasStarted = false;
private _startTime: number = null;
private maxGameDuration = 5 * 60 * 60 * 1000 // 5 hours
private endTurnIntervalID;
private turns: Turn[] = []
private intents: Intent[] = []
public activeClients: Client[] = []
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map()
private _hasStarted = false
private _startTime: number = null
private lastPingUpdate = 0;
private endTurnIntervalID
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
private gameConfig: GameConfig
) {}
private lastPingUpdate = 0
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
private gameConfig: GameConfig,
) { }
public updateGameConfig(gameConfig: GameConfig): void {
if (gameConfig.gameMap != null) {
this.gameConfig.gameMap = gameConfig.gameMap
}
if (gameConfig.difficulty != null) {
this.gameConfig.difficulty = gameConfig.difficulty
}
public updateGameConfig(gameConfig: GameConfig): void {
if (gameConfig.gameMap != null) {
this.gameConfig.gameMap = gameConfig.gameMap;
}
if (gameConfig.difficulty != null) {
this.gameConfig.difficulty = gameConfig.difficulty;
}
}
public addClient(client: Client, lastTurn: number) {
console.log(`${this.id}: adding client ${client.clientID}`)
slog({
logKey: 'client_joined_game',
msg: `client ${client.clientID} (re)joining game ${this.id}`,
data: {
clientID: client.clientID,
clientIP: client.ip,
gameID: this.id,
isRejoin: lastTurn > 0
},
clientID: client.clientID,
persistentID: client.persistentID,
gameID: this.id,
public addClient(client: Client, lastTurn: number) {
console.log(`${this.id}: adding client ${client.clientID}`);
slog({
logKey: "client_joined_game",
msg: `client ${client.clientID} (re)joining game ${this.id}`,
data: {
clientID: client.clientID,
clientIP: client.ip,
gameID: this.id,
isRejoin: lastTurn > 0,
},
clientID: client.clientID,
persistentID: client.persistentID,
gameID: this.id,
});
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(
(c) => c.clientID == client.clientID
);
if (existing != null) {
existing.ws.removeAllListeners("message");
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID
);
this.activeClients.push(client);
client.lastPing = Date.now();
this.allClients.set(client.clientID, client);
client.ws.on("message", (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message)
);
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`
);
}
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`
);
}
});
client.ws.on("close", () => {
console.log(`${this.id}: client ${client.clientID} disconnected`);
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID
);
});
client.ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
client.ws.close(1002);
}
});
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn);
}
}
public numClients(): number {
return this.activeClients.length;
}
public startTime(): number {
if (this._startTime > 0) {
return this._startTime;
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.lobbyLifetime();
}
}
public start() {
this._hasStarted = true;
this._startTime = Date.now();
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now();
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.config.turnIntervalMs()
);
this.activeClients.forEach((c) => {
console.log(`${this.id}: sending start message to ${c.clientID}`);
this.sendStartGameMsg(c.ws, 0);
});
}
private addIntent(intent: Intent) {
this.intents.push(intent);
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
})
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(c => c.clientID == client.clientID)
if (existing != null) {
existing.ws.removeAllListeners('message')
}
this.activeClients = this.activeClients.filter(c => c.clientID != client.clientID)
this.activeClients.push(client)
client.lastPing = Date.now()
)
);
}
this.allClients.set(client.clientID, client)
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents,
};
this.turns.push(pastTurn);
this.intents = [];
client.ws.on('message', (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent)
} else {
console.warn(`${this.id}: client ${clientMsg.clientID} sent to wrong game`)
}
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now()
client.lastPing = Date.now()
}
} catch (error) {
console.log(`error handline websocket request in game server: ${error}`)
}
})
client.ws.on('close', () => {
console.log(`${this.id}: client ${client.clientID} disconnected`)
this.activeClients = this.activeClients.filter(c => c.clientID != client.clientID)
})
const msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
})
);
this.activeClients.forEach((c) => {
c.ws.send(msg);
});
}
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn)
}
}
public numClients(): number {
return this.activeClients.length
}
public startTime(): number {
if (this._startTime > 0) {
return this._startTime
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.lobbyLifetime()
}
}
public start() {
this._hasStarted = true
this._startTime = Date.now()
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now()
this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs());
this.activeClients.forEach(c => {
console.log(`${this.id}: sending start message to ${c.clientID}`)
this.sendStartGameMsg(c.ws, 0)
})
}
private addIntent(intent: Intent) {
this.intents.push(intent)
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(JSON.stringify(ServerStartGameMessageSchema.parse(
{
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig
}
)))
}
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
const msg = JSON.stringify(ServerTurnMessageSchema.parse(
{
type: "turn",
turn: pastTurn
}
))
this.activeClients.forEach(c => {
c.ws.send(msg)
})
}
async endGame() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.activeClients.forEach(client => {
client.ws.removeAllListeners('message'); // TODO: remove this?
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "game has ended");
}
});
console.log(`${this.id}: ending game ${this.id} with ${this.turns.length} turns`)
async endGame() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.activeClients.forEach((client) => {
client.ws.removeAllListeners("message"); // TODO: remove this?
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "game has ended");
}
});
console.log(
`${this.id}: ending game ${this.id} with ${this.turns.length} turns`
);
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values()
).map((client) => ({
ip: client.ip,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
}));
archive(
CreateGameRecord(
this.id,
this.gameConfig,
playerRecords,
this.turns,
this._startTime,
Date.now()
)
);
} else {
console.log(`${this.id}: no clients joined, not archiving game`);
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack,
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(this.allClients.values()).map(client => ({
ip: client.ip,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
}));
archive(
CreateGameRecord(
this.id,
this.gameConfig,
playerRecords,
this.turns,
this._startTime,
Date.now()
)
)
} else {
console.log(`${this.id}: no clients joined, not archiving game`)
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
console.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails
});
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
console.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails,
});
}
}
phase(): GamePhase {
const now = Date.now();
const alive = [];
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
console.log(
`${this.id}: no pings from ${client.clientID}, terminating connection`
);
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client);
}
}
this.activeClients = alive;
if (
now >
this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration
) {
console.warn(`${this.id}: game past max duration ${this.id}`);
return GamePhase.Finished;
}
phase(): GamePhase {
const now = Date.now()
const alive = []
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
console.log(`${this.id}: no pings from ${client.clientID}, terminating connection`)
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client)
}
}
this.activeClients = alive
if (now > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
console.warn(`${this.id}: game past max duration ${this.id}`)
return GamePhase.Finished
}
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length == 0;
const noRecentPings = now > this.lastPingUpdate + 20 * 1000
const noActive = this.activeClients.length == 0
if (!this.isPublic) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`)
return GamePhase.Finished
} else {
return GamePhase.Active
}
} else {
return GamePhase.Lobby
}
if (!this.isPublic) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`);
return GamePhase.Finished;
} else {
return GamePhase.Active;
}
if (now - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby
}
const warmupOver = now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished
}
return GamePhase.Active
} else {
return GamePhase.Lobby;
}
}
hasStarted(): boolean {
return this._hasStarted
if (now - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
return GamePhase.Active;
}
}
hasStarted(): boolean {
return this._hasStarted;
}
}
+7
View File
@@ -175,6 +175,13 @@ wss.on("connection", (ws, req) => {
console.log(`errror handling websocket message: ${error}`);
}
});
ws.on("error", (error) => {
const clientIP = req.socket.remoteAddress;
if ("code" in error && error.code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
console.log(`RSV1 error from IP: ${clientIP}`);
}
});
});
function runGame() {