Skip to content

websocket=True callbacks don't see ContextVars set by ASGI middleware (copy_context() called on the wrong thread in run_callback_in_executor) #3861

Description

@biyani701

Describe your context

  • Dash version: 4.3.0
  • Python version: 3.13.7
  • Backend: FastAPI (server="fastapi"). Confirmed the same code path is shared by the Quart backend (dash/backends/_quart.py imports the same run_callback_in_executor from dash/backends/ws.py), so this likely reproduces there too.

Describe the bug

websocket=True callbacks cannot see contextvars.ContextVar values set by ASGI middleware upstream of Dash (e.g. a session/auth ContextVar bound once per WebSocket connection by a custom Starlette/ASGI middleware). Regular HTTP callbacks running through the same middleware stack see the ContextVar correctly — only websocket=True callbacks lose it.

Root cause

https://github.com/plotly/dash/blob/v4.3.0/dash/backends/ws.py#L394-L451

def run_callback_in_executor(
    executor: ThreadPoolExecutor,
    dash_app: "dash.Dash",
    payload: CallbackExecutionBody,
    ws_callback: DashWebsocketCallback,
    response_adapter: "ResponseAdapter",
) -> concurrent.futures.Future:
    def execute() -> dict:
        try:
            ...
            ctx = copy_context()          # <-- called here
            ...
            response_data = ctx.run(run_callback)
            ...
    return executor.submit(execute)       # <-- but execute() already runs in a new thread

execute is submitted to a ThreadPoolExecutor via executor.submit(execute). contextvars.copy_context() is called inside execute(), i.e. after it is already running on the fresh worker thread the executor spawned. Per the contextvars docs, a new threading.Thread (and, transitively, each new worker thread owned by a ThreadPoolExecutor) starts with its own top-level Context containing only default values — it does not inherit ContextVar values set on the thread that called executor.submit(). So copy_context() here just snapshots an already-empty context; it never captures whatever the ASGI request-handling coroutine (running on the event-loop thread) had set moments earlier via some_var.set(...).

This is different from asyncio.get_running_loop().run_in_executor(...) / asyncio.to_thread(...), both of which call contextvars.copy_context() before dispatching to the executor and explicitly run the target inside that captured context — which is exactly why this pattern normally "just works" with asyncio, and why it's surprising here.

Practical impact: any app that uses ASGI middleware to bind per-request/per-connection state into a ContextVar (a common pattern for e.g. request-scoped DB sessions, auth/session context, tracing context) and also uses websocket=True callbacks will silently get None/default values for that state inside the callback body — with no exception, just wrong data. In our case this manifested as an authenticated user's session/role data reading back empty only inside websocket=True callbacks, while working normally in every other callback and route on the same connection.

Suggested fix

Capture copy_context() in run_callback_in_executor itself — synchronously, before executor.submit() — so it snapshots the context on the calling thread (the event-loop thread, where the ASGI middleware chain, including SessionMiddleware-style ContextVar binding, has already run for this request/connection):

def run_callback_in_executor(
    executor: ThreadPoolExecutor,
    dash_app: "dash.Dash",
    payload: CallbackExecutionBody,
    ws_callback: DashWebsocketCallback,
    response_adapter: "ResponseAdapter",
) -> concurrent.futures.Future:
    caller_ctx = copy_context()  # captured on the calling (event-loop) thread

    def execute() -> dict:
        try:
            cb_ctx = create_ws_context(payload, response_adapter, ws_callback)
            func = dash_app._prepare_callback(cb_ctx, payload)
            args = dash_app._inputs_to_vals(cb_ctx.inputs_list + cb_ctx.states_list)
            partial_func = dash_app._execute_callback(func, args, cb_ctx.outputs_list, cb_ctx)

            def run_callback():
                result = partial_func()
                if inspect.iscoroutine(result):
                    return asyncio.run(result)
                return result

            response_data = caller_ctx.run(run_callback)
            return {"status": "ok", "data": json.loads(response_data)}
        except PreventUpdate:
            return {"status": "prevent_update"}
        except WebsocketDisconnected:
            return {"status": "prevent_update"}
        except Exception as e:
            traceback.print_exc()
            return {"status": "error", "message": str(e)}

    return executor.submit(execute)

The same fix applies to dash/backends/_quart.py, which imports and calls the same function.

Reproduction sketch

  1. Add a pure-ASGI middleware (à la Starlette's SessionMiddleware) that does some_var.set(value) once per connection (both "http" and "websocket" scope types) before calling into the downstream app, and resets it in a finally.
  2. Add an ordinary HTTP callback/route that reads some_var.get() — it correctly sees value.
  3. Add a @callback(persistent=True, websocket=True) callback that reads some_var.get() inside its body — it sees the ContextVar's default, not value.

Current workaround

We're monkeypatching run_callback_in_executor at app startup (reassigning the function object on dash.backends.ws, dash.backends._fastapi, and dash.backends._quart) with the copy_context()-before-submit() version above. Works, but obviously fragile across Dash upgrades — would much rather have this fixed upstream.

Happy to open a PR with the one-line-timing fix above if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions