Skip to content

Multi-session contention on shared per-project content DB → busy-wait CPU spin & event-loop hang (Windows) #985

Description

@alove20

Summary

On Windows, with multiple concurrent Claude Code sessions open in the same project, context-mode MCP server instances intermittently (a) pin a CPU core at ~100% accumulating hundreds of CPU-seconds, or (b) go completely unresponsive to ctx_* calls while sitting at low CPU. Both trace to write contention on the shared per-project content DB, amplified by a synchronous busy-wait retry backoff.

Root cause

1. The content DB is shared per-project, not per-session.
Captures are stored in ~/.claude/context-mode/content/<projectHash>.db, keyed by project. Every concurrent session working in the same project therefore opens and writes the same SQLite file. (Per-session DBs under sessions/ are not contended — their WALs stay empty.) On one real machine this DB had grown to 386 MB with a 30 MB un-checkpointed WAL (checkpoint starvation) with 5 live server instances writing it.

2. The SQLITE_BUSY backoff is a synchronous busy-wait.
The retry wrapper (minified Yr in server.bundle.mjs) uses backoff steps [100, 500, 2000] ms, but implements the delay as a spin loop:

function Yr(t, e=[100,500,2000]) {
  for (let r=0; r<=e.length; r++)
    try { return t(); }
    catch(o) {
      const s = o?.message ?? String(o);
      if (!s.includes("SQLITE_BUSY") && !s.includes("database is locked")) throw o;
      if (r < e.length) {
        const i = e[r], a = Date.now();
        for (; Date.now() - a < i; );   // <-- synchronous busy-wait: pins a core
      }
    }
  throw new Error("SQLITE_BUSY: database is locked after " + e.length + " retries...");
}

Each fully-contended operation spins a core for up to 100+500+2000 = ~2.6 s. Under a burst of contended writes this accumulates unbounded CPU. Observed: an instance at 99% of a core, 665 lifetime CPU-seconds.

3. better-sqlite3 is synchronous, so a blocked query freezes the event loop.
When an operation blocks waiting on the lock (rather than returning SQLITE_BUSY), the single-threaded server stalls and stops servicing its stdio transport — it goes unresponsive at low CPU (blocked in a native lock wait). On Windows a console node.exe has no UI message pump, so the OS always reports the process as "Responding", making this hang indistinguishable from healthy-idle to any external monitor.

Environment

  • Windows 11, Claude Code desktop, context-mode v1.0.169
  • PRAGMA journal_mode=WAL, synchronous=NORMAL, mmap_size=268435456

Suggested fixes (any of these helps; ideally 1+2)

  1. Replace the busy-wait with a real delay. The retry path should yield rather than spin — e.g. make the DB call path async and await a timer, or at minimum use Atomics.wait on a throwaway SharedArrayBuffer for a truly-blocking sleep that doesn't peg the CPU.
  2. Reduce contention on the shared DB. Serialize writers within a process, run a more aggressive wal_autocheckpoint, and/or shard the content DB per-session (like the sessions/ DBs already are) and merge/query across shards, so N concurrent sessions don't serialize on one writer.
  3. Add a real liveness signal. Because "Responding" is meaningless for a console process, a wedged event loop is invisible externally; a heartbeat (e.g. touch a file or advance a counter each event-loop tick) would let supervisors detect a stalled instance.

Related

Filed separately: the Windows parent-death / orphan-reaping issue (#982). This report is the distinct SQLite-contention cause of the in-session spin/hang.

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