From 061aeac8cdcb7fc35d84a73191e43cf2bd6236cf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 02:57:44 +0000 Subject: [PATCH] fix(diff): keep Neovim alive when a handler raises during a failed openDiff setup Neovim 0.8.0 bundles a LuaJIT that has issue #1066 (fixed in September 2023). A Neovim API call raises inside the openDiff coroutine because an autocmd callback it ran raised. LuaJIT then keeps pointing at the main Lua thread, and the next JIT trace exit corrupts the stack of that thread. Neovim aborts (exit 134, "LuaJIT ASSERT lj_api.c:40: index2adr: bad stack slot -1"). plenary ignores the death by signal, so diff_failed_setup_guards_spec.lua printed no summary about 3 times in 100 runs on Neovim 0.8.0. Make one Neovim API call that returns normally before the failed setup rethrows. It points LuaJIT at the running coroutine again. The spec gets a case that forces the JIT to compile at once and repeats the three raising-handler cases. Without the change it aborts Neovim 0.8.0 in most runs. --- .../skills/verify-diff-lifecycle/covered.lock | 4 +- lua/claudecode/diff.lua | 7 +++ .../diff_failed_setup_guards_spec.lua | 59 +++++++++++++++---- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 72bef18b6..109b585c1 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -3,11 +3,11 @@ file lua/claudecode/config.lua 787fa0432d1d77d7 == lua/claudecode/cwd.lua file lua/claudecode/cwd.lua 55d26ea362173afa == lua/claudecode/diff.lua -file lua/claudecode/diff.lua a21129cdc802e417 +file lua/claudecode/diff.lua 7c0ff2555546a88a block resolve_saved 46d5f3f732711fe1 block resolve_rejected c7a620816abff9a1 block open_blocking 92067df7f9d4d4d9 -block setup_blocking 5873be8346fa1b52 +block setup_blocking fd766adac2f7ed2a block close_by_name cc40c0d2ffdf7946 block close_pending 31bc827d1065da70 block close_for_client 330cc91666bc7884 diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 5875c295f..a1d4f8d8d 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1864,6 +1864,13 @@ function M._setup_blocking_diff(params, resolution_callback) end end + -- A raising handler of the user (autocmd callback) makes the cleanup above fail inside Neovim API calls. Neovim + -- 0.8.0 bundles a LuaJIT that then keeps pointing at the main Lua thread, while this code runs in the coroutine + -- of tools.handle_invoke. The next JIT trace exit corrupts the stack of the main thread and Neovim aborts + -- (LuaJIT issue #1066, fixed in September 2023). An API call that returns normally points LuaJIT at the running + -- coroutine again. Make one before the error leaves the coroutine. + vim.api.nvim_get_current_tabpage() + -- Re-throw the error for MCP compliance error({ code = -32000, diff --git a/tests/integration/diff_failed_setup_guards_spec.lua b/tests/integration/diff_failed_setup_guards_spec.lua index ff66b888b..e2ed52abd 100644 --- a/tests/integration/diff_failed_setup_guards_spec.lua +++ b/tests/integration/diff_failed_setup_guards_spec.lua @@ -1,6 +1,6 @@ -- Real-Neovim guards for openDiff with `diff_opts.open_in_new_tab`: when the diff setup fails after `:tabnew` and a -- handler of the user raises while the plugin cleans up, openDiff still answers "Diff setup failed", closes the tab --- it opened and goes back to the tab it left. Under busted's mock vim this is pending; +-- it opened and goes back to the tab it left, and Neovim keeps running. Under busted's mock vim this is pending; -- scripts/run_integration_tests_individually.sh runs it in Neovim, including CI's oldest supported version. if vim == nil or vim.fn.has("nvim") ~= 1 then describe("openDiff when the setup fails in a new tab", function() @@ -101,32 +101,67 @@ describe("openDiff when the setup fails in a new tab", function() _G.claude_deferred_responses = {} end) - after_each(function() + local function reset() pcall(vim.api.nvim_del_augroup_by_name, group) diff._cleanup_all_active_diffs("test cleanup") vim.cmd("silent! tabonly!") vim.cmd("silent! only!") vim.cmd("silent! %bwipeout!") + end + + after_each(function() + reset() vim.fn.delete(dir, "rf") _G.claude_deferred_responses = nil + if jit then + jit.opt.start("hotloop=56", "hotexit=10", "tryside=4") -- LuaJIT's defaults + end end) + local function a_WinClosed_handler_raises() + wipe_the_proposal_when_shown() + raise_when_a_window_closes() + end + + local function a_WinLeave_handler_raises_during_the_cleanup() + fail_the_setup_and_then_raise_when("WinLeave") + end + + local function a_BufLeave_handler_raises_during_the_cleanup() + fail_the_setup_and_then_raise_when("BufLeave") + end + it("closes the new tab and goes back to the tab it left when a WinClosed handler raises", function() - closes_the_new_tab_and_goes_back(function() - wipe_the_proposal_when_shown() - raise_when_a_window_closes() - end) + closes_the_new_tab_and_goes_back(a_WinClosed_handler_raises) end) it("closes the new tab and goes back when a WinLeave handler raises during the cleanup", function() - closes_the_new_tab_and_goes_back(function() - fail_the_setup_and_then_raise_when("WinLeave") - end) + closes_the_new_tab_and_goes_back(a_WinLeave_handler_raises_during_the_cleanup) end) it("closes the new tab and goes back when a BufLeave handler raises during the cleanup", function() - closes_the_new_tab_and_goes_back(function() - fail_the_setup_and_then_raise_when("BufLeave") - end) + closes_the_new_tab_and_goes_back(a_BufLeave_handler_raises_during_the_cleanup) + end) + + -- Neovim 0.8.0 bundles a LuaJIT with a bug (LuaJIT issue #1066, fixed in September 2023). When a Neovim API call + -- raises inside a coroutine because a handler it ran raised, LuaJIT keeps pointing at the main Lua thread, and the + -- next JIT trace exit corrupts the stack of that thread. Neovim then aborts (exit 134, "bad stack slot") and plenary + -- prints no summary. openDiff runs in a coroutine (tools.handle_invoke). The three cases above hit the bug in a few + -- of 100 runs. A JIT that compiles at once makes the passes below hit it in most runs, so this case fails on Neovim + -- 0.8.0 when diff.lua leaves the bug in place. + it("keeps Neovim running when handlers raise on repeated failed setups under an eager JIT", function() + if jit then + jit.opt.start("hotloop=1", "hotexit=1", "tryside=1") + end + local arrangements = { + a_WinClosed_handler_raises, + a_WinLeave_handler_raises_during_the_cleanup, + a_BufLeave_handler_raises_during_the_cleanup, + } + for pass = 1, 6 do + vim.api.nvim_create_augroup(group, { clear = true }) + closes_the_new_tab_and_goes_back(arrangements[pass % #arrangements + 1]) + reset() + end end) end)