fix(event): wait() skips final condition check #42600

Problem:
`wait()` and `vim.wait()` return -1 without checking the condition again
if an event processed during the last poll exceed the timeout, though
the docs say the condition is evaluated on internal events. So -1 may
mean the condition is already true.

If we "document" this, we would essentially be documenting a race
condition.

Solution:
Check the condition once more after the last poll, like Python's
`Condition.wait_for()`.
This commit is contained in:
Justin M. Keyes authored and GitHub committed 2026-10-31 06:52:03 -04:00
1 parent bca9361085
commit 1d5c87b845
4 files changed
+43 -19

No files matched your search

+9 -10
View File
@@ -145,6 +145,7 @@ function vim.wait(time, callback, interval, fast_only)
-- Flush screen updates before blocking.
vim._core.ui_flush()
local expired = false
while true do
if vim._core.check_interrupt() then
cleanup()
@@ -162,6 +163,11 @@ function vim.wait(time, callback, interval, fast_only)
end
end
if expired then
cleanup()
return false, -1
end
local poll_timeout = -1
if has_deadline then
local remaining_ms = time - (vim.uv.hrtime() - start) / 1e6
@@ -175,16 +181,9 @@ function vim.wait(time, callback, interval, fast_only)
-- polling would only resume for unrelated events or the final timeout.
vim._core.loop_poll(poll_timeout, fast_only)
-- Check deadline AFTER polling (mirrors LOOP_PROCESS_EVENTS_UNTIL behavior).
-- `got_int` may have been set during poll; let check_interrupt() catch it.
if has_deadline and time - (vim.uv.hrtime() - start) / 1e6 <= 0 then
if vim._core.check_interrupt() then
cleanup()
return false, -2
end
cleanup()
return false, -1
end
-- Checks `callback` after the last poll, which may have run past the deadline (same as
-- LOOP_PROCESS_EVENTS_UNTIL).
expired = has_deadline and time - (vim.uv.hrtime() - start) / 1e6 <= 0
end
end
+7 -9
View File
@@ -22,21 +22,19 @@
} \
} while (0)
// Poll for events until a condition or timeout
// Poll for events until `condition` or timeout. Checks `condition` after the last poll, which may
// have run past the timeout.
#define LOOP_PROCESS_EVENTS_UNTIL(loop, multiqueue, timeout, condition) \
do { \
const int64_t timeout_ms = (timeout); \
const uint64_t start = (timeout_ms > 0) ? os_hrtime() : 0; \
const uint64_t start = (timeout_ms >= 0) ? os_hrtime() : 0; \
int64_t remaining = timeout_ms; \
while (!(condition)) { \
bool expired = false; \
while (!(condition) && !expired) { \
LOOP_PROCESS_EVENTS(loop, multiqueue, remaining); \
if (remaining == 0) { \
break; \
} else if (remaining > 0) { \
if (timeout_ms >= 0) { \
remaining = timeout_ms - (int64_t)((os_hrtime() - start) / 1000000); \
if (remaining <= 0) { \
break; \
} \
expired = remaining <= 0; \
} \
} \
} while (0)
+15
View File
@@ -2430,6 +2430,21 @@ describe('lua stdlib', function()
)
end)
it('checks the callback after a deferred function runs past the timeout #11193', function()
eq(
true,
exec_lua(function()
vim.defer_fn(function()
vim.uv.sleep(100)
vim.g.timer_result = true
end, 0)
return vim.wait(50, function()
return vim.g.timer_result
end)
end)
)
end)
it('does not crash when callback errors', function()
local result = exec_lua [[
return {pcall(function() vim.wait(1000, function() error("As Expected") end) end)}
+12
View File
@@ -33,6 +33,18 @@ describe('wait()', function()
eq(0, eval('wait(0, 1)'))
end)
it('checks the condition after a timer callback runs past the timeout #11193', function()
-- Starts the timer in the same request as wait(), so it fires inside wait().
source([[
func StartAndWait()
let g:_awake = 0
call timer_start(0, { -> execute(['sleep 100m', 'let g:_awake = 1']) })
return wait(50, { -> g:_awake })
endfunc
]])
eq(0, eval('StartAndWait()'))
end)
it('returns -1 on timeout', function()
eq(-1, eval('wait(0, 0)'))
eq(-1, eval('wait(50, 0)'))