Skip to content
Merged
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
21 changes: 21 additions & 0 deletions src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

using System.Diagnostics;
using System.IO.Pipes;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.DotNet.HotReload;
using Microsoft.DotNet.Watch;

Expand All @@ -20,12 +22,15 @@ internal sealed class StartupHook
private static PosixSignalRegistration? s_signalRegistration;
#endif

private static Func<AssemblyLoadContext, AssemblyName, Assembly?>? s_assemblyResolvingEventHandler;

/// <summary>
/// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS.
/// </summary>
public static void Initialize()
{
var processPath = Environment.GetCommandLineArgs().FirstOrDefault();
var processDir = Path.GetDirectoryName(processPath)!;

Log($"Loaded into process: {processPath} ({typeof(StartupHook).Assembly.Location})");

Expand Down Expand Up @@ -61,6 +66,14 @@ public static void Initialize()

RegisterSignalHandlers();

// prepare handler, it will be installed on first managed update:
s_assemblyResolvingEventHandler = (_, args) =>
{
Log($"Resolving '{args.Name}, Version={args.Version}'");
var path = Path.Combine(processDir, args.Name + ".dll");
return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null;
};

var agent = new HotReloadAgent();
try
{
Expand Down Expand Up @@ -134,6 +147,14 @@ private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipe
// Shouldn't get initial managed code updates when the debugger is attached.
// The debugger itself applies these updates when launching process with the debugger attached.
Debug.Assert(!Debugger.IsAttached);

var handler = s_assemblyResolvingEventHandler;
if (handler != null)
{
AssemblyLoadContext.Default.Resolving += handler;
s_assemblyResolvingEventHandler = null;
}

await ReadAndApplyManagedCodeUpdateAsync(pipeClient, agent, cancellationToken);
break;

Expand Down
2 changes: 2 additions & 0 deletions src/BuiltInTools/dotnet-watch/Build/BuildNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ internal static class ItemNames
internal static class MetadataNames
{
public const string Watch = nameof(Watch);
public const string TargetPath = nameof(TargetPath);
}

internal static class TargetNames
{
public const string Compile = nameof(Compile);
public const string Restore = nameof(Restore);
public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets);
public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup);
}
93 changes: 50 additions & 43 deletions src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,11 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
}
}

public async ValueTask<(ImmutableDictionary<ProjectId, string> projectsToRebuild, ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
public async ValueTask<(
ImmutableArray<WatchHotReloadService.Update> projectUpdates,
ImmutableArray<string> projectsToRebuild,
ImmutableArray<string> projectsToRedeploy,
ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
bool autoRestart,
Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
CancellationToken cancellationToken)
Expand All @@ -258,7 +262,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
// changes and await the next file change.

// Note: CommitUpdate/DiscardUpdate is not expected to be called.
return ([], []);
return ([], [], [], []);
}

var projectsToPromptForRestart =
Expand All @@ -274,61 +278,64 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
_reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", emoji: "🔥");
await Task.Delay(-1, cancellationToken);

return ([], []);
}

if (!updates.ProjectUpdates.IsEmpty)
{
ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
lock (_runningProjectsAndUpdatesGuard)
{
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
_previousUpdates = _previousUpdates.AddRange(updates.ProjectUpdates);

// Capture the set of processes that do not have the currently calculated deltas yet.
projectsToUpdate = _runningProjects;
}

// Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
// The process may load any of the binaries using MEF or some other runtime dependency loader.

await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
{
try
{
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
if (applySucceded)
{
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
if (runningProject.BrowserRefreshServer is { } server)
{
runningProject.Reporter.Verbose("Refreshing browser.");
await server.RefreshBrowserAsync(cancellationToken);
}
}
}
catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥");
}
}, cancellationToken);
return ([], [], [], []);
}

// Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding.
_hotReloadService.CommitUpdate();

DiscardPreviousUpdates(updates.ProjectsToRebuild);

var projectsToRebuild = updates.ProjectsToRebuild.ToImmutableDictionary(keySelector: id => id, elementSelector: id => currentSolution.GetProject(id)!.FilePath!);
var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();

// Terminate all tracked processes that need to be restarted,
// except for the root process, which will terminate later on.
var terminatedProjects = updates.ProjectsToRestart.IsEmpty
? []
: await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken);

return (projectsToRebuild, terminatedProjects);
return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, terminatedProjects);
}

public async ValueTask ApplyUpdatesAsync(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
{
Debug.Assert(!updates.IsEmpty);

ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
lock (_runningProjectsAndUpdatesGuard)
{
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
_previousUpdates = _previousUpdates.AddRange(updates);

// Capture the set of processes that do not have the currently calculated deltas yet.
projectsToUpdate = _runningProjects;
}

// Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
// The process may load any of the binaries using MEF or some other runtime dependency loader.

await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
{
try
{
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
if (applySucceded)
{
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
if (runningProject.BrowserRefreshServer is { } server)
{
runningProject.Reporter.Verbose("Refreshing browser.");
await server.RefreshBrowserAsync(cancellationToken);
}
}
}
catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥");
}
}, cancellationToken);
}

private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects)
Expand Down
96 changes: 89 additions & 7 deletions src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;

namespace Microsoft.DotNet.Watch
Expand Down Expand Up @@ -240,7 +242,7 @@ void FileChangedCallback(ChangedPath change)

extendTimeout = false;

var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: null);
var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []);
if (changedFiles is [])
{
continue;
Expand Down Expand Up @@ -269,7 +271,7 @@ void FileChangedCallback(ChangedPath change)

HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler);

var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(
var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(
autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true,
restartPrompt: async (projectNames, cancellationToken) =>
{
Expand Down Expand Up @@ -334,7 +336,7 @@ void FileChangedCallback(ChangedPath change)
try
{
var buildResults = await Task.WhenAll(
projectsToRebuild.Values.Select(projectPath => BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken)));
projectsToRebuild.Select(projectPath => BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken)));

foreach (var (success, output, projectPath) in buildResults)
{
Expand Down Expand Up @@ -363,7 +365,21 @@ void FileChangedCallback(ChangedPath change)
// Apply them to the workspace.
_ = await CaptureChangedFilesSnapshot(projectsToRebuild);

_context.Reporter.Report(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Count);
_context.Reporter.Report(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length);
}

// Deploy dependencies after rebuilding and before restarting.
if (!projectsToRedeploy.IsEmpty)
{
DeployProjectDependencies(evaluationResult.ProjectGraph, projectsToRedeploy, iterationCancellationToken);
_context.Reporter.Report(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length);
}

// Apply updates only after dependencies have been deployed,
// so that updated code doesn't attempt to access the dependency before it has been deployed.
if (!managedCodeUpdates.IsEmpty)
{
await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, iterationCancellationToken);
}

if (!projectsToRestart.IsEmpty)
Expand Down Expand Up @@ -393,7 +409,7 @@ await Task.WhenAll(

_context.Reporter.Report(MessageDescriptor.HotReloadChangeHandled, stopwatch.ElapsedMilliseconds);

async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDictionary<ProjectId, string>? rebuiltProjects)
async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableArray<string> rebuiltProjects)
{
var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []);
if (changedPaths is [])
Expand Down Expand Up @@ -464,12 +480,12 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
_context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted);
}

if (rebuiltProjects != null)
if (!rebuiltProjects.IsEmpty)
{
// Filter changed files down to those contained in projects being rebuilt.
// File changes that affect projects that are not being rebuilt will stay in the accumulator
// and be included in the next Hot Reload change set.
var rebuiltProjectPaths = rebuiltProjects.Values.ToHashSet();
var rebuiltProjectPaths = rebuiltProjects.ToHashSet();

var newAccumulator = ImmutableList<ChangedPath>.Empty;
var newChangedFiles = ImmutableList<ChangedFile>.Empty;
Expand Down Expand Up @@ -555,6 +571,72 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
}
}

private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray<string> projectPaths, CancellationToken cancellationToken)
{
var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer);
var buildReporter = new BuildReporter(_context.Reporter, _context.Options, _context.EnvironmentOptions);
var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup;

foreach (var node in graph.ProjectNodes)
{
cancellationToken.ThrowIfCancellationRequested();

var projectPath = node.ProjectInstance.FullPath;

if (!projectPathSet.Contains(projectPath))
{
continue;
}

if (!node.ProjectInstance.Targets.ContainsKey(targetName))
{
continue;
}

if (node.GetOutputDirectory() is not { } relativeOutputDir)
{
continue;
}

using var loggers = buildReporter.GetLoggers(projectPath, targetName);
if (!node.ProjectInstance.Build([targetName], loggers, out var targetOutputs))
{
_context.Reporter.Verbose($"{targetName} target failed");
loggers.ReportOutput();
continue;
}

var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir);

foreach (var item in targetOutputs[targetName].Items)
{
cancellationToken.ThrowIfCancellationRequested();

var sourcePath = item.ItemSpec;
var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath));
if (!File.Exists(targetPath))
{
_context.Reporter.Verbose($"Deploying project dependency '{targetPath}' from '{sourcePath}'");

try
{
var directory = Path.GetDirectoryName(targetPath);
if (directory != null)
{
Directory.CreateDirectory(directory);
}

File.Copy(sourcePath, targetPath, overwrite: false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the file is different? Is it expected that we use the old dependency?

Copy link
Member Author

@tmat tmat Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if the file exists it might have been loaded already. We could try to overwrite it and catch the exception, but I think that would result in less predictable behavior. The app might load the dependency at any point in time while it's running.

Roslyn will report a rude edit if the assembly version changed. In such scenario we would not get here.
We also don't get here when a referenced project is updated because that produces update delta, not a new binary that needs to be copied over.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! Thanks for the explanation.

}
catch (Exception e)
{
_context.Reporter.Verbose($"Copy failed: {e.Message}");
}
}
}
}
}

private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken)
{
if (evaluationResult != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ public async Task UpdateProjectConeAsync(string rootProjectPath, CancellationTok
UpdateReferencesAfterAdd();

ProjectReference MapProjectReference(ProjectReference pr)
// Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing:
=> new(projectIdMap.TryGetValue(pr.ProjectId, out var mappedId) ? mappedId : pr.ProjectId, pr.Aliases, pr.EmbedInteropTypes);
// Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing.
// When a new project is added along with a new project reference the old project id is also null.
=> new(projectIdMap.TryGetValue(pr.ProjectId, out var oldProjectId) && oldProjectId != null ? oldProjectId : pr.ProjectId, pr.Aliases, pr.EmbedInteropTypes);

ImmutableArray<DocumentInfo> MapDocuments(ProjectId mappedProjectId, IReadOnlyList<DocumentInfo> documents)
=> documents.Select(docInfo =>
Expand Down
Loading
Loading