From ce8a897f985b2cfd1194f6c03fe5388f9bc6d6bf Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Tue, 25 Aug 2026 17:37:27 +0100 Subject: 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 --- runtime/lua/vim/_core/editor.lua | 1 + runtime/lua/vim/_core/util.lua | 21 + runtime/lua/vim/_meta.lua | 2 + runtime/lua/vim/async.lua | 317 ++++++++++ runtime/lua/vim/async/_core.lua | 1128 ++++++++++++++++++++++++++++++++++ runtime/lua/vim/async/_event.lua | 125 ++++ runtime/lua/vim/async/_future.lua | 77 +++ runtime/lua/vim/async/_queue.lua | 107 ++++ runtime/lua/vim/async/_runtime.lua | 46 ++ runtime/lua/vim/async/_semaphore.lua | 133 ++++ 10 files changed, 1957 insertions(+) create mode 100644 runtime/lua/vim/async.lua create mode 100644 runtime/lua/vim/async/_core.lua create mode 100644 runtime/lua/vim/async/_event.lua create mode 100644 runtime/lua/vim/async/_future.lua create mode 100644 runtime/lua/vim/async/_queue.lua create mode 100644 runtime/lua/vim/async/_runtime.lua create mode 100644 runtime/lua/vim/async/_semaphore.lua (limited to 'runtime/lua/vim') diff --git a/runtime/lua/vim/_core/editor.lua b/runtime/lua/vim/_core/editor.lua index 66cc57f3d2..3fde7b4ef3 100644 --- a/runtime/lua/vim/_core/editor.lua +++ b/runtime/lua/vim/_core/editor.lua @@ -21,6 +21,7 @@ for k, v in pairs({ secure = true, snippet = true, pack = true, + async = true, _watch = true, net = true, pos = true, diff --git a/runtime/lua/vim/_core/util.lua b/runtime/lua/vim/_core/util.lua index 02e5e6af04..08cbf17f04 100644 --- a/runtime/lua/vim/_core/util.lua +++ b/runtime/lua/vim/_core/util.lua @@ -2,6 +2,27 @@ local M = {} +-- Generated from async.nvim/lua/async/_errors.lua: start +local nil_error = 'error(nil)' + +--- Normalize a failed Lua operation for error slots where `nil` means success. +--- @param err any +--- @return any +--- @private +function M._normalize_error(err) + return err == nil and nil_error or err +end + +--- Convert an error to a string without letting its metamethod interrupt cleanup. +--- @param err any +--- @return string +--- @private +function M._stringify_error(err) + local ok, message = pcall(tostring, err) + return ok and message or '' +end +-- Generated from async.nvim/lua/async/_errors.lua: end + --- Adds one or more blank lines above or below the cursor. --- @param above? boolean Place blank line(s) above the cursor local function add_blank(above) diff --git a/runtime/lua/vim/_meta.lua b/runtime/lua/vim/_meta.lua index 18dbe49c85..e285cd5a12 100644 --- a/runtime/lua/vim/_meta.lua +++ b/runtime/lua/vim/_meta.lua @@ -66,6 +66,8 @@ vim.uv = ... vim.F = require('vim.F') vim._watch = require('vim._watch') +---@diagnostic disable-next-line: no-unknown +vim.async = require('vim.async') vim.diagnostic = require('vim.diagnostic') vim.filetype = require('vim.filetype') vim.fs = require('vim.fs') diff --git a/runtime/lua/vim/async.lua b/runtime/lua/vim/async.lua new file mode 100644 index 0000000000..8483b45add --- /dev/null +++ b/runtime/lua/vim/async.lua @@ -0,0 +1,317 @@ +local core = require('vim.async._core') +local validate = vim.validate +local new_queue = require('vim.async._queue') +local runtime = require('vim.async._runtime') +local F = vim.F + +--- Structured async API for Lua code that waits on event-loop work. +--- +--- `vim.async` lets Lua code wait for timers, callbacks, and other tasks +--- without blocking Nvim's event loop. Async work runs inside tasks, which can +--- pause at checkpoints and manage child tasks created while they are running. +--- +--- Start async work with [vim.async.run()]. Inside a task, use +--- [vim.async.await()] to wait for callback-style APIs or other tasks without +--- blocking the event loop. Use [vim.async.pawait()] when an awaited operation +--- can fail and the current task should continue. +--- +--- Examples in this help use `local async = vim.async` for brevity. +--- +--- Example: run async work without blocking Nvim: +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- vim.notify('waiting...') +--- async.sleep(1000) +--- vim.notify('done') +--- end) +--- ``` +--- +--- Example: await a callback-style API. Callback results are returned +--- unchanged, so an error-first callback still returns `err, value`: +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local err, stat = async.await(2, fs_stat, 'notes.txt') +--- if err then +--- error(err, 0) +--- end +--- print(('notes.txt is %d bytes'):format(stat.size)) +--- end) +--- ``` +--- +--- A task has two roles: +--- +--- - it is a handle that can be awaited, waited for, or closed +--- - it is a scope for child tasks created while the task is running +--- +--- [vim.async.run()] creates a task. A top-level task starts immediately. A +--- task created while another task is running becomes a child of that task, and +--- its function starts when the parent reaches its next checkpoint. A parent +--- task finishes only after its attached children finish. If a child fails +--- without being handled, the parent fails and closes the remaining children. +--- +--- Use [Task:detach()] for background work that should keep running after the +--- current task finishes. A detached task becomes top-level work; the original +--- parent no longer waits for it or closes it. +--- +--- Awaiting a task observes that task's result; it does not attach the task to +--- the awaiter or change which task owns it. Ownership is decided when the task +--- is created. +--- +--- Scheduling is cooperative. When a task awaits a timer, I/O operation, +--- callback, or another task, `vim.async` saves the Lua stack and returns +--- control to the event loop. Other callbacks can run while the task is paused. +--- Nothing interrupts synchronous Lua code in the middle of a stack frame. +--- +--- Checkpoints are the places where a task can pause, start pending child +--- tasks, observe cancellation, and receive unhandled child failures. Inside a +--- task, these operations are checkpoints: +--- +--- - `vim.async.await(...)` +--- - `vim.async.pawait(...)` +--- - `vim.async.checkpoint()` +--- - successful return from the task function, which is the final checkpoint +--- for child management +--- +--- Convenience APIs such as [vim.async.sleep()] and [vim.async.timeout()] can +--- also checkpoint because they call checkpointing APIs internally. +--- +--- Closing a task closes its attached children. Cancellation is cooperative: +--- [Task:close()] marks a task as closing, and the task observes that state at +--- a checkpoint. If a task is suspended on a closable operation such as a timer +--- or child task, `vim.async` closes that operation before reporting the +--- cancellation. +--- +--- Use [vim.async.await()] inside a task to suspend until work completes. It +--- accepts a task, a callback-taking function, or an argument position plus a +--- callback-taking function. `await(task)` returns the task result or raises +--- the task failure. [vim.async.pawait()] is the async counterpart to `pcall()` +--- for recoverable awaited-operation failures; it returns `ok, ...` instead of +--- failing the current task for that awaited operation. It does not suppress +--- cancellation or a failure already pending on the current task. +--- +--- From synchronous code, use [Task:wait()] or [Task:pwait()] to pump the event +--- loop until the task completes. Use [Task:on_complete()] to observe +--- completion without blocking. +--- +--- Coordination helpers work with task handles. [vim.async.iter()] yields +--- completed task handles in completion order, [vim.async.timeout()] awaits a +--- task with a deadline, and `vim.async.semaphore(permits)` creates a +--- [vim.async.Semaphore] that limits how many tasks can hold a permit for a +--- section at once. +--- @class vim.async: vim.async._core +local M = setmetatable({}, { __index = core }) + +M.semaphore = require('vim.async._semaphore') + +--- @param unsubscribe fun()[] +local function unsubscribe_all(unsubscribe) + for _, unsub in ipairs(unsubscribe) do + unsub() + end +end + +--- Create an async function from a callback-style function. +--- +--- The callback is inserted at argument position `argc`. If `func` returns a +--- closable handle, it is closed when the awaiting task is cancelled. +--- +--- This is a reusable wrapper around [vim.async.await()]. Use it when the same +--- callback API is awaited from more than one place. +--- +--- ```lua +--- local async = vim.async +--- local fs_stat = async.wrap(2, vim.uv.fs_stat) +--- +--- async.run(function() +--- local err, stat = fs_stat(vim.api.nvim_buf_get_name(0)) +--- if not err and stat then +--- print(stat.size) +--- end +--- end) +--- ``` +--- +--- @generic T, R +--- @param argc integer +--- @param func fun(...: T..., callback: fun(...: R...)): vim.async.Closable? +--- @return async fun(...: T...): R... +function M.wrap(argc, func) + validate('argc', argc, 'number') + validate('func', func, 'callable') + --- @async + return function(...) + return M.await(argc, func, ...) + end +end + +--- Iterate completed tasks in completion order. +--- +--- The iterator yields task handles, not task results. Use +--- [vim.async.await()] or [vim.async.pawait()] to retrieve each result. The +--- tasks are observed in the order they complete, regardless of the order in +--- the input list. +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local tasks = { +--- async.run(function() return 'cache', read_cache() end):detach(), +--- async.run(function() return 'disk', read_file() end):detach(), +--- } +--- +--- for task in async.iter(tasks) do +--- local ok, source, text = async.pawait(task) +--- if ok then +--- for _, other in ipairs(tasks) do +--- if other ~= task then +--- other:close() +--- end +--- end +--- print(('loaded from %s'):format(source)) +--- return text +--- end +--- end +--- end) +--- ``` +--- +--- If code must support PUC Lua 5.1, use the direct-call form instead of a +--- generic `for` loop. The iterator may need to suspend while waiting for the +--- next completed task, and PUC Lua 5.1 cannot yield from a generic-for +--- iterator call. +--- +--- ```lua +--- local next_task = async.iter(tasks) +--- while true do +--- local task = next_task() +--- if task == nil then +--- break +--- end +--- async.await(task) +--- end +--- ``` +--- @async +--- @generic R +--- @param tasks vim.async.Task[] A list of tasks to wait for and iterate over. +--- @return async fun(): vim.async.Task? iterator that yields each completed task. +function M.iter(tasks) + validate('tasks', tasks, 'table') + + local remaining = #tasks + local queue = new_queue() + local unsubscribe = {} --- @type fun()[] + + if remaining == 0 then + queue:put_nowait() + else + for _, task in ipairs(tasks) do + unsubscribe[#unsubscribe + 1] = task:on_complete(function() + remaining = remaining - 1 + queue:put_nowait(task) + if remaining == 0 then + queue:put_nowait() + end + end) + end + end + + local proxy = newproxy(true) + getmetatable(proxy).__gc = function() + unsubscribe_all(unsubscribe) + end + + --- @async + return function() + local _ = proxy -- Keep the GC proxy alive with the iterator. + return queue:get() + end +end + +--- Asynchronously sleep for a given duration. +--- +--- Suspends the current task for the given duration without blocking the event +--- loop. After the delay and timer cleanup complete, `sleep()` returns to its +--- caller through the runtime's `schedule` hook. +--- +--- ```lua +--- vim.async.run(function() +--- vim.async.sleep(100) +--- vim.notify('resumed later') +--- end) +--- ``` +--- @async +--- @param duration integer ms +function M.sleep(duration) + validate('duration', duration, 'number') + M.await(function(callback) + local timer = runtime.new_timer() + timer:start(duration, 0, callback) + return timer + end) + -- Timer cleanup resumes this function directly from its close callback. + -- Yield once more so M.sleep() returns through the runtime scheduler. + M.await(runtime.schedule) +end + +--- Await a task with a timeout. +--- +--- If the task completes first, returns or raises the task result as +--- [vim.async.await()] would. If the deadline wins, closes the task and raises +--- `"timeout"` after the target task finishes cancellation cleanup. +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local task = async.run(read_file, 'notes.txt') +--- local text = async.timeout(5000, task) +--- show_buffer(text) +--- end) +--- ``` +--- @async +--- @generic R +--- @param duration integer Timeout duration in milliseconds +--- @param task vim.async.Task +--- @return R... +function M.timeout(duration, task) + validate('duration', duration, 'number') + validate('task', task, 'table') + + local timed_out = false + local timer = M.run('__timeout', function() + M.sleep(duration) + timed_out = true + task:close() + end) + --- @diagnostic disable-next-line: invisible + timer._hidden = true + + local result = F.pack_len(M.pawait(task)) + timer:close() + M.pawait(timer) + + if timed_out then + error('timeout', 0) + end + + if not result[1] then + error(result[2], 0) + end + + return unpack(result, 2, result.n) +end + +if type(vim) == 'table' then + runtime.config({ + wait = vim.wait, + schedule = vim.schedule, + new_timer = vim.uv.new_timer, + }) +end + +return M diff --git a/runtime/lua/vim/async/_core.lua b/runtime/lua/vim/async/_core.lua new file mode 100644 index 0000000000..25837598a4 --- /dev/null +++ b/runtime/lua/vim/async/_core.lua @@ -0,0 +1,1128 @@ +-- LuaLS cannot model the generic annotations used by this vendored implementation. +---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync + +local util = require('vim._core.util') +local future = require('vim.async._future') +local runtime = require('vim.async._runtime') + +local is_callable = vim.is_callable +local validate = vim.validate +local pcall = pcall +local coroutine_running = coroutine.running +do + local ok, coxpcall = pcall(require, 'coxpcall') + if ok and type(coxpcall) == 'table' then + if type(coxpcall.pcall) == 'function' then + pcall = coxpcall.pcall + end + if type(coxpcall.running) == 'function' then + coroutine_running = coxpcall.running + end + end +end +local maxint = 2 ^ 32 - 1 +local pack_len = vim.F.pack_len +local unpack_len = vim.F.unpack_len + +--- @class vim.async._core +--- @nodoc +local M = {} + +--- Weak table to keep track of running tasks +--- @type table?> +local threads = setmetatable({}, { __mode = 'k' }) + +--- Returns the currently running task. +--- @return vim.async.Task? +local function running() + --- @diagnostic disable-next-line: invisible, undefined-field + local task = threads[coroutine_running()] + if task and not task:completed() then + return task + end +end + +--- Internal marker used to identify that a yielded value is an asynchronous yielding. +local yield_marker = {} +local resume_marker = {} + +local resume_error = 'Unexpected coroutine.resume()' +local yield_error = 'Unexpected coroutine.yield()' + +--- @return vim.async.Task +local function current_task() + return (assert(running(), 'Not in async context')) +end + +--- Checks the arguments of a `coroutine.resume`. +--- This is used to ensure that a resume is expected. +--- @generic T +--- @param marker any +--- @param err? any +--- @param ... T... +--- @return T... +local function check_yield(marker, err, ...) + if marker ~= resume_marker then + current_task():_raise(resume_error) + -- Return an error to the caller. This will also leave the task in a dead + -- and unfinished state. + error(resume_error, 0) + elseif err ~= nil then + error(err, 0) + end + return ... +end + +--- @class vim.async.Closable +--- @field close fun(self, callback?: fun()) +--- @field is_closing? fun(self): boolean + +--- A coroutine-backed async operation and concurrency scope. +--- +--- Use [vim.async.run()] to create tasks. A task may be awaited by more than +--- one waiter. When a task is created inside another task, it is attached to +--- that parent and becomes part of the parent's concurrency scope. +--- +--- @class vim.async.Task: vim.async.Closable +--- @field package _thread thread +--- @field package _future vim.async.Future +--- @field package _closing boolean +--- @field package _error? any +--- @field package _finalizing_children boolean +--- @field package _started boolean +--- +--- Reference to parent to handle attaching/detaching. +--- @field package _parent? vim.async.Task +--- @field package _parent_children_idx? integer +--- +--- Name of the task +--- @field name? string +--- +--- Hide implementation tasks from user-facing inspection output. +--- @field package _hidden? boolean +--- +--- The source line that created this task, used for inspect(). +--- @field package _caller? string +--- +--- Maintain children as an array to preserve closure order. +--- @field package _children table?> +--- +--- Pointer to last child in children +--- @field package _children_idx integer +--- +--- Tasks can await other async functions (task of callback functions) +--- when we are waiting on a child, we store the handle to it here so we can +--- close it. +--- @field package _awaiting? vim.async.Task | vim.async.Closable +--- Removes the completion callback when a Task await is abandoned. +--- @field package _awaiting_unsubscribe? fun() +local Task = {} + +--- @return_cast x vim.async.Task +local function is_task(x) + return getmetatable(x) == Task +end + +do --- Task + Task.__index = Task + + --- @package + --- @param name? string + --- @param func async fun(...: any) + --- @return vim.async.Task + function Task._new(name, func, ...) + local func_args = pack_len(...) --[[@as any[]? ]] + local thread = coroutine.create(function(marker, err) + -- Drop the packed vararg table before user code can suspend; otherwise + -- the coroutine closure retains it for the task lifetime. + local args = func_args + func_args = nil + check_yield(marker, err) + return func(unpack_len(args)) + end) + + local self = setmetatable({ + name = name, + _closing = false, + _finalizing_children = false, + _started = false, + _thread = thread, + _future = future(), + _children = {}, + _children_idx = 0, + }, Task) + + threads[thread] = self + + return self + end + + --- Returns whether the Task has completed. + --- @return boolean + function Task:completed() + return self._future:completed() + end + + --- Add a callback to be run when the Task has completed. + --- + --- If the Task is already done when this method is called, the callback is + --- called immediately with the results. + --- + --- This only observes completion. It does not start a pending task. + --- @param callback fun(err?: any, ...: R...) + --- @return fun() unsubscribe + function Task:on_complete(callback) + validate('callback', callback, 'callable') + return self._future:on_complete(callback) + end + + --- Synchronously wait for the Task to complete. + --- + --- If a timeout is provided, waits for the given time in milliseconds before + --- failing with `"timeout"`. With no timeout, waits indefinitely. + --- + --- This is for synchronous code. Inside a task, prefer [vim.async.await()] so + --- the current task suspends instead of pumping the event loop itself. + --- + --- ```lua + --- local result = task:wait(10) -- wait for 10ms or raise "timeout" + --- + --- local result = task:wait() -- wait indefinitely + --- ``` + --- @param timeout integer? + --- @return R... + function Task:wait(timeout) + validate('timeout', timeout, 'number', true) + self:_start() + + if not runtime.wait(timeout or maxint, function() + return self:completed() + end) then + error('timeout', 2) + end + local res = pack_len(self._future:result()) + + assert(self:status() == 'completed' or res[2] == yield_error) + + if not res[1] then + error(res[2], 2) + end + + return unpack(res, 2, res.n) + end + + --- Protected-call version of [Task:wait()]. + --- + --- Equivalent to `pcall(task.wait, task, timeout)`. + --- + --- ```lua + --- local ok, result_or_err = task:pwait(1000) + --- if not ok then + --- vim.notify(tostring(result_or_err), vim.log.levels.ERROR) + --- end + --- ``` + --- @param timeout integer? + --- @return boolean, R... + function Task:pwait(timeout) + validate('timeout', timeout, 'number', true) + return pcall(self.wait, self, timeout) + end + + --- @package + --- @param parent? vim.async.Task + function Task:_attach(parent) + if parent then + -- Attach to parent + parent._children_idx = parent._children_idx + 1 + parent._children[parent._children_idx] = self + + -- Keep track of the parent and this tasks index so we can detach + self._parent = parent + self._parent_children_idx = parent._children_idx + end + end + + --- Remove this task from its parent without changing execution state. + --- @private + --- @return boolean removed + function Task:_detach() + if not self._parent then + return false + end + + self._parent._children[self._parent_children_idx] = nil + self._parent = nil + self._parent_children_idx = nil + return true + end + + --- Detach a task from its parent. + --- + --- The task becomes a top-level task. + --- If it was waiting for a parent checkpoint, it is scheduled to start. + --- + --- Use this for background work that should not be cancelled when the + --- current task finishes. Detached task failures no longer fail the original + --- parent, so observe them explicitly with [Task:on_complete()], + --- [Task:wait()], or [vim.async.await()]. + --- + --- ```lua + --- vim.async.run(function() + --- while true do + --- refresh_index() + --- vim.async.sleep(1000) + --- end + --- end):detach() + --- ``` + --- @return vim.async.Task + function Task:detach() + local should_start = self._parent and not self._started and not self:completed() + self:_detach() + if should_start then + runtime.schedule(function() + self:_start() + end) + end + return self + end + + --- Get the traceback of a task when it is not active. + --- Will also get the traceback of nested tasks. + --- + --- @param msg? string + --- @param level? integer + --- @return string traceback + function Task:traceback(msg, level) + level = level or 0 + + local thread = '[' .. tostring(self._thread) .. '] ' + + local awaiting = self._awaiting + if is_task(awaiting) then + msg = awaiting:traceback(msg, level + 1) + end + + local tblvl = is_task(awaiting) and 2 or nil + local tb = debug.traceback(self._thread, '', tblvl) or '' + msg = (msg == nil and '' or tostring(msg)) .. tb:gsub('\n\t', '\n\t' .. thread) + + if level == 0 then + --- @type string + msg = msg + :gsub('\nstack traceback:\n', '\nSTACK TRACEBACK:\n', 1) + :gsub('\nstack traceback:\n', '\n') + :gsub('\nSTACK TRACEBACK:\n', '\nstack traceback:\n', 1) + end + + return msg + end + + --- Raise this task's error when it completes. + --- + --- Use this for detached or top-level fire-and-forget tasks whose completion + --- will not otherwise be observed. Attached task errors already propagate to + --- their parent. + --- + --- Detached tasks do not raise errors automatically. Detaching changes + --- ownership only; their completion can still be handled with + --- [vim.async.await()], [Task:wait()], [Task:pwait()], or [Task:on_complete()]. + --- @return vim.async.Task self + function Task:raise_on_error() + self:on_complete(function(err) + if err ~= nil then + error(self:traceback(err), 0) + end + end) + return self + end + + --- @package + function Task:_start() + if self._started or self:completed() then + return + end + + self._started = true + self:_resume() + end + + --- Start children whose first resume was deferred by `run()`. + --- + --- Deferring child start lets `await()` or `pawait()` claim the task boundary + --- before child code runs. At await checkpoints and successful parent finish, + --- any remaining pending children must start so implicit waits, cancellation, + --- and inspection see the full task tree. + --- @package + function Task:_start_pending_children() + for i = 1, self._children_idx do + local child = self._children[i] + if child then + child:_start() + end + end + end + + --- @private + function Task:_close_children() + for i = 1, self._children_idx do + local child = self._children[i] + if child then + child:close() + end + end + end + + --- Keep the first task error. The error can be any non-nil Lua value. + --- @package + --- @param err any + --- @return any + function Task:_set_error(err) + if self._error == nil then + self._error = err + end + return self._error + end + + --- @package + --- @param err any + function Task:_raise(err) + if self:status() == 'running' then + -- A running coroutine cannot be resumed recursively, so deliver the + -- error on a later event-loop turn after the current stack unwinds. + runtime.schedule(function() + if not self:completed() then + self:_resume(err) + end + end) + else + self:_resume(err) + end + end + + --- Request cooperative close for the task and all of its children. + --- + --- The optional callback observes task completion and may run immediately if + --- the task has already completed. + --- + --- Closing is cooperative. The task observes the close request at a + --- checkpoint such as [vim.async.await()] or [vim.async.checkpoint()]. If the + --- task is suspended on an owned closable operation, that operation is closed + --- before the task reports `"closed"`. + --- + --- @param callback? fun() + function Task:close(callback) + if not self:completed() and not self._closing then + self._closing = true + self:_raise('closed') + end + if callback then + self:on_complete(function() + callback() + end) + end + end + + --- Record a child failure on this task and either deliver it to a live + --- parent or close sibling children during finalization. + --- @package + --- @param child vim.async.Task + --- @param err any + --- @param child_was_awaited boolean? + function Task:_child_failed(child, err, child_was_awaited) + -- A parent close turns child "closed" results into cleanup, not failure. + if child._closing or child_was_awaited or (self._closing and err == 'closed') then + return + end + + local task_err = self:_set_error('child error: ' .. util._stringify_error(err)) + if self._finalizing_children then + -- The parent coroutine is already dead, so sibling cleanup must wake the + -- finalizer instead of trying to resume the parent. + self:_close_children() + else + self:_raise(task_err) + end + end + + --- Checks if an object is closable, i.e., has a `close` method. + --- @param obj any + --- @return boolean + --- @return_cast obj vim.async.Closable + local function is_closable(obj) + local ty = type(obj) + return (ty == 'table' or ty == 'userdata') and is_callable(obj.close) + end + + do -- Task:_resume() + --- Complete this task with an error and propagate it to the parent if the + --- parent did not explicitly await this task. + --- @param parent? vim.async.Task + --- @param err any + function Task:_finish_error(parent, err) + if err == nil then + err = self._error + end + err = util._normalize_error(err) + if parent then + parent:_child_failed(self, err, parent._awaiting == self) + end + self._future:complete(err) + end + + --- @private + --- @param stat boolean + --- @param ... R... result + function Task:_finish(stat, ...) + if self:completed() then + return + end + + local parent = self._parent + self:_detach() + + threads[self._thread] = nil + + if not stat then + self:_finish_error(parent, ...) + else + if self._error ~= nil then + self:_finish_error(parent, self._error) + else + self._future:complete(nil, ...) + end + end + end + + --- @package + --- @param stat boolean + --- @param ... R... result + function Task:_finalize(stat, ...) + if next(self._children) == nil then + self:_finish(stat, ...) + return + end + + local finish_args = pack_len(stat, ...) + self._finalizing_children = true + -- Only spawn the helper after the no-child path; an empty helper task + -- would otherwise finalize by recursively spawning another helper. + local await_children = Task._new('await_children', function() + -- TODO(lewis6991): should we collect all errors? + local close_remaining = not stat + + if close_remaining then + self:_close_children() + else + self:_start_pending_children() + end + + for i = 1, self._children_idx do + local child = self._children[i] + if child then + -- Child failures are recorded on `self`. Protect this helper so one + -- failed child cannot stop it awaiting the remaining cleanup. + local ok, err = M.pawait(child) + -- A close can arrive while normal finalization is awaiting + -- children; from that point child errors are cleanup results. + if not close_remaining and not self._closing and not ok and not child._closing then + self:_set_error('child error: ' .. util._stringify_error(err)) + close_remaining = true + self:_close_children() + end + end + end + + self._finalizing_children = false + if stat and self._closing and self._error == nil then + self:_finish(false, 'closed') + else + self:_finish(unpack_len(finish_args)) + end + end) + await_children._hidden = true + await_children:_start() + end + + --- Resume a task with the raw or protected result of an await. + --- @param task vim.async.Task + --- @param yielded vim.async.Task|fun(callback: fun(err?: any, ...: any)): vim.async.Closable? + --- @param protected boolean? + --- @param err? any + --- @param ... any + local function resume_from_await(task, yielded, protected, err, ...) + -- An error from `await(task)` also marks the waiting task as failed, even + -- if the error raised by `await()` is caught. + if not protected and is_task(yielded) and err ~= nil then + task:_set_error(err) + end + + if protected then + if err ~= nil then + return task:_resume(nil, false, err) + end + return task:_resume(nil, true, ...) + end + + return task:_resume(err, ...) + end + + --- Begin waiting on a yielded awaitable. + --- @param task vim.async.Task + --- @param yielded vim.async.Task|fun(callback: fun(err?: any, ...: any)): vim.async.Closable? + --- @param protected boolean? + local function start_await(task, yielded, protected) + -- TODO(#36): Defer task control until setup returns its cleanup handle. + -- The first callback or setup failure settles the await. + -- Ignore any callback that arrives afterwards. + local settled = false + local setup_ok --- @type boolean? + + -- Await setup may invoke the callback before `_awaiting` is installed. + -- Save those arguments so the task resumes only after setup finishes. + -- `sync_args` has three states: + -- - `nil`: the callback did not fire during setup; + -- - `false`: `callback(nil)`, the common no-error/no-result case, + -- avoiding a table allocation; + -- - a table: every other argument list, packed to preserve nils. + local sync_args --- @type false|{[integer]: any, n: integer}? + local awaiting --- @type vim.async.Task|vim.async.Closable? + + local function complete_await(err, ...) + -- Cancellation and child failures resume through `_raise()`. Ignore a + -- racing result so `_resume()` can finish awaitable cleanup first. + if settled or task._closing or task._error ~= nil then + return + end + settled = true + + if setup_ok == nil then + if err == nil and select('#', ...) == 0 then + sync_args = false + else + sync_args = pack_len(err, ...) + end + else + -- The callback has fired. Keep `_awaiting` so `_resume()` can close a + -- callback-style handle or retain a failed Task for its traceback. + task._awaiting_unsubscribe = nil + + if not task:completed() then + return resume_from_await(task, yielded, protected, err, ...) + end + end + end + + local unsubscribe + local task_await --- @type boolean + local setup_result + -- Either call below may invoke `complete_await()` before returning. In that + -- case, `unsubscribe` or `awaiting` has not yet received the returned cleanup + -- handle. While `setup_ok` is nil, `complete_await()` saves its arguments + -- in `sync_args`. This lets the code below handle setup errors and install the + -- cleanup state before using the buffered result. + if is_task(yielded) then + task_await = true + --- @diagnostic disable-next-line: cast-local-type + awaiting = yielded + setup_ok, setup_result = pcall(yielded._future.on_complete, yielded._future, complete_await) + else + task_await = false + -- Callback setup has one result: the optional closable. + --- @type fun(callback: fun(err?: any, ...: any)): vim.async.Closable? + local awaitable = yielded + setup_ok, setup_result = pcall(awaitable, complete_await) + end + + if not setup_ok then + local err = util._normalize_error(setup_result) + if protected and settled then + -- The first synchronous callback wins over a later setup error. + awaiting = nil + else + settled = true + return resume_from_await(task, yielded, protected, err) + end + elseif task_await then + unsubscribe = setup_result + else + awaiting = setup_result + end + + if not is_closable(awaiting) then + awaiting = nil + end + --- @diagnostic disable-next-line: assign-type-mismatch + task._awaiting = awaiting + + if is_task(awaiting) then + if not settled and unsubscribe then + --- @diagnostic disable-next-line: assign-type-mismatch + task._awaiting_unsubscribe = unsubscribe + end + awaiting:_start() + end + + if task:completed() then + return + end + + task:_start_pending_children() + + if sync_args == false then + return resume_from_await(task, yielded, protected) + elseif sync_args then + return resume_from_await(task, yielded, protected, unpack_len(sync_args)) + end + end + + --- Finalize a completed coroutine or start its yielded await. + --- Keep results in varargs to preserve nils without packing them. + --- @param task vim.async.Task + --- @param stat boolean + --- @param ... any + local function handle_resume(task, stat, ...) + if coroutine.status(task._thread) == 'dead' then + -- The coroutine finished during resume. A normal return must not + -- overwrite a pending task failure. + if task._error ~= nil and stat then + task:_finalize(false, task._error) + elseif task._closing and stat then + task:_finalize(false, 'closed') + else + task:_finalize(stat, ...) + end + return + end + + local marker, yielded, protected = ... + if marker ~= yield_marker or (not is_task(yielded) and not is_callable(yielded)) then + task:_finalize(false, yield_error) + return + end + + return start_await(task, yielded, protected) + end + + --- Clear an await boundary and remove its Task completion callback. + --- @param task vim.async.Task + local function clear_awaiting(task) + local unsubscribe = task._awaiting_unsubscribe + task._awaiting = nil + task._awaiting_unsubscribe = nil + if unsubscribe then + unsubscribe() + end + end + + --- @package + --- @param err? any + --- @param ... any resume values + function Task:_resume(err, ...) + -- Clear self._awaiting when either: + -- - this task resumes before a non-child finishes, so its callback + -- cannot retain this task; or + -- - there is no raw error needing its traceback frames and + -- self._awaiting is finished. + if + is_task(self._awaiting) + and ( + (self._awaiting._parent ~= self and self._awaiting_unsubscribe) + or (err == nil and self._awaiting:completed()) + ) + then + clear_awaiting(self) + end + + local awaiting = self._awaiting + -- Only close awaitables owned by this task; external tasks are observed. + if awaiting and (not is_task(awaiting) or awaiting._parent == self) then + local already_closing = false + if type(awaiting.is_closing) == 'function' then + already_closing = awaiting:is_closing() + end + + if already_closing then + clear_awaiting(self) + return self:_resume(err, ...) + end + + local args = pack_len(err, ...) + -- We must close the closable child before we resume to ensure + -- all resources are collected. + --- @diagnostic disable-next-line: param-type-mismatch + local close_ok, close_err = pcall(awaiting.close, awaiting, function() + clear_awaiting(self) + return self:_resume(unpack_len(args)) + end) + + if close_ok then + return + end + clear_awaiting(self) + return self:_resume(util._normalize_error(close_err)) + end + + -- An external coroutine.resume() may have already killed the coroutine. + -- Finalize its pending failure instead of trying to resume it again. + if coroutine.status(self._thread) == 'dead' then + self:_finalize(false, err, ...) + return + end + + return handle_resume(self, coroutine.resume(self._thread, resume_marker, err, ...)) + end + end + + --- @package + function Task:_log(...) + print(tostring(self._thread), ...) + end + + --- Returns the status of the task: + --- + --- - `"running"`: task is currently executing Lua code + --- - `"normal"`: task is active but another coroutine is running + --- - `"awaiting"`: task is suspended at a checkpoint or waiting for children + --- - `"completed"`: task and all attached children have completed + --- @return "running" | "awaiting" | "normal" | "completed" + function Task:status() + if self:completed() then + return 'completed' + end + + local co_status = coroutine.status(self._thread) + if co_status == 'dead' then + return 'awaiting' + elseif co_status == 'suspended' then + return 'awaiting' + elseif co_status == 'normal' then + -- TODO(lewis6991): This state is a bit ambiguous. If all tasks + -- are started from the main thread, then we can remove this state. + -- Though it still may be possible if the user resumes a non-task + -- coroutine. + return 'normal' + end + assert(co_status == 'running') + return 'running' + end +end + +--- @generic T, R +--- @param name? string +--- @param func async fun(...: T...): R... Function to run in an async context +--- @param ... T... Arguments to pass to the function +--- @return vim.async.Task +local function run(name, func, ...) + validate('func', func, 'callable') + local task = Task._new(name, func, ...) + task:_attach(running()) + if runtime.debug then + local info = debug.getinfo(2, 'Sl') + if info and info.currentline then + task._caller = ('%s:%d'):format(info.source, info.currentline) + end + end + + -- Top-level tasks have no parent checkpoint to start them, so they start + -- immediately. Attached children start when their parent next reaches an + -- await checkpoint, or when the parent finishes successfully and implicitly + -- waits for its children. If the parent errors or closes, pending children + -- are closed without running user code. + if not task._parent then + task:_start() + end + + return task +end + +--- Create a task from an async function. +--- +--- Top-level tasks start immediately. Child tasks are attached immediately and +--- first run when their parent reaches a checkpoint. +--- +--- Creating a task decides ownership. Awaiting the task later only observes +--- its result; it does not attach the task to the awaiter. +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local child = async.run(function() +--- return read_file('notes.txt') +--- end) +--- +--- local text = async.await(child) +--- show_buffer(text) +--- end) +--- ``` +--- +--- A task created from synchronous code is top-level: +--- +--- ```lua +--- local task = vim.async.run(function() +--- vim.async.sleep(100) +--- return 'done' +--- end) +--- +--- print(task:wait()) +--- ``` +--- @generic T, R +--- @param func async fun(...: T...): R... +--- @param ... T... Arguments to pass to the function +--- @return vim.async.Task +--- @overload fun(name: string, func: async fun(...: T...), ...: T...): vim.async.Task +function M.run(func, ...) + if type(func) == 'string' then + return run(func, ...) + elseif is_callable(func) then + return run(nil, func, ...) + end + error('Invalid arguments') +end + +--- @generic T, R +--- @param argc integer +--- @param fun fun(...: T..., callback: fun(...: R...)) +--- @param ... T... func arguments +--- @return fun(callback: fun(...: R...)) +local function norm_cb_fun(argc, fun, ...) + if argc == 1 and select('#', ...) == 0 then + -- Avoid allocating an empty argument table for the common await(func) shape. + local cb_fun = fun + --- @cast cb_fun fun(callback: fun(...: any)): any? + --- @param callback fun(...: any) + --- @return any? + return function(callback) + return cb_fun(function(...) + callback(nil, ...) + end) + end + end + + local args = pack_len(...) + + --- @param callback fun(...: any) + --- @return any? + return function(callback) + args[argc] = function(...) + callback(nil, ...) + end + args.n = math.max(args.n, argc) + return fun(unpack_len(args)) + end +end + +--- Get the current task, failing before an operation yields if it is closing or +--- failed. +--- @return vim.async.Task +local function check_current_task() + local task = current_task() + + if task._closing then + error('closed', 0) + elseif task._error ~= nil then + error(task._error, 0) + end + + return task +end + +--- Convert the public await forms into a Task or callback awaitable. +--- +--- Callback-style APIs do not have an error slot, so `norm_cb_fun()` inserts +--- `nil`; the scheduler observes Task futures directly. +--- @param ... any +--- @return vim.async.Task|fun(callback: fun(err?: any, ...: any)): vim.async.Closable? +local function to_awaitable(...) + local arg1 = select(1, ...) + + if type(arg1) == 'number' then + return norm_cb_fun(...) + elseif type(arg1) == 'function' then + return norm_cb_fun(1, arg1) + elseif is_task(arg1) then + return arg1 + else + error('Invalid arguments, expected Task or (argc, func) got: ' .. tostring(arg1), 2) + end +end + +--- Suspend the current task until an awaitable completes. +--- +--- Accepts a task, a callback-taking function, or an argument position plus a +--- callback-taking function. Raises awaited errors and current task-control +--- errors. +--- +--- The callback forms return callback arguments unchanged: +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local err, stat = async.await(2, vim.uv.fs_stat, 'notes.txt') +--- if err then +--- error(err, 0) +--- end +--- print(stat.size) +--- end) +--- ``` +--- +--- If a callback API starts cancellable work, return a closable handle from the +--- await callback. [Task:close()] will close that handle if cancellation +--- arrives while the task is suspended there. +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local lines = async.await(function(done) +--- return start_read_lines('notes.txt', done) +--- end) +--- render(lines) +--- end) +--- ``` +--- @async +--- @generic T, R +--- @param ... any see overloads +--- @overload async fun(func: (fun(callback: fun(...: R...)): vim.async.Closable?)): R... +--- @overload async fun(argc: integer, func: (fun(...: T..., callback: fun(...: R...)): vim.async.Closable?), ...: T...): R... +--- @overload async fun(task: vim.async.Task): R... +--- @return R... +function M.await(...) + check_current_task() + return check_yield(coroutine.yield(yield_marker, to_awaitable(...))) +end + +--- Protected await. +--- +--- Async counterpart to `pcall()`. Accepts the same forms as +--- [vim.async.await()], but returns a leading `ok` boolean for +--- awaited-operation failures. +--- +--- Use this when the awaited task or operation is allowed to fail and the +--- current task should continue. Cancellation or already pending failure from +--- the current task is not protected. +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local ok, text_or_err = async.pawait(async.run(read_file, 'notes.txt')) +--- if not ok then +--- text_or_err = '' +--- end +--- +--- show_buffer(text_or_err) +--- end) +--- ``` +--- @async +--- @generic T, R +--- @param ... any see overloads +--- @overload async fun(func: (fun(callback: fun(...: R...)): vim.async.Closable?)): boolean, R... +--- @overload async fun(argc: integer, func: (fun(...: T..., callback: fun(...: R...)): vim.async.Closable?), ...: T...): boolean, R... +--- @overload async fun(task: vim.async.Task): boolean, R... +--- @return boolean ok +--- @return R... ... result or error +--- @return_overload true, R... +--- @return_overload false, any +function M.pawait(...) + check_current_task() + return check_yield(coroutine.yield(yield_marker, to_awaitable(...), true)) +end + +--- Start pending child tasks and deliver pending cancellation or task failure +--- from the current task. +--- +--- This does not yield to the event loop. +--- +--- Use this after cleanup code that catches an async failure or close signal, +--- so persistent task state is delivered again before normal execution +--- continues. +--- +--- ```lua +--- local ok, err = pcall(cleanup_sensitive_work) +--- cleanup_resources() +--- vim.async.checkpoint() +--- if not ok then +--- error(err, 0) +--- end +--- ``` +--- @async +function M.checkpoint() + M.await(function(callback) + callback() + end) +end + +--- Returns true if the current task has been closed. +--- +--- Can be used in an async function to do cleanup when a task is closing. +--- +--- ```lua +--- while not vim.async.is_closing() do +--- poll_once() +--- vim.async.sleep(1000) +--- end +--- ``` +--- @return boolean +function M.is_closing() + local task = running() + return task and task._closing or false +end + +--- @private +--- @param parent? vim.async.Task +--- @param prefix? string +--- @return string[] +local function inspect(parent, prefix) + local tasks = {} --- @type table?> + if parent then + for _, task in pairs(parent._children) do + if not task._hidden then + tasks[#tasks + 1] = task + end + end + else + -- Gather for all detached tasks + for _, task in pairs(threads) do + if not task._parent and not task._hidden then + tasks[#tasks + 1] = task + end + end + end + + local r = {} --- @type string[] + for i, task in ipairs(tasks) do + local last = i == #tasks + local label = task.name or '' + if task._caller then + label = label .. task._caller + end + if label ~= '' then + label = label .. ' ' + end + r[#r + 1] = ('%s%s%s[%s]'):format( + prefix or '', + parent and (last and '└─ ' or '├─ ') or '', + label, + task:status() + ) + local child_prefix = (prefix or '') .. (parent and (last and ' ' or '│ ') or '') + for _, line in ipairs(inspect(task, child_prefix)) do + r[#r + 1] = line + end + end + return r +end + +--- Inspect the current async task tree. +--- +--- Returns a string representation of the task tree, showing the names and +--- statuses of each task. +--- @return string +function M._inspect_tree() + -- Inspired by https://docs.python.org/3.14/whatsnew/3.14.html#asyncio-introspection-capabilities + return table.concat(inspect(), '\n') +end + +return M diff --git a/runtime/lua/vim/async/_event.lua b/runtime/lua/vim/async/_event.lua new file mode 100644 index 0000000000..376def201b --- /dev/null +++ b/runtime/lua/vim/async/_event.lua @@ -0,0 +1,125 @@ +-- LuaLS cannot model the generic annotations used by this vendored implementation. +---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync + +local async = require('vim.async._core') +local runtime = require('vim.async._runtime') + +--- An event can be used to notify multiple tasks that some event has +--- happened. An Event object manages an internal flag that can be set to true +--- with the `set()` method and reset to `false` with the `clear()` method. +--- The `wait()` method blocks until the flag is set to `true`. The flag is +--- set to `false` initially. +--- @class vim.async.Event +--- @field private _is_set boolean +--- @field private _waiters (function|false)[] +local Event = {} +Event.__index = Event + +--- @param waiters (function|false)[] +--- @return boolean +local function has_waiters(waiters) + for _, waiter in ipairs(waiters) do + if waiter then + return true + end + end + return false +end + +--- Set the event. +--- +--- All tasks waiting for event to be set will be awakened on a later event-loop +--- turn. +--- +--- If `max_woken` is provided, only up to `max_woken` waiters will be woken. +--- If waiters are woken this way, the event is reset because the signal is +--- reserved for those waiters. +--- @param max_woken? integer +function Event:set(max_woken) + if self._is_set then + return + end + + local limited = max_woken ~= nil + if not has_waiters(self._waiters) then + self._is_set = true + return + end + + self._is_set = true + if limited then + -- The signal is reserved for existing waiters and will be assigned on the + -- scheduled turn. New waiters must not consume it first. + self._is_set = false + end + + runtime.schedule(function() + local waiters = self._waiters + local waiters_to_notify = {} --- @type function[] + local limit = max_woken or math.huge + while #waiters > 0 and #waiters_to_notify < limit do + local waiter = table.remove(waiters, 1) + if waiter then + waiters_to_notify[#waiters_to_notify + 1] = waiter + end + end + + if limited and #waiters_to_notify == 0 and not has_waiters(waiters) then + self._is_set = true + end + + for _, waiter in ipairs(waiters_to_notify) do + waiter() + end + end) +end + +--- Wait until the event is set. +--- +--- If the event is set, return immediately. Otherwise block until another +--- task calls set(). +--- @async +function Event:wait() + async.await(function(callback) + if self._is_set then + callback() + else + table.insert(self._waiters, callback) + return { + close = function(_, on_close) + -- set() compacts the waiter list, so cancellation cannot rely on the + -- original insertion index still pointing at this callback. + for i, waiter in ipairs(self._waiters) do + if waiter == callback then + self._waiters[i] = false + break + end + end + if on_close then + on_close() + end + end, + } + end + end) +end + +--- Clear (unset) the event. +--- +--- Tasks awaiting on wait() will now block until the set() method is called +--- again. +function Event:clear() + self._is_set = false +end + +--- Create a new event. +--- +--- An event can signal to multiple listeners to resume execution. +--- The event can be set from a non-async context. +--- @return vim.async.Event +return function() + return setmetatable({ + _waiters = {}, + _is_set = false, + }, Event) +end diff --git a/runtime/lua/vim/async/_future.lua b/runtime/lua/vim/async/_future.lua new file mode 100644 index 0000000000..46b036b88c --- /dev/null +++ b/runtime/lua/vim/async/_future.lua @@ -0,0 +1,77 @@ +-- LuaLS cannot model the generic annotations used by this vendored implementation. +---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync + +local F = vim.F +local util = require('vim._core.util') + +local Future = {} +Future.__index = Future + +function Future:completed() + return self._err ~= nil or self._result ~= nil +end + +function Future:result() + if not self:completed() then + error('Future has not completed', 2) + end + if self._err ~= nil then + return false, self._err + else + return true, F.unpack_len(self._result) + end +end + +function Future:on_complete(callback) + if self:completed() then + -- Already completed or closed + if self._err ~= nil then + callback(self._err) + else + callback(nil, F.unpack_len(self._result)) + end + return function() end + end + + local id = self._callback_pos + self._callback_pos = id + 1 + self._callbacks[id] = callback + + return function() + self._callbacks[id] = nil + end +end + +function Future:complete(err, ...) + if self:completed() then + error('Future is already completed', 2) + end + + if err ~= nil then + self._err = err + else + self._result = F.pack_len(...) + end + + local callbacks = self._callbacks + self._callbacks = {} + + local errs = {} -- Need to use pairs to avoid gaps caused by removed callbacks + for _, cb in pairs(callbacks) do + local ok, cb_err = pcall(cb, err, ...) + if not ok then + errs[#errs + 1] = util._stringify_error(util._normalize_error(cb_err)) + end + end + + if #errs > 0 then + error(table.concat(errs, '\n'), 0) + end +end + +return function() + return setmetatable({ + _callbacks = {}, + _callback_pos = 1, + }, Future) +end diff --git a/runtime/lua/vim/async/_queue.lua b/runtime/lua/vim/async/_queue.lua new file mode 100644 index 0000000000..5a2d9b4952 --- /dev/null +++ b/runtime/lua/vim/async/_queue.lua @@ -0,0 +1,107 @@ +-- LuaLS cannot model the generic annotations used by this vendored implementation. +---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync + +local new_event = require('vim.async._event') + +--- An optionally bounded FIFO queue for passing values between async tasks. +--- +--- `put()` suspends the current task while the queue is full, and `get()` +--- suspends it while the queue is empty. The `put_nowait()` and `get_nowait()` +--- variants never suspend and raise an error when the operation cannot proceed. +--- @class vim.async.Queue +--- @field private _non_empty vim.async.Event +--- @field package _non_full vim.async.Event +--- @field private _max_size? integer +--- @field private _items R[] +--- @field private _right_i integer +--- @field private _left_i integer +local Queue = {} +Queue.__index = Queue + +--- Returns the number of items in the queue. +--- @return integer +function Queue:size() + return self._right_i - self._left_i +end + +--- Returns the maximum number of items in the queue. +--- @return integer? +function Queue:max_size() + return self._max_size +end + +--- Put an item into the queue. +--- +--- If the queue is full, wait until a free slot is available. +--- @async +--- @param value any +function Queue:put(value) + while self:size() == self:max_size() do + self._non_full:wait() + end + self:put_nowait(value) +end + +--- Get an item from the queue. +--- +--- If the queue is empty, wait until an item is available. +--- @async +--- @return any +function Queue:get() + while self:size() == 0 do + self._non_empty:wait() + end + return self:get_nowait() +end + +--- Get an item from the queue without blocking. +--- +--- If the queue is empty, raise an error. +--- @return any +function Queue:get_nowait() + if self:size() == 0 then + error('Queue is empty', 2) + end + -- TODO(lewis6991): For a long_running queue, _left_i might overflow. + self._left_i = self._left_i + 1 + local item = self._items[self._left_i] + self._items[self._left_i] = nil + if self._left_i == self._right_i then + self._non_empty:clear() + end + self._non_full:set(1) + return item +end + +--- Put an item into the queue without blocking. +--- If no free slot is immediately available, raise "Queue is full" error. +--- @param value any +function Queue:put_nowait(value) + if self:size() == self:max_size() then + error('Queue is full', 2) + end + self._right_i = self._right_i + 1 + self._items[self._right_i] = value + self._non_empty:set(1) + if self:size() == self:max_size() then + self._non_full:clear() + end +end + +--- Create a new FIFO queue with async support. +--- @param max_size? integer The maximum number of items in the queue, defaults to no limit +--- @return vim.async.Queue +return function(max_size) + local self = setmetatable({ + _items = {}, + _left_i = 0, + _right_i = 0, + _max_size = max_size, + _non_empty = new_event(), + _non_full = new_event(), + }, Queue) + + self._non_full:set() + + return self +end diff --git a/runtime/lua/vim/async/_runtime.lua b/runtime/lua/vim/async/_runtime.lua new file mode 100644 index 0000000000..d7a84f65af --- /dev/null +++ b/runtime/lua/vim/async/_runtime.lua @@ -0,0 +1,46 @@ +-- LuaLS cannot model the generic annotations used by this vendored implementation. +---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync + +local validate = vim.validate + +--- @class vim.async.Timer: vim.async.Closable +--- @nodoc +--- @field start fun(self, timeout: integer, repeat_interval: integer, callback: fun()) + +--- @alias vim.async.TimerFactory fun(): vim.async.Timer +--- @nodoc + +--- @class vim.async.ConfigOpts +--- @nodoc +--- @field wait? fun(timeout: integer, predicate: fun(): boolean): boolean Run the event loop until the predicate succeeds or the timeout expires. +--- @field schedule? fun(callback: fun()) Queue a callback to run once on a later event-loop turn. +--- @field new_timer? vim.async.TimerFactory Create libuv-compatible timers for `sleep()` and `timeout()`. +--- @field debug? boolean Capture task creation metadata for debugging. + +--- @class vim.async.Runtime +--- @nodoc +--- @field wait fun(timeout: integer, predicate: fun(): boolean): boolean +--- @field schedule fun(callback: fun()) +--- @field new_timer vim.async.TimerFactory +--- @field debug boolean +local M = {} +M.debug = false + +--- @nodoc +--- @param opts vim.async.ConfigOpts +function M.config(opts) + validate('opts', opts, 'table') + validate('opts.wait', opts.wait, 'callable', true) + validate('opts.schedule', opts.schedule, 'callable', true) + validate('opts.new_timer', opts.new_timer, 'callable', true) + validate('opts.debug', opts.debug, 'boolean', true) + + M.wait = opts.wait or M.wait + M.schedule = opts.schedule or M.schedule + M.new_timer = opts.new_timer or M.new_timer + if opts.debug ~= nil then + M.debug = opts.debug + end +end + +return M diff --git a/runtime/lua/vim/async/_semaphore.lua b/runtime/lua/vim/async/_semaphore.lua new file mode 100644 index 0000000000..79091f42b9 --- /dev/null +++ b/runtime/lua/vim/async/_semaphore.lua @@ -0,0 +1,133 @@ +-- LuaLS cannot model the generic annotations used by this vendored implementation. +---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync + +local F = vim.F +local new_event = require('vim.async._event') + +local pcall = pcall +do + local ok, coxpcall = pcall(require, 'coxpcall') + if ok and type(coxpcall) == 'table' and type(coxpcall.pcall) == 'function' then + pcall = coxpcall.pcall + end +end +local validate = vim.validate + +--- A semaphore manages an internal permit counter. [Semaphore:acquire()] +--- consumes one permit and [Semaphore:release()] returns one permit. If no +--- permits are available, `acquire()` suspends the current task until another +--- task releases one. +--- +--- The preferred way to use a Semaphore is with the `with()` method, which +--- automatically acquires and releases the semaphore around a function call. +--- This is useful for limiting sections that start external work and then +--- await it, such as file reads, requests, or subprocesses. +--- +--- ```lua +--- local async = vim.async +--- +--- async.run(function() +--- local limit = async.semaphore(4) +--- local tasks = {} +--- +--- for _, path in ipairs(paths) do +--- table.insert(tasks, async.run(function() +--- return limit:with(function() +--- return read_file(path) +--- end) +--- end)) +--- end +--- +--- local next_task = async.iter(tasks) +--- while true do +--- local task = next_task() +--- if task == nil then +--- break +--- end +--- async.await(task) +--- end +--- end) +--- ``` +--- @class vim.async.Semaphore +--- @field private _permits integer +--- @field private _max_permits integer +--- @field package _event vim.async.Event +local Semaphore = {} +Semaphore.__index = Semaphore + +--- Executes a function while holding one semaphore permit. +--- +--- This acquires the semaphore before running the function and releases it +--- after the function completes, even if it errors or the current task is +--- closed. +--- @async +--- @generic R +--- @param fn async fun(): R... # Function to execute within the semaphore's context. +--- @return R... # Result(s) of the executed function. +function Semaphore:with(fn) + self:acquire() + -- This pcall is only