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
84 changes: 84 additions & 0 deletions fixtures/issue-218/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Fixture: issue #218 — Neovim crashes accepting a new-file markdown diff with render-markdown.nvim

> [BUG] Neovim crashes when accepting new file diff with render-markdown.nvim installed
> https://github.com/coder/claudecode.nvim/issues/218

Accepting (`:w`) a **new-file** diff whose proposed buffer is **markdown**, when the
diff was opened in a **new tab** (`diff_opts.open_in_new_tab = true`) and
**render-markdown.nvim** is installed, abnormally terminates Neovim. Existing-file
diffs are fine — only new files (the original side is an empty buffer, so every
proposed line is a diff "add").

This fixture mirrors the reporter's minimal `repro.lua` (snacks.nvim +
claudecode.nvim + render-markdown.nvim) but loads the **local** claudecode.nvim
checkout (resolved in `lua/config/lazy.lua`), so it exercises this repo's code.

## Reproduce (no real Claude needed)

```sh
source fixtures/nvim-aliases.sh
vv issue-218
```

Then in Neovim:

1. `:Repro218` — opens a harmless Claude terminal split and a **new-file markdown
diff in a new tab**, leaving the cursor in the proposed (right) pane. It drives
the same `openDiff` coroutine flow the MCP server uses and wires the post-accept
`close_tab` exactly as the Claude CLI would.
2. Press `:w` to accept.
3. **Neovim disappears.** Depending on the Neovim build the symptom is either a
`SIGSEGV` (raw exit `139`, what the reporter saw) or an abnormal exit `0` with
no `VimLeave` — both are the same memory-unsafety bug.

`:Repro218Reset` restores a single clean tab if you ran the setup but did not press
`:w`.

### Scripted / CI-style verification

```sh
# Expect: "#218 REPRODUCED — Neovim SIGSEGV (139) on diff accept."
NVIM_BIN=/path/to/nvim-0.12.x scripts/repro_issue_218.sh

# Control — expect: "without render-markdown the diff accepts cleanly (no crash)."
scripts/repro_issue_218.sh --no-render-markdown
```

The crash lives in the redraw/teardown path, which does **not** run under
`--headless`, so the driver uses the `agent-tty` CLI to get a real terminal UI.

## Reproduce with the real Claude CLI (faithful to the report)

1. `vv issue-218`, then open the Claude terminal (`<C-,>` / `:ClaudeCode`).
2. **Turn off auto-accept** (`shift+tab` until the "auto mode" indicator is gone) —
in auto mode Claude writes files directly and never sends `openDiff`.
3. Ask Claude to create a new `.md` file (e.g. _"create demo.md with a heading, a
list, a code block and a table using the Write tool"_).
4. When the diff opens, with the cursor in the proposed pane, press `:w`.

## Root cause (verified)

On accept, claudecode resolves the diff and the Claude CLI sends `close_tab`, which
runs `diff.close_diff_by_tab_name` → `_cleanup_diff_state`. For the
`open_in_new_tab` path this executes `vim.cmd("tabclose")` on the tab whose windows
are **still in diff mode**, while render-markdown.nvim is attached to the markdown
proposed buffer and the Claude terminal is open in the other tab. That `:tabclose`
is where Neovim dies (the surrounding `pcall` cannot catch it — it is a C-level
abnormal termination, not a Lua error, and `VimLeave` never fires).

Confirmed by isolation:

| render-markdown | Claude terminal | outcome on `:w` accept |
| ---------------------------------------------------- | --------------- | ---------------------- |
| installed | open | **crash** (tabclose) |
| **removed** | open | clean teardown |
| installed | not open | clean teardown |
| installed (diff mode turned **off** before teardown) | open | clean teardown |

The reporter's workaround — `pcall(vim.cmd, "diffoff")` at the end of the
`BufWriteCmd` callback — works because turning diff mode off before the tab is
closed removes the trigger. A more targeted plugin-side fix is to turn diff mode
off on the diff windows inside `_cleanup_diff_state` **before** `:tabclose`.

Reproduced on: Neovim 0.11.0 (abnormal exit 0) and Neovim 0.12.3 (SIGSEGV 139),
render-markdown.nvim 8.12.0 and 8.13.0, claudecode.nvim current `main`.
27 changes: 27 additions & 0 deletions fixtures/issue-218/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Fixture for issue #218:
-- "[BUG] Neovim crashes when accepting new file diff with render-markdown.nvim
-- installed"
-- https://github.com/coder/claudecode.nvim/issues/218
--
-- Symptom: Neovim abnormally terminates (SIGSEGV / exit 139 for the reporter)
-- when a NEW-file diff opened in a NEW TAB (open_in_new_tab = true) and whose
-- proposed buffer is markdown is accepted with `:w`, but only when
-- render-markdown.nvim is installed (attached to that markdown buffer) and the
-- Claude terminal is open. Existing-file diffs are fine; the crash is specific
-- to new files (the original side is an empty buffer, so every proposed line is
-- a diff "add"). See lua/plugins/dev-claudecode.lua and the README for the
-- verified root cause (the teardown's `:tabclose` over still-in-diff windows).
--
-- This fixture mirrors the reporter's minimal repro.lua (snacks.nvim +
-- claudecode.nvim + render-markdown.nvim) but loads the LOCAL claudecode.nvim
-- checkout (via `dir`, resolved in lua/config/lazy.lua) so we test this repo's
-- code, including git worktrees.
--
-- Usage (from repo root):
-- source fixtures/nvim-aliases.sh && vv issue-218
-- then run `:Repro218` and, once it reports "Repro218 ready", type `:w` with the
-- cursor in the proposed (right) pane. Neovim disappears on accept.
--
-- A faithful, scripted driver lives in scripts/repro_issue_218.sh (agent-tty).

require("config.lazy")
7 changes: 7 additions & 0 deletions fixtures/issue-218/lazy-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
"nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" },
"nvim-web-devicons": { "branch": "master", "commit": "dfbfaa967a6f7ec50789bead7ef87e336c1fa63c" },
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" },
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }
}
37 changes: 37 additions & 0 deletions fixtures/issue-218/lua/config/lazy.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
if vim.v.shell_error ~= 0 then
vim.api.nvim_echo({
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
{ out, "WarningMsg" },
{ "\nPress any key to exit..." },
}, true, {})
vim.fn.getchar()
os.exit(1)
end
end
vim.opt.rtp:prepend(lazypath)

vim.g.mapleader = " "
vim.g.maplocalleader = "\\"

-- Resolve the claudecode.nvim checkout that owns this fixture.
-- XDG_CONFIG_HOME is set to the `fixtures/` dir by the `vv` launcher, so the
-- repository root is its parent. This makes the fixture load the local plugin
-- copy (including git worktrees) without relying on lazy's default dev path.
local repo_root = vim.fn.fnamemodify(vim.env.XDG_CONFIG_HOME or vim.fn.getcwd(), ":h")
vim.g.claudecode_dev_dir = repo_root

require("lazy").setup({
spec = {
{ import = "plugins" },
},
install = { colorscheme = { "habamax" } },
checker = { enabled = false },
})

vim.keymap.set("n", "<leader>l", "<cmd>Lazy<cr>", { desc = "Lazy Plugin Manager" })
vim.keymap.set("t", "<Esc><Esc>", "<C-\\><C-n>", { desc = "Exit terminal mode (double esc)" })
150 changes: 150 additions & 0 deletions fixtures/issue-218/lua/plugins/dev-claudecode.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
-- claudecode.nvim (local checkout) configured exactly like the reporter's
-- repro.lua for issue #218, plus a deterministic, Claude-free trigger
-- (:Repro218) that recreates the precise state that crashes Neovim.
--
-- Root cause (verified): accepting (:w) a NEW-file *markdown* diff that was
-- opened in a NEW TAB (diff_opts.open_in_new_tab = true) tears the diff down via
-- diff.close_diff_by_tab_name -> _cleanup_diff_state, which runs `:tabclose` on
-- the tab whose windows are STILL in diff mode. When render-markdown.nvim is
-- attached to that markdown buffer and the Claude terminal is open in the other
-- tab, that `:tabclose` abnormally terminates Neovim (SIGSEGV / exit 139 for the
-- reporter). Removing render-markdown, or turning diff mode off before the
-- teardown (the reporter's `diffoff` workaround), avoids it.
--
-- :Repro218 reproduces this with NO real Claude needed: it opens a harmless
-- Claude *terminal* (a sleeping process) so the layout matches real usage, then
-- opens the new-tab markdown diff through the SAME coroutine machinery the
-- openDiff MCP tool uses (diff.open_diff_blocking), wiring the deferred response
-- so that accepting the diff simulates Claude writing the file and sending
-- close_tab. Focus is left in the proposed pane: just press `:w` to crash.
--
-- We load eagerly (lazy = false) so the diff module is configured at startup.
return {
"coder/claudecode.nvim",
dir = vim.g.claudecode_dev_dir,
dependencies = { "folke/snacks.nvim" },
lazy = false,
keys = {
{ "<C-,>", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
},
---@type PartialClaudeCodeConfig
opts = {
terminal = { split_side = "left", split_width_percentage = 0.5 },
diff_opts = { open_in_new_tab = true, hide_terminal_in_new_tab = true },
},
config = function(_, opts)
require("claudecode").setup(opts)

-- Markdown payload with the structures render-markdown attaches to (headings,
-- fenced code, lists, blockquotes, tables).
local function payload()
return table.concat({
"# Issue 218 Repro",
"",
"This is a **new** markdown file proposed by Claude.",
"",
"## Section heading",
"",
"- item one",
"- item two",
" - nested",
"",
"```lua",
"local x = 1",
"print(x)",
"```",
"",
"> A blockquote for render-markdown to decorate.",
"",
"| col a | col b |",
"| ----- | ----- |",
"| 1 | 2 |",
"",
"1. first",
"2. second",
"",
}, "\n") .. "\n"
end

-- Open a harmless Claude *terminal* (a sleeping process) without stealing
-- focus, so the window layout matches real usage. The crash only reproduces
-- when the Claude terminal is present in the original tab.
local function ensure_dummy_terminal()
local term = require("claudecode.terminal")
if term.get_active_terminal_bufnr and term.get_active_terminal_bufnr() then
return -- a terminal is already open (e.g. real :ClaudeCode); leave it
end
-- Override the launched command so no real Claude/API is needed.
term.defaults.terminal_cmd = "sh -c 'while :; do sleep 3600; done'"
pcall(vim.cmd, "ClaudeCodeStart")
pcall(function()
term.toggle_open_no_focus()
end)
end

-- Recreate the exact openDiff flow the server runs: open_diff_blocking inside
-- a coroutine, with _G.claude_deferred_responses wired so the :w resolution
-- resumes the coroutine and then simulates Claude (write the file to disk,
-- then send close_tab via close_diff_by_tab_name on a later tick).
local function repro_218()
ensure_dummy_terminal()
local diff = require("claudecode.diff")
local new_file = vim.fn.tempname() .. "_issue218.md"
pcall(os.remove, new_file) -- ensure it does not exist => is_new_file = true
local contents = payload()
local tab_name = "✻ [Claude Code] issue218.md ⧉"

_G.claude_deferred_responses = _G.claude_deferred_responses or {}
local co = coroutine.create(function()
return diff.open_diff_blocking(new_file, new_file, contents, tab_name, nil)
end)
_G.claude_deferred_responses[tostring(co)] = function()
-- Claude received FILE_SAVED: write the file, then send close_tab.
vim.schedule(function()
local fh = io.open(new_file, "w")
if fh then
fh:write(contents)
fh:close()
end
vim.schedule(function()
pcall(diff.close_diff_by_tab_name, tab_name)
end)
end)
end

-- Defer the open so the terminal split settles first, then resume the
-- coroutine (which sets up the diff) and leave focus in the proposed pane.
vim.schedule(function()
coroutine.resume(co)
vim.schedule(function()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(b)
if name:match("proposed") and vim.bo[b].buftype == "acwrite" then
local win = vim.fn.win_findbuf(b)[1]
if win then
vim.api.nvim_set_current_win(win)
end
end
end
vim.api.nvim_echo({
{
"Repro218 ready. Press :w in the proposed pane to accept (Neovim crashes — issue #218).",
"WarningMsg",
},
}, false, {})
end)
end)
end

vim.api.nvim_create_user_command("Repro218", repro_218, {
desc = "Set up the issue #218 crash: open a NEW-file markdown diff in a new tab; then :w",
})

vim.api.nvim_create_user_command("Repro218Reset", function()
require("claudecode.diff")._cleanup_all_active_diffs("repro reset")
vim.cmd("silent! tabonly!")
vim.cmd("silent! only!")
vim.cmd("silent! enew!")
end, { desc = "Reset the #218 repro layout" })
end,
}
20 changes: 20 additions & 0 deletions fixtures/issue-218/lua/plugins/render-markdown.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- render-markdown.nvim — the plugin the reporter fingered. Removing it makes the
-- crash disappear (verified). Note: it does NOT render diff-mode windows by
-- default; merely being *attached* to the markdown proposed buffer (its
-- buffer-local autocmds + treesitter) is enough to make the teardown's
-- `:tabclose` crash Neovim.
--
-- Mirrors the reporter's spec (deps on nvim-treesitter + web-devicons, default
-- opts). Neovim 0.11+ bundles the markdown/markdown_inline treesitter parsers, so
-- render-markdown attaches even without nvim-treesitter installing anything.
-- Loaded eagerly so it is attached before any diff opens.
return {
"MeanderingProgrammer/render-markdown.nvim",
dependencies = {
"nvim-treesitter/nvim-treesitter",
"nvim-tree/nvim-web-devicons",
},
lazy = false,
---@type render.md.UserConfig
opts = {},
}
10 changes: 10 additions & 0 deletions fixtures/issue-218/lua/plugins/snacks.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- snacks.nvim, present in the reporter's repro. claudecode's terminal provider
-- auto-selects snacks when available; including it keeps the environment faithful
-- even though the crash itself is in the diff/redraw path, not the terminal.
return {
"folke/snacks.nvim",
priority = 1000,
lazy = false,
---@type snacks.Config
opts = {},
}
5 changes: 5 additions & 0 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,11 @@ local function register_diff_autocmds(tab_name, new_buffer)
buffer = new_buffer,
callback = function()
M._resolve_diff_as_saved(tab_name, new_buffer)
-- Explicitly turn off diff mode before Neovim does its post-write redraw.
-- This prevents a crash (exit code 139) when render-markdown.nvim is installed
-- and the diff involves a new file. Without this, the post-callback redraw
-- triggers render-markdown on a buffer still in diff mode, causing a segfault.
pcall(vim.cmd, "diffoff")
-- Prevent actual file write since we're handling it through MCP
return true
end,
Expand Down
Loading
Loading