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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions