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(<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.
This commit is contained in:
Justin M. Keyes
2026-08-22 16:24:05 -04:00
committed by GitHub
parent 9b0bc7edef
commit b296666e41
7 changed files with 79 additions and 16 deletions

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -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"<CR>')
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, {

View File

@@ -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