Skip to content

Commit 8258e1d

Browse files
committed
Implement support for changing project and package references
1 parent 2a4195b commit 8258e1d

File tree

9 files changed

+310
-63
lines changed

9 files changed

+310
-63
lines changed

src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System.Diagnostics;
55
using System.IO.Pipes;
6+
using System.Reflection;
7+
using System.Runtime.Loader;
68
using Microsoft.DotNet.HotReload;
79
using Microsoft.DotNet.Watch;
810

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

25+
private static Func<AssemblyLoadContext, AssemblyName, Assembly?>? s_assemblyResolvingEventHandler;
26+
2327
/// <summary>
2428
/// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS.
2529
/// </summary>
2630
public static void Initialize()
2731
{
2832
var processPath = Environment.GetCommandLineArgs().FirstOrDefault();
33+
var processDir = Path.GetDirectoryName(processPath)!;
2934

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

@@ -61,6 +66,14 @@ public static void Initialize()
6166

6267
RegisterSignalHandlers();
6368

69+
// prepare handler, it will be installed on first managed update:
70+
s_assemblyResolvingEventHandler = (_, args) =>
71+
{
72+
Log($"Resolving '{args.Name}, Version={args.Version}'");
73+
var path = Path.Combine(processDir, args.Name + ".dll");
74+
return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null;
75+
};
76+
6477
var agent = new HotReloadAgent();
6578
try
6679
{
@@ -134,6 +147,14 @@ private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipe
134147
// Shouldn't get initial managed code updates when the debugger is attached.
135148
// The debugger itself applies these updates when launching process with the debugger attached.
136149
Debug.Assert(!Debugger.IsAttached);
150+
151+
var handler = s_assemblyResolvingEventHandler;
152+
if (handler != null)
153+
{
154+
AssemblyLoadContext.Default.Resolving += handler;
155+
s_assemblyResolvingEventHandler = null;
156+
}
157+
137158
await ReadAndApplyManagedCodeUpdateAsync(pipeClient, agent, cancellationToken);
138159
break;
139160

src/BuiltInTools/dotnet-watch/Build/BuildNames.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ internal static class ItemNames
3737
internal static class MetadataNames
3838
{
3939
public const string Watch = nameof(Watch);
40+
public const string TargetPath = nameof(TargetPath);
4041
}
4142

4243
internal static class TargetNames
4344
{
4445
public const string Compile = nameof(Compile);
4546
public const string Restore = nameof(Restore);
4647
public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets);
48+
public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup);
4749
}

src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,11 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
232232
}
233233
}
234234

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

260264
// Note: CommitUpdate/DiscardUpdate is not expected to be called.
261-
return ([], []);
265+
return ([], [], [], []);
262266
}
263267

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

277-
return ([], []);
278-
}
279-
280-
if (!updates.ProjectUpdates.IsEmpty)
281-
{
282-
ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
283-
lock (_runningProjectsAndUpdatesGuard)
284-
{
285-
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
286-
_previousUpdates = _previousUpdates.AddRange(updates.ProjectUpdates);
287-
288-
// Capture the set of processes that do not have the currently calculated deltas yet.
289-
projectsToUpdate = _runningProjects;
290-
}
291-
292-
// Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
293-
// The process may load any of the binaries using MEF or some other runtime dependency loader.
294-
295-
await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
296-
{
297-
try
298-
{
299-
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
300-
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
301-
if (applySucceded)
302-
{
303-
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
304-
if (runningProject.BrowserRefreshServer is { } server)
305-
{
306-
runningProject.Reporter.Verbose("Refreshing browser.");
307-
await server.RefreshBrowserAsync(cancellationToken);
308-
}
309-
}
310-
}
311-
catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
312-
{
313-
runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥");
314-
}
315-
}, cancellationToken);
281+
return ([], [], [], []);
316282
}
317283

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

321287
DiscardPreviousUpdates(updates.ProjectsToRebuild);
322288

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

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

331-
return (projectsToRebuild, terminatedProjects);
298+
return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, terminatedProjects);
299+
}
300+
301+
public async ValueTask ApplyUpdatesAsync(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
302+
{
303+
Debug.Assert(!updates.IsEmpty);
304+
305+
ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
306+
lock (_runningProjectsAndUpdatesGuard)
307+
{
308+
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
309+
_previousUpdates = _previousUpdates.AddRange(updates);
310+
311+
// Capture the set of processes that do not have the currently calculated deltas yet.
312+
projectsToUpdate = _runningProjects;
313+
}
314+
315+
// Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
316+
// The process may load any of the binaries using MEF or some other runtime dependency loader.
317+
318+
await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
319+
{
320+
try
321+
{
322+
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
323+
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
324+
if (applySucceded)
325+
{
326+
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
327+
if (runningProject.BrowserRefreshServer is { } server)
328+
{
329+
runningProject.Reporter.Verbose("Refreshing browser.");
330+
await server.RefreshBrowserAsync(cancellationToken);
331+
}
332+
}
333+
}
334+
catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
335+
{
336+
runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥");
337+
}
338+
}, cancellationToken);
332339
}
333340

334341
private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects)

src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System.Collections.Immutable;
55
using System.Diagnostics;
6+
using Microsoft.Build.Execution;
7+
using Microsoft.Build.Graph;
68
using Microsoft.CodeAnalysis;
79

810
namespace Microsoft.DotNet.Watch
@@ -240,7 +242,7 @@ void FileChangedCallback(ChangedPath change)
240242

241243
extendTimeout = false;
242244

243-
var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: null);
245+
var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []);
244246
if (changedFiles is [])
245247
{
246248
continue;
@@ -269,7 +271,7 @@ void FileChangedCallback(ChangedPath change)
269271

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

272-
var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(
274+
var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(
273275
autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true,
274276
restartPrompt: async (projectNames, cancellationToken) =>
275277
{
@@ -334,7 +336,7 @@ void FileChangedCallback(ChangedPath change)
334336
try
335337
{
336338
var buildResults = await Task.WhenAll(
337-
projectsToRebuild.Values.Select(projectPath => BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken)));
339+
projectsToRebuild.Select(projectPath => BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken)));
338340

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

366-
_context.Reporter.Report(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Count);
368+
_context.Reporter.Report(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length);
369+
}
370+
371+
// Deploy dependencies after rebuilding and before restarting.
372+
if (!projectsToRedeploy.IsEmpty)
373+
{
374+
DeployProjectDependencies(evaluationResult.ProjectGraph, projectsToRedeploy, iterationCancellationToken);
375+
_context.Reporter.Report(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length);
376+
}
377+
378+
// Apply updates only after dependencies have been deployed,
379+
// so that updated code doesn't attempt to access the dependency before it has been deployed.
380+
if (!managedCodeUpdates.IsEmpty)
381+
{
382+
await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, iterationCancellationToken);
367383
}
368384

369385
if (!projectsToRestart.IsEmpty)
@@ -393,7 +409,7 @@ await Task.WhenAll(
393409

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

396-
async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDictionary<ProjectId, string>? rebuiltProjects)
412+
async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableArray<string> rebuiltProjects)
397413
{
398414
var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []);
399415
if (changedPaths is [])
@@ -464,12 +480,12 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
464480
_context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted);
465481
}
466482

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

474490
var newAccumulator = ImmutableList<ChangedPath>.Empty;
475491
var newChangedFiles = ImmutableList<ChangedFile>.Empty;
@@ -555,6 +571,72 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
555571
}
556572
}
557573

574+
private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray<string> projectPaths, CancellationToken cancellationToken)
575+
{
576+
var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer);
577+
var buildReporter = new BuildReporter(_context.Reporter, _context.Options, _context.EnvironmentOptions);
578+
var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup;
579+
580+
foreach (var node in graph.ProjectNodes)
581+
{
582+
cancellationToken.ThrowIfCancellationRequested();
583+
584+
var projectPath = node.ProjectInstance.FullPath;
585+
586+
if (!projectPathSet.Contains(projectPath))
587+
{
588+
continue;
589+
}
590+
591+
if (!node.ProjectInstance.Targets.ContainsKey(targetName))
592+
{
593+
continue;
594+
}
595+
596+
if (node.GetOutputDirectory() is not { } relativeOutputDir)
597+
{
598+
continue;
599+
}
600+
601+
using var loggers = buildReporter.GetLoggers(projectPath, targetName);
602+
if (!node.ProjectInstance.Build([targetName], loggers, out var targetOutputs))
603+
{
604+
_context.Reporter.Verbose($"{targetName} target failed");
605+
loggers.ReportOutput();
606+
continue;
607+
}
608+
609+
var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir);
610+
611+
foreach (var item in targetOutputs[targetName].Items)
612+
{
613+
cancellationToken.ThrowIfCancellationRequested();
614+
615+
var sourcePath = item.ItemSpec;
616+
var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath));
617+
if (!File.Exists(targetPath))
618+
{
619+
_context.Reporter.Verbose($"Deploying project dependency '{targetPath}' from '{sourcePath}'");
620+
621+
try
622+
{
623+
var directory = Path.GetDirectoryName(targetPath);
624+
if (directory != null)
625+
{
626+
Directory.CreateDirectory(directory);
627+
}
628+
629+
File.Copy(sourcePath, targetPath, overwrite: false);
630+
}
631+
catch (Exception e)
632+
{
633+
_context.Reporter.Verbose($"Copy failed: {e.Message}");
634+
}
635+
}
636+
}
637+
}
638+
}
639+
558640
private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken)
559641
{
560642
if (evaluationResult != null)

src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ public async Task UpdateProjectConeAsync(string rootProjectPath, CancellationTok
9797
UpdateReferencesAfterAdd();
9898

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

103104
ImmutableArray<DocumentInfo> MapDocuments(ProjectId mappedProjectId, IReadOnlyList<DocumentInfo> documents)
104105
=> documents.Select(docInfo =>

0 commit comments

Comments
 (0)