Skip to content

fix(lsp): read a monotonic clock for idle bookkeeping, not the wall clock - #62

Merged
pai-scaffolde merged 1 commit into
mainfrom
fix/lsp-reaper-monotonic-clock
Aug 20, 2026
Merged

fix(lsp): read a monotonic clock for idle bookkeeping, not the wall clock#62
pai-scaffolde merged 1 commit into
mainfrom
fix/lsp-reaper-monotonic-clock

Conversation

@pai-scaffolde

Copy link
Copy Markdown
Collaborator

The idle reaper is on the wall clock, and the wall clock steps

LSPService's idle bookkeeping compared _last_used against time.time():

manager.py:559,610,632   self._last_used[key] = time.time()
manager.py:649           cutoff = time.time() - self._idle_timeout

Those values are only ever compared to each other. What they need is elapsed time, which the wall clock does not provide — NTP correction and sleep/wake both step it, and this fork runs on laptops and a Mac Mini that sleep.

  • Backwards step > idle_timeout: every cutoff is dragged into the past, no client ever looks idle, and the reaper silently stops reaping — reviving the unbounded language-server accumulation the reaper exists to close.
  • Forwards step: the opposite — servers still in active use get evicted.

Neither failure surfaces anywhere. The reaper keeps sweeping and keeps logging; it just stops finding anything.

The change

Routes the four idle-bookkeeping sites through a single _idle_clock() returning time.monotonic(), so the clock source is declared once, documented once, and greppable.

The change is closed: _last_used is internal, and nothing outside the reaper reads it, serializes it, or reports it as an absolute time (grep -rn _last_used --include='*.py'manager.py plus one test doing purely relative arithmetic). Swapping the clock changes no observable value.

+87 / −4 across 2 files. Deliberately narrow: no config surface, no docs churn, no population cap. ruff format is not clean on either file on main either and CI does not run it, so no reformatting is included.

Verification

Positive control first, because a regression test that has never been seen red is indistinguishable from one that cannot go red. The new test steps the wall clock an hour into the past mid-flight while leaving monotonic() untouched:

tree test_reaper_is_immune_to_wall_clock_steps
origin/main's manager.py (unfixed) FAILS — idle client survives the sweep, the exact stalled-reaper symptom
this branch PASSES
# unfixed
E  AssertionError: a backwards wall-clock step stalled the reaper —
   idle bookkeeping must read a monotonic clock
   assert ('pyright', '.../repo') not in {('pyright', '.../repo'): <LSPClient>}
   1 failed in 0.10s

Rest:

Provenance and relationship to the open SCA-4389 PRs

time.monotonic() here is salvaged from #50, where it was hardening earned in a Codex review round. #50/#51/#52 predate upstream's reaper landing on main (d7578018c5 + 24a56f027c, 2026-07-29) and now conflict with it in manager.py/eventlog.py; #58 is cap-only and does not touch the clock. So this fix is orthogonal to whatever is decided about those four, and is the one piece of that work that measurement did not retire.

Upstreamable as-is — it is a fix to upstream-authored code with no fork-local dependency.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1cc084db0

ℹ️ 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".

Comment thread agent/lsp/manager.py
pai-scaffolde pushed a commit that referenced this pull request Aug 10, 2026
…(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).
@pai-scaffolde
pai-scaffolde changed the base branch from main to fix/sca-4628-evict-outside-budget August 10, 2026 19:27
pai-scaffolde pushed a commit that referenced this pull request Aug 10, 2026
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.
pai-scaffolde pushed a commit that referenced this pull request Aug 10, 2026
Addresses the open Codex P2 on #62.  The monotonic clock this PR
introduced fixes wall-clock stepping but has the mirror-image bug:
CLOCK_MONOTONIC stops while the machine is suspended, so a laptop that
sleeps longer than idle_timeout wakes with every _last_used stamp still
inside the window.  Each sleep/wake cycle then leaks another generation
of language servers -- strictly worse than the wall clock, which at
least aged them, and a revival of the exact accumulation the reaper
exists to close.

_idle_clock() now reads CLOCK_BOOTTIME, which is monotonic *and* counts
suspended time, falling back to monotonic() on platforms that lack it
(macOS, some BSDs).  Resolution stays per-call so the module's `time`
reference remains patchable by the clock tests.

The existing test modelled suspend as a *backward* wall-clock step,
which is the NTP case, not the Linux suspend case -- the two have
opposite shapes.  That test is kept (renamed in intent, still covering
NTP) and _SuspendedClock is added to model what the kernel actually
does: wall clock and BOOTTIME advance, monotonic frozen.

Positive control: test_reaper_is_immune_to_suspend and
test_idle_clock_counts_suspended_time both fail against the
monotonic-only resolver (client survives an hour of suspend) and pass
against this one.  Fallback path covered for no-BOOTTIME platforms.

Tests: tests/agent/lsp/ 118 passed.
@pai-scaffolde

Copy link
Copy Markdown
Collaborator Author

Merge-order composition verified for the LSP stack

All of #62#67 report mergeStateStatus: CLEAN and six of them edit agent/lsp/manager.py. CLEAN is computed per-PR against main and is blind to sibling collisions, so I composed the set instead of trusting it.

Ancestry (git merge-base --is-ancestor, origin/main 54d19ecded)

main → #63 → #64 → #65 → #66 → ┬→ #62  (+16 vs main)
                               └→ #67  (+15 vs main)
#61 — independent (+3), no file overlap

This PR (#62) subsumes #63, #64, #65 and #66. Merging it lands SCA-4620, SCA-4623, SCA-4627, SCA-4628 and SCA-4639 in one go, and those four PRs close as merged.

#62 vs #67 — the one real sibling pair, and it composes

I expected these to collide (both edit manager.py and test_client_cap_e2e.py). They don't:

  • git merge-tree --write-tree → clean, exit 0, no conflicting paths.
  • Real compose on a scratch worktree (6d3296d838): Auto-merging agent/lsp/manager.py, Auto-merging tests/agent/lsp/test_client_cap_e2e.py, exit 0.
  • Clean text ≠ correct semantics, so I ran the suite on the composed tree:
    • pytest tests/agent/lsp139 passed (13.13s)
    • + tests/hermes_cli/test_tui_heap_sizing.py148 passed (12.19s)

No restack of #67 needed.

Please merge-commit, don't squash

main has required_status_checks.strict = true. Squashing this PR rewrites its 16 commits into one new SHA; #66's commits would then be in main by content but not by SHA, and #67 — built on #66 — would need a rebase and would likely conflict in manager.py. A merge commit keeps #67 mergeable.

Suggested order

  1. fix(tests): stop the LSP suite writing into the operator's real agent.log #61 — independent, any time
  2. fix(lsp): read a monotonic clock for idle bookkeeping, not the wall clock #62 (this PR, merge commit) — closes fix(lsp): bound concurrent language servers with an LRU cap (SCA-4389) #63, fix(lsp): read the memory limit from this process's cgroup, not the hierarchy root (SCA-4623) #64, fix(cli): size the TUI V8 heap from this process's cgroup, not the hierarchy root (SCA-4627) #65, fix(lsp): keep a wedged eviction off the caller's diagnostics budget (SCA-4628) #66
  3. fix(lsp): charge each language server its own measured footprint (SCA-4688) #67 — update branch, then merge (SCA-4688)

3 merges for 7 PRs. A GraphQL sweep for unresolved review threads across #61#72 returns empty on every PR.

Posted by the Engineer agent. Merge authority remains with the operator.

pai-scaffolde pushed a commit that referenced this pull request Aug 14, 2026
…or-byte

Both #62 and #76 add contributors/emails/engineer@scaffolde.ai as a NEW file
with different bytes (#62 carries a provenance comment, #76 did not). That is
an add/add conflict: the two PRs merge clean against main individually and
collide with each other, which GitHub's per-PR mergeStateStatus cannot see.
Detected by scripts/pr_merge_order.py (SCA-4638).

Aligning #76 to #62's exact bytes makes the add/add resolve trivially in
either merge order. The mapped login is identical (pai-scaffolde) either way,
so attribution is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pai-scaffolde
pai-scaffolde changed the base branch from fix/sca-4628-evict-outside-budget to main August 20, 2026 01:58
…lock

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>
@pai-scaffolde
pai-scaffolde force-pushed the fix/lsp-reaper-monotonic-clock branch from 6624173 to dccbe5b Compare August 20, 2026 03:43
@pai-scaffolde
pai-scaffolde merged commit 294da13 into main Aug 20, 2026
37 checks passed
@pai-scaffolde
pai-scaffolde deleted the fix/lsp-reaper-monotonic-clock branch August 20, 2026 03:48
pai-scaffolde added a commit that referenced this pull request Aug 20, 2026
get_status() computed each client's age with time.time() while
_last_used is stamped by _idle_clock() (CLOCK_BOOTTIME since #62), so
every reported idle_seconds was the gap between the two epochs (~1.79e9)
rather than an elapsed time. The comment directly above the line already
required both to read the same clock; this makes the code match it.

Surfaced by this PR's own guard test once #62 landed on main:
test_status_idle_seconds_reads_the_same_clock_as_last_used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pai-scaffolde added a commit that referenced this pull request Aug 20, 2026
…rce (SCA-4721) (#76)

* fix(lsp): guard a non-finite idle_timeout and report the bounds in force (SCA-4721)

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>

* fix(lsp): read idle_seconds from the idle clock, not the wall clock

get_status() computed each client's age with time.time() while
_last_used is stamped by _idle_clock() (CLOCK_BOOTTIME since #62), so
every reported idle_seconds was the gap between the two epochs (~1.79e9)
rather than an elapsed time. The comment directly above the line already
required both to read the same clock; this makes the code match it.

Surfaced by this PR's own guard test once #62 landed on main:
test_status_idle_seconds_reads_the_same_clock_as_last_used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant