Skip to content

remember hangs indefinitely on Windows: _git_root's subprocess timeout is defeated by subprocess.run's post-kill communicate() (pipe handle-inheritance race) #91

Description

@mbe14

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

  1. Replace _git_root / _get_remote_url with the non-subprocess versions (or the kill-without-recollect pattern).
  2. Audit the remaining subprocess-with-PIPE call sites reachable from tool handlers on Windows for the same post-timeout communicate() trap.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions