From 9914e26ca08a36df00fd935bbe9ab9386b8736a5 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sun, 24 May 2026 17:47:16 -0400 Subject: [PATCH] =?UTF-8?q?fix(web-ui):=20harden=20SSE=20broadcaster=20?= =?UTF-8?q?=E2=80=94=20drop-oldest=20on=20full=20queue,=20exit=20on=20no?= =?UTF-8?q?=20subscribers,=20reattach=20reconnect=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _broadcast: on queue.Full drop the oldest item and retry the put instead of removing the client from _clients — a slow tab now stays subscribed and receives the latest data rather than being silently ejected - _broadcast: break instead of continue when _clients is empty so the background generator thread exits rather than spinning indefinitely; subscribe() already restarts it on the next connection - base.html: expose _statsOpenHandler, _statsErrorHandler, and _displayErrorHandler as window properties so reconnectSSE() can reattach them after replacing the EventSource instances - app.js: reconnectSSE() now reattaches those handlers after creating each new EventSource so the status badge and display-stream console logging survive a manual reconnect Heartbeat path (~line 646) is a queue read (q.get), not a write; no queue.Full can occur there so no change needed. Co-Authored-By: Claude Sonnet 4.6 --- web_interface/app.py | 17 +++++++++++++---- web_interface/static/v3/app.js | 6 +++++- web_interface/templates/v3/base.html | 21 +++++++++++++-------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/web_interface/app.py b/web_interface/app.py index 33321b93..97287119 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -445,14 +445,23 @@ class _StreamBroadcaster: for data in self._generator_factory(): with self._lock: if not self._clients: - continue - dead = set() + # No subscribers — exit so the thread doesn't spin indefinitely. + # subscribe() will restart it when a new client arrives. + break for q in self._clients: try: q.put_nowait(data) except queue.Full: - dead.add(q) - self._clients -= dead + # Client is reading too slowly; drop the oldest item and + # deliver the latest so the queue never stalls the client. + try: + q.get_nowait() + except queue.Empty: + pass + try: + q.put_nowait(data) + except queue.Full: + pass # System status generator for SSE def system_status_generator(): diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index aa2f933a..fae7f5e0 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) { } }); -// SSE reconnection helper — closes and reopens both SSE streams. +// SSE reconnection helper — closes and reopens both SSE streams, +// reattaching the open/error handlers defined in base.html. window.reconnectSSE = function() { if (window.statsSource) { window.statsSource.close(); @@ -60,6 +61,8 @@ window.reconnectSSE = function() { const data = JSON.parse(event.data); if (typeof updateSystemStats === 'function') updateSystemStats(data); }; + if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler); + if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler); } if (window.displaySource) { @@ -69,6 +72,7 @@ window.reconnectSSE = function() { const data = JSON.parse(event.data); if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data); }; + if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler); } }; diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index b4b9cf47..bca338d1 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1406,23 +1406,28 @@ } var _statsErrorCount = 0; - window.statsSource.addEventListener('open', function() { + + // Named on window so reconnectSSE() in app.js can reattach them after + // replacing the EventSource instances. + window._statsOpenHandler = function() { _statsErrorCount = 0; _setConnectionStatus(true, false); - }); - - window.statsSource.addEventListener('error', function() { + }; + window._statsErrorHandler = function() { _statsErrorCount++; // EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED var reconnecting = window.statsSource.readyState === EventSource.CONNECTING; _setConnectionStatus(false, reconnecting && _statsErrorCount <= 3); - }); - - window.displaySource.addEventListener('error', function() { + }; + window._displayErrorHandler = function() { // Display stream errors don't change the status badge but log to console // so failures aren't completely silent. console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')'); - }); + }; + + window.statsSource.addEventListener('open', window._statsOpenHandler); + window.statsSource.addEventListener('error', window._statsErrorHandler); + window.displaySource.addEventListener('error', window._displayErrorHandler); function updateSystemStats(data) { // Update CPU in header