Files
neovim/test/functional/ex_cmds/dict_notifications_spec.lua
Sean Dewar 6617f85b76 vim-patch:9.2.0253: various issues with wrong b_nwindows after closing buffers
Problem:  close_buffer() callers incorrectly handle b_nwindows,
          especially after nasty autocmds, allowing it to go
          out-of-sync.  May lead to buffers that can't be unloaded, or
          buffers that are prematurely freed whilst displayed.
Solution: Modify close_buffer() and review its callers; let them
          decrement b_nwindows if it didn't unload the buffer.  Remove
          some now unneeded workarounds like 8.2.2354, 9.1.0143,
          9.1.0764, which didn't always work (Sean Dewar)

(endless yapping omitted)

related: vim/vim#19728

bf21df1c7b

b_nwindows = 0 change for free_all_mem() was already ported.

Originally Nvim returned true when b_nwindows was decremented before the end was
reached (to better indicate the decrement). That's not needed anymore, so just
return true only at the end, like Vim. (retval isn't used anywhere now anyways)

Set textlock for dict watchers at the end of close_buffer() to prevent them from
switching windows, as that can leave a window with a NULL buffer. (possible
before this PR, but the new assert catches it; added a test)

Despite textlock, things still aren't ideal, as watchers may observe the buffer
as unloaded and hidden (b_nwindows was decremented), yet still in a window...
Likewise, for Nvim, wipe_qf_buffer()'s comment may not be entirely accurate;
autocmds are blocked, but on_detach callbacks (textlocked) and dict watchers may
still run. Might be problematic, but those aren't new issues.

Co-authored-by: Sean Dewar <6256228+seandewar@users.noreply.github.com>
2026-03-29 23:49:24 +01:00

583 lines
17 KiB
Lua

local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local assert_alive = n.assert_alive
local clear, source = n.clear, n.source
local api = n.api
local insert = n.insert
local eq, next_msg = t.eq, n.next_msg
local matches = t.matches
local exc_exec = n.exc_exec
local exec_lua = n.exec_lua
local command = n.command
local eval = n.eval
local pcall_err = t.pcall_err
describe('Vimscript dictionary notifications', function()
local channel
before_each(function()
clear()
channel = api.nvim_get_chan_info(0).id
api.nvim_set_var('channel', channel)
end)
-- the same set of tests are applied to top-level dictionaries(g:, b:, w: and
-- t:) and a dictionary variable, so we generate them in the following
-- function.
local function gentests(dict_expr, dict_init)
local is_g = dict_expr == 'g:'
local function update(opval, key)
if not key then
key = 'watched'
end
if opval == '' then
command(("unlet %s['%s']"):format(dict_expr, key))
else
command(("let %s['%s'] %s"):format(dict_expr, key, opval))
end
end
local function update_with_api(opval, key)
if not key then
key = 'watched'
end
if opval == '' then
exec_lua(("vim.api.nvim_del_var('%s')"):format(key))
else
exec_lua(("vim.api.nvim_set_var('%s', %s)"):format(key, opval))
end
end
local function update_with_vim_g(opval, key)
if not key then
key = 'watched'
end
if opval == '' then
exec_lua(('vim.g.%s = nil'):format(key))
else
exec_lua(('vim.g.%s %s'):format(key, opval))
end
end
local function verify_echo()
-- helper to verify that no notifications are sent after certain change
-- to a dict
command("call rpcnotify(g:channel, 'echo')")
eq({ 'notification', 'echo', {} }, next_msg())
end
local function verify_value(vals, key)
if not key then
key = 'watched'
end
eq({ 'notification', 'values', { key, vals } }, next_msg())
end
describe(dict_expr .. ' watcher', function()
if dict_init then
before_each(function()
source(dict_init)
end)
end
before_each(function()
source([[
function! g:Changed(dict, key, value)
if a:dict isnot ]] .. dict_expr .. [[ |
throw 'invalid dict'
endif
call rpcnotify(g:channel, 'values', a:key, a:value)
endfunction
call dictwatcheradd(]] .. dict_expr .. [[, "watched", "g:Changed")
call dictwatcheradd(]] .. dict_expr .. [[, "watched2", "g:Changed")
]])
end)
after_each(function()
source([[
call dictwatcherdel(]] .. dict_expr .. [[, "watched", "g:Changed")
call dictwatcherdel(]] .. dict_expr .. [[, "watched2", "g:Changed")
]])
update('= "test"')
update('= "test2"', 'watched2')
update('', 'watched2')
update('')
verify_echo()
if is_g then
update_with_api('"test"')
update_with_api('"test2"', 'watched2')
update_with_api('', 'watched2')
update_with_api('')
verify_echo()
update_with_vim_g('= "test"')
update_with_vim_g('= "test2"', 'watched2')
update_with_vim_g('', 'watched2')
update_with_vim_g('')
verify_echo()
end
end)
it('is not triggered when unwatched keys are updated', function()
update('= "noop"', 'unwatched')
update('.= "noop2"', 'unwatched')
update('', 'unwatched')
verify_echo()
if is_g then
update_with_api('"noop"', 'unwatched')
update_with_api('vim.g.unwatched .. "noop2"', 'unwatched')
update_with_api('', 'unwatched')
verify_echo()
update_with_vim_g('= "noop"', 'unwatched')
update_with_vim_g('= vim.g.unwatched .. "noop2"', 'unwatched')
update_with_vim_g('', 'unwatched')
verify_echo()
end
end)
it('is triggered by remove()', function()
update('= "test"')
verify_value({ new = 'test' })
command('call remove(' .. dict_expr .. ', "watched")')
verify_value({ old = 'test' })
end)
if is_g then
it('is triggered by remove() when updated with nvim_*_var', function()
update_with_api('"test"')
verify_value({ new = 'test' })
command('call remove(' .. dict_expr .. ', "watched")')
verify_value({ old = 'test' })
end)
it('is triggered by remove() when updated with vim.g', function()
update_with_vim_g('= "test"')
verify_value({ new = 'test' })
command('call remove(' .. dict_expr .. ', "watched")')
verify_value({ old = 'test' })
end)
end
it('is triggered by extend()', function()
update('= "xtend"')
verify_value({ new = 'xtend' })
command([[
call extend(]] .. dict_expr .. [[, {'watched': 'xtend2', 'watched2': 5, 'watched3': 'a'})
]])
verify_value({ old = 'xtend', new = 'xtend2' })
verify_value({ new = 5 }, 'watched2')
update('')
verify_value({ old = 'xtend2' })
update('', 'watched2')
verify_value({ old = 5 }, 'watched2')
update('', 'watched3')
verify_echo()
end)
it('is triggered with key patterns', function()
source([[
call dictwatcheradd(]] .. dict_expr .. [[, "wat*", "g:Changed")
]])
update('= 1')
verify_value({ new = 1 })
verify_value({ new = 1 })
update('= 3', 'watched2')
verify_value({ new = 3 }, 'watched2')
verify_value({ new = 3 }, 'watched2')
verify_echo()
source([[
call dictwatcherdel(]] .. dict_expr .. [[, "wat*", "g:Changed")
]])
-- watch every key pattern
source([[
call dictwatcheradd(]] .. dict_expr .. [[, "*", "g:Changed")
]])
update('= 3', 'another_key')
update('= 4', 'another_key')
update('', 'another_key')
update('= 2')
verify_value({ new = 3 }, 'another_key')
verify_value({ old = 3, new = 4 }, 'another_key')
verify_value({ old = 4 }, 'another_key')
verify_value({ old = 1, new = 2 })
verify_value({ old = 1, new = 2 })
verify_echo()
source([[
call dictwatcherdel(]] .. dict_expr .. [[, "*", "g:Changed")
]])
end)
it('is triggered for empty keys', function()
command([[
call dictwatcheradd(]] .. dict_expr .. [[, "", "g:Changed")
]])
update('= 1', '')
verify_value({ new = 1 }, '')
update('= 2', '')
verify_value({ old = 1, new = 2 }, '')
command([[
call dictwatcherdel(]] .. dict_expr .. [[, "", "g:Changed")
]])
end)
it('is triggered for empty keys when using catch-all *', function()
command([[
call dictwatcheradd(]] .. dict_expr .. [[, "*", "g:Changed")
]])
update('= 1', '')
verify_value({ new = 1 }, '')
update('= 2', '')
verify_value({ old = 1, new = 2 }, '')
command([[
call dictwatcherdel(]] .. dict_expr .. [[, "*", "g:Changed")
]])
end)
-- test a sequence of updates of different types to ensure proper memory
-- management(with ASAN)
local function test_updates(tests)
it('test change sequence', function()
local input, output
for i = 1, #tests do
input, output = unpack(tests[i])
update(input)
verify_value(output)
end
end)
end
test_updates({
{ '= 3', { new = 3 } },
{ '= 6', { old = 3, new = 6 } },
{ '+= 3', { old = 6, new = 9 } },
{ '', { old = 9 } },
})
test_updates({
{ '= "str"', { new = 'str' } },
{ '= "str2"', { old = 'str', new = 'str2' } },
{ '.= "2str"', { old = 'str2', new = 'str22str' } },
{ '', { old = 'str22str' } },
})
test_updates({
{ '= [1, 2]', { new = { 1, 2 } } },
{ '= [1, 2, 3]', { old = { 1, 2 }, new = { 1, 2, 3 } } },
-- the += will update the list in place, so old and new are the same
{ '+= [4, 5]', { old = { 1, 2, 3, 4, 5 }, new = { 1, 2, 3, 4, 5 } } },
{ '', { old = { 1, 2, 3, 4, 5 } } },
})
test_updates({
{ '= {"k": "v"}', { new = { k = 'v' } } },
{ '= {"k1": 2}', { old = { k = 'v' }, new = { k1 = 2 } } },
{ '', { old = { k1 = 2 } } },
})
end)
end
gentests('g:')
gentests('b:')
gentests('w:')
gentests('t:')
gentests('g:dict_var', 'let g:dict_var = {}')
describe('multiple watchers on the same dict/key', function()
before_each(function()
source([[
function! g:Watcher1(dict, key, value)
call rpcnotify(g:channel, '1', a:key, a:value)
endfunction
function! g:Watcher2(dict, key, value)
call rpcnotify(g:channel, '2', a:key, a:value)
endfunction
call dictwatcheradd(g:, "key", "g:Watcher1")
call dictwatcheradd(g:, "key", "g:Watcher2")
]])
end)
it('invokes all callbacks when the key is changed', function()
command('let g:key = "value"')
eq({ 'notification', '1', { 'key', { new = 'value' } } }, next_msg())
eq({ 'notification', '2', { 'key', { new = 'value' } } }, next_msg())
end)
it('only removes watchers that fully match dict, key and callback', function()
command('let g:key = "value"')
eq({ 'notification', '1', { 'key', { new = 'value' } } }, next_msg())
eq({ 'notification', '2', { 'key', { new = 'value' } } }, next_msg())
command('call dictwatcherdel(g:, "key", "g:Watcher1")')
command('let g:key = "v2"')
eq({ 'notification', '2', { 'key', { old = 'value', new = 'v2' } } }, next_msg())
end)
end)
it('errors out when adding to v:_null_dict', function()
command([[
function! g:Watcher1(dict, key, value)
call rpcnotify(g:channel, '1', a:key, a:value)
endfunction
]])
eq(
'Vim(call):E46: Cannot change read-only variable "dictwatcheradd() argument"',
exc_exec('call dictwatcheradd(v:_null_dict, "x", "g:Watcher1")')
)
end)
describe('errors', function()
before_each(function()
source([[
function! g:Watcher1(dict, key, value)
call rpcnotify(g:channel, '1', a:key, a:value)
endfunction
function! g:Watcher2(dict, key, value)
call rpcnotify(g:channel, '2', a:key, a:value)
endfunction
]])
end)
-- WARNING: This suite depends on the above tests
it('fails to remove if no watcher with matching callback is found', function()
eq(
"Vim(call):Couldn't find a watcher matching key and callback",
exc_exec('call dictwatcherdel(g:, "key", "g:Watcher1")')
)
end)
it('fails to remove if no watcher with matching key is found', function()
eq(
"Vim(call):Couldn't find a watcher matching key and callback",
exc_exec('call dictwatcherdel(g:, "invalid_key", "g:Watcher2")')
)
end)
it("does not fail to add/remove if the callback doesn't exist", function()
command('call dictwatcheradd(g:, "key", "g:InvalidCb")')
command('call dictwatcherdel(g:, "key", "g:InvalidCb")')
end)
it('fails to remove watcher from v:_null_dict', function()
eq(
"Vim(call):Couldn't find a watcher matching key and callback",
exc_exec('call dictwatcherdel(v:_null_dict, "x", "g:Watcher2")')
)
end)
--[[
[ it("fails to add/remove if the callback doesn't exist", function()
[ eq("Vim(call):Function g:InvalidCb doesn't exist",
[ exc_exec('call dictwatcheradd(g:, "key", "g:InvalidCb")'))
[ eq("Vim(call):Function g:InvalidCb doesn't exist",
[ exc_exec('call dictwatcherdel(g:, "key", "g:InvalidCb")'))
[ end)
]]
it('does not fail to replace a watcher function', function()
source([[
let g:key = 'v2'
call dictwatcheradd(g:, "key", "g:Watcher2")
function! g:ReplaceWatcher2()
function! g:Watcher2(dict, key, value)
call rpcnotify(g:channel, '2b', a:key, a:value)
endfunction
endfunction
]])
command('call g:ReplaceWatcher2()')
command('let g:key = "value"')
eq({ 'notification', '2b', { 'key', { old = 'v2', new = 'value' } } }, next_msg())
end)
it('does not crash when freeing a watched dictionary', function()
source([[
function! Watcher(dict, key, value)
echo a:key string(a:value)
endfunction
function! MakeWatch()
let d = {'foo': 'bar'}
call dictwatcheradd(d, 'foo', function('Watcher'))
endfunction
]])
command('call MakeWatch()')
assert_alive()
end)
end)
describe('with lambdas', function()
it('works correctly', function()
source([[
let d = {'foo': 'baz'}
call dictwatcheradd(d, 'foo', {dict, key, value -> rpcnotify(g:channel, '2', key, value)})
let d.foo = 'bar'
]])
eq({ 'notification', '2', { 'foo', { old = 'baz', new = 'bar' } } }, next_msg())
end)
end)
it('for b:changedtick', function()
source([[
function! OnTickChanged(dict, key, value)
call rpcnotify(g:channel, 'SendChangeTick', a:key, a:value)
endfunction
call dictwatcheradd(b:, 'changedtick', 'OnTickChanged')
]])
insert('t')
eq({ 'notification', 'SendChangeTick', { 'changedtick', { old = 2, new = 3 } } }, next_msg())
command([[call dictwatcherdel(b:, 'changedtick', 'OnTickChanged')]])
insert('t')
assert_alive()
command([[call dictwatcheradd(b:, 'changedtick', {-> execute('bwipe!')})]])
insert('t')
eq('E937: Attempt to delete a buffer that is in use: [No Name]', api.nvim_get_vvar('errmsg'))
assert_alive()
command([[enew | set modified | call dictwatcheradd(b:, 'changedtick', {-> execute('split')})]])
-- Used to instead leave a window open to a NULL buffer.
matches(
'E565: Not allowed to change text or change window: split$',
pcall_err(command, 'bdelete!')
)
end)
it('does not cause use-after-free when unletting from callback', function()
source([[
let g:called = 0
function W(...) abort
unlet g:d
let g:called = 1
endfunction
let g:d = {}
call dictwatcheradd(g:d, '*', function('W'))
let g:d.foo = 123
]])
eq(1, eval('g:called'))
end)
it('does not crash when using dictwatcherdel in callback', function()
source([[
let g:d = {}
function! W1(...)
" Delete current and following watcher.
call dictwatcherdel(g:d, '*', function('W1'))
call dictwatcherdel(g:d, '*', function('W2'))
try
call dictwatcherdel({}, 'meh', function('tr'))
catch
let g:exc = v:exception
endtry
endfunction
call dictwatcheradd(g:d, '*', function('W1'))
function! W2(...)
endfunction
call dictwatcheradd(g:d, '*', function('W2'))
let g:d.foo = 23
]])
eq(23, eval('g:d.foo'))
eq("Vim(call):Couldn't find a watcher matching key and callback", eval('g:exc'))
end)
it('does not call watcher added in callback', function()
source([[
let g:d = {}
let g:calls = []
function! W1(...) abort
call add(g:calls, 'W1')
call dictwatcheradd(g:d, '*', function('W2'))
endfunction
function! W2(...) abort
call add(g:calls, 'W2')
endfunction
call dictwatcheradd(g:d, '*', function('W1'))
let g:d.foo = 23
]])
eq(23, eval('g:d.foo'))
eq({ 'W1' }, eval('g:calls'))
end)
it('calls watcher deleted in callback', function()
source([[
let g:d = {}
let g:calls = []
function! W1(...) abort
call add(g:calls, "W1")
call dictwatcherdel(g:d, '*', function('W2'))
endfunction
function! W2(...) abort
call add(g:calls, "W2")
endfunction
call dictwatcheradd(g:d, '*', function('W1'))
call dictwatcheradd(g:d, '*', function('W2'))
let g:d.foo = 123
unlet g:d
let g:d = {}
call dictwatcheradd(g:d, '*', function('W2'))
call dictwatcheradd(g:d, '*', function('W1'))
let g:d.foo = 123
]])
eq(123, eval('g:d.foo'))
eq({ 'W1', 'W2', 'W2', 'W1' }, eval('g:calls'))
end)
end)
describe('tabpagebuflist() with dict watcher during buffer close/wipe', function()
before_each(function()
clear()
end)
it(
'does not segfault when called from dict watcher on b:changedtick (bufhidden=unload)',
function()
command([[
new
set bufhidden=unload
call dictwatcheradd(b:, 'changedtick', {-> tabpagebuflist()})
close
]])
assert_alive()
end
)
it('does not segfault when wiping buffer with dict watcher', function()
command([[
new
call setline(1, 'test')
call dictwatcheradd(b:, 'changedtick', {-> tabpagebuflist()})
bwipeout!
]])
assert_alive()
end)
it('does not segfault with multiple windows in the tabpage', function()
command([[
" create two windows in the current tab
edit foo
vnew
call setline(1, 'bar')
" attach watcher to the current buffer in the split
call dictwatcheradd(b:, 'changedtick', {-> tabpagebuflist()})
" close the split window (triggers close_buffer on this buffer)
close
]])
assert_alive()
end)
end)