diff --git a/src/Dotnet.Watch/.editorconfig b/src/Dotnet.Watch/.editorconfig
new file mode 100644
index 000000000000..54d2f34da8e4
--- /dev/null
+++ b/src/Dotnet.Watch/.editorconfig
@@ -0,0 +1,26 @@
+# EditorConfig to suppress warnings/errors for Watch solution
+
+root = false
+
+[*.cs]
+# CA - Code Analysis warnings
+dotnet_diagnostic.CA1305.severity = none # Specify IFormatProvider
+dotnet_diagnostic.CA1822.severity = none # Mark members as static
+dotnet_diagnostic.CA1835.severity = none # Prefer Memory-based overloads for ReadAsync/WriteAsync
+dotnet_diagnostic.CA1852.severity = none # Seal internal types
+dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task
+dotnet_diagnostic.CA2201.severity = none # Do not raise reserved exception types
+dotnet_diagnostic.CA2008.severity = none # Do not create tasks without passing a TaskScheduler
+
+# CS - C# compiler warnings/errors
+dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member
+dotnet_diagnostic.CS1573.severity = none # Parameter 'sourceFile' has no matching param tag in the XML comment
+
+# IDE - IDE/Style warnings
+dotnet_diagnostic.IDE0005.severity = none # Using directive is unnecessary
+dotnet_diagnostic.IDE0011.severity = none # Add braces
+dotnet_diagnostic.IDE0036.severity = none # Order modifiers
+dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter
+dotnet_diagnostic.IDE0073.severity = none # File header does not match required text
+dotnet_diagnostic.IDE0161.severity = none # Convert to file-scoped namespace
+dotnet_diagnostic.IDE1006.severity = none # Naming rule violation
diff --git a/src/Dotnet.Watch/Directory.Build.props b/src/Dotnet.Watch/Directory.Build.props
new file mode 100644
index 000000000000..6fcadbd40f20
--- /dev/null
+++ b/src/Dotnet.Watch/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+
+ $(RepoRoot)src\Microsoft.DotNet.ProjectTools\
+
+
diff --git a/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs b/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs
index 9ca5fb9cc271..c07dc05444fe 100644
--- a/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs
+++ b/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs
@@ -129,7 +129,7 @@ private async Task ListenForResponsesAsync(CancellationToken cancellationToken)
{
if (!cancellationToken.IsCancellationRequested)
{
- Logger.LogError("Failed to read response: {Message}", e.Message);
+ Logger.LogError("Failed to read response: {Exception}", e.ToString());
}
}
}
diff --git a/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs
index 2ab6027faf4c..0328c5aa4aca 100644
--- a/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs
+++ b/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs
@@ -49,7 +49,7 @@ public override void ConfigureEnvironment(IDictionary env)
public override async Task WaitForConnectionAsync(CancellationToken cancellationToken)
{
- _logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", _namedPipeName);
+ _logger.LogDebug("Waiting for application to connect to pipe '{NamedPipeName}'.", _namedPipeName);
try
{
diff --git a/src/Dotnet.Watch/Watch.Aspire/AspireLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/AspireLauncher.cs
new file mode 100644
index 000000000000..2dee0c755caf
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/AspireLauncher.cs
@@ -0,0 +1,52 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal abstract class AspireLauncher
+{
+ public EnvironmentOptions EnvironmentOptions { get; }
+ public GlobalOptions GlobalOptions { get; }
+ public PhysicalConsole Console { get; }
+ public ConsoleReporter Reporter { get; }
+ public LoggerFactory LoggerFactory { get; }
+ public ILogger Logger { get; }
+
+ public AspireLauncher(GlobalOptions globalOptions, EnvironmentOptions environmentOptions)
+ {
+ GlobalOptions = globalOptions;
+ EnvironmentOptions = environmentOptions;
+ Console = new PhysicalConsole(environmentOptions.TestFlags);
+ Reporter = new ConsoleReporter(Console, environmentOptions.LogMessagePrefix, environmentOptions.SuppressEmojis);
+ LoggerFactory = new LoggerFactory(Reporter, environmentOptions.CliLogLevel ?? globalOptions.LogLevel);
+ Logger = LoggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName);
+ }
+
+ public static AspireLauncher? TryCreate(string[] args)
+ {
+ var rootCommand = new AspireRootCommand();
+
+ var parseResult = rootCommand.Parse(args);
+ if (parseResult.Errors.Count > 0)
+ {
+ foreach (var error in parseResult.Errors)
+ {
+ System.Console.Error.WriteLine(error);
+ }
+
+ return null;
+ }
+
+ return parseResult.CommandResult.Command switch
+ {
+ AspireServerCommandDefinition serverCommand => AspireServerLauncher.TryCreate(parseResult, serverCommand),
+ AspireResourceCommandDefinition resourceCommand => AspireResourceLauncher.TryCreate(parseResult, resourceCommand),
+ AspireHostCommandDefinition hostCommand => AspireHostLauncher.TryCreate(parseResult, hostCommand),
+ _ => throw new InvalidOperationException(),
+ };
+ }
+
+ public abstract Task LaunchAsync(CancellationToken cancellationToken);
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireCommandDefinition.cs
new file mode 100644
index 000000000000..0b3748dc2350
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireCommandDefinition.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal abstract class AspireCommandDefinition : Command
+{
+ public readonly Option QuietOption = new("--quiet") { Arity = ArgumentArity.Zero };
+ public readonly Option VerboseOption = new("--verbose") { Arity = ArgumentArity.Zero };
+
+ protected AspireCommandDefinition(string name, string description)
+ : base(name, description)
+ {
+ Options.Add(VerboseOption);
+ Options.Add(QuietOption);
+
+ VerboseOption.Validators.Add(v =>
+ {
+ if (v.HasOption(QuietOption) && v.HasOption(VerboseOption))
+ {
+ v.AddError("Cannot specify both '--quiet' and '--verbose' options.");
+ }
+ });
+ }
+
+ public LogLevel GetLogLevel(ParseResult parseResult)
+ => parseResult.GetValue(QuietOption) ? LogLevel.Warning : parseResult.GetValue(VerboseOption) ? LogLevel.Debug : LogLevel.Information;
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireHostCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireHostCommandDefinition.cs
new file mode 100644
index 000000000000..4475a7fcf2ce
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireHostCommandDefinition.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireHostCommandDefinition : AspireCommandDefinition
+{
+ public readonly Option SdkOption = new("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false };
+
+ ///
+ /// Project or file.
+ ///
+ public readonly Option EntryPointOption = new("--entrypoint") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false };
+
+ public readonly Argument ApplicationArguments = new("arguments") { Arity = ArgumentArity.ZeroOrMore };
+ public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Arity = ArgumentArity.Zero };
+ public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Arity = ArgumentArity.ExactlyOne };
+
+ public AspireHostCommandDefinition()
+ : base("host", "Starts AppHost project.")
+ {
+ Arguments.Add(ApplicationArguments);
+
+ Options.Add(SdkOption);
+ Options.Add(EntryPointOption);
+ Options.Add(NoLaunchProfileOption);
+ Options.Add(LaunchProfileOption);
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs
new file mode 100644
index 000000000000..88ead313c6bc
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs
@@ -0,0 +1,87 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.CommandLine.Parsing;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireResourceCommandDefinition : AspireCommandDefinition
+{
+ public readonly Argument ApplicationArguments = new("arguments") { Arity = ArgumentArity.ZeroOrMore };
+
+ ///
+ /// Server pipe name.
+ ///
+ public readonly Option ServerOption = new("--server")
+ {
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true,
+ AllowMultipleArgumentsPerToken = false
+ };
+
+ public readonly Option EntryPointOption = new("--entrypoint")
+ {
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true,
+ AllowMultipleArgumentsPerToken = false
+ };
+
+ public readonly Option> EnvironmentOption = new("--environment", "-e")
+ {
+ Description = "Environment variables for the process",
+ CustomParser = ParseEnvironmentVariables,
+ AllowMultipleArgumentsPerToken = false
+ };
+
+ public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Arity = ArgumentArity.Zero };
+ public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Arity = ArgumentArity.ExactlyOne };
+
+ public AspireResourceCommandDefinition()
+ : base("resource", "Starts resource project.")
+ {
+ Arguments.Add(ApplicationArguments);
+
+ Options.Add(ServerOption);
+ Options.Add(EntryPointOption);
+ Options.Add(EnvironmentOption);
+ Options.Add(NoLaunchProfileOption);
+ Options.Add(LaunchProfileOption);
+ }
+
+ private static IReadOnlyDictionary ParseEnvironmentVariables(ArgumentResult argumentResult)
+ {
+ var result = new Dictionary(PathUtilities.OSSpecificPathComparer);
+
+ List? invalid = null;
+
+ foreach (var token in argumentResult.Tokens)
+ {
+ var separator = token.Value.IndexOf('=');
+ var (name, value) = (separator >= 0)
+ ? (token.Value[0..separator], token.Value[(separator + 1)..])
+ : (token.Value, "");
+
+ name = name.Trim();
+
+ if (name != "")
+ {
+ result[name] = value;
+ }
+ else
+ {
+ invalid ??= [];
+ invalid.Add(token);
+ }
+ }
+
+ if (invalid != null)
+ {
+ argumentResult.AddError(string.Format(
+ "Incorrectly formatted environment variables {0}",
+ string.Join(", ", invalid.Select(x => $"'{x.Value}'"))));
+ }
+
+ return result;
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireRootCommand.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireRootCommand.cs
new file mode 100644
index 000000000000..f9d3b6f6b266
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireRootCommand.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireRootCommand : RootCommand
+{
+ public readonly AspireServerCommandDefinition ServerCommand = new();
+ public readonly AspireResourceCommandDefinition ResourceCommand = new();
+ public readonly AspireHostCommandDefinition HostCommand = new();
+
+ public AspireRootCommand()
+ {
+ Directives.Add(new EnvironmentVariablesDirective());
+
+ Subcommands.Add(ServerCommand);
+ Subcommands.Add(ResourceCommand);
+ Subcommands.Add(HostCommand);
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireServerCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireServerCommandDefinition.cs
new file mode 100644
index 000000000000..dfb64002f2ad
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireServerCommandDefinition.cs
@@ -0,0 +1,41 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireServerCommandDefinition : AspireCommandDefinition
+{
+ ///
+ /// Server pipe name.
+ ///
+ public readonly Option ServerOption = new("--server") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false };
+
+ public readonly Option SdkOption = new("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false };
+
+ ///
+ /// Paths to resource projects or entry-point files.
+ ///
+ public readonly Option ResourceOption = new("--resource") { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true };
+
+ ///
+ /// Status pipe name for sending watch status events back to the AppHost.
+ ///
+ public readonly Option StatusPipeOption = new("--status-pipe") { Arity = ArgumentArity.ExactlyOne, AllowMultipleArgumentsPerToken = false };
+
+ ///
+ /// Control pipe name for receiving commands from the AppHost.
+ ///
+ public readonly Option ControlPipeOption = new("--control-pipe") { Arity = ArgumentArity.ExactlyOne, AllowMultipleArgumentsPerToken = false };
+
+ public AspireServerCommandDefinition()
+ : base("server", "Starts the dotnet watch server.")
+ {
+ Options.Add(ServerOption);
+ Options.Add(SdkOption);
+ Options.Add(ResourceOption);
+ Options.Add(StatusPipeOption);
+ Options.Add(ControlPipeOption);
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/OptionExtensions.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/OptionExtensions.cs
new file mode 100644
index 000000000000..2ec5b9f2007d
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Commands/OptionExtensions.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.CommandLine.Parsing;
+
+namespace Microsoft.DotNet.Watch;
+
+internal static class OptionExtensions
+{
+ public static bool HasOption(this SymbolResult symbolResult, Option option)
+ => symbolResult.GetResult(option) is OptionResult or && !or.Implicit;
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/DotNetWatchLauncher.cs
deleted file mode 100644
index dd56ed47d0c3..000000000000
--- a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchLauncher.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Microsoft.Extensions.Logging;
-
-namespace Microsoft.DotNet.Watch;
-
-internal static class DotNetWatchLauncher
-{
- public static async Task RunAsync(string workingDirectory, DotNetWatchOptions options)
- {
- var globalOptions = new GlobalOptions()
- {
- LogLevel = options.LogLevel,
- NoHotReload = false,
- NonInteractive = true,
- };
-
- var commandArguments = new List();
- if (options.NoLaunchProfile)
- {
- commandArguments.Add("--no-launch-profile");
- }
-
- commandArguments.AddRange(options.ApplicationArguments);
-
- var mainProjectOptions = new ProjectOptions()
- {
- IsMainProject = true,
- Representation = options.Project,
- WorkingDirectory = workingDirectory,
- LaunchProfileName = options.NoLaunchProfile ? default : null,
- Command = "run",
- CommandArguments = [.. commandArguments],
- LaunchEnvironmentVariables = [],
- };
-
- var muxerPath = Path.GetFullPath(Path.Combine(options.SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension));
-
- // msbuild tasks depend on host path variable:
- Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, muxerPath);
-
- var console = new PhysicalConsole(TestFlags.None);
- var reporter = new ConsoleReporter(console, suppressEmojis: false);
- var environmentOptions = EnvironmentOptions.FromEnvironment(muxerPath);
- var processRunner = new ProcessRunner(environmentOptions.GetProcessCleanupTimeout());
- var loggerFactory = new LoggerFactory(reporter, globalOptions.LogLevel);
- var logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName);
-
- using var context = new DotNetWatchContext()
- {
- ProcessOutputReporter = reporter,
- LoggerFactory = loggerFactory,
- Logger = logger,
- BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName),
- ProcessRunner = processRunner,
- Options = globalOptions,
- EnvironmentOptions = environmentOptions,
- MainProjectOptions = mainProjectOptions,
- RootProjects = [mainProjectOptions.Representation],
- BuildArguments = [],
- TargetFramework = null,
- BrowserRefreshServerFactory = new BrowserRefreshServerFactory(),
- BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions),
- };
-
- using var shutdownHandler = new ShutdownHandler(console, logger);
-
- try
- {
- var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null);
- await watcher.WatchAsync(shutdownHandler.CancellationToken);
- }
- catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested)
- {
- // Ctrl+C forced an exit
- }
- catch (Exception e)
- {
- logger.LogError("An unexpected error occurred: {Exception}", e.ToString());
- return false;
- }
-
- return true;
- }
-}
diff --git a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchOptions.cs b/src/Dotnet.Watch/Watch.Aspire/DotNetWatchOptions.cs
deleted file mode 100644
index 4558d5ba2273..000000000000
--- a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchOptions.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Collections.Immutable;
-using System.CommandLine;
-using System.CommandLine.Parsing;
-using System.Diagnostics.CodeAnalysis;
-using Microsoft.Extensions.Logging;
-
-namespace Microsoft.DotNet.Watch;
-
-internal sealed class DotNetWatchOptions
-{
- ///
- /// The .NET SDK directory to load msbuild from (e.g. C:\Program Files\dotnet\sdk\10.0.100).
- /// Also used to locate `dotnet` executable.
- ///
- public required string SdkDirectory { get; init; }
-
- public required ProjectRepresentation Project { get; init; }
- public required ImmutableArray ApplicationArguments { get; init; }
- public LogLevel LogLevel { get; init; }
- public bool NoLaunchProfile { get; init; }
-
- public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOptions? options)
- {
- var sdkOption = new Option("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false };
- var projectOption = new Option("--project") { Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false };
- var fileOption = new Option("--file") { Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false };
- var quietOption = new Option("--quiet") { Arity = ArgumentArity.Zero };
- var verboseOption = new Option("--verbose") { Arity = ArgumentArity.Zero };
- var noLaunchProfileOption = new Option("--no-launch-profile") { Arity = ArgumentArity.Zero };
- var applicationArguments = new Argument("arguments") { Arity = ArgumentArity.ZeroOrMore };
-
- var rootCommand = new RootCommand()
- {
- Directives = { new EnvironmentVariablesDirective() },
- Options =
- {
- sdkOption,
- projectOption,
- fileOption,
- quietOption,
- verboseOption,
- noLaunchProfileOption
- },
- Arguments =
- {
- applicationArguments
- }
- };
-
- rootCommand.Validators.Add(v =>
- {
- if (HasOption(v, quietOption) && HasOption(v, verboseOption))
- {
- v.AddError("Cannot specify both '--quiet' and '--verbose' options.");
- }
-
- if (HasOption(v, projectOption) && HasOption(v, fileOption))
- {
- v.AddError("Cannot specify both '--file' and '--project' options.");
- }
- else if (!HasOption(v, projectOption) && !HasOption(v, fileOption))
- {
- v.AddError("Must specify either '--file' or '--project' option.");
- }
- });
-
- var parseResult = rootCommand.Parse(args);
- if (parseResult.Errors.Count > 0)
- {
- foreach (var error in parseResult.Errors)
- {
- Console.Error.WriteLine(error);
- }
-
- options = null;
- return false;
- }
-
- options = new DotNetWatchOptions()
- {
- SdkDirectory = parseResult.GetRequiredValue(sdkOption),
- Project = new ProjectRepresentation(projectPath: parseResult.GetValue(projectOption), entryPointFilePath: parseResult.GetValue(fileOption)),
- LogLevel = parseResult.GetValue(quietOption) ? LogLevel.Warning : parseResult.GetValue(verboseOption) ? LogLevel.Debug : LogLevel.Information,
- ApplicationArguments = [.. parseResult.GetValue(applicationArguments) ?? []],
- NoLaunchProfile = parseResult.GetValue(noLaunchProfileOption),
- };
-
- return true;
- }
-
- private static bool HasOption(SymbolResult symbolResult, Option option)
- => symbolResult.GetResult(option) is OptionResult or && !or.Implicit;
-}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Host/AspireHostLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Host/AspireHostLauncher.cs
new file mode 100644
index 000000000000..bb3c9a92f11f
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Host/AspireHostLauncher.cs
@@ -0,0 +1,87 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.CommandLine;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireHostLauncher(
+ GlobalOptions globalOptions,
+ EnvironmentOptions environmentOptions,
+ ProjectRepresentation entryPoint,
+ ImmutableArray applicationArguments,
+ Optional launchProfileName)
+ : AspireWatcherLauncher(globalOptions, environmentOptions)
+{
+ internal const string LogMessagePrefix = "aspire watch host";
+
+ public ProjectRepresentation EntryPoint => entryPoint;
+ public ImmutableArray ApplicationArguments => applicationArguments;
+ public Optional LaunchProfileName => launchProfileName;
+
+ internal static AspireHostLauncher? TryCreate(ParseResult parseResult, AspireHostCommandDefinition command)
+ {
+ var sdkDirectory = parseResult.GetValue(command.SdkOption)!;
+ var entryPointPath = parseResult.GetValue(command.EntryPointOption)!;
+ var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? [];
+ var launchProfile = parseResult.GetValue(command.LaunchProfileOption);
+ var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption);
+
+ var globalOptions = new GlobalOptions()
+ {
+ LogLevel = command.GetLogLevel(parseResult),
+ NoHotReload = false,
+ NonInteractive = true,
+ };
+
+ return new AspireHostLauncher(
+ globalOptions,
+ EnvironmentOptions.FromEnvironment(sdkDirectory, LogMessagePrefix),
+ entryPoint: ProjectRepresentation.FromProjectOrEntryPointFilePath(entryPointPath),
+ applicationArguments: [.. applicationArguments],
+ launchProfileName: noLaunchProfile ? Optional.NoValue : launchProfile);
+ }
+
+ internal ProjectOptions GetProjectOptions()
+ {
+ var commandArguments = new List()
+ {
+ EntryPoint.IsProjectFile ? "--project" : "--file",
+ EntryPoint.ProjectOrEntryPointFilePath,
+ };
+
+ if (LaunchProfileName.Value != null)
+ {
+ commandArguments.Add("--launch-profile");
+ commandArguments.Add(LaunchProfileName.Value);
+ }
+ else if (!LaunchProfileName.HasValue)
+ {
+ commandArguments.Add("--no-launch-profile");
+ }
+
+ commandArguments.AddRange(ApplicationArguments);
+
+ return new ProjectOptions()
+ {
+ IsMainProject = true,
+ Representation = EntryPoint,
+ WorkingDirectory = EnvironmentOptions.WorkingDirectory,
+ LaunchProfileName = LaunchProfileName,
+ Command = "run",
+ CommandArguments = [.. commandArguments],
+ LaunchEnvironmentVariables = [],
+ };
+ }
+
+ public override async Task LaunchAsync(CancellationToken cancellationToken)
+ {
+ return await LaunchWatcherAsync(
+ rootProjects: [EntryPoint],
+ LoggerFactory,
+ processLauncherFactory: null,
+ cancellationToken);
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj b/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj
index fb08c258b800..3e7fef4bc7c5 100644
--- a/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj
+++ b/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj
@@ -22,8 +22,10 @@
+
+
-
+
diff --git a/src/Dotnet.Watch/Watch.Aspire/Program.cs b/src/Dotnet.Watch/Watch.Aspire/Program.cs
index 37de5ceca579..bb758c667b15 100644
--- a/src/Dotnet.Watch/Watch.Aspire/Program.cs
+++ b/src/Dotnet.Watch/Watch.Aspire/Program.cs
@@ -1,12 +1,26 @@
-using Microsoft.Build.Locator;
+using System.Diagnostics;
+using Microsoft.Build.Locator;
using Microsoft.DotNet.Watch;
-if (!DotNetWatchOptions.TryParse(args, out var options))
+try
{
- return -1;
-}
+ if (AspireLauncher.TryCreate(args) is not { } launcher)
+ {
+ return -1;
+ }
-MSBuildLocator.RegisterMSBuildPath(options.SdkDirectory);
+ if (launcher.EnvironmentOptions.SdkDirectory != null)
+ {
+ MSBuildLocator.RegisterMSBuildPath(launcher.EnvironmentOptions.SdkDirectory);
-var workingDirectory = Directory.GetCurrentDirectory();
-return await DotNetWatchLauncher.RunAsync(workingDirectory, options) ? 0 : 1;
+ // msbuild tasks depend on host path variable:
+ Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, launcher.EnvironmentOptions.GetMuxerPath());
+ }
+
+ return await launcher.LaunchAsync(CancellationToken.None);
+}
+catch (Exception e)
+{
+ Console.Error.WriteLine($"Unexpected exception: {e}");
+ return -1;
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Resource/AspireResourceLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Resource/AspireResourceLauncher.cs
new file mode 100644
index 000000000000..c38f561f1382
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Resource/AspireResourceLauncher.cs
@@ -0,0 +1,158 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.CommandLine;
+using System.IO.Pipes;
+using System.Text.Json;
+using Microsoft.DotNet.HotReload;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireResourceLauncher(
+ GlobalOptions globalOptions,
+ EnvironmentOptions environmentOptions,
+ string serverPipeName,
+ string entryPoint,
+ ImmutableArray applicationArguments,
+ IReadOnlyDictionary environmentVariables,
+ Optional launchProfileName,
+ TimeSpan pipeConnectionTimeout)
+ : AspireLauncher(globalOptions, environmentOptions)
+{
+ internal const string LogMessagePrefix = "aspire watch resource";
+
+ public const byte Version = 1;
+
+ // Output message type bytes
+ public const byte OutputTypeStdout = 1;
+ public const byte OutputTypeStderr = 2;
+
+ public string ServerPipeName => serverPipeName;
+ public string EntryPoint => entryPoint;
+ public ImmutableArray ApplicationArguments => applicationArguments;
+ public IReadOnlyDictionary EnvironmentVariables => environmentVariables;
+ public Optional LaunchProfileName => launchProfileName;
+
+ public static AspireResourceLauncher? TryCreate(ParseResult parseResult, AspireResourceCommandDefinition command)
+ {
+ var serverPipeName = parseResult.GetValue(command.ServerOption)!;
+ var entryPointPath = parseResult.GetValue(command.EntryPointOption)!;
+ var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? [];
+ var environmentVariables = parseResult.GetValue(command.EnvironmentOption) ?? ImmutableDictionary.Empty;
+ var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption);
+ var launchProfile = parseResult.GetValue(command.LaunchProfileOption);
+
+ var globalOptions = new GlobalOptions()
+ {
+ LogLevel = command.GetLogLevel(parseResult),
+ NoHotReload = false,
+ NonInteractive = true,
+ };
+
+ return new AspireResourceLauncher(
+ globalOptions,
+ // SDK directory is not needed for the resource launcher since it doesn't interact with MSBuild:
+ EnvironmentOptions.FromEnvironment(sdkDirectory: null, LogMessagePrefix),
+ serverPipeName: serverPipeName,
+ entryPoint: entryPointPath,
+ applicationArguments: [.. applicationArguments],
+ environmentVariables: environmentVariables,
+ launchProfileName: noLaunchProfile ? Optional.NoValue : launchProfile,
+ pipeConnectionTimeout: AspireEnvironmentVariables.PipeConnectionTimeout);
+ }
+
+ ///
+ /// Connects to the server via named pipe, sends resource options as JSON, waits for ACK,
+ /// then stays alive proxying stdout/stderr from the server back to the console.
+ ///
+ public override async Task LaunchAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ Logger.LogDebug("Connecting to {ServerPipeName}...", ServerPipeName);
+
+ using var pipeClient = new NamedPipeClientStream(
+ serverName: ".",
+ ServerPipeName,
+ PipeDirection.InOut,
+ PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);
+
+ // Timeout ensures we don't hang indefinitely if the server isn't ready or the pipe name is wrong.
+ using var connectionCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ connectionCancellationSource.CancelAfter(pipeConnectionTimeout);
+ await pipeClient.ConnectAsync(connectionCancellationSource.Token);
+
+ var request = new LaunchResourceRequest()
+ {
+ EntryPoint = EntryPoint,
+ ApplicationArguments = ApplicationArguments,
+ EnvironmentVariables = EnvironmentVariables,
+ LaunchProfileName = LaunchProfileName,
+ };
+
+ await pipeClient.WriteAsync(Version, cancellationToken);
+
+ var json = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(request));
+ await pipeClient.WriteAsync(json, cancellationToken);
+
+ // Wait for ACK byte
+ var status = await pipeClient.ReadByteAsync(cancellationToken);
+ if (status == 0)
+ {
+ Logger.LogDebug("Server closed connection without sending ACK.");
+ return 1;
+ }
+
+ Logger.LogDebug("Request sent. Waiting for output...");
+
+ // Stay alive and proxy output from the server
+ return await ProxyOutputAsync(pipeClient, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return 0;
+ }
+ catch (EndOfStreamException)
+ {
+ // Pipe disconnected - server shut down
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug("Failed to communicate with server: {Message}", ex.Message);
+ return 1;
+ }
+ }
+
+ private async Task ProxyOutputAsync(NamedPipeClientStream pipe, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ byte typeByte;
+ try
+ {
+ typeByte = await pipe.ReadByteAsync(cancellationToken);
+ }
+ catch (EndOfStreamException)
+ {
+ // Pipe closed, server shut down
+ return 0;
+ }
+
+ var content = await pipe.ReadStringAsync(cancellationToken);
+
+ var output = typeByte switch
+ {
+ OutputTypeStdout => Console.Out,
+ OutputTypeStderr => Console.Error,
+ _ => throw new InvalidOperationException($"Unexpected output type: '{typeByte:X2}'")
+ };
+
+ output.WriteLine(content);
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Resource/LaunchResourceRequest.cs b/src/Dotnet.Watch/Watch.Aspire/Resource/LaunchResourceRequest.cs
new file mode 100644
index 000000000000..209edaf42f42
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Resource/LaunchResourceRequest.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class LaunchResourceRequest
+{
+ public required string EntryPoint { get; init; }
+ public required ImmutableArray ApplicationArguments { get; init; }
+ public required IReadOnlyDictionary EnvironmentVariables { get; init; }
+ public required Optional LaunchProfileName { get; init; }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs
new file mode 100644
index 000000000000..00d40cfecd85
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs
@@ -0,0 +1,67 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.CommandLine;
+using System.Threading.Channels;
+using Microsoft.CodeAnalysis.Elfie.Diagnostics;
+using Microsoft.DotNet.HotReload;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using static System.Runtime.InteropServices.JavaScript.JSType;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class AspireServerLauncher(
+ GlobalOptions globalOptions,
+ EnvironmentOptions environmentOptions,
+ string serverPipeName,
+ ImmutableArray resourcePaths,
+ string? statusPipeName,
+ string? controlPipeName)
+ : AspireWatcherLauncher(globalOptions, environmentOptions)
+{
+ private const string LogMessagePrefix = "aspire watch server";
+
+ public string ServerPipeName => serverPipeName;
+ public ImmutableArray ResourcePaths => resourcePaths;
+ public string? StatusPipeName => statusPipeName;
+ public string? ControlPipeName => controlPipeName;
+
+ public static AspireServerLauncher? TryCreate(ParseResult parseResult, AspireServerCommandDefinition command)
+ {
+ var serverPipeName = parseResult.GetValue(command.ServerOption)!;
+ var sdkDirectory = parseResult.GetValue(command.SdkOption)!;
+ var resourcePaths = parseResult.GetValue(command.ResourceOption) ?? [];
+ var statusPipeName = parseResult.GetValue(command.StatusPipeOption);
+ var controlPipeName = parseResult.GetValue(command.ControlPipeOption);
+
+ var globalOptions = new GlobalOptions()
+ {
+ LogLevel = command.GetLogLevel(parseResult),
+ NoHotReload = false,
+ NonInteractive = true,
+ };
+
+ return new AspireServerLauncher(
+ globalOptions,
+ EnvironmentOptions.FromEnvironment(sdkDirectory, LogMessagePrefix),
+ serverPipeName: serverPipeName,
+ resourcePaths: [.. resourcePaths],
+ statusPipeName: statusPipeName,
+ controlPipeName: controlPipeName);
+ }
+
+ public override async Task LaunchAsync(CancellationToken cancellationToken)
+ {
+ await using var statusWriter = StatusPipeName != null ? new WatchStatusWriter(StatusPipeName, Logger) : null;
+
+ var processLauncherFactory = new ProcessLauncherFactory(ServerPipeName, ControlPipeName, statusWriter, launchProfile: null, cancellationToken);
+
+ return await LaunchWatcherAsync(
+ rootProjects: [.. ResourcePaths.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)],
+ statusWriter != null ? new StatusReportingLoggerFactory(statusWriter, LoggerFactory) : LoggerFactory,
+ processLauncherFactory,
+ cancellationToken);
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs
new file mode 100644
index 000000000000..0ccc26894288
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs
@@ -0,0 +1,55 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis.Elfie.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal abstract class AspireWatcherLauncher(GlobalOptions globalOptions, EnvironmentOptions environmentOptions)
+ : AspireLauncher(globalOptions, environmentOptions)
+{
+ protected async Task LaunchWatcherAsync(
+ ImmutableArray rootProjects,
+ ILoggerFactory loggerFactory,
+ IRuntimeProcessLauncherFactory? processLauncherFactory,
+ CancellationToken cancellationToken)
+ {
+ var logger = loggerFactory != LoggerFactory
+ ? loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName)
+ : Logger;
+
+ using var context = new DotNetWatchContext()
+ {
+ ProcessOutputReporter = Reporter,
+ LoggerFactory = loggerFactory,
+ Logger = logger,
+ BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName),
+ ProcessRunner = new ProcessRunner(EnvironmentOptions.GetProcessCleanupTimeout()),
+ Options = GlobalOptions,
+ EnvironmentOptions = EnvironmentOptions,
+ MainProjectOptions = null,
+ BuildArguments = [],
+ TargetFramework = null,
+ RootProjects = rootProjects,
+ BrowserRefreshServerFactory = new BrowserRefreshServerFactory(),
+ BrowserLauncher = new BrowserLauncher(logger, Reporter, EnvironmentOptions),
+ };
+
+ using var shutdownHandler = new ShutdownHandler(Console, context.Logger);
+ using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, shutdownHandler.CancellationToken);
+
+ try
+ {
+ var watcher = new HotReloadDotNetWatcher(context, Console, processLauncherFactory);
+ await watcher.WatchAsync(cancellationSource.Token);
+ }
+ catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested)
+ {
+ // Ctrl+C forced an exit
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs
new file mode 100644
index 000000000000..8bb1e7548f30
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs
@@ -0,0 +1,358 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading.Channels;
+using Aspire.Tools.Service;
+using Microsoft.CodeAnalysis;
+using Microsoft.DotNet.HotReload;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class ProcessLauncherFactory(
+ string serverPipeName,
+ string? controlPipeName,
+ WatchStatusWriter? statusWriter,
+ Optional launchProfile,
+ CancellationToken shutdownCancellationToken) : IRuntimeProcessLauncherFactory
+{
+ public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher)
+ {
+ // Connect to control pipe if provided
+ var controlReader = controlPipeName != null
+ ? new WatchControlReader(controlPipeName, projectLauncher.CompilationHandler, projectLauncher.Logger)
+ : null;
+
+ return new Launcher(serverPipeName, controlReader, projectLauncher, statusWriter, launchProfile, shutdownCancellationToken);
+ }
+
+ private sealed class Launcher : IRuntimeProcessLauncher
+ {
+ private const byte Version = 1;
+
+ private readonly Optional _launchProfileName;
+ private readonly Task _listenerTask;
+ private readonly WatchStatusWriter? _statusWriter;
+ private readonly WatchControlReader? _controlReader;
+
+ private CancellationTokenSource? _disposalCancellationSource;
+ private ImmutableHashSet _pendingRequestCompletions = [];
+ private volatile ProjectLauncher _projectLauncher;
+
+ public Launcher(
+ string serverPipeName,
+ WatchControlReader? controlReader,
+ ProjectLauncher projectLauncher,
+ WatchStatusWriter? statusWriter,
+ Optional launchProfile,
+ CancellationToken shutdownCancellationToken)
+ {
+ _projectLauncher = projectLauncher;
+ _statusWriter = statusWriter;
+ _launchProfileName = launchProfile;
+ _controlReader = controlReader;
+ _disposalCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken);
+ _listenerTask = StartListeningAsync(serverPipeName, _disposalCancellationSource.Token);
+ }
+
+ public bool IsDisposed
+ => _disposalCancellationSource == null;
+
+ private ILogger Logger
+ => _projectLauncher.Logger;
+
+ public async ValueTask DisposeAsync()
+ {
+ var disposalCancellationSource = Interlocked.Exchange(ref _disposalCancellationSource, null);
+ ObjectDisposedException.ThrowIf(disposalCancellationSource == null, this);
+
+ Logger.LogDebug("Disposing process launcher.");
+ await disposalCancellationSource.CancelAsync();
+
+ if (_controlReader != null)
+ {
+ await _controlReader.DisposeAsync();
+ }
+
+ await _listenerTask;
+ await Task.WhenAll(_pendingRequestCompletions);
+
+ disposalCancellationSource.Dispose();
+ }
+
+ private async Task StartListeningAsync(string pipeName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ NamedPipeServerStream? pipe = null;
+ try
+ {
+ pipe = new NamedPipeServerStream(
+ pipeName,
+ PipeDirection.InOut,
+ NamedPipeServerStream.MaxAllowedServerInstances,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ await pipe.WaitForConnectionAsync(cancellationToken);
+
+ Logger.LogDebug("Connected to '{PipeName}'", pipeName);
+
+ var version = await pipe.ReadByteAsync(cancellationToken);
+ if (version != Version)
+ {
+ Logger.LogDebug("Unsupported protocol version '{Version}'", version);
+ await pipe.WriteAsync((byte)0, cancellationToken);
+ continue;
+ }
+
+ var json = await pipe.ReadStringAsync(cancellationToken);
+
+ var request = JsonSerializer.Deserialize(json) ?? throw new JsonException("Unexpected null");
+
+ Logger.LogDebug("Request received.");
+ await pipe.WriteAsync((byte)1, cancellationToken);
+
+ _ = HandleRequestAsync(request, pipe, cancellationToken);
+
+ // Don't dispose the pipe - it's now owned by HandleRequestAsync
+ // which will keep it alive for output proxying
+ pipe = null;
+ }
+ finally
+ {
+ if (pipe != null)
+ {
+ await pipe.DisposeAsync();
+ }
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // nop
+ }
+ catch (Exception e)
+ {
+ Logger.LogError("Failed to launch resource: {Exception}", e.Message);
+ }
+ }
+
+ private async Task HandleRequestAsync(LaunchResourceRequest request, NamedPipeServerStream pipe, CancellationToken cancellationToken)
+ {
+ var completionSource = new TaskCompletionSource();
+ ImmutableInterlocked.Update(ref _pendingRequestCompletions, set => set.Add(completionSource.Task));
+
+ // Shared box to track the latest RunningProject across restarts.
+ // restartOperation creates new RunningProjects — we always need the latest one.
+ var currentProject = new StrongBox(null);
+
+ // Create a per-connection token that cancels when the pipe disconnects OR on shutdown.
+ // DCP Stop kills the resource command, which closes the pipe from the other end.
+ // We detect that by reading from the pipe — when it breaks, we cancel.
+ using var pipeDisconnectedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var connectionToken = pipeDisconnectedSource.Token;
+
+ try
+ {
+ var projectOptions = GetProjectOptions(request);
+
+ await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: currentProject.Value is not null, connectionToken);
+ Debug.Assert(currentProject.Value != null);
+
+ var projectLogger = currentProject.Value.ClientLogger;
+ projectLogger.LogDebug("Waiting for resource to disconnect or relaunch.");
+
+ await WaitForPipeDisconnectAsync(pipe, connectionToken);
+
+ projectLogger.LogDebug("Resource pipe disconnected.");
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutdown or DCP killed the resource command
+ }
+ catch (Exception e)
+ {
+ Logger.LogError("Failed to start '{Path}': {Exception}", request.EntryPoint, e.Message);
+ }
+ finally
+ {
+ // Cancel the connection token so any in-flight restartOperation / drain tasks stop.
+ await pipeDisconnectedSource.CancelAsync();
+
+ // Terminate the project process when the resource command disconnects.
+ // This handles DCP Stop — the resource command is killed, pipe breaks,
+ // and we clean up the project process the watch server launched.
+ if (currentProject.Value is { } project)
+ {
+ Logger.LogDebug("Pipe disconnected for '{Path}', terminating project process.", request.EntryPoint);
+ await project.Process.TerminateAsync();
+ }
+
+ await pipe.DisposeAsync();
+ Logger.LogDebug("HandleRequest completed for '{Path}'.", request.EntryPoint);
+ }
+
+ ImmutableInterlocked.Update(ref _pendingRequestCompletions, set => set.Remove(completionSource.Task));
+ completionSource.SetResult();
+ }
+
+ private static async Task WaitForPipeDisconnectAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var buffer = new byte[1];
+ while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
+ {
+ var bytesRead = await pipe.ReadAsync(buffer, cancellationToken);
+ if (bytesRead == 0)
+ {
+ break;
+ }
+ }
+ }
+ catch (IOException)
+ {
+ // Pipe disconnected
+ }
+ }
+
+ private async ValueTask StartProjectAsync(ProjectOptions projectOptions, NamedPipeServerStream pipe, StrongBox currentProject, bool isRestart, CancellationToken cancellationToken)
+ {
+ // Buffer output through a channel to avoid blocking the synchronous onOutput callback.
+ // The channel is drained asynchronously by DrainOutputChannelAsync which writes to the pipe.
+ var outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ });
+
+ var outputChannelDrainTask = WriteProcessOutputToPipeAsync();
+
+ currentProject.Value = await _projectLauncher.TryLaunchProcessAsync(
+ projectOptions,
+ onOutput: line => outputChannel.Writer.TryWrite(line),
+ onExit: async (processId, exitCode) =>
+ {
+ var isRestarting = currentProject.Value?.IsRestarting == true;
+ if (exitCode is not null and not 0 && !cancellationToken.IsCancellationRequested && !isRestarting)
+ {
+ // Emit a status event for non-zero exit codes so the dashboard shows the crash.
+ // Skip if cancellation is requested (DCP Stop/shutdown) or if the project
+ // is being deliberately restarted (rude edit restart).
+ _statusWriter?.WriteEvent(new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.ProcessExited,
+ Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath],
+ ExitCode = exitCode,
+ });
+ }
+
+ // DON'T complete the output channel.
+ // dotnet-watch will auto-retry on crash and reuse the same onOutput callback,
+ // so new output from the retried process flows through the same channel/pipe.
+ // Completing the channel would starve the pipe and cause DCP to kill the
+ // resource command, triggering a disconnect → terminate → reconnect storm.
+ },
+ restartOperation: async cancellationToken =>
+ {
+ // Complete the old channel so the old drain task finishes before
+ // StartProjectAsync creates a new channel + drain on the same pipe.
+ outputChannel.Writer.TryComplete();
+ await outputChannelDrainTask;
+
+ await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: true, cancellationToken);
+ },
+ cancellationToken)
+ ?? throw new InvalidOperationException();
+
+ // Emit ProcessStarted so the dashboard knows the process is actually running.
+ _statusWriter?.WriteEvent(new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.ProcessStarted,
+ Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath],
+ });
+
+ async Task WriteProcessOutputToPipeAsync()
+ {
+ try
+ {
+ await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken))
+ {
+ await pipe.WriteAsync(line.IsError ? AspireResourceLauncher.OutputTypeStderr : AspireResourceLauncher.OutputTypeStdout, cancellationToken);
+ await pipe.WriteAsync(line.Content, cancellationToken);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException)
+ {
+ // Pipe disconnected or cancelled
+ }
+ }
+ }
+
+ private ProjectOptions GetProjectOptions(LaunchResourceRequest request)
+ {
+ var project = ProjectRepresentation.FromProjectOrEntryPointFilePath(request.EntryPoint);
+
+ return new()
+ {
+ IsMainProject = false,
+ Representation = project,
+ WorkingDirectory = Path.GetDirectoryName(request.EntryPoint) ?? throw new InvalidOperationException(),
+ Command = "run",
+ CommandArguments = GetRunCommandArguments(request, _launchProfileName.Value),
+ LaunchEnvironmentVariables = request.EnvironmentVariables?.Select(e => (e.Key, e.Value))?.ToArray() ?? [],
+ LaunchProfileName = request.LaunchProfileName,
+ };
+ }
+
+ // internal for testing
+ internal static IReadOnlyList GetRunCommandArguments(LaunchResourceRequest request, string? hostLaunchProfile)
+ {
+ var arguments = new List();
+
+ if (!request.LaunchProfileName.HasValue)
+ {
+ arguments.Add("--no-launch-profile");
+ }
+ else if (!string.IsNullOrEmpty(request.LaunchProfileName.Value))
+ {
+ arguments.Add("--launch-profile");
+ arguments.Add(request.LaunchProfileName.Value);
+ }
+ else if (hostLaunchProfile != null)
+ {
+ arguments.Add("--launch-profile");
+ arguments.Add(hostLaunchProfile);
+ }
+
+ if (request.ApplicationArguments != null)
+ {
+ if (request.ApplicationArguments.Any())
+ {
+ arguments.AddRange(request.ApplicationArguments);
+ }
+ else
+ {
+ // indicate that no arguments should be used even if launch profile specifies some:
+ arguments.Add("--no-launch-profile-arguments");
+ }
+ }
+
+ return arguments;
+ }
+
+ public IEnumerable<(string name, string value)> GetEnvironmentVariables()
+ => [];
+
+ public ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken)
+ => ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs
new file mode 100644
index 000000000000..e676a2ff0b7f
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs
@@ -0,0 +1,85 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+///
+/// Intercepts select log messages reported by watch and forwards them to to be sent to an external listener.
+///
+internal sealed class StatusReportingLoggerFactory(WatchStatusWriter writer, LoggerFactory underlyingFactory) : ILoggerFactory
+{
+ public void Dispose()
+ {
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ => new Logger(writer, underlyingFactory.CreateLogger(categoryName));
+
+ public void AddProvider(ILoggerProvider provider)
+ => underlyingFactory.AddProvider(provider);
+
+ private sealed class Logger(WatchStatusWriter writer, ILogger underlyingLogger) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull
+ => underlyingLogger.BeginScope(state);
+
+ public bool IsEnabled(LogLevel logLevel)
+ => logLevel == LogLevel.None || underlyingLogger.IsEnabled(logLevel);
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ underlyingLogger.Log(logLevel, eventId, state, exception, formatter);
+
+ WatchStatusEvent? status = null;
+
+ if (eventId == MessageDescriptor.BuildStartedNotification.Id)
+ {
+ var logState = (LogState>)(object)state!;
+
+ status = new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.Building,
+ Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath),
+ };
+ }
+ else if (eventId == MessageDescriptor.BuildCompletedNotification.Id)
+ {
+ var logState = (LogState<(IEnumerable projects, bool success)>)(object)state!;
+
+ status = new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.BuildComplete,
+ Projects = logState.Arguments.projects.Select(r => r.ProjectOrEntryPointFilePath),
+ Success = logState.Arguments.success,
+ };
+ }
+ else if (eventId == MessageDescriptor.ChangesAppliedToProjectsNotification.Id)
+ {
+ var logState = (LogState>)(object)state!;
+
+ status = new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.HotReloadApplied,
+ Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath),
+ };
+ }
+ else if (eventId == MessageDescriptor.RestartingProjectsNotification.Id)
+ {
+ var logState = (LogState>)(object)state!;
+
+ status = new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.Restarting,
+ Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath)
+ };
+ }
+
+ if (status != null)
+ {
+ writer.WriteEvent(status);
+ }
+ }
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlCommand.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlCommand.cs
new file mode 100644
index 000000000000..7b8f554c9a17
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlCommand.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchControlCommand
+{
+ [JsonPropertyName("type")]
+ public required string Type { get; init; }
+
+ ///
+ /// Paths of projects to restart.
+ ///
+ [JsonPropertyName("projects")]
+ public ImmutableArray Projects { get; init; }
+
+ public static class Types
+ {
+ public const string Rebuild = "rebuild";
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs
new file mode 100644
index 000000000000..858ec4f8a9b5
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs
@@ -0,0 +1,109 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchControlReader : IAsyncDisposable
+{
+ private readonly CompilationHandler _compilationHandler;
+ private readonly string _pipeName;
+ private readonly NamedPipeClientStream _pipe;
+ private readonly ILogger _logger;
+ private readonly CancellationTokenSource _disposalCancellationSource = new();
+ private readonly Task _listener;
+
+ public WatchControlReader(string pipeName, CompilationHandler compilationHandler, ILogger logger)
+ {
+ _pipe = new NamedPipeClientStream(
+ serverName: ".",
+ pipeName,
+ PipeDirection.In,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ _pipeName = pipeName;
+ _compilationHandler = compilationHandler;
+ _logger = logger;
+ _listener = ListenAsync(_disposalCancellationSource.Token);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _logger.LogDebug("Disposing control pipe.");
+
+ _disposalCancellationSource.Cancel();
+ await _listener;
+
+ try
+ {
+ await _pipe.DisposeAsync();
+ }
+ catch (IOException)
+ {
+ // Pipe may already be broken if the server disconnected
+ }
+ }
+
+ private async Task ListenAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogDebug("Connecting to control pipe '{PipeName}'.", _pipeName);
+ await _pipe.ConnectAsync(cancellationToken);
+
+ using var reader = new StreamReader(_pipe);
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(cancellationToken);
+ if (line is null)
+ {
+ return;
+ }
+
+ var command = JsonSerializer.Deserialize(line);
+ if (command is null)
+ {
+ break;
+ }
+
+ if (command.Type == WatchControlCommand.Types.Rebuild)
+ {
+ _logger.LogDebug("Received request to restart projects");
+ await RestartProjectsAsync(command.Projects.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath), cancellationToken);
+ }
+ else
+ {
+ _logger.LogError("Unknown control command: '{Type}'", command.Type);
+ }
+ }
+ }
+ catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException)
+ {
+ // expected when disposing or if the server disconnects
+ }
+ catch (Exception e)
+ {
+ _logger.LogDebug("Control pipe listener failed: {Message}", e.Message);
+ }
+ }
+
+ private async ValueTask RestartProjectsAsync(IEnumerable projects, CancellationToken cancellationToken)
+ {
+ var projectsToRestart = await _compilationHandler.TerminatePeripheralProcessesAsync(projects.Select(p => p.ProjectGraphPath), cancellationToken);
+
+ foreach (var project in projects)
+ {
+ if (!projectsToRestart.Any(p => p.Options.Representation == project))
+ {
+ _compilationHandler.Logger.LogDebug("Restart of '{Project}' requested but the project is not running.", project);
+ }
+ }
+
+ await _compilationHandler.RestartPeripheralProjectsAsync(projectsToRestart, cancellationToken);
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusEvent.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusEvent.cs
new file mode 100644
index 000000000000..5e96168e1fd9
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusEvent.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchStatusEvent
+{
+ [JsonPropertyName("type")]
+ public required string Type { get; init; }
+
+ [JsonPropertyName("projects")]
+ public required IEnumerable Projects { get; init; }
+
+ [JsonPropertyName("success")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? Success { get; init; }
+
+ [JsonPropertyName("error")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Error { get; init; }
+
+ [JsonPropertyName("exitCode")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? ExitCode { get; init; }
+
+ public static class Types
+ {
+ public const string Building = "building";
+ public const string BuildComplete = "build_complete";
+ public const string HotReloadApplied = "hot_reload_applied";
+ public const string Restarting = "restarting";
+ public const string ProcessExited = "process_exited";
+ public const string ProcessStarted = "process_started";
+ }
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs
new file mode 100644
index 000000000000..a208be7836d5
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Reflection.Metadata;
+using System.Text.Json;
+using System.Threading.Channels;
+using Microsoft.CodeAnalysis.Elfie.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchStatusWriter : IAsyncDisposable
+{
+ private readonly Channel _eventChannel = Channel.CreateUnbounded(new()
+ {
+ SingleReader = true,
+ SingleWriter = false
+ });
+
+ private readonly string? _pipeName;
+ private readonly NamedPipeClientStream _pipe;
+ private readonly ILogger _logger;
+ private readonly Task _channelReader;
+ private readonly CancellationTokenSource _disposalCancellationSource = new();
+
+ public WatchStatusWriter(string pipeName, ILogger logger)
+ {
+ _pipe = new NamedPipeClientStream(
+ serverName: ".",
+ pipeName,
+ PipeDirection.Out,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ _pipeName = pipeName;
+ _logger = logger;
+ _channelReader = StartChannelReaderAsync(_disposalCancellationSource.Token);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _logger.LogDebug("Disposing status pipe.");
+
+ _disposalCancellationSource.Cancel();
+ await _channelReader;
+
+ try
+ {
+ await _pipe.DisposeAsync();
+ }
+ catch (IOException)
+ {
+ // Pipe may already be broken if the server disconnected
+ }
+
+ _disposalCancellationSource.Dispose();
+ }
+
+ private async Task StartChannelReaderAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogDebug("Connecting to status pipe '{PipeName}'...", _pipeName);
+
+ await _pipe.ConnectAsync(cancellationToken);
+
+ using var streamWriter = new StreamWriter(_pipe) { AutoFlush = true };
+
+ await foreach (var statusEvent in _eventChannel.Reader.ReadAllAsync(cancellationToken))
+ {
+ var json = JsonSerializer.Serialize(statusEvent);
+ await streamWriter.WriteLineAsync(json.AsMemory(), cancellationToken);
+ }
+ }
+ catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException)
+ {
+ // expected when disposing or if the server disconnects
+ }
+ catch (Exception e)
+ {
+ _logger.LogError("Unexpected error reading status event: {Exception}", e);
+ }
+ }
+
+ public void WriteEvent(WatchStatusEvent statusEvent)
+ => _eventChannel.Writer.TryWrite(statusEvent);
+}
diff --git a/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs b/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs
new file mode 100644
index 000000000000..ffdd4f424a4b
--- /dev/null
+++ b/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.DotNet.Watch;
+
+internal static class AspireEnvironmentVariables
+{
+ public static TimeSpan PipeConnectionTimeout
+ => EnvironmentVariables.ReadTimeSpanSeconds("ASPIRE_WATCH_PIPE_CONNECTION_TIMEOUT_SECONDS") ?? TimeSpan.FromSeconds(30);
+}
diff --git a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs
index 6258919dbdd0..b4b72f3b7a64 100644
--- a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs
+++ b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs
@@ -57,7 +57,7 @@ private static string GetMiddlewareAssemblyPath()
logger,
context.LoggerFactory,
middlewareAssemblyPath: GetMiddlewareAssemblyPath(),
- dotnetPath: context.EnvironmentOptions.MuxerPath,
+ dotnetPath: context.EnvironmentOptions.GetMuxerPath(),
webSocketConfig: context.EnvironmentOptions.BrowserWebSocketConfig,
suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None);
}
diff --git a/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs b/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs
index 99539e56433d..10d46c189096 100644
--- a/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs
+++ b/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs
@@ -110,7 +110,7 @@ async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, Proj
return sessionId;
}
- public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken)
+ public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken)
{
// Neither request from DCP nor restart should happen once the disposal has started.
ObjectDisposedException.ThrowIf(_isDisposed, this);
@@ -179,7 +179,6 @@ public async ValueTask StartProjectAsync(string dcpId, string se
}
_logger.LogDebug("[#{SessionId}] Session started", sessionId);
- return runningProject;
async Task StartChannelReader(CancellationToken cancellationToken)
{
diff --git a/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs b/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs
index d7d8ccd9777d..25e12e6a4aa8 100644
--- a/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs
+++ b/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs
@@ -20,6 +20,12 @@ internal sealed class ProjectBuildManager(ProjectCollection collection, BuildRep
public readonly ProjectCollection Collection = collection;
public readonly BuildReporter BuildReporter = reporter;
+ ///
+ /// Used by tests to ensure no more than one build is running at a time, which is required by MSBuild.
+ ///
+ internal static SemaphoreSlim Test_BuildSemaphore
+ => s_buildSemaphore;
+
///
/// Executes the specified build requests.
///
diff --git a/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs b/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs
index 76526a8a0cb6..1aa8aedfb682 100644
--- a/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs
+++ b/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics.CodeAnalysis;
using Microsoft.DotNet.ProjectTools;
namespace Microsoft.DotNet.Watch;
@@ -8,30 +9,23 @@ namespace Microsoft.DotNet.Watch;
///
/// Project can be reprented by project file or by entry point file (for single-file apps).
///
-internal readonly struct ProjectRepresentation(string projectGraphPath, string? projectPath, string? entryPointFilePath)
+/// Path used in Project Graph (may be virtual).
+/// Path to an physical (non-virtual) project, if available.
+/// Path to an entry point file, if available.
+internal readonly record struct ProjectRepresentation(string ProjectGraphPath, string? PhysicalPath, string? EntryPointFilePath)
{
- ///
- /// Path used in Project Graph (may be virtual).
- ///
- public readonly string ProjectGraphPath = projectGraphPath;
-
- ///
- /// Path to an physical (non-virtual) project, if available.
- ///
- public readonly string? PhysicalPath = projectPath;
-
- ///
- /// Path to an entry point file, if available.
- ///
- public readonly string? EntryPointFilePath = entryPointFilePath;
-
public ProjectRepresentation(string? projectPath, string? entryPointFilePath)
: this(projectPath ?? VirtualProjectBuilder.GetVirtualProjectPath(entryPointFilePath!), projectPath, entryPointFilePath)
{
}
+ [MemberNotNullWhen(true, nameof(PhysicalPath))]
+ [MemberNotNullWhen(false, nameof(EntryPointFilePath))]
+ public bool IsProjectFile
+ => PhysicalPath != null;
+
public string ProjectOrEntryPointFilePath
- => PhysicalPath ?? EntryPointFilePath!;
+ => IsProjectFile ? PhysicalPath : EntryPointFilePath;
public string GetContainingDirectory()
=> Path.GetDirectoryName(ProjectOrEntryPointFilePath)!;
@@ -43,4 +37,10 @@ public static ProjectRepresentation FromProjectOrEntryPointFilePath(string proje
public ProjectRepresentation WithProjectGraphPath(string projectGraphPath)
=> new(projectGraphPath, PhysicalPath, EntryPointFilePath);
+
+ public bool Equals(ProjectRepresentation other)
+ => PathUtilities.OSSpecificPathComparer.Equals(ProjectGraphPath, other.ProjectGraphPath);
+
+ public override int GetHashCode()
+ => PathUtilities.OSSpecificPathComparer.GetHashCode(ProjectGraphPath);
}
diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs
index 7a69972fac19..2953e4760c57 100644
--- a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs
+++ b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs
@@ -28,8 +28,9 @@ internal enum TestFlags
internal sealed record EnvironmentOptions(
string WorkingDirectory,
- string MuxerPath,
- TimeSpan? ProcessCleanupTimeout,
+ string? SdkDirectory,
+ string LogMessagePrefix,
+ TimeSpan? ProcessCleanupTimeout = null,
bool IsPollingEnabled = false,
bool SuppressHandlingStaticWebAssets = false,
bool SuppressMSBuildIncrementalism = false,
@@ -44,10 +45,11 @@ internal sealed record EnvironmentOptions(
TestFlags TestFlags = TestFlags.None,
string TestOutput = "")
{
- public static EnvironmentOptions FromEnvironment(string muxerPath) => new
+ public static EnvironmentOptions FromEnvironment(string? sdkDirectory, string logMessagePrefix) => new
(
WorkingDirectory: Directory.GetCurrentDirectory(),
- MuxerPath: ValidateMuxerPath(muxerPath),
+ SdkDirectory: sdkDirectory,
+ LogMessagePrefix: logMessagePrefix,
ProcessCleanupTimeout: EnvironmentVariables.ProcessCleanupTimeout,
IsPollingEnabled: EnvironmentVariables.IsPollingEnabled,
SuppressHandlingStaticWebAssets: EnvironmentVariables.SuppressHandlingStaticWebAssets,
@@ -68,16 +70,17 @@ public TimeSpan GetProcessCleanupTimeout()
// Allow sufficient time for the process to exit gracefully and release resources (e.g., network ports).
=> ProcessCleanupTimeout ?? TimeSpan.FromSeconds(5);
+ private readonly string? _muxerPath = SdkDirectory != null
+ ? Path.GetFullPath(Path.Combine(SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension))
+ : null;
+
+ public string GetMuxerPath()
+ => _muxerPath ?? throw new InvalidOperationException("SDK directory is required to determine muxer path.");
+
private int _uniqueLogId;
public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; }
- private static string ValidateMuxerPath(string path)
- {
- Debug.Assert(Path.GetFileNameWithoutExtension(path) == "dotnet");
- return path;
- }
-
public string? GetBinLogPath(string projectPath, string operationName, GlobalOptions options)
=> options.BinaryLogPath != null
? $"{Path.Combine(WorkingDirectory, options.BinaryLogPath)[..^".binlog".Length]}-dotnet-watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref _uniqueLogId)}.binlog"
diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs
index ac4dfa5db843..ad9d499e1bb5 100644
--- a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs
+++ b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs
@@ -42,7 +42,7 @@ public static LogLevel? CliLogLevel
public static bool IsPollingEnabled => ReadBool("DOTNET_USE_POLLING_FILE_WATCHER");
public static bool SuppressEmojis => ReadBool("DOTNET_WATCH_SUPPRESS_EMOJIS");
public static bool RestartOnRudeEdit => ReadBool("DOTNET_WATCH_RESTART_ON_RUDE_EDIT");
- public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpan("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS");
+ public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpanMilliseconds("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS");
public static string SdkRootDirectory =>
#if DEBUG
@@ -89,9 +89,12 @@ public static LogLevel? CliLogLevel
private static bool ReadBool(string variableName)
=> ParseBool(Environment.GetEnvironmentVariable(variableName));
- private static TimeSpan? ReadTimeSpan(string variableName)
+ internal static TimeSpan? ReadTimeSpanMilliseconds(string variableName)
=> Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromMilliseconds(intValue) : null;
+ internal static TimeSpan? ReadTimeSpanSeconds(string variableName)
+ => Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromSeconds(intValue) : null;
+
private static int? ReadInt(string variableName)
=> Environment.GetEnvironmentVariable(variableName) is var value && int.TryParse(value, out var intValue) ? intValue : null;
diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs
index 2f44472013f0..91d14e2bc234 100644
--- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs
+++ b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs
@@ -28,8 +28,10 @@ internal sealed class CompilationHandler : IDisposable
///
/// Projects that have been launched and to which we apply changes.
+ /// Maps to the list of running instances of that project.
///
- private ImmutableDictionary> _runningProjects = ImmutableDictionary>.Empty;
+ private ImmutableDictionary> _runningProjects
+ = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer);
///
/// All updates that were attempted. Includes updates whose application failed.
@@ -43,7 +45,8 @@ internal sealed class CompilationHandler : IDisposable
/// Current set of project instances indexed by .
/// Updated whenever the project graph changes.
///
- private ImmutableDictionary> _projectInstances = [];
+ private ImmutableDictionary> _projectInstances
+ = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer);
public CompilationHandler(DotNetWatchContext context)
{
@@ -58,7 +61,7 @@ public void Dispose()
Workspace?.Dispose();
}
- private ILogger Logger
+ public ILogger Logger
=> _context.Logger;
public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken cancellationToken)
@@ -81,9 +84,10 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil
_previousUpdates = _previousUpdates.RemoveAll(update => projectsToBeRebuilt.Contains(update.ProjectId));
}
}
+
public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
{
- Logger.Log(MessageDescriptor.HotReloadSessionStarting);
+ Logger.Log(MessageDescriptor.HotReloadSessionStartingNotification);
var solution = Workspace.CurrentSolution;
@@ -286,7 +290,7 @@ private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, str
await runningProject.Process.TerminateAsync();
// Creates a new running project and launches it:
- await runningProject.RestartOperation(cancellationToken);
+ await runningProject.RestartAsync(cancellationToken);
}
catch (Exception e)
{
@@ -393,10 +397,10 @@ public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync(
CancellationToken cancellationToken)
{
var applyTasks = new List();
+ ImmutableDictionary> projectsToUpdate = [];
if (managedCodeUpdates is not [])
{
- ImmutableDictionary> projectsToUpdate;
lock (_runningProjectsAndUpdatesGuard)
{
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
@@ -442,34 +446,38 @@ public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync(
applyTasks.AddRange(await Task.WhenAll(staticAssetApplyTaskProducers));
// fire and forget:
- _ = CompleteApplyOperationAsync(applyTasks, stopwatch, managedCodeUpdates.Count > 0, staticAssetUpdates.Count > 0);
- }
+ _ = CompleteApplyOperationAsync();
- private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, bool hasManagedCodeUpdates, bool hasStaticAssetUpdates)
- {
- try
+ async Task CompleteApplyOperationAsync()
{
- await Task.WhenAll(applyTasks);
+ try
+ {
+ await Task.WhenAll(applyTasks);
- var elapsedMilliseconds = stopwatch.ElapsedMilliseconds;
+ var elapsedMilliseconds = stopwatch.ElapsedMilliseconds;
- if (hasManagedCodeUpdates)
- {
- _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds);
- }
+ if (managedCodeUpdates.Count > 0)
+ {
+ _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds);
+ }
- if (hasStaticAssetUpdates)
- {
- _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds);
- }
- }
- catch (Exception e)
- {
- // Handle all exceptions since this is a fire-and-forget task.
+ if (staticAssetUpdates.Count > 0)
+ {
+ _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds);
+ }
- if (e is not OperationCanceledException)
+ _context.Logger.Log(MessageDescriptor.ChangesAppliedToProjectsNotification,
+ projectsToUpdate.Select(e => e.Value.First().Options.Representation).Concat(
+ staticAssetUpdates.Select(e => e.Key.Options.Representation)));
+ }
+ catch (Exception e)
{
- _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString());
+ // Handle all exceptions since this is a fire-and-forget task.
+
+ if (e is not OperationCanceledException)
+ {
+ _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString());
+ }
}
}
}
@@ -838,6 +846,25 @@ internal async ValueTask> TerminatePeripheralProc
return projectsToRestart;
}
+ ///
+ /// Restarts given projects after their process have been terminated via .
+ ///
+ internal async Task RestartPeripheralProjectsAsync(IReadOnlyList projectsToRestart, CancellationToken cancellationToken)
+ {
+ if (projectsToRestart.Any(p => p.Options.IsMainProject))
+ {
+ throw new InvalidOperationException("Main project can't be restarted.");
+ }
+
+ _context.Logger.Log(MessageDescriptor.RestartingProjectsNotification, projectsToRestart.Select(p => p.Options.Representation));
+
+ await Task.WhenAll(
+ projectsToRestart.Select(async runningProject => runningProject.RestartAsync(cancellationToken)))
+ .WaitAsync(cancellationToken);
+
+ _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Count);
+ }
+
private bool RemoveRunningProject(RunningProject project)
{
var projectPath = project.ProjectNode.ProjectInstance.FullPath;
@@ -894,15 +921,10 @@ private static ImmutableDictionary> Crea
public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken)
{
- Logger.LogInformation("Loading projects ...");
- var stopwatch = Stopwatch.StartNew();
-
_projectInstances = CreateProjectInstanceMap(projectGraph);
var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken);
await SolutionUpdatedAsync(solution, "project update", cancellationToken);
-
- Logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0"));
}
public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken)
diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs
index 05c363660763..8f82392d5872 100644
--- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs
+++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs
@@ -287,11 +287,7 @@ await compilationHandler.GetManagedCodeUpdatesAsync(
if (updates.ProjectsToRestart is not [])
{
- await Task.WhenAll(
- updates.ProjectsToRestart.Select(async runningProject => runningProject.RestartOperation(shutdownCancellationToken)))
- .WaitAsync(shutdownCancellationToken);
-
- _context.Logger.Log(MessageDescriptor.ProjectsRestarted, updates.ProjectsToRestart.Count);
+ await compilationHandler.RestartPeripheralProjectsAsync(updates.ProjectsToRestart, shutdownCancellationToken);
}
async Task> CaptureChangedFilesSnapshot(IReadOnlyList rebuiltProjects)
@@ -881,15 +877,14 @@ private async ValueTask EvaluateProjectGraphAsync(bool restore
{
cancellationToken.ThrowIfCancellationRequested();
- _context.Logger.LogInformation("Loading projects ...");
+ _context.Logger.Log(MessageDescriptor.LoadingProjects);
var stopwatch = Stopwatch.StartNew();
var result = await EvaluationResult.TryCreateAsync(_designTimeBuildGraphFactory, _context.Options, _context.EnvironmentOptions, restore, cancellationToken);
- var timeDisplay = stopwatch.Elapsed.TotalSeconds.ToString("0.0");
if (result != null)
{
- _context.Logger.LogInformation("Loaded {ProjectCount} project(s) in {Time}s.", result.ProjectGraph.Graph.ProjectNodes.Count, timeDisplay);
+ _context.Logger.Log(MessageDescriptor.LoadedProjects, result.ProjectGraph.Graph.ProjectNodes.Count, stopwatch.Elapsed.TotalSeconds);
return result;
}
@@ -907,57 +902,74 @@ internal async Task BuildProjectsAsync(ImmutableArray p.PhysicalPath != null).Select(p => p.PhysicalPath!).ToArray();
+ return success;
- if (projectPaths is [var singleProjectPath])
+ async Task BuildAsync()
{
- success |= await BuildFileOrProjectOrSolutionAsync(singleProjectPath, cancellationToken);
- }
- else if (projectPaths is not [])
- {
- var solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx");
- var solutionElement = new XElement("Solution");
-
- foreach (var projectPath in projectPaths)
+ if (projects is [var singleProject])
{
- solutionElement.Add(new XElement("Project", new XAttribute("Path", projectPath)));
+ return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, cancellationToken);
}
- var doc = new XDocument(solutionElement);
- doc.Save(solutionFile);
+ // TODO: workaround for https://github.com/dotnet/sdk/issues/51311
- try
+ var projectPaths = projects.Where(p => p.PhysicalPath != null).Select(p => p.PhysicalPath!).ToArray();
+
+ if (projectPaths is [var singleProjectPath])
{
- success |= await BuildFileOrProjectOrSolutionAsync(solutionFile, cancellationToken);
+ if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, cancellationToken))
+ {
+ return false;
+ }
}
- finally
+ else if (projectPaths is not [])
{
+ var solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx");
+ var solutionElement = new XElement("Solution");
+
+ foreach (var projectPath in projectPaths)
+ {
+ solutionElement.Add(new XElement("Project", new XAttribute("Path", projectPath)));
+ }
+
+ var doc = new XDocument(solutionElement);
+ doc.Save(solutionFile);
+
try
{
- File.Delete(solutionFile);
+ if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, cancellationToken))
+ {
+ return false;
+ }
}
- catch
+ finally
{
- // ignore
+ try
+ {
+ File.Delete(solutionFile);
+ }
+ catch
+ {
+ // ignore
+ }
}
}
- }
- // To maximize parallelism of building dependencies, build file-based projects after all physical projects:
- foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!))
- {
- success |= await BuildFileOrProjectOrSolutionAsync(file, cancellationToken);
- }
+ // To maximize parallelism of building dependencies, build file-based projects after all physical projects:
+ foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!))
+ {
+ if (!await BuildFileOrProjectOrSolutionAsync(file, cancellationToken))
+ {
+ return false;
+ }
+ }
- return success;
+ return true;
+ }
}
private async Task BuildFileOrProjectOrSolutionAsync(string path, CancellationToken cancellationToken)
@@ -965,7 +977,7 @@ private async Task BuildFileOrProjectOrSolutionAsync(string path, Cancella
List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null;
var processSpec = new ProcessSpec
{
- Executable = _context.EnvironmentOptions.MuxerPath,
+ Executable = _context.EnvironmentOptions.GetMuxerPath(),
WorkingDirectory = Path.GetDirectoryName(path),
IsUserApplication = false,
diff --git a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj
index c43f9e6fac55..5b46ae0b06e7 100644
--- a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj
+++ b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj
@@ -31,10 +31,11 @@
+
-
+
diff --git a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs
index f0357fd1e03e..4e31e8cced3a 100644
--- a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs
+++ b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs
@@ -27,6 +27,9 @@ public ILoggerFactory LoggerFactory
public EnvironmentOptions EnvironmentOptions
=> context.EnvironmentOptions;
+ public CompilationHandler CompilationHandler
+ => compilationHandler;
+
public async ValueTask TryLaunchProcessAsync(
ProjectOptions projectOptions,
Action? onOutput,
@@ -51,7 +54,7 @@ public EnvironmentOptions EnvironmentOptions
var processSpec = new ProcessSpec
{
- Executable = EnvironmentOptions.MuxerPath,
+ Executable = EnvironmentOptions.GetMuxerPath(),
IsUserApplication = true,
WorkingDirectory = projectOptions.WorkingDirectory,
OnOutput = onOutput,
@@ -82,7 +85,7 @@ public EnvironmentOptions EnvironmentOptions
if (clients.IsManagedAgentSupported && Logger.IsEnabled(LogLevel.Trace))
{
environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] =
- (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]";
+ (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix(EnvironmentOptions.LogMessagePrefix) + $"[{projectDisplayName}]";
}
clients.ConfigureLaunchEnvironment(environmentBuilder);
diff --git a/src/Dotnet.Watch/Watch/Process/RunningProject.cs b/src/Dotnet.Watch/Watch/Process/RunningProject.cs
index c12294aba562..ca1498aecbc5 100644
--- a/src/Dotnet.Watch/Watch/Process/RunningProject.cs
+++ b/src/Dotnet.Watch/Watch/Process/RunningProject.cs
@@ -8,7 +8,7 @@
namespace Microsoft.DotNet.Watch
{
- internal delegate ValueTask RestartOperation(CancellationToken cancellationToken);
+ internal delegate ValueTask RestartOperation(CancellationToken cancellationToken);
internal sealed class RunningProject(
ProjectGraphNode projectNode,
@@ -27,7 +27,6 @@ internal sealed class RunningProject(
public ILogger ClientLogger => clientLogger;
public ImmutableArray ManagedCodeUpdateCapabilities => managedCodeUpdateCapabilities;
public RunningProcess Process => process;
- public RestartOperation RestartOperation => restartOperation;
///
/// Set to true when the process termination is being requested so that it can be auto-restarted.
@@ -85,5 +84,15 @@ public async Task CompleteApplyOperationAsync(Task applyTask)
ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", process.Id, e.ToString());
}
}
+
+ ///
+ /// Triggers restart operation.
+ ///
+ public async ValueTask RestartAsync(CancellationToken cancellationToken)
+ {
+ ClientLogger.Log(MessageDescriptor.ProjectRestarting);
+ await restartOperation(cancellationToken);
+ ClientLogger.Log(MessageDescriptor.ProjectRestarted);
+ }
}
}
diff --git a/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs b/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs
index 60b156142b08..baa0807958d0 100644
--- a/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs
+++ b/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs
@@ -9,10 +9,8 @@ namespace Microsoft.DotNet.Watch
/// This API supports infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
///
- internal sealed class ConsoleReporter(IConsole console, bool suppressEmojis) : IReporter, IProcessOutputReporter
+ internal sealed class ConsoleReporter(IConsole console, string logMessagePrefix, bool suppressEmojis) : IReporter, IProcessOutputReporter
{
- public bool SuppressEmojis { get; } = suppressEmojis;
-
private readonly Lock _writeLock = new();
bool IProcessOutputReporter.PrefixProcessOutput
@@ -31,7 +29,7 @@ private void WriteLine(TextWriter writer, string message, ConsoleColor? color, E
lock (_writeLock)
{
console.ForegroundColor = ConsoleColor.DarkGray;
- writer.Write((SuppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix());
+ writer.Write((suppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix(logMessagePrefix));
console.ResetColor();
if (color.HasValue)
diff --git a/src/Dotnet.Watch/Watch/UI/IReporter.cs b/src/Dotnet.Watch/Watch/UI/IReporter.cs
index d47cc2910cfc..4e2c7a0f9359 100644
--- a/src/Dotnet.Watch/Watch/UI/IReporter.cs
+++ b/src/Dotnet.Watch/Watch/UI/IReporter.cs
@@ -3,6 +3,7 @@
using System.Collections.Immutable;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;
@@ -52,8 +53,8 @@ public static string ToDisplay(this Emoji emoji)
_ => throw new InvalidOperationException()
};
- public static string GetLogMessagePrefix(this Emoji emoji)
- => $"dotnet watch {emoji.ToDisplay()} ";
+ public static string GetLogMessagePrefix(this Emoji emoji, string logMessagePrefix)
+ => $"{logMessagePrefix} {emoji.ToDisplay()} ";
public static void Log(this ILogger logger, MessageDescriptor descriptor)
=> Log(logger, descriptor, default);
@@ -96,7 +97,7 @@ public bool IsEnabled(LogLevel logLevel)
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
- if (!IsEnabled(logLevel))
+ if (logLevel == LogLevel.None || !IsEnabled(logLevel))
{
return;
}
@@ -133,12 +134,12 @@ public void AddProvider(ILoggerProvider provider)
=> throw new NotImplementedException();
}
- internal abstract class MessageDescriptor(string format, Emoji emoji, LogLevel level, EventId id)
+ internal abstract class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id)
{
private static int s_id;
private static ImmutableDictionary s_descriptors = [];
- public string Format { get; } = format;
+ public string? Format { get; } = format;
public Emoji Emoji { get; } = emoji;
public LogLevel Level { get; } = level;
public EventId Id { get; } = id;
@@ -153,7 +154,14 @@ private static MessageDescriptor Create(string format, Emoji emoji
private static MessageDescriptor Create(LogEvent logEvent, Emoji emoji)
=> Create(logEvent.Id, logEvent.Message, emoji, logEvent.Level);
- private static MessageDescriptor Create(EventId id, string format, Emoji emoji, LogLevel level)
+ ///
+ /// Creates a descriptor that's only used for notifications not displayed to the user.
+ /// These can be used for testing or for custom loggers (e.g. Aspire status reporting).
+ ///
+ private static MessageDescriptor CreateNotification()
+ => Create(new EventId(++s_id), format: null, Emoji.Default, LogLevel.None);
+
+ private static MessageDescriptor Create(EventId id, string? format, Emoji emoji, LogLevel level)
{
var descriptor = new MessageDescriptor(format, emoji, level, id);
s_descriptors = s_descriptors.Add(id, descriptor);
@@ -176,16 +184,20 @@ public static MessageDescriptor GetDescriptor(EventId id)
// predefined messages used for testing:
public static readonly MessageDescriptor CommandDoesNotSupportHotReload = Create("Command '{0}' does not support Hot Reload.", Emoji.HotReload, LogLevel.Debug);
public static readonly MessageDescriptor HotReloadDisabledByCommandLineSwitch = Create("Hot Reload disabled by command line switch.", Emoji.HotReload, LogLevel.Debug);
- public static readonly MessageDescriptor HotReloadSessionStarting = Create("Hot reload session starting.", Emoji.HotReload, LogLevel.None);
+ public static readonly MessageDescriptor HotReloadSessionStartingNotification = CreateNotification();
public static readonly MessageDescriptor HotReloadSessionStarted = Create("Hot reload session started.", Emoji.HotReload, LogLevel.Debug);
public static readonly MessageDescriptor ProjectsRebuilt = Create("Projects rebuilt ({0})", Emoji.HotReload, LogLevel.Debug);
public static readonly MessageDescriptor ProjectsRestarted = Create("Projects restarted ({0})", Emoji.HotReload, LogLevel.Debug);
+ public static readonly MessageDescriptor> RestartingProjectsNotification = CreateNotification>();
+ public static readonly MessageDescriptor ProjectRestarting = Create("Restarting ...", Emoji.Watch, LogLevel.Debug);
+ public static readonly MessageDescriptor ProjectRestarted = Create("Restarted", Emoji.Watch, LogLevel.Debug);
public static readonly MessageDescriptor ProjectDependenciesDeployed = Create("Project dependencies deployed ({0})", Emoji.HotReload, LogLevel.Debug);
public static readonly MessageDescriptor FixBuildError = Create("Fix the error to continue or press Ctrl+C to exit.", Emoji.Watch, LogLevel.Warning);
public static readonly MessageDescriptor WaitingForChanges = Create("Waiting for changes", Emoji.Watch, LogLevel.Information);
public static readonly MessageDescriptor<(string, string, int)> LaunchedProcess = Create<(string, string, int)>("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, LogLevel.Debug);
public static readonly MessageDescriptor ManagedCodeChangesApplied = Create("C# and Razor changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information);
public static readonly MessageDescriptor StaticAssetsChangesApplied = Create("Static asset changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information);
+ public static readonly MessageDescriptor> ChangesAppliedToProjectsNotification = CreateNotification>();
public static readonly MessageDescriptor SendingUpdateBatch = Create(LogEvents.SendingUpdateBatch, Emoji.HotReload);
public static readonly MessageDescriptor UpdateBatchCompleted = Create(LogEvents.UpdateBatchCompleted, Emoji.HotReload);
public static readonly MessageDescriptor UpdateBatchFailed = Create(LogEvents.UpdateBatchFailed, Emoji.HotReload);
@@ -255,29 +267,40 @@ public static MessageDescriptor GetDescriptor(EventId id)
public static readonly MessageDescriptor ApplicationKind_WebSockets = Create("Application kind: WebSockets.", Emoji.Default, LogLevel.Debug);
public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug);
public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace);
- public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Information);
- public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Information);
- public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Information);
+ public static readonly MessageDescriptor LoadingProjects = Create("Loading projects ...", Emoji.Watch, LogLevel.Information);
+ public static readonly MessageDescriptor<(int, double)> LoadedProjects = Create<(int, double)>("Loaded {0} project(s) in {1:0.0}s.", Emoji.Watch, LogLevel.Information);
+ public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Debug);
+ public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Debug);
+ public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Debug);
+ public static readonly MessageDescriptor> BuildStartedNotification = CreateNotification>();
+ public static readonly MessageDescriptor<(IEnumerable projects, bool success)> BuildCompletedNotification = CreateNotification<(IEnumerable projects, bool success)>();
}
- internal sealed class MessageDescriptor(string format, Emoji emoji, LogLevel level, EventId id)
- : MessageDescriptor(VerifyFormat(format), emoji, level, id)
+ internal sealed class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id)
+ : MessageDescriptor(VerifyFormat(format, level), emoji, level, id)
{
- private static string VerifyFormat(string format)
+ private static string? VerifyFormat(string? format, LogLevel level)
{
+ Debug.Assert(format is null == level is LogLevel.None);
#if DEBUG
- var actualArity = format.Count(c => c == '{');
- var expectedArity = typeof(TArgs) == typeof(None) ? 0
- : typeof(TArgs).IsAssignableTo(typeof(ITuple)) ? typeof(TArgs).GenericTypeArguments.Length
- : 1;
+ if (format != null)
+ {
+ var actualArity = format.Count(c => c == '{');
+ var expectedArity = typeof(TArgs) == typeof(None) ? 0
+ : typeof(TArgs).IsAssignableTo(typeof(ITuple)) ? typeof(TArgs).GenericTypeArguments.Length
+ : 1;
- Debug.Assert(actualArity == expectedArity, $"Arguments of format string '{format}' do not match the specified type: {typeof(TArgs)} (actual arity: {actualArity}, expected arity: {expectedArity})");
+ Debug.Assert(actualArity == expectedArity, $"Arguments of format string '{format}' do not match the specified type: {typeof(TArgs)} (actual arity: {actualArity}, expected arity: {expectedArity})");
+ }
#endif
return format;
}
public string GetMessage(TArgs args)
- => Id.Id == 0 ? Format : string.Format(Format, LogEvents.GetArgumentValues(args));
+ {
+ Debug.Assert(Format != null);
+ return Id.Id == 0 ? Format : string.Format(Format, LogEvents.GetArgumentValues(args));
+ }
}
internal interface IProcessOutputReporter
diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs
index 32eed20213ff..0e6678dca900 100644
--- a/src/Dotnet.Watch/dotnet-watch/Program.cs
+++ b/src/Dotnet.Watch/dotnet-watch/Program.cs
@@ -21,6 +21,7 @@ internal sealed class Program(
EnvironmentOptions environmentOptions)
{
public const string LogComponentName = nameof(Program);
+ private const string LogMessagePrefix = "dotnet watch";
public static async Task Main(string[] args)
{
@@ -39,16 +40,12 @@ public static async Task Main(string[] args)
MSBuildLocator.RegisterMSBuildPath(sdkRootDirectory);
+ var environmentOptions = EnvironmentOptions.FromEnvironment(sdkRootDirectory, LogMessagePrefix);
+
// Register listeners that load Roslyn-related assemblies from the `Roslyn/bincore` directory.
RegisterAssemblyResolutionEvents(sdkRootDirectory);
-
- var processPath = Environment.ProcessPath;
- Debug.Assert(processPath != null);
-
- var environmentOptions = EnvironmentOptions.FromEnvironment(processPath);
-
// msbuild tasks depend on host path variable:
- Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, environmentOptions.MuxerPath);
+ Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, environmentOptions.GetMuxerPath());
var program = TryCreate(
args,
@@ -73,7 +70,8 @@ public static async Task Main(string[] args)
private static Program? TryCreate(IReadOnlyList args, IConsole console, EnvironmentOptions environmentOptions, out int errorCode)
{
- var parsingLoggerFactory = new LoggerFactory(new ConsoleReporter(console, environmentOptions.SuppressEmojis), environmentOptions.CliLogLevel ?? LogLevel.Information);
+ var reporter = new ConsoleReporter(console, environmentOptions.LogMessagePrefix, environmentOptions.SuppressEmojis);
+ var parsingLoggerFactory = new LoggerFactory(reporter, environmentOptions.CliLogLevel ?? LogLevel.Information);
var options = CommandLineOptions.Parse(args, parsingLoggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName), console.Out, out errorCode);
if (options == null)
{
@@ -81,14 +79,12 @@ public static async Task Main(string[] args)
return null;
}
- var logLevel = environmentOptions.CliLogLevel ?? options.GlobalOptions.LogLevel;
- var reporter = new ConsoleReporter(console, environmentOptions.SuppressEmojis);
- var loggerFactory = new LoggerFactory(reporter, logLevel);
+ var loggerFactory = new LoggerFactory(reporter, environmentOptions.CliLogLevel ?? options.GlobalOptions.LogLevel);
return TryCreate(options, console, environmentOptions, loggerFactory, reporter, out errorCode);
}
// internal for testing
- internal static Program? TryCreate(CommandLineOptions options, IConsole console, EnvironmentOptions environmentOptions, LoggerFactory loggerFactory, IProcessOutputReporter processOutputReporter, out int errorCode)
+ internal static Program? TryCreate(CommandLineOptions options, IConsole console, EnvironmentOptions environmentOptions, ILoggerFactory loggerFactory, IProcessOutputReporter processOutputReporter, out int errorCode)
{
var logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName);
diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs b/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs
index 62c6f6e894fe..650aa1b16ff3 100644
--- a/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs
+++ b/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs
@@ -50,7 +50,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke
var processSpec = new ProcessSpec
{
- Executable = context.EnvironmentOptions.MuxerPath,
+ Executable = context.EnvironmentOptions.GetMuxerPath(),
WorkingDirectory = context.EnvironmentOptions.WorkingDirectory,
IsUserApplication = true,
Arguments = buildEvaluator.GetProcessArguments(iteration),
diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs
index 5a023d529d73..7217ffcf5366 100644
--- a/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs
+++ b/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs
@@ -55,7 +55,7 @@ internal sealed class EvaluationResult(IReadOnlyDictionary fil
var processSpec = new ProcessSpec
{
- Executable = environmentOptions.MuxerPath,
+ Executable = environmentOptions.GetMuxerPath(),
WorkingDirectory = projectDir,
IsUserApplication = false,
Arguments = arguments,
diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif
index 07aaa4edfbba..498b1e3a5e93 100644
--- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif
+++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif
@@ -7092,4 +7092,4 @@
}
}
]
-}
+}
\ No newline at end of file
diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs
index 4ecca0d7bef7..9f38f3af4545 100644
--- a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs
+++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs
@@ -1,38 +1,39 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-#nullable disable
-
using System.Diagnostics;
-using System.Threading.Tasks.Dataflow;
+using System.Threading.Channels;
using Microsoft.NET.TestFramework.Utilities;
-using Xunit.Abstractions;
+using Xunit;
namespace Microsoft.DotNet.Watch.UnitTests
{
- internal class AwaitableProcess(ITestOutputHelper logger) : IDisposable
+ internal sealed class AwaitableProcess : IAsyncDisposable
{
// cancel just before we hit timeout used on CI (XUnitWorkItemTimeout value in sdk\test\UnitTests.proj)
private static readonly TimeSpan s_timeout = Environment.GetEnvironmentVariable("HELIX_WORK_ITEM_TIMEOUT") is { } value
- ? TimeSpan.Parse(value).Subtract(TimeSpan.FromSeconds(10)) : TimeSpan.FromMinutes(1);
-
- private readonly object _testOutputLock = new();
+ ? TimeSpan.Parse(value).Subtract(TimeSpan.FromSeconds(10)) : TimeSpan.FromMinutes(10);
private readonly List _lines = [];
- private readonly BufferBlock _source = new();
- private Process _process;
- private bool _disposed;
- public IEnumerable Output => _lines;
- public int Id => _process.Id;
- public Process Process => _process;
+ private CancellationTokenSource? _disposalCompletionSource = new();
+ private readonly CancellationTokenSource _outputCompletionSource = new();
- public void Start(ProcessStartInfo processStartInfo)
+ private readonly Channel _outputChannel = Channel.CreateUnbounded(new()
{
- if (_process != null)
- {
- throw new InvalidOperationException("Already started");
- }
+ SingleReader = true,
+ SingleWriter = false
+ });
+
+ public int Id { get; }
+ public Process Process { get; }
+ public DebugTestOutputLogger Logger { get; }
+
+ private readonly Task _processExitAwaiter;
+
+ public AwaitableProcess(ProcessStartInfo processStartInfo, DebugTestOutputLogger logger)
+ {
+ Logger = logger;
if (!processStartInfo.UseShellExecute && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
@@ -49,141 +50,169 @@ public void Start(ProcessStartInfo processStartInfo)
processStartInfo.StandardOutputEncoding = Encoding.UTF8;
processStartInfo.StandardErrorEncoding = Encoding.UTF8;
- _process = new Process
+ Process = new Process
{
EnableRaisingEvents = true,
StartInfo = processStartInfo,
};
- _process.OutputDataReceived += OnData;
- _process.ErrorDataReceived += OnData;
- _process.Exited += OnExit;
+ Process.OutputDataReceived += OnData;
+ Process.ErrorDataReceived += OnData;
+
+ Process.Start();
- WriteTestOutput($"{DateTime.Now}: starting process: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'");
- _process.Start();
- _process.BeginErrorReadLine();
- _process.BeginOutputReadLine();
- WriteTestOutput($"{DateTime.Now}: process started: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'");
+ Process.BeginErrorReadLine();
+ Process.BeginOutputReadLine();
+
+ try
+ {
+ Id = Process.Id;
+ }
+ catch
+ {
+ }
+
+ _processExitAwaiter = WaitForProcessExitAsync();
}
+ public IEnumerable Output
+ => _lines;
+
public void ClearOutput()
=> _lines.Clear();
- public async Task GetOutputLineAsync(Predicate success, Predicate failure)
+ public async Task WaitForProcessExitAsync()
{
- using var cancellationOnFailure = new CancellationTokenSource();
-
- if (!Debugger.IsAttached)
- {
- cancellationOnFailure.CancelAfter(s_timeout);
- }
-
- var failedLineCount = 0;
- while (!_source.Completion.IsCompleted && failedLineCount == 0)
+ while (true)
{
try
{
- while (await _source.OutputAvailableAsync(cancellationOnFailure.Token))
+ if (Process.HasExited)
{
- var line = await _source.ReceiveAsync(cancellationOnFailure.Token);
- _lines.Add(line);
- if (success(line))
- {
- return line;
- }
-
- if (failure(line))
- {
- if (failedLineCount == 0)
- {
- // Limit the time to collect remaining output after a failure to avoid hangs:
- cancellationOnFailure.CancelAfter(TimeSpan.FromSeconds(1));
- }
-
- if (failedLineCount > 100)
- {
- break;
- }
-
- failedLineCount++;
- }
+ break;
}
}
- catch (OperationCanceledException) when (failedLineCount > 0)
+ catch
{
break;
}
- }
- return null;
- }
+ using var iterationCancellationSource = new CancellationTokenSource();
+ iterationCancellationSource.CancelAfter(TimeSpan.FromSeconds(1));
- public async Task> GetAllOutputLinesAsync(CancellationToken cancellationToken)
- {
- var lines = new List();
- while (!_source.Completion.IsCompleted)
- {
- while (await _source.OutputAvailableAsync(cancellationToken))
+ try
+ {
+ await Process.WaitForExitAsync(iterationCancellationSource.Token);
+ break;
+ }
+ catch
{
- lines.Add(await _source.ReceiveAsync(cancellationToken));
}
}
- return lines;
+
+ Logger.Log($"Process {Id} exited");
+ _outputCompletionSource.Cancel();
}
- private void OnData(object sender, DataReceivedEventArgs args)
+ public async Task GetRequiredOutputLineAsync(Predicate selector)
{
- var line = args.Data ?? string.Empty;
- if (line.StartsWith("\x1b]"))
- {
- // strip terminal logger progress indicators from line
- line = line.StripTerminalLoggerProgressIndicators();
- }
+ var line = await GetOutputLineAsync(selector);
- WriteTestOutput(line);
- _source.Post(line);
+ // process terminated without producing required output
+ Assert.NotNull(line);
+
+ return line;
}
- private void WriteTestOutput(string text)
+ public async Task GetOutputLineAsync(Predicate selector)
{
- lock (_testOutputLock)
+ var disposalCompletionSource = _disposalCompletionSource;
+ ObjectDisposedException.ThrowIf(disposalCompletionSource == null, this);
+
+ using var timeoutCancellation = new CancellationTokenSource();
+ if (!Debugger.IsAttached)
+ {
+ timeoutCancellation.CancelAfter(s_timeout);
+ }
+
+ using var outputReadCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ _outputCompletionSource.Token,
+ disposalCompletionSource.Token,
+ timeoutCancellation.Token);
+
+ if (!Debugger.IsAttached)
{
- if (!_disposed)
+ outputReadCancellation.CancelAfter(s_timeout);
+ }
+
+ try
+ {
+ while (!outputReadCancellation.IsCancellationRequested)
{
- logger.WriteLine(text);
+ var line = await _outputChannel.Reader.ReadAsync(outputReadCancellation.Token);
+ _lines.Add(line);
+ if (selector(line))
+ {
+ return line;
+ }
}
}
- }
+ catch (OperationCanceledException)
+ {
+ if (timeoutCancellation.Token.IsCancellationRequested)
+ {
+ Assert.Fail($"Output not found within {s_timeout}");
+ }
- private void OnExit(object sender, EventArgs args)
- {
- // Wait to ensure the process has exited and all output consumed
- _process.WaitForExit();
+ if (disposalCompletionSource.Token.IsCancellationRequested)
+ {
+ Assert.Fail($"Test disposed while waiting for output");
+ }
+ }
- // Signal test methods waiting on all process output to be completed:
- _source.Complete();
+ // Read the remaining output that may have been written to
+ // the channel but not read yet when the process exited.
+ while (_outputChannel.Reader.TryRead(out var line))
+ {
+ _lines.Add(line);
+ if (selector(line))
+ {
+ return line;
+ }
+ }
+
+ return null;
}
- public void Dispose()
- {
- _source.Complete();
+ public Task WaitUntilOutputCompleted()
+ => GetOutputLineAsync(_ => false);
- lock (_testOutputLock)
+ private void OnData(object sender, DataReceivedEventArgs args)
+ {
+ var line = args.Data ?? string.Empty;
+ if (line.StartsWith("\x1b]"))
{
- _disposed = true;
+ // strip terminal logger progress indicators from line
+ line = line.StripTerminalLoggerProgressIndicators();
}
- if (_process == null)
- {
- return;
- }
+ Logger.WriteLine(line);
+
+ Assert.True(_outputChannel.Writer.TryWrite(line));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ var disposalCompletionSource = Interlocked.Exchange(ref _disposalCompletionSource, null);
+ ObjectDisposedException.ThrowIf(disposalCompletionSource == null, this);
+ disposalCompletionSource.Cancel();
- _process.ErrorDataReceived -= OnData;
- _process.OutputDataReceived -= OnData;
+ Process.ErrorDataReceived -= OnData;
+ Process.OutputDataReceived -= OnData;
try
{
- _process.CancelErrorRead();
+ Process.CancelErrorRead();
}
catch
{
@@ -191,7 +220,7 @@ public void Dispose()
try
{
- _process.CancelOutputRead();
+ Process.CancelOutputRead();
}
catch
{
@@ -199,14 +228,18 @@ public void Dispose()
try
{
- _process.Kill(entireProcessTree: false);
+ Process.Kill(entireProcessTree: true);
}
catch
{
}
- _process.Dispose();
- _process = null;
+ // ensure process has exited
+ await _processExitAwaiter;
+
+ Process.Dispose();
+
+ _outputCompletionSource.Dispose();
}
}
}
diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs
index a6f51843881f..411cbb360967 100644
--- a/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs
+++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs
@@ -17,10 +17,19 @@ public void Log(string message, [CallerFilePath] string? testPath = null, [Calle
public void WriteLine(string message)
{
Debug.WriteLine(message);
- logger.WriteLine(message);
+
+ try
+ {
+ logger.WriteLine(message);
+ }
+ catch (InvalidOperationException)
+ {
+ // test output might have been disposed
+ }
+
OnMessage?.Invoke(message);
}
public void WriteLine(string format, params object[] args)
- => WriteLine(string.Format(format, args));
+ => WriteLine(string.Format(format, args));
}
diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs
index e388919a4672..be7b5f747c80 100644
--- a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs
+++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs
@@ -19,6 +19,11 @@ internal class TestLogger(ITestOutputHelper? output = null) : ILogger
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
+ if (logLevel == LogLevel.None)
+ {
+ return;
+ }
+
var message = $"[{logLevel}] {formatter(state, exception)}";
lock (Guard)
diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs
new file mode 100644
index 000000000000..9ec72d8412cb
--- /dev/null
+++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+using Microsoft.NET.TestFramework;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.Watch.UnitTests;
+
+public abstract class WatchSdkTest(ITestOutputHelper logger)
+ : SdkTest(new DebugTestOutputLogger(logger))
+{
+ public TestAssetsManager TestAssets
+ => _testAssetsManager;
+
+ public DebugTestOutputLogger Logger
+ => (DebugTestOutputLogger)base.Log;
+
+ public new void Log(string message, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
+ => Logger.Log(message, testPath, testLine);
+
+ public void UpdateSourceFile(string path, string text, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
+ {
+ var existed = File.Exists(path);
+ WriteAllText(path, text);
+ Log($"File '{path}' " + (existed ? "updated" : "added"), testPath, testLine);
+ }
+
+ public void UpdateSourceFile(string path, Func contentTransform, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
+ => UpdateSourceFile(path, contentTransform(File.ReadAllText(path, Encoding.UTF8)), testPath, testLine);
+
+ ///
+ /// Replacement for , which fails to write to hidden file
+ ///
+ public static void WriteAllText(string path, string text)
+ {
+ using var stream = File.Open(path, FileMode.OpenOrCreate);
+
+ using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true))
+ {
+ writer.Write(text);
+ }
+
+ // truncate the rest of the file content:
+ stream.SetLength(stream.Position);
+ }
+
+ public void UpdateSourceFile(string path)
+ => UpdateSourceFile(path, content => content);
+}
diff --git a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs
similarity index 50%
rename from test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs
rename to test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs
index 5aca2df2b4c7..f858d5026a27 100644
--- a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs
+++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs
@@ -1,38 +1,45 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-#nullable disable
-
+using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
+using Microsoft.DotNet.Cli.Utils;
+using Microsoft.NET.TestFramework;
+using Xunit;
+using Xunit.Abstractions;
namespace Microsoft.DotNet.Watch.UnitTests
{
- internal sealed class WatchableApp(DebugTestOutputLogger logger) : IDisposable
+ internal sealed class WatchableApp(
+ ITestOutputHelper logger,
+ string executablePath,
+ string commandName,
+ IEnumerable commandArguments)
+ : IAsyncDisposable
{
- // Test apps should output this message as soon as they start running:
- private const string StartedMessage = "Started";
-
- // Test apps should output this message as soon as they exit:
- private const string ExitingMessage = "Exiting";
+ public static WatchableApp CreateDotnetWatchApp(ITestOutputHelper logger)
+ => new(logger, TestContext.Current.ToolsetUnderTest.DotNetHostPath, "watch", ["-bl"]);
- private const string WatchErrorOutputEmoji = "❌";
- private const string WatchFileChanged = "dotnet watch ⌚ File changed:";
+ public DebugTestOutputLogger Logger { get; } = new DebugTestOutputLogger(logger);
public TestFlags TestFlags { get; private set; }
- public DebugTestOutputLogger Logger => logger;
+ private AwaitableProcess? _process;
- public AwaitableProcess Process { get; private set; }
+ public AwaitableProcess Process
+ {
+ get => _process ?? throw new InvalidOperationException("Process has not been started yet.");
+ }
- public List DotnetWatchArgs { get; } = ["--verbose", "-bl"];
+ public List WatchArgs { get; } = [.. commandArguments];
public Dictionary EnvironmentVariables { get; } = [];
public void SuppressVerboseLogging()
{
- // remove default --verbose and -bl args
- DotnetWatchArgs.Clear();
+ // remove default -bl args
+ WatchArgs.Clear();
// override the default used for testing ("trace"):
EnvironmentVariables.Add("DOTNET_CLI_CONTEXT_VERBOSE", "");
@@ -44,7 +51,7 @@ public void AssertOutputContains(string message)
public void AssertOutputContains(Regex pattern)
=> AssertEx.ContainsPattern(pattern, Process.Output);
- public void AssertOutputContains(MessageDescriptor descriptor, string projectDisplay = null)
+ public void AssertOutputContains(MessageDescriptor descriptor, string? projectDisplay = null)
=> AssertOutputContains(GetPattern(descriptor, projectDisplay, out _));
public void AssertOutputDoesNotContain(string message)
@@ -53,10 +60,10 @@ public void AssertOutputDoesNotContain(string message)
public void AssertOutputDoesNotContain(Regex pattern)
=> AssertEx.DoesNotContainPattern(pattern, Process.Output);
- public void AssertOutputDoesNotContain(MessageDescriptor descriptor, string projectDisplay = null)
+ public void AssertOutputDoesNotContain(MessageDescriptor descriptor, string? projectDisplay = null)
=> AssertOutputDoesNotContain(GetPattern(descriptor, projectDisplay, out _));
- private static Regex GetPattern(MessageDescriptor descriptor, string projectDisplay, out string patternDisplay)
+ private static Regex GetPattern(MessageDescriptor descriptor, string? projectDisplay, out string patternDisplay)
{
var prefix = projectDisplay != null ? $"[{projectDisplay}] " : "";
var pattern = new Regex(Regex.Replace(Regex.Escape(prefix + descriptor.Format), @"\\\{[0-9]+\}", ".*"));
@@ -64,100 +71,99 @@ private static Regex GetPattern(MessageDescriptor descriptor, string projectDisp
return pattern;
}
- private void LogFoundOutput(string pattern, string testPath, int testLine)
+ private void LogFoundOutput(string pattern, string? testPath, int testLine)
=> Logger.Log($"Found output matching: '{pattern}'", testPath, testLine);
- private void LogWaitingForOutput(string pattern, string testPath, int testLine)
+ private void LogWaitingForOutput(string pattern, string? testPath, int testLine)
=> Logger.Log($"Waiting for output matching: '{pattern}'", testPath, testLine);
- public async ValueTask WaitUntilOutputContains(string text, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
+ public async ValueTask WaitUntilOutputContains(string text, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
{
- if (!Process.Output.Any(line => line.Contains(text)))
+ var matchingLine = Process.Output.FirstOrDefault(line => line.Contains(text));
+ if (matchingLine == null)
{
LogWaitingForOutput(text, testPath, testLine);
- _ = await WaitForOutputLineMatching(line => line.Contains(text));
+ matchingLine = await Process.GetRequiredOutputLineAsync(line => line.Contains(text));
}
LogFoundOutput(text, testPath, testLine);
+ return matchingLine;
}
- public async ValueTask WaitUntilOutputContains(Regex pattern, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
+ public async ValueTask WaitUntilOutputContains(Regex pattern, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
{
var patternDisplay = pattern.ToString();
- if (!Process.Output.Any(line => pattern.IsMatch(line)))
+ var matchingLine = Process.Output.FirstOrDefault(pattern.IsMatch);
+ if (matchingLine == null)
{
LogWaitingForOutput(patternDisplay, testPath, testLine);
- _ = await WaitForOutputLineMatching(line => pattern.IsMatch(line));
+ matchingLine = await Process.GetRequiredOutputLineAsync(pattern.IsMatch);
}
LogFoundOutput(patternDisplay, testPath, testLine);
+ return matchingLine;
}
- public async ValueTask WaitUntilOutputContains(MessageDescriptor descriptor, string projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string testPath = null)
+ public async ValueTask WaitUntilOutputContains(MessageDescriptor descriptor, string? projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string? testPath = null)
{
var pattern = GetPattern(descriptor, projectDisplay, out var patternDisplay);
+ var matchingLine = Process.Output.FirstOrDefault(pattern.IsMatch);
- if (!Process.Output.Any(line => pattern.IsMatch(line)))
+ if (matchingLine == null)
{
LogWaitingForOutput(patternDisplay, testPath, testLine);
- _ = await WaitForOutputLineMatching(line => pattern.IsMatch(line));
+ matchingLine = await Process.GetRequiredOutputLineAsync(line => pattern.IsMatch(line));
}
LogFoundOutput(patternDisplay, testPath, testLine);
+ return matchingLine;
}
- public Task WaitForOutputLineContaining(string text, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
+ public Task WaitForOutputLineContaining(string text, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
{
LogWaitingForOutput(text, testPath, testLine);
- var line = Process.GetOutputLineAsync(success: line => line.Contains(text), failure: _ => false);
+ var line = Process.GetRequiredOutputLineAsync(line => line.Contains(text));
LogFoundOutput(text, testPath, testLine);
return line;
}
- public Task WaitForOutputLineContaining(MessageDescriptor descriptor, string projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string testPath = null)
+ public Task WaitForOutputLineContaining(MessageDescriptor descriptor, string? projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string? testPath = null)
{
var pattern = GetPattern(descriptor, projectDisplay, out var patternDisplay);
LogWaitingForOutput(patternDisplay, testPath, testLine);
- var line = Process.GetOutputLineAsync(success: line => pattern.IsMatch(line), failure: _ => false);
+ var line = Process.GetRequiredOutputLineAsync(line => pattern.IsMatch(line));
LogFoundOutput(patternDisplay, testPath, testLine);
return line;
}
- public Task WaitForOutputLineContaining(Regex pattern, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
+ public Task WaitForOutputLineContaining(Regex pattern, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
{
var patternDisplay = pattern.ToString();
LogWaitingForOutput(patternDisplay, testPath, testLine);
- var line = Process.GetOutputLineAsync(success: line => pattern.IsMatch(line), failure: _ => false);
+ var line = Process.GetRequiredOutputLineAsync(line => pattern.IsMatch(line));
LogFoundOutput(patternDisplay, testPath, testLine);
return line;
}
- private Task WaitForOutputLineMatching(Predicate predicate)
- => Process.GetOutputLineAsync(success: predicate, failure: _ => false);
-
///
/// Asserts that the watched process outputs a line starting with and returns the remainder of that line.
///
- public async Task AssertOutputLineStartsWith(string expectedPrefix, Predicate failure = null, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
+ public async Task AssertOutputLineStartsWith(string expectedPrefix, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0)
{
var display = $"^{expectedPrefix}.*";
LogWaitingForOutput(display, testPath, testLine);
- var line = await Process.GetOutputLineAsync(
- success: line => line.StartsWith(expectedPrefix, StringComparison.Ordinal),
- failure: failure ?? new Predicate(line => line.Contains(WatchErrorOutputEmoji, StringComparison.Ordinal)));
+ var line = await Process.GetOutputLineAsync(line => line.StartsWith(expectedPrefix, StringComparison.Ordinal));
if (line == null)
{
- Assert.Fail(failure != null
- ? "Encountered failure condition"
- : $"Failed to find expected prefix: '{expectedPrefix}'");
+ Assert.Fail($"Failed to find expected prefix: '{expectedPrefix}'");
}
else
{
@@ -172,16 +178,58 @@ public async Task AssertOutputLineStartsWith(string expectedPrefix, Pred
public async Task AssertOutputLineEquals(string expectedLine)
=> Assert.Equal("", await AssertOutputLineStartsWith(expectedLine));
- public Task AssertStarted()
- => AssertOutputLineEquals(StartedMessage);
+ public ProcessStartInfo GetProcessStartInfo(string workingDirectory, string testOutputPath, IEnumerable arguments, TestFlags testFlags)
+ {
+ var args = new List()
+ {
+ commandName
+ };
+
+ args.AddRange(WatchArgs);
+ args.AddRange(arguments);
+
+ var info = new ProcessStartInfo
+ {
+ FileName = executablePath,
+ WorkingDirectory = workingDirectory,
+ Arguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(args),
+ UseShellExecute = false,
+ RedirectStandardInput = false,
+ };
+
+ // FileSystemWatcher is unreliable. Use polling for testing to avoid flakiness.
+ info.Environment.Add("DOTNET_USE_POLLING_FILE_WATCHER", "true");
+ info.Environment.Add("__DOTNET_WATCH_TEST_FLAGS", testFlags.ToString());
+ info.Environment.Add("__DOTNET_WATCH_TEST_OUTPUT_DIR", testOutputPath);
+ info.Environment.Add("Microsoft_CodeAnalysis_EditAndContinue_LogDir", testOutputPath);
+ info.Environment.Add("DOTNET_CLI_CONTEXT_VERBOSE", "trace");
+
+ // suppress all timeouts:
+ info.Environment.Add("DCP_IDE_REQUEST_TIMEOUT_SECONDS", "100000");
+ info.Environment.Add("DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS", "100000");
+ info.Environment.Add("DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS", "100000");
+ info.Environment.Add("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "1");
+ info.Environment.Add("ASPIRE_WATCH_PIPE_CONNECTION_TIMEOUT_SECONDS", "100000");
+
+ // override defaults:
+ foreach (var (name, value) in EnvironmentVariables)
+ {
+ info.Environment[name] = value;
+ }
- public Task AssertFileChanged()
- => AssertOutputLineStartsWith(WatchFileChanged);
+ TestContext.Current.AddTestEnvironmentVariables(info.Environment);
- public Task AssertExiting()
- => AssertOutputLineStartsWith(ExitingMessage);
+ return info;
+ }
- public void Start(TestAsset asset, IEnumerable arguments, string relativeProjectDirectory = null, string workingDirectory = null, TestFlags testFlags = TestFlags.RunningAsTest)
+ public void Start(
+ TestAsset asset,
+ IEnumerable arguments,
+ string? relativeProjectDirectory = null,
+ string? workingDirectory = null,
+ TestFlags testFlags = TestFlags.RunningAsTest,
+ [CallerFilePath] string? testPath = null,
+ [CallerLineNumber] int testLine = 0)
{
if (testFlags != TestFlags.None)
{
@@ -190,43 +238,25 @@ public void Start(TestAsset asset, IEnumerable arguments, string relativ
var projectDirectory = (relativeProjectDirectory != null) ? Path.Combine(asset.Path, relativeProjectDirectory) : asset.Path;
- var commandSpec = new DotnetCommand(Logger, ["watch", .. DotnetWatchArgs, .. arguments])
- {
- WorkingDirectory = workingDirectory ?? projectDirectory,
- };
-
var testOutputPath = asset.GetWatchTestOutputPath();
Directory.CreateDirectory(testOutputPath);
- // FileSystemWatcher is unreliable. Use polling for testing to avoid flakiness.
- commandSpec.WithEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER", "true");
+ var processStartInfo = GetProcessStartInfo(workingDirectory ?? projectDirectory, testOutputPath, arguments, testFlags);
- commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS", testFlags.ToString());
- commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR", testOutputPath);
- commandSpec.WithEnvironmentVariable("Microsoft_CodeAnalysis_EditAndContinue_LogDir", testOutputPath);
- commandSpec.WithEnvironmentVariable("DOTNET_CLI_CONTEXT_VERBOSE", "trace");
+ _process = new AwaitableProcess(processStartInfo, Logger);
- // suppress all timeouts:
- commandSpec.WithEnvironmentVariable("DCP_IDE_REQUEST_TIMEOUT_SECONDS", "100000");
- commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS", "100000");
- commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS", "100000");
- commandSpec.WithEnvironmentVariable("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "1");
-
- foreach (var env in EnvironmentVariables)
- {
- commandSpec.WithEnvironmentVariable(env.Key, env.Value);
- }
-
- var processStartInfo = commandSpec.GetProcessStartInfo();
- Process = new AwaitableProcess(Logger);
- Process.Start(processStartInfo);
+ Logger.Log($"Process {Process.Id} started: '{processStartInfo.FileName} {processStartInfo.Arguments}'", testPath, testLine);
TestFlags = testFlags;
}
- public void Dispose()
+ public async ValueTask DisposeAsync()
{
- Process?.Dispose();
+ var process = _process;
+ if (process != null)
+ {
+ await process.DisposeAsync();
+ }
}
public void SendControlC()
diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs
new file mode 100644
index 000000000000..b7d9c7f69059
--- /dev/null
+++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs
@@ -0,0 +1,145 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch.UnitTests;
+
+public class AspireHostLauncherCliTests
+{
+ [Fact]
+ public void RequiredSdkOption()
+ {
+ // --sdk option is missing
+ var args = new[] { "host", "--entrypoint", "proj", "a", "b" };
+ var launcher = AspireLauncher.TryCreate(args);
+ Assert.Null(launcher);
+ }
+
+ [Fact]
+ public void RequiredEntryPointOption()
+ {
+ // --entrypoint option is missing
+ var args = new[] { "host", "--sdk", "sdk", "--verbose" };
+ var launcher = AspireLauncher.TryCreate(args);
+ Assert.Null(launcher);
+ }
+
+ [Fact]
+ public void ProjectAndSdkPaths()
+ {
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myproject.csproj" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory);
+ Assert.True(launcher.EntryPoint.IsProjectFile);
+ Assert.Equal("myproject.csproj", launcher.EntryPoint.PhysicalPath);
+ Assert.Empty(launcher.ApplicationArguments);
+ Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel);
+ }
+
+ [Fact]
+ public void FilePath()
+ {
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "file.cs" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory);
+ Assert.False(launcher.EntryPoint.IsProjectFile);
+ Assert.Equal("file.cs", launcher.EntryPoint.EntryPointFilePath);
+ Assert.Empty(launcher.ApplicationArguments);
+ Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel);
+ }
+
+ [Fact]
+ public void ApplicationArguments()
+ {
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose", "a", "b" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments);
+ Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel);
+ }
+
+ [Fact]
+ public void VerboseOption()
+ {
+ // With verbose flag
+ var argsVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose" };
+ var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose));
+ Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel);
+
+ // Without verbose flag
+ var argsNotVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" };
+ var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose));
+ Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel);
+ }
+
+ [Fact]
+ public void QuietOption()
+ {
+ // With quiet flag
+ var argsQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet" };
+ var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet));
+ Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel);
+
+ // Without quiet flag
+ var argsNotQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" };
+ var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet));
+ Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel);
+ }
+
+ [Fact]
+ public void NoLaunchProfileOption()
+ {
+ // With no-launch-profile flag
+ var argsNoProfile = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--no-launch-profile" };
+ var launcherNoProfile = Assert.IsType(AspireLauncher.TryCreate(argsNoProfile));
+ Assert.False(launcherNoProfile.LaunchProfileName.HasValue);
+
+ // Without no-launch-profile flag
+ var argsDefault = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" };
+ var launcherDefault = Assert.IsType(AspireLauncher.TryCreate(argsDefault));
+ Assert.True(launcherDefault.LaunchProfileName.HasValue);
+ Assert.Null(launcherDefault.LaunchProfileName.Value);
+ }
+
+ [Fact]
+ public void LaunchProfileOption()
+ {
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--launch-profile", "MyProfile" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ Assert.True(launcher.LaunchProfileName.HasValue);
+ Assert.Equal("MyProfile", launcher.LaunchProfileName.Value);
+ }
+
+ [Fact]
+ public void ConflictingOptions()
+ {
+ // Cannot specify both --quiet and --verbose
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet", "--verbose" };
+ var launcher = AspireLauncher.TryCreate(args);
+ Assert.Null(launcher);
+ }
+
+ [Fact]
+ public void EntryPoint_MultipleValues()
+ {
+ // EntryPoint option should only accept one value; extra values become application arguments
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj1", "proj2" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ Assert.Equal("proj1", launcher.EntryPoint.ProjectOrEntryPointFilePath);
+ AssertEx.SequenceEqual(["proj2"], launcher.ApplicationArguments);
+ }
+
+ [Fact]
+ public void AllOptionsSet()
+ {
+ var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myapp.csproj", "--verbose", "--no-launch-profile", "arg1", "arg2", "arg3" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+
+ Assert.True(launcher.EntryPoint.IsProjectFile);
+ Assert.Equal("myapp.csproj", launcher.EntryPoint.PhysicalPath);
+ Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory);
+ Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel);
+ Assert.False(launcher.LaunchProfileName.HasValue);
+ AssertEx.SequenceEqual(["arg1", "arg2", "arg3"], launcher.ApplicationArguments);
+ }
+}
diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs
new file mode 100644
index 000000000000..52ac55d6cbdf
--- /dev/null
+++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs
@@ -0,0 +1,156 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.CommandLine;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.DotNet.Cli.Utils;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch.UnitTests;
+
+public class AspireHostLauncherTests
+{
+ private static AspireHostLauncher CreateLauncher(
+ string entryPointPath,
+ Optional launchProfileName = default,
+ ImmutableArray applicationArguments = default,
+ string? workingDirectory = null,
+ string? sdkDirectory = null)
+ {
+ return new AspireHostLauncher(
+ globalOptions: new GlobalOptions()
+ {
+ LogLevel = LogLevel.Information,
+ NoHotReload = false,
+ NonInteractive = true,
+ },
+ environmentOptions: new EnvironmentOptions(
+ WorkingDirectory: workingDirectory ?? "/work",
+ SdkDirectory: sdkDirectory ?? "/sdk",
+ LogMessagePrefix: AspireHostLauncher.LogMessagePrefix),
+ entryPoint: ProjectRepresentation.FromProjectOrEntryPointFilePath(entryPointPath),
+ applicationArguments: applicationArguments.IsDefault ? [] : applicationArguments,
+ launchProfileName: launchProfileName);
+ }
+
+ private static void AssertCommonProperties(ProjectOptions options, AspireHostLauncher launcher)
+ {
+ Assert.True(options.IsMainProject);
+ Assert.Equal("run", options.Command);
+ Assert.Equal(launcher.EntryPoint, options.Representation);
+ Assert.Empty(options.LaunchEnvironmentVariables);
+ }
+
+ [Fact]
+ public void GetProjectOptions_ProjectFile_UsesProjectFlag()
+ {
+ var launcher = CreateLauncher("myapp.csproj");
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.False(options.LaunchProfileName.HasValue);
+ AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_EntryPointFile_UsesFileFlag()
+ {
+ var launcher = CreateLauncher("Program.cs");
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.False(options.LaunchProfileName.HasValue);
+ AssertEx.SequenceEqual(["--file", "Program.cs", "--no-launch-profile"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_WithLaunchProfile_AddsLaunchProfileArguments()
+ {
+ var launcher = CreateLauncher("myapp.csproj", launchProfileName: "MyProfile");
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.True(options.LaunchProfileName.HasValue);
+ Assert.Equal("MyProfile", options.LaunchProfileName.Value);
+ AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "MyProfile"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_NoLaunchProfile_AddsNoLaunchProfileFlag()
+ {
+ var launcher = CreateLauncher("myapp.csproj", launchProfileName: Optional.NoValue);
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.False(options.LaunchProfileName.HasValue);
+ AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_NullLaunchProfile_UsesDefault()
+ {
+ // null value (HasValue=true) means use default launch profile - no --launch-profile or --no-launch-profile flag
+ var launcher = CreateLauncher("myapp.csproj", launchProfileName: (string?)null);
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.True(options.LaunchProfileName.HasValue);
+ Assert.Null(options.LaunchProfileName.Value);
+ AssertEx.SequenceEqual(["--project", "myapp.csproj"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_WithApplicationArguments_AppendsArguments()
+ {
+ var launcher = CreateLauncher("myapp.csproj", launchProfileName: "Profile", applicationArguments: ["arg1", "arg2"]);
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.True(options.LaunchProfileName.HasValue);
+ Assert.Equal("Profile", options.LaunchProfileName.Value);
+ AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "Profile", "arg1", "arg2"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_SetsCustomWorkingDirectory()
+ {
+ var launcher = CreateLauncher("myapp.csproj", workingDirectory: "/custom/path");
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.Equal("/custom/path", options.WorkingDirectory);
+ }
+
+ [Fact]
+ public void GetProjectOptions_EntryPointFile_WithLaunchProfileAndArguments()
+ {
+ var launcher = CreateLauncher("Program.cs", launchProfileName: "Dev", applicationArguments: ["--port", "8080"]);
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.True(options.LaunchProfileName.HasValue);
+ Assert.Equal("Dev", options.LaunchProfileName.Value);
+ AssertEx.SequenceEqual(["--file", "Program.cs", "--launch-profile", "Dev", "--port", "8080"], options.CommandArguments);
+ }
+
+ [Fact]
+ public void GetProjectOptions_NoLaunchProfile_WithApplicationArguments()
+ {
+ var launcher = CreateLauncher("myapp.csproj", launchProfileName: Optional.NoValue, applicationArguments: ["--urls", "http://localhost:5000"]);
+
+ var options = launcher.GetProjectOptions();
+
+ AssertCommonProperties(options, launcher);
+ Assert.False(options.LaunchProfileName.HasValue);
+ AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile", "--urls", "http://localhost:5000"], options.CommandArguments);
+ }
+}
diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs
new file mode 100644
index 000000000000..6a02ec6ab4c8
--- /dev/null
+++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs
@@ -0,0 +1,145 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Reflection.Metadata;
+using System.Text.Json;
+using Elfie.Serialization;
+using Xunit.Runners;
+
+namespace Microsoft.DotNet.Watch.UnitTests;
+
+public class AspireLauncherTests(ITestOutputHelper logger) : WatchSdkTest(logger)
+{
+ private WatchableApp CreateHostApp()
+ => new(
+ Logger,
+ executablePath: Path.ChangeExtension(typeof(AspireLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'),
+ commandName: "host",
+ commandArguments: ["--sdk", TestContext.Current.ToolsetUnderTest.SdkFolderUnderTest]);
+
+ private WatchableApp CreateServerApp(string serverPipe)
+ => new(
+ Logger,
+ executablePath: Path.ChangeExtension(typeof(AspireLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'),
+ commandName: "server",
+ commandArguments: ["--sdk", TestContext.Current.ToolsetUnderTest.SdkFolderUnderTest, "--server", serverPipe]);
+
+ private WatchableApp CreateResourceApp(string serverPipe)
+ => new(
+ Logger,
+ executablePath: Path.ChangeExtension(typeof(AspireLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'),
+ commandName: "resource",
+ commandArguments: ["--server", serverPipe]);
+
+ [PlatformSpecificFact(TestPlatforms.Windows | TestPlatforms.Linux)] // https://github.com/dotnet/sdk/issues/53061
+ public async Task Host()
+ {
+ var testAsset = _testAssetsManager.CopyTestAsset("WatchAppWithProjectDeps")
+ .WithSource();
+
+ var projectDir = Path.Combine(testAsset.Path, "AppWithDeps");
+ var projectPath = Path.Combine(projectDir, "App.WithDeps.csproj");
+
+ await using var host = CreateHostApp();
+ host.Start(testAsset, ["--entrypoint", projectPath]);
+
+ await host.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
+ }
+
+ [PlatformSpecificFact(TestPlatforms.Windows | TestPlatforms.Linux)] // https://github.com/dotnet/sdk/issues/53061
+ public async Task ServerAndResources()
+ {
+ var testAsset = _testAssetsManager.CopyTestAsset("WatchAppMultiProc")
+ .WithSource();
+
+ var tfm = ToolsetInfo.CurrentTargetFramework;
+ var serviceDirA = Path.Combine(testAsset.Path, "ServiceA");
+ var serviceProjectA = Path.Combine(serviceDirA, "A.csproj");
+ var serviceDirB = Path.Combine(testAsset.Path, "ServiceB");
+ var serviceProjectB = Path.Combine(serviceDirB, "B.csproj");
+ var libDir = Path.Combine(testAsset.Path, "Lib");
+ var libSource = Path.Combine(libDir, "Lib.cs");
+
+ var pipeId = Guid.NewGuid();
+ var serverPipe = $"SERVER_{pipeId:N}";
+ var statusPipeName = $"STATUS_{pipeId:N}";
+ var controlPipeName = $"CONTROL_{pipeId:N}";
+
+ await using var server = CreateServerApp(serverPipe);
+ await using var serviceA = CreateResourceApp(serverPipe);
+ await using var serviceB = CreateResourceApp(serverPipe);
+
+ // resource can be started before the server, they will wait for the server to start:
+ serviceA.Start(testAsset, ["--entrypoint", serviceProjectA]);
+ serviceB.Start(testAsset, ["--entrypoint", serviceProjectB]);
+
+ using var statusCancellationSource = new CancellationTokenSource();
+ var statusReaderTask = PipeUtilities.ReadStatusEventsAsync(statusPipeName, statusCancellationSource.Token);
+
+ server.Start(testAsset,
+ [
+ "--status-pipe", statusPipeName,
+ "--control-pipe", controlPipeName,
+ "--resource", serviceProjectA,
+ "--resource", serviceProjectB,
+ ]);
+
+ await server.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
+
+ // valid code change:
+ UpdateSourceFile(libSource, """
+ using System;
+
+ public class Lib
+ {
+ public static void Common()
+ {
+ Console.WriteLine("");
+ }
+ }
+ """);
+
+ await server.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied);
+ await serviceA.WaitUntilOutputContains("");
+ await serviceB.WaitUntilOutputContains("");
+
+ server.Process.ClearOutput();
+ serviceA.Process.ClearOutput();
+ serviceB.Process.ClearOutput();
+
+ using var controlPipe = new NamedPipeServerStream(controlPipeName, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ await controlPipe.WaitForConnectionAsync();
+ using var controlPipeWriter = new StreamWriter(controlPipe) { AutoFlush = true };
+
+ // restart resource process:
+ await controlPipeWriter.WriteLineAsync(JsonSerializer.Serialize(new WatchControlCommand()
+ {
+ Type = WatchControlCommand.Types.Rebuild,
+ Projects = [serviceProjectA],
+ }));
+
+ await server.WaitUntilOutputContains("Received request to restart projects");
+ await server.WaitUntilOutputContains(MessageDescriptor.ProjectRestarting, $"A ({tfm})");
+ await server.WaitUntilOutputContains(MessageDescriptor.ProjectRestarted, $"A ({tfm})");
+
+ // initial updates are applied when the process restarts:
+ await server.WaitUntilOutputContains(MessageDescriptor.SendingUpdateBatch.GetMessage(0), $"A ({tfm})");
+
+ statusCancellationSource.Cancel();
+ var statusEvents = await statusReaderTask;
+
+ // validate that we received the expected status events from the server, ignoring the order:
+ AssertEx.SequenceEqual(
+ [
+ $"type=build_complete, projects=[{serviceProjectA};{serviceProjectB}]",
+ $"type=building, projects=[{serviceProjectA};{serviceProjectB}]",
+ $"type=hot_reload_applied, projects=[{serviceProjectA};{serviceProjectB}]",
+ $"type=process_started, projects=[{serviceProjectA}]",
+ $"type=process_started, projects=[{serviceProjectA}]",
+ $"type=process_started, projects=[{serviceProjectB}]",
+ $"type=restarting, projects=[{serviceProjectA}]",
+ ], statusEvents.Select(e => $"type={e.Type}, projects=[{string.Join(";", e.Projects.Order())}]").Order());
+ }
+}
diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs
new file mode 100644
index 000000000000..d0151c2102fb
--- /dev/null
+++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs
@@ -0,0 +1,274 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch.UnitTests;
+
+public class AspireResourceLauncherCliTests
+{
+ [Fact]
+ public void RequiredServerOption()
+ {
+ // --server option is missing
+ var args = new[] { "resource", "--entrypoint", "proj" };
+ var launcher = AspireLauncher.TryCreate(args);
+ Assert.Null(launcher);
+ }
+
+ [Fact]
+ public void RequiredEntryPointOption()
+ {
+ // --entrypoint option is missing
+ var args = new[] { "resource", "--server", "pipe1" };
+ var launcher = AspireLauncher.TryCreate(args);
+ Assert.Null(launcher);
+ }
+
+ [Fact]
+ public void MinimalRequiredOptions()
+ {
+ var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj.csproj" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ Assert.Equal("pipe1", launcher.ServerPipeName);
+ Assert.Equal("proj.csproj", launcher.EntryPoint);
+ Assert.Empty(launcher.ApplicationArguments);
+ Assert.Empty(launcher.EnvironmentVariables);
+ Assert.True(launcher.LaunchProfileName.HasValue);
+ Assert.Null(launcher.LaunchProfileName.Value);
+ Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel);
+ }
+
+ [Fact]
+ public void ApplicationArguments()
+ {
+ var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "a", "b" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments);
+ }
+
+ [Fact]
+ public void EnvironmentOption_SingleVariable()
+ {
+ var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=value" };
+ var launcher = Assert.IsType(AspireLauncher.TryCreate(args));
+ Assert.Single(launcher.EnvironmentVariables);
+ Assert.Equal("value", launcher.EnvironmentVariables["KEY"]);
+ }
+
+ [Fact]
+ public void EnvironmentOption_MultipleVariables()
+ {
+ var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY1=val1", "-e", "KEY2=val2" };
+ var launcher = Assert.IsType