Skip to content

fix(mcp): a quiet pool keeps one trimmed worker, so the next call stays warm - #3167

Merged
colbymchenry merged 1 commit into
mainfrom
claude/fx-idle-first-call
Nov 1, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/fx-idle-first-call

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

#3126 (unreleased) ends every query worker after five minutes idle, so a burst's memory comes back. That fixed #3027, but it also made the first call after a pause slower than 1.6.2. REG measured 634 to 729 ms on django against 565 to 587.

The pool now keeps the worker that served last and shrinks it in place, instead of ending it and starting a clean one. The first call after idle is now about as fast as any other, and the burst's memory still comes back. It costs about 70 MB more than main when idle, on any index.

First call after 5 real idle minutes (ms, 3 gaps) 1.6.2 main this PR warm on this PR
flask 111 / 79 / 82 180 / 172 / 166 43 / 41 / 43 41
django 886 / 676 / 443 744 / 878 / 660 170 / 171 / 200 214
elasticsearch 956 / 641 / 674 2752 / 2561 / 1419 764 / 234 / 243 256

Root cause

The 500 ms isn't worker startup: the replacement worker is already ready when the call arrives. It isn't compiled code either: reopening the project inside a warm isolate still costs 460 to 660 ms. Giving the worker a compile cache, as the indexing workers have, moved ready by under 40 ms and the first call not at all.

It is what a connection works out once and remembers. Profile of the first call on django:

Part ms Per
Dominant file, a scan of every edge 212 to 234 connection
Name indexes about 85 connection
git probe for a nested repo about 30 handler
tree-sitter's first parse about 50 worker

These can't be handed to a clean worker, because the change stamp they're checked against only means something on the connection that took it.

A call landing as the recycle ran was worse than the gap numbers suggest. With no warm worker it ran on the main thread, cold: 884 to 1,154 ms just after the deadline, the next call just as slow, and the transport held for up to 417 ms in one case.

Fix

  • The pool retires the workers a burst added, as before, and keeps the one that served last.
  • The kept worker closes every project but the last one used, and its connections give up their page cache and file mapping. The next reads fill them again.
  • Compiled code, the remembered answers and tree-sitter all stay. No worker stops, so ready never drops and a call during the trim just waits a few milliseconds.
  • A worker still holding over 256 MB after its trim is replaced as on main, so keeping one can't keep hold of what a large query left behind. A trimmed worker holds about 80 MB, so this hasn't fired on any repo here. In that rare path a call during the replacement's start still runs in process, as it does on main.

The options

On django, one worker, each measured the same way:

Option Working set / private MB First call
Clean worker (main) 106 / 79 732 ms
Clean worker primed with the dominant file and git probe 144 / 80 410 ms
...plus the substring index 182 / 148 335 ms
...plus every remembered answer 213 / 153 319 ms
Clean worker replaying the last query 295 / 258 189 ms
Kept worker, trimmed (this PR) 168 / 175 152 ms

Priming only gets part of the way, and a replay ends up holding as much as a kept worker with a worker restart on top. Keeping one is also simpler: it removes the replacement's start from the normal path.

What the trim contains, and why. Each step was tested alone on elasticsearch, side by side through the same real gaps:

Arm First call (3 gaps) Idle ws / private
1.6.2 986 / 661 / 908 393 / 294
No trim 210 / 172 / 167 429 / 325
File mapping only 200 / 254 / 185 274 / 317
Page cache only 641 / 208 / 211 353 / 246
Both (this PR) 654 / 222 / 222 205 / 250
  • The mapping step costs nothing measurable and gives back about 155 MB of working set.
  • The page cache step gives back about 80 MB of private memory, which is the 64 MB per connection The query worker pool grows on a burst and never shrinks; each worker opens every project #3027 names. Its cost appears on the first gap only, in the arms that release it, which fits those pages being read back once. I didn't observe the disk reads directly. It stays under 1.6.2 in the paired run, so I kept it. Dropping it would give an even first call on large indexes for about 70 MB more when idle, if you'd rather have that.
  • There's deliberately no forced garbage collection. I started with a last-resort one, then found V8 shrinks an idle thread's heap by itself well before five minutes. It added 3 MB at that point, so it came out and worker-teardown.ts is untouched.

Validation

All on Windows, through the real MCP server, under 1.6.2's node.exe and flags with REG's method.

Memory after a burst of four workers and five idle minutes (working set / private MB):

Repo Peak 1.6.2 idle main idle this PR idle First call: main → this PR
excalidraw 812 / 832 505 / 482 177 / 221 244 / 295 382 → 167 ms
django 1093 / 902 695 / 486 179 / 223 247 / 295 804 → 193 ms
elasticsearch 2266 / 1756 not run 221 / 265 298 / 348 2922 → 1771 ms

The elasticsearch row is one sample on a busy machine (main's second call there took 1,532 ms). The three-gap table above is the better read.

Worst case: a call at the deadline, django: 142 to 226 ms at every offset from -300 ms to +3 s, the next call warm, the transport never held over 19 ms.

Stability:

  • 240 processes, each with four real workers, shut down after, during and right behind a trim: all exited 0. That's 960 worker exits with no crash, though this box has never reproduced that crash, so it shows the path works rather than that the crash can't happen.
  • 30 busy-then-quiet cycles, 600 mixed calls: no errors, and idle memory goes up and down without creeping.
  • The real shared daemon behaves the same: 155 ms at the deadline.
  • __tests__/query-pool.test.ts 5 times on this commit: 43 passed each time.

Tests:

  • Red on main: 13 new tests fail, none of them timing-dependent: they count worker starts and follow which worker answers.
  • 12 mutations undoing each new line are all caught.
  • fix(mcp): idle query workers are recycled, so a burst's memory is given back #3126's six recycle tests still run, because the fake worker now reports itself too heavy to keep by default, so they exercise the replacement path. The test bodies are unchanged, apart from one option on the real-worker one.

Full suite through the gate, with the kernel, daemon enabled, 4 workers: 854 files, 10,565 passed, 0 failed, 87 skipped, 2 expected failures. No reruns.

Not covered

  • macOS and Linux. PRAGMA mmap_size and shrink_memory are plain SQLite, but none of these numbers were taken there.
  • Sessions longer than the 30 cycles above. The 256 MB check is the backstop.

Refs #3027. Follows #3126.

🤖 Generated with Claude Code

…ys warm

PR #3126 ends every query worker after five minutes idle so a burst's memory
comes back, which fixed issue #3027. It also made the first call after a pause
slower than 1.6.2: 634 to 729 ms on django against 565 to 587, where warm is 165.

Root cause. The replacement worker is already up when that call arrives, so the
500 ms is not worker startup. Reopening the project inside a warm isolate costs
nearly the same, so it isn't compiled code either, and a compile cache for the
worker moved nothing. It is what a connection works out once and remembers: the
dominant file's scan of every edge (230 ms on django), the name indexes (85 ms)
and a git probe (30 ms), then tree-sitter's first parse. A new connection has to
work all of that out again, and a worker's memos can't be handed over, because
their change stamp only means something on the connection that took it.

A call landing as the recycle ran fared worse. With no warm worker it ran on the
main thread, cold: 884 to 1,154 ms, the next call just as slow, and the transport
held for up to 417 ms.

Fix. The pool keeps the worker that served last and retires the rest. The kept
worker closes every project but the last one used, and its connections give up
their page cache and file mapping, which the next reads fill again. Everything
else stays: compiled code, the memos, tree-sitter. No worker stops, so there is
no moment when a call finds none, and a call that lands during the trim waits a
few milliseconds. A worker still holding over 256 MB afterwards is replaced the
old way, so keeping one can't keep hold of what a large query left behind. A
trimmed worker reports about 80 MB, so this has not fired on any repo here.

There is no forced collection: V8 already shrinks an idle isolate's heap by
itself, well before the five minutes are up. A last-resort collection added
3 MB at that point, so it came out.

Options measured on django, one worker (working set / private MB, then the
first call):
- a clean worker, as on main: 106/79, 732 ms
- a clean worker made to compute the dominant file and git probe first: 144/80, 410 ms
- the same plus every memo: 213/153, 319 ms
- a clean worker replaying the last query: 295/258, 189 ms
- a kept worker, trimmed (this): 168/175, 152 ms

Validation, on Windows, through the real MCP server under 1.6.2's node and flags:
- First call after five real idle minutes, three gaps:
  django: 1.6.2 886/676/443, main 744/878/660, this 170/171/200 (warm 214)
  flask: 1.6.2 111/79/82, main 180/172/166, this 43/41/43 (warm 41)
  elasticsearch: 1.6.2 956/641/674, main 2752/2561/1419, this 764/234/243 (warm 256)
  The first gap's extra 500 ms shows only on arms that release the page cache, and
  is gone by the second, which fits its pages being read back once.
- A call at the deadline on django: 142 to 226 ms at every offset from -300 ms to
  +3 s, the next call warm, the transport never held over 19 ms.
- Memory after a burst of four workers and five idle minutes (ws/priv):
  excalidraw: 1.6.2 505/482, main 177/221, this 244/295
  django: 1.6.2 695/486, main 179/223, this 247/295
  elasticsearch: main 221/265, this 298/348
  So about 70 MB more than main, on any index, in exchange for the first call.
- 30 busy-then-quiet cycles, 600 mixed calls, no errors and no creep.
- 240 processes, each with four real workers, shut down after, during and right
  behind a trim: all exited 0. 960 worker exits, no crash.
- The real shared daemon behaves the same: 155 ms at the deadline.
- Red on main: 13 new tests fail, counting worker starts rather than time. Twelve
  mutations undoing each new line are all caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 3ea6d23 into main Nov 1, 2026
@colbymchenry
colbymchenry deleted the claude/fx-idle-first-call branch November 1, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The query worker pool grows on a burst and never shrinks; each worker opens every project

1 participant