Skip to content
Merged
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
17 changes: 16 additions & 1 deletion src/room-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ export class RoomManager {
if (roomId == null) throw new TypeError("roomId is required");

const room = this._rooms.get(roomId);
if (room) {
if (room && room.has(clientId)) {
room.delete(clientId);
this._totalMembers--;
this._cleanupRoom(roomId);
}

Expand Down Expand Up @@ -272,6 +273,7 @@ export class RoomManager {

const rooms = this._clientRooms.get(clientId);
if (rooms) {
this._totalMembers -= rooms.size;
for (const roomId of rooms) {
const room = this._rooms.get(roomId);
if (room) {
Expand Down Expand Up @@ -324,4 +326,17 @@ export class RoomManager {
get clientCount() {
return this._clientRooms.size;
}

/**
* RoomManager statistics and metrics.
* @type {{ roomCount: number, clientCount: number, totalMembers: number, circuitBreakerState: string }}
*/
get stats() {
return {
roomCount: this._rooms.size,
clientCount: this._clientRooms.size,
totalMembers: this._totalMembers,
circuitBreakerState: this._circuitBreakerState,
};
}
}
228 changes: 228 additions & 0 deletions tests/room-manager-dos.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { RoomManager } from "../src/room-manager.js";

describe("RoomManager — DoS protection and limits", () => {
let rooms;
const fakeWs = { readyState: 1, send: () => {} };

afterEach(() => {
vi.restoreAllMocks();
});

describe("Constructor defaults & stats getter", () => {
it("provides sane default stats initially", () => {
rooms = new RoomManager();
expect(rooms.stats).toEqual({
roomCount: 0,
clientCount: 0,
totalMembers: 0,
circuitBreakerState: "CLOSED",
});
});

it("accurately tracks totalMembers across multiple joins and leaves", () => {
rooms = new RoomManager();
rooms.join("c1", "r1", fakeWs);
rooms.join("c1", "r2", fakeWs);
rooms.join("c2", "r1", fakeWs);

expect(rooms.stats).toEqual({
roomCount: 2,
clientCount: 2,
totalMembers: 3,
circuitBreakerState: "CLOSED",
});

rooms.leave("c1", "r1");
expect(rooms.stats.totalMembers).toBe(2);

rooms.disconnect("c1");
expect(rooms.stats.totalMembers).toBe(1);
expect(rooms.stats.clientCount).toBe(1);

rooms.disconnect("c2");
expect(rooms.stats.totalMembers).toBe(0);
expect(rooms.stats.clientCount).toBe(0);
expect(rooms.stats.roomCount).toBe(0);
});
});

describe("Per-client room limit (maxRoomsPerClient)", () => {
it("allows joining up to maxRoomsPerClient rooms", () => {
rooms = new RoomManager({ maxRoomsPerClient: 3 });
expect(rooms.join("c1", "r1", fakeWs)).toBeUndefined();
expect(rooms.join("c1", "r2", fakeWs)).toBeUndefined();
expect(rooms.join("c1", "r3", fakeWs)).toBeUndefined();
expect(rooms.getClientRooms("c1").size).toBe(3);
});

it("rejects joins when maxRoomsPerClient is exceeded", () => {
rooms = new RoomManager({ maxRoomsPerClient: 2 });
rooms.join("c1", "r1", fakeWs);
rooms.join("c1", "r2", fakeWs);

const err = rooms.join("c1", "r3", fakeWs);
expect(err).toEqual({
type: "error",
payload: {
code: "ROOM_LIMIT_EXCEEDED",
message: "Client room limit exceeded (2)",
},
});
expect(rooms.getClientRooms("c1").size).toBe(2);
});

it("allows re-joining an already joined room even if at maxRoomsPerClient", () => {
rooms = new RoomManager({ maxRoomsPerClient: 2 });
rooms.join("c1", "r1", fakeWs);
rooms.join("c1", "r2", fakeWs);

const newWs = { readyState: 1, send: () => {} };
expect(rooms.join("c1", "r1", newWs)).toBeUndefined();
expect(rooms.getClientRooms("c1").size).toBe(2);
});
});

describe("Per-room member limit (maxMembersPerRoom)", () => {
it("allows joining a room up to maxMembersPerRoom", () => {
rooms = new RoomManager({ maxMembersPerRoom: 2 });
expect(rooms.join("c1", "r1", fakeWs)).toBeUndefined();
expect(rooms.join("c2", "r1", fakeWs)).toBeUndefined();
expect(rooms.getRoomSize("r1")).toBe(2);
});

it("rejects new members when maxMembersPerRoom is exceeded", () => {
rooms = new RoomManager({ maxMembersPerRoom: 2 });
rooms.join("c1", "r1", fakeWs);
rooms.join("c2", "r1", fakeWs);

const err = rooms.join("c3", "r1", fakeWs);
expect(err).toEqual({
type: "error",
payload: {
code: "ROOM_FULL",
message: "Room member limit reached (2)",
},
});
expect(rooms.getRoomSize("r1")).toBe(2);
});

it("allows existing member to update socket even if room is full", () => {
rooms = new RoomManager({ maxMembersPerRoom: 2 });
rooms.join("c1", "r1", fakeWs);
rooms.join("c2", "r1", fakeWs);

const updatedWs = { readyState: 1, send: () => {} };
expect(rooms.join("c1", "r1", updatedWs)).toBeUndefined();
expect(rooms.getRoomSize("r1")).toBe(2);
});
});

describe("Total room count ceiling (maxRooms)", () => {
it("allows creating new rooms up to maxRooms", () => {
rooms = new RoomManager({ maxRooms: 2 });
expect(rooms.join("c1", "r1", fakeWs)).toBeUndefined();
expect(rooms.join("c2", "r2", fakeWs)).toBeUndefined();
expect(rooms.roomCount).toBe(2);
});

it("rejects first join to a new room when maxRooms is reached", () => {
rooms = new RoomManager({ maxRooms: 2 });
rooms.join("c1", "r1", fakeWs);
rooms.join("c2", "r2", fakeWs);

const err = rooms.join("c3", "r3", fakeWs);
expect(err).toEqual({
type: "error",
payload: {
code: "MAX_ROOMS_REACHED",
message: "Maximum room count ceiling reached (2)",
},
});
expect(rooms.roomCount).toBe(2);
});

it("allows joins to existing rooms when maxRooms is reached", () => {
rooms = new RoomManager({ maxRooms: 2 });
rooms.join("c1", "r1", fakeWs);
rooms.join("c2", "r2", fakeWs);

expect(rooms.join("c3", "r1", fakeWs)).toBeUndefined();
expect(rooms.getRoomSize("r1")).toBe(2);
});
});

describe("Resource-pressure circuit breaker", () => {
it("opens circuit breaker when heapUsed exceeds memoryThresholdBytes", () => {
const memorySpy = vi.spyOn(process, "memoryUsage");
memorySpy.mockReturnValue({ heapUsed: 1000, heapTotal: 2000, rss: 3000, external: 0, arrayBuffers: 0 });

rooms = new RoomManager({
circuitBreaker: {
enabled: true,
memoryThresholdBytes: 500,
recoveryThresholdBytes: 300,
},
});

const err = rooms.join("c1", "r1", fakeWs);
expect(err).toEqual({
type: "error",
payload: {
code: "CIRCUIT_BREAKER_OPEN",
message: "Circuit breaker is open due to high resource pressure",
},
});
expect(rooms.stats.circuitBreakerState).toBe("OPEN");
expect(rooms.roomCount).toBe(0);
});

it("recovers and closes circuit breaker when heapUsed drops below recoveryThresholdBytes", () => {
const memorySpy = vi.spyOn(process, "memoryUsage");

rooms = new RoomManager({
circuitBreaker: {
enabled: true,
memoryThresholdBytes: 500,
recoveryThresholdBytes: 300,
},
});

// Trip breaker
memorySpy.mockReturnValue({ heapUsed: 600, heapTotal: 1000, rss: 2000, external: 0, arrayBuffers: 0 });
const err = rooms.join("c1", "r1", fakeWs);
expect(err?.payload?.code).toBe("CIRCUIT_BREAKER_OPEN");
expect(rooms.stats.circuitBreakerState).toBe("OPEN");

// Memory pressure drops to 250 (< 300 recovery threshold)
memorySpy.mockReturnValue({ heapUsed: 250, heapTotal: 1000, rss: 2000, external: 0, arrayBuffers: 0 });
const res = rooms.join("c1", "r1", fakeWs);
expect(res).toBeUndefined();
expect(rooms.stats.circuitBreakerState).toBe("CLOSED");
expect(rooms.roomCount).toBe(1);
});

it("remains open if heapUsed is between recovery and memory thresholds", () => {
const memorySpy = vi.spyOn(process, "memoryUsage");

rooms = new RoomManager({
circuitBreaker: {
enabled: true,
memoryThresholdBytes: 500,
recoveryThresholdBytes: 300,
},
});

// Trip breaker
memorySpy.mockReturnValue({ heapUsed: 600, heapTotal: 1000, rss: 2000, external: 0, arrayBuffers: 0 });
rooms.join("c1", "r1", fakeWs);
expect(rooms.stats.circuitBreakerState).toBe("OPEN");

// Memory drops to 400 (between 300 and 500)
memorySpy.mockReturnValue({ heapUsed: 400, heapTotal: 1000, rss: 2000, external: 0, arrayBuffers: 0 });
const err = rooms.join("c1", "r1", fakeWs);
expect(err?.payload?.code).toBe("CIRCUIT_BREAKER_OPEN");
expect(rooms.stats.circuitBreakerState).toBe("OPEN");
});
});
});