fix(selection): close debounce timers safely #174
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Fixes #172.
What changed
vim.uv/vim.loop) rather thanvim.defer_fn().:stop()'d +:close()'d when cancelled or fired.selection.disable()cancels any pending debounce/demotion timers.Why
The previous code mixed
vim.defer_fn()for creation withvim.loop.timer_stop()for cancellation and never closed the timer handle, which could lead to leaked handles and/or stale callbacks under rapid selection/cursor events.Testing
nix develop .#ci -c luacheck lua/claudecode/selection.lua tests/selection_test.lua --no-unused-args --no-max-line-lengthnix develop .#ci -c make test📋 Implementation Plan
Plan: Fix issue #172 (timer API mismatch / unsafe debounce timer cleanup)
Context / Why
Issue #172 reports that
lua/claudecode/selection.luacreates a debounce timer viavim.defer_fn()but cancels it viavim.loop.timer_stop(), and does notclose()the timer handle. This can lead to inconsistent timer handling and (more importantly) leaked or double-closed timer handles under rapid events, which may contribute to crashes on newer Neovim versions.Goal: Make selection debouncing use a single, consistent libuv timer API and ensure timers are always safely stopped + closed when cancelled or fired.
Evidence (verification)
lua/claudecode/selection.lua:49and:111callvim.loop.timer_stop(M.state.debounce_timer).lua/claudecode/selection.lua:114assignsM.state.debounce_timer = vim.defer_fn(...).vim.defer_fn()returns a userdata timer handle with:stop()and:close()methods.Plan
1) Implement a safe debounce timer using a libuv handle (no
vim.defer_fn)File:
lua/claudecode/selection.luavim.loop.new_timer()(orlocal uv = vim.uv or vim.loopthenuv.new_timer()), matching the rest of the module’s timer usage.stop()+close()the previous timer and setM.state.debounce_timer = nilbefore stopping/closing so any already-scheduled callback becomes a no-op.if M.state.debounce_timer ~= timer then return end) to avoid stale callbacks running after re-debounce.stop()+close()the timer and clear state before callingM.update_selection().Suggested structure (pseudocode):
M._cancel_debounce_timer()helper:M.state.debounce_timerthent = M.state.debounce_timerM.state.debounce_timer = nilt:stop(); t:close()M.debounce_update():M._cancel_debounce_timer()timer = uv.new_timer()M.state.debounce_timer = timertimer:start(M.state.debounce_ms, 0, vim.schedule_wrap(function() ... end))2) Fix
disable()cleanup to stop + close the debounce timerFile:
lua/claudecode/selection.luavim.loop.timer_stop(M.state.debounce_timer)call with the same safe cancellation helper from step 1.M.state.demotion_timerindisable()using the existing:stop(); :close(); nilpattern to prevent delayed callbacks firing after tracking is disabled.3) Update/extend unit tests to cover the debounce contract
Files:
tests/selection_test.lua(standalone vim mock)tests/mocks/vim.lua(shared mock) if neededAdd tests that validate:
selection.debounce_update()twice rapidly cancels/cleans the previous timer (assertstop()andclose()called once on the old handle).update_selection().selection.disable()cancels the active debounce timer without error.To make this deterministic in unit tests, adjust the timer mock to:
callbackfromtimer:start(...)without auto-executing, and expose atimer:fire()helper for tests.4) Regression check
make check(syntax + luacheck).make test(busted).Notes / Rationale
Why avoid “just change timer_stop to :stop()/:close()” while keeping vim.defer_fn?
vim.defer_fn()typically wraps a libuv timer and closes it internally when it fires. If we alsoclose()the handle on cancel, there is a race where the timer fires, schedules its wrapper callback, then we cancel+close, and later the wrapper tries to close again. Implementing the debounce timer directly withuv.new_timer()gives us full control and enables a simple “stale timer” guard.Generated with
mux• Model: openai:gpt-5.2 • Thinking: xhigh