Files
neovim/test/functional/lua/async_spec.lua
Lewis Russell 630e0592b7 test(lua): avoid async task completion flake #41702
Allow enough time for the timer and scheduled continuation to run on
Windows.

fixes #41699

AI-assisted
2026-09-04 12:51:23 -04: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(100)
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)