Skip to content

fix(roots): no attach after close; coalesce roots/list_changed (#514) - #534

Merged
atlas-from-plumb merged 7 commits into
mainfrom
atlas/fix-514-roots-handler
Oct 1, 2026
Merged

atlas-from-plumb merged 7 commits into
mainfrom
atlas/fix-514-roots-handler

Conversation

@atlas-from-plumb

Copy link
Copy Markdown
Collaborator

Why

handleRootsListChanged (live since #511) had two robustness gaps (#514):

  1. Attach after close. The handler checked s.ctx.Err() and then attached or re-pinned. close() cancels s.ctx and then releases the language-server ref, the quality runner and the write budget in a mutate. If close() landed between the handler's check and the attach's own mutate, the attach committed after the release. The LS reference leaked, so the pooled server never idled out. OnInit's attach ladder, the enable-lsp primary refresh and the write-budget bind that follows every attach had the same window.
  2. No coalescing. The mcp server runs each notification in its own goroutine. Each did its own roots/list round trip, and whichever took the mutation lock last won. A slow answer to an early notification could therefore replace a newer one.

Change

  • mutateLive (internal/cli/conn_lane.go) is mutate that runs its closure only while the connection is open. The check sits inside the lane close() uses, so it cannot miss a close: a closure that sees an open connection commits before close's release runs, and one that runs after sees the cancel. These paths now use it:
    • attachWorkspacePinFrom and attachSynthetic (roots, OnInit, hint, tool-seed attaches)
    • attachOrRepinTo, which returns errConnClosed; onRootsChanged treats that as a quiet no-op
    • bindRefreshedPrimary
    • bindWriteLimiterParent: the budget acquire also moved into the lane next to the key publish. Before, close() could read and release the key between the publish and the acquire. sharedBudgets.mu is a leaf lock.
  • Coalescing (internal/cli/conn_roots.go, rootsCoalescer). At most one run is in flight per connection. A notification that arrives during it records the newest request function and marks the run stale. The owner re-fetches once when it finishes, so the last fetch starts after the last notification. A burst costs at most two roots/list calls. Ownership is released in the same critical section that finds nothing outstanding, so no notification is lost. A panicking run (recovered by safeRun) releases the loop too.
  • Unchanged: the handler still waits for OnInit's attach ladder, an explicit session_start pin still outranks roots, and a reordered root list still does not move the pin (onRootsChanged is untouched apart from the closed-error branch).

Tests

New file internal/cli/conn_roots_race_test.go. Close-at-the-lane cases use the beforeLiveMutate seam, which calls close() at the exact point an attach has decided to commit but has not taken the lock. Each case has a positive control or a fired-probe assertion.

  • TestRootsAttach_ / TestRootsRepin_ / TestOnInitAttach_ / TestRefreshPrimary_CloseAtTheLaneTakesNoReference: pool refcounts stay 0 after close.
  • TestAttachSynthetic_CloseAtTheLaneCommitsNothing
  • TestBindWriteLimiterParent_NoBudgetAfterClose (control, bind after close, close at the lane)
  • TestBindWriteLimiterParent_AcquiresUnderTheLane: a callback probe in sharedBudgets.acquire TryLocks the lane.
  • TestHandleRootsListChanged_FirstAnswerArrivesLast: the first roots/list blocks and answers stale, the second notification arrives meanwhile, and the final pin is the newest answer with exactly 2 calls.
  • TestHandleRootsListChanged_BurstCostsAtMostTwoFetches: 8 notifications during one in-flight fetch make 2 roots/list calls.
  • TestHandleRootsListChanged_PanickingRunReleasesTheLoop, TestRootsCoalescer_Protocol

All tests are channel- or probe-driven, with no sleeps.

Evidence:

  • On origin/main, the tests that compile there fail as intended: the budget leaks after close (1 entry, want 0), the stale first answer wins, and 8 notifications make 8 roots/list calls.
  • Every other guard was mutation-checked on this branch, and each mutant is killed by the test that targets it:
    • removing the in-lane check
    • moving the check before the lane
    • reverting each call site to plain mutate
    • acquiring the budget outside the lane
    • four coalescer mutants
    • dropping the panic release
  • go test -race -count=20 passes on the new tests.
  • go test ./... -count=1 passes, as do go test -race ./internal/cli/ -run 'Roots|OnInit' -count=5 and go test -tags=integration ./internal/cli/ -count=1.
  • The existing tests in conn_roots_wire_test.go, conn_rootsrotation_test.go and repin_test.go pass unchanged.

Closes #514.

🤖 Generated with Claude Code

golimpio pushed a commit that referenced this pull request Sep 30, 2026
…rent in lane

- trackProjectWatch acquires the project-config watcher before publishing
  it and publishes under mutateLive, dropping its own reference when close()
  won the lane or a concurrent track already holds the root. The old
  publish-then-acquire order leaked a watcher reference when close() landed
  in between. The acquire stays outside any lane (it waits on filesystem
  setup).
- bindWriteLimiterParent re-parents the write limiter inside the lane, so
  concurrent binds cannot leave it on a budget the other has released.
- A lane-probe context pins that mutateLive consults the connection only
  with the lane held; the close-at-the-lane tests could not tell a check
  placed between the seam and the lock.
- The sharedBudgets concurrency note states the leaf-lock rule, and the
  coalescing wording says what a burst actually costs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
atlas-from-plumb and others added 4 commits October 1, 2026 07:25
A close() that landed between an attach's closed-check and its mutation
let the attach commit after close() had released the connection's
resources, leaking a language-server reference (and a quality runner or
write budget). The attach, re-pin, synthetic-attach, enable-lsp refresh
and write-budget bind now go through mutateLive, which checks the
connection context inside the mutation lane close() itself uses, and
abort without taking anything once it is cancelled.

roots/list_changed notifications are now coalesced per connection: one
fetch in flight, and notifications arriving during it fold into exactly
one re-fetch after it, so the pin follows the newest roots/list answer
and a burst costs at most two round trips.

Closes #514.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rent in lane

- trackProjectWatch acquires the project-config watcher before publishing
  it and publishes under mutateLive, dropping its own reference when close()
  won the lane or a concurrent track already holds the root. The old
  publish-then-acquire order leaked a watcher reference when close() landed
  in between. The acquire stays outside any lane (it waits on filesystem
  setup).
- bindWriteLimiterParent re-parents the write limiter inside the lane, so
  concurrent binds cannot leave it on a budget the other has released.
- A lane-probe context pins that mutateLive consults the connection only
  with the lane held; the close-at-the-lane tests could not tell a check
  placed between the seam and the lock.
- The sharedBudgets concurrency note states the leaf-lock rule, and the
  coalescing wording says what a burst actually costs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The concurrent-binds stress test never caught re-parenting after the
release (0 of 2000 iterations on that mutant). It is replaced by a release
probe that charges one write when the old budget is released and asserts
it landed on the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two binds race to different keys each round; one write afterwards must
charge the bound key's budget. Re-parenting after the lane but before the
old budget's release passes the release probe yet fails this test (5-16
bad rounds per 20000 across 8 runs, with and without -race). The CHANGELOG
entry now names the project-config watcher and its wrapping is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@golimpio
golimpio force-pushed the atlas/fix-514-roots-handler branch from 0a7e564 to ab8fc7c Compare September 30, 2026 21:39

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Taken over to merge at the owner's request. Independent review found no blocking findings; every fix is mutation-proven, and a seam-free stress test reproduces the leak without the fix. The conflict with #533 in conn_repin.go was resolved by keeping #533's prevRoot return and #534's mutateLive closed-connection check. The full cli suite, race stress and lint pass.

@atlas-from-plumb
atlas-from-plumb merged commit e4c2bd5 into main Oct 1, 2026
17 of 18 checks passed
golimpio pushed a commit that referenced this pull request Oct 1, 2026
Merging main (#534) into this branch took internal/cli/conn.go to 602
lines and check-size failed on both verify jobs. Condense the configRoot
field comment; the meaning is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: teal-dingo
golimpio pushed a commit that referenced this pull request Oct 1, 2026
Brings in #534 (roots handler lane) and #557 (shared-daemon friction).
No conflicts. A closed connection now fails attachOrRepinTo with
errConnClosed, so a connection-scoped move on one returns before
connScopeCallerRoot runs and materialises no shard.

Plumb-Session: gentle-cobra
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.

roots list_changed handler: attach can commit after close; notifications are not coalesced

2 participants