Skip to content

Commit 0e86a15

Browse files
committed
fix(selection): close debounce timers safely
Change-Id: I4caa6d010f8f824aff1c38a7a73d08d47b400cce Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 2ec1609 commit 0e86a15

2 files changed

Lines changed: 172 additions & 11 deletions

File tree

lua/claudecode/selection.lua

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ local M = {}
55
local logger = require("claudecode.logger")
66
local terminal = require("claudecode.terminal")
77

8+
local uv = vim.uv or vim.loop
9+
810
M.state = {
911
latest_selection = nil,
1012
tracking_enabled = false,
@@ -45,10 +47,33 @@ function M.disable()
4547
M.state.latest_selection = nil
4648
M.server = nil
4749

48-
if M.state.debounce_timer then
49-
vim.loop.timer_stop(M.state.debounce_timer)
50-
M.state.debounce_timer = nil
50+
M._cancel_debounce_timer()
51+
52+
if M.state.demotion_timer then
53+
local demotion_timer = M.state.demotion_timer
54+
M.state.demotion_timer = nil
55+
56+
demotion_timer:stop()
57+
demotion_timer:close()
58+
end
59+
end
60+
61+
---Cancels and closes the current debounce timer, if any.
62+
---@local
63+
function M._cancel_debounce_timer()
64+
local timer = M.state.debounce_timer
65+
if not timer then
66+
return
5167
end
68+
69+
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
70+
M.state.debounce_timer = nil
71+
72+
assert(timer.stop, "Expected debounce timer to have :stop()")
73+
assert(timer.close, "Expected debounce timer to have :close()")
74+
75+
timer:stop()
76+
timer:close()
5277
end
5378

5479
---Creates autocommands for tracking selections.
@@ -107,14 +132,36 @@ end
107132
---Ensures that `update_selection` is not called too frequently by deferring
108133
---its execution.
109134
function M.debounce_update()
110-
if M.state.debounce_timer then
111-
vim.loop.timer_stop(M.state.debounce_timer)
112-
end
135+
M._cancel_debounce_timer()
136+
137+
assert(type(M.state.debounce_ms) == "number", "Expected debounce_ms to be a number")
138+
139+
local timer = uv.new_timer()
140+
assert(timer, "Expected uv.new_timer() to return a timer handle")
141+
assert(timer.start, "Expected debounce timer to have :start()")
142+
assert(timer.stop, "Expected debounce timer to have :stop()")
143+
assert(timer.close, "Expected debounce timer to have :close()")
144+
145+
M.state.debounce_timer = timer
113146

114-
M.state.debounce_timer = vim.defer_fn(function()
115-
M.update_selection()
116-
M.state.debounce_timer = nil
117-
end, M.state.debounce_ms)
147+
timer:start(
148+
M.state.debounce_ms,
149+
0, -- 0 repeat = one-shot
150+
vim.schedule_wrap(function()
151+
-- Ignore stale timers (e.g., cancelled and replaced before callback runs)
152+
if M.state.debounce_timer ~= timer then
153+
return
154+
end
155+
156+
-- Clear state before stopping/closing so cancellation is idempotent.
157+
M.state.debounce_timer = nil
158+
159+
timer:stop()
160+
timer:close()
161+
162+
M.update_selection()
163+
end)
164+
)
118165
end
119166

120167
---Updates the current selection state.

tests/selection_test.lua

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
if not _G.vim then
2+
local next_timer_id = 0
3+
24
_G.vim = { ---@type vim_global_api
35
schedule_wrap = function(fn)
46
return fn
@@ -192,7 +194,49 @@ if not _G.vim then
192194
end,
193195

194196
loop = {
195-
timer_stop = function(_timer) -- Prefix unused param with underscore
197+
now = function()
198+
return 0
199+
end,
200+
new_timer = function()
201+
next_timer_id = next_timer_id + 1
202+
203+
local timer = {
204+
_id = next_timer_id,
205+
_start_calls = 0,
206+
_stop_calls = 0,
207+
_close_calls = 0,
208+
_callback = nil,
209+
}
210+
211+
function timer:start(timeout, repeat_interval, callback)
212+
self._start_calls = self._start_calls + 1
213+
self._timeout = timeout
214+
self._repeat_interval = repeat_interval
215+
self._callback = callback
216+
return true
217+
end
218+
219+
function timer:stop()
220+
self._stop_calls = self._stop_calls + 1
221+
return true
222+
end
223+
224+
function timer:close()
225+
self._close_calls = self._close_calls + 1
226+
return true
227+
end
228+
229+
function timer:fire()
230+
assert(self._callback, "Timer has no callback; did you call :start()?")
231+
return self._callback()
232+
end
233+
234+
return timer
235+
end,
236+
timer_stop = function(timer)
237+
if timer and timer.stop then
238+
timer:stop()
239+
end
196240
return true
197241
end,
198242
},
@@ -362,6 +406,76 @@ describe("Selection module", function()
362406
assert(selection.state.latest_selection == nil)
363407
end)
364408

409+
describe("debounce_update", function()
410+
it("should cancel and close previous debounce timer when re-debouncing", function()
411+
local update_calls = 0
412+
local old_update_selection = selection.update_selection
413+
414+
selection.update_selection = function()
415+
update_calls = update_calls + 1
416+
end
417+
418+
selection.debounce_update()
419+
local timer1 = selection.state.debounce_timer
420+
assert(timer1 ~= nil)
421+
422+
selection.debounce_update()
423+
local timer2 = selection.state.debounce_timer
424+
assert(timer2 ~= nil)
425+
assert.are_not.equal(timer1, timer2)
426+
427+
assert.are.equal(1, timer1._stop_calls)
428+
assert.are.equal(1, timer1._close_calls)
429+
430+
-- Clean up the active timer
431+
timer2:fire()
432+
assert.are.equal(1, update_calls)
433+
434+
selection.update_selection = old_update_selection
435+
end)
436+
437+
it("should ignore stale debounce timer callbacks", function()
438+
local update_calls = 0
439+
local old_update_selection = selection.update_selection
440+
441+
selection.update_selection = function()
442+
update_calls = update_calls + 1
443+
end
444+
445+
selection.debounce_update()
446+
local timer1 = selection.state.debounce_timer
447+
assert(timer1 ~= nil)
448+
449+
selection.debounce_update()
450+
local timer2 = selection.state.debounce_timer
451+
assert(timer2 ~= nil)
452+
453+
-- A callback from a cancelled timer should be ignored.
454+
timer1:fire()
455+
assert.are.equal(0, update_calls)
456+
457+
timer2:fire()
458+
assert.are.equal(1, update_calls)
459+
assert(selection.state.debounce_timer == nil)
460+
assert.are.equal(1, timer2._stop_calls)
461+
assert.are.equal(1, timer2._close_calls)
462+
463+
selection.update_selection = old_update_selection
464+
end)
465+
466+
it("disable() should cancel an active debounce timer", function()
467+
selection.enable(mock_server)
468+
selection.debounce_update()
469+
local timer = selection.state.debounce_timer
470+
assert(timer ~= nil)
471+
472+
selection.disable()
473+
assert(selection.state.debounce_timer == nil)
474+
assert.are.equal(1, timer._stop_calls)
475+
assert.are.equal(1, timer._close_calls)
476+
end)
477+
end)
478+
365479
it("should get cursor position in normal mode", function()
366480
local old_win_get_cursor = _G.vim.api.nvim_win_get_cursor
367481
_G.vim.api.nvim_win_get_cursor = function()

0 commit comments

Comments
 (0)