Skip to content

Commit 7f99987

Browse files
committed
Make stale departure claims terminal
Carry the membership revision captured when a socket leaves through reconnect cleanup and reject a departure when that generation no longer matches. This prevents a losing old finalizer from removing a later rejoin.\n\nRefs #153
1 parent 9e572b4 commit 7f99987

5 files changed

Lines changed: 59 additions & 58 deletions

File tree

docs/room-ownership.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ rejoins, they reclaim admin before the join is acknowledged.
3737
The final persisted departure is a MongoDB compare-and-set keyed by the active
3838
membership revision. This applies across Socket.IO processes: exactly one
3939
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.
40+
concurrent rejoin advances the revision, so a losing leave is terminal and
41+
cannot overwrite the new membership.
4242

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

server/models/room.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,13 @@ Room.methods.dropUser = async function (user, updateAdmin) {
244244
return this.save();
245245
};
246246

247-
Room.methods.dropUserAtomically = async function (user) {
247+
Room.methods.dropUserAtomically = async function (
248+
user,
249+
expectedMembershipRevision = this.membershipRevision || 0,
250+
) {
248251
const userKey = user.id.toString();
249-
if (!this.inRoom.get(userKey)) {
252+
const membershipRevision = this.membershipRevision || 0;
253+
if (!this.inRoom.get(userKey) || expectedMembershipRevision !== membershipRevision) {
250254
return null;
251255
}
252256

@@ -260,14 +264,10 @@ Room.methods.dropUserAtomically = async function (user) {
260264
admin: this.admin,
261265
});
262266
const adminChanged = !sameUser(this.admin, nextAdmin);
263-
const membershipRevision = this.membershipRevision || 0;
264267
const condition = {
265268
_id: this._id,
266269
[`inRoom.${userKey}`]: true,
267270
};
268-
if (this.updatedAt) {
269-
condition.updatedAt = this.updatedAt;
270-
}
271271
if (membershipRevision > 0) {
272272
condition.membershipRevision = membershipRevision;
273273
} else {

server/models/room.test.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,6 @@ describe('room security helpers', () => {
275275
const model = {
276276
findOneAndUpdate: jest.fn(() => query),
277277
};
278-
const updatedAt = new Date('2026-07-13T04:30:00.000Z');
279278
const room = {
280279
_id: 'room-one',
281280
owner,
@@ -284,7 +283,6 @@ describe('room security helpers', () => {
284283
inRoom: new Map([['101', true], ['202', true]]),
285284
type: 'normal',
286285
membershipRevision: 7,
287-
updatedAt,
288286
constructor: model,
289287
};
290288

@@ -294,7 +292,6 @@ describe('room security helpers', () => {
294292
_id: room._id,
295293
'inRoom.101': true,
296294
membershipRevision: 7,
297-
updatedAt,
298295
}, {
299296
$set: {
300297
'inRoom.101': false,

server/socket/namespaces/rooms.js

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ module.exports = (io, middlewares) => {
8787
return rooms.flatMap((room) => room.usersInRoom.map((user) => ({
8888
roomId: room._id,
8989
userId: user.id,
90+
membershipRevision: room.membershipRevision,
9091
leaveReason: 'disconnect',
9192
})));
9293
}
@@ -125,32 +126,24 @@ module.exports = (io, middlewares) => {
125126
}
126127

127128
async function finalizeUserDeparture({
128-
roomId, userId, connectionId, leaveReason,
129+
roomId, userId, connectionId, leaveReason, membershipRevision,
129130
}) {
130131
const userKey = userId.toString();
131132

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-
}
133+
const room = await fetchRoom(roomId);
134+
if (!room || !room.inRoom.get(userKey)
135+
|| room.membershipRevision !== membershipRevision) {
136+
return false;
137+
}
149138

150-
return departure;
139+
if (leaveReason === 'disconnect'
140+
&& await hasActiveSocketsForUserRoom(userId, roomId)) {
141+
return false;
151142
}
152143

153-
const departure = await claimDeparture();
144+
// The membership revision is the departure generation. A failed claim is
145+
// terminal: retrying it after a rejoin would remove the new membership.
146+
const departure = await room.dropUserAtomically({ id: userId }, membershipRevision);
154147
if (!departure) {
155148
return false;
156149
}
@@ -378,6 +371,7 @@ module.exports = (io, middlewares) => {
378371
roomId: socket.roomId,
379372
userId: socket.userId,
380373
connectionId: socket.id,
374+
membershipRevision: socket.room.membershipRevision,
381375
leaveReason,
382376
});
383377
} catch (e) {
@@ -1077,6 +1071,7 @@ module.exports = (io, middlewares) => {
10771071
roomId: socket.roomId,
10781072
userId: socket.userId,
10791073
connectionId: socket.id,
1074+
membershipRevision: socket.room.membershipRevision,
10801075
leaveReason: 'disconnect',
10811076
});
10821077
} else if (socket.room) {

server/socket/namespaces/rooms.test.js

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const makeRoom = (overrides = {}) => ({
7171
inRoom: new Map(),
7272
banned: new Map(),
7373
registered: new Map(),
74+
membershipRevision: 7,
7475
requireRevealedIdentity: false,
7576
usersLength: 1,
7677
authenticate: jest.fn().mockResolvedValue(true),
@@ -378,14 +379,15 @@ describe('room namespace departures and moderation', () => {
378379
roomId: room._id,
379380
userId: 101,
380381
connectionId: 'host-socket',
382+
membershipRevision: room.membershipRevision,
381383
leaveReason: 'explicit',
382384
});
383385

384386
expect(connect.roomChannel.emit).not.toHaveBeenCalledWith(Protocol.UPDATE_ADMIN, null);
385387
expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101);
386388
});
387389

388-
it('emits metrics and leave events only for the process that claims a departure', async () => {
390+
it('does not let a losing departure remove a later rejoin', async () => {
389391
const room = makeRoom({
390392
private: false,
391393
password: null,
@@ -394,41 +396,47 @@ describe('room namespace departures and moderation', () => {
394396
const updatedRoom = {
395397
...room,
396398
inRoom: new Map([['101', false]]),
399+
membershipRevision: 8,
397400
usersLength: 0,
398401
doneWithScramble: jest.fn().mockReturnValue(false),
399402
};
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 };
403+
room.dropUserAtomically = jest.fn().mockResolvedValue({
404+
room: updatedRoom,
405+
adminChanged: false,
407406
});
408407
const connect = setup(new Map([[room._id, room]]));
409-
const persistedRoom = {
408+
let persistedRoom = room;
409+
Room.findById.mockImplementation(() => queryResult(persistedRoom));
410+
const reconnectGrace = createReconnectGrace.mock.results[0].value;
411+
412+
await expect(reconnectGrace.finalizeDeparture({
413+
roomId: room._id,
414+
userId: 101,
415+
connectionId: 'winner-socket',
416+
membershipRevision: 7,
417+
leaveReason: 'explicit',
418+
})).resolves.toBe(true);
419+
420+
const rejoinedRoom = {
410421
...room,
411-
inRoom: new Map([['101', false]]),
422+
admin: { id: 101 },
423+
inRoom: new Map([['101', true]]),
424+
membershipRevision: 9,
412425
};
413-
Room.findById.mockImplementation(() => queryResult(claimed ? persistedRoom : room));
414-
const reconnectGrace = createReconnectGrace.mock.results[0].value;
426+
persistedRoom = rejoinedRoom;
415427

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-
]);
428+
await expect(reconnectGrace.finalizeDeparture({
429+
roomId: room._id,
430+
userId: 101,
431+
connectionId: 'loser-socket',
432+
membershipRevision: 7,
433+
leaveReason: 'explicit',
434+
})).resolves.toBe(false);
430435

431-
expect(room.dropUserAtomically).toHaveBeenCalledTimes(2);
436+
expect(room.dropUserAtomically).toHaveBeenCalledTimes(1);
437+
expect(rejoinedRoom.inRoom.get('101')).toBe(true);
438+
expect(rejoinedRoom.admin).toEqual({ id: 101 });
439+
expect(rejoinedRoom.membershipRevision).toBe(updatedRoom.membershipRevision + 1);
432440
expect(metrics.endRoomVisit).toHaveBeenCalledTimes(1);
433441
expect(connect.roomChannel.emit).toHaveBeenCalledTimes(1);
434442
expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101);
@@ -468,6 +476,7 @@ describe('room namespace departures and moderation', () => {
468476
expect(reconnectGrace.finalize).toHaveBeenCalledWith(expect.objectContaining({
469477
roomId: room._id,
470478
userId: 101,
479+
membershipRevision: room.membershipRevision,
471480
leaveReason: 'explicit',
472481
}));
473482
});

0 commit comments

Comments
 (0)