Skip to content

Commit b5e0075

Browse files
authored
feat(lsp): track document versions and include in publish_diagnostics (#544)
* feat(lsp): track document versions and include in publish_diagnostics (#478) Add a document_versions map to the LSP Backend that tracks the version number reported by the client on did_open and did_change events. The tracked version is passed to publish_diagnostics instead of None, allowing editors to correctly associate diagnostics with the document revision they were computed against. - Add document_versions field to Backend struct - Store version on did_open and did_change, remove on did_close - Add get_document_version helper method - Replace None with tracked version in validate_from_content_and_publish - Keep None for did_close (clearing diagnostics for closed documents) - Add 5 unit tests and 1 integration test for version lifecycle * fix(lsp): address review findings for document version tracking (#478) * fix(lsp): atomize version+content updates, fix version tracking on empty changes (#478) - Hold both documents and document_versions write locks simultaneously in handle_did_open and handle_did_change so readers never see a state where content is updated but version is not (or vice versa). - Always update document version on did_change regardless of whether content_changes is empty, aligning with LSP spec where VersionedTextDocumentIdentifier.version is authoritative. - Restore get_document_version to pub(crate) visibility and remove duplicate integration test (unit tests in backend/tests.rs cover direct version access). - Fix stale comment in integration test to accurately describe the test's approach. * fix(lsp): correct misleading comment about lock drop order * docs: add changelog entry for LSP document version tracking (#478) * fix(lsp): apply rustfmt formatting to LSP backend code Fixes formatting issues detected in CI Format Check. * fix(lsp): address race condition and misleading comments in version tracking Addresses three Copilot review comments: 1. Fixed TOCTOU race in validate_from_content_and_publish: capture version at the same time as content snapshot to ensure diagnostics are published with the version matching the content they were validated against, not a newer version from concurrent did_change events. 2. Corrected misleading comments in handle_did_open/did_change about lock guarantees. The comments suggested locks prevent readers from seeing inconsistency, but that's only true if readers atomically capture both values together (which they now do). 3. Clarified integration test comment to explain why it doesn't assert version field in publish_diagnostics (requires consuming socket messages). Referenced unit tests in backend/tests.rs that verify version lifecycle.
1 parent 5c0adb3 commit b5e0075

7 files changed

Lines changed: 471 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6363
- **`LintConfig` cheap cloning**: Introduced `Arc<ConfigData>` inner struct to hold all serializable fields. Cloning a `LintConfig` (e.g., in `validate_project` / `validate_project_with_registry` parallel dispatch) now bumps an `Arc` refcount instead of deep-copying `Vec<String>` fields and nested structs. Mutations use `Arc::make_mut` for copy-on-write semantics, so the allocation only occurs when the `Arc` is actually shared (#467)
6464

6565
### Fixed
66+
- **LSP document version tracking**: The LSP backend now tracks document versions reported by the client (`did_open`, `did_change`) and includes them in all `publish_diagnostics` calls. Editors that inspect diagnostic version tags (e.g., for stale-result suppression) now receive accurate version numbers instead of `None`. Version and content updates are atomized under a single lock acquisition so readers never observe a state where content and version are out of sync. Empty `did_change` notifications (no content changes) also correctly advance the tracked version per the LSP spec (#478)
6667
- **Frontmatter leading newline stripped**: `split_frontmatter()` no longer includes the newline that follows the opening `---` delimiter in the extracted frontmatter string. Downstream validators (`AgentValidator`, `AmpValidator`, `KiroSteeringValidator`) have been updated to compute correct 1-based line numbers; diagnostic line numbers for AMP-001, CC-AG-007, and KIRO-001 through KIRO-004 are now accurate (#482)
6768
- **Empty-frontmatter panic guard**: `split_frontmatter()` now uses `str::get()` instead of direct slice indexing when extracting frontmatter content, preventing an index-out-of-bounds panic on files with an opening `---` delimiter but no content (#482)
6869
- **Predictable UUID Generation for Telemetry**: Replaced the custom, insecure random number generator with a cryptographically secure pseudo-random number generator (CSPRNG) using the `uuid` crate. Ensures telemetry installation IDs are unpredictable and unique.

crates/agnix-lsp/src/backend.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ pub struct Backend {
5353
/// Canonicalized workspace root cached at initialize() to avoid blocking I/O on hot paths.
5454
pub(crate) workspace_root_canonical: Arc<RwLock<Option<PathBuf>>>,
5555
pub(crate) documents: Arc<RwLock<HashMap<Url, Arc<String>>>>,
56+
/// Tracks the latest document version from the client (did_open / did_change).
57+
/// Used to tag published diagnostics with the version they were computed against.
58+
pub(crate) document_versions: Arc<RwLock<HashMap<Url, i32>>>,
5659
/// Monotonic generation incremented on each config change.
5760
/// Used to drop stale diagnostics from older revalidation batches.
5861
pub(crate) config_generation: Arc<AtomicU64>,
@@ -78,6 +81,7 @@ impl Backend {
7881
workspace_root: Arc::new(RwLock::new(None)),
7982
workspace_root_canonical: Arc::new(RwLock::new(None)),
8083
documents: Arc::new(RwLock::new(HashMap::new())),
84+
document_versions: Arc::new(RwLock::new(HashMap::new())),
8185
config_generation: Arc::new(AtomicU64::new(0)),
8286
project_validation_generation: Arc::new(AtomicU64::new(0)),
8387
registry: Arc::new(agnix_core::ValidatorRegistry::with_defaults()),
@@ -188,19 +192,23 @@ impl Backend {
188192
let config = self.config.load();
189193
let file_type = agnix_core::resolve_file_type(&file_path, &config);
190194
if file_type.is_generic() {
195+
// Read version just-in-time to minimize TOCTOU window
196+
let version = self.get_document_version(&uri).await;
191197
// Publish empty diagnostics to clear any stale results
192-
self.client.publish_diagnostics(uri, vec![], None).await;
198+
self.client.publish_diagnostics(uri, vec![], version).await;
193199
return;
194200
}
195201
}
196202

197-
// Get content from cache
198-
let (content, expected_content) = {
203+
// Get content from cache and capture version at same time to avoid TOCTOU
204+
// between content validation and version publish
205+
let (content, expected_content, captured_version) = {
199206
let docs = self.documents.read().await;
200207
match docs.get(&uri) {
201208
Some(cached) => {
202209
let snapshot = Arc::clone(cached);
203-
(Arc::clone(&snapshot), Some(snapshot))
210+
let version = self.get_document_version(&uri).await;
211+
(Arc::clone(&snapshot), Some(snapshot), version)
204212
}
205213
None => {
206214
// Fall back to file-based validation
@@ -212,8 +220,10 @@ impl Backend {
212220
{
213221
return;
214222
}
223+
// Read version just-in-time to minimize TOCTOU window
224+
let version = self.get_document_version(&uri).await;
215225
self.client
216-
.publish_diagnostics(uri, diagnostics, None)
226+
.publish_diagnostics(uri, diagnostics, version)
217227
.await;
218228
return;
219229
}
@@ -259,8 +269,11 @@ impl Backend {
259269
return;
260270
}
261271

272+
// Use version captured at time of content snapshot to avoid publishing
273+
// newer version with older (already-validated) diagnostics
274+
let version = captured_version;
262275
self.client
263-
.publish_diagnostics(uri, diagnostics, None)
276+
.publish_diagnostics(uri, diagnostics, version)
264277
.await;
265278
}
266279
}

crates/agnix-lsp/src/backend/events.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use super::*;
44

55
impl Backend {
66
pub(crate) async fn handle_did_open(&self, params: DidOpenTextDocumentParams) {
7+
let version = params.text_document.version;
78
let uri = params.text_document.uri;
89
// Normalize CRLF so the cached content matches the LF-relative byte offsets
910
// produced by validate_content and used by code actions for fix ranges.
@@ -13,14 +14,21 @@ impl Backend {
1314
std::borrow::Cow::Borrowed(_) => raw,
1415
std::borrow::Cow::Owned(normalized) => normalized,
1516
};
17+
// Acquire both locks atomically to update content and version together.
18+
// Readers that need both values must capture them in a single operation
19+
// (see validate_from_content_and_publish).
1620
{
1721
let mut docs = self.documents.write().await;
22+
let mut versions = self.document_versions.write().await;
1823
docs.insert(uri.clone(), Arc::new(text));
24+
versions.insert(uri.clone(), version);
25+
// Both guards dropped here in reverse acquisition order (versions then docs)
1926
}
2027
self.validate_from_content_and_publish(uri, None).await;
2128
}
2229

2330
pub(crate) async fn handle_did_change(&self, params: DidChangeTextDocumentParams) {
31+
let version = params.text_document.version;
2432
let uri = params.text_document.uri;
2533
if let Some(change) = params.content_changes.into_iter().next() {
2634
// Normalize CRLF so the cached content matches the LF-relative byte offsets
@@ -31,11 +39,21 @@ impl Backend {
3139
std::borrow::Cow::Borrowed(_) => raw,
3240
std::borrow::Cow::Owned(normalized) => normalized,
3341
};
42+
// Acquire both locks atomically to update content and version together.
43+
// Readers that need both values must capture them in a single operation
44+
// (see validate_from_content_and_publish).
3445
{
3546
let mut docs = self.documents.write().await;
47+
let mut versions = self.document_versions.write().await;
3648
docs.insert(uri.clone(), Arc::new(text));
49+
versions.insert(uri.clone(), version);
50+
// Both guards dropped here in reverse acquisition order (versions then docs)
3751
}
3852
self.validate_from_content_and_publish(uri, None).await;
53+
} else {
54+
// Even when content_changes is empty, the version from
55+
// VersionedTextDocumentIdentifier is authoritative per LSP spec.
56+
self.document_versions.write().await.insert(uri, version);
3957
}
4058
}
4159

@@ -53,12 +71,14 @@ impl Backend {
5371
}
5472

5573
pub(crate) async fn handle_did_close(&self, params: DidCloseTextDocumentParams) {
74+
let uri = params.text_document.uri;
5675
{
5776
let mut docs = self.documents.write().await;
58-
docs.remove(&params.text_document.uri);
77+
docs.remove(&uri);
5978
}
60-
self.client
61-
.publish_diagnostics(params.text_document.uri, vec![], None)
62-
.await;
79+
self.document_versions.write().await.remove(&uri);
80+
// Clearing diagnostics for a closed document - version is intentionally None
81+
// since the document is no longer tracked.
82+
self.client.publish_diagnostics(uri, vec![], None).await;
6383
}
6484
}

crates/agnix-lsp/src/backend/helpers.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,11 @@ impl Backend {
8585
pub(crate) async fn get_document_content(&self, uri: &Url) -> Option<Arc<String>> {
8686
self.documents.read().await.get(uri).cloned()
8787
}
88+
89+
/// Get the latest document version reported by the client for a URI.
90+
///
91+
/// Returns `None` if the document has not been opened or has been closed.
92+
pub(crate) async fn get_document_version(&self, uri: &Url) -> Option<i32> {
93+
self.document_versions.read().await.get(uri).copied()
94+
}
8895
}

crates/agnix-lsp/src/backend/revalidation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ impl Backend {
215215

216216
// Publish diagnostics for files not open in the editor
217217
for (uri, lsp_diags) in non_open_publish {
218+
// None: non-open files have no client-tracked version
218219
self.client.publish_diagnostics(uri, lsp_diags, None).await;
219220
}
220221

0 commit comments

Comments
 (0)