Skip to content

Commit b82a003

Browse files
committed
Allow watching project graph with multiple roots and no main project
1 parent 5cf6d15 commit b82a003

23 files changed

+310
-237
lines changed

src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,12 @@ public static async Task<bool> RunAsync(string workingDirectory, DotNetWatchOpti
2424

2525
commandArguments.AddRange(options.ApplicationArguments);
2626

27-
var rootProjectOptions = new ProjectOptions()
27+
var mainProjectOptions = new ProjectOptions()
2828
{
29-
IsRootProject = true,
29+
IsMainProject = true,
3030
Representation = options.Project,
3131
WorkingDirectory = workingDirectory,
32-
TargetFramework = null,
33-
BuildArguments = [],
34-
NoLaunchProfile = options.NoLaunchProfile,
35-
LaunchProfileName = null,
32+
LaunchProfileName = options.NoLaunchProfile ? default : null,
3633
Command = "run",
3734
CommandArguments = [.. commandArguments],
3835
LaunchEnvironmentVariables = [],
@@ -59,7 +56,10 @@ public static async Task<bool> RunAsync(string workingDirectory, DotNetWatchOpti
5956
ProcessRunner = processRunner,
6057
Options = globalOptions,
6158
EnvironmentOptions = environmentOptions,
62-
RootProjectOptions = rootProjectOptions,
59+
MainProjectOptions = mainProjectOptions,
60+
RootProjects = [mainProjectOptions.Representation],
61+
BuildArguments = [],
62+
TargetFramework = null,
6363
BrowserRefreshServerFactory = new BrowserRefreshServerFactory(),
6464
BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions),
6565
};

src/BuiltInTools/Watch/Aspire/AspireServiceFactory.cs

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66
using System.Globalization;
77
using System.Threading.Channels;
88
using Aspire.Tools.Service;
9-
using Microsoft.Build.Graph;
109
using Microsoft.Extensions.Logging;
1110

1211
namespace Microsoft.DotNet.Watch;
1312

14-
internal class AspireServiceFactory : IRuntimeProcessLauncherFactory
13+
internal class AspireServiceFactory(ProjectOptions hostProjectOptions) : IRuntimeProcessLauncherFactory
1514
{
1615
internal sealed class SessionManager : IAspireServerEvents, IRuntimeProcessLauncher
1716
{
@@ -30,8 +29,8 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r
3029
};
3130

3231
private readonly ProjectLauncher _projectLauncher;
33-
private readonly AspireServerService _service;
3432
private readonly ProjectOptions _hostProjectOptions;
33+
private readonly AspireServerService _service;
3534
private readonly ILogger _logger;
3635

3736
/// <summary>
@@ -215,23 +214,16 @@ private async Task TerminateSessionAsync(Session session)
215214
}
216215

217216
private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo)
218-
{
219-
var hostLaunchProfile = _hostProjectOptions.NoLaunchProfile ? null : _hostProjectOptions.LaunchProfileName;
220-
221-
return new()
217+
=> new()
222218
{
223-
IsRootProject = false,
219+
IsMainProject = false,
224220
Representation = ProjectRepresentation.FromProjectOrEntryPointFilePath(projectLaunchInfo.ProjectPath),
225221
WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(),
226-
BuildArguments = _hostProjectOptions.BuildArguments,
227222
Command = "run",
228-
CommandArguments = GetRunCommandArguments(projectLaunchInfo, hostLaunchProfile),
223+
CommandArguments = GetRunCommandArguments(projectLaunchInfo, _hostProjectOptions.LaunchProfileName.Value),
229224
LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(e => (e.Key, e.Value))?.ToArray() ?? [],
230-
LaunchProfileName = projectLaunchInfo.LaunchProfile,
231-
NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile,
232-
TargetFramework = _hostProjectOptions.TargetFramework,
225+
LaunchProfileName = projectLaunchInfo.DisableLaunchProfile ? default : projectLaunchInfo.LaunchProfile,
233226
};
234-
}
235227

236228
// internal for testing
237229
internal static IReadOnlyList<string> GetRunCommandArguments(ProjectLaunchRequest projectLaunchInfo, string? hostLaunchProfile)
@@ -276,13 +268,9 @@ internal static IReadOnlyList<string> GetRunCommandArguments(ProjectLaunchReques
276268
}
277269
}
278270

279-
public static readonly AspireServiceFactory Instance = new();
280-
281271
public const string AspireLogComponentName = "Aspire";
282272
public const string AppHostProjectCapability = ProjectCapability.Aspire;
283273

284-
public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions)
285-
=> projectNode.GetCapabilities().Contains(AppHostProjectCapability)
286-
? new SessionManager(projectLauncher, hostProjectOptions)
287-
: null;
274+
public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher)
275+
=> new SessionManager(projectLauncher, hostProjectOptions);
288276
}

src/BuiltInTools/Watch/Browser/BrowserLauncher.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public void InstallBrowserLaunchTrigger(
3838

3939
WebServerProcessStateObserver.Observe(projectNode, processSpec, url =>
4040
{
41-
if (projectOptions.IsRootProject &&
41+
if (projectOptions.IsMainProject &&
4242
ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.ProjectInstance.GetId()))
4343
{
4444
// first build iteration of a root project:
@@ -127,7 +127,10 @@ private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)]
127127

128128
private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions)
129129
{
130-
return (projectOptions.NoLaunchProfile == true
131-
? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName, logger)) ?? new();
130+
var profile = projectOptions.LaunchProfileName.HasValue
131+
? LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName.Value, logger)
132+
: null;
133+
134+
return profile ?? new();
132135
}
133136
}

src/BuiltInTools/Watch/Context/DotNetWatchContext.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections.Immutable;
45
using Microsoft.Extensions.Logging;
56

67
namespace Microsoft.DotNet.Watch
@@ -18,7 +19,25 @@ internal sealed class DotNetWatchContext : IDisposable
1819
public required ILoggerFactory LoggerFactory { get; init; }
1920
public required ProcessRunner ProcessRunner { get; init; }
2021

21-
public required ProjectOptions RootProjectOptions { get; init; }
22+
/// <summary>
23+
/// Roots of the project graph to watch.
24+
/// </summary>
25+
public required ImmutableArray<ProjectRepresentation> RootProjects { get; init; }
26+
27+
/// <summary>
28+
/// Options for launching a main project. If null no main project is being launched.
29+
/// </summary>
30+
public required ProjectOptions? MainProjectOptions { get; init; }
31+
32+
/// <summary>
33+
/// Default target framework.
34+
/// </summary>
35+
public required string? TargetFramework { get; init; }
36+
37+
/// <summary>
38+
/// Additional arguments passed to `dotnet build` when building projects.
39+
/// </summary>
40+
public required IReadOnlyList<string> BuildArguments { get; init; }
2241

2342
public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; }
2443
public required BrowserLauncher BrowserLauncher { get; init; }

src/BuiltInTools/Watch/Context/ProjectOptions.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@ namespace Microsoft.DotNet.Watch;
55

66
internal sealed record ProjectOptions
77
{
8-
public required bool IsRootProject { get; init; }
98
public required ProjectRepresentation Representation { get; init; }
9+
10+
/// <summary>
11+
/// True if the project has been launched by watch in the main iteration loop.
12+
/// </summary>
13+
public required bool IsMainProject { get; init; }
14+
1015
public required string WorkingDirectory { get; init; }
11-
public required string? TargetFramework { get; init; }
12-
public required IReadOnlyList<string> BuildArguments { get; init; }
13-
public required bool NoLaunchProfile { get; init; }
14-
public required string? LaunchProfileName { get; init; }
16+
17+
/// <summary>
18+
/// No value indicates that no launch profile should be used.
19+
/// Null value indicates that the default launch profile should be used.
20+
/// </summary>
21+
public required Optional<string?> LaunchProfileName { get; init; }
1522

1623
/// <summary>
1724
/// Command to use to launch the project.

src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal class FileWatcher(ILogger logger, EnvironmentOptions environmentOptions
2121
public event Action<ChangedPath>? OnFileChange;
2222

2323
public bool SuppressEvents { get; set; }
24+
public DateTime StartTime { get; set; }
2425

2526
public void Dispose()
2627
{
@@ -205,20 +206,22 @@ void FileChangedCallback(ChangedPath change)
205206
return change;
206207
}
207208

208-
public static async ValueTask WaitForFileChangeAsync(string filePath, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken)
209+
public static async ValueTask WaitForFileChangeAsync(IEnumerable<string> filePaths, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken)
209210
{
210211
using var watcher = new FileWatcher(logger, environmentOptions);
211212

212-
watcher.WatchContainingDirectories([filePath], includeSubdirectories: false);
213+
watcher.WatchContainingDirectories(filePaths, includeSubdirectories: false);
214+
215+
var pathSet = filePaths.ToHashSet();
213216

214217
var fileChange = await watcher.WaitForFileChangeAsync(
215-
acceptChange: change => change.Path == filePath,
218+
acceptChange: change => pathSet.Contains(change.Path),
216219
startedWatching,
217220
cancellationToken);
218221

219222
if (fileChange != null)
220223
{
221-
logger.LogInformation("File changed: {FilePath}", filePath);
224+
logger.LogInformation("File changed: {FilePath}", fileChange.Value.Path);
222225
}
223226
}
224227
}

src/BuiltInTools/Watch/HotReload/CompilationHandler.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal sealed class CompilationHandler : IDisposable
2727
private readonly object _runningProjectsAndUpdatesGuard = new();
2828

2929
/// <summary>
30-
/// Projects that have been launched and to which we apply changes.
30+
/// Projects that have been launched and to which we apply changes.
3131
/// </summary>
3232
private ImmutableDictionary<string, ImmutableArray<RunningProject>> _runningProjects = ImmutableDictionary<string, ImmutableArray<RunningProject>>.Empty;
3333

@@ -61,10 +61,10 @@ public void Dispose()
6161
private ILogger Logger
6262
=> _context.Logger;
6363

64-
public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken)
64+
public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken cancellationToken)
6565
{
6666
Logger.LogDebug("Terminating remaining child processes.");
67-
await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken);
67+
await TerminatePeripheralProcessesAsync(projectPaths: null, cancellationToken);
6868
Dispose();
6969
}
7070

@@ -373,7 +373,7 @@ public async ValueTask GetManagedCodeUpdatesAsync(
373373
// except for the root process, which will terminate later on.
374374
if (!updates.ProjectsToRestart.IsEmpty)
375375
{
376-
builder.ProjectsToRestart.AddRange(await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken));
376+
builder.ProjectsToRestart.AddRange(await TerminatePeripheralProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken));
377377
}
378378
}
379379

@@ -803,15 +803,15 @@ public async ValueTask GetStaticAssetUpdatesAsync(
803803
}
804804

805805
/// <summary>
806-
/// Terminates all processes launched for non-root projects with <paramref name="projectPaths"/>,
807-
/// or all running non-root project processes if <paramref name="projectPaths"/> is null.
806+
/// Terminates all processes launched for peripheral projects with <paramref name="projectPaths"/>,
807+
/// or all running peripheral project processes if <paramref name="projectPaths"/> is null.
808808
///
809809
/// Removes corresponding entries from <see cref="_runningProjects"/>.
810810
///
811-
/// Does not terminate the root project.
811+
/// Does not terminate the main project.
812812
/// </summary>
813-
/// <returns>All processes (including root) to be restarted.</returns>
814-
internal async ValueTask<ImmutableArray<RunningProject>> TerminateNonRootProcessesAsync(
813+
/// <returns>All processes (including main) to be restarted.</returns>
814+
internal async ValueTask<ImmutableArray<RunningProject>> TerminatePeripheralProcessesAsync(
815815
IEnumerable<string>? projectPaths, CancellationToken cancellationToken)
816816
{
817817
ImmutableArray<RunningProject> projectsToRestart = [];
@@ -826,7 +826,7 @@ internal async ValueTask<ImmutableArray<RunningProject>> TerminateNonRootProcess
826826
// Do not terminate root process at this time - it would signal the cancellation token we are currently using.
827827
// The process will be restarted later on.
828828
// Wait for all processes to exit to release their resources, so we can rebuild.
829-
await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsRootProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken);
829+
await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsMainProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken);
830830

831831
return projectsToRestart;
832832
}
@@ -885,14 +885,14 @@ private static ImmutableDictionary<string, ImmutableArray<ProjectInstance>> Crea
885885
keySelector: static group => group.Key,
886886
elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray());
887887

888-
public async Task UpdateProjectConeAsync(ProjectGraph projectGraph, ProjectRepresentation project, CancellationToken cancellationToken)
888+
public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken)
889889
{
890890
Logger.LogInformation("Loading projects ...");
891891
var stopwatch = Stopwatch.StartNew();
892892

893893
_projectInstances = CreateProjectInstanceMap(projectGraph);
894894

895-
var solution = await Workspace.UpdateProjectConeAsync(project.ProjectGraphPath, cancellationToken);
895+
var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken);
896896
await SolutionUpdatedAsync(solution, "project update", cancellationToken);
897897

898898
Logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0"));

0 commit comments

Comments
 (0)