Repository navigation
Conversation
There was a problem hiding this comment.
Code Review: Diagnostic Refresh Coalescing
Summary
The change introduces a debounced/coalesced workspace/diagnostic/refresh notification, switches inter_file_dependencies to true, and adds tests. Overall the design is sound, but there are several correctness and robustness concerns.
Issues
1. Drop impl is effectively dead code / misleading (High)
impl Drop for DiagnosticService {
fn drop(&mut self) {
if let Some(token) = self.refresh_token.get_mut().take() {
token.cancel();
}
}
}DiagnosticServiceis almost certainly held inside anArcand lives for the whole process lifetime, soDropwill rarely (if ever) run in practice. The testdropping_diagnostics_cancels_pending_refreshpasses only because the test constructs a bare value.- More importantly,
Mutex::get_mut()is fine here, but the cancellation is redundant: the spawned task holds a clone of the token, and when theDiagnosticServiceis dropped theMutexis dropped too — but the spawned task still holds its owntokenclone, so it will still fireclient.refresh_workspace_diagnostics()unless cancelled. So theDropimpl is needed for correctness, yet it can't be relied on because the service isArc-held. This is a latent bug: on shutdown the refresh may still be sent. - Recommendation: Either document that this only matters for tests, or wire cancellation into the actual shutdown path (e.g. cancel via the existing
workspace_diagnostic_token/ a shutdown signal). Consider usingWeak<ClientProxy>in the spawned task so it doesn't keep the client alive.
2. Spawned task captures Arc<ClientProxy> strongly (Medium)
let client = self.client.clone();
tokio::spawn(async move { ... client.refresh_workspace_diagnostics(); });This keeps the client (and its connection) alive for the duration of the sleep. Combined with issue #1, a pending refresh can outlive the intended lifetime. Prefer Weak + upgrade() inside the task.
3. schedule_diagnostic_refresh holds the mutex across await points (Low/Medium)
let mut pending = self.refresh_token.lock().await;
if let Some(token) = pending.take() { token.cancel(); }
let token = CancellationToken::new();
*pending = Some(token.clone());
let client = self.client.clone();
tokio::spawn(...);The lock is held while spawning. tokio::spawn itself doesn't await, so this is not a deadlock, but it does serialize callers and means the guard is held longer than necessary. Minor; consider dropping the guard before spawn. Not a blocker.
4. interval semantics: 0 is treated as "fire immediately" (Low)
In the test, schedule_diagnostic_refresh(0) is used to force an immediate refresh. With tokio::time::sleep(Duration::from_millis(0)) this works, but callers passing 0 from config would bypass coalescing entirely. Worth documenting or clamping.
5. inter_file_dependencies: true is a behavioral change with no migration note (Medium)
Switching this to true tells clients that cross-file invalidation is supported. This is only correct if the server actually emits workspace/diagnostic/refresh in all cases where cross-file results change. The watcher handler now does this, but check other mutation paths (e.g. config changes, didChangeConfiguration, workspace folder changes) — if any of them mutate analysis without scheduling a refresh, clients will show stale diagnostics. Audit all analysis().update(...) call sites.
6. Watcher handler: removed_file computed inside the closure but file_ids may be empty on removal (Low)
let mut removed_file = false;
for uri in &removed_uris {
removed_file |= analysis.get_file_id(uri).is_some();
analysis.remove_file_by_uri(uri);
}This is correct, but note removed_uris may contain URIs that were never tracked — removed_file stays false and no refresh is scheduled. That's probably intended, but if a removed file was a dependency of another file, the dependent's diagnostics could be stale. Consider whether removal of an untracked file that is still referenced by tracked files should trigger a refresh.
7. Test flakiness / timing assumptions (Medium)
refresh_coalesces_and_is_scoped_to_its_clientsleeps 100ms for a 30ms interval — reasonable, but on a loaded CI machine the spawned task may not have run. Prefertokio::time::pause()/advance()or a channel-based synchronization instead of wall-clock sleeps.Client.__init__doestime.sleep(1)afterinitialized— brittle. Prefer waiting for a specific notification/response.refreshes(duration=0.6)uses wall-clock polling; on slow CI the refresh may arrive after the window and the assertion== int(refresh)will fail intermittently. Consider a longer/adaptive deadline or explicit synchronization.- The Python test asserts
client.refreshes() == int(refresh)immediately after 10 rapiddidChangenotifications. This depends on the server having processed all 10 before the 0.6s window closes. Under load this can flake.
8. Python test: receive auto-responds to any request with id (Low)
if 'method' in message and 'id' in message:
result = [] if message['method'] == 'workspace/configuration' else None
self.write({'id': message['id'], 'result': result})This blindly replies null to server→client requests other than workspace/configuration. If the server sends client/registerCapability or similar, the null result may be invalid and could cause the server to error. Safer to handle known methods explicitly and log unexpected ones.
9. Drop + Mutex interaction with async (Low)
Drop uses get_mut() which is correct (no async), but if DiagnosticService is ever dropped while another task holds the lock, get_mut() will panic? No — get_mut on tokio::sync::Mutex returns &mut T only if &mut self is available, which it is in Drop. Fine. Just noting for reviewers.
10. Missing refresh on didClose (Low)
process_did_open_text_document and process_did_change_text_document schedule a refresh, but didClose (if it mutates analysis) does not appear to. If closing a file changes cross-file diagnostics, clients won't be refreshed. Verify.
Recommendations (priority order)
- Fix lifetime of spawned refresh task — use
Weak<ClientProxy>and/or tie cancellation to real shutdown, notDropof anArc-held struct. - Audit all analysis mutation paths for missing
schedule_diagnostic_refreshnow thatinter_file_dependencies = true. - Reduce test flakiness — replace wall-clock sleeps with
tokio::time::pause/advancein Rust tests and explicit synchronization in the Python test. - Harden the Python test client to not blindly reply
nullto unknown server requests. - Minor: drop the mutex guard before
tokio::spawn; documentinterval == 0behavior.
Nits
refresh_token: Mutex<Option<CancellationToken>>is notArc-wrapped unlike the other token fields — inconsistent, though fine given&selfusage. Confirm noCloneofDiagnosticServiceis expected.- The doc comment "Coalesce cross-file invalidations after analysis has incorporated an edit" is good; consider noting the
intervalunit (ms) explicitly.
|
I had GPT address the issues. I'd like to re-trigger the code review but I don't know how. |
Cancel pending refreshes before acknowledging shutdown, reject new work from retained contexts, and avoid keeping the client alive in timer tasks. Serialize final sends with shutdown and document zero-delay behavior. Refresh after tracked-file removal on close and file renames. Route configuration reloads through the capability-gated refresh lifecycle. Use a paused clock for debounce tests and synchronize protocol tests on analysis state. Cover close, rename, configuration reload, capability combinations and shutdown, with explicit handling of client requests.
1c47a24 to
9e8ed55
Compare
|
rebased to current main |
|
Doing dependency diagnostics for Lua is meaningless. In practice, because globals can be defined anywhere, a change to any line of code in Lua can cause semantic changes across all files. We split diagnostics into two phases: a fast current-file diagnostic via textDocument/diagnostic, and a slow full global diagnostic via workspace/diagnostic. The current workspace/diagnostic implementation may not be able to respond promptly due to issues with the migration. |
|
Thanks for explaining. I have seen this with VSCode, where the IDE, after a few seconds of inactivity does a full scan of all files in the workspace. How much of that support is supposed to come from the IDE proper and how much can be driven by the analyzer? Lastly, I am using this change with a local fork of emmylua and I had great success (especially with files updating instantly). I have a modest sized workspace (~ 2,000 files) and I don't really see any performance issues. I would be curious to learn more what problem you see with potential semantic changes across all files if it is fast enough. The current lsp4ij for IntelliJ does not implement workspace/diagnostic. I might start by addressing that issue first. GPT also pointed out to me that "There is also an analyzer-side gap to investigate: its workspace diagnostic loop is invalidated on saves and workspace reloads, but ordinary unsaved edits and watched Lua-file changes do not mark it for another pass." I will work in that direction, would you be open to reviewing a patch once I wrap that work up? |
fix(diagnostics): refresh dependent documents after file changes
With pull diagnostics, changing a type declaration in one file can leave stale
warnings in another open file until the language server restarts or the client
requests diagnostics again. The server advertises no inter-file dependencies
and does not request a refresh after these edits.
For example, open these two files with a pull-diagnostic client:
Comment out the field declaration. The missing-field diagnostic in
settings.luashould disappear, and reappear when the declaration is restored.
This change advertises inter-file dependencies and schedules
workspace/diagnostic/refreshafter analysis updates from document open/change,watched Lua file events, tracked-file removal on close, and file renames.
Configuration reloads use the same refresh lifecycle and capability checks.
Refreshes are sent only when the client supports pull diagnostics and refresh
requests; existing push-diagnostic scheduling is retained.
Rapid edits coalesce using the configured diagnostic interval in milliseconds.
Zero requests a refresh without a debounce delay. Pending tasks hold a weak
client reference. Shutdown cancels pending refreshes before its response and
rejects new scheduling by outstanding updates, even while server snapshots remain
alive. Final sends are serialized with shutdown and replacement; dropping the
service provides fallback cancellation.
Validation on macOS arm64:
cargo test --locked -p emmylua_ls --all-features: 228 tests passed.cargo fmt --all -- --check: passed.cargo clippy --locked -p emmylua_ls --all-targets --all-features -- -D warnings: passed.python3 crates/emmylua_ls/tests/diagnostic_refresh.py <standalone-server>: passedwith pull/refresh enabled, pull without refresh support, and push diagnostics.
Rust debounce/lifetime tests use a paused Tokio clock. Protocol tests wait for
analysis changes and refresh requests, and handle known server requests explicitly.
They cover required-field removal/restoration, watcher change/delete/create,
untracked deletion, close/delete, rename, watched and client configuration reloads,
and absence of refreshes between shutdown acknowledgement and exit.