Repository navigation
nvim crashes with compositor assert (attrs[i] >= 0) in float layout; unset attrs reach the compositor on redraw after win config changes #521
Description
nvim crashes with compositor assert attrs[i] >= 0 when input window resizes frequently (float layout)
Environment
- opencode.nvim:
ee684b1(feat: worktree extension apis feat: worktree extension apis #517),ui.position = "float",border = "rounded" - nvim:
v0.13.0-dev-1464+g57d97e513d(RelWithDebInfo, so asserts are live) - macOS (Darwin), tmux-driven terminal
- Trigger context: Chinese (multibyte, double-width) input in the input window, insert mode, frequent height growth + terminal width changes
Symptom
While typing in the input window, nvim occasionally hard-crashes. Crash report
(nvim-2026-10-13-170607.ips, real-world occurrence after ~1h session):
__assert_rtn
ui_comp_raw_line +804 (src/nvim/ui_compositor.c, assert(attrs[i] >= 0))
ui_line +352
grid_draw_border +3424
update_screen +4244
ins_redraw +1004
insert_check
I.e. the float border redraw path emits cells whose highlight attr is -1
(unset) into the compositor's "raw" path (line not flagged invalid, not covered,
not blending).
Root-cause analysis
Reading ui_compositor.c at 57d97e513d:
compose_line()has a defensive sweep:if (attrbuf[i] < 0) attrbuf[i] = 0;
(aborts only underredrawdebug=invalid). So the compose path silently
swallows unset attrs.ui_comp_raw_line()has no such sweep on the raw path
(!(kLineFlagInvalid || covered || blending)): it assertsattrs[i] >= 0
directly.
Both entries receive cells from the same source: float-grid cells that were
never painted after a resize (attrs still -1) reaching the compositor before
the grid resize has been fully applied. Same underlying race as neovim
#41860 (stale column boundaries after resize), surfacing through the border
draw path.
Why it matters for this plugin: the float layout calls nvim_win_set_config()
on the output/input/footer windows unconditionally on every relevant event —
including no-op calls with identical geometry. Instrumentation (wrapper
around vim.api.nvim_win_set_config) during one typing session recorded
933 calls in ~2 minutes, many with before == after geometry. Each call
forces border recomposition, multiplying exposure to the core race by orders
of magnitude compared to a static float.
Reproduction
The race is intermittent in normal use. Two ingredients make it near-deterministic:
set redrawdebug=invalid— convertscompose_line's silent-1fix into
abort(), so the same invariant violation is caught on the compose path too.- Rapid terminal-width churn combined with per-keystroke input growth.
repro_plugin.lua (real plugin, float layout, instrumentation):
-- usage: nvim -u repro_plugin.lua (inside a tmux window, default size 220x50)
-- requires the opencode.nvim repo at the path below
local LOG = '/tmp/repro_trace.log'
local function tlog(msg)
local f = io.open(LOG, 'a')
if f then f:write(string.format('%s\n', msg)); f:close() end
end
vim.opt.redrawdebug:append('invalid') -- abort instead of silent attr fix
vim.opt.runtimepath:prepend('/Users/you/Projects/nvim-plugins/opencode.nvim')
local orig = vim.api.nvim_win_set_config
vim.api.nvim_win_set_config = function(win, cfg)
local ok, before = pcall(vim.api.nvim_win_get_config, win)
tlog(string.format('set_config win=%s h=%s w=%s (was h=%s w=%s)',
vim.api.nvim_win_get_number(win),
cfg.height and string.format('%.1f', cfg.height) or '-',
cfg.width and string.format('%.1f', cfg.width) or '-',
ok and before.height and string.format('%.1f', before.height) or '-',
ok and before.width and string.format('%.1f', before.width) or '-'))
return orig(win, cfg)
end
vim.api.nvim_create_autocmd('TextChangedI', { callback = function()
tlog('TextChangedI') end })
vim.defer_fn(function()
require('opencode.commands.handlers.window').actions.open_input()
end, 500)
require('opencode').setup({ ui = { position = 'float', float = { border = 'rounded' } } })churn driver (run from another terminal; the kill -0 pid is the
nvim --embed child):
# type long Chinese text to make the input window grow, then:
for i in {1..120}; do
tmux resize-window -t <session> -x $((140 + (i % 12) * 8))
tmux send-keys -t <session> -l "第${i}轮高频抖动文本,这个范式的价值是压缩矩阵低秩分解注意力头并行切分数据并行流水线并张量并行混合精度"
sleep 0.15
tmux send-keys -t <session> C-u
sleep 0.15
doneResult with the plugin: crashed at round 21 (nvim-2026-10-13-174637.ips):
abort
compose_line + (attrbuf[i] < 0 under redrawdebug=invalid)
ui_comp_put_grid +
ui_ext_win_position +
win_ui_flush +
update_screen +
screen_resize +
ui_refresh (terminal resize)
Same invariant as the raw crash, entered via the recompose path this time.
Attribution (bisection)
To check whether the plugin is merely a trigger or a required ingredient:
nvim --clean+ one floating window with border + Chinese text + same width churn + same-valueset_configchurn: no crash (40+30 rounds).- Same + second float stacked under it (z40/z41 like output/input) + footer float (z50) + per-frame height oscillation on all three: no crash.
So plain core churn with floats does not reproduce; the plugin's real layout
flow (per-keystroke resize of the input float driven by TextChangedI →
schedule_resize → float_layout.update, plus the output float's renderer
rewriting content) is what pushes the core race over the edge.
Suggested mitigation
- Guard every
nvim_win_set_configcall site (input, output and footer)
with a geometry comparison and skip no-op calls. Note: a naive guard in
float_layout.update()only is not sufficient — the footer window's
config is set elsewhere and was still observed doing same-value calls with
the guard in place. - Consider debouncing the resize behind
vim.defer_fnwith a slightly longer
window than one frame whenmode() == 'i'.
This would not fix the core race (neovim #41860 family), but should cut the
crash frequency in real sessions drastically.
Activity
jensenojs commentedon Oct 13, 2026
Repro artifacts: https://gist.github.com/jensenojs/d90ebd70f062268a8ed27f21e1a4be55
repro_plugin.lua— instrumented plugin repro (set_config wrapper + TextChangedI probe +redrawdebug=invalid)repro_core.lua— synthetic bisection attempts (plain float / 3-float overlap + height oscillation; these do not crash)churn_loop.sh— tmux width-churn driver (crashed at round 21)repro_trace.log— last recordednvim_win_set_configcalls before the abort (note the same-value footer calls)nvim-crash-user-report.ips— the original real-world crash (ui_comp_raw_lineassert,grid_draw_border)nvim-crash-repro-report.ips— the reproduced abort (compose_lineunderredrawdebug=invalid)jensenojs commentedon Oct 13, 2026
Follow-up with a stronger reproduction result.
With
set redrawdebug=invalidin the repro config, reproduction is deterministic: two runs out of two aborted, one at churn round 21, one at round 1. A third crash report shows the abort can also fire without any churn at all, right after the plugin opens:So the terminal-resize entry in the original report is only one way in; a plain redraw after opening the plugin can hit the same unset-attr state. Without
redrawdebug=invalidthe race stays intermittent (100+ churn rounds ran clean), which matches how rarely the real-worldui_comp_raw_lineassert shows up.The repro harness is now committed in this repo under
tests/manual/compositor_assert_repro/(plugin entryinit.lua, churn driverrun_churn.sh, no-plugin controlcontrol_floats.lua, README with steps and stacks). Artifacts and crash reports remain in the gist above.[-]nvim crashes with compositor assert (attrs[i] >= 0) when input window resizes frequently (float layout)[/-][+]nvim crashes with compositor assert (attrs[i] >= 0) in float layout; unset attrs reach the compositor on redraw after win config changes[/+]jensenojs commentedon Oct 13, 2026
Related neovim issues for context:
ui_comp_raw_line: attrs[i] >= 0, reached via statuslinejensenojs commentedon Oct 13, 2026
Bisection results — the churn is not the cause.
With
nvim_win_set_configstubbed to a no-op after the floats open (so the plugin never changes any float geometry), the first terminal resize still aborts. Same when all plugin resize reactions are stubbed out, whenstyle=minimal/winblend/winbar/placeholder are removed, and when the float buffers are cleared to plain ASCII with all extmarks cleared. The abort follows the float layout itself, not any config-change churn or buffer content:position = "current"(split layout)So per-keystroke config churn only raises the probability of hitting it; any redraw touching the plugin's float grids can. A plugin-side workaround that completely avoids the crash today is the split layout; a fix needs the unset-attr path in core identified (neovim/neovim#41860 family). A bisect driver is available next to the repro (
tests/manual/compositor_assert_repro/init_bisect.lua).jensenojs commentedon Oct 13, 2026
The issue might be resolved without 0.13-dev. I'll monitor it for two days, and if that's really the case, I'll close this issue.