diff --git a/Directory.Build.props b/Directory.Build.props
index 9c3eaf36972..8db4531fca6 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -56,6 +56,7 @@
$(MSBuildThisFileDirectory)/artifacts/bin/Aspire.Dashboard/$(Configuration)/net8.0/
+ $(MSBuildThisFileDirectory)/artifacts/bin/Microsoft.DotNet.HotReload.Watch.Aspire/$(Configuration)/net10.0/
diff --git a/NuGet.config b/NuGet.config
index a59f8b5a05e..25581bb7c05 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -1,4 +1,4 @@
-
+
@@ -31,6 +31,7 @@
+
@@ -39,7 +40,7 @@
-
+
diff --git a/playground/mongo/Mongo.ApiService/Program.cs b/playground/mongo/Mongo.ApiService/Program.cs
index c2ae7038b31..a96405a8ee9 100644
--- a/playground/mongo/Mongo.ApiService/Program.cs
+++ b/playground/mongo/Mongo.ApiService/Program.cs
@@ -13,6 +13,8 @@
var app = builder.Build();
app.MapDefaultEndpoints();
+app.MapGet("/ping", () => "pong");
+app.MapGet("/health-check", () => "healthy");
app.MapGet("/", async (IMongoClient mongoClient) =>
{
const string collectionName = "entries";
diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets
index 1b2c8bebf55..d36c48332db 100644
--- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets
+++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets
@@ -230,6 +230,22 @@ namespace Projects%3B
+
+
+ $([MSBuild]::EnsureTrailingSlash('$(WatchAspireDir)'))
+ $([MSBuild]::NormalizePath($(WatchAspireDir), 'Microsoft.DotNet.HotReload.Watch.Aspire'))
+ $(WatchAspirePath).exe
+ $(WatchAspirePath).dll
+
+
+
+
+ <_Parameter1>watchaspirepath
+ <_Parameter2>$(WatchAspirePath)
+
+
+
+
diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
index 27c57963bd2..614d96535e9 100644
--- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
@@ -10,6 +10,7 @@
using System.Data;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.IO.Pipes;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
@@ -79,6 +80,7 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I
private readonly DcpExecutorEvents _executorEvents;
private readonly Locations _locations;
private readonly IDeveloperCertificateService _developerCertificateService;
+ private readonly ResourceNotificationService _notificationService;
private readonly DcpResourceState _resourceState;
private readonly ResourceSnapshotBuilder _snapshotBuilder;
private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1);
@@ -93,6 +95,11 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I
private DcpInfo? _dcpInfo;
private Task? _resourceWatchTask;
private int _stopped;
+ private string? _watchAspireServerPipeName;
+ private NamedPipeServerStream? _controlPipe;
+ private StreamWriter? _controlPipeWriter;
+ private readonly SemaphoreSlim _controlPipeLock = new(1, 1);
+ private Dictionary? _projectPathToResource;
private readonly record struct LogInformationEntry(string ResourceName, bool? LogsAvailable, bool? HasSubscribers);
private readonly Channel _logInformationChannel = Channel.CreateUnbounded(
@@ -113,7 +120,8 @@ public DcpExecutor(ILogger logger,
DcpNameGenerator nameGenerator,
DcpExecutorEvents executorEvents,
Locations locations,
- IDeveloperCertificateService developerCertificateService)
+ IDeveloperCertificateService developerCertificateService,
+ ResourceNotificationService notificationService)
{
_distributedApplicationLogger = distributedApplicationLogger;
_kubernetesService = kubernetesService;
@@ -133,6 +141,7 @@ public DcpExecutor(ILogger logger,
_normalizedApplicationName = NormalizeApplicationName(hostEnvironment.ApplicationName);
_locations = locations;
_developerCertificateService = developerCertificateService;
+ _notificationService = notificationService;
DeleteResourceRetryPipeline = DcpPipelineBuilder.BuildDeleteRetryPipeline(logger);
WatchResourceRetryPipeline = DcpPipelineBuilder.BuildWatchResourcePipeline(logger);
@@ -296,6 +305,27 @@ public async ValueTask DisposeAsync()
var disposeCts = new CancellationTokenSource();
disposeCts.CancelAfter(s_disposeTimeout);
_serverCertificateCacheSemaphore.Dispose();
+
+ await _controlPipeLock.WaitAsync(disposeCts.Token).ConfigureAwait(false);
+ try
+ {
+ if (_controlPipeWriter is not null)
+ {
+ await _controlPipeWriter.DisposeAsync().ConfigureAwait(false);
+ _controlPipeWriter = null;
+ }
+
+ if (_controlPipe is not null)
+ {
+ await _controlPipe.DisposeAsync().ConfigureAwait(false);
+ _controlPipe = null;
+ }
+ }
+ finally
+ {
+ _controlPipeLock.Release();
+ }
+
await StopAsync(disposeCts.Token).ConfigureAwait(false);
}
@@ -1251,11 +1281,249 @@ private void PrepareServices()
private void PrepareExecutables()
{
+ PrepareWatchAspireServer();
PrepareProjectExecutables();
PreparePlainExecutables();
PrepareContainerExecutables();
}
+ private void PrepareWatchAspireServer()
+ {
+ // Find the watch server resource created by WatchAspireEventHandlers during BeforeStartEvent
+ var watchServerResource = _model.Resources
+ .OfType()
+ .FirstOrDefault(r => StringComparers.ResourceName.Equals(r.Name, WatchAspireEventHandlers.WatchServerResourceName));
+
+ if (watchServerResource is null)
+ {
+ return;
+ }
+
+ if (!watchServerResource.TryGetLastAnnotation(out var annotation))
+ {
+ _logger.LogWarning("Watch server resource found but missing WatchAspireAnnotation. Skipping watch setup.");
+ return;
+ }
+
+ _watchAspireServerPipeName = annotation.ServerPipeName;
+ _projectPathToResource = annotation.ProjectPathToResource;
+
+ _logger.LogDebug("Setting up Watch.Aspire runtime with server pipe '{PipeName}'.", annotation.ServerPipeName);
+
+ // Start background task to listen for status events from the watch server
+ _ = Task.Run(() => ListenForWatchStatusEventsAsync(annotation.StatusPipeName, _shutdownCancellation.Token));
+
+ // Start background task for control pipe server (AppHost → watch server)
+ _ = Task.Run(() => StartControlPipeServerAsync(annotation.ControlPipeName, _shutdownCancellation.Token));
+
+ // Add rebuild command to each watched resource
+ foreach (var (projectPath, resource) in _projectPathToResource)
+ {
+ resource.Annotations.Add(new ResourceCommandAnnotation(
+ name: "watch-rebuild",
+ displayName: "Rebuild",
+ updateState: context => context.ResourceSnapshot.State?.Text is "Running" or "Build failed"
+ ? ResourceCommandState.Enabled : ResourceCommandState.Hidden,
+ executeCommand: async context =>
+ {
+ await SendControlCommandAsync(new WatchControlCommand { Type = WatchControlCommand.Types.Rebuild, Projects = [projectPath] }).ConfigureAwait(false);
+ return CommandResults.Success();
+ },
+ displayDescription: "Force rebuild and restart this project",
+ parameter: null,
+ confirmationMessage: null,
+ iconName: "ArrowSync",
+ iconVariant: IconVariant.Regular,
+ isHighlighted: false));
+ }
+ }
+
+ private async Task ListenForWatchStatusEventsAsync(string pipeName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var pipe = new NamedPipeServerStream(
+ pipeName,
+ PipeDirection.In,
+ 1,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ _logger.LogDebug("Waiting for watch status pipe connection on '{PipeName}'.", pipeName);
+ await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogDebug("Watch status pipe connected.");
+
+ using var reader = new StreamReader(pipe);
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ if (line is null)
+ {
+ // Pipe closed
+ break;
+ }
+
+ try
+ {
+ var statusEvent = JsonSerializer.Deserialize(line);
+ if (statusEvent is not null)
+ {
+ _logger.LogDebug("Watch status event received: Type={Type}, Success={Success}, Projects=[{Projects}]",
+ statusEvent.Type, statusEvent.Success, string.Join(", ", statusEvent.Projects ?? []));
+ await ProcessWatchStatusEventAsync(statusEvent).ConfigureAwait(false);
+ }
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogDebug("Failed to deserialize watch status event: {Message}", ex.Message);
+ }
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Shutdown requested
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug("Watch status pipe listener ended: {Message}", ex.Message);
+ }
+ }
+
+ private async Task StartControlPipeServerAsync(string pipeName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ _controlPipe = new NamedPipeServerStream(
+ pipeName,
+ PipeDirection.Out,
+ 1,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ _logger.LogDebug("Waiting for control pipe connection on '{PipeName}'.", pipeName);
+ await _controlPipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ _controlPipeWriter = new StreamWriter(_controlPipe) { AutoFlush = true };
+ _logger.LogDebug("Control pipe connected.");
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Shutdown requested
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug("Control pipe server failed: {Message}", ex.Message);
+ }
+ }
+
+ private async Task SendControlCommandAsync(WatchControlCommand command)
+ {
+ await _controlPipeLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ var writer = _controlPipeWriter;
+ if (writer is null)
+ {
+ _logger.LogDebug("Control pipe not connected. Cannot send command.");
+ return;
+ }
+
+ try
+ {
+ var json = JsonSerializer.Serialize(command);
+ await writer.WriteLineAsync(json).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ _logger.LogDebug("Control pipe disconnected: {Message}", ex.Message);
+ _controlPipeWriter = null;
+ }
+ }
+ finally
+ {
+ _controlPipeLock.Release();
+ }
+ }
+
+ private async Task ProcessWatchStatusEventAsync(WatchStatusEvent statusEvent)
+ {
+ if (_projectPathToResource is null || statusEvent.Projects is null)
+ {
+ return;
+ }
+
+ foreach (var projectPath in statusEvent.Projects)
+ {
+ if (!_projectPathToResource.TryGetValue(projectPath, out var resource))
+ {
+ _logger.LogDebug("Watch status event for unrecognized project path: '{ProjectPath}'", projectPath);
+ continue;
+ }
+
+ switch (statusEvent.Type)
+ {
+ case WatchStatusEvent.Types.Building:
+ _logger.LogDebug("Setting resource '{Resource}' state to Building.", resource.Name);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Building", KnownResourceStateStyles.Info)
+ }).ConfigureAwait(false);
+ break;
+
+ case WatchStatusEvent.Types.BuildComplete when statusEvent.Success == true:
+ _logger.LogDebug("Setting resource '{Resource}' state to Starting (build succeeded).", resource.Name);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Starting", KnownResourceStateStyles.Info)
+ }).ConfigureAwait(false);
+ break;
+
+ case WatchStatusEvent.Types.BuildComplete when statusEvent.Success == false:
+ _logger.LogDebug("Setting resource '{Resource}' state to Build failed.", resource.Name);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Build failed", KnownResourceStateStyles.Error)
+ }).ConfigureAwait(false);
+ break;
+
+ case WatchStatusEvent.Types.HotReloadApplied:
+ _logger.LogDebug("Setting resource '{Resource}' state to Running (hot reload applied).", resource.Name);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = KnownResourceStates.Running
+ }).ConfigureAwait(false);
+ break;
+
+ case WatchStatusEvent.Types.Restarting:
+ _logger.LogDebug("Setting resource '{Resource}' state to Restarting.", resource.Name);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Restarting", KnownResourceStateStyles.Info)
+ }).ConfigureAwait(false);
+ break;
+
+ case WatchStatusEvent.Types.ProcessExited:
+ var exitCode = statusEvent.ExitCode;
+ _logger.LogDebug("Setting resource '{Resource}' state to Exited (code {ExitCode}).", resource.Name, exitCode);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ ExitCode = exitCode,
+ State = new ResourceStateSnapshot($"Exited", KnownResourceStateStyles.Error)
+ }).ConfigureAwait(false);
+ break;
+
+ case WatchStatusEvent.Types.ProcessStarted:
+ _logger.LogDebug("Setting resource '{Resource}' state to Running (process started).", resource.Name);
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = KnownResourceStates.Running
+ }).ConfigureAwait(false);
+ break;
+ }
+ }
+ }
+
private void PrepareContainerExecutables()
{
var modelContainerExecutableResources = _model.GetContainerExecutableResources();
@@ -1373,8 +1641,27 @@ private void PrepareProjectExecutables()
{
exe.Spec.ExecutionType = ExecutionType.Process;
- // `dotnet watch` does not work with file-based apps yet, so we have to use `dotnet run` in that case
- if (_configuration.GetBool("DOTNET_WATCH") is not true || projectMetadata.IsFileBasedApp)
+ if (_watchAspireServerPipeName is not null && !projectMetadata.IsFileBasedApp && _projectPathToResource?.ContainsKey(projectMetadata.ProjectPath) == true)
+ {
+ // Use Watch.Aspire resource command - the server handles building and hot reload
+ var watchAspireDllPath = _options.Value.WatchAspirePath!;
+ if (!watchAspireDllPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ {
+ watchAspireDllPath = Path.ChangeExtension(watchAspireDllPath, ".dll");
+ }
+
+ projectArgs.AddRange([
+ "exec",
+ watchAspireDllPath,
+ "resource",
+ "--server",
+ _watchAspireServerPipeName,
+ "--entrypoint",
+ projectMetadata.ProjectPath,
+ "--no-launch-profile"
+ ]);
+ }
+ else
{
projectArgs.Add("run");
projectArgs.Add(projectMetadata.IsFileBasedApp ? "--file" : "--project");
@@ -1387,29 +1674,14 @@ private void PrepareProjectExecutables()
{
projectArgs.Add("--no-build");
}
- }
- else
- {
- projectArgs.AddRange([
- "watch",
- "--non-interactive",
- "--no-hot-reload",
- "--project",
- projectMetadata.ProjectPath
- ]);
- }
- if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration))
- {
- projectArgs.AddRange(new[] { "--configuration", _distributedApplicationOptions.Configuration });
- }
+ if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration))
+ {
+ projectArgs.AddRange(new[] { "--configuration", _distributedApplicationOptions.Configuration });
+ }
- // We pretty much always want to suppress the normal launch profile handling
- // because the settings from the profile will override the ambient environment settings, which is not what we want
- // (the ambient environment settings for service processes come from the application model
- // and should be HIGHER priority than the launch profile settings).
- // This means we need to apply the launch profile settings manually inside CreateExecutableAsync().
- projectArgs.Add("--no-launch-profile");
+ projectArgs.Add("--no-launch-profile");
+ }
}
// We want this annotation even if we are not using IDE execution; see ToSnapshot() for details.
@@ -1728,6 +2000,23 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou
throw new FailedToApplyEnvironmentException();
}
+ // In watch-aspire mode, pass environment variables as -e KEY=VALUE arguments
+ // to the resource command instead of setting them on the DCP executable directly.
+ // The resource command forwards them to the watch server via the named pipe.
+ if (_watchAspireServerPipeName is not null
+ && er.ModelResource is ProjectResource projectResource
+ && projectResource.TryGetLastAnnotation(out var projMeta)
+ && !projMeta.IsFileBasedApp)
+ {
+ spec.Args ??= [];
+ foreach (var envVar in spec.Env)
+ {
+ spec.Args.Add("-e");
+ spec.Args.Add($"{envVar.Name}={envVar.Value}");
+ }
+ spec.Env = [];
+ }
+
await _kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false);
}
finally
diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs
index 4cb636d9677..ff7cbab88be 100644
--- a/src/Aspire.Hosting/Dcp/DcpOptions.cs
+++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs
@@ -109,6 +109,11 @@ internal sealed class DcpOptions
/// Enables Aspire container tunnel for container-to-host connectivity across all container orchestrators.
///
public bool EnableAspireContainerTunnel { get; set; }
+
+ ///
+ /// Optional path to the Watch.Aspire tool used for hot reload support.
+ ///
+ public string? WatchAspirePath { get; set; }
}
internal class ValidateDcpOptions : IValidateOptions
@@ -138,6 +143,7 @@ internal class ConfigureDefaultDcpOptions(
private const string DcpCliPathMetadataKey = "DcpCliPath";
private const string DcpExtensionsPathMetadataKey = "DcpExtensionsPath";
private const string DashboardPathMetadataKey = "aspiredashboardpath";
+ private const string WatchAspirePathMetadataKey = "watchaspirepath";
public static string DcpPublisher = nameof(DcpPublisher);
@@ -213,6 +219,15 @@ public void Configure(DcpOptions options)
options.DiagnosticsLogLevel = dcpPublisherConfiguration[nameof(options.DiagnosticsLogLevel)];
options.PreserveExecutableLogs = dcpPublisherConfiguration.GetValue(nameof(options.PreserveExecutableLogs), options.PreserveExecutableLogs);
options.EnableAspireContainerTunnel = configuration.GetValue(KnownConfigNames.EnableContainerTunnel, options.EnableAspireContainerTunnel);
+
+ if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.WatchAspirePath)]))
+ {
+ options.WatchAspirePath = dcpPublisherConfiguration[nameof(options.WatchAspirePath)];
+ }
+ else
+ {
+ options.WatchAspirePath = GetMetadataValue(assemblyMetadata, WatchAspirePathMetadataKey);
+ }
}
private static string? GetMetadataValue(IEnumerable? assemblyMetadata, string key)
diff --git a/src/Aspire.Hosting/Dcp/WatchAspireAnnotation.cs b/src/Aspire.Hosting/Dcp/WatchAspireAnnotation.cs
new file mode 100644
index 00000000000..ff9536e0331
--- /dev/null
+++ b/src/Aspire.Hosting/Dcp/WatchAspireAnnotation.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Dcp;
+
+internal sealed class WatchAspireAnnotation(
+ string serverPipeName,
+ string statusPipeName,
+ string controlPipeName,
+ Dictionary projectPathToResource) : IResourceAnnotation
+{
+ public string ServerPipeName { get; } = serverPipeName;
+ public string StatusPipeName { get; } = statusPipeName;
+ public string ControlPipeName { get; } = controlPipeName;
+ public Dictionary ProjectPathToResource { get; } = projectPathToResource;
+}
diff --git a/src/Aspire.Hosting/Dcp/WatchAspireEventHandlers.cs b/src/Aspire.Hosting/Dcp/WatchAspireEventHandlers.cs
new file mode 100644
index 00000000000..a76cec0fc95
--- /dev/null
+++ b/src/Aspire.Hosting/Dcp/WatchAspireEventHandlers.cs
@@ -0,0 +1,125 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Eventing;
+using Aspire.Hosting.Lifecycle;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Aspire.Hosting.Dcp;
+
+internal sealed class WatchAspireEventHandlers(
+ IOptions options,
+ ILogger logger,
+ DcpNameGenerator nameGenerator,
+ DistributedApplicationOptions distributedApplicationOptions) : IDistributedApplicationEventingSubscriber
+{
+ internal const string WatchServerResourceName = "aspire-watch-server";
+
+ public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
+ {
+ if (executionContext.IsRunMode)
+ {
+ eventing.Subscribe(OnBeforeStartAsync);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken)
+ {
+ var watchAspirePath = options.Value.WatchAspirePath;
+ logger.LogDebug("WatchAspirePath resolved to: {WatchAspirePath}", watchAspirePath ?? "(null)");
+ if (watchAspirePath is null)
+ {
+ return;
+ }
+
+ // Collect all project resource paths (skip file-based apps) and build path → resource mapping
+ var projectPaths = new List();
+ var projectPathToResource = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var project in @event.Model.GetProjectResources())
+ {
+ if (project.TryGetLastAnnotation(out var metadata) && !metadata.IsFileBasedApp
+ && !StringComparers.ResourceName.Equals(project.Name, KnownResourceNames.AspireDashboard))
+ {
+ projectPaths.Add(metadata.ProjectPath);
+ projectPathToResource[metadata.ProjectPath] = project;
+ }
+ }
+
+ if (projectPaths.Count == 0)
+ {
+ return;
+ }
+
+ // Resolve SDK path using `dotnet --version` from the AppHost project directory so global.json is respected
+ var sdkPath = await DotnetSdkUtils.TryGetSdkDirectoryAsync(distributedApplicationOptions.ProjectDirectory).ConfigureAwait(false);
+ if (sdkPath is null)
+ {
+ logger.LogWarning("Cannot resolve .NET SDK path. Watch.Aspire hot reload server will not be started.");
+ return;
+ }
+
+ // Generate unique pipe names
+ var serverPipeName = $"aw-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}";
+ var statusPipeName = $"aws-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}";
+ var controlPipeName = $"awc-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}";
+
+ // Determine the working directory
+ var cwd = Path.GetDirectoryName(watchAspirePath) ?? Directory.GetCurrentDirectory();
+
+ // Resolve the DLL path - if the path is not a .dll, find the .dll next to it
+ var watchAspireDllPath = watchAspirePath;
+ if (!watchAspirePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ {
+ watchAspireDllPath = Path.ChangeExtension(watchAspirePath, ".dll");
+ }
+
+ // Create the watch server as a hidden ExecutableResource (following the Dashboard pattern)
+ var watchServerResource = new ExecutableResource(WatchServerResourceName, "dotnet", cwd);
+
+ watchServerResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args =>
+ {
+ args.Add("exec");
+ args.Add(watchAspireDllPath);
+ args.Add("server");
+ args.Add("--server");
+ args.Add(serverPipeName);
+ args.Add("--sdk");
+ args.Add(sdkPath);
+ args.Add("--status-pipe");
+ args.Add(statusPipeName);
+ args.Add("--control-pipe");
+ args.Add(controlPipeName);
+ foreach (var projPath in projectPaths)
+ {
+ args.Add("--resource");
+ args.Add(projPath);
+ }
+ }));
+
+ nameGenerator.EnsureDcpInstancesPopulated(watchServerResource);
+
+ // Mark as hidden and exclude lifecycle commands
+ var snapshot = new CustomResourceSnapshot
+ {
+ Properties = [],
+ ResourceType = watchServerResource.GetResourceType(),
+ IsHidden = true
+ };
+ watchServerResource.Annotations.Add(new ResourceSnapshotAnnotation(snapshot));
+ watchServerResource.Annotations.Add(new ExcludeLifecycleCommandsAnnotation());
+
+ // Store pipe names and project mapping so DcpExecutor can find them
+ watchServerResource.Annotations.Add(new WatchAspireAnnotation(
+ serverPipeName, statusPipeName, controlPipeName, projectPathToResource));
+
+ // Insert first so DCP starts it before project resources
+ @event.Model.Resources.Insert(0, watchServerResource);
+
+ logger.LogInformation("Watch.Aspire hot reload server enabled for {Count} project(s).", projectPaths.Count);
+ }
+}
diff --git a/src/Aspire.Hosting/Dcp/WatchControlCommand.cs b/src/Aspire.Hosting/Dcp/WatchControlCommand.cs
new file mode 100644
index 00000000000..f4ee8f8424b
--- /dev/null
+++ b/src/Aspire.Hosting/Dcp/WatchControlCommand.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.Dcp;
+
+internal sealed class WatchControlCommand
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("projects")]
+ public string[]? Projects { get; set; }
+
+ public static class Types
+ {
+ public const string Rebuild = "rebuild";
+ }
+}
diff --git a/src/Aspire.Hosting/Dcp/WatchStatusEvent.cs b/src/Aspire.Hosting/Dcp/WatchStatusEvent.cs
new file mode 100644
index 00000000000..11d0ec3e727
--- /dev/null
+++ b/src/Aspire.Hosting/Dcp/WatchStatusEvent.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.Dcp;
+
+internal sealed class WatchStatusEvent
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("projects")]
+ public string[]? Projects { get; set; }
+
+ [JsonPropertyName("success")]
+ public bool? Success { get; set; }
+
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+
+ [JsonPropertyName("exitCode")]
+ public int? ExitCode { get; set; }
+
+ 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/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
index 10f5234d0a3..eba7028bf34 100644
--- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs
+++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
@@ -452,6 +452,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
_innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ValidateDashboardOptions>());
}
+ _innerBuilder.Services.AddEventingSubscriber();
+
if (options.EnableResourceLogging)
{
// This must be added before DcpHostService to ensure that it can subscribe to the ResourceNotificationService and ResourceLoggerService
diff --git a/src/Aspire.Hosting/Utils/DotnetSdkUtils.cs b/src/Aspire.Hosting/Utils/DotnetSdkUtils.cs
index b81909f486f..e600c46696c 100644
--- a/src/Aspire.Hosting/Utils/DotnetSdkUtils.cs
+++ b/src/Aspire.Hosting/Utils/DotnetSdkUtils.cs
@@ -18,8 +18,111 @@ internal static class DotnetSdkUtils
public static async Task TryGetVersionAsync(string? workingDirectory)
{
- // Get version by parsing the SDK version string
+ var (version, _) = await RunDotnetVersionAsync(workingDirectory).ConfigureAwait(false);
+ return version;
+ }
+
+ ///
+ /// Resolves the active .NET SDK directory path by running dotnet --list-sdks and dotnet --version.
+ /// Returns the full path to the SDK directory (e.g., /usr/local/share/dotnet/sdk/10.0.100), or null on failure.
+ ///
+ public static async Task TryGetSdkDirectoryAsync(string? workingDirectory)
+ {
+ var (_, rawVersionString) = await RunDotnetVersionAsync(workingDirectory).ConfigureAwait(false);
+ if (rawVersionString is null)
+ {
+ return null;
+ }
+
+ // Use dotnet --list-sdks to find the actual path for this SDK version.
+ // This handles cases where the SDK is in a non-standard location (e.g., repo-local .dotnet/).
+ // Output format: "10.0.102 [/path/to/sdk]"
+ var sdkPath = await FindSdkPathFromListAsync(rawVersionString, workingDirectory).ConfigureAwait(false);
+ if (sdkPath is not null)
+ {
+ return sdkPath;
+ }
+
+ // Fallback: try well-known dotnet root locations
+ var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { } hostPath
+ ? Path.GetDirectoryName(hostPath)
+ : Environment.GetEnvironmentVariable("DOTNET_ROOT");
+
+ if (string.IsNullOrEmpty(dotnetRoot))
+ {
+ var runtimeDir = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();
+ dotnetRoot = Path.GetFullPath(Path.Combine(runtimeDir, "..", "..", ".."));
+ }
+
+ var sdkDir = Path.Combine(dotnetRoot, "sdk", rawVersionString);
+ return Directory.Exists(sdkDir) ? sdkDir : null;
+ }
+
+ private static async Task FindSdkPathFromListAsync(string version, string? workingDirectory)
+ {
+ var lines = new List();
+ try
+ {
+ var (task, _) = ProcessUtil.Run(new("dotnet")
+ {
+ WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
+ Arguments = "--list-sdks",
+ EnvironmentVariables = s_dotnetCliEnvVars,
+ OnOutputData = data =>
+ {
+ if (!string.IsNullOrWhiteSpace(data))
+ {
+ lines.Add(data.Trim());
+ }
+ }
+ });
+ var result = await task.ConfigureAwait(false);
+ if (result.ExitCode != 0)
+ {
+ return null;
+ }
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ // Parse lines like: "10.0.102 [/Users/davidfowler/.dotnet/sdk]"
+ foreach (var line in lines)
+ {
+ var spaceIndex = line.IndexOf(' ');
+ if (spaceIndex <= 0)
+ {
+ continue;
+ }
+
+ var lineVersion = line[..spaceIndex];
+ if (!string.Equals(lineVersion, version, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // Extract the path from brackets: "[/path/to/sdk]"
+ var bracketStart = line.IndexOf('[', spaceIndex);
+ var bracketEnd = line.IndexOf(']', bracketStart + 1);
+ if (bracketStart >= 0 && bracketEnd > bracketStart)
+ {
+ var basePath = line[(bracketStart + 1)..bracketEnd];
+ var fullPath = Path.Combine(basePath, version);
+ if (Directory.Exists(fullPath))
+ {
+ return fullPath;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static async Task<(Version? Parsed, string? Raw)> RunDotnetVersionAsync(string? workingDirectory)
+ {
Version? parsedVersion = null;
+ string? rawVersionString = null;
try
{
@@ -30,11 +133,12 @@ internal static class DotnetSdkUtils
EnvironmentVariables = s_dotnetCliEnvVars,
OnOutputData = data =>
{
- if (!string.IsNullOrWhiteSpace(data))
+ if (!string.IsNullOrWhiteSpace(data) && rawVersionString is null)
{
- // The SDK version is in the first line of output
- var line = data.AsSpan().Trim();
- // Trim any pre-release suffix
+ rawVersionString = data.Trim();
+
+ // Parse the version, trimming any pre-release suffix
+ var line = rawVersionString.AsSpan();
var hyphenIndex = line.IndexOf('-');
var versionSpan = hyphenIndex >= 0 ? line[..hyphenIndex] : line;
if (Version.TryParse(versionSpan, out var v))
@@ -47,10 +151,10 @@ internal static class DotnetSdkUtils
var result = await task.ConfigureAwait(false);
if (result.ExitCode == 0)
{
- return parsedVersion;
+ return (parsedVersion, rawVersionString);
}
}
catch (Exception) { }
- return null;
+ return (null, null);
}
}
diff --git a/src/WatchPrototype/Directory.Packages.props b/src/WatchPrototype/Directory.Packages.props
index 7ca24acc6cf..dd4f6317684 100644
--- a/src/WatchPrototype/Directory.Packages.props
+++ b/src/WatchPrototype/Directory.Packages.props
@@ -8,10 +8,11 @@
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs b/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs
index 550f1b62555..55de97a5568 100644
--- a/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs
+++ b/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable enable
@@ -20,6 +20,12 @@ internal readonly struct StaticWebAsset(string filePath, string relativeUrl, str
public const string WebRoot = "wwwroot";
public const string ManifestFileName = "staticwebassets.development.json";
+ public static string? TryGetExistingManifestFile(string outputDirectory)
+ {
+ var dev = Path.Combine(outputDirectory, ManifestFileName);
+ return File.Exists(dev) ? dev : null;
+ }
+
public static bool IsScopedCssFile(string filePath)
=> filePath.EndsWith(".razor.css", StringComparison.Ordinal) ||
filePath.EndsWith(".cshtml.css", StringComparison.Ordinal);
diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj
index 83af1863d36..ac59270a311 100644
--- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj
+++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj
@@ -12,7 +12,8 @@
-
+
+
diff --git a/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs b/src/WatchPrototype/Watch.Aspire/AspireHostLauncher.cs
similarity index 81%
rename from src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs
rename to src/WatchPrototype/Watch.Aspire/AspireHostLauncher.cs
index 121b600ef50..5a7f024ba49 100644
--- a/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs
+++ b/src/WatchPrototype/Watch.Aspire/AspireHostLauncher.cs
@@ -1,13 +1,13 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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
+internal static class AspireHostLauncher
{
- public static async Task RunAsync(string workingDirectory, DotNetWatchOptions options)
+ public static async Task LaunchAsync(string workingDirectory, AspireHostWatchOptions options)
{
var globalOptions = new GlobalOptions()
{
@@ -27,10 +27,10 @@ public static async Task RunAsync(string workingDirectory, DotNetWatchOpti
var rootProjectOptions = new ProjectOptions()
{
IsRootProject = true,
- Representation = options.Project,
+ Representation = options.EntryPoint,
WorkingDirectory = workingDirectory,
- TargetFramework = null,
- BuildArguments = [],
+ TargetFramework = null, // TODO
+ BuildArguments = [], // TODO
NoLaunchProfile = options.NoLaunchProfile,
LaunchProfileName = null,
Command = "run",
@@ -60,6 +60,10 @@ public static async Task RunAsync(string workingDirectory, DotNetWatchOpti
Options = globalOptions,
EnvironmentOptions = environmentOptions,
RootProjectOptions = rootProjectOptions,
+ BuildArguments = rootProjectOptions.BuildArguments,
+ TargetFramework = rootProjectOptions.TargetFramework,
+ LaunchProfileName = rootProjectOptions.NoLaunchProfile ? null : rootProjectOptions.LaunchProfileName,
+ RootProjects = [options.EntryPoint],
BrowserRefreshServerFactory = new BrowserRefreshServerFactory(),
BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions),
};
@@ -78,9 +82,9 @@ public static async Task RunAsync(string workingDirectory, DotNetWatchOpti
catch (Exception e)
{
logger.LogError("An unexpected error occurred: {Exception}", e.ToString());
- return false;
+ return -1;
}
- return true;
+ return 0;
}
}
diff --git a/src/WatchPrototype/Watch.Aspire/AspireResourceLauncher.cs b/src/WatchPrototype/Watch.Aspire/AspireResourceLauncher.cs
new file mode 100644
index 00000000000..78b02c80091
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/AspireResourceLauncher.cs
@@ -0,0 +1,118 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO.Pipes;
+using System.Text.Json;
+using Microsoft.DotNet.HotReload;
+
+namespace Microsoft.DotNet.Watch;
+
+internal static class AspireResourceLauncher
+{
+ public const byte Version = 1;
+
+ // Output message type bytes
+ public const byte OutputTypeStdout = 1;
+ public const byte OutputTypeStderr = 2;
+ public const byte OutputTypeExit = 0;
+
+ ///
+ /// 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 static async Task LaunchAsync(AspireResourceWatchOptions options, CancellationToken cancellationToken)
+ {
+ try
+ {
+ Console.Error.WriteLine($"Connecting to {options.ServerPipeName}...");
+
+ using var pipeClient = new NamedPipeClientStream(
+ serverName: ".",
+ options.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 timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(60));
+ await pipeClient.ConnectAsync(timeoutCts.Token);
+
+ var request = new LaunchResourceRequest()
+ {
+ EntryPoint = options.EntryPoint,
+ ApplicationArguments = options.ApplicationArguments,
+ EnvironmentVariables = options.EnvironmentVariables,
+ LaunchProfile = options.LaunchProfile,
+ TargetFramework = options.TargetFramework,
+ NoLaunchProfile = options.NoLaunchProfile,
+ };
+
+ 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)
+ {
+ Console.Error.WriteLine("Server closed connection without sending ACK.");
+ return 1;
+ }
+
+ Console.Error.WriteLine("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)
+ {
+ Console.Error.WriteLine($"Failed to communicate with server: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private static 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);
+
+ switch (typeByte)
+ {
+ case OutputTypeStdout:
+ Console.Out.WriteLine(content);
+ break;
+ case OutputTypeStderr:
+ Console.Error.WriteLine(content);
+ break;
+ case OutputTypeExit:
+ // Don't exit — the server may restart the process (e.g. after a rude edit)
+ // and continue writing to the same pipe. We stay alive until the pipe closes.
+ break;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/WatchPrototype/Watch.Aspire/AspireServerLauncher.cs b/src/WatchPrototype/Watch.Aspire/AspireServerLauncher.cs
new file mode 100644
index 00000000000..047d056da2a
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/AspireServerLauncher.cs
@@ -0,0 +1,124 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal static class AspireServerLauncher
+{
+ public static async Task LaunchAsync(AspireServerWatchOptions options)
+ {
+ var globalOptions = new GlobalOptions()
+ {
+ LogLevel = options.LogLevel,
+ NoHotReload = false,
+ NonInteractive = true,
+ };
+
+ 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);
+
+ // Connect to status pipe if provided
+ WatchStatusWriter? statusWriter = null;
+ if (options.StatusPipeName is { } statusPipeName)
+ {
+ statusWriter = await WatchStatusWriter.TryConnectAsync(statusPipeName, logger, CancellationToken.None);
+ }
+
+ await using var statusWriterDispose = statusWriter;
+
+ // Connect to control pipe if provided
+ WatchControlReader? controlReader = null;
+ if (options.ControlPipeName is { } controlPipeName)
+ {
+ controlReader = await WatchControlReader.TryConnectAsync(controlPipeName, logger, CancellationToken.None);
+ }
+
+ await using var controlReaderDispose = controlReader;
+
+ using var context = new DotNetWatchContext()
+ {
+ ProcessOutputReporter = reporter,
+ LoggerFactory = loggerFactory,
+ Logger = logger,
+ BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName),
+ ProcessRunner = processRunner,
+ Options = globalOptions,
+ EnvironmentOptions = environmentOptions,
+ RootProjectOptions = null,
+ BuildArguments = [], // TODO?
+ TargetFramework = null, // TODO?
+ LaunchProfileName = null, // TODO: options.NoLaunchProfile ? null : options.LaunchProfileName,
+ RootProjects = [.. options.ResourcePaths.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)],
+ BrowserRefreshServerFactory = new BrowserRefreshServerFactory(),
+ BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions),
+ StatusEventWriter = statusWriter is not null ? statusWriter.WriteEventAsync : null,
+ };
+
+ using var shutdownHandler = new ShutdownHandler(console, logger);
+
+ try
+ {
+ var processLauncherFactory = new ProcessLauncherFactory(options.ServerPipeName, context.StatusEventWriter, shutdownHandler.CancellationToken);
+ var watcher = new HotReloadDotNetWatcher(context, console, processLauncherFactory);
+
+ if (controlReader is not null)
+ {
+ _ = ListenForControlCommandsAsync(controlReader, watcher, logger, shutdownHandler.CancellationToken);
+ }
+
+ 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 -1;
+ }
+
+ return 0;
+ }
+
+ static async Task ListenForControlCommandsAsync(
+ WatchControlReader reader, HotReloadDotNetWatcher watcher,
+ ILogger logger, CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var command = await reader.ReadCommandAsync(cancellationToken);
+ if (command is null)
+ {
+ break;
+ }
+
+ logger.LogInformation("Received control command: {Type}", command.Type);
+
+ if (command.Type == WatchControlCommand.Types.Rebuild)
+ {
+ watcher.RequestRestart();
+ }
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
+ catch (Exception ex)
+ {
+ logger.LogDebug("Control pipe listener ended: {Message}", ex.Message);
+ }
+ }
+}
diff --git a/src/WatchPrototype/Watch.Aspire/AspireWatchOptions.cs b/src/WatchPrototype/Watch.Aspire/AspireWatchOptions.cs
new file mode 100644
index 00000000000..9e635015cca
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/AspireWatchOptions.cs
@@ -0,0 +1,315 @@
+// 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 System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Components.Forms.Mapping;
+using Microsoft.Extensions.Logging;
+
+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);
+ }
+}
+
+internal abstract class AspireCommandDefinition : Command
+{
+ public readonly Option QuietOption = new("--quiet") { Arity = ArgumentArity.Zero };
+ public readonly Option VerboseOption = new("--verbose") { Arity = ArgumentArity.Zero };
+
+ public 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;
+}
+
+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);
+ }
+}
+
+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 AspireHostCommandDefinition()
+ : base("host", "Starts AppHost project.")
+ {
+ Arguments.Add(ApplicationArguments);
+
+ Options.Add(SdkOption);
+ Options.Add(EntryPointOption);
+ Options.Add(NoLaunchProfileOption);
+ }
+}
+
+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 readonly Option TargetFramework = new("--target-framework", "-tf") { 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);
+ Options.Add(TargetFramework);
+ }
+
+ private static IReadOnlyDictionary ParseEnvironmentVariables(ArgumentResult argumentResult)
+ {
+ var result = new Dictionary(
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
+
+ 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(
+ "IncorrectlyFormattedEnvironmentVariables",
+ string.Join(", ", invalid.Select(x => $"'{x.Value}'"))));
+ }
+
+ return result;
+ }
+}
+
+internal abstract class AspireWatchOptions
+{
+ public required LogLevel LogLevel { get; init; }
+
+ public abstract string? SdkDirectoryToRegister { get; }
+
+ public static AspireWatchOptions? TryParse(string[] args)
+ {
+ var rootCommand = new AspireRootCommand();
+
+ var parseResult = rootCommand.Parse(args);
+ if (parseResult.Errors.Count > 0)
+ {
+ foreach (var error in parseResult.Errors)
+ {
+ Console.Error.WriteLine(error);
+ }
+
+ return null;
+ }
+
+ return parseResult.CommandResult.Command switch
+ {
+ AspireServerCommandDefinition serverCommand => AspireServerWatchOptions.TryParse(parseResult, serverCommand),
+ AspireResourceCommandDefinition resourceCommand => AspireResourceWatchOptions.TryParse(parseResult, resourceCommand),
+ AspireHostCommandDefinition hostCommand => AspireHostWatchOptions.TryParse(parseResult, hostCommand),
+ _ => throw new InvalidOperationException(),
+ };
+ }
+}
+
+internal sealed class AspireServerWatchOptions : AspireWatchOptions
+{
+ public required string ServerPipeName { get; init; }
+
+ public required ImmutableArray ResourcePaths { get; init; }
+ public required string SdkDirectory { get; init; }
+ public string? StatusPipeName { get; init; }
+ public string? ControlPipeName { get; init; }
+
+ public override string? SdkDirectoryToRegister => SdkDirectory;
+
+ internal static AspireWatchOptions? TryParse(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);
+
+ return new AspireServerWatchOptions
+ {
+ ServerPipeName = serverPipeName,
+ LogLevel = command.GetLogLevel(parseResult),
+ SdkDirectory = sdkDirectory,
+ ResourcePaths = [.. resourcePaths],
+ StatusPipeName = statusPipeName,
+ ControlPipeName = controlPipeName,
+ };
+ }
+}
+
+internal sealed class AspireHostWatchOptions : AspireWatchOptions
+{
+ public required ProjectRepresentation EntryPoint { get; init; }
+ public required ImmutableArray ApplicationArguments { get; init; }
+ public required string SdkDirectory { get; init; }
+ public bool NoLaunchProfile { get; init; }
+
+ public override string? SdkDirectoryToRegister => SdkDirectory;
+
+ internal static AspireWatchOptions? TryParse(ParseResult parseResult, AspireHostCommandDefinition command)
+ {
+ var sdkDirectory = parseResult.GetValue(command.SdkOption)!;
+ var entryPointPath = parseResult.GetValue(command.EntryPointOption)!;
+ var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? [];
+ var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption);
+
+ return new AspireHostWatchOptions
+ {
+ LogLevel = command.GetLogLevel(parseResult),
+ SdkDirectory = sdkDirectory,
+ EntryPoint = ProjectRepresentation.FromProjectOrEntryPointFilePath(entryPointPath),
+ ApplicationArguments = [.. applicationArguments],
+ NoLaunchProfile = noLaunchProfile,
+ };
+ }
+}
+
+internal sealed class AspireResourceWatchOptions : AspireWatchOptions
+{
+ public required string ServerPipeName { get; init; }
+
+ public required string EntryPoint { get; init; }
+ public required ImmutableArray ApplicationArguments { get; init; }
+ public required IReadOnlyDictionary EnvironmentVariables { get; init; }
+ public required string? LaunchProfile { get; init; }
+ public required string? TargetFramework { get; init; }
+ public bool NoLaunchProfile { get; init; }
+
+ public override string? SdkDirectoryToRegister => null;
+
+ internal static AspireWatchOptions? TryParse(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 targetFramework = parseResult.GetValue(command.TargetFramework);
+
+ return new AspireResourceWatchOptions
+ {
+ LogLevel = command.GetLogLevel(parseResult),
+ ServerPipeName = serverPipeName,
+ EntryPoint = entryPointPath,
+ ApplicationArguments = [.. applicationArguments],
+ EnvironmentVariables = environmentVariables,
+ NoLaunchProfile = noLaunchProfile,
+ LaunchProfile = launchProfile,
+ TargetFramework = targetFramework,
+ };
+ }
+}
+
+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/WatchPrototype/Watch.Aspire/DotNetWatchOptions.cs b/src/WatchPrototype/Watch.Aspire/DotNetWatchOptions.cs
deleted file mode 100644
index 497078f9322..00000000000
--- a/src/WatchPrototype/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 };
-
- verboseOption.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 rootCommand = new RootCommand()
- {
- Directives = { new EnvironmentVariablesDirective() },
- Options =
- {
- sdkOption,
- projectOption,
- fileOption,
- quietOption,
- verboseOption,
- noLaunchProfileOption
- },
- Arguments =
- {
- applicationArguments
- }
- };
-
- 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/WatchPrototype/Watch.Aspire/LaunchResourceRequestJson.cs b/src/WatchPrototype/Watch.Aspire/LaunchResourceRequestJson.cs
new file mode 100644
index 00000000000..e71c7c66a15
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/LaunchResourceRequestJson.cs
@@ -0,0 +1,16 @@
+// 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 string? LaunchProfile { get; init; }
+ public required string? TargetFramework { get; init; }
+ public bool NoLaunchProfile { get; init; }
+}
diff --git a/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj b/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj
index fb08c258b80..caef0edc7af 100644
--- a/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj
+++ b/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/WatchPrototype/Watch.Aspire/ProcessLauncherFactory.cs b/src/WatchPrototype/Watch.Aspire/ProcessLauncherFactory.cs
new file mode 100644
index 00000000000..c7fcc447635
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/ProcessLauncherFactory.cs
@@ -0,0 +1,416 @@
+// 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 Microsoft.DotNet.HotReload;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class ProcessLauncherFactory(string namedPipe, Func? statusEventWriter, CancellationToken shutdownCancellationToken) : IRuntimeProcessLauncherFactory
+{
+ private Launcher? _currentLauncher;
+
+ public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher, string? launchProfile, string? targetFramework, IReadOnlyList buildArguments, Action? onLaunchedProcessCrashed = null)
+ {
+ // Reuse the existing launcher if the pipe is still alive (crash-restart iteration).
+ // This keeps the DCP resource command connected across iteration restarts.
+ if (_currentLauncher is { IsDisposed: false } launcher)
+ {
+ projectLauncher.Logger.LogDebug("Reusing existing launcher (pipe still alive)");
+ launcher.UpdateProjectLauncher(projectLauncher);
+ return launcher;
+ }
+
+ _currentLauncher = new Launcher(namedPipe, statusEventWriter, projectLauncher, launchProfile, targetFramework, buildArguments, onLaunchedProcessCrashed, shutdownCancellationToken);
+ return _currentLauncher;
+ }
+
+ private sealed class Launcher : IRuntimeProcessLauncher
+ {
+ private const byte Version = 1;
+
+ private volatile ProjectLauncher _projectLauncher;
+ private readonly Func? _statusEventWriter;
+ private readonly string? _launchProfile;
+ private readonly string? _targetFramework;
+ private readonly IReadOnlyList _buildArguments;
+ private readonly Action? _onLaunchedProcessCrashed;
+ private readonly Task _listenerTask;
+ private readonly CancellationTokenSource _launcherCts;
+
+ private ImmutableHashSet _pendingRequestCompletions = [];
+ private volatile bool _crashCallbackFired;
+
+ public bool IsDisposed { get; private set; }
+
+ public Launcher(
+ string namedPipe,
+ Func? statusEventWriter,
+ ProjectLauncher projectLauncher,
+ string? launchProfile,
+ string? targetFramework,
+ IReadOnlyList buildArguments,
+ Action? onLaunchedProcessCrashed,
+ CancellationToken shutdownCancellationToken)
+ {
+ _projectLauncher = projectLauncher;
+ _statusEventWriter = statusEventWriter;
+ _launchProfile = launchProfile;
+ _targetFramework = targetFramework;
+ _buildArguments = buildArguments;
+ _onLaunchedProcessCrashed = onLaunchedProcessCrashed;
+
+ _launcherCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken);
+ _listenerTask = StartListeningAsync(namedPipe, _launcherCts.Token);
+ }
+
+ private TaskCompletionSource? _relaunchSignal;
+
+ public void UpdateProjectLauncher(ProjectLauncher projectLauncher)
+ {
+ _projectLauncher = projectLauncher;
+ // Reset crash flag so new iteration can detect crashes again
+ _crashCallbackFired = false;
+ // Signal existing HandleRequestAsync to relaunch the project
+ _relaunchSignal?.TrySetResult();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Logger.LogDebug("DisposeAsync: cancelling listener");
+ IsDisposed = true;
+ await _launcherCts.CancelAsync();
+ await _listenerTask;
+ await Task.WhenAll(_pendingRequestCompletions);
+ _launcherCts.Dispose();
+ Logger.LogDebug("DisposeAsync: completed");
+ }
+
+ private ILogger Logger => _projectLauncher.Logger;
+
+ private async Task StartListeningAsync(string pipeName, CancellationToken cancellationToken)
+ {
+ Logger.LogDebug("Listening on '{PipeName}'", pipeName);
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var 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);
+ await pipe.DisposeAsync();
+ 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);
+
+ // Don't dispose the pipe - it's now owned by HandleRequestAsync
+ // which will keep it alive for output proxying
+ _ = HandleRequestAsync(request, pipe, cancellationToken);
+ }
+ }
+ 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 pipeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var connectionToken = pipeCts.Token;
+
+ try
+ {
+ Logger.LogDebug("HandleRequest: starting '{EntryPoint}'", request.EntryPoint);
+ var projectOptions = GetProjectOptions(request);
+
+ while (true)
+ {
+ // Set up a relaunch signal for crash recovery.
+ // UpdateProjectLauncher() completes this when a new iteration starts after a crash.
+ _relaunchSignal = new TaskCompletionSource();
+
+ currentProject.Value = await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: currentProject.Value is not null, connectionToken);
+
+ Logger.LogDebug("Project started, waiting for pipe disconnect or relaunch.");
+
+ // Wait for either: pipe disconnects (DCP Stop) OR relaunch signal (crash recovery)
+ var pipeDisconnectTask = WaitForPipeDisconnectAsync(pipe, connectionToken);
+ var completedTask = await Task.WhenAny(pipeDisconnectTask, _relaunchSignal.Task);
+
+ if (completedTask == _relaunchSignal.Task)
+ {
+ Logger.LogDebug("Relaunch signal received for '{EntryPoint}'.", request.EntryPoint);
+ // New iteration started after crash — relaunch the project with the updated _projectLauncher
+ continue;
+ }
+ else
+ {
+ Logger.LogDebug("Pipe disconnected for '{EntryPoint}'.", request.EntryPoint);
+ break;
+ }
+ }
+ }
+ 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 pipeCts.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.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
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ }
+
+ private async ValueTask StartProjectAsync(ProjectOptions projectOptions, NamedPipeServerStream pipe, StrongBox currentProject, bool isRestart, CancellationToken cancellationToken)
+ {
+ Logger.LogDebug("{Action}: '{Path}'", isRestart ? "Restarting" : "Starting", projectOptions.Representation.ProjectOrEntryPointFilePath);
+
+ // 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<(byte Type, string Content)>(new UnboundedChannelOptions
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ });
+
+ var drainTask = DrainOutputChannelAsync(outputChannel.Reader, pipe, cancellationToken);
+
+ var runningProject = await _projectLauncher.TryLaunchProcessAsync(
+ projectOptions,
+ processTerminationSource: new CancellationTokenSource(),
+ onOutput: line =>
+ {
+ var typeByte = line.IsError ? AspireResourceLauncher.OutputTypeStderr : AspireResourceLauncher.OutputTypeStdout;
+ outputChannel.Writer.TryWrite((typeByte, line.Content));
+ },
+ onExit: async (processId, exitCode) =>
+ {
+ var isRestarting = currentProject.Value?.IsRestarting == true;
+ Logger.LogDebug("Process {ProcessId} exited with code {ExitCode} for '{Path}'",
+ processId, exitCode, projectOptions.Representation.ProjectOrEntryPointFilePath);
+
+ // 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).
+ if (exitCode is not null and not 0 && _statusEventWriter is not null && !cancellationToken.IsCancellationRequested && !isRestarting)
+ {
+ await _statusEventWriter(new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.ProcessExited,
+ Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath],
+ ExitCode = exitCode,
+ });
+ }
+
+ // Signal the iteration to restart so dotnet-watch rebuilds on next file change.
+ // Only for actual crashes (non-zero exit, not cancelled, not a deliberate restart).
+ // Use once-only flag to prevent storms from dotnet-watch auto-retry.
+ if (exitCode is not null and not 0 && !cancellationToken.IsCancellationRequested && !isRestarting)
+ {
+ if (!_crashCallbackFired && _onLaunchedProcessCrashed is not null)
+ {
+ _crashCallbackFired = true;
+ Logger.LogDebug("Launched process crashed, cancelling iteration.");
+ _onLaunchedProcessCrashed();
+ }
+ }
+
+ // Signal the exit to the resource command but DON'T complete the 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.
+ outputChannel.Writer.TryWrite((AspireResourceLauncher.OutputTypeExit, (exitCode ?? -1).ToString()));
+ },
+ restartOperation: async ct =>
+ {
+ Logger.LogDebug("Restart operation initiated.");
+ // 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 drainTask;
+ var newProject = await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: true, ct);
+ currentProject.Value = newProject;
+ Logger.LogDebug("Restart operation completed.");
+ return newProject;
+ },
+ cancellationToken)
+ ?? throw new InvalidOperationException();
+
+ // Emit ProcessStarted so the dashboard knows the process is actually running.
+ if (_statusEventWriter is not null)
+ {
+ await _statusEventWriter(new WatchStatusEvent
+ {
+ Type = WatchStatusEvent.Types.ProcessStarted,
+ Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath],
+ });
+ }
+
+ return runningProject;
+ }
+
+ private static async Task DrainOutputChannelAsync(ChannelReader<(byte Type, string Content)> reader, NamedPipeServerStream pipe, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var (typeByte, content) in reader.ReadAllAsync(cancellationToken))
+ {
+ await pipe.WriteAsync(typeByte, cancellationToken);
+ await pipe.WriteAsync(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()
+ {
+ IsRootProject = false,
+ Representation = project,
+ WorkingDirectory = Path.GetDirectoryName(request.EntryPoint) ?? throw new InvalidOperationException(),
+ BuildArguments = _buildArguments,
+ Command = "run",
+ CommandArguments = GetRunCommandArguments(request, _launchProfile),
+ LaunchEnvironmentVariables = request.EnvironmentVariables?.Select(e => (e.Key, e.Value))?.ToArray() ?? [],
+ LaunchProfileName = request.LaunchProfile,
+ NoLaunchProfile = request.NoLaunchProfile,
+ TargetFramework = _targetFramework,
+ };
+ }
+
+ // internal for testing
+ internal static IReadOnlyList GetRunCommandArguments(LaunchResourceRequest request, string? hostLaunchProfile)
+ {
+ var arguments = new List();
+
+ // Implements https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration
+
+ if (request.NoLaunchProfile)
+ {
+ arguments.Add("--no-launch-profile");
+ }
+ else if (!string.IsNullOrEmpty(request.LaunchProfile))
+ {
+ arguments.Add("--launch-profile");
+ arguments.Add(request.LaunchProfile);
+ }
+ 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/WatchPrototype/Watch.Aspire/Program.cs b/src/WatchPrototype/Watch.Aspire/Program.cs
index 37de5ceca57..160dd89a89c 100644
--- a/src/WatchPrototype/Watch.Aspire/Program.cs
+++ b/src/WatchPrototype/Watch.Aspire/Program.cs
@@ -1,12 +1,20 @@
-using Microsoft.Build.Locator;
+using Microsoft.Build.Locator;
using Microsoft.DotNet.Watch;
-if (!DotNetWatchOptions.TryParse(args, out var options))
+if (AspireWatchOptions.TryParse(args) is not { } options)
{
return -1;
}
-MSBuildLocator.RegisterMSBuildPath(options.SdkDirectory);
+if (options.SdkDirectoryToRegister is { } sdkDirectory)
+{
+ MSBuildLocator.RegisterMSBuildPath(sdkDirectory);
+}
-var workingDirectory = Directory.GetCurrentDirectory();
-return await DotNetWatchLauncher.RunAsync(workingDirectory, options) ? 0 : 1;
+return options switch
+{
+ AspireHostWatchOptions hostOptions => await AspireHostLauncher.LaunchAsync(Directory.GetCurrentDirectory(), hostOptions),
+ AspireResourceWatchOptions resourceOptions => await AspireResourceLauncher.LaunchAsync(resourceOptions, CancellationToken.None),
+ AspireServerWatchOptions serverOptions => await AspireServerLauncher.LaunchAsync(serverOptions),
+ _ => -1,
+};
diff --git a/src/WatchPrototype/Watch.Aspire/Properties/launchSettings.json b/src/WatchPrototype/Watch.Aspire/Properties/launchSettings.json
new file mode 100644
index 00000000000..d781b810348
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/Properties/launchSettings.json
@@ -0,0 +1,20 @@
+{
+ "profiles": {
+ "Host": {
+ "commandName": "Project",
+ "commandLineArgs": "host --verbose --entrypoint E:\\Temp\\AspireApp3\\AspireApp3.AppHost\\AspireApp3.AppHost.csproj --sdk E:\\aspire\\.dotnet\\sdk\\10.0.102"
+ },
+ "Server": {
+ "commandName": "Project",
+ "commandLineArgs": "server --verbose --server NamedPipe_6CE8A258-A439-4459-A348-1546EA0E202C --sdk E:\\aspire\\.dotnet\\sdk\\10.0.102 --resource E:\\Temp\\AspireApp3\\AspireApp3.ApiService\\AspireApp3.ApiService.csproj --resource E:\\Temp\\AspireApp3\\AspireApp3.Web\\AspireApp3.Web.csproj"
+ },
+ "ApiService Resource": {
+ "commandName": "Project",
+ "commandLineArgs": "resource --verbose --server NamedPipe_6CE8A258-A439-4459-A348-1546EA0E202C --entrypoint E:\\Temp\\AspireApp3\\AspireApp3.ApiService\\AspireApp3.ApiService.csproj"
+ },
+ "WebApp Resource": {
+ "commandName": "Project",
+ "commandLineArgs": "resource --verbose --server NamedPipe_6CE8A258-A439-4459-A348-1546EA0E202C --entrypoint E:\\Temp\\AspireApp3\\AspireApp3.Web\\AspireApp3.Web.csproj"
+ }
+ }
+}
diff --git a/src/WatchPrototype/Watch.Aspire/WatchControlReader.cs b/src/WatchPrototype/Watch.Aspire/WatchControlReader.cs
new file mode 100644
index 00000000000..7a61a462885
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/WatchControlReader.cs
@@ -0,0 +1,79 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO.Pipes;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchControlReader : IAsyncDisposable
+{
+ private readonly NamedPipeClientStream _pipe;
+ private readonly StreamReader _reader;
+ private readonly ILogger _logger;
+
+ private WatchControlReader(NamedPipeClientStream pipe, StreamReader reader, ILogger logger)
+ {
+ _pipe = pipe;
+ _reader = reader;
+ _logger = logger;
+ }
+
+ public static async Task TryConnectAsync(string pipeName, ILogger logger, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var pipe = new NamedPipeClientStream(
+ serverName: ".",
+ pipeName,
+ PipeDirection.In,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
+ await pipe.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
+
+ var reader = new StreamReader(pipe);
+ logger.LogDebug("Connected to control pipe '{PipeName}'.", pipeName);
+ return new WatchControlReader(pipe, reader, logger);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning("Failed to connect to control pipe '{PipeName}': {Message}. External rebuild commands will not be available.", pipeName, ex.Message);
+ return null;
+ }
+ }
+
+ public async Task ReadCommandAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ if (line is null)
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize(line);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ _logger.LogDebug("Control pipe disconnected: {Message}", ex.Message);
+ return null;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ _reader.Dispose();
+ }
+ catch (IOException)
+ {
+ // Pipe may already be broken if the server disconnected
+ }
+ await _pipe.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/WatchPrototype/Watch.Aspire/WatchStatusWriter.cs b/src/WatchPrototype/Watch.Aspire/WatchStatusWriter.cs
new file mode 100644
index 00000000000..41402eca22c
--- /dev/null
+++ b/src/WatchPrototype/Watch.Aspire/WatchStatusWriter.cs
@@ -0,0 +1,93 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO.Pipes;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchStatusWriter : IAsyncDisposable
+{
+ private readonly NamedPipeClientStream _pipe;
+ private readonly StreamWriter _writer;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+ private bool _connected;
+
+ private WatchStatusWriter(NamedPipeClientStream pipe, StreamWriter writer, ILogger logger)
+ {
+ _pipe = pipe;
+ _writer = writer;
+ _logger = logger;
+ _connected = true;
+ }
+
+ public static async Task TryConnectAsync(string pipeName, ILogger logger, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var pipe = new NamedPipeClientStream(
+ serverName: ".",
+ pipeName,
+ PipeDirection.Out,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
+ await pipe.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
+
+ var writer = new StreamWriter(pipe) { AutoFlush = true };
+ logger.LogDebug("Connected to status pipe '{PipeName}'.", pipeName);
+ return new WatchStatusWriter(pipe, writer, logger);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning("Failed to connect to status pipe '{PipeName}': {Message}. Status events will not be reported.", pipeName, ex.Message);
+ return null;
+ }
+ }
+
+ public async Task WriteEventAsync(WatchStatusEvent statusEvent)
+ {
+ if (!_connected)
+ {
+ return;
+ }
+
+ await _writeLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ if (!_connected)
+ {
+ return;
+ }
+
+ var json = JsonSerializer.Serialize(statusEvent);
+ await _writer.WriteLineAsync(json).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ _logger.LogDebug("Status pipe disconnected: {Message}", ex.Message);
+ _connected = false;
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _connected = false;
+ try
+ {
+ await _writer.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (IOException)
+ {
+ // Pipe may already be broken if the server disconnected
+ }
+ await _pipe.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs b/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs
index caf071157e2..52f90b29078 100644
--- a/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs
+++ b/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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;
@@ -31,9 +31,12 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r
private readonly ProjectLauncher _projectLauncher;
private readonly AspireServerService _service;
- private readonly ProjectOptions _hostProjectOptions;
private readonly ILogger _logger;
+ private readonly string? _launchProfile;
+ private readonly string? _targetFramework;
+ private readonly IReadOnlyList _buildArguments;
+
///
/// Lock to access:
///
@@ -46,10 +49,14 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r
private volatile bool _isDisposed;
- public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions)
+ public SessionManager(ProjectLauncher projectLauncher, string? launchProfile, string? targetFramework, IReadOnlyList buildArguments)
{
_projectLauncher = projectLauncher;
- _hostProjectOptions = hostProjectOptions;
+
+ _launchProfile = launchProfile;
+ _targetFramework = targetFramework;
+ _buildArguments = buildArguments;
+
_logger = projectLauncher.LoggerFactory.CreateLogger(AspireLogComponentName);
_service = new AspireServerService(
@@ -216,20 +223,18 @@ private async Task TerminateSessionAsync(Session session)
private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo)
{
- var hostLaunchProfile = _hostProjectOptions.NoLaunchProfile ? null : _hostProjectOptions.LaunchProfileName;
-
return new()
{
IsRootProject = false,
Representation = ProjectRepresentation.FromProjectOrEntryPointFilePath(projectLaunchInfo.ProjectPath),
WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(),
- BuildArguments = _hostProjectOptions.BuildArguments,
+ BuildArguments = _buildArguments,
Command = "run",
- CommandArguments = GetRunCommandArguments(projectLaunchInfo, hostLaunchProfile),
+ CommandArguments = GetRunCommandArguments(projectLaunchInfo, _launchProfile),
LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(e => (e.Key, e.Value))?.ToArray() ?? [],
LaunchProfileName = projectLaunchInfo.LaunchProfile,
NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile,
- TargetFramework = _hostProjectOptions.TargetFramework,
+ TargetFramework = _targetFramework,
};
}
@@ -281,8 +286,6 @@ internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchReques
public const string AspireLogComponentName = "Aspire";
public const string AppHostProjectCapability = ProjectCapability.Aspire;
- public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions)
- => projectNode.GetCapabilities().Contains(AppHostProjectCapability)
- ? new SessionManager(projectLauncher, hostProjectOptions)
- : null;
+ public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher, string? launchProfile, string? targetFramework, IReadOnlyList buildArguments, Action? onLaunchedProcessCrashed = null)
+ => new SessionManager(projectLauncher, launchProfile, targetFramework, buildArguments);
}
diff --git a/src/WatchPrototype/Watch/Build/EvaluationResult.cs b/src/WatchPrototype/Watch/Build/EvaluationResult.cs
index 5ea9546bb2c..58910ba6c18 100644
--- a/src/WatchPrototype/Watch/Build/EvaluationResult.cs
+++ b/src/WatchPrototype/Watch/Build/EvaluationResult.cs
@@ -40,6 +40,8 @@ public ImmutableArray RestoredProjectInstances
public void WatchFiles(FileWatcher fileWatcher)
{
+ fileWatcher.Reset();
+
fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true);
fileWatcher.WatchContainingDirectories(
@@ -84,17 +86,18 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera
return null;
}
- var rootNode = projectGraph.GraphRoots.Single();
-
if (restore)
{
- using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore"))
+ foreach (var node in projectGraph.ProjectNodesTopologicallySorted)
{
- if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers))
+ using (var loggers = buildReporter.GetLoggers(node.ProjectInstance.FullPath, "Restore"))
{
- logger.LogError("Failed to restore '{Path}'.", rootNode.ProjectInstance.FullPath);
- loggers.ReportOutput();
- return null;
+ if (!node.ProjectInstance.Build([TargetNames.Restore], loggers))
+ {
+ logger.LogError("Failed to restore '{Path}'.", node.ProjectInstance.FullPath);
+ loggers.ReportOutput();
+ return null;
+ }
}
}
}
@@ -140,7 +143,8 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera
if (targets.Contains(TargetNames.GenerateComputedBuildStaticWebAssets) &&
projectInstance.GetIntermediateOutputDirectory() is { } outputDir &&
- StaticWebAssetsManifest.TryParseFile(Path.Combine(outputDir, StaticWebAsset.ManifestFileName), logger) is { } manifest)
+ StaticWebAsset.TryGetExistingManifestFile(outputDir) is { } manifestFilePath &&
+ StaticWebAssetsManifest.TryParseFile(manifestFilePath, logger) is { } manifest)
{
staticWebAssetManifests.Add(projectInstance.GetId(), manifest);
diff --git a/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs b/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs
index 321a1c5428b..9a2281fdaae 100644
--- a/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs
+++ b/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs
@@ -13,7 +13,10 @@
namespace Microsoft.DotNet.Watch;
-internal sealed class ProjectGraphFactory
+internal sealed class ProjectGraphFactory(
+ ImmutableArray rootProjects,
+ string? targetFramework,
+ ImmutableDictionary globalOptions)
{
///
/// Reuse with XML element caching to improve performance.
@@ -21,38 +24,18 @@ internal sealed class ProjectGraphFactory
/// The cache is automatically updated when build files change.
/// https://github.com/dotnet/msbuild/blob/b6f853defccd64ae1e9c7cf140e7e4de68bff07c/src/Build/Definition/ProjectCollection.cs#L343-L354
///
- private readonly ProjectCollection _collection;
-
- private readonly ImmutableDictionary _globalOptions;
- private readonly ProjectRepresentation _rootProject;
-
- // Only the root project can be virtual. #:project does not support targeting other single-file projects.
- private readonly VirtualProjectBuilder? _virtualRootProjectBuilder;
-
- public ProjectGraphFactory(
- ProjectRepresentation rootProject,
- string? targetFramework,
- ImmutableDictionary globalOptions)
- {
- _collection = new(
- globalProperties: globalOptions,
- loggers: [],
- remoteLoggers: [],
- ToolsetDefinitionLocations.Default,
- maxNodeCount: 1,
- onlyLogCriticalEvents: false,
- loadProjectsReadOnly: false,
- useAsynchronousLogging: false,
- reuseProjectRootElementCache: true);
-
- _globalOptions = globalOptions;
- _rootProject = rootProject;
-
- if (rootProject.EntryPointFilePath != null)
- {
- _virtualRootProjectBuilder = new VirtualProjectBuilder(rootProject.EntryPointFilePath, targetFramework ?? GetProductTargetFramework());
- }
- }
+ private readonly ProjectCollection _collection = new(
+ globalProperties: globalOptions,
+ loggers: [],
+ remoteLoggers: [],
+ ToolsetDefinitionLocations.Default,
+ maxNodeCount: 1,
+ onlyLogCriticalEvents: false,
+ loadProjectsReadOnly: false,
+ useAsynchronousLogging: false,
+ reuseProjectRootElementCache: true);
+
+ private readonly string _targetFramework = targetFramework ?? GetProductTargetFramework();
private static string GetProductTargetFramework()
{
@@ -62,17 +45,14 @@ private static string GetProductTargetFramework()
}
///
- /// Tries to create a project graph by running the build evaluation phase on the .
+ /// Tries to create a project graph by running the build evaluation phase on root projects.
///
- public ProjectGraph? TryLoadProjectGraph(
- ILogger logger,
- bool projectGraphRequired,
- CancellationToken cancellationToken)
+ public ProjectGraph? TryLoadProjectGraph(ILogger logger, bool projectGraphRequired, CancellationToken cancellationToken)
{
- var entryPoint = new ProjectGraphEntryPoint(_rootProject.ProjectGraphPath, _globalOptions);
+ var entryPoints = rootProjects.Select(p => new ProjectGraphEntryPoint(p.ProjectGraphPath, globalOptions));
try
{
- return new ProjectGraph([entryPoint], _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken);
+ return new ProjectGraph(entryPoints, _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken);
}
catch (ProjectCreationFailedException)
{
@@ -119,11 +99,18 @@ void Report(Exception e)
private ProjectInstance CreateProjectInstance(string projectPath, Dictionary globalProperties, ProjectCollection projectCollection, ILogger logger)
{
- if (_virtualRootProjectBuilder != null && projectPath == _rootProject.ProjectGraphPath)
+ if (!File.Exists(projectPath))
{
+ var entryPointFilePath = Path.ChangeExtension(projectPath, ".cs");
+ if (!File.Exists(entryPointFilePath))
+ {
+ throw new ProjectCreationFailedException();
+ }
+
+ var builder = new VirtualProjectBuilder(entryPointFilePath, _targetFramework);
var anyError = false;
- _virtualRootProjectBuilder.CreateProjectInstance(
+ builder.CreateProjectInstance(
projectCollection,
(sourceFile, textSpan, message) =>
{
diff --git a/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs b/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs
index f9cb892e412..886d474f086 100644
--- a/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs
+++ b/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs
@@ -1,6 +1,7 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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;
@@ -38,8 +39,8 @@ public string GetContainingDirectory()
public static ProjectRepresentation FromProjectOrEntryPointFilePath(string projectOrEntryPointFilePath)
=> string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".csproj", StringComparison.OrdinalIgnoreCase)
- ? new(projectPath: null, entryPointFilePath: projectOrEntryPointFilePath)
- : new(projectPath: projectOrEntryPointFilePath, entryPointFilePath: null);
+ ? new(projectPath: projectOrEntryPointFilePath, entryPointFilePath: null)
+ : new(projectPath: null, entryPointFilePath: projectOrEntryPointFilePath);
public ProjectRepresentation WithProjectGraphPath(string projectGraphPath)
=> new(projectGraphPath, PhysicalPath, EntryPointFilePath);
diff --git a/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs b/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs
index f7caada6824..7c84dbe8c2f 100644
--- a/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs
+++ b/src/WatchPrototype/Watch/Context/DotNetWatchContext.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.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace Microsoft.DotNet.Watch
@@ -18,11 +19,21 @@ internal sealed class DotNetWatchContext : IDisposable
public required ILoggerFactory LoggerFactory { get; init; }
public required ProcessRunner ProcessRunner { get; init; }
- public required ProjectOptions RootProjectOptions { get; init; }
+ public required ProjectOptions? RootProjectOptions { get; init; }
+
+ public required ImmutableArray RootProjects { get; init; }
+ public required string? LaunchProfileName { get; init; }
+ public required string? TargetFramework { get; init; }
+ public required IReadOnlyList BuildArguments { get; init; }
public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; }
public required BrowserLauncher BrowserLauncher { get; init; }
+ ///
+ /// Optional writer for sending watch status events (building, hot reload applied, etc.) back to the AppHost.
+ ///
+ public Func? StatusEventWriter { get; init; }
+
public void Dispose()
{
BrowserRefreshServerFactory.Dispose();
diff --git a/src/WatchPrototype/Watch/Context/ProjectOptions.cs b/src/WatchPrototype/Watch/Context/ProjectOptions.cs
index abede2e8ef2..0f3cf9e93d5 100644
--- a/src/WatchPrototype/Watch/Context/ProjectOptions.cs
+++ b/src/WatchPrototype/Watch/Context/ProjectOptions.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
namespace Microsoft.DotNet.Watch;
diff --git a/src/WatchPrototype/Watch/Context/WatchControlCommand.cs b/src/WatchPrototype/Watch/Context/WatchControlCommand.cs
new file mode 100644
index 00000000000..6183d7ccead
--- /dev/null
+++ b/src/WatchPrototype/Watch/Context/WatchControlCommand.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DotNet.Watch;
+
+internal sealed class WatchControlCommand
+{
+ [JsonPropertyName("type")]
+ public required string Type { get; init; }
+
+ [JsonPropertyName("projects")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? Projects { get; init; }
+
+ public static class Types
+ {
+ public const string Rebuild = "rebuild";
+ }
+}
diff --git a/src/WatchPrototype/Watch/Context/WatchStatusEvent.cs b/src/WatchPrototype/Watch/Context/WatchStatusEvent.cs
new file mode 100644
index 00000000000..2b3cb3841a6
--- /dev/null
+++ b/src/WatchPrototype/Watch/Context/WatchStatusEvent.cs
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+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 string[] 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/WatchPrototype/Watch/FileWatcher/FileWatcher.cs b/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs
index 1e445f5e9cb..b7d735f2bc3 100644
--- a/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs
+++ b/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs
@@ -21,6 +21,7 @@ internal class FileWatcher(ILogger logger, EnvironmentOptions environmentOptions
public event Action? OnFileChange;
public bool SuppressEvents { get; set; }
+ public DateTime StartTime { get; set; }
public void Dispose()
{
@@ -39,6 +40,34 @@ public void Dispose()
}
}
+ public void Reset()
+ {
+ _directoryTreeWatchers.Clear();
+ _directoryWatchers.Clear();
+ StartTime = DateTime.UtcNow;
+ }
+
+ public bool IsRecentChange(string path)
+ {
+ try
+ {
+ var lastWrite = File.GetLastWriteTimeUtc(path);
+ if (lastWrite >= StartTime)
+ {
+ logger.LogDebug("File last write time {LastWrite} >= {StartTime}", lastWrite, StartTime);
+ return true;
+ }
+
+ logger.LogDebug("Ignoring '{Path}' change as it predates the watcher", path);
+ return false;
+ }
+ catch (Exception e)
+ {
+ logger.LogDebug("Ignoring '{Path}' change. Unable to determine last write time: {Message}", path, e.Message);
+ return false;
+ }
+ }
+
protected virtual DirectoryWatcher CreateDirectoryWatcher(string directory, ImmutableHashSet fileNames, bool includeSubdirectories)
{
var watcher = DirectoryWatcher.Create(directory, fileNames, environmentOptions.IsPollingEnabled, includeSubdirectories);
@@ -205,20 +234,22 @@ void FileChangedCallback(ChangedPath change)
return change;
}
- public static async ValueTask WaitForFileChangeAsync(string filePath, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken)
+ public static async ValueTask WaitForFileChangeAsync(IEnumerable filePaths, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken)
{
using var watcher = new FileWatcher(logger, environmentOptions);
- watcher.WatchContainingDirectories([filePath], includeSubdirectories: false);
+ watcher.WatchContainingDirectories(filePaths, includeSubdirectories: false);
+
+ var pathSet = filePaths.ToHashSet();
var fileChange = await watcher.WaitForFileChangeAsync(
- acceptChange: change => change.Path == filePath,
+ acceptChange: change => pathSet.Contains(change.Path),
startedWatching,
cancellationToken);
if (fileChange != null)
{
- logger.LogInformation("File changed: {FilePath}", filePath);
+ logger.LogInformation("File changed: {FilePath}", fileChange.Value.Path);
}
}
}
diff --git a/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs b/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs
index 3a134fbc976..ab3c7046c2a 100644
--- a/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs
+++ b/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs
@@ -847,14 +847,14 @@ private static ImmutableDictionary> Crea
keySelector: static group => group.Key,
elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray());
- public async Task UpdateProjectConeAsync(ProjectGraph projectGraph, ProjectRepresentation project, CancellationToken cancellationToken)
+ public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken)
{
Logger.LogInformation("Loading projects ...");
var stopwatch = Stopwatch.StartNew();
_projectInstances = CreateProjectInstanceMap(projectGraph);
- var solution = await Workspace.UpdateProjectConeAsync(project.ProjectGraphPath, cancellationToken);
+ 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"));
diff --git a/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs
index c0ce376c665..2d4b4774ccd 100644
--- a/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs
+++ b/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs
@@ -4,6 +4,8 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Encodings.Web;
+using System.Xml.Linq;
+using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;
@@ -22,7 +24,9 @@ internal sealed class HotReloadDotNetWatcher
private readonly RestartPrompt? _rudeEditRestartPrompt;
private readonly DotNetWatchContext _context;
- private readonly ProjectGraphFactory _designTimeBuildGraphFactory;
+ private readonly ProjectGraphFactory _designTimeBuildGraphFactory = null!;
+
+ private volatile CancellationTokenSource? _forceRestartCancellationSource;
internal Task? Test_FileChangesCompletedTask { get; set; }
@@ -45,23 +49,26 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun
}
_designTimeBuildGraphFactory = new ProjectGraphFactory(
- _context.RootProjectOptions.Representation,
- _context.RootProjectOptions.TargetFramework,
+ _context.RootProjects,
+ _context.TargetFramework,
globalOptions: EvaluationResult.GetGlobalBuildOptions(
- context.RootProjectOptions.BuildArguments,
+ context.BuildArguments,
context.EnvironmentOptions));
}
- public async Task WatchAsync(CancellationToken shutdownCancellationToken)
+ internal void RequestRestart()
{
- CancellationTokenSource? forceRestartCancellationSource = null;
+ _forceRestartCancellationSource?.Cancel();
+ }
+ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
+ {
_context.Logger.Log(MessageDescriptor.HotReloadEnabled);
_context.Logger.Log(MessageDescriptor.PressCtrlRToRestart);
_console.KeyPressed += (key) =>
{
- if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.R && forceRestartCancellationSource is { } source)
+ if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.R && _forceRestartCancellationSource is { } source)
{
// provide immediate feedback to the user:
_context.Logger.Log(source.IsCancellationRequested ? MessageDescriptor.RestartInProgress : MessageDescriptor.RestartRequested);
@@ -73,12 +80,12 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++)
{
- Interlocked.Exchange(ref forceRestartCancellationSource, new CancellationTokenSource())?.Dispose();
+ Interlocked.Exchange(ref _forceRestartCancellationSource, new CancellationTokenSource())?.Dispose();
using var rootProcessTerminationSource = new CancellationTokenSource();
// This source will signal when the user cancels (either Ctrl+R or Ctrl+C) or when the root process terminates:
- using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token, rootProcessTerminationSource.Token);
+ using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, _forceRestartCancellationSource.Token, rootProcessTerminationSource.Token);
var iterationCancellationToken = iterationCancellationSource.Token;
var waitForFileChangeBeforeRestarting = true;
@@ -90,9 +97,13 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
try
{
- var rootProjectOptions = _context.RootProjectOptions;
+ var rootProjectPaths = _context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath);
+ await EmitStatusEventAsync(WatchStatusEvent.Types.Building, rootProjectPaths);
+
+ var buildSucceeded = await BuildProjectsAsync(_context.RootProjects, _context.BuildArguments, iterationCancellationToken);
+
+ await EmitStatusEventAsync(WatchStatusEvent.Types.BuildComplete, rootProjectPaths, success: buildSucceeded);
- var buildSucceeded = await BuildProjectAsync(rootProjectOptions.Representation, rootProjectOptions.BuildArguments, iterationCancellationToken);
if (!buildSucceeded)
{
continue;
@@ -101,78 +112,94 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
// Evaluate the target to find out the set of files to watch.
// In case the app fails to start due to build or other error we can wait for these files to change.
// Avoid restore since the build above already restored the root project.
- evaluationResult = await EvaluateRootProjectAsync(restore: false, iterationCancellationToken);
-
- var rootProject = evaluationResult.ProjectGraph.GraphRoots.Single();
-
- // use normalized MSBuild path so that we can index into the ProjectGraph
- rootProjectOptions = rootProjectOptions with
- {
- Representation = rootProjectOptions.Representation.WithProjectGraphPath(rootProject.ProjectInstance.FullPath)
- };
-
- var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory;
- var rootProjectCapabilities = rootProject.GetCapabilities();
- if (rootProjectCapabilities.Contains(AspireServiceFactory.AppHostProjectCapability))
- {
- runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance;
- _context.Logger.LogDebug("Using Aspire process launcher.");
- }
+ evaluationResult = await EvaluateProjectGraphAsync(restore: false, iterationCancellationToken);
var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger);
compilationHandler = new CompilationHandler(_context);
var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration);
evaluationResult.ItemExclusions.Report(_context.Logger);
- runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions);
- if (runtimeProcessLauncher != null)
+ var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory;
+
+ var rootProjectOptions = _context.RootProjectOptions;
+ var rootProject = (rootProjectOptions != null) ? evaluationResult.ProjectGraph.GraphRoots.Single() : null;
+
+ //var rootProjectCapabilities = rootProject.GetCapabilities();
+ //if (rootProjectCapabilities.Contains(AspireServiceFactory.AppHostProjectCapability))
+ //{
+ // runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance;
+ // _context.Logger.LogDebug("Using Aspire process launcher.");
+ //}
+
+ runtimeProcessLauncher = runtimeProcessLauncherFactory?.Create(
+ projectLauncher,
+ launchProfile: _context.LaunchProfileName,
+ targetFramework: _context.TargetFramework,
+ buildArguments: _context.BuildArguments,
+ onLaunchedProcessCrashed: () =>
+ {
+ // Mirror the root process onExit behavior (line 154-159):
+ // cancel the iteration, wait for file change, then restart.
+ waitForFileChangeBeforeRestarting = true;
+ iterationCancellationSource.Cancel();
+ });
+
+ if (rootProjectOptions != null)
{
- var launcherEnvironment = runtimeProcessLauncher.GetEnvironmentVariables();
- rootProjectOptions = rootProjectOptions with
+ if (runtimeProcessLauncher != null)
{
- LaunchEnvironmentVariables = [.. rootProjectOptions.LaunchEnvironmentVariables, .. launcherEnvironment]
- };
- }
+ rootProjectOptions = rootProjectOptions with
+ {
+ LaunchEnvironmentVariables = [.. rootProjectOptions.LaunchEnvironmentVariables, .. runtimeProcessLauncher.GetEnvironmentVariables()]
+ };
+ }
- rootRunningProject = await projectLauncher.TryLaunchProcessAsync(
- rootProjectOptions,
- rootProcessTerminationSource,
- onOutput: null,
- onExit: null,
- restartOperation: new RestartOperation(_ => default), // the process will automatically restart
- iterationCancellationToken);
+ rootRunningProject = await projectLauncher.TryLaunchProcessAsync(
+ rootProjectOptions,
+ rootProcessTerminationSource,
+ onOutput: null,
+ onExit: (_, _) =>
+ {
+ // Process exited: cancel the iteration, but wait for a file change before starting a new one
+ waitForFileChangeBeforeRestarting = true;
+ iterationCancellationSource.Cancel();
+ return ValueTask.CompletedTask;
+ },
+ restartOperation: new RestartOperation(_ => default), // the process will automatically restart
+ iterationCancellationToken);
- if (rootRunningProject == null)
- {
- // error has been reported:
- waitForFileChangeBeforeRestarting = false;
- return;
- }
+ if (rootRunningProject == null)
+ {
+ // error has been reported:
+ waitForFileChangeBeforeRestarting = false;
+ return;
+ }
- // Cancel iteration as soon as the root process exits, so that we don't spent time loading solution, etc. when the process is already dead.
- rootRunningProject.ProcessExitedCancellationToken.Register(iterationCancellationSource.Cancel);
+ // Cancel iteration as soon as the root process exits, so that we don't spent time loading solution, etc. when the process is already dead.
+ rootRunningProject.ProcessExitedCancellationToken.Register(iterationCancellationSource.Cancel);
- if (shutdownCancellationToken.IsCancellationRequested)
- {
- // Ctrl+C:
- return;
- }
+ if (shutdownCancellationToken.IsCancellationRequested)
+ {
+ // Ctrl+C:
+ return;
+ }
- if (!await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken))
- {
- // Process might have exited while we were trying to communicate with it.
- // Cancel the iteration, but wait for a file change before starting a new one.
- iterationCancellationSource.Cancel();
- iterationCancellationSource.Token.ThrowIfCancellationRequested();
- }
+ if (!await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken))
+ {
+ // Process might have exited while we were trying to communicate with it.
+ // Cancel the iteration, but wait for a file change before starting a new one.
+ iterationCancellationSource.Cancel();
+ iterationCancellationSource.Token.ThrowIfCancellationRequested();
+ }
- if (shutdownCancellationToken.IsCancellationRequested)
- {
- // Ctrl+C:
- return;
+ if (shutdownCancellationToken.IsCancellationRequested)
+ {
+ // Ctrl+C:
+ return;
+ }
}
- await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.Representation, iterationCancellationToken);
+ await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph, iterationCancellationToken);
// Solution must be initialized after we load the solution but before we start watching for file changes to avoid race condition
// when the EnC session captures content of the file after the changes has already been made.
@@ -203,7 +230,7 @@ void FileChangedCallback(ChangedPath change)
_context.Logger.Log(MessageDescriptor.WaitingForChanges);
// Hot Reload loop - exits when the root process needs to be restarted.
- bool extendTimeout = false;
+ var extendTimeout = false;
while (true)
{
try
@@ -215,16 +242,7 @@ void FileChangedCallback(ChangedPath change)
// Use timeout to batch file changes. If the process doesn't exit within the given timespan we'll check
// for accumulated file changes. If there are any we attempt Hot Reload. Otherwise we come back here to wait again.
- _ = await rootRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken);
-
- // Process exited: cancel the iteration, but wait for a file change before starting a new one
- waitForFileChangeBeforeRestarting = true;
- iterationCancellationSource.Cancel();
- break;
- }
- catch (TimeoutException)
- {
- // check for changed files
+ await Task.Delay(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken);
}
catch (OperationCanceledException)
{
@@ -252,16 +270,6 @@ void FileChangedCallback(ChangedPath change)
continue;
}
- if (!rootProjectCapabilities.Contains("SupportsHotReload"))
- {
- _context.Logger.LogWarning("Project '{Name}' does not support Hot Reload and must be rebuilt.", rootProject.GetDisplayName());
-
- // file change already detected
- waitForFileChangeBeforeRestarting = false;
- iterationCancellationSource.Cancel();
- break;
- }
-
HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.Main);
var stopwatch = Stopwatch.StartNew();
@@ -331,33 +339,27 @@ void FileChangedCallback(ChangedPath change)
{
iterationCancellationToken.ThrowIfCancellationRequested();
+ await EmitStatusEventAsync(WatchStatusEvent.Types.Building, projectsToRebuild);
+
// pause accumulating file changes during build:
fileWatcher.SuppressEvents = true;
+ bool rebuildSuccess;
try
{
- // Build projects sequentially to avoid failed attempts to overwrite dependent project outputs.
- // TODO: Ideally, dotnet build would be able to build multiple projects. https://github.com/dotnet/sdk/issues/51311
- var success = true;
- foreach (var projectPath in projectsToRebuild)
- {
- // The path of the Workspace Project is the entry-point file path for single-file apps.
- success = await BuildProjectAsync(ProjectRepresentation.FromProjectOrEntryPointFilePath(projectPath), rootProjectOptions.BuildArguments, iterationCancellationToken);
- if (!success)
- {
- break;
- }
- }
-
- if (success)
- {
- break;
- }
+ rebuildSuccess = await BuildProjectsAsync([.. projectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], _context.BuildArguments, iterationCancellationToken);
}
finally
{
fileWatcher.SuppressEvents = false;
}
+ await EmitStatusEventAsync(WatchStatusEvent.Types.BuildComplete, projectsToRebuild, success: rebuildSuccess);
+
+ if (rebuildSuccess)
+ {
+ break;
+ }
+
iterationCancellationToken.ThrowIfCancellationRequested();
_ = await fileWatcher.WaitForFileChangeAsync(
@@ -385,10 +387,13 @@ void FileChangedCallback(ChangedPath change)
if (!managedCodeUpdates.IsEmpty)
{
await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, stopwatch, iterationCancellationToken);
+ await EmitStatusEventAsync(WatchStatusEvent.Types.HotReloadApplied, changedFiles.SelectMany(f => f.Item.ContainingProjectPaths).Distinct());
}
if (!projectsToRestart.IsEmpty)
{
+ await EmitStatusEventAsync(WatchStatusEvent.Types.Restarting, projectsToRestart.Select(p => p.Options.Representation.ProjectOrEntryPointFilePath));
+
await Task.WhenAll(
projectsToRestart.Select(async runningProject =>
{
@@ -397,6 +402,9 @@ await Task.WhenAll(
}))
.WaitAsync(shutdownCancellationToken);
+ // ProcessStarted event (emitted by ProcessLauncherFactory.StartProjectAsync)
+ // already set the state to Running. No additional signal needed here.
+
_context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Length);
}
@@ -433,6 +441,12 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr
new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] },
changedPath.Kind);
})
+ .Where(change => change.Kind switch
+ {
+ ChangeKind.Add or ChangeKind.Update => fileWatcher.IsRecentChange(change.Item.FilePath),
+ ChangeKind.Delete => true,
+ _ => throw new InvalidOperationException()
+ })
.ToList();
ReportFileChanges(changedFiles);
@@ -442,12 +456,12 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr
if (evaluationRequired)
{
// TODO: consider re-evaluating only affected projects instead of the whole graph.
- evaluationResult = await EvaluateRootProjectAsync(restore: true, iterationCancellationToken);
+ evaluationResult = await EvaluateProjectGraphAsync(restore: true, iterationCancellationToken);
// additional files/directories may have been added:
evaluationResult.WatchFiles(fileWatcher);
- await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.Representation, iterationCancellationToken);
+ await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph, iterationCancellationToken);
if (shutdownCancellationToken.IsCancellationRequested)
{
@@ -543,15 +557,21 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr
if (runtimeProcessLauncher != null)
{
- await runtimeProcessLauncher.DisposeAsync();
+ // Only dispose the launcher on full shutdown (Ctrl+C).
+ // During iteration restarts (crash recovery, rebuild command, forced restart),
+ // keep it alive so the DCP resource command's pipe connection survives.
+ if (shutdownCancellationToken.IsCancellationRequested)
+ {
+ await runtimeProcessLauncher.DisposeAsync();
+ }
}
if (waitForFileChangeBeforeRestarting &&
!shutdownCancellationToken.IsCancellationRequested &&
- !forceRestartCancellationSource.IsCancellationRequested &&
+ !_forceRestartCancellationSource.IsCancellationRequested &&
rootRunningProject?.IsRestarting != true)
{
- using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token);
+ using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, _forceRestartCancellationSource.Token);
await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token);
}
}
@@ -742,7 +762,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
else
{
// evaluation cancelled - watch for any changes in the directory tree containing the root project or entry-point file:
- fileWatcher.WatchContainingDirectories([_context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath], includeSubdirectories: true);
+ fileWatcher.WatchContainingDirectories(_context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), includeSubdirectories: true);
_ = await fileWatcher.WaitForFileChangeAsync(
acceptChange: AcceptChange,
@@ -919,7 +939,7 @@ static string GetPluralMessage(ChangeKind kind)
};
}
- private async ValueTask EvaluateRootProjectAsync(bool restore, CancellationToken cancellationToken)
+ private async ValueTask EvaluateProjectGraphAsync(bool restore, CancellationToken cancellationToken)
{
while (true)
{
@@ -944,7 +964,7 @@ private async ValueTask EvaluateRootProjectAsync(bool restore,
}
await FileWatcher.WaitForFileChangeAsync(
- _context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath,
+ _context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath),
_context.Logger,
_context.EnvironmentOptions,
startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError),
@@ -952,43 +972,102 @@ await FileWatcher.WaitForFileChangeAsync(
}
}
- private async Task BuildProjectAsync(ProjectRepresentation project, IReadOnlyList buildArguments, CancellationToken cancellationToken)
+ private async Task BuildProjectsAsync(ImmutableArray projects, IReadOnlyList buildArguments, CancellationToken cancellationToken)
{
List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null;
-
- var processSpec = new ProcessSpec
+ string? solutionFile = null;
+ try
{
- Executable = _context.EnvironmentOptions.MuxerPath,
- WorkingDirectory = project.GetContainingDirectory(),
- IsUserApplication = false,
-
- // Capture output if running in a test environment.
- // If the output is not captured dotnet build will show live build progress.
- OnOutput = capturedOutput != null
- ? line =>
+ // TODO: workaround for https://github.com/dotnet/sdk/issues/51311
+ // does not work with single-file apps
+ if (projects is not [var project])
+ {
+ solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx");
+
+ var solutionElement = new XElement("Solution");
+
+ foreach (var p in projects)
{
- lock (capturedOutput)
+ if (p.PhysicalPath != null)
{
- capturedOutput.Add(line);
+ solutionElement.Add(new XElement("Project", new XAttribute("Path", p.PhysicalPath)));
}
}
+
+ var doc = new XDocument(solutionElement);
+ doc.Save(solutionFile);
+
+ project = new ProjectRepresentation(projectPath: solutionFile, entryPointFilePath: null);
+ }
+
+ var processSpec = new ProcessSpec
+ {
+ Executable = _context.EnvironmentOptions.MuxerPath,
+ WorkingDirectory = project.GetContainingDirectory(),
+ IsUserApplication = false,
+
+ // Capture output if running in a test environment.
+ // If the output is not captured dotnet build will show live build progress.
+ OnOutput = capturedOutput != null
+ ? line =>
+ {
+ lock (capturedOutput)
+ {
+ capturedOutput.Add(line);
+ }
+ }
: null,
- // pass user-specified build arguments last to override defaults:
- Arguments = ["build", project.ProjectOrEntryPointFilePath, .. buildArguments]
- };
+ // pass user-specified build arguments last to override defaults:
+ Arguments = ["build", project.ProjectOrEntryPointFilePath, .. buildArguments]
+ };
- _context.BuildLogger.Log(MessageDescriptor.Building, project.ProjectOrEntryPointFilePath);
+ _context.BuildLogger.Log(MessageDescriptor.Building, project.ProjectOrEntryPointFilePath);
- var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0;
+ var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0;
+
+ if (capturedOutput != null)
+ {
+ _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, project.ProjectOrEntryPointFilePath);
+ BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success);
+ }
- if (capturedOutput != null)
+ return success;
+ }
+ finally
+ {
+ if (solutionFile != null)
+ {
+ try
+ {
+ File.Delete(solutionFile);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+ }
+
+ private Task EmitStatusEventAsync(string type, IEnumerable projectPaths, bool? success = null, string? error = null)
+ {
+ var projects = projectPaths.ToArray();
+ _context.Logger.LogDebug("Status event: {Type} (success={Success}) for [{Projects}]",
+ type, success, string.Join(", ", projects.Select(p => Path.GetFileNameWithoutExtension(p))));
+
+ if (_context.StatusEventWriter is not { } writer)
{
- _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, project.ProjectOrEntryPointFilePath);
- BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success);
+ return Task.CompletedTask;
}
- return success;
+ return writer(new WatchStatusEvent
+ {
+ Type = type,
+ Projects = projects,
+ Success = success,
+ Error = error,
+ });
}
private string GetRelativeFilePath(string path)
diff --git a/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj
index 282250d8e49..af9f6f7ea32 100644
--- a/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj
+++ b/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj
@@ -25,10 +25,11 @@
-
-
-
-
+
+
+
+
+
diff --git a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs
index 93d69f69db5..aa83b04defd 100644
--- a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs
+++ b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Build.Graph;
@@ -12,5 +12,5 @@ namespace Microsoft.DotNet.Watch;
///
internal interface IRuntimeProcessLauncherFactory
{
- public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions);
+ IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher, string? launchProfile, string? targetFramework, IReadOnlyList buildArguments, Action? onLaunchedProcessCrashed = null);
}
diff --git a/src/WatchPrototype/Watch/Process/ProjectLauncher.cs b/src/WatchPrototype/Watch/Process/ProjectLauncher.cs
index e3e97b3da4f..3de75f7c600 100644
--- a/src/WatchPrototype/Watch/Process/ProjectLauncher.cs
+++ b/src/WatchPrototype/Watch/Process/ProjectLauncher.cs
@@ -1,6 +1,7 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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.Globalization;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;
@@ -88,7 +89,8 @@ public EnvironmentOptions EnvironmentOptions
}
// override any project settings:
- environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1";
+ // TODO: Suppress DCP watch mode.
+ // environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1";
environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture);
if (Logger.IsEnabled(LogLevel.Trace))
@@ -117,12 +119,25 @@ public EnvironmentOptions EnvironmentOptions
private static IReadOnlyList GetProcessArguments(ProjectOptions projectOptions, IDictionary environmentBuilder)
{
- var arguments = new List()
+ var arguments = new List
{
projectOptions.Command,
- "--no-build"
+ "--no-build",
};
+ if (projectOptions.Representation.PhysicalPath != null)
+ {
+ arguments.Add("--project");
+ arguments.Add(projectOptions.Representation.PhysicalPath);
+ }
+ else
+ {
+ Debug.Assert(projectOptions.Representation.EntryPointFilePath != null);
+
+ arguments.Add("--file");
+ arguments.Add(projectOptions.Representation.EntryPointFilePath);
+ }
+
foreach (var (name, value) in environmentBuilder)
{
arguments.Add("-e");
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index 8840c964dd1..b4ceb57f9b4 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -2191,7 +2191,8 @@ private static DcpExecutor CreateAppExecutor(
IKubernetesService? kubernetesService = null,
DcpOptions? dcpOptions = null,
ResourceLoggerService? resourceLoggerService = null,
- DcpExecutorEvents? events = null)
+ DcpExecutorEvents? events = null,
+ ResourceNotificationService? notificationService = null)
{
if (configuration == null)
{
@@ -2208,6 +2209,7 @@ private static DcpExecutor CreateAppExecutor(
resourceLoggerService ??= new ResourceLoggerService();
dcpOptions ??= new DcpOptions { DashboardPath = "./dashboard" };
+ notificationService ??= ResourceNotificationServiceTestHelpers.Create();
var developerCertificateService = new TestDeveloperCertificateService(new List(), false, false, false);
@@ -2234,7 +2236,8 @@ private static DcpExecutor CreateAppExecutor(
new DcpNameGenerator(configuration, Options.Create(dcpOptions)),
events ?? new DcpExecutorEvents(),
new Locations(new FileSystemService(configuration ?? new ConfigurationBuilder().Build())),
- developerCertificateService);
+ developerCertificateService,
+ notificationService);
#pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}