Skip to content

perf(lsp): debounce didChange notifications to reduce processing#86

Merged
mpiton merged 3 commits into
mainfrom
feature/debounce-did-change
Jan 8, 2026
Merged

perf(lsp): debounce didChange notifications to reduce processing#86
mpiton merged 3 commits into
mainfrom
feature/debounce-did-change

Conversation

@mpiton
Copy link
Copy Markdown
Owner

@mpiton mpiton commented Jan 8, 2026

Summary

Add debouncing to textDocument/didChange handler to avoid excessive processing during rapid typing. This significantly reduces network requests, CPU usage, and respects rate limits.

Key Changes

  • ProcessingContext struct: Cloneable struct holding all Arc-wrapped state needed for async document processing
  • Per-URI debounce tracking: Each document URI has its own debounce task that can be cancelled independently
  • Configurable delay: New debounce_ms config option in CacheConfig (default: 200ms)
  • Save bypasses debounce: did_save cancels pending debounce and processes immediately
  • Proper cleanup: did_close cancels pending tasks and cleans up state

Performance Improvement

Before (current behavior):

  • Typing "express" (7 letters) → 7 network requests, 7 parses, 7 vulnerability checks
  • CPU spikes on every keystroke
  • Risk of rate limiting (crates.io: 1 req/s)

After (with debouncing):

  • Typing "express" → 1 network request, 1 parse, 1 vulnerability check
  • CPU only used after typing stops
  • Rate limits respected

Result: ~84% reduction in network requests and CPU usage during rapid typing.

Configuration

The debounce delay is configurable via LSP initialization options:

{
  "cache": {
    "debounce_ms": 200
  }
}

Test Plan

  • All existing unit tests pass (235 tests)
  • All integration tests pass (7 tests)
  • Clippy passes with no warnings
  • Code formatting verified
  • Release build succeeds
  • Manual testing with rapid typing in Cargo.toml
  • Verify document save triggers immediate processing
  • Verify document close cancels pending tasks

Closes #73

Summary by CodeRabbit

  • New Features

    • Debounced per-document change processing with configurable debounce_ms (default 200ms); saves bypass debounce for immediate processing.
    • Background vulnerability checks and concurrency-limited registry lookups to improve responsiveness.
  • Refactor

    • Centralized async processing and shared state to improve reliability of diagnostics, inlay hints, and change handling.

✏️ Tip: You can customize this high-level summary in your review settings.

Add debouncing to textDocument/didChange handler to avoid excessive
processing during rapid typing. This significantly reduces network
requests, CPU usage, and respects rate limits.

Key changes:
- Add ProcessingContext struct for cloneable async processing state
- Implement per-URI debounce tracking with configurable delay
- Add debounce_ms config option (default: 200ms)
- Cancel pending debounce on document save (immediate processing)
- Clean up debounce tasks on document close

Performance improvement: ~84% reduction in network requests during
rapid typing (e.g., typing "express" now triggers 1 request instead
of 7).

Closes #73
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 8, 2026

Warning

Rate limit exceeded

@mpiton has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 42 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 4b935dc and ba7de96.

📒 Files selected for processing (3)
  • coverage/tarpaulin-report.html
  • coverage/tarpaulin-report.json
  • dependi-lsp/src/backend.rs
📝 Walkthrough

Walkthrough

Debounce was added for per-URI didChange handling with a configurable delay (default 200ms). A private ProcessingContext centralizes async document parsing and dependency resolution. Backend state and registries/parsers are Arc-wrapped for safe sharing with spawned debounce and processing tasks.

Changes

Cohort / File(s) Summary
Debounce & Async Processing
dependi-lsp/src/backend.rs
Added private ProcessingContext and create_processing_context. Reworked DependiBackend fields to Arc<...>. Introduced debounce_tasks and pending_changes. Reimplemented did_change with per-URI debounce spawn/cancel, did_save to bypass debounce, and did_close to cancel/clear. Centralized async processing via ProcessingContext::process_document.
Registry & Concurrency
dependi-lsp/src/backend.rs (same file, related changes)
Added shared HTTP client usage, a 5-task concurrency semaphore for registry fetches, per-registry caching in processing flow, and immediate document state updates followed by diagnostics/inlay hints and background vulnerability checks.
Configuration
dependi-lsp/src/config.rs
Added cache.debounce_ms: u64 with default DEFAULT_DEBOUNCE_MS = 200. Tests updated to assert new default.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant LSPHandler
    participant DebounceMap
    participant ProcessingContext
    participant RegistryLayer
    participant VulnerabilityService

    Client->>LSPHandler: textDocument/didChange(uri, content)
    LSPHandler->>DebounceMap: store pending_changes[uri]=content
    LSPHandler->>DebounceMap: abort existing debounce_tasks[uri]
    LSPHandler->>DebounceMap: spawn debounce task (delay = config.cache.debounce_ms)
    DebounceMap->>DebounceMap: wait delay
    DebounceMap->>LSPHandler: (on delay) validate latest pending content
    LSPHandler->>ProcessingContext: process_document(uri, content)
    ProcessingContext->>RegistryLayer: resolve dependencies (bounded concurrency)
    RegistryLayer->>ProcessingContext: return resolved data
    ProcessingContext->>VulnerabilityService: schedule/check vulnerabilities (background)
    ProcessingContext->>LSPHandler: update document state & publish diagnostics/inlays

    Note over Client,LSPHandler: textDocument/didSave bypasses debounce and calls ProcessingContext immediately
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰
I debounce hops through code and light,
Per-URI timers keep edits right,
I cancel old tasks, let new ones run,
Five tasks fetch, then diagnostics done,
Two hundred ms — a gentle byte delight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: implementing debouncing for didChange notifications to improve performance by reducing processing on every keystroke.
Linked Issues check ✅ Passed The PR implements all core coding requirements from issue #73: per-URI debounce with JoinHandle abort, configurable 200ms default via CacheConfig.debounce_ms, cancellation on new didChange, immediate processing on did_save, Arc-wrapped cloneable state, and ProcessingContext for async task access.
Out of Scope Changes check ✅ Passed All changes are scoped to debouncing implementation: ProcessingContext extraction (refactoring for testability), Arc-wrapping backend fields (enabling cloneability), debounce task tracking, and config addition. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mpiton mpiton mentioned this pull request Jan 8, 2026
17 tasks
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @dependi-lsp/src/backend.rs:
- Around line 85-236: The PR has two duplicated implementations of the same
logic: ProcessingContext::process_document and DependiBackend::process_document;
remove the duplication by making DependiBackend::process_document delegate to
ProcessingContext::process_document (or vice‑versa) so there is a single
implementation. Modify DependiBackend::process_document to construct/obtain a
ProcessingContext (or pass &self into an existing context) and call
ProcessingContext::process_document(uri, content). Move any backend-specific
state access (e.g., self.crates_io, self.version_cache, self.client,
self.config, osv_client, vuln_cache) into ProcessingContext so the delegated
function has the required Arcs/clones, and update any callers to rely on the
single method; then delete the redundant block to eliminate the ~150-line
duplicate.
🧹 Nitpick comments (1)
dependi-lsp/src/backend.rs (1)

71-83: Consider extracting shared parsing logic to reduce duplication.

The parse_document method duplicates the exact logic from DependiBackend::parse_document (lines 351-363). Consider extracting this into a standalone function that both can call, or having ProcessingContext::parse_document delegate to a shared implementation.

♻️ Potential refactor to eliminate duplication

One approach is to make the parsing logic a standalone function:

+fn parse_document_internal(
+    uri: &Url,
+    content: &str,
+    cargo_parser: &CargoParser,
+    npm_parser: &NpmParser,
+    python_parser: &PythonParser,
+    go_parser: &GoParser,
+    php_parser: &PhpParser,
+    dart_parser: &DartParser,
+    csharp_parser: &CsharpParser,
+    ruby_parser: &RubyParser,
+) -> Vec<crate::parsers::Dependency> {
+    match FileType::detect(uri) {
+        Some(FileType::Cargo) => cargo_parser.parse(content),
+        Some(FileType::Npm) => npm_parser.parse(content),
+        Some(FileType::Python) => python_parser.parse(content),
+        Some(FileType::Go) => go_parser.parse(content),
+        Some(FileType::Php) => php_parser.parse(content),
+        Some(FileType::Dart) => dart_parser.parse(content),
+        Some(FileType::Csharp) => csharp_parser.parse(content),
+        Some(FileType::Ruby) => ruby_parser.parse(content),
+        None => vec![],
+    }
+}
+
 impl ProcessingContext {
     fn parse_document(&self, uri: &Url, content: &str) -> Vec<crate::parsers::Dependency> {
-        match FileType::detect(uri) {
-            Some(FileType::Cargo) => self.cargo_parser.parse(content),
-            Some(FileType::Npm) => self.npm_parser.parse(content),
-            Some(FileType::Python) => self.python_parser.parse(content),
-            Some(FileType::Go) => self.go_parser.parse(content),
-            Some(FileType::Php) => self.php_parser.parse(content),
-            Some(FileType::Dart) => self.dart_parser.parse(content),
-            Some(FileType::Csharp) => self.csharp_parser.parse(content),
-            Some(FileType::Ruby) => self.ruby_parser.parse(content),
-            None => vec![],
-        }
+        parse_document_internal(
+            uri, content,
+            &self.cargo_parser, &self.npm_parser, &self.python_parser,
+            &self.go_parser, &self.php_parser, &self.dart_parser,
+            &self.csharp_parser, &self.ruby_parser,
+        )
     }

Then apply similar changes to DependiBackend::parse_document.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 21378d3 and edb4b85.

📒 Files selected for processing (2)
  • dependi-lsp/src/backend.rs
  • dependi-lsp/src/config.rs
🧰 Additional context used
🧬 Code graph analysis (2)
dependi-lsp/src/config.rs (8)
dependi-lsp/src/cache/mod.rs (2)
  • default (116-118)
  • default (245-247)
dependi-lsp/src/registries/npm.rs (1)
  • default (113-115)
dependi-lsp/src/registries/crates_io.rs (1)
  • default (144-146)
dependi-lsp/src/registries/pub_dev.rs (1)
  • default (112-114)
dependi-lsp/src/registries/nuget.rs (1)
  • default (117-119)
dependi-lsp/src/vulnerabilities/cache.rs (1)
  • default (186-188)
dependi-lsp/src/cache/sqlite.rs (1)
  • default (40-49)
dependi-lsp/src/vulnerabilities/osv.rs (1)
  • default (80-82)
dependi-lsp/src/backend.rs (10)
dependi-lsp/src/file_types.rs (2)
  • detect (39-64)
  • cache_key (86-97)
dependi-lsp/src/parsers/cargo.rs (1)
  • new (12-14)
dependi-lsp/src/parsers/csharp.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/ruby.rs (1)
  • new (15-17)
dependi-lsp/src/parsers/dart.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/go.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/npm.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/php.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/python.rs (1)
  • new (10-12)
dependi-lsp/src/providers/diagnostics.rs (1)
  • create_diagnostics (15-77)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Code coverage
  • GitHub Check: security-audit
  • GitHub Check: test
  • GitHub Check: Analyze (rust)
🔇 Additional comments (6)
dependi-lsp/src/backend.rs (5)

42-68: LGTM! Well-designed context struct for debounce tasks.

The ProcessingContext struct appropriately wraps all necessary state in Arc for safe sharing across async debounce tasks, and the documentation clearly explains its purpose.


238-271: LGTM! Appropriate Arc-wrapping for concurrent access.

The Arc-wrapping of fields and addition of debounce_tasks and pending_changes maps correctly support per-URI debounce tracking with safe concurrent access.


823-877: Debounce implementation is sound with good race condition handling.

The debouncing logic correctly:

  • Cancels previous tasks before spawning new ones
  • Verifies content hasn't changed before processing (line 859-862)
  • Cleans up task handles after completion

One minor edge case: if did_save is called while a debounce task is already executing process_document (past the cancellation point), both will process the document. This is acceptable since processing is idempotent, but you may want to add a comment documenting this behavior.

Optional: Consider adding a comment to document the idempotency requirement:

// Note: If did_save is called while a debounce task is already processing,
// both will complete. This is acceptable as processing is idempotent.

879-895: LGTM! Correct implementation of save bypassing debounce.

The logic properly cancels any pending debounce task, cleans up pending state, and processes immediately as expected for save operations.


897-911: LGTM! Proper cleanup prevents memory leaks.

The close handler correctly cancels pending tasks and cleans up all associated state, preventing memory leaks from abandoned debounce tasks.

dependi-lsp/src/config.rs (1)

61-72: LGTM! Well-documented configuration field with sensible default.

The 200ms default debounce delay strikes a good balance between responsiveness and reducing unnecessary processing. The field is properly documented and follows the existing configuration patterns.

Comment thread dependi-lsp/src/backend.rs
Remove ~160 lines of duplicate code by having DependiBackend::process_document
delegate to ProcessingContext::process_document. The ProcessingContext now
contains the single canonical implementation for document processing.

Changes:
- Remove DependiBackend::parse_document (use ProcessingContext::parse_document)
- Replace DependiBackend::process_document body with delegation to context
- ProcessingContext remains the single source of truth for processing logic
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @dependi-lsp/src/backend.rs:
- Around line 694-715: The debounce task cleanup races because tasks remove the
entry unconditionally; fix by storing a generation id with each task handle
(change debounce_tasks to map URI -> (u64, JoinHandle<_>)), increment a
generation on each did_change spawn, capture that generation in the spawned
task, and when the task finishes only remove the entry if the stored generation
still equals the captured generation (i.e., compare the tuple's generation
before removing). Also update the abort/update path to destructure the tuple and
compare generations when aborting/replacing so you only abort the matching
generation; ensure all uses of debounce_tasks insert, remove and abort handle
the (gen, handle) tuple consistently and capture gen in the task closure.
🧹 Nitpick comments (1)
dependi-lsp/src/backend.rs (1)

160-162: Consider logging task failures.

The error from handle.await is silently ignored. While best-effort fetching is reasonable, logging join errors would help diagnose panics or task failures during development.

📊 Optional: Add debug logging for task failures
 for handle in handles {
-    let _ = handle.await;
+    if let Err(e) = handle.await {
+        tracing::debug!("Registry fetch task failed: {}", e);
+    }
 }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between edb4b85 and 4b935dc.

📒 Files selected for processing (1)
  • dependi-lsp/src/backend.rs
🧰 Additional context used
🧬 Code graph analysis (1)
dependi-lsp/src/backend.rs (9)
dependi-lsp/src/file_types.rs (2)
  • detect (39-64)
  • cache_key (86-97)
dependi-lsp/src/parsers/cargo.rs (1)
  • new (12-14)
dependi-lsp/src/parsers/ruby.rs (1)
  • new (15-17)
dependi-lsp/src/parsers/dart.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/csharp.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/go.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/npm.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/php.rs (1)
  • new (10-12)
dependi-lsp/src/parsers/python.rs (1)
  • new (10-12)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (rust)
🔇 Additional comments (8)
dependi-lsp/src/backend.rs (8)

42-68: Well-designed context struct for async processing.

The ProcessingContext encapsulates all necessary state with Arc-wrapped references, enabling efficient cloning for debounced tasks. The design properly separates concerns and avoids expensive deep copies.


85-162: Solid concurrency control and processing pipeline.

The implementation correctly:

  • Limits concurrent registry fetches to 5 using a semaphore
  • Updates document state and diagnostics immediately before background vulnerability checks
  • Properly clones Arc references for async tasks

238-271: Appropriate Arc-wrapping for debounce support.

The field updates correctly introduce Arc-wrapping for shared state and add the necessary debounce tracking structures. Documentation clearly explains the purpose of each change.


287-322: Constructor correctly initializes Arc-wrapped fields.

All parsers, config, and documents are properly wrapped in Arc, and the new debounce tracking structures are initialized as empty DashMaps.


324-349: Clean helper for creating processing context.

The method efficiently creates a ProcessingContext by cloning Arc references, which is a cheap operation (only reference count increments).


719-735: did_save correctly bypasses debounce for immediate processing.

The implementation properly cancels any pending debounce task and processes the document immediately on save, which aligns with user expectations (save should be synchronous).


737-751: did_close correctly cancels debounce and cleans up state.

The cleanup logic properly cancels pending debounce tasks, removes pending changes, clears document state, and publishes empty diagnostics.


490-494: Clean delegation eliminates duplication.

The refactoring successfully centralizes document processing logic in ProcessingContext, removing the ~160 lines of duplication mentioned in the PR objectives.

Comment thread dependi-lsp/src/backend.rs Outdated
Add generation IDs to debounce tasks to prevent racing cleanup.

The issue: when task A is aborted and task B spawns for the same URI,
task A's cleanup code could still run and remove task B's handle from
the map, orphaning task B.

Solution:
- Store (generation, JoinHandle) tuples in debounce_tasks map
- Increment global generation counter on each task spawn
- Capture generation in spawned task closure
- Only remove entry on cleanup if generation matches (remove_if)
- Update did_save/did_close to destructure the new tuple format

This ensures only the task that was actually spawned for an entry
can clean it up.
@mpiton mpiton merged commit b7e0397 into main Jan 8, 2026
8 checks passed
@mpiton mpiton deleted the feature/debounce-did-change branch January 10, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: Debounce didChange notifications

1 participant