From ee6888e737f2673b848f6855d6d8a33cdef996c2 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Apr 2026 10:13:00 -0500 Subject: [PATCH] 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) --- frontend/js/views/dashboard.js | 24 ++++++++++++++++++------ server/ws/deviceSocket.js | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index bbfb349..94b01dc 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -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 = ''; diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 0b1316a..7e3bb7f 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -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);