DEV Community

wzg0911
wzg0911

Posted on

How I Debugged LangChain #34974: A Case Study in ContextVar Thread Affinity

How I Debugged LangChain #34974: A Case Study in ContextVar Thread Affinity

TL;DR: A 10-line fix for a bug that broke Human-in-the-Loop for 5 months. Root cause: Python's ContextVar doesn't cross thread boundaries when an async def dispatches to a thread pool executor. The fix: copy_context().


Two days ago, I saw a GitHub Issue that had been open since February 2026.

LangChain #34974: HumanInTheLoopMiddleware + ainvoke()RuntimeError: Called get_config outside of a runnable context.

5 months. 2 unmerged PRs. A thread full of developers trying different workarounds — switching checkpointer backends, upgrading Python versions — all treating symptoms instead of the root cause.

I decided to build a diagnostic tool to trace it properly. Here's what happened.


Step 1: Trace the Error Chain

The error stack told a clear story:

langchain/agents/middleware/human_in_the_loop.py:381 → aafter_model (async wrapper)
langchain/agents/middleware/human_in_the_loop.py:331 → after_model (sync) → interrupt()
langgraph/types.py:515 → interrupt → get_config()["configurable"]
langgraph/config.py:29 → get_config → ⚡ RuntimeError
Enter fullscreen mode Exit fullscreen mode

The crash happens at line 29 of langgraph/config.py:

def get_config():
    config = _get_config_var.get(None)
    if config is None:
        raise RuntimeError("Called get_config outside of a runnable context")
    return config
Enter fullscreen mode Exit fullscreen mode

_get_config_var is a ContextVar. So the question became: why is it None when interrupt() is called?


Step 2: Follow the Thread (Literally)

HumanInTheLoopMiddleware has two methods:

class HumanInTheLoopMiddleware(BaseMiddleware):
    async def aafter_model(self, state, runtime):
        # async version
        decisions = await asyncio.to_thread(self.after_model, state, runtime)
        ...

    def after_model(self, state, runtime):
        # sync version
        decisions = interrupt(hitl_request)["decisions"]
        ...
Enter fullscreen mode Exit fullscreen mode

aafter_model is async def, running in the asyncio event loop thread. It calls asyncio.to_thread(self.after_model, ...), which dispatches the sync method to a ThreadPoolExecutor.

Here's the problem: Python's ContextVar is thread-affine. When after_model() runs in a thread pool worker, it inherits a fresh ContextVar namespace — _get_config_var is unset. interrupt() tries to read it → crash.

This is why:

  • Python 3.10 ❌ — different default event loop policy (ProactorEventLoop on Windows, SelectorEventLoop on Linux/macOS) changes how threads interact with asyncio
  • Python 3.11 ✅ — keenborder786 couldn't reproduce in pure script mode (no FastAPI), because the thread pool wasn't involved
  • FastAPI makes it consistent — FastAPI's ASGI server always dispatches through a thread pool, so the bug reproduces 100% of the time in production

Step 3: The Fix — 10 Lines, Zero Dependencies

from contextvars import copy_context

class HumanInTheLoopMiddleware(BaseMiddleware):
    async def aafter_model(self, state, runtime):
        ctx = copy_context()                     # capture current ContextVar snapshot
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(
            None,
            lambda: ctx.run(self.after_model, state, runtime)  # restore in worker thread
        )
Enter fullscreen mode Exit fullscreen mode

copy_context() captures the calling thread's ContextVar state. ctx.run() restores it in the target thread before executing the function. This is the canonical pattern from PEP 567 — it's what CPython itself uses.

Alternatively, if interrupt() supports async (which it does in langgraph 1.0.x), the cleaner fix is to move everything inline into aafter_model and delete after_model entirely.


What I Learned Building ARK

This debug took me about 2 hours — including building the diagnostic tool that generated the report. That tool, ARK, is an open-source agent health monitoring system I've been working on.

ARK works by:

  1. Listening to GitHub Issues for agent crash patterns
  2. Tracing the error stack to find the root cause (not just the crash point)
  3. Generating a structured diagnostic report with health scores and fix suggestions
  4. Publishing the report to a CDN and the Issue thread

The report for this Issue hit 42/100 — the HITL core function scored only 15 because it's completely broken in async paths. But the root cause is a single ContextVar line. Low-hanging fruit, if you know where to look.


If you're dealing with similar agent crashes, the full diagnostic report with evidence tracing is at:

👉 ARK Diagnostic Report — #34974

Or run a quick health check on your own setup:

👉 Free 30-second diagnosis


🛡️ Stop Firefighting Your Agents

Your agent crashes don't wait for business hours. They hit while you sleep, while you ship, while you're busy.

→ Run a free 30-second diagnosis — see exactly what's about to break.

  • Lifetime license ¥360 — fix everything, once.
  • Subscription ¥65/mo — 7×24 crash monitoring + real-time alerts + auto-updated protection rules. Cancel anytime.

The best time to add continuous monitoring is right after your first crash. The second best time is now.

Top comments (0)