Skip to content

Commit bb2e5a4

Browse files
ThomasK33claude
andcommitted
fix(selection): apply identity-check pattern to demotion timer
Extracts _cancel_demotion_timer() mirroring _cancel_debounce_timer() and applies the same identity-check guard to the demotion timer callback so late-firing libuv callbacks cannot mutate state after the timer was cancelled (e.g. by disable() or by being replaced). Also adds defense-in-depth by gating handle_selection_demotion() on tracking_enabled, switches the demotion timer to the uv alias, and adds a regression test that exercises a stale demotion callback firing after disable(). Change-Id: Ifce08a736d550605ce99a697a187deecf62768a1 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 0602e36 commit bb2e5a4

2 files changed

Lines changed: 102 additions & 43 deletions

File tree

lua/claudecode/selection.lua

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ function M.enable(server, visual_demotion_delay_ms)
3434
end
3535

3636
---Disables selection tracking.
37-
---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.
3839
function M.disable()
3940
if not M.state.tracking_enabled then
4041
return
@@ -48,14 +49,7 @@ function M.disable()
4849
M.server = nil
4950

5051
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
52+
M._cancel_demotion_timer()
5953
end
6054

6155
---Cancels and closes the current debounce timer, if any.
@@ -69,8 +63,20 @@ function M._cancel_debounce_timer()
6963
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
7064
M.state.debounce_timer = nil
7165

72-
assert(timer.stop, "Expected debounce timer to have :stop()")
73-
assert(timer.close, "Expected debounce timer to have :close()")
66+
timer:stop()
67+
timer:close()
68+
end
69+
70+
---Cancels and closes the current demotion timer, if any.
71+
---@local
72+
function M._cancel_demotion_timer()
73+
local timer = M.state.demotion_timer
74+
if not timer then
75+
return
76+
end
77+
78+
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
79+
M.state.demotion_timer = nil
7480

7581
timer:stop()
7682
timer:close()
@@ -153,7 +159,7 @@ function M.debounce_update()
153159
return
154160
end
155161

156-
-- Clear state before stopping/closing so cancellation is idempotent.
162+
-- Clear state so _cancel_debounce_timer() is a no-op if called after firing.
157163
M.state.debounce_timer = nil
158164

159165
timer:stop()
@@ -178,11 +184,7 @@ function M.update_selection()
178184
-- If the buffer name starts with "term://" and contains "claude", do not update selection
179185
if buf_name and buf_name:match("^term://") and buf_name:lower():find("claude", 1, true) then
180186
-- Optionally, cancel demotion timer like for the terminal
181-
if M.state.demotion_timer then
182-
M.state.demotion_timer:stop()
183-
M.state.demotion_timer:close()
184-
M.state.demotion_timer = nil
185-
end
187+
M._cancel_demotion_timer()
186188
return
187189
end
188190

@@ -191,11 +193,7 @@ function M.update_selection()
191193
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
192194
if claude_term_bufnr and current_buf == claude_term_bufnr then
193195
-- Cancel any pending demotion if we switch to the Claude terminal
194-
if M.state.demotion_timer then
195-
M.state.demotion_timer:stop()
196-
M.state.demotion_timer:close()
197-
M.state.demotion_timer = nil
198-
end
196+
M._cancel_demotion_timer()
199197
return
200198
end
201199
end
@@ -206,11 +204,7 @@ function M.update_selection()
206204

207205
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
208206
-- If a new visual selection is made, cancel any pending demotion
209-
if M.state.demotion_timer then
210-
M.state.demotion_timer:stop()
211-
M.state.demotion_timer:close()
212-
M.state.demotion_timer = nil
213-
end
207+
M._cancel_demotion_timer()
214208

215209
current_selection = M.get_visual_selection()
216210

@@ -246,21 +240,25 @@ function M.update_selection()
246240
-- The 'current_selection' for comparison should also be this visual one.
247241
current_selection = M.state.latest_selection
248242

249-
if M.state.demotion_timer then -- Should not happen due to elseif, but as safeguard
250-
M.state.demotion_timer:stop()
251-
M.state.demotion_timer:close()
252-
end
243+
local timer = uv.new_timer()
244+
assert(timer, "Expected uv.new_timer() to return a timer handle")
253245

254-
M.state.demotion_timer = vim.loop.new_timer()
255-
M.state.demotion_timer:start(
246+
M.state.demotion_timer = timer
247+
timer:start(
256248
M.state.visual_demotion_delay_ms,
257249
0, -- 0 repeat = one-shot
258250
vim.schedule_wrap(function()
259-
if M.state.demotion_timer then -- Check if it wasn't cancelled right before firing
260-
M.state.demotion_timer:stop()
261-
M.state.demotion_timer:close()
262-
M.state.demotion_timer = nil
251+
-- Ignore stale timers (e.g., cancelled and replaced before callback runs)
252+
if M.state.demotion_timer ~= timer then
253+
return
263254
end
255+
256+
-- Clear state so _cancel_demotion_timer() is a no-op if called after firing.
257+
M.state.demotion_timer = nil
258+
259+
timer:stop()
260+
timer:close()
261+
264262
M.handle_selection_demotion(current_buf) -- Pass buffer at time of scheduling
265263
end)
266264
)
@@ -296,6 +294,10 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled)
296294
-- Timer object is already stopped and cleared by its own callback wrapper or cancellation points.
297295
-- M.state.demotion_timer should be nil here if it fired normally or was cancelled.
298296

297+
if not M.state.tracking_enabled then
298+
return
299+
end
300+
299301
local current_buf = vim.api.nvim_get_current_buf()
300302
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
301303

tests/selection_test.lua

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,6 @@ if not _G.vim then
233233

234234
return timer
235235
end,
236-
timer_stop = function(timer)
237-
if timer and timer.stop then
238-
timer:stop()
239-
end
240-
return true
241-
end,
242236
},
243237

244238
test = { ---@type vim_test_utils
@@ -453,6 +447,9 @@ describe("Selection module", function()
453447
-- A callback from a cancelled timer should be ignored.
454448
timer1:fire()
455449
assert.are.equal(0, update_calls)
450+
-- Stale callback must not double-stop or double-close the already-cancelled timer.
451+
assert.are.equal(1, timer1._stop_calls)
452+
assert.are.equal(1, timer1._close_calls)
456453

457454
timer2:fire()
458455
assert.are.equal(1, update_calls)
@@ -476,6 +473,66 @@ describe("Selection module", function()
476473
end)
477474
end)
478475

476+
describe("demotion_timer", function()
477+
local function install_terminal_stub()
478+
local terminal_module = package.loaded["claudecode.terminal"]
479+
local original_get = terminal_module and terminal_module.get_active_terminal_bufnr or nil
480+
if not terminal_module then
481+
terminal_module = {}
482+
package.loaded["claudecode.terminal"] = terminal_module
483+
end
484+
terminal_module.get_active_terminal_bufnr = function()
485+
return nil
486+
end
487+
return original_get, terminal_module
488+
end
489+
490+
it("disable() should cancel an active demotion timer and ignore stale callbacks", function()
491+
local original_get, terminal_module = install_terminal_stub()
492+
493+
selection.enable(mock_server)
494+
495+
-- Seed a non-empty visual selection so the demotion path triggers on normal-mode entry.
496+
selection.state.last_active_visual_selection = {
497+
bufnr = 1,
498+
selection_data = {
499+
text = "x",
500+
filePath = "/path/to/test.lua",
501+
fileUrl = "file:///path/to/test.lua",
502+
selection = {
503+
start = { line = 0, character = 0 },
504+
["end"] = { line = 0, character = 1 },
505+
isEmpty = false,
506+
},
507+
},
508+
timestamp = 0,
509+
}
510+
selection.state.latest_selection = selection.state.last_active_visual_selection.selection_data
511+
512+
_G.vim.test.set_mode("n")
513+
selection.update_selection()
514+
515+
local timer = selection.state.demotion_timer
516+
assert(timer ~= nil)
517+
518+
selection.disable()
519+
520+
assert(selection.state.demotion_timer == nil)
521+
assert(selection.state.latest_selection == nil)
522+
assert.are.equal(1, timer._stop_calls)
523+
assert.are.equal(1, timer._close_calls)
524+
525+
-- A late-firing callback from the cancelled timer must not mutate state after teardown.
526+
timer:fire()
527+
assert(selection.state.latest_selection == nil)
528+
assert(selection.state.demotion_timer == nil)
529+
assert.are.equal(1, timer._stop_calls)
530+
assert.are.equal(1, timer._close_calls)
531+
532+
terminal_module.get_active_terminal_bufnr = original_get
533+
end)
534+
end)
535+
479536
it("should get cursor position in normal mode", function()
480537
local old_win_get_cursor = _G.vim.api.nvim_win_get_cursor
481538
_G.vim.api.nvim_win_get_cursor = function()

0 commit comments

Comments
 (0)