Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ src/assets/
*debug*.txt
eslint_out.txt
tests/perf/output/
tests/load/results/

# Locally built Go binary (gen-maps uses `go run .`)
map-generator/map-generator
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default [
"__mocks__/fileMock.js",
"eslint.config.js",
"scripts/sync-assets.mjs",
"tests/load/*.mjs",
"tests/matchmaking/*.mjs",
],
},
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@
"build-prod": "concurrently --kill-others-on-fail \"tsc --noEmit\" \"vite build\"",
"start:client": "vite",
"start:server": "tsx src/server/Server.ts",
"start:server:bun": "bun src/server/Server.ts",
"start:server-dev": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION ADMIN_BOT_API_KEY=WARNING_DEV_ADMIN_BOT_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts",
"start:server-dev:bun": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION ADMIN_BOT_API_KEY=WARNING_DEV_ADMIN_BOT_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV bun src/server/Server.ts",
"dev:bun": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev:bun\"",
"dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:host": "cross-env GAME_ENV=dev VITE_HOST=lan concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"docs:map-generator": "cd map-generator && go doc -cmd -u -all",
"tunnel": "npm run build-prod && npm run start:server",
"test": "vitest run && vitest run tests/server",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:bun": "cross-env E2E_RUNTIME=bun vitest run --config vitest.e2e.config.ts",
"perf:server": "node tests/load/loadtest.mjs",
"test:matchmaking": "node tests/matchmaking/contained.mjs",
"test:matchmaking:e2e": "node tests/matchmaking/e2e.mjs",
"test:matchmaking:cancel": "node tests/matchmaking/e2e-cancel.mjs",
Expand Down
10 changes: 10 additions & 0 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ const playlist = new MapPlaylist();
export async function startWorker() {
log.info(`Worker starting...`);

// Exit when the IPC channel to the master closes. Node's cluster workers
// already die with the master; Bun's do not — an orphaned worker would
// keep its port bound (SO_REUSEPORT) and serve stale state next to the
// restarted server's workers. Explicit exit makes the lifecycle identical
// on both runtimes.
process.on("disconnect", () => {
log.info("IPC channel to master closed, shutting down worker");
process.exit(0);
});

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Expand Down
8 changes: 7 additions & 1 deletion src/server/WorkerLobbyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ export class WorkerLobbyService {
}

private sendToMaster(msg: WorkerReady | WorkerLobbyList) {
process.send?.(msg);
// On Node a closed IPC channel makes process.send return false; on Bun
// it throws instead. Treat both as "master is gone, drop the message".
try {
process.send?.(msg);
} catch (error) {
this.log.warn(`Failed to send IPC message to master: ${error}`);
}
}

private sendMyLobbiesToMaster() {
Expand Down
238 changes: 238 additions & 0 deletions tests/e2e/GameFlow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// End-to-end test of the game server over real HTTP + WebSocket: boots the
// actual master + cluster workers (Node via tsx, or Bun with
// E2E_RUNTIME=bun) and drives the full lobby -> start -> turn-relay ->
// rejoin -> kick flow that production clients exercise.

import { randomUUID } from "crypto";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
createGame,
gameInfo,
RUNTIME,
sleep,
TestClient,
TestServer,
waitFor,
} from "./util";

describe(`game server e2e (runtime: ${RUNTIME})`, () => {
const server = new TestServer();
const creatorToken = randomUUID();
let game: { gameID: string; workerIndex: number; port: number };
let creator: TestClient;
let playerB: TestClient;
let playerC: TestClient;

beforeAll(async () => {
await server.start();
game = await createGame(creatorToken);
creator = new TestClient(game.port, game.gameID, "creator", creatorToken);
playerB = new TestClient(game.port, game.gameID, "playerB");
playerC = new TestClient(game.port, game.gameID, "playerC");
});

afterAll(async () => {
for (const c of [creator, playerB, playerC]) c?.close();
await server.stop();
});

test("health endpoint reports ok once workers are ready", async () => {
const res = await fetch("http://127.0.0.1:3000/api/health");
expect(res.ok).toBe(true);
expect(await res.json()).toEqual({ status: "ok" });
});

test("create_game requires an auth token", async () => {
const res = await fetch(`http://127.0.0.1:${game.port}/api/create_game`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(400);
});

test("created game is queryable on its worker", async () => {
const info = await gameInfo(game.port, game.gameID);
expect(info).not.toBeNull();
expect(info.gameID).toBe(game.gameID);
expect(info.gameConfig.gameType).toBe("Private");
});

test("clients join over WebSocket and get server-assigned clientIDs", async () => {
await creator.join();
await playerB.join();
await playerC.join();
expect(creator.clientID).toBeTruthy();
expect(playerB.clientID).toBeTruthy();
expect(playerC.clientID).toBeTruthy();
// All three ids are distinct.
expect(
new Set([creator.clientID, playerB.clientID, playerC.clientID]).size,
).toBe(3);

// Lobby info converges to 3 clients for everyone.
await waitFor(
async () => {
const info = await gameInfo(game.port, game.gameID);
return info?.clients?.length === 3;
},
10_000,
"lobby to report 3 clients",
);
});

test("lobby identifies the creator", async () => {
const info = await gameInfo(game.port, game.gameID);
expect(info.lobbyCreatorClientID).toBe(creator.clientID);
});

test("non-creator cannot start the game", async () => {
playerB.sendIntent({ type: "toggle_game_start_timer" });
await sleep(1500);
const info = await gameInfo(game.port, game.gameID);
expect(info.startsAt ?? undefined).toBeUndefined();
});

test("creator starts the game; every client receives prestart and start", async () => {
creator.sendIntent({ type: "toggle_game_start_timer" });
for (const c of [creator, playerB, playerC]) {
await c.waitForMessage((m) => m.type === "prestart", 15_000);
const start = (await c.waitForMessage(
(m) => m.type === "start",
15_000,
)) as any;
expect(start.gameStartInfo.gameID).toBe(game.gameID);
expect(start.gameStartInfo.players).toHaveLength(3);
expect(start.myClientID).toBe(c.clientID);
const usernames = start.gameStartInfo.players.map((p: any) => p.username);
expect(usernames).toEqual(
expect.arrayContaining(["creator", "playerB", "playerC"]),
);
}
});

test("server broadcasts turns at the 100ms tick", async () => {
const before = creator.turns().length;
await sleep(1200);
const after = creator.turns().length;
const gained = after - before;
// ~12 expected; allow generous slack for CI jitter.
expect(gained).toBeGreaterThanOrEqual(8);
expect(gained).toBeLessThanOrEqual(16);
Comment on lines +113 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Drop the upper bound on the turn count.

The lower bound checks the real property: the server keeps broadcasting turns. The upper bound checks how accurately this test process wakes from sleep(1200). Under CI load the sleep can overrun by hundreds of milliseconds while the server keeps ticking, so gained exceeds 16 and the test fails for a reason unrelated to the server. Measure the elapsed time and derive the expectation from it, or assert only the lower bound.

💚 Proposed fix
   test("server broadcasts turns at the 100ms tick", async () => {
     const before = creator.turns().length;
+    const t0 = Date.now();
     await sleep(1200);
+    const elapsed = Date.now() - t0;
     const after = creator.turns().length;
     const gained = after - before;
-    // ~12 expected; allow generous slack for CI jitter.
-    expect(gained).toBeGreaterThanOrEqual(8);
-    expect(gained).toBeLessThanOrEqual(16);
+    // ~1 turn per 100ms. Scale the bound to the time we actually slept, so a
+    // slow test process does not fail the server.
+    expect(gained).toBeGreaterThanOrEqual(8);
+    expect(gained).toBeLessThanOrEqual(Math.ceil(elapsed / 100) + 4);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("server broadcasts turns at the 100ms tick", async () => {
const before = creator.turns().length;
await sleep(1200);
const after = creator.turns().length;
const gained = after - before;
// ~12 expected; allow generous slack for CI jitter.
expect(gained).toBeGreaterThanOrEqual(8);
expect(gained).toBeLessThanOrEqual(16);
test("server broadcasts turns at the 100ms tick", async () => {
const before = creator.turns().length;
const t0 = Date.now();
await sleep(1200);
const elapsed = Date.now() - t0;
const after = creator.turns().length;
const gained = after - before;
// ~1 turn per 100ms. Scale the bound to the time we actually slept, so a
// slow test process does not fail the server.
expect(gained).toBeGreaterThanOrEqual(8);
expect(gained).toBeLessThanOrEqual(Math.ceil(elapsed / 100) + 4);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/GameFlow.test.ts` around lines 113 - 120, Remove the upper-bound
assertion on gained in the “server broadcasts turns at the 100ms tick” test;
retain the lower-bound check that verifies broadcasting continues, without
constraining scheduler timing from sleep(1200).

// Turn numbers are consecutive.
const numbers = creator.turns().map((t) => t.turn.turnNumber);
for (let i = 1; i < numbers.length; i++) {
expect(numbers[i]).toBe(numbers[i - 1] + 1);
}
});

test("an intent is relayed to every client, stamped with the sender's clientID", async () => {
const marker = 987654321; // distinctive troops value to find the intent
playerB.sendIntent({ type: "attack", targetID: null, troops: marker });
for (const c of [creator, playerB, playerC]) {
const turnMsg = (await c.waitForMessage(
(m) =>
m.type === "turn" &&
(m as any).turn.intents.some((i: any) => i.troops === marker),
5_000,
)) as any;
const intent = turnMsg.turn.intents.find((i: any) => i.troops === marker);
// The clientID comes from the authenticated connection, not the payload.
expect(intent.clientID).toBe(playerB.clientID);
}
});

test("a client that vanishes can rejoin and receives the missed turns", async () => {
// Hard-drop C's socket (no close frame ≈ network loss).
playerC.ws!.terminate();
await sleep(600);
const lastTurn =
playerC.turns().length === 0
? 0
: playerC.turns()[playerC.turns().length - 1].turn.turnNumber + 1;

const rejoined = new TestClient(
game.port,
game.gameID,
"playerC",
playerC.token,
);
await rejoined.connect();
rejoined.send({
type: "rejoin",
gameID: game.gameID,
lastTurn,
token: playerC.token,
});
const start = (await rejoined.waitForMessage(
(m) => m.type === "start",
10_000,
)) as any;
// The catch-up slice starts exactly where the client left off.
expect(start.gameStartInfo.gameID).toBe(game.gameID);
if (start.turns.length > 0) {
expect(start.turns[0].turnNumber).toBe(lastTurn);
}
// Same identity as before the drop.
expect(start.myClientID).toBe(playerC.clientID);
playerC = rejoined;
playerC.clientID = start.myClientID;
});

test("a client sending garbage is kicked with a reason", async () => {
const victim = new TestClient(game.port, game.gameID, "victim");
await victim.join();
victim.ws!.send("this is not json");
await victim.waitForMessage(
(m) => m.type === "error" && (m as any).error.includes("invalid_message"),
5_000,
);
await waitFor(
() => victim.closeCode !== null,
5_000,
"victim socket to close",
);
expect(victim.closeCode).toBe(1000);

// Kicked identity cannot rejoin.
const comeback = new TestClient(
game.port,
game.gameID,
"victim",
victim.token,
);
await comeback.connect();
comeback.send({
type: "join",
token: comeback.token,
gameID: game.gameID,
username: "victim",
clanTag: null,
turnstileToken: null,
});
await waitFor(
() => comeback.closeCode !== null,
5_000,
"kicked rejoin to be rejected",
);
expect(comeback.closeCode).toBe(1002);
});

test("games on the wrong worker are rejected", async () => {
// Join a game that lives on worker A via worker B's port: the message
// is dropped (no lobby_info ever arrives).
const wrongPort = game.port === 3001 ? 3002 : 3001;
const lost = new TestClient(wrongPort, game.gameID, "lostsoul");
await lost.connect();
lost.send({
type: "join",
token: lost.token,
gameID: game.gameID,
username: "lostsoul",
clanTag: null,
turnstileToken: null,
});
await sleep(1500);
expect(lost.messages.find((m) => m.type === "lobby_info")).toBeUndefined();
lost.close();
});
});
50 changes: 50 additions & 0 deletions tests/e2e/WorkerLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Regression test for the cluster worker lifecycle: when the master dies,
// every worker must exit too. Node's cluster does this implicitly; Bun's
// does not, which orphaned workers that kept their ports bound (via
// SO_REUSEPORT) and served stale state next to a restarted server. Worker.ts
// now exits explicitly on IPC disconnect — this test pins that behavior on
// both runtimes.

import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { RUNTIME, TestServer, waitFor } from "./util";

function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

describe(`worker lifecycle (runtime: ${RUNTIME})`, () => {
const server = new TestServer();

beforeAll(async () => {
await server.start();
});

afterAll(async () => {
await server.stop();
});

test("workers exit when the master is killed", async () => {
const workerPids = server.workerPids();
expect(workerPids.length).toBeGreaterThanOrEqual(2);
for (const pid of workerPids) {
expect(pidAlive(pid)).toBe(true);
}

const masterPid = server.masterPid();
expect(masterPid).not.toBeNull();
// SIGKILL: the harshest case — no signal handler can run, only the IPC
// channel closing tells the workers their master is gone.
process.kill(masterPid!, "SIGKILL");

await waitFor(
() => workerPids.every((pid) => !pidAlive(pid)),
10_000,
`workers ${workerPids.join(",")} to exit after master SIGKILL`,
);
});
});
Loading
Loading