From 9beb2431895e375a693f1d4fbb9586ffc1ff3d5c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 3 Aug 2026 01:20:17 +0200 Subject: [PATCH 1/4] Add version-stamp cache to FSharpDocumentDiagnosticAnalyzer to avoid recomputing diagnostics when document/project version is unchanged --- .../Diagnostics/DocumentDiagnosticAnalyzer.fs | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs index a1a2bbe9f90..3cb80f18e26 100644 --- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs +++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs @@ -1,8 +1,9 @@ -// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. namespace Microsoft.VisualStudio.FSharp.Editor open System.Composition +open System.Collections.Concurrent open System.Collections.Immutable open System.Collections.Generic open System.Threading @@ -27,6 +28,9 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = let shouldProduceDiagnostics (document: Document) = document.Project.Solution.GetFSharpExtensionConfig().ShouldProduceDiagnostics() + static let cache = + ConcurrentDictionary>() + static let diagnosticEqualityComparer = { new IEqualityComparer with @@ -72,6 +76,27 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = let! ct = CancellableTask.getCancellationToken () + let! textVersion = document.GetTextVersionAsync(ct) + + let! projectVersion = + match diagnosticType with + | DiagnosticsType.Syntax -> CancellableTask.singleton VersionStamp.Default + | DiagnosticsType.Semantic -> (fun ct -> document.Project.GetDependentVersionAsync(ct)) + + let cacheKey = struct (document.Id, diagnosticType) + + let cached = + match cache.TryGetValue(cacheKey) with + | true, (cachedTextVersion, cachedProjectVersion, cachedDiagnostics) when + cachedTextVersion = textVersion && cachedProjectVersion = projectVersion + -> + ValueSome cachedDiagnostics + | _ -> ValueNone + + match cached with + | ValueSome cachedDiagnostics -> return cachedDiagnostics + | ValueNone -> + let! sourceText = document.GetTextAsync(ct) let filePath = document.FilePath @@ -98,35 +123,39 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = UnnecessaryParenthesesDiagnosticAnalyzer.GetDiagnostics document | _ -> CancellableTask.singleton ImmutableArray.Empty - if errors.Count = 0 && unnecessaryParentheses.IsEmpty then - return ImmutableArray.Empty - else - let iab = ImmutableArray.CreateBuilder(errors.Count + unnecessaryParentheses.Length) + let result = + if errors.Count = 0 && unnecessaryParentheses.IsEmpty then + ImmutableArray.Empty + else + let iab = ImmutableArray.CreateBuilder(errors.Count + unnecessaryParentheses.Length) + + for diagnostic in errors do + if diagnostic.StartLine <> 0 && diagnostic.EndLine <> 0 then + let linePositionSpan = + LinePositionSpan( + LinePosition(diagnostic.StartLine - 1, diagnostic.StartColumn), + LinePosition(diagnostic.EndLine - 1, diagnostic.EndColumn) + ) - for diagnostic in errors do - if diagnostic.StartLine <> 0 && diagnostic.EndLine <> 0 then - let linePositionSpan = - LinePositionSpan( - LinePosition(diagnostic.StartLine - 1, diagnostic.StartColumn), - LinePosition(diagnostic.EndLine - 1, diagnostic.EndColumn) - ) + let textSpan = sourceText.Lines.GetTextSpan(linePositionSpan) - let textSpan = sourceText.Lines.GetTextSpan(linePositionSpan) + // F# compiler report errors at end of file if parsing fails. It should be corrected to match Roslyn boundaries + let correctedTextSpan = + if textSpan.End <= sourceText.Length then + textSpan + else + let start = min textSpan.Start (sourceText.Length - 1) |> max 0 - // F# compiler report errors at end of file if parsing fails. It should be corrected to match Roslyn boundaries - let correctedTextSpan = - if textSpan.End <= sourceText.Length then - textSpan - else - let start = min textSpan.Start (sourceText.Length - 1) |> max 0 + TextSpan.FromBounds(start, sourceText.Length) - TextSpan.FromBounds(start, sourceText.Length) + let location = Location.Create(filePath, correctedTextSpan, linePositionSpan) + iab.Add(RoslynHelpers.ConvertError(diagnostic, location)) - let location = Location.Create(filePath, correctedTextSpan, linePositionSpan) - iab.Add(RoslynHelpers.ConvertError(diagnostic, location)) + iab.AddRange unnecessaryParentheses + iab.ToImmutable() - iab.AddRange unnecessaryParentheses - return iab.ToImmutable() + cache.[cacheKey] <- (textVersion, projectVersion, result) + return result } interface IFSharpDocumentDiagnosticAnalyzer with From 4aaddc58a6a6e7842564500986783fd50a8075df Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 13 Aug 2026 23:05:23 +0200 Subject: [PATCH 2/4] Perf: Refactor diagnostics cache with metadata record * Refactored the diagnostics cache in `FSharpDocumentDiagnosticAnalyzer` to use a new `CachedDiagnosticsEntry` record, storing `TextVersion`, `ProjectVersion`, `FilePath`, `IsRemoveParensEnabled`, and cached `Diagnostics`. * The cache key remains `(DocumentId * DiagnosticsType)`, but the value is now the new record. Cache lookup now checks all relevant fields for equality, ensuring diagnostics are reused only when context matches. * Added `evictRemovedDocuments` to remove cache entries for deleted documents. * Updated logic for "Remove Parentheses" diagnostics to use the cached flag, and updated cache storage to the new structure. --- .../Diagnostics/DocumentDiagnosticAnalyzer.fs | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs index 3cb80f18e26..437b8d02e7d 100644 --- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs +++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs @@ -22,14 +22,29 @@ type internal DiagnosticsType = | Syntax | Semantic +type private CachedDiagnosticsEntry = + { + TextVersion: VersionStamp + ProjectVersion: VersionStamp + FilePath: string + IsRemoveParensEnabled: bool + Diagnostics: ImmutableArray + } + [)>] type internal FSharpDocumentDiagnosticAnalyzer [] () = let shouldProduceDiagnostics (document: Document) = document.Project.Solution.GetFSharpExtensionConfig().ShouldProduceDiagnostics() - static let cache = - ConcurrentDictionary>() + static let cache = ConcurrentDictionary() + + static let evictRemovedDocuments (solution: Solution) = + for KeyValue(cacheKey, _) in cache do + let struct (documentId, _) = cacheKey + + if isNull (solution.GetDocument(documentId)) then + cache.TryRemove(cacheKey) |> ignore static let diagnosticEqualityComparer = { new IEqualityComparer with @@ -83,14 +98,26 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = | DiagnosticsType.Syntax -> CancellableTask.singleton VersionStamp.Default | DiagnosticsType.Semantic -> (fun ct -> document.Project.GetDependentVersionAsync(ct)) + let filePath = document.FilePath + + let isRemoveParensEnabled = + match diagnosticType with + | DiagnosticsType.Syntax -> document.Project.IsFsharpRemoveParensEnabled + | DiagnosticsType.Semantic -> false + + evictRemovedDocuments document.Project.Solution + let cacheKey = struct (document.Id, diagnosticType) let cached = match cache.TryGetValue(cacheKey) with - | true, (cachedTextVersion, cachedProjectVersion, cachedDiagnostics) when - cachedTextVersion = textVersion && cachedProjectVersion = projectVersion + | true, cachedEntry when + cachedEntry.TextVersion = textVersion + && cachedEntry.ProjectVersion = projectVersion + && cachedEntry.FilePath = filePath + && cachedEntry.IsRemoveParensEnabled = isRemoveParensEnabled -> - ValueSome cachedDiagnostics + ValueSome cachedEntry.Diagnostics | _ -> ValueNone match cached with @@ -98,7 +125,6 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = | ValueNone -> let! sourceText = document.GetTextAsync(ct) - let filePath = document.FilePath let errors = HashSet(diagnosticEqualityComparer) @@ -119,7 +145,7 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = let! unnecessaryParentheses = match diagnosticType with - | DiagnosticsType.Syntax when document.Project.IsFsharpRemoveParensEnabled -> + | DiagnosticsType.Syntax when isRemoveParensEnabled -> UnnecessaryParenthesesDiagnosticAnalyzer.GetDiagnostics document | _ -> CancellableTask.singleton ImmutableArray.Empty @@ -154,7 +180,15 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = iab.AddRange unnecessaryParentheses iab.ToImmutable() - cache.[cacheKey] <- (textVersion, projectVersion, result) + cache.[cacheKey] <- + { + TextVersion = textVersion + ProjectVersion = projectVersion + FilePath = filePath + IsRemoveParensEnabled = isRemoveParensEnabled + Diagnostics = result + } + return result } From 6c0b5f542a266854f07ae1afc34dd87fe563084c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 13 Aug 2026 23:06:23 +0200 Subject: [PATCH 3/4] Perf: Add release notes (#20121) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index cffa42edc9c..0252668b490 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -7,6 +7,7 @@ * Fixed Rename incorrectly renaming `get` and `set` keywords for properties with explicit accessors. ([Issue #18270](https://github.com/dotnet/fsharp/issues/18270), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) +* Prevent stale and leaked per-document diagnostics by tightening cache validity and evicting entries for removed documents. ([PR #20121](https://github.com/dotnet/fsharp/pull/20121)) * Find All References for external DLL symbols now only searches projects that reference the specific assembly. ([Issue #10227](https://github.com/dotnet/fsharp/issues/10227), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Improve static compilation of state machines. ([PR #19297](https://github.com/dotnet/fsharp/pull/19297)) * Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421)) From c9296d7a565d5b2ac8e0e547715d6a5f4b90a5ee Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 13 Aug 2026 23:08:01 +0200 Subject: [PATCH 4/4] fixup! Perf: Refactor diagnostics cache with metadata record --- .../Diagnostics/DocumentDiagnosticAnalyzer.fs | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs index 437b8d02e7d..9d0c8ca66f5 100644 --- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs +++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs @@ -37,14 +37,15 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = let shouldProduceDiagnostics (document: Document) = document.Project.Solution.GetFSharpExtensionConfig().ShouldProduceDiagnostics() - static let cache = ConcurrentDictionary() + static let cache = + ConcurrentDictionary() static let evictRemovedDocuments (solution: Solution) = - for KeyValue(cacheKey, _) in cache do - let struct (documentId, _) = cacheKey + for entry in cache do + let struct (documentId, _) = entry.Key if isNull (solution.GetDocument(documentId)) then - cache.TryRemove(cacheKey) |> ignore + cache.TryRemove(entry.Key) |> ignore static let diagnosticEqualityComparer = { new IEqualityComparer with @@ -124,72 +125,71 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = | ValueSome cachedDiagnostics -> return cachedDiagnostics | ValueNone -> - let! sourceText = document.GetTextAsync(ct) + let! sourceText = document.GetTextAsync(ct) - let errors = HashSet(diagnosticEqualityComparer) + let errors = HashSet(diagnosticEqualityComparer) - let! parseResults = document.GetFSharpParseResultsAsync("GetDiagnostics") + let! parseResults = document.GetFSharpParseResultsAsync("GetDiagnostics") - match diagnosticType with - | DiagnosticsType.Syntax -> - for diagnostic in parseResults.Diagnostics do - errors.Add(diagnostic) |> ignore + match diagnosticType with + | DiagnosticsType.Syntax -> + for diagnostic in parseResults.Diagnostics do + errors.Add(diagnostic) |> ignore - | DiagnosticsType.Semantic -> - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync("GetDiagnostics") + | DiagnosticsType.Semantic -> + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync("GetDiagnostics") - for diagnostic in checkResults.Diagnostics do - errors.Add(diagnostic) |> ignore + for diagnostic in checkResults.Diagnostics do + errors.Add(diagnostic) |> ignore - errors.ExceptWith(parseResults.Diagnostics) + errors.ExceptWith(parseResults.Diagnostics) - let! unnecessaryParentheses = - match diagnosticType with - | DiagnosticsType.Syntax when isRemoveParensEnabled -> - UnnecessaryParenthesesDiagnosticAnalyzer.GetDiagnostics document - | _ -> CancellableTask.singleton ImmutableArray.Empty - - let result = - if errors.Count = 0 && unnecessaryParentheses.IsEmpty then - ImmutableArray.Empty - else - let iab = ImmutableArray.CreateBuilder(errors.Count + unnecessaryParentheses.Length) - - for diagnostic in errors do - if diagnostic.StartLine <> 0 && diagnostic.EndLine <> 0 then - let linePositionSpan = - LinePositionSpan( - LinePosition(diagnostic.StartLine - 1, diagnostic.StartColumn), - LinePosition(diagnostic.EndLine - 1, diagnostic.EndColumn) - ) - - let textSpan = sourceText.Lines.GetTextSpan(linePositionSpan) - - // F# compiler report errors at end of file if parsing fails. It should be corrected to match Roslyn boundaries - let correctedTextSpan = - if textSpan.End <= sourceText.Length then - textSpan - else - let start = min textSpan.Start (sourceText.Length - 1) |> max 0 - - TextSpan.FromBounds(start, sourceText.Length) - - let location = Location.Create(filePath, correctedTextSpan, linePositionSpan) - iab.Add(RoslynHelpers.ConvertError(diagnostic, location)) - - iab.AddRange unnecessaryParentheses - iab.ToImmutable() - - cache.[cacheKey] <- - { - TextVersion = textVersion - ProjectVersion = projectVersion - FilePath = filePath - IsRemoveParensEnabled = isRemoveParensEnabled - Diagnostics = result - } - - return result + let! unnecessaryParentheses = + match diagnosticType with + | DiagnosticsType.Syntax when isRemoveParensEnabled -> UnnecessaryParenthesesDiagnosticAnalyzer.GetDiagnostics document + | _ -> CancellableTask.singleton ImmutableArray.Empty + + let result = + if errors.Count = 0 && unnecessaryParentheses.IsEmpty then + ImmutableArray.Empty + else + let iab = ImmutableArray.CreateBuilder(errors.Count + unnecessaryParentheses.Length) + + for diagnostic in errors do + if diagnostic.StartLine <> 0 && diagnostic.EndLine <> 0 then + let linePositionSpan = + LinePositionSpan( + LinePosition(diagnostic.StartLine - 1, diagnostic.StartColumn), + LinePosition(diagnostic.EndLine - 1, diagnostic.EndColumn) + ) + + let textSpan = sourceText.Lines.GetTextSpan(linePositionSpan) + + // F# compiler report errors at end of file if parsing fails. It should be corrected to match Roslyn boundaries + let correctedTextSpan = + if textSpan.End <= sourceText.Length then + textSpan + else + let start = min textSpan.Start (sourceText.Length - 1) |> max 0 + + TextSpan.FromBounds(start, sourceText.Length) + + let location = Location.Create(filePath, correctedTextSpan, linePositionSpan) + iab.Add(RoslynHelpers.ConvertError(diagnostic, location)) + + iab.AddRange unnecessaryParentheses + iab.ToImmutable() + + cache.[cacheKey] <- + { + TextVersion = textVersion + ProjectVersion = projectVersion + FilePath = filePath + IsRemoveParensEnabled = isRemoveParensEnabled + Diagnostics = result + } + + return result } interface IFSharpDocumentDiagnosticAnalyzer with