Environment
- Cortex plugin 4.3.0 (Claude Code marketplace install), PostgreSQL backend (pgvector/pg17, Docker)
- Windows 11 Pro (26200), Python 3.13.13
- Client: Claude Code desktop; reproduced on FastMCP 3.2.4 and 3.4.4 (version-independent)
Symptom
Calling the remember tool over a live MCP connection never returns — the call sits 10–30 minutes until the client's idle timeout aborts it (or the user cancels). Meanwhile:
memory_stats on the same connection responds instantly, before and after the hang.
- The DB write itself sometimes lands (row appears), sometimes not — but the tool response never arrives.
- The auto-capture hooks are unaffected (separate short-lived processes).
- The hang does not reproduce when driving the same server binary out-of-band (raw JSON-RPC over stdio, same env, same protocol version, same arguments, with or without
_meta.progressToken) — only on a real long-running client connection.
Diagnosis
I added a temporary watchdog to mcp_server/__main__.py (a daemon thread dumping all thread stacks every 30 s). During a live 10-minute hang it captured the handler wedged here, on every tick:
[thread asyncio_0]
...
tool_error_handler.py:107 _run_coroutine_on_thread → loop.run_until_complete(handler_fn(args))
handlers/remember.py:345 _handler_impl → _resolve_domain(directory, ...)
handlers/remember.py:246 _resolve_domain → resolve_cwd(directory)
shared/domain_mapping.py:404 resolve_cwd → _git_root(cwd)
shared/domain_mapping.py:236 _git_root → subprocess.check_output(["git", "-C", path, "rev-parse", "--show-toplevel"], stderr=DEVNULL, timeout=3)
subprocess.py:472 check_output → run(...)
subprocess.py:565 run → exc.stdout, exc.stderr = process.communicate() ← inside the TimeoutExpired handler
subprocess.py:1222 communicate → self._communicate(input, endtime, timeout)
subprocess.py:1663 _communicate → self.stdout_thread.join(...) ← blocks forever
[thread Thread-1 (_readerthread)]
subprocess.py:1615 _readerthread → buffer.append(fh.read()) ← never sees EOF
Note what this means: the timeout=3 did fire and git was killed. The hang is after that. On Windows, subprocess.run's TimeoutExpired handler calls process.communicate() a second time — without a timeout — to collect post-kill output (CPython 3.13 subprocess.py:565):
except TimeoutExpired as exc:
process.kill()
if _mswindows:
# Windows accumulates the output in a single blocking
# read() call run on child threads, ...
exc.stdout, exc.stderr = process.communicate()
That second communicate() joins the stdout reader thread with no deadline. The reader thread is blocked in fh.read() and never sees EOF even though git is dead, because the pipe's write end is still open in another process: with the classic Windows handle-inheritance race, a child spawned concurrently from another thread of the same parent inherits the (temporarily inheritable) stdout pipe write-handle of the git spawn. Cortex spawns other children from the server process (e.g. the AP upstream bridge — CORTEX_MEMORY_AP_ENABLED defaults to 1), so a long-lived sibling child can end up holding the duplicated write handle → EOF never arrives → communicate() blocks forever → the tool never responds.
This also explains every secondary observation:
memory_stats never touches domain resolution → no git spawn → instant.
- Out-of-band repros don't hang: without the concurrent sibling spawn there is no handle leak, git returns in milliseconds and the timeout never even fires.
- In one instance the pending write flushed exactly when the client reconnected and killed the server — pipe teardown finally unblocked the wedged call.
_get_remote_url (domain_mapping.py:36) has the identical pattern (check_output(["git", ...], timeout=3)) and runs inside _build_registry(), which is also on the first-request path via resolve_cwd. Other subprocess-with-PIPE call sites (git_diff*.py, hooks) share the risk class, though the hooks live in short-lived processes where the damage is bounded.
Fix I applied locally (validated)
Both functions can avoid subprocess entirely, which sidesteps the whole class:
def _git_root(path: str) -> str | None:
"""Find the git repo root for a path. Returns None if not in a repo."""
try:
p = Path(path).resolve()
for candidate in (p, *p.parents):
if (candidate / ".git").exists(): # dir (normal) or file (worktree)
return str(candidate).replace("\\", "/") # match git's forward-slash output
return None
except Exception:
return None
def _get_remote_url(repo_path: Path) -> str:
"""Get git remote origin URL. Returns '' if no remote."""
try:
cfg = repo_path / ".git" / "config"
if not cfg.is_file():
return ""
section = ""
for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if line.startswith("["):
section = line.lower()
elif section == '[remote "origin"]' and line.lower().startswith("url"):
_, _, value = line.partition("=")
if value.strip():
return value.strip()
return ""
except Exception:
return ""
With these two changes (plus CORTEX_MEMORY_AP_ENABLED=0, see below) the hang is gone: remember over the live Claude Code connection returns in ~1 s warm / ~6 s on first call (model load), verified repeatedly.
If you prefer keeping git for correctness in exotic setups (GIT_DIR overrides, bare repos), the minimal alternative is Popen + communicate(timeout=3) + kill() and never calling communicate() again — return None and let the fallback path handle it. But note the pure-Python walk matches rev-parse --show-toplevel for the setups the registry can represent anyway (it only scans .git directories).
Suggestions
- Replace
_git_root / _get_remote_url with the non-subprocess versions (or the kill-without-recollect pattern).
- Audit the remaining subprocess-with-PIPE call sites reachable from tool handlers on Windows for the same post-timeout
communicate() trap.
- Consider whether the AP upstream spawn can use non-inheritable handles /
close_fds-equivalent hygiene, since it is the long-lived sibling that keeps leaked pipe handles alive.
Happy to provide the full watchdog dumps if useful.
Environment
Symptom
Calling the
remembertool over a live MCP connection never returns — the call sits 10–30 minutes until the client's idle timeout aborts it (or the user cancels). Meanwhile:memory_statson the same connection responds instantly, before and after the hang._meta.progressToken) — only on a real long-running client connection.Diagnosis
I added a temporary watchdog to
mcp_server/__main__.py(a daemon thread dumping all thread stacks every 30 s). During a live 10-minute hang it captured the handler wedged here, on every tick:Note what this means: the
timeout=3did fire and git was killed. The hang is after that. On Windows,subprocess.run'sTimeoutExpiredhandler callsprocess.communicate()a second time — without a timeout — to collect post-kill output (CPython 3.13subprocess.py:565):That second
communicate()joins the stdout reader thread with no deadline. The reader thread is blocked infh.read()and never sees EOF even though git is dead, because the pipe's write end is still open in another process: with the classic Windows handle-inheritance race, a child spawned concurrently from another thread of the same parent inherits the (temporarily inheritable) stdout pipe write-handle of the git spawn. Cortex spawns other children from the server process (e.g. the AP upstream bridge —CORTEX_MEMORY_AP_ENABLEDdefaults to1), so a long-lived sibling child can end up holding the duplicated write handle → EOF never arrives →communicate()blocks forever → the tool never responds.This also explains every secondary observation:
memory_statsnever touches domain resolution → no git spawn → instant._get_remote_url(domain_mapping.py:36) has the identical pattern (check_output(["git", ...], timeout=3)) and runs inside_build_registry(), which is also on the first-request path viaresolve_cwd. Other subprocess-with-PIPE call sites (git_diff*.py, hooks) share the risk class, though the hooks live in short-lived processes where the damage is bounded.Fix I applied locally (validated)
Both functions can avoid subprocess entirely, which sidesteps the whole class:
With these two changes (plus
CORTEX_MEMORY_AP_ENABLED=0, see below) the hang is gone:rememberover the live Claude Code connection returns in ~1 s warm / ~6 s on first call (model load), verified repeatedly.If you prefer keeping git for correctness in exotic setups (
GIT_DIRoverrides, bare repos), the minimal alternative isPopen+communicate(timeout=3)+kill()and never callingcommunicate()again — returnNoneand let the fallback path handle it. But note the pure-Python walk matchesrev-parse --show-toplevelfor the setups the registry can represent anyway (it only scans.gitdirectories).Suggestions
_git_root/_get_remote_urlwith the non-subprocess versions (or the kill-without-recollect pattern).communicate()trap.close_fds-equivalent hygiene, since it is the long-lived sibling that keeps leaked pipe handles alive.Happy to provide the full watchdog dumps if useful.