Skip to content

Commit 102d835

Browse files
authored
fix(selection): close debounce timers safely (#245)
## Fix debounce timer lifecycle: use `uv` timer API with proper stop/close Replaces the `vim.defer_fn`-based debounce implementation with an explicit `uv.new_timer()` approach that correctly calls both `:stop()` and `:close()` on the timer handle. Previously, cancelled timers were only stopped but never closed, leaking libuv handles. Stale timer callbacks could also fire after being superseded by a newer debounce call. Key changes: - Introduces `M._cancel_debounce_timer()` to consistently stop and close the active debounce timer, clearing state before doing so to make cancellation idempotent - The debounce callback now checks whether its timer is still the active one before proceeding, ignoring stale callbacks from replaced timers - `disable()` also properly stops and closes the demotion timer - Uses `vim.uv or vim.loop` for forward compatibility - Expands the test mock to provide a full `uv`-compatible timer object with `:start()`, `:stop()`, `:close()`, and `:fire()` methods, along with new tests covering timer replacement, stale callback suppression, and `disable()` cleanup Change-Id: I4caa6d010f8f824aff1c38a7a73d08d47b400cce Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 022ac44 commit 102d835

2 files changed

Lines changed: 312 additions & 38 deletions

File tree

lua/claudecode/selection.lua

Lines changed: 86 additions & 36 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,
@@ -32,7 +34,8 @@ function M.enable(server, visual_demotion_delay_ms)
3234
end
3335

3436
---Disables selection tracking.
35-
---Clears autocommands, resets internal state, and stops any active debounce timers.
37+
---Clears autocommands, resets internal state, and stops any active debounce or
38+
---demotion timers.
3639
function M.disable()
3740
if not M.state.tracking_enabled then
3841
return
@@ -43,12 +46,41 @@ function M.disable()
4346
M._clear_autocommands()
4447

4548
M.state.latest_selection = nil
49+
M.state.last_active_visual_selection = nil
4650
M.server = nil
4751

48-
if M.state.debounce_timer then
49-
vim.loop.timer_stop(M.state.debounce_timer)
50-
M.state.debounce_timer = nil
52+
M._cancel_debounce_timer()
53+
M._cancel_demotion_timer()
54+
end
55+
56+
---Cancels and closes the current debounce timer, if any.
57+
---@local
58+
function M._cancel_debounce_timer()
59+
local timer = M.state.debounce_timer
60+
if not timer then
61+
return
62+
end
63+
64+
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
65+
M.state.debounce_timer = nil
66+
67+
timer:stop()
68+
timer:close()
69+
end
70+
71+
---Cancels and closes the current demotion timer, if any.
72+
---@local
73+
function M._cancel_demotion_timer()
74+
local timer = M.state.demotion_timer
75+
if not timer then
76+
return
5177
end
78+
79+
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
80+
M.state.demotion_timer = nil
81+
82+
timer:stop()
83+
timer:close()
5284
end
5385

5486
---Creates autocommands for tracking selections.
@@ -107,14 +139,36 @@ end
107139
---Ensures that `update_selection` is not called too frequently by deferring
108140
---its execution.
109141
function M.debounce_update()
110-
if M.state.debounce_timer then
111-
vim.loop.timer_stop(M.state.debounce_timer)
112-
end
142+
M._cancel_debounce_timer()
143+
144+
assert(type(M.state.debounce_ms) == "number", "Expected debounce_ms to be a number")
145+
146+
local timer = uv.new_timer()
147+
assert(timer, "Expected uv.new_timer() to return a timer handle")
148+
assert(timer.start, "Expected debounce timer to have :start()")
149+
assert(timer.stop, "Expected debounce timer to have :stop()")
150+
assert(timer.close, "Expected debounce timer to have :close()")
151+
152+
M.state.debounce_timer = timer
113153

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)
154+
timer:start(
155+
M.state.debounce_ms,
156+
0, -- 0 repeat = one-shot
157+
vim.schedule_wrap(function()
158+
-- Ignore stale timers (e.g., cancelled and replaced before callback runs)
159+
if M.state.debounce_timer ~= timer then
160+
return
161+
end
162+
163+
-- Clear state so _cancel_debounce_timer() is a no-op if called after firing.
164+
M.state.debounce_timer = nil
165+
166+
timer:stop()
167+
timer:close()
168+
169+
M.update_selection()
170+
end)
171+
)
118172
end
119173

120174
---Updates the current selection state.
@@ -131,11 +185,7 @@ function M.update_selection()
131185
-- If the buffer name starts with "term://" and contains "claude", do not update selection
132186
if buf_name and buf_name:match("^term://") and buf_name:lower():find("claude", 1, true) then
133187
-- Optionally, cancel demotion timer like for the terminal
134-
if M.state.demotion_timer then
135-
M.state.demotion_timer:stop()
136-
M.state.demotion_timer:close()
137-
M.state.demotion_timer = nil
138-
end
188+
M._cancel_demotion_timer()
139189
return
140190
end
141191

@@ -144,11 +194,7 @@ function M.update_selection()
144194
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
145195
if claude_term_bufnr and current_buf == claude_term_bufnr then
146196
-- Cancel any pending demotion if we switch to the Claude terminal
147-
if M.state.demotion_timer then
148-
M.state.demotion_timer:stop()
149-
M.state.demotion_timer:close()
150-
M.state.demotion_timer = nil
151-
end
197+
M._cancel_demotion_timer()
152198
return
153199
end
154200
end
@@ -159,11 +205,7 @@ function M.update_selection()
159205

160206
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
161207
-- If a new visual selection is made, cancel any pending demotion
162-
if M.state.demotion_timer then
163-
M.state.demotion_timer:stop()
164-
M.state.demotion_timer:close()
165-
M.state.demotion_timer = nil
166-
end
208+
M._cancel_demotion_timer()
167209

168210
current_selection = M.get_visual_selection()
169211

@@ -199,21 +241,25 @@ function M.update_selection()
199241
-- The 'current_selection' for comparison should also be this visual one.
200242
current_selection = M.state.latest_selection
201243

202-
if M.state.demotion_timer then -- Should not happen due to elseif, but as safeguard
203-
M.state.demotion_timer:stop()
204-
M.state.demotion_timer:close()
205-
end
244+
local timer = uv.new_timer()
245+
assert(timer, "Expected uv.new_timer() to return a timer handle")
206246

207-
M.state.demotion_timer = vim.loop.new_timer()
208-
M.state.demotion_timer:start(
247+
M.state.demotion_timer = timer
248+
timer:start(
209249
M.state.visual_demotion_delay_ms,
210250
0, -- 0 repeat = one-shot
211251
vim.schedule_wrap(function()
212-
if M.state.demotion_timer then -- Check if it wasn't cancelled right before firing
213-
M.state.demotion_timer:stop()
214-
M.state.demotion_timer:close()
215-
M.state.demotion_timer = nil
252+
-- Ignore stale timers (e.g., cancelled and replaced before callback runs)
253+
if M.state.demotion_timer ~= timer then
254+
return
216255
end
256+
257+
-- Clear state so _cancel_demotion_timer() is a no-op if called after firing.
258+
M.state.demotion_timer = nil
259+
260+
timer:stop()
261+
timer:close()
262+
217263
M.handle_selection_demotion(current_buf) -- Pass buffer at time of scheduling
218264
end)
219265
)
@@ -249,6 +295,10 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled)
249295
-- Timer object is already stopped and cleared by its own callback wrapper or cancellation points.
250296
-- M.state.demotion_timer should be nil here if it fired normally or was cancelled.
251297

298+
if not M.state.tracking_enabled then
299+
return
300+
end
301+
252302
local current_buf = vim.api.nvim_get_current_buf()
253303
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
254304

0 commit comments

Comments
 (0)