Skip to content

Commit c41939b

Browse files
authored
Merge pull request #3797 from plotly/fix/ws-connection-management
improved websocket connection management
2 parents 715dae8 + 8c2329f commit c41939b

14 files changed

Lines changed: 496 additions & 39 deletions

File tree

.ai/ARCHITECTURE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,12 +905,14 @@ app = Dash(
905905
server=server,
906906
websocket_callbacks=True,
907907
websocket_inactivity_timeout=300000, # 5 minutes (default)
908+
websocket_heartbeat_interval=30000, # 30 seconds (default)
908909
websocket_allowed_origins=['https://example.com'],
909910
)
910911
```
911912

912913
- **`websocket_callbacks`** - Enable WebSocket for all callbacks (default: `False`)
913914
- **`websocket_inactivity_timeout`** - Close WebSocket after period of inactivity in milliseconds (default: `300000` = 5 minutes). Heartbeats do not count as activity. Set to `0` to disable timeout. Connection automatically reconnects when needed.
915+
- **`websocket_heartbeat_interval`** - Interval for heartbeat/keep-alive checks in milliseconds (default: `30000` = 30 seconds). Also determines how frequently inactivity timeout is checked.
914916
- **`websocket_allowed_origins`** - List of allowed origins for WebSocket connections (security)
915917

916918
### Architecture
@@ -968,6 +970,7 @@ WebSocket callbacks can stream updates to the client during execution using `set
968970
```python
969971
import asyncio
970972
from dash import callback, Output, Input, set_props, ctx
973+
from dash.exceptions import PreventUpdate
971974

972975
@callback(
973976
Output('result', 'children'),
@@ -981,6 +984,9 @@ async def long_running_task(n_clicks):
981984

982985
# Stream progress updates to the client
983986
for i in range(100):
987+
# IMPORTANT: Check is_shutdown in loops to detect disconnections
988+
if ws.is_shutdown:
989+
raise PreventUpdate # Exit gracefully on disconnect
984990
await asyncio.sleep(0.1)
985991
set_props('progress-bar', {'value': i + 1})
986992
set_props('status', {'children': f'Processing step {i + 1}/100...'})
@@ -991,9 +997,19 @@ async def long_running_task(n_clicks):
991997
return f"Completed! Input was: {current_value}"
992998
```
993999

1000+
**IMPORTANT - Checking `is_shutdown` in Loops:**
1001+
1002+
Long-running callbacks that use loops **must** check `ws.is_shutdown` to detect when the WebSocket connection has closed. Without this check:
1003+
- Callbacks continue running after the client disconnects, wasting server resources
1004+
- `set_props` calls go to a closed connection and are lost
1005+
- The callback result is never delivered to the client
1006+
1007+
Only "persistent callbacks" (callbacks with no Output and no Input that use only `set_props`) are automatically restarted when the WebSocket reconnects. Regular callbacks with outputs are not restarted.
1008+
9941009
**API:**
9951010
- `set_props(component_id, props_dict)` - Stream prop updates immediately to client
9961011
- `ctx.websocket` - Get WebSocket interface (returns `None` if not in WS context)
1012+
- `ws.is_shutdown` - Check if the WebSocket connection has been closed
9971013
- `await ws.get_prop(component_id, prop_name)` - Read current prop value from client
9981014
- `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version)
9991015
- `await ws.close(code, reason)` - Close the WebSocket connection

@plotly/dash-websocket-worker/src/WebSocketManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ export class WebSocketManager {
144144
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
145145
}
146146

147+
/**
148+
* Reset the activity timer.
149+
* Call this when a tab becomes visible to prevent inactivity timeout.
150+
*/
151+
public resetActivity(): void {
152+
this.lastActivityTime = Date.now();
153+
}
154+
147155
private createConnection(): void {
148156
if (!this.serverUrl) {
149157
return;

@plotly/dash-websocket-worker/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum WorkerMessageType {
77
DISCONNECT = 'disconnect',
88
CALLBACK_REQUEST = 'callback_request',
99
GET_PROPS_RESPONSE = 'get_props_response',
10+
TAB_VISIBLE = 'tab_visible',
1011

1112
// Worker -> Renderer
1213
CONNECTED = 'connected',
@@ -39,6 +40,7 @@ export interface ConnectMessage extends WorkerMessage {
3940
payload: {
4041
serverUrl: string;
4142
inactivityTimeout?: number;
43+
heartbeatInterval?: number;
4244
};
4345
}
4446

@plotly/dash-websocket-worker/src/worker.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,24 @@ self.onconnect = (event: MessageEvent) => {
8181
const rendererId = connectMsg.rendererId;
8282
const newServerUrl = connectMsg.payload.serverUrl;
8383
const inactivityTimeout = connectMsg.payload.inactivityTimeout;
84+
const heartbeatInterval = connectMsg.payload.heartbeatInterval;
8485

8586
// Register the renderer
8687
router.registerRenderer(rendererId, port);
8788
rendererIds.add(rendererId);
8889

89-
console.log(`[DashWSWorker] Renderer ${rendererId} connected, inactivityTimeout: ${inactivityTimeout}`);
90+
console.log(`[DashWSWorker] Renderer ${rendererId} connected, inactivityTimeout: ${inactivityTimeout}, heartbeatInterval: ${heartbeatInterval}`);
9091

91-
// Update inactivity timeout if provided
92+
// Update config if provided
93+
const configUpdate: {inactivityTimeout?: number; heartbeatInterval?: number} = {};
9294
if (typeof inactivityTimeout === 'number') {
93-
wsManager.setConfig({ inactivityTimeout });
95+
configUpdate.inactivityTimeout = inactivityTimeout;
96+
}
97+
if (typeof heartbeatInterval === 'number') {
98+
configUpdate.heartbeatInterval = heartbeatInterval;
99+
}
100+
if (Object.keys(configUpdate).length > 0) {
101+
wsManager.setConfig(configUpdate);
94102
}
95103

96104
// Connect to server if not already connected
@@ -122,6 +130,13 @@ self.onconnect = (event: MessageEvent) => {
122130
break;
123131
}
124132

133+
case WorkerMessageType.TAB_VISIBLE: {
134+
// Reset activity timer when tab becomes visible
135+
// This prevents inactivity timeout while user is viewing the tab
136+
wsManager.resetActivity();
137+
break;
138+
}
139+
125140
default:
126141
// Forward other messages through the router
127142
router.handleRendererMessage(message.rendererId, message);

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
99
- [#3669](https://github.com/plotly/dash/pull/3669) Selection for DataTable cleared with custom action settings
1010
- [#3680](https://github.com/plotly/dash/pull/3680) Added `search_order` prop to `Dropdown` to allow users to preserve original option order during search
1111
- Added `csrf_token_name` and `csrf_header_name` config options to allow configuring the CSRF cookie and header names. Fixes [#729](https://github.com/plotly/dash/issues/729)
12+
- [#3797](https://github.com/plotly/dash/pull/3797) Improved websocket callback management.
1213
- [#3523](https://github.com/plotly/dash/pull/3523) Fall back to background callback function names if source cannot be found
1314
- [#3785](https://github.com/plotly/dash/pull/3785) Fix patch with dcc.Graph figure.
1415
- [#3785](https://github.com/plotly/dash/pull/3785) Fix dcc.Graph not sending duplicate clicks because it had the same payload by adding a timestamp in the click event object.

dash/backends/_fastapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,7 @@ async def websocket_handler(websocket: WebSocket):
761761
dash_app._websocket_callbacks,
762762
)
763763

764-
# Create WebSocket callback instance with outbound queue
764+
# Create WebSocket callback instance
765765
ws_cb = DashWebsocketCallback(
766766
pending_get_props,
767767
renderer_id,

dash/backends/_quart.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ async def websocket_handler(): # pylint: disable=too-many-branches
608608
dash_app._websocket_callbacks,
609609
)
610610

611-
# Create WebSocket callback instance with outbound queue
611+
# Create WebSocket callback instance
612612
ws_cb = DashWebsocketCallback(
613613
pending_get_props,
614614
renderer_id,

dash/backends/ws.py

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ class DashWebsocketCallback:
4141
Uses janus.Queue for outbound messages (serialized with to_json) and
4242
queue.Queue for get_props responses, enabling thread-safe communication
4343
between worker threads and the main event loop.
44+
45+
IMPORTANT: For long-running callbacks that use loops (e.g., streaming updates),
46+
you MUST check `ws.is_shutdown` in your loop to detect disconnections:
47+
48+
@callback(Input('btn', 'n_clicks')) # No Output - uses set_props only
49+
async def long_running(n_clicks):
50+
ws = ctx.websocket
51+
while True:
52+
if ws and ws.is_shutdown:
53+
raise PreventUpdate # Exit gracefully on disconnect
54+
set_props('progress', {'value': get_data()})
55+
await asyncio.sleep(0.1)
56+
57+
Without this check, callbacks will continue running after the client disconnects,
58+
wasting server resources.
59+
60+
Note: Only "persistent callbacks" (callbacks with no Output and no Input that use
61+
only set_props) are automatically restarted when the WebSocket reconnects. Regular
62+
callbacks with outputs are not restarted.
4463
"""
4564

4665
def __init__(
@@ -69,15 +88,25 @@ def is_shutdown(self) -> bool:
6988
"""Check if the websocket connection has been shut down."""
7089
return self._shutdown_event.is_set()
7190

91+
def _get_outbound_queue(self) -> janus.Queue[str] | None:
92+
"""Get the outbound queue."""
93+
return self._outbound_queue
94+
95+
def _get_pending_get_props(self) -> Dict[str, queue.Queue[Any]] | None:
96+
"""Get the pending_get_props dict."""
97+
return self._pending_get_props
98+
7299
def _queue_message(self, msg: dict) -> None:
73100
"""Serialize and queue message for sending (thread-safe, non-blocking).
74101
75102
Uses to_json for proper serialization of Dash components.
76103
Does nothing if the connection has been shut down.
77104
"""
78-
if self._shutdown_event.is_set():
105+
if self.is_shutdown:
79106
return
80-
self._outbound_queue.sync_q.put_nowait(cast(str, to_json(msg)))
107+
outbound_queue = self._get_outbound_queue()
108+
if outbound_queue is not None:
109+
outbound_queue.sync_q.put_nowait(cast(str, to_json(msg)))
81110

82111
async def set_prop(self, component_id: str, prop_name: str, value: Any) -> None:
83112
"""Send immediate prop update to the client via WebSocket.
@@ -115,7 +144,11 @@ async def get_prop(
115144
WebsocketDisconnected: If the websocket connection has been closed.
116145
TimeoutError: If the response doesn't arrive within the timeout.
117146
"""
118-
if self._shutdown_event.is_set():
147+
if self.is_shutdown:
148+
raise WebsocketDisconnected()
149+
150+
pending_get_props = self._get_pending_get_props()
151+
if pending_get_props is None:
119152
raise WebsocketDisconnected()
120153

121154
request_id = str(uuid.uuid4())
@@ -128,7 +161,7 @@ async def get_prop(
128161

129162
# Use standard queue.Queue for response
130163
response_queue: queue.Queue = queue.Queue()
131-
self._pending_get_props[request_id] = response_queue
164+
pending_get_props[request_id] = response_queue
132165

133166
# Queue the outbound request via janus sync interface
134167
self._queue_message(msg)
@@ -146,7 +179,10 @@ async def get_prop(
146179
f"Timeout waiting for {component_id}.{prop_name}"
147180
) from exc
148181
finally:
149-
self._pending_get_props.pop(request_id, None)
182+
# Get fresh reference in case of reconnection
183+
current_pending = self._get_pending_get_props()
184+
if current_pending is not None:
185+
current_pending.pop(request_id, None)
150186

151187

152188
def create_ws_context(
@@ -209,31 +245,60 @@ async def run_ws_sender(
209245
messages: list[str] = []
210246
try:
211247
while True:
212-
# Wait indefinitely for first message, then use timeout for batching
213-
timeout = batch_delay if messages else None
214-
try:
215-
msg = await asyncio.wait_for(q.get(), timeout=timeout)
216-
if msg == SHUTDOWN_SIGNAL:
217-
if messages:
218-
await _send_batched(send_text, messages)
219-
return
220-
if msg == FLUSH_SIGNAL:
221-
if messages:
222-
await _send_batched(send_text, messages)
223-
messages = []
224-
continue
225-
if not batch_delay:
226-
await send_text(msg)
227-
else:
228-
messages.append(msg)
229-
except asyncio.TimeoutError:
230-
await _send_batched(send_text, messages)
231-
messages = []
248+
result = await _process_ws_message(q, send_text, messages, batch_delay)
249+
if result is False:
250+
return
232251
except asyncio.CancelledError:
233252
pass
234253

235254

236-
async def _send_batched(send_text: Callable[[str], Any], messages: list) -> None:
255+
async def _process_ws_message(
256+
q: "janus._AsyncQueueProxy[str]",
257+
send_text: Callable[[str], Any],
258+
messages: list[str],
259+
batch_delay: float,
260+
) -> bool:
261+
"""Process a single WebSocket message from the queue.
262+
263+
Args:
264+
q: The async queue to read from
265+
send_text: Async function to send text data over WebSocket
266+
messages: List to accumulate messages for batching (mutated in place)
267+
batch_delay: Batch delay in seconds
268+
269+
Returns:
270+
True to continue processing, False to stop the sender loop.
271+
"""
272+
timeout = batch_delay if messages else None
273+
try:
274+
msg = await asyncio.wait_for(q.get(), timeout=timeout)
275+
except asyncio.TimeoutError:
276+
success = await _send_batched(send_text, messages)
277+
messages.clear()
278+
return success
279+
280+
if msg == SHUTDOWN_SIGNAL:
281+
if messages:
282+
await _send_batched(send_text, messages)
283+
return False
284+
285+
if msg == FLUSH_SIGNAL:
286+
success = not messages or await _send_batched(send_text, messages)
287+
messages.clear()
288+
return success
289+
290+
if not batch_delay:
291+
try:
292+
await send_text(msg)
293+
except Exception: # pylint: disable=broad-exception-caught
294+
return False # WebSocketDisconnect, RuntimeError, etc.
295+
else:
296+
messages.append(msg)
297+
298+
return True
299+
300+
301+
async def _send_batched(send_text: Callable[[str], Any], messages: list) -> bool:
237302
"""Send messages as a batch.
238303
239304
Single messages are sent as-is. Multiple messages are wrapped
@@ -242,12 +307,19 @@ async def _send_batched(send_text: Callable[[str], Any], messages: list) -> None
242307
Args:
243308
send_text: Async function to send text data over WebSocket
244309
messages: List of pre-serialized JSON message strings
310+
311+
Returns:
312+
True if send succeeded, False if connection was closed
245313
"""
246-
if len(messages) == 1:
247-
await send_text(messages[0])
248-
else:
249-
# Wrap in array: "[msg1,msg2,msg3]"
250-
await send_text("[" + ",".join(messages) + "]")
314+
try:
315+
if len(messages) == 1:
316+
await send_text(messages[0])
317+
else:
318+
# Wrap in array: "[msg1,msg2,msg3]"
319+
await send_text("[" + ",".join(messages) + "]")
320+
return True
321+
except Exception: # pylint: disable=broad-exception-caught
322+
return False # WebSocketDisconnect, RuntimeError, etc.
251323

252324

253325
def make_callback_done_handler(

dash/dash-renderer/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type DashConfig = {
2727
url: string;
2828
worker_url: string;
2929
inactivity_timeout?: number;
30+
heartbeat_interval?: number;
3031
};
3132
csrf_token_name?: string;
3233
csrf_header_name?: string;

0 commit comments

Comments
 (0)