Skip to content

Commit bd7bb16

Browse files
committed
Implement support for changing project and package references
1 parent 1177acc commit bd7bb16

File tree

9 files changed

+198
-15
lines changed

9 files changed

+198
-15
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

810
/// <summary>
@@ -19,12 +21,15 @@ internal sealed class StartupHook
1921
private static PosixSignalRegistration? s_signalRegistration;
2022
#endif
2123

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

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

@@ -60,6 +65,14 @@ public static void Initialize()
6065

6166
RegisterPosixSignalHandlers();
6267

68+
// prepare handler, it will be installed on first managed update:
69+
s_assemblyResolvingEventHandler = (_, args) =>
70+
{
71+
Log($"Resolving {args.Name}");
72+
var path = Path.Combine(processDir, args.Name + ".dll");
73+
return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null;
74+
};
75+
6376
var agent = new HotReloadAgent();
6477
try
6578
{
@@ -126,6 +139,14 @@ private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipe
126139
// Shouldn't get initial managed code updates when the debugger is attached.
127140
// The debugger itself applies these updates when launching process with the debugger attached.
128141
Debug.Assert(!Debugger.IsAttached);
142+
143+
var handler = s_assemblyResolvingEventHandler;
144+
if (handler != null)
145+
{
146+
AssemblyLoadContext.Default.Resolving += handler;
147+
s_assemblyResolvingEventHandler = null;
148+
}
149+
129150
await ReadAndApplyManagedCodeUpdateAsync(pipeClient, agent, cancellationToken);
130151
break;
131152

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: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
237237
}
238238
}
239239

240-
public async ValueTask<(ImmutableDictionary<ProjectId, string> projectsToRebuild, ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
240+
public async ValueTask<(
241+
ImmutableArray<string> projectsToRebuild,
242+
ImmutableArray<string> projectsToRedeploy,
243+
ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
241244
bool autoRestart,
242245
Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
243246
CancellationToken cancellationToken)
@@ -263,7 +266,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
263266
// changes and await the next file change.
264267

265268
// Note: CommitUpdate/DiscardUpdate is not expected to be called.
266-
return ([], []);
269+
return ([], [], []);
267270
}
268271

269272
var projectsToPromptForRestart =
@@ -279,7 +282,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
279282
_reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", emoji: "🔥");
280283
await Task.Delay(-1, cancellationToken);
281284

282-
return ([], []);
285+
return ([], [], []);
283286
}
284287

285288
if (!updates.ProjectUpdates.IsEmpty)
@@ -325,15 +328,18 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT
325328

326329
DiscardPreviousUpdates(updates.ProjectsToRebuild);
327330

328-
var projectsToRebuild = updates.ProjectsToRebuild.ToImmutableDictionary(keySelector: id => id, elementSelector: id => currentSolution.GetProject(id)!.FilePath!);
331+
var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
332+
// TODO: needs Roslyn update
333+
// var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
334+
var projectsToRedeploy = ImmutableArray<string>.Empty;
329335

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

336-
return (projectsToRebuild, terminatedProjects);
342+
return (projectsToRebuild, projectsToRedeploy, terminatedProjects);
337343
}
338344

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

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

Lines changed: 82 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 (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,14 @@ 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);
367376
}
368377

369378
if (!projectsToRestart.IsEmpty)
@@ -393,7 +402,7 @@ await Task.WhenAll(
393402

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

396-
async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDictionary<ProjectId, string>? rebuiltProjects)
405+
async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableArray<string> rebuiltProjects)
397406
{
398407
var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []);
399408
if (changedPaths is [])
@@ -464,12 +473,12 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
464473
_context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted);
465474
}
466475

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

474483
var newAccumulator = ImmutableList<ChangedPath>.Empty;
475484
var newChangedFiles = ImmutableList<ChangedFile>.Empty;
@@ -555,6 +564,72 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
555564
}
556565
}
557566

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

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

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

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

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

src/BuiltInTools/dotnet-watch/UI/IReporter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public MessageDescriptor ToErrorWhen(bool condition)
6565
public static readonly MessageDescriptor HotReloadSessionStarted = new("Hot reload session started.", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
6666
public static readonly MessageDescriptor ProjectsRebuilt = new("Projects rebuilt ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
6767
public static readonly MessageDescriptor ProjectsRestarted = new("Projects restarted ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
68+
public static readonly MessageDescriptor ProjectDependenciesDeployed = new("Project dependencies deployed ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
6869
public static readonly MessageDescriptor FixBuildError = new("Fix the error to continue or press Ctrl+C to exit.", WatchEmoji, MessageSeverity.Warning, s_id++);
6970
public static readonly MessageDescriptor WaitingForChanges = new("Waiting for changes", WatchEmoji, MessageSeverity.Verbose, s_id++);
7071
public static readonly MessageDescriptor LaunchedProcess = new("Launched '{0}' with arguments '{1}': process id {2}", LaunchEmoji, MessageSeverity.Verbose, s_id++);

test/TestAssets/TestProjects/WatchAppWithProjectDeps/AppWithDeps/Program.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ public static void Main(string[] args)
1919

2020
while (true)
2121
{
22-
Lib.Print();
22+
CallLib();
2323
Thread.Sleep(1000);
2424
}
2525
}
26+
27+
public static void CallLib()
28+
{
29+
Lib.Print();
30+
}
2631
}
2732
}

test/TestAssets/TestProjects/WatchAppWithProjectDeps/Dependency/Foo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ public class Lib
44
{
55
public static void Print()
66
{
7+
System.Console.WriteLine("<Lib>");
78
}
89
}

test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,77 @@ public static void Print()
236236
await App.AssertOutputLineStartsWith("BUILD_CONST_IN_PROPS not set");
237237
}
238238

239+
[Fact]
240+
public async Task ProjectChange_AddProjectReference()
241+
{
242+
var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
243+
.WithSource()
244+
.WithProjectChanges(project =>
245+
{
246+
foreach (var r in project.Root!.Descendants().Where(e => e.Name.LocalName == "ProjectReference").ToArray())
247+
{
248+
r.Remove();
249+
}
250+
});
251+
252+
var appProjDir = Path.Combine(testAsset.Path, "AppWithDeps");
253+
var appProjFile = Path.Combine(appProjDir, "App.WithDeps.csproj");
254+
var appFile = Path.Combine(appProjDir, "Program.cs");
255+
256+
UpdateSourceFile(appFile, code => code.Replace("Lib.Print();", "// Lib.Print();"));
257+
258+
App.Start(testAsset, [], "AppWithDeps");
259+
260+
await App.AssertWaitingForChanges();
261+
262+
UpdateSourceFile(appProjFile, src => src.Replace("""
263+
<ItemGroup />
264+
""", """
265+
<ItemGroup>
266+
<ProjectReference Include="..\Dependency\Dependency.csproj" />
267+
</ItemGroup>
268+
"""));
269+
270+
UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();"));
271+
272+
await App.WaitUntilOutputContains("<Lib>");
273+
274+
App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"AppWithDeps ({ToolsetInfo.CurrentTargetFramework})");
275+
App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
276+
}
277+
278+
[Fact]
279+
public async Task ProjectChange_AddPackageReference()
280+
{
281+
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
282+
.WithSource();
283+
284+
var projFilePath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj");
285+
var programFilePath = Path.Combine(testAsset.Path, "Program.cs");
286+
287+
App.Start(testAsset, []);
288+
289+
await App.AssertWaitingForChanges();
290+
App.Process.ClearOutput();
291+
292+
UpdateSourceFile(projFilePath, source => source.Replace("""
293+
<!-- items placeholder -->
294+
""", """
295+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
296+
"""));
297+
298+
UpdateSourceFile(programFilePath, source => source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(typeof(Newtonsoft.Json.Linq.JToken));"));
299+
300+
await App.WaitUntilOutputContains("Newtonsoft.Json.Linq.JToken");
301+
302+
var binDirDll = Path.Combine(testAsset.Path, "bin", "Debug", ToolsetInfo.CurrentTargetFramework, "Newtonsoft.Json.dll");
303+
304+
App.AssertOutputContains($"Deploying project dependency '{binDirDll}'");
305+
App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})");
306+
App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
307+
App.AssertOutputContains("Resolving Newtonsoft.Json");
308+
}
309+
239310
[Fact]
240311
public async Task DefaultItemExcludes_DefaultItemsEnabled()
241312
{

0 commit comments

Comments
 (0)