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))