diff --git a/lua/shelltime/heartbeat.lua b/lua/shelltime/heartbeat.lua index ab3d2a5..7efb89c 100644 --- a/lua/shelltime/heartbeat.lua +++ b/lua/shelltime/heartbeat.lua @@ -13,6 +13,9 @@ local PLUGIN_VERSION = '0.0.4' -- x-release-please-version -- Pending heartbeats queue local pending_heartbeats = {} +-- Upper bound for the queue while the daemon is unreachable +local MAX_PENDING = 5000 + -- Last heartbeat time per file (for debouncing) local last_heartbeat_time = {} @@ -55,7 +58,7 @@ local function is_valid_buffer(bufnr) end -- Skip .git directory files - if file_path:match('/.git/') then + if file_path:match('[/\\]%.git[/\\]') then return false end @@ -158,10 +161,19 @@ local function create_heartbeat(bufnr, is_write) } end +--- Drop the oldest heartbeats once the queue exceeds MAX_PENDING +local function trim_queue() + local overflow = #pending_heartbeats - MAX_PENDING + if overflow > 0 then + pending_heartbeats = vim.list_slice(pending_heartbeats, overflow + 1) + end +end + --- Add heartbeat to pending queue ---@param heartbeat table Heartbeat data local function add_heartbeat(heartbeat) table.insert(pending_heartbeats, heartbeat) + trim_queue() if config.get('debug') then vim.notify( @@ -173,7 +185,8 @@ end --- Handle editor event ---@param is_write boolean Whether this is a write event -local function on_event(is_write) +---@param is_navigation boolean Whether this is a navigation event (BufEnter, cursor moves) +local function on_event(is_write, is_navigation) if not config.is_enabled() then return end @@ -191,8 +204,9 @@ local function on_event(is_write) local line_number = cursor[1] local cursor_position = cursor[2] - -- Skip duplicate events (same file and cursor position) - if is_duplicate_activity(file_path, line_number, cursor_position, is_write) then + -- Skip repeated navigation events (same file and cursor position). + -- Edits always count, even when the cursor stays in place (x, dd). + if is_navigation and is_duplicate_activity(file_path, line_number, cursor_position, is_write) then return end @@ -222,7 +236,7 @@ function M.start() vim.api.nvim_create_autocmd('BufEnter', { group = augroup, callback = function() - on_event(false) + on_event(false, true) end, }) @@ -230,7 +244,7 @@ function M.start() vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { group = augroup, callback = function() - on_event(false) + on_event(false, false) end, }) @@ -238,7 +252,7 @@ function M.start() vim.api.nvim_create_autocmd('BufWritePost', { group = augroup, callback = function() - on_event(true) + on_event(true, false) end, }) @@ -246,7 +260,7 @@ function M.start() vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { group = augroup, callback = function() - on_event(false) + on_event(false, true) end, }) end @@ -267,6 +281,16 @@ function M.flush() return heartbeats end +--- Put heartbeats that could not be delivered back at the front of the queue +---@param heartbeats table[] Heartbeats to retry +function M.requeue(heartbeats) + if #heartbeats == 0 then + return + end + pending_heartbeats = vim.list_extend(vim.list_extend({}, heartbeats), pending_heartbeats) + trim_queue() +end + --- Get pending heartbeat count ---@return number Count function M.get_pending_count() diff --git a/lua/shelltime/sender.lua b/lua/shelltime/sender.lua index fb4074f..080bb7c 100644 --- a/lua/shelltime/sender.lua +++ b/lua/shelltime/sender.lua @@ -10,6 +10,12 @@ local M = {} -- Flush timer local flush_timer = nil +-- Autocmd group for the exit flush +local augroup = nil + +-- How long Neovim may wait on exit for pending heartbeats to be sent +local EXIT_FLUSH_TIMEOUT = 1500 + -- Connection status local is_connected = false @@ -28,6 +34,11 @@ local function send_heartbeats(callback) socket.send_heartbeats(heartbeats, function(success, err) is_connected = success + if not success then + -- Keep them for the next flush instead of dropping them + heartbeat.requeue(heartbeats) + end + if config.get('debug') then if success then vim.notify( @@ -64,6 +75,16 @@ function M.start() end) end) + -- Sessions are often shorter than the flush interval, so send what is + -- pending before Neovim exits. + augroup = vim.api.nvim_create_augroup('ShellTimeSender', { clear = true }) + vim.api.nvim_create_autocmd('VimLeavePre', { + group = augroup, + callback = function() + M.flush_sync(EXIT_FLUSH_TIMEOUT) + end, + }) + -- Check initial connection status and CLI version vim.schedule(function() is_connected = socket.is_connected_sync() @@ -86,6 +107,11 @@ function M.stop() flush_timer:close() flush_timer = nil end + + if augroup then + vim.api.nvim_del_augroup_by_id(augroup) + augroup = nil + end end --- Force flush pending heartbeats @@ -94,6 +120,19 @@ function M.flush(callback) send_heartbeats(callback) end +--- Flush pending heartbeats and block until sent or timed out +---@param timeout number Maximum wait in milliseconds +---@return boolean True if the send finished within the timeout +function M.flush_sync(timeout) + local done = false + send_heartbeats(function() + done = true + end) + return vim.wait(timeout, function() + return done + end, 10) +end + --- Get connection status ---@return boolean function M.is_connected() diff --git a/lua/shelltime/utils/system.lua b/lua/shelltime/utils/system.lua index 3019ffe..9d98d7c 100644 --- a/lua/shelltime/utils/system.lua +++ b/lua/shelltime/utils/system.lua @@ -92,15 +92,41 @@ function M.get_project_name(project_root) return tail end +-- Whether the math.random fallback has been seeded +local seeded = false + +--- Get 16 random bytes, from the OS when possible +---@return number[] Byte values (0-255) +local function random_bytes() + local uv = vim.uv or vim.loop + local ok, bytes = pcall(uv.random, 16) + if ok and type(bytes) == 'string' and #bytes == 16 then + return { bytes:byte(1, 16) } + end + + -- Seed once: reseeding on every call from the clock repeats sequences + -- across calls and Neovim instances, and heartbeat ids must be unique. + if not seeded then + math.randomseed(uv.hrtime() + uv.os_getpid()) + seeded = true + end + local result = {} + for i = 1, 16 do + result[i] = math.random(0, 255) + end + return result +end + --- Generate UUID v4 ---@return string UUID string function M.uuid() - math.randomseed(os.time() + os.clock() * 1000000) - local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' - return string.gsub(template, '[xy]', function(c) - local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb) - return string.format('%x', v) - end) + local b = random_bytes() + b[7] = (b[7] % 16) + 0x40 -- version 4 + b[9] = (b[9] % 64) + 0x80 -- RFC 4122 variant + return string.format( + '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x', + unpack(b) + ) end --- Get current Unix timestamp in seconds diff --git a/tests/heartbeat_spec.lua b/tests/heartbeat_spec.lua index 9f43a7c..ed3a428 100644 --- a/tests/heartbeat_spec.lua +++ b/tests/heartbeat_spec.lua @@ -180,6 +180,98 @@ describe('shelltime.heartbeat', function() end) end) + describe('requeue', function() + it('should put heartbeats back ahead of newer ones', function() + heartbeat.requeue({ { entity = 'newer' } }) + heartbeat.requeue({ { entity = 'older' } }) + + local pending = heartbeat.flush() + assert.equals(2, #pending) + assert.equals('older', pending[1].entity) + assert.equals('newer', pending[2].entity) + end) + + it('should ignore an empty list', function() + heartbeat.requeue({}) + assert.equals(0, heartbeat.get_pending_count()) + end) + + it('should cap the queue and drop the oldest heartbeats', function() + local list = {} + for i = 1, 5001 do + list[i] = { entity = 'file-' .. i } + end + + heartbeat.requeue(list) + + local pending = heartbeat.flush() + assert.equals(5000, #pending) + assert.equals('file-2', pending[1].entity) + assert.equals('file-5001', pending[5000].entity) + end) + end) + + describe('autocmd events (integration)', function() + local buffers = {} + + -- Open a named file buffer in the current window, dropping its BufEnter heartbeat + local function open_buffer(path) + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(bufnr, path) + table.insert(buffers, bufnr) + vim.api.nvim_set_current_buf(bufnr) + heartbeat.flush() + return bufnr + end + + before_each(function() + config._set_for_testing({ debounce_interval = 0 }) + heartbeat.start() + end) + + after_each(function() + heartbeat.stop() + for _, bufnr in ipairs(buffers) do + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + end + buffers = {} + end) + + it('should skip files inside .git', function() + local bufnr = open_buffer('/tmp/shelltime-test/.git/COMMIT_EDITMSG') + + vim.api.nvim_exec_autocmds('BufWritePost', { buffer = bufnr }) + + assert.equals(0, heartbeat.get_pending_count()) + end) + + it('should track directories that only look like .git', function() + local bufnr = open_buffer('/tmp/shelltime-test/egit/main.lua') + + vim.api.nvim_exec_autocmds('BufWritePost', { buffer = bufnr }) + + assert.equals(1, heartbeat.get_pending_count()) + end) + + it('should count edits that leave the cursor in place', function() + local bufnr = open_buffer('/tmp/shelltime-test/edit.lua') + + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + + assert.equals(2, heartbeat.get_pending_count()) + end) + + it('should skip repeated cursor events at the same position', function() + local bufnr = open_buffer('/tmp/shelltime-test/nav.lua') + + vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr }) + vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr }) + + assert.equals(0, heartbeat.get_pending_count()) + end) + end) + describe('buffer validation (integration)', function() -- These tests verify buffer validation through behavior diff --git a/tests/sender_spec.lua b/tests/sender_spec.lua index 9f8f8a1..86ae702 100644 --- a/tests/sender_spec.lua +++ b/tests/sender_spec.lua @@ -120,6 +120,93 @@ describe('shelltime.sender', function() sender.flush() end) end) + + it('should re-queue heartbeats when the send fails', function() + heartbeat.requeue({ { entity = 'a' }, { entity = 'b' } }) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + callback(false, 'Connection failed: ENOENT') + end) + + local called = false + sender.flush(function(success) + called = true + assert.is_false(success) + end) + + vim.wait(100, function() return called end) + assert.is_true(called) + local pending = heartbeat.flush() + assert.equals(2, #pending) + assert.equals('a', pending[1].entity) + end) + + it('should not keep heartbeats after a successful send', function() + heartbeat.requeue({ { entity = 'a' } }) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + callback(true, nil) + end) + + local called = false + sender.flush(function() + called = true + end) + + vim.wait(100, function() return called end) + assert.equals(0, heartbeat.get_pending_count()) + end) + end) + + describe('flush_sync', function() + local socket_stub + + after_each(function() + if socket_stub and socket_stub.revert then + socket_stub:revert() + socket_stub = nil + end + end) + + it('should wait for a scheduled send to finish', function() + heartbeat.requeue({ { entity = 'a' } }) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + vim.schedule(function() + callback(true, nil) + end) + end) + + assert.is_true(sender.flush_sync(500)) + assert.stub(socket_stub).was_called(1) + assert.equals(0, heartbeat.get_pending_count()) + end) + + it('should give up after the timeout', function() + heartbeat.requeue({ { entity = 'a' } }) + socket_stub = stub(socket, 'send_heartbeats') + + assert.is_false(sender.flush_sync(20)) + end) + end) + + describe('exit flush', function() + it('should register a VimLeavePre autocmd on start', function() + sender.start() + + local autocmds = vim.api.nvim_get_autocmds({ group = 'ShellTimeSender', event = 'VimLeavePre' }) + assert.equals(1, #autocmds) + + sender.stop() + end) + + it('should remove the autocmd on stop', function() + sender.start() + sender.stop() + + local ok = pcall(vim.api.nvim_get_autocmds, { group = 'ShellTimeSender' }) + assert.is_false(ok) + end) end) describe('is_connected', function() diff --git a/tests/system_spec.lua b/tests/system_spec.lua index ce5d1e0..485079a 100644 --- a/tests/system_spec.lua +++ b/tests/system_spec.lua @@ -56,6 +56,24 @@ describe('shelltime.utils.system', function() assert.equals('-', id:sub(19, 19)) assert.equals('-', id:sub(24, 24)) end) + + it('should fall back to math.random when OS randomness is unavailable', function() + local uv = vim.uv or vim.loop + local random_stub = require('luassert.stub')(uv, 'random') + random_stub.invokes(function() + error('not supported') + end) + + local ids = {} + for _ = 1, 100 do + local id = system.uuid() + assert.matches('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4%x%x%x%-[89ab]%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$', id) + assert.is_nil(ids[id], 'UUID collision detected') + ids[id] = true + end + + random_stub:revert() + end) end) describe('get_timestamp', function()