Problem:
A Normal-mode mapping that moves before entering Insert, does not
live-mirror. Its pending motion defers insert-cascade until Insert ends.
Solution:
Cascade pending atoms before the Insert entry replay, then start the
live insert span.
Problem:
`]C` jumps the primary onto another cursor, which dedupes at the next
edit. Every `]C` consumes a cursor.
Solution:
`]C` adds a cursor at the current position before jumping. This
effectively "rotates" the primary cursor.
Problem:
A non-edit mapping that moves the cursor by API (Lua,
`:call nvim_win_set_cursor()`, …) is not cascaded.
Solution:
Fallback to LHS-replay if the cursor moved and follow-mode is enabled.
Problem:
"v{motion}<Esc>" moves the primary cursor to the selection end, but the
other cursors snap back to their anchors unless follow-mode ("q=") is
on. The selection preview showed them at the selection end.
Solution:
Replay the abandoned selection at every cursor, not only in follow-mode.
An abandoned typed selection now always emits a "visual" CmdAtom.
Problem:
'guicursor' is not respected when extra cursors exist.
Per-cursor selection toggles Visual off, and the sandbox restores it
only after restore_current_state(), whose ui_cursor_shape() caches the
Normal shape.
Solution:
Restore Visual before restore_current_state().
Problem:
`shada_read()` rebuilds `v:oldfiles` for any "forced" read, which first
*clears* the list. For an in-memory caller such as multicursor, the list
is never repopulated. So `v:oldfiles` is empty after `Q` + edit.
Solution:
Rebuild `v:oldfiles` only when `kShaDaGetOldfiles` is requested.
`shada_read_everything()` (`:rshada[!]`, startup) always requests it.
old bug from 411a06c8b6
Problem:
During 'autocomplete', a completion-ending `<BS>` restarts completion
immediately. Multicursor insert-cascade skips replay (`edit()` refuses
to nest if compl/pum is active), so the mcursors do not update until
ESC.
Solution:
Flush pending keys in `TRIGGER_AUTOCOMPLETE`, just before autocomplete
restarts again.
Problem:
Insert entered by a Visual-mode operator mapping (`xnoremap c c`) does
not live-mirror at cursors.
Solution:
Decide `typed` from the Visual session's own provenance (`vatom`, which
spans the selection and knows whether it was user input) instead of only
negating it.
Problem:
With follow-mode ("q="), an 'operatorfunc' that only moves the cursor
does not cascade.
Solution:
Update the `effect` condition to include cursor-moves if follow-mode is
active.
Problem:
With mcursors and 'autocomplete', a non-literal key (`<BS>`) during an
active completion attempts to insert-cascade, which attempts a nested
`edit()`, which refuses with E565.
Solution:
Defer the flush while completion is active; the pending keys will be
handled later.
Problem:
An atom queued in buffer A cascades on B's cursors if the mapping ends
in B ("nnoremap X x:bnext<CR>").
Solution:
Check the atom's origin buffer (`CmdAtom.origin.buf`).
Note: This does not preclude mappings etc from doing work in temporary
throwaway buffers, as long as they return to the origin buffer.
Problem:
If an edit cascades to cursor(s) that happen to be within a closed fold,
the edit acts on the fold itself (`:h fold-behavior`). E.g. "dd" deletes
the entire fold, not the cursor's line within the fold.
Solution:
Check `mc_replaying()` in `hasAnyFolding()`, so folds are ignored during
a multicursor cascade. This means multicursor always replays relative to
text *within* the fold.
Other (squashed) commits:
fix(tui): emit ui_send output atomically with the frame
Problem:
tui_ui_send() writes directly to the TTY, bypassing the output buffer.
Sequences sent via nvim_ui_send() (e.g. kitty multiple-cursors, or
visual-dot-repeat) always arrive in a separate TTY write from the frame
they were computed for. This manifests as "tearing", or e.g. in the case
of multicursor the terminal renders text with stale cursor overlays.
Solution:
- tui_ui_send(): while a frame is being assembled (pending invalid
regions or buffered output), buffer instead of writing directly.
- Out-of-frame sends (tty queries, clear-on-disable) still write
immediately.
- mcursor.lua: emit the terminal-cursor update at the end of the redraw
cycle (`on_end`, when screen positions are final) instead of
vim.schedule().
Problem:
`shorten_fnames()` always redraws the statusline/tabline, even if no
buffer name changed. Since b296666e a temp context-switch
(`win_execute()`, `vim._with{win=}`) restores the CWD, so every such
switch flickers the message area.
Solution:
Redraw only if `shorten_buf_fname()` actually changed a name.
Problem:
The "." example mapping at `:h edit-repeat` doesn't work well with
`nvim_feedkeys(…, 'mt', false)`.
Solution:
Use `vim.b[ev.buf].maxseq` instead of `undotree()`.
Problem:
Replaying a deleted Lua mapping, may call an arbitrary function.
RHS of a Lua mapping embeds its LuaRef (`<K_LUA><ref><CR>`). The raw
keys may outlive the ref (redobuff ".", CmdAtom `keys`). If the mapping
is deleted, replaying it either (1) dereferences a freed registry slot,
or (2) calls whatever callback reused the slot (autocmd, timer, other
mapping).
Solution:
Assign a monotonic (never recycled) id to Lua mappings and encode the
mapping keys as `<K_LUA><id><CR>`.
Note: in the case of Vimscript, a deleted function raises E117, but if
the function is redefined with the same name, the mapping will find it.
Alternatives?:
- Globally ensure `LuaRef` ids are not recycled.
- Problem: could exhaust `int` in a long-lived Nvim session? Also,
difficult to impl bc the "recycling" is done by `luaL_ref` itself.
ref: 5ac2e47acc
Problem:
An operator completed by a Lua `:omap` textobject emits
`CmdAtom.type="mapping"` (lhs-only, no keys) instead of `type="operator"`.
Analysis:
`atom_redo_set()` declined K_LUA, though the prepped redo
("op" + K_LUA + id + CR) is exactly what "." replays. A no-edit "g@"
emits nothing at all.
Solution:
- `atom_redo_set`: don't decline K_LUA; the redo route now captures the
operator atom.
- `atom_capture_cmd`: don't early-return if the frame has prepped redo.
- op_function(): save/restore redobuff when invoking 'operatorfunc',
like `call_user_func()` does for Vimscript. (Else the Lua callback
may clobber the prepped "g@" redo / dot-repeat.)
fix#41482
TODO:
- async Lua (timer/vim.schedule) can still clobber the pending dot-repeat...
Problem:
0c091cedc2 fixed the "immediate exit" but the 2s `nvim_get_proc()`
check still fails on slow (ASAN) CI.
Solution:
Check the pid after the `screen:expect`. Anyway, `assert_nolog` is the
"meaningful" part of the test since 0c091cedc2.
Problem:
The Vim "stuff" concept breaks the ability to reason about the call
stack and thus the boundaries of a `CmdAtom`: a stuffed translation ("x"
=> "dl") defers to the main loop. This "continuation" must be modeled in
`CmdAtom`, by checking global flags at undefined times, during undefined
circumstances.
Solution:
- After a stuffed "translation", eagerly execute the stuff buffer
(`exec_stuffed()`).
- Delete the CmdAtom "continuation" junk.
Note:
- op_colon runs its cmdline "nested", but that's fine because operators
already nest interactive sessions there (op_change runs edit()), and
the cmdline is frameless so the operator's frame can own/capture it.
Problem:
Undo places the cursor wherever the cursor happened to sit at "save
time" (`uh_cursor` is sampled lazily on the first change).
Examples:
- `i` preserves, but `a` does not
- `diw`, `atest<Esc>`, `d^` abandon the original position
- `D`, `o` restore it (by accident).
Solution:
`composite` tracks the pending atom (and its `origin`) across frames.
A `stuffed` continuation frame inherits the `origin` + prepped redo.
Store `origin` info in the undo header, so undo can restore it.
- Not for a mid-command undo break (i_CTRL-G_u).
- Undoing a mapping restores where the mapping started (which
technically may be different than where the "edit" started).
Problem:
An insert-session entered by a scheduled `feedkeys('i','n')` is
classified on entry (as "not typed"), and not reevaluated after that,
thus user input following it is not captured.
Repro: an `:imap` that does `feedkeys('<esc>','n')` and schedules
re-entering insert, emits one CmdAtom for the first press, then nothing
else.
Solution:
Sample `maptick` (ticked by `gotchars()` on typed input, including
mappings) at session start; if it advanced by session end, the session
is user input.
Problem:
`nlua_pcall()` references `_G.debug.traceback`. If user code deletes it
or breaks it some other way, various Lua features are broken.
_G.debug = nil
vim.schedule(function() end)
vim.wait(100)
E5113: Lua chunk: attempt to index a nil value
stack traceback:
[C]: in function 'loop_poll'
[string "vim/_core/editor"]:176: in function 'wait'
crash.lua:3: in main chunk
PANIC: unprotected error in call to Lua API (attempt to index a nil value)
Solution:
Check `_G.debug.traceback` before using it as errfunc. If it's broken,
omit the traceback and say so in the error message.
Note: We could cache `_G.debug` in LUA_REGISTRYINDEX on startup, but
that would prevent plugins from providing custom functionality there
(and we happen to do so in `tui_spec.lua` for example).
Problem:
Too many "jobs" listed in the CI report.
Solution:
- Do the "ai-assisted" step in the "label" job. No need for them to be
separate.
- Also drop the "already labeled" check; `gh pr edit --add-label` is
idempotent.
- Note: AI-assisted PRs are now labeled only on the "opened" event,
which is fine.
Problem:
Claude and friends love to sprinkle advertisements in commit messages.
Solution:
Allow a generic "AI-assisted" token only.
Reject useless mentions of AI services, sessions, and other cringe.
Disclosure: This PR was authored by an sapient turnip , raised in
moist, fluffy soil, tilled by the blurry wings of a hummingbird.
Problem:
Test sockets live under `$TMPDIR`, which the harness points at the build
dir. On macOS/BSD `sockaddr_un.sun_path` is 104 bytes, and a CI build
path plus "nvim.<pid>.<n>" leaves little room:
/Users/runner/work/neovim/neovim/build/Xtest_tmpdir_terminal/nvim.runner/aBcDeF/nvim.12345.0
Solution:
Point XDG_RUNTIME_DIR (`stdpath('run')`) at "/tmp/nvim_<pid>". 28 bytes:
/tmp/nvim_19916/nvim.19919.1
TODO?:
- `TEMP_DIR_NAMES` prefers `$TMPDIR` over `/tmp`, so on macOS
`stdpath('run')` defaults to the long `/var/folders/<xx>/<…>/T/` path
instead of the short `/tmp` alias.
Note:
- The 104-byte limit applies to the `bind()` arg, not its "realpath",
to, so `/tmp/…` symlinks can be used to workaround the limit.
Problem:
Cannot retry a test with its full `after_each`/`before_each` lifecycle.
Solution:
- Overload `it()` to accept an `opts` param:
```
it('flaky', { retries = 2 }, function(ctx) end) -- 3 attempts.
```
- Pass `ctx` to test functions.
- Fix a bug in `t.read_file_list()`.
Problem:
The runs ":helptags", which needs the tree-sitter-vimdoc parser.
This may cause problems for package maintainers / distros?
Solution:
Use running to generate the tags, like the zig build already does.
It also errors on duplicate tags, which is good.
The `:helptags` command still uses treesitter.
Problem:
Since 20edb8fa9aac, generating helptags requires the
`tree-sitter-vimdoc` parser, but the external_deps CI job builds with
USE_BUNDLED_TS_PARSERS disabled.
Solution:
Set USE_BUNDLED_TS_PARSERS.
Problem:
The vimdoc parser does not treat "\r" as whitespace, so in a CRLF
helpfile the codeblock rule fails and "*" pairs inside examples become
tags. On Windows this generates duplicate "." and "/" tags:
D:/a/neovim/neovim/build/bin/nvim.exe -u NONE -i NONE -e --headless -c "helptags ++t doc" -c "exe 'cquit' !empty(v:errmsg)""
Error in command line:
E154: Duplicate tag "." in gui.txt and repeat.txt
E154: Duplicate tag "/" in pattern.txt and usr_08.txt
E154: Duplicate tag "/" in usr_08.txt and pattern.txt
Note: The old C parser was unaffected because it read helpfiles in text
mode (`os_fopen(…, "r")`).
Solution:
Strip "\r" before parsing.
Problem:
Parent commit regressed some behavior of the old C helptags-gen impl:
- helpfiles in sub-directories are named by basename, so :help fails
- "help-tags" always names "tags", never "tags-nl"
- E150 E151 E152 E153 are never reported
- existing tags file is not overwritten if no tags were found
- a duplicate tag aborts the run, skipping the remaining directories
- `*.TXT` and `*.FRX` (uppercase) are not recognized as helpfiles
- tree-sitter-vimdoc accepts tags that the C parser rejected: `*a|b*`
(breaks |links|) and unterminated `*tag`
- PUC Lua "<" compares with (localized) `strcoll()`, so the tags file is
not sorted by byte value (E432)
- requiring `vim.treesitter` at load time breaks :help itself, not just
:helptags, where the module is unavailable
- `vim.pack` runs :helptags for plugins that have no "doc/" directory,
so every install/update emits E150
Solution:
- Fail the build if generating helptags reports any `v:errmsg`.
- Restore old behavior: tags generated for runtime/doc are now identical
to those from the C implementation. Errors are non-fatal messages
instead of exceptions, so all directories are still processed.
- Load treesitter lazily, report a plain error if parser is missing.
Problem:
In order for ui2 to graduate to the main "messages ui" its configuration
needs to graduate into actual options.
Solution:
Migrate some of its config to 'messagesopt':
- "maxheight" (note: currently this is an integer treated as
a "percentage"; if we want to support a row count we could allow
values with units, like `"42%"`)
- "pager"
- "timeout"
Also improves error messages:
messagesopt=hit-enter,history:500,bogus E474: Unknown item 'bogus'
messagesopt=hit-enter,history:500,progress:x E474: 'progress' must be one of: , c
messagesopt=hit-enter,history:abc E474: 'history' requires a number
Problem:
`pager_char` compares the `keytrans()` result exactly. E.g. `pager_char
= "<cr>"` does not work, it expects `<CR>` (uppercase).
Solution:
Normalize `pager_char` in enable().
Problem:
`vim.on_key()` callbacks are invoked for keys that `getchar()` consumes,
which never reach the main loop, and there is no way to tell such a key
apart. With ui2 the `on_key` handler activated after interactive
cmdline, handles it as `pager_char` (thus enters the ui2 pager).
Solution:
Skip `vim.on_key()` callbacks during `getchar()`/`getcharstr()`.
Problem:
When ui2 is enabled, opening an already-open file in another Neovim
instance results in the error `E812: Autocommands changed buffer…`.
Analysis:
On E812 the file is not loaded. The default SwapExists handler notifies
`W325: Ignoring swapfile…`, and ui2 shows that message by opening
a window, which is a temp context switch. `ctx_dirs_restore()`
re-shortens every buffer name on the way out, and `shorten_buf_fname()`
always frees and reallocates `b_sfname`. `readfile()` aliases `b_fname`
across `check_need_swap()` and compares the pointer to detect a rename.
Regression by b296666e41, which replaced the `cs_save_sfname` restore
(that kept curbuf's pointer) with `shorten_fnames(true)`.
Solution:
Keep the allocation in `shorten_buf_fname()` when the short name is
unchanged. Pointer stability is what the E200/E201/E812 guards actually
assert.
Problem:
A mapping that enters terminal-mode (`:FzfLua files` via `:startinsert`)
never reaches a composite-end: terminal-mode runs no "normal" CmdFrames.
The composite collects the entire terminal session (and more) into `lhs`.
E.g. if I have `<M-/>` mapped to open `:FzfLua files`, then interact
with fzf-lua UI, the emitted CmdAtom looks like:
<M-/>… => { type='mapping', lhs='<M-/><C-N><C-N><CR>', keys=nil }
Solution:
End the composite when terminal-mode is entered, which emits a more
meaningful and repeatable atom:
<M-/>… => { type='mapping', lhs='<M-/>', keys=nil }
Problem:
The "!" operator stuffs its cmdline continuation (`:.,.+1!`), so its
frame ends before capture (stuff pending) and the redo-prep disappears
with it.
!ipsort<CR> => { type='excmd', lhs=':.,.+1!sort<NL>', keys=':.,.+1!sort<NL>' }
Compare to builtin "." which works bc `do_bang()` completes the redo
(`!ip` + `sort<NL>`).
Solution:
Appoint the stuffed continuation frame as the "redo-prep" frame.
!ipsort<CR> => { type='operator', operator='!', lhs='!ipsort<NL>', keys='!ipsort<NL>' }
Notes:
- atom_cmd_end(): a frame ending with stuff pending re-points its
redo-prep to the next frame.
- atom_cmd_start(): a stuffed continuation frame (`KeyStuffed`) keeps
the redo-prep; flushed stuff discards it.
Problem:
1. `ctx_dirs_restore()` is the only chdir site that doesn't re-shorten
buffer names; `post_chdir()`, `update_cwd()` and `do_autochdir()` all
call `shorten_fnames(true)`. So after a temp window-context switch
that moved the CWD, every buffer's `b_fname` is still relative to the
other directory. ui2 renders messages in a float, and entering it is
such a switch, so with a `:bcd` in a `BufReadPost` handler 'statusline'
"%f" shows ".config/nvim/init.lua" while the CWD is already
`~/.config/nvim`, and :write resolves the name against it:
```
E212: Can't open file for writing: no such file or directory
```
2. `msg_multihl()` leaves `msg_ext_id` pointing at the caller's storage
when nothing was emitted (e.g. 'msg_silent'): the reset only ran on
a flush that produced chunks. The next message then ships a dead
stack frame as its msg_show id. After ":silent write" the id is
buf_write()'s `msg_id[MAXPATHL + 32]`:
```
id = "\0\0\0\0\0\0\0\0\29\0(<C6>k\24R\17p<C7><C7><C7>\1\0\0\0..."
```
Solution:
1. `shorten_fnames(true)` after restoring the CWD. Drop `cs_save_sfname`,
which was a partial workaround for the same bug.
2. Release the id in `msg_multihl()`, where it is set and the caller's
frame is still alive. Stop reading `.data.integer` out of a String
union member.
Problem:
`buf_write()` captures the buffer's `fname`/`sfname`/`ffname`, then emits its
progress-message before opening the file. This may run user code
synchronously: the `Progress` autocmd, and the `msg_show` handler of an
in-process UI (ui2). Either can change the CWD, and `shorten_fnames()`
then frees/reallocs every buffer's short name. The rest of `buf_write()`
reads the freed name...
"foldtext()" [New] 41L, 997B written
E212: Can't open file for writing: illegal byte sequence
ASAN, with ui2 enabled and a `BufEnter` handler that runs `:lcd`:
READ path_skip_sep <- path_tail <- match_file_list <- buf_write
FREE shorten_buf_fname <- shorten_fnames <- update_cwd <- set_curbuf
<- win_set_buf <- nvim_open_win <- ui2 msg_show handler
<- ui_call_msg_show <- msg_ext_ui_flush <- buf_write
Solution:
Copy the names after the `*Pre` autocmds.
Note: `readfile()` has the same shape, but its messages pass no
progress-id, so they skip `msg_progress()`. Safe, for now...
The perf cost is negligible, but it's easy to address so might as well.
CID 653826: Performance inefficiencies (PASS_BY_VALUE)
/src/nvim/input_cmdatom.c: 385 in atom_push_raw()
384 /// Takes ownership of the atom's allocated members. Caller sets `atom.changed`.
>>> CID 653826: Performance inefficiencies (PASS_BY_VALUE)
>>> Passing parameter atom of type "CmdAtom" (size 176 bytes) by value, which exceeds the low threshold of 128 bytes.
385 void atom_push_raw(bool cascade, CmdAtom atom)
CID 653825: Performance inefficiencies (PASS_BY_VALUE)
/src/nvim/input_cmdatom.c: 438 in atom_stage_set()
436 /// Stages an atom built before its command executes (do_pending_operator() prep-exempt, Visual
437 /// ops), in the command's frame; pushed at frame end, once `changed` is known.
>>> CID 653825: Performance inefficiencies (PASS_BY_VALUE)
>>> Passing parameter atom of type "CmdAtom" (size 176 bytes) by value, which exceeds the low threshold of 128 bytes.
438 static void atom_stage_set(CmdAtom atom)
CID 653824: Performance inefficiencies (PASS_BY_VALUE)
/src/nvim/input_cmdatom.c: 427 in atom_push()
425 /// Pushes an atom (emit + maybe cascade), or drops it if replay/Visual/internal-op already
426 /// in-progress.
>>> CID 653824: Performance inefficiencies (PASS_BY_VALUE)
>>> Passing parameter atom of type "CmdAtom" (size 176 bytes) by value, which exceeds the low threshold of 128 bytes.
427 static void atom_push(bool cascade, CmdAtom atom)
Problem:
A mapping that ends mid-operation (`nnoremap ,D d`) emits a content-free
"mapping" atom plus a `pending` field, and the "continuation" motion
arrives as a sibling atom. Consumers must stitch the two together (which
has broken cases, e.g. Insert-opening mappings (",i") lose their session
entirely).
- ",i": the session atom is dropped bc the mapping RHS is consumed
before the session starts (typebuf_maplen()==0), so
atom_is_user_input()=false.
- ":normal"-in-opfunc: the opfunc internal "v..y" session (a) became
kVatomTyped just because the deferred composite was open, masking the
real operator capture via atom_captures, and (b) its nested frames
re-derived the outer redo.
- "Motion" based on `moved=true`, has false negatives.
- `CmdAtom.remap` is unnecessary, and clutters the docs/usage.
Solution:
- Introduce `frame_id` to identify CmdFrames.
- Classify `type=motion` better, via `NV_MOTION` flag on the `nv_cmds` table.
- Drop `CmdAtom.pending`, `CmdAtom.remap`.
- Defer atom_composite_end() at the clock edge while an operator is
pending, Visual is active, or `restart_edit` is set: the composite
keeps collecting, so the continuation is captured in the mapping atom.
- ",i": Now an open composite counts as user input.
- ":normal"-in-opfunc: Now handled correctly.
- `remap` is now decided by `composite.payload || 0 subatoms`.
atom_payload_mark() records the read the resolution never captures.
- `toplevel` is now decided by `CmdFrame.parent == NULL`.
before/after:
INPUT BEFORE AFTER
---------------------------------------------------------------------
,D w {mapping lhs=,D pending=operator} {operator lhs=,Dw keys=dw}
+ {operator keys=dw}
ysiw" {mapping lhs=ys pending=operator} {operator lhs=ysiw" keys=g@iw"}
+ {operator lhs=g@iw"}
,v d {mapping pending=visual} {visual lhs=,vd keys=viwd}
+ {visual lhs=viwd}
,i XY<Esc> {normal keys=i lhs=,iXY<Esc>} {insert keys=1iXY<Esc> text=XY}
Problem:
- `lhs` is not fully realized. E.g. for a "payload" mapping
`lhs` omits the `getchar()` payload during a mapping (vim-surround
`ds'` reports `lhs="ds"`). This means plugins like vim-repeat are
still needed...
- The "delta" fields of a CmdAtom are calculated too late.
- `<abuf>` and `changed` check whatever (wrong) buffer a command
(":bnext") might land in.
- CTRL-W_w between two windows on the same buffer reports type="motion".
Solution:
- `CmdOrigin` samples (buf/win/cursor/changedtick) at each "scope" entry
(CmdFrame, composite, Visual session, insert session).
- `dd<C-w>l` reports `changed=true` for the buffer it edited, regardless
of where the cursor ends up.
- New fields:
- `pos`: cursor position at command start.
- `moved`: indicates whether the cursor moved (in same buffer).
- `undoseq`: undo state at settlement.
- lhs now includes the payload: "ds)" reports lhs="ds)" instead of "ds".
- Easy for users to "replay" any atom.
- Rename: type "command" => "normal", "ex" => "excmd"; `arg` => `cmdarg`
- Drop `cascade` field (no reason to expose it)
Problem:
An unreplayable Visual operation does not emit a CmdAtom event. That's
maybe not super important, but it hints at a flaw in how `vatom`
"voiding" is plumbed: `vatom.state=kVatomVoid` replaces the "kind", so
that info is lost to later parts in the lifecycle.
Solution:
- Define `VatomState` as "flags", so `kVatomVoid` can "poison"
`vatom.state` without losing its kind flag.
- Unify `lhs`: always report the original user input in `CmdAtom.lhs`,
for all kinds of user actions: visual, "translated"/"stuffed" cmds,
and dot-repeat (".") itself.
- Unreplayable Visual atom emits CmdAtom with non-empty `lhs` and empty
`keys`, like a mapping/macro composite.
Problem:
`signal_ignore_deadly` doesn't work for Windows, where the console still
may terminate Nvim during teardown (after `signal_teardown`), while it
is already trying to exit. Besides interrupting any housekeeping we are
doing, it results in an unpredictable exit code (flaky tests).
[Process exited -1073741510] // 0xC000013A STATUS_CONTROL_C_EXIT
Solution:
Register our own CTRL_CLOSE_EVENT handler which "blocks" the signal.
Note: if exit takes longer than 5s, Windows will consider the process
"hung" and kill it anyway.
Problem:
A deadly signal arriving during teardown can kill Nvim while it is
preserving swapfiles. `os_exit()` ignores deadly signals via
`signal_reject_deadly()`, but `signal_teardown()` => `uv_signal_stop()`
resets them to the default behavior, so SIGHUP arriving after that
kills the process:
[Process exited 129] // 128 + SIGHUP
This is a race when closing a pty: kernel sends SIGHUP to foreground
process group *and* the reads return EOF, so `chanclose()` on a TUI job
prepares to exit twice. This means it is unpredictable whether Nvim
exits 1 or is terminated.
Solution:
Ignore deadly signals once the watchers are closed. Only SIGKILL
interrupts it now.
Problem:
As it turns out, when you have 17 different ways to "exit", you need to
plug 17 different holes.
Leak still reported by ASAN CI, even after 1eae512285f238788601.
ERROR: LeakSanitizer: detected memory leaks
strbuf_init src/cjson/strbuf.c:62:22
json_create_config src/cjson/lua_cjson.c:635:5
lua_cjson_new src/cjson/lua_cjson.c:2060:5
nlua_state_add_stdlib src/nvim/lua/stdlib.c:840:3
nlua_state_init src/nvim/lua/executor.c:973:3
Analysis:
`os_breakcheck()` may process a `exit_event` placed by
`exit_on_closed_chan()` during shutdown. This would re-enter
`preserve_exit()`, which then skips everything because it sees
`really_exiting=true`.
Solution:
Set `exiting` in `preserve_exit()`, not only in `getout()`.
Problem:
SIGTERM received during `os_exit()` can cause it to "nest":
os_exit src/nvim/main.c:720
preserve_exit src/nvim/main.c:910
deadly_signal src/nvim/os/signal.c:205
on_signal src/nvim/os/signal.c:240
signal_event src/nvim/event/signal.c:45
multiqueue_process_events src/nvim/event/multiqueue.c:159
event_teardown src/nvim/main.c:178
os_exit src/nvim/main.c:720
Solution:
Call `signal_reject_deadly()` at the start of `os_exit()` like
`preserve_exit()` already does.
Problem:
TSan CI, always at exit:
WARNING: ThreadSanitizer: use of an invalid mutex (e.g. uninitialized or destroyed)
#1 uv_mutex_lock
#2 tui_flush src/nvim/tui/tui.c:1708
#3 ui_client_event_flush
#4 parse_msgpack src/nvim/msgpack_rpc/channel.c:259
...
#10 event_teardown src/nvim/main.c:187
#11 os_exit src/nvim/main.c:720
...
#19 tinput_done_event src/nvim/tui/input.c:184
Analysis (guess):
`rpc_close_event()` may run during `event_teardown()` and reattach to
a restarted server, then `ui_client_attached=true` gets set and redraws
resume in an already-stopped TUI.
Solution:
Check `!exiting` in `rpc_close_event`, like we already do in
`channel_proc_exit_cb()`.
Problem:
Unreliable test on slow CI (ASAN/TSAN):
FAILED .../tui_spec.lua @ 3054: TUI exits immediately when stdin is closed
retry() attempts: 1
Expected: vim.NIL
Actual: { name = "nvim", pid = 33201, ppid = -1 }
The test asserts "immediate" exit of the Nvim process, but this may be
subject to OS delays outside of our control.
Solution:
Make the "timed out waiting for DA1" log conditional on the actual
timeout, and assert the logs in the test.
Problem:
Leak reported by ASAN CI, always at Lua state init.
ERROR: LeakSanitizer: detected memory leaks
strbuf_init src/cjson/strbuf.c:62:22
json_create_config src/cjson/lua_cjson.c:635:5
lua_cjson_new src/cjson/lua_cjson.c:2060:5
nlua_state_add_stdlib src/nvim/lua/stdlib.c:840:3
nlua_state_init src/nvim/lua/executor.c:973:3
...
SUMMARY: AddressSanitizer: 1055 byte(s) leaked in 2 allocation(s).
Analysis:
`getout()` may run during `free_all_mem()` (that's why it checks
`!entered_free_all_mem`). If that happens, the second `os_exit()`
returns early from `free_all_mem()` then `exit()` is called and Lua
state is never closed.
Solution:
Always call `nlua_free_all_mem()` in the early-return case.
Problem:
- Dot-repeat of a Visual selection prepared by a `<Cmd>` mapping,
results in E1255 and leaves Visual mode active.
- Visual-mode capture is implemented as a second, parallel "capture
engine": it re-composes a CmdSpec from cmdarg_T per key and decides
replayability from its own table of "void" key classes.
Solution:
- Model nested `normal_execute()` as a `CmdFrame` stack,
instead of a single module-scoped `stage`.
- Capture `<Cmd>` ":norm …" commands as subatoms from its nested frames.
- Produce every command exactly once; atom_push_raw() routes the atom to
the Visual composite while a selection is open, like it already does
for mapping composites.
- `v/pat<CR>d` is now repeatable and cascades.
Problem:
Flaky test:
RUN T1158 nvim.zip reports an incorrect archive password: 11092.84 ms FAIL
The prompt is detected only if the pty output *ends* with "password: ",
but a read may return the prompt plus following bytes.
Solution:
- Match anywhere in the output since the last password was sent. The
buffer is cleared before each send, so won't match stale text.
- Assert on the reported message, so a failure shows what was reported.
Problem:
`buffheader_T` is a linked list (Vim's favorite data structure) with
complex bookkeeping, optimized for ancient hardware:
1. Memory topology: On old platforms (Amiga 512KB without MMU, MS-DOS
64KB segments), large contiguous allocations were hazardous (no VM).
2. Never-move appends: `add_buff()` fills spare space or links a new
block.
3. OOM "recovery": a failed block allocation only loses one append.
To avoid OOM, it prefers to allocate small, linked chunks, instead of
resizing one continguous slice. Each stuff/drain cycle costs
a malloc+free.
Solution:
Replace Vim's favorite data structure with Nvim's favorite data structure.
Nvim doesn't have granular handling of OOM (`xmalloc`), and hardware has
changed: caches favor contiguous memory, allocators handle
fragmentation. And these buffers are ~kb scale, so OOM is irrelevant on
any system that can run Nvim.
- Use a flat `StringBuilder` + `read`/`insert` offsets.
- Keeps capacity (does not shrink) until `free_buff`.
- "Steady state" allocates nothing: 1 fewer malloc+free per dot-repeat.
Problem:
`RedoBuf` is mostly indirection. It has a mild benefit as an "ownership"
signal but it counteracts the general goal of unifying how "redo state"
is passed throughout the system, tends to sprout redundant interfaces,
and reduces clarity.
Solution:
Add `CmdSpec.body` to hold the "prefixless" key sequence.
Reuse `CmdSpec` to represent a "redo" buf.
Problem:
Redo prep is scattered/duplicated.
- `do_pending_operator()` has 3 prep blocks whose conditions must be in
sync with `atom_capture_op()`.
- insert.c, spell_suggest() hand-roll `redo_new()` + `redo_append_xx()`
sequences.
- prep_redo() has 2 roles, decided by `keys != NULL`.
Solution:
- `atom_capture_op()` is the "operator" entry point: capture, then
prep.
- Extract `prep_redo_visual()`, `atom_capturable()`.
Problem:
`<cmd>` mappings do not emit `CmdAtom.text`.
`<cmd>` and Lua-callback mappings that edit the buffer apply only at the
primary cursor, not cascaded (multicursor).
Solution:
Capture the `<cmd>` command in getcmdkeycmd().
Add kKeyOpaque ("no capturable keys"); narrow kKeySynthetic ("not
a keystroke") to K_EVENT/K_IGNORE, so an opaque mapping's edit still
sets `map_edit` and cascades via LHS-replay.
Problem:
`:rundo` on a corrupted undo file crashes or hangs, instead of failing
with E825. Patching one 4-byte field is enough:
ue_size = 0xFFFFFFFF " walks a NULL ue_array
ue_size = 0x7FFFFFF0 " 17 GB xmalloc + memset, then preserve_exit()
ue_top = 0xFFFFFFFB " negative lnum reaches ml_delete()
Analysis:
Every count in the file is read with `undo_read_4c()` and then checked,
differently at each site. None bounds the value by what the file can
hold, so a 2 GB count reaches `xmalloc()`.
Note:
- Vim doesn't have `bi_fsize` because it checks `U_ALLOC_LINE` result
everywhere (thus doesn't crash, but may thrash...); those checks were
dropped when Nvim moved to `xmalloc()`, and the `ue_size` loop counter
became unsigned.
- Vim *does* have the negative line numbers bug: `u_undoredo()` checks
`top > ml_line_count || top >= bot || bot > ml_line_count + 1`, which
rejects none of them.
Solution:
- Introduce `undo_read_len()` and use it to fail early instead of
continuing with nonsense.
- Validate `ue_top`/`ue_bot`/ `ue_lcount`.
- Use `xcalloc()`, so no site can proceed with a NULL array.
- Report a truncated "U" line, distinguish EOF from a 0xFFFFFFFF field,
and free the header on the extmark error path.
Problem:
Undo of a change spanning many paired marks is quadratic. A node's
"intersect" array holds every pair crossing that node, and both
intersect_node() and unintersect_node() walked it linearly. Undoing an
edit over 1M paired marks spends 68% of its time in unintersect_node()'s
scan alone.
Solution:
The array is sorted, so binary search it.
marks undo before after
200k 831ms 385ms
1M 14006ms 3820ms
Redo is unaffected: it is dominated by marktree_move() actually
repositioning the marks.
Problem:
A named mark updated after a change is moved back (treated as the
original mark) by undo:
:1mark d
:$
dw
:2mark d " 'd is on line 2
:undo " 'd is back on line 1
The undo header snapshots `b_namedm` when the change is recorded, and
`u_undoredo()` restores that snapshot indiscriminately.
Solution:
Update the pending header's snapshot when a mark is set explicitly.
Marks that the change itself moved go through mark_adjust(), not
setmark_pos(), so those are still reverted.
Similar to 2546741d1b (for extmarks): an explicit set inside an undo
block is confused with an edit-driven adjustment. But the extmarks case
is dealing with mid-edit moves, whereas named/regular marks only need
the stale snapshot dropped.
Problem:
Setting a :bcd buffer into another window, modifies the caller's CWD.
local b = vim.api.nvim_create_buf(true, true)
vim.api.nvim_buf_call(b, function() vim.cmd.bcd('..') end)
vim.cmd('vsplit')
vim.api.nvim_win_set_buf(vim.fn.win_getid(2), b)
:echo haslocaldir(0) haslocaldir(-1,0) haslocaldir(-1,-1,0)
0 0 0
:echo getcwd() ==# getcwd(-1,-1)
0
Analysis:
`ctx_dirs_save` only saves CWD if it predicts the switch can change it.
But win_set_buf() replaces the target window's buffer *after* the
switch, which cannot be "predicted" from `ctx_dirs_save`.
Solution:
Always snapshot whenever the switch enters another window.
Skipping `os_dirname` was a micro-optimization.
Problem:
nvim_win_set_buf() on a non-current win, while the current win has
a win-local dir, changes the global CWD:
:vsplit | lcd ..
:call nvim_win_set_buf(other_win, buf)
:wincmd l
:verbose pwd
[global] /parent " expected: the initial cwd
Analysis:
`globaldir` is where to return when no local dir applies; NULL means the
process CWD is already there. Switching to a window with no local dir
makes update_cwd() chdir back to `globaldir` and clear it. kCtxKeepCwd
restores the process CWD but not that bookkeeping, so the restored
window-local dir is mistaken for the global one.
Solution:
Save/restore `globaldir` with the CWD.
Problem:
A mark created by `nvim_buf_set_extmark()` while an undo block is open
never comes back on redo.
Analysis:
Undo deletes the text it covers and collapses the range; redo replays
the splices, which re-insert the text but cannot re-expand the mark.
`extmark_set()` records a position only for a mark it moves, not for one
it creates.
Solution:
Record the created position for redo; undo leaves the mark to the splice
replay, since it did not exist before the edit. Each side of a paired
mark gets its own entry. Redo also revives a mark that undo invalidated,
else its position returns but its highlight does not.
Problem:
There is no unified notion of a "user action".
Vim processes input by one-char-at-a-time, and mostly throws away any
hints it might gather about the user's action, with one exception: it
stores the last _edit_ action (the "redo buffer", encoded as
unstructured `["x][v][count]body` bytes).
Plugins can only observe individual keys (vim.on_key) and high-level
effects (TextChanged, CursorMoved).
Solution:
- Users can subscribe to `CmdAtom` events to handle any user action.
- Event is deferred; handlers cannot cancel or interfere with user
actions.
- Capture `CmdSpec` from the normal/insert/visual subsystems.
- typeahead/readahead stay unstructured (`buffheader_T`): they are key
streams, not commands.
- the redo/record buffers become `StringBuilder`: fewer
allocations/copies.
- Repurpose the input/redo engine to accept `CmdSpec` objects.
"atom": one repeatable unit of user input, as a resolved (post-mapping)
keysequence plus structured fields. Only user actions, not `:normal`,
API calls, or non-"t" `feedkeys`.
BREAKING: dot-repeat of an Insert session, replays the entire session
including cursor-moves (:help ins-repeat).
BREAKING: dot-repeat of a Visual operation, replays the selection
instead of operating on a fixed-size region.
Problem:
A mark moved by nvim_buf_set_extmark() during an edit is misplaced by
undo and redo. Only splices ("edits") are recorded, and replaying them
reproduces the shifts they caused, never the explicit set: the mark ends
up wherever the text pushed it.
Solution:
When an open undo block moves an existing mark, record both positions.
Undo restores the pre-set position, redo re-applies the set.
Partially reverts 18334a4a0c ; ExtmarkSavePos.row/col were unused
because nothing recorded an explicit move, but now `extmark_set()` does.