Skip to content

Commit acd14d2

Browse files
Debounce disconnect state to suppress button flicker
When the browser WebSocket drops and reconnects within 8 seconds, on_all_browsers_disconnected() is now suppressed — preventing the brief red stop-icon flash on mic/camera buttons during active calls.
1 parent 609b379 commit acd14d2

2 files changed

Lines changed: 124 additions & 4 deletions

File tree

streamdeck-plugin/src/browser_websocket_server.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010

1111
class BrowserWebsocketServer:
12+
DISCONNECT_NOTIFY_DELAY_SECONDS = 8.0
13+
1214
"""
1315
The BrowserWebsocketServer manages our connection to our browser extension,
1416
brokering messages between Google Meet and our plugin's EventHandler.
@@ -22,12 +24,17 @@ class BrowserWebsocketServer:
2224
websockets hanging around, or if we have multiple Meet tabs.
2325
"""
2426

25-
def __init__(self):
27+
def __init__(self, disconnect_notify_delay_seconds: float | None = None):
2628
"""
2729
Remember to call start() before attempting to use your new instance!
2830
"""
2931

3032
self._logger = logging.getLogger(__name__)
33+
self._disconnect_notify_delay_seconds = (
34+
disconnect_notify_delay_seconds
35+
if disconnect_notify_delay_seconds is not None
36+
else self.DISCONNECT_NOTIFY_DELAY_SECONDS
37+
)
3138

3239
"""
3340
Store all of the connected sockets we have open to the browser extension,
@@ -40,6 +47,7 @@ def __init__(self):
4047
Any EventHandlers registered to receive inbound events from the browser extension.
4148
"""
4249
self._handlers: List["EventHandler"] = []
50+
self._disconnect_notify_task: asyncio.Task | None = None
4351

4452
async def start(self, hostname: str, port: int) -> websockets.Server:
4553
return await websockets.serve(self._message_receive_loop, hostname, port)
@@ -70,6 +78,7 @@ def num_connected_clients(self) -> int:
7078
return len(self._ws_clients)
7179

7280
def _register_client(self, ws: websockets.ServerConnection) -> None:
81+
self._cancel_disconnect_notification()
7382
self._ws_clients.add(ws)
7483
self._logger.info(
7584
(f"{ws.remote_address} has connected to our browser websocket."
@@ -113,12 +122,51 @@ async def _message_receive_loop(self, ws: websockets.ServerConnection) -> None:
113122
await self._unregister_client(ws)
114123

115124
if not self._ws_clients:
125+
self._schedule_disconnect_notification()
126+
127+
def _schedule_disconnect_notification(self) -> None:
128+
if self._disconnect_notify_task and not self._disconnect_notify_task.done():
129+
return
130+
131+
self._logger.info(
132+
("Scheduling browser disconnect notification in"
133+
f" {self._disconnect_notify_delay_seconds} seconds."))
134+
self._disconnect_notify_task = asyncio.create_task(
135+
self._notify_all_browsers_disconnected_after_delay())
136+
137+
def _cancel_disconnect_notification(self) -> None:
138+
if not self._disconnect_notify_task:
139+
return
140+
141+
if self._disconnect_notify_task.done():
142+
self._disconnect_notify_task = None
143+
return
144+
145+
self._logger.info(
146+
("Cancelled pending browser disconnect notification because a"
147+
" browser client connected."))
148+
self._disconnect_notify_task.cancel()
149+
self._disconnect_notify_task = None
150+
151+
async def _notify_all_browsers_disconnected_after_delay(self) -> None:
152+
current_task = asyncio.current_task()
153+
154+
try:
155+
await asyncio.sleep(self._disconnect_notify_delay_seconds)
156+
self._logger.info(
157+
("Browser disconnect notification fired after"
158+
f" {self._disconnect_notify_delay_seconds} seconds."))
116159
for handler in self._handlers:
117160
try:
118161
await handler.on_all_browsers_disconnected()
119162
except Exception:
120163
self._logger.exception(
121164
"Connection mananger received an exception from EventHandler!")
165+
except asyncio.CancelledError:
166+
raise
167+
finally:
168+
if self._disconnect_notify_task is current_task:
169+
self._disconnect_notify_task = None
122170

123171
async def _process_inbound_message(self, message: str | bytes) -> None:
124172
"""

streamdeck-plugin/tests/test_browser_websocket_server.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
1+
import asyncio
12
from unittest import IsolatedAsyncioTestCase
23
from unittest.mock import AsyncMock, MagicMock, call
34

45
from src.browser_websocket_server import BrowserWebsocketServer
56

67

8+
class BlockingWebsocket:
9+
10+
def __init__(self):
11+
self.close = AsyncMock()
12+
self.remote_address = ("127.0.0.1", 9999)
13+
self._disconnect_event = asyncio.Event()
14+
15+
def disconnect(self):
16+
self._disconnect_event.set()
17+
18+
def __aiter__(self):
19+
return self._message_iterator()
20+
21+
async def _message_iterator(self):
22+
await self._disconnect_event.wait()
23+
if False:
24+
yield "unused"
25+
26+
727
class BrowserWebsocketServerTests(IsolatedAsyncioTestCase):
828

929
async def test_handler_registration(self):
@@ -60,19 +80,23 @@ async def test_sockets_gracefully_closed(self):
6080

6181
mock_websocket.close.assert_called_once()
6282

63-
async def test_browser_disconnected_callback_called(self):
83+
async def test_browser_disconnected_callback_called_after_delay(self):
6484
"""
6585
Test that our EventHandler's on_all_browsers_disconnected is called
66-
once there are no connected clients.
86+
once there are no connected clients for the full debounce window.
6787
"""
6888
event_handler = AsyncMock()
69-
server = BrowserWebsocketServer()
89+
server = BrowserWebsocketServer(disconnect_notify_delay_seconds=0.01)
7090
server.register_event_handler(event_handler)
7191
mock_websocket = AsyncMock()
7292

7393
await server._message_receive_loop(mock_websocket)
94+
event_handler.on_all_browsers_disconnected.assert_not_called()
95+
96+
await asyncio.sleep(0.02)
7497

7598
event_handler.on_all_browsers_disconnected.assert_called_with()
99+
self.assertIsNone(server._disconnect_notify_task)
76100

77101
async def test_browser_connected_callback_called(self):
78102
"""
@@ -105,6 +129,54 @@ async def test_browser_disconnected_callback_not_called(self):
105129
event_handler.on_all_browsers_disconnected.assert_not_called()
106130
event_handler.on_browser_connected.assert_not_called()
107131

132+
async def test_transient_browser_disconnect_suppressed_within_window(self):
133+
"""
134+
Test that transient websocket disconnects do not notify handlers when a
135+
browser reconnects before the debounce window expires.
136+
"""
137+
event_handler = AsyncMock()
138+
server = BrowserWebsocketServer(disconnect_notify_delay_seconds=0.05)
139+
server.register_event_handler(event_handler)
140+
141+
await server._message_receive_loop(AsyncMock())
142+
143+
blocking_websocket = BlockingWebsocket()
144+
reconnect_task = asyncio.create_task(
145+
server._message_receive_loop(blocking_websocket))
146+
await asyncio.sleep(0.06)
147+
148+
event_handler.on_all_browsers_disconnected.assert_not_called()
149+
150+
blocking_websocket.disconnect()
151+
await reconnect_task
152+
server._disconnect_notify_task.cancel()
153+
await asyncio.sleep(0)
154+
155+
async def test_disconnect_notification_task_cancelled_on_reconnect(self):
156+
"""
157+
Test that a pending disconnect notification task is cancelled when a
158+
new browser websocket reconnects within the debounce window.
159+
"""
160+
server = BrowserWebsocketServer(disconnect_notify_delay_seconds=0.05)
161+
162+
await server._message_receive_loop(AsyncMock())
163+
disconnect_notify_task = server._disconnect_notify_task
164+
self.assertIsNotNone(disconnect_notify_task)
165+
self.assertFalse(disconnect_notify_task.done())
166+
167+
blocking_websocket = BlockingWebsocket()
168+
reconnect_task = asyncio.create_task(
169+
server._message_receive_loop(blocking_websocket))
170+
await asyncio.sleep(0.01)
171+
172+
self.assertTrue(disconnect_notify_task.cancelled())
173+
self.assertIsNone(server._disconnect_notify_task)
174+
175+
blocking_websocket.disconnect()
176+
await reconnect_task
177+
server._disconnect_notify_task.cancel()
178+
await asyncio.sleep(0)
179+
108180
async def test_socket_messages_read(self):
109181
"""
110182
Test that our code reads inbound messages from websockets.

0 commit comments

Comments
 (0)