fix(lsp): read the memory limit from this process's cgroup, not the hierarchy root (SCA-4623) - #64
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5415984c7f
ℹ️ 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".
Codex review on #64 (P2, agent/lsp/manager.py:173). `_cgroup_mounts` used `setdefault`, so the first cgroup2 (or v1-memory) entry in mountinfo won permanently. A namespace can expose several. When the first is a bind mount rooted outside this process's cgroup, `_cgroup_dir` could not map the process path onto it, fell back to `rel = "/"`, and read *that unrelated mount's* root. Unlimited there, so resolution returned None and `host_memory_bytes()` sized the cap from node RAM again — the exact failure this PR exists to fix, reachable through a different door. The bug was the fallback, not the selection. A mount that cannot see our path is not a weaker answer about our limit, it is an answer about a different cgroup, so `_cgroup_dir` now returns None to say so and the caller keeps every candidate that maps. Only when nothing maps is a mount root read, and that is exactly the pre-existing fixed-path answer. Positive control (assert None == 4 GiB before the fix): test_a_bind_mount_listed_first_does_not_shadow_the_real_hierarchy Two cgroup2 mounts, the bind mount listed first, the process's 4 GiB limit reachable only through the second. Also handles `cgroup_path == mount_root` exactly, which previously fell into the startswith miss and resolved to the mount point by accident rather than by intent. tests/agent/lsp/test_client_cap.py: 38 passed. ruff clean; ty clean on manager.py (the 4 test-file diagnostics are pre-existing FakeClient duck-typing).
…(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).
Merge-order warning for this stack (verified locally, not speculation)This is now a 3-deep stack — #63 → #64 → #65 — and all three show Scenario A — merge commit for each, in order: Scenario B — squash #63 first, then merge #64: The conflict is not a content disagreement. The simulated squash produces a tree byte-identical to #63's: It conflicts purely because squashing rewrites #63's four commits into one new SHA, so they are no longer ancestors of This is worth flagging because the repo allows all three merge methods and recent history uses a mix — #59, #57, #55, #53 landed as merge commits; #60, #56, #49, #48 landed with a single parent (squash or rebase). Either is normal here, so the choice is not obvious at merge time. Recommended: merge-commit each, in order #63 → #64 → #65. Verified clean above. If you prefer squash/rebase: it still works, but each child must be rebased onto No action needed from me either way. Flagging it now rather than letting it surface as a surprise mid-merge. |
Update:
|
…ierarchy root (SCA-4623) Replayed onto current main. This branch was stacked on PRs that landed as squashes, so its original history conflicted with itself; only this PR's own delta is kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5b58a6e to
5cc453f
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>
Fixes SCA-4623. Stacked on #63 —
host_memory_bytes()only exists on that branch, so this targetsfix/sca-4389-lsp-client-cap, notmain.The defect
_cgroup_memory_limit_bytesconsulted two fixed paths:Both are hierarchy roots. Under a systemd unit with
MemoryMax=, or in a container without a private cgroup namespace, the limit that binds the process lives under the path named in/proc/self/cgroup; the root readsmax(v2) or the LONG_MAX sentinel (v1). Both are skipped, the function returnsNone, andhost_memory_bytes()falls through toSC_PHYS_PAGES— the node's RAM.On a 64 GiB node inside a 4 GiB unit that derives a cap of 12 against a budget that affords 1, so the cap permits the OOM kill inside the child cgroup that the cgroup branch was added to prevent.
Loose bound, not a regression: it degrades to the pre-cap sizing rather than breaking anything that worked. That is why it was P2 and did not block #63.
The fix
/proc/self/cgroupand the mount from/proc/self/mountinfo, then read the limit file under that path.rootprefix: a mount can expose a subtree, so/system.slice/hermes.serviceunder a mount of/system.sliceis on disk at<mount_point>/hermes.service.max,<= 0,>= 1 << 50) moved verbatim into_parse_memory_limit; the fixed paths remain the fallback for hosts with no/proc(macOS, Windows)._UNRESOLVEDkeeps "/proccould not answer" distinct from "/procsays unlimited", so an unconstrained host does not fall back to re-reading the roots and a constrained one is not second-guessed.Anti-criterion
LSP_MEMORY_BUDGET_FRACTION,LSP_CLIENT_FOOTPRINT_BYTESand the[MIN_CLIENT_CAP, MAX_CLIENT_CAP]clamp are untouched — verified by grepping the diff for every one of them. This is limit discovery only.Why the existing test could not catch it
test_cgroup_limit_wins_over_node_memory_when_smallermonkeypatchesCGROUP_MEMORY_LIMIT_PATHSto a temp file. It proves the parsing and themin(), and cannot observe path resolution — the deployment behaviour is exactly what it stubs out.The new tests build a fabricated
/proc+ cgroupfs under a newCGROUP_FS_ROOTknob, so resolution itself is the thing under test. The two existing tests gained ano_proc()call; without it they would read the real/procon Linux CI and stop being deterministic.Positive control
The new tests were confirmed failing against the current fixed-path behaviour reproduced under the fabricated root (a scratch patch adding only
CGROUP_FS_ROOT, resolution unchanged):Verification
pytest tests/agent/lsp/test_client_cap.py— 37 passed (8 new)./proc): resolution reports_UNRESOLVED, falls back to the fixed paths,host_memory_bytes()= 16 GiB,default_max_clients()= 3 — the documented incident-host answer, unchanged.ruff 0.15.10clean on both files.ty 0.0.21reports the same 4 pre-existingFakeClientdiagnostics before and after the change; zero new.tests/agent/lspreproduce identically on the untouched base commit (conftest.py:1091RuntimeError, missing server binaries) — pre-existing, unrelated.Provenance
Codex review thread on #63 (P2,
agent/lsp/manager.py:89), re-triaged and confirmed against head58b1a4d70d, which is this branch's base.