mirror of
https://github.com/neovim/neovim.git
synced 2026-09-04 13:20:34 +00:00
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
This commit is contained in:
committed by
Lewis Russell
parent
da4355ab8f
commit
ce8a897f98
1128
runtime/lua/vim/async/_core.lua
Normal file
1128
runtime/lua/vim/async/_core.lua
Normal file
File diff suppressed because it is too large
Load Diff
125
runtime/lua/vim/async/_event.lua
Normal file
125
runtime/lua/vim/async/_event.lua
Normal file
@@ -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
|
||||
77
runtime/lua/vim/async/_future.lua
Normal file
77
runtime/lua/vim/async/_future.lua
Normal file
@@ -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
|
||||
107
runtime/lua/vim/async/_queue.lua
Normal file
107
runtime/lua/vim/async/_queue.lua
Normal file
@@ -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<R>
|
||||
--- @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<any>
|
||||
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
|
||||
46
runtime/lua/vim/async/_runtime.lua
Normal file
46
runtime/lua/vim/async/_runtime.lua
Normal file
@@ -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
|
||||
133
runtime/lua/vim/async/_semaphore.lua
Normal file
133
runtime/lua/vim/async/_semaphore.lua
Normal file
@@ -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 a try/finally guard for release(); all errors are
|
||||
-- immediately rethrown so it is not an async recovery boundary.
|
||||
local r = F.pack_len(pcall(fn))
|
||||
self:release()
|
||||
local stat = r[1]
|
||||
if not stat then
|
||||
local err = r[2]
|
||||
error(err, 0)
|
||||
end
|
||||
return unpack(r, 2, r.n)
|
||||
end
|
||||
|
||||
--- Acquire a semaphore permit.
|
||||
---
|
||||
--- If the internal counter is greater than zero, decrement it by `1` and
|
||||
--- return immediately. If it is `0`, wait until [Semaphore:release()] is
|
||||
--- called.
|
||||
--- @async
|
||||
function Semaphore:acquire()
|
||||
self._event:wait()
|
||||
self._permits = self._permits - 1
|
||||
assert(self._permits >= 0, 'Semaphore value is negative')
|
||||
if self._permits == 0 then
|
||||
self._event:clear()
|
||||
end
|
||||
end
|
||||
|
||||
--- Release a semaphore permit.
|
||||
---
|
||||
--- Increments the internal counter by `1` and can wake a task waiting in
|
||||
--- [Semaphore:acquire()].
|
||||
---
|
||||
--- Calling this more times than permits were acquired raises an error.
|
||||
function Semaphore:release()
|
||||
if self._permits >= self._max_permits then
|
||||
error('Semaphore value is greater than max permits', 2)
|
||||
end
|
||||
self._permits = self._permits + 1
|
||||
self._event:set(1)
|
||||
end
|
||||
|
||||
--- Create an async semaphore that allows up to a given number of acquisitions.
|
||||
---
|
||||
--- Prefer [Semaphore:with()] for most uses so permits are released reliably.
|
||||
--- Use [Semaphore:acquire()] and [Semaphore:release()] directly only when the
|
||||
--- acquire and release points cannot be expressed as one function call.
|
||||
--- @param permits? integer (default: 1)
|
||||
--- @return vim.async.Semaphore
|
||||
local function new_semaphore(permits)
|
||||
validate('permits', permits, 'number', true)
|
||||
permits = permits or 1
|
||||
if permits < 1 or permits % 1 ~= 0 then
|
||||
error('permits: expected positive integer', 2)
|
||||
end
|
||||
|
||||
local obj = setmetatable({
|
||||
_max_permits = permits,
|
||||
_permits = permits,
|
||||
_event = new_event(),
|
||||
}, Semaphore)
|
||||
obj._event:set()
|
||||
return obj
|
||||
end
|
||||
|
||||
return new_semaphore
|
||||
Reference in New Issue
Block a user