mirror of
https://github.com/neovim/neovim.git
synced 2026-04-02 05:39:26 +00:00
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>
583 lines
17 KiB
Lua
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)
|