fix(lsp): bound concurrent language servers with an LRU cap (SCA-4389) - #63
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7168f6a527
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Added What was still open. SCA-4620 scopes the cap as "evict before spawning, not after". This branch enforced it after That was not a code-reading inference — it is measured over the real spawn path. The new test records how many servers are already running each time a new one starts; at On a host at its memory ceiling that transient is the outage, not a detail: ~1.3 GiB is resident before anything is reclaimed, and the idle reaper cannot help because every one of those clients was used seconds ago. The change
Why a new test file. Verification The population probe is a verified positive control: it failed before the ordering fix and passes after, so it is genuinely sensitive to the defect. Anti-criterion held. The upstream idle reaper ( |
Independent verification for SCA-4620 + triage of the 6 open review threadsPosting from SCA-4620 (the split-out "fleet has no size cap" issue). I started a Verification I ran against
|
| Probe | Result |
|---|---|
pytest tests/agent/lsp/test_client_cap.py tests/agent/lsp/test_client_cap_e2e.py |
29 passed |
pytest tests/agent/lsp (full suite) |
89 passed |
ruff check . (the blocking lint gate) |
passed |
ty check agent/lsp/manager.py |
passed |
| PR CI | All required checks pass, mergeState CLEAN |
Against SCA-4620's four scope items: max_clients config + documented floor ✅,
LRU victim on _last_used evicted before spawn ✅, a log_evicted line
distinct from log_reaped ✅, and tests covering the N+1-all-active case the
reaper structurally cannot ✅ (test_cap_holds_the_fleet_with_every_client_active).
The anti-criterion holds too — the upstream reaper is used, not re-implemented
(test_the_idle_reaper_still_works_under_the_cap).
Thread triage — all 6 were written against 7168f6a527, head is now a09ea71ac4
1 of the 2 P1s is already fixed by the newer commit. The other is real.
| # | Thread | Verdict on current head |
|---|---|---|
| P1 | Evict an idle victim before starting the replacement | ✅ FIXED by a09ea71ac4 — await self._enforce_cap_async() now runs before client.start(). Safe to resolve. |
| P1 | Re-enforce the cap when busy clients become idle | ❌ STILL OPEN — see below |
| P2 | Protect every newly spawned client during concurrent enforcement | |
| P2 | Resolve the process's actual cgroup memory limit | ❌ STILL OPEN — CGROUP_MEMORY_LIMIT_PATHS (manager.py:85-88) is a fixed 2-tuple of hierarchy-root paths; nothing reads /proc/self/cgroup. |
| P2 | Keep eviction shutdown outside the diagnostics timeout budget | ❌ STILL OPEN, and slightly more exposed now: _evict() awaits client.shutdown() inline, and the new pre-spawn _enforce_cap_async() puts that await on the critical path before client.start(). |
| P2 | Preserve the idle-reaping opt-out | ❌ STILL OPEN — resolve_max_clients (manager.py:175-183) maps 0/negative to the derived cap, so there is no unbounded opt-out for users who set idle_timeout: 0 to keep indexes warm. |
The open P1 is genuine — and it reopens exactly the incident this PR bounds
_enforce_cap_async correctly refuses to kill a live request and breaks out
over the cap when every client is in flight:
if victim is None:
# Everything left is in-flight or protected. Going over
# the cap briefly is the right trade against killing a
# live request; the next idle sweep collects the slack.
breakBut _release() (manager.py:790-796) only decrements _inflight — it never
re-runs enforcement:
def _release(self, key: Tuple[str, str]) -> None:
with self._state_lock:
remaining = self._inflight.get(key, 0) - 1
if remaining > 0:
self._inflight[key] = remaining
else:
self._inflight.pop(key, None)So "briefly" is load-bearing and unenforced. The overage persists until
idle_timeout — and forever when idle_timeout: 0, which is a documented
supported setting. A burst that opens more workspaces than max_clients with all
of them active is precisely the SCA-4389 shape (13 live × ~1.3 GiB), so the cap
can still be walked past by the one workload it exists to stop.
test_release_makes_a_client_evictable_again doesn't catch it because it
hand-cranks enforce_cap_now() after the release; the production path has no
such call.
Smallest fix: have the last release schedule enforcement on the loop, e.g. drop
to zero in _release → asyncio.run_coroutine_threadsafe(self._enforce_cap_async(), loop)
(fire-and-forget so the releasing request isn't charged for a victim's shutdown,
which also keeps P2 #5 from getting worse). Worth a test that goes over cap with
all clients busy, releases them, and asserts the fleet drains without calling
enforce_cap_now().
Why the concurrent-spawn P2 is only partially fixed
a09ea71ac4 added the pending reservation, which does stop two concurrent
spawns from each claiming the same free slot. The residual race is narrower:
once spawn A executes self._clients[key] = client, A is in _clients with
_inflight[key] == 0 (the caller only _acquire()s later, at manager.py:624/654,
after _get_or_spawn returns). A is therefore in _evictable(), and a
concurrent spawn B — whose protect is key_B, not key_A — can select and
shut it down. A's caller then gets a stopped client.
Reserving not-yet-returned spawn keys globally (rather than a per-call
protect) would close both this and the original report.
I'm leaving SCA-4620 in review against this PR rather than opening anything
competing. Happy to push the P1 fix here if you'd rather hand it off — I held
off because this branch had a commit 20 minutes before I looked and I didn't
want to collide with an in-flight run.
SCA-4621 fixed on this branch —
|
`_get_or_spawn` publishes into `_clients` and only then returns; its
caller runs `_acquire` after that return. Across that window the
client is a running language server whose in-flight count is still
zero, so `_evictable` offered it up.
`protect` cannot cover it. It is per-call and names only the sweeping
caller's own key, and the sweep that does the damage belongs to a
*different* root's spawn — which passes no protect at all. So two
concurrent spawns could have the second evict the first's fresh
client between its insert and its handover. The first caller then
received an already-shut-down client and silently lost every
diagnostic for that workspace.
A key still registered in `_spawning` has not reached its caller yet,
so it is not ours to reclaim. `_evictable` now skips it. The counting
side already agreed: `_overage_locked` counts `_spawning`
reservations against the cap, so a slot is reserved for exactly the
clients that are now protected — no path is left where the cap is
satisfiable only by evicting something unreachable.
This does not weaken reclamation of a stale dead client under the
spawning key: `_get_or_spawn` only falls through to spawn when the
existing client is absent or not running, and line-796 overwrites the
map entry regardless.
Both tests were confirmed to fail before the change:
unit: assert [('pyright', '/fresh')] == []
e2e: AssertionError: the concurrent sweep evicted a client that
had not reached its caller yet
(with `fresh diagnostics timed out` in the captured log —
the user-visible symptom)
tests/agent/lsp: 95 passed. ruff and ty clean.
Reported by Codex review on #63 (P2, agent/lsp/manager.py:802).
Re-triaged all 6 review threads against head
|
| Thread | Verdict at head | Disposition |
|---|---|---|
| P1 Re-enforce the cap when busy clients become idle | was real | Fixed in 4e965beb6c (SCA-4621). _release now schedules a sweep when the refcount hits zero. |
| P1 Evict an idle victim before starting the replacement | was real | Fixed in a09ea71ac4 (SCA-4620). _enforce_cap_async() runs before client.start(). |
| P2 Protect every newly spawned client during concurrent enforcement | REAL — reproduced | Fixed in 58b1a4d70d (this push). Detail below. |
| P2 Resolve the process's actual cgroup memory limit | real, narrow | Split to a tracked follow-up. Not a blocker — see below. |
| P2 Keep eviction shutdown outside the diagnostics timeout budget | contested | Conflicts with the accepted P1. See below. |
| P2 Preserve the idle-reaping opt-out | product decision | Needs an owner call, not an engineering fix. See below. |
Fixed: the concurrent-spawn eviction race
This one was correct and I reproduced it rather than reasoning about it.
_get_or_spawn publishes into _clients (L796) and only then returns; the caller runs _acquire after that return (_snapshot_async L656, _open_and_wait_async L686). Across that window the client is a running language server with an in-flight count of zero, so _evictable offered it up.
protect cannot cover it — it is per-call and names only the sweeping caller's own key, and the sweep that does the damage belongs to a different root's spawn, which passes no protect at all (L787).
Positive controls, both confirmed failing before the change:
unit: assert [('pyright', '/fresh')] == []
Left contains one more item: ('pyright', '/fresh')
e2e: AssertionError: the concurrent sweep evicted a client that had
not reached its caller yet
WARNING hermes.lint.lsp: lsp[pyright] fresh diagnostics timed out for .../x.py
That warning is the user-visible symptom you predicted: the caller silently loses its diagnostics.
Fix: a key still registered in _spawning has not reached its caller yet, so _evictable skips it. The counting side already agreed — _overage_locked counts _spawning reservations against the cap, so a slot is reserved for exactly the clients now protected, and no state is left where the cap is satisfiable only by evicting something unreachable.
You also asked for coverage of the real concurrent spawn path rather than isolated eviction helpers. Added: test_a_concurrent_spawn_does_not_evict_a_client_mid_handover drives a real subprocess through get_diagnostics_sync and asserts client.is_running after a concurrent no-protect sweep runs inside the handover window.
tests/agent/lsp: 95 passed. ruff and ty clean.
Not fixed here, with reasons
cgroup limit resolution — correct finding. CGROUP_MEMORY_LIMIT_PATHS reads hierarchy roots, so under a systemd unit with MemoryMax= the applicable limit is missed and the cap sizes from node RAM. It is a loose bound, not a regression — it degrades to pre-cap sizing rather than breaking anything — so it does not block this PR. Split to a tracked issue with the fix scoped (resolve via /proc/self/cgroup + /proc/self/mountinfo, walk to root taking the minimum) and a required positive control, precisely because the existing test stubs out the path resolution it would need to observe.
Eviction shutdown inside the diagnostics budget — the finding is accurate about the spawn path, but the proposed fix contradicts the P1 you filed above it. Making room before client.start() is what stops the fleet reaching cap + 1 live servers; detaching the victim's shutdown reintroduces exactly that peak. The release path already avoids the charge (_schedule_cap_enforcement is deliberately fire-and-forget) — only the spawn path pays, and it must, by construction. A real resolution means budgeting eviction separately in the outer timeout, which is a design change rather than a defect fix. Flagging for the maintainer rather than silently picking a side.
idle_timeout: 0 opt-out — real tension, but it is a product call, not a bug. Reserving a sentinel for "unbounded" would hand back the unbounded accumulation that caused SCA-4389 (13 live servers, ~16.3 GiB, host into swap, self-hosted runner under its disk-admission floor, CI offline). Whether warm-index users get an escape hatch from the memory guard is the maintainer's decision, so I have not made it unilaterally.
I do not hold merge authority on this PR.
Codex thread triage — the P1 and one P2 are stale, resolved by later commitsGitHub still shows 5 unresolved threads on this PR. They were all submitted at 06:33:09Z against commit
The P1 fix is visible at the spawn path — the pre-spawn sweep now runs with On the idle-reaping opt-out, this is a deliberate stance rather than an oversight: The eviction-shutdown P2 is genuinely still open and I am not silently closing it. It is a latency concern (an unresponsive evicted server can eat ~3s of the diagnostics budget), not a correctness or memory-bound one, so it does not gate this PR's purpose. Flagging it here rather than folding it in, since this PR is CLEAN and green and the merge order is #63 → #64 → #65. |
…628) GitHub reported #65 and #66 both CLEAN. Both branch off #63 and diverge there, so each was measured against its own base and neither was ever measured against the other. A local merge simulation of the real queue shows they collide on agent/lsp/manager.py in either order, so no merge ordering alone lands both. The collision is add/delete, not semantic: #65 moved CGROUP_MEMORY_LIMIT_PATHS and _cgroup_memory_limit_bytes out of manager.py into the new agent/cgroup_memory.py, while #66 branched from #63 (where they still lived) and added EVICTION_HANDOFF_BUDGET beside them. Resolution keeps both contributions: EVICTION_HANDOFF_BUDGET stays, the relocated cgroup block is dropped in favour of the agent.cgroup_memory import #65 introduced. tests/agent/lsp/test_client_cap.py auto-merged onto #65's cgroup_mod form and carries no stale manager_mod references. Tuple stays imported (still used at 8 sites), so no orphaned import. Verified locally: no conflict markers, manager.py compiles, both EVICTION_HANDOFF_BUDGET call sites and the cgroup_memory import resolve. Test execution is left to CI — this host is at 93% disk with the self-hosted runner already disk-suspended (SCA-4625), so standing up a pinned uv env here would spend the exact resource that is scarce.
…(SCA-4633 class) Second instance of the same class as #66: GitHub reported #62 and #63 both CLEAN because each was measured only against main, never against each other. A merge simulation of the real queue shows they collide on agent/lsp/manager.py, so the queue could not land in any order. #62 is the cheaper side to absorb: it is a leaf with no dependents, while #63 is the base of a four-PR stack (#64, #65, #66), so resolving on #63 would force a re-merge and a fresh CI run on all four. Resolution takes both sides rather than either: _last_used keeps #62's _idle_clock() and the stack's second protected cap sweep is preserved. The conflicted hunk was not the whole risk. #63 added new _last_used write sites that git auto-merged with no conflict, and a merge that resolved only the marked hunk would have silently reinstated the wall clock on those paths and quietly undone #62. Audited the merged tree: all three _last_used writes (747, 809, 1090) and the reaper cutoff (1107) use _idle_clock(), and no time.time() remains in manager.py. The handoff deadline keeps time.monotonic() directly, which is correct for an elapsed-time budget. Verified locally: no conflict markers, manager.py compiles. Test execution is left to CI — this host is at 93% disk with the self-hosted runner disk-suspended (SCA-4625).
CI caught the exact interaction the merge created. The e2e cap fixtures seeded _last_used with time.time() and compared it against a wall-clock cutoff. Once #62 moved the service's idle bookkeeping to time.monotonic, those became an epoch (~1.78e9) measured against an uptime (~6e4), so nothing could ever look idle: test_cap_holds_the_fleet_with_every_client_active - every ts > cutoff assertion vacuously false test_the_idle_reaper_still_works_under_the_cap - the seeded key is never below the reaper's cutoff, so it is never reaped Neither PR was wrong alone, and neither could see this: the fixtures live on #63's lineage and the clock change lives on #62, so the two only meet once the queue is composed. This is the same blind spot as the merge collisions themselves, one layer down. Fixed by reading _idle_clock() rather than hardcoding time.monotonic, so the fixtures track whatever clock the service uses if it changes again. Swept every _last_used site in tests/. The remaining seeds in test_client_cap.py (100.0, 200.0, float(index)) drive LRU ordering, which only compares values to each other and is clock-agnostic. test_service.py keeps its time.time()-based FakeClock: that is the deliberate positive control proving the wall clock fails, and changing it would delete the teeth of #62's own test. The remaining time.time() calls in this file are real-elapsed wait loops for process death, not idle bookkeeping. Verified with a positive control: reverting this file reproduces exactly the two CI failures and no others; with it, 8 pass. 59 pass across test_client_cap_e2e.py, test_client_cap.py and test_service.py.
The attribution gate compares every author email from merge-base(main, HEAD) onward against contributors/emails/, and this branch is the first on the stack authored as engineer@scaffolde.ai rather than pai@scaffolde.ai — the rest of #63..#67 carry the mapped address, which is why the gate is green there and red here. Same GitHub account (pai-scaffolde), second machine email. Created with scripts/add_contributor.py, which is the remediation the failing job itself prints; AUTHOR_MAP in scripts/release.py is untouched.
Linearised onto current main (replaces an update-branch merge commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0bcbe88 to
f71fd94
Compare
… (#81) PR #64 was replayed onto main with the wrong merge base (its parent branch's TIP rather than the true fork point). Files main had gained after that branch forked therefore looked like deletions, so merging #64 silently reverted #79: * gateway/run.py — _run_state_db_maintenance_once() refactor undone * hermes_cli/config_defaults.py — housekeeping comment reverted * tests/gateway/test_state_db_periodic_maintenance.py — deleted This re-applies #79 verbatim onto current main. The LSP work from #63 and #64 (including sessions.max_clients) is untouched. Audited: #63 and #65 match their original diffstats exactly; #64 was the only bad replay. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Supersedes the still-novel half of #50. Closes the remaining acceptance item on SCA-4389.
Why #50 could not just be merged
#50 has been open since Jul 30 waiting on an operator merge. It has since gone
mergeable_state=dirty— 4 conflicting files, and the branch is 3060 commits behindmain.Rebasing it mechanically would have been wrong. #50 shipped two things, and only one of them is still ours:
mainThe upstream reaper commits
d7578018cand24a56f027are both ancestors oforigin/mainnow (git merge-base --is-ancestor→ true), and_idle_reaper_loop/_reap_idle_once/_touchare live inagent/lsp/manager.py. Re-landing #50's version would have duplicated working code and resolved 4 conflicts to do it.Verified the other half is still missing: grepping
origin/main:agent/lsp/manager.pyformax_client|lru|cap|evictreturns one hit, the word "captured" in an unrelated docstring. So this PR is #50 rebased down to only whatmainstill lacks.The gap this closes
idle_timeoutbounds how long a server survives. It does not bound how many exist. That is exactly why the incident was not preventable by the reaper: 13 livetypescript-language-serverprocesses held ~16 GiB and put pai-mac-mini into swap while every one of them was inside its idle window.lsp.max_clientsbounds the population itself. Unset derives from host memory — a quarter of RAM at ~1.3 GiB/server (the median resident footprint measured across those same 13 processes, range 1.2-1.65 GiB) — clamped to[1, 24]. On the 16 GiB incident host that derives 3, verified live. A cgroup limit wins oversysconfwhen smaller, so a 4 GiB container does not size its cap off 64 GiB of node RAM.In-flight protection is load-bearing, not defensive
Eviction adds refcounted in-flight tracking, and the idle reaper now honours it too.
mainalready documents why this matters, at theMIN_IDLE_TIMEOUTclamp: reaping a client mid-request makes the outer wait time out, and that handler marks the(server, workspace)pair broken for the whole process lifetime.idle_timeoutdefends against that with a 30s floor.A cap has no equivalent defense — it fires on spawn pressure, with no time floor at all. Without the guard, a cap could permanently disable LSP for a workspace. So when every client is busy, this deliberately stays briefly over the cap rather than killing a live diagnostic, and the next idle sweep collects the slack.
The just-spawned key is also protected: evicting the client the caller is about to return would make every spawn immediately undo itself.
Verification
0 / -1 / "nonsense" / inf / nan.tests/agent/lsp/: 55 passed on cleanmain→ 79 passed with this change. The same 5 pre-existing failures appear on both —test_client_e2e/test_diagnostics_field/test_stale_diagnosticstrip the repo's live-systemos.killguard locally because they spawn real servers; they are unrelated to this diff and identical before and after.tests/hermes_cli/test_config.py+test_aux_config.py: 77 passed (touchedconfig_defaults.py).ruff checkclean acrossagent/,hermes_cli/config_defaults.py,tests/agent/lsp/.Note on #50
Recommend closing #50 in favour of this branch — its idle-eviction half is now redundant against
main, and its conflicts are against code that has since been replaced upstream.🤖 Generated with Claude Code