Fix display duplication on WebSocket reconnect

Server-side: when a device reconnects on a fresh socket while the old
TCP zombie is still around, the old socket's eventual disconnect handler
flipped the device offline and removed the new heartbeat entry. Now we
proactively evict any prior socket on register and ignore disconnects
from sockets that are no longer the registered one for that device_id.

Frontend: dedupe devices by id from the API response and only render
each device in the first group it belongs to (multi-group membership
is still tracked for the Manage modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-28 10:13:00 -05:00
parent 05f70b7910
commit ee6888e737
2 changed files with 42 additions and 6 deletions

View file

@ -246,7 +246,6 @@ export function render(container) {
};
screenshotHandler = (data) => {
// Update all instances of this device's preview (may appear in multiple groups)
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
const img = preview.querySelector('img');
@ -286,7 +285,13 @@ async function loadDashboard() {
if (!main) return;
try {
const [devices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]);
const [rawDevices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]);
// Deduplicate devices by id — a stale reconnect race can briefly cause the same
// device to appear twice in the list. Last-write-wins keeps the freshest state.
const seen = new Map();
for (const d of rawDevices) seen.set(d.id, d);
const devices = Array.from(seen.values());
// Stats
const online = devices.filter(d => d.status === 'online').length;
@ -339,10 +344,17 @@ async function loadDashboard() {
return { ...g, devices: fullDevices, memberIds };
}));
// Find ungrouped devices
const allGroupedIds = new Set();
groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id)));
const ungrouped = devices.filter(d => !allGroupedIds.has(d.id));
// Render each device exactly once: the first group it belongs to wins.
// memberIds is preserved for the Manage modal so multi-group membership info stays accurate.
const renderedIds = new Set();
for (const g of groupsWithDevices) {
g.devices = g.devices.filter(d => {
if (renderedIds.has(d.id)) return false;
renderedIds.add(d.id);
return true;
});
}
const ungrouped = devices.filter(d => !renderedIds.has(d.id));
let html = '';

View file

@ -118,6 +118,19 @@ module.exports = function setupDeviceSocket(io) {
const deviceNs = io.of('/device');
const dashboardNs = io.of('/dashboard');
// Disconnect any existing socket that is currently registered for this device_id.
// Called when a fresh registration comes in for the same device so the old (likely
// half-dead) socket can't fire its disconnect handler and clobber the new entry.
function evictPriorSocket(deviceId, exceptSocketId) {
const prior = heartbeat.getConnection(deviceId);
if (!prior || prior.socketId === exceptSocketId) return;
const oldSocket = deviceNs.sockets.get(prior.socketId);
if (oldSocket) {
console.log(`Evicting prior socket ${prior.socketId} for device ${deviceId}`);
try { oldSocket.disconnect(true); } catch (_) {}
}
}
deviceNs.on('connection', (socket) => {
console.log(`Device socket connected: ${socket.id}`);
let currentDeviceId = null;
@ -145,6 +158,7 @@ module.exports = function setupDeviceSocket(io) {
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
authenticated = true;
evictPriorSocket(existing.device_id, socket.id);
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(getClientIp(socket), existing.device_id);
socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
@ -189,6 +203,7 @@ module.exports = function setupDeviceSocket(io) {
currentDeviceId = device_id;
authenticated = true;
evictPriorSocket(device_id, socket.id);
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(getClientIp(socket), device_id);
@ -384,6 +399,15 @@ module.exports = function setupDeviceSocket(io) {
socket.on('disconnect', () => {
if (currentDeviceId) {
// If a newer socket has already taken over this device_id, this is a stale
// disconnect from a replaced socket — skip the offline transition so we don't
// flip an actively-connected device offline or clobber the new heartbeat entry.
const activeConn = heartbeat.getConnection(currentDeviceId);
if (activeConn && activeConn.socketId !== socket.id) {
console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`);
return;
}
console.log(`Device disconnected: ${currentDeviceId}`);
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
.run(currentDeviceId);