Skip to content

fix(server): never repeat a client id within one Neovim process - #383

Merged
ThomasK33 merged 4 commits into
mainfrom
fix/client-id-stability
Nov 1, 2026
Merged

ThomasK33 merged 4 commits into
mainfrom
fix/client-id-stability

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Why

The operator goal is to clear the open follow-up issues in this repository (#362, #363 and #374 now) with small reviewed PRs. This PR takes #363 items 2 and 3.

  1. Item 3. A reload repeated client ids. A diff that the user accepted with :w stays tied to the id of the client that opened it, and no other client may close it. Since fix(diff): keep accepted diffs through reentrant bulk closes and other clients #361 the ids come from a counter, but the counter was a local of claudecode.server.client. A reload of claudecode.server.* restarted it at 1, while claudecode.diff kept the old ids. After a server restart, the next Claude got the id of a Claude that had exited. It then passed as the owner of that Claude's accepted diff, and its closeAllDiffTabs dropped the accepted edits before anything wrote them to disk.
  2. Item 2. A Claude CLI that reconnects gets a new id. I read the Claude Code 2.1.312 bundle to find anything that ties a new connection to the old one. Nothing does, and the CLI never refers to an older diff over a new connection (details below). The only per-process value is a pid, which is not safe to match on. So this PR documents the behavior and does not change it.

What changed

  • lua/claudecode/server/client.lua: create_client counts in _G.claudecode_clients_created, which a module reload keeps. Ids keep the form client_<n>.
  • PROTOCOL.md, section "4. Claude Connects": each connection is a client, a reconnect makes a new client, and what Claude Code 2.1.312 sends and does on a reconnect.
  • lua/claudecode/server/README.md: the id invariant and the new global.
  • verify-diff-lifecycle: the ClientIdsUnique fact now reads the global counter. It also requires that nothing else in lua/ or plugin/ assigns that global, with or without spaces, through _G["..."], or with rawset. covered.lock is re-recorded.
  • tests/unit/server/client_id_spec.lua: a test that reloads the module three times and asserts that no id comes back.
What Claude Code 2.1.312 sends and does on a reconnect (read from ~/.local/share/claude/versions/2.1.312)
  • The ws-ide transport opens the socket with subprotocol mcp and two headers, User-Agent and X-Claude-Code-Ide-Authorization. The token comes from the lock file, so every Claude of this Neovim sends the same one.
  • initialize carries clientInfo { name: "claude-code", title: "Claude Code", version: "2.1.312", description, websiteUrl } and capabilities, the same for every process and with no session id.
  • After the connection succeeds, it sends the notification ide_connected with { pid: process.pid }. The plugin has no handler for it. A pid is not unique across pid namespaces (two Claude CLIs in two containers can both report the same pid), and the OS reuses it. Matching on it could hand one live Claude's accepted diff to another, which is the harm fix(diff): keep accepted diffs through reentrant bulk closes and other clients #361 fixed.
  • Each openDiff gets the tab_name ✻ [Claude Code] <basename> (<first 6 chars of a random UUID>) ⧉, so a new connection does not reuse an old name.
  • The CLI keeps the IDE client object from the moment it calls openDiff and sends that diff's close_tab through it. After a reconnect, that close_tab fails on the old connection. The new connection never names the old diff.

So matching a reconnect to its old id would change nothing for this CLI. The leftover diff is the same as the accepted diff of a Claude that exited (#363 item 1). #376 lets :ClaudeCodeCloseAllDiffs finish such a diff.

Scope

#363 items 2 and 3 only. Item 2 changes only docs. This PR does not touch diff.lua, which #376 changes.

Blast Radius

  • Users who reload claudecode.server.* in a running Neovim and then restart the server. A new Claude can no longer take over an accepted diff of a Claude that exited. Nobody else sees a change: ids keep their form and still count up from 1 in a fresh Neovim.
  • One new Lua global, _G.claudecode_clients_created.
  • covered.lock will conflict with fix(diff): let :ClaudeCodeCloseAllDiffs finish an accepted diff #376, because both PRs re-record it. Whichever PR merges second must re-run check.sh --record after the merge.

Verification

Run Code Result
busted tests/unit/server/client_id_spec.lua (red) 3919af6, the test on main id client_1 came back after a reload
Same (green) this PR 2 successes. mise run test: 839 successes, 0 failures
Real Neovim 0.12.3 through verify-claudecode-nvim (below) main (96a4774) the new Claude gets client_1, closeAllDiffTabs answers CLOSED_1_DIFF_TABS, and the accepted text is gone
Same this PR (4aa133b) the new Claude gets client_2, closeAllDiffTabs answers CLOSED_0_DIFF_TABS, and the accepted diff stays
verify-diff-lifecycle/check.sh with the fix and main's fact regex the fix on 96a4774 ClientIdsUnique FALSE, main: BROKEN: SavedDiffKept with the trace ClaudeOpensDiff, UserHandlerResolves, SetupEnds, ClientDisconnects, ClientIdReused, ClaudeOpensDiff
check.sh this PR (7fb8f7b, again on 9ec4014) PASS. main: pass (89205 distinct states found), live: pass (5269 distinct states found)
check.sh --mutants this PR every guard is caught. ClientIdsUnique=FALSE caught by: SavedDiffKept
check.sh with one extra assignment of the counter: _G.claudecode_clients_created=0, _G["claudecode_clients_created"] = 0, or rawset(_G, "claudecode_clients_created", 0) 9ec4014 each: ClientIdsUnique FALSE, main: BROKEN: SavedDiffKept

The real-Neovim script: Claude calls openDiff, the user saves it with :w, and Claude gets FILE_SAVED. Claude exits before it writes the file. The user types :lua to clear claudecode.server.* from package.loaded, then :ClaudeCodeStop and :ClaudeCodeStart on the same port. A second Claude connects and calls closeAllDiffTabs.

Output on main
1. launch client ids: ["client_1"]
2. user :w -> openDiff answer: ["FILE_SAVED","alpha\nBETA accepted\ngamma\n"]
3. Claude exited before writing the file. diffs (owner status): {"✻ [Claude Code] hello.txt (vcn) ⧉":"client_1 saved"}  proposed buffer exists: 1
4. reloaded claudecode.server.* and restarted on port 39032
5. new client b ids: ["client_1"]
6. b closeAllDiffTabs answer: "CLOSED_1_DIFF_TABS"
7. diffs (owner status): []  proposed buffer exists: 0
8. hello.txt on disk: alpha|beta|gamma|
Output on this PR
1. launch client ids: ["client_1"]
2. user :w -> openDiff answer: ["FILE_SAVED","alpha\nBETA accepted\ngamma\n"]
3. Claude exited before writing the file. diffs (owner status): {"✻ [Claude Code] hello.txt (vcn) ⧉":"client_1 saved"}  proposed buffer exists: 1
4. reloaded claudecode.server.* and restarted on port 44871
5. new client b ids: ["client_2"]
6. b closeAllDiffTabs answer: "CLOSED_0_DIFF_TABS"
7. diffs (owner status): {"✻ [Claude Code] hello.txt (vcn) ⧉":"client_1 saved"}  proposed buffer exists: 1
8. hello.txt on disk: alpha|beta|gamma|

covered.lock on main was already stale for selection.lua, tools/open_file.lua, utils.lua and the send_message part of server/client.lua (#367, #375, #378). None of those changes can reach diff state, an openDiff answer, a disconnect or a diff command, so the new record covers them too.

Part of #363


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

Reloading claudecode.server.client resets its id counter, while the diff
module keeps the ids of the clients that opened its diffs. A new connection
then gets an old id and passes as the owner of a diff the user accepted for
a Claude that is gone. This test fails on main with "id client_1 came back
after a reload".

Part of #363

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
A diff the user accepted stays tied to the id of the client that opened
it. The id counter was a local of claudecode.server.client, so a reload of
claudecode.server.* restarted it at 1 while the diff module kept the old
ids. After a server restart, a new Claude got the id of a Claude that had
exited, passed as the owner of its accepted diff, and closeAllDiffTabs
dropped the accepted edits before anything wrote them.

The counter now lives in _G.claudecode_clients_created, which a module
reload keeps. check.sh reads the new counter for ClientIdsUnique.

A Claude CLI that reconnects still gets a new id. Claude Code 2.1.312
sends no session id, and it never refers to an older diff over a new
connection, so PROTOCOL.md documents this instead of matching on its
ide_connected pid.

Part of #363

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
check.sh --mutants catches every guard, and check.sh passes on this code.
The record also takes in the selection, open_file, utils and send_message
changes from #367, #375 and #378, which main had not recorded. None of
them can reach diff state, an openDiff answer, a disconnect or a diff
command.

Part of #363

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T04:28:46.856679Z 9ec4014 Manual request
🔒 Security Review ✅ Completed 2026-11-01T04:29:17.089206Z 9ec4014 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 7fb8f7bd6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 7fb8f7bd6c

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Claude Code 2.1.312 sends clientInfo with name, title, version,
description and websiteUrl, plus capabilities. None of them names a
session. ClientIdsUnique in check.sh now also counts an assignment of the
counter written without spaces, through _G["..."], or with rawset.

Part of #363

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 9ec40146a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 9ec40146a8

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit fead7f6 Nov 1, 2026
6 checks passed
@ThomasK33
ThomasK33 deleted the fix/client-id-stability branch November 1, 2026 04:31
@github-actions github-actions Bot mentioned this pull request Nov 1, 2026
ThomasK33 added a commit that referenced this pull request Nov 1, 2026
The merge combines #383's ClientIdsUnique regex with this branch's
UserCloseKeepsAcceptedText. check.sh --mutants on the merged tree catches
every guard, and check.sh passes.

Part of #363

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
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.

1 participant