Skip to content

Commit 5a04f8b

Browse files
bluzernameThomasK33claude
authored
fix: prevent crash with render-markdown.nvim on new file diff accept (#224)
## Problem Neovim crashes with exit code 139 (segfault) when you accept a new file diff by pressing `:w` while `render-markdown.nvim` is installed. Only happens with new files, existing file diffs work fine. I was losing my neovim session every time Claude suggested a new markdown file and I tried to accept it. Very annoying because you lose all your buffers. ## Root Cause After the `BufWriteCmd` callback finishes `_resolve_diff_as_saved()` and returns, Neovim does a post-write redraw. At this point the buffer is still in diff mode, and `render-markdown.nvim` tries to render it during the redraw cycle. For new files (where no old file existed on disk), this combination triggers a segfault. The issue reporter (@tomerlevy1) did excellent debugging with breadcrumb logging and confirmed that all Lua code completes successfully - the crash happens after the callback returns control back to Neovim's C code. ## Fix Added `pcall(vim.cmd, "diffoff")` after `_resolve_diff_as_saved()` but before `return true` in the BufWriteCmd callback. This explicitly turns off diff mode before Neovim does the post-write redraw, so when render-markdown.nvim runs during redraw the buffer is no longer in diff state. Using `pcall` because if diffoff fails for whatever reason we still want the callback to complete normally. ```lua callback = function() M._resolve_diff_as_saved(tab_name, new_buffer) pcall(vim.cmd, "diffoff") -- prevents segfault with render-markdown.nvim return true end, ``` ## Testing - Lua syntax verified - This is the exact fix that the issue author tested and confirmed prevents the crash - The change is minimal (one line) and uses pcall so it cannot introduce new errors - Only affects the BufWriteCmd callback for new file diffs, existing behavior is unchanged Closes #218 --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6f4a2ce commit 5a04f8b

9 files changed

Lines changed: 490 additions & 0 deletions

File tree

fixtures/issue-218/README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Fixture: issue #218 — Neovim crashes accepting a new-file markdown diff with render-markdown.nvim
2+
3+
> [BUG] Neovim crashes when accepting new file diff with render-markdown.nvim installed
4+
> https://github.com/coder/claudecode.nvim/issues/218
5+
6+
Accepting (`:w`) a **new-file** diff whose proposed buffer is **markdown**, when the
7+
diff was opened in a **new tab** (`diff_opts.open_in_new_tab = true`) and
8+
**render-markdown.nvim** is installed, abnormally terminates Neovim. Existing-file
9+
diffs are fine — only new files (the original side is an empty buffer, so every
10+
proposed line is a diff "add").
11+
12+
This fixture mirrors the reporter's minimal `repro.lua` (snacks.nvim +
13+
claudecode.nvim + render-markdown.nvim) but loads the **local** claudecode.nvim
14+
checkout (resolved in `lua/config/lazy.lua`), so it exercises this repo's code.
15+
16+
## Reproduce (no real Claude needed)
17+
18+
```sh
19+
source fixtures/nvim-aliases.sh
20+
vv issue-218
21+
```
22+
23+
Then in Neovim:
24+
25+
1. `:Repro218` — opens a harmless Claude terminal split and a **new-file markdown
26+
diff in a new tab**, leaving the cursor in the proposed (right) pane. It drives
27+
the same `openDiff` coroutine flow the MCP server uses and wires the post-accept
28+
`close_tab` exactly as the Claude CLI would.
29+
2. Press `:w` to accept.
30+
3. **Neovim disappears.** Depending on the Neovim build the symptom is either a
31+
`SIGSEGV` (raw exit `139`, what the reporter saw) or an abnormal exit `0` with
32+
no `VimLeave` — both are the same memory-unsafety bug.
33+
34+
`:Repro218Reset` restores a single clean tab if you ran the setup but did not press
35+
`:w`.
36+
37+
### Scripted / CI-style verification
38+
39+
```sh
40+
# Expect: "#218 REPRODUCED — Neovim SIGSEGV (139) on diff accept."
41+
NVIM_BIN=/path/to/nvim-0.12.x scripts/repro_issue_218.sh
42+
43+
# Control — expect: "without render-markdown the diff accepts cleanly (no crash)."
44+
scripts/repro_issue_218.sh --no-render-markdown
45+
```
46+
47+
The crash lives in the redraw/teardown path, which does **not** run under
48+
`--headless`, so the driver uses the `agent-tty` CLI to get a real terminal UI.
49+
50+
## Reproduce with the real Claude CLI (faithful to the report)
51+
52+
1. `vv issue-218`, then open the Claude terminal (`<C-,>` / `:ClaudeCode`).
53+
2. **Turn off auto-accept** (`shift+tab` until the "auto mode" indicator is gone) —
54+
in auto mode Claude writes files directly and never sends `openDiff`.
55+
3. Ask Claude to create a new `.md` file (e.g. _"create demo.md with a heading, a
56+
list, a code block and a table using the Write tool"_).
57+
4. When the diff opens, with the cursor in the proposed pane, press `:w`.
58+
59+
## Root cause (verified)
60+
61+
On accept, claudecode resolves the diff and the Claude CLI sends `close_tab`, which
62+
runs `diff.close_diff_by_tab_name``_cleanup_diff_state`. For the
63+
`open_in_new_tab` path this executes `vim.cmd("tabclose")` on the tab whose windows
64+
are **still in diff mode**, while render-markdown.nvim is attached to the markdown
65+
proposed buffer and the Claude terminal is open in the other tab. That `:tabclose`
66+
is where Neovim dies (the surrounding `pcall` cannot catch it — it is a C-level
67+
abnormal termination, not a Lua error, and `VimLeave` never fires).
68+
69+
Confirmed by isolation:
70+
71+
| render-markdown | Claude terminal | outcome on `:w` accept |
72+
| ---------------------------------------------------- | --------------- | ---------------------- |
73+
| installed | open | **crash** (tabclose) |
74+
| **removed** | open | clean teardown |
75+
| installed | not open | clean teardown |
76+
| installed (diff mode turned **off** before teardown) | open | clean teardown |
77+
78+
The reporter's workaround — `pcall(vim.cmd, "diffoff")` at the end of the
79+
`BufWriteCmd` callback — works because turning diff mode off before the tab is
80+
closed removes the trigger. A more targeted plugin-side fix is to turn diff mode
81+
off on the diff windows inside `_cleanup_diff_state` **before** `:tabclose`.
82+
83+
Reproduced on: Neovim 0.11.0 (abnormal exit 0) and Neovim 0.12.3 (SIGSEGV 139),
84+
render-markdown.nvim 8.12.0 and 8.13.0, claudecode.nvim current `main`.

fixtures/issue-218/init.lua

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- Fixture for issue #218:
2+
-- "[BUG] Neovim crashes when accepting new file diff with render-markdown.nvim
3+
-- installed"
4+
-- https://github.com/coder/claudecode.nvim/issues/218
5+
--
6+
-- Symptom: Neovim abnormally terminates (SIGSEGV / exit 139 for the reporter)
7+
-- when a NEW-file diff opened in a NEW TAB (open_in_new_tab = true) and whose
8+
-- proposed buffer is markdown is accepted with `:w`, but only when
9+
-- render-markdown.nvim is installed (attached to that markdown buffer) and the
10+
-- Claude terminal is open. Existing-file diffs are fine; the crash is specific
11+
-- to new files (the original side is an empty buffer, so every proposed line is
12+
-- a diff "add"). See lua/plugins/dev-claudecode.lua and the README for the
13+
-- verified root cause (the teardown's `:tabclose` over still-in-diff windows).
14+
--
15+
-- This fixture mirrors the reporter's minimal repro.lua (snacks.nvim +
16+
-- claudecode.nvim + render-markdown.nvim) but loads the LOCAL claudecode.nvim
17+
-- checkout (via `dir`, resolved in lua/config/lazy.lua) so we test this repo's
18+
-- code, including git worktrees.
19+
--
20+
-- Usage (from repo root):
21+
-- source fixtures/nvim-aliases.sh && vv issue-218
22+
-- then run `:Repro218` and, once it reports "Repro218 ready", type `:w` with the
23+
-- cursor in the proposed (right) pane. Neovim disappears on accept.
24+
--
25+
-- A faithful, scripted driver lives in scripts/repro_issue_218.sh (agent-tty).
26+
27+
require("config.lazy")

fixtures/issue-218/lazy-lock.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
3+
"nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" },
4+
"nvim-web-devicons": { "branch": "master", "commit": "dfbfaa967a6f7ec50789bead7ef87e336c1fa63c" },
5+
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" },
6+
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }
7+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- Bootstrap lazy.nvim
2+
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
3+
if not (vim.uv or vim.loop).fs_stat(lazypath) then
4+
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
5+
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
6+
if vim.v.shell_error ~= 0 then
7+
vim.api.nvim_echo({
8+
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
9+
{ out, "WarningMsg" },
10+
{ "\nPress any key to exit..." },
11+
}, true, {})
12+
vim.fn.getchar()
13+
os.exit(1)
14+
end
15+
end
16+
vim.opt.rtp:prepend(lazypath)
17+
18+
vim.g.mapleader = " "
19+
vim.g.maplocalleader = "\\"
20+
21+
-- Resolve the claudecode.nvim checkout that owns this fixture.
22+
-- XDG_CONFIG_HOME is set to the `fixtures/` dir by the `vv` launcher, so the
23+
-- repository root is its parent. This makes the fixture load the local plugin
24+
-- copy (including git worktrees) without relying on lazy's default dev path.
25+
local repo_root = vim.fn.fnamemodify(vim.env.XDG_CONFIG_HOME or vim.fn.getcwd(), ":h")
26+
vim.g.claudecode_dev_dir = repo_root
27+
28+
require("lazy").setup({
29+
spec = {
30+
{ import = "plugins" },
31+
},
32+
install = { colorscheme = { "habamax" } },
33+
checker = { enabled = false },
34+
})
35+
36+
vim.keymap.set("n", "<leader>l", "<cmd>Lazy<cr>", { desc = "Lazy Plugin Manager" })
37+
vim.keymap.set("t", "<Esc><Esc>", "<C-\\><C-n>", { desc = "Exit terminal mode (double esc)" })
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
-- claudecode.nvim (local checkout) configured exactly like the reporter's
2+
-- repro.lua for issue #218, plus a deterministic, Claude-free trigger
3+
-- (:Repro218) that recreates the precise state that crashes Neovim.
4+
--
5+
-- Root cause (verified): accepting (:w) a NEW-file *markdown* diff that was
6+
-- opened in a NEW TAB (diff_opts.open_in_new_tab = true) tears the diff down via
7+
-- diff.close_diff_by_tab_name -> _cleanup_diff_state, which runs `:tabclose` on
8+
-- the tab whose windows are STILL in diff mode. When render-markdown.nvim is
9+
-- attached to that markdown buffer and the Claude terminal is open in the other
10+
-- tab, that `:tabclose` abnormally terminates Neovim (SIGSEGV / exit 139 for the
11+
-- reporter). Removing render-markdown, or turning diff mode off before the
12+
-- teardown (the reporter's `diffoff` workaround), avoids it.
13+
--
14+
-- :Repro218 reproduces this with NO real Claude needed: it opens a harmless
15+
-- Claude *terminal* (a sleeping process) so the layout matches real usage, then
16+
-- opens the new-tab markdown diff through the SAME coroutine machinery the
17+
-- openDiff MCP tool uses (diff.open_diff_blocking), wiring the deferred response
18+
-- so that accepting the diff simulates Claude writing the file and sending
19+
-- close_tab. Focus is left in the proposed pane: just press `:w` to crash.
20+
--
21+
-- We load eagerly (lazy = false) so the diff module is configured at startup.
22+
return {
23+
"coder/claudecode.nvim",
24+
dir = vim.g.claudecode_dev_dir,
25+
dependencies = { "folke/snacks.nvim" },
26+
lazy = false,
27+
keys = {
28+
{ "<C-,>", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
29+
},
30+
---@type PartialClaudeCodeConfig
31+
opts = {
32+
terminal = { split_side = "left", split_width_percentage = 0.5 },
33+
diff_opts = { open_in_new_tab = true, hide_terminal_in_new_tab = true },
34+
},
35+
config = function(_, opts)
36+
require("claudecode").setup(opts)
37+
38+
-- Markdown payload with the structures render-markdown attaches to (headings,
39+
-- fenced code, lists, blockquotes, tables).
40+
local function payload()
41+
return table.concat({
42+
"# Issue 218 Repro",
43+
"",
44+
"This is a **new** markdown file proposed by Claude.",
45+
"",
46+
"## Section heading",
47+
"",
48+
"- item one",
49+
"- item two",
50+
" - nested",
51+
"",
52+
"```lua",
53+
"local x = 1",
54+
"print(x)",
55+
"```",
56+
"",
57+
"> A blockquote for render-markdown to decorate.",
58+
"",
59+
"| col a | col b |",
60+
"| ----- | ----- |",
61+
"| 1 | 2 |",
62+
"",
63+
"1. first",
64+
"2. second",
65+
"",
66+
}, "\n") .. "\n"
67+
end
68+
69+
-- Open a harmless Claude *terminal* (a sleeping process) without stealing
70+
-- focus, so the window layout matches real usage. The crash only reproduces
71+
-- when the Claude terminal is present in the original tab.
72+
local function ensure_dummy_terminal()
73+
local term = require("claudecode.terminal")
74+
if term.get_active_terminal_bufnr and term.get_active_terminal_bufnr() then
75+
return -- a terminal is already open (e.g. real :ClaudeCode); leave it
76+
end
77+
-- Override the launched command so no real Claude/API is needed.
78+
term.defaults.terminal_cmd = "sh -c 'while :; do sleep 3600; done'"
79+
pcall(vim.cmd, "ClaudeCodeStart")
80+
pcall(function()
81+
term.toggle_open_no_focus()
82+
end)
83+
end
84+
85+
-- Recreate the exact openDiff flow the server runs: open_diff_blocking inside
86+
-- a coroutine, with _G.claude_deferred_responses wired so the :w resolution
87+
-- resumes the coroutine and then simulates Claude (write the file to disk,
88+
-- then send close_tab via close_diff_by_tab_name on a later tick).
89+
local function repro_218()
90+
ensure_dummy_terminal()
91+
local diff = require("claudecode.diff")
92+
local new_file = vim.fn.tempname() .. "_issue218.md"
93+
pcall(os.remove, new_file) -- ensure it does not exist => is_new_file = true
94+
local contents = payload()
95+
local tab_name = "✻ [Claude Code] issue218.md ⧉"
96+
97+
_G.claude_deferred_responses = _G.claude_deferred_responses or {}
98+
local co = coroutine.create(function()
99+
return diff.open_diff_blocking(new_file, new_file, contents, tab_name, nil)
100+
end)
101+
_G.claude_deferred_responses[tostring(co)] = function()
102+
-- Claude received FILE_SAVED: write the file, then send close_tab.
103+
vim.schedule(function()
104+
local fh = io.open(new_file, "w")
105+
if fh then
106+
fh:write(contents)
107+
fh:close()
108+
end
109+
vim.schedule(function()
110+
pcall(diff.close_diff_by_tab_name, tab_name)
111+
end)
112+
end)
113+
end
114+
115+
-- Defer the open so the terminal split settles first, then resume the
116+
-- coroutine (which sets up the diff) and leave focus in the proposed pane.
117+
vim.schedule(function()
118+
coroutine.resume(co)
119+
vim.schedule(function()
120+
for _, b in ipairs(vim.api.nvim_list_bufs()) do
121+
local name = vim.api.nvim_buf_get_name(b)
122+
if name:match("proposed") and vim.bo[b].buftype == "acwrite" then
123+
local win = vim.fn.win_findbuf(b)[1]
124+
if win then
125+
vim.api.nvim_set_current_win(win)
126+
end
127+
end
128+
end
129+
vim.api.nvim_echo({
130+
{
131+
"Repro218 ready. Press :w in the proposed pane to accept (Neovim crashes — issue #218).",
132+
"WarningMsg",
133+
},
134+
}, false, {})
135+
end)
136+
end)
137+
end
138+
139+
vim.api.nvim_create_user_command("Repro218", repro_218, {
140+
desc = "Set up the issue #218 crash: open a NEW-file markdown diff in a new tab; then :w",
141+
})
142+
143+
vim.api.nvim_create_user_command("Repro218Reset", function()
144+
require("claudecode.diff")._cleanup_all_active_diffs("repro reset")
145+
vim.cmd("silent! tabonly!")
146+
vim.cmd("silent! only!")
147+
vim.cmd("silent! enew!")
148+
end, { desc = "Reset the #218 repro layout" })
149+
end,
150+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- render-markdown.nvim — the plugin the reporter fingered. Removing it makes the
2+
-- crash disappear (verified). Note: it does NOT render diff-mode windows by
3+
-- default; merely being *attached* to the markdown proposed buffer (its
4+
-- buffer-local autocmds + treesitter) is enough to make the teardown's
5+
-- `:tabclose` crash Neovim.
6+
--
7+
-- Mirrors the reporter's spec (deps on nvim-treesitter + web-devicons, default
8+
-- opts). Neovim 0.11+ bundles the markdown/markdown_inline treesitter parsers, so
9+
-- render-markdown attaches even without nvim-treesitter installing anything.
10+
-- Loaded eagerly so it is attached before any diff opens.
11+
return {
12+
"MeanderingProgrammer/render-markdown.nvim",
13+
dependencies = {
14+
"nvim-treesitter/nvim-treesitter",
15+
"nvim-tree/nvim-web-devicons",
16+
},
17+
lazy = false,
18+
---@type render.md.UserConfig
19+
opts = {},
20+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- snacks.nvim, present in the reporter's repro. claudecode's terminal provider
2+
-- auto-selects snacks when available; including it keeps the environment faithful
3+
-- even though the crash itself is in the diff/redraw path, not the terminal.
4+
return {
5+
"folke/snacks.nvim",
6+
priority = 1000,
7+
lazy = false,
8+
---@type snacks.Config
9+
opts = {},
10+
}

lua/claudecode/diff.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,11 @@ local function register_diff_autocmds(tab_name, new_buffer)
979979
buffer = new_buffer,
980980
callback = function()
981981
M._resolve_diff_as_saved(tab_name, new_buffer)
982+
-- Explicitly turn off diff mode before Neovim does its post-write redraw.
983+
-- This prevents a crash (exit code 139) when render-markdown.nvim is installed
984+
-- and the diff involves a new file. Without this, the post-callback redraw
985+
-- triggers render-markdown on a buffer still in diff mode, causing a segfault.
986+
pcall(vim.cmd, "diffoff")
982987
-- Prevent actual file write since we're handling it through MCP
983988
return true
984989
end,

0 commit comments

Comments
 (0)