Repository navigation
x/tools/gopls: codeAction/resolve after a document change computes the edit at the old position #81812
Description
gopls version
golang.org/x/tools/gopls v0.23.0 (h1:Dn6mf9WXu9iLnTftDDMb9wV0c6Se7PjzEMqP0LEe08Y=), built with go1.27.1.
v0.23.0 is still the newest gopls release (checked 2026-09-28); reproduces on it again in 3 of 3 runs.
Same result with gopls v0.20.0.
go env
GOARCH=arm64
GOOS=darwin
GOVERSION=go1.27.1
GOFLAGS=-modcacherw
GOTOOLCHAIN=local(macOS 27 on Apple silicon; path variables left out.)
What did you do?
I drive gopls over LSP from a small script. The client declares codeAction.resolveSupport for edit and dataSupport, so gopls returns actions without edits and fills them in on codeAction/resolve.
nektos/act at 4f411281417e88660bea1c1a1749aa71ae0bd60f, file pkg/container/linux_container_environment_extensions_test.go, lines 34 and 35 (1-based):
{"/mnt/c/windows/to/unix", "windows\\to\\unix", fmt.Sprintf("%s\\", rootDrive)},
{fmt.Sprintf("/mnt/%v/act", rootDriveLetter), "act", fmt.Sprintf("%s\\", rootDrive)},Positions below are 0-based LSP positions.
didOpenthe file (version 1).textDocument/codeActionat{line: 34, character: 38}(insiderootDriveLetter). The result includes "Split arguments into separate lines", withdataholding the commandgopls.apply_fix, the URI and the range 34:38. Resolving it at this point gives the expected edit (the arguments offmt.Sprintfon that line).didChange(version 2): insert"\n"at{line: 0, character: 0}. The row withfmt.Sprintf("/mnt/%v/act", ...)is now LSP line 35.codeAction/resolvewith the action object from step 2.
What did you see happen?
The resolve returns an edit for version 2 whose four ranges are all on LSP line 34, the row above the one the action was requested on. Applied, it splits the elements of that other composite literal:
{
"/mnt/c/windows/to/unix",
"windows\\to\\unix",
fmt.Sprintf("%s\\", rootDrive),
},
{fmt.Sprintf("/mnt/%v/act", rootDriveLetter), "act", fmt.Sprintf("%s\\", rootDrive)},It looks like the stored range 34:38 was applied to the new text, where it falls inside "windows\\to\\unix" on the row above. The action's data carries only the URI and the range, no version, and the returned documentChanges entry is labelled version 2, so a client that checks versions applies it. The result is valid Go, so nothing flags it afterwards. Same in 3 of 3 runs.
What did you expect to see?
An error or no edit, because the document changed after the action was computed; or the edit for the call the action was requested on. A new textDocument/codeAction at the new position {line: 35, character: 38}, resolved, gives:
{fmt.Sprintf(
"/mnt/%v/act",
rootDriveLetter,
), "act", fmt.Sprintf("%s\\", rootDrive)},Editor and settings
No editor; a script over stdio (gopls serve). Settings returned for workspace/configuration: {"semanticTokens": true, "hints": {"assignVariableTypes": true, "compositeLiteralFields": true, "compositeLiteralTypes": true, "constantValues": true, "functionTypeParameters": true, "parameterNames": true, "rangeVariableTypes": true}}.
Logs
LSP trace
URIs shortened to file:///repo/...; F stands for file:///repo/pkg/container/linux_container_environment_extensions_test.go.
--> textDocument/didOpen {"textDocument":{"uri":F,"languageId":"go","version":1,"text":"..."}}
--> textDocument/codeAction {"textDocument":{"uri":F},"range":{"start":{"line":34,"character":38},"end":{"line":34,"character":38}},"context":{"diagnostics":[],"triggerKind":1}}
<-- [..., {"title":"Split arguments into separate lines","kind":"refactor.rewrite.splitLines",
"data":{"title":"Split arguments into separate lines","command":"gopls.apply_fix",
"arguments":[{"Fix":"split_lines","Location":{"uri":F,"range":{"start":{"line":34,"character":38},"end":{"line":34,"character":38}}},"ResolveEdits":true}]}}, ...]
--> codeAction/resolve (that action)
<-- {"title":"Split arguments into separate lines","kind":"refactor.rewrite.splitLines","edit":{"documentChanges":[{"textDocument":{"version":1,"uri":F},"edits":[
{"range":{"start":{"line":34,"character":16},"end":{"line":34,"character":16}},"newText":"\n\t\t\t\t"},
{"range":{"start":{"line":34,"character":46},"end":{"line":34,"character":46}},"newText":",\n\t\t\t"},
{"range":{"start":{"line":34,"character":29},"end":{"line":34,"character":31}},"newText":",\n\t\t\t\t"}]}]}}
--> textDocument/didChange {"textDocument":{"uri":F,"version":2},"contentChanges":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}},"text":"\n"}]}
--> codeAction/resolve (the same action object again)
<-- {"title":"Split arguments into separate lines","kind":"refactor.rewrite.splitLines","edit":{"documentChanges":[{"textDocument":{"version":2,"uri":F},"edits":[
{"range":{"start":{"line":34,"character":4},"end":{"line":34,"character":4}},"newText":"\n\t\t\t\t"},
{"range":{"start":{"line":34,"character":81},"end":{"line":34,"character":81}},"newText":",\n\t\t\t"},
{"range":{"start":{"line":34,"character":28},"end":{"line":34,"character":30}},"newText":",\n\t\t\t\t"},
{"range":{"start":{"line":34,"character":49},"end":{"line":34,"character":51}},"newText":",\n\t\t\t\t"}]}]}}
--> textDocument/codeAction {"textDocument":{"uri":F},"range":{"start":{"line":35,"character":38},"end":{"line":35,"character":38}},"context":{"diagnostics":[],"triggerKind":1}}
--> codeAction/resolve ("Split arguments into separate lines" from that result, range 35:38)
<-- {"title":"Split arguments into separate lines","kind":"refactor.rewrite.splitLines","edit":{"documentChanges":[{"textDocument":{"version":2,"uri":F},"edits":[
{"range":{"start":{"line":35,"character":16},"end":{"line":35,"character":16}},"newText":"\n\t\t\t\t"},
{"range":{"start":{"line":35,"character":46},"end":{"line":35,"character":46}},"newText":",\n\t\t\t"},
{"range":{"start":{"line":35,"character":29},"end":{"line":35,"character":31}},"newText":",\n\t\t\t\t"}]}]}}
Activity
adonovan commentedon Sep 28, 2026
cc: @h9jiang
gabyhelp commentedon Sep 28, 2026
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
h9jiang commentedon Sep 28, 2026
Hi @wangzhihao-lab, thanks for raising this,
To make sure we are on the same page, here is our understanding of what is happening:
Could you confirm the symptom: When the client resolve the code action the first time, gopls is working as intended, compute the proper edits and return it. But the second time (after insert the "\n"), when resolve the same code action (the old line column uri), gopls failed to handle it properly, it find a wrong line number. User's intention was at line X but because of the "\n", the intention moved to X+1, but gopls still interpret the user intention as line X which results in wrong edits.
What you want to see is: either the gopls properly capture the user intention at X+1, or the gopls return error or nil edits because gopls is no longer able to capture the user intention properly.
Following are reasons why this is happening, the most important is, gopls always read the latest snapshot from the file system.
Why
goplscannot reuse the original snapshot:textDocument/codeActionandcodeAction/resolveare two separate LSP requests, and the client controls when (or if)codeAction/resolveis called. BecausecodeAction/resolvemay be called much later—or never called at all—goplscannot hold onto theSnapshotfromtextDocument/codeActionand must release it once the request finishes.Why the wrong edit is computed and labeled as
version: 2:When
textDocument/codeActionreturns a lazy code action (at version 1), it embeds thegopls.apply_fixcommand arguments(
ApplyFixArgs) insideCodeAction.data. Currently,ApplyFixArgsonly records theLocation(URI, line, and column), butnot the document version.
When the file is modified via
textDocument/didChange(to version 2) and the client subsequently sendscodeAction/resolvewith the stale code action from version 1,
goplsfetches the latest snapshot (version 2) and re-evaluates the fix at theold range (
34:38from version 1). Because the edits are computed against the version 2 snapshot,goplsstamps theresulting
TextDocumentEditwith"version": 2, so the client cannot detect that the edit was derived from a stale position.Proposed Fix
We can include the document version in the command data (e.g., in
ApplyFixArgs/CodeAction.data) when constructing the lazy code action duringtextDocument/codeAction. When resolving or executing the action,goplswill check that the current file version matches the version recorded in the action, and reject it if the file has changed in the meantime.wangzhihao-lab commentedon Sep 29, 2026
Thanks for looking into this so quickly. Yes, that is the symptom: the first resolve (at version 1) returns the right edit, and after the insertion at line 0, resolving the same action object returns edits on the old line 34, computed on version 2 and labeled version 2. Either outcome you describe would work for us; rejecting the stale action seems simplest, since the client can request code actions again at the new position.