Skip to content

Every idle MCP client burns ~0.7 core since 0.9.1-rc.1 (0.9.0: 0%) — N concurrent sessions cost N cores, Windows #1764

Closed

Description

@PabloSoage

Version

codebase-memory-mcp 0.10.8

Platform

Windows (x64)

Install channel

GitHub release archive / install.sh / install.ps1

Binary variant

standard

What happened, and what did you expect?

A codebase-memory-mcp stdio client process consumes 55-86% of one CPU core while completely idle, from the moment it starts, and never settles. Expected: ~0%, which is exactly what 0.9.0 does on the same machine.

This is not agent-specific and needs no MCP traffic to reproduce: spawning the binary from a plain shell and never sending a single JSON-RPC message already burns a core. The daemon itself is idle (1.2% of one core); the whole cost is in the client processes.

Because each editor/agent session spawns its own client, the cost multiplies. Snapshot from this machine with 5 client processes alive (one per open Claude Code session) plus the shared daemon:

PID     % of one core    RSS
4024        69.5         12.9 MB
37336       67.7         12.2 MB
24208       65.3         13.7 MB
21848       63.9         13.6 MB
10784       47.3         10.4 MB
20812        1.2          6.2 MB   <- the daemon, idle as expected

sum: 3.15 cores = 26% of a 12-thread CPU, permanently, doing nothing

RAM is fine (~13 MB per client). This is purely CPU.

Reproduction

No agent, no editor, no handshake required.

  1. On Windows, spawn the server with stdin held open as a pipe and write nothing to it:
// node spin.js
const { spawn, execSync } = require('child_process');
const p = spawn("C:/path/to/codebase-memory-mcp.exe", [], { stdio: ['pipe','pipe','pipe'] });
p.stdout.on('data',()=>{}); p.stderr.on('data',()=>{});
const cpu = () => parseFloat(execSync(
  'powershell -NoProfile -Command "(Get-Process -Id ' + p.pid + ').TotalProcessorTime.TotalMilliseconds"'
).toString().trim());
setTimeout(() => { const a = cpu(), t = Date.now();
  setTimeout(() => { const b = cpu();
    console.log(((b-a)/(Date.now()-t)*100).toFixed(1) + '% of one core'); p.kill(); }, 10000);
}, 3000);
  1. Run it against 0.9.0 and against 0.10.8.

Metric: delta of TotalProcessorTime over a 10 s wall-clock window, expressed as a percentage of a single core. Measurement starts 3 s after spawn so startup is excluded.

A/B, same machine, alternated runs

Real user profile, 9 indexed projects:

Version run 1 run 2 run 3
0.9.0 0.0% 0.0% 0.1%
0.10.8 70.2% 76.4% 85.9%

Version bisect

Each version run with an isolated LOCALAPPDATA / APPDATA / USERPROFILE / HOME, so every binary got a private daemon endpoint and an empty state directory with zero indexed projects:

Version % of one core
0.9.0 0.0 / 0.0 / 0.1
0.9.1-rc.1 27.0 / 29.5 / 39.1
0.10.0 26.8
0.10.1 17.3
0.10.2 20.0
0.10.4 34.6
0.10.6 30.4
0.10.8 22.7

The regression boundary is exactly v0.9.0 → v0.9.1-rc.1. Absolute numbers are lower in this table than in the A/B above because these runs had no indexed projects; the spin happens regardless.

Logs

Nothing. The loop is completely silent. Measured simultaneously over one 12 s window with CBM_LOG_LEVEL=trace set: 66.8% of one core, and 0 bytes on stderr (0 on stdout too).

Diagnostics trajectory (memory / performance / leak issues)

Thread-level breakdown

One client, alive 24 minutes, still spinning. Two threads carry all of it:

thread 14032    30.5%   Running
thread 22428    29.0%   Wait / ExecutionDelay
thread 14616     0.0%   Wait / Executive

A freshly spawned client shows the same shape: 6 threads, two of them at 50.3% and 54.1%.

Ruled out (each one measured, not assumed)

  • Number of concurrent sessions — a single client, alone, spawned from a plain shell, spins.
  • MCP traffic — spins before any initialize; 62.1% with no handshake at all vs 55.0% after one.
  • The daemon — daemon status reports all clients committed; the daemon uses 1.2% of a core.
  • auto_watch — no change with it set to false (47-64% either way). Note the watcher thread is created unconditionally in src/main.c (if (g_watcher)), so the config key does not gate it.
  • auto_index — false throughout (this is what distinguishes it from CPU memory usage is too high #1084).
  • Indexed projects — the isolated-home runs above have zero projects and still spin.
  • ui_enabled — 64.4% with --ui=false.
  • --tool-profile=scout — 70.0%.
  • Warm-up — the thread breakdown above was taken on a client that had been alive for 24 minutes; it was still carrying 59.5% of a core across its two hot threads.
  • The parent-death watchdog — excluded on Windows (#ifndef _WIN32) and polls at 500 ms.
  • The watcher's own loop — POLL_BASE_MS 5000 chunked by SLEEP_CHUNK_MS 500 is 10 wakeups per cycle; far too cheap to account for this.

Where I would look (hypothesis, NOT verified)

I could not attribute the CPU to a specific line — the shipped binary is stripped and the loop logs nothing — so please treat this section as a starting point rather than a diagnosis.

The regression window v0.9.0..v0.9.1-rc.1 is 486 commits and contains 0e00ef57 ("feat: coordinate concurrent CBM sessions" — same title as the closed issue #1139, though the commit message itself carries no issue reference), which introduces all of src/daemon/, including src/daemon/frontend.c. That file is the client side: frontend.h describes it as "Stateless stdio bridge for a daemon-backed MCP session", and src/main.c:2946 calls cbm_daemon_frontend_mcp_run(..., stdin, stdout) in the client process. So in this window the client's stdio path went from having no daemon bridge at all to having one built on poll loops:

  • FRONTEND_WAIT_US = 1000 (1 ms) — 3 call sites, including the enqueue path and the join watchdog
  • FRONTEND_IDLE_WAIT_US = 10 * 1000 (10 ms) — the idle path of frontend_worker
  • FRONTEND_MAINTENANCE_POLL_MS = 10 — frontend_worker calls frontend_exit_for_maintenance() 100 times per second, which calls cbm_version_cohort_maintenance_presence_terminal()

FRONTEND_IDLE_WAIT_US did not exist in 0e00ef57; it was added later, which may explain why the figure varies across 0.10.x instead of being flat.

Secondary observation, possibly unrelated

On Windows cbm_usleep is integer division:

#define cbm_usleep(us) Sleep((DWORD)((us) / 1000))   // src/foundation/compat.h:33

so any argument below 1000 µs becomes Sleep(0), which does not sleep. All the named poll constants are >= 1000 µs, but version_cohort_retry_sleep() (src/daemon/version_cohort.c:93) passes VERSION_COHORT_RETRY_US / 2U + (mix % VERSION_COHORT_RETRY_US) = 500-1499 µs, so roughly half of its jitter range degrades to Sleep(0) on Windows. I do not think this is the driver here (a single isolated client has no lock contention and still spins), but it looked worth flagging.

Not a duplicate of

I also searched issues and PRs for frontend.c, FRONTEND_WAIT_US, FRONTEND_IDLE_WAIT_US, "stdio bridge", "idle cpu" and version_cohort and found nothing covering this. No commit on main after v0.10.8 touches frontend.c, version_cohort.c or main.c, and the three constants are unchanged on main, so this should still be live on tip.

Environment

  • Windows 11 Business 10.0.26200 (x64)
  • 12th Gen Intel Core i7-1255U — 10 physical / 12 logical cores
  • 23.7 GB RAM
  • The installed 0.10.8 binary is byte-identical to the codebase-memory-mcp-windows-amd64.zip release artifact — SHA256 b4b403b1d7c4def3785f148b93f345ce8427858f4f5489ce28580c4387a336a6, matching the build id the daemon reports. Every version in the bisect came from that same windows-amd64 release family, so no build-variant difference is involved. (Note that in 0.10.8 this "standard" artifact does serve the graph UI on 9749.)
  • The 0.9.0 binary came from the installer's own backup, %LOCALAPPDATA%\Programs\codebase-memory-mcp\codebase-memory-mcp.exe.old

Project scale (if relevant)

9 indexed projects on the affected machine. Also reproduces with 0 indexed projects (see bisect table), so scale is not a factor.

Confirmations

  • I searched existing issues and this is not a duplicate.
  • My reproduction uses shareable code (a dummy snippet or a public OSS repository), not proprietary code.

Activity

  1. added this to the 0.10.0-rc milestone on Aug 20, 2026
  2. DeusData commented on Aug 20, 2026

    @DeusData
    Owner

    Thanks for the unusually thorough, reproducible report and version bisect — the per-thread data was especially useful. We checked current main (dfe67cc7): the daemon frontend currently has two independent 10 ms maintenance observers, and each observation performs the full secure version-cohort file-lock path on Windows. That is a concrete source-level match for the two hot client threads you measured, and these paths remain unchanged on tip.

    We are treating this as a current regression. The next gate is a deterministic RED on our real Windows runner, followed by a minimal change that preserves update/maintenance fail-stop behavior and a RED-on-revert check. Our local Windows VM is currently unavailable, so I do not want to claim a runtime reproduction or fix before that gate. No more information is needed from you right now; thank you again for the excellent diagnostics.

  3. detrax commented on Aug 28, 2026

    @detrax

    Chiming in with a macOS data point, since the bisect and per-thread breakdown above are Windows-only: this reproduces on macOS too (Intel MacBook Pro, Darwin 25.5.0, 0.10.8 from the release archive), just with a much smaller absolute burn.

    Idle clients here sit at roughly 4% of a core each. That sounds harmless compared to the 70% above, but it adds up quietly — I noticed it because I had 11 client processes alive (one per open Claude Code terminal), and the oldest ones had accumulated 128 and 170 CPU-minutes doing literally nothing. Daemon idle at ~0.5%, same picture as your snapshot.

    The nice thing about macOS is that sample can attribute the time even though the binary is stripped. 5-second sample of one idle client, the busy worker thread (4309 samples total, so 138 ≈ 3.2% of a core — which matches what ps shows for the whole process):

        4309 Thread_8950908
        +     4170 ??? (in codebase-memory-mcp) + 0x8251a
        +     ! 4169 usleep -> nanosleep -> __semwait_signal
        +     138 ??? (in codebase-memory-mcp) + 0x824b2
        +     ! 135 ??? + 0x7206d
        +     ! : 97 ??? + 0x14eee
        +     ! : | 70 lstat$INODE64
        +     ! : | 15 fstat$INODE64
        +     ! : | 8 ??? + 0x152cd
        +     ! : | + 6 acl_get_fd_np -> fstatx_np$INODE64
    

    So essentially all of the on-CPU time is filesystem work: an lstat walk plus fstat/acl_get_fd_np, i.e. the secure lock-path validation you described, done ~100×/s per observer. The sleep/wake overhead itself is nearly free here — which would explain the platform gap: on macOS these syscalls are cheap, on Windows the equivalent security checks presumably cost an order of magnitude more, but the loop structure is identical.

    One observation on top of #1845: deduplicating the two observers should halve this, but the remaining observer still runs the full lock validation at 10 ms cadence. Given that FRONTEND_MAINTENANCE_GRACE_MS exists anyway, is there a reason maintenance detection needs to react within 10 ms? Even 250–500 ms would cut the remaining burn by 25–50× without meaningfully delaying an update quiesce.

    Happy to test a macOS build of #1845 (or a cadence tweak) against the same setup — the 11-client scenario is my daily driver, so regressions would show up fast.

  4. PabloSoage commented on Aug 28, 2026

    @PabloSoage
    Author

    Windows counterpart to @detrax's macOS attribution, since that was the piece missing on this side.

    I can't get a symbolised stack on Windows (stripped binary, no sample equivalent), but the kernel/user split and the per-process I/O counters say the same thing. Idle client, no handshake, sampled over 10 s with Win32_Process (UserModeTime / KernelModeTime, and OtherOperationCount — I/O operations that are neither reads nor writes, i.e. metadata and control):

    0.9.0 0.10.8
    CPU, idle 0.0% of a core 55.7% / 56.8% / 61.5%
    Share of that time in kernel mode — 91-94%
    "Other" I/O operations per second 0 18,537 / 19,500 / 22,091
    Read ops/s 0 8-11
    Write ops/s 0 0

    Three runs, alternated, same machine as the original report.

    Two things follow:

    • The burn is not sleep/wake overhead or userspace spinning — ~92% of it is syscall time, with ~20k metadata operations per second and essentially zero reads or writes. That matches the lstat walk + fstat/acl_get_fd_np profile @detrax captured, so the loop structure really does look identical across platforms.

    • I read the source to check the "two independent 10 ms observers" against the counters, and it lines up exactly — including a detail worth spelling out. Both observers poll the same cbm_version_cohort_manager_t: cbm_daemon_frontend_mcp_run stores it as .cohort_manager = cohort_manager (src/daemon/frontend.c:581) and then hands the very same pointer to cbm_daemon_maintenance_monitor_start (:586-587). So frontend_maintenance_monitor_worker (thread at :159) and frontend_worker (thread at :594) each call cbm_version_cohort_maintenance_presence_terminal() on the same object every FRONTEND_MAINTENANCE_POLL_MS = 10 — the monitor via its own cbm_usleep at :106/:126, the worker via next_maintenance_check_ms at :422. Two hot threads, which is exactly what the per-thread breakdown showed. Identical at dfe67cc7 and at current main (2cc80f20).

    • At 200 observations/s that puts ~20,000 ops/s at roughly 100 filesystem metadata operations per observation, which is plausible given what each one does: version_cohort_maintenance_presence_internal performs a full cbm_private_file_lock_try_acquire/release cycle on VERSION_COHORT_MAINTENANCE_FILE, and private_file_lock.c revalidates on every acquire (fstat + lstat + inode comparison + ACL checks, and the SECURITY_DESCRIPTOR/DACL path on Windows). On macOS those syscalls are cheap; on Windows the security-descriptor work is not, which would account for the ~4% vs ~60% gap between the two reports rather than any difference in cadence.

    For anyone else hitting this before the fix lands: downgrading to 0.9.0 is viable and does not require reindexing — the 0.10.8 index stays fully readable (all 9 of my projects report ready with their node/edge counts intact, and content queries return real results). One caveat: under 0.9.0 list_projects only enumerated 3 of my 9 projects, deterministically and with no error, though every one of the missing six answers normally when named explicitly. That's a 0.9.0-side wart, not index damage.

    Happy to run any Windows build against the same setup — the counters above are a two-minute check, so a before/after on #1845 is easy to produce.

  5. DeusData commented on Aug 29, 2026

    @DeusData
    Owner

    Fixed on main in 57435634 (PR #1845, "fix(daemon): avoid duplicate idle maintenance polling").

    Thank you for this one — the diagnosis in this thread did the hard part. @detrax, the macOS data point mattered specifically because the original bisect and per-thread breakdown were Windows-only, so it established the behaviour was not platform-specific. @PabloSoage, the kernel/user split from the Windows side was the complementary half; between them the shape was pinned well before anyone read the scheduling code.

    An idle client should not cost measurable CPU, and a regression that only shows up as "warm laptop" is easy to live with and hard to attribute — reporting it with a bisect rather than an impression is what made it actionable.

  6. Tomenatore commented on Sep 1, 2026

    @Tomenatore

    Still reproducing on v0.10.8 (Windows 11 23H2, build 22631), and there is no release containing the fix yet: v0.10.8 shipped 2026-08-19, while #1845 merged 2026-08-27 and this issue closed 2026-08-28.

    Measurements from a production workstation (i9-13900HX, 32 logical CPUs, 64 GB RAM):

    Per idle client:

    • front-end process: ~19,000 IO "other" ops/s, 0 bytes read or written, 3 threads
    • CPU: 50.5% of one core (front-end) + 47.1% (internal daemon) — roughly one core per client, matching the ~0.7 core in the original report
    • the internal daemon on its own, with no front-end attached, is quiet (~5 ops/s)

    Scaling: this host runs Hermes, which registers the MCP server per profile. With 4 concurrent instances:

    • System process (PID 4): 270–1231% privileged time, 905–966 threads (normally <5% and 150–250 threads)
    • one core pinned at 100%, entirely in kernel mode
    • system-wide: 507k–820k syscalls/s, processor queue length up to 15

    Second-order effects, all measured before and after stopping the instances:

    • process creation: 1,102 ms -> 194 ms
    • Explorer "This PC" enumeration: 1,917 ms -> 936 ms
    • a browser restoring saved tabs took 5–10 minutes with the disk 97–100% idle; 1,137 of 1,285 threads in Wait/UserRequest, 113 in Wait/EventPairLow (ALPC to CSRSS), exactly 1 running

    Configuration had no effect: with both auto_watch=false and ui_enabled=false, a freshly spawned front-end still spins at 18,783 ops/s. So this is not the file watcher and not the UI server.

    Workaround in place: limit the MCP server to a single profile, and drive indexing from a scheduled task calling cli index_repository behind a git-fingerprint gate.

    Would it be possible to cut a 0.10.9 containing #1837 and #1845? v0.10.8 is currently the only version install.ps1 resolves to, and on Windows it costs roughly one core per concurrent session.

  7. added
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    on Sep 3, 2026
  8. TaiwanTammy commented on Sep 8, 2026

    @TaiwanTammy

    Adding a v0.10.6 data point, plus a nudge on the release: @Tomenatore's "no release contains the fix yet" note is eight days old now and still true.

    0.10.6 is affected, so downgrading below 0.10.7 is not an escape

    Windows 11 Home 26200, x64, 0.10.6 from the release archive, auto_watch = false. Four live clients (three Claude Code sessions + one Codex session) plus the shared daemon, sampled over 10 s with Win32_PerfRawData_PerfProc_Process:

    process age CPU idle "other" IO ops/s read+write ops/s
    client A 18h 27m 69% of a core 15,240 0
    client B 8h 27m 50% of a core 14,798 0
    client C 7h 59m 70% of a core 13,679 0
    client D 7m 70% of a core 13,831 0
    shared daemon 18h 51m 0% 6 0

    Same shape as the original report and as @PabloSoage's table: zero bytes moved, five figures of "other" ops per second, and the daemon itself innocent.

    Clean-room check, since none of these clients had been asked to do anything: spawning the binary over a plain pipe from a Node script, no JSON-RPC sent, no project attached, 78% of a core within six seconds of launch.

    The accumulated number is the one that's easy to miss. Client A had been sitting in an open, idle window since the previous lunchtime and had burned 6 h 54 m of CPU time having done no work at all.

    Why the release specifically

    v0.10.9 is also carrying the auto_watch reindex-loop fix (6741c29fc24d, #1953). On Windows today that means both paths cost a core: leave the watcher on and it reindexes itself forever, turn it off and every idle client still spins. The only way out of either is a tag that has not shipped, so the two fixes are compounding behind one release rather than one.

    Anything that gets 0.10.9 out would help, including a pre-release. Happy to test a Windows 11 build against both symptoms and report back if that is useful.

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

    bugSomething isn't workingeditor/integrationEditor compatibility and CLI integrationpriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.stability/performanceServer crashes, OOM, hangs, high CPU/memoryux/behaviorDisplay bugs, docs, adoption UXwindowsWindows-specific issues

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions