Files
neovim/test/functional/lua/async_spec.lua
Lewis Russell ce8a897f98 feat(lua): add vim.async
Problem: Nvim has many Lua APIs that start callback-driven work: timers,
jobs, libuv handles, and other event-loop tasks. Callers that need to
sequence or cancel that work have to build their own coroutine wrappers,
task bookkeeping, and cleanup rules. This makes async control flow hard
to share, test, and document.

Solution: Add `vim.async`, a structured-concurrency module vendored from
async.nvim. It provides task handles, await/pawait helpers,
sleep/timeout helpers, completion-order iteration, and semaphores on top
of Nvim's event loop.

The API follows the same broad model as Trio: async work has an owner,
tasks are awaited explicitly, and cancellation is cooperative. Include
generated vimdoc with an introductory overview and examples, a news
entry, and functional tests for the new module.

AI-assisted
2026-09-03 19:17:25 +01:00

2688 lines
70 KiB
Lua

local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local describe, it, before_each, after_each = t.describe, t.it, t.before_each, t.after_each
local exec_lua = n.exec_lua
-- TODO: test error message has correct stack trace when:
-- task finishes with no continuation
-- task finishes with synchronous wait
-- nil in results
-- TODO(lewis6991): test for cyclic await
-- - child awaiting an ancestor (not allowed)
-- - cyclic chain with detached tasks
--- @param s string
--- @param f fun(...)
--- @param ... any
local function it_exec(s, f, ...)
local args = { ... }
it(s, function()
exec_lua(f, unpack(args))
end)
end
describe('async', function()
before_each(function()
n.clear()
exec_lua('package.path = ...', package.path)
exec_lua(function()
_G.Async = require('vim.async')
_G.AsyncRuntime = require('vim.async._runtime')
local safe_pcall = pcall
local ok, coxpcall = pcall(require, 'coxpcall')
if ok and type(coxpcall) == 'table' and type(coxpcall.pcall) == 'function' then
safe_pcall = coxpcall.pcall
end
_G.pcall = safe_pcall
_G.await = Async.await
_G.run = Async.run
_G.wrap = Async.wrap
_G.uv_handles = setmetatable({}, { __mode = 'v' })
--- Keep track of uv handles so we can ensure they are closed
--- @generic T
--- @param name string
--- @param handle T?
--- @return T - ?
function _G.add_handle(name, handle)
uv_handles[name] = assert(handle)
return handle
end
--- Check task eventually completes with an error
--- @param task vim.async.Task
--- @param pat string
--- @return string
function _G.check_task_err(task, pat)
local ok, err = task:pwait(100)
if ok then
error('Expected task to error, but it completed successfully', 2)
elseif not (err:match('^' .. pat .. '$') or (pat == 'closed' and is_closed_error(err))) then
error('Unexpected error: ' .. tostring(task:traceback(err)), 2)
end
return err
end
--- @param s string
--- @return { [1]: string, pattern: boolean }
function _G.p(s)
return { s, pattern = true }
end
--- @param err any
--- @return boolean
function _G.is_closed_error(err)
return err == 'closed'
or (type(err) == 'string' and err:match('^closed\nstack traceback:') ~= nil)
end
--- @param err any
--- @return boolean
function _G.is_timeout_error(err)
return err == 'timeout'
or (type(err) == 'string' and err:match('^timeout\nstack traceback:') ~= nil)
end
function _G.is_jit()
return package.loaded.jit ~= nil
end
--- @param expected any
--- @param actual any
--- @param msg? string
function _G.eq(expected, actual, msg)
local match
if
type(expected) == 'table'
and type(expected[1]) == 'string'
and expected.pattern == true
then
match = actual:match(expected[1]) ~= nil
expected = expected[1]
else
match = vim.deep_equal(expected, actual)
end
if not match then
if type(actual) == 'string' then
actual = '\n' .. actual:gsub('\n', '\n')
else
actual = vim.inspect(actual)
end
if type(expected) == 'string' then
expected = '\n' .. expected:gsub('\n', '\n')
else
expected = vim.inspect(expected)
end
error(
('%s\n\nactual: %s\n\nexpected: %s'):format(msg or 'Mismatch:', actual, expected),
2
)
end
end
--- @async~
function _G.eternity()
await(function(_cb)
-- Never call callback
return add_handle('timer', vim.uv.new_timer()) --[[@as vim.async.Closable]]
end)
end
end)
end)
after_each(function()
exec_lua(function()
for k, v in pairs(uv_handles) do
assert(v:is_closing(), ('uv handle %s is not closing'):format(k))
end
collectgarbage('collect')
assert(not next(uv_handles), 'Resources not collected')
end)
end)
describe('basic operations', function()
it_exec('can error stack trace on sync wait', function()
local task = run(function()
error('SYNC ERR')
end)
check_task_err(task, '.*async_spec.lua:%d+: SYNC ERR')
end)
it_exec('can await a uv callback function', function()
--- @param path string
--- @param options uv.spawn.options
--- @param on_exit fun(code: integer, signal: integer)
--- @return uv.uv_process_t handle
local function spawn(path, options, on_exit)
return add_handle('process', vim.uv.spawn(path, options, on_exit))
end
local done = run(function()
local code1 = await(3, spawn, 'echo', { args = { 'foo' } })
assert(code1 == 0)
local code2 = await(3, spawn, 'echo', { args = { 'bar' } })
assert(code2 == 0)
await(vim.schedule)
return true
end):wait(1000)
eq(true, done)
end)
it_exec('resumes sleep outside a fast event', function()
local in_fast_event = run(function()
Async.sleep(0)
return vim.in_fast_event()
end):wait(100)
eq(false, in_fast_event)
end)
it_exec('can await a run task', function()
local a = run(function()
return await(run(function()
await(vim.schedule)
return 'JJ'
end))
end):wait(10)
assert(a == 'JJ', 'GOT ' .. tostring(a))
end)
it_exec('can wait on an empty task', function()
local did_cb = false
local a = 1
local task = run(function()
-- task does not await anything, should complete immediately
a = a + 1
end)
task:on_complete(function()
did_cb = true
end) -- non-blocking
task:wait(100) -- blocking
assert(a == 2)
assert(did_cb)
end)
it_exec('on_complete observes a pending child task without starting it', function()
local results = {}
run(function()
local child = run(function()
results[#results + 1] = 'child_started'
return 'child_done'
end)
child:on_complete(function(err, value)
assert(not err, tostring(err))
results[#results + 1] = value
end)
results[#results + 1] = 'after_on_complete'
end):wait(100)
eq({
'after_on_complete',
'child_started',
'child_done',
}, results)
end)
it_exec('child tasks start when the parent reaches a checkpoint', function()
local results = {}
run(function()
run(function()
results[#results + 1] = 'child_started'
end)
results[#results + 1] = 'before_checkpoint'
await(vim.schedule)
results[#results + 1] = 'after_checkpoint'
end):wait(100)
eq({
'before_checkpoint',
'child_started',
'after_checkpoint',
}, results)
end)
it_exec('child tasks start at an explicit checkpoint', function()
local results = {}
run(function()
run(function()
results[#results + 1] = 'child_started'
end)
results[#results + 1] = 'before_checkpoint'
Async.checkpoint()
results[#results + 1] = 'after_checkpoint'
end):wait(100)
eq({
'before_checkpoint',
'child_started',
'after_checkpoint',
}, results)
end)
it_exec('handles tasks that complete', function()
local task = run(function()
-- should wait for 1 ms
await(function(callback)
local timer = add_handle('timer', vim.uv.new_timer())
timer:start(1, 0, callback)
return timer --[[@as vim.async.Closable]]
end)
await(vim.schedule)
return nil, 1
end)
local r1, r2 = task:wait(10)
eq(r1, nil)
eq(r2, 1)
end)
it_exec('can provide a traceback for nested tasks', function()
if not is_jit() then
return
end
--- @async
local function t1()
await(run(function()
error('GOT HERE')
end))
end
local task = run(function()
await(run(function()
await(run(function()
await(run(function()
t1()
end))
end))
end))
end)
local err = check_task_err(task, '.*async_spec.lua:%d+: GOT HERE')
local m = [[.*async_spec.lua:%d+: GOT HERE
stack traceback:
%[thread: 0x%x+%] %[C%]: in function 'error'
%[thread: 0x%x+%] .*async_spec.lua:%d+: in function <.*async_spec.lua:%d+>
%[thread: 0x%x+%] .*async_spec.lua:%d+: in function 't1'
%[thread: 0x%x+%] .*async_spec.lua:%d+: in function <.*async_spec.lua:%d+>
%[thread: 0x%x+%] .*async_spec.lua:%d+: in function <.*async_spec.lua:%d+>
%[thread: 0x%x+%] .*async_spec.lua:%d+: in function <.*async_spec.lua:%d+>
%[thread: 0x%x+%] .*async_spec.lua:%d+: in function <.*async_spec.lua:%d+>]]
local tb = tostring(task:traceback(err) or ''):gsub('\t', ' ')
assert(tb:match(m), 'ERROR: ' .. tostring(tb))
end)
it_exec('does not keep completed awaited tasks in later tracebacks', function()
if not is_jit() then
return
end
for _, await_child in ipairs({
function()
await(run(function()
return 'done'
end))
end,
function()
local ok = Async.pawait(run(function()
error('child error')
end))
eq(false, ok)
end,
}) do
local task = run(function()
await_child()
error('parent error')
end)
local err = check_task_err(task, '.*async_spec.lua:%d+: parent error')
local tb = tostring(task:traceback(err) or '')
assert(tb:match("%[C%]: in function 'error'"), 'ERROR: ' .. tostring(tb))
assert(not tb:match('child error'), 'ERROR: ' .. tostring(tb))
assert(not tb:match('stack traceback:\nstack traceback:'), 'ERROR: ' .. tostring(tb))
end
end)
it_exec('does not print nil for tracebacks without a message', function()
if not is_jit() then
return
end
local task = run(function()
await(function() end)
end)
local tb = tostring(task:traceback() or '')
assert(not tb:match('^nil\n'), 'ERROR: ' .. tostring(tb))
task:close()
check_task_err(task, 'closed')
end)
it_exec('does not need new stack frame for non-deferred continuations', function()
--- @async
local function deep(n)
if n == 0 then
return 'done'
end
await(function(cb)
cb()
end)
return deep(n - 1)
end
local res = run(function()
return deep(10000)
end):wait()
assert(res == 'done')
end)
it_exec('does not retain unused run arguments after task starts', function()
local unused = {}
local weak = setmetatable({ unused }, { __mode = 'v' })
local task = run(function(_)
Async.sleep(100)
end, 'used', unused)
unused = nil
collectgarbage('collect')
collectgarbage('collect')
local retained = weak[1]
task:close()
check_task_err(task, 'closed')
eq(nil, retained)
end)
end)
describe('task cancellation and closing', function()
it_exec('can close tasks', function()
local task = run(eternity)
task:close()
check_task_err(task, 'closed')
end)
it_exec('can close tasks which waiting on a wrapped callback function', function()
local wfn = wrap(1, function(_callback)
return add_handle('timer', vim.uv.new_timer()) --[[@as vim.async.Closable]]
end)
local task = run(function()
wfn()
end)
task:close()
check_task_err(task, 'closed')
end)
it_exec('gracefully handles when closables are prematurely closed', function()
local result = run(function()
await(1, function(callback)
local timer = add_handle('timer', vim.uv.new_timer())
timer:close(callback)
return timer --[[@as vim.async.Closable]]
end)
return 'FINISH'
end):wait()
eq('FINISH', result)
end)
it_exec('callback function can be closed (nested)', function()
local child --- @type vim.async.Task
local task = run(function()
child = run(eternity)
await(child)
end)
task:close()
check_task_err(task, 'closed')
check_task_err(child, 'closed')
end)
it_exec('can timeout tasks', function()
local task = run(eternity)
check_task_err(task, 'timeout')
task:close()
check_task_err(task, 'closed')
end)
it_exec('can async timeout a test', function()
local task = run(eternity)
check_task_err(run(Async.timeout, 10, task), 'timeout')
end)
it_exec('timeout waits for target cleanup before raising timeout', function()
local cleanup_done = false
local task = run(function()
await(function()
return {
close = function(_, callback)
vim.schedule(function()
cleanup_done = true
callback()
end)
end,
}
end)
end)
check_task_err(run(Async.timeout, 1, task), 'timeout')
eq(true, cleanup_done)
check_task_err(task, 'closed')
end)
it_exec('timeout preserves target failure before the deadline', function()
local task = run(function()
Async.sleep(1)
error('TARGET_ERROR')
end)
check_task_err(run(Async.timeout, 100, task), '.*async_spec.lua:%d+: TARGET_ERROR')
end)
it_exec('returns when the task completes before the timeout', function()
local timeout_timer = {
closed = false,
close = function(self, callback)
self.closed = true
if callback then
callback()
end
end,
is_closing = function(self)
return self.closed
end,
start = function() end,
}
AsyncRuntime.config({
wait = vim.wait,
schedule = vim.schedule,
new_timer = function()
return timeout_timer
end,
})
local ok, err = pcall(function()
local task = run(function()
return 'FINISH'
end)
eq('FINISH', run(Async.timeout, 100, task):wait(10))
assert(timeout_timer.closed)
end)
AsyncRuntime.config({
wait = vim.wait,
schedule = vim.schedule,
new_timer = vim.uv.new_timer,
})
if not ok then
error(err, 0)
end
end)
it_exec('closes detached child tasks', function()
local task1 = run(eternity)
task1:close()
local task2 = run(function()
await(task1)
end)
check_task_err(task2, 'closed')
end)
end)
describe('error handling', function()
it_exec('handles tasks that error', function()
local task = run(function()
await(function(callback)
local timer = add_handle('timer', vim.uv.new_timer())
timer:start(1, 0, callback)
return timer --[[@as vim.async.Closable]]
end)
await(vim.schedule)
error('GOT HERE')
end)
check_task_err(task, '.*async_spec.lua:%d+: GOT HERE')
end)
it_exec('can handle errors in wrapped functions', function()
local task = run(function()
await(function(_callback)
error('ERROR')
end)
end)
check_task_err(task, '.*async_spec.lua:%d+: ERROR')
end)
it_exec('can pcall errors in wrapped functions', function()
local task = run(function()
return pcall(function()
await(function(_callback)
error('ERROR')
end)
end)
end)
local ok, msg = task:wait()
assert(not ok and msg, 'Expected error, got success')
assert(msg:match('^.*async_spec.lua:%d+: ERROR'), 'Got unexpected error: ' .. msg)
end)
it_exec('handles when a floating child errors', function()
local parent = run(function()
local _child = run(function(...)
Async.sleep(5)
error('CHILD ERROR')
end)
end)
check_task_err(parent, 'child error: .*async_spec.lua:%d+: CHILD ERROR')
end)
it_exec('handles when a floating child errors and parent errors', function()
local parent = run(function()
local _child = run(function(...)
Async.sleep(5)
error('CHILD ERROR')
end)
error('PARENT ERROR')
end)
check_task_err(parent, '.*async_spec.lua:%d+: PARENT ERROR')
end)
end)
describe('task iteration', function()
it_exec('can iterate detached tasks', function()
local tasks = {} --- @type vim.async.Task<any>[]
local expected = {} --- @type table[]
for i = 1, 10 do
tasks[i] = run(function()
if i % 2 == 0 then
await(vim.schedule)
end
return 'FINISH', i
end)
expected[i] = { 'FINISH', i }
end
local results = {} --- @type table[]
run(function()
local next_task = Async.iter(tasks)
while true do
local task = next_task()
if not task then
break
end
local r1, r2 = await(task)
results[r2] = { r1, r2 }
end
end):wait(1000)
eq(expected, results)
end)
it_exec('can inspect errors when iterating detached tasks', function()
local results = {} --- @type table[]
local tasks = {} --- @type vim.async.Task<any>[]
local task_err --- @type any
for i = 1, 10 do
tasks[i] = run(function()
await(vim.schedule)
if i == 3 then
error('ERROR IN TASK ' .. i)
end
return 'FINISH', i
end)
end
run(function()
local next_task = Async.iter(tasks)
while true do
local task = next_task()
if not task then
break
end
local ok, r1, r2 = Async.pawait(task)
if not ok then
task_err = r1
break
end
results[r2] = { r1, r2 }
end
end):wait(100)
--- @cast task_err string
assert(task_err:match('.*async_spec.lua:%d+: ERROR IN TASK 3'), task_err)
eq({
{ 'FINISH', 1 },
{ 'FINISH', 2 },
}, results)
end)
it_exec('iterates tasks in completion order', function()
--- @async
--- @param count integer
--- @param id integer
local function after_schedules(count, id)
for _ = 1, count do
await(vim.schedule)
end
return id
end
local tasks = {
run(after_schedules, 3, 1),
run(after_schedules, 1, 2),
run(after_schedules, 2, 3),
}
local order = {}
run(function()
local next_task = Async.iter(tasks)
while true do
local task = next_task()
if not task then
break
end
order[#order + 1] = await(task)
end
end):wait(100)
eq({ 2, 3, 1 }, order)
end)
it_exec('treats false task errors as errors when iterating', function()
local task = run(function()
await(vim.schedule)
error(false, 0)
end)
run(function()
local completed = Async.iter({ task })()
local ok, err = Async.pawait(completed)
eq(false, ok)
eq(false, err)
end):wait(100)
end)
it_exec('can iter tasks followed by error', function()
local task = run(function()
await(vim.schedule)
return 'FINISH', 1
end)
local expected = { { 'FINISH', 1 } }
local results = {} --- @type table[]
local task2 = run(function()
local next_task = Async.iter({ task })
while true do
local completed = next_task()
if not completed then
break
end
local r1, r2 = await(completed)
results[r2] = { r1, r2 }
end
error('GOT HERE')
end)
check_task_err(task2, '.*async_spec.lua:%d+: GOT HERE')
eq(expected, results)
end)
it_exec('can iter tasks with cancellation', function()
local tasks = {} --- @type vim.async.Task<any>[]
for i = 1, 4 do
tasks[i] = run(function()
if i == 2 then
eternity()
end
return 'FINISH', i
end)
end
assert(tasks[2]):close()
local results = {} --- @type table[]
local errs = {} --- @type any[]
run(function()
local next_task = Async.iter(tasks)
while true do
local task = next_task()
if not task then
break
end
local ok, r1, r2 = Async.pawait(task)
if ok then
results[r2] = { r1, r2 }
else
errs[#errs + 1] = r1
end
end
end):wait(100)
eq({
[1] = { 'FINISH', 1 },
[3] = { 'FINISH', 3 },
[4] = { 'FINISH', 4 },
}, results)
eq({ 'closed' }, errs)
end)
it_exec('can iter tasks with garbage collection', function()
--- @param task vim.async.Task
--- @return integer
local function get_task_callback_count(task)
--- @diagnostic disable-next-line: invisible
return vim.tbl_count(task._future._callbacks)
end
local task = run(eternity)
run(function()
local itr = Async.iter({ task })
eq(get_task_callback_count(task), 1, 'task should have one callback')
itr = nil
collectgarbage('collect')
eq(get_task_callback_count(task), 0, 'task should have no callbacks')
end):wait(100)
task:close()
check_task_err(task, 'closed')
end)
it_exec('handles empty task lists', function()
run(function()
eq(nil, Async.iter({})())
end):wait(100)
end)
end)
describe('child task management', function()
it_exec('does not close child tasks created outside of parent', function()
local t1 = run(Async.sleep, 10)
local t2 --- @type vim.async.Task
local t3 --- @type vim.async.Task
local parent = run(function()
t2 = run(Async.sleep, 10)
t3 = run(Async.sleep, 10):detach()
await(t1)
end)
parent:close()
check_task_err(parent, 'closed')
t1:wait()
check_task_err(t2, 'closed')
t3:wait()
end)
it_exec('stops observing external tasks when the waiter closes', function()
for _, wait in ipairs({ await, Async.pawait }) do
local external = run(eternity)
local waiter = run(function()
wait(external)
end)
waiter:close()
check_task_err(waiter, 'closed')
--- @diagnostic disable-next-line: invisible
local callback_count = vim.tbl_count(external._future._callbacks)
external:close()
check_task_err(external, 'closed')
eq(0, callback_count)
end
end)
it_exec('stops observing external tasks when a child fails', function()
for _, wait in ipairs({ await, Async.pawait }) do
local external = run(eternity)
local waiter = run(function()
run(function()
await(vim.schedule)
error('CHILD ERROR')
end)
wait(external)
end)
check_task_err(waiter, 'child error: .*async_spec.lua:%d+: CHILD ERROR')
--- @diagnostic disable-next-line: invisible
local callback_count = vim.tbl_count(external._future._callbacks)
external:close()
check_task_err(external, 'closed')
eq(0, callback_count)
end
end)
it_exec('stops observing children detached while being awaited', function()
for _, wait in ipairs({ await, Async.pawait }) do
local child --- @type vim.async.Task
local completions = 0
local parent = run(function()
child = run(eternity)
wait(child)
end)
parent:on_complete(function()
completions = completions + 1
end)
child:detach()
parent:close()
check_task_err(parent, 'closed')
eq(false, child:completed())
--- @diagnostic disable-next-line: invisible
local callback_count = vim.tbl_count(child._future._callbacks)
child:close()
check_task_err(child, 'closed')
eq(0, callback_count)
eq(1, completions)
end
end)
it_exec('ignores extra callback awaitable results', function()
for _, wait in ipairs({ await, Async.pawait }) do
local external = run(eternity)
local waiter = run(function()
wait(function()
return external, function()
error('EXTRA_RETURN_CALLED')
end
end)
end)
waiter:close()
check_task_err(waiter, 'closed')
external:close()
check_task_err(external, 'closed')
end
end)
it_exec('detached pending child starts independently', function()
local results = {}
run(function()
run(function()
results[#results + 1] = 'detached_started'
end):detach()
results[#results + 1] = 'parent_done'
await(vim.schedule)
end):wait(100)
eq({
'parent_done',
'detached_started',
}, results)
end)
it_exec('detached child failures do not fail the original parent', function()
local child --- @type vim.async.Task
local parent = run(function()
child = run(function()
await(vim.schedule)
error('DETACHED_ERROR')
end):detach()
await(vim.schedule)
return 'parent ok'
end)
eq('parent ok', parent:wait(100))
check_task_err(child, '.*async_spec.lua:%d+: DETACHED_ERROR')
end)
it_exec('attaches tasks created from synchronous callbacks inside a task', function()
local release --- @type fun()?
local results = {}
local parent = run(function()
local function call(callback)
callback()
end
call(function()
run(function()
await(function(callback)
release = callback
end)
results[#results + 1] = 'child_done'
end)
end)
results[#results + 1] = 'parent_body_done'
end)
local ok, err = parent:pwait(10)
eq(false, ok)
assert(is_timeout_error(err), 'Expected timeout, got: ' .. tostring(err))
eq({ 'parent_body_done' }, results)
assert(release, 'attached child was not started at parent finish')
release()
parent:wait(50)
eq({ 'parent_body_done', 'child_done' }, results)
end)
it_exec('does not attach tasks created from event-loop callbacks', function()
local release --- @type fun()?
local child --- @type vim.async.Task
local results = {}
local parent = run(function()
await(function(callback)
vim.schedule(function()
child = run(function()
await(function(child_callback)
release = child_callback
end)
results[#results + 1] = 'child_done'
end)
callback()
end)
end)
results[#results + 1] = 'parent_done'
end)
parent:wait(50)
eq({ 'parent_done' }, results)
assert(child, 'event-loop callback did not create child task')
assert(release, 'top-level callback task was not started')
release()
child:wait(50)
eq({ 'parent_done', 'child_done' }, results)
end)
it_exec('does not run pending children when parent errors before a checkpoint', function()
local child --- @type vim.async.Task
local child_ran = false
local parent = run(function()
child = run(function()
child_ran = true
end)
error('PARENT_ERROR')
end)
check_task_err(parent, '.*async_spec.lua:%d+: PARENT_ERROR')
check_task_err(child, 'closed')
eq(false, child_ran)
end)
it_exec('synchronous child wait starts only the waited child', function()
local results = {}
run(function()
local child1 = run(function()
results[#results + 1] = 'child1'
end)
run(function()
results[#results + 1] = 'child2'
end)
child1:wait(100)
eq({ 'child1' }, results)
end):wait(100)
eq({ 'child1', 'child2' }, results)
end)
it_exec('does not wait for detached task children after sync wait times out', function()
local detached --- @type vim.async.Task
local release --- @type fun()?
local results = {}
local parent = run(function()
await(vim.schedule)
detached = run(function()
run(function()
await(function(callback)
release = callback
end)
results[#results + 1] = 'detached_child_done'
end)
end):detach()
local ok, err = detached:pwait(10)
eq(false, ok)
assert(is_timeout_error(err), 'Expected timeout, got: ' .. tostring(err))
results[#results + 1] = 'parent_done'
end)
parent:wait(50)
eq({ 'parent_done' }, results)
assert(release, 'detached child was not started')
release()
detached:wait(50)
eq({ 'parent_done', 'detached_child_done' }, results)
end)
it_exec('automatically awaits child tasks', function()
local child1, child2 --- @type vim.async.Task, vim.async.Task
local main = run(function()
child1 = run(Async.sleep, 10)
child2 = run(Async.sleep, 10)
end)
main:wait()
assert(child1:completed())
assert(child2:completed())
end)
it_exec('should not fail the parent task if children finish before parent', function()
local release_parent --- @type fun()?
local release_child1 --- @type fun()?
local release_child2 --- @type fun()?
local child1, child2 --- @type vim.async.Task, vim.async.Task
local main = run(function()
child1 = run(function()
await(function(callback)
release_child1 = callback
end)
end)
child2 = run(function()
await(function(callback)
release_child2 = callback
end)
end)
await(function(callback)
release_parent = callback
end)
end)
assert(release_child1)
assert(release_child2)
assert(release_parent)
release_child1()
release_child2()
assert(child1:completed())
assert(child2:completed())
release_parent()
main:wait()
end)
it_exec('automatically closes suspended child tasks', function()
local forever_child --- @type vim.async.Task
local main = run(function()
forever_child = run(function()
while true do
Async.sleep(1)
end
end)
Async.sleep(2)
end)
eq(forever_child:status(), 'awaiting')
main:close()
check_task_err(main, 'closed')
check_task_err(forever_child, 'closed')
end)
it_exec('child failure while parent is suspended closes siblings', function()
local sibling --- @type vim.async.Task
local continued = false
local parent = run(function()
run(function()
Async.sleep(1)
error('CHILD_ERROR')
end)
sibling = run(eternity)
Async.sleep(100)
continued = true
end)
check_task_err(parent, 'child error: .*async_spec.lua:%d+: CHILD_ERROR')
check_task_err(sibling, 'closed')
eq(false, continued)
end)
it_exec('should not close the parent task when child task is closed', function()
run(function()
run(eternity):close()
end):wait()
end)
end)
describe('semaphore', function()
it_exec('rejects invalid permit counts', function()
for _, permits in ipairs({ 0, -1, 1.5, math.huge }) do
local ok, err = pcall(Async.semaphore, permits)
eq(false, ok)
--- @cast err string
assert(
err:match('permits: expected positive integer'),
'Unexpected error: ' .. tostring(err)
)
end
end)
it_exec('runs', function()
local ret = {}
run(function()
local semaphore = Async.semaphore(3)
local tasks = {} --- @type vim.async.Task<nil>[]
for i = 1, 5 do
tasks[#tasks + 1] = run(function()
semaphore:with(function()
ret[#ret + 1] = 'start' .. i
await(vim.schedule)
ret[#ret + 1] = 'end' .. i
end)
end)
end
local next_task = Async.iter(tasks)
while true do
local task = next_task()
if not task then
break
end
await(task)
end
end):wait()
eq({
'start1',
'start2',
'start3',
'end1',
'end2',
'end3',
'start4',
'start5',
'end4',
'end5',
}, ret)
end)
it_exec('ping pong', function()
local msgs = {}
local ball = { hits = 0 }
local max_hits = 10
--- @async
--- @param name string
--- @param sem vim.async.Semaphore
local function player(name, sem)
while ball.hits < max_hits do
local ok, err = pcall(sem.acquire, sem)
if not ok or ball.hits >= max_hits then
if not ok and not tostring(err):match('closed') then
error(err)
end
break
end
ball.hits = ball.hits + 1
msgs[#msgs + 1] = name
Async.sleep(2)
sem:release()
end
end
run(function()
local sem = Async.semaphore(1)
local p1 = run(player, 'ping', sem)
local p2 = run(player, 'pong', sem)
local next_task = Async.iter({ p1, p2 })
while true do
local task = next_task()
if not task then
break
end
await(task)
end
end):wait()
eq({ 'ping', 'pong', 'ping', 'pong', 'ping', 'pong', 'ping', 'pong', 'ping', 'pong' }, msgs)
end)
it_exec('does not lose a semaphore wake after closing a waiter', function()
local sem = Async.semaphore(1)
local second_acquired = false
run(function()
sem:acquire()
local first = run(function()
sem:acquire()
end)
local second = run(function()
sem:acquire()
second_acquired = true
end)
Async.checkpoint()
first:close()
Async.pawait(first)
sem:release()
await(second)
end):wait(100)
eq(true, second_acquired)
end)
it_exec('releases semaphore permits when with errors', function()
run(function()
local sem = Async.semaphore(1)
local ok, err = pcall(function()
sem:with(function()
error('WITH_ERROR')
end)
end)
eq(false, ok)
--- @cast err string
assert(err:match('WITH_ERROR'), 'Expected WITH_ERROR, got: ' .. tostring(err))
sem:acquire()
sem:release()
end):wait(100)
end)
it_exec('releases semaphore permits when with is cancelled', function()
local release --- @type fun()?
local sem = Async.semaphore(1)
local task = run(function()
sem:with(function()
await(function(callback)
release = callback
end)
end)
end)
run(function()
Async.checkpoint()
assert(release, 'semaphore body did not start')
task:close()
Async.pawait(task)
sem:acquire()
sem:release()
end):wait(100)
check_task_err(task, 'closed')
end)
it_exec('does not resume semaphore waiters inline on release', function()
local results = {}
run(function()
local sem = Async.semaphore(1)
sem:acquire()
run(function()
sem:acquire()
results[#results + 1] = 'waiter_acquired'
end)
Async.checkpoint()
results[#results + 1] = 'before_release'
sem:release()
results[#results + 1] = 'after_release'
eq({ 'before_release', 'after_release' }, results)
await(vim.schedule)
eq({ 'before_release', 'after_release', 'waiter_acquired' }, results)
end):wait(100)
end)
end)
describe('queue', function()
it_exec('does not resume get waiters inline on put_nowait', function()
local new_queue = require('vim.async._queue')
local results = {}
run(function()
local queue = new_queue()
run(function()
local item = queue:get()
results[#results + 1] = 'got_' .. item
end)
Async.checkpoint()
results[#results + 1] = 'before_put'
queue:put_nowait('item')
results[#results + 1] = 'after_put'
eq({ 'before_put', 'after_put' }, results)
await(vim.schedule)
eq({ 'before_put', 'after_put', 'got_item' }, results)
end):wait(100)
end)
it_exec('get waiters retry if a deferred item is consumed first', function()
local new_queue = require('vim.async._queue')
local results = {}
run(function()
local queue = new_queue()
run(function()
results[#results + 1] = queue:get()
end)
Async.checkpoint()
queue:put_nowait('first')
eq('first', queue:get_nowait())
await(vim.schedule)
eq({}, results)
queue:put_nowait('second')
await(vim.schedule)
eq({ 'second' }, results)
end):wait(100)
end)
it_exec('does not resume put waiters inline on get_nowait', function()
local new_queue = require('vim.async._queue')
local results = {}
run(function()
local queue = new_queue(1)
queue:put_nowait('first')
run(function()
queue:put('second')
results[#results + 1] = 'put_second'
end)
Async.checkpoint()
results[#results + 1] = 'before_get'
eq('first', queue:get_nowait())
results[#results + 1] = 'after_get'
eq({ 'before_get', 'after_get' }, results)
await(vim.schedule)
eq({ 'before_get', 'after_get', 'put_second' }, results)
end):wait(100)
end)
it_exec('put waiters retry if a deferred slot is filled first', function()
local new_queue = require('vim.async._queue')
local results = {}
run(function()
local queue = new_queue(1)
queue:put_nowait('first')
run(function()
queue:put('second')
results[#results + 1] = 'put_second'
end)
Async.checkpoint()
eq('first', queue:get_nowait())
queue:put_nowait('interloper')
await(vim.schedule)
eq({}, results)
eq('interloper', queue:get_nowait())
await(vim.schedule)
eq({ 'put_second' }, results)
eq('second', queue:get_nowait())
end):wait(100)
end)
end)
describe('coroutine safety', function()
it_exec('does not allow coroutine.yield', function()
local task = run(function()
coroutine.yield('This will cause an error.')
end)
check_task_err(task, 'Unexpected coroutine.yield().*')
end)
it_exec('does not allow coroutine.resume', function()
local co --- @type thread
local task = run(function()
co = coroutine.running()
eternity()
end)
local status, err = coroutine.resume(co)
assert(not status, 'Expected coroutine.resume to fail')
eq(err, 'Unexpected coroutine.resume()')
check_task_err(task, 'Unexpected coroutine.resume%(%)')
end)
it_exec('does not allow coroutine.resume when awaiting detached task', function()
local t = run(eternity)
local co --- @type thread
local task = run(function()
co = coroutine.running()
await(t)
end)
local status, err = coroutine.resume(co)
assert(not status, 'Expected coroutine.resume to fail')
eq(err, 'Unexpected coroutine.resume()')
check_task_err(task, 'Unexpected coroutine.resume%(%)')
t:close()
end)
it_exec('preserves child errors after invalid coroutine.resume', function()
local blocker = run(eternity)
local co --- @type thread
local parent = run(function()
co = coroutine.running()
run(function()
await(vim.schedule)
error('CHILD ERROR')
end)
await(blocker)
end)
local status, err = coroutine.resume(co)
assert(not status, 'Expected coroutine.resume to fail')
eq(err, 'Unexpected coroutine.resume()')
local check_ok, check_err =
pcall(check_task_err, parent, 'child error: .*async_spec.lua:%d+: CHILD ERROR')
blocker:close()
check_task_err(blocker, 'closed')
if not check_ok then
error(check_err, 0)
end
end)
end)
describe('inspect_tree', function()
local outside_tree = t.dedent([=[
parent %[awaiting%]
├─ child1 %[awaiting%]
├─ child2 %[awaiting%]
└─ child3 %[awaiting%]
├─ sub_child1 %[awaiting%]
├─ sub_child2 %[awaiting%]
└─ %[awaiting%]]=])
local inside_tree = t.dedent([=[
parent %[awaiting%]
├─ child1 %[awaiting%]
├─ child2 %[awaiting%]
└─ child3 %[running%]
├─ sub_child1 %[awaiting%]
├─ sub_child2 %[awaiting%]
└─ %[awaiting%]]=])
local jit_tree = t.dedent([=[
parent@.*async_spec.lua:%d+ %[awaiting%]
└─ child@.*async_spec.lua:%d+ %[awaiting%]]=])
local puc_tree = t.dedent([=[
parent=.* %[awaiting%]
└─ child=.* %[awaiting%]]=])
it_exec('outside of tasks', function(expected)
local parent = run('parent', function()
run('child1', eternity)
run('child2', eternity)
run('child3', function(...)
run('sub_child1', eternity)
run('sub_child2', eternity)
run(eternity)
end)
end)
eq(p(expected), Async._inspect_tree())
parent:close()
check_task_err(parent, 'closed')
end, outside_tree)
it_exec('inside a task', function(expected)
local inspect
local parent = run('parent', function()
run('child1', eternity)
run('child2', eternity)
run('child3', function(...)
run('sub_child1', eternity)
run('sub_child2', eternity)
run(eternity)
inspect = Async._inspect_tree()
end)
end)
eq(p(expected), inspect)
parent:close()
check_task_err(parent, 'closed')
end, inside_tree)
it_exec('can show task creation locations in debug mode', function(jit_expected, puc_expected)
AsyncRuntime.config({ debug = true })
local parent
local ok, err = pcall(function()
parent = run('parent', function()
run('child', eternity)
end)
local expected = is_jit() and jit_expected or puc_expected
eq(p(expected), Async._inspect_tree())
end)
if parent then
parent:close()
end
AsyncRuntime.config({ debug = false })
if parent then
check_task_err(parent, 'closed')
end
if not ok then
error(err, 0)
end
end, jit_tree, puc_tree)
end)
describe('pcall and task-control errors', function()
it_exec('child errors remain terminal after pcall catches delivery', function()
local results = {}
local parent = run(function()
local _child = run(function()
Async.sleep(5)
error('CHILD ERROR')
end)
local ok1, err1 = pcall(function()
Async.sleep(100)
end)
if not ok1 then
results[#results + 1] = 'caught_first'
results[#results + 1] = err1:match('CHILD ERROR') and 'has_error' or 'no_error'
end
local ok2, err2 = pcall(function()
Async.sleep(1)
end)
if not ok2 then
results[#results + 1] = 'caught_second'
results[#results + 1] = err2:match('CHILD ERROR') and 'has_error' or 'no_error'
else
results[#results + 1] = 'no_second_error'
end
results[#results + 1] = 'returned'
end)
local ok, err = parent:pwait(200)
eq(false, ok)
--- @cast err string
assert(err:match('child error:.*CHILD ERROR'), 'Expected child error, got: ' .. tostring(err))
eq({
'caught_first',
'has_error',
'caught_second',
'has_error',
'returned',
}, results)
end)
it_exec('awaited child errors remain terminal without child wrapper', function()
local results = {}
local parent = run(function()
local child = run(function()
error('AWAITED CHILD ERROR')
end)
local ok1, err1 = pcall(function()
await(child)
end)
if not ok1 then
results[#results + 1] = err1:match('AWAITED CHILD ERROR') and 'caught_child' or 'other'
results[#results + 1] = err1:match('child error:') and 'wrapped' or 'unwrapped'
end
local ok2, err2 = pcall(function()
Async.sleep(1)
end)
if not ok2 then
results[#results + 1] = err2:match('AWAITED CHILD ERROR') and 'caught_again' or 'other'
results[#results + 1] = err2:match('child error:') and 'wrapped' or 'unwrapped'
end
results[#results + 1] = 'returned'
end)
local ok, err = parent:pwait(200)
eq(false, ok)
--- @cast err string
assert(
err:match('.*async_spec.lua:%d+: AWAITED CHILD ERROR'),
'Expected awaited child error, got: ' .. tostring(err)
)
assert(not err:match('child error:'), 'Did not expect child wrapper, got: ' .. err)
eq({
'caught_child',
'unwrapped',
'caught_again',
'unwrapped',
'returned',
}, results)
end)
it_exec('false awaited child errors remain terminal after pcall catches delivery', function()
local parent = run(function()
local child = run(function()
error(false, 0)
end)
local ok1, err1 = pcall(function()
await(child)
end)
eq(false, ok1)
eq(false, err1)
local ok2, err2 = pcall(function()
Async.sleep(1)
end)
eq(false, ok2)
eq(false, err2)
end)
local ok, err = parent:pwait(200)
eq(false, ok)
eq(false, err)
end)
it_exec('pawait returns successful task results', function()
local parent = run(function()
local ok, a, b, c = Async.pawait(run(function()
Async.sleep(1)
return 1, 'two', true
end))
eq(true, ok)
eq(1, a)
eq('two', b)
eq(true, c)
return 'parent ok'
end)
eq('parent ok', parent:wait(100))
end)
it_exec('pawait accepts await callback overloads', function()
local parent = run(function()
local ok1, value = Async.pawait(function(callback)
vim.schedule(function()
callback('scheduled')
end)
end)
local ok2, a, b = Async.pawait(2, function(prefix, callback)
vim.schedule(function()
callback(prefix, 'done')
end)
end, 'arg')
eq({ true, 'scheduled' }, { ok1, value })
eq({ true, 'arg', 'done' }, { ok2, a, b })
return 'parent ok'
end)
eq('parent ok', parent:wait(100))
end)
it_exec('pawait returns awaitable setup errors as data', function()
local parent = run(function()
local ok, err = Async.pawait(function(_callback)
error()
end)
eq(false, ok)
eq('error(nil)', err)
return 'parent ok'
end)
eq('parent ok', parent:wait(100))
end)
it_exec('pawait keeps synchronous results before a setup error', function()
local parent = run(function()
local function callback_then_error(callback)
callback('result')
error('LATE_SETUP_ERROR')
end
local ok, result = Async.pawait(callback_then_error)
eq({ true, 'result' }, { ok, result })
local raw_ok, err = pcall(await, callback_then_error)
eq(false, raw_ok)
assert(tostring(err):match('LATE_SETUP_ERROR'), tostring(err))
end)
parent:wait(100)
end)
it_exec('pawait ignores a late callback after setup fails', function()
local child --- @type vim.async.Task
local callback_ran = false
local parent = run(function()
child = run(function()
Async.sleep(5)
return 'child finished'
end)
local ok, err = Async.pawait(function(callback)
vim.schedule(function()
callback_ran = true
callback('late result')
end)
error('SETUP_ERROR')
end)
eq(false, ok)
assert(tostring(err):match('SETUP_ERROR'), tostring(err))
return 'parent finished'
end)
eq('parent finished', parent:wait(100))
eq(true, callback_ran)
eq('child finished', child:wait(100))
end)
it_exec('pawait returns synchronous child errors as data', function()
local results = {}
local parent = run(function()
local child = run(function()
results[#results + 1] = 'child_started'
error('SYNC CHILD ERROR')
end)
results[#results + 1] = 'after_run'
local ok, err = Async.pawait(child)
eq(false, ok)
--- @cast err string
results[#results + 1] = err:match('SYNC CHILD ERROR') and 'got_error' or 'other'
results[#results + 1] = err:match('child error:') and 'wrapped' or 'unwrapped'
Async.sleep(1)
results[#results + 1] = 'continued'
return 'parent ok'
end)
eq('parent ok', parent:wait(100))
eq({
'after_run',
'child_started',
'got_error',
'unwrapped',
'continued',
}, results)
end)
it_exec('pawait returns asynchronous child errors as data', function()
local results = {}
local parent = run(function()
local ok, err = Async.pawait(run(function()
Async.sleep(1)
error('ASYNC CHILD ERROR')
end))
eq(false, ok)
--- @cast err string
results[#results + 1] = err:match('ASYNC CHILD ERROR') and 'got_error' or 'other'
results[#results + 1] = err:match('child error:') and 'wrapped' or 'unwrapped'
Async.sleep(1)
results[#results + 1] = 'continued'
return 'parent ok'
end)
eq('parent ok', parent:wait(100))
eq({
'got_error',
'unwrapped',
'continued',
}, results)
end)
it_exec('pawait does not protect current task cancellation', function()
local results = {}
local parent = run(function()
local ok, err = pcall(function()
Async.pawait(function(_callback)
return add_handle('pawait_current_cancellation_timer', vim.uv.new_timer())
end)
end)
eq(false, ok)
results[#results + 1] = is_closed_error(err) and 'caught_closed' or 'other_error'
results[#results + 1] = Async.is_closing() and 'is_closing' or 'not_closing'
results[#results + 1] = 'cleanup'
end)
parent:close()
check_task_err(parent, 'closed')
eq({
'caught_closed',
'is_closing',
'cleanup',
}, results)
end)
it_exec('pawait does not protect unrelated current task errors', function()
local results = {}
local parent = run(function()
local _child = run(function()
Async.sleep(5)
error('CHILD ERROR')
end)
local ok, err = pcall(function()
Async.pawait(function(callback)
local timer = add_handle('pending_child_error_timer', vim.uv.new_timer())
timer:start(100, 0, function()
timer:close()
callback('done')
end)
return timer
end)
end)
eq(false, ok)
--- @cast err string
results[#results + 1] = err:match('child error:.*CHILD ERROR') and 'child_error'
or 'other_error'
results[#results + 1] = Async.is_closing() and 'is_closing' or 'not_closing'
results[#results + 1] = 'cleanup'
end)
local ok, err = parent:pwait(200)
eq(false, ok)
--- @cast err string
assert(err:match('child error:.*CHILD ERROR'), 'Expected child error, got: ' .. tostring(err))
eq({
'child_error',
'not_closing',
'cleanup',
}, results)
end)
it_exec('cancellations are level-triggered (persist across catches)', function()
local results = {}
local task = run(function()
local ok1, err1 = pcall(function()
Async.sleep(100)
end)
if not ok1 then
results[#results + 1] = 'caught_first'
results[#results + 1] = is_closed_error(err1) and 'is_closed' or 'other_error'
end
local ok2, err2 = pcall(function()
Async.sleep(1)
end)
if not ok2 then
results[#results + 1] = 'caught_second'
results[#results + 1] = is_closed_error(err2) and 'is_closed' or 'other_error'
end
results[#results + 1] = 'should_not_reach'
end)
task:close()
check_task_err(task, 'closed')
eq({
'caught_first',
'is_closed',
'caught_second',
'is_closed',
'should_not_reach',
}, results)
end)
it_exec('checkpoint rethrows current task cancellation after cleanup', function()
local results = {}
local task = run(function()
local ok, err = pcall(function()
Async.sleep(100)
end)
eq(false, ok)
results[#results + 1] = is_closed_error(err) and 'caught_closed' or 'other_error'
results[#results + 1] = 'cleanup'
Async.checkpoint()
results[#results + 1] = 'after_checkpoint'
end)
task:close()
check_task_err(task, 'closed')
eq({
'caught_closed',
'cleanup',
}, results)
end)
it_exec('checkpoint rethrows current task failure after cleanup', function()
local results = {}
local parent = run(function()
local _child = run(function()
Async.sleep(5)
error('CHILD ERROR')
end)
local ok, err = pcall(function()
Async.sleep(100)
end)
eq(false, ok)
--- @cast err string
results[#results + 1] = err:match('CHILD ERROR') and 'caught_child_error' or 'other_error'
results[#results + 1] = 'cleanup'
Async.checkpoint()
results[#results + 1] = 'after_checkpoint'
end)
check_task_err(parent, 'child error:.*CHILD ERROR')
eq({
'caught_child_error',
'cleanup',
}, results)
end)
it_exec('can recover synchronous errors inside async tasks', function()
local results = {}
run(function()
local ok = pcall(function()
error('BAD CONFIG')
end)
if not ok then
results[#results + 1] = 'error_caught'
end
Async.sleep(1)
results[#results + 1] = 'finished'
end):wait(200)
eq({
'error_caught',
'finished',
}, results)
end)
it_exec('cancellation persists even after pcall catches it', function()
local results = {}
local task = run(function()
for i = 1, 5 do
local ok, err = pcall(function()
Async.sleep(10)
end)
if not ok then
if is_closed_error(err) then
results[#results + 1] = ('closed_iteration_%d'):format(i)
else
results[#results + 1] = ('error_iteration_%d'):format(i)
end
else
results[#results + 1] = ('success_iteration_%d'):format(i)
end
end
end)
task:close()
check_task_err(task, 'closed')
eq({
'closed_iteration_1',
'closed_iteration_2',
'closed_iteration_3',
'closed_iteration_4',
'closed_iteration_5',
}, results)
end)
it_exec('is_closing() reflects level-triggered cancellation state', function()
local results = {}
local task = run(function()
for _ = 1, 3 do
results[#results + 1] = ('is_closing_%d'):format(Async.is_closing() and 1 or 0)
local ok = pcall(function()
Async.sleep(10)
end)
if not ok then
results[#results + 1] = ('after_catch_is_closing_%d'):format(
Async.is_closing() and 1 or 0
)
end
end
end)
task:close()
check_task_err(task, 'closed')
eq({
'is_closing_0',
'after_catch_is_closing_1',
'is_closing_1',
'after_catch_is_closing_1',
'is_closing_1',
'after_catch_is_closing_1',
}, results)
end)
it_exec('first child error remains pending across subsequent awaits', function()
local results = {}
local release_first --- @type fun()?
-- Child failure, rather than this awaitable, resumes the parent.
local function wait_for_child_error()
await(function() end)
end
local parent = run(function()
local _child1 = run(function()
await(function(callback)
release_first = callback
end)
error('ERROR_1')
end)
local release_second --- @type fun()?
local _child2 = run(function()
await(function(callback)
release_second = callback
end)
error('ERROR_2')
end)
local ok1, err1 = pcall(wait_for_child_error)
if not ok1 then
results[#results + 1] = err1:match('ERROR_1') and 'got_error_1' or 'other'
end
assert(release_second)
release_second()
local ok2, err2 = pcall(wait_for_child_error)
if not ok2 then
results[#results + 1] = err2:match('ERROR_1') and 'got_error_1_again' or 'other'
end
results[#results + 1] = 'returned'
end)
assert(release_first)
release_first()
local ok, err = parent:pwait(200)
eq(false, ok)
--- @cast err string
assert(
err:match('child error:.*ERROR_1'),
'Expected first child error, got: ' .. tostring(err)
)
eq({
'got_error_1',
'got_error_1_again',
'returned',
}, results)
end)
it_exec('task error takes precedence over cancellation when both occur', function()
local task = run(function()
pcall(function()
Async.sleep(10)
end)
error('TASK_ERROR')
end)
task:close()
local ok, err = task:pwait(100)
assert(not ok, 'Expected task to error')
eq(true, err:match('TASK_ERROR') ~= nil, 'Expected TASK_ERROR, got: ' .. tostring(err))
end)
it_exec(
'cancellation takes precedence when task completes successfully while closing',
function()
local results = {}
local task = run(function()
local ok, err = pcall(function()
await(function(_callback)
return {
close = function(_, callback)
results[#results + 1] = 'close_called'
callback()
end,
}
end)
end)
eq(false, ok)
eq(true, is_closed_error(err), 'Expected closed error, got: ' .. tostring(err))
results[#results + 1] = 'caught_close'
results[#results + 1] = 'completed'
return 'SUCCESS'
end)
eq('awaiting', task:status())
task:close()
check_task_err(task, 'closed')
eq({
'close_called',
'caught_close',
'completed',
}, results)
end
)
end)
describe('edge case tests', function()
it_exec('handles awaiting closable that is already closing', function()
-- Test for potential issue where is_closing() returns true
local close_count = 0
local callback_called = false
local closable = {
_closing = false,
is_closing = function(self)
return self._closing
end,
close = function(self, cb)
close_count = close_count + 1
self._closing = true
if cb then
vim.schedule(cb)
end
end,
}
local task = run(function()
-- Start closing the closable
closable:close()
-- Now try to await something that returns this already-closing closable
local result = await(function(callback)
vim.schedule(function()
callback('RESULT')
end)
return closable
end)
callback_called = true
return result
end)
local result = task:wait(100)
eq('RESULT', result)
eq(true, callback_called)
-- The closable should only be closed once (by the explicit close call)
-- handle_close_awaiting should detect is_closing and not call close again
eq(1, close_count)
end)
it_exec('child error during parent finalization is handled', function()
local parent = run(function()
local _child = run(function()
Async.sleep(5)
error('CHILD_ERROR')
end)
-- Returning starts finalization, which waits for attached child work.
end)
local ok, err = parent:pwait(100)
eq(false, ok)
--- @cast err string
assert(err:match('child error:.*CHILD_ERROR'), 'Expected child error, got: ' .. tostring(err))
end)
it_exec('child error formatting cannot interrupt parent finalization', function()
local bad_error = setmetatable({}, {
__tostring = function()
error('TOSTRING_ERROR')
end,
})
local parent = run(function()
local _child = run(function()
Async.sleep(1)
error(bad_error, 0)
end)
end)
check_task_err(parent, 'child error: <unprintable error>')
end)
it_exec('child error during parent finalization completes once and closes siblings', function()
local completions = 0
local sibling --- @type vim.async.Task
local parent = run(function()
local _child = run(function()
Async.sleep(1)
error('CHILD_ERROR')
end)
sibling = run(eternity)
end)
parent:on_complete(function()
completions = completions + 1
end)
local ok, err = parent:pwait(100)
eq(false, ok)
--- @cast err string
assert(err:match('child error:.*CHILD_ERROR'), 'Expected child error, got: ' .. tostring(err))
eq(1, completions)
check_task_err(sibling, 'closed')
end)
it_exec('later child error during parent finalization closes earlier siblings', function()
local completions = 0
local sibling --- @type vim.async.Task
local parent = run(function()
sibling = run(eternity)
local _child = run(function()
Async.sleep(1)
error('CHILD_ERROR')
end)
end)
parent:on_complete(function()
completions = completions + 1
end)
local ok, err = parent:pwait(100)
eq(false, ok)
--- @cast err string
assert(err:match('child error:.*CHILD_ERROR'), 'Expected child error, got: ' .. tostring(err))
eq(1, completions)
check_task_err(sibling, 'closed')
end)
it_exec('child error during parent finalization waits for sibling cleanup', function()
local cleanup_done = false
local sibling --- @type vim.async.Task
local parent = run(function()
sibling = run(function()
await(function()
return {
close = function(_, callback)
vim.schedule(function()
cleanup_done = true
callback()
end)
end,
}
end)
end)
local _child = run(function()
Async.sleep(1)
error('CHILD_ERROR')
end)
end)
local ok, err = parent:pwait(100)
eq(false, ok)
--- @cast err string
assert(err:match('child error:.*CHILD_ERROR'), 'Expected child error, got: ' .. tostring(err))
eq(true, cleanup_done)
check_task_err(sibling, 'closed')
end)
it_exec('parent failure waits for every child cleanup', function()
local first_cleanup_done = false
local second_cleanup_done = false
local parent = run(function()
run(function()
await(function()
return {
close = function(_, callback)
first_cleanup_done = true
callback()
end,
}
end)
end)
run(function()
await(function()
return {
close = function(_, callback)
vim.schedule(function()
second_cleanup_done = true
callback()
end)
end,
}
end)
end)
Async.checkpoint()
error('PARENT_ERROR')
end)
local ok, err = parent:pwait(100)
eq(false, ok)
--- @cast err string
assert(err:match('PARENT_ERROR'), 'Expected parent error, got: ' .. tostring(err))
eq(true, first_cleanup_done)
eq(true, second_cleanup_done)
end)
it_exec('does not resume a closing task before awaitable cleanup', function()
local operation_callback --- @type fun(...: any)
local body_ran = false
local cleanup_done = false
local closable = { closing = false }
function closable:is_closing()
return self.closing
end
function closable:close(callback)
self.closing = true
operation_callback('RESULT')
vim.schedule(function()
cleanup_done = true
callback()
end)
end
local task = run(function()
Async.pawait(function(callback)
operation_callback = callback
return closable
end)
body_ran = true
end)
task:close()
check_task_err(task, 'closed')
eq(false, body_ran)
eq(true, cleanup_done)
end)
it_exec('future complete is one-shot', function()
local future = require('vim.async._future')()
future:complete(nil, 'first')
local ok, err = pcall(function()
future:complete(nil, 'second')
end)
eq(false, ok)
--- @cast err string
assert(err:match('Future is already completed'), 'Unexpected error: ' .. tostring(err))
local stat, result = future:result()
eq(true, stat)
eq('first', result)
end)
it_exec('future false error still completes', function()
local future = require('vim.async._future')()
future:complete(false)
eq(true, future:completed())
local stat, err = future:result()
eq(false, stat)
eq(false, err)
end)
it_exec('normalizes nil task errors', function()
check_task_err(
run(function()
error()
end),
'error%(nil%)'
)
end)
it_exec('normalizes nil awaitable setup errors', function()
local task = run(function()
await(function()
error()
end)
end)
check_task_err(task, 'error%(nil%)')
end)
it_exec('normalizes nil close errors', function()
local task = run(function()
await(function()
return {
close = function()
error()
end,
}
end)
end)
task:close()
check_task_err(task, 'error%(nil%)')
end)
it_exec('normalizes nil future callback errors', function()
local future = require('vim.async._future')()
future:on_complete(function()
error()
end)
local ok, err = pcall(function()
future:complete(nil, 'value')
end)
eq(false, ok)
--- @cast err string
assert(err:match('error%(nil%)'), 'Unexpected error: ' .. tostring(err))
end)
it_exec('continues future callbacks after an unprintable error', function()
local future = require('vim.async._future')()
local observed = false
local unprintable = setmetatable({}, {
__tostring = function()
error('TOSTRING_ERROR')
end,
})
future:on_complete(function()
error(unprintable)
end)
future:on_complete(function()
observed = true
end)
local ok, err = pcall(function()
future:complete(nil, 'value')
end)
eq(false, ok)
eq(true, observed)
assert(tostring(err):match('<unprintable error>'), 'Unexpected error: ' .. tostring(err))
end)
it_exec('callback called multiple times is handled gracefully', function()
-- Test that calling callback multiple times doesn't break things
local call_count = 0
local results = {}
local task = run(function()
local result = await(function(callback)
call_count = call_count + 1
callback('FIRST_CALL')
-- Try calling again (should be ignored)
vim.schedule(function()
call_count = call_count + 1
callback('SECOND_CALL')
end)
end)
table.insert(results, result)
return result
end)
local final_result = task:wait(100)
-- Should only get the first callback result
eq('FIRST_CALL', final_result)
eq(1, #results)
eq('FIRST_CALL', results[1])
-- Wait a bit for the second callback to potentially fire
run(function()
Async.sleep(20)
end):wait()
-- Both callbacks should have been called
eq(2, call_count)
-- But only the first one should have been processed
eq(1, #results)
end)
it_exec('closable cleanup happens even if close() errors', function()
-- Test that if a closable's close() method errors, we handle it gracefully
local close_called = false
local task = run(function()
local result = await(function(callback)
local closable = {
close = function()
close_called = true
error('CLOSE_ERROR')
end,
}
vim.schedule(function()
callback('RESULT')
end)
return closable
end)
return result
end)
task:close() -- This should trigger closing the closable
-- The task should complete with the close error
local ok, err = task:pwait(100)
eq(true, close_called, 'close() should have been called')
assert(not ok, 'Task should have errored')
assert(err:match('CLOSE_ERROR'), 'Expected CLOSE_ERROR, got: ' .. tostring(err))
end)
end)
end)