Skip to content

Windows: killing one endpoint's process takes down the shared daemon and every attached connection ("Connection closed") #2796

Open

Description

@Minervaowl7

Version

codebase-memory-mcp 0.12.1

Platform

Windows x64 (10.0.26300)

Install channel

GitHub release archive (windows-amd64.zip)

Binary variant

standard

What happened, and what did you expect?

Short version: when an MCP client kills one of its codebase-memory-mcp.exe child processes (e.g. an idle-connection eviction sweep), the shared daemon dies with it, and every other endpoint exe that was attached to that daemon exits with code 1. The next tool call on any surviving pooled connection fails with Connection closed. Reconnect heals it, but every in-flight/idle connection breaks at once.

Observed with the ZCode desktop client (Electron), whose MCP pool keeps several stdio connections per server and evicts idle ones ~30s after their lease refcount drops to 0 — the eviction path terminates the child process. Two failures on the first evening of multi-session usage, both with the identical signature.

Topology observed (v0.12.1, Windows):

  • Each stdio spawn is a thin endpoint; endpoints share one detached daemon that listens on 127.0.0.1:9749 (default UI port) and owns the cache/index.
  • Daemon lifetime is managed by a "last committed client disconnected" heuristic (runtime_stopping → lifetime_end).

Evidence from both sides (same incident):

  • Client pool log: victim connection idle-but-leased; death discovered at next tools/call → instant connection_lost → Connection closed. In both incidents the victim died 4s / 20s after a sibling pooled connection was closed by the pool.
  • Server cbm-daemon.log: zero entries for the victim endpoints' death — no runtime_stopping, no lifetime_end, no error. They vanished without running any graceful path (consistent with external termination). The failed tool calls never reached a handler (no tools/call line).

Reproduction

Deterministic lab repro (Node, Windows — two pooled endpoints + kill the daemon):

// A and B = two spawned `codebase-memory-mcp --tool-profile=analysis` stdio endpoints
// (initialize handshake done on both; a tools/call completed on each)
// daemon = the process listening on 127.0.0.1:9749 (spawned by A's launch)
killProcess(daemonPid)          // simulate a client sweep killing a child
// → B's exe exits with code 1 immediately
// → B's next JSON-RPC request hits EOF  → client reports "Connection closed"

Control: closing endpoint A's stdin cleanly leaves the daemon and B unharmed (daemon survives, B keeps serving). Only abrupt process termination of the daemon cascades.

Full repro script and analysis artifacts: I can attach on request (client-side pool events with traceIds, server log timeline, process-table correlation).

Logs

Server-side (C:/cbm-cache/logs/cbm-daemon.log) around an incident — note the absence of any shutdown trail for the victim:

level=info msg=mcp.request protocol=jsonrpc method=tools/call tool=list_projects status=ok duration_ms=1678
level=info msg=mcp.request protocol=jsonrpc method=tools/call tool=index_status status=ok duration_ms=109
... 15 minutes of no server-side activity (client idle) ...
[victim endpoint dies: NO log line at all — no runtime_stopping, no lifetime_end]
level=info msg=mcp.request protocol=jsonrpc method=tools/call tool=get_architecture status=ok duration_ms=1219   <- after client reconnect

Client-side pool events (ZCode zcode-<date>.jsonl), same incident:

12:06:25Z info  mcp.pool.lease...  (idle begins)
12:20:14Z info  mcp.pool.lease.released  (sibling connection, ref=0)
12:20:45Z info  mcp.server.closed (sibling, status=completed)
12:21:05Z info  tool.call.started          <- my next call on the victim connection
12:21:05Z warn  mcp.server.connection_lost status=failed
12:21:05Z error tool.call.failed  "Connection closed"

Diagnosis and suggested fix directions

Root cause chain: the daemon is spawned (detached) by an endpoint, so on Windows it remains inside the spawning client's job/process tree; a client that reaps its children with a kill sweep takes down the shared daemon that other, still-live connections depend on. Endpoints treat daemon loss as fatal (exit 1) rather than re-attaching.

Two candidate fixes (either one breaks the cascade; both is best):

  1. Escape the sweep (Windows, minimal): spawn the daemon with CREATE_BREAKAWAY_FROM_JOB / assign it to its own job object, so a client-side kill-on-close sweep of its tree cannot reach a daemon that serves multiple clients.
  2. Endpoint resilience: on daemon connection loss, attempt to spawn/re-attach to a daemon before giving up, instead of exiting.

Workaround currently deployed

A "keeper" process outside the client's tree holds an initialized MCP connection, so the daemon is owned by the keeper and client sweeps can't reach it. Works reliably; happy to share as a script if useful.

Diagnostics trajectory (memory / performance / leak issues)

Not a memory/leak issue; lifecycle only. Side observation: killed endpoints leak as orphaned codebase-memory-mcp.exe processes (several MB each) — related to #2400's "multiple exe" report, but the cascade above is the core defect.

Activity

  1. added
    bugSomething isn't working
    stability/performanceServer crashes, OOM, hangs, high CPU/memory
    awaiting-reporterWaiting on the reporter for info/repro; stale bot will warn then close
    on Oct 25, 2026
  2. DeusData commented on Oct 25, 2026

    @DeusData
    Owner

    Thank you for the process timeline and clean-stdin control. One distinction matters: the supplied lab reproduction kills the daemon directly, while the reported incident starts with an endpoint being evicted. Could you share a small sanitized reproduction that kills only endpoint A and records the daemon and endpoint B outcomes, plus whether the client uses a process-tree/job kill? That would separate the trigger from the resulting disconnect cascade.

  3. github-actions commented on Oct 25, 2026

    @github-actions

    Thanks for the report! To move this forward we need a bit more so we can reproduce it ourselves:

    • the codebase-memory-mcp --version you're on
    • the exact steps or command you ran
    • a public repo (or a small dummy snippet) that shows the problem — please don't paste proprietary code

    Once that's here we'll pick it straight back up. Heads-up: issues left awaiting-reporter are automatically closed after a few weeks of silence, but a comment reopens the door anytime.

  4. Minervaowl7 commented on Oct 26, 2026

    @Minervaowl7
    Author

    A fix is up for review: #2803.

    Design note after digging into the frontend failure path: the immediate-exit behavior is deliberate — the worker calls _Exit(EXIT_FAILURE) precisely because a failed daemon transport cannot portably wake the thread blocked in stdio. So the fix does not try to wake the reader; it recovers on the worker: on exchange failure it swaps in a replacement client supplied by a new daemon_lost callback from main(), re-mints the request token, and re-issues the very request whose exchange was interrupted (bounded to 3 attempts). The client never notices.

    Verified end-to-end on Linux/WSL with a real daemon: kill -9 the daemon mid-session, the next request is served by a re-spawned daemon; pre-fix the same scenario exits 1 and loses the request. Details and scope notes (mid-reconnect cancellation is dropped; UI flags not re-applied) are in the PR.

  5. Minervaowl7 commented on Oct 26, 2026

    @Minervaowl7
    Author

    Quick follow-up on the trigger question, and thanks for separating it from the disconnect cascade.

    Current state: the lab repro we have kills the daemon directly, so it reproduces the cascade but not the eviction trigger. The incident evidence only shows endpoint A disappearing without a graceful shutdown line; we have not yet isolated "kill only endpoint A" or confirmed whether ZCode's eviction is a process-tree/job kill rather than a single-process termination.

    I can run both variants on Windows — A only, and A's tree — with two live endpoints, and record the daemon PID/outcome plus endpoint B's response. The keeper workaround is currently holding the daemon, so I'd need to stop it for a clean run; after that I can post the sanitized script and the process-tree evidence here. I've also replied on #2803 with the current review state.

  6. Today20092 commented on Nov 8, 2026

    @Today20092

    This is a repro of the case you asked about: killing only a client process and never touching the daemon. I hit this on a real install and it took down the shared daemon.

    Environment: codebase-memory-mcp 0.12.1, Windows 11 (10.0.26200), Claude Code as the MCP client, about 143 indexed projects.

    Method: with the daemon already running, start a hook-augment client, write a PreToolUse payload to its stdin, wait a set delay, then call Process.Kill() on that one process. No tree or job kill is used, and the daemon is not signalled. Wait 4s, then compare the daemon PID and the daemon.forced_shutdown count in cbm-daemon.log.

    Binary Kill delay Killed while running forced_shutdown delta Daemon PID
    second install path (same bytes, not the daemon's image) 100 ms yes 0 unchanged
    second install path 250 ms yes 0 unchanged
    second install path 400 ms yes 0 unchanged
    same path as the daemon 100 ms yes 0 unchanged
    same path as the daemon 250 ms yes +1 gone, no daemon process
    same path as the daemon 400 ms yes 0 (daemon already gone)

    So it fired once in the 4 kills made against a live daemon from the daemon's own path, and 0 of 3 from the second path. It looks timing-dependent, which fits the kill having to land while a request is in flight. The sample is small.

    Effect on other clients: a separate stdio endpoint from a live Claude Code session was attached at the time. Its next tools/call failed with Connection closed. A retry shortly afterwards succeeded against a new daemon.

    Not established:

    • I did not capture the killed process's PID, so I can't confirm it matches the peer_pid in the matching log window (2632 below). That window is the one right after my run's delete_project, immediately before the kill test.
    • This uses hook-augment as the client rather than a pooled stdio endpoint, so it may or may not be the same path as the original eviction report.
    • Whether real incidents are triggered by hook timeouts is unproven. Claude Code hooks here had a 5s timeout, and list_projects took 12–67s (n=17). One natural crash came right after a 12s list_projects, which suggests a hook stalled and got killed, but that is only a correlation.

    Possibly useful: in the whole log, all 42 forced_shutdown lines come immediately after application_request_unresponsive, always with request_token=2 in the windows below and component=application_request_join. That is across 170 daemon starts, so natural occurrences are not rare on this machine. PR #2803 looks like it would help clients recover, but this suggests the daemon's own exit on one unresponsive peer is the separate root trigger.

    Repro script (PowerShell)
    $log = "$HOME\.cache\codebase-memory-mcp\logs\cbm-daemon.log"
    $exe = '<path to codebase-memory-mcp.exe, same path as the running daemon>'
    $payload = '{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"codebase"},"cwd":"<an indexed repo>"}'
    function Count($p) { (Select-String -Path $log -SimpleMatch $p).Count }
    function DaemonPid { (Get-CimInstance Win32_Process -Filter "Name='codebase-memory-mcp.exe'" | Where-Object CommandLine -like '*daemon-internal*' | Select-Object -First 1).ProcessId }
    
    foreach ($delay in 100, 250, 400) {
      $fsBefore = Count 'daemon.forced_shutdown'
      $pidBefore = DaemonPid
      $psi = [Diagnostics.ProcessStartInfo]::new($exe, 'hook-augment')
      $psi.RedirectStandardInput = $true
      $psi.RedirectStandardOutput = $true
      $psi.RedirectStandardError = $true
      $psi.UseShellExecute = $false
      $p = [Diagnostics.Process]::Start($psi)
      $p.StandardInput.Write($payload)
      $p.StandardInput.Close()
      Start-Sleep -Milliseconds $delay
      $alive = -not $p.HasExited
      if ($alive) { $p.Kill() }
      Start-Sleep -Seconds 4
      "delay=$delay killed=$alive forced_delta=$((Count 'daemon.forced_shutdown') - $fsBefore) daemon $pidBefore->$(DaemonPid)"
    }
    Sanitized cbm-daemon.log excerpt

    Reduced to an allowlist of fields. Project names, paths and detail strings are removed, so some lines look shorter than the originals. The window at line 12860 is the one from the repro; the others are natural occurrences.

    # aggregate counts (whole log)
    lines=12934
    daemon.start=170
    daemon.forced_shutdown=42
    daemon.application_request_unresponsive=43
    daemon.lifetime_end=64
    daemon.runtime_stopping=69
    daemon.client_image_unverifiable_admitted=575
    forced_shutdown_immediately_preceded_by_unresponsive=42/42
    list_projects_ms n=17 min=12050 max=67530
    
    # window ending at forced_shutdown (log line 12821)
    level=warn msg=daemon.client_image_unverifiable_admitted reason=image_unverifiable basis=rendezvous_hello_verified
    level=info msg=daemon.connection_end received=-1 frame_type=none
    level=warn msg=daemon.client_image_unverifiable_admitted reason=image_unverifiable basis=rendezvous_hello_verified
    level=info msg=mcp.request protocol=jsonrpc method=tools/call tool=list_projects status=ok duration_ms=12050
    level=error msg=daemon.application_request_unresponsive peer_pid=19896 request_token=2
    level=error msg=daemon.forced_shutdown component=application_request_join
    level=warn msg=mem.allocator.preloading_completed
    
    # window ending at forced_shutdown (log line 12860)
    level=info msg=mcp.request protocol=jsonrpc method=tools/call tool=delete_project status=ok duration_ms=6
    level=warn msg=daemon.client_image_unverifiable_admitted reason=image_unverifiable basis=rendezvous_hello_verified
    level=info msg=daemon.connection_end received=-1 frame_type=none
    level=warn msg=daemon.client_image_unverifiable_admitted reason=image_unverifiable basis=rendezvous_hello_verified
    level=error msg=daemon.application_request_unresponsive peer_pid=2632 request_token=2
    level=error msg=daemon.forced_shutdown component=application_request_join
    level=warn msg=mem.allocator.preloading_completed
    
    # window ending at forced_shutdown (log line 12898)
    level=info msg=daemon.connection_end received=-1 frame_type=none
    level=info msg=daemon.connection_end received=-1 frame_type=none
    level=error msg=daemon.application_request_unresponsive peer_pid=16624 request_token=2
    level=error msg=daemon.forced_shutdown component=application_request_join
    level=warn msg=mem.allocator.preloading_completed
    

    Possibly related: every run from a second install path with identical bytes logged client_image_unverifiable_admitted and returned empty hook output (3 of 3 runs, about 350 ms slower), while the daemon's own path returned output. That may be relevant to #2398.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    awaiting-reporterWaiting on the reporter for info/repro; stale bot will warn then closebugSomething isn't workingeditor/integrationEditor compatibility and CLI integrationstability/performanceServer crashes, OOM, hangs, high CPU/memoryux/behaviorDisplay bugs, docs, adoption UXwindowsWindows-specific issues

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions