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,16 @@ 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())); + + // 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() { _isDisposed = true; + _fsharpHotReloadService.EndSession(); Workspace?.Dispose(); } @@ -82,7 +88,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 +98,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 +108,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 +350,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,20 +361,54 @@ 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. + // 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; + } + + 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; } @@ -379,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); @@ -386,25 +430,68 @@ 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); + 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(); + } - builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates); - builder.ProjectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!)); + var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + var restartProjectPaths = updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!).ToImmutableArray(); + + 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 +623,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 +1059,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 +1092,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 +1105,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); @@ -1023,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 new file mode 100644 index 000000000000..9320e720e664 --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/FSharp/FSharpHotReloadService.cs @@ -0,0 +1,2540 @@ +// 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; + + /// + /// 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 + /// 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; + 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 + /// 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; + _trace = IsTraceEnabled(); + _disabled = IsDisabled(); + _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) + { + 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))); + + // 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(); + } + } + + 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. + 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 (_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 _, 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 (_disabled) + { + return; + } + + 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); + } + + _host?.TryEndSession(); + _activeProject = null; + _activeProjectInput = null; + _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 (_disabled) + { + return false; + } + + 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 + /// 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 (_disabled) + { + return; + } + + 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 (_disabled) + { + return; + } + + 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, + ImmutableDictionary> runningProjects, + CancellationToken cancellationToken) + { + if (_disabled) + { + return new FSharpManagedUpdateResult(FSharpManagedUpdateStatus.NoChanges, [], null, null); + } + + 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 projectInput, 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, projectInput!, cancellationToken); + } + + if (!changedDependencyFiles.IsEmpty) + { + host.InvalidateConfiguration(projectInput!, projectInfo.ProjectPath); + } + + if (!host.TryRefreshProjectInput(projectInfo, projectInput!, out var refreshedProjectInput, out var refreshMessage)) + { + return new FSharpManagedUpdateResult( + FSharpManagedUpdateStatus.RestartRequired, + [], + projectInfo.ProjectPath, + refreshMessage); + } + + projectInput = refreshedProjectInput; + if (_activeProject is { } legacyActiveProject && legacyActiveProject.Equals(projectInfo.ProjectId)) + { + _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 = EmitDeltaCore(host, projectInput!, 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 projectInput, out var retryStatus, out var retryMessage)) + { + emit = EmitDeltaCore(host, projectInput!, 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); + } + + // 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); + } + + if (mappedStatus == FSharpManagedUpdateStatus.RestartRequired) + { + // The watch rebuilds and restarts the project; drop it from the session object so + // the next edit recaptures the baseline (and its module id) from the rebuilt + // output the new process actually loads. + _sessionObjectProjects = _sessionObjectProjects.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) + { + // 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; + 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, includeNonRunningProjects, out var containingProject)) + { + return containingProject; + } + + if (isDependencyChange && + TryMatchRunningProjectByDependencyPath(filePath, runningProjects, includeNonRunningProjects, 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, includeNonRunningProjects, 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, + bool includeNonRunningProjects, + out ProjectInstanceId projectId) + { + projectId = default; + + foreach (var containingProjectPath in containingProjectPaths) + { + if (!includeNonRunningProjects && !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, + bool includeNonRunningProjects, + out ProjectInstanceId projectId) + { + projectId = default; + + if (!TryNormalizeFullPath(filePath, out var normalizedFilePath)) + { + return false; + } + + foreach (var projectInfo in _projects.Values) + { + if (!includeNonRunningProjects && !runningProjects.ContainsKey(projectInfo.ProjectPath)) + { + continue; + } + + if (IsCommandLineDependencyPath(normalizedFilePath, projectInfo)) + { + projectId = projectInfo.ProjectId; + return true; + } + } + + return false; + } + + private bool TryMatchRunningProjectByPath( + string filePath, + ImmutableDictionary> runningProjects, + bool includeNonRunningProjects, + out ProjectInstanceId projectId) + { + projectId = default; + + string normalizedFilePath; + try + { + normalizedFilePath = Path.GetFullPath(filePath); + } + catch + { + return false; + } + + foreach (var knownProject in _projects.Keys) + { + if (!includeNonRunningProjects && !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; + } + } + + /// + /// 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; + + 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; + } + + projectInput = _activeProjectInput; + return true; + } + + EndSession(); + + if (!_cachedProjectInputs.TryGetValue(projectInfo.ProjectId, out projectInput) && + !host.TryCreateProjectInput(projectInfo, out projectInput, out message)) + { + status = FSharpManagedUpdateStatus.RestartRequired; + return false; + } + + var start = host.StartSession(projectInput!, currentCapabilities, 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; + _activeSessionCapabilities = currentCapabilities; + _cachedProjectInputs = _cachedProjectInputs.SetItem(projectInfo.ProjectId, projectInput!); + 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!); + + // 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); + } + } + + 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) + .SequenceEqual(right.OrderBy(static c => c, StringComparer.Ordinal), StringComparer.Ordinal); + + 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); + } + + /// + /// 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; + + 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; + + // 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; + + /// + /// FSharpChecker.UpdateHotReloadCapabilities, probed lazily because older compiler + /// service builds do not expose it. Null after probing means unsupported. + /// + 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; + 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, + SessionObjectApi? sessionApi, + 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; + _sessionApi = sessionApi; + _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(); + + // 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) + .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); + + // 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 — 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) && + // 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, + 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( + 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 + ?? 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); + } + 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, + sessionApi, + invalidateConfigurationMethods, + runSynchronously, + useWorkspaceSnapshots, + workspaceProjects, + workspaceFiles, + workspaceQuery, + workspaceProjectAddOrUpdate, + workspaceQueryGetProjectSnapshot, + workspaceFilesEdit, + workspaceFilesClose); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + 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"); + 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, ImmutableArray capabilities, CancellationToken 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; + + /// + /// 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 + /// 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 + /// (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) + { + 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) + => _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) + { + 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() + { + 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); + } + catch (Exception ex) + { + if (_trace) + { + _logger.LogDebug("Ignoring F# session cleanup failure: {Message}", ex.Message); + } + } + } + + private FSharpInvocationResult InvokeResult(object target, MethodInfo method, object?[] args, CancellationToken cancellationToken) + { + try + { + var asyncComputation = method.Invoke(target, 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, "--test:HotReloadDeltas", StringComparison.OrdinalIgnoreCase)) + ? args + : args.Add("--test: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]); + + 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) && + 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); + + /// + /// 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, + }; + } + } + } +} 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); + } +} 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; } = []; 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..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,4 +24,18 @@ 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..9928c1f68915 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,103 @@ 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_under_dotnet_watch() + { + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + msbuildArgs: new[] { "/p:DotNetWatchBuild=true" }, + 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_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() + { + var projectCapabilities = GetValuesFromTestLibrary( + Log, + TestAssetsManager, + "ProjectCapability", + msbuildArgs: new[] { "/p:DotNetWatchBuild=true" }, + 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", + msbuildArgs: new[] { "/p:DotNetWatchBuild=true" }, + 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"); + } } } diff --git a/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj b/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj new file mode 100644 index 000000000000..609e8e679f83 --- /dev/null +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/App/App.fsproj @@ -0,0 +1,17 @@ + + + + $(CurrentTargetFramework) + Exe + + true + $(OtherFlags) --test: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..2ff1e9facf69 --- /dev/null +++ b/test/TestAssets/TestProjects/FSharpAppWithLib/Lib/Lib.fsproj @@ -0,0 +1,15 @@ + + + + $(CurrentTargetFramework) + + true + $(OtherFlags) --test:HotReloadDeltas + + + + + + + + diff --git a/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs new file mode 100644 index 000000000000..e31c7c754f28 --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadServiceTests.cs @@ -0,0 +1,218 @@ +// 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); + } + + [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() + { + 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..b244529d1a4e --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/FSharpHotReloadTests.cs @@ -0,0 +1,455 @@ +// 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 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() + { + 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; + } +}