fix(lua): close vim.defer_fn() timer if vim.schedule() failed (#37647)

Problem:
Using vim.defer_fn() just before Nvim exit leaks luv handles.

Solution:
Make vim.schedule() return an error message if scheduling failed.
Make vim.defer_fn() close timer if vim.schedule() failed.
This commit is contained in:
zeertzjq
2026-02-01 21:29:19 +08:00
committed by GitHub
parent 0501c5fd09
commit 1906da52db
6 changed files with 41 additions and 11 deletions

View File

@@ -722,6 +722,10 @@ vim.schedule({fn}) *vim.schedule()*
Parameters: ~ Parameters: ~
• {fn} (`fun()`) • {fn} (`fun()`)
Return (multiple): ~
(`nil`) result
(`string?`) err Error message if scheduling failed, `nil` otherwise.
vim.str_utf_end({str}, {index}) *vim.str_utf_end()* vim.str_utf_end({str}, {index}) *vim.str_utf_end()*
Gets the distance (in bytes) from the last byte of the codepoint Gets the distance (in bytes) from the last byte of the codepoint
(character) that {index} points to. (character) that {index} points to.
@@ -1272,7 +1276,7 @@ vim.defer_fn({fn}, {timeout}) *vim.defer_fn()*
Defers calling {fn} until {timeout} ms passes. Defers calling {fn} until {timeout} ms passes.
Use to do a one-shot timer that calls {fn} Note: The {fn} is Use to do a one-shot timer that calls {fn} Note: The {fn} is
|vim.schedule_wrap()|ped automatically, so API functions are safe to call. |vim.schedule()|d automatically, so API functions are safe to call.
Parameters: ~ Parameters: ~
• {fn} (`function`) Callback to call once `timeout` expires • {fn} (`function`) Callback to call once `timeout` expires

View File

@@ -509,25 +509,28 @@ end
--- Defers calling {fn} until {timeout} ms passes. --- Defers calling {fn} until {timeout} ms passes.
--- ---
--- Use to do a one-shot timer that calls {fn} --- Use to do a one-shot timer that calls {fn}
--- Note: The {fn} is |vim.schedule_wrap()|ped automatically, so API functions are --- Note: The {fn} is |vim.schedule()|d automatically, so API functions are
--- safe to call. --- safe to call.
---@param fn function Callback to call once `timeout` expires ---@param fn function Callback to call once `timeout` expires
---@param timeout integer Number of milliseconds to wait before calling `fn` ---@param timeout integer Number of milliseconds to wait before calling `fn`
---@return table timer luv timer object ---@return table timer luv timer object
function vim.defer_fn(fn, timeout) function vim.defer_fn(fn, timeout)
vim.validate('fn', fn, 'callable', true) vim.validate('fn', fn, 'callable', true)
local timer = assert(vim.uv.new_timer()) local timer = assert(vim.uv.new_timer())
timer:start( timer:start(timeout, 0, function()
timeout, local _, err = vim.schedule(function()
0,
vim.schedule_wrap(function()
if not timer:is_closing() then if not timer:is_closing() then
timer:close() timer:close()
end end
fn() fn()
end) end)
)
if err then
timer:close()
end
end)
return timer return timer
end end

View File

@@ -178,6 +178,8 @@ function vim.iconv(str, from, to, opts) end
--- Schedules {fn} to be invoked soon by the main event-loop. Useful --- Schedules {fn} to be invoked soon by the main event-loop. Useful
--- to avoid |textlock| or other temporary restrictions. --- to avoid |textlock| or other temporary restrictions.
--- @param fn fun() --- @param fn fun()
--- @return nil result
--- @return string? err Error message if scheduling failed, `nil` otherwise.
function vim.schedule(fn) end function vim.schedule(fn) end
--- Waits up to `time` milliseconds, until `callback` returns `true` (success). Executes --- Waits up to `time` milliseconds, until `callback` returns `true` (success). Executes

View File

@@ -407,17 +407,20 @@ static int nlua_schedule(lua_State *const lstate)
return lua_error(lstate); return lua_error(lstate);
} }
lua_pushnil(lstate);
// If main_loop is closing don't schedule tasks to run in the future, // If main_loop is closing don't schedule tasks to run in the future,
// otherwise any refs allocated here will not be cleaned up. // otherwise any refs allocated here will not be cleaned up.
if (main_loop.closing) { if (main_loop.closing) {
return 0; lua_pushliteral(lstate, "main loop is closing");
return 2;
} }
LuaRef cb = nlua_ref_global(lstate, 1); LuaRef cb = nlua_ref_global(lstate, 1);
// Pass along UI event handler to disable on error. // Pass along UI event handler to disable on error.
multiqueue_put(main_loop.events, nlua_schedule_event, (void *)(ptrdiff_t)cb, multiqueue_put(main_loop.events, nlua_schedule_event, (void *)(ptrdiff_t)cb,
(void *)(ptrdiff_t)ui_event_ns_id); (void *)(ptrdiff_t)ui_event_ns_id);
return 0; lua_pushnil(lstate);
return 2;
} }
// Dummy timer callback. Used by f_wait(). // Dummy timer callback. Used by f_wait().

View File

@@ -1786,6 +1786,24 @@ describe('lua stdlib', function()
eq(true, exec_lua [[return vim.g.test]]) eq(true, exec_lua [[return vim.g.test]])
end) end)
it('nested vim.defer_fn does not leak handles on exit #19727', function()
n.expect_exit(exec_lua, function()
vim.defer_fn(function()
vim.defer_fn(function()
vim.defer_fn(function() end, 0)
end, 0)
end, 0)
vim.cmd('qall')
end)
end)
it('vim.defer_fn with timeout does not leak handles on exit', function()
n.expect_exit(exec_lua, function()
vim.defer_fn(function() end, 50)
vim.cmd('qall')
end)
end)
describe('vim.region', function() describe('vim.region', function()
it('charwise', function() it('charwise', function()
insert(dedent([[ insert(dedent([[

View File

@@ -510,8 +510,8 @@ function M.new_session(keep, ...)
end end
if delta > 500 then if delta > 500 then
print( print(
('Nvim session %s took %d milliseconds to exit\n'):format(test_id, delta) ('\nNvim session %s took %d milliseconds to exit\n'):format(test_id, delta)
.. 'This indicates a likely problem with the test even if it passed!\n' .. 'This indicates a likely problem with the test even if it passed!'
) )
io.stdout:flush() io.stdout:flush()
end end