Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/verify-diff-lifecycle/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ fact[ClientTagged]=$(as_bool client_tagged)
# the running coroutine, and open_diff_blocking returns that result.
setup_answer_returned() {
in_order '^ *local waiting = false$' 'pcall\(M\._setup_blocking_diff' '^ *if not waiting then$' \
'^ *setup_result = result$' '^ *return$' 'coroutine\.resume\(co, result\)' '^ *if setup_result then$' \
'^ *setup_result = result$' '^ *return$' '^ *local .* = utils\.resume_tool_coroutine\(co, result\)$' '^ *if setup_result then$' \
'^ *return setup_result$' '^ *waiting = true$' 'coroutine\.yield\(\)' <<<"$open_blocking"
}
fact[SetupAnswerReturned]=$(as_bool setup_answer_returned)
Expand Down
12 changes: 6 additions & 6 deletions .agents/skills/verify-diff-lifecycle/covered.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ file lua/claudecode/config.lua 3afb1bdeff27bfb6
== lua/claudecode/cwd.lua
file lua/claudecode/cwd.lua b034e82c76e8dee4
== lua/claudecode/diff.lua
file lua/claudecode/diff.lua a646d1da839b9f92
file lua/claudecode/diff.lua 56ed24d0d5b77e5c
block resolve_saved 4d34b0c7e34d561f
block resolve_rejected c7a620816abff9a1
block open_blocking 92067df7f9d4d4d9
block setup_blocking b96c84e0961817fc
block open_blocking 13e23b610cab9f40
block setup_blocking b45a7bfd5203a085
block close_by_name cc40c0d2ffdf7946
block close_pending 31bc827d1065da70
block close_for_client 330cc91666bc7884
Expand Down Expand Up @@ -204,8 +204,8 @@ file lua/claudecode/tools/get_open_editors.lua 96392f05d7af7521
== lua/claudecode/tools/get_workspace_folders.lua
file lua/claudecode/tools/get_workspace_folders.lua 80f38bdf62e55c3e
== lua/claudecode/tools/init.lua
file lua/claudecode/tools/init.lua 76b2b584a0799874
block handle_invoke d70cbd96c9ab5655
file lua/claudecode/tools/init.lua a8ff3ef7cd3c9df9
block handle_invoke 5c2422dfe7e2ea93
== lua/claudecode/tools/open_diff.lua
file lua/claudecode/tools/open_diff.lua d4050352cb09dfb9
block open_diff_tool 1a0e157e8e0a9452
Expand All @@ -216,7 +216,7 @@ file lua/claudecode/tools/save_document.lua 584b046bec6cb1f6
== lua/claudecode/types.lua
file lua/claudecode/types.lua 69e2907853403759
== lua/claudecode/utils.lua
file lua/claudecode/utils.lua c4a21fe74858afe2
file lua/claudecode/utils.lua 33fe91b68063b71d
== lua/claudecode/visual_commands.lua
file lua/claudecode/visual_commands.lua f396dd26cc0c1050
== plugin/claudecode.lua
Expand Down
2 changes: 1 addition & 1 deletion lua/claudecode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Module map (direct files):
- `visual_commands.lua`: `create_visual_command_wrapper`, `exit_visual_and_schedule`, `validate_visual_mode`, `get_visual_range`, `get_tree_state`, `capture_visual_selection_data`, and `get_files_from_visual_selection` [visual_commands.lua:M.create_visual_command_wrapper]
- `visual_commands.get_tree_state()` knows only neo-tree (when its window is current), nvim-tree, oil, and netrw. It returns `nil` for `minifiles` and `snacks_picker_list` [visual_commands.lua:M.get_tree_state]
- `cwd.lua`: `resolve(term_cfg, ctx)` and `git_root(start_dir)` [cwd.lua:M.resolve]. Nothing in `lua/` calls `cwd.resolve`. `terminal.lua:build_config` has its own copy of the cwd order and calls only `cwd.git_root`, so a change to `resolve` does not change the terminal [terminal.lua:build_config]
- `utils.lua`: `shell_split`, `parse_command`, `expand_tilde`, `normalize_focus`, `selection_type`, `utf8_sequence_length`, `valid_utf8`, `map_terminal_suspend`, `win_set_buf_without_bufenter`, and `when_windows_allowed` [utils.lua:M.parse_command] [utils.lua:M.valid_utf8] [utils.lua:M.map_terminal_suspend] [utils.lua:M.win_set_buf_without_bufenter] [utils.lua:M.when_windows_allowed]
- `utils.lua`: `shell_split`, `parse_command`, `expand_tilde`, `normalize_focus`, `selection_type`, `utf8_sequence_length`, `valid_utf8`, `map_terminal_suspend`, `win_set_buf_without_bufenter`, `when_windows_allowed`, and `resume_tool_coroutine` [utils.lua:M.parse_command] [utils.lua:M.valid_utf8] [utils.lua:M.map_terminal_suspend] [utils.lua:M.win_set_buf_without_bufenter] [utils.lua:M.when_windows_allowed] [utils.lua:M.resume_tool_coroutine]
- `logger.lua`: `levels`, `setup`, `error`, `warn`, `info`, `one_line`, `debug`, `trace`, and `is_level_enabled` [logger.lua:M.setup]
- `health.lua`: `check()`. It checks, in order: Neovim 0.8.0 or later, `require("claudecode")`, that `setup()` ran, the CLI (the first word of `terminal_cmd`, or `claude`) and its `--version`, the terminal provider, then the server, its lock file (present, missing, or there but not readable), and a connected client. It stops after a failed load, a missing `setup()`, or a stopped server [health.lua:M.check] [health.lua:check_cli] [health.lua:check_server]

Expand Down
9 changes: 1 addition & 8 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1964,13 +1964,6 @@ 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. A LuaJIT
-- older than September 2023 (bundled by Neovim 0.8 and 0.9) 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 or crashes, exit 134 or 139 (LuaJIT issue #1066). 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,
Expand Down Expand Up @@ -2049,7 +2042,7 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t
return
end
-- Resume the coroutine with the result
local resume_success, resume_result, resume_err = coroutine.resume(co, result)
local resume_success, resume_result, resume_err = utils.resume_tool_coroutine(co, result)
if not resume_success then
logger.error("diff", "Coroutine failed:", tostring(resume_result))
end
Expand Down
3 changes: 2 additions & 1 deletion lua/claudecode/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- `M.register_all()`: registers 10 exposed tools and the internal `close_tab` [tools/init.lua:M.register_all]
- `M.get_tool_list()`: returns `{ name, description, inputSchema }` only for tools that have a `schema`. It walks `M.tools` with `pairs()`, so the order is not defined [tools/init.lua:M.get_tool_list]
- `M.handle_invoke(client, params)`: looks up `params.name` and runs the tool with `params.arguments`, or with `{}` when the call has none. It expects the params shape that the server's `tools/call` handler checked [tools/init.lua:M.handle_invoke]
- `M.build_response(ok, value, err)`: maps a handler outcome, as `pcall` or `coroutine.resume` returns it, to `{ result }` or `{ error }`. `handle_invoke` uses it, and so does `diff.open_diff_blocking` when it resumes a deferred tool [tools/init.lua:M.build_response]
- `M.build_response(ok, value, err)`: maps a handler outcome, as `pcall` or `utils.resume_tool_coroutine` returns it, to `{ result }` or `{ error }`. `handle_invoke` uses it, and so does `diff.open_diff_blocking` when it resumes a deferred tool [tools/init.lua:M.build_response]

### Registered tools

Expand All @@ -42,6 +42,7 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o
- A successful handler returns `{ result = <value> }` [tools/init.lua:M.handle_invoke]. Each tool handler builds the MCP shape `{ content = { { type = "text", text = ... } } }` itself [tools/open_file.lua:handler]. `openDiff` returns the value of `diff.open_diff_blocking` unchanged, and `claudecode.diff` builds that shape, or `claudecode.diff_inline` for the unified layout [tools/open_diff.lua:handler] [diff.lua:M._resolve_diff_as_saved]
- A call without `arguments` runs the tool with `{}`. A tool with a required parameter then raises `-32602`, and `getDiagnostics` returns the diagnostics of all buffers. No handler enforces `additionalProperties = false` [tools/init.lua:M.handle_invoke] [tools/get_diagnostics.lua:handler]
- Tools with `requires_coroutine` run in `coroutine.create`. If the coroutine yields, `handle_invoke` returns `{ _deferred = true, coroutine, client, params }` and sends no response yet. Only `openDiff` sets `requires_coroutine = true` [tools/init.lua:M.handle_invoke]
- `handle_invoke` and the answer to a deferred `openDiff` resume the tool's coroutine through `utils.resume_tool_coroutine`. On a LuaJIT build older than 2023-09-21 (the release builds of Neovim 0.8 and 0.9 are such builds), or one whose `jit.version` the plugin cannot read, 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. This guards against LuaJIT issue 1066, which aborts Neovim. On those builds each such resume discards the compiled traces of the whole editor, which LuaJIT compiles again when the code gets hot. Any other LuaJIT build gets a plain `coroutine.resume` [utils.lua:M.resume_tool_coroutine] [tools/init.lua:M.handle_invoke] [diff.lua:M.open_diff_blocking]
- `openDiff` raises `-32602` when a required parameter is missing or is not a string. Outside a non-main coroutine it raises `-32000` with `message = "Internal server error"` and `data = "openDiff must run in coroutine context"`. It raises `-32000` with `data = "Failed to load diff module"` when `claudecode.diff` does not load [tools/open_diff.lua:handler]
- After a deferred `openDiff` yields, `diff.open_diff_blocking` maps the outcome with `build_response` and sends it through `_G.claude_deferred_responses[tostring(co)]`, then clears that entry [diff.lua:M.open_diff_blocking]
- `openDiff` calls `diff.open_diff_blocking(old, new, contents, tab_name, client.id)`. Structured errors pass through. Other errors become `-32000` "Error opening blocking diff" [tools/open_diff.lua:handler]
Expand Down
4 changes: 3 additions & 1 deletion lua/claudecode/tools/init.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
-- Tool implementation for Claude Code Neovim integration
local M = {}

local utils = require("claudecode.utils")

M.ERROR_CODES = {
PARSE_ERROR = -32700,
INVALID_REQUEST = -32600,
Expand Down Expand Up @@ -110,7 +112,7 @@ function M.handle_invoke(client, params) -- client needed for blocking tools
end)

require("claudecode.logger").debug("tools", "About to resume coroutine for " .. tool_name)
local success, result, err = coroutine.resume(co)
local success, result, err = utils.resume_tool_coroutine(co)
require("claudecode.logger").debug(
"tools",
"Coroutine resume returned - success:",
Expand Down
33 changes: 33 additions & 0 deletions lua/claudecode/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,37 @@ function M.valid_utf8(text)
return table.concat(parts)
end

-- 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`). The commit sets `cur_L`
-- again in `err_unwind` (src/lj_err.c). A Neovim API call in a coroutine that runs a Lua callback and then raises
-- behaves like that C function, and Neovim aborts (exit 134 or 139). We did not read the LuaJIT source for other code
-- that uses `cur_L`. Measured: with the JIT off and its traces flushed while the coroutine runs, Neovim survives
-- (tests/integration/diff_tool_coroutine_jit_spec.lua). Rolling releases are numbered 2.1.<unix time of the commit>.
-- A version that is no string, or does not read that way, counts as affected, which costs only a flush.
local jit_affected = jit ~= nil and (tonumber(tostring(jit.version):match("^LuaJIT 2%.1%.(%d+)$")) or 0) < 1695261248

---Turns the JIT back on when `was_on`, and passes the other arguments on as they are, trailing nils included.
local function restore_jit(was_on, ...)
if was_on then
jit.on()
end
return ...
end

---`coroutine.resume` for the coroutine of a tool (`handle_invoke` and the resolution of a blocking diff). On a LuaJIT
---with issue 1066 it turns the JIT off and flushes its traces before the resume, and gives the JIT back as it was.
---@param co thread
---@param ... any Values for the coroutine
---@return boolean success
---@return any ... What `coroutine.resume` returns
function M.resume_tool_coroutine(co, ...)
local was_on = jit_affected and jit.status()
if jit_affected then
jit.off()
jit.flush()
end
return restore_jit(was_on, coroutine.resume(co, ...))
end

return M
14 changes: 8 additions & 6 deletions tests/integration/diff_failed_setup_guards_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,14 @@ describe("openDiff when the setup fails in a new tab", function()
closes_the_new_tab_and_goes_back(a_BufLeave_handler_raises_during_the_cleanup)
end)

-- A LuaJIT older than September 2023 (bundled by Neovim 0.8 and 0.9) has a bug (LuaJIT issue #1066). 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") or
-- crashes (exit 139), and plenary prints no summary. openDiff runs in a coroutine (tools.handle_invoke). On Neovim
-- 0.8.0 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 and 0.9 when diff.lua leaves the bug in place.
-- A LuaJIT older than 2023-09-21 (the release builds of Neovim 0.8 and 0.9) has a bug (LuaJIT issue #1066): after a
-- C function runs Lua on another thread and then raises into a pcall, the next exit from a compiled trace crashes. A
-- Neovim API call that raises inside a coroutine because a handler it ran raised is that case. Neovim then aborts
-- (exit 134, "bad stack slot") or crashes (exit 139), and plenary prints no summary. openDiff runs in a coroutine
-- (tools.handle_invoke). On such a LuaJIT, utils.resume_tool_coroutine turns the JIT off and flushes its traces
-- before it resumes that coroutine. Without that guard, the three cases above ended with a plenary summary in 100 of
-- 100 runs each on v0.8.0 and on v0.9.5, so they do not show the bug. A JIT that compiles at once makes the passes
-- below hit it in most runs, so this case fails on Neovim 0.8 and 0.9 when the guard is missing.
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")
Expand Down
Loading
Loading