Skip to content

x/tools/gopls: codeAction/resolve after a document change computes the edit at the old position #81812

Open

Description

@wangzhihao-lab

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.

  1. didOpen the file (version 1).
  2. textDocument/codeAction at {line: 34, character: 38} (inside rootDriveLetter). The result includes "Split arguments into separate lines", with data holding the command gopls.apply_fix, the URI and the range 34:38. Resolving it at this point gives the expected edit (the arguments of fmt.Sprintf on that line).
  3. didChange (version 2): insert "\n" at {line: 0, character: 0}. The row with fmt.Sprintf("/mnt/%v/act", ...) is now LSP line 35.
  4. codeAction/resolve with 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

  1. added
    ToolsThis label describes issues relating to any tools in the x/tools repository.
    goplsIssues related to the Go language server, gopls.
    on Sep 28, 2026
  2. added this to the Unreleased milestone on Sep 28, 2026
  3. adonovan commented on Sep 28, 2026

    @adonovan
    Member
  4. self-assigned this
    on Sep 28, 2026
  5. h9jiang commented on Sep 28, 2026

    @h9jiang
    Member

    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.

    1. Why gopls cannot reuse the original snapshot:
      textDocument/codeAction and codeAction/resolve are two separate LSP requests, and the client controls when (or if) codeAction/resolve is called. Because codeAction/resolve may be called much later—or never called at all—gopls cannot hold onto the Snapshot from textDocument/codeAction and must release it once the request finishes.

    2. Why the wrong edit is computed and labeled as version: 2:
      When textDocument/codeAction returns a lazy code action (at version 1), it embeds the gopls.apply_fix command arguments
      (ApplyFixArgs) inside CodeAction.data. Currently, ApplyFixArgs only records the Location (URI, line, and column), but
      not the document version.
      When the file is modified via textDocument/didChange (to version 2) and the client subsequently sends codeAction/resolve
      with the stale code action from version 1, gopls fetches the latest snapshot (version 2) and re-evaluates the fix at the
      old range (34:38 from version 1). Because the edits are computed against the version 2 snapshot, gopls stamps the
      resulting TextDocumentEdit with "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 during textDocument/codeAction. When resolving or executing the action, gopls will check that the current file version matches the version recorded in the action, and reject it if the file has changed in the meantime.

  6. wangzhihao-lab commented on Sep 29, 2026

    @wangzhihao-lab
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

DocumentationIssues describing a change to documentation.ToolsThis label describes issues relating to any tools in the x/tools repository.goplsIssues related to the Go language server, gopls.

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions