Skip to content

Commit af3602b

Browse files
committed
Fence duplicate room joins from stale leaves
Advance a durable per-user presence revision before acknowledging an already-active room membership. Departure claims now require that generation, so an old leave cannot remove a newly opened tab.\n\nRefs #153
1 parent 7f99987 commit af3602b

5 files changed

Lines changed: 248 additions & 40 deletions

File tree

docs/room-ownership.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +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 is terminal and
41-
cannot overwrite the new membership.
37+
The final persisted departure is a MongoDB compare-and-set keyed by room
38+
membership and the active user's presence revision. This applies across
39+
Socket.IO processes: exactly one process can claim a leave, emit its room
40+
events, and record its metrics. Every authenticated duplicate join advances
41+
that user's durable presence revision before acknowledgment, so an older leave
42+
is terminal and cannot overwrite an active new tab.
4243

4344
An empty room persists `admin: null`, but the established `UPDATE_ADMIN`
4445
Socket.IO event is emitted only for a non-null active admin. Spectators and

server/models/room.js

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ const selectRoomAdmin = ({ usersInRoom, owner, admin }) => {
2323
return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0];
2424
};
2525

26+
const presenceRevisionFor = (room, userKey) => (
27+
room.presenceRevision ? room.presenceRevision.get(userKey) || 0 : 0
28+
);
29+
30+
const advancePresenceRevisionFor = (room, userKey) => {
31+
if (!room.presenceRevision) {
32+
room.presenceRevision = new Map();
33+
}
34+
room.presenceRevision.set(userKey, presenceRevisionFor(room, userKey) + 1);
35+
};
36+
2637
// const lengths = {
2738

2839
// };
@@ -109,6 +120,11 @@ const Room = new mongoose.Schema({
109120
type: Number,
110121
default: 0,
111122
},
123+
presenceRevision: {
124+
type: Map,
125+
of: Number,
126+
default: {},
127+
},
112128
admin: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
113129
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
114130
type: {
@@ -210,6 +226,7 @@ Room.methods.addUser = async function (user, spectating, updateAdmin) {
210226

211227
this.inRoom.set(user.id.toString(), true);
212228
this.membershipRevision = (this.membershipRevision || 0) + 1;
229+
advancePresenceRevisionFor(this, user.id.toString());
213230

214231
if (this.waitingForCount === 0) {
215232
this.waitingFor.set(user.id.toString(), true);
@@ -234,6 +251,7 @@ Room.methods.dropUser = async function (user, updateAdmin) {
234251
this.inRoom.set(user.id.toString(), false);
235252
this.waitingFor.set(user.id.toString(), false);
236253
this.membershipRevision = (this.membershipRevision || 0) + 1;
254+
advancePresenceRevisionFor(this, user.id.toString());
237255

238256
await this.updateAdminIfNeeded(updateAdmin);
239257

@@ -247,10 +265,14 @@ Room.methods.dropUser = async function (user, updateAdmin) {
247265
Room.methods.dropUserAtomically = async function (
248266
user,
249267
expectedMembershipRevision = this.membershipRevision || 0,
268+
expectedPresenceRevision = presenceRevisionFor(this, user.id.toString()),
250269
) {
251270
const userKey = user.id.toString();
252271
const membershipRevision = this.membershipRevision || 0;
253-
if (!this.inRoom.get(userKey) || expectedMembershipRevision !== membershipRevision) {
272+
const presenceRevision = presenceRevisionFor(this, userKey);
273+
if (!this.inRoom.get(userKey)
274+
|| expectedMembershipRevision !== membershipRevision
275+
|| expectedPresenceRevision !== presenceRevision) {
254276
return null;
255277
}
256278

@@ -268,13 +290,29 @@ Room.methods.dropUserAtomically = async function (
268290
_id: this._id,
269291
[`inRoom.${userKey}`]: true,
270292
};
293+
const revisionConditions = [];
271294
if (membershipRevision > 0) {
272295
condition.membershipRevision = membershipRevision;
273296
} else {
274-
condition.$or = [
275-
{ membershipRevision: 0 },
276-
{ membershipRevision: { $exists: false } },
277-
];
297+
revisionConditions.push({
298+
$or: [
299+
{ membershipRevision: 0 },
300+
{ membershipRevision: { $exists: false } },
301+
],
302+
});
303+
}
304+
if (presenceRevision > 0) {
305+
condition[`presenceRevision.${userKey}`] = presenceRevision;
306+
} else {
307+
revisionConditions.push({
308+
$or: [
309+
{ [`presenceRevision.${userKey}`]: 0 },
310+
{ [`presenceRevision.${userKey}`]: { $exists: false } },
311+
],
312+
});
313+
}
314+
if (revisionConditions.length > 0) {
315+
condition.$and = revisionConditions;
278316
}
279317

280318
const update = {
@@ -285,6 +323,7 @@ Room.methods.dropUserAtomically = async function (
285323
},
286324
$inc: {
287325
membershipRevision: 1,
326+
[`presenceRevision.${userKey}`]: 1,
288327
},
289328
};
290329
if (usersInRoom.length === 0 && this.type === 'normal') {
@@ -308,6 +347,20 @@ Room.methods.dropUserAtomically = async function (
308347
return { room: updatedRoom, adminChanged };
309348
};
310349

350+
Room.methods.advancePresenceRevision = function (userId) {
351+
const userKey = userId.toString();
352+
return this.constructor.findOneAndUpdate({
353+
_id: this._id,
354+
[`inRoom.${userKey}`]: true,
355+
}, {
356+
$inc: {
357+
[`presenceRevision.${userKey}`]: 1,
358+
},
359+
}, {
360+
new: true,
361+
}).populate('users').populate('admin').populate('owner');
362+
};
363+
311364
Room.methods.banUser = async function (userId) {
312365
this.banned.set(userId.toString(), true);
313366
return this.dropUser({ id: userId });

server/models/room.test.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ describe('room security helpers', () => {
283283
inRoom: new Map([['101', true], ['202', true]]),
284284
type: 'normal',
285285
membershipRevision: 7,
286+
presenceRevision: new Map([['101', 7]]),
286287
constructor: model,
287288
};
288289

@@ -292,13 +293,17 @@ describe('room security helpers', () => {
292293
_id: room._id,
293294
'inRoom.101': true,
294295
membershipRevision: 7,
296+
'presenceRevision.101': 7,
295297
}, {
296298
$set: {
297299
'inRoom.101': false,
298300
'waitingFor.101': false,
299301
admin: nextAdmin._id,
300302
},
301-
$inc: { membershipRevision: 1 },
303+
$inc: {
304+
membershipRevision: 1,
305+
'presenceRevision.101': 1,
306+
},
302307
}, { new: true });
303308
expect(departure).toEqual({ room: updatedRoom, adminChanged: true });
304309
expect(mirrorRoomChanges).toHaveBeenCalledWith(updatedRoom, {
@@ -309,6 +314,30 @@ describe('room security helpers', () => {
309314
});
310315
});
311316

317+
it('advances an active user presence revision atomically', async () => {
318+
const updatedRoom = { id: 'room-one' };
319+
const query = {
320+
populate: jest.fn(() => query),
321+
then: (resolve, reject) => Promise.resolve(updatedRoom).then(resolve, reject),
322+
};
323+
const model = {
324+
findOneAndUpdate: jest.fn(() => query),
325+
};
326+
const room = {
327+
_id: 'room-one',
328+
constructor: model,
329+
};
330+
331+
await expect(Room.methods.advancePresenceRevision.call(room, 101)).resolves.toBe(updatedRoom);
332+
333+
expect(model.findOneAndUpdate).toHaveBeenCalledWith({
334+
_id: room._id,
335+
'inRoom.101': true,
336+
}, {
337+
$inc: { 'presenceRevision.101': 1 },
338+
}, { new: true });
339+
});
340+
312341
it('persists and announces the owner reclaiming admin controls', async () => {
313342
const owner = { id: 101 };
314343
const room = {

server/socket/namespaces/rooms.js

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ module.exports = (io, middlewares) => {
8888
roomId: room._id,
8989
userId: user.id,
9090
membershipRevision: room.membershipRevision,
91+
presenceRevision: room.presenceRevision.get(user.id.toString()),
9192
leaveReason: 'disconnect',
9293
})));
9394
}
@@ -126,13 +127,14 @@ module.exports = (io, middlewares) => {
126127
}
127128

128129
async function finalizeUserDeparture({
129-
roomId, userId, connectionId, leaveReason, membershipRevision,
130+
roomId, userId, connectionId, leaveReason, membershipRevision, presenceRevision,
130131
}) {
131132
const userKey = userId.toString();
132133

133134
const room = await fetchRoom(roomId);
134135
if (!room || !room.inRoom.get(userKey)
135-
|| room.membershipRevision !== membershipRevision) {
136+
|| room.membershipRevision !== membershipRevision
137+
|| room.presenceRevision.get(userKey) !== presenceRevision) {
136138
return false;
137139
}
138140

@@ -143,7 +145,11 @@ module.exports = (io, middlewares) => {
143145

144146
// The membership revision is the departure generation. A failed claim is
145147
// terminal: retrying it after a rejoin would remove the new membership.
146-
const departure = await room.dropUserAtomically({ id: userId }, membershipRevision);
148+
const departure = await room.dropUserAtomically(
149+
{ id: userId },
150+
membershipRevision,
151+
presenceRevision,
152+
);
147153
if (!departure) {
148154
return false;
149155
}
@@ -372,6 +378,7 @@ module.exports = (io, middlewares) => {
372378
userId: socket.userId,
373379
connectionId: socket.id,
374380
membershipRevision: socket.room.membershipRevision,
381+
presenceRevision: socket.room.presenceRevision.get(socket.userId.toString()),
375382
leaveReason,
376383
});
377384
} catch (e) {
@@ -423,18 +430,42 @@ module.exports = (io, middlewares) => {
423430
});
424431
}
425432

426-
const r = await activeRoom.addUser(socket.user, spectating, sendAdminUpdate);
433+
const presentRoom = await activeRoom.advancePresenceRevision(socket.userId);
434+
if (presentRoom) {
435+
socket.room = presentRoom;
436+
await metrics.beginRoomVisit({
437+
room: presentRoom,
438+
userId: socket.userId,
439+
connectionId: socket.id,
440+
activeUserCount: presentRoom.usersLength,
441+
});
442+
return cb(null, joinRoomMask(presentRoom));
443+
}
427444

445+
const currentRoom = await fetchRoom(room._id);
446+
if (!currentRoom) {
447+
return cb({
448+
statusCode: 404,
449+
message: 'Room no longer exists',
450+
});
451+
}
452+
const r = await currentRoom.addUser(socket.user, spectating, sendAdminUpdate);
428453
if (!r) {
429-
// The user is already present in persisted room state.
430-
socket.room = activeRoom;
454+
const fencedRoom = await currentRoom.advancePresenceRevision(socket.userId);
455+
if (!fencedRoom) {
456+
return cb({
457+
statusCode: 409,
458+
message: 'Room membership changed while joining',
459+
});
460+
}
461+
socket.room = fencedRoom;
431462
await metrics.beginRoomVisit({
432-
room: activeRoom,
463+
room: fencedRoom,
433464
userId: socket.userId,
434465
connectionId: socket.id,
435-
activeUserCount: activeRoom.usersLength,
466+
activeUserCount: fencedRoom.usersLength,
436467
});
437-
return cb(null, joinRoomMask(activeRoom));
468+
return cb(null, joinRoomMask(fencedRoom));
438469
}
439470

440471
socket.room = r;
@@ -1072,6 +1103,7 @@ module.exports = (io, middlewares) => {
10721103
userId: socket.userId,
10731104
connectionId: socket.id,
10741105
membershipRevision: socket.room.membershipRevision,
1106+
presenceRevision: socket.room.presenceRevision.get(socket.userId.toString()),
10751107
leaveReason: 'disconnect',
10761108
});
10771109
} else if (socket.room) {

0 commit comments

Comments
 (0)