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. }