Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/Dotnet.Watch/HotReloadAgent/HotReloadAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssemblyLoadContext, AssemblyName, Assembly?>? _assemblyResolvingHandlerToInstall;
Expand Down Expand Up @@ -140,14 +141,39 @@ public void ApplyManagedCodeUpdates(IEnumerable<RuntimeManagedCodeUpdate> 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 ?? "<unknown>";
var assemblyModuleId = TryGetModuleId(assembly);
return assemblyModuleId is Guid id
? $"{assemblyName}:{id}"
: $"{assemblyName}:<no-module-id>";
})
.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);
Expand Down Expand Up @@ -269,6 +295,13 @@ private void ApplyDeltas(Assembly assembly, IReadOnlyList<RuntimeManagedCodeUpda
}
}

private static bool IsTraceFSharpHotReloadEnabled()
{
var value = Environment.GetEnvironmentVariable("DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD");
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}

private static Guid? TryGetModuleId(Assembly loadedAssembly)
{
try
Expand Down
133 changes: 116 additions & 17 deletions src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal sealed class CompilationHandler : IDisposable
public readonly HotReloadMSBuildWorkspace Workspace;
private readonly DotNetWatchContext _context;
private readonly HotReloadService _hotReloadService;
private readonly FSharpHotReloadService _fsharpHotReloadService;

/// <summary>
/// Lock to synchronize:
Expand Down Expand Up @@ -47,7 +48,7 @@ private ImmutableDictionary<string, ImmutableArray<RestartOperation>> _activePro
/// <summary>
/// All updates that were attempted. Includes updates whose application failed.
/// </summary>
private ImmutableList<HotReloadService.Update> _previousUpdates = [];
private ImmutableList<ManagedCodeUpdateEnvelope> _previousUpdates = [];

private bool _isDisposed;
private int _solutionUpdateId;
Expand All @@ -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();
}

Expand All @@ -82,7 +88,7 @@ public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken
Dispose();
}

private void DiscardPreviousUpdates(ImmutableArray<ProjectId> projectsToBeRebuilt)
private void DiscardPreviousUpdates(IReadOnlyList<string> projectsToBeRebuilt)
{
// Remove previous updates to all modules that were affected by rude edits.
// All running projects that statically reference these modules have been terminated.
Expand All @@ -92,7 +98,8 @@ private void DiscardPreviousUpdates(ImmutableArray<ProjectId> 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));
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -342,6 +350,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C

public async ValueTask GetManagedCodeUpdatesAsync(
HotReloadProjectUpdatesBuilder builder,
IReadOnlyList<ChangedFile> changedFiles,
Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
bool autoRestart,
CancellationToken cancellationToken)
Expand All @@ -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;
}
Expand All @@ -379,32 +422,76 @@ public async ValueTask GetManagedCodeUpdatesAsync(
!await restartPrompt.Invoke(projectsToPromptForRestart, cancellationToken))
{
_hotReloadService.DiscardUpdate();
_fsharpHotReloadService.DiscardUpdates();

Logger.Log(MessageDescriptor.HotReloadSuspended);
await Task.Delay(-1, cancellationToken);

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<HotReloadService.Update> managedCodeUpdates,
IReadOnlyList<ManagedCodeUpdateEnvelope> managedCodeUpdates,
IReadOnlyDictionary<RunningProject, List<StaticWebAsset>> staticAssetUpdates,
ImmutableArray<ChangedFile> changedFiles,
LoadedProjectGraph projectGraph,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -972,6 +1059,9 @@ private static IEnumerable<RunningProject> 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<RunningProject> GetCorrespondingRunningProjects(ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects, ProjectInstance project)
{
if (!runningProjects.TryGetValue(project.FullPath, out var projectsWithPath))
Expand Down Expand Up @@ -1002,8 +1092,8 @@ private ProjectInstance GetProjectInstance(Project project)
return instances.Single(instance => string.Equals(instance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
}

private static ImmutableArray<HotReloadManagedCodeUpdate> ToManagedCodeUpdates(IEnumerable<HotReloadService.Update> updates)
=> [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))];
private static ImmutableArray<HotReloadManagedCodeUpdate> ToManagedCodeUpdates(IEnumerable<ManagedCodeUpdateEnvelope> updates)
=> [.. updates.Select(update => update.Update)];

private static ImmutableDictionary<string, ImmutableArray<ProjectInstance>> CreateProjectInstanceMap(ProjectGraph graph)
=> graph.ProjectNodes
Expand All @@ -1015,6 +1105,7 @@ private static ImmutableDictionary<string, ImmutableArray<ProjectInstance>> Crea
public async Task<Solution> 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);
Expand All @@ -1023,7 +1114,15 @@ public async Task<Solution> UpdateProjectGraphAsync(ProjectGraph projectGraph, C

public async Task UpdateFileContentAsync(IReadOnlyList<ChangedFile> 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);
}

Expand Down
Loading