mirror of
https://github.com/neovim/neovim.git
synced 2026-09-12 17:11:02 +00:00
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
This commit is contained in:
committed by
Lewis Russell
parent
318ea4de21
commit
efdd73c096
@@ -209,9 +209,11 @@ end
|
||||
--- @param name string Plugin name, e.g. "zip".
|
||||
--- @param msg string
|
||||
--- @param level? integer Level from |vim.log.levels|. Defaults to ERROR.
|
||||
function M.notify(name, msg, level)
|
||||
--- @param once? boolean Only show the message once.
|
||||
function M.notify(name, msg, level, once)
|
||||
vim.schedule(function()
|
||||
vim.notify(('%s: %s'):format(name, msg), level or vim.log.levels.ERROR)
|
||||
local notify = once and vim.notify_once or vim.notify
|
||||
notify(('%s: %s'):format(name, msg), level or vim.log.levels.ERROR)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
local uv = vim.uv
|
||||
local log = vim.log.new({ name = 'nvim-watch' })
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -36,6 +37,9 @@ end
|
||||
---
|
||||
--- @field debounce? integer ms
|
||||
---
|
||||
--- Handles watcher failures. Defaults to logging at ERROR in nvim-watch.log.
|
||||
--- @field on_error? fun(err: string)
|
||||
---
|
||||
--- An |lpeg| pattern. Only changes to files whose full paths match the pattern
|
||||
--- will be reported. Only matches against non-directoriess, all directories will
|
||||
--- be watched for new potentially-matching files. exclude_pattern can be used to
|
||||
@@ -87,6 +91,7 @@ function M.watch(path, opts, callback)
|
||||
vim.validate('callback', callback, 'function')
|
||||
|
||||
opts = opts or {}
|
||||
local on_error = opts.on_error or log.error
|
||||
|
||||
path = vim.fs.normalize(path)
|
||||
local uvflags = opts and opts.uvflags or {}
|
||||
@@ -94,8 +99,10 @@ function M.watch(path, opts, callback)
|
||||
|
||||
local watching_dir = (uv.fs_stat(path) or {}).type == 'directory'
|
||||
|
||||
local _, start_err, start_errname = handle:start(path, uvflags, function(err, filename, events)
|
||||
assert(not err, err)
|
||||
local _, start_err = handle:start(path, uvflags, function(err, filename, events)
|
||||
if err then
|
||||
return on_error(err)
|
||||
end
|
||||
local fullpath = path
|
||||
if filename and watching_dir then
|
||||
fullpath = vim.fs.normalize(vim.fs.joinpath(fullpath, filename))
|
||||
@@ -112,7 +119,9 @@ function M.watch(path, opts, callback)
|
||||
if staterrname == 'ENOENT' then
|
||||
change_type = M.FileChangeType.Deleted
|
||||
else
|
||||
assert(not staterr, staterr)
|
||||
if staterr then
|
||||
return on_error(staterr)
|
||||
end
|
||||
change_type = M.FileChangeType.Created
|
||||
end
|
||||
elseif events.change then
|
||||
@@ -122,13 +131,8 @@ function M.watch(path, opts, callback)
|
||||
end)
|
||||
|
||||
if start_err then
|
||||
if start_errname == 'ENOENT' then
|
||||
-- Server may send "workspace/didChangeWatchedFiles" with nonexistent `baseUri` path.
|
||||
-- This is mostly a placeholder until we have `nvim_log` API.
|
||||
vim.notify_once(('watch.watch: %s'):format(start_err), vim.log.levels.INFO)
|
||||
end
|
||||
handle:close()
|
||||
-- TODO(justinmk): log important errors once we have `nvim_log` API.
|
||||
on_error(start_err)
|
||||
return function() end
|
||||
end
|
||||
|
||||
@@ -156,6 +160,7 @@ function M.watchdirs(path, opts, callback)
|
||||
vim.validate('callback', callback, 'function')
|
||||
|
||||
opts = opts or {}
|
||||
local on_error = opts.on_error or log.error
|
||||
local debounce = opts.debounce or 500
|
||||
|
||||
---@type table<string, uv.uv_fs_event_t> handle by fullpath
|
||||
@@ -174,7 +179,9 @@ function M.watchdirs(path, opts, callback)
|
||||
--- @return uv.fs_event_start.callback
|
||||
local function create_on_change(filepath)
|
||||
return function(err, filename, events)
|
||||
assert(not err, err)
|
||||
if err then
|
||||
return on_error(err)
|
||||
end
|
||||
local fullpath = vim.fs.joinpath(filepath, filename)
|
||||
if skip(fullpath, opts) then
|
||||
return
|
||||
@@ -204,7 +211,15 @@ function M.watchdirs(path, opts, callback)
|
||||
if not handle then
|
||||
handle = assert(uv.new_fs_event())
|
||||
handles[fullpath] = handle
|
||||
handle:start(fullpath, {}, create_on_change(fullpath))
|
||||
local _, err, errname = handle:start(fullpath, {}, create_on_change(fullpath))
|
||||
if err then
|
||||
handle:close()
|
||||
handles[fullpath] = nil
|
||||
-- The directory may have disappeared since fs_stat().
|
||||
if errname ~= 'ENOENT' then
|
||||
on_error(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
@@ -225,17 +240,13 @@ function M.watchdirs(path, opts, callback)
|
||||
|
||||
local root_handle = assert(uv.new_fs_event())
|
||||
handles[path] = root_handle
|
||||
local _, start_err, start_errname = root_handle:start(path, {}, create_on_change(path))
|
||||
local _, start_err = root_handle:start(path, {}, create_on_change(path))
|
||||
|
||||
if start_err then
|
||||
if start_errname == 'ENOENT' then
|
||||
-- Server may send "workspace/didChangeWatchedFiles" with nonexistent `baseUri` path.
|
||||
-- This is mostly a placeholder until we have `nvim_log` API.
|
||||
vim.notify_once(('watch.watchdirs: %s'):format(start_err), vim.log.levels.INFO)
|
||||
end
|
||||
-- TODO(justinmk): log important errors once we have `nvim_log` API.
|
||||
|
||||
-- Continue. vim.fs.dir() will return nothing, so the code below is harmless.
|
||||
root_handle:close()
|
||||
timer:close()
|
||||
on_error(start_err)
|
||||
return function() end
|
||||
end
|
||||
|
||||
--- "640K ought to be enough for anyone"
|
||||
@@ -256,7 +267,15 @@ function M.watchdirs(path, opts, callback)
|
||||
if not skip(filepath, opts) then
|
||||
local handle = assert(uv.new_fs_event())
|
||||
handles[filepath] = handle
|
||||
handle:start(filepath, {}, create_on_change(filepath))
|
||||
local _, err, errname = handle:start(filepath, {}, create_on_change(filepath))
|
||||
if err then
|
||||
handle:close()
|
||||
handles[filepath] = nil
|
||||
-- The directory may have disappeared since it was listed.
|
||||
if errname ~= 'ENOENT' then
|
||||
on_error(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -314,7 +333,10 @@ end
|
||||
--- @param callback vim._watch.Callback Callback for new events
|
||||
--- @return fun() cancel Stops the watcher
|
||||
function M.inotify(path, opts, callback)
|
||||
local obj = vim.system({
|
||||
opts = opts or {}
|
||||
local on_error = opts.on_error or log.error
|
||||
local cancelled = false
|
||||
local ok, obj = pcall(vim.system, {
|
||||
'inotifywait',
|
||||
'--quiet', -- suppress startup messages
|
||||
'--no-dereference', -- don't follow symlinks
|
||||
@@ -333,22 +355,21 @@ function M.inotify(path, opts, callback)
|
||||
}, {
|
||||
stderr = function(err, data)
|
||||
if err then
|
||||
error(err)
|
||||
on_error(err)
|
||||
return
|
||||
end
|
||||
|
||||
if data and #vim.trim(data) > 0 then
|
||||
vim.schedule(function()
|
||||
if vim.fn.has('linux') == 1 and vim.startswith(data, 'Failed to watch') then
|
||||
data = 'inotify(7) limit reached, see :h inotify-limitations for more info.'
|
||||
end
|
||||
|
||||
vim.notify('inotify: ' .. data, vim.log.levels.ERROR)
|
||||
end)
|
||||
if vim.fn.has('linux') == 1 and vim.startswith(data, 'Failed to watch') then
|
||||
data = 'inotify(7) limit reached, see :h inotify-limitations for more info.'
|
||||
end
|
||||
on_error(data)
|
||||
end
|
||||
end,
|
||||
stdout = function(err, data)
|
||||
if err then
|
||||
error(err)
|
||||
on_error(err)
|
||||
return
|
||||
end
|
||||
|
||||
for line in vim.gsplit(data or '', '\n', { plain = true, trimempty = true }) do
|
||||
@@ -357,9 +378,19 @@ function M.inotify(path, opts, callback)
|
||||
end,
|
||||
-- --latency is locale dependent but tostring() isn't and will always have '.' as decimal point.
|
||||
env = { LC_NUMERIC = 'C' },
|
||||
})
|
||||
}, function(result)
|
||||
if not cancelled then
|
||||
on_error(('inotifywait exited with code %d'):format(result.code))
|
||||
end
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
on_error(obj)
|
||||
return function() end
|
||||
end
|
||||
|
||||
return tracked_cancel('inotify', function()
|
||||
cancelled = true
|
||||
obj:kill(2)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ local bit = require('bit')
|
||||
local glob = vim.glob
|
||||
local watch = vim._watch
|
||||
local log = require('vim.lsp.log')
|
||||
local notify = require('vim._core.util').notify
|
||||
local protocol = require('vim.lsp.protocol')
|
||||
local lpeg = vim.lpeg
|
||||
|
||||
@@ -185,6 +186,20 @@ function M.register(reg, client_id)
|
||||
-- match a *particular* pattern+kind pair.
|
||||
include_pattern = include_pattern,
|
||||
exclude_pattern = M._poll_exclude_pattern,
|
||||
on_error = function(err)
|
||||
local name = string.format('LSP[%s]', client.name)
|
||||
local message = string.format('file watcher failed for %s', base_dir)
|
||||
local level = vim.log.levels.ERROR
|
||||
-- Servers may register a nonexistent baseUri. Keep this informational
|
||||
-- and continue registering the other watchers.
|
||||
if err:match('^ENOENT:') then
|
||||
level = vim.log.levels.INFO
|
||||
log.info(name, message, err)
|
||||
else
|
||||
log.error(name, message, err)
|
||||
end
|
||||
notify(name, message .. ': ' .. err, level, true)
|
||||
end,
|
||||
}, callback(base_dir))
|
||||
)
|
||||
end
|
||||
|
||||
@@ -52,24 +52,49 @@ describe('vim._watch', function()
|
||||
end
|
||||
end)
|
||||
|
||||
local function run(watchfunc)
|
||||
-- Monkey-patches vim.notify_once so we can "spy" on it.
|
||||
local function spy_notify_once()
|
||||
exec_lua [[
|
||||
_G.__notify_once_msgs = {}
|
||||
vim.notify_once = (function(overridden)
|
||||
return function(msg, level, opts)
|
||||
table.insert(_G.__notify_once_msgs, msg)
|
||||
return overridden(msg, level, opts)
|
||||
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
|
||||
end)(vim.notify_once)
|
||||
]]
|
||||
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)
|
||||
|
||||
local function last_notify_once_msg()
|
||||
return exec_lua 'return _G.__notify_once_msgs[#_G.__notify_once_msgs]'
|
||||
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(
|
||||
[[
|
||||
@@ -90,28 +115,48 @@ describe('vim._watch', function()
|
||||
)
|
||||
end
|
||||
|
||||
it(watchfunc .. '() ignores nonexistent paths', function()
|
||||
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
|
||||
|
||||
local msg = ('watch.%s: ENOENT: no such file or directory'):format(watchfunc)
|
||||
|
||||
spy_notify_once()
|
||||
do_watch('/i am /very/funny.go', watchfunc)
|
||||
|
||||
if watchfunc ~= 'inotify' then -- watch.inotify() doesn't (currently) call vim.notify_once.
|
||||
t.retry(nil, 2000, function()
|
||||
t.eq(msg, last_notify_once_msg())
|
||||
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)
|
||||
end
|
||||
eq(0, exec_lua [[return #_G.events]])
|
||||
|
||||
exec_lua [[_G.stop_watch()]]
|
||||
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')
|
||||
|
||||
@@ -4084,9 +4084,13 @@ describe('LSP', function()
|
||||
deleted = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]])
|
||||
end)
|
||||
|
||||
local function test_filechanges(watchfunc)
|
||||
local function test_filechanges(watchfunc, missing_root)
|
||||
it(
|
||||
string.format('sends notifications when files change (watchfunc=%s)', watchfunc),
|
||||
string.format(
|
||||
'sends notifications when files change (watchfunc=%s)%s',
|
||||
watchfunc,
|
||||
missing_root and ' after root is created' or ''
|
||||
),
|
||||
function()
|
||||
if watchfunc == 'inotify' then
|
||||
skip(is_os('win'), 'not supported on windows')
|
||||
@@ -4112,12 +4116,19 @@ describe('LSP', function()
|
||||
end
|
||||
|
||||
local root_dir = tmpname(false)
|
||||
mkdir(root_dir)
|
||||
if not missing_root then
|
||||
mkdir(root_dir)
|
||||
end
|
||||
|
||||
exec_lua(create_server_definition)
|
||||
local result = exec_lua(function()
|
||||
local logfile = vim.lsp.log.get_filename()
|
||||
vim.lsp.log.set_level('info')
|
||||
vim.fn.writefile({ '' }, logfile)
|
||||
local notifications = {}
|
||||
vim.notify = function(message, level)
|
||||
notifications[#notifications + 1] = { message, level }
|
||||
end
|
||||
local server = _G._create_server()
|
||||
local client_id = assert(vim.lsp.start({
|
||||
name = 'watchfiles-test',
|
||||
@@ -4179,6 +4190,19 @@ describe('LSP', function()
|
||||
},
|
||||
}, { client_id = client_id })
|
||||
|
||||
if missing_root then
|
||||
local client = assert(vim.lsp.get_client_by_id(client_id))
|
||||
local method = 'workspace/didChangeWatchedFiles'
|
||||
local reg = vim.deepcopy(client.registrations[method][1])
|
||||
reg.id = 'watchfiles-test-missing'
|
||||
client:_register({ reg })
|
||||
client:_unregister({ { id = reg.id, method = method } })
|
||||
vim.fn.mkdir(root_dir)
|
||||
reg.id = 'watchfiles-test-1'
|
||||
client:_register({ reg })
|
||||
client:_unregister({ { id = 'watchfiles-test-0', method = method } })
|
||||
end
|
||||
|
||||
if watchfunc ~= 'watch' then
|
||||
vim.wait(100)
|
||||
end
|
||||
@@ -4196,7 +4220,7 @@ describe('LSP', function()
|
||||
|
||||
vim.lsp.get_client_by_id(client_id):stop()
|
||||
|
||||
return { logfile = logfile, messages = server.messages }
|
||||
return { logfile = logfile, messages = server.messages, notifications = notifications }
|
||||
end)
|
||||
|
||||
local uri = vim.uri_from_fname(root_dir .. '/watch')
|
||||
@@ -4238,6 +4262,18 @@ describe('LSP', function()
|
||||
.. pesc('{foo}'),
|
||||
result.logfile
|
||||
)
|
||||
if missing_root then
|
||||
t.assert_log(
|
||||
'%[INFO%].-file watcher failed for ' .. pesc(root_dir) .. '.-ENOENT',
|
||||
result.logfile
|
||||
)
|
||||
eq(1, #result.notifications)
|
||||
eq(vim.log.levels.INFO, result.notifications[1][2])
|
||||
matches(
|
||||
'file watcher failed for ' .. pesc(root_dir) .. ': ENOENT',
|
||||
result.notifications[1][1]
|
||||
)
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
@@ -4245,6 +4281,7 @@ describe('LSP', function()
|
||||
test_filechanges('watch')
|
||||
test_filechanges('watchdirs')
|
||||
test_filechanges('inotify')
|
||||
test_filechanges('watchdirs', true)
|
||||
|
||||
it('correctly registers and unregisters', function()
|
||||
local root_dir = '/some_dir'
|
||||
|
||||
Reference in New Issue
Block a user