Skip to content

Commit c9a47ea

Browse files
committed
Implement support for changing project and package references
1 parent b3a01af commit c9a47ea

File tree

9 files changed

+196
-15
lines changed

9 files changed

+196
-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: 9 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,16 @@ 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+
var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
329333

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

336-
return (projectsToRebuild, terminatedProjects);
340+
return (projectsToRebuild, projectsToRedeploy, terminatedProjects);
337341
}
338342

339343
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
@@ -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 =>

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
@@ -233,6 +233,77 @@ public static void Print()
233233
await App.AssertOutputLineStartsWith("BUILD_CONST_IN_PROPS not set");
234234
}
235235

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

0 commit comments

Comments
 (0)