rover server restart desync fix hopefully

This commit is contained in:
legop3
2026-08-20 15:27:23 -04:00
parent f77a969098
commit c8836f7d65
6 changed files with 146 additions and 4 deletions
@@ -72,6 +72,7 @@ const {
} = privateAccess;
const roverLifecycle = createRoverLifecycle({
io,
rovers,
socketToRovers,
managerEvents,
@@ -86,6 +87,7 @@ const roverLifecycle = createRoverLifecycle({
const {
requestControl,
releaseControl,
removeRoverDrivers,
isDriver,
canDrive,
getRoversForSocket,
@@ -119,6 +121,7 @@ const rosterLifecycle = createRosterLifecycle({
normalizePrivateSafety,
stopDockGuard: (...args) => stopDockGuard(...args),
getControlDenialReason,
removeRoverDrivers,
});
const {
@@ -24,6 +24,7 @@ function createRosterLifecycle(deps) {
isRoverVisibleToSocket,
normalizePrivateSafety,
stopDockGuard,
removeRoverDrivers,
} = deps;
function ensureRecord(id) {
@@ -106,7 +107,14 @@ function createRosterLifecycle(deps) {
function removeRover(id) {
const record = rovers.get(id);
if (!record) return;
/*
Remove the public record before emitting driver-removal events. Any
session sync caused by those events must already see this rover as
offline, while removeRoverDrivers still receives the captured record so
it can clean the reverse membership index and Socket.IO room membership.
*/
rovers.delete(id);
removeRoverDrivers(id, record);
stopDockGuard(id);
privateButtonStates.delete(id);
privateNoUsersSince.delete(id);
@@ -3,6 +3,7 @@
// Scope: Keeps runtime behavior unchanged by reusing rover-manager state maps and injected policy helpers.
function createRoverLifecycle(deps) {
const {
io,
rovers,
socketToRovers,
managerEvents,
@@ -85,6 +86,40 @@ function createRoverLifecycle(deps) {
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
}
function removeRoverDrivers(roverId, removedRecord = null) {
/*
A rover connection owns the record that contains its driver set, but the
reverse socket-to-rover index outlives that record. Disconnect cleanup
must therefore remove both halves before a reconnect creates a fresh
record with the same id. Otherwise session assignment can name the rover
while video/control authorization correctly sees no driver membership.
removedRecord is accepted because rosterLifecycle deliberately deletes
the public rover record first. Session syncs triggered by the driver
events below will consequently hide the unavailable rover immediately,
even before assignmentService finishes normal reassignment.
*/
const record = removedRecord || rovers.get(roverId);
if (!record) return [];
const driverIds = Array.from(record.drivers || []);
driverIds.forEach((socketId) => {
const joined = socketToRovers.get(socketId);
if (joined) {
joined.delete(roverId);
if (joined.size === 0) socketToRovers.delete(socketId);
}
const socket = io.sockets.sockets.get(socketId);
socket?.leave(record.room);
record.drivers.delete(socketId);
turnService.driverRemoved(roverId, socketId);
managerEvents.emit('driver', { socketId, roverId, action: 'remove' });
});
return driverIds;
}
function isDriver(roverId, socket) {
const record = rovers.get(roverId);
if (!record) return false;
@@ -175,6 +210,7 @@ function createRoverLifecycle(deps) {
removeSocket,
requestControl,
releaseControl,
removeRoverDrivers,
isDriver,
canDrive,
getRoversForSocket,
@@ -0,0 +1,59 @@
// Rover Manager Lifecycle Tests
// Purpose: Verifies that physical rover removal clears every ownership index before a same-id reconnect.
// Scope: Covers driver membership cleanup only; assignment placement policy remains in assignmentService.
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('node:events');
const { createRoverLifecycle } = require('./roverLifecycle');
test('removing a rover clears driver sets, reverse membership, rooms, and turns', () => {
const roverId = 'rover-one';
const socketId = 'driver-one';
const leftRooms = [];
const removedTurns = [];
const driverEvents = [];
const socket = {
id: socketId,
leave: (room) => leftRooms.push(room),
};
const record = {
id: roverId,
room: `rover:${roverId}`,
drivers: new Set([socketId]),
};
const rovers = new Map([[roverId, record]]);
const socketToRovers = new Map([[socketId, new Set([roverId])]]);
const managerEvents = new EventEmitter();
managerEvents.on('driver', (event) => driverEvents.push(event));
const lifecycle = createRoverLifecycle({
io: { sockets: { sockets: new Map([[socketId, socket]]) } },
rovers,
socketToRovers,
managerEvents,
turnService: {
driverRemoved: (removedRoverId, removedSocketId) => {
removedTurns.push([removedRoverId, removedSocketId]);
},
},
isAdmin: () => false,
sendAlert: () => {},
ALERT_COLOR: '#000000',
getMode: () => 'public',
getControlDenialReason: () => null,
});
/* Mirror rosterLifecycle's ordering: the public record is gone before the
captured record is supplied for complete membership cleanup. */
rovers.delete(roverId);
const removedDriverIds = lifecycle.removeRoverDrivers(roverId, record);
assert.deepEqual(removedDriverIds, [socketId]);
assert.equal(record.drivers.size, 0);
assert.equal(socketToRovers.has(socketId), false);
assert.deepEqual(leftRooms, [`rover:${roverId}`]);
assert.deepEqual(removedTurns, [[roverId, socketId]]);
assert.deepEqual(driverEvents, [
{ socketId, roverId, action: 'remove' },
]);
});