diff --git a/docs/ide/file-watching.md b/docs/ide/file-watching.md
new file mode 100644
index 00000000000..53c00399a63
--- /dev/null
+++ b/docs/ide/file-watching.md
@@ -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.
diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
index 9441f8587e0..e7651463bd5 100644
--- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
+++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
@@ -148,6 +148,7 @@
* IL: fix leaking binary view ([PR #20250](https://github.com/dotnet/fsharp/pull/20250))
### Added
+* `Internal.Utilities.Library`: `String.StartsWithOrdinalIgnoreCase` extension, the `StartsWith` sibling of `EndsWithOrdinalIgnoreCase`. ([PR #20457](https://github.com/dotnet/fsharp/pull/20457))
* Added a "most concrete" tiebreaker for overload resolution (`--langversion:preview`). ([RFC FS-1340](https://github.com/fsharp/fslang-design/pull/834), [PR #19277](https://github.com/dotnet/fsharp/pull/19277))
* Added support for `OverloadResolutionPriorityAttribute` in overload resolution (`--langversion:preview`). ([RFC FS-1338](https://github.com/fsharp/fslang-design/pull/828), [PR #19277](https://github.com/dotnet/fsharp/pull/19277))
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index ef64e1a75c4..5b22655b08e 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -4,6 +4,7 @@
* Expand `` 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))
* Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128))
* 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))
diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs
index 5842492b31d..d84eccf6dae 100644
--- a/src/Compiler/Utilities/illib.fs
+++ b/src/Compiler/Utilities/illib.fs
@@ -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)
diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi
index 7c598ffa7a2..a216da229e0 100644
--- a/src/Compiler/Utilities/illib.fsi
+++ b/src/Compiler/Utilities/illib.fsi
@@ -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
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 319bdd5a264..6c510a77c14 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -55,6 +55,7 @@
+
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs
index 08bfbbddaa8..cecf4306e84 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs
@@ -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"
[]
@@ -113,7 +114,7 @@ type private FSharpProjectOptionsMessage =
| ClearSingleFileOptionsCache of DocumentId
[]
-type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
+type private FSharpProjectOptionsReactor(checker: FSharpChecker, fileChangeWatcher: IFSharpFileChangeWatcher) =
let cancellationTokenSource = new CancellationTokenSource()
// Store command line options
@@ -133,6 +134,51 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
let scriptUpdatedEvent = Event()
+ // 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>()
+
+ let referencePaths (projectOptions: FSharpProjectOptions) =
+ let paths = HashSet(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
@@ -408,7 +454,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
@@ -428,6 +476,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
let parsingOptions, _ = checker.GetParsingOptionsFromProjectOptions(projectOptions)
cache.[projectId] <- (project, parsingOptions, projectOptions)
+ watchReferenceFiles projectId projectOptions
return ValueSome(parsingOptions, projectOptions)
@@ -514,6 +563,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
| _ -> ()
legacyProjectSites.TryRemove(projectId) |> ignore
+ clearReferenceWatches projectId
| FSharpProjectOptionsMessage.ClearSingleFileOptionsCache(documentId) ->
match singleFileCache.TryRemove(documentId) with
| true, (_, _, _, projectOptions, subscription) ->
@@ -556,18 +606,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.
@@ -636,4 +692,6 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor
member _.ClearAllCaches() = reactor.ClearAllCaches()
+ member _.ReferenceStamps = reactor.ReferenceStamps
+
member _.Checker = checker
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs b/vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs
new file mode 100644
index 00000000000..399f24464ed
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs
@@ -0,0 +1,493 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System
+open System.Collections.Concurrent
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Threading
+open System.Threading.Tasks
+open Microsoft.VisualStudio
+open Microsoft.VisualStudio.Shell
+open Microsoft.VisualStudio.Shell.Interop
+
+open Microsoft.VisualStudio.FSharp.Editor.DebugHelpers
+
+open CancellableTasks
+
+// Push-based file watching for FSharp.Editor, modelled on Roslyn's
+// Microsoft.VisualStudio.LanguageServices FileChangeWatcher (which is internal and not
+// exposed through ExternalAccess.FSharp). Uses the free-threaded IVsAsyncFileChangeEx2
+// service: subscriptions are batched off the UI thread and callbacks never marshal to it.
+
+/// A directory to watch recursively (with optional extension filters) so that individual
+/// files under it don't each need their own advise cookie.
+[]
+type internal WatchedDirectory(path: string, extensionFilters: ImmutableArray) =
+ let path =
+ if path.EndsWith(string IO.Path.DirectorySeparatorChar, StringComparison.Ordinal) then
+ path
+ else
+ $"{path}{IO.Path.DirectorySeparatorChar}"
+
+ do
+ for filter in extensionFilters do
+ if not (filter.StartsWith(".", StringComparison.Ordinal)) then
+ invalidArg (nameof extensionFilters) $"Filter '{filter}' must start with a period."
+
+ member _.Path = path
+ member _.ExtensionFilters = extensionFilters
+
+ static member FilePathCoveredByWatchedDirectories(watchedDirectories: ImmutableArray, filePath: string) =
+ watchedDirectories
+ |> Seq.exists (fun w ->
+ filePath.StartsWith(w.Path, StringComparison.OrdinalIgnoreCase)
+ && (w.ExtensionFilters.IsEmpty
+ || w.ExtensionFilters
+ |> Seq.exists (fun filter -> filePath.EndsWith(filter, StringComparison.OrdinalIgnoreCase))))
+
+/// A single watched file; disposing stops watching.
+type internal IFSharpWatchedFile =
+ inherit IDisposable
+
+/// A group of file/directory watches sharing one event sink. Disposing unsubscribes everything.
+type internal IFSharpFileChangeContext =
+ inherit IDisposable
+
+ []
+ abstract FileChanged: IEvent
+
+ /// Starts watching a file without waiting for the OS registration. No-op (but still valid
+ /// to dispose) when the path is already covered by one of the context's watched directories.
+ abstract EnqueueWatchingFile: filePath: string -> IFSharpWatchedFile
+
+type internal IFSharpFileChangeWatcher =
+ abstract CreateContext: watchedDirectories: ImmutableArray -> IFSharpFileChangeContext
+
+/// Last-write stamps of watched reference files; a path nobody watches is stat'd directly.
+type internal IReferenceStamps =
+ abstract GetLastWriteTimeUtc: fullFilePath: string -> DateTime
+ abstract Invalidate: fullFilePath: string -> unit
+
+type private WatchedReference =
+ {
+ Token: IFSharpWatchedFile
+ mutable Count: int
+ mutable Stamp: DateTime voption
+ }
+
+[]
+module private FileChangeWatcherImpl =
+
+ // Same flags Roslyn uses for both subscribing and filtering callbacks.
+ let watchFlags =
+ _VSFILECHANGEFLAGS.VSFILECHG_Size ||| _VSFILECHANGEFLAGS.VSFILECHG_Time
+
+ let relevantFlags =
+ _VSFILECHANGEFLAGS.VSFILECHG_Time
+ ||| _VSFILECHANGEFLAGS.VSFILECHG_Add
+ ||| _VSFILECHANGEFLAGS.VSFILECHG_Del
+ ||| _VSFILECHANGEFLAGS.VSFILECHG_Size
+
+ /// Empirically strong batching window during high activity (solution open/close); see
+ /// Roslyn's FileChangeWatcher.
+ let defaultBatchingDelay = TimeSpan.FromMilliseconds 500.
+
+ /// Delay between the last observed change to a path and the callback: a rebuild typically
+ /// writes a temp file then renames, producing several rapid notifications.
+ let defaultNotificationDelay = TimeSpan.FromSeconds 2.
+
+ let noOpWatchedFile =
+ { new IFSharpWatchedFile with
+ member _.Dispose() = ()
+ }
+
+[]
+type internal FSharpWatchedFileToken() =
+ member val Cookie: uint32 voption = ValueNone with get, set
+
+/// Subscription operations queued for batched application against the file change service.
+type private WatcherOperation =
+ | WatchDir of path: string * filters: ImmutableArray * sink: IVsFreeThreadedFileChangeEvents2 * cookies: List
+ | WatchFiles of paths: string list * tokens: FSharpWatchedFileToken list * sink: IVsFreeThreadedFileChangeEvents2
+ | UnwatchFiles of tokens: FSharpWatchedFileToken list
+ | UnwatchDirs of cookies: List
+
+[]
+type internal FSharpFileChangeWatcher(fileChangeService: Task, batchingDelay: TimeSpan) =
+
+ let applyBatch (service: IVsAsyncFileChangeEx2) (ops: WatcherOperation list) =
+ cancellableTask {
+ let! ct = CancellableTask.getCancellationToken ()
+
+ // Coalesce adjacent same-kind operations into single service calls, preserving order
+ // between kinds (a watch enqueued before an unwatch must be applied first).
+ let mutable pending = ops
+
+ while not pending.IsEmpty do
+ match pending with
+ | [] -> ()
+ | WatchDir(path, filters, sink, cookies) :: rest ->
+ pending <- rest
+ let! cookie = service.AdviseDirChangeAsync(path, true, sink, ct)
+ cookies.Add cookie
+
+ if not filters.IsEmpty then
+ do! service.FilterDirectoryChangesAsync(cookie, Seq.toArray filters, ct)
+
+ | WatchFiles(_, _, sink) :: _ ->
+ let batch =
+ pending
+ |> Seq.takeWhile (function
+ | WatchFiles(_, _, s) -> obj.ReferenceEquals(s, sink)
+ | _ -> false)
+ |> Seq.toArray
+
+ pending <- pending |> List.skip batch.Length
+
+ let paths =
+ [|
+ for op in batch do
+ match op with
+ | WatchFiles(p, _, _) -> yield! p
+ | _ -> ()
+ |]
+
+ let tokens =
+ [|
+ for op in batch do
+ match op with
+ | WatchFiles(_, t, _) -> yield! t
+ | _ -> ()
+ |]
+
+ let! cookies = service.AdviseFileChangesAsync(paths, watchFlags, sink, ct)
+
+ (tokens, cookies)
+ ||> Array.iter2 (fun token cookie -> token.Cookie <- ValueSome cookie)
+
+ | UnwatchFiles _ :: _ ->
+ let batch =
+ pending
+ |> Seq.takeWhile (function
+ | UnwatchFiles _ -> true
+ | _ -> false)
+ |> Seq.toArray
+
+ pending <- pending |> List.skip batch.Length
+
+ // A token whose watch never got a cookie (or was already unadvised) is a no-op.
+ let cookies =
+ [|
+ for op in batch do
+ match op with
+ | UnwatchFiles tokens ->
+ for token in tokens do
+ match token.Cookie with
+ | ValueSome cookie ->
+ token.Cookie <- ValueNone
+ cookie
+ | ValueNone -> ()
+ | _ -> ()
+ |]
+
+ if cookies.Length > 0 then
+ let! _ = service.UnadviseFileChangesAsync(cookies, ct)
+ ()
+
+ | UnwatchDirs cookies :: rest ->
+ pending <- rest
+
+ if cookies.Count > 0 then
+ let! _ = service.UnadviseDirChangesAsync(cookies.ToArray(), ct)
+ ()
+ }
+
+ let cancellationTokenSource = new CancellationTokenSource()
+
+ // Single consumer loop: waits for the first queued operation, sleeps out the batching
+ // window, drains the queue and applies everything in one pass. Nothing ever blocks on the
+ // service being available.
+ let agent =
+ MailboxProcessor
+ .Start(
+ (fun inbox ->
+ async {
+ let! ct = Async.CancellationToken
+
+ while true do
+ try
+ let! first = inbox.Receive()
+ do! Async.Sleep(int batchingDelay.TotalMilliseconds)
+
+ let ops = ResizeArray [ first ]
+
+ while inbox.CurrentQueueLength > 0 do
+ let! op = inbox.Receive()
+ ops.Add op
+
+ let! service = fileChangeService |> Async.AwaitTask
+
+ do!
+ applyBatch service (List.ofSeq ops)
+ |> CancellableTask.startAsTask ct
+ |> Async.AwaitTask
+ with ex when not (ex :? OperationCanceledException) ->
+ // Never let a failed advise/unadvise (e.g. non-existent path) kill the
+ // subscription loop; we simply won't get events for that path.
+ FSharpOutputPane.logExceptionWithContext (ex, nameof FSharpFileChangeWatcher)
+ }),
+ cancellationTokenSource.Token
+ )
+
+ new(fileChangeService) = new FSharpFileChangeWatcher(fileChangeService, defaultBatchingDelay)
+
+ member private _.Enqueue(op: WatcherOperation) = agent.Post op
+
+ /// Production factory: obtains SVsFileChangeEx asynchronously without blocking any
+ /// background thread on UI-thread availability.
+ static member CreateDefaultServiceTask() =
+ task {
+ let! service = AsyncServiceProvider.GlobalProvider.GetServiceAsync(typeof)
+ return service :?> IVsAsyncFileChangeEx2
+ }
+
+ interface IFSharpFileChangeWatcher with
+ member _.CreateContext(watchedDirectories) =
+ new FileChangeContext(agent.Post, watchedDirectories) :> IFSharpFileChangeContext
+
+ interface IDisposable with
+ member _.Dispose() =
+ cancellationTokenSource.Cancel()
+ cancellationTokenSource.Dispose()
+ agent.Dispose()
+
+and [] private FileChangeContext(enqueue: WatcherOperation -> unit, watchedDirectories: ImmutableArray) as this =
+
+ let gate = obj ()
+ let mutable disposed = false
+ let activeFileTokens = HashSet()
+ let directoryCookies = List()
+ let fileChanged = Event()
+
+ let raiseChanges (count: uint32) (files: string[]) (changeFlags: uint32[]) =
+ for i in 0 .. int count - 1 do
+ if
+ (enum<_VSFILECHANGEFLAGS> (int changeFlags[i]) &&& relevantFlags)
+ <> enum<_VSFILECHANGEFLAGS> 0
+ then
+ fileChanged.Trigger files[i]
+
+ VSConstants.S_OK
+
+ do
+ for watchedDirectory in watchedDirectories do
+ enqueue (
+ WatchDir(
+ watchedDirectory.Path,
+ watchedDirectory.ExtensionFilters,
+ this :> IVsFreeThreadedFileChangeEvents2,
+ directoryCookies
+ )
+ )
+
+ member private _.StopWatchingFile(token: FSharpWatchedFileToken) =
+ lock gate (fun () -> activeFileTokens.Remove token |> ignore)
+ enqueue (UnwatchFiles [ token ])
+
+ interface IFSharpFileChangeContext with
+ []
+ member _.FileChanged = fileChanged.Publish
+
+ member _.EnqueueWatchingFile filePath =
+ if WatchedDirectory.FilePathCoveredByWatchedDirectories(watchedDirectories, filePath) then
+ noOpWatchedFile
+ else
+ let token = FSharpWatchedFileToken()
+ lock gate (fun () -> activeFileTokens.Add token |> ignore)
+ enqueue (WatchFiles([ filePath ], [ token ], this :> IVsFreeThreadedFileChangeEvents2))
+
+ { new IFSharpWatchedFile with
+ member _.Dispose() = this.StopWatchingFile token
+ }
+
+ interface IDisposable with
+ member _.Dispose() =
+ let alreadyDisposed =
+ lock gate (fun () ->
+ let d = disposed
+ disposed <- true
+ d)
+
+ if not alreadyDisposed then
+ enqueue (UnwatchDirs directoryCookies)
+ enqueue (UnwatchFiles(lock gate (fun () -> List.ofSeq activeFileTokens)))
+
+ // Free-threaded sink: callbacks arrive on background threads and stay there.
+ interface IVsFreeThreadedFileChangeEvents2 with
+ member _.FilesChanged(cChanges, rgpszFile, rggrfChange) =
+ raiseChanges cChanges rgpszFile rggrfChange
+
+ member _.DirectoryChanged _ = VSConstants.E_NOTIMPL
+ member _.DirectoryChangedEx(_, _) = VSConstants.E_NOTIMPL
+
+ member _.DirectoryChangedEx2(_, cChanges, rgpszFile, rggrfChange) =
+ raiseChanges cChanges rgpszFile rggrfChange
+
+ interface IVsFreeThreadedFileChangeEvents with
+ member _.FilesChanged(cChanges, rgpszFile, rggrfChange) =
+ raiseChanges cChanges rgpszFile rggrfChange
+
+ member _.DirectoryChanged _ = VSConstants.E_NOTIMPL
+ member _.DirectoryChangedEx(_, _) = VSConstants.E_NOTIMPL
+
+ interface IVsFileChangeEvents with
+ member _.FilesChanged(cChanges, rgpszFile, rggrfChange) =
+ raiseChanges cChanges rgpszFile rggrfChange
+
+ member _.DirectoryChanged _ = VSConstants.E_NOTIMPL
+
+/// Ref-counted, debounced watching of reference assemblies (or any other off-workspace files),
+/// modelled on Roslyn's ReferenceFileChangeTracker. Multiple projects watching the same dll
+/// share one subscription; bursts of writes produce a single callback per path. The last-write
+/// stamp of a path lives inside its watch entry, so a cached stamp is only ever served while a
+/// change notification can still reach it.
+[]
+type internal FSharpReferenceChangeTracker(watcher: IFSharpFileChangeWatcher, onChanged: string -> unit, notificationDelay: TimeSpan) =
+
+ let gate = obj ()
+ let mutable disposed = false
+
+ let watchedFiles =
+ Dictionary(StringComparer.OrdinalIgnoreCase)
+
+ let pendingTimers = Dictionary(StringComparer.OrdinalIgnoreCase)
+
+ // On each platform there is a place framework reference assemblies live; these rarely change
+ // but account for most watched paths, so cover them with directory watches up front.
+ static let defaultWatchedDirectories () =
+ let dotnetRoot = Environment.GetEnvironmentVariable "DOTNET_ROOT"
+ let nugetPackages = Environment.GetEnvironmentVariable "NUGET_PACKAGES"
+
+ let directories =
+ seq {
+ if not (String.IsNullOrEmpty dotnetRoot) then
+ IO.Path.Combine(dotnetRoot, "packs")
+
+ IO.Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.ProgramFiles, "dotnet", "packs")
+
+ IO.Path.Combine(
+ Environment.GetFolderPath Environment.SpecialFolder.ProgramFilesX86,
+ "Reference Assemblies",
+ "Microsoft",
+ "Framework"
+ )
+
+ if String.IsNullOrEmpty nugetPackages then
+ IO.Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.UserProfile, ".nuget", "packages")
+ else
+ nugetPackages
+ }
+ |> Seq.distinct
+ |> Seq.map (fun d -> WatchedDirectory(d, ImmutableArray.Create ".dll"))
+
+ directories.ToImmutableArray()
+
+ let context =
+ lazy
+ (let ctx = watcher.CreateContext(defaultWatchedDirectories ())
+
+ ctx.FileChanged.Add(fun path ->
+ let fire (_: obj) =
+ let isWatched =
+ lock gate (fun () ->
+ match pendingTimers.TryGetValue path with
+ | true, timer ->
+ pendingTimers.Remove path |> ignore
+ timer.Dispose()
+ | _ -> ()
+
+ watchedFiles.ContainsKey path)
+
+ if isWatched then
+ onChanged path
+
+ lock gate (fun () ->
+ // Directory watches cover whole trees; only debounce paths someone watches.
+ match watchedFiles.TryGetValue path with
+ | true, entry when not disposed ->
+ entry.Stamp <- ValueNone
+
+ let timer =
+ match pendingTimers.TryGetValue path with
+ | true, timer -> timer
+ | _ ->
+ let timer = new Timer(fire, null, Timeout.Infinite, Timeout.Infinite)
+ pendingTimers[path] <- timer
+ timer
+
+ timer.Change(notificationDelay, Timeout.InfiniteTimeSpan) |> ignore
+ | _ -> ()))
+
+ ctx)
+
+ new(watcher, onChanged) = new FSharpReferenceChangeTracker(watcher, onChanged, defaultNotificationDelay)
+
+ /// Starts watching a path, ref-counted. Call StopWatchingReference exactly once per start.
+ member _.StartWatchingReference(fullFilePath: string) =
+ lock gate (fun () ->
+ if not disposed then
+ match watchedFiles.TryGetValue fullFilePath with
+ | true, entry -> entry.Count <- entry.Count + 1
+ | _ ->
+ watchedFiles[fullFilePath] <-
+ {
+ Token = context.Value.EnqueueWatchingFile fullFilePath
+ Count = 1
+ Stamp = ValueNone
+ })
+
+ member _.StopWatchingReference(fullFilePath: string) =
+ lock gate (fun () ->
+ if not disposed then
+ match watchedFiles.TryGetValue fullFilePath with
+ | true, { Count = 1; Token = token } ->
+ watchedFiles.Remove fullFilePath |> ignore
+ token.Dispose()
+ | true, entry -> entry.Count <- entry.Count - 1
+ | _ -> ())
+
+ interface IReferenceStamps with
+ member _.GetLastWriteTimeUtc fullFilePath =
+ lock gate (fun () ->
+ match watchedFiles.TryGetValue fullFilePath with
+ | true, { Stamp = ValueSome stamp } -> stamp
+ | true, entry ->
+ let stamp = IO.File.GetLastWriteTimeUtc fullFilePath
+ entry.Stamp <- ValueSome stamp
+ stamp
+ | _ -> IO.File.GetLastWriteTimeUtc fullFilePath)
+
+ member _.Invalidate fullFilePath =
+ lock gate (fun () ->
+ match watchedFiles.TryGetValue fullFilePath with
+ | true, entry -> entry.Stamp <- ValueNone
+ | _ -> ())
+
+ member _.Dispose() =
+ lock gate (fun () ->
+ if not disposed then
+ disposed <- true
+ watchedFiles.Clear()
+
+ for KeyValue(_, timer) in pendingTimers do
+ timer.Dispose()
+
+ pendingTimers.Clear()
+
+ if context.IsValueCreated then
+ context.Value.Dispose())
+
+ interface IDisposable with
+ member this.Dispose() = this.Dispose()
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs
index 427baf0c6ab..810861270d8 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs
@@ -205,7 +205,11 @@ type internal FSharpWorkspaceServiceFactory
|> CancellableTask.startAsTask CancellationToken.None
|> ignore)
- let optionsManager = FSharpProjectOptionsManager(checker, workspace)
+ let fileChangeWatcher =
+ new FSharpFileChangeWatcher(FSharpFileChangeWatcher.CreateDefaultServiceTask())
+
+ let optionsManager =
+ FSharpProjectOptionsManager(checker, workspace, fileChangeWatcher)
{ new IFSharpWorkspaceService with
member _.Checker = checker
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
index 2406f3a6e32..4bdd9d432cb 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
@@ -212,17 +212,33 @@ module private CheckerExtensions =
|> CancellableTask.whenAll
|> CancellableTask.map (Seq.map (fun x -> x.ToString()) >> Set)
- let getOnDiskReferences (options: FSharpProjectOptions) =
- options.OtherOptions
- |> Seq.filter (fun x -> x.StartsWith("-r:"))
- |> Seq.map (fun x ->
- let path = x.Substring(3)
+ let getOnDiskReferences (stamps: IReferenceStamps) (options: FSharpProjectOptions) =
+ [
+ for option in options.OtherOptions do
+ if option.StartsWith("-r:", StringComparison.Ordinal) then
+ let path = option.Substring "-r:".Length
- {
- Path = path
- LastModified = System.IO.File.GetLastWriteTimeUtc path
- })
- |> Seq.toList
+ {
+ Path = path
+ LastModified = stamps.GetLastWriteTimeUtc path
+ }
+ ]
+
+ // A snapshot's own ReferencesOnDisk come from FCS stat'ing the files, so a mismatch with the
+ // cached stamps means one side is behind; dropping the stamps costs one re-stat instead of a
+ // rebuild on every future Project instance after a missed notification.
+ let referencesOnDiskChanged (project: Project) (oldSnapshot: FSharpProjectSnapshot) options =
+ let stamps =
+ project.Solution.GetFSharpWorkspaceService().FSharpProjectOptionsManager.ReferenceStamps
+
+ let current = getOnDiskReferences stamps options
+ let changed = current <> oldSnapshot.ProjectSnapshot.ReferencesOnDisk
+
+ if changed then
+ for reference in current do
+ stamps.Invalidate reference.Path
+
+ changed
let createProjectSnapshot (snapshotAccumulatorOpt) (project: Project) (options: FSharpProjectOptions option) =
cancellableTask {
@@ -246,9 +262,7 @@ module private CheckerExtensions =
System.Diagnostics.Trace.TraceWarning "Reference versions changed"
None
- | true, (true, (_, _, _, _, oldSnapshot: FSharpProjectSnapshot)) when
- oldSnapshot.ProjectSnapshot.ReferencesOnDisk <> (getOnDiskReferences options)
- ->
+ | true, (true, (_, _, _, _, oldSnapshot: FSharpProjectSnapshot)) when referencesOnDiskChanged project oldSnapshot options ->
System.Diagnostics.Trace.TraceWarning "References on disk changed"
None
@@ -295,7 +309,6 @@ module private CheckerExtensions =
| _ -> None
let! newSnapshot =
-
match updatedSnapshot with
| Some snapshot -> snapshot
| _ ->
@@ -616,7 +629,6 @@ type Document with
let! checker, _, _, projectOptions = this.GetFSharpCompilationOptionsAsync(userOpName)
let! symbolUses =
-
if this.Project.UseTransparentCompiler then
checker.FindBackgroundReferencesInFile(this.FilePath, projectSnapshot, symbol)
else
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
index ecce1205b8c..01b6be54542 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -82,6 +82,7 @@
+
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FileChangeWatcherTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FileChangeWatcherTests.fs
new file mode 100644
index 00000000000..269f5476603
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/FileChangeWatcherTests.fs
@@ -0,0 +1,360 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace FSharp.Editor.Tests
+
+open System
+open System.Collections.Immutable
+open System.IO
+open System.Threading
+open System.Threading.Tasks
+open Xunit
+open Microsoft.VisualStudio.Shell
+open Microsoft.VisualStudio.FSharp.Editor
+
+type private MockFileChangeContext() =
+ let fileChanged = Event()
+ let watched = ResizeArray()
+
+ member _.WatchedFiles = List.ofSeq watched
+ member _.Fire path = fileChanged.Trigger path
+
+ interface IFSharpFileChangeContext with
+ []
+ member _.FileChanged = fileChanged.Publish
+
+ member _.EnqueueWatchingFile path =
+ watched.Add path
+
+ { new IFSharpWatchedFile with
+ member _.Dispose() = watched.Remove path |> ignore
+ }
+
+ member _.Dispose() = watched.Clear()
+
+type private MockFileChangeWatcher() =
+ let mutable context: MockFileChangeContext voption = ValueNone
+
+ member _.Context = context
+
+ interface IFSharpFileChangeWatcher with
+ member _.CreateContext _ =
+ let ctx = new MockFileChangeContext()
+ context <- ValueSome ctx
+ ctx :> IFSharpFileChangeContext
+
+type private ServiceCall =
+ | AdvisedDir of path: string * cookie: uint32
+ | FilteredDir of cookie: uint32 * extensions: string list
+ | AdvisedFiles of paths: string list * sink: obj * cookies: uint32 list
+ | UnadvisedFiles of cookies: uint32 list
+ | UnadvisedDirs of cookies: uint32 list
+
+///
+/// Stands in for : hands out sequential cookies and records every call, so a
+/// test can see how the watcher turned its queue into service calls.
+///
+type private RecordingFileChangeService() =
+ let calls = ResizeArray()
+ let mutable nextCookie = 0u
+
+ let record call = lock calls (fun () -> calls.Add call)
+
+ let newCookie () =
+ nextCookie <- nextCookie + 1u
+ nextCookie
+
+ member _.Calls = lock calls (fun () -> List.ofSeq calls)
+
+ member this.WaitForCalls(count: int) =
+ let deadline = DateTime.UtcNow + TimeSpan.FromSeconds 10.
+
+ while lock calls (fun () -> calls.Count) < count && DateTime.UtcNow < deadline do
+ Thread.Sleep 10
+
+ this.Calls
+
+ interface IVsAsyncFileChangeEx2 with
+ member _.AdviseFileChangesAsync(filenames, _, sink, _) =
+ let cookies = [| for _ in filenames -> newCookie () |]
+ record (AdvisedFiles(List.ofSeq filenames, box sink, List.ofArray cookies))
+ Task.FromResult cookies
+
+ interface IVsAsyncFileChangeEx with
+ member _.AdviseFileChangeAsync(_, _, _, _) = Task.FromResult(newCookie ())
+ member _.UnadviseFileChangeAsync(_, _) = Task.FromResult ""
+
+ member _.UnadviseFileChangesAsync(cookies, _) =
+ record (UnadvisedFiles(List.ofSeq cookies))
+ Task.FromResult Array.empty
+
+ member _.AdviseDirChangeAsync(directory, _, _, _) =
+ let cookie = newCookie ()
+ record (AdvisedDir(directory, cookie))
+ Task.FromResult cookie
+
+ member _.UnadviseDirChangeAsync(_, _) = Task.FromResult ""
+
+ member _.UnadviseDirChangesAsync(cookies, _) =
+ record (UnadvisedDirs(List.ofSeq cookies))
+ Task.FromResult Array.empty
+
+ member _.SyncFileAsync(_, _) = Task.CompletedTask
+ member _.IgnoreFileAsync(_, _, _, _) = Task.CompletedTask
+ member _.IgnoreDirAsync(_, _, _) = Task.CompletedTask
+
+ member _.FilterDirectoryChangesAsync(cookie, extensions, _) =
+ record (FilteredDir(cookie, List.ofArray extensions))
+ Task.CompletedTask
+
+module FileChangeWatcherTests =
+
+ let private testDelay = TimeSpan.FromMilliseconds 50.
+
+ let private batchDelay = TimeSpan.FromMilliseconds 100.
+
+ let private noDirectories = ImmutableArray.Empty
+
+ let private createWatcher (service: RecordingFileChangeService) =
+ new FSharpFileChangeWatcher(Task.FromResult(service :> IVsAsyncFileChangeEx2), batchDelay)
+
+ []
+ let ``WatchedDirectory covers files under it matching the extension filter`` () =
+ let dirs =
+ ImmutableArray.Create(WatchedDirectory(@"C:\refs", ImmutableArray.Create ".dll"))
+
+ Assert.True(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refs\sub\a.dll"))
+ Assert.True(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\REFS\A.DLL"))
+ Assert.False(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refs\a.xml"))
+ Assert.False(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\other\a.dll"))
+
+ []
+ let ``WatchedDirectory without filters covers any file under it`` () =
+ let dirs =
+ ImmutableArray.Create(WatchedDirectory(@"C:\refs", ImmutableArray.Empty))
+
+ Assert.True(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refs\a.xml"))
+ Assert.False(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refsx\a.xml"))
+
+ []
+ let ``Tracker ref-counts subscriptions per path`` () =
+ let watcher = MockFileChangeWatcher()
+ use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay)
+
+ tracker.StartWatchingReference @"C:\x\a.dll"
+ tracker.StartWatchingReference @"C:\x\a.dll"
+ tracker.StartWatchingReference @"C:\x\b.dll"
+
+ Assert.Equal([ @"C:\x\a.dll"; @"C:\x\b.dll" ], watcher.Context.Value.WatchedFiles)
+
+ tracker.StopWatchingReference @"C:\x\a.dll"
+ Assert.Contains(@"C:\x\a.dll", watcher.Context.Value.WatchedFiles)
+
+ tracker.StopWatchingReference @"C:\x\a.dll"
+ Assert.Equal([ @"C:\x\b.dll" ], watcher.Context.Value.WatchedFiles)
+
+ []
+ let ``Tracker debounces bursts into a single callback for watched paths only`` () =
+ let watcher = MockFileChangeWatcher()
+ let calls = ResizeArray()
+ use signal = new ManualResetEventSlim(false)
+
+ use tracker =
+ new FSharpReferenceChangeTracker(
+ watcher,
+ (fun path ->
+ lock calls (fun () -> calls.Add path)
+ signal.Set()),
+ testDelay
+ )
+
+ tracker.StartWatchingReference @"C:\x\a.dll"
+ let context = watcher.Context.Value
+
+ context.Fire @"C:\x\a.dll"
+ context.Fire @"C:\x\a.dll"
+ context.Fire @"C:\x\unwatched.dll"
+
+ Assert.True(signal.Wait(TimeSpan.FromSeconds 10.))
+ // Allow a trailing duplicate timer to fire if one was pending.
+ Thread.Sleep(testDelay + testDelay)
+
+ Assert.Equal([ @"C:\x\a.dll" ], lock calls (fun () -> List.ofSeq calls))
+
+ []
+ let ``Disposed tracker ignores further changes`` () =
+ let watcher = MockFileChangeWatcher()
+ let mutable called = false
+
+ let tracker =
+ new FSharpReferenceChangeTracker(watcher, (fun _ -> called <- true), testDelay)
+
+ tracker.StartWatchingReference @"C:\x\a.dll"
+ let context = watcher.Context.Value
+ (tracker :> IDisposable).Dispose()
+
+ context.Fire @"C:\x\a.dll"
+ Thread.Sleep(testDelay + testDelay)
+
+ Assert.False called
+
+ []
+ let ``Consecutive file watches are advised in one service call`` () =
+ let service = RecordingFileChangeService()
+ use watcher = createWatcher service
+ use context = (watcher :> IFSharpFileChangeWatcher).CreateContext noDirectories
+
+ for path in [ @"C:\x\a.dll"; @"C:\x\b.dll"; @"C:\x\c.dll" ] do
+ context.EnqueueWatchingFile path |> ignore
+
+ match service.WaitForCalls 1 with
+ | [ AdvisedFiles(paths, _, cookies) ] ->
+ Assert.Equal([ @"C:\x\a.dll"; @"C:\x\b.dll"; @"C:\x\c.dll" ], paths)
+ Assert.Equal([ 1u; 2u; 3u ], cookies)
+ | calls -> failwith $"Unexpected calls: %A{calls}"
+
+ []
+ let ``A run of file watches is split when the sink changes`` () =
+ let service = RecordingFileChangeService()
+ use watcher = createWatcher service
+ let factory = watcher :> IFSharpFileChangeWatcher
+ use first = factory.CreateContext noDirectories
+ use second = factory.CreateContext noDirectories
+
+ first.EnqueueWatchingFile @"C:\x\a.dll" |> ignore
+ first.EnqueueWatchingFile @"C:\x\b.dll" |> ignore
+ second.EnqueueWatchingFile @"C:\x\c.dll" |> ignore
+
+ match service.WaitForCalls 2 with
+ | [ AdvisedFiles(firstPaths, firstSink, [ 1u; 2u ]); AdvisedFiles(secondPaths, secondSink, [ 3u ]) ] ->
+ Assert.Equal([ @"C:\x\a.dll"; @"C:\x\b.dll" ], firstPaths)
+ Assert.Equal([ @"C:\x\c.dll" ], secondPaths)
+ Assert.False(obj.ReferenceEquals(firstSink, secondSink))
+ | calls -> failwith $"Unexpected calls: %A{calls}"
+
+ []
+ let ``A watch followed by an unwatch in the same batch unadvises the cookie the watch received`` () =
+ let service = RecordingFileChangeService()
+ use watcher = createWatcher service
+ use context = (watcher :> IFSharpFileChangeWatcher).CreateContext noDirectories
+
+ let watched = context.EnqueueWatchingFile @"C:\x\a.dll"
+ watched.Dispose()
+
+ match service.WaitForCalls 2 with
+ | [ AdvisedFiles(_, _, [ advised ]); UnadvisedFiles [ unadvised ] ] -> Assert.Equal(advised, unadvised)
+ | calls -> failwith $"Unexpected calls: %A{calls}"
+
+ []
+ let ``Unwatching a token that holds no cookie is a no-op`` () =
+ let service = RecordingFileChangeService()
+ use watcher = createWatcher service
+ use context = (watcher :> IFSharpFileChangeWatcher).CreateContext noDirectories
+
+ let watched = context.EnqueueWatchingFile @"C:\x\a.dll"
+ service.WaitForCalls 1 |> ignore
+
+ watched.Dispose()
+ service.WaitForCalls 2 |> ignore
+
+ watched.Dispose()
+ Thread.Sleep(batchDelay + batchDelay + batchDelay)
+
+ match service.Calls with
+ | [ AdvisedFiles(_, _, [ advised ]); UnadvisedFiles [ unadvised ] ] -> Assert.Equal(advised, unadvised)
+ | calls -> failwith $"Unexpected calls: %A{calls}"
+
+ []
+ let ``Disposing a context unadvises its directory and remaining file cookies`` () =
+ let service = RecordingFileChangeService()
+ use watcher = createWatcher service
+
+ let directories =
+ ImmutableArray.Create(WatchedDirectory(@"C:\refs", ImmutableArray.Create ".dll"))
+
+ let context = (watcher :> IFSharpFileChangeWatcher).CreateContext directories
+ context.EnqueueWatchingFile @"C:\refs\covered.dll" |> ignore
+ context.EnqueueWatchingFile @"C:\other\a.dll" |> ignore
+ service.WaitForCalls 3 |> ignore
+
+ context.Dispose()
+
+ match service.WaitForCalls 5 with
+ | [ AdvisedDir(directory, 1u)
+ FilteredDir(1u, [ ".dll" ])
+ AdvisedFiles([ @"C:\other\a.dll" ], _, [ 2u ])
+ UnadvisedDirs [ 1u ]
+ UnadvisedFiles [ 2u ] ] -> Assert.Equal(@"C:\refs\", directory)
+ | calls -> failwith $"Unexpected calls: %A{calls}"
+
+ let private t0 = DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
+ let private t1 = t0.AddHours 1.
+
+ let private withTempFile (test: string -> unit) =
+ let path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.dll")
+ File.WriteAllBytes(path, Array.empty)
+ File.SetLastWriteTimeUtc(path, t0)
+
+ try
+ test path
+ finally
+ File.Delete path
+
+ []
+ let ``Watched path is served from the cache until a change notification`` () =
+ withTempFile (fun path ->
+ let watcher = MockFileChangeWatcher()
+ use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay)
+ let stamps = tracker :> IReferenceStamps
+
+ tracker.StartWatchingReference path
+ Assert.Equal(t0, stamps.GetLastWriteTimeUtc path)
+
+ File.SetLastWriteTimeUtc(path, t1)
+ Assert.Equal(t0, stamps.GetLastWriteTimeUtc path)
+
+ watcher.Context.Value.Fire path
+ Assert.Equal(t1, stamps.GetLastWriteTimeUtc path))
+
+ []
+ let ``Unwatched path is stat'd on every read`` () =
+ withTempFile (fun path ->
+ let watcher = MockFileChangeWatcher()
+ use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay)
+ let stamps = tracker :> IReferenceStamps
+
+ Assert.Equal(t0, stamps.GetLastWriteTimeUtc path)
+
+ File.SetLastWriteTimeUtc(path, t1)
+ Assert.Equal(t1, stamps.GetLastWriteTimeUtc path))
+
+ []
+ let ``Invalidate drops the cached stamp`` () =
+ withTempFile (fun path ->
+ let watcher = MockFileChangeWatcher()
+ use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay)
+ let stamps = tracker :> IReferenceStamps
+
+ tracker.StartWatchingReference path
+ Assert.Equal(t0, stamps.GetLastWriteTimeUtc path)
+
+ File.SetLastWriteTimeUtc(path, t1)
+ stamps.Invalidate path
+ Assert.Equal(t1, stamps.GetLastWriteTimeUtc path))
+
+ []
+ let ``Stopping the last watch on a path falls back to stat`` () =
+ withTempFile (fun path ->
+ let watcher = MockFileChangeWatcher()
+ use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay)
+ let stamps = tracker :> IReferenceStamps
+
+ tracker.StartWatchingReference path
+ tracker.StartWatchingReference path
+ Assert.Equal(t0, stamps.GetLastWriteTimeUtc path)
+
+ File.SetLastWriteTimeUtc(path, t1)
+ tracker.StopWatchingReference path
+ Assert.Equal(t0, stamps.GetLastWriteTimeUtc path)
+
+ tracker.StopWatchingReference path
+ Assert.Equal(t1, stamps.GetLastWriteTimeUtc path))