|
5 | 5 | import concurrent.futures |
6 | 6 | import json |
7 | 7 | import queue |
8 | | -from typing import TYPE_CHECKING, Any, Callable, Dict |
| 8 | +from typing import TYPE_CHECKING, Any, Callable, Dict, List |
9 | 9 | import sys |
10 | 10 | import mimetypes |
11 | 11 | import hashlib |
|
46 | 46 | DashWebsocketCallback, |
47 | 47 | run_ws_sender, |
48 | 48 | run_callback_in_executor, |
| 49 | + run_callback_on_loop, |
49 | 50 | make_callback_done_handler, |
50 | | - SHUTDOWN_SIGNAL, |
51 | | - DISCONNECTED, |
| 51 | + shutdown_ws_connection, |
52 | 52 | ) |
53 | 53 | from ._utils import format_traceback_html |
54 | 54 |
|
@@ -263,8 +263,8 @@ def __init__(self, server: FastAPI): |
263 | 263 | self.error_handling_mode = "ignore" |
264 | 264 | self.request_adapter = FastAPIRequestAdapter |
265 | 265 | self.response_adapter = FastAPIResponseAdapter |
266 | | - self._before_request_funcs = [] |
267 | | - self._after_request_func = None |
| 266 | + self._before_request_funcs: List[Callable[[], Any]] = [] |
| 267 | + self._after_request_func: Callable[[], Any] | None = None |
268 | 268 | self._enable_timing = False |
269 | 269 |
|
270 | 270 | def __call__(self, *args: Any, **kwargs: Any): |
@@ -685,7 +685,7 @@ def serve_websocket_callback(self, dash_app: "Dash"): |
685 | 685 | Args: |
686 | 686 | dash_app: The Dash application instance |
687 | 687 | """ |
688 | | - # pylint: disable=too-many-statements,too-many-locals |
| 688 | + # pylint: disable=too-many-statements,too-many-locals,too-many-branches |
689 | 689 | ws_path = dash_app.config.routes_pathname_prefix + "_dash-ws-callback" |
690 | 690 |
|
691 | 691 | # Get allowed origins from dash app config |
@@ -729,16 +729,28 @@ async def websocket_handler(websocket: WebSocket): |
729 | 729 |
|
730 | 730 | await websocket.accept() |
731 | 731 |
|
| 732 | + # The connection's event loop, used to dispatch async callbacks as |
| 733 | + # tasks and to resolve get_props futures. |
| 734 | + loop = asyncio.get_running_loop() |
| 735 | + |
732 | 736 | # Create janus queue for outbound messages (main loop context) |
733 | 737 | outbound_queue: janus.Queue[str] = janus.Queue() |
734 | | - # Track pending get_props requests with standard queue.Queue for responses |
735 | | - pending_get_props: Dict[str, queue.Queue] = {} |
| 738 | + # Track pending get_props requests. Values are queue.Queue (threadpool / |
| 739 | + # sync path) or asyncio.Future (event-loop / async path). |
| 740 | + pending_get_props: Dict[str, queue.Queue | asyncio.Future] = {} |
736 | 741 | # Shutdown event to signal connection closure to worker threads |
737 | 742 | shutdown_event = threading.Event() |
738 | | - # Get thread pool executor |
739 | | - executor = self.get_callback_executor() |
740 | | - # Track pending callback futures |
741 | | - pending_callbacks: Dict[str, concurrent.futures.Future] = {} |
| 743 | + # Sync callbacks run on a shared thread pool executor (async callbacks |
| 744 | + # run directly on the event loop). A single bounded pool caps the total |
| 745 | + # worker-thread count regardless of how many connections are open. |
| 746 | + executor = self.get_callback_executor( |
| 747 | + getattr(dash_app, "_websocket_max_workers", 4) |
| 748 | + ) |
| 749 | + # Track pending callbacks: concurrent.futures.Future (sync/threadpool) |
| 750 | + # or asyncio.Task (async/event-loop). |
| 751 | + pending_callbacks: Dict[ |
| 752 | + str, concurrent.futures.Future | asyncio.Future |
| 753 | + ] = {} |
742 | 754 |
|
743 | 755 | # Start sender task to drain outbound queue (sends pre-serialized text) |
744 | 756 | # pylint: disable=protected-access |
@@ -777,66 +789,78 @@ async def websocket_handler(websocket: WebSocket): |
777 | 789 | dash_app._websocket_callbacks, |
778 | 790 | ) |
779 | 791 |
|
780 | | - # Create WebSocket callback instance |
| 792 | + # Async callbacks (incl. persistent ones) run as tasks on |
| 793 | + # the event loop; sync callbacks go to the threadpool. |
| 794 | + # pylint: disable=protected-access |
| 795 | + cb_spec = dash_app.callback_map.get(payload.get("output"), {}) |
| 796 | + is_async = inspect.iscoroutinefunction(cb_spec.get("callback")) |
| 797 | + |
| 798 | + # Create WebSocket callback instance. The loop is passed only |
| 799 | + # for the async path so get_prop awaits instead of blocking. |
781 | 800 | ws_cb = DashWebsocketCallback( |
782 | 801 | pending_get_props, |
783 | 802 | renderer_id, |
784 | 803 | outbound_queue, |
785 | 804 | shutdown_event, |
| 805 | + loop if is_async else None, |
786 | 806 | ) |
787 | 807 |
|
788 | | - # Submit callback to executor |
789 | | - future = run_callback_in_executor( |
790 | | - executor, |
791 | | - dash_app, |
792 | | - payload, |
793 | | - ws_cb, |
794 | | - FastAPIResponseAdapter(), |
| 808 | + done_handler = make_callback_done_handler( |
| 809 | + outbound_queue, |
| 810 | + pending_callbacks, |
| 811 | + request_id, |
| 812 | + renderer_id, |
| 813 | + shutdown_event, |
795 | 814 | ) |
796 | 815 |
|
797 | | - # Set up done callback to send response |
798 | | - future.add_done_callback( |
799 | | - make_callback_done_handler( |
800 | | - outbound_queue, |
801 | | - pending_callbacks, |
802 | | - request_id, |
803 | | - renderer_id, |
804 | | - shutdown_event, |
| 816 | + if is_async: |
| 817 | + task = asyncio.create_task( |
| 818 | + run_callback_on_loop( |
| 819 | + dash_app, |
| 820 | + payload, |
| 821 | + ws_cb, |
| 822 | + FastAPIResponseAdapter(), |
| 823 | + ) |
805 | 824 | ) |
806 | | - ) |
807 | | - pending_callbacks[request_id] = future |
| 825 | + task.add_done_callback(done_handler) |
| 826 | + pending_callbacks[request_id] = task |
| 827 | + else: |
| 828 | + # Submit callback to executor |
| 829 | + future = run_callback_in_executor( |
| 830 | + executor, |
| 831 | + dash_app, |
| 832 | + payload, |
| 833 | + ws_cb, |
| 834 | + FastAPIResponseAdapter(), |
| 835 | + ) |
| 836 | + # Set up done callback to send response |
| 837 | + future.add_done_callback(done_handler) |
| 838 | + pending_callbacks[request_id] = future |
808 | 839 |
|
809 | 840 | elif msg_type == "get_props_response": |
810 | | - # Put response in waiting queue (non-blocking) |
| 841 | + # Resolve the waiting future (async path) or queue (thread |
| 842 | + # path) for this request (non-blocking). |
811 | 843 | request_id = message.get("requestId") |
812 | | - response_queue = pending_get_props.get(request_id) |
813 | | - if response_queue is not None: |
814 | | - response_queue.put_nowait(message.get("payload")) |
| 844 | + pending = pending_get_props.get(request_id) |
| 845 | + if isinstance(pending, asyncio.Future): |
| 846 | + if not pending.done(): |
| 847 | + pending.set_result(message.get("payload")) |
| 848 | + elif pending is not None: |
| 849 | + pending.put_nowait(message.get("payload")) |
815 | 850 |
|
816 | 851 | elif msg_type == "heartbeat": |
817 | 852 | outbound_queue.sync_q.put_nowait('{"type": "heartbeat_ack"}') |
818 | 853 |
|
819 | 854 | except WebSocketDisconnect: |
820 | 855 | pass # Clean disconnect |
821 | 856 | finally: |
822 | | - # Signal shutdown to worker threads |
823 | | - shutdown_event.set() |
824 | | - # Unblock any threads waiting on get_prop responses |
825 | | - for response_queue in pending_get_props.values(): |
826 | | - response_queue.put_nowait(DISCONNECTED) |
827 | | - # Signal sender to shutdown and cancel it |
828 | | - outbound_queue.sync_q.put_nowait(SHUTDOWN_SIGNAL) |
829 | | - sender_task.cancel() |
830 | | - try: |
831 | | - await sender_task |
832 | | - except asyncio.CancelledError: |
833 | | - pass |
834 | | - # Close the janus queue |
835 | | - outbound_queue.close() |
836 | | - await outbound_queue.wait_closed() |
837 | | - # Cancel any pending futures |
838 | | - for f in pending_callbacks.values(): |
839 | | - f.cancel() |
| 857 | + await shutdown_ws_connection( |
| 858 | + shutdown_event, |
| 859 | + pending_get_props, |
| 860 | + pending_callbacks, |
| 861 | + outbound_queue, |
| 862 | + sender_task, |
| 863 | + ) |
840 | 864 |
|
841 | 865 | self.server.add_api_websocket_route(ws_path, websocket_handler) |
842 | 866 |
|
|
0 commit comments