From c54f36f403f9180b127ca1bb46ff5ec4e11bbc1f Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 10 Jun 2026 12:27:01 -0400 Subject: [PATCH 01/13] Add SupportsHotReload project capability to F# SDK targets Mirror the C#/VB SupportsHotReload project capability for F# projects targeting .NET 6.0 or newer, with an opt-out via SupportsHotReload=false. Add coverage in GivenThatWeWantToBuildALibraryWithFSharp. --- .../targets/Microsoft.NET.Sdk.FSharp.targets | 5 ++ ...ivenThatWeWantToBuildALibraryWithFSharp.cs | 60 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets index 0f0a6f61011e..6d3ac366c2d5 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets @@ -24,4 +24,9 @@ Copyright (c) .NET Foundation. All rights reserved. + + + + + diff --git a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs index ca283d63f277..af33267100dc 100644 --- a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs +++ b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs @@ -63,12 +63,11 @@ internal static List GetValuesFromTestLibrary( string[] msbuildArgs = null, GetValuesCommand.ValueType valueType = GetValuesCommand.ValueType.Item, [CallerMemberName] string callingMethod = "", - Action projectChanges = null) + Action projectChanges = null, + string targetFramework = "netstandard2.0") { msbuildArgs = msbuildArgs ?? Array.Empty(); - string targetFramework = "netstandard2.0"; - var testAsset = testAssetsManager .CopyTestAsset("AppWithLibraryFS", callingMethod) .WithSource(); @@ -226,5 +225,60 @@ public void It_implicitly_defines_compilation_constants_for_the_target_framework definedConstants.Should().BeEquivalentTo(new[] { "DEBUG", "TRACE" }.Concat(expectedDefines).ToArray()); } + + [Fact] + public void It_sets_SupportsHotReload_capability_for_net6_or_newer() + { + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + valueType: GetValuesCommand.ValueType.Item, + projectChanges: project => + { + var ns = project.Root.Name.Namespace; + project.Root.Descendants(ns + "TargetFramework").Single().Value = "net6.0"; + }, + targetFramework: "net6.0"); + + projectCapabilities.Should().Contain("SupportsHotReload"); + } + + [Fact] + public void It_does_not_set_SupportsHotReload_capability_for_pre_net6() + { + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + valueType: GetValuesCommand.ValueType.Item, + projectChanges: project => + { + var ns = project.Root.Name.Namespace; + project.Root.Descendants(ns + "TargetFramework").Single().Value = "net5.0"; + }, + targetFramework: "net5.0"); + + projectCapabilities.Should().NotContain("SupportsHotReload"); + } + + [Fact] + public void It_respects_SupportsHotReload_false_override() + { + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + valueType: GetValuesCommand.ValueType.Item, + projectChanges: project => + { + var ns = project.Root.Name.Namespace; + project.Root.Descendants(ns + "TargetFramework").Single().Value = "net6.0"; + project.Root.Descendants(ns + "PropertyGroup").First().Add(new XElement(ns + "SupportsHotReload", "false")); + }, + targetFramework: "net6.0"); + + projectCapabilities.Should().NotContain("SupportsHotReload"); + } } } From d27cb5431faf32ff2dcab9db6031cb914b5b8fd5 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 10 Jun 2026 12:33:53 -0400 Subject: [PATCH 02/13] Add F# hot reload service for dotnet-watch Add FSharpHotReloadService and FSharpProjectInfo, which bridge dotnet-watch to FSharp.Compiler.Service hot reload APIs (StartHotReloadSession, EmitHotReloadDelta, EndHotReloadSession) via reflection. The service discovers F# projects in the project graph, tracks per-project compiler inputs, and emits managed code deltas for changed F# sources, with an optional FSharpWorkspace snapshot bridge for custom compiler service builds. --- .../FSharp/FSharpHotReloadService.cs | 1760 +++++++++++++++++ .../HotReload/FSharp/FSharpProjectInfo.cs | 146 ++ 2 files changed, 1906 insertions(+) create mode 100644 src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs create mode 100644 src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpProjectInfo.cs diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs new file mode 100644 index 000000000000..5e387ed071fe --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -0,0 +1,1760 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal enum FSharpManagedUpdateStatus +{ + NoChanges, + ReadyToApply, + RestartRequired, + Blocked, +} + +internal readonly record struct FSharpManagedUpdate(string ProjectPath, HotReloadManagedCodeUpdate Update); + +internal readonly record struct FSharpManagedUpdateResult( + FSharpManagedUpdateStatus Status, + ImmutableArray Updates, + string? ProjectPath, + string? Message); + +internal sealed class FSharpHotReloadService +{ + private readonly ILogger _logger; + private readonly bool _trace; + + private ImmutableDictionary _projects = ImmutableDictionary.Empty; + private ImmutableDictionary _cachedProjectInputs = ImmutableDictionary.Empty; + private ImmutableDictionary _runtimeModuleIds = ImmutableDictionary.Empty; + private FSharpReflectionHost? _host; + private ProjectInstanceId? _activeProject; + private object? _activeProjectInput; + + public FSharpHotReloadService(ILogger logger) + { + _logger = logger; + _trace = IsTraceEnabled(); + } + + public void UpdateProjects(ProjectGraph projectGraph) + { + _projects = FSharpProjectInfo.Collect(projectGraph, _logger); + _cachedProjectInputs = _cachedProjectInputs.RemoveRange(_cachedProjectInputs.Keys.Where(key => !_projects.ContainsKey(key))); + _runtimeModuleIds = _runtimeModuleIds.RemoveRange(_runtimeModuleIds.Keys.Where(key => !_projects.ContainsKey(key))); + + if (_activeProject is { } activeProject && !_projects.ContainsKey(activeProject)) + { + EndSession(); + } + } + + public ValueTask StartSessionAsync(CancellationToken cancellationToken) + { + // Prime per-project inputs from the baseline build before edits arrive. + // This mirrors Roslyn's committed-solution model where the first edit is compared + // against the last built state rather than being used as the session baseline. + var cachedInputsBuilder = ImmutableDictionary.CreateBuilder(); + + foreach (var projectInfo in _projects.Values) + { + if (!TryGetHost(projectInfo, out var host, out var hostError)) + { + if (_trace) + { + _logger.LogDebug( + "Skipping F# project input bootstrap for '{ProjectPath}': {Message}", + projectInfo.ProjectPath, + hostError); + } + + continue; + } + + if (!host.TryCreateProjectInput(projectInfo, out var projectInput, out var inputError) || projectInput == null) + { + if (_trace) + { + _logger.LogDebug( + "Skipping F# project input bootstrap for '{ProjectPath}': {Message}", + projectInfo.ProjectPath, + inputError ?? "Project input was null."); + } + + continue; + } + + cachedInputsBuilder[projectInfo.ProjectId] = projectInput; + } + + _cachedProjectInputs = cachedInputsBuilder.ToImmutable(); + _runtimeModuleIds = ImmutableDictionary.Empty; + + if (_projects.Count == 1) + { + var projectInfo = _projects.Values.First(); + if (TryGetHost(projectInfo, out var host, out var hostError)) + { + if (!EnsureSession(host, projectInfo, out var status, out var message)) + { + if (_trace) + { + _logger.LogDebug( + "Unable to prestart F# hot reload session for '{ProjectPath}': {Status} ({Message})", + projectInfo.ProjectPath, + status, + message); + } + } + else if (_trace) + { + _logger.LogDebug("F# hot reload session prestarted for '{ProjectPath}'.", projectInfo.ProjectPath); + } + } + else if (_trace) + { + _logger.LogDebug( + "Unable to prestart F# hot reload session for '{ProjectPath}': {Message}", + projectInfo.ProjectPath, + hostError); + } + } + + return ValueTask.CompletedTask; + } + + public void EndSession() + { + if (_activeProject is { } activeProject) + { + _runtimeModuleIds = _runtimeModuleIds.Remove(activeProject); + } + + _host?.TryEndSession(); + _activeProject = null; + _activeProjectInput = null; + } + +#pragma warning disable CS1998 // Intentional sync fast-path wrapped in ValueTask-returning API. + public async ValueTask TryEmitUpdatesAsync( + IReadOnlyList changedFiles, + ImmutableDictionary> runningProjects, + CancellationToken cancellationToken) + { + var changedProject = TryGetChangedRunningFSharpProject(changedFiles, runningProjects); + if (changedProject == null) + { + if (_trace) + { + _logger.LogDebug("No running F# project matched the current file changes."); + } + + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.NoChanges, [], null, null); + } + + if (!_projects.TryGetValue(changedProject.Value, out var projectInfo)) + { + if (_trace) + { + _logger.LogDebug( + "Changed F# project '{ProjectPath}' is not present in the current project graph snapshot.", + changedProject.Value.ProjectPath); + } + + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.NoChanges, [], null, null); + } + + if (!TryGetHost(projectInfo, out var host, out var hostError)) + { + _logger.LogDebug( + "F# managed hot reload bridge unavailable for '{ProjectPath}': {Message}. Falling back to restart.", + projectInfo.ProjectPath, + hostError); + + return new FSharpManagedUpdateResult( + FSharpManagedUpdateStatus.RestartRequired, + [], + projectInfo.ProjectPath, + hostError); + } + + if (!EnsureSession(host, projectInfo, out var ensureStatus, out var ensureMessage)) + { + return new FSharpManagedUpdateResult(ensureStatus, [], projectInfo.ProjectPath, ensureMessage); + } + + var moduleIdBeforeCompile = TryGetModuleVersionId(projectInfo.TargetPath); + if (!TryCompileProjectOutput(projectInfo, out var compileMessage)) + { + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.Blocked, [], projectInfo.ProjectPath, compileMessage); + } + + var changedSourceFiles = GetChangedSourceFilesForProject(changedFiles, projectInfo.ProjectId); + var changedDependencyFiles = GetChangedDependencyFilesForProject(changedFiles, projectInfo); + + if (_trace && !changedDependencyFiles.IsEmpty) + { + _logger.LogDebug( + "F# dependency changes considered for managed update in '{ProjectPath}': {ChangedFiles}", + projectInfo.ProjectPath, + string.Join(", ", changedDependencyFiles)); + } + + foreach (var changedSourceFile in changedSourceFiles) + { + host.NotifyFileChanged(changedSourceFile, projectInfo, _activeProjectInput!, cancellationToken); + } + + if (!changedDependencyFiles.IsEmpty) + { + host.InvalidateConfiguration(_activeProjectInput!, projectInfo.ProjectPath); + } + + if (!host.TryRefreshProjectInput(projectInfo, _activeProjectInput!, out var refreshedProjectInput, out var refreshMessage)) + { + return new FSharpManagedUpdateResult( + FSharpManagedUpdateStatus.RestartRequired, + [], + projectInfo.ProjectPath, + refreshMessage); + } + + _activeProjectInput = refreshedProjectInput; + _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, refreshedProjectInput!); + + var moduleIdAfterCompile = TryGetModuleVersionId(projectInfo.TargetPath); + var targetModuleId = + _runtimeModuleIds.TryGetValue(projectInfo.ProjectId, out var runtimeModuleId) + ? runtimeModuleId + : moduleIdBeforeCompile ?? moduleIdAfterCompile; + if (targetModuleId == null) + { + var message = $"Unable to read module id from '{projectInfo.TargetPath}'."; + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.RestartRequired, [], projectInfo.ProjectPath, message); + } + + if (_trace) + { + if (moduleIdBeforeCompile == null) + { + _logger.LogDebug( + "F# target module id for '{ProjectPath}' was unavailable before forced compile; post-compile id={ModuleId}.", + projectInfo.ProjectPath, + moduleIdAfterCompile); + } + else if (moduleIdAfterCompile == null) + { + _logger.LogDebug( + "F# target module id unavailable after forced compile for '{ProjectPath}'; using pre-compile id={ModuleId}.", + projectInfo.ProjectPath, + moduleIdBeforeCompile.Value); + } + else if (moduleIdBeforeCompile != moduleIdAfterCompile.Value) + { + _logger.LogDebug( + "F# target module id changed after forced compile for '{ProjectPath}': before={BeforeModuleId}, after={AfterModuleId}; targeting loaded module id={TargetModuleId}.", + projectInfo.ProjectPath, + moduleIdBeforeCompile.Value, + moduleIdAfterCompile.Value, + targetModuleId.Value); + } + else + { + _logger.LogDebug( + "F# target module id for '{ProjectPath}' is stable across forced compile: {ModuleId}.", + projectInfo.ProjectPath, + targetModuleId.Value); + } + } + + var emit = host.EmitDelta(_activeProjectInput!, cancellationToken); + if (!emit.IsSuccess) + { + var mappedStatus = MapErrorStatus(emit.ErrorCase); + + if (emit.ErrorCase == "NoActiveSession") + { + if (_trace) + { + _logger.LogDebug("F# hot reload session went inactive for '{ProjectPath}', restarting session.", projectInfo.ProjectPath); + } + + EndSession(); + if (EnsureSession(host, projectInfo, out var retryStatus, out var retryMessage)) + { + emit = host.EmitDelta(_activeProjectInput!, cancellationToken); + if (emit.IsSuccess) + { + mappedStatus = FSharpManagedUpdateStatus.ReadyToApply; + } + else + { + mappedStatus = MapErrorStatus(emit.ErrorCase); + } + } + else + { + return new FSharpManagedUpdateResult(retryStatus, [], projectInfo.ProjectPath, retryMessage); + } + } + + if (mappedStatus == FSharpManagedUpdateStatus.NoChanges) + { + if (!changedSourceFiles.IsEmpty) + { + var message = "F# managed update produced no semantic delta for edited source files."; + + if (_trace) + { + _logger.LogDebug( + "{Message} Project='{ProjectPath}', Files='{ChangedFiles}'", + message, + projectInfo.ProjectPath, + string.Join(", ", changedSourceFiles)); + } + } + else if (!changedDependencyFiles.IsEmpty && _trace) + { + _logger.LogDebug( + "F# managed update produced no semantic delta for edited dependency files. Project='{ProjectPath}', Files='{ChangedFiles}'", + projectInfo.ProjectPath, + string.Join(", ", changedDependencyFiles)); + } + + // Roslyn parity: source edits with insignificant/no semantic changes stay in NoChangesToApply + // and should not force restart/rebuild of the running process. + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.NoChanges, [], null, null); + } + + if (mappedStatus == FSharpManagedUpdateStatus.Blocked) + { + _logger.Log(MessageDescriptor.UnableToApplyChanges); + if (!string.IsNullOrEmpty(emit.ErrorText)) + { + _logger.LogWarning("F# compilation blocked hot reload: {Message}", emit.ErrorText); + } + } + else + { + _logger.LogDebug( + "F# managed hot reload requires restart for '{ProjectPath}': {ErrorCase} ({ErrorText})", + projectInfo.ProjectPath, + emit.ErrorCase, + emit.ErrorText); + } + + if (mappedStatus == FSharpManagedUpdateStatus.RestartRequired || mappedStatus == FSharpManagedUpdateStatus.Blocked) + { + _runtimeModuleIds = _runtimeModuleIds.Remove(projectInfo.ProjectId); + } + + return new FSharpManagedUpdateResult(mappedStatus, [], projectInfo.ProjectPath, emit.ErrorText); + } + + var update = host.CreateManagedUpdate(targetModuleId.Value, emit.Value!); + if (update == null) + { + return new FSharpManagedUpdateResult( + FSharpManagedUpdateStatus.RestartRequired, + [], + projectInfo.ProjectPath, + "Unable to decode F# delta payload."); + } + + if (_trace) + { + _logger.LogDebug( + "F# managed delta ready for '{ProjectPath}' (metadata={MetadataBytes} il={IlBytes} pdb={PdbBytes}).", + projectInfo.ProjectPath, + update.Value.MetadataDelta.Length, + update.Value.ILDelta.Length, + update.Value.PdbDelta.Length); + } + + _runtimeModuleIds = _runtimeModuleIds.SetItem(projectInfo.ProjectId, targetModuleId.Value); + + return new FSharpManagedUpdateResult( + FSharpManagedUpdateStatus.ReadyToApply, + [new FSharpManagedUpdate(projectInfo.ProjectPath, update.Value)], + projectInfo.ProjectPath, + null); + } +#pragma warning restore CS1998 + + private ProjectInstanceId? TryGetChangedRunningFSharpProject( + IReadOnlyList changedFiles, + ImmutableDictionary> runningProjects) + { + foreach (var file in changedFiles) + { + var filePath = file.Item.FilePath; + var isSourceChange = IsFSharpSourcePath(filePath); + var isDependencyChange = IsManagedDependencyCandidatePath(filePath); + + if (_trace) + { + _logger.LogDebug( + "F# changed file candidate '{FilePath}': source={IsSourceChange}, dependency={IsDependencyChange}, containingProjects=[{ContainingProjects}].", + filePath, + isSourceChange, + isDependencyChange, + string.Join(", ", file.Item.ContainingProjectPaths)); + } + + if (!isSourceChange && !isDependencyChange) + { + continue; + } + + if (TryMatchRunningProjectByContainingPaths(file.Item.ContainingProjectPaths, runningProjects, out var containingProject)) + { + return containingProject; + } + + if (isDependencyChange && + TryMatchRunningProjectByDependencyPath(filePath, runningProjects, out var dependencyProject)) + { + if (_trace) + { + _logger.LogDebug( + "F# dependency change '{FilePath}' matched project '{ProjectPath}' via command-line dependency mapping.", + filePath, + dependencyProject.ProjectPath); + } + + return dependencyProject; + } + + if ((isSourceChange || isDependencyChange) && + TryMatchRunningProjectByPath(filePath, runningProjects, out var fallbackProject)) + { + if (_trace) + { + _logger.LogDebug( + "F# changed file '{FilePath}' matched project '{ProjectPath}' using path fallback ({ChangeKind}).", + filePath, + fallbackProject.ProjectPath, + isSourceChange ? "source" : "dependency"); + } + + return fallbackProject; + } + } + + if (_trace) + { + var candidateFiles = changedFiles + .Where(file => + { + var candidatePath = file.Item.FilePath; + return IsFSharpSourcePath(candidatePath) || IsManagedDependencyCandidatePath(candidatePath); + }) + .Select(file => file.Item.FilePath) + .ToImmutableArray(); + + if (!candidateFiles.IsEmpty) + { + _logger.LogDebug( + "F# change matching failed. Candidates=[{ChangedFiles}] RunningProjects=[{RunningProjects}] KnownProjects=[{KnownProjects}].", + string.Join(", ", candidateFiles), + string.Join(", ", runningProjects.Keys.OrderBy(static key => key)), + string.Join(", ", _projects.Keys.Select(static project => project.ProjectPath).OrderBy(static key => key))); + } + } + + return null; + } + + private bool TryMatchRunningProjectByContainingPaths( + IReadOnlyList containingProjectPaths, + ImmutableDictionary> runningProjects, + out ProjectInstanceId projectId) + { + projectId = default; + + foreach (var containingProjectPath in containingProjectPaths) + { + if (!runningProjects.ContainsKey(containingProjectPath)) + { + continue; + } + + foreach (var knownProject in _projects.Keys) + { + if (PathUtilities.OSSpecificPathComparer.Equals(knownProject.ProjectPath, containingProjectPath)) + { + projectId = knownProject; + return true; + } + } + } + + return false; + } + + private bool TryMatchRunningProjectByDependencyPath( + string filePath, + ImmutableDictionary> runningProjects, + out ProjectInstanceId projectId) + { + projectId = default; + + if (!TryNormalizeFullPath(filePath, out var normalizedFilePath)) + { + return false; + } + + foreach (var projectInfo in _projects.Values) + { + if (!runningProjects.ContainsKey(projectInfo.ProjectPath)) + { + continue; + } + + if (IsCommandLineDependencyPath(normalizedFilePath, projectInfo)) + { + projectId = projectInfo.ProjectId; + return true; + } + } + + return false; + } + + private bool TryMatchRunningProjectByPath( + string filePath, + ImmutableDictionary> runningProjects, + out ProjectInstanceId projectId) + { + projectId = default; + + string normalizedFilePath; + try + { + normalizedFilePath = Path.GetFullPath(filePath); + } + catch + { + return false; + } + + foreach (var knownProject in _projects.Keys) + { + if (!runningProjects.ContainsKey(knownProject.ProjectPath)) + { + continue; + } + + if (IsFileWithinProjectDirectory(normalizedFilePath, knownProject.ProjectPath)) + { + projectId = knownProject; + return true; + } + } + + return false; + } + + private ImmutableArray GetChangedDependencyFilesForProject( + IReadOnlyList changedFiles, + FSharpProjectInfo projectInfo) + { + var changedDependencyFiles = new HashSet(PathUtilities.OSSpecificPathComparer); + + foreach (var changedFile in changedFiles) + { + var filePath = changedFile.Item.FilePath; + if (!IsManagedDependencyCandidatePath(filePath)) + { + continue; + } + + var isProjectMatchFromContainingPaths = + changedFile.Item.ContainingProjectPaths.Any(containingProjectPath => + PathUtilities.OSSpecificPathComparer.Equals(containingProjectPath, projectInfo.ProjectPath)); + var isCommandLineDependency = IsCommandLineDependencyPath(filePath, projectInfo); + var isProjectDirectoryDependency = IsFileWithinProjectDirectory(filePath, projectInfo.ProjectPath); + if (!isProjectMatchFromContainingPaths && !isCommandLineDependency && !isProjectDirectoryDependency) + { + continue; + } + + if (TryNormalizeFullPath(filePath, out var normalizedFilePath)) + { + changedDependencyFiles.Add(normalizedFilePath); + } + else + { + changedDependencyFiles.Add(filePath); + } + } + + return [.. changedDependencyFiles]; + } + + private ImmutableArray GetChangedSourceFilesForProject( + IReadOnlyList changedFiles, + ProjectInstanceId projectId) + { + var changedSourceFiles = new HashSet(PathUtilities.OSSpecificPathComparer); + + foreach (var changedFile in changedFiles) + { + var filePath = changedFile.Item.FilePath; + if (!IsFSharpSourcePath(filePath)) + { + continue; + } + + var isProjectMatchFromContainingPaths = + changedFile.Item.ContainingProjectPaths.Any(containingProjectPath => + PathUtilities.OSSpecificPathComparer.Equals(containingProjectPath, projectId.ProjectPath)); + + if (isProjectMatchFromContainingPaths || IsFileWithinProjectDirectory(filePath, projectId.ProjectPath)) + { + try + { + changedSourceFiles.Add(Path.GetFullPath(filePath)); + } + catch + { + changedSourceFiles.Add(filePath); + } + } + } + + return [.. changedSourceFiles]; + } + + private static bool IsFileWithinProjectDirectory(string filePath, string projectPath) + { + try + { + var projectDirectory = Path.GetDirectoryName(projectPath); + if (projectDirectory == null) + { + return false; + } + + var normalizedFilePath = Path.GetFullPath(filePath); + var normalizedProjectDirectory = PathUtilities.EnsureTrailingSlash(Path.GetFullPath(projectDirectory)); + return normalizedFilePath.StartsWith(normalizedProjectDirectory, PathUtilities.OSSpecificPathComparison); + } + catch + { + return false; + } + } + + private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectInfo, out FSharpManagedUpdateStatus status, out string? message) + { + status = FSharpManagedUpdateStatus.NoChanges; + message = null; + + if (_activeProject is { } activeProject && + _activeProjectInput != null && + activeProject.Equals(projectInfo.ProjectId)) + { + return true; + } + + EndSession(); + + if (!_cachedProjectInputs.TryGetValue(projectInfo.ProjectId, out var projectInput) && + !host.TryCreateProjectInput(projectInfo, out projectInput, out message)) + { + status = FSharpManagedUpdateStatus.RestartRequired; + return false; + } + + var start = host.StartSession(projectInput!, CancellationToken.None); + if (!start.IsSuccess) + { + status = MapErrorStatus(start.ErrorCase); + message = start.ErrorText; + + if (status == FSharpManagedUpdateStatus.Blocked) + { + _logger.Log(MessageDescriptor.UnableToApplyChanges); + } + + return false; + } + + _activeProject = projectInfo.ProjectId; + _activeProjectInput = projectInput; + _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, projectInput!); + return true; + } + + private bool TryGetHost(FSharpProjectInfo projectInfo, out FSharpReflectionHost host, out string? message) + { + host = _host!; + message = null; + + if (_host != null) + { + host = _host; + return true; + } + + if (FSharpReflectionHost.TryCreate(projectInfo, _logger, _trace, out var createdHost, out message)) + { + _host = createdHost; + host = createdHost; + return true; + } + + return false; + } + + private static FSharpManagedUpdateStatus MapErrorStatus(string? errorCase) + => errorCase switch + { + "NoChanges" => FSharpManagedUpdateStatus.NoChanges, + "CompilationFailed" => FSharpManagedUpdateStatus.Blocked, + _ => FSharpManagedUpdateStatus.RestartRequired, + }; + + private static Guid? TryGetModuleVersionId(string assemblyPath) + { + try + { + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + var metadataReader = peReader.GetMetadataReader(); + return metadataReader.GetGuid(metadataReader.GetModuleDefinition().Mvid); + } + catch + { + return null; + } + } + + private static bool IsFSharpSourcePath(string path) + { + var extension = Path.GetExtension(path); + return string.Equals(extension, ".fs", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".fsi", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".fsx", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsManagedDependencyCandidatePath(string path) + { + if (string.IsNullOrWhiteSpace(path) || IsFSharpSourcePath(path)) + { + return false; + } + + var fileName = Path.GetFileName(path); + if (fileName.Length == 0) + { + return false; + } + + if (fileName.EndsWith("~", StringComparison.Ordinal) || + fileName.StartsWith("~$", StringComparison.Ordinal) || + (fileName.StartsWith("#", StringComparison.Ordinal) && fileName.EndsWith("#", StringComparison.Ordinal))) + { + return false; + } + + var extension = Path.GetExtension(path); + if (string.IsNullOrEmpty(extension)) + { + return false; + } + + if (string.Equals(extension, ".fsproj", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".props", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".targets", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".sln", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".slnx", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".proj", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(extension, ".swp", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".swo", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".swx", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".tmp", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".temp", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + private static bool IsCommandLineDependencyPath(string filePath, FSharpProjectInfo projectInfo) + { + if (!TryNormalizeFullPath(filePath, out var normalizedFilePath)) + { + return false; + } + + var projectDirectory = Path.GetDirectoryName(projectInfo.ProjectPath) ?? Directory.GetCurrentDirectory(); + foreach (var commandLineArg in projectInfo.CommandLineArgs) + { + if (!TryGetCommandLineDependencyPath(commandLineArg, projectDirectory, out var dependencyPath)) + { + continue; + } + + if (PathUtilities.OSSpecificPathComparer.Equals(normalizedFilePath, dependencyPath)) + { + return true; + } + } + + return false; + } + + private static bool TryGetCommandLineDependencyPath(string commandLineArg, string projectDirectory, out string? dependencyPath) + { + dependencyPath = null; + + if (string.IsNullOrWhiteSpace(commandLineArg)) + { + return false; + } + + var arg = commandLineArg.Trim().Trim('"'); + if (arg.Length == 0) + { + return false; + } + + if (arg.StartsWith("-r:", StringComparison.OrdinalIgnoreCase) || + arg.StartsWith("--reference:", StringComparison.OrdinalIgnoreCase) || + arg.StartsWith("-o:", StringComparison.OrdinalIgnoreCase) || + arg.StartsWith("--out:", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + { + if (!TryExtractValueFromKnownPrefix( + arg, + ["--resource:", "-resource:", "--res:", "-res:", "--win32res:", "--keyfile:", "--load:", "--use:"], + out var optionValue, + out var matchedPrefix)) + { + return false; + } + + if (matchedPrefix is "--resource:" or "-resource:" or "--res:" or "-res:" or "--win32res:") + { + // F# resource switches can include metadata after commas, e.g. --resource:path,logicalName. + // We only need the physical file path for dependency invalidation matching. + var commaIndex = optionValue!.IndexOf(','); + if (commaIndex >= 0) + { + optionValue = optionValue[..commaIndex]; + } + } + + return TryNormalizeDependencyPath(optionValue!, projectDirectory, out dependencyPath); + } + + return TryNormalizeDependencyPath(arg, projectDirectory, out dependencyPath); + } + + private static bool TryExtractValueFromKnownPrefix( + string value, + IReadOnlyList prefixes, + out string? extractedValue, + out string? matchedPrefix) + { + foreach (var prefix in prefixes) + { + if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && value.Length > prefix.Length) + { + extractedValue = value[prefix.Length..]; + matchedPrefix = prefix; + return true; + } + } + + extractedValue = null; + matchedPrefix = null; + return false; + } + + private static bool TryNormalizeDependencyPath(string path, string projectDirectory, out string? normalizedPath) + { + normalizedPath = null; + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var candidatePath = path.Trim().Trim('"'); + if (candidatePath.Length == 0) + { + return false; + } + + if (!Path.IsPathRooted(candidatePath)) + { + candidatePath = Path.Combine(projectDirectory, candidatePath); + } + + return TryNormalizeFullPath(candidatePath, out normalizedPath); + } + + private static bool TryNormalizeFullPath(string path, out string normalizedPath) + { + normalizedPath = path; + try + { + normalizedPath = Path.GetFullPath(path); + return true; + } + catch + { + return false; + } + } + + private static bool IsTraceEnabled() + { + var value = Environment.GetEnvironmentVariable("DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD"); + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + private bool TryCompileProjectOutput(FSharpProjectInfo projectInfo, out string? message) + { + message = null; + + try + { + var projectDirectory = Path.GetDirectoryName(projectInfo.ProjectPath) ?? Directory.GetCurrentDirectory(); + + var dotnetHostPath = + Environment.ProcessPath ?? + Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? + "dotnet"; + + var startInfo = new ProcessStartInfo + { + FileName = dotnetHostPath, + WorkingDirectory = projectDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + // Use full build semantics so F# project outputs (DLL/PDB) are refreshed for delta emission. + // -t:Compile can leave HotReload target outputs stale for SDK-style F# projects. + startInfo.ArgumentList.Add("build"); + startInfo.ArgumentList.Add(projectInfo.ProjectPath); + startInfo.ArgumentList.Add("-nologo"); + startInfo.ArgumentList.Add("-consoleLoggerParameters:NoSummary;Verbosity=minimal"); + startInfo.ArgumentList.Add("-p:NuGetInteractive=true"); + + using var process = Process.Start(startInfo); + if (process == null) + { + message = $"Failed to start dotnet build for '{projectInfo.ProjectPath}'."; + return false; + } + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + + if (process.ExitCode == 0) + { + return true; + } + + var standardOutput = standardOutputTask.GetAwaiter().GetResult(); + var standardError = standardErrorTask.GetAwaiter().GetResult(); + var details = string.IsNullOrWhiteSpace(standardError) ? standardOutput : standardError; + message = $"dotnet build failed for '{projectInfo.ProjectPath}' (exit code {process.ExitCode}). {details.Trim()}"; + return false; + } + catch (Exception ex) + { + message = ex.Message; + return false; + } + } + + private sealed class FSharpReflectionHost + { + private readonly ILogger _logger; + private readonly bool _trace; + private readonly object _checker; + private readonly MethodInfo _getProjectOptions; + private readonly MethodInfo _startSession; + private readonly MethodInfo? _notifyFileChanged; + private readonly MethodInfo _emitDelta; + private readonly MethodInfo _endSession; + private readonly ImmutableArray _invalidateConfigurationMethods; + private readonly MethodInfo _runSynchronously; + private readonly bool _useWorkspaceSnapshots; + private readonly object? _workspaceProjects; + private readonly object? _workspaceFiles; + private readonly object? _workspaceQuery; + private readonly MethodInfo? _workspaceProjectAddOrUpdate; + private readonly MethodInfo? _workspaceQueryGetProjectSnapshot; + private readonly MethodInfo? _workspaceFilesEdit; + private readonly MethodInfo? _workspaceFilesClose; + + private FSharpReflectionHost( + ILogger logger, + bool trace, + object checker, + MethodInfo getProjectOptions, + MethodInfo startSession, + MethodInfo? notifyFileChanged, + MethodInfo emitDelta, + MethodInfo endSession, + ImmutableArray invalidateConfigurationMethods, + MethodInfo runSynchronously, + bool useWorkspaceSnapshots, + object? workspaceProjects, + object? workspaceFiles, + object? workspaceQuery, + MethodInfo? workspaceProjectAddOrUpdate, + MethodInfo? workspaceQueryGetProjectSnapshot, + MethodInfo? workspaceFilesEdit, + MethodInfo? workspaceFilesClose) + { + _logger = logger; + _trace = trace; + _checker = checker; + _getProjectOptions = getProjectOptions; + _startSession = startSession; + _notifyFileChanged = notifyFileChanged; + _emitDelta = emitDelta; + _endSession = endSession; + _invalidateConfigurationMethods = invalidateConfigurationMethods; + _runSynchronously = runSynchronously; + _useWorkspaceSnapshots = useWorkspaceSnapshots; + _workspaceProjects = workspaceProjects; + _workspaceFiles = workspaceFiles; + _workspaceQuery = workspaceQuery; + _workspaceProjectAddOrUpdate = workspaceProjectAddOrUpdate; + _workspaceQueryGetProjectSnapshot = workspaceQueryGetProjectSnapshot; + _workspaceFilesEdit = workspaceFilesEdit; + _workspaceFilesClose = workspaceFilesClose; + } + + public static bool TryCreate( + FSharpProjectInfo projectInfo, + ILogger logger, + bool trace, + out FSharpReflectionHost host, + out string? error) + { + host = null!; + error = null; + + var servicePathOverride = Environment.GetEnvironmentVariable("DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"); + var servicePath = servicePathOverride; + if (string.IsNullOrEmpty(servicePath)) + { + var compilerDirectory = Path.GetDirectoryName(projectInfo.DotnetFscCompilerPath); + servicePath = compilerDirectory == null ? null : Path.Combine(compilerDirectory, "FSharp.Compiler.Service.dll"); + } + + if (string.IsNullOrEmpty(servicePath) || !File.Exists(servicePath)) + { + error = "FSharp.Compiler.Service.dll with hot reload APIs was not found."; + return false; + } + + try + { + var assembly = Assembly.LoadFrom(servicePath); + var checkerType = assembly.GetType("FSharp.Compiler.CodeAnalysis.FSharpChecker", throwOnError: true)!; + + var createMethod = checkerType.GetMethod("Create", BindingFlags.Public | BindingFlags.Static) + ?? throw new MissingMethodException(checkerType.FullName, "Create"); + + var createArguments = CreateCheckerArguments(createMethod.GetParameters()); + var checker = createMethod.Invoke(null, createArguments) + ?? throw new InvalidOperationException("FSharpChecker.Create returned null."); + + var getProjectOptions = checkerType.GetMethod("GetProjectOptionsFromCommandLineArgs", BindingFlags.Public | BindingFlags.Instance) + ?? throw new MissingMethodException(checkerType.FullName, "GetProjectOptionsFromCommandLineArgs"); + + var startSessionMethods = checkerType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.Name == "StartHotReloadSession") + .ToImmutableArray(); + + var notifyFileChanged = checkerType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(method => method.Name == "NotifyFileChanged" && method.GetParameters().Length >= 2); + + var emitDeltaMethods = checkerType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.Name == "EmitHotReloadDelta") + .ToImmutableArray(); + + var endSession = checkerType.GetMethod("EndHotReloadSession", BindingFlags.Public | BindingFlags.Instance) + ?? throw new MissingMethodException(checkerType.FullName, "EndHotReloadSession"); + + var invalidateConfigurationMethods = checkerType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.Name == "InvalidateConfiguration" && method.GetParameters().Length >= 1) + .ToImmutableArray(); + + var fsharpAsyncType = Type.GetType("Microsoft.FSharp.Control.FSharpAsync, FSharp.Core", throwOnError: true)!; + var runSynchronously = fsharpAsyncType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(method => method.Name == "RunSynchronously" && method.IsGenericMethod && method.GetParameters().Length == 3); + + var startSessionWithOptions = + startSessionMethods.FirstOrDefault(HasFirstParameterNamedProjectOptions) + ?? startSessionMethods.FirstOrDefault(method => method.GetParameters().Length > 0); + var emitDeltaWithOptions = + emitDeltaMethods.FirstOrDefault(HasFirstParameterNamedProjectOptions) + ?? emitDeltaMethods.FirstOrDefault(method => method.GetParameters().Length > 0); + + var projectSnapshotType = assembly.GetType("FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot", throwOnError: false); + var startSessionWithSnapshot = projectSnapshotType == null + ? null + : startSessionMethods.FirstOrDefault(method => HasFirstParameterType(method, projectSnapshotType)); + var emitDeltaWithSnapshot = projectSnapshotType == null + ? null + : emitDeltaMethods.FirstOrDefault(method => HasFirstParameterType(method, projectSnapshotType)); + + var useWorkspaceSnapshots = false; + object? workspaceProjects = null; + object? workspaceFiles = null; + object? workspaceQuery = null; + MethodInfo? workspaceProjectAddOrUpdate = null; + MethodInfo? workspaceQueryGetProjectSnapshot = null; + MethodInfo? workspaceFilesEdit = null; + MethodInfo? workspaceFilesClose = null; + string? workspaceError = null; + MethodInfo selectedStartSession; + MethodInfo selectedEmitDelta; + var preferWorkspaceSnapshots = ShouldPreferWorkspaceSnapshots(servicePathOverride); + + if (preferWorkspaceSnapshots && + startSessionWithSnapshot != null && + emitDeltaWithSnapshot != null && + TryCreateWorkspaceBridge( + assembly, + checkerType, + checker, + out workspaceProjects, + out workspaceFiles, + out workspaceQuery, + out workspaceProjectAddOrUpdate, + out workspaceQueryGetProjectSnapshot, + out workspaceFilesEdit, + out workspaceFilesClose, + out workspaceError)) + { + useWorkspaceSnapshots = true; + selectedStartSession = startSessionWithSnapshot; + selectedEmitDelta = emitDeltaWithSnapshot; + + if (trace) + { + logger.LogDebug("F# managed hot reload is using workspace snapshot bridge."); + } + } + else + { + selectedStartSession = startSessionWithOptions + ?? throw new MissingMethodException(checkerType.FullName, "StartHotReloadSession(projectOptions)"); + selectedEmitDelta = emitDeltaWithOptions + ?? throw new MissingMethodException(checkerType.FullName, "EmitHotReloadDelta(projectOptions)"); + + if (trace && !string.IsNullOrEmpty(workspaceError)) + { + logger.LogDebug("F# workspace snapshot bridge unavailable, using project-options path: {Message}", workspaceError); + } + else if (trace && !preferWorkspaceSnapshots && startSessionWithSnapshot != null && emitDeltaWithSnapshot != null) + { + logger.LogDebug( + "F# workspace snapshot bridge is disabled by default for bundled compiler service builds. Set DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS=1 to force-enable it."); + } + } + + host = new FSharpReflectionHost( + logger, + trace, + checker, + getProjectOptions, + selectedStartSession, + notifyFileChanged, + selectedEmitDelta, + endSession, + invalidateConfigurationMethods, + runSynchronously, + useWorkspaceSnapshots, + workspaceProjects, + workspaceFiles, + workspaceQuery, + workspaceProjectAddOrUpdate, + workspaceQueryGetProjectSnapshot, + workspaceFilesEdit, + workspaceFilesClose); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + private static bool ShouldPreferWorkspaceSnapshots(string? servicePathOverride) + { + var overrideValue = Environment.GetEnvironmentVariable("DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"); + if (!string.IsNullOrEmpty(overrideValue)) + { + return string.Equals(overrideValue, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(overrideValue, "true", StringComparison.OrdinalIgnoreCase); + } + + // Default to project-options mode for bundled SDK bits and enable workspace snapshots + // when the user explicitly points watch at a custom FSharp.Compiler.Service build. + return !string.IsNullOrEmpty(servicePathOverride); + } + + private static bool TryCreateWorkspaceBridge( + Assembly assembly, + Type checkerType, + object checker, + out object? workspaceProjects, + out object? workspaceFiles, + out object? workspaceQuery, + out MethodInfo? workspaceProjectAddOrUpdate, + out MethodInfo? workspaceQueryGetProjectSnapshot, + out MethodInfo? workspaceFilesEdit, + out MethodInfo? workspaceFilesClose, + out string? error) + { + workspaceProjects = null; + workspaceFiles = null; + workspaceQuery = null; + workspaceProjectAddOrUpdate = null; + workspaceQueryGetProjectSnapshot = null; + workspaceFilesEdit = null; + workspaceFilesClose = null; + error = null; + + var workspaceType = assembly.GetType("FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspace", throwOnError: false); + if (workspaceType == null) + { + error = "FSharpWorkspace type was not found in FSharp.Compiler.Service."; + return false; + } + + var workspaceCtor = workspaceType.GetConstructor([checkerType]) ?? workspaceType.GetConstructor(Type.EmptyTypes); + if (workspaceCtor == null) + { + error = "FSharpWorkspace constructor was not found."; + return false; + } + + var workspace = workspaceCtor.GetParameters().Length == 1 + ? workspaceCtor.Invoke([checker]) + : workspaceCtor.Invoke([]); + + var projects = workspaceType.GetProperty("Projects", BindingFlags.Public | BindingFlags.Instance)?.GetValue(workspace); + var files = workspaceType.GetProperty("Files", BindingFlags.Public | BindingFlags.Instance)?.GetValue(workspace); + var query = workspaceType.GetProperty("Query", BindingFlags.Public | BindingFlags.Instance)?.GetValue(workspace); + + if (projects == null || files == null || query == null) + { + error = "FSharpWorkspace properties (Projects/Files/Query) were not available."; + return false; + } + + workspaceProjectAddOrUpdate = projects.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(method => + { + if (method.Name != "AddOrUpdate") + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 3 && + parameters[0].ParameterType == typeof(string) && + parameters[1].ParameterType == typeof(string); + }); + + workspaceQueryGetProjectSnapshot = query.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(method => method.Name == "GetProjectSnapshot" && method.GetParameters().Length == 1); + + workspaceFilesEdit = files.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(method => + { + if (method.Name != "Edit") + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 2 && + parameters[0].ParameterType == typeof(Uri) && + parameters[1].ParameterType == typeof(string); + }); + + workspaceFilesClose = files.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(method => + { + if (method.Name != "Close") + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 1 && parameters[0].ParameterType == typeof(Uri); + }); + + if (workspaceProjectAddOrUpdate == null || + workspaceQueryGetProjectSnapshot == null || + (workspaceFilesEdit == null && workspaceFilesClose == null)) + { + error = "FSharpWorkspace method surface is missing required members."; + return false; + } + + workspaceProjects = projects; + workspaceFiles = files; + workspaceQuery = query; + return true; + } + + private static bool HasFirstParameterNamedProjectOptions(MethodInfo method) + { + var parameters = method.GetParameters(); + return parameters.Length > 0 && + string.Equals(parameters[0].Name, "projectOptions", StringComparison.Ordinal); + } + + private static bool HasFirstParameterType(MethodInfo method, Type parameterType) + { + var parameters = method.GetParameters(); + return parameters.Length > 0 && parameters[0].ParameterType == parameterType; + } + + public bool TryCreateProjectInput(FSharpProjectInfo projectInfo, out object? projectInput, out string? error) + => _useWorkspaceSnapshots + ? TryCreateWorkspaceSnapshotInput(projectInfo, out projectInput, out error) + : TryCreateProjectOptionsInput(projectInfo, out projectInput, out error); + + public bool TryRefreshProjectInput(FSharpProjectInfo projectInfo, object currentProjectInput, out object? refreshedProjectInput, out string? error) + { + if (_useWorkspaceSnapshots) + { + return TryCreateWorkspaceSnapshotInput(projectInfo, out refreshedProjectInput, out error); + } + + return TryCreateProjectOptionsInput(projectInfo, out refreshedProjectInput, out error); + } + + public FSharpInvocationResult StartSession(object projectInput, CancellationToken cancellationToken) + => InvokeResult(_startSession, [projectInput, null], cancellationToken); + + public void InvalidateConfiguration(object projectInput, string projectPath) + { + if (_invalidateConfigurationMethods.IsDefaultOrEmpty) + { + return; + } + + try + { + var projectInputType = projectInput.GetType(); + var method = _invalidateConfigurationMethods.FirstOrDefault(candidate => + { + var parameters = candidate.GetParameters(); + return parameters.Length >= 1 && parameters[0].ParameterType.IsAssignableFrom(projectInputType); + }); + + if (method == null) + { + return; + } + + var parameterCount = method.GetParameters().Length; + var args = parameterCount switch + { + 1 => [projectInput], + _ => new object?[] { projectInput, null }, + }; + + _ = method.Invoke(_checker, args); + } + catch (Exception ex) + { + if (_trace) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + _logger.LogDebug( + "F# InvalidateConfiguration failed for '{ProjectPath}': {Message}", + projectPath, + rootException.Message); + } + } + } + + public void NotifyFileChanged(string filePath, FSharpProjectInfo projectInfo, object projectInput, CancellationToken cancellationToken) + { + if (_useWorkspaceSnapshots) + { + NotifyWorkspaceFileChanged(filePath); + NotifyCheckerFileChanged(filePath, projectInfo); + return; + } + + NotifyCheckerFileChanged(filePath, projectInput); + } + + private void NotifyCheckerFileChanged(string filePath, FSharpProjectInfo projectInfo) + { + if (!TryCreateProjectOptionsInput(projectInfo, out var projectOptions, out _)) + { + return; + } + + NotifyCheckerFileChanged(filePath, projectOptions); + } + + private void NotifyCheckerFileChanged(string filePath, object? projectInput) + { + if (_notifyFileChanged == null) + { + return; + } + + try + { + var asyncComputation = _notifyFileChanged.Invoke(_checker, [filePath, projectInput, null]); + if (asyncComputation == null) + { + return; + } + + var asyncResultType = _notifyFileChanged.ReturnType.GetGenericArguments().Single(); + var runSync = _runSynchronously.MakeGenericMethod(asyncResultType); + _ = runSync.Invoke(null, [asyncComputation, null, null]); + } + catch (Exception ex) + { + if (_trace) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + _logger.LogDebug("F# NotifyFileChanged failed for '{FilePath}': {Message}", filePath, rootException.Message); + } + } + } + + public FSharpInvocationResult EmitDelta(object projectInput, CancellationToken cancellationToken) + => InvokeResult(_emitDelta, [projectInput, null], cancellationToken); + + private bool TryCreateProjectOptionsInput(FSharpProjectInfo projectInfo, out object? projectInput, out string? error) + { + projectInput = null; + error = null; + + try + { + var args = EnsureHotReloadFlag(projectInfo.CommandLineArgs); + projectInput = _getProjectOptions.Invoke(_checker, [projectInfo.ProjectPath, args.ToArray(), null, null, null]); + return projectInput != null; + } + catch (Exception ex) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + error = rootException.Message; + return false; + } + } + + private bool TryCreateWorkspaceSnapshotInput(FSharpProjectInfo projectInfo, out object? projectInput, out string? error) + { + projectInput = null; + error = null; + + if (_workspaceProjects == null || + _workspaceQuery == null || + _workspaceProjectAddOrUpdate == null || + _workspaceQueryGetProjectSnapshot == null) + { + error = "FSharpWorkspace bridge is not initialized."; + return false; + } + + try + { + var args = EnsureHotReloadFlag(projectInfo.CommandLineArgs); + var projectIdentifier = _workspaceProjectAddOrUpdate.Invoke( + _workspaceProjects, + [projectInfo.ProjectPath, projectInfo.TargetPath, args.ToArray()]); + + if (projectIdentifier == null) + { + error = $"FSharpWorkspace.Projects.AddOrUpdate returned null for '{projectInfo.ProjectPath}'."; + return false; + } + + var snapshotOption = _workspaceQueryGetProjectSnapshot.Invoke(_workspaceQuery, [projectIdentifier]); + if (!TryGetFSharpOptionValue(snapshotOption, out var snapshot)) + { + error = $"FSharpWorkspace.Query.GetProjectSnapshot returned None for '{projectInfo.ProjectPath}'."; + return false; + } + + if (snapshot == null) + { + error = $"FSharpWorkspace.Query.GetProjectSnapshot returned Some(null) for '{projectInfo.ProjectPath}'."; + return false; + } + + projectInput = snapshot; + return true; + } + catch (Exception ex) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + error = rootException.Message; + return false; + } + } + + private void NotifyWorkspaceFileChanged(string filePath) + { + if (_workspaceFiles == null || (_workspaceFilesEdit == null && _workspaceFilesClose == null)) + { + return; + } + + try + { + var normalizedPath = Path.GetFullPath(filePath); + var fileUri = new Uri(normalizedPath); + + if (_workspaceFilesEdit != null) + { + var content = File.ReadAllText(normalizedPath); + _workspaceFilesEdit.Invoke(_workspaceFiles, [fileUri, content]); + return; + } + + _workspaceFilesClose!.Invoke(_workspaceFiles, [fileUri]); + } + catch (Exception ex) + { + if (_trace) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + _logger.LogDebug("F# workspace file refresh failed for '{FilePath}': {Message}", filePath, rootException.Message); + } + } + } + + public HotReloadManagedCodeUpdate? CreateManagedUpdate(Guid moduleId, object delta) + { + try + { + var deltaType = delta.GetType(); + var metadata = (byte[]?)deltaType.GetProperty("Metadata")?.GetValue(delta) ?? []; + var il = (byte[]?)deltaType.GetProperty("IL")?.GetValue(delta) ?? []; + + var pdbOption = deltaType.GetProperty("Pdb")?.GetValue(delta); + var pdb = pdbOption == null + ? [] + : (byte[]?)pdbOption.GetType().GetProperty("Value")?.GetValue(pdbOption) ?? []; + + var updatedTypesEnumerable = deltaType.GetProperty("UpdatedTypes")?.GetValue(delta) as IEnumerable; + var updatedTypes = updatedTypesEnumerable == null + ? ImmutableArray.Empty + : updatedTypesEnumerable.Cast().Select(Convert.ToInt32).ToImmutableArray(); + + var updatedMethodsEnumerable = deltaType.GetProperty("UpdatedMethods")?.GetValue(delta) as IEnumerable; + var updatedMethods = updatedMethodsEnumerable == null + ? ImmutableArray.Empty + : updatedMethodsEnumerable.Cast().Select(Convert.ToInt32).ToImmutableArray(); + + if (_trace) + { + _logger.LogDebug( + "F# managed delta token summary: UpdatedTypes=[{UpdatedTypes}], UpdatedMethods=[{UpdatedMethods}].", + FormatTokenSet(updatedTypes), + FormatTokenSet(updatedMethods)); + } + + return new HotReloadManagedCodeUpdate( + moduleId, + ImmutableArray.CreateRange(metadata), + ImmutableArray.CreateRange(il), + ImmutableArray.CreateRange(pdb), + updatedTypes, + ImmutableArray.Empty); + } + catch (Exception ex) + { + _logger.LogDebug("Failed to materialize F# managed update payload: {Message}", ex.Message); + return null; + } + } + + public void TryEndSession() + { + try + { + _ = _endSession.Invoke(_checker, null); + } + catch (Exception ex) + { + if (_trace) + { + _logger.LogDebug("Ignoring F# session cleanup failure: {Message}", ex.Message); + } + } + } + + private FSharpInvocationResult InvokeResult(MethodInfo method, object?[] args, CancellationToken cancellationToken) + { + try + { + var asyncComputation = method.Invoke(_checker, args) + ?? throw new InvalidOperationException($"{method.Name} returned null."); + + var asyncResultType = method.ReturnType.GetGenericArguments().Single(); + var runSync = _runSynchronously.MakeGenericMethod(asyncResultType); + var result = runSync.Invoke(null, [asyncComputation, null, null]) + ?? throw new InvalidOperationException($"{method.Name} returned null result."); + + var tag = (int)(result.GetType().GetProperty("Tag")?.GetValue(result) ?? -1); + if (tag == 0) + { + var value = result.GetType().GetProperty("ResultValue")?.GetValue(result); + return new FSharpInvocationResult(true, value, null, null); + } + + var error = result.GetType().GetProperty("ErrorValue")?.GetValue(result); + var errorText = error?.ToString() ?? "Unknown error"; + var errorCase = ParseErrorCase(errorText); + return new FSharpInvocationResult(false, null, errorCase, errorText); + } + catch (Exception ex) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + return new FSharpInvocationResult(false, null, null, rootException.Message); + } + } + + private static ImmutableArray EnsureHotReloadFlag(ImmutableArray args) + => args.Any(static arg => string.Equals(arg, "--enable:hotreloaddeltas", StringComparison.OrdinalIgnoreCase)) + ? args + : args.Add("--enable:hotreloaddeltas"); + + private static bool TryGetFSharpOptionValue(object? option, out object? value) + { + value = null; + if (option == null) + { + return false; + } + + var optionType = option.GetType(); + var isSomeAccessor = optionType.GetMethod("get_IsSome", BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); + var valueAccessor = optionType.GetMethod("get_Value", BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); + if (isSomeAccessor == null || valueAccessor == null) + { + return false; + } + + if (InvokeFSharpOptionAccessor(isSomeAccessor, option) is not bool isSome || !isSome) + { + return false; + } + + value = InvokeFSharpOptionAccessor(valueAccessor, option); + return true; + } + + private static object? InvokeFSharpOptionAccessor(MethodInfo accessor, object option) + => accessor.GetParameters().Length switch + { + 0 => accessor.Invoke(option, null), + 1 => accessor.Invoke(null, [option]), + _ => throw new InvalidOperationException($"Unexpected option accessor shape: {accessor}") + }; + + private static string? ParseErrorCase(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var delimiters = new[] { ' ', '(', ':' }; + var index = text.IndexOfAny(delimiters); + return index > 0 ? text[..index] : text; + } + + private static string FormatTokenSet(ImmutableArray tokens) + => tokens.IsDefaultOrEmpty + ? "" + : string.Join(", ", tokens.Select(static token => $"0x{token:X8}")); + + private static object?[] CreateCheckerArguments(ParameterInfo[] parameters) + { + var arguments = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + // Hot reload APIs require keepAssemblyContents=true in recent FCS versions. + if (string.Equals(parameter.Name, "keepAssemblyContents", StringComparison.Ordinal)) + { + if (parameter.ParameterType == typeof(bool) || parameter.ParameterType == typeof(bool?)) + { + arguments[i] = true; + continue; + } + + if (IsFSharpOptionOfBoolean(parameter.ParameterType)) + { + arguments[i] = CreateFSharpOptionSomeBoolean(parameter.ParameterType, value: true); + continue; + } + } + } + + return arguments; + } + + private static bool IsFSharpOptionOfBoolean(Type parameterType) + => parameterType.IsGenericType && + string.Equals(parameterType.GetGenericTypeDefinition().FullName, "Microsoft.FSharp.Core.FSharpOption`1", StringComparison.Ordinal) && + parameterType.GetGenericArguments() is [var argumentType] && + argumentType == typeof(bool); + + private static object? CreateFSharpOptionSomeBoolean(Type optionType, bool value) + => optionType.GetMethod("Some", BindingFlags.Public | BindingFlags.Static, [typeof(bool)])?.Invoke(null, [value]); + + internal readonly record struct FSharpInvocationResult(bool IsSuccess, object? Value, string? ErrorCase, string? ErrorText); + } +} diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpProjectInfo.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpProjectInfo.cs new file mode 100644 index 000000000000..4a692b3c0497 --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpProjectInfo.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.IO; +using Microsoft.Build.Graph; +using Microsoft.Extensions.Logging; +using BuildLogger = Microsoft.Build.Framework.ILogger; + +namespace Microsoft.DotNet.Watch; + +internal sealed record FSharpProjectInfo( + ProjectInstanceId ProjectId, + string ProjectPath, + string TargetFramework, + string TargetPath, + string DotnetFscCompilerPath, + ImmutableArray CommandLineArgs) +{ + public static ImmutableDictionary Collect(ProjectGraph projectGraph, ILogger logger) + { + var trace = IsTraceEnabled(); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var node in projectGraph.ProjectNodes) + { + if (!IsFSharpProject(node)) + { + continue; + } + + var projectPath = node.ProjectInstance.FullPath; + var targetFramework = node.ProjectInstance.GetTargetFramework(); + var targetPath = node.ProjectInstance.GetPropertyValue(PropertyNames.TargetPath); + var dotnetFscCompilerPath = node.ProjectInstance.GetPropertyValue("DotnetFscCompilerPath"); + var commandLineArgs = GetCommandLineArgs(node, logger, trace); + + if (string.IsNullOrEmpty(targetPath) || string.IsNullOrEmpty(dotnetFscCompilerPath) || commandLineArgs.IsEmpty) + { + if (trace) + { + logger.LogDebug( + "Skipping F# project '{ProjectPath}' for managed updates (TargetPath='{TargetPath}', DotnetFscCompilerPath='{CompilerPath}', ArgCount={ArgCount}).", + projectPath, + targetPath, + dotnetFscCompilerPath, + commandLineArgs.Length); + } + + continue; + } + + var projectId = new ProjectInstanceId(projectPath, targetFramework); + var projectInfo = new FSharpProjectInfo( + projectId, + projectPath, + targetFramework, + NormalizeFullPath(targetPath), + NormalizeFullPath(dotnetFscCompilerPath), + commandLineArgs); + + if (trace) + { + logger.LogDebug( + "F# hot reload project discovered: '{ProjectPath}' ({TargetFramework}), compiler='{CompilerPath}', args={ArgCount}.", + projectInfo.ProjectPath, + projectInfo.TargetFramework, + projectInfo.DotnetFscCompilerPath, + projectInfo.CommandLineArgs.Length); + } + + builder[projectId] = projectInfo; + } + + return builder.ToImmutable(); + } + + private static ImmutableArray GetCommandLineArgs(ProjectGraphNode node, ILogger logger, bool trace) + { + var commandLineArgs = node.ProjectInstance.GetItems("FscCommandLineArgs").Select(item => item.EvaluatedInclude).ToImmutableArray(); + if (!commandLineArgs.IsEmpty) + { + return commandLineArgs; + } + + // CoreCompile is often skipped as up-to-date during design-time evaluation, which leaves + // FscCommandLineArgs empty. Force a no-op compile pass to materialize captured arguments. + var designTimeProject = node.ProjectInstance.DeepCopy(); + var forcedOutputPath = Path.Combine( + Path.GetDirectoryName(designTimeProject.FullPath) ?? Directory.GetCurrentDirectory(), + "obj", + $".dotnet-watch-fsharp-force-{Guid.NewGuid():N}.tmp"); + designTimeProject.SetProperty("NonExistentFile", forcedOutputPath); + + var customCollectWatchItems = designTimeProject.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems); + if (!designTimeProject.Build([TargetNames.Compile, .. customCollectWatchItems], Array.Empty())) + { + if (trace) + { + logger.LogDebug("F# design-time compile failed while collecting command-line arguments for '{ProjectPath}'.", designTimeProject.FullPath); + } + + return []; + } + + commandLineArgs = designTimeProject.GetItems("FscCommandLineArgs").Select(item => item.EvaluatedInclude).ToImmutableArray(); + if (trace) + { + logger.LogDebug( + "F# command-line argument capture after forced compile for '{ProjectPath}': {ArgCount} argument(s).", + designTimeProject.FullPath, + commandLineArgs.Length); + } + + return commandLineArgs; + } + + private static bool IsFSharpProject(ProjectGraphNode node) + { + if (Path.GetExtension(node.ProjectInstance.FullPath).Equals(".fsproj", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var language = node.ProjectInstance.GetPropertyValue("Language"); + return string.Equals(language, "F#", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTraceEnabled() + { + var value = Environment.GetEnvironmentVariable("DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD"); + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeFullPath(string path) + { + var normalized = path.Trim(); + if (normalized.Length >= 2 && normalized[0] == '"' && normalized[^1] == '"') + { + normalized = normalized[1..^1]; + } + + return Path.GetFullPath(normalized); + } +} From 54176b053e0388f2077e6e4a6bb5458871821cba Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 10 Jun 2026 12:39:06 -0400 Subject: [PATCH 03/13] Integrate F# hot reload into the watch update pipeline Route file changes through FSharpHotReloadService alongside the Roslyn update path in CompilationHandler. Managed code updates are now tracked as ManagedCodeUpdateEnvelope (project path + runtime update) so that F# deltas, which have no Roslyn ProjectId, flow through the same builder, previous-update replay, and discard-on-rebuild logic as C#/VB updates. F# edits that cannot be applied as deltas fall back to rebuild + restart of the affected project. The agent reports trace-only diagnostics when no loaded assembly matches an update's module id and DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD is set. --- .../HotReloadAgent/HotReloadAgent.cs | 33 ++++++ .../Watch/HotReload/CompilationHandler.cs | 101 +++++++++++++++--- .../Watch/HotReload/HotReloadDotNetWatcher.cs | 1 + .../HotReloadProjectUpdatesBuilder.cs | 9 +- 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/src/Dotnet.Watch/HotReloadAgent/HotReloadAgent.cs b/src/Dotnet.Watch/HotReloadAgent/HotReloadAgent.cs index 3cc8e36cd88f..9bf5be828d22 100644 --- a/src/Dotnet.Watch/HotReloadAgent/HotReloadAgent.cs +++ b/src/Dotnet.Watch/HotReloadAgent/HotReloadAgent.cs @@ -33,6 +33,7 @@ internal sealed class HotReloadAgent : IDisposable, IHotReloadAgent private readonly ApplyUpdateDelegate? _applyUpdate; private readonly string? _capabilities; private readonly MetadataUpdateHandlerInvoker _metadataUpdateHandlerInvoker; + private static readonly bool s_traceFSharpHotReload = IsTraceFSharpHotReloadEnabled(); // handler to install on first managed update: private Func? _assemblyResolvingHandlerToInstall; @@ -140,14 +141,39 @@ public void ApplyManagedCodeUpdates(IEnumerable update Reporter.Report($"Applying updates to module {update.ModuleId}.", AgentMessageSeverity.Verbose); + var matchedAssemblyCount = 0; foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { if (TryGetModuleId(assembly) is Guid moduleId && moduleId == update.ModuleId) { + matchedAssemblyCount++; _applyUpdate(assembly, update.MetadataDelta, update.ILDelta, update.PdbDelta); } } + if (s_traceFSharpHotReload && matchedAssemblyCount == 0) + { + // Trace-only diagnostics for cases where an update is accepted but no loaded module matches its MVID. + Reporter.Report( + $"No loaded assembly matched module {update.ModuleId}. The update was cached for future assembly loads.", + AgentMessageSeverity.Verbose); + + var loadedModuleIds = AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => + { + var assemblyName = assembly.GetName().Name ?? ""; + var assemblyModuleId = TryGetModuleId(assembly); + return assemblyModuleId is Guid id + ? $"{assemblyName}:{id}" + : $"{assemblyName}:"; + }) + .Take(16); + + Reporter.Report( + $"Loaded assembly module ids (first 16): {string.Join(", ", loadedModuleIds)}", + AgentMessageSeverity.Verbose); + } + // Additionally stash the deltas away so it may be applied to assemblies loaded later. var cachedModuleUpdates = _moduleUpdates.GetOrAdd(update.ModuleId, static _ => []); cachedModuleUpdates.Add(update); @@ -269,6 +295,13 @@ private void ApplyDeltas(Assembly assembly, IReadOnlyList /// Lock to synchronize: @@ -47,7 +48,7 @@ private ImmutableDictionary> _activePro /// /// All updates that were attempted. Includes updates whose application failed. /// - private ImmutableList _previousUpdates = []; + private ImmutableList _previousUpdates = []; private bool _isDisposed; private int _solutionUpdateId; @@ -64,11 +65,13 @@ public CompilationHandler(DotNetWatchContext context) _context = context; Workspace = new HotReloadMSBuildWorkspace(context.Logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); + _fsharpHotReloadService = new FSharpHotReloadService(context.Logger); } public void Dispose() { _isDisposed = true; + _fsharpHotReloadService.EndSession(); Workspace?.Dispose(); } @@ -82,7 +85,7 @@ public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken Dispose(); } - private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuilt) + private void DiscardPreviousUpdates(IReadOnlyList projectsToBeRebuilt) { // Remove previous updates to all modules that were affected by rude edits. // All running projects that statically reference these modules have been terminated. @@ -92,7 +95,8 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil lock (_runningProjectsAndUpdatesGuard) { - _previousUpdates = _previousUpdates.RemoveAll(update => projectsToBeRebuilt.Contains(update.ProjectId)); + var rebuiltProjects = projectsToBeRebuilt.ToHashSet(PathUtilities.OSSpecificPathComparer); + _previousUpdates = _previousUpdates.RemoveAll(update => rebuiltProjects.Contains(update.ProjectPath)); } } @@ -101,6 +105,7 @@ public async ValueTask StartSessionAsync(ProjectGraph graph, CancellationToken c var solution = await UpdateProjectGraphAsync(graph, cancellationToken); await _hotReloadService.StartSessionAsync(solution, cancellationToken); + await _fsharpHotReloadService.StartSessionAsync(cancellationToken); // TODO: StartSessionAsync should do this: https://github.com/dotnet/roslyn/issues/80687 foreach (var project in solution.Projects) @@ -342,6 +347,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C public async ValueTask GetManagedCodeUpdatesAsync( HotReloadProjectUpdatesBuilder builder, + IReadOnlyList changedFiles, Func, CancellationToken, Task> restartPrompt, bool autoRestart, CancellationToken cancellationToken) @@ -352,16 +358,32 @@ public async ValueTask GetManagedCodeUpdatesAsync( var runningProjectInfos = (from project in currentSolution.Projects let runningProject = GetCorrespondingRunningProjects(runningProjects, project).FirstOrDefault() - where runningProject != null + // F#-owned running projects must not be handed to the Roslyn update service: it has no EnC + // support for them and would demand a restart (ENC1009) even when the F# compiler service + // produced an applicable delta. Their updates flow exclusively through _fsharpHotReloadService. + where runningProject != null && IsRoslynManagedProject(project) let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled() select (project.Id, info: new HotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject })) .ToImmutableDictionary(e => e.Id, e => e.info); var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken); + // F# projects are not represented in the Roslyn solution; their updates are emitted by the F# compiler service. + var fsharpResult = await _fsharpHotReloadService.TryEmitUpdatesAsync(changedFiles, runningProjects, cancellationToken); + + if (fsharpResult.Status == FSharpManagedUpdateStatus.RestartRequired && + !string.IsNullOrEmpty(fsharpResult.Message) && + Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug( + "F# managed update fallback reason for '{ProjectPath}': {Message}", + fsharpResult.ProjectPath, + fsharpResult.Message); + } + await DisplayResultsAsync(updates, currentSolution, runningProjectInfos, cancellationToken); - if (updates.Status is HotReloadService.Status.NoChangesToApply or HotReloadService.Status.Blocked) + if (updates.Status == HotReloadService.Status.Blocked || fsharpResult.Status == FSharpManagedUpdateStatus.Blocked) { // If Hot Reload is blocked (due to compilation error) we ignore the current // changes and await the next file change. @@ -370,6 +392,16 @@ public async ValueTask GetManagedCodeUpdatesAsync( return; } + var roslynHasUpdates = updates.Status == HotReloadService.Status.ReadyToApply; + var fsharpHasUpdates = fsharpResult.Status == FSharpManagedUpdateStatus.ReadyToApply; + if (!roslynHasUpdates && !fsharpHasUpdates && fsharpResult.Status != FSharpManagedUpdateStatus.RestartRequired) + { + Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply); + + // Note: CommitUpdate/DiscardUpdate is not expected to be called. + return; + } + var projectsToPromptForRestart = (from projectId in updates.ProjectsToRestart.Keys where !runningProjectInfos[projectId].RestartWhenChangesHaveNoEffect // equivallent to auto-restart @@ -386,25 +418,58 @@ public async ValueTask GetManagedCodeUpdatesAsync( return; } - // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. - _hotReloadService.CommitUpdate(); + if (roslynHasUpdates) + { + // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. + _hotReloadService.CommitUpdate(); + } - DiscardPreviousUpdates(updates.ProjectsToRebuild); + var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + var restartProjectPaths = updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!).ToImmutableArray(); - builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates); - builder.ProjectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!)); + if (fsharpResult.Status == FSharpManagedUpdateStatus.RestartRequired && fsharpResult.ProjectPath != null) + { + // F# rude edits (changes the compiler service can't apply as a delta) fall back to rebuild + restart. + if (!projectsToRebuild.Contains(fsharpResult.ProjectPath, PathUtilities.OSSpecificPathComparer)) + { + projectsToRebuild = projectsToRebuild.Add(fsharpResult.ProjectPath); + } + + if (!restartProjectPaths.Contains(fsharpResult.ProjectPath, PathUtilities.OSSpecificPathComparer)) + { + restartProjectPaths = restartProjectPaths.Add(fsharpResult.ProjectPath); + } + + if (updates.ProjectsToRestart.IsEmpty) + { + // DisplayResultsAsync only reports Roslyn restarts. + Logger.Log(MessageDescriptor.RestartNeededToApplyChanges); + } + } + + DiscardPreviousUpdates(projectsToRebuild); + + if (roslynHasUpdates) + { + builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates.Select(update => new ManagedCodeUpdateEnvelope( + currentSolution.GetProject(update.ProjectId)!.FilePath!, + new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities)))); + } + + builder.ManagedCodeUpdates.AddRange(fsharpResult.Updates.Select(update => new ManagedCodeUpdateEnvelope(update.ProjectPath, update.Update))); + builder.ProjectsToRebuild.AddRange(projectsToRebuild); builder.ProjectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!)); // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. - if (!updates.ProjectsToRestart.IsEmpty) + if (!restartProjectPaths.IsEmpty) { - builder.ProjectsToRestart.AddRange(await TerminatePeripheralProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken)); + builder.ProjectsToRestart.AddRange(await TerminatePeripheralProcessesAsync(restartProjectPaths, cancellationToken)); } } public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAndRelaunchAsync( - IReadOnlyList managedCodeUpdates, + IReadOnlyList managedCodeUpdates, IReadOnlyDictionary> staticAssetUpdates, ImmutableArray changedFiles, LoadedProjectGraph projectGraph, @@ -536,7 +601,7 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, So break; case HotReloadService.Status.NoChangesToApply: - Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply); + // No-change messaging is emitted by GetManagedCodeUpdatesAsync after combining the Roslyn and F# update paths. break; case HotReloadService.Status.Blocked: @@ -972,6 +1037,9 @@ private static IEnumerable GetCorrespondingRunningProjects(Immut return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); } + private static bool IsRoslynManagedProject(Project project) + => project.Language is LanguageNames.CSharp or LanguageNames.VisualBasic; + private static IEnumerable GetCorrespondingRunningProjects(ImmutableDictionary> runningProjects, ProjectInstance project) { if (!runningProjects.TryGetValue(project.FullPath, out var projectsWithPath)) @@ -1002,8 +1070,8 @@ private ProjectInstance GetProjectInstance(Project project) return instances.Single(instance => string.Equals(instance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); } - private static ImmutableArray ToManagedCodeUpdates(IEnumerable updates) - => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; + private static ImmutableArray ToManagedCodeUpdates(IEnumerable updates) + => [.. updates.Select(update => update.Update)]; private static ImmutableDictionary> CreateProjectInstanceMap(ProjectGraph graph) => graph.ProjectNodes @@ -1015,6 +1083,7 @@ private static ImmutableDictionary> Crea public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken) { _projectInstances = CreateProjectInstanceMap(projectGraph); + _fsharpHotReloadService.UpdateProjects(projectGraph); var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken); await SolutionUpdatedAsync(solution, "project update", cancellationToken); diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index 91ae3f2a609e..185a304f84e4 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -255,6 +255,7 @@ void FileChangedCallback(ChangedPath change) await compilationHandler.GetManagedCodeUpdatesAsync( updates, + changedFiles, restartPrompt: async (projectNames, cancellationToken) => { // stop before waiting for user input: diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs index ae5ef0168787..7d4bd0f93721 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs @@ -1,14 +1,19 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; using Microsoft.DotNet.HotReload; namespace Microsoft.DotNet.Watch; +/// +/// Managed code update annotated with the path of the project it originates from. +/// Allows tracking updates produced by both the Roslyn (C#/VB) and the F# update paths. +/// +internal readonly record struct ManagedCodeUpdateEnvelope(string ProjectPath, HotReloadManagedCodeUpdate Update); + internal sealed class HotReloadProjectUpdatesBuilder { - public List ManagedCodeUpdates { get; } = []; + public List ManagedCodeUpdates { get; } = []; public Dictionary> StaticAssetsToUpdate { get; } = []; public List ProjectsToRebuild { get; } = []; public List ProjectsToRedeploy { get; } = []; From a317f3638fbf57cb091e592c11a666ac5973d345 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 10 Jun 2026 12:49:42 -0400 Subject: [PATCH 04/13] Add dotnet-watch tests for F# hot reload Add unit tests for FSharpHotReloadService change classification and the reflection host F# option accessor handling, and integration scenarios covering in-place apply, computation expression edits, whitespace-only edits, dependency and XAML dependency edits, and rude-edit restart fallback for F# projects. --- .../HotReload/FSharpHotReloadServiceTests.cs | 167 +++++++ .../HotReload/FSharpHotReloadTests.cs | 410 ++++++++++++++++++ .../HotReload/FSharpReflectionHostTests.cs | 54 +++ 3 files changed, 631 insertions(+) create mode 100644 test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs create mode 100644 test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs create mode 100644 test/dotnet-watch.Tests/HotReload/FSharpReflectionHostTests.cs diff --git a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs new file mode 100644 index 000000000000..bbec4a5c7e74 --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs @@ -0,0 +1,167 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Reflection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class FSharpHotReloadServiceTests +{ + [Theory] + [InlineData("/tmp/Program.fs", false)] + [InlineData("/tmp/Program.fsi", false)] + [InlineData("/tmp/Project.fsproj", false)] + [InlineData("/tmp/.Program.fs.swp", false)] + [InlineData("/tmp/payload.txt", true)] + [InlineData("/tmp/view.xaml", true)] + public void IsManagedDependencyCandidatePath_ClassifiesProjectAndTempFiles(string path, bool expected) + { + var method = typeof(FSharpHotReloadService).GetMethod( + "IsManagedDependencyCandidatePath", + BindingFlags.Static | BindingFlags.NonPublic)!; + + var result = Assert.IsType(method.Invoke(null, [path])); + Assert.Equal(expected, result); + } + + [Fact] + public void TryGetChangedRunningFSharpProject_MatchesDependencyByProjectDirectoryFallback() + { + var projectDirectory = Path.Combine(Path.GetTempPath(), "dotnet-watch-fsharp-tests"); + var projectPath = Path.Combine(projectDirectory, "TestApp.fsproj"); + var targetPath = Path.Combine(projectDirectory, "bin", "Debug", "net10.0", "TestApp.dll"); + var compilerPath = Path.Combine(projectDirectory, "fsc.dll"); + + var projectId = new ProjectInstanceId(projectPath, "net10.0"); + var projectInfo = new FSharpProjectInfo(projectId, projectPath, "net10.0", targetPath, compilerPath, []); + + var service = new FSharpHotReloadService(NullLogger.Instance); + var projectsField = typeof(FSharpHotReloadService).GetField("_projects", BindingFlags.Instance | BindingFlags.NonPublic)!; + projectsField.SetValue(service, ImmutableDictionary.Empty.Add(projectId, projectInfo)); + + var changedFiles = new List + { + new( + new FileItem + { + FilePath = Path.Combine(projectDirectory, "payload.txt"), + ContainingProjectPaths = [] + }, + ChangeKind.Update) + }; + + var runningProjects = ImmutableDictionary>.Empty.Add(projectPath, []); + + var method = typeof(FSharpHotReloadService).GetMethod( + "TryGetChangedRunningFSharpProject", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var result = method.Invoke(service, [changedFiles, runningProjects]); + + Assert.NotNull(result); + Assert.Equal(projectId, Assert.IsType(result)); + } + + [Fact] + public void TryGetChangedRunningFSharpProject_MatchesCommandLineDependencyOutsideProjectDirectory() + { + var projectDirectory = Path.Combine(Path.GetTempPath(), "dotnet-watch-fsharp-tests-cmdline"); + var projectPath = Path.Combine(projectDirectory, "TestApp.fsproj"); + var targetPath = Path.Combine(projectDirectory, "bin", "Debug", "net10.0", "TestApp.dll"); + var compilerPath = Path.Combine(projectDirectory, "fsc.dll"); + + var externalDependency = Path.Combine(Path.GetTempPath(), "dotnet-watch-fsharp-external", "MainPage.xaml"); + + var projectId = new ProjectInstanceId(projectPath, "net10.0"); + var projectInfo = + new FSharpProjectInfo( + projectId, + projectPath, + "net10.0", + targetPath, + compilerPath, + [$"--resource:{externalDependency},MainPage.xaml"]); + + var service = new FSharpHotReloadService(NullLogger.Instance); + var projectsField = typeof(FSharpHotReloadService).GetField("_projects", BindingFlags.Instance | BindingFlags.NonPublic)!; + projectsField.SetValue(service, ImmutableDictionary.Empty.Add(projectId, projectInfo)); + + var changedFiles = new List + { + new( + new FileItem + { + FilePath = externalDependency, + ContainingProjectPaths = [] + }, + ChangeKind.Update) + }; + + var runningProjects = ImmutableDictionary>.Empty.Add(projectPath, []); + + var method = typeof(FSharpHotReloadService).GetMethod( + "TryGetChangedRunningFSharpProject", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var result = method.Invoke(service, [changedFiles, runningProjects]); + + Assert.NotNull(result); + Assert.Equal(projectId, Assert.IsType(result)); + } + + [Fact] + public void TryGetChangedRunningFSharpProject_IgnoresEditorTempFiles() + { + var projectDirectory = Path.Combine(Path.GetTempPath(), "dotnet-watch-fsharp-tests-temp"); + var projectPath = Path.Combine(projectDirectory, "TestApp.fsproj"); + var targetPath = Path.Combine(projectDirectory, "bin", "Debug", "net10.0", "TestApp.dll"); + var compilerPath = Path.Combine(projectDirectory, "fsc.dll"); + + var projectId = new ProjectInstanceId(projectPath, "net10.0"); + var projectInfo = new FSharpProjectInfo(projectId, projectPath, "net10.0", targetPath, compilerPath, []); + + var service = new FSharpHotReloadService(NullLogger.Instance); + var projectsField = typeof(FSharpHotReloadService).GetField("_projects", BindingFlags.Instance | BindingFlags.NonPublic)!; + projectsField.SetValue(service, ImmutableDictionary.Empty.Add(projectId, projectInfo)); + + var changedFiles = new List + { + new( + new FileItem + { + FilePath = Path.Combine(projectDirectory, ".Program.fs.swp"), + ContainingProjectPaths = [] + }, + ChangeKind.Update) + }; + + var runningProjects = ImmutableDictionary>.Empty.Add(projectPath, []); + + var method = typeof(FSharpHotReloadService).GetMethod( + "TryGetChangedRunningFSharpProject", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var result = method.Invoke(service, [changedFiles, runningProjects]); + + Assert.Null(result); + } + + [Fact] + public void TryGetCommandLineDependencyPath_ParsesResourceLogicalNameSuffix() + { + var method = typeof(FSharpHotReloadService).GetMethod( + "TryGetCommandLineDependencyPath", + BindingFlags.Static | BindingFlags.NonPublic)!; + + var projectDirectory = Path.Combine(Path.GetTempPath(), "dotnet-watch-fsharp-tests-parse"); + var expectedPath = Path.GetFullPath(Path.Combine(projectDirectory, "Views", "MainPage.xaml")); + var arguments = new object?[] { "--resource:Views/MainPage.xaml,MainPage.xaml", projectDirectory, null }; + + var parsed = Assert.IsType(method.Invoke(null, arguments)); + + Assert.True(parsed); + Assert.Equal(expectedPath, Assert.IsType(arguments[2])); + } +} diff --git a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs new file mode 100644 index 000000000000..1ae77eab0e56 --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs @@ -0,0 +1,410 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Text.RegularExpressions; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class FSharpHotReloadTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) +{ + private static string DescriptorPattern(MessageDescriptor descriptor) + => Regex.Replace(Regex.Escape(descriptor.Format), @"\\\{[0-9]+\}", ".*"); + + private void AssertFSharpEditAppliedOrRestarted() + { + var appliedPattern = new Regex(DescriptorPattern(MessageDescriptor.ManagedCodeChangesApplied)); + + var managedApplied = App.Process.Output.Any(appliedPattern.IsMatch); + var restartApplied = App.Process.Output.Any(line => line.Contains(MessageDescriptor.RestartNeededToApplyChanges.GetMessage(), StringComparison.Ordinal)); + + Assert.True(managedApplied || restartApplied, "Expected either managed hot reload apply or restart fallback."); + + if (managedApplied) + { + App.AssertOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + return; + } + + App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges); + } + + private void AssertFSharpEditAppliedInPlace() + { + App.AssertOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + App.AssertOutputDoesNotContain(MessageDescriptor.RestartNeededToApplyChanges.GetMessage()); + } + + private Task WaitForFSharpEditOutcomeAsync() + { + var succeeded = DescriptorPattern(MessageDescriptor.ManagedCodeChangesApplied); + var restarted = Regex.Escape(MessageDescriptor.RestartNeededToApplyChanges.GetMessage()); + return App.WaitForOutputLineContaining(new Regex($"{succeeded}|{restarted}")); + } + + private Task WaitForFSharpManagedUpdateDecisionAsync() + { + var noManagedChanges = Regex.Escape(MessageDescriptor.NoManagedCodeChangesToApply.GetMessage()); + var succeeded = DescriptorPattern(MessageDescriptor.ManagedCodeChangesApplied); + var restarted = Regex.Escape(MessageDescriptor.RestartNeededToApplyChanges.GetMessage()); + return App.WaitForOutputLineContaining(new Regex($"{noManagedChanges}|{succeeded}|{restarted}")); + } + + private string GetFSharpCompilerServicePath() + { + var sdkDirectory = SdkTestContext.Current.ToolsetUnderTest.SdkFolderUnderTest; + var fsharpCompilerServicePath = Path.Combine(sdkDirectory, "FSharp", "FSharp.Compiler.Service.dll"); + Assert.True(File.Exists(fsharpCompilerServicePath), $"Missing FSharp.Compiler.Service.dll at '{fsharpCompilerServicePath}'."); + return fsharpCompilerServicePath; + } + + [Fact] + public async Task ChangeFileInFSharpProjectWithLoop_AppliesOrRestarts() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + let message () = "Waiting" + + [] + let main argv = + while true do + printfn "%s" (message()) + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + + File.WriteAllText(sourcePath, source); + + App.Start(testAsset, ["--non-interactive"]); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); + + await WaitForFSharpEditOutcomeAsync(); + AssertFSharpEditAppliedOrRestarted(); + await App.AssertOutputLineStartsWith(""); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("", "")); + + await WaitForFSharpEditOutcomeAsync(); + AssertFSharpEditAppliedOrRestarted(); + await App.AssertOutputLineStartsWith(""); + } + + [Fact] + public async Task ChangeFileInFSharpProjectWithLoop_FirstEditAppliesInPlace() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"] = GetFSharpCompilerServicePath(); + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"] = "1"; + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + [] + let main argv = + while true do + printfn "Waiting" + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + File.WriteAllText(sourcePath, source); + + App.Start(testAsset, []); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + } + + [Fact] + public async Task ChangeComputationExpressionUsageInFSharpProject_AppliesInPlace() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"] = GetFSharpCompilerServicePath(); + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"] = "1"; + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + open System.Runtime.CompilerServices + + type HtmlBuilder() = + member _.Yield(text: string) = text + member _.Combine(a: string, b: string) = a + b + member _.Delay(f: unit -> string) = f() + member _.Run(text: string) = text + member _.Zero() = "" + + let html = HtmlBuilder() + + [] + let message () = + html { + yield "Hello, " + yield "watch" + } + + [] + let main argv = + while true do + printfn "%s" (message ()) + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + File.WriteAllText(sourcePath, source); + + App.Start(testAsset, []); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("Hello, ", "Welcome, ")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + // CE desugaring can route updates through synthesized helpers; assert in-place behavior + // and changed output shape, while allowing either full combined string or reduced payload. + await App.WaitForOutputLineContaining(new Regex("^Welcome, watch$|^watch$")); + } + + [Fact] + public async Task ChangeFileInFSharpProject_WhitespaceOnlyEditDoesNotRestart() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"] = GetFSharpCompilerServicePath(); + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"] = "1"; + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + type Greeter() = + let mutable count = 0 + member _.Message() = + count <- count + 1 + sprintf "Waiting (count: %d)" count + + let greeter = Greeter() + + [] + let main argv = + while true do + printfn "%s" (greeter.Message()) + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + File.WriteAllText(sourcePath, source); + + App.Start(testAsset, []); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + // Roslyn parity: insignificant source edits should never force a restart. + // Depending on compiler output diff, this may classify as either no-op or in-place apply. + UpdateSourceFile(sourcePath, content => content.Replace("member _.Message() =", "member _.Message() = ")); + + await WaitForFSharpManagedUpdateDecisionAsync(); + App.AssertOutputDoesNotContain(MessageDescriptor.RestartNeededToApplyChanges.GetMessage()); + } + + [Fact] + public async Task ChangeDependencyFileInFSharpProject_DoesNotRestart_AndSourceEditsStillApplyInPlace() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource() + .WithProjectChanges(project => + { + var ns = project.Root.Name.Namespace; + project.Root.Add( + new XElement(ns + "ItemGroup", + new XElement(ns + "EmbeddedResource", new XAttribute("Include", "payload.txt")), + new XElement(ns + "Watch", new XAttribute("Include", "payload.txt")))); + }); + + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"] = GetFSharpCompilerServicePath(); + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"] = "1"; + App.EnvironmentVariables["DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD"] = "1"; + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + let message () = "Waiting" + + [] + let main argv = + while true do + printfn "%s" (message()) + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + var dependencyPath = Path.Combine(testAsset.Path, "payload.txt"); + + File.WriteAllText(sourcePath, source); + File.WriteAllText(dependencyPath, "payload-v1"); + + App.Start(testAsset, []); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(dependencyPath, "payload-v2"); + + await WaitForFSharpManagedUpdateDecisionAsync(); + App.AssertOutputDoesNotContain(MessageDescriptor.RestartNeededToApplyChanges.GetMessage()); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + await App.AssertOutputLineStartsWith(""); + } + + [Fact] + public async Task ChangeXamlDependencyInFSharpProject_DoesNotRestart_AndSourceEditsStillApplyInPlace() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource() + .WithProjectChanges(project => + { + var ns = project.Root.Name.Namespace; + project.Root.Add( + new XElement(ns + "ItemGroup", + new XElement(ns + "Watch", new XAttribute("Include", "MainPage.xaml")))); + }); + + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"] = GetFSharpCompilerServicePath(); + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"] = "1"; + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + let message () = "Waiting" + + [] + let main argv = + while true do + printfn "%s" (message()) + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + var xamlPath = Path.Combine(testAsset.Path, "MainPage.xaml"); + + File.WriteAllText(sourcePath, source); + File.WriteAllText(xamlPath, ""); + + App.Start(testAsset, []); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(xamlPath, ""); + + await WaitForFSharpManagedUpdateDecisionAsync(); + App.AssertOutputDoesNotContain(MessageDescriptor.RestartNeededToApplyChanges.GetMessage()); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + await App.AssertOutputLineStartsWith(""); + } + + [Fact] + public async Task ChangeFileInFSharpProject_RudeEditTriggersRestart() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + [] + let main argv = + while true do + printfn "Waiting" + Thread.Sleep(200) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + + File.WriteAllText(sourcePath, source); + + App.Start(testAsset, ["--non-interactive"]); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + // rename the entry point method: this should trigger restart semantics + // instead of managed hot reload. + UpdateSourceFile(sourcePath, content => content.Replace("let main argv =", "let mainRenamed argv =")); + + await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges); + + App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges); + App.AssertOutputDoesNotContain(MessageDescriptor.ManagedCodeChangesApplied); + App.Process.ClearOutput(); + + // Ensure subsequent edits continue applying after restart. + UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); + + await App.WaitForOutputLineContaining(new Regex(@"Launched '")); + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + // The second edit can be consumed by the restart build before a new + // managed-update attempt is logged, so assert on observable app output. + await App.AssertOutputLineStartsWith(""); + } +} diff --git a/test/dotnet-watch.Tests/HotReload/FSharpReflectionHostTests.cs b/test/dotnet-watch.Tests/HotReload/FSharpReflectionHostTests.cs new file mode 100644 index 000000000000..c009d754bb21 --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/FSharpReflectionHostTests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class FSharpReflectionHostTests +{ + [Fact] + public void TryGetFSharpOptionValue_HandlesStaticIsSomeAccessor() + { + var assembly = typeof(MessageDescriptor).Assembly; + var serviceType = assembly.GetType("Microsoft.DotNet.Watch.FSharpHotReloadService", throwOnError: true)!; + var reflectionHostType = serviceType.GetNestedType("FSharpReflectionHost", BindingFlags.NonPublic)!; + + var tryGetOptionValue = reflectionHostType.GetMethod( + "TryGetFSharpOptionValue", + BindingFlags.NonPublic | BindingFlags.Static)!; + + var someValue = StaticAccessorOption.Some("workspace"); + + var someArgs = new object?[] { someValue, null }; + var someResult = (bool)tryGetOptionValue.Invoke(null, someArgs)!; + Assert.True(someResult); + Assert.Equal("workspace", someArgs[1]); + + var noneValue = StaticAccessorOption.None; + var noneArgs = new object?[] { noneValue, null }; + var noneResult = (bool)tryGetOptionValue.Invoke(null, noneArgs)!; + Assert.False(noneResult); + Assert.Null(noneArgs[1]); + } + + private sealed class StaticAccessorOption + { + private StaticAccessorOption(bool isSome, object? value) + { + _isSome = isSome; + _value = value; + } + + private readonly bool _isSome; + private readonly object? _value; + + public static StaticAccessorOption None { get; } = new(false, null); + + public static StaticAccessorOption Some(string value) => new(true, value); + + public static bool get_IsSome(StaticAccessorOption option) => option._isSome; + + public static object? get_Value(StaticAccessorOption option) => option._value; + } +} From 2acf544aafa703ad240b308a48f8805216c369df Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 10 Jun 2026 14:44:31 -0400 Subject: [PATCH 05/13] Forward runtime capabilities to the F# hot reload session Pass the same aggregate runtime edit-and-continue capabilities that the Roslyn hot reload service receives into FSharpHotReloadService, and probe the loaded FCS surface for the optional capabilities parameter on StartHotReloadSession via reflection. The capabilities are forwarded as FSharpOption> when the parameter exists and gracefully omitted for older FCS builds without it. --- .../Watch/HotReload/CompilationHandler.cs | 5 +- .../FSharp/FSharpHotReloadService.cs | 80 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs index 13936e15cb51..4603bcb9ec4f 100644 --- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs +++ b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs @@ -65,7 +65,10 @@ public CompilationHandler(DotNetWatchContext context) _context = context; Workspace = new HotReloadMSBuildWorkspace(context.Logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); - _fsharpHotReloadService = new FSharpHotReloadService(context.Logger); + + // Forward the same aggregate runtime capabilities to the F# hot reload session so its + // edit classification can distinguish runtime-unsupported edits from rude edits. + _fsharpHotReloadService = new FSharpHotReloadService(context.Logger, GetAggregateCapabilities); } public void Dispose() diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index 5e387ed071fe..6b4cf144c0d5 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -36,6 +36,13 @@ internal sealed class FSharpHotReloadService private readonly ILogger _logger; private readonly bool _trace; + /// + /// Provides the aggregate runtime edit-and-continue capabilities of the running processes, + /// matching the capabilities the Roslyn hot reload service receives. Evaluated lazily at + /// session start so newly launched processes are taken into account. + /// + private readonly Func>? _getCapabilities; + private ImmutableDictionary _projects = ImmutableDictionary.Empty; private ImmutableDictionary _cachedProjectInputs = ImmutableDictionary.Empty; private ImmutableDictionary _runtimeModuleIds = ImmutableDictionary.Empty; @@ -43,10 +50,28 @@ internal sealed class FSharpHotReloadService private ProjectInstanceId? _activeProject; private object? _activeProjectInput; - public FSharpHotReloadService(ILogger logger) + public FSharpHotReloadService(ILogger logger, Func>? getCapabilities = null) { _logger = logger; _trace = IsTraceEnabled(); + _getCapabilities = getCapabilities; + } + + private ImmutableArray GetRuntimeCapabilities() + { + try + { + return _getCapabilities?.Invoke() ?? []; + } + catch (Exception ex) + { + if (_trace) + { + _logger.LogDebug("Failed to query runtime hot reload capabilities: {Message}", ex.Message); + } + + return []; + } } public void UpdateProjects(ProjectGraph projectGraph) @@ -679,7 +704,7 @@ private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectI return false; } - var start = host.StartSession(projectInput!, CancellationToken.None); + var start = host.StartSession(projectInput!, GetRuntimeCapabilities(), CancellationToken.None); if (!start.IsSuccess) { status = MapErrorStatus(start.ErrorCase); @@ -1370,8 +1395,44 @@ public bool TryRefreshProjectInput(FSharpProjectInfo projectInfo, object current return TryCreateProjectOptionsInput(projectInfo, out refreshedProjectInput, out error); } - public FSharpInvocationResult StartSession(object projectInput, CancellationToken cancellationToken) - => InvokeResult(_startSession, [projectInput, null], cancellationToken); + public FSharpInvocationResult StartSession(object projectInput, ImmutableArray capabilities, CancellationToken cancellationToken) + => InvokeResult(_startSession, CreateStartSessionArguments(_startSession.GetParameters(), projectInput, capabilities), cancellationToken); + + /// + /// Builds the StartHotReloadSession argument list against whatever FCS surface is loaded. + /// Recent FCS builds expose an optional capabilities: string seq parameter + /// (surfaced via reflection as FSharpOption<IEnumerable<string>>); when present the + /// aggregate runtime capabilities are forwarded, otherwise they are gracefully omitted so the + /// host keeps working against older FCS builds. Unrecognized optional parameters are passed + /// null (None), matching the existing userOpName handling. + /// + private static object?[] CreateStartSessionArguments(ParameterInfo[] parameters, object projectInput, ImmutableArray capabilities) + { + var arguments = new object?[parameters.Length]; + + if (parameters.Length > 0) + { + arguments[0] = projectInput; + } + + if (capabilities.IsDefaultOrEmpty) + { + return arguments; + } + + for (var i = 1; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + if (string.Equals(parameter.Name, "capabilities", StringComparison.Ordinal) && + IsFSharpOptionOfStringSequence(parameter.ParameterType)) + { + arguments[i] = CreateFSharpOptionSomeStringSequence(parameter.ParameterType, [.. capabilities]); + } + } + + return arguments; + } public void InvalidateConfiguration(object projectInput, string projectPath) { @@ -1755,6 +1816,17 @@ private static bool IsFSharpOptionOfBoolean(Type parameterType) private static object? CreateFSharpOptionSomeBoolean(Type optionType, bool value) => optionType.GetMethod("Some", BindingFlags.Public | BindingFlags.Static, [typeof(bool)])?.Invoke(null, [value]); + private static bool IsFSharpOptionOfStringSequence(Type parameterType) + => parameterType.IsGenericType && + string.Equals(parameterType.GetGenericTypeDefinition().FullName, "Microsoft.FSharp.Core.FSharpOption`1", StringComparison.Ordinal) && + parameterType.GetGenericArguments() is [var argumentType] && + argumentType.IsAssignableFrom(typeof(string[])); + + private static object? CreateFSharpOptionSomeStringSequence(Type optionType, string[] value) + => optionType.GetGenericArguments() is [var argumentType] + ? optionType.GetMethod("Some", BindingFlags.Public | BindingFlags.Static, [argumentType])?.Invoke(null, [value]) + : null; + internal readonly record struct FSharpInvocationResult(bool IsSuccess, object? Value, string? ErrorCase, string? ErrorText); } } From 4d1109acb7fa9d80367c47c11a81c4c99ab39eb6 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Thu, 11 Jun 2026 02:50:12 -0400 Subject: [PATCH 06/13] Refresh F# session capabilities in place once runtime processes report them The F# hot reload session is prestarted before any agent connects, freezing an empty capability set into the session and pinning classification to baseline-only edits. Update the live session via the compiler service's UpdateHotReloadCapabilities once the real aggregate set is available. Restarting the session instead is not an option: a restart re-captures the baseline from sources that may already contain the pending edit, producing an empty diff against the running module. Older compiler services without the update API keep the session's original capabilities. --- .../FSharp/FSharpHotReloadService.cs | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index 6b4cf144c0d5..7cc349e074be 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -50,6 +50,15 @@ internal sealed class FSharpHotReloadService private ProjectInstanceId? _activeProject; private object? _activeProjectInput; + /// + /// The capability set the active session currently uses. The session is prestarted before + /// any agent connects (so this is often empty initially); once processes report their + /// capabilities the live session is updated in place via + /// FSharpChecker.UpdateHotReloadCapabilities — never restarted, because a restart would + /// re-capture the baseline from sources that may already contain pending edits. + /// + private ImmutableArray _activeSessionCapabilities = []; + public FSharpHotReloadService(ILogger logger, Func>? getCapabilities = null) { _logger = logger; @@ -170,6 +179,7 @@ public void EndSession() _host?.TryEndSession(); _activeProject = null; _activeProjectInput = null; + _activeSessionCapabilities = []; } #pragma warning disable CS1998 // Intentional sync fast-path wrapped in ValueTask-returning API. @@ -688,10 +698,37 @@ private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectI status = FSharpManagedUpdateStatus.NoChanges; message = null; + var currentCapabilities = GetRuntimeCapabilities(); + if (_activeProject is { } activeProject && _activeProjectInput != null && activeProject.Equals(projectInfo.ProjectId)) { + // The prestarted session is created before any agent reports its capabilities + // (empty set => baseline-only classification). Update the live session in place + // once the real set is available — never restart it: a restart would re-capture + // the baseline from sources that already contain the pending edit. + if (!CapabilitySetsEqual(_activeSessionCapabilities, currentCapabilities)) + { + var updated = host.TryUpdateSessionCapabilities(currentCapabilities); + + if (_trace) + { + _logger.LogDebug( + updated switch + { + true => "F# hot reload session capabilities refreshed ({Capabilities}).", + false => "F# hot reload session capability refresh failed; keeping previous set ({Capabilities}).", + null => "Loaded F# compiler service does not support capability refresh; keeping session capabilities ({Capabilities}).", + }, + string.Join(" ", currentCapabilities)); + } + + // Record regardless of outcome so an unsupported/failed refresh is not retried + // on every edit. + _activeSessionCapabilities = currentCapabilities; + } + return true; } @@ -704,7 +741,7 @@ private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectI return false; } - var start = host.StartSession(projectInput!, GetRuntimeCapabilities(), CancellationToken.None); + var start = host.StartSession(projectInput!, currentCapabilities, CancellationToken.None); if (!start.IsSuccess) { status = MapErrorStatus(start.ErrorCase); @@ -720,10 +757,16 @@ private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectI _activeProject = projectInfo.ProjectId; _activeProjectInput = projectInput; + _activeSessionCapabilities = currentCapabilities; _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, projectInput!); return true; } + private static bool CapabilitySetsEqual(ImmutableArray left, ImmutableArray right) + => left.Length == right.Length && + left.OrderBy(static c => c, StringComparer.Ordinal) + .SequenceEqual(right.OrderBy(static c => c, StringComparer.Ordinal), StringComparer.Ordinal); + private bool TryGetHost(FSharpProjectInfo projectInfo, out FSharpReflectionHost host, out string? message) { host = _host!; @@ -1032,6 +1075,13 @@ private sealed class FSharpReflectionHost private readonly MethodInfo? _notifyFileChanged; private readonly MethodInfo _emitDelta; private readonly MethodInfo _endSession; + + /// + /// FSharpChecker.UpdateHotReloadCapabilities, probed lazily because older compiler + /// service builds do not expose it. Null after probing means unsupported. + /// + private MethodInfo? _updateCapabilities; + private bool _updateCapabilitiesProbed; private readonly ImmutableArray _invalidateConfigurationMethods; private readonly MethodInfo _runSynchronously; private readonly bool _useWorkspaceSnapshots; @@ -1398,6 +1448,35 @@ public bool TryRefreshProjectInput(FSharpProjectInfo projectInfo, object current public FSharpInvocationResult StartSession(object projectInput, ImmutableArray capabilities, CancellationToken cancellationToken) => InvokeResult(_startSession, CreateStartSessionArguments(_startSession.GetParameters(), projectInput, capabilities), cancellationToken); + /// + /// Replaces the active session's capability set without restarting it (restarting would + /// re-capture the baseline from already-edited sources). Returns null when the loaded + /// compiler service predates FSharpChecker.UpdateHotReloadCapabilities, false when the + /// call failed or no session was active, true on success. + /// + public bool? TryUpdateSessionCapabilities(ImmutableArray capabilities) + { + if (!_updateCapabilitiesProbed) + { + _updateCapabilities = _checker.GetType().GetMethod("UpdateHotReloadCapabilities", BindingFlags.Public | BindingFlags.Instance); + _updateCapabilitiesProbed = true; + } + + if (_updateCapabilities == null) + { + return null; + } + + try + { + return _updateCapabilities.Invoke(_checker, [capabilities.ToArray()]) as bool? ?? false; + } + catch + { + return false; + } + } + /// /// Builds the StartHotReloadSession argument list against whatever FCS surface is loaded. /// Recent FCS builds expose an optional capabilities: string seq parameter From 747fbaac1aa04f34a10e631f6cb30ec05ab08150 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Thu, 11 Jun 2026 17:09:07 -0400 Subject: [PATCH 07/13] Use the F# hot reload session object for multi-project sessions When the loaded FSharp.Compiler.Service exposes FSharpChecker.CreateHotReloadSession, the watch bridge holds one FSharpHotReloadSession per watch session instead of switching the process-wide checker session between projects: - the session is created at StartSessionAsync with the current aggregate runtime capabilities and refreshed in place via the session's UpdateCapabilities; - every discovered F# project is captured into the session with AddProject(snapshot, outputPath) (eagerly at session start, lazily on first edit otherwise), so edits to F# library projects loaded into a running process now match and emit per-project deltas; - edits emit through the session's EmitDelta(snapshot); pending updates are committed at delta hand-off (the watch applies them immediately, mirroring Roslyn's CommitUpdate point) and discarded when hot reload is blocked or the restart prompt is declined; - EndSession disposes the session object. When CreateHotReloadSession is absent (older compiler service builds) the existing single-active-project switching path is kept unchanged as the fallback. --- .../Watch/HotReload/CompilationHandler.cs | 19 + .../FSharp/FSharpHotReloadService.cs | 530 +++++++++++++++++- 2 files changed, 525 insertions(+), 24 deletions(-) diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs index 4603bcb9ec4f..733fd929d835 100644 --- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs +++ b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs @@ -392,6 +392,14 @@ public async ValueTask GetManagedCodeUpdatesAsync( // changes and await the next file change. // Note: CommitUpdate/DiscardUpdate is not expected to be called. + + // A successful F# emit stages a pending update in the compiler-service session; + // it will not be applied, so drop it. + if (fsharpResult.Status == FSharpManagedUpdateStatus.ReadyToApply) + { + _fsharpHotReloadService.DiscardUpdates(); + } + return; } @@ -414,6 +422,7 @@ public async ValueTask GetManagedCodeUpdatesAsync( !await restartPrompt.Invoke(projectsToPromptForRestart, cancellationToken)) { _hotReloadService.DiscardUpdate(); + _fsharpHotReloadService.DiscardUpdates(); Logger.Log(MessageDescriptor.HotReloadSuspended); await Task.Delay(-1, cancellationToken); @@ -427,6 +436,16 @@ public async ValueTask GetManagedCodeUpdatesAsync( _hotReloadService.CommitUpdate(); } + if (fsharpHasUpdates) + { + // The F# emit staged pending per-project updates in the compiler-service session. + // The watch hands the deltas returned from this method to every running process + // immediately and unconditionally, so the committed baselines advance at hand-off — + // the same point Roslyn's CommitUpdate is invoked above. A runtime apply failure + // surfaces as an agent error and restarts the process, which recaptures baselines. + _fsharpHotReloadService.CommitUpdates(); + } + var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); var restartProjectPaths = updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!).ToImmutableArray(); diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index 7cc349e074be..1d6b1ce116a5 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -47,9 +47,29 @@ internal sealed class FSharpHotReloadService private ImmutableDictionary _cachedProjectInputs = ImmutableDictionary.Empty; private ImmutableDictionary _runtimeModuleIds = ImmutableDictionary.Empty; private FSharpReflectionHost? _host; + + /// + /// The active project for the LEGACY single-active-project checker surface (older compiler + /// service builds without FSharpChecker.CreateHotReloadSession). The legacy surface + /// holds one process-wide session, so the bridge has to switch it between projects. Unused + /// in session-object mode. + /// private ProjectInstanceId? _activeProject; private object? _activeProjectInput; + /// + /// The FSharpHotReloadSession instance (reflection-typed) when the loaded compiler service + /// exposes the session-object API. One session per watch session; each discovered F# + /// project is captured into it via AddProject (eagerly at session start, lazily on first + /// edit otherwise), edits emit per-project deltas, and pending updates are resolved by + /// /. Disposed by + /// . + /// + private object? _sessionObject; + + /// Projects whose baseline has been captured into . + private ImmutableHashSet _sessionObjectProjects = []; + /// /// The capability set the active session currently uses. The session is prestarted before /// any agent connects (so this is often empty initially); once processes report their @@ -89,6 +109,10 @@ public void UpdateProjects(ProjectGraph projectGraph) _cachedProjectInputs = _cachedProjectInputs.RemoveRange(_cachedProjectInputs.Keys.Where(key => !_projects.ContainsKey(key))); _runtimeModuleIds = _runtimeModuleIds.RemoveRange(_runtimeModuleIds.Keys.Where(key => !_projects.ContainsKey(key))); + // A project that leaves the graph and later returns must be re-added to the session + // object so its baseline is recaptured from the then-current build output. + _sessionObjectProjects = _sessionObjectProjects.Except(_sessionObjectProjects.Where(key => !_projects.ContainsKey(key))); + if (_activeProject is { } activeProject && !_projects.ContainsKey(activeProject)) { EndSession(); @@ -136,12 +160,43 @@ public ValueTask StartSessionAsync(CancellationToken cancellationToken) _cachedProjectInputs = cachedInputsBuilder.ToImmutable(); _runtimeModuleIds = ImmutableDictionary.Empty; - if (_projects.Count == 1) + if (_host is { } sessionHost && sessionHost.SupportsSessionObject) + { + // Session-object mode: create ONE session for the whole watch session and capture + // every discovered F# project into it up front (the launched project and the F# + // libraries loaded into it alike), so first edits diff against the pre-edit + // baseline build. Projects that fail to capture here are retried lazily on first + // edit by EnsureSession. + foreach (var projectInfo in _projects.Values) + { + if (!_cachedProjectInputs.ContainsKey(projectInfo.ProjectId)) + { + continue; + } + + if (!EnsureSession(sessionHost, projectInfo, out _, out var status, out var message)) + { + if (_trace) + { + _logger.LogDebug( + "Unable to prestart F# hot reload session project '{ProjectPath}': {Status} ({Message})", + projectInfo.ProjectPath, + status, + message); + } + } + else if (_trace) + { + _logger.LogDebug("F# hot reload session project prestarted for '{ProjectPath}'.", projectInfo.ProjectPath); + } + } + } + else if (_projects.Count == 1) { var projectInfo = _projects.Values.First(); if (TryGetHost(projectInfo, out var host, out var hostError)) { - if (!EnsureSession(host, projectInfo, out var status, out var message)) + if (!EnsureSession(host, projectInfo, out _, out var status, out var message)) { if (_trace) { @@ -171,6 +226,18 @@ public ValueTask StartSessionAsync(CancellationToken cancellationToken) public void EndSession() { + if (_sessionObject is { } sessionObject) + { + // Session-object mode: disposing the session ends it; per-project baselines and any + // pending updates go with it. + _host?.DisposeSession(sessionObject); + _sessionObject = null; + _sessionObjectProjects = []; + _runtimeModuleIds = ImmutableDictionary.Empty; + _activeSessionCapabilities = []; + return; + } + if (_activeProject is { } activeProject) { _runtimeModuleIds = _runtimeModuleIds.Remove(activeProject); @@ -182,6 +249,34 @@ public void EndSession() _activeSessionCapabilities = []; } + /// + /// Commits ALL pending F# project updates staged by the last successful emit. dotnet-watch + /// hands the emitted deltas to every running process immediately and unconditionally once it + /// decides to apply them, so the committed per-project baselines are advanced at hand-off + /// time — the same point the Roslyn watch service calls CommitUpdate. No-op for the legacy + /// checker surface, which auto-commits each successful emit. + /// + public void CommitUpdates() + { + if (_sessionObject is { } sessionObject) + { + _host?.SessionCommit(sessionObject); + } + } + + /// + /// Discards ALL pending F# project updates when the watch decides not to apply the emitted + /// deltas (hot reload blocked, or the user declined the restart prompt), so the next emit + /// diffs against the unchanged committed view. No-op for the legacy checker surface. + /// + public void DiscardUpdates() + { + if (_sessionObject is { } sessionObject) + { + _host?.SessionDiscard(sessionObject); + } + } + #pragma warning disable CS1998 // Intentional sync fast-path wrapped in ValueTask-returning API. public async ValueTask TryEmitUpdatesAsync( IReadOnlyList changedFiles, @@ -225,7 +320,7 @@ public async ValueTask TryEmitUpdatesAsync( hostError); } - if (!EnsureSession(host, projectInfo, out var ensureStatus, out var ensureMessage)) + if (!EnsureSession(host, projectInfo, out var projectInput, out var ensureStatus, out var ensureMessage)) { return new FSharpManagedUpdateResult(ensureStatus, [], projectInfo.ProjectPath, ensureMessage); } @@ -249,15 +344,15 @@ public async ValueTask TryEmitUpdatesAsync( foreach (var changedSourceFile in changedSourceFiles) { - host.NotifyFileChanged(changedSourceFile, projectInfo, _activeProjectInput!, cancellationToken); + host.NotifyFileChanged(changedSourceFile, projectInfo, projectInput!, cancellationToken); } if (!changedDependencyFiles.IsEmpty) { - host.InvalidateConfiguration(_activeProjectInput!, projectInfo.ProjectPath); + host.InvalidateConfiguration(projectInput!, projectInfo.ProjectPath); } - if (!host.TryRefreshProjectInput(projectInfo, _activeProjectInput!, out var refreshedProjectInput, out var refreshMessage)) + if (!host.TryRefreshProjectInput(projectInfo, projectInput!, out var refreshedProjectInput, out var refreshMessage)) { return new FSharpManagedUpdateResult( FSharpManagedUpdateStatus.RestartRequired, @@ -266,7 +361,12 @@ public async ValueTask TryEmitUpdatesAsync( refreshMessage); } - _activeProjectInput = refreshedProjectInput; + projectInput = refreshedProjectInput; + if (_activeProject is { } legacyActiveProject && legacyActiveProject.Equals(projectInfo.ProjectId)) + { + _activeProjectInput = refreshedProjectInput; + } + _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, refreshedProjectInput!); var moduleIdAfterCompile = TryGetModuleVersionId(projectInfo.TargetPath); @@ -314,7 +414,7 @@ public async ValueTask TryEmitUpdatesAsync( } } - var emit = host.EmitDelta(_activeProjectInput!, cancellationToken); + var emit = EmitDeltaCore(host, projectInput!, cancellationToken); if (!emit.IsSuccess) { var mappedStatus = MapErrorStatus(emit.ErrorCase); @@ -327,9 +427,9 @@ public async ValueTask TryEmitUpdatesAsync( } EndSession(); - if (EnsureSession(host, projectInfo, out var retryStatus, out var retryMessage)) + if (EnsureSession(host, projectInfo, out projectInput, out var retryStatus, out var retryMessage)) { - emit = host.EmitDelta(_activeProjectInput!, cancellationToken); + emit = EmitDeltaCore(host, projectInput!, cancellationToken); if (emit.IsSuccess) { mappedStatus = FSharpManagedUpdateStatus.ReadyToApply; @@ -395,6 +495,14 @@ public async ValueTask TryEmitUpdatesAsync( _runtimeModuleIds = _runtimeModuleIds.Remove(projectInfo.ProjectId); } + if (mappedStatus == FSharpManagedUpdateStatus.RestartRequired) + { + // The watch rebuilds and restarts the project; drop it from the session object so + // the next edit recaptures the baseline from the rebuilt output the new process + // actually loads. + _sessionObjectProjects = _sessionObjectProjects.Remove(projectInfo.ProjectId); + } + return new FSharpManagedUpdateResult(mappedStatus, [], projectInfo.ProjectPath, emit.ErrorText); } @@ -432,6 +540,12 @@ [new FSharpManagedUpdate(projectInfo.ProjectPath, update.Value)], IReadOnlyList changedFiles, ImmutableDictionary> runningProjects) { + // The session object emits per-project deltas, so an edit to an F# library that is + // loaded into a running process (but is not itself a running project) is matched to the + // library project itself. The legacy single-session surface can only target the running + // project, so it keeps the running-projects-only matching. + var includeNonRunningProjects = _host?.SupportsSessionObject == true; + foreach (var file in changedFiles) { var filePath = file.Item.FilePath; @@ -453,13 +567,13 @@ [new FSharpManagedUpdate(projectInfo.ProjectPath, update.Value)], continue; } - if (TryMatchRunningProjectByContainingPaths(file.Item.ContainingProjectPaths, runningProjects, out var containingProject)) + if (TryMatchRunningProjectByContainingPaths(file.Item.ContainingProjectPaths, runningProjects, includeNonRunningProjects, out var containingProject)) { return containingProject; } if (isDependencyChange && - TryMatchRunningProjectByDependencyPath(filePath, runningProjects, out var dependencyProject)) + TryMatchRunningProjectByDependencyPath(filePath, runningProjects, includeNonRunningProjects, out var dependencyProject)) { if (_trace) { @@ -473,7 +587,7 @@ [new FSharpManagedUpdate(projectInfo.ProjectPath, update.Value)], } if ((isSourceChange || isDependencyChange) && - TryMatchRunningProjectByPath(filePath, runningProjects, out var fallbackProject)) + TryMatchRunningProjectByPath(filePath, runningProjects, includeNonRunningProjects, out var fallbackProject)) { if (_trace) { @@ -515,13 +629,14 @@ [new FSharpManagedUpdate(projectInfo.ProjectPath, update.Value)], private bool TryMatchRunningProjectByContainingPaths( IReadOnlyList containingProjectPaths, ImmutableDictionary> runningProjects, + bool includeNonRunningProjects, out ProjectInstanceId projectId) { projectId = default; foreach (var containingProjectPath in containingProjectPaths) { - if (!runningProjects.ContainsKey(containingProjectPath)) + if (!includeNonRunningProjects && !runningProjects.ContainsKey(containingProjectPath)) { continue; } @@ -542,6 +657,7 @@ private bool TryMatchRunningProjectByContainingPaths( private bool TryMatchRunningProjectByDependencyPath( string filePath, ImmutableDictionary> runningProjects, + bool includeNonRunningProjects, out ProjectInstanceId projectId) { projectId = default; @@ -553,7 +669,7 @@ private bool TryMatchRunningProjectByDependencyPath( foreach (var projectInfo in _projects.Values) { - if (!runningProjects.ContainsKey(projectInfo.ProjectPath)) + if (!includeNonRunningProjects && !runningProjects.ContainsKey(projectInfo.ProjectPath)) { continue; } @@ -571,6 +687,7 @@ private bool TryMatchRunningProjectByDependencyPath( private bool TryMatchRunningProjectByPath( string filePath, ImmutableDictionary> runningProjects, + bool includeNonRunningProjects, out ProjectInstanceId projectId) { projectId = default; @@ -587,7 +704,7 @@ private bool TryMatchRunningProjectByPath( foreach (var knownProject in _projects.Keys) { - if (!runningProjects.ContainsKey(knownProject.ProjectPath)) + if (!includeNonRunningProjects && !runningProjects.ContainsKey(knownProject.ProjectPath)) { continue; } @@ -693,8 +810,20 @@ private static bool IsFileWithinProjectDirectory(string filePath, string project } } - private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectInfo, out FSharpManagedUpdateStatus status, out string? message) + /// + /// Makes the compiler-service session ready to emit a delta for the given project and + /// returns the project input (snapshot or options) to emit with. In session-object mode the + /// ONE session is created on first use and the project is added to it; the legacy checker + /// surface keeps its single-active-project switching behavior unchanged. + /// + private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectInfo, out object? projectInput, out FSharpManagedUpdateStatus status, out string? message) { + if (host.SupportsSessionObject) + { + return EnsureSessionObject(host, projectInfo, out projectInput, out status, out message); + } + + projectInput = null; status = FSharpManagedUpdateStatus.NoChanges; message = null; @@ -729,12 +858,13 @@ private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectI _activeSessionCapabilities = currentCapabilities; } + projectInput = _activeProjectInput; return true; } EndSession(); - if (!_cachedProjectInputs.TryGetValue(projectInfo.ProjectId, out var projectInput) && + if (!_cachedProjectInputs.TryGetValue(projectInfo.ProjectId, out projectInput) && !host.TryCreateProjectInput(projectInfo, out projectInput, out message)) { status = FSharpManagedUpdateStatus.RestartRequired; @@ -762,6 +892,107 @@ private bool EnsureSession(FSharpReflectionHost host, FSharpProjectInfo projectI return true; } + /// + /// Session-object mode: creates the watch-session-wide FSharpHotReloadSession on first use, + /// keeps its capability set current, and captures the project's baseline into it (AddProject) + /// the first time the project is seen. Never switches sessions between projects — every + /// tracked project keeps its own committed baseline and generation chain inside the session. + /// + private bool EnsureSessionObject(FSharpReflectionHost host, FSharpProjectInfo projectInfo, out object? projectInput, out FSharpManagedUpdateStatus status, out string? message) + { + projectInput = null; + status = FSharpManagedUpdateStatus.NoChanges; + message = null; + + var currentCapabilities = GetRuntimeCapabilities(); + + if (_sessionObject == null) + { + if (!host.TryCreateSession(currentCapabilities, out _sessionObject, out message)) + { + status = FSharpManagedUpdateStatus.RestartRequired; + return false; + } + + _sessionObjectProjects = []; + _activeSessionCapabilities = currentCapabilities; + + if (_trace) + { + _logger.LogDebug( + "F# hot reload session object created (capabilities: {Capabilities}).", + currentCapabilities.IsDefaultOrEmpty ? "" : string.Join(" ", currentCapabilities)); + } + } + else if (!CapabilitySetsEqual(_activeSessionCapabilities, currentCapabilities)) + { + // The session is created before any agent reports its capabilities (empty set => + // baseline-only classification). Replace the live session's capability set in place + // once the real set is available — never recreate the session: that would drop every + // project baseline and recapture from sources that already contain pending edits. + var updated = host.TrySessionUpdateCapabilities(_sessionObject, currentCapabilities); + + if (_trace) + { + _logger.LogDebug( + updated + ? "F# hot reload session capabilities refreshed ({Capabilities})." + : "F# hot reload session capability refresh failed; keeping previous set ({Capabilities}).", + string.Join(" ", currentCapabilities)); + } + + // Record regardless of outcome so a failed refresh is not retried on every edit. + _activeSessionCapabilities = currentCapabilities; + } + + if (!_cachedProjectInputs.TryGetValue(projectInfo.ProjectId, out projectInput) && + !host.TryCreateProjectInput(projectInfo, out projectInput, out message)) + { + status = FSharpManagedUpdateStatus.RestartRequired; + return false; + } + + if (!_sessionObjectProjects.Contains(projectInfo.ProjectId)) + { + // AddProject captures the project's committed baseline from the built output on disk. + // At this point the output is still the pre-edit build (forced compilation of the + // edited sources happens after EnsureSession), so the baseline matches what the + // running process loaded. + var add = host.SessionAddProject(_sessionObject!, projectInput!, projectInfo.TargetPath, CancellationToken.None); + if (!add.IsSuccess) + { + status = MapErrorStatus(add.ErrorCase); + message = add.ErrorText; + + if (status == FSharpManagedUpdateStatus.Blocked) + { + _logger.Log(MessageDescriptor.UnableToApplyChanges); + } + + return false; + } + + _sessionObjectProjects = _sessionObjectProjects.Add(projectInfo.ProjectId); + _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, projectInput!); + + if (_trace) + { + _logger.LogDebug("F# hot reload session now tracks '{ProjectPath}'.", projectInfo.ProjectPath); + } + } + + return true; + } + + /// + /// Emits the per-project delta through the session object when available, otherwise through + /// the legacy process-wide checker session. + /// + private FSharpReflectionHost.FSharpInvocationResult EmitDeltaCore(FSharpReflectionHost host, object projectInput, CancellationToken cancellationToken) + => host.SupportsSessionObject && _sessionObject is { } sessionObject + ? host.SessionEmitDelta(sessionObject, projectInput, cancellationToken) + : host.EmitDelta(projectInput, cancellationToken); + private static bool CapabilitySetsEqual(ImmutableArray left, ImmutableArray right) => left.Length == right.Length && left.OrderBy(static c => c, StringComparer.Ordinal) @@ -1082,6 +1313,14 @@ private sealed class FSharpReflectionHost /// private MethodInfo? _updateCapabilities; private bool _updateCapabilitiesProbed; + + /// + /// Reflection surface of FSharpHotReloadSession (the session-object API). Non-null + /// only when the loaded compiler service exposes FSharpChecker.CreateHotReloadSession + /// and the workspace snapshot bridge is available (the session API consumes project + /// snapshots). Null means the legacy single-active-project checker surface is used. + /// + private readonly SessionObjectApi? _sessionApi; private readonly ImmutableArray _invalidateConfigurationMethods; private readonly MethodInfo _runSynchronously; private readonly bool _useWorkspaceSnapshots; @@ -1102,6 +1341,7 @@ private FSharpReflectionHost( MethodInfo? notifyFileChanged, MethodInfo emitDelta, MethodInfo endSession, + SessionObjectApi? sessionApi, ImmutableArray invalidateConfigurationMethods, MethodInfo runSynchronously, bool useWorkspaceSnapshots, @@ -1121,6 +1361,7 @@ private FSharpReflectionHost( _notifyFileChanged = notifyFileChanged; _emitDelta = emitDelta; _endSession = endSession; + _sessionApi = sessionApi; _invalidateConfigurationMethods = invalidateConfigurationMethods; _runSynchronously = runSynchronously; _useWorkspaceSnapshots = useWorkspaceSnapshots; @@ -1225,7 +1466,12 @@ public static bool TryCreate( MethodInfo selectedEmitDelta; var preferWorkspaceSnapshots = ShouldPreferWorkspaceSnapshots(servicePathOverride); - if (preferWorkspaceSnapshots && + // The session-object API (FSharpChecker.CreateHotReloadSession) supersedes the + // process-wide single-session checker surface. It consumes project snapshots, so + // it requires the workspace bridge regardless of the snapshot-mode preference. + var sessionApi = SessionObjectApi.TryCreate(checkerType); + + if ((preferWorkspaceSnapshots || sessionApi != null) && startSessionWithSnapshot != null && emitDeltaWithSnapshot != null && TryCreateWorkspaceBridge( @@ -1247,11 +1493,17 @@ public static bool TryCreate( if (trace) { - logger.LogDebug("F# managed hot reload is using workspace snapshot bridge."); + logger.LogDebug( + sessionApi != null + ? "F# managed hot reload is using the session-object API over the workspace snapshot bridge." + : "F# managed hot reload is using workspace snapshot bridge."); } } else { + // Without snapshot inputs the session-object API cannot be used; fall back to + // the legacy single-active-project checker surface. + sessionApi = null; selectedStartSession = startSessionWithOptions ?? throw new MissingMethodException(checkerType.FullName, "StartHotReloadSession(projectOptions)"); selectedEmitDelta = emitDeltaWithOptions @@ -1277,6 +1529,7 @@ public static bool TryCreate( notifyFileChanged, selectedEmitDelta, endSession, + sessionApi, invalidateConfigurationMethods, runSynchronously, useWorkspaceSnapshots, @@ -1446,7 +1699,180 @@ public bool TryRefreshProjectInput(FSharpProjectInfo projectInfo, object current } public FSharpInvocationResult StartSession(object projectInput, ImmutableArray capabilities, CancellationToken cancellationToken) - => InvokeResult(_startSession, CreateStartSessionArguments(_startSession.GetParameters(), projectInput, capabilities), cancellationToken); + => InvokeResult(_checker, _startSession, CreateStartSessionArguments(_startSession.GetParameters(), projectInput, capabilities), cancellationToken); + + public bool SupportsSessionObject => _sessionApi != null; + + /// + /// Creates one FSharpHotReloadSession for the whole watch session. Per-project committed + /// baselines and generation chains live inside the session object; projects are captured + /// into it via and edits emitted via + /// . + /// + public bool TryCreateSession(ImmutableArray capabilities, out object? session, out string? error) + { + session = null; + error = null; + + if (_sessionApi == null) + { + error = "The loaded F# compiler service does not expose CreateHotReloadSession."; + return false; + } + + try + { + var parameters = _sessionApi.Create.GetParameters(); + var arguments = new object?[parameters.Length]; + + if (!capabilities.IsDefaultOrEmpty) + { + for (var i = 0; i < parameters.Length; i++) + { + if (string.Equals(parameters[i].Name, "capabilities", StringComparison.Ordinal) && + IsFSharpOptionOfStringSequence(parameters[i].ParameterType)) + { + arguments[i] = CreateFSharpOptionSomeStringSequence(parameters[i].ParameterType, [.. capabilities]); + } + } + } + + session = _sessionApi.Create.Invoke(_checker, arguments); + if (session == null) + { + error = "CreateHotReloadSession returned null."; + return false; + } + + return true; + } + catch (Exception ex) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + error = rootException.Message; + return false; + } + } + + /// + /// Captures the project's committed baseline into the session from the built output on + /// disk. Re-adding a project the session already tracks recaptures its baseline. + /// + public FSharpInvocationResult SessionAddProject(object session, object projectInput, string outputPath, CancellationToken cancellationToken) + { + if (_sessionApi == null) + { + return new FSharpInvocationResult(false, null, null, "The session-object API is unavailable."); + } + + var parameters = _sessionApi.AddProject.GetParameters(); + var arguments = new object?[parameters.Length]; + arguments[0] = projectInput; + + for (var i = 1; i < parameters.Length; i++) + { + if (string.Equals(parameters[i].Name, "outputPath", StringComparison.Ordinal) && + IsFSharpOptionOfString(parameters[i].ParameterType)) + { + arguments[i] = CreateFSharpOptionSomeString(parameters[i].ParameterType, outputPath); + } + } + + return InvokeResult(session, _sessionApi.AddProject, arguments, cancellationToken); + } + + /// + /// Emits a delta for one project against its committed baseline in the session. The + /// emitted update is staged as pending until or + /// . + /// + public FSharpInvocationResult SessionEmitDelta(object session, object projectInput, CancellationToken cancellationToken) + { + if (_sessionApi == null) + { + return new FSharpInvocationResult(false, null, null, "The session-object API is unavailable."); + } + + var arguments = new object?[_sessionApi.EmitDelta.GetParameters().Length]; + arguments[0] = projectInput; + return InvokeResult(session, _sessionApi.EmitDelta, arguments, cancellationToken); + } + + public void SessionCommit(object session) + { + try + { + _sessionApi?.Commit.Invoke(session, null); + } + catch (Exception ex) + { + if (_trace) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + _logger.LogDebug("F# session Commit failed: {Message}", rootException.Message); + } + } + } + + public void SessionDiscard(object session) + { + try + { + _sessionApi?.Discard.Invoke(session, null); + } + catch (Exception ex) + { + if (_trace) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + _logger.LogDebug("F# session Discard failed: {Message}", rootException.Message); + } + } + } + + /// + /// Replaces the session-wide capability set in place on the session object (the + /// session-object analogue of ). + /// + public bool TrySessionUpdateCapabilities(object session, ImmutableArray capabilities) + { + if (_sessionApi == null) + { + return false; + } + + try + { + _sessionApi.UpdateCapabilities.Invoke(session, [capabilities.ToArray()]); + return true; + } + catch (Exception ex) + { + if (_trace) + { + var rootException = (ex as TargetInvocationException)?.InnerException ?? ex.GetBaseException(); + _logger.LogDebug("F# session UpdateCapabilities failed: {Message}", rootException.Message); + } + + return false; + } + } + + /// Ends the session: FSharpHotReloadSession implements IDisposable. + public void DisposeSession(object session) + { + try + { + (session as IDisposable)?.Dispose(); + } + catch (Exception ex) + { + if (_trace) + { + _logger.LogDebug("Ignoring F# session dispose failure: {Message}", ex.Message); + } + } + } /// /// Replaces the active session's capability set without restarting it (restarting would @@ -1608,7 +2034,7 @@ private void NotifyCheckerFileChanged(string filePath, object? projectInput) } public FSharpInvocationResult EmitDelta(object projectInput, CancellationToken cancellationToken) - => InvokeResult(_emitDelta, [projectInput, null], cancellationToken); + => InvokeResult(_checker, _emitDelta, [projectInput, null], cancellationToken); private bool TryCreateProjectOptionsInput(FSharpProjectInfo projectInfo, out object? projectInput, out string? error) { @@ -1772,11 +2198,11 @@ public void TryEndSession() } } - private FSharpInvocationResult InvokeResult(MethodInfo method, object?[] args, CancellationToken cancellationToken) + private FSharpInvocationResult InvokeResult(object target, MethodInfo method, object?[] args, CancellationToken cancellationToken) { try { - var asyncComputation = method.Invoke(_checker, args) + var asyncComputation = method.Invoke(target, args) ?? throw new InvalidOperationException($"{method.Name} returned null."); var asyncResultType = method.ReturnType.GetGenericArguments().Single(); @@ -1895,6 +2321,15 @@ private static bool IsFSharpOptionOfBoolean(Type parameterType) private static object? CreateFSharpOptionSomeBoolean(Type optionType, bool value) => optionType.GetMethod("Some", BindingFlags.Public | BindingFlags.Static, [typeof(bool)])?.Invoke(null, [value]); + private static bool IsFSharpOptionOfString(Type parameterType) + => parameterType.IsGenericType && + string.Equals(parameterType.GetGenericTypeDefinition().FullName, "Microsoft.FSharp.Core.FSharpOption`1", StringComparison.Ordinal) && + parameterType.GetGenericArguments() is [var argumentType] && + argumentType == typeof(string); + + private static object? CreateFSharpOptionSomeString(Type optionType, string value) + => optionType.GetMethod("Some", BindingFlags.Public | BindingFlags.Static, [typeof(string)])?.Invoke(null, [value]); + private static bool IsFSharpOptionOfStringSequence(Type parameterType) => parameterType.IsGenericType && string.Equals(parameterType.GetGenericTypeDefinition().FullName, "Microsoft.FSharp.Core.FSharpOption`1", StringComparison.Ordinal) && @@ -1907,5 +2342,52 @@ private static bool IsFSharpOptionOfStringSequence(Type parameterType) : null; internal readonly record struct FSharpInvocationResult(bool IsSuccess, object? Value, string? ErrorCase, string? ErrorText); + + /// + /// Late-bound member set of FSharp.Compiler.CodeAnalysis.FSharpHotReloadSession: + /// one session object per watch session, per-project baselines added via AddProject, + /// deltas staged by EmitDelta and resolved by Commit/Discard, session-wide capabilities + /// replaced via UpdateCapabilities, ended by IDisposable.Dispose. + /// + private sealed class SessionObjectApi + { + public required MethodInfo Create { get; init; } + public required MethodInfo AddProject { get; init; } + public required MethodInfo EmitDelta { get; init; } + public required MethodInfo Commit { get; init; } + public required MethodInfo Discard { get; init; } + public required MethodInfo UpdateCapabilities { get; init; } + + public static SessionObjectApi? TryCreate(Type checkerType) + { + var create = checkerType.GetMethod("CreateHotReloadSession", BindingFlags.Public | BindingFlags.Instance); + if (create == null || !typeof(IDisposable).IsAssignableFrom(create.ReturnType)) + { + return null; + } + + var sessionType = create.ReturnType; + var addProject = sessionType.GetMethod("AddProject", BindingFlags.Public | BindingFlags.Instance); + var emitDelta = sessionType.GetMethod("EmitDelta", BindingFlags.Public | BindingFlags.Instance); + var commit = sessionType.GetMethod("Commit", BindingFlags.Public | BindingFlags.Instance, Type.EmptyTypes); + var discard = sessionType.GetMethod("Discard", BindingFlags.Public | BindingFlags.Instance, Type.EmptyTypes); + var updateCapabilities = sessionType.GetMethod("UpdateCapabilities", BindingFlags.Public | BindingFlags.Instance); + + if (addProject == null || emitDelta == null || commit == null || discard == null || updateCapabilities == null) + { + return null; + } + + return new SessionObjectApi + { + Create = create, + AddProject = addProject, + EmitDelta = emitDelta, + Commit = commit, + Discard = discard, + UpdateCapabilities = updateCapabilities, + }; + } + } } } From 2435effdf10ffce062b593de01c7849d9239e572 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Thu, 11 Jun 2026 17:38:53 -0400 Subject: [PATCH 08/13] Own F# file changes end-to-end in session-object mode Two fixes surfaced by running the F# watch e2e suite against a compiler service with the session-object API: - Changed files owned exclusively by F# projects the session tracks are no longer surfaced to the Roslyn workspace. The Roslyn EnC service has no F# support, so it reported rude edit ENC1009 with a redundant auto-rebuild for every F# edit; when the F# emit was a no-op the empty Roslyn result swallowed the user-facing decision message and dotnet-watch reported nothing. Legacy single-session mode keeps the Roslyn auto-rebuild as the fallback for edits the single-active-project bridge cannot target. - The module id of the output captured by AddProject is recorded as the runtime target id for the project. Forced design-time rebuilds (after a no-op or dependency-only emit) move the on-disk MVID without the process reloading, which made the next delta target a module the runtime never loaded. A blocked emit keeps the recorded id (the loaded module is unchanged); a restart drops it so re-adding the project recaptures both. Also adds DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT=0 as a safety valve forcing the legacy single-active-project path when the session-object API is present. --- .../Watch/HotReload/CompilationHandler.cs | 10 +++- .../FSharp/FSharpHotReloadService.cs | 56 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs index 733fd929d835..577f68e648b7 100644 --- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs +++ b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs @@ -1114,7 +1114,15 @@ public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, C public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) { - var solution = await Workspace.UpdateFileContentAsync(changedFiles.Select(static f => (f.Item.FilePath, f.Kind.Convert())), cancellationToken); + // Changes owned end-to-end by the F# hot reload service are not surfaced to the Roslyn + // workspace: the Roslyn EnC service has no F# support and would report rude edit ENC1009 + // with a redundant auto-rebuild even for F# edits applied in place (or classified as + // no-ops) by the F# compiler service. + var roslynChangedFiles = changedFiles + .Where(file => !_fsharpHotReloadService.OwnsChangedFile(file)) + .Select(static f => (f.Item.FilePath, f.Kind.Convert())); + + var solution = await Workspace.UpdateFileContentAsync(roslynChangedFiles, cancellationToken); await SolutionUpdatedAsync(solution, "document update", cancellationToken); } diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index 1d6b1ce116a5..393de8d12de2 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -249,6 +249,31 @@ public void EndSession() _activeSessionCapabilities = []; } + /// + /// True when the changed file belongs exclusively to F# projects whose updates this service + /// owns end-to-end (session-object mode). Such changes must not be surfaced to the Roslyn + /// workspace: its EnC service has no F# support, so it reports rude edit ENC1009 ("project + /// does not support Hot Reload") and schedules a redundant rebuild for every F# edit — + /// including edits the F# compiler service applies in place or correctly classifies as + /// no-ops, where the empty Roslyn result would otherwise swallow the user-facing + /// "no changes"/"applied" decision message. In legacy single-session mode files are not + /// claimed: the Roslyn auto-rebuild remains the fallback for edits the single-active-project + /// bridge cannot target (such as F# library projects). + /// + public bool OwnsChangedFile(ChangedFile changedFile) + { + if (_host?.SupportsSessionObject != true) + { + return false; + } + + var containingProjectPaths = changedFile.Item.ContainingProjectPaths; + return containingProjectPaths.Count > 0 && + containingProjectPaths.All(containingProjectPath => + _projects.Keys.Any(knownProject => + PathUtilities.OSSpecificPathComparer.Equals(knownProject.ProjectPath, containingProjectPath))); + } + /// /// Commits ALL pending F# project updates staged by the last successful emit. dotnet-watch /// hands the emitted deltas to every running process immediately and unconditionally once it @@ -490,7 +515,11 @@ public async ValueTask TryEmitUpdatesAsync( emit.ErrorText); } - if (mappedStatus == FSharpManagedUpdateStatus.RestartRequired || mappedStatus == FSharpManagedUpdateStatus.Blocked) + // Session-object mode keeps the module id recorded at baseline capture while the + // process keeps running (a blocked emit does not change the loaded module); the + // legacy path keeps its historical reset-on-blocked behavior. + if (mappedStatus == FSharpManagedUpdateStatus.RestartRequired || + (mappedStatus == FSharpManagedUpdateStatus.Blocked && _sessionObject == null)) { _runtimeModuleIds = _runtimeModuleIds.Remove(projectInfo.ProjectId); } @@ -498,8 +527,8 @@ public async ValueTask TryEmitUpdatesAsync( if (mappedStatus == FSharpManagedUpdateStatus.RestartRequired) { // The watch rebuilds and restarts the project; drop it from the session object so - // the next edit recaptures the baseline from the rebuilt output the new process - // actually loads. + // the next edit recaptures the baseline (and its module id) from the rebuilt + // output the new process actually loads. _sessionObjectProjects = _sessionObjectProjects.Remove(projectInfo.ProjectId); } @@ -975,6 +1004,15 @@ private bool EnsureSessionObject(FSharpReflectionHost host, FSharpProjectInfo pr _sessionObjectProjects = _sessionObjectProjects.Add(projectInfo.ProjectId); _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, projectInput!); + // Record the module id of the output AddProject just baselined — the id the running + // process loads. Forced design-time rebuilds (for example after a no-op or + // dependency-only emit) move the on-disk MVID without the process reloading, so + // later emits must keep targeting this baseline id rather than the disk's. + if (TryGetModuleVersionId(projectInfo.TargetPath) is { } baselineModuleId) + { + _runtimeModuleIds = _runtimeModuleIds.SetItem(projectInfo.ProjectId, baselineModuleId); + } + if (_trace) { _logger.LogDebug("F# hot reload session now tracks '{ProjectPath}'.", projectInfo.ProjectPath); @@ -1469,7 +1507,9 @@ public static bool TryCreate( // The session-object API (FSharpChecker.CreateHotReloadSession) supersedes the // process-wide single-session checker surface. It consumes project snapshots, so // it requires the workspace bridge regardless of the snapshot-mode preference. - var sessionApi = SessionObjectApi.TryCreate(checkerType); + // DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT=0 is a safety valve forcing the legacy + // single-active-project path even when the new API is available. + var sessionApi = ShouldUseSessionObject() ? SessionObjectApi.TryCreate(checkerType) : null; if ((preferWorkspaceSnapshots || sessionApi != null) && startSessionWithSnapshot != null && @@ -1549,6 +1589,14 @@ public static bool TryCreate( } } + private static bool ShouldUseSessionObject() + { + var overrideValue = Environment.GetEnvironmentVariable("DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT"); + return string.IsNullOrEmpty(overrideValue) || + (!string.Equals(overrideValue, "0", StringComparison.OrdinalIgnoreCase) && + !string.Equals(overrideValue, "false", StringComparison.OrdinalIgnoreCase)); + } + private static bool ShouldPreferWorkspaceSnapshots(string? servicePathOverride) { var overrideValue = Environment.GetEnvironmentVariable("DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"); From 22234294e348aa29378164e50a3fffb0e0c0195e Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Thu, 11 Jun 2026 17:39:07 -0400 Subject: [PATCH 09/13] Add multi-project F# hot reload end-to-end test Adds the FSharpAppWithLib test asset (an F# App referencing an F# Lib, both compiled with --enable:hotreloaddeltas so baseline builds are deterministic) and an end-to-end watch test that interleaves edits across the two projects: edit Lib's method body (in-place apply targeting the Lib module loaded into the running App process), then App's body, then Lib again. The legacy single-active-session bridge could not interleave projects without recapturing baselines from already-edited sources; the session object keeps each project's committed baseline and generation chain independent. Verified locally: the new test passes, and the full F# watch test classes (FSharpHotReloadTests, FSharpHotReloadServiceTests, FSharpReflectionHostTests) pass 19/19 against an SDK layout provisioned with the hot-reload-v2 FSharp.Compiler.Service. --- .../FSharpAppWithLib/App/App.fsproj | 17 +++++++ .../FSharpAppWithLib/App/Program.fs | 16 +++++++ .../TestProjects/FSharpAppWithLib/Lib/Lib.fs | 8 ++++ .../FSharpAppWithLib/Lib/Lib.fsproj | 15 +++++++ .../HotReload/FSharpHotReloadTests.cs | 45 +++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj create mode 100644 test/TestAssets/TestProjects/FSharpAppWithLib/App/Program.fs create mode 100644 test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fs create mode 100644 test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj b/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj new file mode 100644 index 000000000000..53d3a3148d19 --- /dev/null +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj @@ -0,0 +1,17 @@ + + + + $(CurrentTargetFramework) + Exe + + true + $(OtherFlags) --enable:hotreloaddeltas + + + + + + + + + diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/App/Program.fs b/test/TestAssets/TestProjects/FSharpAppWithLib/App/Program.fs new file mode 100644 index 000000000000..54f064d77f16 --- /dev/null +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/App/Program.fs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +module FSharpAppWithLib.Program + +open System.Runtime.CompilerServices +open System.Threading + +[] +let decorate (text: string) = sprintf "App[%s]" text + +[] +let main argv = + while true do + printfn "%s" (decorate (FSharpAppWithLib.Lib.message ())) + Thread.Sleep(200) + 0 diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fs b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fs new file mode 100644 index 000000000000..f12bed4e2f36 --- /dev/null +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fs @@ -0,0 +1,8 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +module FSharpAppWithLib.Lib + +open System.Runtime.CompilerServices + +[] +let message () = "LibWaiting" diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj new file mode 100644 index 000000000000..2920d7c187dd --- /dev/null +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj @@ -0,0 +1,15 @@ + + + + $(CurrentTargetFramework) + + true + $(OtherFlags) --enable:hotreloaddeltas + + + + + + + + diff --git a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs index 1ae77eab0e56..b244529d1a4e 100644 --- a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs @@ -359,6 +359,51 @@ let message () = "Waiting" await App.AssertOutputLineStartsWith(""); } + [Fact] + public async Task ChangeFilesInFSharpAppAndLib_InterleavedEditsApplyInPlace() + { + var testAsset = TestAssets.CopyTestAsset("FSharpAppWithLib") + .WithSource(); + + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_COMPILER_SERVICE_PATH"] = GetFSharpCompilerServicePath(); + App.EnvironmentVariables["DOTNET_WATCH_FSHARP_USE_WORKSPACE_SNAPSHOTS"] = "1"; + + var libPath = Path.Combine(testAsset.Path, "Lib", "Lib.fs"); + var appPath = Path.Combine(testAsset.Path, "App", "Program.fs"); + + App.Start(testAsset, [], "App"); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + // Edit the LIBRARY's method body: the per-project delta targets the Lib module loaded + // into the running App process. + UpdateSourceFile(libPath, content => content.Replace("LibWaiting", "LibEdit1")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + await App.AssertOutputLineStartsWith("App[LibEdit1]"); + App.Process.ClearOutput(); + + // Edit the APP's method body: same watch session, different project. The legacy + // single-active-session bridge could not interleave projects without recapturing + // baselines from already-edited sources. + UpdateSourceFile(appPath, content => content.Replace("App[%s]", "App2[%s]")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + await App.AssertOutputLineStartsWith("App2[LibEdit1]"); + App.Process.ClearOutput(); + + // Edit the LIBRARY again: its committed baseline and generation chain advanced + // independently of the App project's inside the one session object. + UpdateSourceFile(libPath, content => content.Replace("LibEdit1", "LibEdit2")); + + await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + AssertFSharpEditAppliedInPlace(); + await App.AssertOutputLineStartsWith("App2[LibEdit2]"); + } + [Fact] public async Task ChangeFileInFSharpProject_RudeEditTriggersRestart() { From 8465dedfbf33a4d80ae96f177d82788144ab0ba3 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Fri, 12 Jun 2026 13:41:40 -0400 Subject: [PATCH 10/13] Add DOTNET_WATCH_FSHARP_HOTRELOAD master kill switch for the F# bridge --- .../FSharp/FSharpHotReloadService.cs | 58 +++++++++++++++++++ .../HotReload/FSharpHotReloadServiceTests.cs | 51 ++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index 393de8d12de2..d5418b9c515b 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -36,6 +36,17 @@ internal sealed class FSharpHotReloadService private readonly ILogger _logger; private readonly bool _trace; + /// + /// Master kill switch for the entire F# hot reload bridge. Set + /// DOTNET_WATCH_FSHARP_HOTRELOAD=0 (or false) to make this service behave as if no F# + /// projects exist: session prestart does nothing, reports + /// no changes, and claims nothing, restoring the stock + /// restart-on-edit behavior for F# projects without having to know the narrower + /// DOTNET_WATCH_FSHARP_* tuning variables. Unset (the default) means enabled. Read once at + /// construction. + /// + private readonly bool _disabled; + /// /// Provides the aggregate runtime edit-and-continue capabilities of the running processes, /// matching the capabilities the Roslyn hot reload service receives. Evaluated lazily at @@ -83,6 +94,7 @@ public FSharpHotReloadService(ILogger logger, Func>? getC { _logger = logger; _trace = IsTraceEnabled(); + _disabled = IsDisabled(); _getCapabilities = getCapabilities; } @@ -105,6 +117,11 @@ private ImmutableArray GetRuntimeCapabilities() public void UpdateProjects(ProjectGraph projectGraph) { + if (_disabled) + { + return; + } + _projects = FSharpProjectInfo.Collect(projectGraph, _logger); _cachedProjectInputs = _cachedProjectInputs.RemoveRange(_cachedProjectInputs.Keys.Where(key => !_projects.ContainsKey(key))); _runtimeModuleIds = _runtimeModuleIds.RemoveRange(_runtimeModuleIds.Keys.Where(key => !_projects.ContainsKey(key))); @@ -121,6 +138,11 @@ public void UpdateProjects(ProjectGraph projectGraph) public ValueTask StartSessionAsync(CancellationToken cancellationToken) { + if (_disabled) + { + return ValueTask.CompletedTask; + } + // Prime per-project inputs from the baseline build before edits arrive. // This mirrors Roslyn's committed-solution model where the first edit is compared // against the last built state rather than being used as the session baseline. @@ -226,6 +248,11 @@ public ValueTask StartSessionAsync(CancellationToken cancellationToken) public void EndSession() { + if (_disabled) + { + return; + } + if (_sessionObject is { } sessionObject) { // Session-object mode: disposing the session ends it; per-project baselines and any @@ -262,6 +289,11 @@ public void EndSession() /// public bool OwnsChangedFile(ChangedFile changedFile) { + if (_disabled) + { + return false; + } + if (_host?.SupportsSessionObject != true) { return false; @@ -283,6 +315,11 @@ public bool OwnsChangedFile(ChangedFile changedFile) /// public void CommitUpdates() { + if (_disabled) + { + return; + } + if (_sessionObject is { } sessionObject) { _host?.SessionCommit(sessionObject); @@ -296,6 +333,11 @@ public void CommitUpdates() /// public void DiscardUpdates() { + if (_disabled) + { + return; + } + if (_sessionObject is { } sessionObject) { _host?.SessionDiscard(sessionObject); @@ -308,6 +350,11 @@ public async ValueTask TryEmitUpdatesAsync( ImmutableDictionary> runningProjects, CancellationToken cancellationToken) { + if (_disabled) + { + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.NoChanges, [], null, null); + } + var changedProject = TryGetChangedRunningFSharpProject(changedFiles, runningProjects); if (changedProject == null) { @@ -1275,6 +1322,17 @@ private static bool IsTraceEnabled() string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); } + /// + /// DOTNET_WATCH_FSHARP_HOTRELOAD=0 (or false) disables the F# hot reload bridge entirely; + /// any other value, including unset, leaves it enabled. See . + /// + private static bool IsDisabled() + { + var value = Environment.GetEnvironmentVariable("DOTNET_WATCH_FSHARP_HOTRELOAD"); + return string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); + } + private bool TryCompileProjectOutput(FSharpProjectInfo projectInfo, out string? message) { message = null; diff --git a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs index bbec4a5c7e74..e31c7c754f28 100644 --- a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs +++ b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs @@ -148,6 +148,57 @@ public void TryGetChangedRunningFSharpProject_IgnoresEditorTempFiles() Assert.Null(result); } + [Theory] + [InlineData("0")] + [InlineData("false")] + public async Task KillSwitch_DisablesEntireBridge(string killSwitchValue) + { + var originalValue = Environment.GetEnvironmentVariable("DOTNET_WATCH_FSHARP_HOTRELOAD"); + try + { + Environment.SetEnvironmentVariable("DOTNET_WATCH_FSHARP_HOTRELOAD", killSwitchValue); + + var projectDirectory = Path.Combine(Path.GetTempPath(), "dotnet-watch-fsharp-tests-killswitch"); + var projectPath = Path.Combine(projectDirectory, "TestApp.fsproj"); + var targetPath = Path.Combine(projectDirectory, "bin", "Debug", "net10.0", "TestApp.dll"); + var compilerPath = Path.Combine(projectDirectory, "fsc.dll"); + + var projectId = new ProjectInstanceId(projectPath, "net10.0"); + var projectInfo = new FSharpProjectInfo(projectId, projectPath, "net10.0", targetPath, compilerPath, []); + + // The kill switch is read once at construction. + var service = new FSharpHotReloadService(NullLogger.Instance); + + // Simulate a discovered F# project so that, were the bridge enabled, the change below + // would match the running project and produce a result carrying the project path. + var projectsField = typeof(FSharpHotReloadService).GetField("_projects", BindingFlags.Instance | BindingFlags.NonPublic)!; + projectsField.SetValue(service, ImmutableDictionary.Empty.Add(projectId, projectInfo)); + + var changedFile = new ChangedFile( + new FileItem + { + FilePath = Path.Combine(projectDirectory, "Program.fs"), + ContainingProjectPaths = [projectPath] + }, + ChangeKind.Update); + + var runningProjects = ImmutableDictionary>.Empty.Add(projectPath, []); + + await service.StartSessionAsync(CancellationToken.None); + var result = await service.TryEmitUpdatesAsync([changedFile], runningProjects, CancellationToken.None); + + // Disabled: the service behaves as if no F# projects exist. + Assert.Equal(FSharpManagedUpdateStatus.NoChanges, result.Status); + Assert.Empty(result.Updates); + Assert.Null(result.ProjectPath); + Assert.False(service.OwnsChangedFile(changedFile)); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_WATCH_FSHARP_HOTRELOAD", originalValue); + } + } + [Fact] public void TryGetCommandLineDependencyPath_ParsesResourceLogicalNameSuffix() { From 5ef537dfee9ce6e3a76fe0a28d829fd2eda3360c Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Fri, 12 Jun 2026 13:45:03 -0400 Subject: [PATCH 11/13] Advertise F# SupportsHotReload capability only under dotnet-watch or explicit opt-in --- .../targets/Microsoft.NET.Sdk.FSharp.targets | 13 +++++- ...ivenThatWeWantToBuildALibraryWithFSharp.cs | 45 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets index 6d3ac366c2d5..913e48c7bff5 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.FSharp.targets @@ -24,8 +24,17 @@ Copyright (c) .NET Foundation. All rights reserved. - - + + diff --git a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs index af33267100dc..9928c1f68915 100644 --- a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs +++ b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildALibraryWithFSharp.cs @@ -227,12 +227,13 @@ public void It_implicitly_defines_compilation_constants_for_the_target_framework } [Fact] - public void It_sets_SupportsHotReload_capability_for_net6_or_newer() + public void It_sets_SupportsHotReload_capability_for_net6_or_newer_under_dotnet_watch() { var projectCapabilities = GetValuesFromTestLibrary( Log, TestAssetsManager, "ProjectCapability", + msbuildArgs: new[] { "/p:DotNetWatchBuild=true" }, valueType: GetValuesCommand.ValueType.Item, projectChanges: project => { @@ -244,6 +245,46 @@ public void It_sets_SupportsHotReload_capability_for_net6_or_newer() projectCapabilities.Should().Contain("SupportsHotReload"); } + [Fact] + public void It_does_not_set_SupportsHotReload_capability_outside_dotnet_watch() + { + // ProjectCapabilities are read by other hosts such as Visual Studio, which has no + // F# Edit-and-Continue support, so the capability is only advertised when + // dotnet-watch sets DotNetWatchBuild=true (or a project opts in explicitly). + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + valueType: GetValuesCommand.ValueType.Item, + projectChanges: project => + { + var ns = project.Root.Name.Namespace; + project.Root.Descendants(ns + "TargetFramework").Single().Value = "net6.0"; + }, + targetFramework: "net6.0"); + + projectCapabilities.Should().NotContain("SupportsHotReload"); + } + + [Fact] + public void It_sets_SupportsHotReload_capability_when_explicitly_opted_in() + { + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + valueType: GetValuesCommand.ValueType.Item, + projectChanges: project => + { + var ns = project.Root.Name.Namespace; + project.Root.Descendants(ns + "TargetFramework").Single().Value = "net6.0"; + project.Root.Descendants(ns + "PropertyGroup").First().Add(new XElement(ns + "SupportsHotReload", "true")); + }, + targetFramework: "net6.0"); + + projectCapabilities.Should().Contain("SupportsHotReload"); + } + [Fact] public void It_does_not_set_SupportsHotReload_capability_for_pre_net6() { @@ -251,6 +292,7 @@ public void It_does_not_set_SupportsHotReload_capability_for_pre_net6() Log, TestAssetsManager, "ProjectCapability", + msbuildArgs: new[] { "/p:DotNetWatchBuild=true" }, valueType: GetValuesCommand.ValueType.Item, projectChanges: project => { @@ -269,6 +311,7 @@ public void It_respects_SupportsHotReload_false_override() Log, TestAssetsManager, "ProjectCapability", + msbuildArgs: new[] { "/p:DotNetWatchBuild=true" }, valueType: GetValuesCommand.ValueType.Item, projectChanges: project => { From b71bbe7f745e57087fe92f4ce2054bc2201f7a2d Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Fri, 12 Jun 2026 16:06:59 -0400 Subject: [PATCH 12/13] Support FCS builds without the legacy hot reload checker surface in the F# bridge --- .../FSharp/FSharpHotReloadService.cs | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index d5418b9c515b..d509288916e2 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -1398,10 +1398,15 @@ private sealed class FSharpReflectionHost private readonly bool _trace; private readonly object _checker; private readonly MethodInfo _getProjectOptions; - private readonly MethodInfo _startSession; + + // The legacy process-wide checker surface (StartHotReloadSession/EmitHotReloadDelta/ + // EndHotReloadSession/NotifyFileChanged). Current compiler service builds no longer ship + // it — all four are null in session-object mode, where FSharpHotReloadSession members + // (via _sessionApi) are the replacements. + private readonly MethodInfo? _startSession; private readonly MethodInfo? _notifyFileChanged; - private readonly MethodInfo _emitDelta; - private readonly MethodInfo _endSession; + private readonly MethodInfo? _emitDelta; + private readonly MethodInfo? _endSession; /// /// FSharpChecker.UpdateHotReloadCapabilities, probed lazily because older compiler @@ -1433,10 +1438,10 @@ private FSharpReflectionHost( bool trace, object checker, MethodInfo getProjectOptions, - MethodInfo startSession, + MethodInfo? startSession, MethodInfo? notifyFileChanged, - MethodInfo emitDelta, - MethodInfo endSession, + MethodInfo? emitDelta, + MethodInfo? endSession, SessionObjectApi? sessionApi, ImmutableArray invalidateConfigurationMethods, MethodInfo runSynchronously, @@ -1522,8 +1527,10 @@ public static bool TryCreate( .Where(method => method.Name == "EmitHotReloadDelta") .ToImmutableArray(); - var endSession = checkerType.GetMethod("EndHotReloadSession", BindingFlags.Public | BindingFlags.Instance) - ?? throw new MissingMethodException(checkerType.FullName, "EndHotReloadSession"); + // Null on current compiler service builds: the legacy process-wide checker + // surface was retired in favor of CreateHotReloadSession. Only required when the + // legacy path ends up selected below. + var endSession = checkerType.GetMethod("EndHotReloadSession", BindingFlags.Public | BindingFlags.Instance); var invalidateConfigurationMethods = checkerType .GetMethods(BindingFlags.Public | BindingFlags.Instance) @@ -1558,20 +1565,38 @@ public static bool TryCreate( MethodInfo? workspaceFilesEdit = null; MethodInfo? workspaceFilesClose = null; string? workspaceError = null; - MethodInfo selectedStartSession; - MethodInfo selectedEmitDelta; + MethodInfo? selectedStartSession; + MethodInfo? selectedEmitDelta; var preferWorkspaceSnapshots = ShouldPreferWorkspaceSnapshots(servicePathOverride); // The session-object API (FSharpChecker.CreateHotReloadSession) supersedes the // process-wide single-session checker surface. It consumes project snapshots, so // it requires the workspace bridge regardless of the snapshot-mode preference. // DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT=0 is a safety valve forcing the legacy - // single-active-project path even when the new API is available. - var sessionApi = ShouldUseSessionObject() ? SessionObjectApi.TryCreate(checkerType) : null; + // single-active-project path even when the new API is available — honored only + // while the loaded compiler service still ships that legacy surface. + var legacySurfaceAvailable = startSessionMethods.Length > 0 && emitDeltaMethods.Length > 0 && endSession != null; + var sessionApi = SessionObjectApi.TryCreate(checkerType); + if (sessionApi != null && !ShouldUseSessionObject()) + { + if (legacySurfaceAvailable) + { + sessionApi = null; + } + else + { + // The valve has nothing to fall back to; ignore it loudly rather than + // failing host creation (which would silently degrade to restarts). + logger.LogDebug( + "Ignoring DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT=0: the loaded F# compiler service no longer exposes the legacy hot reload checker surface (StartHotReloadSession/EmitHotReloadDelta/EndHotReloadSession). Proceeding with the session-object API."); + } + } if ((preferWorkspaceSnapshots || sessionApi != null) && - startSessionWithSnapshot != null && - emitDeltaWithSnapshot != null && + // Session-object mode drives emit through FSharpHotReloadSession members and + // does not need the legacy snapshot overloads (current compiler service + // builds no longer ship them). + (sessionApi != null || (startSessionWithSnapshot != null && emitDeltaWithSnapshot != null)) && TryCreateWorkspaceBridge( assembly, checkerType, @@ -1607,6 +1632,11 @@ public static bool TryCreate( selectedEmitDelta = emitDeltaWithOptions ?? throw new MissingMethodException(checkerType.FullName, "EmitHotReloadDelta(projectOptions)"); + if (endSession == null) + { + throw new MissingMethodException(checkerType.FullName, "EndHotReloadSession"); + } + if (trace && !string.IsNullOrEmpty(workspaceError)) { logger.LogDebug("F# workspace snapshot bridge unavailable, using project-options path: {Message}", workspaceError); @@ -1805,7 +1835,9 @@ public bool TryRefreshProjectInput(FSharpProjectInfo projectInfo, object current } public FSharpInvocationResult StartSession(object projectInput, ImmutableArray capabilities, CancellationToken cancellationToken) - => InvokeResult(_checker, _startSession, CreateStartSessionArguments(_startSession.GetParameters(), projectInput, capabilities), cancellationToken); + => _startSession == null + ? new FSharpInvocationResult(false, null, null, "The loaded F# compiler service does not expose StartHotReloadSession; use the session-object API.") + : InvokeResult(_checker, _startSession, CreateStartSessionArguments(_startSession.GetParameters(), projectInput, capabilities), cancellationToken); public bool SupportsSessionObject => _sessionApi != null; @@ -2140,7 +2172,9 @@ private void NotifyCheckerFileChanged(string filePath, object? projectInput) } public FSharpInvocationResult EmitDelta(object projectInput, CancellationToken cancellationToken) - => InvokeResult(_checker, _emitDelta, [projectInput, null], cancellationToken); + => _emitDelta == null + ? new FSharpInvocationResult(false, null, null, "The loaded F# compiler service does not expose EmitHotReloadDelta; use the session-object API.") + : InvokeResult(_checker, _emitDelta, [projectInput, null], cancellationToken); private bool TryCreateProjectOptionsInput(FSharpProjectInfo projectInfo, out object? projectInput, out string? error) { @@ -2291,6 +2325,13 @@ private void NotifyWorkspaceFileChanged(string filePath) public void TryEndSession() { + if (_endSession == null) + { + // Session-object mode: sessions end via DisposeSession; there is no process-wide + // legacy session to tear down. + return; + } + try { _ = _endSession.Invoke(_checker, null); From ab1a0a0dbb42b7f39c2aadf9b26c22268a16b311 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Fri, 19 Jun 2026 09:29:03 -0400 Subject: [PATCH 13/13] Rename F# hot reload flag to --test:HotReloadDeltas Match the F# compiler change moving the experimental hot reload Edit-and- Continue flag from the public --enable:hotreloaddeltas option to the help-hidden --test:HotReloadDeltas incubation switch. Update the watch service's flag detection/injection and the F# test assets accordingly. --- .../Watch/HotReload/FSharp/FSharpHotReloadService.cs | 4 ++-- test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj | 2 +- test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs index d509288916e2..9320e720e664 100644 --- a/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -2377,9 +2377,9 @@ private FSharpInvocationResult InvokeResult(object target, MethodInfo method, ob } private static ImmutableArray EnsureHotReloadFlag(ImmutableArray args) - => args.Any(static arg => string.Equals(arg, "--enable:hotreloaddeltas", StringComparison.OrdinalIgnoreCase)) + => args.Any(static arg => string.Equals(arg, "--test:HotReloadDeltas", StringComparison.OrdinalIgnoreCase)) ? args - : args.Add("--enable:hotreloaddeltas"); + : args.Add("--test:HotReloadDeltas"); private static bool TryGetFSharpOptionValue(object? option, out object? value) { diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj b/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj index 53d3a3148d19..609e8e679f83 100644 --- a/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj @@ -5,7 +5,7 @@ Exe true - $(OtherFlags) --enable:hotreloaddeltas + $(OtherFlags) --test:HotReloadDeltas diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj index 2920d7c187dd..2ff1e9facf69 100644 --- a/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj @@ -4,7 +4,7 @@ $(CurrentTargetFramework) true - $(OtherFlags) --enable:hotreloaddeltas + $(OtherFlags) --test:HotReloadDeltas