Skip to content

Commit 3892cd4

Browse files
committed
remove ActiveCallbackRegistry & callback adoption
1 parent 2f42201 commit 3892cd4

6 files changed

Lines changed: 90 additions & 517 deletions

File tree

.ai/ARCHITECTURE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,7 @@ WebSocket callbacks can stream updates to the client during execution using `set
968968
```python
969969
import asyncio
970970
from dash import callback, Output, Input, set_props, ctx
971+
from dash.exceptions import PreventUpdate
971972

972973
@callback(
973974
Output('result', 'children'),
@@ -981,6 +982,9 @@ async def long_running_task(n_clicks):
981982

982983
# Stream progress updates to the client
983984
for i in range(100):
985+
# IMPORTANT: Check is_shutdown in loops to detect disconnections
986+
if ws.is_shutdown:
987+
raise PreventUpdate # Exit gracefully on disconnect
984988
await asyncio.sleep(0.1)
985989
set_props('progress-bar', {'value': i + 1})
986990
set_props('status', {'children': f'Processing step {i + 1}/100...'})
@@ -991,9 +995,19 @@ async def long_running_task(n_clicks):
991995
return f"Completed! Input was: {current_value}"
992996
```
993997

998+
**IMPORTANT - Checking `is_shutdown` in Loops:**
999+
1000+
Long-running callbacks that use loops **must** check `ws.is_shutdown` to detect when the WebSocket connection has closed. Without this check:
1001+
- Callbacks continue running after the client disconnects, wasting server resources
1002+
- `set_props` calls go to a closed connection and are lost
1003+
- The callback result is never delivered to the client
1004+
1005+
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.
1006+
9941007
**API:**
9951008
- `set_props(component_id, props_dict)` - Stream prop updates immediately to client
9961009
- `ctx.websocket` - Get WebSocket interface (returns `None` if not in WS context)
1010+
- `ws.is_shutdown` - Check if the WebSocket connection has been closed
9971011
- `await ws.get_prop(component_id, prop_name)` - Read current prop value from client
9981012
- `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version)
9991013
- `await ws.close(code, reason)` - Close the WebSocket connection

dash/backends/_fastapi.py

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
SHUTDOWN_SIGNAL,
5151
DISCONNECTED,
5252
)
53-
from ._ws_registry import ActiveCallbackRegistry
5453
from ._utils import format_traceback_html
5554

5655
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -678,12 +677,6 @@ def serve_websocket_callback(self, dash_app: "Dash"):
678677
dash_app, "_websocket_allowed_origins", []
679678
) # pylint: disable=protected-access
680679

681-
# Initialize registry on dash_app if not present
682-
# pylint: disable=protected-access
683-
if not hasattr(dash_app, "_ws_callback_registry"):
684-
dash_app._ws_callback_registry = ActiveCallbackRegistry()
685-
registry: ActiveCallbackRegistry = dash_app._ws_callback_registry
686-
687680
def validate_origin(origin: str | None, host: str | None) -> str | None:
688681
"""Validate WebSocket origin. Returns error message or None if valid."""
689682
if not origin:
@@ -730,8 +723,6 @@ async def websocket_handler(websocket: WebSocket):
730723
executor = self.get_callback_executor()
731724
# Track pending callback futures
732725
pending_callbacks: Dict[str, concurrent.futures.Future] = {}
733-
# Track current renderer ID for this connection
734-
current_renderer_id: str | None = None
735726

736727
# Start sender task to drain outbound queue (sends pre-serialized text)
737728
# pylint: disable=protected-access
@@ -762,22 +753,6 @@ async def websocket_handler(websocket: WebSocket):
762753
renderer_id = message.get("rendererId", "")
763754
payload = message.get("payload", {})
764755

765-
# Update current renderer ID for cleanup
766-
current_renderer_id = renderer_id
767-
768-
# Adopt connection for this renderer (allows reconnection)
769-
# Called for every callback to ensure registry entry exists
770-
# (entry may have been cleaned up after previous callback)
771-
registry.adopt_connection(
772-
renderer_id,
773-
outbound_queue,
774-
pending_get_props,
775-
shutdown_event,
776-
)
777-
778-
# Register this callback with the registry
779-
registry.register_callback(renderer_id)
780-
781756
# Validate that the callback is allowed to use WebSocket transport
782757
# pylint: disable=protected-access
783758
_validate.validate_websocket_callback_request(
@@ -786,13 +761,12 @@ async def websocket_handler(websocket: WebSocket):
786761
dash_app._websocket_callbacks,
787762
)
788763

789-
# Create WebSocket callback instance with registry
764+
# Create WebSocket callback instance
790765
ws_cb = DashWebsocketCallback(
791766
pending_get_props,
792767
renderer_id,
793768
outbound_queue,
794769
shutdown_event,
795-
registry=registry,
796770
)
797771

798772
# Submit callback to executor
@@ -812,7 +786,6 @@ async def websocket_handler(websocket: WebSocket):
812786
request_id,
813787
renderer_id,
814788
shutdown_event,
815-
registry=registry,
816789
)
817790
)
818791
pending_callbacks[request_id] = future
@@ -848,9 +821,6 @@ async def websocket_handler(websocket: WebSocket):
848821
# Cancel any pending futures
849822
for f in pending_callbacks.values():
850823
f.cancel()
851-
# Cleanup registry entry if no active callbacks
852-
if current_renderer_id is not None:
853-
registry.cleanup_renderer(current_renderer_id)
854824

855825
self.server.add_api_websocket_route(ws_path, websocket_handler)
856826

dash/backends/_quart.py

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
SHUTDOWN_SIGNAL,
5656
DISCONNECTED,
5757
)
58-
from ._ws_registry import ActiveCallbackRegistry
5958
from ._utils import format_traceback_html
6059

6160
if TYPE_CHECKING:
@@ -522,12 +521,6 @@ def serve_websocket_callback(self, dash_app: "Dash"):
522521
# pylint: disable=protected-access
523522
allowed_origins = getattr(dash_app, "_websocket_allowed_origins", [])
524523

525-
# Initialize registry on dash_app if not present
526-
# pylint: disable=protected-access
527-
if not hasattr(dash_app, "_ws_callback_registry"):
528-
dash_app._ws_callback_registry = ActiveCallbackRegistry()
529-
registry: ActiveCallbackRegistry = dash_app._ws_callback_registry
530-
531524
@self.server.websocket(ws_path)
532525
async def websocket_handler(): # pylint: disable=too-many-branches
533526
ws = websocket
@@ -571,8 +564,6 @@ async def websocket_handler(): # pylint: disable=too-many-branches
571564
executor = self.get_callback_executor()
572565
# Track pending callback futures
573566
pending_callbacks: Dict[str, concurrent.futures.Future] = {}
574-
# Track current renderer ID for this connection
575-
current_renderer_id: str | None = None
576567

577568
# Start sender task to drain outbound queue (sends pre-serialized text)
578569
# pylint: disable=protected-access
@@ -610,22 +601,6 @@ async def websocket_handler(): # pylint: disable=too-many-branches
610601
renderer_id = message.get("rendererId", "")
611602
payload = message.get("payload", {})
612603

613-
# Update current renderer ID for cleanup
614-
current_renderer_id = renderer_id
615-
616-
# Adopt connection for this renderer (allows reconnection)
617-
# Called for every callback to ensure registry entry exists
618-
# (entry may have been cleaned up after previous callback)
619-
registry.adopt_connection(
620-
renderer_id,
621-
outbound_queue,
622-
pending_get_props,
623-
connection_shutdown_event,
624-
)
625-
626-
# Register this callback with the registry
627-
registry.register_callback(renderer_id)
628-
629604
# Validate that the callback is allowed to use WebSocket transport
630605
# pylint: disable=protected-access
631606
_validate.validate_websocket_callback_request(
@@ -634,13 +609,12 @@ async def websocket_handler(): # pylint: disable=too-many-branches
634609
dash_app._websocket_callbacks,
635610
)
636611

637-
# Create WebSocket callback instance with registry
612+
# Create WebSocket callback instance
638613
ws_cb = DashWebsocketCallback(
639614
pending_get_props,
640615
renderer_id,
641616
outbound_queue,
642617
connection_shutdown_event,
643-
registry=registry,
644618
)
645619

646620
# Submit callback to executor
@@ -660,7 +634,6 @@ async def websocket_handler(): # pylint: disable=too-many-branches
660634
request_id,
661635
renderer_id,
662636
connection_shutdown_event,
663-
registry=registry,
664637
)
665638
)
666639
pending_callbacks[request_id] = future
@@ -699,9 +672,6 @@ async def websocket_handler(): # pylint: disable=too-many-branches
699672
# Cancel any pending futures
700673
for f in pending_callbacks.values():
701674
f.cancel()
702-
# Cleanup registry entry if no active callbacks
703-
if current_renderer_id is not None:
704-
registry.cleanup_renderer(current_renderer_id)
705675

706676

707677
class QuartRequestAdapter(RequestAdapter):

dash/backends/_ws_registry.py

Lines changed: 0 additions & 166 deletions
This file was deleted.

0 commit comments

Comments
 (0)