neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit 1906da52dbc9876046ec9866a5aae25309d7587e
parent 0501c5fd0959895b18d8166c6c034cdf1832edf3
Author: zeertzjq <zeertzjq@outlook.com>
Date:   Sun,  1 Feb 2026 21:29:19 +0800

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.
Diffstat:
Mruntime/doc/lua.txt | 6+++++-
Mruntime/lua/vim/_core/editor.lua | 15+++++++++------
Mruntime/lua/vim/_meta/builtin.lua | 2++
Msrc/nvim/lua/executor.c | 7+++++--
Mtest/functional/lua/vim_spec.lua | 18++++++++++++++++++
Mtest/functional/testnvim.lua | 4++--
6 files changed, 41 insertions(+), 11 deletions(-)

diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt @@ -722,6 +722,10 @@ vim.schedule({fn}) *vim.schedule()* Parameters: ~ • {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()* Gets the distance (in bytes) from the last byte of the codepoint (character) that {index} points to. @@ -1272,7 +1276,7 @@ vim.defer_fn({fn}, {timeout}) *vim.defer_fn()* Defers calling {fn} until {timeout} ms passes. 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: ~ • {fn} (`function`) Callback to call once `timeout` expires diff --git a/runtime/lua/vim/_core/editor.lua b/runtime/lua/vim/_core/editor.lua @@ -509,25 +509,28 @@ end --- Defers calling {fn} until {timeout} ms passes. --- --- 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. ---@param fn function Callback to call once `timeout` expires ---@param timeout integer Number of milliseconds to wait before calling `fn` ---@return table timer luv timer object function vim.defer_fn(fn, timeout) vim.validate('fn', fn, 'callable', true) + local timer = assert(vim.uv.new_timer()) - timer:start( - timeout, - 0, - vim.schedule_wrap(function() + timer:start(timeout, 0, function() + local _, err = vim.schedule(function() if not timer:is_closing() then timer:close() end fn() end) - ) + + if err then + timer:close() + end + end) return timer end diff --git a/runtime/lua/vim/_meta/builtin.lua b/runtime/lua/vim/_meta/builtin.lua @@ -178,6 +178,8 @@ function vim.iconv(str, from, to, opts) end --- Schedules {fn} to be invoked soon by the main event-loop. Useful --- to avoid |textlock| or other temporary restrictions. --- @param fn fun() +--- @return nil result +--- @return string? err Error message if scheduling failed, `nil` otherwise. function vim.schedule(fn) end --- Waits up to `time` milliseconds, until `callback` returns `true` (success). Executes diff --git a/src/nvim/lua/executor.c b/src/nvim/lua/executor.c @@ -407,17 +407,20 @@ static int nlua_schedule(lua_State *const lstate) return lua_error(lstate); } + lua_pushnil(lstate); // If main_loop is closing don't schedule tasks to run in the future, // otherwise any refs allocated here will not be cleaned up. if (main_loop.closing) { - return 0; + lua_pushliteral(lstate, "main loop is closing"); + return 2; } LuaRef cb = nlua_ref_global(lstate, 1); // Pass along UI event handler to disable on error. multiqueue_put(main_loop.events, nlua_schedule_event, (void *)(ptrdiff_t)cb, (void *)(ptrdiff_t)ui_event_ns_id); - return 0; + lua_pushnil(lstate); + return 2; } // Dummy timer callback. Used by f_wait(). diff --git a/test/functional/lua/vim_spec.lua b/test/functional/lua/vim_spec.lua @@ -1786,6 +1786,24 @@ describe('lua stdlib', function() eq(true, exec_lua [[return vim.g.test]]) 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() it('charwise', function() insert(dedent([[ diff --git a/test/functional/testnvim.lua b/test/functional/testnvim.lua @@ -510,8 +510,8 @@ function M.new_session(keep, ...) end if delta > 500 then print( - ('Nvim 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' + ('\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!' ) io.stdout:flush() end