From b296666e419cdf7a673fdac7c348d0d95577f5ac Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Sat, 22 Aug 2026 16:24:05 -0400 Subject: [PATCH] fix(cwd): stale buffer names after temp context-switch #41433 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(k\24R\17p\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. --- src/nvim/context.c | 14 +++---------- src/nvim/context_defs.h | 1 - src/nvim/message.c | 8 +++++++- test/functional/ex_cmds/cd_spec.lua | 17 ++++++++++++++++ test/functional/options/autochdir_spec.lua | 23 ++++++++++++++++++++++ test/functional/ui/messages_spec.lua | 20 +++++++++++++++++++ test/functional/ui/screen.lua | 12 ++++++++--- 7 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/nvim/context.c b/src/nvim/context.c index df77828a17..a61ed3ad9e 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -33,6 +33,7 @@ #include "nvim/eval/userfunc.h" #include "nvim/eval/vars.h" #include "nvim/ex_docmd.h" +#include "nvim/fileio.h" #include "nvim/globals.h" #include "nvim/hashtab.h" #include "nvim/keycodes.h" @@ -348,9 +349,6 @@ static void ctx_dirs_save(CtxSwitch *cs, win_T *wp, tabpage_T *tp, buf_T *buf) // If 'acd' is set, check we are using that directory. If yes, then // apply 'acd' afterwards, otherwise restore the current directory. if (cs->cs_cwd != NULL && p_acd) { - if (curbuf->b_sfname != NULL && curbuf->b_fname == curbuf->b_sfname) { - cs->cs_save_sfname = xstrdup(curbuf->b_sfname); - } do_autochdir(); char autocwd[MAXPATHL]; if (os_dirname(autocwd, MAXPATHL) == OK) { @@ -387,17 +385,11 @@ static void ctx_dirs_restore(CtxSwitch *cs) // Restore the CWD itself. After an explicit chdir, ctx_restore() re-derives it instead. if (cs->cs_apply_acd) { - xfree(cs->cs_save_sfname); do_autochdir(); } else if (cs->cs_cwd != NULL && ((cs->cs_flags & kCtxKeepDirs) || !_ctx_did_chdir)) { os_chdir(cs->cs_cwd); - if (cs->cs_save_sfname != NULL) { - xfree(curbuf->b_sfname); - curbuf->b_sfname = cs->cs_save_sfname; - curbuf->b_fname = curbuf->b_sfname; - } - } else { - xfree(cs->cs_save_sfname); + // Buffer names are relative to the CWD, so they must follow it back. #41424 + shorten_fnames(true); } XFREE_CLEAR(cs->cs_cwd); } diff --git a/src/nvim/context_defs.h b/src/nvim/context_defs.h index ad93343700..6e34ba8ec6 100644 --- a/src/nvim/context_defs.h +++ b/src/nvim/context_defs.h @@ -116,5 +116,4 @@ typedef struct { char *cs_globaldir; ///< Saved globaldir char *cs_cwd; ///< Saved CWD (kCtxKeepCwd/kCtxKeepDirs). bool cs_apply_acd; ///< Re-apply 'autochdir' on ctx_restore(). - char *cs_save_sfname; ///< Saved b_sfname (kCtxKeepCwd/kCtxKeepDirs). } CtxSwitch; diff --git a/src/nvim/message.c b/src/nvim/message.c index 4394983cce..34b939e3f2 100644 --- a/src/nvim/message.c +++ b/src/nvim/message.c @@ -415,6 +415,9 @@ MsgID msg_multihl(MsgID id, HlMessage hl_msg, const char *kind, bool history, bo if (hl_msg_updated && !(history && kv_size(hl_msg))) { hl_msg_free(hl_msg); } + // Release the id: it belongs to this message, and a String id only borrows the caller's storage + // (often a stack buffer). Redundant unless nothing was emitted (e.g. 'msg_silent'). + msg_ext_id = INTEGER_OBJ(msg_id_next); return id; } @@ -3462,7 +3465,10 @@ void msg_ext_ui_flush(void) msg_ext_append = false; msg_ext_fast = true; msg_ext_kind = NULL; - msg_id_next += (msg_ext_id.data.integer == msg_id_next); + // Consume the pre-allocated id, if the message did not get one from msg_multihl(). + if (msg_ext_id.type == kObjectTypeInteger && msg_ext_id.data.integer == msg_id_next) { + msg_id_next++; + } msg_ext_id = INTEGER_OBJ(msg_id_next); } } diff --git a/test/functional/ex_cmds/cd_spec.lua b/test/functional/ex_cmds/cd_spec.lua index df2d7a4817..c7f73ba349 100644 --- a/test/functional/ex_cmds/cd_spec.lua +++ b/test/functional/ex_cmds/cd_spec.lua @@ -572,6 +572,23 @@ describe('cd during temp context-switch', function() eq({ 1, tabdir, tabdir, startdir }, { tlwd(), tcwd(), cwd(), cwd(-1, -1) }) end) + it('does not linger in buffer names after switch back #41424', function() + local bufdir = join(startdir, directories.buffer) + command('edit ' .. join(bufdir, tmpfile)) + command('bcd ' .. bufdir) + command('split Xtest-cd-other') -- Buffer with no local dir. + local otherwin = call('win_getid') + command('wincmd p') + eq({ bufdir, tmpfile }, { cwd(), call('bufname', '%') }) + + -- Entering a window leaves `bufdir`, since the buffer there has no local dir. + call('win_execute', otherwin, 'split | close') + + -- Names relative to the old CWD would cause ":write" to target nonsense. + eq({ bufdir, tmpfile }, { cwd(), call('bufname', '%') }) + command('write') + end) + it("nvim_open_win / nvim_win_set_buf keep the caller's cwd", function() local bufdir = join(startdir, directories.buffer) command('bcd ' .. bufdir) diff --git a/test/functional/options/autochdir_spec.lua b/test/functional/options/autochdir_spec.lua index 983e2a1c83..3c72b0794d 100644 --- a/test/functional/options/autochdir_spec.lua +++ b/test/functional/options/autochdir_spec.lua @@ -6,6 +6,7 @@ local clear = n.clear local eq = t.eq local fn = n.fn local command = n.command +local api = n.api local mkdir = t.mkdir describe("'autochdir'", function() @@ -44,4 +45,26 @@ describe("'autochdir'", function() n.rmdir(dir_a) n.rmdir(dir_b) end) + + it('win_execute() keeps buffer names #41417', function() + local root = vim.fs.normalize(t.tmpname(false)) + mkdir(root) + mkdir(root .. '/a') + mkdir(root .. '/b') + clear() + command('set shellslash') + command('edit ' .. root .. '/a/file_a') + command('vsplit ' .. root .. '/b/file_b') + local win_b, buf_b = api.nvim_get_current_win(), api.nvim_get_current_buf() + command('wincmd p') + command('set autochdir') + -- Sit in an ancestor of both files: 'autochdir' moves away from it during win_execute(), and + -- both buffer names stay shortened, i.e. relative to the CWD. + command('cd ' .. root) + eq({ 'a/file_a', 'b/file_b' }, { fn.bufname('%'), fn.bufname(buf_b) }) + + fn.win_execute(win_b, 'echo') + + eq({ 'a/file_a', 'b/file_b' }, { fn.bufname('%'), fn.bufname(buf_b) }) + end) end) diff --git a/test/functional/ui/messages_spec.lua b/test/functional/ui/messages_spec.lua index 44f2e50caf..d1b7a28835 100644 --- a/test/functional/ui/messages_spec.lua +++ b/test/functional/ui/messages_spec.lua @@ -3859,6 +3859,26 @@ describe('progress-message', function() eq(8, id8) end) + it('msg-id is not inherited by the next message #41417', function() + local fname = 'Xtest_progress_msgid' + finally(function() + os.remove(fname) + end) + -- The write emits nothing ('msg_silent'), but must still release its msg-id. + command('silent write ' .. fname) + feed(':echoerr "boom"') + screen:expect({ + messages = { + { + content = { { 'boom', 9, 'ErrorMsg' } }, + history = true, + id = 1, + kind = 'echoerr', + }, + }, + }) + end) + it('accepts caller-defined id (string)', function() -- string id works local id = api.nvim_echo({ { 'supports str-id' } }, true, { diff --git a/test/functional/ui/screen.lua b/test/functional/ui/screen.lua index d143ed3d49..7986ad29ec 100644 --- a/test/functional/ui/screen.lua +++ b/test/functional/ui/screen.lua @@ -1597,17 +1597,23 @@ function Screen:_extstate_repr(attr_state, exp) local messages = {} for i, entry in ipairs(self.messages) do + local exp_msg = exp and exp.messages and exp.messages[i] + -- Late addition, only include when expected state includes it. local trigger = nil - if exp and exp.messages and exp.messages[i] and exp.messages[i].trigger ~= nil then - -- Late addition, only include when expected state includes it. + if exp_msg and exp_msg.trigger ~= nil then trigger = entry.trigger end + -- Progress messages are identified by their id, so always show it for them. + local id = nil + if entry.kind == 'progress' or (exp_msg and exp_msg.id ~= nil) then + id = entry.id + end messages[i] = { kind = entry.kind, content = self:_chunks_repr(entry.content, attr_state), history = entry.history or nil, append = entry.append or nil, - id = entry.kind == 'progress' and entry.id or nil, + id = id, trigger = trigger, } end