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)
- 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.
- 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.
- 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.
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 undersessions/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_BUSYbackoff is a synchronous busy-wait.The retry wrapper (minified
Yrinserver.bundle.mjs) uses backoff steps[100, 500, 2000]ms, but implements the delay as a spin loop: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 consolenode.exehas 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
PRAGMA journal_mode=WAL, synchronous=NORMAL, mmap_size=268435456Suggested fixes (any of these helps; ideally 1+2)
awaita timer, or at minimum useAtomics.waiton a throwaway SharedArrayBuffer for a truly-blocking sleep that doesn't peg the CPU.wal_autocheckpoint, and/or shard the content DB per-session (like thesessions/DBs already are) and merge/query across shards, so N concurrent sessions don't serialize on one writer.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.