Skip to content

Fix first-embed deadlock on Windows (sentence-transformers) - #171

Open
danieltsai0423 wants to merge 1 commit into
neo4j-labs:mainfrom
danieltsai0423:fix/windows-sentence-transformers-embed-deadlock
Open

Fix first-embed deadlock on Windows (sentence-transformers)#171
danieltsai0423 wants to merge 1 commit into
neo4j-labs:mainfrom
danieltsai0423:fix/windows-sentence-transformers-embed-deadlock

Conversation

@danieltsai0423

Copy link
Copy Markdown

Summary

On Windows, the MCP server hangs forever on the first memory_add_fact / memory_search when using a local sentence-transformers embedder (neo4j-agent-memory mcp serve --embedding sentence-transformers/...). The tool call never returns and never errors — the process just wedges.

Root cause

SentenceTransformerEmbedder.embed() correctly offloads model.encode() to a thread executor, but calls self._ensure_model() synchronously on the asyncio event-loop thread. _ensure_model() performs the first import sentence_transformers, which pulls in scipy's native BLAS extension.

Importing that native extension for the first time while the asyncio event loop is already running deadlocks in the Windows DLL loader lock — and it does so regardless of which thread runs the import.

Confirmed with two py-spy dumps of the hung process taken seconds apart: both wedged at the exact same frame, with zero CPU progress:

memory_add_fact (mcp/_tools.py) -> add_fact (integration.py)
  -> add_fact (memory/long_term.py) -> embed (embeddings/base.py)
    -> embed_one (llm/adapters/sentence_transformers.py)
      -> embed (embeddings/sentence_transformers.py)
        -> _ensure_model (embeddings/sentence_transformers.py)
          -> import sentence_transformers -> scipy ...
            -> create_module (importlib._bootstrap_external)
              -> <module> (scipy/linalg/blas.py)   # wedged here, 0 CPU growth

The model was already fully downloaded (no network activity), Neo4j was healthy, and the process used ~4.5s CPU across several hours — a deadlock, not slow loading or OOM.

Why offloading _ensure_model alone is not enough

My first attempt was to move _ensure_model() into the same run_in_executor call. py-spy then showed the import running on a worker thread instead of the loop thread — but it still deadlocked at the same scipy/linalg/blas.py frame. The trigger is "first native sentence_transformers import while an event loop is running", not which thread performs it. The native import must happen before the loop starts.

Fix

  1. embeddings/sentence_transformers.py — offload _ensure_model() to the executor in embed() / embed_batch() (so a model load never blocks the event loop), and add a preload() helper that imports the native stack eagerly.
  2. cli/main.py (mcp serve) — call preload() on the main thread before asyncio.run() when the configured embedder resolves to SentenceTransformersProvider, so the native import happens in a safe, no-loop context. No-op for cloud embedders and when sentence-transformers isn't installed.
  3. Tests — regression tests asserting embed() / embed_batch() load the model off the event-loop thread, plus preload() unit tests.

Verification

  • New unit tests pass; ruff check and ruff format --check are clean.
  • End-to-end on Windows 11 / Python 3.10 with --embedding sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 --embedding-dimensions 384: memory_add_fact now returns {"stored": true} in ~9s instead of hanging indefinitely.

Notes for reviewers

  • I could not run the full make check (mypy --strict + ty) locally, as it needs uv sync --all-extras (torch etc.). The changes use only stdlib + internal, already-typed APIs and do not alter the existing typed surface, but please confirm in CI.
  • The synchronous _ensure_model() on the event-loop thread is arguably a latent issue on all platforms (the first embed blocks the whole server while the model loads); the Windows DLL loader-lock deadlock is the platform-specific escalation of the same root cause.

The first embed() call lazily imports sentence_transformers (which pulls in
scipy's native BLAS extension) via SentenceTransformerEmbedder._ensure_model.
When that first-time native import runs inside the already-running asyncio
event loop, it deadlocks in the Windows DLL loader lock and the MCP server
hangs forever on the first memory_add_fact / memory_search -- regardless of
which thread runs the import. Confirmed with two py-spy dumps wedged at
scipy/linalg/blas.py create_module with zero CPU progress over minutes.

Fix:
- embeddings/sentence_transformers.py: offload _ensure_model() to the executor
  in embed()/embed_batch() so the model load never runs on the event-loop
  thread, and add a preload() helper that imports the native stack eagerly.
- cli/main.py (mcp serve): call preload() on the main thread before
  asyncio.run() when a local sentence-transformers embedder is configured, so
  the native import happens in a safe, no-loop context. No-op for cloud
  embedders and when sentence-transformers isn't installed.
- tests: regression tests that embed()/embed_batch() load the model off the
  event-loop thread, plus preload() unit tests.

Verified end-to-end on Windows 11 / Python 3.10: memory_add_fact returns
{"stored": true} in ~9s instead of hanging indefinitely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

@danieltsai0423 is attempting to deploy a commit to the lyonwj's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant