Skip to content

Implement support for changing project and package references #49611

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
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
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;

/// <summary>
Expand All @@ -19,12 +21,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 @@ -60,6 +65,14 @@ public static void Initialize()

RegisterPosixSignalHandlers();

// prepare handler, it will be installed on first managed update:
s_assemblyResolvingEventHandler = (_, args) =>
{
Log($"Resolving {args.Name}");
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 @@ -126,6 +139,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);
}
16 changes: 11 additions & 5 deletions src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
}
}

public async ValueTask<(ImmutableDictionary<ProjectId, string> projectsToRebuild, ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
public async ValueTask<(
ImmutableArray<string> projectsToRebuild,
ImmutableArray<string> projectsToRedeploy,
ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
bool autoRestart,
Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
CancellationToken cancellationToken)
Expand All @@ -263,7 +266,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 @@ -279,7 +282,7 @@ 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 ([], []);
return ([], [], []);
}

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

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();
// TODO: needs Roslyn update
// var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
var projectsToRedeploy = ImmutableArray<string>.Empty;

// 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 (projectsToRebuild, projectsToRedeploy, terminatedProjects);
}

private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects)
Expand Down
89 changes: 82 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 (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,14 @@ 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);
}

if (!projectsToRestart.IsEmpty)
Expand Down Expand Up @@ -393,7 +402,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 +473,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 +564,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.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);
}
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 @@ -95,8 +95,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
1 change: 1 addition & 0 deletions src/BuiltInTools/dotnet-watch/UI/IReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public MessageDescriptor ToErrorWhen(bool condition)
public static readonly MessageDescriptor HotReloadSessionStarted = new("Hot reload session started.", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ProjectsRebuilt = new("Projects rebuilt ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ProjectsRestarted = new("Projects restarted ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ProjectDependenciesDeployed = new("Project dependencies deployed ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor FixBuildError = new("Fix the error to continue or press Ctrl+C to exit.", WatchEmoji, MessageSeverity.Warning, s_id++);
public static readonly MessageDescriptor WaitingForChanges = new("Waiting for changes", WatchEmoji, MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor LaunchedProcess = new("Launched '{0}' with arguments '{1}': process id {2}", LaunchEmoji, MessageSeverity.Verbose, s_id++);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ public static void Main(string[] args)

while (true)
{
Lib.Print();
CallLib();
Thread.Sleep(1000);
}
}

public static void CallLib()
{
Lib.Print();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public class Lib
{
public static void Print()
{
System.Console.WriteLine("<Lib>");
}
}
71 changes: 71 additions & 0 deletions test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,77 @@ public static void Print()
await App.AssertOutputLineStartsWith("BUILD_CONST_IN_PROPS not set");
}

[Fact]
public async Task ProjectChange_AddProjectReference()
{
var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
.WithSource()
.WithProjectChanges(project =>
{
foreach (var r in project.Root!.Descendants().Where(e => e.Name.LocalName == "ProjectReference").ToArray())
{
r.Remove();
}
});

var appProjDir = Path.Combine(testAsset.Path, "AppWithDeps");
var appProjFile = Path.Combine(appProjDir, "App.WithDeps.csproj");
var appFile = Path.Combine(appProjDir, "Program.cs");

UpdateSourceFile(appFile, code => code.Replace("Lib.Print();", "// Lib.Print();"));

App.Start(testAsset, [], "AppWithDeps");

await App.AssertWaitingForChanges();

UpdateSourceFile(appProjFile, src => src.Replace("""
<ItemGroup />
""", """
<ItemGroup>
<ProjectReference Include="..\Dependency\Dependency.csproj" />
</ItemGroup>
"""));

UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();"));

await App.WaitUntilOutputContains("<Lib>");

App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"AppWithDeps ({ToolsetInfo.CurrentTargetFramework})");
App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
}

[Fact]
public async Task ProjectChange_AddPackageReference()
{
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
.WithSource();

var projFilePath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj");
var programFilePath = Path.Combine(testAsset.Path, "Program.cs");

App.Start(testAsset, []);

await App.AssertWaitingForChanges();
App.Process.ClearOutput();

UpdateSourceFile(projFilePath, source => source.Replace("""
<!-- items placeholder -->
""", """
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
"""));

UpdateSourceFile(programFilePath, source => source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(typeof(Newtonsoft.Json.Linq.JToken));"));

await App.WaitUntilOutputContains("Newtonsoft.Json.Linq.JToken");

var binDirDll = Path.Combine(testAsset.Path, "bin", "Debug", ToolsetInfo.CurrentTargetFramework, "Newtonsoft.Json.dll");

App.AssertOutputContains($"Deploying project dependency '{binDirDll}'");
App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})");
App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
App.AssertOutputContains("Resolving Newtonsoft.Json");
}

[Fact]
public async Task DefaultItemExcludes_DefaultItemsEnabled()
{
Expand Down
Loading