feat(language-service): implement pull-based diagnostics (LSP 3.17)#66700
feat(language-service): implement pull-based diagnostics (LSP 3.17)#66700kbrilla wants to merge 2 commits intoangular:mainfrom
Conversation
|
/gemini review |
There was a problem hiding this comment.
Code Review
This is an excellent pull request that implements a significant performance improvement with pull-based diagnostics. The code is well-structured, particularly the new diagnostics handler and the clean integration into the session management. The dependency upgrades and necessary code adaptations for the new LSP library version are handled correctly. The addition of a comprehensive test suite is also a great step to ensure the feature's stability. I have one suggestion to improve the robustness of the new tests by replacing fixed timeouts with a more reliable synchronization mechanism.
| }); | ||
|
|
||
| // Wait a bit for the server to process | ||
| await setTimeout(500); |
There was a problem hiding this comment.
Using a fixed setTimeout can lead to flaky tests if the server takes longer than expected to process. A more robust approach would be to wait for the server to signal that it's ready for diagnostics to be pulled. The server does this by sending a workspace/diagnostic/refresh request.
You can create a promise that resolves when this request is received and await it instead of using setTimeout. This would make the test more reliable and potentially faster.
This pattern should be applied to the other new tests in this suite that use setTimeout for waiting.
There was a problem hiding this comment.
The tests now use an event-driven approach instead of fixed setTimeout(500). They wait for the server's workspace/diagnostic/refresh notification via waitForDiagnosticRefresh(), making the tests more robust and faster.
There was a problem hiding this comment.
Done. All tests now use waitForDiagnosticRefresh() which awaits the server's workspace/diagnostic/refresh notification instead of a fixed setTimeout(500). This is event-driven and resolves immediately when the server signals readiness.
| * Extract the numeric version from TypeScript's ScriptInfo version string. | ||
| * Version format is like "ScriptInfo-0-123" - we extract the final numeric part. | ||
| */ | ||
| function extractVersionNumber(versionString: string): number { |
There was a problem hiding this comment.
Risk: You are relying on the internal format of ScriptInfo version strings (e.g., "SVC-1-123").
- If the format changes (e.g., to a hash or UUID), this function returns 0.
- Bug: If it returns 0 for changed content (because parsing failed), the cache check currentVersionNum === cached.version (0 === 0) will falsely return DocumentDiagnosticReportKind.Unchanged.
- Fix: Store the full versionString in the cache and compare strings. The LSP version field in the report can be null or valid integer if available, but for our internal change detection, exact string equality is safer and more robust.
There was a problem hiding this comment.
Instead of parsing the internal TypeScript ScriptInfo version string format (which could change), the code now stores and compares the full version string directly. This prevents false "unchanged" reports if parsing fails.
There was a problem hiding this comment.
Fixed. The extractVersionNumber function is removed. The cache now stores and compares the full version string directly via scriptInfo.getLatestVersion(), using exact string equality. No parsing, no risk of false cache hits.
| // Get all open files from the session | ||
| const openFiles = session.getOpenFiles(); | ||
|
|
||
| for (const filePath of openFiles) { |
There was a problem hiding this comment.
Observation: onWorkspaceDiagnostic iterates synchronously over all open files and calls getSemanticDiagnostics.
- Impact: On a large project with many open files, this could freeze the Node.js process for a significant time, blocking other LSP requests (like completion or hover).
- Fix: Consider awaiting setImmediate or a small delay between file processing to yield to the event loop, allowing other requests to be handled.
There was a problem hiding this comment.
The onWorkspaceDiagnostic function now yields to the event loop between processing files using await yieldToEventLoop() (which uses setImmediate), preventing the server from freezing on large workspaces with many open files.
There was a problem hiding this comment.
Fixed. onWorkspaceDiagnostic now awaits yieldToEventLoop() (which uses setImmediate) between processing each file, allowing hover/completion/etc. requests to be handled during workspace diagnostic computation.
7059f72 to
f6f6663
Compare
d508f15 to
01f44fc
Compare
| if (isDebugMode) { | ||
| console.timeEnd(label); | ||
| } | ||
|
|
||
| const suggestionLabel = `Pull diagnostics - getSuggestionDiagnostics for ${fileName}`; | ||
| if (isDebugMode) { | ||
| console.time(suggestionLabel); | ||
| } |
There was a problem hiding this comment.
| if (isDebugMode) { | |
| console.timeEnd(label); | |
| } | |
| const suggestionLabel = `Pull diagnostics - getSuggestionDiagnostics for ${fileName}`; | |
| if (isDebugMode) { | |
| console.time(suggestionLabel); | |
| } | |
| const suggestionLabel = `Pull diagnostics - getSuggestionDiagnostics for ${fileName}`; | |
| if (isDebugMode) { | |
| console.timeEnd(label); | |
| console.time(suggestionLabel); | |
| } |
There was a problem hiding this comment.
Applied. Both console.timeEnd(semanticLabel) and console.time(suggestionLabel) are now in a single if (isDebugMode) block. Also renamed label → semanticLabel for consistency with suggestionLabel.
| * Clear all diagnostic caches. | ||
| * Should be called on workspace reset or server restart. | ||
| */ | ||
| export function clearAllDiagnosticCaches(): void { |
There was a problem hiding this comment.
this appears unused. It should likely be hooked into onDidChangeConfiguration or shutdown, or removed if unnecessary.
There was a problem hiding this comment.
Removed. Only clearDiagnosticCache(uri) remains — called when individual documents are closed. Since the cache is module-scoped and the server process terminates on shutdown, a full-cache clear is not needed.
| * Uses setImmediate between files to yield to the event loop, preventing | ||
| * the server from blocking on large workspaces with many open files. | ||
| */ | ||
| export async function onWorkspaceDiagnostic( |
There was a problem hiding this comment.
It seems like there could/should be more code sharing between this and the onDocumentDiagnostic function?
There was a problem hiding this comment.
Refactored. Extracted a shared computeDiagnosticsForFile(session, uri, previousResultId) function that handles caching, version checking, and diagnostic computation. Both onDocumentDiagnostic and onWorkspaceDiagnostic now delegate to it — onDocumentDiagnostic is just a one-liner wrapper.
| /** | ||
| * Handle the workspace/diagnostic request (LSP 3.17 Pull Diagnostics). | ||
| * | ||
| * This handler provides diagnostics for all open files in the workspace. |
There was a problem hiding this comment.
From what I gather, the workspace/diagnostic LSP method is explicitly designed to provide diagnostics for the entire project (workspace), not just open files.
Introduced in LSP 3.17, this "pull" model allows the client to actively ask the server for diagnostics across the entire project.
Scope: Specifically designed to handle closed files and provide a project-wide overview.
Workflow: The client can request a "workspace" report. This is what allows you to see errors in a file you haven't even opened yet, similar to how a full build command would behave.
Some servers (like pyright or rust-analyzer) have specific settings to toggle "check all files" vs. "check open files only" because full-workspace scanning is resource-heavy.
There was a problem hiding this comment.
Fixed. onWorkspaceDiagnostic now calls session.getProjectFileNames() which enumerates all source files across all configured Angular projects (via project.getFileNames(true, true) — excludes .d.ts library files and config files). Open files are processed first for priority, followed by remaining project files. This provides full project-wide diagnostic coverage.
| * Generate a unique result ID for caching purposes. | ||
| */ | ||
| function generateResultId(): string { | ||
| return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; |
There was a problem hiding this comment.
Why not just use a counter that's incremented?
There was a problem hiding this comment.
Changed. Now uses a simple monotonically increasing counter: let nextResultId = 1; function generateResultId(): string { return String(nextResultId++); }
9577b5b to
bba9fb2
Compare
bba9fb2 to
8a4bb40
Compare
…agnostics When pull-based diagnostics (LSP 3.17) is active, progressive project init now sends a workspace/diagnostic/refresh notification instead of computing and pushing diagnostics for each open file. This avoids wasted computation since the pull-based client would discard pushed diagnostics anyway. Falls back to the chunked push-based approach when pull diagnostics is not supported by the client. Depends on: - PR angular#66700 (Pull-Based Diagnostics) - PR angular#66966 (Progressive Project Initialization)
|
@kbrilla is this ready for review again? |
| if (isDebugMode) { | ||
| console.time(semanticLabel); | ||
| } | ||
| const diagnostics = languageService.getSemanticDiagnostics(fileName); |
There was a problem hiding this comment.
Can we add a shared helper that does the time logging and the two types of diagnostic fetching and share it here and with sendPendingDiagnostics
There was a problem hiding this comment.
Implemented. We now use a shared helper for diagnostics computation (getLspDiagnosticsForFile) and a shared file-iteration helper (forEachDiagnosticsFile) used by both pull and push (sendPendingDiagnostics).
| const currentVersion = scriptInfo.getLatestVersion(); | ||
|
|
||
| // Check if we can return an unchanged report | ||
| if (previousResultId && cached && cached.resultId === previousResultId) { |
There was a problem hiding this comment.
Issue: scriptInfo.getLatestVersion() only reflects changes to the file content. It does not reflect changes to the project configuration (e.g. enabling strictTemplates or changing angularCompilerOptions). If the project configuration changes, the server triggers a refreshDiagnostics(). The client will then request diagnostics again (potentially sending the previousResultId). Since the file content hasn't changed, currentVersion === cached.version will be true, and the server will return Unchanged. However, the diagnostics should be recomputed because the compilation settings changed.
Fix: The diagnosticResultCache should be cleared (or version invalidated) whenever a global diagnostic refresh occurs (i.e. inside Session.refreshDiagnostics or when the project updates). You might need to expose a clearAllDiagnosticCache() function from handlers/diagnostics.ts and call it in Session.refreshDiagnostics().
There was a problem hiding this comment.
Addressed, but via epochs (not full cache clear). We track project/global diagnostic epochs in cache entries and bump them on project/global invalidation. So config/project updates force Full recomputation even when file text version is unchanged. It is not exacly what You asked for so if I should use simpler approach for invalidating cache please say.
atscott
left a comment
There was a problem hiding this comment.
I don't think we need 15 commits for this change. Please clean up the commit history.
| if (!project || project.isClosed()) { | ||
| continue; | ||
| } | ||
| impactedProjects.add(project.getProjectName()); |
There was a problem hiding this comment.
In the comment above, it states:
// For open/change triggers, use lightweight tiered invalidation:
// - external template edits: invalidate template + owning component files
// - TS edits: invalidate project diagnostics epoch
But this adds the project unconditionally, even when the changed file is a template file. Should this instead go in the else block below? Otherwise edits to template files invalidate the project diagnostics when changes there cannot affect additional files in the project.
There was a problem hiding this comment.
impactedProjects.add() is now in the else block — template edits only clear cache for the template + owning component files via impactedFiles, and do NOT bump the project epoch.
| this.openFiles.delete(filePath); | ||
| this.projectService.closeClientFile(filePath); | ||
| // Clear the diagnostic cache for this file | ||
| clearDiagnosticCache(textDocument.uri); |
There was a problem hiding this comment.
The diagnosticResultCache Map in handlers/diagnostics.ts only purges entries when a document is explicitly closed (textDocument/didClose). If files in the workspace are deleted or renamed outside the boundaries of the VS Code editor buffers, their URIs persist in the cache indefinitely for the lifetime of the process. While the objects are tiny, it essentially constitutes a small memory leak in scenarios involving mass file moving/deleting. Consider implementing it as an LRU map or adding a mechanism to evict non-existent paths during project epoch bumps or watched-files events.
There was a problem hiding this comment.
Aded stale entry eviction in onWorkspaceDiagnostic. After each workspace diagnostic sweep, entries for URIs no longer in any active project are removed. Combined with the didChangeWatchedFiles handler clearing entries on FileChangeType.Deleted, deleted/renamed files are evicted through multiple paths. The only scenario where the sweep doesn't run is if the client doesn't support workspace/diagnostic at all — but then there's nothing to cache because onWorkspaceDiagnostic is never called in the first place.
If You prefer LRU, please say so and will add it today.
a6f28fe to
806219c
Compare
Implement support for LSP 3.17 Pull-Based Diagnostics, allowing clients to request diagnostics on-demand rather than receiving them pushed from the server. Key changes: - Add diagnostics handler with onDocumentDiagnostic and onWorkspaceDiagnostic implementations - Register diagnosticProvider capability in server initialization - Detect client support for pull diagnostics and workspace refresh - Implement result caching with resultId to return unchanged reports when documents haven't been modified - Support fallback to push-based diagnostics for older clients - Tiered invalidation: template edits invalidate only template + owning component; TS edits bump project epoch for dependents - Evict diagnostic cache on file delete from watched-files events - Add integration tests for pull-based diagnostics Closes angular#66699
806219c to
bf2697d
Compare
|
Changes since last review:
|
atscott
left a comment
There was a problem hiding this comment.
Thanks for the thorough work on this PR and for addressing the edge cases around cache invalidation and memory leaks.
As I'm evaluating this, I'm trying to balance the performance benefits against the long-term maintenance cost of the added complexity.
Specifically, I'm wondering if the resultId caching mechanism is worth the complexity it introduces (epochs, eviction logic, stateful map). Since the Angular compiler and the underlying TypeScript program are already highly incremental and cache diagnostics internally, we aren't saving core compilation work here. The saved work is primarily the TCB-to-template mapping and JSON serialization.
I'm wondering if a simpler middle ground would be a better fit for maintainability: What if we support the LSP textDocument/diagnostic request (the pull model) but without the resultId caching?
In this model, when the client pulls diagnostics for a file, we would always compute and return the full diagnostic report for that file, rather than checking a cache to return Unchanged.
This would allow us to:
- Keep the main benefit of pull diagnostics: The client only asks for what it needs (e.g., the active file), saving work by not processing the other hundreds of files in the project.
- Eliminate the complexity: We could remove the diagnosticResultCache, the project epochs, and the eviction logic entirely, making the implementation stateless and much easier to maintain.
This simpler approach also seems to be what other reference implementations do (for example, typescript-go handles the pull request but doesn't implement resultId caching - https://github.com/microsoft/typescript-go/blob/main/internal/ls/diagnostics.go, https://github.com/microsoft/typescript-go/blob/fdea8102676c0f3f5027b026a9bd4f289c1c471c/internal/lsp/server.go#L1269-L1271).
What are your thoughts on this alternative?
| // Template edit: invalidate template + owning component files only. | ||
| // Templates cannot affect other files in the project. | ||
| for (const component of languageService.getComponentLocationsForTemplate(changedFile)) { | ||
| impactedFiles.add(component.fileName); | ||
| } |
There was a problem hiding this comment.
I'm not sure diagnostics of the component file are affected by template changes unless this is actually due to the template being deleted?
| // TS edit: needs project-wide epoch bump since exports may affect dependents. | ||
| impactedProjects.add(project.getProjectName()); |
There was a problem hiding this comment.
Is this just because we don't have access to the dependency graph in the language service and you're just invalidating the entire project when a TS file changes as a result?
PR: feat(language-service): implement pull-based diagnostics (LSP 3.17)
Description
Implements Pull-Based Diagnostics (LSP 3.17) for the Angular Language Service, allowing the VS Code client to request diagnostics on-demand rather than having the server push them on every change.
This feature works together with #66668 (Client-Side File Watching) to provide comprehensive performance improvements across the entire development workflow:
Features
LSP 3.17 Pull Diagnostics Support
textDocument/diagnostichandler for document-level diagnosticsworkspace/diagnostichandler for workspace-wide diagnosticsresultIdto skip unchanged documentsworkspace/diagnostic/refreshfor server-initiated refreshResult Caching
When a file hasn't changed and the client provides the
previousResultId, the server returns anUnchangedreport in ~0.2ms instead of recomputing diagnostics:How They Work Together
Testing
Measured Performance (Integration Tests)
UnchangedreportBreaking Changes
None
Related Issues
Closes #66699
Complements #66668 (Client-Side File Watching)
Dependencies
This PR is built on top of the LSP library upgrade PR and should be merged after it:
AI Disclosure
This PR was developed using Claude Opus 4.5 AI assistant under human orchestration and review by @kbrilla.