From 45b2ddb2e81337b2e421051e81b408412d7f0a54 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Fri, 9 Oct 2026 14:40:30 -0600 Subject: [PATCH 1/2] perf(mcp): let a pool without a default project retire its last idle worker Idle retirement kept one query worker alive in every pool. In a session with no default project, that worker held the projects it had opened through projectPath after the main thread released its own copies. Such a pool now retires every idle worker; warm() starts one again on the next call that names a project. A pool with a default project still keeps one. Measured on a session started outside any project that queried two projects by path, with both idle timeouts at 20 s: after 90 s idle the server's PSS fell from 179 MB to 68 MB and its threads from 13 to 6. --- __tests__/query-pool.test.ts | 26 ++++++++++++++++++++++++++ src/mcp/query-pool.ts | 7 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index 5cf2f582a..66f97296a 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -268,6 +268,32 @@ describe('QueryPool', () => { await pool.destroy(); }); + it('retires idle workers down to one with a default project, and to none without one', async () => { + const make = (root: string | null) => { + let created = 0; + const pool = new QueryPool({ root, size: 2, idleRetireMs: 1_000, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r:${m.toolName}`) })); } }); + return { pool, created: () => created }; + }; + const retire = (pool: QueryPool) => (pool as unknown as { retireIdle(now: number): void }).retireIdle(Date.now() + 60_000); + + const rooted = make('/x'); + await sleep(5); + expect(rooted.pool.liveWorkers).toBe(1); + retire(rooted.pool); + expect(rooted.pool.liveWorkers).toBe(1); // stays warm for the default project + await rooted.pool.destroy(); + + const rootless = make(null); + rootless.pool.warm(); + await sleep(5); + expect(await rootless.pool.run('codegraph_explore', {})).toEqual(ok('r:codegraph_explore')); + retire(rootless.pool); + expect(rootless.pool.liveWorkers).toBe(0); + rootless.pool.warm(); // the next call that names a project starts one again + expect(rootless.created()).toBe(2); + await rootless.pool.destroy(); + }); + it('retires a failed cold start and serves queued work on its replacement', async () => { const workers: FakeWorker[] = []; const pool = new QueryPool({ diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 09ac847ed..0838dd8ad 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -224,17 +224,20 @@ export class QueryPool { } /** - * Terminate workers idle for `idleRetireMs`, oldest idle first, keeping one. + * Terminate workers idle for `idleRetireMs`, oldest idle first. A pool with a + * default project keeps one warm; a pool without one retires them all, since + * {@link warm} starts another on the next call that names a project. * A retired worker leaves `workers` before it is terminated, so its exit is * not counted as a crash (onWorkerGone ignores workers it no longer owns). */ private retireIdle(now = Date.now()): void { if (this.destroyed) return; + const keep = this.root === null ? 0 : 1; const stale = this.idle .filter((w) => now - (this.idleSince.get(w) ?? now) >= this.idleRetireMs) .sort((a, b) => (this.idleSince.get(a) ?? 0) - (this.idleSince.get(b) ?? 0)); for (const w of stale) { - if (this.workers.size <= 1) break; + if (this.workers.size <= keep) break; this.workers.delete(w); this.idle = this.idle.filter((x) => x !== w); this.idleSince.delete(w); From 72ac47b45403c036ab3a44f4a07764892949733a Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Fri, 9 Oct 2026 14:50:12 -0600 Subject: [PATCH 2/2] fix(mcp): serve in-process again after idle retirement empties the pool A pool that retired its last worker still reported ready, so the next call that named a project waited behind the replacement worker's cold start. Readiness now resets when the pool empties. --- __tests__/query-pool.test.ts | 4 ++++ src/mcp/query-pool.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index 66f97296a..c5ab411ee 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -287,10 +287,14 @@ describe('QueryPool', () => { rootless.pool.warm(); await sleep(5); expect(await rootless.pool.run('codegraph_explore', {})).toEqual(ok('r:codegraph_explore')); + expect(rootless.pool.ready).toBe(true); retire(rootless.pool); expect(rootless.pool.liveWorkers).toBe(0); + expect(rootless.pool.ready).toBe(false); // calls run in-process again rootless.pool.warm(); // the next call that names a project starts one again expect(rootless.created()).toBe(2); + await sleep(5); + expect(rootless.pool.ready).toBe(true); await rootless.pool.destroy(); }); diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 0838dd8ad..46e62ba0e 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -243,6 +243,8 @@ export class QueryPool { this.idleSince.delete(w); try { void w.terminate(); } catch { /* already gone */ } } + // An empty pool is cold again: serve in-process until the next worker is ready. + if (this.workers.size === 0) this.everReady = false; } /** Put a worker back on the idle stack and note when it went idle. */ @@ -275,8 +277,8 @@ export class QueryPool { * hitting that window was the recurring #662 test flake (and a real * first-call stall for agents). The pool exists for CONCURRENT load, which * by definition arrives after warm-up; the pre-pool in-process path is - * strictly better while nothing is warm. Stays true for the pool's - * lifetime — later crash-respawn gaps are covered by retry + backstop. + * strictly better while nothing is warm. Stays true until idle retirement + * empties the pool — crash-respawn gaps are covered by retry + backstop. */ get ready(): boolean { return this.everReady && !this.destroyed;