Skip to content

ADFA-5231: Refuse edits computed against a joined stale pin - #1746

Open
itsaky-adfa wants to merge 4 commits into
ADFA-5231-migrate-call-sitesfrom
ADFA-5231-stale-pin-refusals
Open

ADFA-5231: Refuse edits computed against a joined stale pin#1746
itsaky-adfa wants to merge 4 commits into
ADFA-5231-migrate-call-sitesfrom
ADFA-5231-stale-pin-refusals

Conversation

@itsaky-adfa

@itsaky-adfa itsaky-adfa commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Stack 4 of 5 for ADFA-5231. Closes the one hole the pin itself opens.

The problem

Because a second request for a pinned path joins the existing pin, it gets that scope's text - which can be older than the buffer. Before the migration every site resolved the current document version, so text and request coordinates were always coherent. Now they can diverge, and the worst case is a silent wrong edit:

A usage search holds a pin at version N. The user types (N+1) and invokes extract-method. ExtractMethodAction stamps documentVersion from the live buffer (N+1) while the planner reads fileText from the joined pin (N). The guard at ExtractMethodAction.kt:136 compares the stamp against the live buffer, passes, and offsets computed against version-N text are applied to the version-N+1 buffer. The check that exists to prevent exactly this cannot see the mismatch.

Completion had a crash variant: originalText from the older pin with an offset from the current request makes the placeholder splice throw IndexOutOfBoundsException.

The fix

Every site whose output is an edit now checks LiveKtFile.isStale and refuses rather than computing against frozen text: both extraction planners, completion, organize-imports, implement-members, add-import, and the null-safety action. Each degrades to its existing "nothing to offer" answer. A refusal is recoverable; a wrong edit to the user's source is not.

Navigation and info sites (GoToDefinition, FindUsages, KotlinSignatureHelp) deliberately keep tolerating being one edit behind - they already document that, and their failure mode is a wrong jump rather than a corrupted file. GoToDefinition's comment claiming the caret offset and PSI come from the same text was false in the join case and is corrected.

Also clamps the completion offset to the pinned text length, closing a narrower pre-existing crash where the request's position was measured against a snapshot that has since moved.

Trade-off you should weigh

Five features now silently do nothing while another scope holds a pin on the same path and the user has typed. That is the right trade against a wrong edit, but the real fix is shortening pin duration rather than degrading the victims - see the follow-ups on the ticket. The refusals are also currently indistinguishable from "nothing to do", which wants one shared "the file changed, try again" message.

Testing

461 tests, 0 failures. StalePinEditRefusalTest covers all six sites plus a control, and each guard is mutation-checked - forcing isStale false fails exactly the covering test. One test bumps the version with genuinely changed content, so it demonstrates the corruption these guards prevent rather than only that they fire.

@claude claude 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.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@github-actions github-actions Bot deleted a comment from atlassian Bot Aug 25, 2026
@itsaky-adfa itsaky-adfa self-assigned this Aug 25, 2026
@itsaky-adfa
itsaky-adfa requested a review from a team August 25, 2026 16:53
@itsaky-adfa itsaky-adfa changed the title ADFA 5231 stale pin refusals ADFA-5231: Refuse edits computed against a joined stale pin Aug 25, 2026
The pin is process-wide, so a request arriving during another feature's
scope joins it and gets that scope's text, however old. The action layer
stamps its version guard from the live buffer, so a joined stale pin passes
the guard and then applies offsets measured against older text to the newer
buffer. Every site whose output is an edit now checks isStale and degrades.

The repro test needed a competing acquisition to reproduce at all: bumping
the document version only updates FileManager, and a second KtFile is
installed by the index's own refresh. Without it both tests passed unpinned.
The variants carry raw PSI offsets and nothing downstream re-checks them
against the document, so a joined stale pin inserted !!/? at the wrong
offset. Its body moves into an internal computeNullSafetyVariants taking
AbstractCompilationEnvironment, mirroring the three sibling actions, so the
guard is reachable from a test.

The completion offset is clamped to the pinned text's length: the staleness
guard compares against the current document version, not the version
params.position was measured against, and CompletionParams carries none.
@itsaky-adfa
itsaky-adfa force-pushed the ADFA-5231-stale-pin-refusals branch from 8154d68 to 09d7a86 Compare August 26, 2026 15:33
The stale-pin refusal this replaces returned before analyzingVariant, so an
INTERACTIVE request never reached the scheduler and stopped preempting the older
completion whose pin it joined - leaving that older one to publish items for a
caret the user had already moved past.
The pre-acquisition check only covers a pin that was already stale on
acquisition. The wider window is the computation itself: nothing between these
sites and performCodeAction re-checks the offsets the edits were measured
against.
@itsaky-adfa
itsaky-adfa requested a review from jatezzz August 26, 2026 19:28
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.

2 participants