fix: three hangs that wedged the MCP server while it looked healthy (v2.67.1) - #62
Merged
Merged
Conversation
An idle event loop, no logs, no crash — and every c3 tool call dying at the client's 120s timeout. Three independent causes, all on startup threads. 1. services/embedding_index.py — `collection.delete(where=...)` was caught never returning inside chromadb's Rust bindings (rust.py, RustBindingsAPI._delete): two py-spy dumps four minutes apart with byte-identical frames, 0.031s CPU over 3s, zero writes to chroma.sqlite3 in ten hours. The get-ids-then-delete-by-ids path that already existed as a fallback is now the only path — it sat behind `except`, and an `except` clause cannot catch a call that never comes back, so it was unreachable by construction. 2. services/embedding_index.py — build() held self._lock with a bare `with` across the whole build loop, so one wedged delete parked every later caller forever. It now acquires with a timeout (mirroring the _init_lock pattern _ensure_ready already used correctly), warns once, and returns a `degraded` result with the normal stats shape. This is what makes a future backend hang survivable rather than fatal. 3. cli/tools/delegate.py — check_gemini/check_codex/check_claude used subprocess.run(timeout=10), which on Windows hangs inside its own timeout handler: CPython kills only the direct child, then calls communicate() a second time with NO timeout, joining reader threads that never see EOF while a grandchild holds the pipe write-ends. Wedged the c3-delegate-prewarm thread for 10h, leaking two threads. Replaced with _probe_cli_version: Popen + taskkill /T tree kill + bounded communicate() in finally. tests/test_cli_smoke.py documented this footgun for test code in 2.43.0; production code now follows the same convention. Verified against the installed chromadb 1.5.6, not just fakes: get(where=) returns in ~0.03s at 100/16k/33k chunks, single-threaded, multi-threaded and under cross-process contention; the fixed EmbeddingIndex was driven end to end against a real PersistentClient (build, targeted removal, incremental rebuild with a deleted + an edited file, force rebuild, semantic search). Note for the record: delete(where=) could NOT be made to hang in isolation across five configurations, so the hang is condition-dependent and the swap alone is not proven to be the whole story — which is exactly why the bounded lock in (2) is load-bearing and not merely defence-in-depth. Tests: 1923 -> 1949 passing (+26 new). 22 of the 26 fail against the pre-fix source. The 3 pre-existing failures (enforcement-policy scope) are unrelated and environmental — a global ~/.c3 config on the dev box that the tests do not isolate. Claude-Session: https://claude.ai/code/session_01NzEEswycm78bBHD3Cak53L
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The symptom
An MCP server that looks perfectly healthy. Event loop alive and idle, nothing logged, nothing crashed — and every
c3_*tool call dying at Claude Code's 120s timeout. Diagnosed with py-spy on a live wedged process (v2.67.0, the version installed to site-packages).Three independent causes, all on background threads started during MCP startup.
1.
collection.delete(where=...)never returnscli/mcp_server.pyspawns thec3-embed-indexthread →EmbeddingIndex.build()→_remove_file_chunks()→self._collection.delete(where={"doc_id": doc_id}).Evidence: two py-spy dumps four minutes apart with byte-identical frames at
chromadb/api/rust.py(RustBindingsAPI._delete), 0.031s of CPU over 3s, and zero writes tochroma.sqlite3in ten hours.The file already had a get-ids-then-delete-by-ids fallback, and its comment already suspected the where-delete — but it sat behind
except, and anexceptclause cannot catch a call that never comes back. It was unreachable by construction.That fallback is now the only path. The metadata filtering moves to
get(where=...), which does return;delete()is left with the one argument shape never seen to stall.2. An unbounded lock turned one slow call into a dead server
build()tookself._lockwith a barewithand held it across the entire build loop, so the wedged delete parked every later caller behind it forever.It now acquires with a timeout via
_acquire_build_lock(), mirroring the_init_lockpattern that_ensure_readyalready used correctly. On contention it logs once at WARNING and returns adegradedresult carrying the normal stats shape, so the callers that read it with.get()defaults (cli/c3.py,cli/hub_server.py,services/subprojects.py,oracle/services/c3_bridge.py) keep working untouched. A redundant build is worth far less than a responsive server.with self._lock:→try: … finally: self._lock.release(), with a test pinning that a mid-build exception still releases.3.
subprocess.run(timeout=...)hangs inside its own timeout handlercheck_gemini/check_codex/check_claudepassedstdin=DEVNULLandtimeout=10and hung anyway. On Windows, when the timeout fires, CPython's handler kills only the direct child and then callsprocess.communicate()a second time with no timeout (the_mswindowsbranch ofrun()inLib/subprocess.py). That join never completes while a surviving grandchild still holds the stdout/stderr write-ends.Observed wedging the
c3-delegate-prewarmthread for 10h and leaking its two reader threads, so delegate health checks never completed and every firstc3_agentcall paid full preflight.All three now go through
_probe_cli_version():Popen+ ataskkill /Tprocess-tree kill (the grandchild case CPython's barekill()misses) + a boundedcommunicate()infinally.tests/test_cli_smoke.pydocumented this exact footgun for test code back in 2.43.0 and called it "the repo convention". Production code now follows it.What was actually verified vs. taken on trust
Verified empirically against the installed chromadb 1.5.6:
get(where=...)does not hang — 0.003–0.234s across 100 / 16k / 33k chunks at 768 dims, single-threaded, 7-thread concurrent, and with a second process holding the same persist dir. The replacement path is safe and performs comparably to the call it replaces.EmbeddingIndexdriven end to end against a realPersistentClient: initial build (300 chunks), targeted removal, incremental rebuild with one deleted and one edited file (no duplicate chunks), force rebuild, and semantic search. All pass.RustBindingsAPI._deleteis confirmed by reading the installed source to be the frame the py-spy dumps point at.communicate()confirmed by reading the installedLib/subprocess.py.Taken on trust, and worth stating plainly:
delete(where=...)could not be made to hang in isolation across five configurations (fresh small collection; 16k chunks at 768 dims; after a writer was SIGKILLed mid-write; under cross-process contention; under 7-way same-process thread contention). So the hang is condition-dependent, and swapping the call is not proven on its own to be the whole story.That is precisely why fix (2) matters: the bounded lock is load-bearing, not merely defence-in-depth. Even if some future backend call stalls the same way, it can no longer take the server down with it.
Tests
22 of the 26 new tests fail against the pre-fix source, including the deadlock reproduction itself. The hang is simulated with a blocking fake collection joined with a bound in a daemon thread, so a regression fails fast instead of wedging pytest — same convention
test_cli_smoke.pyuses.The 3 failures are pre-existing on a clean
mainand unrelated (enforcement-policyscoperesolving toglobalinstead ofdefault): the tests do not isolateHOME, so a global~/.c3config on the dev box leaks in. They should pass on clean CI runners.ruff check .clean.Install
Version bumped 2.67.0 → 2.67.1 in both
pyproject.tomlandcli/c3.py(kept in sync fortests/test_version_sync.py).