Skip to content

fix(diff): remove the legacy diff's temp file and its buffer when a step fails - #516

Merged
ThomasK33 merged 24 commits into
mainfrom
fix/diff-legacy-temp-cleanup
Nov 12, 2026
Merged

ThomasK33 merged 24 commits into
mainfrom
fix/diff-legacy-temp-cleanup

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

The legacy open_diff left its temp file behind when a step failed

require("claudecode.diff").open_diff(...) (the public legacy entry point, which wraps _open_native_diff) writes the proposal to a temp file, then opens the original and the proposal side by side. It registers the autocmd that deletes the temp file as its last step. A step that raises before that leaves the temp file in stdpath("cache")/claudecode_diffs, and sometimes a proposal buffer.

Four paths raise after the temp file exists. The first three were found by the verifier of #501:

  1. A second open_diff for a path whose proposal is still open: the rename to <path> (New) raises E95: Buffer with this name already exists.
  2. A changed swap file for the real file (another Neovim changed it, or a killed one after :preserve): edit of the original raises E325.
  3. An error in a FileType autocmd for lua. Neovim's filetype detection ignores the .new ending of the temp file, so FileType fires for it too. Which edit raises depends on the layout:
    • The original is the current buffer: edit reads it again and FileType fires for the original. The error stops the diff at its first step.
    • The original is loaded but hidden: edit does not read it again, so FileType fires for the proposal's temp file, after the diff has split a window and turned diff mode on.
    • After the rename the plugin sets the filetype again. An error in FileType there reaches the caller on Neovim 0.8 and is only a message on 0.12.
  4. The original has unsaved changes and is the current buffer of the target window: edit of it raises E37: No write since last change. I ran this by hand on 0.12.3 and v0.8.0: the call raises E37, and no temp file, window or buffer is left over. The spec has no case for it.

Any other error that one of these steps raises is cleaned up the same way, because the fix wraps the whole sequence.

Who is affected: only code that calls open_diff itself. Nothing in the plugin calls this path: the openDiff tool uses open_diff_blocking.

The fix

lua/claudecode/diff.lua: the steps after create_temp_file move into show_native_diff, unchanged. _open_native_diff calls it with pcall. If it raises, remove_failed_proposal does two things, then _open_native_diff raises the same error again (the message has one position, as before):

  1. It wipes the buffer that has the temp file's name, unless a window shows that buffer or it has unsaved changes. It finds the buffer by the name or by the real path, because Neovim resolves a link in a buffer's name, so a cache folder that is a link changes the name.
  2. It deletes the temp file and its folder.

Every step is protected, so an error in one cannot stop the next or replace the error that made the diff fail.

It does nothing else. It closes no window, moves no focus or tab, and turns no diff mode off. A failed open_diff can still leave the windows that it had already split, and the diff mode it had turned on, as main does. A proposal buffer that a window shows stays too, and so does a proposal that was already renamed to <path> (New). The window check looks at every tab. Because a window that shows the proposal stays, the temp file behind it is gone, and the next :checktime prints E211: File "..." no longer available once (I checked this on 0.12.3 and v0.8.0). No text is lost. lua/claudecode/README.md says so.

Why this PR does not undo the windows

Earlier versions of this PR also undid the windows, the diff mode and the focus of a failed diff. In four review rounds, Codex and the verifier each found one more way for that undo to reach something that was not the diff's own: another diff's mode, a window that an autocmd opened, the split of another tab, a buffer of the user's with unsaved text. Each one was an autocmd that changes the layout while the diff runs. I removed the undo instead of fixing a fifth case. All Codex threads are resolved. Two of the three round-4 threads (4543276628, 4543276636) are about code that this PR no longer has. The third (4543276642) is about the spec's after_each, which this PR keeps: it now runs tabonly! first.

Spec hygiene

diff_legacy_swap_spec.lua (#501) and the cleanup spec leave a swap file by killing a child Neovim with SIGKILL after :preserve. A killed Neovim cannot delete its socket, so each run of each spec left one folder behind: $TMPDIR/nvim.$USER/<random>/, or nvim.<pid>.0 in $XDG_RUNTIME_DIR when that is set. The child now starts with TMPDIR and XDG_RUNTIME_DIR set to a folder inside the spec's own directory, which after_each deletes. I measured both specs with an empty private TMPDIR and XDG_RUNTIME_DIR, on 0.12.3 and v0.8.0: one folder per run before the change, none after.

The cleanup spec also points XDG_CACHE_HOME at its own folder, as the swap spec does since #520. open_diff writes under stdpath("cache"), and the CI step from #520 fails the job when a spec leaves a folder in the real cache. Run with a private HOME, the integration runner leaves no ~/.cache/nvim.

Proof

tests/integration/diff_legacy_cleanup_spec.lua has 18 real-Neovim cases. Each asserts that the call raises and that no temp file is left, and each one that has a user's window, buffer or diff mode in it asserts that this stays.

# Case What it checks
1 E95, a second diff of the same path The first diff stays open and in diff mode. No window is closed. The error has one position, as before.
2 E325 A swap file for the original.
3 FileType error, original current The diff stops at its first step.
4 FileType error for the proposal, original hidden No window is closed.
5 The cache folder is a link, and a step fails after the rename The unlisted buffer under the temp file's name is found by its real path and wiped.
6 Autocmds raise as that buffer is wiped The cleanup finishes and the first error is raised.
7 A step fails after the rename (BufFilePost raises) No buffer has the temp file's name.
8 A buffer has the proposal's name, original current No window is closed. The buffer that was there stays.
9 Sidebar on the left and a diff already open That diff stays on. The sidebar stays. No window is closed.
10 The user's own :diffthis in two windows That diff stays on. No window is closed.
11 A diff is open, and the call starts in a prompt window of another tab The open diff stays on.
12 The diff starts in a prompt window and picks the editor window of another tab The prompt window stays. No window is closed.
13 An autocmd opens a window, then raises That window stays.
14 A WinNew handler opens a window with unsaved text The text stays. No window is closed.
15 A WinEnter handler sends the cursor back to the editor window The editor window stays. No window is closed.
16 A BufEnter handler wipes the proposal and leaves a new buffer with text The buffer of the user and its text stay.
17 A BufEnter handler changes the proposal and moves the window to another buffer The proposal stays, with its text.
18 A BufEnter handler shows the proposal in a new tab and then gives the split another buffer, so only a window of another tab shows it That window stays. No window is closed.

With the diff.lua of main, 18 of 18 fail on Neovim 0.12.3 and on v0.8.0. With this head, 18 pass on both.

Mutants: I changed remove_failed_proposal and _open_native_diff in 16 ways, one at a time, and ran the spec on both versions. 13 are killed: no wipe, no temp file delete, no cleanup call, no re-raise, error(failure) for error(failure, 0), wipe a buffer with unsaved changes, wipe a buffer that a window shows, no real-path match, delete before wipe, a cleanup that turns diff mode off or closes other windows, and two window checks that look at the current tab only (bufwinnr and bufwinid for win_findbuf). Those last two passed the first 17 cases and fail only case 18, on both versions. Three survive. No pcall around the wipe, because an autocmd that raises during nvim_buf_delete is only a message on both versions. No pcall around the temp file delete, because cleanup_temp_file reports a failed delete with vim.notify and does not raise. Wiping the current buffer when no buffer has the name, because the unsaved-changes check and the window check each keep a buffer of the user's.

The verifier's probe (probe.sh and drive.lua, 22 scenarios) on this head and on main, on both versions: the windows, the buffers, the diff modes and the focus after the call are the same in all 22 scenarios. The only differences are the temp files, which are gone (so a window that still shows the proposal sees E211 once on the next :checktime), and, in one scenario, an unlisted foo.lua.new buffer that no window showed, which is wiped.

Other gates:

  • mise run format-check and mise run check (0 warnings in 177 files) are clean.
  • mise run test: 1655 successes, 0 failures, 24 pending (the cleanup spec is pending under busted, because it needs a real Neovim).
  • The integration runner passes on both versions with 0 failures, once normally and once under a TMPDIR named odd tmp 50% (the two runs that the CI job makes since test: guard CI against odd TMPDIR and real-cache writes, plus follow-ups of #510 and #513 #520), in a private HOME with no XDG_CACHE_HOME: 337 tests per run on 0.12.3 and 250 per run on v0.8.0. The real cache folder stays untouched. The 15 diff_*_spec.lua files (the two legacy specs and 13 others) ran in each run: 30 runs in each mode, 0 failures.
  • .agents/skills/verify-diff-lifecycle/covered.lock is re-recorded. Only the hash of diff.lua changed, and _open_native_diff is outside the model. The model passes (89,205 distinct states).

Findings that main already has

The verifier of this PR found two defects that main has too, and this PR does not change them:

  1. A WinEnter autocmd that raises inside the diff's own split leaves that split open.
  2. A failed diff does not give the target window back the buffer that it showed before.

Both are about windows. Nothing in the plugin calls this path, so they stay as they are.


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

…n a step fails

The public legacy `require("claudecode.diff").open_diff` (through `_open_native_diff`) registers the autocmd that
deletes its temp file after every step that can raise. A failure after the temp file was written left it behind,
with a half-made diff:

- A second open_diff for a path whose proposal is still open raised E95 at the rename to "<path> (New)".
- A changed swap file for the original made `edit` raise E325.
- An error in a FileType autocmd for the filetype of the original raised while the proposal was made (from the edit
  of the temp file, or on Neovim 0.8 from setting the filetype).

The steps after create_temp_file now run in `show_native_diff`, which records what it changes. When it raises,
`undo_native_diff` turns the diff mode of the original off, wipes the proposal's buffer, closes the windows that the
diff split off, goes back to the window that was current, and deletes the temp file. The error is raised again as
before, so callers see the same failure with nothing left behind.

Test: tests/integration/diff_legacy_cleanup_spec.lua has one case per path. All three fail without the change on
Neovim 0.12.3 and v0.8.0 and pass with it. The verify-diff-lifecycle lock is re-recorded: only the hash of diff.lua
changed, and `_open_native_diff` is outside the model.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…IR in the legacy diff specs

Both legacy diff specs leave a swap file by killing a child Neovim with SIGKILL after :preserve. A killed Neovim cannot
delete its socket folder, so each run left $TMPDIR/nvim.$USER/<random>/ with a dead socket behind. The child now
starts with TMPDIR and XDG_RUNTIME_DIR set to a folder inside the spec's own directory, which after_each deletes. With
TMPDIR pointing at an empty folder, both specs left 2 socket folders before and 0 after, on Neovim 0.12.3 and v0.8.0.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-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 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T00:08:20.941550Z c99ad57 Manual request
🔒 Security Review ✅ Completed 2026-11-12T00:14:11.259449Z c99ad57 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 Security Review

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

Reviewed commit: a7aee3dbeb

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a7aee3dbeb

ℹ️ 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".

Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
…ep fails

Codex round 1 on the rollback of the legacy diff found that it closed every window of the starting tab that was not
there before. A window that an autocmd opened while the diff set up (a BufRead, BufEnter or FileType handler) was closed
too. The diff also remembered the tab it started in, although it can open in the editor window of another tab.

show_native_diff now records the handle of each window that its own splits create, and undo_native_diff closes
exactly those, in whatever tab they are. The window and tab the user started in are made current again. Each rollback
step is protected, so an autocmd error in one of them cannot stop the next one or replace the error that made the diff
fail.

Test: a case where a FileType autocmd opens its own window and then fails is red on the previous commit on Neovim
0.12.3 and v0.8.0 (the autocmd's window was closed) and green now. A case where the diff opens in another tab passes
on both, and guards the new bookkeeping. The verify-diff-lifecycle lock is re-recorded: only the hash of diff.lua changed.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-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 Security Review

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

Reviewed commit: bf1d049b22

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf1d049b22

ℹ️ 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".

Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
…n diff alone when a step fails

The legacy diff found its new window as the current window after
create_split(), so a WinEnter handler that changed the window made the
rollback close the wrong one. It now finds the new window by comparing
the tab's windows before and after the split.

It also turned diff mode off in the window it reused, even when that
window was already in a diff of the user's own. It now turns diff off
only in a window where its own diffthis enabled it.
…acy diff rolls back

A verifier found four ways that the rollback of a failed legacy open_diff
touched what was not the diff's own.

1. diffoff ran on the window of the original even when that window was in
   diff mode before the call. The window can be the one of an earlier
   diff or of the user's own :diffthis. It can also be a window that
   `edit` put in diff mode, because `edit` copies the window options of
   another window that shows the file. diff mode is now asked before the
   edit, and diffoff runs only where the diff turned it on.
2. split() took the current window after the split for the diff's own. A
   WinNew or WinEnter handler that opens or enters a window of its own
   made the rollback close that window by force, and wipe unsaved text in
   a buffer with bufhidden=wipe. split() now finds the new window by
   comparing the tab's windows before and after, enters it, so that the
   steps that follow work there, and records it also when a handler
   raises. A rollback closes windows without force.
3. A FileType autocmd that raises while `edit` loads the temp file left
   the proposal's buffer behind, under the temp file's name, and Neovim
   keeps an unlisted buffer for the old name after the rename. The
   rollback now wipes the buffer that has the temp file's name.

The spec runs each of these, and its cases 3 to 5 now assert that the call
raises.
Only the hash of diff.lua changed. No covered block or call site changed:
the legacy open_diff path (_open_native_diff and what it calls) is outside
the model. The model passes (89,205 distinct states).
…cache folder

The merged CI step from #520 fails the integration job when a spec leaves
a folder under the real cache path. The cleanup spec calls the legacy
open_diff, which writes its temp files under stdpath("cache"), so it
points XDG_CACHE_HOME at its own folder, as the swap spec does.
@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 Security Review

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

Reviewed commit: 1280864a8f

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1280864a8f

ℹ️ 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".

Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
…by its name

Codex round 3 on 1280864 raised two cases that a handler can reach.

1. split() listed the windows of "the current tab" after the split. A
   WinNew or WinEnter handler that switches to another tab made it list
   that tab: a window that was already there counted as the diff's own,
   the steps that followed worked there, and a rollback could close it
   while the real split stayed. The tab is now taken before the split and
   listed by its handle.
2. The proposal's buffer was whatever buffer was current after the edit. A
   BufEnter or FileType handler can leave another buffer current without
   raising. The rename and the options then reached that buffer, and a
   rollback wiped it with force. The proposal is now found by the temp
   file's name, and the diff stops with an error when it is not current,
   so the rollback wipes the proposal and nothing else.

Two spec cases fail on 1280864 and pass here, on Neovim 0.12.3 and 0.8.0.
Only the hash of diff.lua changed. No covered block or call site changed.
The model passes (89,205 distinct states).
The unit specs mock vim without vim.fs, and the match only has to make
the separators the same: Neovim can write backslashes on Windows.
Only the hash of diff.lua changed. No covered block or call site changed.
@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 Security Review

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

Reviewed commit: 1d6b24b6f8

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d6b24b6f8

ℹ️ 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".

Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
…and leave the preview window first

Codex round 4 on 1d6b24b raised three cases.

1. A BufEnter handler can wipe the proposal and leave a new buffer of the
   user's. The name lookup found nothing and the diff took the current
   buffer for the proposal, so a later failure wiped it with force. The
   diff now stops when no buffer has the temp file's name and the current
   buffer does not show the temp file. Neovim resolves a link in a
   buffer's name, so the lookup also matches the real path, and the
   current buffer is compared by device and inode, not by spelling. When
   Neovim cannot compare the files, the old behavior stays.
2. split() listed the windows before create_split() left the preview
   window, so a WinEnter handler that opened a window there looked like
   the diff's split. split() now leaves the preview window first. Neovim
   0.8 raises when a handler moves the focus during that switch, so the
   call is protected.
3. The cleanup spec shared one Neovim between cases and left tab pages
   behind. after_each closes them.

Three integration cases and three unit cases fail without these changes.
Only the hash of diff.lua changed. No covered block or call site changed.
…and its buffer

Four review rounds found problems in the same place: a rollback that
closed windows, turned diff mode off and moved focus after a failed
legacy open_diff can reach a window, a buffer or a diff that is not the
diff's own, and every case that a reviewer found was one more autocmd
that changes the layout under it. The rollback is gone.

When a step fails after the temp file was written, _open_native_diff
now does two things and raises the same error:

1. It wipes the buffer that has the temp file's name (found by the name
   or the real path, because Neovim resolves links in buffer names), but
   not when a window shows it or it has unsaved changes.
2. It deletes the temp file and its folder.

It closes no window, moves no focus and turns no diff mode off. The
windows that the failed call had already split stay, as they did before.
Nothing in the plugin calls this path.

The cleanup spec is rewritten: each case checks that no temp file is
left, and the cases for the sidebar, an open diff, the user's own diff,
autocmd windows, a window with unsaved text and a modified proposal
check that nothing of the user's is closed, turned off or wiped.
Only the hash of diff.lua changed. No covered block or call site changed:
the legacy open_diff path is outside the model. The model passes (89,205
distinct states).
@ThomasK33 ThomasK33 changed the title fix(diff): remove the legacy diff's temp file, buffer and windows when a step fails fix(diff): remove the legacy diff's temp file and its buffer when a step fails Nov 11, 2026
@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. Hooray!

Reviewed commit: 4a663f5ad7

ℹ️ 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: 4a663f5ad7

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.

…window of another tab shows

The window guard in remove_failed_proposal had no case for a window of another tab, so a mutant that looked at the current tab only passed all 17 cases. Add that case, and say in the README that a window which still shows the proposal sees E211 once on the next :checktime.
@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 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c99ad57934

ℹ️ 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".

Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

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

Reviewed commit: c99ad57934

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

Copy link
Copy Markdown
Member Author

Review-cap waiver (from the maintainer, 2026-11-12): this PR may merge at the six-round cap. Its last Opus verifier passed with minor findings only (README links, three test gaps, a note that :write in a leftover proposal window now fails with E212), and the three open Codex threads were declined with evidence. A merge of main that changes only covered.lock keeps the verdict; any other code change from the merge gets a new verifier first. The minor findings are tracked for a later change.


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

…cleanup

# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
#	lua/claudecode/README.md
@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit c43b6a3 Nov 12, 2026
6 checks passed
@ThomasK33
ThomasK33 deleted the fix/diff-legacy-temp-cleanup branch November 12, 2026 18:57
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
… an openDiff setup (coder#574)

## Why

The goal of this series: land the behavior fixes and a code-shape
refactor of this plugin on main in small PRs, fixes first. This PR is
one fix.

On Neovim 0.8 and 0.9, a Lua autocmd handler of the user can make Neovim
exit at once (exit 134 or 139) while the plugin opens or closes the
windows of an `openDiff` diff. A user can lose the unsaved changes of
open buffers when that happens. coder#538 fixed one case. Other cases still
abort.

The cause, in three sentences. Neovim 0.8 and 0.9 bundle a LuaJIT older
than its commit e86990f7f of 2023-09-21, which fixes LuaJIT issue 1066:
after a C function runs Lua on another thread and then raises into a
`pcall`, `cur_L` still names that thread, and the next exit from a
compiled trace crashes (the issue shows a crash in `lj_trace_exit`). A
Neovim API call in a coroutine that runs a Lua callback and then raises
behaves like that C function, and Neovim aborts (measured, below). The
plugin runs `openDiff` in a coroutine, and Neovim runs the handlers of
the user while the plugin opens and closes its windows.

What a source shows: the issue text and the patch of e86990f7f, which
sets `cur_L` again in `err_unwind` (`src/lj_err.c`, case `FRAME_PCALL`).
We did not read the LuaJIT source for other code that uses `cur_L`. The
proof below measures what happens instead.

## What changed

- `utils.resume_tool_coroutine(co, ...)` is new. On an affected LuaJIT
it turns the JIT off and flushes the compiled traces before it resumes
the coroutine, and it gives the JIT back as it was when the resume
returns. Everywhere else it is a plain `coroutine.resume`. It returns
exactly what `coroutine.resume` returns, `nil` values and their count
included.
- "Affected" means: the global `jit` exists and `jit.version` is not a
rolling `2.1.<n>` release with `n` of 1695261248 or more. `LuaJIT
2.1.0-beta3` (Neovim v0.8.0) and `LuaJIT 2.1.1692716794` (Neovim v0.9.5)
are affected. A version that is no string, or that the check cannot
read, counts as affected, which costs only a flush.
- `handle_invoke` and the answer to a blocking diff
(`open_diff_blocking`) resume the tool's coroutine through it. `git grep
-n 'coroutine\.resume' -- lua plugin` shows one code line, inside the
helper.
- coder#538's call (`nvim_get_current_tabpage()` before the rethrow in
`_setup_blocking_diff`) and its comment are gone. The cases of coder#538's
spec stay, and their assertions are unchanged. Only the comment above
the eager-JIT case changed.
- `.agents/skills/verify-diff-lifecycle/check.sh`: the fact
`SetupAnswerReturned` now needs the assignment line `local ... =
utils.resume_tool_coroutine(co, result)`. A comment on a line of its own
cannot satisfy it. A comment at the end of a plain-resume line leaves
the fact TRUE, and `check.sh` then exits 3 (stale). The job
`covered-lock` runs every `check.sh` and fails on any non-zero exit,
exit 3 included (`.github/workflows/test.yml`, the loop with
`status=1`). The model is unchanged.
- Docs: `lua/claudecode/tools/README.md` names the helper, the LuaJIT
condition and the cost, and `lua/claudecode/README.md` lists the helper.
The comment above the helper in `utils.lua` names the LuaJIT builds and
the Neovim versions that the bug affects.

## Why coder#538's call goes away

coder#538's call is on main in every run below, and Neovim still died. The
probe runs the tool path (`tools.handle_invoke`) with 8 raising
handlers, 2 passes, and an eager JIT. Two verifiers measured it on two
main commits. Each cell counts the runs that killed Neovim, of 30. The
counts depend on the host and its load: the same probe on main gave 13
of 30 in one run and 8 of 30 twice on v0.9.5.

| Tree | v0.8.0 | v0.9.5 |
| --- | --- | --- |
| main `884f7e22` (round 1 verifier) | 27 | 13 |
| main `523ee200` (round 2 verifier) | 24 | 8 and 8 |
| this PR (both verifiers) | 0 | 0 |

Source: the two verifier reports (`verify-f55-r1-opus.md` and
`verify-f55-r2-opus.md`), kept outside the repository.

In the round 1 verifier's runs on main `884f7e22`, a handler on
`WinEnter` makes every setup fail and killed Neovim in 17 of 20 runs on
v0.8.0 and in 17 of 20 on v0.9.5. Handlers on `WinClosed`,
`BufWinLeave`, `BufHidden`, `BufUnload`, `BufWipeout` and a mix of them,
which the plugin's own `pcall` swallows while the setup goes on, killed
Neovim in 0 of 20 runs on both versions. The new spec case for a setup
that succeeds therefore guards that the answers of `openDiff` stay as
they are. It does not show the bug.

## Proof

Evidence folder: local files, not in the repository
(`evidence/round1/`). File names below are relative to it. The facts
below are the ones that a verifier reproduced, or that I measured on the
head named in the file.

1. **The eager case shows the bug and the guard removes it.** With the
helper forced to a plain `coroutine.resume`, the eager case of
`diff_failed_setup_guards_spec.lua` alone ended without a plenary
summary in 6 of 10 runs on v0.8.0 and 8 of 10 on v0.9.5 (round 2
verifier, all with exit 134 `bad stack slot`), and in 9 of 10 and 6 of
10 (round 1 verifier). The three other cases of that file kept their
summary in 10 of 10 runs on each version (round 2 verifier). With the
guard, the head had 0 of 10 without a summary in round 1.

2. **Each part of the fix has a test that turns red without it** (a
scratch mutant per row, never committed). The two verifiers ran the
mutants again. "Unit" is `utils_resume_tool_coroutine_spec.lua` and
`tool_coroutine_resume_spec.lua`. "Spec" is
`tests/integration/diff_tool_coroutine_jit_spec.lua` on v0.8.0 and
v0.9.5. The unmutated tree is green everywhere. Source:
`R1_mutants_matrix.txt` and `R1_mutants_failed_tests.txt`.

   | Removed part | Red in the unit suite | Red in the spec |
   | --- | --- | --- |
| no `jit.off()` | "turns the JIT off, flushes its traces and turns it
on again" and others | "runs the coroutine of a tool with the JIT off
and no compiled trace, and turns the JIT on again" |
| no `jit.flush()` | the same cases | the same case, and "keeps a JIT
that was off off" |
   | no restore (`jit.on()`) | the same cases | the first case |
| restore turns the JIT on although it was off | "flushes the traces but
keeps the JIT off when the JIT was off before" | "keeps a JIT that was
off off" |
| first resume not on the helper | "goes through the helper when
handle_invoke runs the tool" | both cases |
| second resume not on the helper | "goes through the helper when a
blocking diff is answered" | the first case (it records the JIT at the
answer too) |
| version check reversed | "leaves the JIT alone on ..." and others |
both cases |
| `tostring` removed from the version check | "treats no version / a
number / a table as the version as affected" | none: real Neovim always
sets a string |

The verifiers also showed that with a plain `coroutine.resume(co,
result)` at the diff answer, `SetupAnswerReturned` goes FALSE and
`check.sh` exits 1.

3. **Nothing else changes for a user.** One `openDiff` accept (`:w`) and
one reject (`:q`) through the WebSocket, with
`.agents/skills/verify-claudecode-nvim`, on main and on this PR, on
v0.8.0 and 0.12.3: the answers (`FILE_SAVED` plus the proposal text,
`DIFF_REJECTED` plus the tab name, `TAB_CLOSED`) and the window and
buffer states (names, diff mode, text) are byte-equal in 10 of 10 files
on each version. The round 2 verifier reproduced this. Source:
`R1_vcn_accept_reject_main_vs_head.txt`, `vcn/`.

4. **Stability under load.** Whole-file runs of
`diff_tool_coroutine_jit_spec.lua` and
`diff_failed_setup_guards_spec.lua`, one Neovim per run, pinned to one
core with one busy loop on the same core. Runs that failed or ended
without a summary: 0 of 100 on v0.8.0, 0 of 100 on 0.12.3 (both
verifiers) and 0 of 30 on v0.9.5 (round 1 verifier). My own runs gave
the same (`R1_load_counts.txt`). No run used a retry.

5. **Gates.** On this head (`579df505d800f11fa019607b99b219dbe3a52d6e`,
which merges main `c43b6a308ea97f9dcfb0844ce6770e7ce7699ec0`):
`covered.lock` was the only conflict of that merge. I took main's file
and ran `check.sh --record`, and the merge commit holds the recorded
file. `bash .agents/skills/verify-diff-lifecycle/check.sh` ends `==
RESULT: PASS`. `mise run format-check`: 438 files, 0 changed. `mise run
check`: exit 0, and luacheck reports 0 warnings and 0 errors in 196
files. `mise run test`: 1858 successes, 0 failures, 0 errors, 34
pending. Four integration specs, once on 0.12.3 and once on v0.8.0, with
0 failures and 0 errors each time: `diff_tool_coroutine_jit_spec.lua` 3
successes, `diff_failed_setup_guards_spec.lua` 4, and the two specs that
coder#516 added or changed, `diff_legacy_cleanup_spec.lua` 18 and
`diff_legacy_swap_spec.lua` 1. The whole integration runner did not run
on this head (CI runs it). My change is the same as at `230be2d2` (the
head that the round 2 verifier passed): the added and removed lines
outside `covered.lock` are byte-equal, 469 lines in 10 files. On an
earlier head (`661e4e52`, main `48ca93be` merged), the integration
runner gave 0.12.3 `Total Success: 603`, `Failed: 0`, `Errors: 0`, and
v0.8.0 `Total Success: 585`, `Failed: 0`, `Errors: 0`. A first 0.12.3
run under a host load above 100 lost the summary of three files to the
runner's 40 s limit (`diff_close_save_spec.lua`,
`diff_exit_reject_spec.lua`, `diff_left_window_spec.lua`). Each passed
alone (77, 28 and 33 successes), and the whole runner passed on the
second run. The two new specs ran 20 times per version on v0.8.0, v0.9.5
and 0.12.3 on `661e4e52` with 0 failed runs and 0 runs without a
summary. The round 2 verifier ran the gates on `230be2d2`: `mise run
test` 1758 successes, 0 failures, 0 errors, 29 pending, the integration
runner 530/0/0 on 0.12.3 and 393/0/0 on v0.8.0, and `check.sh` PASS. Its
counts are lower than mine because main had fewer tests then. Sources:
`../round4/` for this head, `../round3/` for the head before it, and
`../round2/R2_*.txt` and `../round2/m2_int_*.txt` for the older heads.

6. **Where the Neovim binaries come from** (`R1_binary_provenance.txt`,
reproduced by the round 2 verifier).
- v0.8.0: the asset `nvim-linux64.tar.gz` of the official GitHub release
v0.8.0 (published 2022-09-30), fetched with `gh release download v0.8.0
-R neovim/neovim -p 'nvim-linux64.tar.gz*'`. Its sha256 matches the
`.sha256sum` file and the binary that was run. `--version` prints `NVIM
v0.8.0-1210-gd367ed9b2`.
- v0.9.5: the asset `nvim-linux64.tar.gz` of the official GitHub release
v0.9.5 (published 2023-12-30), fetched the same way, with the same
sha256 checks.
   - 0.12.3 is the mise install of this repository.

## Cost on Neovim 0.8 and 0.9

On Neovim 0.8 and 0.9, each tool call that runs in a coroutine
(`openDiff`) discards the compiled traces of the whole editor, and
LuaJIT compiles them again when the code gets hot. The round 1 verifier
measured it with about 740 compiled traces: a guarded resume took 18 to
41 µs (a plain resume took 1.1 µs or less), and the first run of a hot
loop of 300 small functions after the flush took 3.6 to 10.3 ms against
0.3 to 0.6 ms when hot. Nobody measured a whole editing session. A
LuaJIT that is not affected gets a plain `coroutine.resume`.

## What this PR does not cover

- A coroutine of another plugin or of a test harness. The helper covers
the coroutines of the plugin's tools only. Plenary runs each spec file
in its own coroutine, so a spec that calls `_setup_blocking_diff`
directly is not covered. Such a spec must go through
`tools.handle_invoke`.
- `scripts/manual_test_helper.lua` resumes its own coroutine. It is a
manual script and not part of the plugin.

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