Skip to content

Commit a0171dd

Browse files
committed
fix: more robust timer closing
1 parent 30e5c51 commit a0171dd

File tree

4 files changed

+163
-16
lines changed

4 files changed

+163
-16
lines changed

lua/gitsigns/debounce.lua

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ local uv = vim.uv or vim.loop ---@diagnostic disable-line: deprecated
22

33
local M = {}
44

5+
--- @class gitsigns.debounce.debounce_trailing.Opts
6+
--- @field timeout integer|fun():integer Timeout in ms
7+
--- @field hash? integer|fun(...): any Function that determines id from arguments to `fn`
8+
59
--- Debounces a function on the trailing edge.
610
---
711
--- Example waveform
@@ -13,16 +17,24 @@ local M = {}
1317
--- With a debounce period of 3 units, the debounced function fires at 3 and 9.
1418
---
1519
--- @generic F: function
16-
--- @param timeout integer|fun():integer Timeout in ms
20+
--- @param opts gitsigns.debounce.debounce_trailing.Opts|integer|fun():integer
1721
--- @param fn F Function to debounce
18-
--- @param hash? integer|fun(...): any Function that determines id from arguments to fn
1922
--- @return F Debounced function.
20-
function M.debounce_trailing(timeout, fn, hash)
21-
local running = {} --- @type table<any, uv.uv_timer_t>
23+
function M.debounce_trailing(opts, fn)
24+
local timeout --- @type (integer|fun():integer)?
25+
local hash --- @type (integer|fun(...): any)?
26+
27+
if type(opts) == 'table' then
28+
timeout = opts.timeout
29+
hash = opts.hash
30+
else
31+
timeout = opts
32+
end
2233

2334
-- Normalize hash to a function if it's a number (argument index)
2435
if type(hash) == 'number' then
2536
local hash_i = hash
37+
--- @return any
2638
hash = function(...)
2739
return select(hash_i, ...)
2840
end
@@ -38,22 +50,22 @@ function M.debounce_trailing(timeout, fn, hash)
3850
end
3951
end
4052

53+
local running = {} --- @type table<any, uv.uv_timer_t?>
54+
4155
return function(...)
4256
local id = hash and hash(...) or true
43-
local argv = { ... }
57+
local argv, argc = { ... }, select('#', ...)
4458

4559
local timer = running[id]
4660
if not timer or timer:is_closing() then
4761
timer = assert(uv.new_timer())
4862
running[id] = timer
4963
end
5064

51-
timer:stop() -- Always stop before (re)starting
5265
timer:start(timeout(), 0, function()
53-
timer:stop()
54-
running[id] = nil
55-
fn(unpack(argv, 1, table.maxn(argv)))
5666
timer:close()
67+
running[id] = nil
68+
fn(unpack(argv, 1, argc))
5769
end)
5870
end
5971
end

lua/gitsigns/manager.lua

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,14 @@ M.update = throttle_async({ hash = 1, schedule = true }, function(bufnr)
455455
end)
456456
end)
457457

458-
M.update_sync_debounced = debounce_trailing(function()
459-
return config.update_debounce
460-
end, function(bufnr)
458+
M.update_sync_debounced = debounce_trailing({
459+
timeout = function()
460+
return config.update_debounce
461+
end,
462+
hash = 1,
463+
}, function(bufnr)
461464
async.run(M.update, bufnr):raise_on_error()
462-
end, 1)
465+
end)
463466

464467
--- @param bufnr integer
465468
--- @param keep_signs? boolean

test/debounce_spec.lua

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
--- @diagnostic disable: global-in-non-module, redundant-parameter
2+
local helpers = require('test.gs_helpers')
3+
4+
local clear = helpers.clear
5+
local eq = helpers.eq
6+
local exec_lua = helpers.exec_lua
7+
8+
helpers.env()
9+
10+
describe('debounce', function()
11+
before_each(function()
12+
clear()
13+
helpers.setup_path()
14+
end)
15+
16+
it('closes the timer even if the function errors', function()
17+
exec_lua(function()
18+
local uv = vim.uv or vim.loop ---@diagnostic disable-line: deprecated
19+
20+
_G._debounce_close_called = 0
21+
22+
local orig_new_timer = uv.new_timer
23+
24+
uv.new_timer = function(...)
25+
local t = assert(orig_new_timer(...))
26+
local proxy = { _t = t }
27+
28+
function proxy:start(...)
29+
return self._t:start(...)
30+
end
31+
32+
function proxy:close(...)
33+
_G._debounce_close_called = _G._debounce_close_called + 1
34+
return self._t:close(...)
35+
end
36+
37+
return proxy
38+
end
39+
40+
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
41+
local debounced = debounce_trailing(1, function()
42+
error('GS_DEBOUNCE_TEST_CLOSE')
43+
end)
44+
debounced()
45+
end)
46+
47+
helpers.expectf(function()
48+
eq(1, exec_lua('return _G._debounce_close_called'))
49+
end)
50+
end)
51+
52+
it('prints a full stacktrace if the function errors', function()
53+
exec_lua(function()
54+
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
55+
local debounced = debounce_trailing(1, function()
56+
error('GS_DEBOUNCE_TEST_STACK')
57+
end)
58+
debounced()
59+
end)
60+
61+
helpers.expectf(function()
62+
local messages = exec_lua(function()
63+
return vim.api.nvim_exec2('messages', { output = true }).output
64+
end) ---@type string
65+
assert(messages:match('debounce_spec.lua:%d+: GS_DEBOUNCE_TEST_STACK'), messages)
66+
assert(messages:match('stack traceback'), messages)
67+
assert(messages:match('lua/gitsigns/debounce.lua'), messages)
68+
end)
69+
end)
70+
71+
it('debounces independently by hash key', function()
72+
exec_lua(function()
73+
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
74+
75+
_G._debounce_hash_calls = {}
76+
77+
local debounced = debounce_trailing({
78+
timeout = 10,
79+
hash = 1,
80+
}, function(id, value)
81+
local t = _G._debounce_hash_calls[id] or { count = 0, value = nil }
82+
t.count = t.count + 1
83+
t.value = value
84+
_G._debounce_hash_calls[id] = t
85+
end)
86+
87+
debounced('a', 1)
88+
debounced('a', 2)
89+
debounced('b', 3)
90+
debounced('b', 4)
91+
end)
92+
93+
helpers.expectf(function()
94+
eq({
95+
a = { count = 1, value = 2 },
96+
b = { count = 1, value = 4 },
97+
}, exec_lua('return _G._debounce_hash_calls'))
98+
end)
99+
end)
100+
101+
it('accepts a hash function for ids', function()
102+
exec_lua(function()
103+
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
104+
105+
_G._debounce_hash_fn_calls = {}
106+
107+
local debounced = debounce_trailing({
108+
timeout = 10,
109+
hash = function(id)
110+
return id
111+
end,
112+
}, function(id, value)
113+
_G._debounce_hash_fn_calls[id] = (_G._debounce_hash_fn_calls[id] or 0) + 1
114+
_G._debounce_hash_fn_value = value
115+
end)
116+
117+
debounced('a', 1)
118+
debounced('a', 2)
119+
end)
120+
121+
helpers.expectf(function()
122+
eq(1, exec_lua("return _G._debounce_hash_fn_calls['a']"))
123+
eq(2, exec_lua('return _G._debounce_hash_fn_value'))
124+
end)
125+
end)
126+
end)

test/gs_helpers.lua

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,17 @@ function M.match_debug_messages(spec)
229229
end)
230230
end
231231

232+
function M.setup_path()
233+
exec_lua(function(path)
234+
package.path = path
235+
end, package.path)
236+
end
237+
232238
--- @param config? table
233239
--- @param on_attach? boolean
234240
function M.setup_gitsigns(config, on_attach)
235-
exec_lua(function(path, config0, on_attach0)
236-
package.path = path
241+
M.setup_path()
242+
exec_lua(function(config0, on_attach0)
237243
if config0 and config0.on_attach then
238244
local maps = config0.on_attach --[[@as [string,string,string][] ]]
239245
config0.on_attach = function(bufnr)
@@ -249,7 +255,7 @@ function M.setup_gitsigns(config, on_attach)
249255
end
250256
require('gitsigns').setup(config0)
251257
vim.o.diffopt = 'internal,filler,closeoff'
252-
end, package.path, config, on_attach)
258+
end, config, on_attach)
253259
end
254260

255261
--- @param status table<string,string|integer>

0 commit comments

Comments
 (0)