Skip to content

Latest commit

 

History

History
543 lines (422 loc) · 19.8 KB

File metadata and controls

543 lines (422 loc) · 19.8 KB

diffusion.nvim — Performance Analysis Report

Generated: 2026-02-22 Method: 5 parallel exploration agents + live log analysis (39,542-line session log) Log span: ~7 minutes of idle plugin activity


Executive Summary

The plugin has one overwhelming bottleneck responsible for ~95% of background CPU and I/O load, plus several secondary issues in the diff flow and event system. All are fixable with targeted changes — the most impactful require under 35 minutes total.

Key finding from live log analysis: 409 log entries/second, 43,733 lines in 7 minutes of normal usage. 12,411 TmuxService entries + 4,121 OpenCodeClient entries — all driven by a single architectural issue in the lualine statusline integration.

Priority Issue Impact Effort
P0 Lualine calls shell spawns every 500ms ~400 log lines/sec, 16k spawns/7min 30 min
P1 UI immersive mode event leak 3 handlers/toggle accumulate forever 15 min
P1 Gemini handler registration not guarded 3 duplicate handlers per reconnect 10 min
P2 get_status() calls is_same_tmux_window() live Each status check spawns tmux 30 min
P2 OpenCode is_available() creates client + calls lsof System-wide TCP scan per check 30 min
P2 Claude is_available() does synchronous file I/O Lock file parse + process check per call 15 min
P2 Selection change broadcasts on every CursorMoved 10-50 table allocs/sec + WebSocket msgs 30 min
P3 get_current_diff() is O(n×m) double loop Buffer lookup scans all diffs 30 min
P3 vim.tbl_keys() inside log statements Array allocation on hot event paths 20 min
P3 Streaming diff: full Myers regen per update O(n²) algo on every streaming character 45 min
P3 _cleanup_oldest_diff() is O(n) Linear scan when concurrent limit hit 30 min
P4 Buffer concat bug in WebSocket server Threshold condition is dead code 5 min
P4 Frame unmasking: byte-by-byte + modulo per byte Minor throughput loss on large frames 20 min
P4 vim.inspect() on every log with data 10-50ms for complex tables 15 min

P0: Lualine Statusline — The Dominant Bottleneck

Root Cause

File: lua/diffusion/lualine.lua:26 Setting: check_interval = 500 — calls diffusion.status() every 500ms per nvim instance.

The full call chain from each lualine redraw:

M.component() + M.get_color()
  → get_cached_status()                     [every 500ms, cache miss]
  → diffusion.status()
  → protocol:get_all_service_statuses()
  → for each handler: get_service_status(handler)
       → handler:is_available()             [OpenCode: new OpenCodeClient() + lsof!]
       → handler:get_status()              [Claude: is_same_tmux_window()]
            → tmux_service:find_claude_pane()
                 → sync_exec("tmux display-message -p '#S'")   [shell spawn]
                 → sync_exec("tmux list-windows -t SESSION")    [shell spawn]
                 → sync_exec("tmux list-panes -t SESSION:WIN")  [shell spawn × N windows]

With 4–5 concurrent nvim instances: ~24 shell processes/second continuously, even when no AI tool is active and Claude is not connected.

Evidence from Log

12,411  TmuxService entries in 7 minutes
 4,121  OpenCodeClient lsof entries in 7 minutes
43,733  total log lines
   409  entries per second (last 10s sample)

Sessions scanned on every call: Chippie:0, Chippie:1, Diffusion:0, Nexus:0, PrivCall:0 — all windows in all sessions, for every nvim instance, every 500ms.

Fix — Three Layers

Layer 1 — 5 minutes, 10× immediate reduction:

-- lua/diffusion/lualine.lua:26
-- BEFORE:
check_interval = 500,  -- ms

-- AFTER:
check_interval = 5000, -- ms

Layer 2 — 30 minutes, removes shell spawns from status path:

Remove is_same_tmux_window() from ClaudeHandler:get_status(). Cache it on connection events:

-- In ClaudeHandler constructor, add:
_tmux_same_window = false,

-- Call once on connect, not on every get_status():
function ClaudeHandler:_cache_tmux_state()
  self._tmux_same_window = self._tmux_service:find_claude_pane() ~= nil
end

-- In get_status(), replace live call with cached value:
-- BEFORE: same_window = self:is_same_tmux_window()
-- AFTER:  same_window = self._tmux_same_window

Layer 3 — 20 minutes, event-driven invalidation:

-- In lualine.lua M.setup():
local ok, diffusion = pcall(require, 'diffusion')
if ok and diffusion._events then
  diffusion._events:on('server:started', M.clear_cache)
  diffusion._events:on('service_switched', M.clear_cache)
end

Expected impact: ~95% reduction in shell spawning. From 24/sec to ~0.2/sec.


P1: Memory Leaks — Event Subscriptions

Leak 1: UI Immersive Mode Toggle (ui/init.lua:717)

Line 717 clears the tracking array with = {} without calling off() on existing handlers. The old handlers remain registered in the event system indefinitely.

-- CURRENT (broken — leaks 3 handlers per toggle):
function UI:_setup_immersive_mode()
  self._immersive_event_subscriptions = {}  -- clears array, NOT subscriptions!
  ...
  self._events:on('service_switched', handler1)
  self._events:on('diff_accepted', handler2)
  self._events:on('diff_rejected', handler3)
-- FIX:
function UI:_setup_immersive_mode()
  -- Unsubscribe previous handlers FIRST
  for _, sub in ipairs(self._immersive_event_subscriptions or {}) do
    self._events:off(sub.event, sub.handler)
  end
  self._immersive_event_subscriptions = {}
  -- then register new ones (existing code unchanged)

Compounding effect: After 10 toggles: 30 active handlers vs 3. After 100: 300 handlers, each firing on every service_switched, diff_accepted, and diff_rejected event.

Leak 2: Gemini Handler Registration (protocol/gemini.lua:_register_event_handlers)

No guard against multiple calls. When start() is called again on reconnection, 3 duplicate handlers are added to the event system without removing the old ones.

-- FIX — add guard at top of _register_event_handlers():
function GeminiHandler:_register_event_handlers()
  if self._handlers_registered then return end
  self._handlers_registered = true
  -- ... rest unchanged ...
end

-- Reset in stop() to allow re-registration on next start():
function GeminiHandler:stop()
  -- ... existing cleanup ...
  self._handlers_registered = false
end

P2: Expensive Operations in Status / Availability Checks

All three issues share the same trigger: protocol:get_all_service_statuses() calls both is_available() and get_status() for every registered handler. This is called from diffusion.status(), which lualine invokes every 500ms.

OpenCode is_available() — Fresh Client + lsof (protocol/opencode.lua:637-643)

-- CURRENT (lsof system scan on every call):
function OpenCodeHandler:is_available()
  local OpenCodeClient = require('diffusion.client.opencode_client')
  local temp_client = OpenCodeClient:new({}, self._events)
  local server, _ = temp_client:_find_server_in_cwd()  -- calls lsof internally
  return server ~= nil
end

-- FIX (use pre-computed connection state):
function OpenCodeHandler:is_available()
  return self._is_server_available or false
  -- Update _is_server_available in connect() / disconnect() callbacks
end

Claude is_available() — Synchronous File I/O (protocol/claude.lua:89-140)

Every call creates a new DiscoveryService, globs the lock directory, opens and JSON-decodes each lock file, and calls is_process_alive() (which spawns kill -0).

-- FIX — module-level cache with 5s TTL:
local _availability_cache = { value = nil, timestamp = 0, ttl = 5 }

function ClaudeHandler:is_available()
  local now = os.time()
  if _availability_cache.value ~= nil and
     (now - _availability_cache.timestamp) < _availability_cache.ttl then
    return _availability_cache.value
  end
  -- ... existing logic ...
  _availability_cache.value = result
  _availability_cache.timestamp = now
  return result
end

Claude get_status() — Live Tmux Discovery (protocol/claude.lua:592)

Covered in P0. The tmux block inside get_status() calls is_same_tmux_window() which spawns shell processes. Move to event-driven cached state (see P0 Fix Layer 2 above).

Selection Change — Table Allocation Per CursorMoved (protocol/claude.lua:757-779)

_handle_selection_change() allocates a new table and broadcasts a WebSocket message on every cursor movement. During active editing: 10–50 allocations/second + WebSocket traffic.

-- FIX — Part 1: pre-allocate message template in constructor:
self._selection_msg = { jsonrpc = "2.0", method = "selection_changed", params = {} }

-- FIX — Part 2: mutate in-place instead of allocating:
function ClaudeHandler:_handle_selection_change(data)
  if not self._server:has_clients() then return end
  self._selection_msg.params = data
  self._server:broadcast_message(self._selection_msg)
end

-- FIX — Part 3: debounce at 50ms (cursor settles before user cares):
-- Wrap the selection event emission in UI with a 50ms debounce timer

Expected impact: ~30–40% reduction in WebSocket message traffic during editing.


P3: diff/manager.lua — Remaining Algorithmic Issues

Note: Stage 1 fixes are confirmed complete. _active_diff_count counter, _file_to_diff_ids index, and _call_id_map are all present and working.

get_current_diff() — O(n×m) Double Loop (diff/manager.lua:484-492)

-- CURRENT: scans all diffs, then all buffers per diff
for diff_id, diff_entry in pairs(self._active_diffs) do
  if diff_entry.buffers then
    for _, bufnr in pairs(diff_entry.buffers) do
      if bufnr == current_buf then return diff_entry end
    end
  end
end
-- FIX — add _buf_to_diff_id index:
-- In show_diff(), after buffers are created:
for _, bufnr in pairs(diff_entry.buffers) do
  self._buf_to_diff_id[bufnr] = diff_id
end

-- In dismiss_diff() cleanup:
for _, bufnr in pairs(diff_entry.buffers or {}) do
  self._buf_to_diff_id[bufnr] = nil
end

-- get_current_diff() becomes O(1):
function DiffManager:get_current_diff()
  local current_buf = vim.api.nvim_get_current_buf()
  local diff_id = self._buf_to_diff_id[current_buf]
  return diff_id and self._active_diffs[diff_id] or nil
end

vim.tbl_keys() Inside Log Statements (diff/manager.lua:139, 769)

These create temporary arrays that are evaluated regardless of log level:

-- Line 769 — called on every diff:user_response event:
active_diffs = vim.tbl_keys(self._active_diffs),  -- O(n) alloc even when INFO suppresses

-- FIX: use the O(1) counter instead:
active_diff_count = self._active_diff_count,

Line 139 in the show_diff() hot path:

-- BEFORE:
config_keys = vim.tbl_keys(self._config or {}),

-- AFTER — just omit it, or guard behind level check

_cleanup_oldest_diff() — O(n) Scan (diff/manager.lua:660-674)

-- CURRENT: linear scan for minimum created_at
for diff_id, diff_entry in pairs(self._active_diffs) do
  if diff_entry.created_at < oldest_time then ...
  end
end
-- FIX — maintain creation order array:
-- In show_diff(): table.insert(self._diff_creation_order, diff_id)
-- In dismiss_diff(): remove from array
-- In _cleanup_oldest_diff():
local oldest_id = self._diff_creation_order[1]
if oldest_id then self:dismiss_diff(oldest_id) end

Note: The concurrent limit is typically 3–10, so the O(n) scan completes in <1ms in practice. This is low-priority unless the limit is raised above ~20.

Streaming Diff — Full Myers Algorithm Per Character (diff/display/unified.lua:198)

Every streaming content update calls diff_engine:generate_unified(), which runs the Myers diff algorithm (O(n²) worst case) on the full content. For a 100-character streaming update: 100 algorithm runs instead of 1.

-- FIX — debounce diff regeneration, show content immediately:
function UnifiedDisplay:update(diff_entry, update_params)
  if update_params.new_content then
    diff_entry.new_content = update_params.new_content

    -- Immediately update buffer content (cheap)
    local lines = vim.split(update_params.new_content, "\n", { plain = true })
    vim.api.nvim_buf_set_lines(diff_entry.buffers.unified_bufnr, 0, -1, false, lines)

    -- Debounce the expensive diff regeneration
    if diff_entry._regen_timer then
      diff_entry._regen_timer:stop()
      diff_entry._regen_timer:close()
    end
    diff_entry._regen_timer = vim.loop.new_timer()
    diff_entry._regen_timer:start(150, 0, vim.schedule_wrap(function()
      diff_entry._regen_timer = nil
      local unified = diff_engine:generate_unified(
        diff_entry.old_content or "",
        diff_entry.new_content
      )
      self:_apply_unified_highlighting(diff_entry.buffers.unified_bufnr, unified.hunks)
    end))
  end
end

Expected impact: 10-character stream: 10 regen calls → 1. 95% reduction.

Blocking Git Diff — vim.fn.system() (diff/display/unified.lua:286)

Direct synchronous call: 15–35ms of blocked editor time per diff creation. discovery.lua already has the correct async pattern but unified.lua doesn't follow it.

-- FIX — use vim.system() async (Neovim 0.10+) with fallback:
if vim.system then
  vim.system({"git", "diff", "--no-index", old_file, new_file},
    { timeout = 30000 },
    function(result)
      vim.schedule(function()
        callback(result.stdout, result.code)
      end)
    end)
else
  -- Fallback: at minimum wrap in vim.schedule to avoid blocking
  vim.schedule(function()
    local output = vim.fn.system(...)
    callback(output, vim.v.shell_error)
  end)
end

P4: WebSocket and Protocol Polish

Buffer Concatenation Bug (server/websocket_server.lua:417-421)

The threshold condition is dead code — both branches do the same thing:

-- CURRENT: threshold is meaningless
if #client.buffer_parts > 10 then
  client.buffer = table.concat(client.buffer_parts)   -- concat
  client.buffer_parts = {client.buffer}
else
  client.buffer = table.concat(client.buffer_parts)   -- ALSO concat!
end

-- FIX: only consolidate when over threshold
if #client.buffer_parts > 10 then
  client.buffer = table.concat(client.buffer_parts)
  client.buffer_parts = {client.buffer}
end
-- Otherwise: leave parts as-is until threshold is reached

Impact: Eliminates 50–80% of redundant O(n) concatenations on typical message flows.

Frame Unmasking — Repeated Modulo (server/frame.lua:136-167)

Both frame.lua and websocket_server.lua unmask byte-by-byte with ((i-1) % 4) + 1 per iteration. For a 10KB WebSocket frame: 10,000 modulo operations.

-- FIX — unroll the loop to eliminate modulo:
local m1, m2, m3, m4 = mask_key:byte(1, 4)
for i = 1, len, 4 do
  if i   <= len then result[i]   = bit.bxor(payload:byte(i),   m1) end
  if i+1 <= len then result[i+1] = bit.bxor(payload:byte(i+1), m2) end
  if i+2 <= len then result[i+2] = bit.bxor(payload:byte(i+2), m3) end
  if i+3 <= len then result[i+3] = bit.bxor(payload:byte(i+3), m4) end
end

Impact: 5–10% throughput improvement for large frames (1–10KB).

OpenCode Curl Calls — Blocking (client/opencode_client.lua:230, 796, 804, 824)

All four curl HTTP calls to the OpenCode API are synchronous: 50–200ms each, freezing the editor. Convert to vim.system() async with timeout (same pattern as discovery.lua:18-26).

vim.inspect() on Every Logged Data Table (utils/logger.lua:317)

_format_message() calls vim.inspect(data, {indent=" ", depth=3}) for every log entry with a data table — including INFO-level logs. For complex nested structures: 10–50ms per call.

-- FIX — cap depth and limit for hot-path logs:
-- In _format_message(), add a fast path for shallow tables:
if entry.data then
  local ok, inspected = pcall(vim.inspect, entry.data, { depth = 2, indent = "" })
  parts[4] = ok and inspected or tostring(entry.data)
end

Method Dispatch: if-elseif → Hash Table (protocol/claude/messages.lua)

The current O(n) if-elseif chain for ~8 message types is negligible in practice but a hash table is cleaner and more maintainable:

local METHOD_HANDLERS = {
  initialize                    = M._handle_initialize,
  ["tools/list"]                = M._handle_tools_list,
  ["tools/call"]                = M._handle_tool_call,
  ["notifications/initialized"] = M._handle_initialized_notification,
}

function M._handle_websocket_message(self, client_id, message)
  self._stats.messages_received = self._stats.messages_received + 1
  local handler = METHOD_HANDLERS[message.method]
  if handler then
    handler(self, client_id, message)
  else
    self._logger:warn("Unknown MCP method", { method = message.method })
  end
end

Event System Assessment

The event system itself is well-implemented:

  • emit() is synchronous — no vim.schedule() overhead on the hot path
  • emit_async() wraps all listeners in a single vim.schedule() call, not one per listener
  • Logger level guards execute before any string formatting or table serialization
  • pcall error isolation is used correctly in _call_listeners()

The problems are in callers of the event system, not the system itself.


Implementation Order

IMMEDIATE — 35 minutes total, ~95% of background load eliminated:

  1. lualine.lua:26    check_interval: 500 → 5000ms               [5 min]
  2. websocket_server.lua:417-421  remove redundant else branch    [5 min]
  3. ui/init.lua:717   call off() before clearing array            [15 min]
  4. protocol/gemini.lua  add _handlers_registered guard           [10 min]

SHORT TERM — 3-4 hours, removes remaining hot-path issues:

  5. protocol/claude.lua:get_status()   cache tmux window state    [30 min]
  6. protocol/opencode.lua:is_available()  use stored state        [30 min]
  7. protocol/claude.lua:is_available()   add 5s TTL cache         [15 min]
  8. protocol/claude.lua:_handle_selection_change()
     pre-allocate table + 50ms debounce                            [30 min]
  9. diff/display/unified.lua:198  debounce streaming regen        [45 min]
  10. diff/manager.lua  add _buf_to_diff_id index                  [30 min]
  11. diff/display/unified.lua:286  async vim.system() for git     [30 min]

MEDIUM TERM — 2-3 hours, polish:

  12. opencode_client.lua curl calls → async vim.system()          [45 min]
  13. messages.lua  method dispatch hash table                      [20 min]
  14. lualine.lua  event-driven cache invalidation                  [20 min]
  15. frame.lua  loop unrolling for mask XOR                        [20 min]
  16. utils/logger.lua  guard vim.inspect behind level check        [15 min]
  17. diff/manager.lua  _diff_creation_order for O(1) oldest       [20 min]

Reference: Already Fixed (Stage 1)

The following are confirmed resolved and do not need attention:

Fix Location What Changed
vim.tbl_count() replaced diff/manager.lua _active_diff_count counter used throughout
File→diff index diff/manager.lua _file_to_diff_ids for O(1) file lookup
Call ID index diff/manager.lua _call_id_map for O(1) provider call lookup
Deferred response safety protocol/claude/diff.lua xpcall wrapping in vim.schedule callback
Navigation double-jump diff/navigation.lua Single atomic cursor positioning
Navigation listener leak diff/navigation.lua _event_subscriptions tracking + cleanup()
Logger early level check utils/logger.lua Guard before string formatting
Workspace directory cache services/discovery.lua 30s TTL cache for git rev-parse
Async git workspace lookup services/discovery.lua vim.system() with fallback

Report generated from live log analysis + 5 parallel codebase exploration agents. Previous report (300 lines) superseded by this document.