Skip to content

nvim crashes with compositor assert (attrs[i] >= 0) in float layout; unset attrs reach the compositor on redraw after win config changes #521

Closed

Description

@jensenojs

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 under redrawdebug=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 asserts attrs[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:

  1. set redrawdebug=invalid — converts compose_line's silent -1 fix into
    abort(), so the same invariant violation is caught on the compose path too.
  2. 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
done

Result 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-value set_config churn: 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

  1. Guard every nvim_win_set_config call 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.
  2. Consider debouncing the resize behind vim.defer_fn with a slightly longer
    window than one frame when mode() == 'i'.

This would not fix the core race (neovim #41860 family), but should cut the
crash frequency in real sessions drastically.

Activity

  1. jensenojs commented on Oct 13, 2026

    @jensenojs
    CollaboratorAuthor

    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 recorded nvim_win_set_config calls before the abort (note the same-value footer calls)
    • nvim-crash-user-report.ips — the original real-world crash (ui_comp_raw_line assert, grid_draw_border)
    • nvim-crash-repro-report.ips — the reproduced abort (compose_line under redrawdebug=invalid)
  2. jensenojs commented on Oct 13, 2026

    @jensenojs
    CollaboratorAuthor

    Follow-up with a stronger reproduction result.

    With set redrawdebug=invalid in 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:

    abort
    compose_line                  (attrbuf[i] < 0 under redrawdebug=invalid)
    ui_comp_put_grid +804
    ui_ext_win_position
    win_ui_flush
    update_screen
    ex_redraw
    execute_cmd0
    

    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=invalid the race stays intermittent (100+ churn rounds ran clean), which matches how rarely the real-world ui_comp_raw_line assert shows up.

    The repro harness is now committed in this repo under tests/manual/compositor_assert_repro/ (plugin entry init.lua, churn driver run_churn.sh, no-plugin control control_floats.lua, README with steps and stacks). Artifacts and crash reports remain in the gist above.

  3. changed the title [-]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[/+] on Oct 13, 2026
  4. jensenojs commented on Oct 13, 2026

    @jensenojs
    CollaboratorAuthor

    Related neovim issues for context:

  5. jensenojs commented on Oct 13, 2026

    @jensenojs
    CollaboratorAuthor

    Bisection results — the churn is not the cause.

    With nvim_win_set_config stubbed 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, when style=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:

    variant (amplified churn, 220x50 tmux) result
    float layout, full plugin dead by round 1-21
    float layout, set_config no-op after open dead round 1
    float layout, set_config no-op + resize reactions stubbed dead round 1
    float layout, + buffers cleared to plain ASCII dead round 1
    position = "current" (split layout) 120 rounds, no crash
    no-plugin control floats with same churn no crash

    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).

  6. jensenojs commented on Oct 13, 2026

    @jensenojs
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions