Skip to content

Commit 9e572b4

Browse files
committed
Make room departures cross-process safe
Claim persisted room departures with a MongoDB compare-and-set on the active membership revision. This prevents competing Socket.IO processes from duplicating leave side effects or overwriting a rejoin, while retaining the reconnect grace behavior.\n\nRefs #153
1 parent b876759 commit 9e572b4

5 files changed

Lines changed: 226 additions & 27 deletions

File tree

docs/room-ownership.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ itself from the per-user socket group before the remaining-tab check, so two
3434
simultaneous explicit leaves cannot strand membership. If the owner later
3535
rejoins, they reclaim admin before the join is acknowledged.
3636

37+
The final persisted departure is a MongoDB compare-and-set keyed by the active
38+
membership revision. This applies across Socket.IO processes: exactly one
39+
process can claim a leave, emit its room events, and record its metrics. A
40+
concurrent rejoin advances the revision, so a losing leave retries from current
41+
state and cannot overwrite the new membership.
42+
3743
An empty room persists `admin: null`, but the established `UPDATE_ADMIN`
3844
Socket.IO event is emitted only for a non-null active admin. Spectators and
3945
other clients therefore never receive a null admin payload.

server/models/room.js

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ const { mirrorRoomChanges } = require('../postgres/dualWrite');
77
const PASSWORD_SALT_ROUNDS = 10;
88
const STALE_ROOM_LIFETIME_MS = 10 * 60 * 1000;
99

10+
const sameUser = (left, right) => left && right
11+
&& String(left.id) === String(right.id);
12+
13+
const selectRoomAdmin = ({ usersInRoom, owner, admin }) => {
14+
if (usersInRoom.length === 0) {
15+
return null;
16+
}
17+
18+
const activeOwner = usersInRoom.find((user) => sameUser(user, owner));
19+
if (activeOwner) {
20+
return activeOwner;
21+
}
22+
23+
return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0];
24+
};
25+
1026
// const lengths = {
1127

1228
// };
@@ -87,6 +103,12 @@ const Room = new mongoose.Schema({
87103
of: Boolean,
88104
default: {},
89105
},
106+
// Incremented for every join or departure so a conditional departure cannot
107+
// overwrite a reconnect that raced it on another Socket.IO process.
108+
membershipRevision: {
109+
type: Number,
110+
default: 0,
111+
},
90112
admin: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
91113
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
92114
type: {
@@ -187,6 +209,7 @@ Room.methods.addUser = async function (user, spectating, updateAdmin) {
187209
}
188210

189211
this.inRoom.set(user.id.toString(), true);
212+
this.membershipRevision = (this.membershipRevision || 0) + 1;
190213

191214
if (this.waitingForCount === 0) {
192215
this.waitingFor.set(user.id.toString(), true);
@@ -210,6 +233,7 @@ Room.methods.addUser = async function (user, spectating, updateAdmin) {
210233
Room.methods.dropUser = async function (user, updateAdmin) {
211234
this.inRoom.set(user.id.toString(), false);
212235
this.waitingFor.set(user.id.toString(), false);
236+
this.membershipRevision = (this.membershipRevision || 0) + 1;
213237

214238
await this.updateAdminIfNeeded(updateAdmin);
215239

@@ -220,6 +244,70 @@ Room.methods.dropUser = async function (user, updateAdmin) {
220244
return this.save();
221245
};
222246

247+
Room.methods.dropUserAtomically = async function (user) {
248+
const userKey = user.id.toString();
249+
if (!this.inRoom.get(userKey)) {
250+
return null;
251+
}
252+
253+
const usersInRoom = this.users.filter((candidate) => (
254+
candidate.id.toString() !== userKey
255+
&& this.inRoom.get(candidate.id.toString())
256+
));
257+
const nextAdmin = selectRoomAdmin({
258+
usersInRoom,
259+
owner: this.owner,
260+
admin: this.admin,
261+
});
262+
const adminChanged = !sameUser(this.admin, nextAdmin);
263+
const membershipRevision = this.membershipRevision || 0;
264+
const condition = {
265+
_id: this._id,
266+
[`inRoom.${userKey}`]: true,
267+
};
268+
if (this.updatedAt) {
269+
condition.updatedAt = this.updatedAt;
270+
}
271+
if (membershipRevision > 0) {
272+
condition.membershipRevision = membershipRevision;
273+
} else {
274+
condition.$or = [
275+
{ membershipRevision: 0 },
276+
{ membershipRevision: { $exists: false } },
277+
];
278+
}
279+
280+
const update = {
281+
$set: {
282+
[`inRoom.${userKey}`]: false,
283+
[`waitingFor.${userKey}`]: false,
284+
admin: nextAdmin ? nextAdmin._id || nextAdmin : null,
285+
},
286+
$inc: {
287+
membershipRevision: 1,
288+
},
289+
};
290+
if (usersInRoom.length === 0 && this.type === 'normal') {
291+
update.$set.expireAt = new Date(Date.now() + STALE_ROOM_LIFETIME_MS);
292+
}
293+
294+
const updatedRoom = await this.constructor.findOneAndUpdate(condition, update, {
295+
new: true,
296+
}).populate('users').populate('admin').populate('owner');
297+
if (!updatedRoom) {
298+
return null;
299+
}
300+
301+
await mirrorRoomChanges(updatedRoom, {
302+
attempts: [],
303+
participantUserIds: [userKey],
304+
syncAllParticipants: false,
305+
syncRoomOwners: adminChanged,
306+
});
307+
308+
return { room: updatedRoom, adminChanged };
309+
};
310+
223311
Room.methods.banUser = async function (userId) {
224312
this.banned.set(userId.toString(), true);
225313
return this.dropUser({ id: userId });
@@ -314,22 +402,6 @@ Room.methods.edit = async function (options) {
314402
return this.save();
315403
};
316404

317-
const sameUser = (left, right) => left && right
318-
&& String(left.id) === String(right.id);
319-
320-
const selectRoomAdmin = ({ usersInRoom, owner, admin }) => {
321-
if (usersInRoom.length === 0) {
322-
return null;
323-
}
324-
325-
const activeOwner = usersInRoom.find((user) => sameUser(user, owner));
326-
if (activeOwner) {
327-
return activeOwner;
328-
}
329-
330-
return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0];
331-
};
332-
333405
Room.methods.updateAdminIfNeeded = async function (cb) {
334406
const nextAdmin = selectRoomAdmin(this);
335407
if (sameUser(this.admin, nextAdmin) || (!this.admin && !nextAdmin)) {

server/models/room.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
const bcrypt = require('bcrypt');
55
const mongoose = require('mongoose');
66
const { generateScramble } = require('letscube-scrambles');
7+
const { mirrorRoomChanges } = require('../postgres/dualWrite');
78
const {
89
collectPostgresChanges,
910
Room,
@@ -13,6 +14,9 @@ const {
1314
jest.mock('letscube-scrambles', () => ({
1415
generateScramble: jest.fn(),
1516
}));
17+
jest.mock('../postgres/dualWrite', () => ({
18+
mirrorRoomChanges: jest.fn().mockResolvedValue(undefined),
19+
}));
1620

1721
const RoomModel = mongoose.model('RoomPostgresChangesTest', Room);
1822

@@ -260,6 +264,54 @@ describe('room security helpers', () => {
260264
expect(onAdminChange).not.toHaveBeenCalled();
261265
});
262266

267+
it('claims a departure with the membership revision compare-and-set', async () => {
268+
const owner = { id: 101, _id: 'owner-id' };
269+
const nextAdmin = { id: 202, _id: 'next-admin-id' };
270+
const updatedRoom = { id: 'room-one' };
271+
const query = {
272+
populate: jest.fn(() => query),
273+
then: (resolve, reject) => Promise.resolve(updatedRoom).then(resolve, reject),
274+
};
275+
const model = {
276+
findOneAndUpdate: jest.fn(() => query),
277+
};
278+
const updatedAt = new Date('2026-07-13T04:30:00.000Z');
279+
const room = {
280+
_id: 'room-one',
281+
owner,
282+
admin: owner,
283+
users: [owner, nextAdmin],
284+
inRoom: new Map([['101', true], ['202', true]]),
285+
type: 'normal',
286+
membershipRevision: 7,
287+
updatedAt,
288+
constructor: model,
289+
};
290+
291+
const departure = await Room.methods.dropUserAtomically.call(room, owner);
292+
293+
expect(model.findOneAndUpdate).toHaveBeenCalledWith({
294+
_id: room._id,
295+
'inRoom.101': true,
296+
membershipRevision: 7,
297+
updatedAt,
298+
}, {
299+
$set: {
300+
'inRoom.101': false,
301+
'waitingFor.101': false,
302+
admin: nextAdmin._id,
303+
},
304+
$inc: { membershipRevision: 1 },
305+
}, { new: true });
306+
expect(departure).toEqual({ room: updatedRoom, adminChanged: true });
307+
expect(mirrorRoomChanges).toHaveBeenCalledWith(updatedRoom, {
308+
attempts: [],
309+
participantUserIds: ['101'],
310+
syncAllParticipants: false,
311+
syncRoomOwners: true,
312+
});
313+
});
314+
263315
it('persists and announces the owner reclaiming admin controls', async () => {
264316
const owner = { id: 101 };
265317
const room = {

server/socket/namespaces/rooms.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,19 +127,38 @@ module.exports = (io, middlewares) => {
127127
async function finalizeUserDeparture({
128128
roomId, userId, connectionId, leaveReason,
129129
}) {
130-
const room = await fetchRoom(roomId);
131130
const userKey = userId.toString();
132131

133-
if (!room || !room.inRoom.get(userKey)) {
134-
return false;
132+
// The compare-and-set in dropUserAtomically is the cross-process claim.
133+
// A losing process re-reads so it never overwrites a concurrent rejoin.
134+
async function claimDeparture() {
135+
const room = await fetchRoom(roomId);
136+
if (!room || !room.inRoom.get(userKey)) {
137+
return null;
138+
}
139+
140+
if (leaveReason === 'disconnect'
141+
&& await hasActiveSocketsForUserRoom(userId, roomId)) {
142+
return null;
143+
}
144+
145+
const departure = await room.dropUserAtomically({ id: userId });
146+
if (!departure) {
147+
return claimDeparture();
148+
}
149+
150+
return departure;
135151
}
136152

137-
if (leaveReason === 'disconnect'
138-
&& await hasActiveSocketsForUserRoom(userId, roomId)) {
153+
const departure = await claimDeparture();
154+
if (!departure) {
139155
return false;
140156
}
141157

142-
const updatedRoom = await room.dropUser({ id: userId }, sendAdminUpdate);
158+
const { room: updatedRoom, adminChanged } = departure;
159+
if (adminChanged) {
160+
sendAdminUpdate(updatedRoom);
161+
}
143162

144163
await metrics.endRoomVisit({
145164
room: updatedRoom,

server/socket/namespaces/rooms.test.js

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,12 @@ describe('room namespace departures and moderation', () => {
363363
inRoom: new Map([['101', true]]),
364364
doneWithScramble: jest.fn().mockReturnValue(false),
365365
});
366-
room.dropUser = jest.fn(async (user, onAdminChange) => {
367-
room.inRoom.set(String(user.id), false);
368-
room.admin = null;
369-
onAdminChange(room);
370-
return room;
366+
room.dropUserAtomically = jest.fn().mockResolvedValue({
367+
room: {
368+
...room,
369+
admin: null,
370+
},
371+
adminChanged: true,
371372
});
372373
const connect = setup(new Map([[room._id, room]]));
373374
await connect(makeSocket({ id: 'spectator-socket', session: {} }));
@@ -384,6 +385,55 @@ describe('room namespace departures and moderation', () => {
384385
expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101);
385386
});
386387

388+
it('emits metrics and leave events only for the process that claims a departure', async () => {
389+
const room = makeRoom({
390+
private: false,
391+
password: null,
392+
inRoom: new Map([['101', true]]),
393+
});
394+
const updatedRoom = {
395+
...room,
396+
inRoom: new Map([['101', false]]),
397+
usersLength: 0,
398+
doneWithScramble: jest.fn().mockReturnValue(false),
399+
};
400+
let claimed = false;
401+
room.dropUserAtomically = jest.fn(async () => {
402+
if (claimed) {
403+
return null;
404+
}
405+
claimed = true;
406+
return { room: updatedRoom, adminChanged: false };
407+
});
408+
const connect = setup(new Map([[room._id, room]]));
409+
const persistedRoom = {
410+
...room,
411+
inRoom: new Map([['101', false]]),
412+
};
413+
Room.findById.mockImplementation(() => queryResult(claimed ? persistedRoom : room));
414+
const reconnectGrace = createReconnectGrace.mock.results[0].value;
415+
416+
await Promise.all([
417+
reconnectGrace.finalizeDeparture({
418+
roomId: room._id,
419+
userId: 101,
420+
connectionId: 'socket-on-process-a',
421+
leaveReason: 'explicit',
422+
}),
423+
reconnectGrace.finalizeDeparture({
424+
roomId: room._id,
425+
userId: 101,
426+
connectionId: 'socket-on-process-b',
427+
leaveReason: 'explicit',
428+
}),
429+
]);
430+
431+
expect(room.dropUserAtomically).toHaveBeenCalledTimes(2);
432+
expect(metrics.endRoomVisit).toHaveBeenCalledTimes(1);
433+
expect(connect.roomChannel.emit).toHaveBeenCalledTimes(1);
434+
expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101);
435+
});
436+
387437
it('finalizes one departure when two tabs explicitly leave together', async () => {
388438
const room = makeRoom({
389439
private: false,

0 commit comments

Comments
 (0)