Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7f287a9
Record file-watching design review vs Roslyn
xperiandri Aug 17, 2026
6246526
Add IVsAsyncFileChangeEx2 file change watcher adapter
xperiandri Aug 17, 2026
dc01204
Wire reference file watching into FSharpProjectOptionsReactor
xperiandri Aug 17, 2026
b762cc4
Add FileChangeWatcher unit tests and VS release notes
xperiandri Aug 17, 2026
934979f
Link the file-watching release note to its PR
xperiandri Sep 5, 2026
a75cac6
Address review: mandatory watcher, voption, illib string helpers
xperiandri Sep 5, 2026
ed72a8b
Address review: keep applyBatch on a list, Seq.toList
xperiandri Sep 5, 2026
38d0eb5
Address review: ImmutableArray contract, cancellable batch application
xperiandri Sep 5, 2026
e90d29b
Add String.StartsWithOrdinalIgnoreCase next to EndsWithOrdinalIgnoreCase
xperiandri Sep 5, 2026
2588bff
Address review: per-sink batching, timer race, diffed watches, tests
xperiandri Sep 5, 2026
f699bac
Use a second constructor instead of an optional delay parameter
xperiandri Sep 5, 2026
04fba82
Wrap the recording service's doc comment in summary for the cref
xperiandri Sep 5, 2026
2b8b2e6
Drop the reference subscription: Roslyn already covers it
xperiandri Sep 6, 2026
8b8b21e
Keep reference stamps inside the tracker's watch entries
xperiandri Sep 6, 2026
45f8373
Watch each project's reference set for its stamps
xperiandri Sep 6, 2026
1d9adec
Read snapshot reference stamps from the tracker instead of stat'ing
xperiandri Sep 6, 2026
09eb975
Document the stamp consumer and add its release note
xperiandri Sep 6, 2026
fa1910f
Expose Dispose directly on the tracker and sort the reactor's opens
xperiandri Sep 6, 2026
8d75f80
Dispose the watcher's agent directly
xperiandri Sep 6, 2026
1c3d942
Call String.StartsWith/EndsWith directly instead of the illib helpers
xperiandri Sep 6, 2026
f7ab57f
Never cache a reference stamp before its watch is confirmed active
xperiandri Sep 9, 2026
41eec4a
Use Seq.tryFindIndexV instead of the option-returning Seq.tryFindIndex
xperiandri Sep 9, 2026
cdf35a7
Fix the FS0760 that broke every Windows leg, and stop timing the IsAc…
xperiandri Sep 11, 2026
a3ec7f5
Keep one release note, in the version in development
xperiandri Sep 26, 2026
c1a2d72
Watch a reference's appearance and removal, not only its writes
xperiandri Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/ide/file-watching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# File watching in FSharp.Editor

`vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs` is the F# counterpart of
Roslyn's `FileChangeWatcher` and `ReferenceFileChangeTracker` (both internal to
`Microsoft.VisualStudio.LanguageServices` and not exposed through `ExternalAccess.FSharp`).

## What the workspace already gives us

Not every on-disk change needs this watcher. A `-r:` that the Roslyn workspace holds as a
`MetadataReference` is already watched by Roslyn: `ProjectSystemProjectFactory` advises every
reference path, and when one changes it swaps the reference on the solution, which bumps
`Project.Version`. `FSharpProjectOptionsReactor` sees that version through `isProjectInvalidated`,
recomputes, and calls `InvalidateConfiguration` — measured in VS, that path wins the race against
a watcher subscribed to the same file, because Roslyn batches over 500 ms where this tracker
additionally debounces for 2 s.

So a second subscription to the same reference set buys nothing. What the workspace does *not*
cover is everything it has no document or reference for, and every stat FCS still performs
internally:

- `#load` sources of a script: not documents, not references, invisible to the workspace.
- `IsReferencesInvalidated` on the incremental builder, which stats every reference on every
request.
- `ReferencesOnDisk` on snapshot reuse, which did the same per comparison — the consumer below.

## Shape

- **Service.** `IVsAsyncFileChangeEx2`, obtained asynchronously; nothing ever blocks on the UI
thread. Callbacks arrive through `IVsFreeThreadedFileChangeEvents2` and stay on background
threads.
- **Batching.** Subscribe/unsubscribe operations go through a single-consumer queue with a 500 ms
window (Roslyn's empirical value for solution open/close). Consecutive operations of the same
kind, and for file watches the same sink, are coalesced into one service call.
- **Directory watches.** A context starts with recursive `.dll` watches on the places reference
assemblies live: `DOTNET_ROOT/packs` and the machine-wide `dotnet/packs`, the .NET Framework
reference assemblies, and the NuGet cache (`NUGET_PACKAGES` or `~/.nuget/packages`). A file
under one of them costs no cookie of its own. Roslyn does not watch the NuGet cache; a consumer
that watches every `-r:` uniformly wants it, since package assemblies are the bulk of them and
the alternative is a per-file advise for each.
- **Per-file watches.** Paths outside those directories (project outputs, loose assemblies) get
an individual advise, ref-counted across consumers by `FSharpReferenceChangeTracker`. The
tracker also keeps the last-write stamp of each watched path (see Consumers).
- **Debounce.** A rebuild writes a temp file and renames it, producing several notifications; the
tracker fires one callback per path after 2 s of quiet.

## Consumers

**Snapshot reference stamps.** `FSharpProjectOptionsReactor` watches the `-r:` set of every
project it computes options for, diffed on recompute so an unchanged set touches nothing. The
tracker keeps each path's last-write stamp inside its watch entry and drops it on the raw change
notification, before the debounce. The `ReferencesOnDisk` guard in `createProjectSnapshot` reads
stamps through `IReferenceStamps`, so the comparison that runs for every new `Project` instance is
a dictionary read per reference instead of a stat. A path nobody watches is stat'd directly. A
mismatch against the snapshot's own stamps (FCS stats when it builds a snapshot) drops the
project's stamps, so a missed notification costs one re-stat pass rather than a rebuild per
`Project` instance. The reactor watch exists for these stamps, not to invalidate the FCS build —
Roslyn already does that, as the previous section says.

Still to come:

1. Scripts: watch `#load` sources so an edit outside the editor drops the cached options for
that document.
2. `FSharpProjectSnapshot.FromOptions` stats every `-r:` when a snapshot is built from scratch;
an overload taking host-supplied stamps lets it read the same cache.
3. A reference-change notification for the incremental builder on the FCS side, the analogue of
`useChangeNotifications` for sources, so `IsReferencesInvalidated` stops stat'ing at all.
1 change: 1 addition & 0 deletions docs/release-notes/.FSharp.Compiler.Service/11.0.200.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### Added

* `Internal.Utilities.Library`: `String.StartsWithOrdinalIgnoreCase` extension, the `StartsWith` sibling of `EndsWithOrdinalIgnoreCase`. ([PR #20457](https://github.com/dotnet/fsharp/pull/20457))
* F# Interactive gains a JSON-RPC server mode, `--fsi-server-jsonrpc:<pipe name>`, in which a host submits interactions over a named pipe and receives structured results — diagnostics with positions, escaping exceptions, the values each interaction bound, and the session's own process id — instead of recovering them by looking for a `SERVER-PROMPT>` marker in the output text. Program output continues to flow through the redirected console streams. The pipe admits only the user running the session; `--fsi-server-client-pid:<pid>` names the host process whose exit ends the session. `FsiEvaluationSession` exposes both options as `JsonRpcServerPipeName` and `JsonRpcClientProcessId`. The mode is part of the .NET fsi only. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396))

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions docs/release-notes/.VisualStudio/18.vNext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Expand `<inheritdoc/>` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188))

### Fixed
* Transparent compiler snapshot reuse no longer stats every `-r:` reference on each project change; reference timestamps are cached and invalidated by `IVsAsyncFileChangeEx2` notifications. ([PR #20457](https://github.com/dotnet/fsharp/pull/20457))

* Go To Definition no longer blocks the UI thread with a bare `Task.Wait`: the synchronous `IFSharpGoToDefinitionService` call now waits through the cancellable threaded-wait dialog, and the editor's `TaskCompletionSource` bridges run their continuations on the thread pool instead of inline on whichever thread finished the check, so repeated F12 on a large solution no longer starves semantic classification and other main-thread work. ([PR #20482](https://github.com/dotnet/fsharp/pull/20482))
* Peek Definition on an F# symbol whose definition lives in metadata no longer deadlocks Visual Studio. Peek holds the main thread in `JoinableTaskFactory.Run` without pumping messages while it asks the language service for the definition, and generating the metadata document needs that same thread; Peek now stops at definitions that already have a document, and Go To Definition, which owns the wait it makes, still opens the generated one. ([PR #20503](https://github.com/dotnet/fsharp/pull/20503))
Expand All @@ -12,6 +13,7 @@
* 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))
* Avoid using `cancellableTask` in `DocumentCache`; the editor cache now uses direct `CancellationToken`-aware `task` wrappers, avoiding the background `Task.Run` offload and a larger wrapper closure from the `cancellableTask` builder. ([Issue #20268](https://github.com/dotnet/fsharp/issues/20268))
* Cache document diagnostics by version stamp, so an unchanged document is not reanalyzed on every crawler pass. ([Issue #20120](https://github.com/dotnet/fsharp/issues/20120), [PR #20121](https://github.com/dotnet/fsharp/pull/20121))
* Watch on-disk `-r:` reference assemblies via `IVsAsyncFileChangeEx2`, so F# project options are invalidated when a referenced assembly is rebuilt instead of waiting for a timestamp poll. ([PR #20457](https://github.com/dotnet/fsharp/pull/20457))
* 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))
Expand Down
3 changes: 3 additions & 0 deletions src/Compiler/Utilities/illib.fs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ module internal PervasiveAutoOpens =
member inline x.StartsWithOrdinal(value: string) =
x.StartsWith(value, StringComparison.Ordinal)

member inline x.StartsWithOrdinalIgnoreCase(value: string) =
x.StartsWith(value, StringComparison.OrdinalIgnoreCase)

member inline x.EndsWithOrdinal(value: string) =
x.EndsWith(value, StringComparison.Ordinal)

Expand Down
2 changes: 2 additions & 0 deletions src/Compiler/Utilities/illib.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ module internal PervasiveAutoOpens =

member inline StartsWithOrdinal: value: string -> bool

member inline StartsWithOrdinalIgnoreCase: value: string -> bool

member inline EndsWithOrdinal: value: string -> bool

member inline EndsWithOrdinalIgnoreCase: value: string -> bool
Expand Down
1 change: 1 addition & 0 deletions vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<Compile Include="LanguageService\IProjectSite.fs" />
<Compile Include="LanguageService\ProvideFSharpVersionRegistrationAttribute.fs" />
<Compile Include="LanguageService\MetadataAsSource.fs" />
<Compile Include="LanguageService\FileChangeWatcher.fs" />
<Compile Include="LanguageService\FSharpProjectOptionsManager.fs" />
<Compile Include="LanguageService\IFSharpWorkspaceService.fs" />
<Compile Include="LanguageService\SingleFileWorkspaceMap.fs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ open System.Collections.Concurrent
open System.Collections.Immutable
open System.IO
open System.Linq
open Microsoft.CodeAnalysis
open System.Runtime.CompilerServices
open System.Threading
open System.Windows
open FSharp.Compiler
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Text
open Microsoft.CodeAnalysis
open Microsoft.VisualStudio
open Microsoft.VisualStudio.FSharp.Editor
open System.Threading
open Microsoft.VisualStudio.FSharp.Interactive.Session
open System.Runtime.CompilerServices
open CancellableTasks
open Microsoft.VisualStudio.FSharp.Editor.Extensions
open System.Windows
open Microsoft.VisualStudio
open FSharp.Compiler.Text
open Microsoft.VisualStudio.TextManager.Interop

open CancellableTasks

#nowarn "57"

[<AutoOpen>]
Expand Down Expand Up @@ -116,7 +117,7 @@ type private FSharpProjectOptionsMessage =
| ClearSingleFileOptionsCache of DocumentId

[<Sealed>]
type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
type private FSharpProjectOptionsReactor(checker: FSharpChecker, fileChangeWatcher: IFSharpFileChangeWatcher) =
let cancellationTokenSource = new CancellationTokenSource()

// Store command line options
Expand All @@ -137,6 +138,51 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =

let scriptUpdatedEvent = Event<FSharpProjectOptions>()

// The '-r:' set of each project is watched for the reference stamps the snapshot-reuse
// guard reads; invalidating the FCS build on a change is Roslyn's job (it swaps the
// MetadataReference and bumps Project.Version, which reaches tryComputeOptions).
let referenceChangeTracker =
new FSharpReferenceChangeTracker(fileChangeWatcher, ignore)

let referenceWatches = ConcurrentDictionary<ProjectId, HashSet<string>>()

let referencePaths (projectOptions: FSharpProjectOptions) =
let paths = HashSet<string>(StringComparer.OrdinalIgnoreCase)

for option in projectOptions.OtherOptions do
if option.StartsWith("-r:", StringComparison.Ordinal) then
paths.Add(option.Substring "-r:".Length) |> ignore

paths

let watchReferenceFiles (projectId: ProjectId) (projectOptions: FSharpProjectOptions) =
let paths = referencePaths projectOptions

match referenceWatches.TryGetValue projectId with
| true, previous ->
for path in previous do
if not (paths.Contains path) then
referenceChangeTracker.StopWatchingReference path

for path in paths do
if not (previous.Contains path) then
referenceChangeTracker.StartWatchingReference path
| _ ->
for path in paths do
referenceChangeTracker.StartWatchingReference path

if paths.Count > 0 then
referenceWatches[projectId] <- paths
else
referenceWatches.TryRemove projectId |> ignore

let clearReferenceWatches (projectId: ProjectId) =
match referenceWatches.TryRemove projectId with
| true, paths ->
for path in paths do
referenceChangeTracker.StopWatchingReference path
| _ -> ()

let createPEReference (referencedProject: Project) (comp: Compilation) =
let projectId = referencedProject.Id

Expand Down Expand Up @@ -410,7 +456,9 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =

if not (Seq.isEmpty projectsToClearCache) then
projectsToClearCache
|> Seq.iter (fun pair -> cache.TryRemove pair.Key |> ignore)
|> Seq.iter (fun pair ->
cache.TryRemove pair.Key |> ignore
clearReferenceWatches pair.Key)

let options =
projectsToClearCache
Expand All @@ -430,6 +478,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
let parsingOptions, _ = checker.GetParsingOptionsFromProjectOptions(projectOptions)

cache.[projectId] <- struct (project, parsingOptions, projectOptions)
watchReferenceFiles projectId projectOptions

return ValueSome struct (parsingOptions, projectOptions)

Expand Down Expand Up @@ -516,6 +565,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
| _ -> ()

legacyProjectSites.TryRemove(projectId) |> ignore
clearReferenceWatches projectId
| FSharpProjectOptionsMessage.ClearSingleFileOptionsCache(documentId) ->
match singleFileCache.TryRemove(documentId) with
| true, (_, _, _, projectOptions, subscription) ->
Expand Down Expand Up @@ -558,18 +608,24 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
singleFileCache.Clear()
lastSuccessfulCompilations.Clear()

for projectId in referenceWatches.Keys |> Array.ofSeq do
clearReferenceWatches projectId

member _.ScriptUpdated = scriptUpdatedEvent.Publish

member _.ReferenceStamps = referenceChangeTracker :> IReferenceStamps

interface IDisposable with
member _.Dispose() =
referenceChangeTracker.Dispose()
cancellationTokenSource.Cancel()
cancellationTokenSource.Dispose()
(agent :> IDisposable).Dispose()
agent.Dispose()

/// Manages mappings of Roslyn workspace Projects/Documents to FCS.
type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Workspace) =
type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Workspace, fileChangeWatcher: IFSharpFileChangeWatcher) =

let reactor = new FSharpProjectOptionsReactor(checker)
let reactor = new FSharpProjectOptionsReactor(checker, fileChangeWatcher)

do
// We need to listen to this event for lifecycle purposes.
Expand Down Expand Up @@ -638,4 +694,6 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor

member _.ClearAllCaches() = reactor.ClearAllCaches()

member _.ReferenceStamps = reactor.ReferenceStamps

member _.Checker = checker
Loading
Loading