Files
neovim/test/functional/lua/watch_spec.lua
Lewis Russell efdd73c096 feat(watch): add on_error callback
Problem: Watcher failures are reported inconsistently, and callers
cannot detect when a watcher is no longer usable.

Solution: Route startup and event failures and unexpected inotifywait
exits through an optional on_error callback. Log to nvim-watch.log by
default. Ignore disappearing child directories and exits caused by
cancellation. Let LSP log failures and notify once per message, using
INFO for missing roots and ERROR otherwise, without interrupting
registration.

AI-assisted
2026-09-09 11:39:57 +01:00

247 lines
7.6 KiB
Lua

local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local describe, it, before_each = t.describe, t.it, t.before_each
local eq = t.eq
local exec_lua = n.exec_lua
local clear = n.clear
local is_ci = t.is_ci
local is_os = t.is_os
local skip = t.skip
-- Create a file via a rename to avoid multiple
-- events which can happen with some backends on some platforms
local function touch(path)
local tmp = t.tmpname()
assert(vim.uv.fs_rename(tmp, path))
end
describe('vim._watch', function()
before_each(function()
clear()
end)
it('watchdirs() does not scan excluded subtrees', function()
local root_dir = t.tmpname(false)
t.finally(function()
n.rmdir(root_dir)
end)
n.mkdir_p(root_dir .. '/src/deep')
n.mkdir_p(root_dir .. '/node_modules/pkg/excluded/deep')
-- Also cover a pattern that matches the directory but not its descendants.
for _, pattern in ipairs({ '**/node_modules/*/**', '**/excluded' }) do
local scanned = exec_lua(function(root, exclude_pattern)
root = vim.fs.normalize(root)
local scanned = {}
local fs_scandir = vim.uv.fs_scandir
vim.uv.fs_scandir = function(path, ...)
scanned[#scanned + 1] = path:sub(#root + 1)
return fs_scandir(path, ...)
end
local cancel = vim._watch.watchdirs(root, {
exclude_pattern = vim.glob.to_lpeg(exclude_pattern),
}, function() end)
vim.uv.fs_scandir = fs_scandir
cancel()
table.sort(scanned)
return scanned
end, root_dir, pattern)
eq({ '', '/node_modules', '/node_modules/pkg', '/src', '/src/deep' }, scanned, pattern)
end
end)
it('watchdirs() tolerates directories deleted during setup', function()
local root_dir = t.tmpname(false)
t.finally(function()
n.rmdir(root_dir)
end)
n.mkdir_p(root_dir .. '/gone')
exec_lua(function(root)
local dir = vim.fs.dir
vim.fs.dir = function(path, opts)
local iter = dir(path, opts)
return function()
local name, kind = iter()
if name == 'gone' then
-- Delete after enumeration, before the backend starts watching it.
assert(vim.uv.fs_rmdir(root .. '/gone'))
end
return name, kind
end
end
local cancel = vim._watch.watchdirs(root, { on_error = error }, function() end)
vim.fs.dir = dir
cancel()
end, root_dir)
end)
it('inotify() reports failure to start the process', function()
exec_lua(function()
vim.env.PATH = ''
local errors = {}
local cancel = vim._watch.inotify('.', {
on_error = function(err)
errors[#errors + 1] = err
end,
}, function() end)
cancel()
assert(#errors == 1, vim.inspect(errors))
assert(errors[1]:find('ENOENT', 1, true), errors[1])
assert(vim._watch.active.inotify == 0)
end)
end)
local function run(watchfunc)
local function do_watch(root_dir, watchfunc_)
exec_lua(
[[
local root_dir, watchfunc = ...
_G.events = {}
_G.stop_watch = vim._watch[watchfunc](root_dir, {
debounce = 100,
include_pattern = vim.lpeg.P(root_dir) * vim.lpeg.P("/file") ^ -1,
exclude_pattern = vim.lpeg.P(root_dir .. '/file.unwatched'),
}, function(path, change_type)
table.insert(_G.events, { path = path, change_type = change_type })
end)
]],
root_dir,
watchfunc_
)
end
it(watchfunc .. '() reports nonexistent paths to on_error', function()
if watchfunc == 'inotify' then
skip(n.fn.executable('inotifywait') == 0, 'inotifywait not found')
skip(is_os('bsd'), 'inotifywait on bsd CI seems to expect path to exist?')
skip(t.is_arch('s390x'), 'inotifywait not available on s390x CI')
end
exec_lua(function(backend)
local errors = {}
local cancel = vim._watch[backend]('/i am /very/funny.go', {
on_error = function(err)
errors[#errors + 1] = err
end,
}, function()
error('Unexpected file change')
end)
assert(vim.wait(2000, function()
return #errors > 0
end))
if backend ~= 'inotify' then
assert(errors[1]:match('^ENOENT:'), errors[1])
end
cancel()
end, watchfunc)
end)
if watchfunc ~= 'inotify' then
it(watchfunc .. '() logs startup failures without on_error', function()
local logfile = exec_lua(function(backend)
local logfile = vim.fs.joinpath(vim.fn.stdpath('log'), 'nvim-watch.log')
vim.fn.writefile({}, logfile)
local cancel = vim._watch[backend]('/i am /very/funny.go', {}, function()
error('Unexpected file change')
end)
cancel()
assert(vim._watch.active[backend] == 0)
return logfile
end, watchfunc)
t.assert_log('%[ERROR%].-ENOENT:', logfile)
end)
end
it(watchfunc .. '() detects file changes', function()
if watchfunc == 'inotify' then
skip(is_os('win'), 'N/A: inotify not supported on Windows')
skip(is_os('mac'), 'flaky test on mac')
skip(not is_ci() and n.fn.executable('inotifywait') == 0, 'inotifywait not found')
skip(t.is_arch('s390x'), 'inotifywait not available on s390x CI')
end
-- Note: because this is not `elseif`, BSD is skipped for *all* cases...?
if watchfunc == 'watch' then
skip(is_os('mac'), 'flaky test on mac')
skip(is_os('bsd'), 'Stopped working on bsd after 3ca967387c49c754561c3b11a574797504d40f38')
elseif watchfunc == 'watchdirs' and is_os('mac') then
skip(true, 'weird failure since macOS 14 CI, see bbf208784ca279178ba0075b60d3e9c80f11da7a')
else
skip(
is_os('bsd'),
'kqueue only reports events on watched folder itself, not contained files #26110'
)
end
local expected_events = 0
--- Waits for a new event, or fails if no events are triggered.
local function wait_for_event()
expected_events = expected_events + 1
exec_lua(
[[
local expected_events = ...
assert(
vim.wait(3000, function()
return #_G.events == expected_events
end),
string.format(
'Timed out waiting for expected event no. %d. Current events seen so far: %s',
expected_events,
vim.inspect(events)
)
)
]],
expected_events
)
end
local root_dir = vim.uv.fs_mkdtemp(vim.fs.dirname(t.tmpname(false)) .. '/nvim_XXXXXXXXXX')
local unwatched_path = root_dir .. '/file.unwatched'
local watched_path = root_dir .. '/file'
do_watch(root_dir, watchfunc)
if watchfunc ~= 'watch' then
vim.uv.sleep(200)
end
touch(watched_path)
touch(unwatched_path)
wait_for_event()
os.remove(watched_path)
os.remove(unwatched_path)
wait_for_event()
exec_lua [[_G.stop_watch()]]
-- No events should come through anymore
vim.uv.sleep(100)
touch(watched_path)
vim.uv.sleep(100)
os.remove(watched_path)
vim.uv.sleep(100)
eq({
{
change_type = exec_lua([[return vim._watch.FileChangeType.Created]]),
path = root_dir .. '/file',
},
{
change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]),
path = root_dir .. '/file',
},
}, exec_lua [[return _G.events]])
end)
end
run('watch')
run('watchdirs')
run('inotify')
end)