fix(roots): no attach after close; coalesce roots/list_changed (#514) - #534
Merged
Merged
Conversation
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>
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
force-pushed
the
atlas/fix-514-roots-handler
branch
from
September 30, 2026 21:39
0a7e564 to
ab8fc7c
Compare
# Conflicts: # internal/cli/conn_repin.go
golimpio
approved these changes
Oct 1, 2026
golimpio
left a comment
Contributor
There was a problem hiding this comment.
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
enabled auto-merge
October 1, 2026 00:35
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
handleRootsListChanged(live since #511) had two robustness gaps (#514):s.ctx.Err()and then attached or re-pinned.close()cancelss.ctxand then releases the language-server ref, the quality runner and the write budget in amutate. Ifclose()landed between the handler's check and the attach's ownmutate, the attach committed after the release. The LS reference leaked, so the pooled server never idled out. OnInit's attach ladder, theenable-lspprimary refresh and the write-budget bind that follows every attach had the same window.roots/listround 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) ismutatethat runs its closure only while the connection is open. The check sits inside the laneclose()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:attachWorkspacePinFromandattachSynthetic(roots, OnInit, hint, tool-seed attaches)attachOrRepinTo, which returnserrConnClosed;onRootsChangedtreats that as a quiet no-opbindRefreshedPrimarybindWriteLimiterParent: the budgetacquirealso moved into the lane next to the key publish. Before,close()could read and release the key between the publish and the acquire.sharedBudgets.muis a leaf lock.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 tworoots/listcalls. Ownership is released in the same critical section that finds nothing outstanding, so no notification is lost. A panicking run (recovered bysafeRun) releases the loop too.session_startpin still outranks roots, and a reordered root list still does not move the pin (onRootsChangedis untouched apart from the closed-error branch).Tests
New file
internal/cli/conn_roots_race_test.go. Close-at-the-lane cases use thebeforeLiveMutateseam, which callsclose()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_CloseAtTheLaneCommitsNothingTestBindWriteLimiterParent_NoBudgetAfterClose(control, bind after close, close at the lane)TestBindWriteLimiterParent_AcquiresUnderTheLane: a callback probe insharedBudgets.acquireTryLocks the lane.TestHandleRootsListChanged_FirstAnswerArrivesLast: the firstroots/listblocks 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 2roots/listcalls.TestHandleRootsListChanged_PanickingRunReleasesTheLoop,TestRootsCoalescer_ProtocolAll tests are channel- or probe-driven, with no sleeps.
Evidence:
roots/listcalls.mutatego test -race -count=20passes on the new tests.go test ./... -count=1passes, as dogo test -race ./internal/cli/ -run 'Roots|OnInit' -count=5andgo test -tags=integration ./internal/cli/ -count=1.conn_roots_wire_test.go,conn_rootsrotation_test.goandrepin_test.gopass unchanged.Closes #514.
🤖 Generated with Claude Code