diff options
| author | Lewis Russell <lewis6991@gmail.com> | 2026-08-25 17:37:27 +0100 |
|---|---|---|
| committer | Lewis Russell <me@lewisr.dev> | 2026-09-03 19:17:25 +0100 |
| commit | ce8a897f985b2cfd1194f6c03fe5388f9bc6d6bf (patch) | |
| tree | 81a85d8e22dc906aea69e0265f2e035f800a6c9c /test/functional/lua/async_spec.lua | |
| parent | da4355ab8f68c4e0290134e691ab7b9dedc2a5df (diff) | |
| download | rneovim-ce8a897f985b2cfd1194f6c03fe5388f9bc6d6bf.tar.gz rneovim-ce8a897f985b2cfd1194f6c03fe5388f9bc6d6bf.tar.bz2 rneovim-ce8a897f985b2cfd1194f6c03fe5388f9bc6d6bf.zip | |
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
Diffstat (limited to 'test/functional/lua/async_spec.lua')
| -rw-r--r-- | test/functional/lua/async_spec.lua | 2687 |
1 files changed, 2687 insertions, 0 deletions
diff --git a/test/functional/lua/async_spec.lua b/test/functional/lua/async_spec.lua new file mode 100644 index 0000000000..146f467fe5 --- /dev/null +++ b/test/functional/lua/async_spec.lua @@ -0,0 +1,2687 @@ +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 vi |