Skip to content

FSharp.Editor: full project-options/snapshot invalidation on reference-assembly change instead of point-in-place metadata reference update (like Roslyn) #20131

Description

@xperiandri

Summary

FSharp.Editor now watches on-disk -r: reference assemblies via IVsFileChangeEx (see FileChangeWatcher.fs, FileChangeWatcherHub, and its consumers in FSharpProjectOptionsManager.fs and WorkspaceExtensions.fs) and invalidates caches reactively instead of polling File.GetLastWriteTimeUtc on every comparison.

However, when a watched reference assembly changes on disk, the current behavior is a coarse, whole-project invalidation:

  • FSharpProjectOptionsReactor (FSharpProjectOptionsManager.fs): cache.TryRemove(projectId) drops the entire cached (Project * FSharpParsingOptions * FSharpProjectOptions) tuple for the project, forcing a full recomputation of project options from scratch on next request.
  • Transparent-compiler snapshot path (WorkspaceExtensions.fs): latestSnapshots.TryRemove(project.Id) drops the entire cached FSharpProjectSnapshot, forcing FSharpProjectSnapshot.FromOptions to rebuild the whole snapshot (all file snapshots, not just the changed reference) on next request.

This is architecturally different from how Roslyn handles the equivalent scenario.

How Roslyn does it

Roslyn's VisualStudioMetadataReferenceManager / FileChangeWatcher infrastructure (src/VisualStudio/Core/Def/ProjectSystem/VisualStudioMetadataReferenceManager.cs and related FileWatchedReferenceFactory<T> types) watches individual metadata reference files (PE references) and, on change, invalidates only the cached PortableExecutableReference/MetadataReference snapshot for that specific file (keyed by path in a ReferenceCountedDisposable / weak-reference cache). Roslyn's immutable Solution/Project/Compilation snapshots are structured so that a single reference can be swapped:

  • Project.WithMetadataReferences(...) (and the underlying Compilation.ReplaceReference / Compilation.WithReferences) only replaces the one MetadataReference whose backing file changed; the rest of the Compilation (syntax trees, other references, symbol caches for unrelated assemblies) is structurally shared and reused.
  • Because Compilation is designed around incremental, reference-level substitution as a first-class operation, invalidating one reference does not force Roslyn to re-parse/re-bind the whole project — only re-resolve/re-bind against the one updated reference.

Why we don't do the same today

FSharpProjectOptions and FSharpProjectSnapshot are immutable value-like structures without a supported "replace this one reference and keep everything else structurally shared" API. FSharpChecker/FCS's incremental checking model is keyed off the whole FSharpProjectOptions/ProjectSnapshot identity (OtherOptions array, ReferencedProjects, etc.), not per-reference substitution. Introducing point-in-place reference updates would require:

  • A stable, addressable identity for each metadata/reference-assembly entry inside FSharpProjectOptions/ProjectSnapshot (today they're just -r: strings in OtherOptions, or FSharpReferencedProject values without independent cache keys).
  • Incremental-checking support in FCS's IncrementalBuilder/TransparentCompiler for substituting a single reference without invalidating the whole project's type-checking state (bound signature data, checked file results, etc. that depend on the reference set as a whole).
  • Care around cross-project referencing (AreFSharpInMemoryCrossProjectReferencesEnabled) where downstream projects' checked state also depends on the changed reference.

This is a change to the core compiler/FCS incremental-checking model, not something that can be done from the editor layer alone (vsintegration/src/FSharp.Editor).

Expected performance win

For solutions with many/large projects referencing a handful of frequently-rebuilt assemblies (e.g. multi-project solutions where a shared library is rebuilt in a loop during active development, or NuGet package caches refreshed on disk), whole-project invalidation means:

  • Every keystroke-triggered background check after an external rebuild re-parses and re-type-checks the entire project (all source files), instead of just re-resolving symbols against the one updated assembly.
  • For large projects (hundreds of files), this can mean seconds of unnecessary recomputation on every reference-assembly rebuild, compared to Roslyn's near-instantaneous reference swap for C#/VB projects of comparable size.
  • This especially affects "F5 workflows" where a referenced class library is rebuilt frequently while iterating on a consuming F# project open in the IDE.

Proposed follow-up

Track this as a separate, larger effort (likely spanning FSharp.Compiler.Service's IncrementalBuilder/TransparentCompiler and the FSharpProjectSnapshot/ProjectSnapshot model) to support per-reference invalidation/substitution, so the editor-layer watcher introduced in FileChangeWatcher.fs can eventually trigger a targeted reference swap instead of a full project options/snapshot rebuild.

Related

  • vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs
  • vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs
  • vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
  • src/Compiler/Service/FSharpProjectSnapshot.fs

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    New

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions