diff --git a/Directory.Build.props b/Directory.Build.props index e6d941e1378..00e186351e2 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 2e61a3dd366..9116a7de0cd 100644 --- a/NuGet.config +++ b/NuGet.config @@ -20,6 +20,7 @@ + @@ -31,6 +32,7 @@ + @@ -38,8 +40,11 @@ + + + - + 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 24c12e01e38..558d09652e0 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; @@ -78,6 +79,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); @@ -92,6 +94,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( @@ -112,7 +119,8 @@ public DcpExecutor(ILogger logger, DcpNameGenerator nameGenerator, DcpExecutorEvents executorEvents, Locations locations, - IDeveloperCertificateService developerCertificateService) + IDeveloperCertificateService developerCertificateService, + ResourceNotificationService notificationService) { _distributedApplicationLogger = distributedApplicationLogger; _kubernetesService = kubernetesService; @@ -132,6 +140,7 @@ public DcpExecutor(ILogger logger, _normalizedApplicationName = NormalizeApplicationName(hostEnvironment.ApplicationName); _locations = locations; _developerCertificateService = developerCertificateService; + _notificationService = notificationService; DeleteResourceRetryPipeline = DcpPipelineBuilder.BuildDeleteRetryPipeline(logger); WatchResourceRetryPipeline = DcpPipelineBuilder.BuildWatchResourcePipeline(logger); @@ -359,6 +368,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); } @@ -1361,11 +1391,249 @@ private async Task PrepareServicesAsync(CancellationToken cancellationToken) 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(); @@ -1483,8 +1751,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"); @@ -1497,29 +1784,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. @@ -1838,6 +2110,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 ed9d18df59f..34aa8f8fa90 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -110,6 +110,11 @@ internal sealed class DcpOptions /// Enables Aspire container tunnel for container-to-host connectivity across all container orchestrators. /// public bool EnableAspireContainerTunnel { get; set; } = true; + + /// + /// Optional path to the Watch.Aspire tool used for hot reload support. + /// + public string? WatchAspirePath { get; set; } } internal class ValidateDcpOptions : IValidateOptions @@ -139,6 +144,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); @@ -232,6 +238,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 d472d4e870c..07ad06f6f9e 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -476,6 +476,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/.editorconfig b/src/WatchPrototype/.editorconfig index 54d2f34da8e..c0d281bec4d 100644 --- a/src/WatchPrototype/.editorconfig +++ b/src/WatchPrototype/.editorconfig @@ -14,12 +14,14 @@ dotnet_diagnostic.CA2008.severity = none # Do not create tasks without passing a # CS - C# compiler warnings/errors dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member -dotnet_diagnostic.CS1573.severity = none # Parameter 'sourceFile' has no matching param tag in the XML comment +dotnet_diagnostic.CS1573.severity = none # Parameter has no matching param tag in the XML comment +dotnet_diagnostic.CS1572.severity = warning # XML comment has a param tag for '...', but there is no parameter by that name # IDE - IDE/Style warnings dotnet_diagnostic.IDE0005.severity = none # Using directive is unnecessary dotnet_diagnostic.IDE0011.severity = none # Add braces dotnet_diagnostic.IDE0036.severity = none # Order modifiers +dotnet_diagnostic.IDE0044.severity = none # Make field readonly dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter dotnet_diagnostic.IDE0073.severity = none # File header does not match required text dotnet_diagnostic.IDE0161.severity = none # Convert to file-scoped namespace diff --git a/src/WatchPrototype/Common/PathUtilities.cs b/src/WatchPrototype/Common/PathUtilities.cs deleted file mode 100644 index 250098acea0..00000000000 --- a/src/WatchPrototype/Common/PathUtilities.cs +++ /dev/null @@ -1,8 +0,0 @@ -// 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; - -static class PathUtilities -{ -} diff --git a/src/WatchPrototype/Directory.Build.props b/src/WatchPrototype/Directory.Build.props index e1dbcf6c450..7e8db377010 100644 --- a/src/WatchPrototype/Directory.Build.props +++ b/src/WatchPrototype/Directory.Build.props @@ -4,7 +4,8 @@ false true - net472 + + net8.0 net10.0 @@ -14,6 +15,10 @@ + + $(RepoRoot)src\WatchPrototype\Microsoft.DotNet.ProjectTools\ + + diff --git a/src/WatchPrototype/Directory.Packages.props b/src/WatchPrototype/Directory.Packages.props index 7ca24acc6cf..822546d50b7 100644 --- a/src/WatchPrototype/Directory.Packages.props +++ b/src/WatchPrototype/Directory.Packages.props @@ -2,16 +2,21 @@ - - + + + + - - - - - - + + + + + + + + + diff --git a/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.cs b/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.cs index 2bcec8b91ff..97bd230100e 100644 --- a/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.cs +++ b/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.cs @@ -13,6 +13,18 @@ internal static class AgentEnvironmentVariables /// public const string DotNetWatchHotReloadNamedPipeName = "DOTNET_WATCH_HOTRELOAD_NAMEDPIPE_NAME"; + /// + /// WebSocket endpoint for hot reload communication. Used for mobile platforms (Android, iOS) + /// where named pipes don't work over the network. + /// + public const string DotNetWatchHotReloadWebSocketEndpoint = "DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT"; + + /// + /// RSA public key (Base64-encoded X.509 SubjectPublicKeyInfo) for WebSocket connection authentication. + /// The client encrypts a random secret with this key and sends it as the WebSocket subprotocol. + /// + public const string DotNetWatchHotReloadWebSocketKey = "DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY"; + /// /// Enables logging from the client delta applier agent. /// diff --git a/src/WatchPrototype/HotReloadAgent.Host/PipeListener.cs b/src/WatchPrototype/HotReloadAgent.Host/Listener.cs similarity index 71% rename from src/WatchPrototype/HotReloadAgent.Host/PipeListener.cs rename to src/WatchPrototype/HotReloadAgent.Host/Listener.cs index dfa108189df..6cf359f55e6 100644 --- a/src/WatchPrototype/HotReloadAgent.Host/PipeListener.cs +++ b/src/WatchPrototype/HotReloadAgent.Host/Listener.cs @@ -4,16 +4,13 @@ #nullable enable using System; -using System.Diagnostics; -using System.IO.Pipes; -using System.Reflection; -using System.Runtime.Loader; +using System.IO; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DotNet.HotReload; -internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Action log, int connectionTimeoutMS = 5000) +internal sealed class Listener(Transport transport, IHotReloadAgent agent, Action log) { /// /// Messages to the client sent after the initial is sent @@ -23,9 +20,6 @@ internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Actio /// private readonly SemaphoreSlim _messageToClientLock = new(initialCount: 1); - // Not-null once initialized: - private NamedPipeClientStream? _pipeClient; - public Task Listen(CancellationToken cancellationToken) { // Connect to the pipe synchronously. @@ -36,20 +30,7 @@ public Task Listen(CancellationToken cancellationToken) // // Updates made before the process is launched need to be applied before loading the affected modules. - log($"Connecting to hot-reload server via pipe {pipeName}"); - - _pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); - try - { - _pipeClient.Connect(connectionTimeoutMS); - log("Connected."); - } - catch (TimeoutException) - { - log($"Failed to connect in {connectionTimeoutMS}ms."); - _pipeClient.Dispose(); - return Task.CompletedTask; - } + log($"Connecting to Hot Reload server via {transport.DisplayName}."); try { @@ -63,7 +44,7 @@ public Task Listen(CancellationToken cancellationToken) log(e.Message); } - _pipeClient.Dispose(); + transport.Dispose(); agent.Dispose(); return Task.CompletedTask; @@ -81,7 +62,7 @@ public Task Listen(CancellationToken cancellationToken) } finally { - _pipeClient.Dispose(); + transport.Dispose(); agent.Dispose(); } }, cancellationToken); @@ -89,12 +70,9 @@ public Task Listen(CancellationToken cancellationToken) private async Task InitializeAsync(CancellationToken cancellationToken) { - Debug.Assert(_pipeClient != null); - agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose); - var initPayload = new ClientInitializationResponse(agent.Capabilities); - await initPayload.WriteAsync(_pipeClient, cancellationToken); + await transport.SendAsync(new ClientInitializationResponse(agent.Capabilities), cancellationToken); // Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules. @@ -106,19 +84,23 @@ private async Task InitializeAsync(CancellationToken cancellationToken) private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, CancellationToken cancellationToken) { - Debug.Assert(_pipeClient != null); - - while (_pipeClient.IsConnected) + while (!cancellationToken.IsCancellationRequested) { - var payloadType = (RequestType)await _pipeClient.ReadByteAsync(cancellationToken); + using var request = await transport.ReceiveAsync(cancellationToken); + if (request.Stream == null) + { + break; + } + + var payloadType = (RequestType)await request.Stream.ReadByteAsync(cancellationToken); switch (payloadType) { case RequestType.ManagedCodeUpdate: - await ReadAndApplyManagedCodeUpdateAsync(cancellationToken); + await ReadAndApplyManagedCodeUpdateAsync(request.Stream, cancellationToken); break; case RequestType.StaticAssetUpdate: - await ReadAndApplyStaticAssetUpdateAsync(cancellationToken); + await ReadAndApplyStaticAssetUpdateAsync(request.Stream, cancellationToken); break; case RequestType.InitialUpdatesCompleted when initialUpdates: @@ -131,11 +113,9 @@ private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, Cancellation } } - private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken cancellationToken) + private async ValueTask ReadAndApplyManagedCodeUpdateAsync(Stream stream, CancellationToken cancellationToken) { - Debug.Assert(_pipeClient != null); - - var request = await ManagedCodeUpdateRequest.ReadAsync(_pipeClient, cancellationToken); + var request = await ManagedCodeUpdateRequest.ReadAsync(stream, cancellationToken); bool success; try @@ -155,11 +135,9 @@ private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken can await SendResponseAsync(new UpdateResponse(logEntries, success), cancellationToken); } - private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken cancellationToken) + private async ValueTask ReadAndApplyStaticAssetUpdateAsync(Stream stream, CancellationToken cancellationToken) { - Debug.Assert(_pipeClient != null); - - var request = await StaticAssetUpdateRequest.ReadAsync(_pipeClient, cancellationToken); + var request = await StaticAssetUpdateRequest.ReadAsync(stream, cancellationToken); try { @@ -181,12 +159,10 @@ private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken can internal async ValueTask SendResponseAsync(T response, CancellationToken cancellationToken) where T : IResponse { - Debug.Assert(_pipeClient != null); try { await _messageToClientLock.WaitAsync(cancellationToken); - await _pipeClient.WriteAsync((byte)response.Type, cancellationToken); - await response.WriteAsync(_pipeClient, cancellationToken); + await transport.SendAsync(response, cancellationToken); } finally { diff --git a/src/WatchPrototype/HotReloadAgent.Host/NamedPipeTransport.cs b/src/WatchPrototype/HotReloadAgent.Host/NamedPipeTransport.cs new file mode 100644 index 00000000000..962c4e85fe6 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/NamedPipeTransport.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class NamedPipeTransport(string pipeName, Action log, int timeoutMS) : Transport(log) +{ + private readonly NamedPipeClientStream _pipeClient = new(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); + + public override void Dispose() + => _pipeClient.Dispose(); + + public override string DisplayName + => $"pipe {pipeName}"; + + public override async ValueTask SendAsync(IResponse response, CancellationToken cancellationToken) + { + if (response.Type == ResponseType.InitializationResponse) + { + try + { + _pipeClient.Connect(timeoutMS); + } + catch (TimeoutException) + { + throw new TimeoutException($"Failed to connect in {timeoutMS}ms."); + } + } + + await _pipeClient.WriteAsync((byte)response.Type, cancellationToken); + await response.WriteAsync(_pipeClient, cancellationToken); + } + + public override ValueTask ReceiveAsync(CancellationToken cancellationToken) + => new(new RequestStream(_pipeClient.IsConnected ? _pipeClient : null, disposeOnCompletion: false)); +} diff --git a/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs b/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs index 3ad6762d2d1..c9fe36e2045 100644 --- a/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs +++ b/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs @@ -6,9 +6,7 @@ using System; using System.Diagnostics; using System.IO; -using System.IO.Pipes; using System.Linq; -using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Loader; using System.Threading; @@ -21,7 +19,6 @@ internal sealed class StartupHook { private static readonly string? s_standardOutputLogPrefix = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages); - private static readonly string? s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName); private static readonly bool s_supportsConsoleColor = !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS() @@ -42,17 +39,19 @@ public static void Initialize() Log($"Loaded into process: {processPath} ({typeof(StartupHook).Assembly.Location})"); + var transport = Transport.TryCreate(Log); + HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook)); - if (string.IsNullOrEmpty(s_namedPipeName)) + if (transport == null) { - Log($"Environment variable {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} has no value"); + Log($"No hot reload endpoint configured. Set {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} or {AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint}"); return; } RegisterSignalHandlers(); - PipeListener? listener = null; + Listener? listener = null; var agent = new HotReloadAgent( assemblyResolvingHandler: (_, args) => @@ -94,7 +93,7 @@ async Task SendAndForgetAsync() } }); - listener = new PipeListener(s_namedPipeName, agent, Log); + listener = new Listener(transport, agent, Log); // fire and forget: _ = listener.Listen(CancellationToken.None); diff --git a/src/WatchPrototype/HotReloadAgent.Host/Transport.cs b/src/WatchPrototype/HotReloadAgent.Host/Transport.cs new file mode 100644 index 00000000000..d48c33bdda3 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/Transport.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal abstract class Transport(Action log) : IDisposable +{ + public readonly struct RequestStream(Stream? stream, bool disposeOnCompletion) : IDisposable + { + public Stream? Stream => stream; + + public void Dispose() + { + if (disposeOnCompletion) + { + stream?.Dispose(); + } + } + } + + public static Transport? TryCreate(Action log, int timeoutMS = 5000) + { + var namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName); + if (!string.IsNullOrEmpty(namedPipeName)) + { + log($"{AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName}={namedPipeName}"); + return new NamedPipeTransport(namedPipeName, log, timeoutMS); + } + + var webSocketEndpoint = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint); + if (!string.IsNullOrEmpty(webSocketEndpoint)) + { + if (!Uri.TryCreate(webSocketEndpoint, UriKind.Absolute, out var uri) || + uri.Scheme is not ("ws" or "wss")) + { + log($"Invalid WebSocket endpoint (expected ws:// or wss:// URL): '{webSocketEndpoint}'"); + return null; + } + + var serverPublicKey = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey); + if (string.IsNullOrEmpty(serverPublicKey)) + { + log($"{AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey} must be set when using WebSocket endpoint."); + return null; + } + + log($"{AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint}={webSocketEndpoint}"); + return new WebSocketTransport(webSocketEndpoint, serverPublicKey, log, timeoutMS); + } + + return null; + } + + protected void Log(string message) + => log(message); + + public abstract void Dispose(); + public abstract string DisplayName { get; } + public abstract ValueTask SendAsync(IResponse response, CancellationToken cancellationToken); + public abstract ValueTask ReceiveAsync(CancellationToken cancellationToken); +} diff --git a/src/WatchPrototype/HotReloadAgent.Host/WebSocketTransport.cs b/src/WatchPrototype/HotReloadAgent.Host/WebSocketTransport.cs new file mode 100644 index 00000000000..899d67cddca --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/WebSocketTransport.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Buffers; +using System.IO; +using System.Net; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +/// +/// WebSocket-based client for hot reload communication. +/// Used for projects with the HotReloadWebSockets capability (e.g., Android, iOS). +/// Mobile workloads add this capability since named pipes don't work over the network. +/// Uses RSA-based shared secret for authentication (same as BrowserRefreshServer). +/// +internal sealed class WebSocketTransport(string serverUrl, string? serverPublicKey, Action log, int connectionTimeoutMS) + : Transport(log) +{ + private readonly ClientWebSocket _webSocket = new(); + + // Buffers for WebSocket messages - reused across calls to avoid allocations. + // SendAsync is invoked under a lock after the first message, so _sendBuffer is safe to reuse. + private MemoryStream? _sendBuffer; + private MemoryStream? _receiveBuffer; + + public override void Dispose() + { + _webSocket.Dispose(); + _sendBuffer?.Dispose(); + _receiveBuffer?.Dispose(); + } + + public override string DisplayName + => $"WebSocket {serverUrl}"; + + public override async ValueTask SendAsync(IResponse response, CancellationToken cancellationToken) + { + // Connect on first send (which is InitializationResponse) + if (response.Type == ResponseType.InitializationResponse) + { + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectCts.CancelAfter(connectionTimeoutMS); + + try + { + // Add encrypted shared secret as subprotocol for authentication + if (serverPublicKey != null) + { + var encryptedSecret = EncryptSharedSecret(serverPublicKey); + _webSocket.Options.AddSubProtocol(encryptedSecret); + } + + Log($"Connecting to {serverUrl}..."); + await _webSocket.ConnectAsync(new Uri(serverUrl), connectCts.Token); + Log("Connected."); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Failed to connect in {connectionTimeoutMS}ms."); + } + } + + // Serialize the response to a reusable buffer + _sendBuffer ??= new MemoryStream(); + _sendBuffer.SetLength(0); + + await _sendBuffer.WriteAsync((byte)response.Type, cancellationToken); + await response.WriteAsync(_sendBuffer, cancellationToken); + + Log($"Sending {response.Type} ({_sendBuffer.Length} bytes)"); + + // Send as binary WebSocket message + await _webSocket.SendAsync( + new ArraySegment(_sendBuffer.GetBuffer(), 0, (int)_sendBuffer.Length), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); + } + + public override async ValueTask ReceiveAsync(CancellationToken cancellationToken) + { + if (_webSocket.State != WebSocketState.Open) + { + return new RequestStream(stream: null, disposeOnCompletion: false); + } + + // Read the complete WebSocket message into a buffer + _receiveBuffer ??= new MemoryStream(); + _receiveBuffer.SetLength(0); + + var buffer = ArrayPool.Shared.Rent(4096); + try + { + WebSocketReceiveResult result; + do + { + result = await _webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + { + Log("Server closed connection."); + return new RequestStream(stream: null, disposeOnCompletion: false); + } + + _receiveBuffer.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + Log($"Received {_receiveBuffer.Length} bytes"); + _receiveBuffer.Position = 0; + + // Return a stream that doesn't dispose the underlying buffer (we reuse it) + return new RequestStream(_receiveBuffer, disposeOnCompletion: false); + } + + /// + /// Encrypts a random shared secret using the server's RSA public key. + /// Uses the same algorithm as BrowserRefreshServer for consistency. + /// + private static string EncryptSharedSecret(string serverPublicKeyBase64) + { + using var rsa = RSA.Create(); + rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(serverPublicKeyBase64), out _); + + // Generate a random 32-byte secret and encrypt with RSA OAEP SHA-256 (same as BrowserRefreshServer) + // RSA.Encrypt(ReadOnlySpan) overload is available in .NET 9+ +#if NET9_0_OR_GREATER + Span secret = stackalloc byte[32]; +#else + var secret = new byte[32]; +#endif + RandomNumberGenerator.Fill(secret); + var encrypted = rsa.Encrypt(secret, RSAEncryptionPadding.OaepSHA256); + + // URL-encode standard Base64 for WebSocket subprotocol header (same encoding as BrowserRefreshServer) + return WebUtility.UrlEncode(Convert.ToBase64String(encrypted)); + } +} diff --git a/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj b/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj deleted file mode 100644 index 1cad23202de..00000000000 --- a/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - $(SdkTargetFramework) - false - false - preview - true - - - true - true - true - Microsoft.DotNet.HotReload.WebAssembly.Browser - HotReload package for WebAssembly - - $(NoWarn);NU5128 - - - - - - - - - diff --git a/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs b/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs deleted file mode 100644 index 1aad8e47f07..00000000000 --- a/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs +++ /dev/null @@ -1,179 +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.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Reflection.Metadata; -using System.Runtime.InteropServices.JavaScript; -using System.Runtime.Versioning; -using System.Text.Json; -using System.Text.Json.Serialization; - -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - -namespace Microsoft.DotNet.HotReload.WebAssembly.Browser; - -/// -/// Contains methods called by interop. Intended for framework use only, not supported for use in application -/// code. -/// -[EditorBrowsable(EditorBrowsableState.Never)] -[UnconditionalSuppressMessage( - "Trimming", - "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", - Justification = "Hot Reload does not support trimming")] -internal static partial class WebAssemblyHotReload -{ - /// - /// For framework use only. - /// - public readonly struct LogEntry - { - public string Message { get; init; } - public int Severity { get; init; } - } - - /// - /// For framework use only. - /// - internal sealed class Update - { - public int Id { get; set; } - public Delta[] Deltas { get; set; } = default!; - } - - /// - /// For framework use only. - /// - public readonly struct Delta - { - public string ModuleId { get; init; } - public byte[] MetadataDelta { get; init; } - public byte[] ILDelta { get; init; } - public byte[] PdbDelta { get; init; } - public int[] UpdatedTypes { get; init; } - } - - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); - - private static bool s_initialized; - private static HotReloadAgent? s_hotReloadAgent; - - [JSExport] - [SupportedOSPlatform("browser")] - public static async Task InitializeAsync(string baseUri) - { - if (MetadataUpdater.IsSupported && Environment.GetEnvironmentVariable("__ASPNETCORE_BROWSER_TOOLS") == "true" && - OperatingSystem.IsBrowser()) - { - s_initialized = true; - - // TODO: Implement hotReloadExceptionCreateHandler: https://github.com/dotnet/sdk/issues/51056 - var agent = new HotReloadAgent(assemblyResolvingHandler: null, hotReloadExceptionCreateHandler: null); - - var existingAgent = Interlocked.CompareExchange(ref s_hotReloadAgent, agent, null); - if (existingAgent != null) - { - throw new InvalidOperationException("Hot Reload agent already initialized"); - } - - await ApplyPreviousDeltasAsync(agent, baseUri); - } - } - - private static async ValueTask ApplyPreviousDeltasAsync(HotReloadAgent agent, string baseUri) - { - string errorMessage; - - using var client = new HttpClient() - { - BaseAddress = new Uri(baseUri, UriKind.Absolute) - }; - - try - { - var response = await client.GetAsync("/_framework/blazor-hotreload"); - if (response.IsSuccessStatusCode) - { - var deltasJson = await response.Content.ReadAsStringAsync(); - var updates = deltasJson != "" ? JsonSerializer.Deserialize(deltasJson, s_jsonSerializerOptions) : null; - if (updates == null) - { - agent.Reporter.Report($"No previous updates to apply.", AgentMessageSeverity.Verbose); - return; - } - - var i = 1; - foreach (var update in updates) - { - agent.Reporter.Report($"Reapplying update {i}/{updates.Length}.", AgentMessageSeverity.Verbose); - - agent.ApplyManagedCodeUpdates( - update.Deltas.Select(d => new RuntimeManagedCodeUpdate(Guid.Parse(d.ModuleId, CultureInfo.InvariantCulture), d.MetadataDelta, d.ILDelta, d.PdbDelta, d.UpdatedTypes))); - - i++; - } - - return; - } - - errorMessage = $"HTTP GET '/_framework/blazor-hotreload' returned {response.StatusCode}"; - } - catch (Exception e) - { - errorMessage = e.ToString(); - } - - agent.Reporter.Report($"Failed to retrieve and apply previous deltas from the server: {errorMessage}", AgentMessageSeverity.Error); - } - - private static HotReloadAgent? GetAgent() - => s_hotReloadAgent ?? (s_initialized ? throw new InvalidOperationException("Hot Reload agent not initialized") : null); - - private static LogEntry[] ApplyHotReloadDeltas(Delta[] deltas, int loggingLevel) - { - var agent = GetAgent(); - if (agent == null) - { - return []; - } - - agent.ApplyManagedCodeUpdates( - deltas.Select(d => new RuntimeManagedCodeUpdate(Guid.Parse(d.ModuleId, CultureInfo.InvariantCulture), d.MetadataDelta, d.ILDelta, d.PdbDelta, d.UpdatedTypes))); - - return agent.Reporter.GetAndClearLogEntries((ResponseLoggingLevel)loggingLevel) - .Select(log => new LogEntry() { Message = log.message, Severity = (int)log.severity }).ToArray(); - } - - private static readonly WebAssemblyHotReloadJsonSerializerContext jsonContext = new(new(JsonSerializerDefaults.Web)); - - [JSExport] - [SupportedOSPlatform("browser")] - public static string GetApplyUpdateCapabilities() - { - return GetAgent()?.Capabilities ?? ""; - } - - [JSExport] - [SupportedOSPlatform("browser")] - public static string? ApplyHotReloadDeltas(string deltasJson, int loggingLevel) - { - var deltas = JsonSerializer.Deserialize(deltasJson, jsonContext.DeltaArray); - if (deltas == null) - { - return null; - } - - var result = ApplyHotReloadDeltas(deltas, loggingLevel); - return result == null ? null : JsonSerializer.Serialize(result, jsonContext.LogEntryArray); - } -} - -[JsonSerializable(typeof(WebAssemblyHotReload.Delta[]))] -[JsonSerializable(typeof(WebAssemblyHotReload.LogEntry[]))] -internal sealed partial class WebAssemblyHotReloadJsonSerializerContext : JsonSerializerContext -{ -} diff --git a/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/wwwroot/Microsoft.DotNet.HotReload.WebAssembly.Browser.lib.module.js b/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/wwwroot/Microsoft.DotNet.HotReload.WebAssembly.Browser.lib.module.js deleted file mode 100644 index 54e496f0153..00000000000 --- a/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/wwwroot/Microsoft.DotNet.HotReload.WebAssembly.Browser.lib.module.js +++ /dev/null @@ -1,44 +0,0 @@ -let isHotReloadEnabled = false; - -export async function onRuntimeConfigLoaded(config) { - // If we have 'aspnetcore-browser-refresh', configure mono runtime for HotReload. - if (config.debugLevel !== 0 && globalThis.window?.document?.querySelector("script[src*='aspnetcore-browser-refresh']")) { - isHotReloadEnabled = true; - - if (!config.environmentVariables["DOTNET_MODIFIABLE_ASSEMBLIES"]) { - config.environmentVariables["DOTNET_MODIFIABLE_ASSEMBLIES"] = "debug"; - } - if (!config.environmentVariables["__ASPNETCORE_BROWSER_TOOLS"]) { - config.environmentVariables["__ASPNETCORE_BROWSER_TOOLS"] = "true"; - } - } - - // Disable HotReload built-into the Blazor WebAssembly runtime - config.environmentVariables["__BLAZOR_WEBASSEMBLY_LEGACY_HOTRELOAD"] = "false"; -} - -export async function onRuntimeReady({ getAssemblyExports }) { - if (!isHotReloadEnabled) { - return; - } - - const exports = await getAssemblyExports("Microsoft.DotNet.HotReload.WebAssembly.Browser"); - await exports.Microsoft.DotNet.HotReload.WebAssembly.Browser.WebAssemblyHotReload.InitializeAsync(document.baseURI); - - if (!window.Blazor) { - window.Blazor = {}; - - if (!window.Blazor._internal) { - window.Blazor._internal = {}; - } - } - - window.Blazor._internal.applyHotReloadDeltas = (deltas, loggingLevel) => { - const result = exports.Microsoft.DotNet.HotReload.WebAssembly.Browser.WebAssemblyHotReload.ApplyHotReloadDeltas(JSON.stringify(deltas), loggingLevel); - return result ? JSON.parse(result) : []; - }; - - window.Blazor._internal.getApplyUpdateCapabilities = () => { - return exports.Microsoft.DotNet.HotReload.WebAssembly.Browser.WebAssemblyHotReload.GetApplyUpdateCapabilities() ?? ''; - }; -} diff --git a/src/WatchPrototype/HotReloadClient/ClientTransport.cs b/src/WatchPrototype/HotReloadClient/ClientTransport.cs new file mode 100644 index 00000000000..0422348dc20 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/ClientTransport.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Transport abstraction for communication between dotnet-watch (server) and the hot reload agent (client). +/// Similar to the agent-side Transport abstraction, but for the server side. +/// +internal abstract class ClientTransport : IDisposable +{ + /// + /// Configure transport-specific environment variables for the target process. + /// May start the transport server (e.g., Kestrel for WebSocket) to determine the endpoint. + /// + public abstract void ConfigureEnvironment(IDictionary env); + + /// + /// Initiates connection with the agent in the target process. + /// Returns a task that completes when the connection is established. + /// The task is started (hot) immediately so the transport is listening before the process launches. + /// + public abstract Task WaitForConnectionAsync(CancellationToken cancellationToken); + + /// + /// Writes a message to the transport: a request type byte followed by optional payload data. + /// + /// The request type byte. + /// Optional callback to serialize payload data to the stream. Null for notification-only messages. + /// Cancellation token. + public abstract ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken); + + /// + /// Reads the next response from the transport. + /// Returns null if the connection has been lost. + /// + public abstract ValueTask ReadAsync(CancellationToken cancellationToken); + + public abstract void Dispose(); +} diff --git a/src/WatchPrototype/HotReloadClient/ClientTransportResponse.cs b/src/WatchPrototype/HotReloadClient/ClientTransportResponse.cs new file mode 100644 index 00000000000..7d3a5a7a8fe --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/ClientTransportResponse.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.IO; + +namespace Microsoft.DotNet.HotReload; + +/// +/// A response read from the transport, containing the response type and a stream to read the response data from. +/// +/// The response type. +/// Stream to read response data from. +/// Whether the stream should be disposed after reading. +internal readonly struct ClientTransportResponse(ResponseType type, Stream data, bool disposeStream) : IDisposable +{ + public ResponseType Type => type; + public Stream Data => data; + + public void Dispose() + { + if (disposeStream) + { + data.Dispose(); + } + } +} diff --git a/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs b/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs index 1642f63f8b8..c07dc05444f 100644 --- a/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs @@ -4,15 +4,11 @@ #nullable enable using System; -using System.Buffers; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.IO.Pipes; using System.Linq; -using System.Net.Sockets; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -20,13 +16,10 @@ namespace Microsoft.DotNet.HotReload { - internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool enableStaticAssetUpdates) + internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool handlesStaticAssetUpdates, ClientTransport transport) : HotReloadClient(logger, agentLogger) { - private readonly string _namedPipeName = Guid.NewGuid().ToString("N"); - private Task>? _capabilitiesTask; - private NamedPipeServerStream? _pipe; private bool _managedCodeUpdateFailedOrCancelled; // The status of the last update response. @@ -34,34 +27,16 @@ internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger public override void Dispose() { - DisposePipe(); + transport.Dispose(); } - private void DisposePipe() - { - if (_pipe != null) - { - Logger.LogDebug("Disposing agent communication pipe"); - - // Dispose the pipe but do not set it to null, so that any in-progress - // operations throw the appropriate exception type. - _pipe.Dispose(); - } - } - - // for testing - internal string NamedPipeName - => _namedPipeName; + /// + /// The transport used for communication with the agent, for testing. + /// + internal ClientTransport Transport => transport; public override void InitiateConnection(CancellationToken cancellationToken) { -#if NET - var options = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly; -#else - var options = PipeOptions.Asynchronous; -#endif - _pipe = new NamedPipeServerStream(_namedPipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, options); - // It is important to establish the connection (WaitForConnectionAsync) before we return, // otherwise the client wouldn't be able to connect. // However, we don't want to wait for the task to complete, so that we can start the client process. @@ -71,13 +46,28 @@ async Task> ConnectAsync() { try { - Logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", _namedPipeName); + await transport.WaitForConnectionAsync(cancellationToken); + + // Read the initialization response (capabilities) from the agent. + var initResponse = await transport.ReadAsync(cancellationToken); + if (initResponse == null) + { + return []; + } - await _pipe.WaitForConnectionAsync(cancellationToken); + using var r = initResponse.Value; + if (r.Type != ResponseType.InitializationResponse) + { + Logger.LogError("Expected initialization response, got: {ResponseType}", r.Type); + return []; + } - // When the client connects, the first payload it sends is the initialization payload which includes the apply capabilities. + var capabilities = (await ClientInitializationResponse.ReadAsync(r.Data, cancellationToken)).Capabilities; - var capabilities = (await ClientInitializationResponse.ReadAsync(_pipe, cancellationToken)).Capabilities; + if (string.IsNullOrEmpty(capabilities)) + { + return []; + } var result = AddImplicitCapabilities(capabilities.Split(' ')); @@ -90,57 +80,57 @@ async Task> ConnectAsync() } catch (Exception e) when (e is not OperationCanceledException) { - ReportPipeReadException(e, "capabilities", cancellationToken); + // Don't report a warning when cancelled. The process has terminated or the host is shutting down in that case. + // Best effort: There is an inherent race condition due to time between the process exiting and the cancellation token triggering. + if (!cancellationToken.IsCancellationRequested) + { + Logger.LogError("Failed to read capabilities: {Message}", e.Message); + } + return []; } } } - private void ReportPipeReadException(Exception e, string responseType, CancellationToken cancellationToken) - { - // Don't report a warning when cancelled or the pipe has been disposed. The process has terminated or the host is shutting down in that case. - // Best effort: There is an inherent race condition due to time between the process exiting and the cancellation token triggering. - // On Unix named pipes can also throw SocketException with ErrorCode 125 (Operation canceled) when disposed. - if (e is ObjectDisposedException or EndOfStreamException or SocketException { ErrorCode: 125 } || cancellationToken.IsCancellationRequested) - { - return; - } - - Logger.LogError("Failed to read {ResponseType} from the pipe: {Exception}", responseType, e.ToString()); - } - private async Task ListenForResponsesAsync(CancellationToken cancellationToken) { - Debug.Assert(_pipe != null); - try { while (!cancellationToken.IsCancellationRequested) { - var type = (ResponseType)await _pipe.ReadByteAsync(cancellationToken); + var response = await transport.ReadAsync(cancellationToken); + if (response == null) + { + return; + } + + using var r = response.Value; - switch (type) + switch (r.Type) { case ResponseType.UpdateResponse: // update request can't be issued again until the status is read and a new source is created: - _updateStatusSource.SetResult(await ReadUpdateResponseAsync(cancellationToken)); + _updateStatusSource.SetResult(await ReadUpdateResponseAsync(r, cancellationToken)); break; case ResponseType.HotReloadExceptionNotification: - var notification = await HotReloadExceptionCreatedNotification.ReadAsync(_pipe, cancellationToken); + var notification = await HotReloadExceptionCreatedNotification.ReadAsync(r.Data, cancellationToken); RuntimeRudeEditDetected(notification.Code, notification.Message); break; default: - // can't continue, the pipe is in undefined state: - Logger.LogError("Unexpected response received from the agent: {ResponseType}", type); + // can't continue, the stream is in undefined state: + Logger.LogError("Unexpected response received from the agent: {ResponseType}", r.Type); return; } } } - catch (Exception e) + catch (Exception e) when (e is not OperationCanceledException) { - ReportPipeReadException(e, "response", cancellationToken); + if (!cancellationToken.IsCancellationRequested) + { + Logger.LogError("Failed to read response: {Exception}", e.ToString()); + } } } @@ -148,14 +138,11 @@ private async Task ListenForResponsesAsync(CancellationToken cancellationToken) private Task> GetCapabilitiesTask() => _capabilitiesTask ?? throw new InvalidOperationException(); - [MemberNotNull(nameof(_pipe))] [MemberNotNull(nameof(_capabilitiesTask))] private void RequireReadyForUpdates() { // should only be called after connection has been created: _ = GetCapabilitiesTask(); - - Debug.Assert(_pipe != null); } public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) @@ -165,7 +152,7 @@ public override void ConfigureLaunchEnvironment(IDictionary envi // HotReload startup hook should be loaded before any other startup hooks: environmentBuilder.InsertListItem(AgentEnvironmentVariables.DotNetStartupHooks, startupHookPath, Path.PathSeparator); - environmentBuilder[AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName] = _namedPipeName; + transport.ConfigureEnvironment(environmentBuilder); } public override Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) @@ -210,7 +197,7 @@ async Task CompleteApplyOperationAsync() Logger.LogWarning("Further changes won't be applied to this process."); _managedCodeUpdateFailedOrCancelled = true; - DisposePipe(); + transport.Dispose(); return false; } @@ -225,7 +212,7 @@ static ImmutableArray ToRuntimeUpdates(IEnumerable> ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken processExitedCancellationToken, CancellationToken cancellationToken) { - if (!enableStaticAssetUpdates) + if (!handlesStaticAssetUpdates) { // The client has no concept of static assets. return Task.FromResult(true); @@ -261,15 +248,10 @@ async Task CompleteApplyOperationAsync() private Task QueueUpdateBatchRequest(TRequest request, CancellationToken applyOperationCancellationToken) where TRequest : IUpdateRequest { - // Not initialized: - Debug.Assert(_pipe != null); - return QueueUpdateBatch( sendAndReceive: async batchId => { - await _pipe.WriteAsync((byte)request.Type, applyOperationCancellationToken); - await request.WriteAsync(_pipe, applyOperationCancellationToken); - await _pipe.FlushAsync(applyOperationCancellationToken); + await transport.WriteAsync((byte)request.Type, request.WriteAsync, applyOperationCancellationToken); var success = await ReceiveUpdateResponseAsync(applyOperationCancellationToken); Logger.Log(success ? LogEvents.UpdateBatchCompleted : LogEvents.UpdateBatchFailed, batchId); @@ -285,12 +267,9 @@ private async ValueTask ReceiveUpdateResponseAsync(CancellationToken cance return result; } - private async ValueTask ReadUpdateResponseAsync(CancellationToken cancellationToken) + private async ValueTask ReadUpdateResponseAsync(ClientTransportResponse r, CancellationToken cancellationToken) { - // Should be initialized: - Debug.Assert(_pipe != null); - - var (success, log) = await UpdateResponse.ReadAsync(_pipe, cancellationToken); + var (success, log) = await UpdateResponse.ReadAsync(r.Data, cancellationToken); await foreach (var (message, severity) in log) { @@ -311,12 +290,11 @@ public override async Task InitialUpdatesAppliedAsync(CancellationToken cancella try { - await _pipe.WriteAsync((byte)RequestType.InitialUpdatesCompleted, cancellationToken); - await _pipe.FlushAsync(cancellationToken); + await transport.WriteAsync((byte)RequestType.InitialUpdatesCompleted, writePayload: null, cancellationToken); } catch (Exception e) when (e is not OperationCanceledException) { - // Pipe might throw another exception when forcibly closed on process termination. + // Transport might throw another exception when forcibly closed on process termination. // Don't report an error when cancelled. The process has terminated or the host is shutting down in that case. // Best effort: There is an inherent race condition due to time between the process exiting and the cancellation token triggering. if (!cancellationToken.IsCancellationRequested) diff --git a/src/WatchPrototype/HotReloadClient/HotReloadClients.cs b/src/WatchPrototype/HotReloadClient/HotReloadClients.cs index 1b02eac9de4..1400a43c7e0 100644 --- a/src/WatchPrototype/HotReloadClient/HotReloadClients.cs +++ b/src/WatchPrototype/HotReloadClient/HotReloadClients.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -16,13 +17,25 @@ namespace Microsoft.DotNet.HotReload; -internal sealed class HotReloadClients(ImmutableArray<(HotReloadClient client, string name)> clients, AbstractBrowserRefreshServer? browserRefreshServer) : IDisposable +/// +/// Facilitates Hot Reload updates across multiple clients/processes. +/// +/// +/// Clients that handle managed updates and static asset updates if is false. +/// +/// +/// Browser refresh server used to communicate managed code update status and errors to the browser, +/// and to apply static asset updates if is true. +/// +/// +/// True to use to apply static asset updates (if available). +/// False to use the to apply static asset updates. +/// +internal sealed class HotReloadClients( + ImmutableArray<(HotReloadClient client, string name)> clients, + AbstractBrowserRefreshServer? browserRefreshServer, + bool useRefreshServerToApplyStaticAssets) : IDisposable { - public HotReloadClients(HotReloadClient client, AbstractBrowserRefreshServer? browserRefreshServer) - : this([(client, "")], browserRefreshServer) - { - } - /// /// Disposes all clients. Can occur unexpectedly whenever the process exits. /// @@ -34,6 +47,16 @@ public void Dispose() } } + /// + /// True if Hot Reload is implemented via managed agents. + /// The update itself might not be managed code update, it may be a static asset update implemented via a managed agent. + /// + public bool IsManagedAgentSupported + => !clients.IsEmpty; + + public bool UseRefreshServerToApplyStaticAssets + => useRefreshServerToApplyStaticAssets; + public AbstractBrowserRefreshServer? BrowserRefreshServer => browserRefreshServer; @@ -59,18 +82,6 @@ public event Action OnRuntimeRudeEdit } } - /// - /// All clients share the same loggers. - /// - public ILogger ClientLogger - => clients.First().client.Logger; - - /// - /// All clients share the same loggers. - /// - public ILogger AgentLogger - => clients.First().client.AgentLogger; - internal void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { foreach (var (client, _) in clients) @@ -99,6 +110,12 @@ internal async ValueTask WaitForConnectionEstablishedAsync(CancellationToken can /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) { + if (!IsManagedAgentSupported) + { + // empty capabilities will cause rude edit ENC0097: NotSupportedByRuntime. + return []; + } + if (clients is [var (singleClient, _)]) { return await singleClient.GetUpdateCapabilitiesAsync(cancellationToken); @@ -114,6 +131,9 @@ public async ValueTask> GetUpdateCapabilitiesAsync(Cancel /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + // Apply to all processes. // The module the change is for does not need to be loaded to any of the processes, yet we still consider it successful if the application does not fail. // In each process we store the deltas for application when/if the module is loaded to the process later. @@ -137,6 +157,9 @@ async Task CompleteApplyOperationAsync() /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + if (clients is [var (singleClient, _)]) { await singleClient.InitialUpdatesAppliedAsync(cancellationToken); @@ -150,23 +173,26 @@ public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellation /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyStaticAssetUpdatesAsync(IEnumerable assets, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { - if (browserRefreshServer != null) + if (useRefreshServerToApplyStaticAssets) { + Debug.Assert(browserRefreshServer != null); return browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.RelativeUrl), applyOperationCancellationToken).AsTask(); } + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + var updates = new List(); foreach (var asset in assets) { try { - ClientLogger.LogDebug("Loading asset '{Url}' from '{Path}'.", asset.RelativeUrl, asset.FilePath); updates.Add(await HotReloadStaticAssetUpdate.CreateAsync(asset, cancellationToken)); } catch (Exception e) when (e is not OperationCanceledException) { - ClientLogger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); + clients.First().client.Logger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); continue; } } @@ -177,6 +203,10 @@ public async Task ApplyStaticAssetUpdatesAsync(IEnumerable /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + Debug.Assert(!useRefreshServerToApplyStaticAssets); + var applyTasks = await Task.WhenAll(clients.Select(c => c.client.ApplyStaticAssetUpdatesAsync(updates, applyOperationCancellationToken, cancellationToken))); return Task.WhenAll(applyTasks); diff --git a/src/WatchPrototype/HotReloadClient/Logging/LogEvents.cs b/src/WatchPrototype/HotReloadClient/Logging/LogEvents.cs index 8b43cb4bad2..d154e76c409 100644 --- a/src/WatchPrototype/HotReloadClient/Logging/LogEvents.cs +++ b/src/WatchPrototype/HotReloadClient/Logging/LogEvents.cs @@ -3,37 +3,78 @@ #nullable enable +using System.Diagnostics; +using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.HotReload; -internal readonly record struct LogEvent(EventId Id, LogLevel Level, string Message); +internal readonly record struct LogEvent(EventId Id, LogLevel Level, string Message); internal static class LogEvents { // Non-shared event ids start at 0. private static int s_id = 1000; - private static LogEvent Create(LogLevel level, string message) + private static LogEvent Create(LogLevel level, string message) + => Create(level, message); + + private static LogEvent Create(LogLevel level, string message) => new(new EventId(s_id++), level, message); - public static void Log(this ILogger logger, LogEvent logEvent, params object[] args) - => logger.Log(logEvent.Level, logEvent.Id, logEvent.Message, args); - - public static readonly LogEvent SendingUpdateBatch = Create(LogLevel.Debug, "Sending update batch #{0}"); - public static readonly LogEvent UpdateBatchCompleted = Create(LogLevel.Debug, "Update batch #{0} completed."); - public static readonly LogEvent UpdateBatchFailed = Create(LogLevel.Debug, "Update batch #{0} failed."); - public static readonly LogEvent UpdateBatchCanceled = Create(LogLevel.Debug, "Update batch #{0} canceled."); - public static readonly LogEvent UpdateBatchFailedWithError = Create(LogLevel.Debug, "Update batch #{0} failed with error: {1}"); - public static readonly LogEvent UpdateBatchExceptionStackTrace = Create(LogLevel.Debug, "Update batch #{0} exception stack trace: {1}"); - public static readonly LogEvent Capabilities = Create(LogLevel.Debug, "Capabilities: '{0}'."); - public static readonly LogEvent RefreshingBrowser = Create(LogLevel.Debug, "Refreshing browser."); - public static readonly LogEvent ReloadingBrowser = Create(LogLevel.Debug, "Reloading browser."); - public static readonly LogEvent SendingWaitMessage = Create(LogLevel.Debug, "Sending wait message."); - public static readonly LogEvent NoBrowserConnected = Create(LogLevel.Debug, "No browser is connected."); - public static readonly LogEvent FailedToReceiveResponseFromConnectedBrowser = Create(LogLevel.Debug, "Failed to receive response from a connected browser."); - public static readonly LogEvent UpdatingDiagnostics = Create(LogLevel.Debug, "Updating diagnostics."); - public static readonly LogEvent SendingStaticAssetUpdateRequest = Create(LogLevel.Debug, "Sending static asset update request to connected browsers: '{0}'."); - public static readonly LogEvent RefreshServerRunningAt = Create(LogLevel.Debug, "Refresh server running at {0}."); - public static readonly LogEvent ConnectedToRefreshServer = Create(LogLevel.Debug, "Connected to refresh server."); + public static void Log(this ILogger logger, LogEvent logEvent) + => logger.Log(logEvent.Level, logEvent.Id, logEvent.Message); + + public static void Log(this ILogger logger, LogEvent logEvent, TArgs args) + { + if (logger.IsEnabled(logEvent.Level)) + { + logger.Log(logEvent.Level, logEvent.Id, logEvent.Message, GetArgumentValues(args)); + } + } + + public static void Log(this ILogger logger, LogEvent<(TArg1, TArg2)> logEvent, TArg1 arg1, TArg2 arg2) + => Log(logger, logEvent, (arg1, arg2)); + + public static void Log(this ILogger logger, LogEvent<(TArg1, TArg2, TArg3)> logEvent, TArg1 arg1, TArg2 arg2, TArg3 arg3) + => Log(logger, logEvent, (arg1, arg2, arg3)); + + public static object?[] GetArgumentValues(TArgs args) + { + if (args?.GetType() == typeof(None)) + { + return []; + } + + if (args is ITuple tuple) + { + var values = new object?[tuple.Length]; + for (int i = 0; i < tuple.Length; i++) + { + values[i] = tuple[i]; + } + + return values; + } + + return [args]; + } + + public static readonly LogEvent SendingUpdateBatch = Create(LogLevel.Debug, "Sending update batch #{0}"); + public static readonly LogEvent UpdateBatchCompleted = Create(LogLevel.Debug, "Update batch #{0} completed."); + public static readonly LogEvent UpdateBatchFailed = Create(LogLevel.Debug, "Update batch #{0} failed."); + public static readonly LogEvent UpdateBatchCanceled = Create(LogLevel.Debug, "Update batch #{0} canceled."); + public static readonly LogEvent<(int, string)> UpdateBatchFailedWithError = Create<(int, string)>(LogLevel.Debug, "Update batch #{0} failed with error: {1}"); + public static readonly LogEvent<(int, string)> UpdateBatchExceptionStackTrace = Create<(int, string)>(LogLevel.Debug, "Update batch #{0} exception stack trace: {1}"); + public static readonly LogEvent Capabilities = Create(LogLevel.Debug, "Capabilities: '{0}'."); + public static readonly LogEvent RefreshingBrowser = Create(LogLevel.Debug, "Refreshing browser."); + public static readonly LogEvent ReloadingBrowser = Create(LogLevel.Debug, "Reloading browser."); + public static readonly LogEvent SendingWaitMessage = Create(LogLevel.Debug, "Sending wait message."); + public static readonly LogEvent NoBrowserConnected = Create(LogLevel.Debug, "No browser is connected."); + public static readonly LogEvent FailedToReceiveResponseFromConnectedBrowser = Create(LogLevel.Debug, "Failed to receive response from a connected browser."); + public static readonly LogEvent UpdatingDiagnostics = Create(LogLevel.Debug, "Updating diagnostics."); + public static readonly LogEvent SendingStaticAssetUpdateRequest = Create(LogLevel.Debug, "Sending static asset update request to connected browsers: '{0}'."); + public static readonly LogEvent RefreshServerRunningAt = Create(LogLevel.Debug, "Refresh server running at {0}."); + public static readonly LogEvent ConnectedToRefreshServer = Create(LogLevel.Debug, "Connected to refresh server."); } + diff --git a/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj index e07e3c4f0e3..93b35d8e495 100644 --- a/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj +++ b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -5,7 +5,7 @@ Used in in-proc VS and VS Code. We also need to target $(SdkTargetFramework) to allow tests to run. --> - $(VisualStudioServiceTargetFramework);$(SdkTargetFramework) + $(VisualStudioServiceTargetFramework);$(SdkTargetFramework);$(VisualStudioTargetFramework) false none false diff --git a/src/WatchPrototype/HotReloadClient/NamedPipeClientTransport.cs b/src/WatchPrototype/HotReloadClient/NamedPipeClientTransport.cs new file mode 100644 index 00000000000..0328c5aa4ac --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/NamedPipeClientTransport.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Named pipe transport for communication between dotnet-watch and the hot reload agent. +/// Used for local processes where named pipes are available. +/// +internal sealed class NamedPipeClientTransport : ClientTransport +{ + private readonly ILogger _logger; + private readonly string _namedPipeName; + private readonly NamedPipeServerStream _pipe; + + public NamedPipeClientTransport(ILogger logger) + { + _logger = logger; + _namedPipeName = Guid.NewGuid().ToString("N"); + +#if NET + var options = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly; +#else + var options = PipeOptions.Asynchronous; +#endif + _pipe = new NamedPipeServerStream(_namedPipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, options); + } + + /// + /// The named pipe name, for testing. + /// + internal string NamedPipeName => _namedPipeName; + + public override void ConfigureEnvironment(IDictionary env) + { + env[AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName] = _namedPipeName; + } + + public override async Task WaitForConnectionAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Waiting for application to connect to pipe '{NamedPipeName}'.", _namedPipeName); + + try + { + await _pipe.WaitForConnectionAsync(cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + // The process may die while we're waiting for the connection and the pipe may be disposed. + // Log and let subsequent ReadAsync return null gracefully. + if (IsExpectedPipeException(e, cancellationToken)) + { + _logger.LogDebug("Pipe connection ended: {Message}", e.Message); + return; + } + + throw; + } + } + + /// + /// Returns true if the exception is expected when the pipe is disposed or the process has terminated. + /// On Unix named pipes can also throw SocketException with ErrorCode 125 (Operation canceled) when disposed. + /// + private static bool IsExpectedPipeException(Exception e, CancellationToken cancellationToken) + { + return e is ObjectDisposedException or EndOfStreamException or SocketException { ErrorCode: 125 } + || cancellationToken.IsCancellationRequested; + } + + public override async ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + { + await _pipe.WriteAsync(type, cancellationToken); + + if (writePayload != null) + { + await writePayload(_pipe, cancellationToken); + } + + await _pipe.FlushAsync(cancellationToken); + } + + public override async ValueTask ReadAsync(CancellationToken cancellationToken) + { + try + { + var type = (ResponseType)await _pipe.ReadByteAsync(cancellationToken); + return new ClientTransportResponse(type, _pipe, disposeStream: false); + } + catch (Exception e) when (e is not OperationCanceledException && IsExpectedPipeException(e, cancellationToken)) + { + // Pipe has been disposed or the process has terminated. + return null; + } + } + + public override void Dispose() + { + _logger.LogDebug("Disposing agent communication pipe"); + + // Dispose the pipe but do not set it to null, so that any in-progress + // operations throw the appropriate exception type. + _pipe.Dispose(); + } +} diff --git a/src/WatchPrototype/HotReloadClient/StaticAsset.cs b/src/WatchPrototype/HotReloadClient/StaticAsset.cs new file mode 100644 index 00000000000..74cf7d1e096 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/StaticAsset.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct StaticAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject) +{ + public string FilePath => filePath; + public string RelativeUrl => relativeUrl; + public string AssemblyName => assemblyName; + public bool IsApplicationProject => isApplicationProject; +} diff --git a/src/WatchPrototype/HotReloadClient/Utilities/VoidResult.cs b/src/WatchPrototype/HotReloadClient/Utilities/None.cs similarity index 83% rename from src/WatchPrototype/HotReloadClient/Utilities/VoidResult.cs rename to src/WatchPrototype/HotReloadClient/Utilities/None.cs index d024e9cb07e..2ecb1bc6029 100644 --- a/src/WatchPrototype/HotReloadClient/Utilities/VoidResult.cs +++ b/src/WatchPrototype/HotReloadClient/Utilities/None.cs @@ -5,6 +5,4 @@ namespace Microsoft.DotNet.HotReload; -internal readonly struct VoidResult -{ -} +internal readonly struct None; diff --git a/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs index 79c5d957265..09b04ab2682 100644 --- a/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs +++ b/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -32,7 +32,7 @@ internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPa private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); private readonly List _activeConnections = []; - private readonly TaskCompletionSource _browserConnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _browserConnected = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly SharedSecretProvider _sharedSecretProvider = new(); @@ -241,7 +241,7 @@ public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) private async ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) { - await SendAndReceiveAsync, VoidResult>(request: _ => messageBytes, response: null, cancellationToken); + await SendAndReceiveAsync, None>(request: _ => messageBytes, response: null, cancellationToken); } public async ValueTask SendAndReceiveAsync( diff --git a/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.cs b/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.cs index 74bfabc268d..101193a7fa3 100644 --- a/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.cs +++ b/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.cs @@ -25,7 +25,7 @@ namespace Microsoft.DotNet.HotReload; public ILogger ServerLogger { get; } public ILogger AgentLogger { get; } - public readonly TaskCompletionSource Disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + public readonly TaskCompletionSource Disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFactory loggerFactory) { diff --git a/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs b/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs index 241bcbe0d13..bc5cbc67d00 100644 --- a/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs +++ b/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs @@ -10,122 +10,41 @@ using System.Diagnostics; using System.Linq; using System.Net; -using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.HotReload; /// /// Kestrel-based Browser Refesh Server implementation. +/// Delegates Kestrel lifecycle to . /// internal sealed class BrowserRefreshServer( ILogger logger, ILoggerFactory loggerFactory, string middlewareAssemblyPath, string dotnetPath, - string? autoReloadWebSocketHostName, - int? autoReloadWebSocketPort, + WebSocketConfig webSocketConfig, bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { - private static bool? s_lazyTlsSupported; - protected override bool SuppressTimeouts => suppressTimeouts; protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { - var hostName = autoReloadWebSocketHostName ?? "127.0.0.1"; - var port = autoReloadWebSocketPort ?? 0; - - var supportsTls = await IsTlsSupportedAsync(cancellationToken); - - var host = new HostBuilder() - .ConfigureWebHost(builder => - { - builder.UseKestrel(); - if (supportsTls) - { - builder.UseUrls($"https://{hostName}:{port}", $"http://{hostName}:{port}"); - } - else - { - builder.UseUrls($"http://{hostName}:{port}"); - } - - builder.Configure(app => - { - app.UseWebSockets(); - app.Run(WebSocketRequestAsync); - }); - }) - .Build(); - - await host.StartAsync(cancellationToken); - - // URLs are only available after the server has started. - return new WebServerHost(host, GetServerUrls(host), virtualDirectory: "/"); - } - - private async ValueTask IsTlsSupportedAsync(CancellationToken cancellationToken) - { - var result = s_lazyTlsSupported; - if (result.HasValue) - { - return result.Value; - } - - try - { - using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); - await process - .WaitForExitAsync(cancellationToken) - .WaitAsync(SuppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); - - result = process.ExitCode == 0; - } - catch + var supportsTls = await KestrelWebSocketServer.IsTlsSupportedAsync(dotnetPath, suppressTimeouts, cancellationToken); + if (!supportsTls) { - result = false; + webSocketConfig = webSocketConfig.WithSecurePort(null); } - s_lazyTlsSupported = result; - return result.Value; - } - - private ImmutableArray GetServerUrls(IHost server) - { - var serverUrls = server.Services - .GetRequiredService() - .Features - .Get()? - .Addresses; - - Debug.Assert(serverUrls != null); - - if (autoReloadWebSocketHostName is null) - { - return [.. serverUrls.Select(s => - s.Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) - .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal))]; - } + var server = await KestrelWebSocketServer.StartServerAsync(webSocketConfig, WebSocketRequestAsync, cancellationToken); - return - [ - serverUrls - .First() - .Replace("https://", "wss://", StringComparison.Ordinal) - .Replace("http://", "ws://", StringComparison.Ordinal) - ]; + // URLs are only available after the server has started. + return new WebServerHost(server, server.ServerUrls, virtualDirectory: "/"); } private async Task WebSocketRequestAsync(HttpContext context) diff --git a/src/WatchPrototype/HotReloadClient/Web/KestrelWebSocketServer.cs b/src/WatchPrototype/HotReloadClient/Web/KestrelWebSocketServer.cs new file mode 100644 index 00000000000..bfcceeb50a9 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/KestrelWebSocketServer.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET + +#nullable enable + +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Sealed WebSocket server using Kestrel. +/// Uses a request handler delegate for all WebSocket handling. +/// +internal sealed class KestrelWebSocketServer(IHost host, ImmutableArray serverUrls) : IDisposable +{ + private static bool? s_lazyTlsSupported; + + public void Dispose() + => host.Dispose(); + + public ImmutableArray ServerUrls + => serverUrls; + + /// + /// Starts the Kestrel WebSocket server. + /// + public static async ValueTask StartServerAsync(WebSocketConfig config, RequestDelegate requestHandler, CancellationToken cancellationToken) + { + var host = new HostBuilder() + .ConfigureWebHost(builder => + { + builder.UseKestrel(); + builder.UseUrls([.. config.GetHttpUrls()]); + + builder.Configure(app => + { + app.UseWebSockets(); + app.Run(requestHandler); + }); + }) + .Build(); + + await host.StartAsync(cancellationToken); + + // URLs are only available after the server has started. + var addresses = host.Services + .GetRequiredService() + .Features + .Get()? + .Addresses ?? []; + + return new KestrelWebSocketServer(host, serverUrls: [.. addresses.Select(GetWebSocketUrl)]); + } + + /// + /// Converts an HTTP(S) URL to a WebSocket URL and replaces 127.0.0.1 with localhost. + /// + internal static string GetWebSocketUrl(string httpUrl) + { + var uri = new Uri(httpUrl, UriKind.Absolute); + var builder = new UriBuilder(uri) + { + Scheme = uri.Scheme == "https" ? "wss" : "ws" + }; + + if (builder.Host == "127.0.0.1") + { + builder.Host = "localhost"; + } + + return builder.Uri.ToString().TrimEnd('/'); + } + + /// + /// Checks whether TLS is supported by running dotnet dev-certs https --check --quiet. + /// + public static async ValueTask IsTlsSupportedAsync(string dotnetPath, bool suppressTimeouts, CancellationToken cancellationToken) + { + var result = s_lazyTlsSupported; + if (result.HasValue) + { + return result.Value; + } + + try + { + using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); + await process + .WaitForExitAsync(cancellationToken) + .WaitAsync(suppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); + + result = process.ExitCode == 0; + } + catch + { + result = false; + } + + s_lazyTlsSupported = result; + return result.Value; + } +} + +#endif diff --git a/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs b/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs index 22a1c4d5e0a..6f9ad71f4ba 100644 --- a/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs +++ b/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs @@ -90,6 +90,11 @@ public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out { stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); } + catch (FileNotFoundException) + { + logger.LogDebug("File '{FilePath}' does not exist.", path); + return null; + } catch (Exception e) { logger.LogError("Failed to read '{FilePath}': {Message}", path, e.Message); diff --git a/src/WatchPrototype/HotReloadClient/Web/WebSocketConfig.cs b/src/WatchPrototype/HotReloadClient/Web/WebSocketConfig.cs new file mode 100644 index 00000000000..66887d28b34 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/WebSocketConfig.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Collections.Generic; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct WebSocketConfig(int port, int? securePort, string? hostName) +{ + /// + /// 0 to auto-assign. + /// + public int Port => port; + + /// + /// 0 to auto-assign, null to disable HTTPS/WSS. + /// + public int? SecurePort => securePort; + + // Use 127.0.0.1 instead of "localhost" because Kestrel doesn't support dynamic port binding with "localhost". + // System.InvalidOperationException: Dynamic port binding is not supported when binding to localhost. + // You must either bind to 127.0.0.1:0 or [::1]:0, or both. + public string HostName => hostName ?? "127.0.0.1"; + + public IEnumerable GetHttpUrls() + { + yield return $"http://{HostName}:{Port}"; + + if (SecurePort.HasValue) + { + yield return $"https://{HostName}:{SecurePort.Value}"; + } + } + + public WebSocketConfig WithSecurePort(int? value) + => new(port, value, hostName); +} diff --git a/src/WatchPrototype/HotReloadClient/WebSocketClientTransport.cs b/src/WatchPrototype/HotReloadClient/WebSocketClientTransport.cs new file mode 100644 index 00000000000..91bff33e95e --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/WebSocketClientTransport.cs @@ -0,0 +1,213 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET + +#nullable enable + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +/// +/// WebSocket transport for communication between dotnet-watch and the hot reload agent. +/// Used for projects with the HotReloadWebSockets capability (e.g., Android, iOS) +/// where named pipes don't work over the network. +/// Manages a Kestrel WebSocket server and handles single-client connections with +/// RSA-based shared secret authentication (same as BrowserRefreshServer). +/// +internal sealed class WebSocketClientTransport : ClientTransport +{ + private readonly KestrelWebSocketServer _server; + private readonly RequestHandler _handler; + + private WebSocketClientTransport(KestrelWebSocketServer server, RequestHandler handler) + { + _server = server; + _handler = handler; + } + + public override void Dispose() + { + _server.Dispose(); + _handler.Dispose(); + } + + /// + /// Creates and starts a new instance. + /// + public static async Task CreateAsync(WebSocketConfig config, ILogger logger, CancellationToken cancellationToken) + { + var handler = new RequestHandler(logger); + var server = await KestrelWebSocketServer.StartServerAsync(config, handler.HandleRequestAsync, cancellationToken); + var transport = new WebSocketClientTransport(server, handler); + + logger.LogDebug("WebSocket server started at: {Urls}", string.Join(", ", server.ServerUrls)); + return transport; + } + + public override void ConfigureEnvironment(IDictionary env) + { + // Set the WebSocket endpoint for the app to connect to. + // Use the actual bound URL from the server (important when port 0 was requested). + env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint] = _server.ServerUrls.First(); + + // Set the RSA public key for the client to encrypt its shared secret. + // This is the same authentication mechanism used by BrowserRefreshServer. + env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey] = _handler.SharedSecretProvider.GetPublicKey(); + } + + public override Task WaitForConnectionAsync(CancellationToken cancellationToken) + => _handler.ClientConnectedSource.Task.WaitAsync(cancellationToken); + + public override ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + => _handler.WriteAsync(type, writePayload, cancellationToken); + + public override ValueTask ReadAsync(CancellationToken cancellationToken) + => _handler.ReadAsync(cancellationToken); + + private sealed class RequestHandler(ILogger logger) : IDisposable + { + public SharedSecretProvider SharedSecretProvider { get; } = new(); + public TaskCompletionSource ClientConnectedSource { get; } = new(); + + private WebSocket? _clientSocket; + + // Reused across WriteAsync calls to avoid allocations. + // WriteAsync is invoked under a semaphore in DefaultHotReloadClient. + private MemoryStream? _sendBuffer; + + public void Dispose() + { + logger.LogDebug("Disposing agent websocket transport"); + + _sendBuffer?.Dispose(); + _clientSocket?.Dispose(); + SharedSecretProvider.Dispose(); + } + + public async Task HandleRequestAsync(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + // Validate the shared secret from the subprotocol + string? subProtocol = context.WebSockets.WebSocketRequestedProtocols is [var sp] ? sp : null; + + if (subProtocol == null) + { + logger.LogWarning("WebSocket connection rejected: missing subprotocol (shared secret)"); + context.Response.StatusCode = 401; + return; + } + + // Decrypt and validate the secret + try + { + SharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)); + } + catch (Exception ex) + { + logger.LogWarning("WebSocket connection rejected: invalid shared secret - {Message}", ex.Message); + context.Response.StatusCode = 401; + return; + } + + var webSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); + + logger.LogDebug("WebSocket client connected"); + + _clientSocket = webSocket; + ClientConnectedSource.TrySetResult(webSocket); + + // Keep the request alive until the connection is closed or aborted + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // Expected when the client disconnects or the request is aborted + } + + logger.LogDebug("WebSocket client disconnected"); + } + + public async ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + { + if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) + { + throw new InvalidOperationException("No active WebSocket connection from the client."); + } + + // Serialize the complete message to a reusable buffer, then send as a single WebSocket message + _sendBuffer ??= new MemoryStream(); + _sendBuffer.SetLength(0); + + await _sendBuffer.WriteAsync(type, cancellationToken); + + if (writePayload != null) + { + await writePayload(_sendBuffer, cancellationToken); + } + + await _clientSocket.SendAsync( + new ArraySegment(_sendBuffer.GetBuffer(), 0, (int)_sendBuffer.Length), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); + } + + public async ValueTask ReadAsync(CancellationToken cancellationToken) + { + if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) + { + return null; + } + + // Receive a complete WebSocket message + var buffer = ArrayPool.Shared.Rent(4096); + try + { + var stream = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await _clientSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + stream.Dispose(); + return null; + } + stream.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + stream.Position = 0; + + // Read the response type byte from the message + var type = (ResponseType)await stream.ReadByteAsync(cancellationToken); + return new ClientTransportResponse(type, stream, disposeStream: true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } +} + +#endif diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index 0af28bb5fd1..cbe3d3838aa 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -1,17 +1,17 @@  - @@ -134,6 +134,10 @@ The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. {Locked="#:property"} + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + Static graph restore is not supported for file-based apps. Remove the '#:property'. {Locked="#:property"} @@ -169,7 +173,16 @@ Unrecognized directive '{0}'. {0} is the directive name like 'package' or 'sdk'. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 681843c6cbc..89413dc860c 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -36,7 +36,7 @@ public static SyntaxTokenParser CreateTokenizer(SourceText text) /// The latter is useful for dotnet run file.cs where if there are app directives after the first token, /// compiler reports anyway, so we speed up success scenarios by not parsing the whole file up front in the SDK CLI. /// - public static ImmutableArray FindDirectives(SourceFile sourceFile, bool reportAllErrors, ErrorReporter reportError) + public static ImmutableArray FindDirectives(SourceFile sourceFile, bool reportAllErrors, ErrorReporter errorReporter) { var builder = ImmutableArray.CreateBuilder(); var tokenizer = CreateTokenizer(sourceFile.Text); @@ -44,7 +44,7 @@ public static ImmutableArray FindDirectives(SourceFile sourceFi var result = tokenizer.ParseLeadingTrivia(); var triviaList = result.Token.LeadingTrivia; - FindLeadingDirectives(sourceFile, triviaList, reportError, builder); + FindLeadingDirectives(sourceFile, triviaList, errorReporter, builder); // In conversion mode, we want to report errors for any invalid directives in the rest of the file // so users don't end up with invalid directives in the converted project. @@ -73,11 +73,10 @@ void ReportErrorFor(SyntaxTrivia trivia) { if (trivia.ContainsDiagnostics && trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia)) { - reportError(sourceFile, trivia.Span, FileBasedProgramsResources.CannotConvertDirective); + errorReporter(sourceFile.Text, sourceFile.Path, trivia.Span, FileBasedProgramsResources.CannotConvertDirective); } } - // The result should be ordered by source location, RemoveDirectivesFromFile depends on that. return builder.ToImmutable(); } @@ -86,11 +85,9 @@ void ReportErrorFor(SyntaxTrivia trivia) public static void FindLeadingDirectives( SourceFile sourceFile, SyntaxTriviaList triviaList, - ErrorReporter reportError, + ErrorReporter errorReporter, ImmutableArray.Builder? builder) { - Debug.Assert(triviaList.Span.Start == 0); - var deduplicated = new Dictionary(NamedDirectiveComparer.Instance); TextSpan previousWhiteSpaceSpan = default; @@ -114,9 +111,10 @@ public static void FindLeadingDirectives( { TextSpan span = GetFullSpan(previousWhiteSpaceSpan, trivia); - var whiteSpace = GetWhiteSpaceInfo(triviaList, index); + var whiteSpace = GetWhiteSpaceInfo(triviaList, index, span); var info = new CSharpDirective.ParseInfo { + SourceFile = sourceFile, Span = span, LeadingWhiteSpace = whiteSpace.Leading, TrailingWhiteSpace = whiteSpace.Trailing, @@ -135,17 +133,17 @@ public static void FindLeadingDirectives( var value = parts.Length > 1 ? parts[1] : ""; Debug.Assert(!(parts.Length > 2)); - var whiteSpace = GetWhiteSpaceInfo(triviaList, index); + var whiteSpace = GetWhiteSpaceInfo(triviaList, index, span); var context = new CSharpDirective.ParseContext { Info = new() { + SourceFile = sourceFile, Span = span, LeadingWhiteSpace = whiteSpace.Leading, TrailingWhiteSpace = whiteSpace.Trailing, }, - ReportError = reportError, - SourceFile = sourceFile, + ErrorReporter = errorReporter, DirectiveKind = name, DirectiveText = value, }; @@ -153,7 +151,7 @@ public static void FindLeadingDirectives( // Block quotes now so we can later support quoted values without a breaking change. https://github.com/dotnet/sdk/issues/49367 if (value.Contains('"')) { - reportError(sourceFile, context.Info.Span, FileBasedProgramsResources.QuoteInDirective); + context.ReportError(FileBasedProgramsResources.QuoteInDirective); } if (CSharpDirective.Parse(context) is { } directive) @@ -162,7 +160,7 @@ public static void FindLeadingDirectives( if (deduplicated.TryGetValue(directive, out var existingDirective)) { var typeAndName = $"#:{existingDirective.GetType().Name.ToLowerInvariant()} {existingDirective.Name}"; - reportError(sourceFile, directive.Info.Span, string.Format(FileBasedProgramsResources.DuplicateDirective, typeAndName)); + context.ReportError(directive.Info.Span, string.Format(FileBasedProgramsResources.DuplicateDirective, typeAndName)); } else { @@ -184,35 +182,42 @@ static TextSpan GetFullSpan(TextSpan previousWhiteSpaceSpan, SyntaxTrivia trivia return previousWhiteSpaceSpan.IsEmpty ? trivia.FullSpan : TextSpan.FromBounds(previousWhiteSpaceSpan.Start, trivia.FullSpan.End); } - static (WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) GetWhiteSpaceInfo(in SyntaxTriviaList triviaList, int index) + static (WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) GetWhiteSpaceInfo(in SyntaxTriviaList triviaList, int index, TextSpan excludeSpan) { (WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) result = default; for (int i = index - 1; i >= 0; i--) { - if (!Fill(ref result.Leading, triviaList, i)) break; + if (!Fill(ref result.Leading, triviaList, i, excludeSpan)) break; } for (int i = index + 1; i < triviaList.Count; i++) { - if (!Fill(ref result.Trailing, triviaList, i)) break; + if (!Fill(ref result.Trailing, triviaList, i, excludeSpan)) break; } return result; - static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int index) + static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int index, TextSpan excludeSpan) { var trivia = triviaList[index]; + + var length = trivia.FullSpan.Length - (trivia.FullSpan.Intersection(excludeSpan)?.Length ?? 0); + if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) { - info.LineBreaks += 1; - info.TotalLength += trivia.FullSpan.Length; + if (length != 0) + { + info.BlankLineLength += info.RestLength + length; + info.RestLength = 0; + } + return true; } if (trivia.IsKind(SyntaxKind.WhitespaceTrivia)) { - info.TotalLength += trivia.FullSpan.Length; + info.RestLength += length; return true; } @@ -231,11 +236,6 @@ public static SourceFile Load(string filePath) return new SourceFile(filePath, SourceText.From(stream, encoding: null)); } - public SourceFile WithText(SourceText newText) - { - return new SourceFile(Path, newText); - } - public void Save() { using var stream = File.Open(Path, FileMode.Create, FileAccess.Write); @@ -245,15 +245,9 @@ public void Save() Text.Write(writer); } - public FileLinePositionSpan GetFileLinePositionSpan(TextSpan span) - { - return new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span)); - } - public string GetLocationString(TextSpan span) { - var positionSpan = GetFileLinePositionSpan(span); - return $"{positionSpan.Path}({positionSpan.StartLinePosition.Line + 1})"; + return $"{Path}({Text.Lines.GetLinePositionSpan(span).Start.Line + 1})"; } } @@ -268,8 +262,15 @@ internal static partial class Patterns internal struct WhiteSpaceInfo { - public int LineBreaks; - public int TotalLength; + /// + /// Size of whitespace that consists of only blank lines (i.e., lines that contain only whitespace). + /// + public int BlankLineLength; + + /// + /// Size of the remaining whitespace on a not-entirely-blank line. + /// + public int RestLength; } /// @@ -282,21 +283,36 @@ internal abstract class CSharpDirective(in CSharpDirective.ParseInfo info) public readonly struct ParseInfo { + public required SourceFile SourceFile { get; init; } + /// /// Span of the full line including the trailing line break. /// public required TextSpan Span { get; init; } + + /// + /// Additional leading whitespace not included in . + /// public required WhiteSpaceInfo LeadingWhiteSpace { get; init; } + + /// + /// Additional trailing whitespace not included in . + /// public required WhiteSpaceInfo TrailingWhiteSpace { get; init; } } public readonly struct ParseContext { public required ParseInfo Info { get; init; } - public required ErrorReporter ReportError { get; init; } - public required SourceFile SourceFile { get; init; } + public required ErrorReporter ErrorReporter { get; init; } public required string DirectiveKind { get; init; } public required string DirectiveText { get; init; } + + public void ReportError(string message) + => ErrorReporter(Info.SourceFile.Text, Info.SourceFile.Path, Info.Span, message); + + public void ReportError(TextSpan span, string message) + => ErrorReporter(Info.SourceFile.Text, Info.SourceFile.Path, span, message); } public static Named? Parse(in ParseContext context) @@ -307,10 +323,11 @@ public readonly struct ParseContext case "property": return Property.Parse(context); case "package": return Package.Parse(context); case "project": return Project.Parse(context); + case "include" or "exclude": return IncludeOrExclude.Parse(context); default: - context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.UnrecognizedDirective, context.DirectiveKind)); + context.ReportError(string.Format(FileBasedProgramsResources.UnrecognizedDirective, context.DirectiveKind)); return null; - }; + } } private static (string, string?)? ParseOptionalTwoParts(in ParseContext context, char separator) @@ -321,14 +338,14 @@ private static (string, string?)? ParseOptionalTwoParts(in ParseContext context, string directiveKind = context.DirectiveKind; if (firstPart.IsWhiteSpace()) { - context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); return null; } // If the name contains characters that resemble separators, report an error to avoid any confusion. if (Patterns.DisallowedNameCharacters.Match(context.DirectiveText, beginning: 0, length: firstPart.Length).Success) { - context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.InvalidDirectiveName, directiveKind, separator)); + context.ReportError(string.Format(FileBasedProgramsResources.InvalidDirectiveName, directiveKind, separator)); return null; } @@ -404,7 +421,7 @@ public sealed class Property(in ParseInfo info) : Named(info) if (propertyValue is null) { - context.ReportError(context.SourceFile, context.Info.Span, FileBasedProgramsResources.PropertyDirectiveMissingParts); + context.ReportError(FileBasedProgramsResources.PropertyDirectiveMissingParts); return null; } @@ -414,14 +431,14 @@ public sealed class Property(in ParseInfo info) : Named(info) } catch (XmlException ex) { - context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.PropertyDirectiveInvalidName, ex.Message)); + context.ReportError(string.Format(FileBasedProgramsResources.PropertyDirectiveInvalidName, ex.Message)); return null; } if (propertyName.Equals("RestoreUseStaticGraphEvaluation", StringComparison.OrdinalIgnoreCase) && MSBuildUtilities.ConvertStringToBool(propertyValue)) { - context.ReportError(context.SourceFile, context.Info.Span, FileBasedProgramsResources.StaticGraphRestoreNotSupported); + context.ReportError(FileBasedProgramsResources.StaticGraphRestoreNotSupported); } return new Property(context.Info) @@ -493,8 +510,7 @@ public Project(in ParseInfo info, string name) : base(info) var directiveText = context.DirectiveText; if (directiveText.IsWhiteSpace()) { - string directiveKind = context.DirectiveKind; - context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); return null; } @@ -532,14 +548,15 @@ public Project WithName(string name, NameKind kind) /// /// If the directive points to a directory, returns a new directive pointing to the corresponding project file. /// - public Project EnsureProjectFilePath(SourceFile sourceFile, ErrorReporter reportError) + public Project EnsureProjectFilePath(ErrorReporter errorReporter) { var resolvedName = Name; + var sourcePath = Info.SourceFile.Path; // If the path is a directory like '../lib', transform it to a project file path like '../lib/lib.csproj'. // Also normalize backslashes to forward slashes to ensure the directive works on all platforms. - var sourceDirectory = Path.GetDirectoryName(sourceFile.Path) - ?? throw new InvalidOperationException($"Source file path '{sourceFile.Path}' does not have a containing directory."); + var sourceDirectory = Path.GetDirectoryName(sourcePath) + ?? throw new InvalidOperationException($"Source file path '{sourcePath}' does not have a containing directory."); var resolvedProjectPath = Path.Combine(sourceDirectory, resolvedName.Replace('\\', '/')); if (Directory.Exists(resolvedProjectPath)) @@ -553,20 +570,220 @@ public Project EnsureProjectFilePath(SourceFile sourceFile, ErrorReporter report } else { - reportError(sourceFile, Info.Span, string.Format(FileBasedProgramsResources.InvalidProjectDirective, error)); + ReportError(string.Format(FileBasedProgramsResources.InvalidProjectDirective, error)); } } else if (!File.Exists(resolvedProjectPath)) { - reportError(sourceFile, Info.Span, - string.Format(FileBasedProgramsResources.InvalidProjectDirective, string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, resolvedProjectPath))); + ReportError(string.Format(FileBasedProgramsResources.InvalidProjectDirective, string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, resolvedProjectPath))); } return WithName(resolvedName, NameKind.ProjectFilePath); + + void ReportError(string message) + => errorReporter(Info.SourceFile.Text, sourcePath, Info.Span, message); } public override string ToString() => $"#:project {Name}"; } + + public enum IncludeOrExcludeKind + { + Include, + Exclude, + } + + /// + /// #:include or #:exclude directive. + /// + public sealed class IncludeOrExclude(in ParseInfo info) : Named(info) + { + public const string ExperimentalFileBasedProgramEnableIncludeDirective = nameof(ExperimentalFileBasedProgramEnableIncludeDirective); + public const string ExperimentalFileBasedProgramEnableExcludeDirective = nameof(ExperimentalFileBasedProgramEnableExcludeDirective); + public const string ExperimentalFileBasedProgramEnableTransitiveDirectives = nameof(ExperimentalFileBasedProgramEnableTransitiveDirectives); + public const string ExperimentalFileBasedProgramEnableItemMapping = nameof(ExperimentalFileBasedProgramEnableItemMapping); + + public const string MappingPropertyName = "FileBasedProgramsItemMapping"; + + public static string DefaultMappingString => ".cs=Compile;.resx=EmbeddedResource;.json=None;.razor=Content"; + + public static ImmutableArray<(string Extension, string ItemType)> DefaultMapping + { + get + { + if (field.IsDefault) + { + field = + [ + (".cs", "Compile"), + (".resx", "EmbeddedResource"), + (".json", "None"), + (".razor", "Content"), + ]; + } + + return field; + } + } + + /// + /// Preserved across calls, i.e., + /// this is the original directive text as entered by the user. + /// + public required string OriginalName { get; init; } + + public required IncludeOrExcludeKind Kind { get; init; } + + public string? ItemType { get; init; } + + public static new IncludeOrExclude? Parse(in ParseContext context) + { + var directiveText = context.DirectiveText; + if (directiveText.IsWhiteSpace()) + { + string directiveKind = context.DirectiveKind; + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); + return null; + } + + return new IncludeOrExclude(context.Info) + { + OriginalName = directiveText, + Name = directiveText, + Kind = KindFromString(context.DirectiveKind), + }; + } + + /// + /// See . + /// + public IncludeOrExclude WithDeterminedItemType(ErrorReporter reportError, ImmutableArray<(string Extension, string ItemType)> mapping) + { + Debug.Assert(ItemType is null); + + string? itemType = null; + foreach (var entry in mapping) + { + if (Name.EndsWith(entry.Extension, StringComparison.OrdinalIgnoreCase)) + { + itemType = entry.ItemType; + break; + } + } + + if (itemType is null) + { + reportError(Info.SourceFile.Text, Info.SourceFile.Path, Info.Span, + string.Format(FileBasedProgramsResources.IncludeOrExcludeDirectiveUnknownFileType, + $"#:{KindToString()}", + string.Join(", ", mapping.Select(static e => e.Extension)))); + return this; + } + + return new IncludeOrExclude(Info) + { + OriginalName = OriginalName, + Name = Name, + Kind = Kind, + ItemType = itemType, + }; + } + + public IncludeOrExclude WithName(string name) + { + if (Name == name) + { + return this; + } + + return new IncludeOrExclude(Info) + { + OriginalName = OriginalName, + Name = name, + Kind = Kind, + ItemType = ItemType, + }; + } + + private static IncludeOrExcludeKind KindFromString(string kind) + { + return kind switch + { + "include" => IncludeOrExcludeKind.Include, + "exclude" => IncludeOrExcludeKind.Exclude, + _ => throw new InvalidOperationException($"Unexpected include/exclude directive kind '{kind}'."), + }; + } + + public string KindToString() + { + return Kind switch + { + IncludeOrExcludeKind.Include => "include", + IncludeOrExcludeKind.Exclude => "exclude", + _ => throw new InvalidOperationException($"Unexpected {nameof(IncludeOrExcludeKind)} value '{Kind}'."), + }; + } + + public string KindToMSBuildString() + { + return Kind switch + { + IncludeOrExcludeKind.Include => "Include", + IncludeOrExcludeKind.Exclude => "Remove", + _ => throw new InvalidOperationException($"Unexpected {nameof(IncludeOrExcludeKind)} value '{Kind}'."), + }; + } + + public override string ToString() => $"#:{KindToString()} {Name}"; + + /// + /// Parses a in the format .protobuf=Protobuf;.cshtml=Content. + /// Should come from MSBuild property with name . + /// + public static ImmutableArray<(string Extension, string ItemType)> ParseMapping( + string value, + SourceFile sourceFile, + ErrorReporter errorReporter) + { + var pairs = value.Split(';'); + + var builder = ImmutableArray.CreateBuilder<(string Extension, string ItemType)>(pairs.Length); + + foreach (var pair in pairs) + { + var parts = pair.Split('='); + + if (parts.Length != 2) + { + ReportError(string.Format(FileBasedProgramsResources.InvalidIncludeExcludeMappingEntry, pair)); + continue; + } + + var extension = parts[0].Trim(); + var itemType = parts[1].Trim(); + + if (extension is not ['.', _, ..]) + { + ReportError(string.Format(FileBasedProgramsResources.InvalidIncludeExcludeMappingExtension, extension, pair)); + continue; + } + + if (itemType.IsWhiteSpace()) + { + ReportError(string.Format(FileBasedProgramsResources.InvalidIncludeExcludeMappingItemType, itemType, pair)); + continue; + } + + builder.Add((extension, itemType)); + } + + return builder.DrainToImmutable(); + + void ReportError(string message) + => errorReporter(sourceFile.Text, sourceFile.Path, default, message); + } + } } /// @@ -617,25 +834,25 @@ public readonly struct Position } } -internal delegate void ErrorReporter(SourceFile sourceFile, TextSpan textSpan, string message); +internal delegate void ErrorReporter(SourceText text, string path, TextSpan textSpan, string message, Exception? innerException = null); internal static partial class ErrorReporters { public static readonly ErrorReporter IgnoringReporter = - static (_, _, _) => { }; + static (_, _, _, _, _) => { }; public static ErrorReporter CreateCollectingReporter(out ImmutableArray.Builder builder) { var capturedBuilder = builder = ImmutableArray.CreateBuilder(); - return (sourceFile, textSpan, message) => + return (text, path, textSpan, message, _) => capturedBuilder.Add(new SimpleDiagnostic { Location = new SimpleDiagnostic.Position() { - Path = sourceFile.Path, + Path = path, TextSpan = textSpan, - Span = sourceFile.GetFileLinePositionSpan(textSpan).Span + Span = text.Lines.GetLinePositionSpan(textSpan) }, Message = message }); diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index 9376a191aa0..8beab97ae92 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -1,5 +1,25 @@ +const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableExcludeDirective = "ExperimentalFileBasedProgramEnableExcludeDirective" -> string! +const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective = "ExperimentalFileBasedProgramEnableIncludeDirective" -> string! +const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableItemMapping = "ExperimentalFileBasedProgramEnableItemMapping" -> string! +const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives = "ExperimentalFileBasedProgramEnableTransitiveDirectives" -> string! +const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.MappingPropertyName = "FileBasedProgramsItemMapping" -> string! Microsoft.DotNet.FileBasedPrograms.CSharpDirective Microsoft.DotNet.FileBasedPrograms.CSharpDirective.CSharpDirective(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.IncludeOrExclude(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ItemType.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ItemType.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.Kind.get -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExcludeKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.Kind.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.KindToMSBuildString() -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.KindToString() -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.OriginalName.get -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.OriginalName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.WithDeterminedItemType(Microsoft.DotNet.FileBasedPrograms.ErrorReporter! reportError, System.Collections.Immutable.ImmutableArray<(string! Extension, string! ItemType)> mapping) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.WithName(string! name) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExcludeKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExcludeKind.Exclude = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExcludeKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExcludeKind.Include = 0 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExcludeKind Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Info.get -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named.Name.get -> string! @@ -14,23 +34,27 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.DirectiveKind.ge Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.DirectiveKind.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.DirectiveText.get -> string! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.DirectiveText.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ErrorReporter.get -> Microsoft.DotNet.FileBasedPrograms.ErrorReporter! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ErrorReporter.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.Info.get -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.Info.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ParseContext() -> void -Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ReportError.get -> Microsoft.DotNet.FileBasedPrograms.ErrorReporter! -Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ReportError.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ReportError(Microsoft.CodeAnalysis.Text.TextSpan span, string! message) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.ReportError(string! message) -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.SourceFile.get -> Microsoft.DotNet.FileBasedPrograms.SourceFile Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.SourceFile.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.LeadingWhiteSpace.get -> Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.LeadingWhiteSpace.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.ParseInfo() -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.SourceFile.get -> Microsoft.DotNet.FileBasedPrograms.SourceFile +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.SourceFile.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.Span.get -> Microsoft.CodeAnalysis.Text.TextSpan Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.Span.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.TrailingWhiteSpace.get -> Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.TrailingWhiteSpace.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project -Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.EnsureProjectFilePath(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! reportError) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.EnsureProjectFilePath(Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.get -> string? Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind @@ -81,7 +105,6 @@ Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.SimpleDiagnostic() -> void Microsoft.DotNet.FileBasedPrograms.SourceFile Microsoft.DotNet.FileBasedPrograms.SourceFile.Deconstruct(out string! Path, out Microsoft.CodeAnalysis.Text.SourceText! Text) -> void Microsoft.DotNet.FileBasedPrograms.SourceFile.Equals(Microsoft.DotNet.FileBasedPrograms.SourceFile other) -> bool -Microsoft.DotNet.FileBasedPrograms.SourceFile.GetFileLinePositionSpan(Microsoft.CodeAnalysis.Text.TextSpan span) -> Microsoft.CodeAnalysis.FileLinePositionSpan Microsoft.DotNet.FileBasedPrograms.SourceFile.GetLocationString(Microsoft.CodeAnalysis.Text.TextSpan span) -> string! Microsoft.DotNet.FileBasedPrograms.SourceFile.Path.get -> string! Microsoft.DotNet.FileBasedPrograms.SourceFile.Path.init -> void @@ -90,19 +113,23 @@ Microsoft.DotNet.FileBasedPrograms.SourceFile.SourceFile() -> void Microsoft.DotNet.FileBasedPrograms.SourceFile.SourceFile(string! Path, Microsoft.CodeAnalysis.Text.SourceText! Text) -> void Microsoft.DotNet.FileBasedPrograms.SourceFile.Text.get -> Microsoft.CodeAnalysis.Text.SourceText! Microsoft.DotNet.FileBasedPrograms.SourceFile.Text.init -> void -Microsoft.DotNet.FileBasedPrograms.SourceFile.WithText(Microsoft.CodeAnalysis.Text.SourceText! newText) -> Microsoft.DotNet.FileBasedPrograms.SourceFile Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo -Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo.LineBreaks -> int -Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo.TotalLength -> int +Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo.BlankLineLength -> int +Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo.RestLength -> int Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo.WhiteSpaceInfo() -> void Microsoft.DotNet.ProjectTools.ProjectLocator override abstract Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ToString() -> string! +override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Shebang.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.SourceFile.GetHashCode() -> int +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.DefaultMapping.get -> System.Collections.Immutable.ImmutableArray<(string! Extension, string! ItemType)> +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.DefaultMappingString.get -> string! +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude? +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ParseMapping(string! value, Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> System.Collections.Immutable.ImmutableArray<(string! Extension, string! ItemType)> static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project? @@ -113,9 +140,8 @@ static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.CombineHashCodes(int v static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.GetRelativePath(string! relativeTo, string! path) -> string! static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.IsPathFullyQualified(string! path) -> bool static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.CreateTokenizer(Microsoft.CodeAnalysis.Text.SourceText! text) -> Microsoft.CodeAnalysis.CSharp.SyntaxTokenParser! -static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.EvaluateDirectives(Microsoft.Build.Execution.ProjectInstance? project, System.Collections.Immutable.ImmutableArray directives, Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> System.Collections.Immutable.ImmutableArray -static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, bool reportAllErrors, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! reportError) -> System.Collections.Immutable.ImmutableArray -static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindLeadingDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.CodeAnalysis.SyntaxTriviaList triviaList, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! reportError, System.Collections.Immutable.ImmutableArray.Builder? builder) -> void +static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, bool reportAllErrors, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> System.Collections.Immutable.ImmutableArray +static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindLeadingDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.CodeAnalysis.SyntaxTriviaList triviaList, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter, System.Collections.Immutable.ImmutableArray.Builder? builder) -> void static Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities.ConvertStringToBool(string? parameterValue, bool defaultValue = false) -> bool static Microsoft.DotNet.FileBasedPrograms.Patterns.DisallowedNameCharacters.get -> System.Text.RegularExpressions.Regex! static Microsoft.DotNet.FileBasedPrograms.Patterns.EscapedCompilerOption.get -> System.Text.RegularExpressions.Regex! @@ -126,6 +152,6 @@ static Microsoft.DotNet.FileBasedPrograms.SourceFile.operator ==(Microsoft.DotNe static Microsoft.DotNet.ProjectTools.ProjectLocator.TryGetProjectFileFromDirectory(string! projectDirectory, out string? projectFilePath, out string? error) -> bool static readonly Microsoft.DotNet.FileBasedPrograms.ErrorReporters.IgnoringReporter -> Microsoft.DotNet.FileBasedPrograms.ErrorReporter! static readonly Microsoft.DotNet.FileBasedPrograms.NamedDirectiveComparer.Instance -> Microsoft.DotNet.FileBasedPrograms.NamedDirectiveComparer! -virtual Microsoft.DotNet.FileBasedPrograms.ErrorReporter.Invoke(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.CodeAnalysis.Text.TextSpan textSpan, string! message) -> void +virtual Microsoft.DotNet.FileBasedPrograms.ErrorReporter.Invoke(Microsoft.CodeAnalysis.Text.SourceText! text, string! path, Microsoft.CodeAnalysis.Text.TextSpan textSpan, string! message, System.Exception? innerException = null) -> void ~override Microsoft.DotNet.FileBasedPrograms.SourceFile.Equals(object obj) -> bool ~override Microsoft.DotNet.FileBasedPrograms.SourceFile.ToString() -> string \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj index 58804bce47a..9ffbf147ff0 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj @@ -34,15 +34,10 @@ - + diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index 37292a467a3..06082920daf 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -27,16 +27,31 @@ Duplicitní direktivy nejsou podporovány: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Nelze určit cestu k dočasnému adresáři. Zvažte konfiguraci proměnné prostředí TEMP v systému Windows nebo místní datové složky aplikace v systému Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Nerozpoznaná přípona souboru v direktivě {0}. V současné době jsou rozpoznávány pouze tyto přípony: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Direktiva by měla obsahovat název bez speciálních znaků a volitelnou hodnotu oddělenou znakem {1}, například #:{0} Název{1}Hodnota. {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Každá položka ve vlastnosti MSBuild FileBasedProgramsItemMapping musí být mapována na neprázdný typ položky. Typ položky {0} v položce {1} je neplatný. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} Direktiva #:project je neplatná: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index 991dcca846e..e5143ceb186 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -27,16 +27,31 @@ Doppelte Anweisungen werden nicht unterstützt: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Ein temporärer Verzeichnispfad kann nicht ermittelt werden. Erwägen Sie, die TEMP-Umgebungsvariable unter Windows oder den lokalen App-Datenordner unter Unix zu konfigurieren. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Unbekannte Dateierweiterung in der „{0}“-Anweisung. Derzeit werden nur diese Erweiterungen erkannt: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Die Anweisung sollte einen Namen ohne Sonderzeichen und einen optionalen Wert enthalten, die durch „{1}“ getrennt sind, wie „#:{0} Name{1}Wert“. {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Jeder Eintrag in der MSBuild-Eigenschaft „FileBasedProgramsItemMapping“ muss einem nicht leeren Elementtyp zugeordnet sein. Der Elementtyp „{0}“ im Eintrag „{1}“ ist ungültig. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} Die Anweisung „#:p roject“ ist ungültig: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 2983aa595df..7fcfcf9a443 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -27,16 +27,31 @@ No se admiten directivas duplicadas: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - No se puede determinar una ruta de acceso temporal al directorio. Considere la posibilidad de configurar la variable de entorno TEMP en Windows o la carpeta de datos de la aplicación local en Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Extensión de archivo no reconocida en la directiva ''{0}. Actualmente solo se reconocen estas extensiones: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. La directiva debe contener un nombre sin caracteres especiales y un valor opcional separado por "{1}" como "#:{0} Nombre{1}Valor". {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Cada entrada de la propiedad MSBuild ''FileBasedProgramsItemMapping'' debe tener dos partes separadas por '='. La entrada ''{0}'' no es válida. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Cada entrada de la propiedad MSBuild ''FileBasedProgramsItemMapping'' debe asignarse desde una extensión de archivo que no esté vacía a partir de '.'. La extensión ''{0}'' de la entrada ''{1}'' no es válida. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Cada entrada de la propiedad MSBuild ''FileBasedProgramsItemMapping'' debe asignarse a un tipo de elemento no vacío. El tipo de elemento ''{0}'' de la entrada ''{1}'' no es válido. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} La directiva "#:project" no es válida: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 1fabce52be8..3219b19dddc 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -27,16 +27,31 @@ Les directives dupliquées ne sont pas prises en charge : {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Impossible de déterminer un chemin d’accès pour le répertoire temporaire. Nous vous recommandons de configurer la variable d’environnement TEMP sous Windows ou le dossier des données d’application locale sous Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Extension de fichier non reconnue dans la directive « {0} ». Seules ces extensions sont actuellement reconnues : {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. La directive dans doit contenir un nom sans caractères spéciaux et une valeur facultative séparée par « {1} » comme « # :{0} Nom{1}Valeur ». {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Chaque entrée de la propriété MSBuild « FileBasedProgramsItemMapping » doit correspondre à un type d’élément non vide. Le type d’élément « {0} » dans l’entrée « {1} » est invalide. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} La directive « #:project » n’est pas valide : {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 93fdb8209fd..e91c768d3a4 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -27,16 +27,31 @@ Le direttive duplicate non supportate: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Non è possibile determinare un percorso per la directory temporanea. Considerare la configurazione della variabile di ambiente TEMP in Windows o della cartella dei dati locali dell'app in Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Estensione file non riconosciuta nella direttiva "{0}". Sono riconosciute solo queste estensioni: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. La direttiva deve contenere un nome senza caratteri speciali e un valore facoltativo delimitato da '{1}' come '#:{0}Nome {1}Valore'. {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Ogni voce nella proprietà MSBuild "FileBasedProgramsItemMapping" deve essere mappata a un tipo di elemento non vuoto. Il tipo di elemento "{0}" nella voce "{1}" non è valido. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} La direttiva '#:project' non è valida: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index f76d2b52824..2d2cc195c23 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -27,16 +27,31 @@ 重複するディレクティブはサポートされていません: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - 一時ディレクトリ パスを特定できません。Windows で TEMP 環境変数を構成するか、Unix でローカル アプリ データ フォルダーを構成することを検討してください。 - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + '{0}' ディレクティブ内の認識されないファイル拡張子。現在認識されている拡張子は次のとおりです: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. ディレクティブには、特殊文字を含まない名前と、'#:{0} Name{1}Value' などの '{1}' で区切られた省略可能な値を含める必要があります。 {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild プロパティの各エントリには、'=' で区切られた 2 つの部分が必要です。エントリ '{0}' が無効です。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild プロパティの各エントリは、'.' で始まる空でないファイル拡張子からマップする必要があります。エントリ '{0}' の拡張子 '{1}' は無効です。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild プロパティの各エントリは、空でないアイテムの種類にマップする必要があります。エントリ '{1}' のアイテムの種類 '{0}' が無効です。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} '#:p roject' ディレクティブが無効です: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index afdd17c8f43..7082b47f9aa 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -27,16 +27,31 @@ 중복 지시문은 지원되지 않습니다. {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - 임시 디렉터리 경로를 확인할 수 없습니다. Windows에서는 TEMP 환경 변수를, Unix에서는 로컬 앱 데이터 폴더를 설정하는 것이 좋습니다. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + '{0}' 지시문에서 인식할 수 없는 파일 확장자입니다. 현재 인식되는 확장자는 다음과 같습니다. {1}. + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. 지시문에는 특수 문자가 없는 이름과 '#:{0} 이름{1}값'과 같이 '{1}'(으)로 구분된 선택적 값이 포함되어야 합니다. {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 속성의 각 항목은 '='로 구분된 두 부분으로 구성되어야 합니다. '{0}' 항목이 잘못되었습니다. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 속성의 각 항목은 '.'로 시작하는 비어 있지 않은 파일 확장명에 매핑되어야 합니다. '{0}' 항목의 확장명 '{1}'이(가) 잘못되었습니다. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 속성의 각 항목은 비어 있지 않은 항목 종류에 매핑되어야 합니다. '{0}' 항목의 항목 종류 '{1}'이(가) 잘못되었습니다. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} '#:p roject' 지시문이 잘못되었습니다. {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index 5e91ebbb30c..9f88bcfee1e 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -27,16 +27,31 @@ Zduplikowane dyrektywy nie są obsługiwane: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Nie można określić tymczasowej ścieżki katalogu. Rozważ skonfigurowanie zmiennej środowiskowej TEMP w systemie Windows lub folderze danych aplikacji lokalnej w systemie Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Nierozpoznane rozszerzenie pliku w dyrektywie „{0}”. Obecnie rozpoznawane są tylko te rozszerzenia: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Dyrektywa powinna zawierać nazwę bez znaków specjalnych i opcjonalną wartość rozdzieloną znakiem "{1}#:{0} Name{1}Value". {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Każdy wpis w właściwości MSBuild „FileBasedProgramsItemMapping” musi mapować na niepusty typ elementu. Typ elementu „{0}” we wpisie „{1}” jest nieprawidłowy. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} Dyrektywa „#:project” jest nieprawidłowa: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index f44fda05d92..a5ef266abb7 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -27,16 +27,31 @@ Diretivas duplicadas não são suportadas:{0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Não é possível determinar um caminho de diretório temporário. Considere configurar a variável de ambiente TEMP no Windows ou a pasta de dados do aplicativo local no Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Extensão de arquivo não reconhecida na diretiva '{0}'. Somente estas extensões são reconhecidas atualmente: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. A diretiva deve conter um nome sem caracteres especiais e um valor opcional separado por '{1}' como '#:{0} Nome{1}Valor'. {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Cada entrada na propriedade MSBuild 'FileBasedProgramsItemMapping' deve ter duas partes separadas por '='. A entrada '{0}' é inválida. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Cada entrada na propriedade MSBuild 'FileBasedProgramsItemMapping' deve mapear a partir de uma extensão de arquivo não vazia que comece com '.'. A extensão '{0}' na entrada '{1}' é inválida. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Cada entrada na propriedade MSBuild 'FileBasedProgramsItemMapping' deve mapear para um tipo de item não vazio. O tipo de item '{0}' na entrada '{1}' é inválido. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} A diretiva '#:project' é inválida:{0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index 5e9af0ed594..dc9834600f8 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -27,16 +27,31 @@ Повторяющиеся директивы не поддерживаются: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Не удалось определить путь к временному каталогу. Рассмотрите возможность настроить переменную среды TEMP в Windows или папку локальных данных приложений в Unix. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + Нераспознанное расширение файла в директиве "{0}". В настоящее время распознаются только следующие расширения: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Директива должна содержать имя без специальных символов и необязательное значение, разделенные символом-разделителем "{1}", например "#:{0} Имя{1}Значение". {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + Каждая запись в свойстве MSBuild "FileBasedProgramsItemMapping" должна сопоставляться с непустым типом элемента. Тип элемента "{0}" в записи "{1}" недопустим. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} Недопустимая директива "#:project": {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index 93c4d39ae84..40ee3f703b9 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -27,16 +27,31 @@ Yinelenen yönergeler desteklenmez: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - Geçici dizin yolu saptanamıyor. Windows'da TEMP ortam değişkenini veya Unix'te yerel uygulama verileri klasörünü yapılandırmayı göz önünde bulundurun. - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + '{0}' yönergesinde tanınmayan dosya uzantısı var. Şu anda yalnızca şu uzantılar tanınıyor: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Yönerge, özel karakterler içermeyen bir ad ve ‘#:{0} Ad{1}Değer’ gibi '{1}' ile ayrılmış isteğe bağlı bir değer içermelidir. {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild özelliğindeki her girdi, '=' ile ayrılmış iki bölümden oluşmalıdır. '{0}' girdisi geçersizdir. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild özelliğindeki her girdi, '.' ile başlayan boş olmayan bir dosya uzantısına karşılık gelmelidir. '{1}' girdisindeki '{0}' uzantısı geçersizdir. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild özelliğindeki her girdi, boş olmayan bir öğe türüne karşılık gelmelidir. '{1}' girdisindeki '{0}' öğe türü geçersizdir. + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} ‘#:project’ yönergesi geçersizdir: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 27b469f6394..b5c92edfd75 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -27,16 +27,31 @@ 不支持重复指令: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - 无法确定临时目录路径。请考虑在 Windows 上配置 TEMP 环境变量,或在 Unix 上配置本地应用数据文件夹。 - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + '{0}' 指令中的文件扩展名无法识别。当前仅识别以下扩展名: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. 该指令应包含一个不带特殊字符的名称,以及一个以 '#:{0} Name{1}Value' 等 ‘{1}’ 分隔的可选值。 {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 属性中的每个条目必须包含由 '=' 分隔的两部分。条目 '{0}' 无效。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 属性中的每个条目必须映射自以 '.' 开头的非空文件扩展名。条目 '{1}' 中的扩展名 '{0}' 无效。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 属性中的每个条目必须映射到非空项类型。条目 '{1}' 中的项类型 '{0}' 无效。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} '#:project' 指令无效: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index f5a7d9f89ee..d65f38fe59f 100644 --- a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -27,16 +27,31 @@ 不支援重複的指示詞: {0} {0} is the directive type and name. - - Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. - 無法判斷暫存 目錄路徑。考慮在 Windows 上或 Unix 上的本機應用程式資料資料資料夾上設定 TEMP 環境變數。 - + + Unrecognized file extension in the '{0}' directive. Only these extensions are currently recognized: {1} + '{0}' 指示詞中無法辨識的副檔名。目前僅能識別這些副檔名: {1} + {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. 指示詞應包含不含特殊字元的名稱,以及 '{1}' 分隔的選用值,例如 '#:{0} Name{1}Value'。 {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must have two parts separated by '='. The entry '{0}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 屬性中的每個項目都必須有兩個部分,以 '=' 區隔。項目 '{0}' 無效。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'='"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map from a non-empty file extension starting with '.'. The extension '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 屬性中的每個項目都必須從開頭為 '.' 的非空白副檔名對應。項目 '{1}' 中的副檔名 '{0}' 無效。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"}{Locked="'.'"} + + + Each entry in 'FileBasedProgramsItemMapping' MSBuild property must map to a non-empty item type. The item type '{0}' in entry '{1}' is invalid. + 'FileBasedProgramsItemMapping' MSBuild 屬性中的每個項目都必須與非空白的項目類型對應。項目 '{1}' 中的項目類型 '{0}' 無效。 + {Locked="FileBasedProgramsItemMapping"}{Locked="MSBuild"} + The '#:project' directive is invalid: {0} '#:project' 指示詞無效: {0} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.cs index 67db2e0b93c..e5b000fe50a 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.ProjectTools; -public sealed class ExecutableLaunchProfile : LaunchProfile +internal sealed class ExecutableLaunchProfile : LaunchProfile { public const string WorkingDirectoryPropertyName = "workingDirectory"; public const string ExecutablePathPropertyName = "executablePath"; diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.cs index 87f6275e83b..e994f6635cd 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.ProjectTools; -public abstract class LaunchProfile +internal abstract class LaunchProfile { [JsonIgnore] public string? LaunchProfileName { get; init; } diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.cs index bf735a81cf0..7bc2e979e3b 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.ProjectTools; -public sealed class LaunchProfileParseResult +internal sealed class LaunchProfileParseResult { public string? FailureReason { get; } diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs index ae1c60161b2..e2f9725f18f 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs @@ -18,12 +18,12 @@ public static class LaunchSettings { ExecutableLaunchProfileParser.CommandName, ExecutableLaunchProfileParser.Instance } }; - public static IEnumerable SupportedProfileTypes => s_providers.Keys; + internal static IEnumerable SupportedProfileTypes => s_providers.Keys; - public static string GetPropertiesLaunchSettingsPath(string directoryPath, string propertiesDirectoryName) + internal static string GetPropertiesLaunchSettingsPath(string directoryPath, string propertiesDirectoryName) => Path.Combine(directoryPath, propertiesDirectoryName, "launchSettings.json"); - public static string GetFlatLaunchSettingsPath(string directoryPath, string projectNameWithoutExtension) + internal static string GetFlatLaunchSettingsPath(string directoryPath, string projectNameWithoutExtension) => Path.Join(directoryPath, $"{projectNameWithoutExtension}.run.json"); public static string? TryFindLaunchSettingsFile(string projectOrEntryPointFilePath, string? launchProfile, Action report) @@ -71,7 +71,7 @@ public static string GetFlatLaunchSettingsPath(string directoryPath, string proj return null; } - public static LaunchProfileParseResult ReadProfileSettingsFromFile(string launchSettingsPath, string? profileName = null) + internal static LaunchProfileParseResult ReadProfileSettingsFromFile(string launchSettingsPath, string? profileName = null) { try { diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.cs index a107dc29db8..d131b1f5c29 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.ProjectTools; -public sealed class ProjectLaunchProfile : LaunchProfile +internal sealed class ProjectLaunchProfile : LaunchProfile { [JsonPropertyName("launchBrowser")] public bool LaunchBrowser { get; init; } diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj index 83af1863d36..a046b7ec516 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj @@ -12,16 +12,14 @@ - + - - diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/PublicAPI.Shipped.txt b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/PublicAPI.Shipped.txt new file mode 100644 index 00000000000..7dc5c58110b --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/PublicAPI.Unshipped.txt b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/PublicAPI.Unshipped.txt new file mode 100644 index 00000000000..df2ed9470e3 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/PublicAPI.Unshipped.txt @@ -0,0 +1,7 @@ +#nullable enable +Microsoft.DotNet.ProjectTools.LaunchSettings +Microsoft.DotNet.ProjectTools.VirtualProjectBuilder +static Microsoft.DotNet.ProjectTools.LaunchSettings.TryFindLaunchSettingsFile(string! projectOrEntryPointFilePath, string? launchProfile, System.Action! report) -> string? +static Microsoft.DotNet.ProjectTools.VirtualProjectBuilder.CreateProjectInstance(string! entryPointFilePath, string! targetFramework, Microsoft.Build.Evaluation.ProjectCollection! projectCollection, System.Action! errorReporter) -> Microsoft.Build.Execution.ProjectInstance! +static Microsoft.DotNet.ProjectTools.VirtualProjectBuilder.GetVirtualProjectPath(string! entryPointFilePath) -> string! +static Microsoft.DotNet.ProjectTools.VirtualProjectBuilder.IsValidEntryPointPath(string! entryPointFilePath) -> bool diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx index 0c69ccc49b2..117e6b5642f 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx @@ -155,4 +155,15 @@ Make the profile names distinct. (Default) + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + + File included via #:include directive (or Compile item) not found: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.cs index 176173e776c..7a7941e02a3 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Utilities; -public static class Sha256Hasher +internal static class Sha256Hasher { /// /// The hashed mac address needs to be the same hashed value as produced by the other distinct sources given the same input. (e.g. VsCode) diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index 5a99e0e6373..2356b742ea2 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -15,13 +15,15 @@ namespace Microsoft.DotNet.ProjectTools; -internal sealed class VirtualProjectBuilder +public sealed class VirtualProjectBuilder { private readonly IEnumerable<(string name, string value)> _defaultProperties; - public string EntryPointFileFullPath { get; } + private (ImmutableArray Original, ImmutableArray Evaluated)? _evaluatedDirectives; - public SourceFile EntryPointSourceFile + internal string EntryPointFileFullPath { get; } + + internal SourceFile EntryPointSourceFile { get { @@ -34,16 +36,17 @@ public SourceFile EntryPointSourceFile } } - public string ArtifactsPath + internal string ArtifactsPath => field ??= GetArtifactsPath(EntryPointFileFullPath); - public string[]? RequestedTargets { get; } + internal string[]? RequestedTargets { get; } - public VirtualProjectBuilder( + internal VirtualProjectBuilder( string entryPointFileFullPath, string targetFramework, string[]? requestedTargets = null, - string? artifactsPath = null) + string? artifactsPath = null, + SourceText? sourceText = null) { Debug.Assert(Path.IsPathFullyQualified(entryPointFileFullPath)); @@ -51,12 +54,17 @@ public VirtualProjectBuilder( RequestedTargets = requestedTargets; ArtifactsPath = artifactsPath; _defaultProperties = GetDefaultProperties(targetFramework); + + if (sourceText != null) + { + EntryPointSourceFile = new SourceFile(entryPointFileFullPath, sourceText); + } } /// /// Kept in sync with the default dotnet new console project file (enforced by DotnetProjectConvertTests.SameAsTemplate). /// - public static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework) => + internal static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework) => [ ("OutputType", "Exe"), ("TargetFramework", targetFramework), @@ -66,7 +74,7 @@ public VirtualProjectBuilder( ("PackAsTool", "true"), ]; - public static string GetArtifactsPath(string entryPointFileFullPath) + internal static string GetArtifactsPath(string entryPointFileFullPath) { // Include entry point file name so the directory name is not completely opaque. string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath); @@ -82,7 +90,7 @@ public static string GetVirtualProjectPath(string entryPointFilePath) /// /// Obtains a temporary subdirectory for file-based app artifacts, e.g., /tmp/dotnet/runfile/. /// - public static string GetTempSubdirectory() + internal static string GetTempSubdirectory() { // We want a location where permissions are expected to be restricted to the current user. string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -91,7 +99,7 @@ public static string GetTempSubdirectory() if (string.IsNullOrEmpty(directory)) { - throw new InvalidOperationException(FileBasedProgramsResources.EmptyTempPath); + throw new InvalidOperationException(Resources.EmptyTempPath); } return Path.Join(directory, "dotnet", "runfile"); @@ -100,7 +108,7 @@ public static string GetTempSubdirectory() /// /// Obtains a specific temporary path in a subdirectory for file-based app artifacts, e.g., /tmp/dotnet/runfile/{name}. /// - public static string GetTempSubpath(string name) + internal static string GetTempSubpath(string name) { return Path.Join(GetTempSubdirectory(), name); } @@ -132,77 +140,198 @@ public static bool IsValidEntryPointPath(string entryPointFilePath) } /// - /// If there are any #:project , - /// evaluates their values as MSBuild expressions (i.e. substitutes $() and @() with property and item values, etc.) and - /// resolves the evaluated values to full project file paths (e.g. if the evaluted value is a directory finds a project in that directory). + /// Evaluates against a and the file system. /// - internal static ImmutableArray EvaluateDirectives( - ProjectInstance? project, + /// + /// All directives that need some other evaluation (described below) are expanded as MSBuild expressions + /// (i.e., $() and @() are substituted with property and item values, etc.). + /// + /// #:project directives are resolved to full project file paths + /// (e.g., if the evaluated value is a directory, finds a project in that directory). + /// + /// #:include/#:exclude have their determined + /// and relative paths resolved relative to their containing file. + /// + private ImmutableArray EvaluateDirectives( + ProjectInstance project, ImmutableArray directives, - SourceFile sourceFile, - ErrorReporter errorReporter) + ErrorReporter reportError) { - if (directives.OfType().Any()) + if (!directives.Any(static d => d is CSharpDirective.Project or CSharpDirective.IncludeOrExclude)) { - return directives - .Select(d => d is CSharpDirective.Project p - ? (project is null - ? p - : p.WithName(project.ExpandString(p.Name), CSharpDirective.Project.NameKind.Expanded)) - .EnsureProjectFilePath(sourceFile, errorReporter) - : d) - .ToImmutableArray(); + return directives; + } + + var builder = ImmutableArray.CreateBuilder(directives.Length); + + ImmutableArray<(string Extension, string ItemType)> mapping = default; + + foreach (var directive in directives) + { + switch (directive) + { + case CSharpDirective.Project projectDirective: + projectDirective = projectDirective.WithName(project.ExpandString(projectDirective.Name), CSharpDirective.Project.NameKind.Expanded); + projectDirective = projectDirective.EnsureProjectFilePath(reportError); + + builder.Add(projectDirective); + break; + + case CSharpDirective.IncludeOrExclude includeOrExcludeDirective: + var expandedPath = project.ExpandString(includeOrExcludeDirective.Name); + var fullPath = Path.GetFullPath(path: expandedPath, basePath: Path.GetDirectoryName(includeOrExcludeDirective.Info.SourceFile.Path)!); + includeOrExcludeDirective = includeOrExcludeDirective.WithName(fullPath); + + if (mapping.IsDefault) + { + mapping = GetItemMapping(project, reportError); + } + + includeOrExcludeDirective = includeOrExcludeDirective.WithDeterminedItemType(reportError, mapping); + + builder.Add(includeOrExcludeDirective); + break; + + default: + builder.Add(directive); + break; + } } - return directives; + return builder.DrainToImmutable(); } - public void CreateProjectInstance( + internal ImmutableArray<(string Extension, string ItemType)> GetItemMapping(ProjectInstance project, ErrorReporter reportError) + { + return MSBuildUtilities.ConvertStringToBool(project.GetPropertyValue(CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableItemMapping)) + ? CSharpDirective.IncludeOrExclude.ParseMapping( + project.GetPropertyValue(CSharpDirective.IncludeOrExclude.MappingPropertyName), + EntryPointSourceFile, + reportError) + : CSharpDirective.IncludeOrExclude.DefaultMapping; + } + + public static ProjectInstance CreateProjectInstance( + string entryPointFilePath, + string targetFramework, ProjectCollection projectCollection, - ErrorReporter errorReporter, + Action errorReporter) + { + var builder = new VirtualProjectBuilder(entryPointFilePath, targetFramework); + + builder.CreateProjectInstance( + projectCollection, + (text, path, textSpan, message, _) => errorReporter(path, text.Lines.GetLinePositionSpan(textSpan).Start.Line + 1, message), + out var projectInstance, + out _); + + return projectInstance; + } + + internal void CreateProjectInstance( + ProjectCollection projectCollection, + ErrorReporter reportError, out ProjectInstance project, out ImmutableArray evaluatedDirectives, ImmutableArray directives = default, Action>? addGlobalProperties = null, bool validateAllDirectives = false) { + var directivesOriginal = directives; + if (directives.IsDefault) { - directives = FileLevelDirectiveHelpers.FindDirectives(EntryPointSourceFile, validateAllDirectives, errorReporter); + directives = FileLevelDirectiveHelpers.FindDirectives(EntryPointSourceFile, validateAllDirectives, reportError); } - project = CreateProjectInstance(projectCollection, directives, addGlobalProperties); + (string ProjectFileText, ProjectInstance ProjectInstance)? lastProject = null; - evaluatedDirectives = EvaluateDirectives(project, directives, EntryPointSourceFile, errorReporter); - if (evaluatedDirectives != directives) + // If we evaluated directives previously (e.g., during restore), reuse them. + // We don't use the additional properties from `addGlobalProperties` + // during directive evaluation anyway, so the directives can be reused safely. + if (_evaluatedDirectives is { } cached && + cached.Original == directivesOriginal) { - project = CreateProjectInstance(projectCollection, evaluatedDirectives, addGlobalProperties); + evaluatedDirectives = cached.Evaluated; + project = CreateProjectInstanceNoEvaluation( + projectCollection, + evaluatedDirectives, + addGlobalProperties); + + CheckDirectives(project, evaluatedDirectives, reportError); + + return; } - } - private ProjectInstance CreateProjectInstance( - ProjectCollection projectCollection, - ImmutableArray directives, - Action>? addGlobalProperties = null) - { - var projectRoot = CreateProjectRootElement(projectCollection); + var entryPointDirectory = Path.GetDirectoryName(EntryPointFileFullPath)!; + var seenFiles = new HashSet(1, StringComparer.Ordinal) { EntryPointFileFullPath }; + var filesToProcess = new Queue(); + var evaluatedDirectiveBuilder = ImmutableArray.CreateBuilder(); - var globalProperties = projectCollection.GlobalProperties; - if (addGlobalProperties is not null) + do { - globalProperties = new Dictionary(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase); - addGlobalProperties(globalProperties); + // Create a project with properties from #:property directives so they can be expanded inside EvaluateDirectives. + project = CreateProjectInstanceNoEvaluation( + projectCollection, + [.. evaluatedDirectiveBuilder, .. directives], + addGlobalProperties); + + // Evaluate directives, e.g., determine item types for #:include/#:exclude from their file extension. + var fileEvaluatedDirectives = EvaluateDirectives(project, directives, reportError); + + evaluatedDirectiveBuilder.AddRange(fileEvaluatedDirectives); + + if (fileEvaluatedDirectives != directives) + { + // This project will contain items from #:include/#:exclude directives which we will traverse recursively. + project = CreateProjectInstanceNoEvaluation( + projectCollection, + evaluatedDirectiveBuilder.ToImmutable(), + addGlobalProperties); + } + + var compileItems = project.GetItems("Compile"); + foreach (var compileItem in compileItems) + { + var compilePath = Path.GetFullPath( + path: compileItem.GetMetadataValue("FullPath"), + basePath: entryPointDirectory); + if (seenFiles.Add(compilePath)) + { + filesToProcess.Enqueue(compilePath); + } + } } + while (TryGetNextFileToProcess()); + + evaluatedDirectives = evaluatedDirectiveBuilder.ToImmutable(); + _evaluatedDirectives = (directivesOriginal, evaluatedDirectives); + + CheckDirectives(project, evaluatedDirectives, reportError); - return ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions + bool TryGetNextFileToProcess() { - ProjectCollection = projectCollection, - GlobalProperties = globalProperties, - }); + while (filesToProcess.TryDequeue(out var filePath)) + { + if (!File.Exists(filePath)) + { + reportError(EntryPointSourceFile.Text, EntryPointSourceFile.Path, default, string.Format(Resources.IncludedFileNotFound, filePath)); + continue; + } + + var sourceFile = SourceFile.Load(filePath); + directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, validateAllDirectives, reportError); + return true; + } - ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) + return false; + } + + ProjectInstance CreateProjectInstanceNoEvaluation( + ProjectCollection projectCollection, + ImmutableArray directives, + Action>? addGlobalProperties = null) { - var projectFileFullPath = GetVirtualProjectPath(EntryPointFileFullPath); var projectFileWriter = new StringWriter(); WriteProjectFile( @@ -210,26 +339,99 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) directives, _defaultProperties, isVirtualProject: true, - targetFilePath: EntryPointFileFullPath, + entryPointFilePath: EntryPointFileFullPath, artifactsPath: ArtifactsPath, includeRuntimeConfigInformation: RequestedTargets?.ContainsAny("Publish", "Pack") != true); var projectFileText = projectFileWriter.ToString(); - using var reader = new StringReader(projectFileText); - using var xmlReader = XmlReader.Create(reader); - var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); - projectRoot.FullPath = projectFileFullPath; - return projectRoot; + // If nothing changed, reuse the previous project instance to avoid unnecessary re-evaluations. + if (lastProject is { } cachedProject && cachedProject.ProjectFileText == projectFileText) + { + return cachedProject.ProjectInstance; + } + + var projectRoot = CreateProjectRootElement(projectFileText, projectCollection); + + var globalProperties = projectCollection.GlobalProperties; + if (addGlobalProperties is not null) + { + globalProperties = new Dictionary(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase); + addGlobalProperties(globalProperties); + } + + var result = ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions + { + ProjectCollection = projectCollection, + GlobalProperties = globalProperties, + }); + + lastProject = (projectFileText, result); + + return result; + + ProjectRootElement CreateProjectRootElement(string projectFileText, ProjectCollection projectCollection) + { + using var reader = new StringReader(projectFileText); + using var xmlReader = XmlReader.Create(reader); + var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); + projectRoot.FullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj"); + return projectRoot; + } + } + } + + private void CheckDirectives( + ProjectInstance project, + ImmutableArray directives, + ErrorReporter reportError) + { + bool? includeEnabled = null; + bool? excludeEnabled = null; + bool? transitiveEnabled = null; + + foreach (var directive in directives) + { + if (directive is CSharpDirective.IncludeOrExclude includeOrExcludeDirective) + { + if (includeOrExcludeDirective.Kind == CSharpDirective.IncludeOrExcludeKind.Include) + { + CheckFlagEnabled(ref includeEnabled, CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective, directive); + } + else + { + Debug.Assert(includeOrExcludeDirective.Kind == CSharpDirective.IncludeOrExcludeKind.Exclude); + CheckFlagEnabled(ref excludeEnabled, CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableExcludeDirective, directive); + } + } + + if (directive.Info.SourceFile.Path != EntryPointSourceFile.Path) + { + CheckFlagEnabled(ref transitiveEnabled, CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives, directive); + } + } + + void CheckFlagEnabled(ref bool? flag, string flagName, CSharpDirective directive) + { + bool value = flag ??= MSBuildUtilities.ConvertStringToBool(project.GetPropertyValue(flagName)); + + if (!value) + { + reportError( + directive.Info.SourceFile.Text, + directive.Info.SourceFile.Path, + directive.Info.Span, + string.Format(Resources.ExperimentalFeatureDisabled, flagName)); + } } } - public static void WriteProjectFile( + internal static void WriteProjectFile( TextWriter writer, ImmutableArray directives, IEnumerable<(string name, string value)> defaultProperties, bool isVirtualProject, - string? targetFilePath = null, + string? entryPointFilePath = null, string? artifactsPath = null, bool includeRuntimeConfigInformation = true, string? userSecretsId = null) @@ -242,6 +444,7 @@ public static void WriteProjectFile( var propertyDirectives = directives.OfType(); var packageDirectives = directives.OfType(); var projectDirectives = directives.OfType(); + var includeOrExcludeDirectives = directives.OfType(); const string defaultSdkName = "Microsoft.NET.Sdk"; string firstSdkName; @@ -275,6 +478,7 @@ public static void WriteProjectFile( artifacts/$(MSBuildProjectName) artifacts/$(MSBuildProjectName) true + {CSharpDirective.IncludeOrExclude.DefaultMappingString} false true """); @@ -285,7 +489,7 @@ public static void WriteProjectFile( bool usingOnlyDefaultSdk = firstSdkName == defaultSdkName && sdkDirectives.Count() <= 1; if (usingOnlyDefaultSdk) { - writer.WriteLine($""" + writer.WriteLine(""" false false """); @@ -416,6 +620,43 @@ public static void WriteProjectFile( """); } + if (!isVirtualProject) + { + // In the real project, files are included by the conversion copying them to the output directory, + // hence we don't need to transfer the #:include/#:exclude directives over. + processedDirectives += includeOrExcludeDirectives.Count(); + } + else if (includeOrExcludeDirectives.Any()) + { + writer.WriteLine(""" + + """); + + foreach (var includeOrExclude in includeOrExcludeDirectives) + { + processedDirectives++; + + var itemType = includeOrExclude.ItemType; + + if (itemType == null) + { + // Before directives are evaluated, the item type is null. + // We still need to create the project (so that we can evaluate $() properties), + // but we can skip the items. + continue; + } + + writer.WriteLine($""" + <{itemType} {includeOrExclude.KindToMSBuildString()}="{EscapeValue(includeOrExclude.Name)}" /> + """); + } + + writer.WriteLine(""" + + + """); + } + if (packageDirectives.Any()) { writer.WriteLine(""" @@ -471,25 +712,25 @@ public static void WriteProjectFile( if (isVirtualProject) { - Debug.Assert(targetFilePath is not null); + Debug.Assert(entryPointFilePath is not null); - // Only add explicit Compile item when EnableDefaultCompileItems is not true. - // When EnableDefaultCompileItems=true, the file is included via default MSBuild globbing. - // See https://github.com/dotnet/sdk/issues/51785 + // We Exclude existing Compile items (which could be added e.g. + // in Microsoft.NET.Sdk.DefaultItems.props when user sets EnableDefaultCompileItems=true, + // or above via #:include/#:exclude directives). writer.WriteLine($""" - + """); if (includeRuntimeConfigInformation) { - var targetDirectory = Path.GetDirectoryName(targetFilePath) ?? ""; + var entryPointDirectory = Path.GetDirectoryName(entryPointFilePath) ?? ""; writer.WriteLine($""" - - + + """); @@ -533,30 +774,4 @@ static void WriteImport(TextWriter writer, string project, CSharpDirective.Sdk s } } } - - public static SourceText? RemoveDirectivesFromFile(ImmutableArray directives, SourceText text) - { - if (directives.Length == 0) - { - return null; - } - - Debug.Assert(directives.OrderBy(d => d.Info.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location."); - - for (int i = directives.Length - 1; i >= 0; i--) - { - var directive = directives[i]; - text = text.Replace(directive.Info.Span, string.Empty); - } - - return text; - } - - public static void RemoveDirectivesFromFile(ImmutableArray directives, SourceText text, string filePath) - { - if (RemoveDirectivesFromFile(directives, text) is { } modifiedText) - { - new SourceFile(filePath, modifiedText).Save(); - } - } } diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.cs.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.cs.xlf index 6b4ff5bb55d..c27bf12ff8d 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.cs.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.cs.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Nastavte odlišné názvy profilů. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Nelze určit cestu k dočasnému adresáři. Zvažte konfiguraci proměnné prostředí TEMP v systému Windows nebo místní datové složky aplikace v systému Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Toto je experimentální funkce. Pokud ji chcete povolit, nastavte vlastnost MSBuild {0} na hodnotu true. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + Soubor zahrnutý prostřednictvím direktivy #:include (nebo položky Compile) nebyl nalezen: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Profil spuštění s názvem {0} neexistuje. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf index 93d3cd73331..be6f488dfaf 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Erstellen Sie eindeutige Profilnamen. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Ein temporärer Verzeichnispfad kann nicht ermittelt werden. Erwägen Sie, die TEMP-Umgebungsvariable unter Windows oder den lokalen App-Datenordner unter Unix zu konfigurieren. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Dies ist eine experimentelle Funktion. Legen Sie die MSBuild-Eigenschaft „{0}“ auf „true“ fest, um sie zu aktivieren. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + Die über die #:include-Anweisung (oder das Compile-Element) eingebundene Datei wurde nicht gefunden: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Es ist kein Startprofil mit dem Namen "{0}" vorhanden. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf index 5e37b3de70a..417c200b9de 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Defina nombres de perfiles distintos. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + No se puede determinar una ruta de acceso temporal al directorio. Considere la posibilidad de configurar la variable de entorno TEMP en Windows o la carpeta de datos de la aplicación local en Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Se trata de una característica experimental, establezca la propiedad "{0}" de MSBuild en "true" para habilitarla. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + No se encuentra el archivo incluido mediante la directiva #:include (o elemento Compile): {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. No existe ningún perfil de inicio con el nombre "{0}". diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf index 6440e8fe7f3..b9afff50e4b 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Faites en sorte que les noms de profil soient distincts. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Impossible de déterminer un chemin d’accès pour le répertoire temporaire. Nous vous recommandons de configurer la variable d’environnement TEMP sous Windows ou le dossier des données d’application locale sous Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Il s’agit d’une fonctionnalité expérimentale, définissez la propriété MSBuild « {0} » sur « true » pour l’activer. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + Fichier inclus via la directive #:include (ou élément Compile) introuvable : {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Un profil de lancement avec le nom '{0}' n'existe pas. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf index 37441888832..b81b4e94697 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Rendi distinti i nomi profilo. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Non è possibile determinare un percorso per la directory temporanea. Considerare la configurazione della variabile di ambiente TEMP in Windows o della cartella dei dati locali dell'app in Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Questa è una funzionalità sperimentale, impostare la proprietà MSBuild "{0}" su "true" per abilitarla. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + File incluso tramite direttiva #:include (o elemento Compile) non trovato: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Non esiste un profilo di avvio con il nome '{0}'. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf index 7a932cd4791..0e0a34c9824 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. プロファイル名を区別できるようにしてください。 + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + 一時ディレクトリ パスを特定できません。Windows で TEMP 環境変数を構成するか、Unix でローカル アプリ データ フォルダーを構成することを検討してください。 + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + これは試験的な機能です。有効にするには、MSBuild プロパティ '{0}' を 'true' に設定してください。 + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + #:include ディレクティブ (または Compile 項目) を介して含められているファイルが見つかりません: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. '{0} ' という名前の起動プロファイルは存在しません。 diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf index 738033adb53..047b32c0f0e 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. 고유한 프로필 이름을 사용하세요. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + 임시 디렉터리 경로를 확인할 수 없습니다. Windows에서는 TEMP 환경 변수를, Unix에서는 로컬 앱 데이터 폴더를 설정하는 것이 좋습니다. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + 이 기능은 실험적인 기능입니다. 사용하려면 MSBuild 속성 '{0}'을(를) 'true'로 설정하세요. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + #:include 지시문(또는 Compile 항목)을 통해 포함된 파일을 찾을 수 없음: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. 이름이 '{0}'인 시작 프로필이 없습니다. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf index cfd4a8d9cf5..cb88c348803 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Rozróżnij nazwy profilów. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Nie można określić tymczasowej ścieżki katalogu. Rozważ skonfigurowanie zmiennej środowiskowej TEMP w systemie Windows lub folderze danych aplikacji lokalnej w systemie Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + To funkcja eksperymentalna, ustaw właściwość MSBuild „{0}” na wartość „true”, aby ją włączyć. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + Nie znaleziono pliku dołączonego za pomocą dyrektywy #:include (lub elementu Compile): {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Profil uruchamiania o nazwie „{0}” nie istnieje. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf index 68e6a06dfb3..904cd0a4983 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Diferencie os nomes dos perfis. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Não é possível determinar um caminho de diretório temporário. Considere configurar a variável de ambiente TEMP no Windows ou a pasta de dados do aplicativo local no Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Esse é um recurso experimental, defina a propriedade "{0}" do MSBuild como "true" para habilitá-lo. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + Arquivo incluído por meio da diretiva #:include (ou item de Compile) não encontrado: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Um perfil de lançamento com o nome '{0}' não existe. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf index 4261e1e952e..11a9bdc7fae 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Сделайте имена профилей разными. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Не удалось определить путь к временному каталогу. Рассмотрите возможность настроить переменную среды TEMP в Windows или папку локальных данных приложений в Unix. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Это экспериментальная функция. Чтобы включить ее, присвойте свойству MSBuild "{0}" значение true. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + Файл, включенный через директиву #:include (или элемент Compile), не найден: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. Профиль запуска с именем "{0}" не существует. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf index 6bd01ca3bd4..d9db5826f9c 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. Profil adlarının birbirinden farklı olmasını sağlayın. + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + Geçici dizin yolu saptanamıyor. Windows'da TEMP ortam değişkenini veya Unix'te yerel uygulama verileri klasörünü yapılandırmayı göz önünde bulundurun. + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + Bu deneysel bir özelliktir, etkinleştirmek için '{0}' MSBuild özelliğini 'true' değerine ayarlayın. + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + #:include yönergesiyle (veya Compile öğesi) eklenen dosya bulunamadı: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. '{0}' adlı bir başlatma profili yok. diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf index 9aded33cb3c..5eefc4c634b 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. 将配置文件名称设为可区分的名称。 + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + 无法确定临时目录路径。请考虑在 Windows 上配置 TEMP 环境变量,或在 Unix 上配置本地应用数据文件夹。 + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + 这是一项实验性功能,将 MSBuild 属性 '{0}' 设置为 'true' 以启用它。 + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + 找不到通过 #:include 指令(或 Compile 项)包含的文件: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. 名为“{0}”的启动配置文件不存在。 diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf index 8650ca7c10d..eb15ea99c93 100644 --- a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf @@ -21,6 +21,21 @@ Make the profile names distinct. 請讓設定檔名稱相異。 + + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + 無法判斷暫存 目錄路徑。考慮在 Windows 上或 Unix 上的本機應用程式資料資料資料夾上設定 TEMP 環境變數。 + + + + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. + 這是實驗性功能,將 MSBuild 屬性 '{0}' 設定為 'true' 以啟用它。 + {Locked="MSBuild"}{Locked="true"}. {0} is MSBuild property name. + + + File included via #:include directive (or Compile item) not found: {0} + 找不到透過 #:include 指示詞 (或 Compile 項目) 包含的檔案: {0} + {Locked="#:include"}{Locked="Compile"}. {0} is file path. + A launch profile with the name '{0}' doesn't exist. 名稱為 '{0}' 的啟動設定檔不存在。 diff --git a/src/WatchPrototype/Watch.Aspire/AspireLauncher.cs b/src/WatchPrototype/Watch.Aspire/AspireLauncher.cs new file mode 100644 index 00000000000..2dee0c755ca --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/AspireLauncher.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class AspireLauncher +{ + public EnvironmentOptions EnvironmentOptions { get; } + public GlobalOptions GlobalOptions { get; } + public PhysicalConsole Console { get; } + public ConsoleReporter Reporter { get; } + public LoggerFactory LoggerFactory { get; } + public ILogger Logger { get; } + + public AspireLauncher(GlobalOptions globalOptions, EnvironmentOptions environmentOptions) + { + GlobalOptions = globalOptions; + EnvironmentOptions = environmentOptions; + Console = new PhysicalConsole(environmentOptions.TestFlags); + Reporter = new ConsoleReporter(Console, environmentOptions.LogMessagePrefix, environmentOptions.SuppressEmojis); + LoggerFactory = new LoggerFactory(Reporter, environmentOptions.CliLogLevel ?? globalOptions.LogLevel); + Logger = LoggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName); + } + + public static AspireLauncher? TryCreate(string[] args) + { + var rootCommand = new AspireRootCommand(); + + var parseResult = rootCommand.Parse(args); + if (parseResult.Errors.Count > 0) + { + foreach (var error in parseResult.Errors) + { + System.Console.Error.WriteLine(error); + } + + return null; + } + + return parseResult.CommandResult.Command switch + { + AspireServerCommandDefinition serverCommand => AspireServerLauncher.TryCreate(parseResult, serverCommand), + AspireResourceCommandDefinition resourceCommand => AspireResourceLauncher.TryCreate(parseResult, resourceCommand), + AspireHostCommandDefinition hostCommand => AspireHostLauncher.TryCreate(parseResult, hostCommand), + _ => throw new InvalidOperationException(), + }; + } + + public abstract Task LaunchAsync(CancellationToken cancellationToken); +} diff --git a/src/WatchPrototype/Watch.Aspire/Commands/AspireCommandDefinition.cs b/src/WatchPrototype/Watch.Aspire/Commands/AspireCommandDefinition.cs new file mode 100644 index 00000000000..0b3748dc235 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Commands/AspireCommandDefinition.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class AspireCommandDefinition : Command +{ + public readonly Option QuietOption = new("--quiet") { Arity = ArgumentArity.Zero }; + public readonly Option VerboseOption = new("--verbose") { Arity = ArgumentArity.Zero }; + + protected AspireCommandDefinition(string name, string description) + : base(name, description) + { + Options.Add(VerboseOption); + Options.Add(QuietOption); + + VerboseOption.Validators.Add(v => + { + if (v.HasOption(QuietOption) && v.HasOption(VerboseOption)) + { + v.AddError("Cannot specify both '--quiet' and '--verbose' options."); + } + }); + } + + public LogLevel GetLogLevel(ParseResult parseResult) + => parseResult.GetValue(QuietOption) ? LogLevel.Warning : parseResult.GetValue(VerboseOption) ? LogLevel.Debug : LogLevel.Information; +} diff --git a/src/WatchPrototype/Watch.Aspire/Commands/AspireHostCommandDefinition.cs b/src/WatchPrototype/Watch.Aspire/Commands/AspireHostCommandDefinition.cs new file mode 100644 index 00000000000..4475a7fcf2c --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Commands/AspireHostCommandDefinition.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireHostCommandDefinition : AspireCommandDefinition +{ + public readonly Option SdkOption = new("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + /// + /// Project or file. + /// + public readonly Option EntryPointOption = new("--entrypoint") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + public readonly Argument ApplicationArguments = new("arguments") { Arity = ArgumentArity.ZeroOrMore }; + public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Arity = ArgumentArity.Zero }; + public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Arity = ArgumentArity.ExactlyOne }; + + public AspireHostCommandDefinition() + : base("host", "Starts AppHost project.") + { + Arguments.Add(ApplicationArguments); + + Options.Add(SdkOption); + Options.Add(EntryPointOption); + Options.Add(NoLaunchProfileOption); + Options.Add(LaunchProfileOption); + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs b/src/WatchPrototype/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs new file mode 100644 index 00000000000..88ead313c6b --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireResourceCommandDefinition : AspireCommandDefinition +{ + public readonly Argument ApplicationArguments = new("arguments") { Arity = ArgumentArity.ZeroOrMore }; + + /// + /// Server pipe name. + /// + public readonly Option ServerOption = new("--server") + { + Arity = ArgumentArity.ExactlyOne, + Required = true, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option EntryPointOption = new("--entrypoint") + { + Arity = ArgumentArity.ExactlyOne, + Required = true, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option> EnvironmentOption = new("--environment", "-e") + { + Description = "Environment variables for the process", + CustomParser = ParseEnvironmentVariables, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Arity = ArgumentArity.Zero }; + public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Arity = ArgumentArity.ExactlyOne }; + + public AspireResourceCommandDefinition() + : base("resource", "Starts resource project.") + { + Arguments.Add(ApplicationArguments); + + Options.Add(ServerOption); + Options.Add(EntryPointOption); + Options.Add(EnvironmentOption); + Options.Add(NoLaunchProfileOption); + Options.Add(LaunchProfileOption); + } + + private static IReadOnlyDictionary ParseEnvironmentVariables(ArgumentResult argumentResult) + { + var result = new Dictionary(PathUtilities.OSSpecificPathComparer); + + List? invalid = null; + + foreach (var token in argumentResult.Tokens) + { + var separator = token.Value.IndexOf('='); + var (name, value) = (separator >= 0) + ? (token.Value[0..separator], token.Value[(separator + 1)..]) + : (token.Value, ""); + + name = name.Trim(); + + if (name != "") + { + result[name] = value; + } + else + { + invalid ??= []; + invalid.Add(token); + } + } + + if (invalid != null) + { + argumentResult.AddError(string.Format( + "Incorrectly formatted environment variables {0}", + string.Join(", ", invalid.Select(x => $"'{x.Value}'")))); + } + + return result; + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Commands/AspireRootCommand.cs b/src/WatchPrototype/Watch.Aspire/Commands/AspireRootCommand.cs new file mode 100644 index 00000000000..f9d3b6f6b26 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Commands/AspireRootCommand.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireRootCommand : RootCommand +{ + public readonly AspireServerCommandDefinition ServerCommand = new(); + public readonly AspireResourceCommandDefinition ResourceCommand = new(); + public readonly AspireHostCommandDefinition HostCommand = new(); + + public AspireRootCommand() + { + Directives.Add(new EnvironmentVariablesDirective()); + + Subcommands.Add(ServerCommand); + Subcommands.Add(ResourceCommand); + Subcommands.Add(HostCommand); + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Commands/AspireServerCommandDefinition.cs b/src/WatchPrototype/Watch.Aspire/Commands/AspireServerCommandDefinition.cs new file mode 100644 index 00000000000..dfb64002f2a --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Commands/AspireServerCommandDefinition.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireServerCommandDefinition : AspireCommandDefinition +{ + /// + /// Server pipe name. + /// + public readonly Option ServerOption = new("--server") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + public readonly Option SdkOption = new("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + /// + /// Paths to resource projects or entry-point files. + /// + public readonly Option ResourceOption = new("--resource") { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true }; + + /// + /// Status pipe name for sending watch status events back to the AppHost. + /// + public readonly Option StatusPipeOption = new("--status-pipe") { Arity = ArgumentArity.ExactlyOne, AllowMultipleArgumentsPerToken = false }; + + /// + /// Control pipe name for receiving commands from the AppHost. + /// + public readonly Option ControlPipeOption = new("--control-pipe") { Arity = ArgumentArity.ExactlyOne, AllowMultipleArgumentsPerToken = false }; + + public AspireServerCommandDefinition() + : base("server", "Starts the dotnet watch server.") + { + Options.Add(ServerOption); + Options.Add(SdkOption); + Options.Add(ResourceOption); + Options.Add(StatusPipeOption); + Options.Add(ControlPipeOption); + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Commands/OptionExtensions.cs b/src/WatchPrototype/Watch.Aspire/Commands/OptionExtensions.cs new file mode 100644 index 00000000000..2ec5b9f2007 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Commands/OptionExtensions.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Microsoft.DotNet.Watch; + +internal static class OptionExtensions +{ + public static bool HasOption(this SymbolResult symbolResult, Option option) + => symbolResult.GetResult(option) is OptionResult or && !or.Implicit; +} diff --git a/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs b/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs deleted file mode 100644 index 121b600ef50..00000000000 --- a/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch; - -internal static class DotNetWatchLauncher -{ - public static async Task RunAsync(string workingDirectory, DotNetWatchOptions options) - { - var globalOptions = new GlobalOptions() - { - LogLevel = options.LogLevel, - NoHotReload = false, - NonInteractive = true, - }; - - var commandArguments = new List(); - if (options.NoLaunchProfile) - { - commandArguments.Add("--no-launch-profile"); - } - - commandArguments.AddRange(options.ApplicationArguments); - - var rootProjectOptions = new ProjectOptions() - { - IsRootProject = true, - Representation = options.Project, - WorkingDirectory = workingDirectory, - TargetFramework = null, - BuildArguments = [], - NoLaunchProfile = options.NoLaunchProfile, - LaunchProfileName = null, - Command = "run", - CommandArguments = [.. commandArguments], - LaunchEnvironmentVariables = [], - }; - - var muxerPath = Path.GetFullPath(Path.Combine(options.SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension)); - - // msbuild tasks depend on host path variable: - Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, muxerPath); - - var console = new PhysicalConsole(TestFlags.None); - var reporter = new ConsoleReporter(console, suppressEmojis: false); - var environmentOptions = EnvironmentOptions.FromEnvironment(muxerPath); - var processRunner = new ProcessRunner(environmentOptions.GetProcessCleanupTimeout()); - var loggerFactory = new LoggerFactory(reporter, globalOptions.LogLevel); - var logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName); - - using var context = new DotNetWatchContext() - { - ProcessOutputReporter = reporter, - LoggerFactory = loggerFactory, - Logger = logger, - BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName), - ProcessRunner = processRunner, - Options = globalOptions, - EnvironmentOptions = environmentOptions, - RootProjectOptions = rootProjectOptions, - BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), - BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions), - }; - - using var shutdownHandler = new ShutdownHandler(console, logger); - - try - { - var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null); - await watcher.WatchAsync(shutdownHandler.CancellationToken); - } - catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested) - { - // Ctrl+C forced an exit - } - catch (Exception e) - { - logger.LogError("An unexpected error occurred: {Exception}", e.ToString()); - return false; - } - - return true; - } -} diff --git a/src/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/Host/AspireHostLauncher.cs b/src/WatchPrototype/Watch.Aspire/Host/AspireHostLauncher.cs new file mode 100644 index 00000000000..bb3c9a92f11 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Host/AspireHostLauncher.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireHostLauncher( + GlobalOptions globalOptions, + EnvironmentOptions environmentOptions, + ProjectRepresentation entryPoint, + ImmutableArray applicationArguments, + Optional launchProfileName) + : AspireWatcherLauncher(globalOptions, environmentOptions) +{ + internal const string LogMessagePrefix = "aspire watch host"; + + public ProjectRepresentation EntryPoint => entryPoint; + public ImmutableArray ApplicationArguments => applicationArguments; + public Optional LaunchProfileName => launchProfileName; + + internal static AspireHostLauncher? TryCreate(ParseResult parseResult, AspireHostCommandDefinition command) + { + var sdkDirectory = parseResult.GetValue(command.SdkOption)!; + var entryPointPath = parseResult.GetValue(command.EntryPointOption)!; + var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? []; + var launchProfile = parseResult.GetValue(command.LaunchProfileOption); + var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption); + + var globalOptions = new GlobalOptions() + { + LogLevel = command.GetLogLevel(parseResult), + NoHotReload = false, + NonInteractive = true, + }; + + return new AspireHostLauncher( + globalOptions, + EnvironmentOptions.FromEnvironment(sdkDirectory, LogMessagePrefix), + entryPoint: ProjectRepresentation.FromProjectOrEntryPointFilePath(entryPointPath), + applicationArguments: [.. applicationArguments], + launchProfileName: noLaunchProfile ? Optional.NoValue : launchProfile); + } + + internal ProjectOptions GetProjectOptions() + { + var commandArguments = new List() + { + EntryPoint.IsProjectFile ? "--project" : "--file", + EntryPoint.ProjectOrEntryPointFilePath, + }; + + if (LaunchProfileName.Value != null) + { + commandArguments.Add("--launch-profile"); + commandArguments.Add(LaunchProfileName.Value); + } + else if (!LaunchProfileName.HasValue) + { + commandArguments.Add("--no-launch-profile"); + } + + commandArguments.AddRange(ApplicationArguments); + + return new ProjectOptions() + { + IsMainProject = true, + Representation = EntryPoint, + WorkingDirectory = EnvironmentOptions.WorkingDirectory, + LaunchProfileName = LaunchProfileName, + Command = "run", + CommandArguments = [.. commandArguments], + LaunchEnvironmentVariables = [], + }; + } + + public override async Task LaunchAsync(CancellationToken cancellationToken) + { + return await LaunchWatcherAsync( + rootProjects: [EntryPoint], + LoggerFactory, + processLauncherFactory: null, + cancellationToken); + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj b/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj index fb08c258b80..3e7fef4bc7c 100644 --- a/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj +++ b/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj @@ -22,8 +22,10 @@ + + - + diff --git a/src/WatchPrototype/Watch.Aspire/Program.cs b/src/WatchPrototype/Watch.Aspire/Program.cs index 37de5ceca57..bb758c667b1 100644 --- a/src/WatchPrototype/Watch.Aspire/Program.cs +++ b/src/WatchPrototype/Watch.Aspire/Program.cs @@ -1,12 +1,26 @@ -using Microsoft.Build.Locator; +using System.Diagnostics; +using Microsoft.Build.Locator; using Microsoft.DotNet.Watch; -if (!DotNetWatchOptions.TryParse(args, out var options)) +try { - return -1; -} + if (AspireLauncher.TryCreate(args) is not { } launcher) + { + return -1; + } -MSBuildLocator.RegisterMSBuildPath(options.SdkDirectory); + if (launcher.EnvironmentOptions.SdkDirectory != null) + { + MSBuildLocator.RegisterMSBuildPath(launcher.EnvironmentOptions.SdkDirectory); -var workingDirectory = Directory.GetCurrentDirectory(); -return await DotNetWatchLauncher.RunAsync(workingDirectory, options) ? 0 : 1; + // msbuild tasks depend on host path variable: + Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, launcher.EnvironmentOptions.GetMuxerPath()); + } + + return await launcher.LaunchAsync(CancellationToken.None); +} +catch (Exception e) +{ + Console.Error.WriteLine($"Unexpected exception: {e}"); + return -1; +} diff --git a/src/WatchPrototype/Watch.Aspire/Resource/AspireResourceLauncher.cs b/src/WatchPrototype/Watch.Aspire/Resource/AspireResourceLauncher.cs new file mode 100644 index 00000000000..c38f561f138 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Resource/AspireResourceLauncher.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using System.IO.Pipes; +using System.Text.Json; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireResourceLauncher( + GlobalOptions globalOptions, + EnvironmentOptions environmentOptions, + string serverPipeName, + string entryPoint, + ImmutableArray applicationArguments, + IReadOnlyDictionary environmentVariables, + Optional launchProfileName, + TimeSpan pipeConnectionTimeout) + : AspireLauncher(globalOptions, environmentOptions) +{ + internal const string LogMessagePrefix = "aspire watch resource"; + + public const byte Version = 1; + + // Output message type bytes + public const byte OutputTypeStdout = 1; + public const byte OutputTypeStderr = 2; + + public string ServerPipeName => serverPipeName; + public string EntryPoint => entryPoint; + public ImmutableArray ApplicationArguments => applicationArguments; + public IReadOnlyDictionary EnvironmentVariables => environmentVariables; + public Optional LaunchProfileName => launchProfileName; + + public static AspireResourceLauncher? TryCreate(ParseResult parseResult, AspireResourceCommandDefinition command) + { + var serverPipeName = parseResult.GetValue(command.ServerOption)!; + var entryPointPath = parseResult.GetValue(command.EntryPointOption)!; + var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? []; + var environmentVariables = parseResult.GetValue(command.EnvironmentOption) ?? ImmutableDictionary.Empty; + var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption); + var launchProfile = parseResult.GetValue(command.LaunchProfileOption); + + var globalOptions = new GlobalOptions() + { + LogLevel = command.GetLogLevel(parseResult), + NoHotReload = false, + NonInteractive = true, + }; + + return new AspireResourceLauncher( + globalOptions, + // SDK directory is not needed for the resource launcher since it doesn't interact with MSBuild: + EnvironmentOptions.FromEnvironment(sdkDirectory: null, LogMessagePrefix), + serverPipeName: serverPipeName, + entryPoint: entryPointPath, + applicationArguments: [.. applicationArguments], + environmentVariables: environmentVariables, + launchProfileName: noLaunchProfile ? Optional.NoValue : launchProfile, + pipeConnectionTimeout: AspireEnvironmentVariables.PipeConnectionTimeout); + } + + /// + /// Connects to the server via named pipe, sends resource options as JSON, waits for ACK, + /// then stays alive proxying stdout/stderr from the server back to the console. + /// + public override async Task LaunchAsync(CancellationToken cancellationToken) + { + try + { + Logger.LogDebug("Connecting to {ServerPipeName}...", ServerPipeName); + + using var pipeClient = new NamedPipeClientStream( + serverName: ".", + ServerPipeName, + PipeDirection.InOut, + PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); + + // Timeout ensures we don't hang indefinitely if the server isn't ready or the pipe name is wrong. + using var connectionCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectionCancellationSource.CancelAfter(pipeConnectionTimeout); + await pipeClient.ConnectAsync(connectionCancellationSource.Token); + + var request = new LaunchResourceRequest() + { + EntryPoint = EntryPoint, + ApplicationArguments = ApplicationArguments, + EnvironmentVariables = EnvironmentVariables, + LaunchProfileName = LaunchProfileName, + }; + + await pipeClient.WriteAsync(Version, cancellationToken); + + var json = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(request)); + await pipeClient.WriteAsync(json, cancellationToken); + + // Wait for ACK byte + var status = await pipeClient.ReadByteAsync(cancellationToken); + if (status == 0) + { + Logger.LogDebug("Server closed connection without sending ACK."); + return 1; + } + + Logger.LogDebug("Request sent. Waiting for output..."); + + // Stay alive and proxy output from the server + return await ProxyOutputAsync(pipeClient, cancellationToken); + } + catch (OperationCanceledException) + { + return 0; + } + catch (EndOfStreamException) + { + // Pipe disconnected - server shut down + return 0; + } + catch (Exception ex) + { + Logger.LogDebug("Failed to communicate with server: {Message}", ex.Message); + return 1; + } + } + + private async Task ProxyOutputAsync(NamedPipeClientStream pipe, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + byte typeByte; + try + { + typeByte = await pipe.ReadByteAsync(cancellationToken); + } + catch (EndOfStreamException) + { + // Pipe closed, server shut down + return 0; + } + + var content = await pipe.ReadStringAsync(cancellationToken); + + var output = typeByte switch + { + OutputTypeStdout => Console.Out, + OutputTypeStderr => Console.Error, + _ => throw new InvalidOperationException($"Unexpected output type: '{typeByte:X2}'") + }; + + output.WriteLine(content); + } + + return 0; + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Resource/LaunchResourceRequest.cs b/src/WatchPrototype/Watch.Aspire/Resource/LaunchResourceRequest.cs new file mode 100644 index 00000000000..209edaf42f4 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Resource/LaunchResourceRequest.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; + +namespace Microsoft.DotNet.Watch; + +internal sealed class LaunchResourceRequest +{ + public required string EntryPoint { get; init; } + public required ImmutableArray ApplicationArguments { get; init; } + public required IReadOnlyDictionary EnvironmentVariables { get; init; } + public required Optional LaunchProfileName { get; init; } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/AspireServerLauncher.cs b/src/WatchPrototype/Watch.Aspire/Server/AspireServerLauncher.cs new file mode 100644 index 00000000000..00d40cfecd8 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/AspireServerLauncher.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using System.Threading.Channels; +using Microsoft.CodeAnalysis.Elfie.Diagnostics; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireServerLauncher( + GlobalOptions globalOptions, + EnvironmentOptions environmentOptions, + string serverPipeName, + ImmutableArray resourcePaths, + string? statusPipeName, + string? controlPipeName) + : AspireWatcherLauncher(globalOptions, environmentOptions) +{ + private const string LogMessagePrefix = "aspire watch server"; + + public string ServerPipeName => serverPipeName; + public ImmutableArray ResourcePaths => resourcePaths; + public string? StatusPipeName => statusPipeName; + public string? ControlPipeName => controlPipeName; + + public static AspireServerLauncher? TryCreate(ParseResult parseResult, AspireServerCommandDefinition command) + { + var serverPipeName = parseResult.GetValue(command.ServerOption)!; + var sdkDirectory = parseResult.GetValue(command.SdkOption)!; + var resourcePaths = parseResult.GetValue(command.ResourceOption) ?? []; + var statusPipeName = parseResult.GetValue(command.StatusPipeOption); + var controlPipeName = parseResult.GetValue(command.ControlPipeOption); + + var globalOptions = new GlobalOptions() + { + LogLevel = command.GetLogLevel(parseResult), + NoHotReload = false, + NonInteractive = true, + }; + + return new AspireServerLauncher( + globalOptions, + EnvironmentOptions.FromEnvironment(sdkDirectory, LogMessagePrefix), + serverPipeName: serverPipeName, + resourcePaths: [.. resourcePaths], + statusPipeName: statusPipeName, + controlPipeName: controlPipeName); + } + + public override async Task LaunchAsync(CancellationToken cancellationToken) + { + await using var statusWriter = StatusPipeName != null ? new WatchStatusWriter(StatusPipeName, Logger) : null; + + var processLauncherFactory = new ProcessLauncherFactory(ServerPipeName, ControlPipeName, statusWriter, launchProfile: null, cancellationToken); + + return await LaunchWatcherAsync( + rootProjects: [.. ResourcePaths.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], + statusWriter != null ? new StatusReportingLoggerFactory(statusWriter, LoggerFactory) : LoggerFactory, + processLauncherFactory, + cancellationToken); + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/AspireWatcherLauncher.cs b/src/WatchPrototype/Watch.Aspire/Server/AspireWatcherLauncher.cs new file mode 100644 index 00000000000..0ccc2689428 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/AspireWatcherLauncher.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Elfie.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class AspireWatcherLauncher(GlobalOptions globalOptions, EnvironmentOptions environmentOptions) + : AspireLauncher(globalOptions, environmentOptions) +{ + protected async Task LaunchWatcherAsync( + ImmutableArray rootProjects, + ILoggerFactory loggerFactory, + IRuntimeProcessLauncherFactory? processLauncherFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory != LoggerFactory + ? loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName) + : Logger; + + using var context = new DotNetWatchContext() + { + ProcessOutputReporter = Reporter, + LoggerFactory = loggerFactory, + Logger = logger, + BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName), + ProcessRunner = new ProcessRunner(EnvironmentOptions.GetProcessCleanupTimeout()), + Options = GlobalOptions, + EnvironmentOptions = EnvironmentOptions, + MainProjectOptions = null, + BuildArguments = [], + TargetFramework = null, + RootProjects = rootProjects, + BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), + BrowserLauncher = new BrowserLauncher(logger, Reporter, EnvironmentOptions), + }; + + using var shutdownHandler = new ShutdownHandler(Console, context.Logger); + using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, shutdownHandler.CancellationToken); + + try + { + var watcher = new HotReloadDotNetWatcher(context, Console, processLauncherFactory); + await watcher.WatchAsync(cancellationSource.Token); + } + catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested) + { + // Ctrl+C forced an exit + } + + return 0; + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/ProcessLauncherFactory.cs b/src/WatchPrototype/Watch.Aspire/Server/ProcessLauncherFactory.cs new file mode 100644 index 00000000000..7e50e0ff571 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/ProcessLauncherFactory.cs @@ -0,0 +1,358 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO.Pipes; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading.Channels; +using Aspire.Tools.Service; +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProcessLauncherFactory( + string serverPipeName, + string? controlPipeName, + WatchStatusWriter? statusWriter, + Optional launchProfile, + CancellationToken shutdownCancellationToken) : IRuntimeProcessLauncherFactory +{ + public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher) + { + // Connect to control pipe if provided + var controlReader = controlPipeName != null + ? new WatchControlReader(controlPipeName, projectLauncher.CompilationHandler, projectLauncher.Logger) + : null; + + return new Launcher(serverPipeName, controlReader, projectLauncher, statusWriter, launchProfile, shutdownCancellationToken); + } + + private sealed class Launcher : IRuntimeProcessLauncher + { + private const byte Version = 1; + + private readonly Optional _launchProfileName; + private readonly Task _listenerTask; + private readonly WatchStatusWriter? _statusWriter; + private readonly WatchControlReader? _controlReader; + private readonly ProjectLauncher _projectLauncher; + + private CancellationTokenSource? _disposalCancellationSource; + private ImmutableHashSet _pendingRequestCompletions = []; + + public Launcher( + string serverPipeName, + WatchControlReader? controlReader, + ProjectLauncher projectLauncher, + WatchStatusWriter? statusWriter, + Optional launchProfile, + CancellationToken shutdownCancellationToken) + { + _projectLauncher = projectLauncher; + _statusWriter = statusWriter; + _launchProfileName = launchProfile; + _controlReader = controlReader; + _disposalCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken); + _listenerTask = StartListeningAsync(serverPipeName, _disposalCancellationSource.Token); + } + + public bool IsDisposed + => _disposalCancellationSource == null; + + private ILogger Logger + => _projectLauncher.Logger; + + public async ValueTask DisposeAsync() + { + var disposalCancellationSource = Interlocked.Exchange(ref _disposalCancellationSource, null); + ObjectDisposedException.ThrowIf(disposalCancellationSource == null, this); + + Logger.LogDebug("Disposing process launcher."); + await disposalCancellationSource.CancelAsync(); + + if (_controlReader != null) + { + await _controlReader.DisposeAsync(); + } + + await _listenerTask; + await Task.WhenAll(_pendingRequestCompletions); + + disposalCancellationSource.Dispose(); + } + + private async Task StartListeningAsync(string pipeName, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + NamedPipeServerStream? pipe = null; + try + { + pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + await pipe.WaitForConnectionAsync(cancellationToken); + + Logger.LogDebug("Connected to '{PipeName}'", pipeName); + + var version = await pipe.ReadByteAsync(cancellationToken); + if (version != Version) + { + Logger.LogDebug("Unsupported protocol version '{Version}'", version); + await pipe.WriteAsync((byte)0, cancellationToken); + continue; + } + + var json = await pipe.ReadStringAsync(cancellationToken); + + var request = JsonSerializer.Deserialize(json) ?? throw new JsonException("Unexpected null"); + + Logger.LogDebug("Request received."); + await pipe.WriteAsync((byte)1, cancellationToken); + + _ = HandleRequestAsync(request, pipe, cancellationToken); + + // Don't dispose the pipe - it's now owned by HandleRequestAsync + // which will keep it alive for output proxying + pipe = null; + } + finally + { + if (pipe != null) + { + await pipe.DisposeAsync(); + } + } + } + } + catch (OperationCanceledException) + { + // nop + } + catch (Exception e) + { + Logger.LogError("Failed to launch resource: {Exception}", e.Message); + } + } + + private async Task HandleRequestAsync(LaunchResourceRequest request, NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + var completionSource = new TaskCompletionSource(); + ImmutableInterlocked.Update(ref _pendingRequestCompletions, set => set.Add(completionSource.Task)); + + // Shared box to track the latest RunningProject across restarts. + // restartOperation creates new RunningProjects — we always need the latest one. + var currentProject = new StrongBox(null); + + // Create a per-connection token that cancels when the pipe disconnects OR on shutdown. + // DCP Stop kills the resource command, which closes the pipe from the other end. + // We detect that by reading from the pipe — when it breaks, we cancel. + using var pipeDisconnectedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var connectionToken = pipeDisconnectedSource.Token; + + try + { + var projectOptions = GetProjectOptions(request); + + await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: currentProject.Value is not null, connectionToken); + Debug.Assert(currentProject.Value != null); + + var projectLogger = currentProject.Value.ClientLogger; + projectLogger.LogDebug("Waiting for resource to disconnect or relaunch."); + + await WaitForPipeDisconnectAsync(pipe, connectionToken); + + projectLogger.LogDebug("Resource pipe disconnected."); + } + catch (OperationCanceledException) + { + // Shutdown or DCP killed the resource command + } + catch (Exception e) + { + Logger.LogError("Failed to start '{Path}': {Exception}", request.EntryPoint, e.Message); + } + finally + { + // Cancel the connection token so any in-flight restartOperation / drain tasks stop. + await pipeDisconnectedSource.CancelAsync(); + + // Terminate the project process when the resource command disconnects. + // This handles DCP Stop — the resource command is killed, pipe breaks, + // and we clean up the project process the watch server launched. + if (currentProject.Value is { } project) + { + Logger.LogDebug("Pipe disconnected for '{Path}', terminating project process.", request.EntryPoint); + await project.Process.TerminateAsync(); + } + + await pipe.DisposeAsync(); + Logger.LogDebug("HandleRequest completed for '{Path}'.", request.EntryPoint); + } + + ImmutableInterlocked.Update(ref _pendingRequestCompletions, set => set.Remove(completionSource.Task)); + completionSource.SetResult(); + } + + private static async Task WaitForPipeDisconnectAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + try + { + var buffer = new byte[1]; + while (pipe.IsConnected && !cancellationToken.IsCancellationRequested) + { + var bytesRead = await pipe.ReadAsync(buffer, cancellationToken); + if (bytesRead == 0) + { + break; + } + } + } + catch (IOException) + { + // Pipe disconnected + } + } + + private async ValueTask StartProjectAsync(ProjectOptions projectOptions, NamedPipeServerStream pipe, StrongBox currentProject, bool isRestart, CancellationToken cancellationToken) + { + // Buffer output through a channel to avoid blocking the synchronous onOutput callback. + // The channel is drained asynchronously by DrainOutputChannelAsync which writes to the pipe. + var outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + var outputChannelDrainTask = WriteProcessOutputToPipeAsync(); + + currentProject.Value = await _projectLauncher.TryLaunchProcessAsync( + projectOptions, + onOutput: line => outputChannel.Writer.TryWrite(line), + onExit: async (processId, exitCode) => + { + var isRestarting = currentProject.Value?.IsRestarting == true; + if (exitCode is not null and not 0 && !cancellationToken.IsCancellationRequested && !isRestarting) + { + // Emit a status event for non-zero exit codes so the dashboard shows the crash. + // Skip if cancellation is requested (DCP Stop/shutdown) or if the project + // is being deliberately restarted (rude edit restart). + _statusWriter?.WriteEvent(new WatchStatusEvent + { + Type = WatchStatusEvent.Types.ProcessExited, + Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath], + ExitCode = exitCode, + }); + } + + // DON'T complete the output channel. + // dotnet-watch will auto-retry on crash and reuse the same onOutput callback, + // so new output from the retried process flows through the same channel/pipe. + // Completing the channel would starve the pipe and cause DCP to kill the + // resource command, triggering a disconnect → terminate → reconnect storm. + }, + restartOperation: async cancellationToken => + { + // Complete the old channel so the old drain task finishes before + // StartProjectAsync creates a new channel + drain on the same pipe. + outputChannel.Writer.TryComplete(); + await outputChannelDrainTask; + + await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: true, cancellationToken); + }, + cancellationToken) + ?? throw new InvalidOperationException(); + + // Emit ProcessStarted so the dashboard knows the process is actually running. + _statusWriter?.WriteEvent(new WatchStatusEvent + { + Type = WatchStatusEvent.Types.ProcessStarted, + Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath], + }); + + async Task WriteProcessOutputToPipeAsync() + { + try + { + await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken)) + { + await pipe.WriteAsync(line.IsError ? AspireResourceLauncher.OutputTypeStderr : AspireResourceLauncher.OutputTypeStdout, cancellationToken); + await pipe.WriteAsync(line.Content, cancellationToken); + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + // Pipe disconnected or cancelled + } + } + } + + private ProjectOptions GetProjectOptions(LaunchResourceRequest request) + { + var project = ProjectRepresentation.FromProjectOrEntryPointFilePath(request.EntryPoint); + + return new() + { + IsMainProject = false, + Representation = project, + WorkingDirectory = Path.GetDirectoryName(request.EntryPoint) ?? throw new InvalidOperationException(), + Command = "run", + CommandArguments = GetRunCommandArguments(request, _launchProfileName.Value), + LaunchEnvironmentVariables = request.EnvironmentVariables?.Select(e => (e.Key, e.Value))?.ToArray() ?? [], + LaunchProfileName = request.LaunchProfileName, + }; + } + + // internal for testing + internal static IReadOnlyList GetRunCommandArguments(LaunchResourceRequest request, string? hostLaunchProfile) + { + var arguments = new List(); + + if (!request.LaunchProfileName.HasValue) + { + arguments.Add("--no-launch-profile"); + } + else if (!string.IsNullOrEmpty(request.LaunchProfileName.Value)) + { + arguments.Add("--launch-profile"); + arguments.Add(request.LaunchProfileName.Value); + } + else if (hostLaunchProfile != null) + { + arguments.Add("--launch-profile"); + arguments.Add(hostLaunchProfile); + } + + if (request.ApplicationArguments != null) + { + if (request.ApplicationArguments.Any()) + { + arguments.AddRange(request.ApplicationArguments); + } + else + { + // indicate that no arguments should be used even if launch profile specifies some: + arguments.Add("--no-launch-profile-arguments"); + } + } + + return arguments; + } + + public IEnumerable<(string name, string value)> GetEnvironmentVariables() + => []; + + public ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken) + => ValueTask.CompletedTask; + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/StatusReportingLoggerFactory.cs b/src/WatchPrototype/Watch.Aspire/Server/StatusReportingLoggerFactory.cs new file mode 100644 index 00000000000..e676a2ff0b7 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/StatusReportingLoggerFactory.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +/// +/// Intercepts select log messages reported by watch and forwards them to to be sent to an external listener. +/// +internal sealed class StatusReportingLoggerFactory(WatchStatusWriter writer, LoggerFactory underlyingFactory) : ILoggerFactory +{ + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + => new Logger(writer, underlyingFactory.CreateLogger(categoryName)); + + public void AddProvider(ILoggerProvider provider) + => underlyingFactory.AddProvider(provider); + + private sealed class Logger(WatchStatusWriter writer, ILogger underlyingLogger) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + => underlyingLogger.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) + => logLevel == LogLevel.None || underlyingLogger.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + underlyingLogger.Log(logLevel, eventId, state, exception, formatter); + + WatchStatusEvent? status = null; + + if (eventId == MessageDescriptor.BuildStartedNotification.Id) + { + var logState = (LogState>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.Building, + Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath), + }; + } + else if (eventId == MessageDescriptor.BuildCompletedNotification.Id) + { + var logState = (LogState<(IEnumerable projects, bool success)>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.BuildComplete, + Projects = logState.Arguments.projects.Select(r => r.ProjectOrEntryPointFilePath), + Success = logState.Arguments.success, + }; + } + else if (eventId == MessageDescriptor.ChangesAppliedToProjectsNotification.Id) + { + var logState = (LogState>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.HotReloadApplied, + Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath), + }; + } + else if (eventId == MessageDescriptor.RestartingProjectsNotification.Id) + { + var logState = (LogState>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.Restarting, + Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath) + }; + } + + if (status != null) + { + writer.WriteEvent(status); + } + } + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/WatchControlCommand.cs b/src/WatchPrototype/Watch.Aspire/Server/WatchControlCommand.cs new file mode 100644 index 00000000000..7b8f554c9a1 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/WatchControlCommand.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchControlCommand +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// Paths of projects to restart. + /// + [JsonPropertyName("projects")] + public ImmutableArray Projects { get; init; } + + public static class Types + { + public const string Rebuild = "rebuild"; + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/WatchControlReader.cs b/src/WatchPrototype/Watch.Aspire/Server/WatchControlReader.cs new file mode 100644 index 00000000000..858ec4f8a9b --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/WatchControlReader.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchControlReader : IAsyncDisposable +{ + private readonly CompilationHandler _compilationHandler; + private readonly string _pipeName; + private readonly NamedPipeClientStream _pipe; + private readonly ILogger _logger; + private readonly CancellationTokenSource _disposalCancellationSource = new(); + private readonly Task _listener; + + public WatchControlReader(string pipeName, CompilationHandler compilationHandler, ILogger logger) + { + _pipe = new NamedPipeClientStream( + serverName: ".", + pipeName, + PipeDirection.In, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + _pipeName = pipeName; + _compilationHandler = compilationHandler; + _logger = logger; + _listener = ListenAsync(_disposalCancellationSource.Token); + } + + public async ValueTask DisposeAsync() + { + _logger.LogDebug("Disposing control pipe."); + + _disposalCancellationSource.Cancel(); + await _listener; + + try + { + await _pipe.DisposeAsync(); + } + catch (IOException) + { + // Pipe may already be broken if the server disconnected + } + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + try + { + _logger.LogDebug("Connecting to control pipe '{PipeName}'.", _pipeName); + await _pipe.ConnectAsync(cancellationToken); + + using var reader = new StreamReader(_pipe); + + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) + { + return; + } + + var command = JsonSerializer.Deserialize(line); + if (command is null) + { + break; + } + + if (command.Type == WatchControlCommand.Types.Rebuild) + { + _logger.LogDebug("Received request to restart projects"); + await RestartProjectsAsync(command.Projects.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath), cancellationToken); + } + else + { + _logger.LogError("Unknown control command: '{Type}'", command.Type); + } + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException) + { + // expected when disposing or if the server disconnects + } + catch (Exception e) + { + _logger.LogDebug("Control pipe listener failed: {Message}", e.Message); + } + } + + private async ValueTask RestartProjectsAsync(IEnumerable projects, CancellationToken cancellationToken) + { + var projectsToRestart = await _compilationHandler.TerminatePeripheralProcessesAsync(projects.Select(p => p.ProjectGraphPath), cancellationToken); + + foreach (var project in projects) + { + if (!projectsToRestart.Any(p => p.Options.Representation == project)) + { + _compilationHandler.Logger.LogDebug("Restart of '{Project}' requested but the project is not running.", project); + } + } + + await _compilationHandler.RestartPeripheralProjectsAsync(projectsToRestart, cancellationToken); + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/WatchStatusEvent.cs b/src/WatchPrototype/Watch.Aspire/Server/WatchStatusEvent.cs new file mode 100644 index 00000000000..5e96168e1fd --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/WatchStatusEvent.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchStatusEvent +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("projects")] + public required IEnumerable Projects { get; init; } + + [JsonPropertyName("success")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Success { get; init; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Error { get; init; } + + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ExitCode { get; init; } + + public static class Types + { + public const string Building = "building"; + public const string BuildComplete = "build_complete"; + public const string HotReloadApplied = "hot_reload_applied"; + public const string Restarting = "restarting"; + public const string ProcessExited = "process_exited"; + public const string ProcessStarted = "process_started"; + } +} diff --git a/src/WatchPrototype/Watch.Aspire/Server/WatchStatusWriter.cs b/src/WatchPrototype/Watch.Aspire/Server/WatchStatusWriter.cs new file mode 100644 index 00000000000..a208be7836d --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Server/WatchStatusWriter.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection.Metadata; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.CodeAnalysis.Elfie.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchStatusWriter : IAsyncDisposable +{ + private readonly Channel _eventChannel = Channel.CreateUnbounded(new() + { + SingleReader = true, + SingleWriter = false + }); + + private readonly string? _pipeName; + private readonly NamedPipeClientStream _pipe; + private readonly ILogger _logger; + private readonly Task _channelReader; + private readonly CancellationTokenSource _disposalCancellationSource = new(); + + public WatchStatusWriter(string pipeName, ILogger logger) + { + _pipe = new NamedPipeClientStream( + serverName: ".", + pipeName, + PipeDirection.Out, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + _pipeName = pipeName; + _logger = logger; + _channelReader = StartChannelReaderAsync(_disposalCancellationSource.Token); + } + + public async ValueTask DisposeAsync() + { + _logger.LogDebug("Disposing status pipe."); + + _disposalCancellationSource.Cancel(); + await _channelReader; + + try + { + await _pipe.DisposeAsync(); + } + catch (IOException) + { + // Pipe may already be broken if the server disconnected + } + + _disposalCancellationSource.Dispose(); + } + + private async Task StartChannelReaderAsync(CancellationToken cancellationToken) + { + try + { + _logger.LogDebug("Connecting to status pipe '{PipeName}'...", _pipeName); + + await _pipe.ConnectAsync(cancellationToken); + + using var streamWriter = new StreamWriter(_pipe) { AutoFlush = true }; + + await foreach (var statusEvent in _eventChannel.Reader.ReadAllAsync(cancellationToken)) + { + var json = JsonSerializer.Serialize(statusEvent); + await streamWriter.WriteLineAsync(json.AsMemory(), cancellationToken); + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException) + { + // expected when disposing or if the server disconnects + } + catch (Exception e) + { + _logger.LogError("Unexpected error reading status event: {Exception}", e); + } + } + + public void WriteEvent(WatchStatusEvent statusEvent) + => _eventChannel.Writer.TryWrite(statusEvent); +} diff --git a/src/WatchPrototype/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs b/src/WatchPrototype/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs new file mode 100644 index 00000000000..ffdd4f424a4 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Watch; + +internal static class AspireEnvironmentVariables +{ + public static TimeSpan PipeConnectionTimeout + => EnvironmentVariables.ReadTimeSpanSeconds("ASPIRE_WATCH_PIPE_CONNECTION_TIMEOUT_SECONDS") ?? TimeSpan.FromSeconds(30); +} diff --git a/src/WatchPrototype/Watch.slnx b/src/WatchPrototype/Watch.slnx index dd498b67ed1..26ad0d7fc3a 100644 --- a/src/WatchPrototype/Watch.slnx +++ b/src/WatchPrototype/Watch.slnx @@ -1,12 +1,10 @@ - - diff --git a/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs index ea3480f979f..ef0c112b98f 100644 --- a/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs @@ -17,11 +17,11 @@ internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, Proj { public override ProjectGraphNode LaunchingProject => clientProject; - public override bool RequiresBrowserRefresh => true; + public override bool ManagedHotReloadRequiresBrowserRefresh => true; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - return new(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), browserRefreshServer); + return [(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "")]; } } diff --git a/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs index 12108762305..768187dbeda 100644 --- a/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -19,17 +19,15 @@ internal sealed class BlazorWebAssemblyHostedAppModel(DotNetWatchContext context { public override ProjectGraphNode LaunchingProject => serverProject; - public override bool RequiresBrowserRefresh => true; + public override bool ManagedHotReloadRequiresBrowserRefresh => true; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - - return new( - [ - (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), - (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: false), "host") - ], - browserRefreshServer); + return + [ + (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), + (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), handlesStaticAssetUpdates: false, new NamedPipeClientTransport(clientLogger)), "host") + ]; } } diff --git a/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs b/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs index 300236d7250..ac79fc90d8a 100644 --- a/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs +++ b/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs @@ -12,6 +12,11 @@ namespace Microsoft.DotNet.Watch; /// internal sealed class DefaultAppModel(ProjectGraphNode project) : HotReloadAppModel { - public override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) - => new(new HotReloadClients(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), enableStaticAssetUpdates: true), browserRefreshServer: null)); + public override ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + => new(new HotReloadClients( + clients: IsManagedAgentSupported(project, clientLogger) + ? [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), handlesStaticAssetUpdates: true, new NamedPipeClientTransport(clientLogger)), "")] + : [], + browserRefreshServer: null, + useRefreshServerToApplyStaticAssets: false)); } diff --git a/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.cs b/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.cs index 7a205a8d1fc..5c9810fa3c1 100644 --- a/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.cs +++ b/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Watch; internal abstract partial class HotReloadAppModel() { - public abstract ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); + public abstract ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); protected static string GetInjectedAssemblyPath(string targetFramework, string assemblyName) => Path.Combine(Path.GetDirectoryName(typeof(HotReloadAppModel).Assembly.Location)!, "hotreload", targetFramework, assemblyName + ".dll"); @@ -42,7 +42,51 @@ public static HotReloadAppModel InferFromProject(DotNetWatchContext context, Pro return new WebServerAppModel(context, serverProject: projectNode); } + if (capabilities.Contains(ProjectCapability.HotReloadWebSockets)) + { + context.Logger.Log(MessageDescriptor.ApplicationKind_WebSockets); + return new MobileAppModel(context, projectNode); + } + context.Logger.Log(MessageDescriptor.ApplicationKind_Default); return new DefaultAppModel(projectNode); } + + /// + /// True if a managed code agent can be injected into the target process. + /// The agent is injected either via dotnet startup hook, or via web server middleware for WASM clients. + /// + internal static bool IsManagedAgentSupported(ProjectGraphNode project, ILogger logger) + { + if (!project.IsNetCoreApp(Versions.Version6_0)) + { + LogWarning("target framework is older than 6.0"); + return false; + } + + // If property is not specified startup hook is enabled: + // https://github.com/dotnet/runtime/blob/4b0b7238ba021b610d3963313b4471517108d2bc/src/libraries/System.Private.CoreLib/src/System/StartupHookProvider.cs#L22 + // Startup hooks are not used for WASM projects. + // + // TODO: Remove once implemented: https://github.com/dotnet/runtime/issues/123778 + if (!project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.StartupHookSupport, defaultValue: true) && + !project.GetCapabilities().Contains(ProjectCapability.WebAssembly)) + { + // Report which property is causing lack of support for startup hooks: + var (propertyName, propertyValue) = + project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.PublishAot) + ? (PropertyNames.PublishAot, true) + : project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.PublishTrimmed) + ? (PropertyNames.PublishTrimmed, true) + : (PropertyNames.StartupHookSupport, false); + + LogWarning(string.Format("'{0}' property is '{1}'", propertyName, propertyValue)); + return false; + } + + return true; + + void LogWarning(string reason) + => logger.Log(MessageDescriptor.ProjectDoesNotSupportHotReload, reason); + } } diff --git a/src/WatchPrototype/Watch/AppModels/MobileAppModel.cs b/src/WatchPrototype/Watch/AppModels/MobileAppModel.cs new file mode 100644 index 00000000000..198f06f6f20 --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/MobileAppModel.cs @@ -0,0 +1,36 @@ +// 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.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class MobileAppModel(DotNetWatchContext context, ProjectGraphNode project) : HotReloadAppModel +{ + // Use WebSocket transport for projects with HotReloadWebSockets capability. + // Mobile workloads (Android, iOS) add this capability since named pipes don't work over the network. + // Pass the startup hook path so it can be included in the environment variables + // passed via `dotnet run -e` as @(RuntimeEnvironmentVariable) items. + public override async ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + { + ImmutableArray<(HotReloadClient client, string name)> clients; + if (IsManagedAgentSupported(project, clientLogger)) + { + var transport = await WebSocketClientTransport.CreateAsync( + context.EnvironmentOptions.AgentWebSocketConfig, + clientLogger, + cancellationToken); + + clients = [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), handlesStaticAssetUpdates: true, transport), "")]; + } + else + { + clients = []; + } + + return new HotReloadClients(clients, browserRefreshServer: null, useRefreshServerToApplyStaticAssets: false); + } +} diff --git a/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.cs b/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.cs index 0f1fbb74d5d..b4b72f3b7a6 100644 --- a/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.cs @@ -16,25 +16,24 @@ internal abstract class WebApplicationAppModel(DotNetWatchContext context) : Hot public DotNetWatchContext Context => context; - public abstract bool RequiresBrowserRefresh { get; } + public abstract bool ManagedHotReloadRequiresBrowserRefresh { get; } /// /// Project that's used for launching the application. /// public abstract ProjectGraphNode LaunchingProject { get; } - protected abstract HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); + protected abstract ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); - public async sealed override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + public async sealed override ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) { var browserRefreshServer = await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(LaunchingProject, this, cancellationToken); - if (RequiresBrowserRefresh && browserRefreshServer == null) - { - // Error has been reported - return null; - } - return CreateClients(clientLogger, agentLogger, browserRefreshServer); + var managedClients = (!ManagedHotReloadRequiresBrowserRefresh || browserRefreshServer != null) && IsManagedAgentSupported(LaunchingProject, clientLogger) + ? CreateManagedClients(clientLogger, agentLogger, browserRefreshServer) + : []; + + return new HotReloadClients(managedClients, browserRefreshServer, useRefreshServerToApplyStaticAssets: true); } protected WebAssemblyHotReloadClient CreateWebAssemblyClient(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, ProjectGraphNode clientProject) @@ -58,9 +57,8 @@ private static string GetMiddlewareAssemblyPath() logger, context.LoggerFactory, middlewareAssemblyPath: GetMiddlewareAssemblyPath(), - dotnetPath: context.EnvironmentOptions.MuxerPath, - autoReloadWebSocketHostName: context.EnvironmentOptions.AutoReloadWebSocketHostName, - autoReloadWebSocketPort: context.EnvironmentOptions.AutoReloadWebSocketPort, + dotnetPath: context.EnvironmentOptions.GetMuxerPath(), + webSocketConfig: context.EnvironmentOptions.BrowserWebSocketConfig, suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } @@ -71,13 +69,29 @@ public bool IsServerSupported(ProjectGraphNode projectNode, ILogger logger) { if (context.EnvironmentOptions.SuppressBrowserRefresh) { - logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); + if (ManagedHotReloadRequiresBrowserRefresh) + { + logger.Log(MessageDescriptor.BrowserRefreshSuppressedViaEnvironmentVariable_ApplicationWillBeRestarted, EnvironmentVariables.Names.SuppressBrowserRefresh); + } + else + { + logger.Log(MessageDescriptor.BrowserRefreshSuppressedViaEnvironmentVariable_ManualRefreshRequired, EnvironmentVariables.Names.SuppressBrowserRefresh); + } + return false; } if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) { - logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh)); + if (ManagedHotReloadRequiresBrowserRefresh) + { + logger.Log(MessageDescriptor.BrowserRefreshNotSupportedByProjectTargetFramework_ApplicationWillBeRestarted); + } + else + { + logger.Log(MessageDescriptor.BrowserRefreshNotSupportedByProjectTargetFramework_ManualRefreshRequired); + } + return false; } diff --git a/src/WatchPrototype/Watch/AppModels/WebServerAppModel.cs b/src/WatchPrototype/Watch/AppModels/WebServerAppModel.cs index d30703b8753..04727dee798 100644 --- a/src/WatchPrototype/Watch/AppModels/WebServerAppModel.cs +++ b/src/WatchPrototype/Watch/AppModels/WebServerAppModel.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.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -12,9 +13,9 @@ internal sealed class WebServerAppModel(DotNetWatchContext context, ProjectGraph { public override ProjectGraphNode LaunchingProject => serverProject; - public override bool RequiresBrowserRefresh + public override bool ManagedHotReloadRequiresBrowserRefresh => false; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) - => new(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: true), browserRefreshServer); + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + => [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), handlesStaticAssetUpdates: true, new NamedPipeClientTransport(clientLogger)), "")]; } diff --git a/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs b/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs index caf071157e2..10d46c18909 100644 --- a/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs +++ b/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs @@ -6,12 +6,11 @@ using System.Globalization; using System.Threading.Channels; using Aspire.Tools.Service; -using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal class AspireServiceFactory : IRuntimeProcessLauncherFactory +internal class AspireServiceFactory(ProjectOptions hostProjectOptions) : IRuntimeProcessLauncherFactory { internal sealed class SessionManager : IAspireServerEvents, IRuntimeProcessLauncher { @@ -30,8 +29,8 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r }; private readonly ProjectLauncher _projectLauncher; - private readonly AspireServerService _service; private readonly ProjectOptions _hostProjectOptions; + private readonly AspireServerService _service; private readonly ILogger _logger; /// @@ -46,6 +45,12 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r private volatile bool _isDisposed; + // The number of sessions whose initialization is in progress. + private int _pendingSessionInitializationCount; + + // Blocks disposal until no session initialization is in progress. + private readonly SemaphoreSlim _postDisposalSessionInitializationCompleted = new(initialCount: 0, maxCount: 1); + public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions) { _projectLauncher = projectLauncher; @@ -60,21 +65,20 @@ public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjec public async ValueTask DisposeAsync() { -#if DEBUG - lock (_guard) - { - Debug.Assert(_sessions.Count == 0); - } -#endif - _isDisposed = true; + ObjectDisposedException.ThrowIf(_isDisposed, this); + _logger.LogDebug("Disposing service factory ..."); + + // stop accepting requests - triggers cancellation token for in-flight operations: await _service.DisposeAsync(); - } - public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(_isDisposed, this); + // should not receive any more requests at this point: + _isDisposed = true; + // wait for all in-flight process initialization to complete: + await _postDisposalSessionInitializationCompleted.WaitAsync(CancellationToken.None); + + // terminate all active sessions: ImmutableArray sessions; lock (_guard) { @@ -83,7 +87,11 @@ public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancell _sessions.Clear(); } - await Task.WhenAll(sessions.Select(TerminateSessionAsync)).WaitAsync(cancellationToken); + await Task.WhenAll(sessions.Select(TerminateSessionAsync)).WaitAsync(CancellationToken.None); + + _postDisposalSessionInitializationCompleted.Dispose(); + + _logger.LogDebug("Service factory disposed"); } public IEnumerable<(string name, string value)> GetEnvironmentVariables() @@ -102,66 +110,75 @@ async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, Proj return sessionId; } - public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken) + public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken) { + // Neither request from DCP nor restart should happen once the disposal has started. ObjectDisposedException.ThrowIf(_isDisposed, this); - _logger.LogDebug("Starting: '{Path}'", projectOptions.Representation.ProjectOrEntryPointFilePath); + _logger.LogDebug("[#{SessionId}] Starting: '{Path}'", sessionId, projectOptions.Representation.ProjectOrEntryPointFilePath); - var processTerminationSource = new CancellationTokenSource(); + RunningProject? runningProject = null; var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); - RunningProject? runningProject = null; + Interlocked.Increment(ref _pendingSessionInitializationCount); - runningProject = await _projectLauncher.TryLaunchProcessAsync( - projectOptions, - processTerminationSource, - onOutput: line => - { - var writeResult = outputChannel.Writer.TryWrite(line); - Debug.Assert(writeResult); - }, - onExit: async (processId, exitCode) => - { - // Project can be null if the process exists while it's being initialized. - if (runningProject?.IsRestarting == false) + try + { + runningProject = await _projectLauncher.TryLaunchProcessAsync( + projectOptions, + onOutput: line => { - try - { - await _service.NotifySessionEndedAsync(dcpId, sessionId, processId, exitCode, cancellationToken); - } - catch (OperationCanceledException) + var writeResult = outputChannel.Writer.TryWrite(line); + Debug.Assert(writeResult); + }, + onExit: async (processId, exitCode) => + { + // Project can be null if the process exists while it's being initialized. + if (runningProject?.IsRestarting == false) { - // canceled on shutdown, ignore + try + { + await _service.NotifySessionEndedAsync(dcpId, sessionId, processId, exitCode, cancellationToken); + } + catch (OperationCanceledException) + { + // canceled on shutdown, ignore + } } - } - }, - restartOperation: cancellationToken => - StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: true, cancellationToken), - cancellationToken); + }, + restartOperation: cancellationToken => + StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: true, cancellationToken), + cancellationToken); - if (runningProject == null) - { - // detailed error already reported: - throw new ApplicationException($"Failed to launch '{projectOptions.Representation.ProjectOrEntryPointFilePath}'."); - } + if (runningProject == null) + { + // detailed error already reported: + throw new ApplicationException($"Failed to launch '{projectOptions.Representation.ProjectOrEntryPointFilePath}'."); + } - await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken); + await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.Process.Id, cancellationToken); - // cancel reading output when the process terminates: - var outputReader = StartChannelReader(runningProject.ProcessExitedCancellationToken); + // cancel reading output when the process terminates: + var outputReader = StartChannelReader(runningProject.Process.ExitedCancellationToken); - lock (_guard) - { - // When process is restarted we reuse the session id. - // The session already exists, it needs to be updated with new info. - Debug.Assert(_sessions.ContainsKey(sessionId) == isRestart); + lock (_guard) + { + // When process is restarted we reuse the session id. + // The session already exists, it needs to be updated with new info. + Debug.Assert(_sessions.ContainsKey(sessionId) == isRestart); - _sessions[sessionId] = new Session(dcpId, sessionId, runningProject, outputReader); + _sessions[sessionId] = new Session(dcpId, sessionId, runningProject, outputReader); + } + } + finally + { + if (Interlocked.Decrement(ref _pendingSessionInitializationCount) == 0 && _isDisposed) + { + _postDisposalSessionInitializationCompleted.Release(); + } } - _logger.LogDebug("Session started: #{SessionId}", sessionId); - return runningProject; + _logger.LogDebug("[#{SessionId}] Session started", sessionId); async Task StartChannelReader(CancellationToken cancellationToken) { @@ -206,32 +223,25 @@ async ValueTask IAspireServerEvents.StopSessionAsync(string dcpId, string private async Task TerminateSessionAsync(Session session) { - _logger.LogDebug("Stop session #{SessionId}", session.Id); + _logger.LogDebug("[#{SessionId}] Stop session", session.Id); - await session.RunningProject.TerminateAsync(); + await session.RunningProject.Process.TerminateAsync(); // process termination should cancel output reader task: await session.OutputReader; } private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) - { - var hostLaunchProfile = _hostProjectOptions.NoLaunchProfile ? null : _hostProjectOptions.LaunchProfileName; - - return new() + => new() { - IsRootProject = false, + IsMainProject = false, Representation = ProjectRepresentation.FromProjectOrEntryPointFilePath(projectLaunchInfo.ProjectPath), WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(), - BuildArguments = _hostProjectOptions.BuildArguments, Command = "run", - CommandArguments = GetRunCommandArguments(projectLaunchInfo, hostLaunchProfile), + CommandArguments = GetRunCommandArguments(projectLaunchInfo, _hostProjectOptions.LaunchProfileName.Value), LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(e => (e.Key, e.Value))?.ToArray() ?? [], - LaunchProfileName = projectLaunchInfo.LaunchProfile, - NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile, - TargetFramework = _hostProjectOptions.TargetFramework, + LaunchProfileName = projectLaunchInfo.DisableLaunchProfile ? default : projectLaunchInfo.LaunchProfile, }; - } // internal for testing internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchRequest projectLaunchInfo, string? hostLaunchProfile) @@ -276,13 +286,9 @@ internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchReques } } - public static readonly AspireServiceFactory Instance = new(); - 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) + => new SessionManager(projectLauncher, hostProjectOptions); } diff --git a/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs b/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs index e789dd05828..cef3c5d5460 100644 --- a/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs +++ b/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs @@ -38,7 +38,7 @@ public void InstallBrowserLaunchTrigger( WebServerProcessStateObserver.Observe(projectNode, processSpec, url => { - if (projectOptions.IsRootProject && + if (projectOptions.IsMainProject && ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.ProjectInstance.GetId())) { // first build iteration of a root project: @@ -66,7 +66,14 @@ private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? serve ? (browserPath, launchUrl, false) : (launchUrl, null, true); - logger.Log(MessageDescriptor.LaunchingBrowser, fileName, arg); + if (arg != null) + { + logger.Log(MessageDescriptor.LaunchingBrowserWithUrl, (fileName, arg)); + } + else + { + logger.Log(MessageDescriptor.LaunchingBrowser, fileName); + } if (environmentOptions.TestFlags != TestFlags.None && environmentOptions.BrowserPath == null) { @@ -127,7 +134,10 @@ private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)] private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) { - return (projectOptions.NoLaunchProfile == true - ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName, logger)) ?? new(); + var profile = projectOptions.LaunchProfileName.HasValue + ? LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName.Value, logger) + : null; + + return profile ?? new(); } } diff --git a/src/WatchPrototype/Watch/Build/BuildNames.cs b/src/WatchPrototype/Watch/Build/BuildNames.cs index 0b2b8d0ccd0..d7ad8b1acbb 100644 --- a/src/WatchPrototype/Watch/Build/BuildNames.cs +++ b/src/WatchPrototype/Watch/Build/BuildNames.cs @@ -22,6 +22,10 @@ internal static class PropertyNames public const string DesignTimeBuild = nameof(DesignTimeBuild); public const string SkipCompilerExecution = nameof(SkipCompilerExecution); public const string ProvideCommandLineArgs = nameof(ProvideCommandLineArgs); + public const string NonExistentFile = nameof(NonExistentFile); + public const string StartupHookSupport = nameof(StartupHookSupport); + public const string PublishTrimmed = nameof(PublishTrimmed); + public const string PublishAot = nameof(PublishAot); } internal static class ItemNames @@ -57,5 +61,6 @@ internal static class ProjectCapability { public const string Aspire = nameof(Aspire); public const string AspNetCore = nameof(AspNetCore); + public const string HotReloadWebSockets = nameof(HotReloadWebSockets); public const string WebAssembly = nameof(WebAssembly); } diff --git a/src/WatchPrototype/Watch/Build/BuildReporter.cs b/src/WatchPrototype/Watch/Build/BuildReporter.cs index 6410476dc3c..de404b1d07f 100644 --- a/src/WatchPrototype/Watch/Build/BuildReporter.cs +++ b/src/WatchPrototype/Watch/Build/BuildReporter.cs @@ -14,12 +14,13 @@ namespace Microsoft.DotNet.Watch; internal sealed class BuildReporter(ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions) { public ILogger Logger => logger; + public GlobalOptions GlobalOptions => options; public EnvironmentOptions EnvironmentOptions => environmentOptions; public Loggers GetLoggers(string projectPath, string operationName) => new(logger, environmentOptions.GetBinLogPath(projectPath, operationName, options)); - public void ReportWatchedFiles(Dictionary fileItems) + public static void ReportWatchedFiles(ILogger logger, IReadOnlyDictionary fileItems) { logger.Log(MessageDescriptor.WatchingFilesForChanges, fileItems.Count); diff --git a/src/WatchPrototype/Watch/Build/BuildRequest.cs b/src/WatchPrototype/Watch/Build/BuildRequest.cs new file mode 100644 index 00000000000..46e35ff3511 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/BuildRequest.cs @@ -0,0 +1,23 @@ +// 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.Build.Execution; + +namespace Microsoft.DotNet.Watch; + +internal readonly struct BuildRequest(ProjectInstance projectInstance, ImmutableArray targets, T data) +{ + public ProjectInstance ProjectInstance { get; } = projectInstance; + public ImmutableArray Targets { get; } = targets; + public T Data { get; } = data; +} + +internal static class BuildRequest +{ + public static BuildRequest Create(ProjectInstance instance, ImmutableArray targets) + => new(instance, targets, data: null); + + public static BuildRequest Create(ProjectInstance instance, ImmutableArray targets, T data) + => new(instance, targets, data); +} diff --git a/src/WatchPrototype/Watch/Build/BuildResult.cs b/src/WatchPrototype/Watch/Build/BuildResult.cs new file mode 100644 index 00000000000..be0ff7d8b62 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/BuildResult.cs @@ -0,0 +1,15 @@ +// 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.Execution; + +namespace Microsoft.DotNet.Watch; + +internal readonly struct BuildResult(IReadOnlyDictionary targetResults, ProjectInstance projectInstance, T data) +{ + public IReadOnlyDictionary TargetResults { get; } = targetResults; + public ProjectInstance ProjectInstance { get; } = projectInstance; + public T Data { get; } = data; + + public bool IsSuccess => TargetResults.Count > 0; +} diff --git a/src/WatchPrototype/Watch/Build/EvaluationResult.cs b/src/WatchPrototype/Watch/Build/EvaluationResult.cs index 5ea9546bb2c..d97d9bb5b57 100644 --- a/src/WatchPrototype/Watch/Build/EvaluationResult.cs +++ b/src/WatchPrototype/Watch/Build/EvaluationResult.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; @@ -10,19 +11,21 @@ namespace Microsoft.DotNet.Watch; internal sealed class EvaluationResult( - ProjectGraph projectGraph, - ImmutableArray restoredProjectInstances, + LoadedProjectGraph projectGraph, + IReadOnlyDictionary restoredProjectInstances, IReadOnlyDictionary files, - IReadOnlyDictionary staticWebAssetsManifests) + IReadOnlyDictionary staticWebAssetsManifests, + ProjectBuildManager buildManager) { public readonly IReadOnlyDictionary Files = files; - public readonly ProjectGraph ProjectGraph = projectGraph; + public readonly LoadedProjectGraph ProjectGraph = projectGraph; + public readonly ProjectBuildManager BuildManager = buildManager; public readonly FilePathExclusions ItemExclusions - = projectGraph != null ? FilePathExclusions.Create(projectGraph) : FilePathExclusions.Empty; + = projectGraph != null ? FilePathExclusions.Create(projectGraph.Graph) : FilePathExclusions.Empty; private readonly Lazy> _lazyBuildFiles - = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet()); + = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph.Graph) : new HashSet()); private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph) => projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths) @@ -35,7 +38,7 @@ public IReadOnlySet BuildFiles public IReadOnlyDictionary StaticWebAssetsManifests => staticWebAssetsManifests; - public ImmutableArray RestoredProjectInstances + public IReadOnlyDictionary RestoredProjectInstances => restoredProjectInstances; public void WatchFiles(FileWatcher fileWatcher) @@ -49,7 +52,7 @@ public void WatchFiles(FileWatcher fileWatcher) fileWatcher.WatchFiles(BuildFiles); } - public static ImmutableDictionary GetGlobalBuildOptions(IEnumerable buildArguments, EnvironmentOptions environmentOptions) + public static ImmutableDictionary GetGlobalBuildProperties(IEnumerable buildArguments, EnvironmentOptions environmentOptions) { // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md @@ -58,91 +61,133 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera .SetItem(PropertyNames.DotNetWatchBuild, "true") .SetItem(PropertyNames.DesignTimeBuild, "true") .SetItem(PropertyNames.SkipCompilerExecution, "true") - .SetItem(PropertyNames.ProvideCommandLineArgs, "true"); + .SetItem(PropertyNames.ProvideCommandLineArgs, "true") + // this will force CoreCompile task to execute and return command line args even if all inputs and outputs are up to date: + .SetItem(PropertyNames.NonExistentFile, "__NonExistentSubDir__\\__NonExistentFile__"); } /// /// Loads project graph and performs design-time build. /// - public static EvaluationResult? TryCreate( + public static async ValueTask TryCreateAsync( ProjectGraphFactory factory, - ILogger logger, - GlobalOptions options, + GlobalOptions globalOptions, EnvironmentOptions environmentOptions, bool restore, CancellationToken cancellationToken) { - var buildReporter = new BuildReporter(logger, options, environmentOptions); + var logger = factory.Logger; + var stopwatch = Stopwatch.StartNew(); - var projectGraph = factory.TryLoadProjectGraph( - logger, - projectGraphRequired: true, - cancellationToken); + var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: true, cancellationToken); if (projectGraph == null) { return null; } - var rootNode = projectGraph.GraphRoots.Single(); + var buildReporter = new BuildReporter(projectGraph.Logger, globalOptions, environmentOptions); + var buildManager = new ProjectBuildManager(projectGraph.ProjectCollection, buildReporter); + + logger.LogDebug("Project graph loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); if (restore) { - using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore")) - { - if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers)) + stopwatch.Restart(); + + var restoreRequests = projectGraph.Graph.GraphRoots.Select(node => BuildRequest.Create(node.ProjectInstance, [TargetNames.Restore])).ToArray(); + + if (await buildManager.BuildAsync( + restoreRequests, + onFailure: failedInstance => { - logger.LogError("Failed to restore '{Path}'.", rootNode.ProjectInstance.FullPath); - loggers.ReportOutput(); - return null; - } + logger.LogError("Failed to restore project '{Path}'.", failedInstance.FullPath); + + // terminate build on first failure: + return false; + }, + operationName: "Restore", + cancellationToken) is []) + { + return null; } + + logger.LogDebug("Projects restored in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); } + stopwatch.Restart(); + // Capture the snapshot of original project instances after Restore target has been run. // These instances can be used to evaluate additional targets (e.g. deployment) if needed. - var restoredProjectInstances = projectGraph.ProjectNodesTopologicallySorted.Select(node => node.ProjectInstance.DeepCopy()).ToImmutableArray(); - - var fileItems = new Dictionary(); - var staticWebAssetManifests = new Dictionary(); + var restoredProjectInstances = projectGraph.Graph.ProjectNodes.ToDictionary( + keySelector: node => node.ProjectInstance.GetId(), + elementSelector: node => node.ProjectInstance.DeepCopy()); // Update the project instances of the graph with design-time build results. // The properties and items set by DTB will be used by the Workspace to create Roslyn representation of projects. - foreach (var project in projectGraph.ProjectNodesTopologicallySorted) - { - var projectInstance = project.ProjectInstance; + var buildRequests = + (from node in projectGraph.Graph.ProjectNodesTopologicallySorted + where node.ProjectInstance.GetPropertyValue(PropertyNames.TargetFramework) != "" + let targets = GetBuildTargets(node.ProjectInstance, environmentOptions) + where targets is not [] + select BuildRequest.Create(node.ProjectInstance, [.. targets])).ToArray(); - // skip outer build project nodes: - if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "") + var buildResults = await buildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => { - continue; - } + logger.LogError("Failed to build project '{Path}'.", failedInstance.FullPath); - var targets = GetBuildTargets(projectInstance, environmentOptions); - if (targets is []) - { - continue; - } + // terminate build on first failure: + return false; + }, + operationName: "DesignTimeBuild", + cancellationToken); - using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) - { - if (!projectInstance.Build(targets, loggers)) - { - logger.LogError("Failed to build project '{Path}'.", projectInstance.FullPath); - loggers.ReportOutput(); - return null; - } - } + if (buildResults is []) + { + return null; + } + + logger.LogDebug("Design-time build completed in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + + ProcessBuildResults(buildResults, logger, out var fileItems, out var staticWebAssetManifests); + + BuildReporter.ReportWatchedFiles(logger, fileItems); + + return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests, buildManager); + } + + private static void ProcessBuildResults( + ImmutableArray> buildResults, + ILogger logger, + out IReadOnlyDictionary fileItems, + out IReadOnlyDictionary staticWebAssetManifests) + { + var fileItemsBuilder = new Dictionary(); + var staticWebAssetManifestsBuilder = new Dictionary(); + + foreach (var buildResult in buildResults) + { + Debug.Assert(buildResult.IsSuccess); + + var projectInstance = buildResult.ProjectInstance; + Debug.Assert(projectInstance != null); + + // command line args items should be available: + Debug.Assert( + !Path.GetExtension(projectInstance.FullPath).Equals(".csproj", PathUtilities.OSSpecificPathComparison) || + projectInstance.GetItems("CscCommandLineArgs").Any()); var projectPath = projectInstance.FullPath; var projectDirectory = Path.GetDirectoryName(projectPath)!; - if (targets.Contains(TargetNames.GenerateComputedBuildStaticWebAssets) && + if (buildResult.TargetResults.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets) && projectInstance.GetIntermediateOutputDirectory() is { } outputDir && StaticWebAssetsManifest.TryParseFile(Path.Combine(outputDir, StaticWebAsset.ManifestFileName), logger) is { } manifest) { - staticWebAssetManifests.Add(projectInstance.GetId(), manifest); + staticWebAssetManifestsBuilder.Add(projectInstance.GetId(), manifest); // watch asset files, but not bundle files as they are regenarated when scoped CSS files are updated: foreach (var (relativeUrl, filePath) in manifest.UrlToPathMap) @@ -157,7 +202,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera // Adds file items for scoped css files. // Scoped css files are bundled into a single entry per project that is represented in the static web assets manifest, // but we need to watch the original individual files. - if (targets.Contains(TargetNames.ResolveScopedCssInputs)) + if (buildResult.TargetResults.ContainsKey(TargetNames.ResolveScopedCssInputs)) { foreach (var item in projectInstance.GetItems(ItemNames.ScopedCssInput)) { @@ -179,9 +224,9 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) { var filePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath)); - if (!fileItems.TryGetValue(filePath, out var existingFile)) + if (!fileItemsBuilder.TryGetValue(filePath, out var existingFile)) { - fileItems.Add(filePath, new FileItem + fileItemsBuilder.Add(filePath, new FileItem { FilePath = filePath, ContainingProjectPaths = [projectPath], @@ -196,9 +241,8 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) } } - buildReporter.ReportWatchedFiles(fileItems); - - return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests); + fileItems = fileItemsBuilder; + staticWebAssetManifests = staticWebAssetManifestsBuilder; } private static string[] GetBuildTargets(ProjectInstance projectInstance, EnvironmentOptions environmentOptions) diff --git a/src/WatchPrototype/Watch/Build/FilePathExclusions.cs b/src/WatchPrototype/Watch/Build/FilePathExclusions.cs index c98def116a2..d205b133088 100644 --- a/src/WatchPrototype/Watch/Build/FilePathExclusions.cs +++ b/src/WatchPrototype/Watch/Build/FilePathExclusions.cs @@ -94,7 +94,7 @@ internal bool IsExcluded(string fullPath, ChangeKind changeKind, ILogger logger) { if (glob.IsMatch(fullPath)) { - logger.Log(MessageDescriptor.IgnoringChangeInExcludedFile, fullPath, changeKind, "DefaultItemExcludes", globValue, projectDir); + logger.Log(MessageDescriptor.IgnoringChangeInExcludedFile, (fullPath, changeKind, "DefaultItemExcludes", globValue, projectDir)); return true; } } diff --git a/src/WatchPrototype/Watch/Build/ProjectNodeMap.cs b/src/WatchPrototype/Watch/Build/LoadedProjectGraph.cs similarity index 89% rename from src/WatchPrototype/Watch/Build/ProjectNodeMap.cs rename to src/WatchPrototype/Watch/Build/LoadedProjectGraph.cs index 69e2e5a0440..93ed69be35b 100644 --- a/src/WatchPrototype/Watch/Build/ProjectNodeMap.cs +++ b/src/WatchPrototype/Watch/Build/LoadedProjectGraph.cs @@ -1,21 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Build.Evaluation; using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch { - internal readonly struct ProjectNodeMap(ProjectGraph graph, ILogger logger) + internal sealed class LoadedProjectGraph(ProjectGraph graph, ProjectCollection collection, ILogger logger) { - public readonly ProjectGraph Graph = graph; - // full path of proj file to list of nodes representing all target frameworks of the project: public readonly IReadOnlyDictionary> Map = graph.ProjectNodes.GroupBy(n => n.ProjectInstance.FullPath).ToDictionary( keySelector: static g => g.Key, elementSelector: static g => (IReadOnlyList)[.. g]); + public ProjectGraph Graph => graph; + public ILogger Logger => logger; + public ProjectCollection ProjectCollection => collection; + public IReadOnlyList GetProjectNodes(string projectPath) { if (Map.TryGetValue(projectPath, out var rootProjectNodes)) diff --git a/src/WatchPrototype/Watch/Build/ProjectBuildManager.cs b/src/WatchPrototype/Watch/Build/ProjectBuildManager.cs new file mode 100644 index 00000000000..25e12e6a4aa --- /dev/null +++ b/src/WatchPrototype/Watch/Build/ProjectBuildManager.cs @@ -0,0 +1,110 @@ +// 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 Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProjectBuildManager(ProjectCollection collection, BuildReporter reporter) +{ + /// + /// Semaphore that ensures we only start one build build at a time per process, which is required by MSBuild. + /// + private static readonly SemaphoreSlim s_buildSemaphore = new(initialCount: 1); + + private static readonly IReadOnlyDictionary s_emptyTargetResults = new Dictionary(); + + public readonly ProjectCollection Collection = collection; + public readonly BuildReporter BuildReporter = reporter; + + /// + /// Used by tests to ensure no more than one build is running at a time, which is required by MSBuild. + /// + internal static SemaphoreSlim Test_BuildSemaphore + => s_buildSemaphore; + + /// + /// Executes the specified build requests. + /// + /// Invoked for each project that fails to build. Returns true to continue build or false to cancel. + /// True if all projects built successfully. + public async Task>> BuildAsync( + IReadOnlyList> requests, + Func onFailure, + string operationName, + CancellationToken cancellationToken) + { + Debug.Assert(requests is not []); + var buildRequests = requests.Select(r => new BuildRequestData(r.ProjectInstance, [.. r.Targets])).ToArray(); + + using var loggers = BuildReporter.GetLoggers(buildRequests[0].ProjectFullPath, operationName); + + using var buildCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + await s_buildSemaphore.WaitAsync(cancellationToken); + + var manager = BuildManager.DefaultBuildManager; + using var _ = buildCancellationTokenSource.Token.Register(manager.CancelAllSubmissions); + + var buildParameters = new BuildParameters(Collection) + { + Loggers = loggers, + }; + + manager.BeginBuild(buildParameters); + try + { + var buildTasks = new List>(buildRequests.Length); + + foreach (var request in buildRequests) + { + var taskSource = new TaskCompletionSource(); + + // Queues the build request and immediately returns. The callback is executed when the build completes. + manager.PendBuildRequest(request).ExecuteAsync( + callback: submission => + { + // Cancel on first failure: + if (submission.BuildResult?.OverallResult != BuildResultCode.Success) + { + var projectInstance = (ProjectInstance)submission.AsyncContext!; + + var continueBuild = onFailure(projectInstance); + if (!continueBuild) + { + buildCancellationTokenSource.Cancel(); + taskSource.SetCanceled(); + return; + } + } + + taskSource.SetResult(submission.BuildResult); + }, + context: request.ProjectInstance); + + buildTasks.Add(taskSource.Task); + } + + var results = await Task.WhenAll(buildTasks); + + return [.. results.Select((result, index) => new BuildResult( + (IReadOnlyDictionary?)result?.ResultsByTarget ?? s_emptyTargetResults, + requests[index].ProjectInstance, + requests[index].Data))]; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // build was canceled + loggers.ReportOutput(); + return []; + } + finally + { + manager.EndBuild(); + s_buildSemaphore.Release(); + } + } +} diff --git a/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs b/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs index 321a1c5428b..79aada5750a 100644 --- a/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs +++ b/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs @@ -13,7 +13,11 @@ namespace Microsoft.DotNet.Watch; -internal sealed class ProjectGraphFactory +internal sealed class ProjectGraphFactory( + ImmutableArray rootProjects, + string? targetFramework, + ImmutableDictionary buildProperties, + ILogger logger) { /// /// Reuse with XML element caching to improve performance. @@ -21,38 +25,20 @@ 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 ProjectCollection _collection = new( + globalProperties: buildProperties, + loggers: [], + remoteLoggers: [], + ToolsetDefinitionLocations.Default, + maxNodeCount: 1, + onlyLogCriticalEvents: false, + loadProjectsReadOnly: false, + useAsynchronousLogging: false, + reuseProjectRootElementCache: true); - private readonly ImmutableDictionary _globalOptions; - private readonly ProjectRepresentation _rootProject; + private readonly string _targetFramework = targetFramework ?? GetProductTargetFramework(); - // 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()); - } - } + public ILogger Logger => logger; private static string GetProductTargetFramework() { @@ -62,17 +48,17 @@ 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 LoadedProjectGraph? TryLoadProjectGraph(bool projectGraphRequired, CancellationToken cancellationToken) { - var entryPoint = new ProjectGraphEntryPoint(_rootProject.ProjectGraphPath, _globalOptions); + var entryPoints = rootProjects.Select(p => new ProjectGraphEntryPoint(p.ProjectGraphPath, buildProperties)); try { - return new ProjectGraph([entryPoint], _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken); + return new LoadedProjectGraph( + new ProjectGraph(entryPoints, _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken), + _collection, + logger); } catch (ProjectCreationFailedException) { @@ -119,19 +105,28 @@ 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)) + { + // `dotnet build` reports a warning when the reference project is missing. + // However, ProjectGraph doesn't allow us to return null to skip the project so we need to be stricter. + logger.LogError("The project file could not be loaded. Could not find a part of the path '{Path}'.", projectPath); + throw new ProjectCreationFailedException(); + } + var anyError = false; - _virtualRootProjectBuilder.CreateProjectInstance( + var projectInstance = VirtualProjectBuilder.CreateProjectInstance( + entryPointFilePath, + _targetFramework, projectCollection, - (sourceFile, textSpan, message) => + (path, line, message) => { anyError = true; - logger.LogError("{Location}: {Message}", sourceFile.GetLocationString(textSpan), message); - }, - out var projectInstance, - out _); + logger.LogError("{Path}({Line}): {Message}", path, line, message); + }); if (anyError) { diff --git a/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs b/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs index f9cb892e412..1aa8aedfb68 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. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.DotNet.ProjectTools; namespace Microsoft.DotNet.Watch; @@ -8,39 +9,38 @@ namespace Microsoft.DotNet.Watch; /// /// Project can be reprented by project file or by entry point file (for single-file apps). /// -internal readonly struct ProjectRepresentation(string projectGraphPath, string? projectPath, string? entryPointFilePath) +/// Path used in Project Graph (may be virtual). +/// Path to an physical (non-virtual) project, if available. +/// Path to an entry point file, if available. +internal readonly record struct ProjectRepresentation(string ProjectGraphPath, string? PhysicalPath, string? EntryPointFilePath) { - /// - /// Path used in Project Graph (may be virtual). - /// - public readonly string ProjectGraphPath = projectGraphPath; - - /// - /// Path to an physical (non-virtual) project, if available. - /// - public readonly string? PhysicalPath = projectPath; - - /// - /// Path to an entry point file, if available. - /// - public readonly string? EntryPointFilePath = entryPointFilePath; - public ProjectRepresentation(string? projectPath, string? entryPointFilePath) : this(projectPath ?? VirtualProjectBuilder.GetVirtualProjectPath(entryPointFilePath!), projectPath, entryPointFilePath) { } + [MemberNotNullWhen(true, nameof(PhysicalPath))] + [MemberNotNullWhen(false, nameof(EntryPointFilePath))] + public bool IsProjectFile + => PhysicalPath != null; + public string ProjectOrEntryPointFilePath - => PhysicalPath ?? EntryPointFilePath!; + => IsProjectFile ? PhysicalPath : EntryPointFilePath; public string GetContainingDirectory() => Path.GetDirectoryName(ProjectOrEntryPointFilePath)!; 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); + + public bool Equals(ProjectRepresentation other) + => PathUtilities.OSSpecificPathComparer.Equals(ProjectGraphPath, other.ProjectGraphPath); + + public override int GetHashCode() + => PathUtilities.OSSpecificPathComparer.GetHashCode(ProjectGraphPath); } diff --git a/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs b/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs index f7caada6824..a8b712f0de3 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,7 +19,25 @@ internal sealed class DotNetWatchContext : IDisposable public required ILoggerFactory LoggerFactory { get; init; } public required ProcessRunner ProcessRunner { get; init; } - public required ProjectOptions RootProjectOptions { get; init; } + /// + /// Roots of the project graph to watch. + /// + public required ImmutableArray RootProjects { get; init; } + + /// + /// Options for launching a main project. If null no main project is being launched. + /// + public required ProjectOptions? MainProjectOptions { get; init; } + + /// + /// Default target framework. + /// + public required string? TargetFramework { get; init; } + + /// + /// Additional arguments passed to `dotnet build` when building projects. + /// + public required IReadOnlyList BuildArguments { get; init; } public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; } public required BrowserLauncher BrowserLauncher { get; init; } diff --git a/src/WatchPrototype/Watch/Context/EnvironmentOptions.cs b/src/WatchPrototype/Watch/Context/EnvironmentOptions.cs index 1cb4f6db7bd..2953e4760c5 100644 --- a/src/WatchPrototype/Watch/Context/EnvironmentOptions.cs +++ b/src/WatchPrototype/Watch/Context/EnvironmentOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -27,8 +28,9 @@ internal enum TestFlags internal sealed record EnvironmentOptions( string WorkingDirectory, - string MuxerPath, - TimeSpan? ProcessCleanupTimeout, + string? SdkDirectory, + string LogMessagePrefix, + TimeSpan? ProcessCleanupTimeout = null, bool IsPollingEnabled = false, bool SuppressHandlingStaticWebAssets = false, bool SuppressMSBuildIncrementalism = false, @@ -37,16 +39,17 @@ internal sealed record EnvironmentOptions( bool SuppressEmojis = false, bool RestartOnRudeEdit = false, LogLevel? CliLogLevel = null, - string? AutoReloadWebSocketHostName = null, - int? AutoReloadWebSocketPort = null, string? BrowserPath = null, + WebSocketConfig BrowserWebSocketConfig = default, + WebSocketConfig AgentWebSocketConfig = default, TestFlags TestFlags = TestFlags.None, string TestOutput = "") { - public static EnvironmentOptions FromEnvironment(string muxerPath) => new + public static EnvironmentOptions FromEnvironment(string? sdkDirectory, string logMessagePrefix) => new ( WorkingDirectory: Directory.GetCurrentDirectory(), - MuxerPath: ValidateMuxerPath(muxerPath), + SdkDirectory: sdkDirectory, + LogMessagePrefix: logMessagePrefix, ProcessCleanupTimeout: EnvironmentVariables.ProcessCleanupTimeout, IsPollingEnabled: EnvironmentVariables.IsPollingEnabled, SuppressHandlingStaticWebAssets: EnvironmentVariables.SuppressHandlingStaticWebAssets, @@ -56,9 +59,9 @@ internal sealed record EnvironmentOptions( SuppressEmojis: EnvironmentVariables.SuppressEmojis, RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit, CliLogLevel: EnvironmentVariables.CliLogLevel, - AutoReloadWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, - AutoReloadWebSocketPort: EnvironmentVariables.AutoReloadWSPort, BrowserPath: EnvironmentVariables.BrowserPath, + BrowserWebSocketConfig: new(EnvironmentVariables.BrowserWebSocketPort, EnvironmentVariables.BrowserWebSocketSecurePort, EnvironmentVariables.BrowserWebSocketHostName), + AgentWebSocketConfig: new(EnvironmentVariables.AgentWebSocketPort, EnvironmentVariables.AgentWebSocketSecurePort, hostName: null), TestFlags: EnvironmentVariables.TestFlags, TestOutput: EnvironmentVariables.TestOutputDir ); @@ -67,16 +70,17 @@ public TimeSpan GetProcessCleanupTimeout() // Allow sufficient time for the process to exit gracefully and release resources (e.g., network ports). => ProcessCleanupTimeout ?? TimeSpan.FromSeconds(5); + private readonly string? _muxerPath = SdkDirectory != null + ? Path.GetFullPath(Path.Combine(SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension)) + : null; + + public string GetMuxerPath() + => _muxerPath ?? throw new InvalidOperationException("SDK directory is required to determine muxer path."); + private int _uniqueLogId; public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; } - private static string ValidateMuxerPath(string path) - { - Debug.Assert(Path.GetFileNameWithoutExtension(path) == "dotnet"); - return path; - } - public string? GetBinLogPath(string projectPath, string operationName, GlobalOptions options) => options.BinaryLogPath != null ? $"{Path.Combine(WorkingDirectory, options.BinaryLogPath)[..^".binlog".Length]}-dotnet-watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref _uniqueLogId)}.binlog" diff --git a/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs b/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs index 6a89a52cdf8..ad9d499e1bb 100644 --- a/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs +++ b/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs @@ -16,6 +16,9 @@ public static class Names public const string DotnetHostPath = "DOTNET_HOST_PATH"; public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName; + public const string DotNetWatchHotReloadWebSocketEndpoint = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint; + public const string DotNetWatchAgentWebSocketPort = "DOTNET_WATCH_AGENT_WEBSOCKET_PORT"; + public const string DotNetWatchAgentWebSocketSecurePort = "DOTNET_WATCH_AGENT_WEBSOCKET_SECURE_PORT"; public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks; public const string DotNetModifiableAssemblies = HotReload.AgentEnvironmentVariables.DotNetModifiableAssemblies; public const string HotReloadDeltaClientLogMessages = HotReload.AgentEnvironmentVariables.HotReloadDeltaClientLogMessages; @@ -39,7 +42,7 @@ public static LogLevel? CliLogLevel public static bool IsPollingEnabled => ReadBool("DOTNET_USE_POLLING_FILE_WATCHER"); public static bool SuppressEmojis => ReadBool("DOTNET_WATCH_SUPPRESS_EMOJIS"); public static bool RestartOnRudeEdit => ReadBool("DOTNET_WATCH_RESTART_ON_RUDE_EDIT"); - public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpan("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS"); + public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpanMilliseconds("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS"); public static string SdkRootDirectory => #if DEBUG @@ -56,16 +59,42 @@ public static LogLevel? CliLogLevel public static TestFlags TestFlags => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS") is { } value ? Enum.Parse(value) : TestFlags.None; public static string TestOutputDir => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR") ?? ""; - public static string? AutoReloadWSHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); - public static int? AutoReloadWSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT"); + public static string? BrowserWebSocketHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); + + /// + /// Port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified. + /// + public static int BrowserWebSocketPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT") ?? 0; + + /// + /// Secure (HTTPS/WSS) port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified. + /// Only used if TLS is supported and enabled. + /// + public static int BrowserWebSocketSecurePort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WSS_PORT") ?? 0; + public static string? BrowserPath => Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); + /// + /// Port for WebSocket hot reload communication. Used for projects with the HotReloadWebSockets capability. + /// Mobile workloads (Android, iOS) add this capability. Defaults to 0 (auto-assign) if not specified. + /// + public static int AgentWebSocketPort => ReadInt(Names.DotNetWatchAgentWebSocketPort) ?? 0; + + /// + /// Secure (HTTPS/WSS) port for WebSocket hot reload communication. + /// If not specified, HTTPS is not enabled for the agent WebSocket server. + /// + public static int? AgentWebSocketSecurePort => ReadInt(Names.DotNetWatchAgentWebSocketSecurePort); + private static bool ReadBool(string variableName) => ParseBool(Environment.GetEnvironmentVariable(variableName)); - private static TimeSpan? ReadTimeSpan(string variableName) + internal static TimeSpan? ReadTimeSpanMilliseconds(string variableName) => Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromMilliseconds(intValue) : null; + internal static TimeSpan? ReadTimeSpanSeconds(string variableName) + => Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromSeconds(intValue) : null; + private static int? ReadInt(string variableName) => Environment.GetEnvironmentVariable(variableName) is var value && int.TryParse(value, out var intValue) ? intValue : null; diff --git a/src/WatchPrototype/Watch/Context/ProjectOptions.cs b/src/WatchPrototype/Watch/Context/ProjectOptions.cs index abede2e8ef2..ee8bde50abc 100644 --- a/src/WatchPrototype/Watch/Context/ProjectOptions.cs +++ b/src/WatchPrototype/Watch/Context/ProjectOptions.cs @@ -5,13 +5,20 @@ namespace Microsoft.DotNet.Watch; internal sealed record ProjectOptions { - public required bool IsRootProject { get; init; } public required ProjectRepresentation Representation { get; init; } + + /// + /// True if the project has been launched by watch in the main iteration loop. + /// + public required bool IsMainProject { get; init; } + public required string WorkingDirectory { get; init; } - public required string? TargetFramework { get; init; } - public required IReadOnlyList BuildArguments { get; init; } - public required bool NoLaunchProfile { get; init; } - public required string? LaunchProfileName { get; init; } + + /// + /// No value indicates that no launch profile should be used. + /// Null value indicates that the default launch profile should be used. + /// + public required Optional LaunchProfileName { get; init; } /// /// Command to use to launch the project. diff --git a/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs b/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs index 1e445f5e9cb..1c5bfbd8aa9 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() { @@ -205,20 +206,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..91d14e2bc23 100644 --- a/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs +++ b/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs @@ -28,8 +28,10 @@ internal sealed class CompilationHandler : IDisposable /// /// Projects that have been launched and to which we apply changes. + /// Maps to the list of running instances of that project. /// - private ImmutableDictionary> _runningProjects = ImmutableDictionary>.Empty; + private ImmutableDictionary> _runningProjects + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); /// /// All updates that were attempted. Includes updates whose application failed. @@ -43,7 +45,8 @@ internal sealed class CompilationHandler : IDisposable /// Current set of project instances indexed by . /// Updated whenever the project graph changes. /// - private ImmutableDictionary> _projectInstances = []; + private ImmutableDictionary> _projectInstances + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); public CompilationHandler(DotNetWatchContext context) { @@ -58,13 +61,13 @@ public void Dispose() Workspace?.Dispose(); } - private ILogger Logger + public ILogger Logger => _context.Logger; - public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken) + public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken cancellationToken) { Logger.LogDebug("Terminating remaining child processes."); - await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); + await TerminatePeripheralProcessesAsync(projectPaths: null, cancellationToken); Dispose(); } @@ -81,9 +84,10 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil _previousUpdates = _previousUpdates.RemoveAll(update => projectsToBeRebuilt.Contains(update.ProjectId)); } } + public async ValueTask StartSessionAsync(CancellationToken cancellationToken) { - Logger.Log(MessageDescriptor.HotReloadSessionStarting); + Logger.Log(MessageDescriptor.HotReloadSessionStartingNotification); var solution = Workspace.CurrentSolution; @@ -110,12 +114,13 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) ProjectGraphNode projectNode, ProjectOptions projectOptions, HotReloadClients clients, + ILogger clientLogger, ProcessSpec processSpec, RestartOperation restartOperation, - CancellationTokenSource processTerminationSource, CancellationToken cancellationToken) { var processExitedSource = new CancellationTokenSource(); + var processTerminationSource = new CancellationTokenSource(); // Cancel process communication as soon as process termination is requested, shutdown is requested, or the process exits (whichever comes first). // If we only cancel after we process exit event handler is triggered the pipe might have already been closed and may fail unexpectedly. @@ -123,7 +128,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) var processCommunicationCancellationToken = processCommunicationCancellationSource.Token; // Dispose these objects on failure: - using var disposables = new Disposables([clients, processExitedSource]); + await using var disposables = new Disposables([clients, processExitedSource, processTerminationSource]); // It is important to first create the named pipe connection (Hot Reload client is the named pipe server) // and then start the process (named pipe client). Otherwise, the connection would fail. @@ -143,39 +148,48 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) // Remove the running project if it has been published to _runningProjects (if it hasn't exited during initialization): if (publishedRunningProject != null && RemoveRunningProject(publishedRunningProject)) { - publishedRunningProject.Dispose(); + await publishedRunningProject.DisposeAsync(isExiting: true); } }; var launchResult = new ProcessLaunchResult(); - var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); + var processTask = _context.ProcessRunner.RunAsync(processSpec, clientLogger, launchResult, processTerminationSource.Token); if (launchResult.ProcessId == null) { + // process failed to start: + Debug.Assert(processTask.IsCompleted && processTask.Result == int.MinValue); + // error already reported return null; } + var runningProcess = new RunningProcess(launchResult.ProcessId.Value, processTask, processExitedSource, processTerminationSource); + + // transfer ownership to the running process: + disposables.Items.Remove(processExitedSource); + disposables.Items.Remove(processTerminationSource); + disposables.Items.Add(runningProcess); + var projectPath = projectNode.ProjectInstance.FullPath; try { // Wait for agent to create the name pipe and send capabilities over. // the agent blocks the app execution until initial updates are applied (if any). - var capabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); + var managedCodeUpdateCapabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); var runningProject = new RunningProject( projectNode, projectOptions, clients, + clientLogger, runningProcess, - launchResult.ProcessId.Value, - processExitedSource: processExitedSource, - processTerminationSource: processTerminationSource, - restartOperation: restartOperation, - capabilities); - - // ownership transferred to running project: - disposables.Items.Clear(); + restartOperation, + managedCodeUpdateCapabilities); + + // transfer ownership to the running project: + disposables.Items.Remove(clients); + disposables.Items.Remove(runningProcess); disposables.Items.Add(runningProject); var appliedUpdateCount = 0; @@ -185,7 +199,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) // and apply them before adding it to running processes. // Do not block on udpates being made to other processes to avoid delaying the new process being up-to-date. var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray(); - if (updatesToApply.Any()) + if (updatesToApply.Any() && clients.IsManagedAgentSupported) { await await clients.ApplyManagedCodeUpdatesAsync( ToManagedCodeUpdates(updatesToApply), @@ -215,30 +229,34 @@ await await clients.ApplyManagedCodeUpdatesAsync( _runningProjects = _runningProjects.SetItem(projectPath, projectInstances.Add(runningProject)); - // ownership transferred to _runningProjects + // transfer ownership to _runningProjects publishedRunningProject = runningProject; - disposables.Items.Clear(); + disposables.Items.Remove(runningProject); + Debug.Assert(disposables.Items is []); break; } } - clients.OnRuntimeRudeEdit += (code, message) => + if (clients.IsManagedAgentSupported) { - // fire and forget: - _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); - }; + clients.OnRuntimeRudeEdit += (code, message) => + { + // fire and forget: + _ = HandleRuntimeRudeEditAsync(publishedRunningProject, message, cancellationToken); + }; - // Notifies the agent that it can unblock the execution of the process: - await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); + // Notifies the agent that it can unblock the execution of the process: + await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); - // If non-empty solution is loaded into the workspace (a Hot Reload session is active): - if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) - { - // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. - PrepareCompilations(currentSolution, projectPath, cancellationToken); + // If non-empty solution is loaded into the workspace (a Hot Reload session is active): + if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) + { + // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. + PrepareCompilations(currentSolution, projectPath, cancellationToken); + } } - return runningProject; + return publishedRunningProject; } catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested) { @@ -250,7 +268,7 @@ await await clients.ApplyManagedCodeUpdatesAsync( private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, string rudeEditMessage, CancellationToken cancellationToken) { - var logger = runningProject.Clients.ClientLogger; + var logger = runningProject.ClientLogger; try { @@ -269,10 +287,10 @@ private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, str await runningProject.Clients.ReportCompilationErrorsInApplicationAsync([rudeEditMessage, MessageDescriptor.RestartingApplication.GetMessage()], cancellationToken); // Terminate the process. - await runningProject.TerminateAsync(); + await runningProject.Process.TerminateAsync(); // Creates a new running project and launches it: - await runningProject.RestartOperation(cancellationToken); + await runningProject.RestartAsync(cancellationToken); } catch (Exception e) { @@ -287,7 +305,7 @@ private ImmutableArray GetAggregateCapabilities() { var capabilities = _runningProjects .SelectMany(p => p.Value) - .SelectMany(p => p.Capabilities) + .SelectMany(p => p.ManagedCodeUpdateCapabilities) .Distinct(StringComparer.Ordinal) .Order() .ToImmutableArray(); @@ -309,13 +327,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C } } - public async ValueTask<( - ImmutableArray projectUpdates, - ImmutableArray projectsToRebuild, - ImmutableArray projectsToRedeploy, - ImmutableArray projectsToRestart)> HandleManagedCodeChangesAsync( - bool autoRestart, + public async ValueTask GetManagedCodeUpdatesAsync( + HotReloadProjectUpdatesBuilder builder, Func, CancellationToken, Task> restartPrompt, + bool autoRestart, CancellationToken cancellationToken) { var currentSolution = Workspace.CurrentSolution; @@ -339,7 +354,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C // changes and await the next file change. // Note: CommitUpdate/DiscardUpdate is not expected to be called. - return ([], [], [], []); + return; } var projectsToPromptForRestart = @@ -355,7 +370,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C Logger.Log(MessageDescriptor.HotReloadSuspended); await Task.Delay(-1, cancellationToken); - return ([], [], [], []); + return; } // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. @@ -363,70 +378,106 @@ private static void PrepareCompilations(Solution solution, string projectPath, C DiscardPreviousUpdates(updates.ProjectsToRebuild); - var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); - var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates); + builder.ProjectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!)); + builder.ProjectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!)); // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. - var projectsToRestart = updates.ProjectsToRestart.IsEmpty - ? [] - : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); - - return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart); + if (!updates.ProjectsToRestart.IsEmpty) + { + builder.ProjectsToRestart.AddRange(await TerminatePeripheralProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken)); + } } - public async ValueTask ApplyUpdatesAsync(ImmutableArray updates, Stopwatch stopwatch, CancellationToken cancellationToken) + public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync( + IReadOnlyList managedCodeUpdates, + IReadOnlyDictionary> staticAssetUpdates, + Stopwatch stopwatch, + CancellationToken cancellationToken) { - Debug.Assert(!updates.IsEmpty); + var applyTasks = new List(); + ImmutableDictionary> projectsToUpdate = []; - ImmutableDictionary> projectsToUpdate; - lock (_runningProjectsAndUpdatesGuard) + if (managedCodeUpdates is not []) { - // Adding the updates makes sure that all new processes receive them before they are added to running processes. - _previousUpdates = _previousUpdates.AddRange(updates); - - // Capture the set of processes that do not have the currently calculated deltas yet. - projectsToUpdate = _runningProjects; - } + lock (_runningProjectsAndUpdatesGuard) + { + // Adding the updates makes sure that all new processes receive them before they are added to running processes. + _previousUpdates = _previousUpdates.AddRange(managedCodeUpdates); - // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. - // The process may load any of the binaries using MEF or some other runtime dependency loader. + // Capture the set of processes that do not have the currently calculated deltas yet. + projectsToUpdate = _runningProjects; + } - var applyTasks = new List(); + // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. + // The process may load any of the binaries using MEF or some other runtime dependency loader. - foreach (var (_, projects) in projectsToUpdate) - { - foreach (var runningProject in projects) + foreach (var (_, projects) in projectsToUpdate) { - // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown. - var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync( - ToManagedCodeUpdates(updates), - applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, - cancellationToken); + foreach (var runningProject in projects) + { + Debug.Assert(runningProject.Clients.IsManagedAgentSupported); + + // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown. + var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync( + ToManagedCodeUpdates(managedCodeUpdates), + applyOperationCancellationToken: runningProject.Process.ExitedCancellationToken, + cancellationToken); - applyTasks.Add(runningProject.CompleteApplyOperationAsync(applyTask)); + applyTasks.Add(runningProject.CompleteApplyOperationAsync(applyTask)); + } } } - // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.ManagedCodeChangesApplied); - } + // Creating apply tasks involves reading static assets from disk. Parallelize this IO. + var staticAssetApplyTaskProducers = new List>(); - private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, MessageDescriptor message) - { - try + foreach (var (runningProject, assets) in staticAssetUpdates) { - await Task.WhenAll(applyTasks); - - _context.Logger.Log(message, stopwatch.ElapsedMilliseconds); + // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, + // but for consistency with managed code updates we only cancel when the process exits. + staticAssetApplyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( + assets, + applyOperationCancellationToken: runningProject.Process.ExitedCancellationToken, + cancellationToken)); } - catch (Exception e) + + applyTasks.AddRange(await Task.WhenAll(staticAssetApplyTaskProducers)); + + // fire and forget: + _ = CompleteApplyOperationAsync(); + + async Task CompleteApplyOperationAsync() { - // Handle all exceptions since this is a fire-and-forget task. + try + { + await Task.WhenAll(applyTasks); - if (e is not OperationCanceledException) + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + if (managedCodeUpdates.Count > 0) + { + _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds); + } + + if (staticAssetUpdates.Count > 0) + { + _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds); + } + + _context.Logger.Log(MessageDescriptor.ChangesAppliedToProjectsNotification, + projectsToUpdate.Select(e => e.Value.First().Options.Representation).Concat( + staticAssetUpdates.Select(e => e.Key.Options.Representation))); + } + catch (Exception e) { - _context.Logger.LogError("Failed to apply updates: {Exception}", e.ToString()); + // Handle all exceptions since this is a fire-and-forget task. + + if (e is not OperationCanceledException) + { + _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString()); + } } } } @@ -456,7 +507,7 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im break; case HotReloadService.Status.NoChangesToApply: - Logger.Log(MessageDescriptor.NoCSharpChangesToApply); + Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply); break; case HotReloadService.Status.Blocked: @@ -509,7 +560,7 @@ void ReportCompilationDiagnostics(DiagnosticSeverity severity) continue; } - ReportDiagnostic(diagnostic, GetMessageDescriptor(diagnostic, verbose: false)); + ReportDiagnostic(diagnostic, autoPrefix: ""); } } @@ -537,8 +588,7 @@ void ReportRudeEdits() projectsRebuiltDueToRudeEdits.Contains(projectId) ? "[auto-rebuild] " : ""; - var descriptor = GetMessageDescriptor(diagnostic, verbose: prefix != ""); - ReportDiagnostic(diagnostic, descriptor, prefix); + ReportDiagnostic(diagnostic, prefix); } } } @@ -546,32 +596,31 @@ void ReportRudeEdits() bool IsAutoRestartEnabled(ProjectId id) => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect; - void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string autoPrefix = "") + void ReportDiagnostic(Diagnostic diagnostic, string autoPrefix) { var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic); - var args = new[] { autoPrefix, display }; - - Logger.Log(descriptor, args); if (autoPrefix != "") { + Logger.Log(MessageDescriptor.ApplyUpdate_AutoVerbose, autoPrefix, display); errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage()); } - else if (descriptor.Level != LogLevel.None) + else { - errorsToDisplayInApp.Add(descriptor.GetMessage(args)); + var descriptor = GetMessageDescriptor(diagnostic); + Logger.Log(descriptor, display); + + if (descriptor.Level != LogLevel.None) + { + errorsToDisplayInApp.Add(descriptor.GetMessage(display)); + } } } // Use the default severity of the diagnostic as it conveys impact on Hot Reload // (ignore warnings as errors and other severity configuration). - static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbose) + static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic) { - if (verbose) - { - return MessageDescriptor.ApplyUpdate_Verbose; - } - if (diagnostic.Id == "ENC0118") { // Changing '' might not have any effect until the application is restarted. @@ -587,20 +636,20 @@ static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbos } } - private static readonly string[] s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; + private static readonly ImmutableArray s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; private static bool HasScopedCssTargets(ProjectInstance projectInstance) => s_targets.All(projectInstance.Targets.ContainsKey); - public async ValueTask HandleStaticAssetChangesAsync( + public async ValueTask GetStaticAssetUpdatesAsync( + HotReloadProjectUpdatesBuilder builder, IReadOnlyList files, - ProjectNodeMap projectMap, - IReadOnlyDictionary manifests, + EvaluationResult evaluationResult, Stopwatch stopwatch, CancellationToken cancellationToken) { var assets = new Dictionary>(); - var projectInstancesToRegenerate = new HashSet(); + var projectInstancesToRegenerate = new HashSet(); foreach (var changedFile in files) { @@ -614,7 +663,7 @@ public async ValueTask HandleStaticAssetChangesAsync( foreach (var containingProjectPath in file.ContainingProjectPaths) { - if (!projectMap.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) + if (!evaluationResult.ProjectGraph.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) { // Shouldn't happen. Logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); @@ -631,7 +680,7 @@ public async ValueTask HandleStaticAssetChangesAsync( continue; } - projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance); + projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance.GetId()); } foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf()) @@ -653,13 +702,13 @@ public async ValueTask HandleStaticAssetChangesAsync( continue; } - projectInstancesToRegenerate.Add(applicationProjectInstance); + projectInstancesToRegenerate.Add(applicationProjectInstance.GetId()); var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName( applicationProjectFilePath: applicationProjectInstance.FullPath, containingProjectFilePath: containingProjectNode.ProjectInstance.FullPath); - if (!manifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) + if (!evaluationResult.StaticWebAssetsManifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) { // Shouldn't happen. Logger.LogWarning("[{Project}] Static web asset manifest not found.", containingProjectNode.GetDisplayName()); @@ -709,31 +758,31 @@ public async ValueTask HandleStaticAssetChangesAsync( return; } - HashSet? failedApplicationProjectInstances = null; + HashSet? failedApplicationProjectInstances = null; if (projectInstancesToRegenerate.Count > 0) { - var buildReporter = new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions); + Logger.LogDebug("Regenerating scoped CSS bundles."); - // Note: MSBuild only allows one build at a time in a process. - foreach (var projectInstance in projectInstancesToRegenerate) - { - Logger.LogDebug("[{Project}] Regenerating scoped CSS bundle.", projectInstance.GetDisplayName()); + // Deep copy instances so that we don't pollute the project graph: + var buildRequests = projectInstancesToRegenerate + .Select(instanceId => BuildRequest.Create(evaluationResult.RestoredProjectInstances[instanceId].DeepCopy(), s_targets)) + .ToArray(); - using var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "ScopedCss"); - - // Deep copy so that we don't pollute the project graph: - if (!projectInstance.DeepCopy().Build(s_targets, loggers)) + _ = await evaluationResult.BuildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => { - loggers.ReportOutput(); + Logger.LogWarning("[{ProjectName}] Failed to regenerate scoped CSS bundle.", failedInstance.GetDisplayName()); failedApplicationProjectInstances ??= []; - failedApplicationProjectInstances.Add(projectInstance); - } - } - } + failedApplicationProjectInstances.Add(failedInstance); - // Creating apply tasks involves reading static assets from disk. Parallelize this IO. - var applyTaskProducers = new List>(); + // continue build + return true; + }, + operationName: "ScopedCss", + cancellationToken); + } foreach (var (applicationProjectInstance, instanceAssets) in assets) { @@ -749,31 +798,35 @@ public async ValueTask HandleStaticAssetChangesAsync( foreach (var runningProject in runningProjects) { - // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, - // but for consistency with managed code updates we only cancel when the process exits. - applyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( - instanceAssets.Values, - applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, - cancellationToken)); + if (!builder.StaticAssetsToUpdate.TryGetValue(runningProject, out var updatesPerRunningProject)) + { + builder.StaticAssetsToUpdate.Add(runningProject, updatesPerRunningProject = []); + } + + if (!runningProject.Clients.UseRefreshServerToApplyStaticAssets && !runningProject.Clients.IsManagedAgentSupported) + { + // Static assets are applied via managed Hot Reload agent (e.g. in MAUI Blazor app), but managed Hot Reload is not supported (e.g. startup hooks are disabled). + builder.ProjectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath); + builder.ProjectsToRestart.Add(runningProject); + } + else + { + updatesPerRunningProject.AddRange(instanceAssets.Values); + } } } - - var applyTasks = await Task.WhenAll(applyTaskProducers); - - // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.StaticAssetsChangesApplied); } /// - /// Terminates all processes launched for non-root projects with , - /// or all running non-root project processes if is null. - /// + /// Terminates all processes launched for peripheral projects with , + /// or all running peripheral project processes if is null. + /// /// Removes corresponding entries from . - /// - /// Does not terminate the root project. + /// + /// Does not terminate the main project. /// - /// All processes (including root) to be restarted. - internal async ValueTask> TerminateNonRootProcessesAsync( + /// All processes (including main) to be restarted. + internal async ValueTask> TerminatePeripheralProcessesAsync( IEnumerable? projectPaths, CancellationToken cancellationToken) { ImmutableArray projectsToRestart = []; @@ -788,11 +841,30 @@ internal async ValueTask> TerminateNonRootProcess // Do not terminate root process at this time - it would signal the cancellation token we are currently using. // The process will be restarted later on. // Wait for all processes to exit to release their resources, so we can rebuild. - await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsRootProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken); + await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsMainProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken); return projectsToRestart; } + /// + /// Restarts given projects after their process have been terminated via . + /// + internal async Task RestartPeripheralProjectsAsync(IReadOnlyList projectsToRestart, CancellationToken cancellationToken) + { + if (projectsToRestart.Any(p => p.Options.IsMainProject)) + { + throw new InvalidOperationException("Main project can't be restarted."); + } + + _context.Logger.Log(MessageDescriptor.RestartingProjectsNotification, projectsToRestart.Select(p => p.Options.Representation)); + + await Task.WhenAll( + projectsToRestart.Select(async runningProject => runningProject.RestartAsync(cancellationToken))) + .WaitAsync(cancellationToken); + + _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Count); + } + private bool RemoveRunningProject(RunningProject project) { var projectPath = project.ProjectNode.ProjectInstance.FullPath; @@ -837,7 +909,7 @@ public bool TryGetRunningProject(string projectPath, out ImmutableArray> projects, Func action, CancellationToken cancellationToken) => Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken); - private static ImmutableArray ToManagedCodeUpdates(ImmutableArray updates) + private static ImmutableArray ToManagedCodeUpdates(IEnumerable updates) => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; private static ImmutableDictionary> CreateProjectInstanceMap(ProjectGraph graph) @@ -847,17 +919,12 @@ 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")); } public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) diff --git a/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs index c0ce376c665..8f82392d587 100644 --- a/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -4,8 +4,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Text.Encodings.Web; -using Microsoft.Build.Execution; -using Microsoft.Build.Graph; +using System.Xml.Linq; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -45,11 +44,12 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun } _designTimeBuildGraphFactory = new ProjectGraphFactory( - _context.RootProjectOptions.Representation, - _context.RootProjectOptions.TargetFramework, - globalOptions: EvaluationResult.GetGlobalBuildOptions( - context.RootProjectOptions.BuildArguments, - context.EnvironmentOptions)); + context.RootProjects, + context.TargetFramework, + buildProperties: EvaluationResult.GetGlobalBuildProperties( + context.BuildArguments, + context.EnvironmentOptions), + context.BuildLogger); } public async Task WatchAsync(CancellationToken shutdownCancellationToken) @@ -75,24 +75,19 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) { 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); + // This source will signal when the user cancels (either Ctrl+R or Ctrl+C): + using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); var iterationCancellationToken = iterationCancellationSource.Token; - var waitForFileChangeBeforeRestarting = true; EvaluationResult? evaluationResult = null; - RunningProject? rootRunningProject = null; + RunningProject? mainRunningProject = null; IRuntimeProcessLauncher? runtimeProcessLauncher = null; CompilationHandler? compilationHandler = null; Action? fileChangedCallback = null; try { - var rootProjectOptions = _context.RootProjectOptions; - - var buildSucceeded = await BuildProjectAsync(rootProjectOptions.Representation, rootProjectOptions.BuildArguments, iterationCancellationToken); + var buildSucceeded = await BuildProjectsAsync(_context.RootProjects, iterationCancellationToken); if (!buildSucceeded) { continue; @@ -100,79 +95,65 @@ 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); + // Avoid restore since the build above already restored all root projects. + evaluationResult = await EvaluateProjectGraphAsync(restore: false, iterationCancellationToken); - var rootProject = evaluationResult.ProjectGraph.GraphRoots.Single(); + compilationHandler = new CompilationHandler(_context); + var projectLauncher = new ProjectLauncher(_context, evaluationResult.ProjectGraph, compilationHandler, iteration); + evaluationResult.ItemExclusions.Report(_context.Logger); - // use normalized MSBuild path so that we can index into the ProjectGraph - rootProjectOptions = rootProjectOptions with - { - Representation = rootProjectOptions.Representation.WithProjectGraphPath(rootProject.ProjectInstance.FullPath) - }; + var mainProjectOptions = _context.MainProjectOptions; + var mainProject = (mainProjectOptions != null) ? evaluationResult.ProjectGraph.Graph.GraphRoots.Single() : null; var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; - var rootProjectCapabilities = rootProject.GetCapabilities(); - if (rootProjectCapabilities.Contains(AspireServiceFactory.AppHostProjectCapability)) + + if (mainProject?.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability) == true) { - runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance; + Debug.Assert(mainProjectOptions != null); + runtimeProcessLauncherFactory ??= new AspireServiceFactory(mainProjectOptions); _context.Logger.LogDebug("Using Aspire process launcher."); } - 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?.Create(projectLauncher); - runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions); - if (runtimeProcessLauncher != null) + if (mainProjectOptions != null) { - var launcherEnvironment = runtimeProcessLauncher.GetEnvironmentVariables(); - rootProjectOptions = rootProjectOptions with + if (runtimeProcessLauncher != null) { - LaunchEnvironmentVariables = [.. rootProjectOptions.LaunchEnvironmentVariables, .. launcherEnvironment] - }; - } - - rootRunningProject = await projectLauncher.TryLaunchProcessAsync( - rootProjectOptions, - rootProcessTerminationSource, - onOutput: null, - onExit: null, - restartOperation: new RestartOperation(_ => default), // the process will automatically restart - iterationCancellationToken); - - if (rootRunningProject == null) - { - // error has been reported: - waitForFileChangeBeforeRestarting = false; - return; - } + mainProjectOptions = mainProjectOptions with + { + LaunchEnvironmentVariables = [.. mainProjectOptions.LaunchEnvironmentVariables, .. runtimeProcessLauncher.GetEnvironmentVariables()] + }; + } - // 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); + mainRunningProject = await projectLauncher.TryLaunchProcessAsync( + mainProjectOptions, + onOutput: null, + onExit: (_, _) => + { + iterationCancellationSource.Cancel(); + return ValueTask.CompletedTask; + }, + restartOperation: new RestartOperation(_ => default), // the process will automatically restart + iterationCancellationToken); - if (shutdownCancellationToken.IsCancellationRequested) - { - // Ctrl+C: - return; - } + if (mainRunningProject == null) + { + // error has been reported: + 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(); - } + // Cancel iteration as soon as the main process exits, so that we don't spent time loading solution, etc. when the process is already dead. + mainRunningProject.Process.ExitedCancellationToken.Register(iterationCancellationSource.Cancel); - 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.Graph, 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. @@ -202,130 +183,59 @@ void FileChangedCallback(ChangedPath change) fileWatcher.OnFileChange += fileChangedCallback; _context.Logger.Log(MessageDescriptor.WaitingForChanges); - // Hot Reload loop - exits when the root process needs to be restarted. - bool extendTimeout = false; - while (true) + if (Test_FileChangesCompletedTask != null) { - try - { - if (Test_FileChangesCompletedTask != null) - { - await Test_FileChangesCompletedTask; - } + await Test_FileChangesCompletedTask; + } + // Hot Reload loop + while (!iterationCancellationToken.IsCancellationRequested) + { + ImmutableArray changedFiles; + do + { // 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 - } - catch (OperationCanceledException) - { - // Ctrl+C, forced restart, or process exited. - Debug.Assert(iterationCancellationToken.IsCancellationRequested); + await Task.Delay(50, iterationCancellationToken); - // Will wait for a file change if process exited. - waitForFileChangeBeforeRestarting = true; - break; - } - - // If the changes include addition/deletion wait a little bit more for possible matching deletion/addition. - // This eliminates reevaluations caused by teared add + delete of a temp file or a move of a file. - if (!extendTimeout && changedFilesAccumulator.Any(change => change.Kind is ChangeKind.Add or ChangeKind.Delete)) - { - extendTimeout = true; - continue; - } - - extendTimeout = false; - - var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []); - if (changedFiles is []) - { - continue; - } - - if (!rootProjectCapabilities.Contains("SupportsHotReload")) - { - _context.Logger.LogWarning("Project '{Name}' does not support Hot Reload and must be rebuilt.", rootProject.GetDisplayName()); + // If the changes include addition/deletion wait a little bit more for possible matching deletion/addition. + // This eliminates reevaluations caused by teared add + delete of a temp file or a move of a file. + if (changedFilesAccumulator.Any(change => change.Kind is ChangeKind.Add or ChangeKind.Delete)) + { + await Task.Delay(150, iterationCancellationToken); + } - // file change already detected - waitForFileChangeBeforeRestarting = false; - iterationCancellationSource.Cancel(); - break; + changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []); } + while (changedFiles is []); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.Main); + var updates = new HotReloadProjectUpdatesBuilder(); var stopwatch = Stopwatch.StartNew(); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler); - await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, stopwatch, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); + await compilationHandler.GetStaticAssetUpdatesAsync(updates, changedFiles, evaluationResult, stopwatch, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); - - var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( - autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, + await compilationHandler.GetManagedCodeUpdatesAsync( + updates, restartPrompt: async (projectNames, cancellationToken) => { - if (_rudeEditRestartPrompt != null) - { - // stop before waiting for user input: - stopwatch.Stop(); - - string question; - if (runtimeProcessLauncher == null) - { - question = "Do you want to restart your app?"; - } - else - { - _context.Logger.LogInformation("Affected projects:"); - - foreach (var projectName in projectNames.OrderBy(n => n)) - { - _context.Logger.LogInformation(" {ProjectName}", projectName); - } - - question = "Do you want to restart these projects?"; - } - - return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); - } - - _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); - - foreach (var projectName in projectNames) - { - _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); - } - - return true; + // stop before waiting for user input: + stopwatch.Stop(); + var result = await RestartPrompt(projectNames, runtimeProcessLauncher, cancellationToken); + stopwatch.Start(); + return result; }, + autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler); - - stopwatch.Stop(); - - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.Main); - // Terminate root process if it had rude edits or is non-reloadable. - if (projectsToRestart.SingleOrDefault(project => project.Options.IsRootProject) is { } rootProjectToRestart) + if (updates.ProjectsToRestart.Any(static project => project.Options.IsMainProject)) { - // Triggers rootRestartCancellationToken. - waitForFileChangeBeforeRestarting = false; + Debug.Assert(mainRunningProject != null); + mainRunningProject.InitiateRestart(); break; } - if (!projectsToRebuild.IsEmpty) + if (updates.ProjectsToRebuild is not []) { while (true) { @@ -335,19 +245,7 @@ void FileChangedCallback(ChangedPath change) fileWatcher.SuppressEvents = true; 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; - } - } - + var success = await BuildProjectsAsync([.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], iterationCancellationToken); if (success) { break; @@ -368,39 +266,31 @@ void FileChangedCallback(ChangedPath change) // Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update. // Apply them to the workspace. - _ = await CaptureChangedFilesSnapshot(projectsToRebuild); + _ = await CaptureChangedFilesSnapshot(updates.ProjectsToRebuild); - _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length); + _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, updates.ProjectsToRebuild.Count); } // Deploy dependencies after rebuilding and before restarting. - if (!projectsToRedeploy.IsEmpty) + if (updates.ProjectsToRedeploy is not []) { - DeployProjectDependencies(evaluationResult.RestoredProjectInstances, projectsToRedeploy, iterationCancellationToken); - _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); + await DeployProjectDependenciesAsync(evaluationResult, updates.ProjectsToRedeploy, iterationCancellationToken); + _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, updates.ProjectsToRedeploy.Count); } // Apply updates only after dependencies have been deployed, // so that updated code doesn't attempt to access the dependency before it has been deployed. - if (!managedCodeUpdates.IsEmpty) + if (updates.ManagedCodeUpdates.Count > 0 || updates.StaticAssetsToUpdate.Count > 0) { - await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, stopwatch, iterationCancellationToken); + await compilationHandler.ApplyManagedCodeAndStaticAssetUpdatesAsync(updates.ManagedCodeUpdates, updates.StaticAssetsToUpdate, stopwatch, iterationCancellationToken); } - if (!projectsToRestart.IsEmpty) + if (updates.ProjectsToRestart is not []) { - await Task.WhenAll( - projectsToRestart.Select(async runningProject => - { - var newRunningProject = await runningProject.RestartOperation(shutdownCancellationToken); - _ = await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); - })) - .WaitAsync(shutdownCancellationToken); - - _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Length); + await compilationHandler.RestartPeripheralProjectsAsync(updates.ProjectsToRestart, shutdownCancellationToken); } - async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) + async Task> CaptureChangedFilesSnapshot(IReadOnlyList rebuiltProjects) { var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []); if (changedPaths is []) @@ -442,12 +332,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.Graph, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -468,7 +358,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr _context.Logger.Log(MessageDescriptor.ReEvaluationCompleted); } - if (!rebuiltProjects.IsEmpty) + if (rebuiltProjects is not []) { // Filter changed files down to those contained in projects being rebuilt. // File changes that affect projects that are not being rebuilt will stay in the accumulator @@ -510,11 +400,6 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr { // start next iteration unless shutdown is requested } - catch (Exception) when ((waitForFileChangeBeforeRestarting = false) == true) - { - // unreachable - throw new InvalidOperationException(); - } finally { // stop watching file changes: @@ -525,31 +410,35 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr if (runtimeProcessLauncher != null) { - // Request cleanup of all processes created by the launcher before we terminate the root process. - // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. - await runtimeProcessLauncher.TerminateLaunchedProcessesAsync(CancellationToken.None); + // Dispose the launcher so that it won't start any new peripheral processes. + // Do this before terminating all processes, so that we don't leave any processes orphaned. + await runtimeProcessLauncher.DisposeAsync(); } if (compilationHandler != null) { // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. - await compilationHandler.TerminateNonRootProcessesAndDispose(CancellationToken.None); + await compilationHandler.TerminatePeripheralProcessesAndDispose(CancellationToken.None); } - if (rootRunningProject != null) + if (mainRunningProject != null) { - await rootRunningProject.TerminateAsync(); + await mainRunningProject.Process.TerminateAsync(); } - if (runtimeProcessLauncher != null) + // Wait for file change + // - if the process hasn't launched (e.g. build failed) + // - if the process launched, has been terminated and is not being auto-restarted (rude edit), + // unless Ctrl+R or Ctrl+C were pressed. + if (shutdownCancellationToken.IsCancellationRequested) { - await runtimeProcessLauncher.DisposeAsync(); + // no op } - - if (waitForFileChangeBeforeRestarting && - !shutdownCancellationToken.IsCancellationRequested && - !forceRestartCancellationSource.IsCancellationRequested && - rootRunningProject?.IsRestarting != true) + else if (forceRestartCancellationSource.IsCancellationRequested) + { + _context.Logger.Log(MessageDescriptor.Restarting); + } + else if (mainRunningProject?.IsRestarting != true) { using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token); @@ -558,6 +447,40 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr } } + private async Task RestartPrompt(IEnumerable projectNames, IRuntimeProcessLauncher? runtimeProcessLauncher, CancellationToken cancellationToken) + { + if (_rudeEditRestartPrompt != null) + { + string question; + if (runtimeProcessLauncher == null) + { + question = "Do you want to restart your app?"; + } + else + { + _context.Logger.LogInformation("Affected projects:"); + + foreach (var projectName in projectNames.Order()) + { + _context.Logger.LogInformation(" {ProjectName}", projectName); + } + + question = "Do you want to restart these projects?"; + } + + return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); + } + + _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); + + foreach (var projectName in projectNames) + { + _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); + } + + return true; + } + private void AnalyzeFileChanges( List changedFiles, EvaluationResult evaluationResult, @@ -612,7 +535,7 @@ private static bool MatchesBuildFile(string filePath) return extension.Equals(".props", PathUtilities.OSSpecificPathComparison) || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison) || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison) - || extension.Equals("projitems", PathUtilities.OSSpecificPathComparison) // shared project items + || extension.Equals(".projitems", PathUtilities.OSSpecificPathComparison) // shared project items || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison); } @@ -656,13 +579,15 @@ private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluation return false; } - private void DeployProjectDependencies(ImmutableArray restoredProjectInstances, ImmutableArray projectPaths, CancellationToken cancellationToken) + private async ValueTask DeployProjectDependenciesAsync(EvaluationResult evaluationResult, IEnumerable projectPaths, CancellationToken cancellationToken) { + const string TargetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; + var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); - var buildReporter = new BuildReporter(_context.Logger, _context.Options, _context.EnvironmentOptions); - var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; - foreach (var restoredProjectInstance in restoredProjectInstances) + var buildRequests = new List>(); + + foreach (var (_, restoredProjectInstance) in evaluationResult.RestoredProjectInstances) { cancellationToken.ThrowIfCancellationRequested(); @@ -676,7 +601,7 @@ private void DeployProjectDependencies(ImmutableArray restoredP continue; } - if (!projectInstance.Targets.ContainsKey(targetName)) + if (!projectInstance.Targets.ContainsKey(TargetName)) { continue; } @@ -686,43 +611,70 @@ private void DeployProjectDependencies(ImmutableArray restoredP continue; } - using var loggers = buildReporter.GetLoggers(projectPath, targetName); - if (!projectInstance.Build([targetName], loggers, out var targetOutputs)) + buildRequests.Add(BuildRequest.Create(projectInstance, [TargetName], relativeOutputDir)); + } + + var results = await evaluationResult.BuildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => + { + _context.Logger.LogDebug("[{ProjectName}] {TargetName} target failed", failedInstance.GetDisplayName(), TargetName); + + // continue build + return true; + }, + operationName: "DeployProjectDependencies", + cancellationToken); + + var copyTasks = new List(); + + foreach (var result in results) + { + if (!result.IsSuccess) { - _context.Logger.LogDebug("{TargetName} target failed", targetName); - loggers.ReportOutput(); continue; } + var relativeOutputDir = result.Data; + var projectInstance = result.ProjectInstance; + + var projectPath = projectInstance.FullPath; + var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir); - foreach (var item in targetOutputs[targetName].Items) + foreach (var item in result.TargetResults[TargetName].Items) { cancellationToken.ThrowIfCancellationRequested(); var sourcePath = item.ItemSpec; var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath)); - if (!File.Exists(targetPath)) - { - _context.Logger.LogDebug("Deploying project dependency '{TargetPath}' from '{SourcePath}'", targetPath, sourcePath); - try + copyTasks.Add(Task.Run(() => + { + if (!File.Exists(targetPath)) { - var directory = Path.GetDirectoryName(targetPath); - if (directory != null) + _context.Logger.LogDebug("Deploying project dependency '{TargetPath}' from '{SourcePath}'", targetPath, sourcePath); + + try { - Directory.CreateDirectory(directory); - } + var directory = Path.GetDirectoryName(targetPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } - File.Copy(sourcePath, targetPath, overwrite: false); - } - catch (Exception e) - { - _context.Logger.LogDebug("Copy failed: {Message}", e.Message); + File.Copy(sourcePath, targetPath, overwrite: false); + } + catch (Exception e) + { + _context.Logger.LogDebug("Copy failed: {Message}", e.Message); + } } - } + }, cancellationToken)); } } + + await Task.WhenAll(copyTasks); } private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken) @@ -741,8 +693,8 @@ 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); + // evaluation cancelled - watch for any changes in the directory trees containing root projects or entry-point files: + fileWatcher.WatchContainingDirectories(_context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), includeSubdirectories: true); _ = await fileWatcher.WaitForFileChangeAsync( acceptChange: AcceptChange, @@ -919,32 +871,25 @@ static string GetPluralMessage(ChangeKind kind) }; } - private async ValueTask EvaluateRootProjectAsync(bool restore, CancellationToken cancellationToken) + private async ValueTask EvaluateProjectGraphAsync(bool restore, CancellationToken cancellationToken) { while (true) { cancellationToken.ThrowIfCancellationRequested(); - _context.Logger.LogInformation("Evaluating projects ..."); + _context.Logger.Log(MessageDescriptor.LoadingProjects); var stopwatch = Stopwatch.StartNew(); - var result = EvaluationResult.TryCreate( - _designTimeBuildGraphFactory, - _context.BuildLogger, - _context.Options, - _context.EnvironmentOptions, - restore, - cancellationToken); - - _context.Logger.LogInformation("Evaluation completed in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + var result = await EvaluationResult.TryCreateAsync(_designTimeBuildGraphFactory, _context.Options, _context.EnvironmentOptions, restore, cancellationToken); if (result != null) { + _context.Logger.Log(MessageDescriptor.LoadedProjects, result.ProjectGraph.Graph.ProjectNodes.Count, stopwatch.Elapsed.TotalSeconds); return result; } await FileWatcher.WaitForFileChangeAsync( - _context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath, + _context.RootProjects.Select(static p => p.ProjectOrEntryPointFilePath), _context.Logger, _context.EnvironmentOptions, startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), @@ -952,14 +897,88 @@ await FileWatcher.WaitForFileChangeAsync( } } - private async Task BuildProjectAsync(ProjectRepresentation project, IReadOnlyList buildArguments, CancellationToken cancellationToken) + // internal for testing + internal async Task BuildProjectsAsync(ImmutableArray projects, CancellationToken cancellationToken) { - List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; + Debug.Assert(projects.Any()); + + _context.Logger.Log(MessageDescriptor.BuildStartedNotification, projects); + var success = await BuildAsync(); + _context.Logger.Log(MessageDescriptor.BuildCompletedNotification, (projects, success)); + + return success; + + async Task BuildAsync() + { + if (projects is [var singleProject]) + { + return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, cancellationToken); + } + + // TODO: workaround for https://github.com/dotnet/sdk/issues/51311 + var projectPaths = projects.Where(p => p.PhysicalPath != null).Select(p => p.PhysicalPath!).ToArray(); + + if (projectPaths is [var singleProjectPath]) + { + if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, cancellationToken)) + { + return false; + } + } + else if (projectPaths is not []) + { + var solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx"); + var solutionElement = new XElement("Solution"); + + foreach (var projectPath in projectPaths) + { + solutionElement.Add(new XElement("Project", new XAttribute("Path", projectPath))); + } + + var doc = new XDocument(solutionElement); + doc.Save(solutionFile); + + try + { + if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, cancellationToken)) + { + return false; + } + } + finally + { + try + { + File.Delete(solutionFile); + } + catch + { + // ignore + } + } + } + + // To maximize parallelism of building dependencies, build file-based projects after all physical projects: + foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!)) + { + if (!await BuildFileOrProjectOrSolutionAsync(file, cancellationToken)) + { + return false; + } + } + + return true; + } + } + + private async Task BuildFileOrProjectOrSolutionAsync(string path, CancellationToken cancellationToken) + { + List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; var processSpec = new ProcessSpec { - Executable = _context.EnvironmentOptions.MuxerPath, - WorkingDirectory = project.GetContainingDirectory(), + Executable = _context.EnvironmentOptions.GetMuxerPath(), + WorkingDirectory = Path.GetDirectoryName(path), IsUserApplication = false, // Capture output if running in a test environment. @@ -975,16 +994,18 @@ private async Task BuildProjectAsync(ProjectRepresentation project, IReadO : null, // pass user-specified build arguments last to override defaults: - Arguments = ["build", project.ProjectOrEntryPointFilePath, .. buildArguments] + Arguments = ["build", path, .. _context.BuildArguments] }; - _context.BuildLogger.Log(MessageDescriptor.Building, project.ProjectOrEntryPointFilePath); + _context.BuildLogger.Log(MessageDescriptor.Building, path); 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); + // To avoid multiple status messages, only log the status if the output of `dotnet build` is not being streamed to the console: + _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, path); + BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success); } diff --git a/src/WatchPrototype/Watch/HotReload/HotReloadEventSource.cs b/src/WatchPrototype/Watch/HotReload/HotReloadEventSource.cs deleted file mode 100644 index 4196f85ba21..00000000000 --- a/src/WatchPrototype/Watch/HotReload/HotReloadEventSource.cs +++ /dev/null @@ -1,31 +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.Diagnostics.Tracing; - -namespace Microsoft.DotNet.Watch -{ - [EventSource(Name = "HotReload")] - internal sealed class HotReloadEventSource : EventSource - { - public enum StartType - { - Main, - StaticHandler, - CompilationHandler, - } - - internal sealed class Keywords - { - public const EventKeywords Perf = (EventKeywords)1; - } - - [Event(1, Message = "Hot reload started for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] - public void HotReloadStart(StartType handlerType) { WriteEvent(1, handlerType); } - - [Event(2, Message = "Hot reload finished for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] - public void HotReloadEnd(StartType handlerType) { WriteEvent(2, handlerType); } - - public static readonly HotReloadEventSource Log = new(); - } -} diff --git a/src/WatchPrototype/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs b/src/WatchPrototype/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs new file mode 100644 index 00000000000..a56fbe8e369 --- /dev/null +++ b/src/WatchPrototype/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; +using Microsoft.DotNet.HotReload; + +namespace Microsoft.DotNet.Watch; + +internal sealed class HotReloadProjectUpdatesBuilder +{ + public List ManagedCodeUpdates { get; } = []; + public Dictionary> StaticAssetsToUpdate { get; } = []; + public List ProjectsToRebuild { get; } = []; + public List ProjectsToRedeploy { get; } = []; + public List ProjectsToRestart { get; } = []; +} diff --git a/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj index 282250d8e49..5b46ae0b06e 100644 --- a/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj +++ b/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj @@ -29,13 +29,13 @@ + - - + - + diff --git a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.cs b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.cs index f91f9342155..d90a0e8c1a5 100644 --- a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.cs +++ b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.cs @@ -10,9 +10,4 @@ namespace Microsoft.DotNet.Watch; internal interface IRuntimeProcessLauncher : IAsyncDisposable { IEnumerable<(string name, string value)> GetEnvironmentVariables(); - - /// - /// Initiates shutdown. Terminates all created processes. - /// - ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken); } diff --git a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs index 93d69f69db5..8859bcbe979 100644 --- a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs +++ b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs @@ -12,5 +12,5 @@ namespace Microsoft.DotNet.Watch; /// internal interface IRuntimeProcessLauncherFactory { - public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions); + public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher); } diff --git a/src/WatchPrototype/Watch/Process/ProcessRunner.cs b/src/WatchPrototype/Watch/Process/ProcessRunner.cs index 2cfba7ade09..310a1479df8 100644 --- a/src/WatchPrototype/Watch/Process/ProcessRunner.cs +++ b/src/WatchPrototype/Watch/Process/ProcessRunner.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class ProcessRunner(TimeSpan processCleanupTimeout) + internal class ProcessRunner(TimeSpan processCleanupTimeout) { private sealed class ProcessState(Process process) : IDisposable { @@ -15,22 +15,16 @@ private sealed class ProcessState(Process process) : IDisposable public int ProcessId; public bool HasExited; - // True if Ctrl+C was sent to the process on Windows. - public bool SentWindowsCtrlC; - - // True if SIGKILL was sent to the process on Unix. - public bool SentUnixSigKill; - public void Dispose() => Process.Dispose(); } - private const int CtlrCExitCode = unchecked((int)0xC000013A); - private const int SigKillExitCode = 137; - // For testing purposes only, lock on access. private static readonly HashSet s_runningApplicationProcesses = []; + // Exit code used by the OS when process is terminated by an external signal. + private static readonly int s_processTerminatedExitCode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? unchecked((int)0xC000013A) : 137; + public static IReadOnlyCollection GetRunningApplicationProcesses() { lock (s_runningApplicationProcesses) @@ -41,8 +35,9 @@ public static IReadOnlyCollection GetRunningApplicationProcesses() /// /// Launches a process. + /// Virutal for testing. /// - public async Task RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) + public virtual async Task RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) { var stopwatch = new Stopwatch(); stopwatch.Start(); @@ -77,7 +72,7 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process // Either Ctrl+C was pressed or the process is being restarted. // Non-cancellable to not leave orphaned processes around blocking resources: - await TerminateProcessAsync(state.Process, processSpec, state, logger, CancellationToken.None); + await TerminateProcessAsync(state.Process, processSpec, state, logger); } } catch (Exception e) @@ -114,9 +109,7 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process if (processSpec.IsUserApplication) { - if (exitCode == 0 || - state.SentWindowsCtrlC && exitCode == CtlrCExitCode || - state.SentUnixSigKill && exitCode == SigKillExitCode) + if (exitCode == 0 || exitCode == s_processTerminatedExitCode) { logger.Log(MessageDescriptor.Exited); } @@ -126,7 +119,7 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process } else { - logger.Log(MessageDescriptor.ExitedWithErrorCode, exitCode); + logger.Log(MessageDescriptor.ExitedWithErrorCode, exitCode.Value); } } @@ -166,16 +159,9 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process process.StartInfo.CreateNewProcessGroup = true; } - if (processSpec.EscapedArguments is not null) - { - process.StartInfo.Arguments = processSpec.EscapedArguments; - } - else if (processSpec.Arguments is not null) + for (var i = 0; i < processSpec.Arguments.Count; i++) { - for (var i = 0; i < processSpec.Arguments.Count; i++) - { - process.StartInfo.ArgumentList.Add(processSpec.Arguments[i]); - } + process.StartInfo.ArgumentList.Add(processSpec.Arguments[i]); } foreach (var env in processSpec.EnvironmentVariables) @@ -244,7 +230,7 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process } } - private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, ILogger logger, CancellationToken cancellationToken) + private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, ILogger logger) { var forceOnly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !processSpec.IsUserApplication; @@ -252,37 +238,38 @@ private async ValueTask TerminateProcessAsync(Process process, ProcessSpec proce if (forceOnly) { - _ = await WaitForExitAsync(process, state, timeout: null, logger, cancellationToken); + _ = await WaitForExitAsync(process, state, timeout: null, logger); return; } // Ctlr+C/SIGTERM has been sent, wait for the process to exit gracefully. if (processCleanupTimeout.TotalMilliseconds == 0 || - !await WaitForExitAsync(process, state, processCleanupTimeout, logger, cancellationToken)) + !await WaitForExitAsync(process, state, processCleanupTimeout, logger)) { // Force termination if the process is still running after the timeout. TerminateProcess(process, state, logger, force: true); - _ = await WaitForExitAsync(process, state, timeout: null, logger, cancellationToken); + _ = await WaitForExitAsync(process, state, timeout: null, logger); } } - private static async ValueTask WaitForExitAsync(Process process, ProcessState state, TimeSpan? timeout, ILogger logger, CancellationToken cancellationToken) + private static async ValueTask WaitForExitAsync(Process process, ProcessState state, TimeSpan? timeout, ILogger logger) { // On Linux simple call WaitForExitAsync does not work reliably (it may hang). // As a workaround we poll for HasExited. // See also https://github.com/dotnet/runtime/issues/109434. - var task = process.WaitForExitAsync(cancellationToken); - - if (timeout is { } timeoutValue) + if (timeout.HasValue) { + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(timeout.Value); + try { - logger.Log(MessageDescriptor.WaitingForProcessToExitWithin, state.ProcessId, timeoutValue.TotalSeconds); - await task.WaitAsync(timeoutValue, cancellationToken); + logger.Log(MessageDescriptor.WaitingForProcessToExitWithin, state.ProcessId, (int)timeout.Value.TotalSeconds); + await process.WaitForExitAsync(cancellationSource.Token); } - catch (TimeoutException) + catch (OperationCanceledException) { try { @@ -312,12 +299,15 @@ private static async ValueTask WaitForExitAsync(Process process, ProcessSt logger.Log(MessageDescriptor.WaitingForProcessToExit, state.ProcessId, i++); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(TimeSpan.FromSeconds(1)); + try { - await task.WaitAsync(TimeSpan.FromSeconds(1), cancellationToken); + await process.WaitForExitAsync(cancellationSource.Token); break; } - catch (TimeoutException) + catch (OperationCanceledException) { } } @@ -366,12 +356,9 @@ private static void TerminateWindowsProcess(Process process, ProcessState state, } else { - state.SentWindowsCtrlC = true; - var error = ProcessUtilities.SendWindowsCtrlCEvent(state.ProcessId); if (error != null) { - state.SentWindowsCtrlC = false; logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error); } } @@ -382,19 +369,9 @@ private static void TerminateUnixProcess(ProcessState state, ILogger logger, boo var signalName = force ? "SIGKILL" : "SIGTERM"; logger.Log(MessageDescriptor.TerminatingProcess, state.ProcessId, signalName); - if (force) - { - state.SentUnixSigKill = true; - } - var error = ProcessUtilities.SendPosixSignal(state.ProcessId, signal: force ? ProcessUtilities.SIGKILL : ProcessUtilities.SIGTERM); if (error != null) { - if (force) - { - state.SentUnixSigKill = false; - } - logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error); } } diff --git a/src/WatchPrototype/Watch/Process/ProcessSpec.cs b/src/WatchPrototype/Watch/Process/ProcessSpec.cs index 15e4f1a12eb..7b4a9306dd0 100644 --- a/src/WatchPrototype/Watch/Process/ProcessSpec.cs +++ b/src/WatchPrototype/Watch/Process/ProcessSpec.cs @@ -5,11 +5,10 @@ namespace Microsoft.DotNet.Watch { internal sealed class ProcessSpec { - public string? Executable { get; set; } + public required string Executable { get; init; } public string? WorkingDirectory { get; set; } public Dictionary EnvironmentVariables { get; } = []; - public IReadOnlyList? Arguments { get; set; } - public string? EscapedArguments { get; set; } + public IReadOnlyList Arguments { get; set; } = []; public Action? OnOutput { get; set; } public ProcessExitAction? OnExit { get; set; } public CancellationToken CancelOutputCapture { get; set; } @@ -24,6 +23,6 @@ internal sealed class ProcessSpec => Path.GetFileNameWithoutExtension(Executable); public string GetArgumentsDisplay() - => EscapedArguments ?? CommandLineUtilities.JoinArguments(Arguments ?? []); + => CommandLineUtilities.JoinArguments(Arguments ?? []); } } diff --git a/src/WatchPrototype/Watch/Process/ProjectLauncher.cs b/src/WatchPrototype/Watch/Process/ProjectLauncher.cs index e3e97b3da4f..4e31e8cced3 100644 --- a/src/WatchPrototype/Watch/Process/ProjectLauncher.cs +++ b/src/WatchPrototype/Watch/Process/ProjectLauncher.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -11,7 +12,7 @@ namespace Microsoft.DotNet.Watch; internal sealed class ProjectLauncher( DotNetWatchContext context, - ProjectNodeMap projectMap, + LoadedProjectGraph projectGraph, CompilationHandler compilationHandler, int iteration) { @@ -26,44 +27,34 @@ public ILoggerFactory LoggerFactory public EnvironmentOptions EnvironmentOptions => context.EnvironmentOptions; + public CompilationHandler CompilationHandler + => compilationHandler; + public async ValueTask TryLaunchProcessAsync( ProjectOptions projectOptions, - CancellationTokenSource processTerminationSource, Action? onOutput, ProcessExitAction? onExit, RestartOperation restartOperation, CancellationToken cancellationToken) { - var projectNode = projectMap.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, projectOptions.TargetFramework); + var projectNode = projectGraph.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, context.TargetFramework); if (projectNode == null) { // error already reported return null; } - if (!projectNode.IsNetCoreApp(Versions.Version6_0)) - { - Logger.LogError($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Use --no-hot-reload switch or update the project's launchSettings.json to disable this feature."); - return null; - } - - var appModel = HotReloadAppModel.InferFromProject(context, projectNode); - // create loggers that include project name in messages: var projectDisplayName = projectNode.GetDisplayName(); var clientLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.ClientLogComponentName, projectDisplayName); var agentLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.AgentLogComponentName, projectDisplayName); - var clients = await appModel.TryCreateClientsAsync(clientLogger, agentLogger, cancellationToken); - if (clients == null) - { - // error already reported - return null; - } + var appModel = HotReloadAppModel.InferFromProject(context, projectNode); + var clients = await appModel.CreateClientsAsync(clientLogger, agentLogger, cancellationToken); var processSpec = new ProcessSpec { - Executable = EnvironmentOptions.MuxerPath, + Executable = EnvironmentOptions.GetMuxerPath(), IsUserApplication = true, WorkingDirectory = projectOptions.WorkingDirectory, OnOutput = onOutput, @@ -91,10 +82,10 @@ public EnvironmentOptions EnvironmentOptions environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1"; environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture); - if (Logger.IsEnabled(LogLevel.Trace)) + if (clients.IsManagedAgentSupported && Logger.IsEnabled(LogLevel.Trace)) { environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] = - (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"; + (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix(EnvironmentOptions.LogMessagePrefix) + $"[{projectDisplayName}]"; } clients.ConfigureLaunchEnvironment(environmentBuilder); @@ -109,9 +100,9 @@ public EnvironmentOptions EnvironmentOptions projectNode, projectOptions, clients, + clientLogger, processSpec, restartOperation, - processTerminationSource, cancellationToken); } diff --git a/src/WatchPrototype/Watch/Process/RunningProcess.cs b/src/WatchPrototype/Watch/Process/RunningProcess.cs new file mode 100644 index 00000000000..33777a531bc --- /dev/null +++ b/src/WatchPrototype/Watch/Process/RunningProcess.cs @@ -0,0 +1,53 @@ +// 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; + +internal sealed class RunningProcess( + int id, + Task task, + CancellationTokenSource exitedSource, + CancellationTokenSource terminationSource) : IAsyncDisposable +{ + private CancellationTokenSource? _terminationSource = terminationSource; + + /// + /// Cancellation token triggered when the process exits. + /// Stores the token to allow callers to use the token even after the source has been disposed. + /// + public readonly CancellationToken ExitedCancellationToken = exitedSource.Token; + + public Task Task => task; + public int Id => id; + + ValueTask IAsyncDisposable.DisposeAsync() + => DisposeAsync(isExiting: false); + + public async ValueTask DisposeAsync(bool isExiting) + { + var terminationSource = Interlocked.Exchange(ref _terminationSource, null); + ObjectDisposedException.ThrowIf(terminationSource == null, this); + + // do not await process termination since it's already in progress: + if (!isExiting) + { + terminationSource.Cancel(); + await task; + } + + terminationSource.Dispose(); + + exitedSource.Cancel(); + exitedSource.Dispose(); + } + + /// + /// Terminates the process if it hasn't terminated yet. + /// Awating the task triggers OnExit handlers, which in turn call . + /// + public Task TerminateAsync() + { + _terminationSource?.Cancel(); + return task; + } +} diff --git a/src/WatchPrototype/Watch/Process/RunningProject.cs b/src/WatchPrototype/Watch/Process/RunningProject.cs index c4d63e953a2..ca1498aecbc 100644 --- a/src/WatchPrototype/Watch/Process/RunningProject.cs +++ b/src/WatchPrototype/Watch/Process/RunningProject.cs @@ -2,97 +2,52 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; -using System.Diagnostics; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch { - internal delegate ValueTask RestartOperation(CancellationToken cancellationToken); + internal delegate ValueTask RestartOperation(CancellationToken cancellationToken); internal sealed class RunningProject( ProjectGraphNode projectNode, ProjectOptions options, HotReloadClients clients, - Task runningProcess, - int processId, - CancellationTokenSource processExitedSource, - CancellationTokenSource processTerminationSource, + ILogger clientLogger, + RunningProcess process, RestartOperation restartOperation, - ImmutableArray capabilities) : IDisposable + ImmutableArray managedCodeUpdateCapabilities) : IAsyncDisposable { - public readonly ProjectGraphNode ProjectNode = projectNode; - public readonly ProjectOptions Options = options; - public readonly HotReloadClients Clients = clients; - public readonly ImmutableArray Capabilities = capabilities; - public readonly Task RunningProcess = runningProcess; - public readonly int ProcessId = processId; - public readonly RestartOperation RestartOperation = restartOperation; + private volatile int _isRestarting; - /// - /// Cancellation token triggered when the process exits. - /// Stores the token to allow callers to use the token even after the source has been disposed. - /// - public CancellationToken ProcessExitedCancellationToken = processExitedSource.Token; + public ProjectGraphNode ProjectNode => projectNode; + public ProjectOptions Options => options; + public HotReloadClients Clients => clients; + public ILogger ClientLogger => clientLogger; + public ImmutableArray ManagedCodeUpdateCapabilities => managedCodeUpdateCapabilities; + public RunningProcess Process => process; /// - /// Set to true when the process termination is being requested so that it can be restarted within - /// the Hot Reload session (i.e. without restarting the root project). + /// Set to true when the process termination is being requested so that it can be auto-restarted. /// public bool IsRestarting => _isRestarting != 0; - private volatile int _isRestarting; - private volatile bool _isDisposed; - /// /// Disposes the project. Can occur unexpectedly whenever the process exits. /// Must only be called once per project. /// - public void Dispose() + /// When invoked in handler. + public async ValueTask DisposeAsync(bool isExiting) { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - _isDisposed = true; - processExitedSource.Cancel(); + // disposes communication channels: + clients.Dispose(); - Clients.Dispose(); - processTerminationSource.Dispose(); - processExitedSource.Dispose(); + await process.DisposeAsync(isExiting); } - /// - /// Waits for the application process to start. - /// Ensures that the build has been complete and the build outputs are available. - /// Returns false if the process has exited before the connection was established. - /// - public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellationToken) - { - using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, ProcessExitedCancellationToken); - - try - { - await Clients.WaitForConnectionEstablishedAsync(processCommunicationCancellationSource.Token); - return true; - } - catch (OperationCanceledException) when (ProcessExitedCancellationToken.IsCancellationRequested) - { - return false; - } - } - - /// - /// Terminates the process if it hasn't terminated yet. - /// - public Task TerminateAsync() - { - if (!_isDisposed) - { - processTerminationSource.Cancel(); - } - - return RunningProcess; - } + ValueTask IAsyncDisposable.DisposeAsync() + => DisposeAsync(isExiting: false); /// /// Marks the as restarting. @@ -108,7 +63,7 @@ public bool InitiateRestart() public Task TerminateForRestartAsync() { InitiateRestart(); - return TerminateAsync(); + return process.TerminateAsync(); } public async Task CompleteApplyOperationAsync(Task applyTask) @@ -126,8 +81,18 @@ public async Task CompleteApplyOperationAsync(Task applyTask) // Handle all exceptions. If one process is terminated or fails to apply changes // it shouldn't prevent applying updates to other processes. - Clients.ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", ProcessId, e.ToString()); + ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", process.Id, e.ToString()); } } + + /// + /// Triggers restart operation. + /// + public async ValueTask RestartAsync(CancellationToken cancellationToken) + { + ClientLogger.Log(MessageDescriptor.ProjectRestarting); + await restartOperation(cancellationToken); + ClientLogger.Log(MessageDescriptor.ProjectRestarted); + } } } diff --git a/src/WatchPrototype/Watch/UI/ConsoleReporter.cs b/src/WatchPrototype/Watch/UI/ConsoleReporter.cs index 60b156142b0..baa0807958d 100644 --- a/src/WatchPrototype/Watch/UI/ConsoleReporter.cs +++ b/src/WatchPrototype/Watch/UI/ConsoleReporter.cs @@ -9,10 +9,8 @@ namespace Microsoft.DotNet.Watch /// This API supports infrastructure and is not intended to be used /// directly from your code. This API may change or be removed in future releases. /// - internal sealed class ConsoleReporter(IConsole console, bool suppressEmojis) : IReporter, IProcessOutputReporter + internal sealed class ConsoleReporter(IConsole console, string logMessagePrefix, bool suppressEmojis) : IReporter, IProcessOutputReporter { - public bool SuppressEmojis { get; } = suppressEmojis; - private readonly Lock _writeLock = new(); bool IProcessOutputReporter.PrefixProcessOutput @@ -31,7 +29,7 @@ private void WriteLine(TextWriter writer, string message, ConsoleColor? color, E lock (_writeLock) { console.ForegroundColor = ConsoleColor.DarkGray; - writer.Write((SuppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix()); + writer.Write((suppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix(logMessagePrefix)); console.ResetColor(); if (color.HasValue) diff --git a/src/WatchPrototype/Watch/UI/IReporter.cs b/src/WatchPrototype/Watch/UI/IReporter.cs index b2421ef89fa..4e2c7a0f935 100644 --- a/src/WatchPrototype/Watch/UI/IReporter.cs +++ b/src/WatchPrototype/Watch/UI/IReporter.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -50,18 +53,39 @@ public static string ToDisplay(this Emoji emoji) _ => throw new InvalidOperationException() }; - public static string GetLogMessagePrefix(this Emoji emoji) - => $"dotnet watch {emoji.ToDisplay()} "; + public static string GetLogMessagePrefix(this Emoji emoji, string logMessagePrefix) + => $"{logMessagePrefix} {emoji.ToDisplay()} "; - public static void Log(this ILogger logger, MessageDescriptor descriptor, params object?[] args) + public static void Log(this ILogger logger, MessageDescriptor descriptor) + => Log(logger, descriptor, default); + + public static void Log(this ILogger logger, MessageDescriptor descriptor, TArgs args) { logger.Log( descriptor.Level, descriptor.Id, - state: (descriptor, args), + state: new LogState(descriptor, args), exception: null, - formatter: static (state, _) => state.descriptor.GetMessage(state.args)); + formatter: static (state, _) => state.Descriptor.GetMessage(state.Arguments)); } + + public static void Log(this ILogger logger, MessageDescriptor<(TArg1, TArg2)> descriptor, TArg1 arg1, TArg2 arg2) + => Log(logger, descriptor, (arg1, arg2)); + + public static void Log(this ILogger logger, MessageDescriptor<(TArg1, TArg2, TArg3)> descriptor, TArg1 arg1, TArg2 arg2, TArg3 arg3) + => Log(logger, descriptor, (arg1, arg2, arg3)); + + public static void Log(this ILogger logger, MessageDescriptor<(TArg1, TArg2, TArg3, TArg4)> descriptor, TArg1 arg1, TArg2 arg2, TArg3 arg3, TArg4 arg4) + => Log(logger, descriptor, (arg1, arg2, arg3, arg4)); + + public static string GetMessage(this MessageDescriptor descriptor) + => descriptor.GetMessage(default); + } + + internal readonly struct LogState(MessageDescriptor descriptor, TArgs arguments) + { + public MessageDescriptor Descriptor { get; } = descriptor; + public TArgs Arguments { get; } = arguments; } internal sealed class LoggerFactory(IReporter reporter, LogLevel level) : ILoggerFactory @@ -73,7 +97,7 @@ public bool IsEnabled(LogLevel logLevel) public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - if (!IsEnabled(logLevel)) + if (logLevel == LogLevel.None || !IsEnabled(logLevel)) { return; } @@ -81,11 +105,11 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except var (name, display) = LoggingUtilities.ParseCategoryName(categoryName); var prefix = display != null ? $"[{display}] " : ""; - var descriptor = eventId.Id != 0 ? MessageDescriptor.GetDescriptor(eventId) : default; + var descriptor = eventId.Id != 0 ? MessageDescriptor.GetDescriptor(eventId) : null; var emoji = logLevel switch { - _ when descriptor.Emoji != Emoji.Default => descriptor.Emoji, + _ when descriptor != null && descriptor.Emoji != Emoji.Default => descriptor.Emoji, LogLevel.Error => Emoji.Error, LogLevel.Warning => Emoji.Warning, _ when MessageDescriptor.ComponentEmojis.TryGetValue(name, out var componentEmoji) => componentEmoji, @@ -110,21 +134,36 @@ public void AddProvider(ILoggerProvider provider) => throw new NotImplementedException(); } - internal readonly record struct MessageDescriptor(string Format, Emoji Emoji, LogLevel Level, EventId Id) + internal abstract class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id) { private static int s_id; private static ImmutableDictionary s_descriptors = []; - - private static MessageDescriptor Create(string format, Emoji emoji, LogLevel level) + + public string? Format { get; } = format; + public Emoji Emoji { get; } = emoji; + public LogLevel Level { get; } = level; + public EventId Id { get; } = id; + + private static MessageDescriptor Create(string format, Emoji emoji, LogLevel level) + => Create(format, emoji, level); + + private static MessageDescriptor Create(string format, Emoji emoji, LogLevel level) // reserve event id 0 for ad-hoc messages - => Create(new EventId(++s_id), format, emoji, level); + => Create(new EventId(++s_id), format, emoji, level); + + private static MessageDescriptor Create(LogEvent logEvent, Emoji emoji) + => Create(logEvent.Id, logEvent.Message, emoji, logEvent.Level); - private static MessageDescriptor Create(LogEvent logEvent, Emoji emoji) - => Create(logEvent.Id, logEvent.Message, emoji, logEvent.Level); + /// + /// Creates a descriptor that's only used for notifications not displayed to the user. + /// These can be used for testing or for custom loggers (e.g. Aspire status reporting). + /// + private static MessageDescriptor CreateNotification() + => Create(new EventId(++s_id), format: null, Emoji.Default, LogLevel.None); - private static MessageDescriptor Create(EventId id, string format, Emoji emoji, LogLevel level) + private static MessageDescriptor Create(EventId id, string? format, Emoji emoji, LogLevel level) { - var descriptor = new MessageDescriptor(format, emoji, level, id.Id); + var descriptor = new MessageDescriptor(format, emoji, level, id); s_descriptors = s_descriptors.Add(id, descriptor); return descriptor; } @@ -132,23 +171,6 @@ private static MessageDescriptor Create(EventId id, string format, Emoji emoji, public static MessageDescriptor GetDescriptor(EventId id) => s_descriptors[id]; - public string GetMessage(params object?[] args) - => Id.Id == 0 ? Format : string.Format(Format, args); - - public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) - => condition && Level != level - ? this with - { - Level = level, - Emoji = level switch - { - LogLevel.Error or LogLevel.Critical => Emoji.Error, - LogLevel.Warning => Emoji.Warning, - _ => Emoji - } - } - : this; - public static readonly ImmutableDictionary ComponentEmojis = ImmutableDictionary.Empty .Add(DotNetWatchContext.DefaultLogComponentName, Emoji.Watch) .Add(DotNetWatchContext.BuildLogComponentName, Emoji.Build) @@ -160,82 +182,125 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) .Add(AspireServiceFactory.AspireLogComponentName, Emoji.Aspire); // predefined messages used for testing: - public static readonly MessageDescriptor HotReloadSessionStarting = Create("Hot reload session starting.", Emoji.HotReload, LogLevel.None); - public static readonly MessageDescriptor HotReloadSessionStarted = Create("Hot reload session started.", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor ProjectsRebuilt = Create("Projects rebuilt ({0})", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor ProjectsRestarted = Create("Projects restarted ({0})", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor ProjectDependenciesDeployed = Create("Project dependencies deployed ({0})", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor FixBuildError = Create("Fix the error to continue or press Ctrl+C to exit.", Emoji.Watch, LogLevel.Warning); - public static readonly MessageDescriptor WaitingForChanges = Create("Waiting for changes", Emoji.Watch, LogLevel.Information); - public static readonly MessageDescriptor LaunchedProcess = Create("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, LogLevel.Debug); - public static readonly MessageDescriptor ManagedCodeChangesApplied = Create("C# and Razor changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor StaticAssetsChangesApplied = Create("Static asset changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor SendingUpdateBatch = Create(LogEvents.SendingUpdateBatch, Emoji.HotReload); - public static readonly MessageDescriptor UpdateBatchCompleted = Create(LogEvents.UpdateBatchCompleted, Emoji.HotReload); - public static readonly MessageDescriptor UpdateBatchFailed = Create(LogEvents.UpdateBatchFailed, Emoji.HotReload); - public static readonly MessageDescriptor UpdateBatchCanceled = Create(LogEvents.UpdateBatchCanceled, Emoji.HotReload); - public static readonly MessageDescriptor UpdateBatchFailedWithError = Create(LogEvents.UpdateBatchFailedWithError, Emoji.HotReload); - public static readonly MessageDescriptor UpdateBatchExceptionStackTrace = Create(LogEvents.UpdateBatchExceptionStackTrace, Emoji.HotReload); - public static readonly MessageDescriptor Capabilities = Create(LogEvents.Capabilities, Emoji.HotReload); - public static readonly MessageDescriptor WaitingForFileChangeBeforeRestarting = Create("Waiting for a file to change before restarting ...", Emoji.Wait, LogLevel.Warning); - public static readonly MessageDescriptor WatchingWithHotReload = Create("Watching with Hot Reload.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor RestartInProgress = Create("Restart in progress.", Emoji.Restart, LogLevel.Information); - public static readonly MessageDescriptor RestartRequested = Create("Restart requested.", Emoji.Restart, LogLevel.Information); - public static readonly MessageDescriptor ShutdownRequested = Create("Shutdown requested. Press Ctrl+C again to force exit.", Emoji.Stop, LogLevel.Information); - public static readonly MessageDescriptor ApplyUpdate_Error = Create("{0}{1}", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor ApplyUpdate_Warning = Create("{0}{1}", Emoji.Warning, LogLevel.Warning); - public static readonly MessageDescriptor ApplyUpdate_Verbose = Create("{0}{1}", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor ApplyUpdate_ChangingEntryPoint = Create("{0} Press \"Ctrl + R\" to restart.", Emoji.Warning, LogLevel.Warning); - public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = Create("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); - public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); - public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); - public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); - public static readonly MessageDescriptor LaunchingBrowser = Create("Launching browser: {0} {1}", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor RefreshingBrowser = Create(LogEvents.RefreshingBrowser, Emoji.Default); - public static readonly MessageDescriptor ReloadingBrowser = Create(LogEvents.ReloadingBrowser, Emoji.Default); - public static readonly MessageDescriptor RefreshServerRunningAt = Create(LogEvents.RefreshServerRunningAt, Emoji.Default); - public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default); - public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor RestartingApplication = Create("Restarting application ...", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Trace); - public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Trace); - public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Trace); - public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor NoCSharpChangesToApply = Create("No C# or Razor changes to apply.", Emoji.Watch, LogLevel.Information); - public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information); - public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor FailedToLaunchProcess = Create("Failed to launch '{0}' with arguments '{1}': {2}", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor ApplicationFailed = Create("Application failed: {0}", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor ProcessRunAndExited = Create("Process id {0} ran for {1}ms and exited with exit code {2}.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor WaitingForProcessToExitWithin = Create("Waiting for process {0} to exit within {1}s.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor WaitingForProcessToExit = Create("Waiting for process {0} to exit ({1}).", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor FailedToKillProcess = Create("Failed to kill process {0}: {1}.", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor TerminatingProcess = Create("Terminating process {0} ({1}).", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor FailedToSendSignalToProcess = Create("Failed to send {0} signal to process {1}: {2}", Emoji.Warning, LogLevel.Warning); - public static readonly MessageDescriptor ErrorReadingProcessOutput = Create("Error reading {0} of process {1}: {2}", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create(LogEvents.SendingStaticAssetUpdateRequest, Emoji.Default); - public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor RestartNeededToApplyChanges = Create("Restart is needed to apply the changes.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor HotReloadEnabled = Create("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor PressCtrlRToRestart = Create("Press Ctrl+R to restart.", Emoji.LightBulb, LogLevel.Information); - public static readonly MessageDescriptor ApplicationKind_BlazorHosted = Create("Application kind: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor ApplicationKind_BlazorWebAssembly = Create("Application kind: BlazorWebAssembly.", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor ApplicationKind_WebApplication = Create("Application kind: WebApplication.", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor ApplicationKind_Default = Create("Application kind: Default.", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace); - public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor CommandDoesNotSupportHotReload = Create("Command '{0}' does not support Hot Reload.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor HotReloadDisabledByCommandLineSwitch = Create("Hot Reload disabled by command line switch.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor HotReloadSessionStartingNotification = CreateNotification(); + public static readonly MessageDescriptor HotReloadSessionStarted = Create("Hot reload session started.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor ProjectsRebuilt = Create("Projects rebuilt ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor ProjectsRestarted = Create("Projects restarted ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor> RestartingProjectsNotification = CreateNotification>(); + public static readonly MessageDescriptor ProjectRestarting = Create("Restarting ...", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ProjectRestarted = Create("Restarted", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ProjectDependenciesDeployed = Create("Project dependencies deployed ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor FixBuildError = Create("Fix the error to continue or press Ctrl+C to exit.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor WaitingForChanges = Create("Waiting for changes", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor<(string, string, int)> LaunchedProcess = Create<(string, string, int)>("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, LogLevel.Debug); + public static readonly MessageDescriptor ManagedCodeChangesApplied = Create("C# and Razor changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor StaticAssetsChangesApplied = Create("Static asset changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor> ChangesAppliedToProjectsNotification = CreateNotification>(); + public static readonly MessageDescriptor SendingUpdateBatch = Create(LogEvents.SendingUpdateBatch, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchCompleted = Create(LogEvents.UpdateBatchCompleted, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchFailed = Create(LogEvents.UpdateBatchFailed, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchCanceled = Create(LogEvents.UpdateBatchCanceled, Emoji.HotReload); + public static readonly MessageDescriptor<(int, string)> UpdateBatchFailedWithError = Create(LogEvents.UpdateBatchFailedWithError, Emoji.HotReload); + public static readonly MessageDescriptor<(int, string)> UpdateBatchExceptionStackTrace = Create(LogEvents.UpdateBatchExceptionStackTrace, Emoji.HotReload); + public static readonly MessageDescriptor Capabilities = Create(LogEvents.Capabilities, Emoji.HotReload); + public static readonly MessageDescriptor WaitingForFileChangeBeforeRestarting = Create("Waiting for a file to change before restarting ...", Emoji.Wait, LogLevel.Warning); + public static readonly MessageDescriptor WatchingWithHotReload = Create("Watching with Hot Reload.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor RestartInProgress = Create("Restart in progress.", Emoji.Restart, LogLevel.Information); + public static readonly MessageDescriptor RestartRequested = Create("Restart requested.", Emoji.Restart, LogLevel.Information); + public static readonly MessageDescriptor Restarting = Create("Restarting.", Emoji.Restart, LogLevel.Information); + public static readonly MessageDescriptor ShutdownRequested = Create("Shutdown requested. Press Ctrl+C again to force exit.", Emoji.Stop, LogLevel.Information); + public static readonly MessageDescriptor ApplyUpdate_Error = Create("{0}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ApplyUpdate_Warning = Create("{0}", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor ApplyUpdate_Verbose = Create("{0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor<(string, string)> ApplyUpdate_AutoVerbose = Create<(string, string)>("{0}{1}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplyUpdate_ChangingEntryPoint = Create("{0} Press \"Ctrl + R\" to restart.", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor BrowserRefreshSuppressedViaEnvironmentVariable_ManualRefreshRequired = Create("Browser refresh is suppressed via environment variable '{0}'. To reload static assets after an update refresh browser manually.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor BrowserRefreshSuppressedViaEnvironmentVariable_ApplicationWillBeRestarted = Create("Browser refresh is suppressed via environment variable '{0}'. Application will be restarted when updated.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshNotSupportedByProjectTargetFramework_ManualRefreshRequired = Create("Browser refresh is not supported by the project target framework. To reload static assets after an update refresh browser manually. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshNotSupportedByProjectTargetFramework_ApplicationWillBeRestarted = Create("Browser refresh is not supported by the project target framework. Application will be restarted when updated. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); + public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); + public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); + public static readonly MessageDescriptor LaunchingBrowser = Create("Launching browser: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor<(string, string)> LaunchingBrowserWithUrl = Create<(string, string)>("Launching browser: {0} {1}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor RefreshingBrowser = Create(LogEvents.RefreshingBrowser, Emoji.Default); + public static readonly MessageDescriptor ReloadingBrowser = Create(LogEvents.ReloadingBrowser, Emoji.Default); + public static readonly MessageDescriptor RefreshServerRunningAt = Create(LogEvents.RefreshServerRunningAt, Emoji.Default); + public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default); + public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor RestartingApplication = Create("Restarting application ...", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor<(string, ChangeKind, string)> IgnoringChangeInHiddenDirectory = Create<(string, ChangeKind, string)>("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor<(ChangeKind, string)> IgnoringChangeInOutputDirectory = Create<(ChangeKind, string)>("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor<(string, ChangeKind, string, string, string)> IgnoringChangeInExcludedFile = Create<(string, ChangeKind, string, string, string)>("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor NoManagedCodeChangesToApply = Create("No managed code changes to apply.", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor<(string, string, string)> FailedToLaunchProcess = Create<(string, string, string)>("Failed to launch '{0}' with arguments '{1}': {2}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ApplicationFailed = Create("Application failed: {0}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor<(int, long, int?)> ProcessRunAndExited = Create<(int, long, int?)>("Process id {0} ran for {1}ms and exited with exit code {2}.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor<(int, int)> WaitingForProcessToExitWithin = Create<(int, int)>("Waiting for process {0} to exit within {1}s.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor<(int, int)> WaitingForProcessToExit = Create<(int, int)>("Waiting for process {0} to exit ({1}).", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor<(int, string)> FailedToKillProcess = Create<(int, string)>("Failed to kill process {0}: {1}.", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor<(int, string)> TerminatingProcess = Create<(int, string)>("Terminating process {0} ({1}).", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor<(string, int, string)> FailedToSendSignalToProcess = Create<(string, int, string)>("Failed to send {0} signal to process {1}: {2}", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor<(string, int, string)> ErrorReadingProcessOutput = Create<(string, int, string)>("Error reading {0} of process {1}: {2}", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create(LogEvents.SendingStaticAssetUpdateRequest, Emoji.Default); + public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor RestartNeededToApplyChanges = Create("Restart is needed to apply the changes.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor HotReloadEnabled = Create("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor ProjectDoesNotSupportHotReload = Create("Project does not support Hot Reload: {0}. Application will be restarted when updated.", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor PressCtrlRToRestart = Create("Press Ctrl+R to restart.", Emoji.LightBulb, LogLevel.Information); + public static readonly MessageDescriptor<(string, string)> ApplicationKind_BlazorHosted = Create<(string, string)>("Application kind: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_BlazorWebAssembly = Create("Application kind: BlazorWebAssembly.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_WebApplication = Create("Application kind: WebApplication.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_Default = Create("Application kind: Default.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_WebSockets = Create("Application kind: WebSockets.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor LoadingProjects = Create("Loading projects ...", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor<(int, double)> LoadedProjects = Create<(int, double)>("Loaded {0} project(s) in {1:0.0}s.", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor> BuildStartedNotification = CreateNotification>(); + public static readonly MessageDescriptor<(IEnumerable projects, bool success)> BuildCompletedNotification = CreateNotification<(IEnumerable projects, bool success)>(); + } + + internal sealed class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id) + : MessageDescriptor(VerifyFormat(format, level), emoji, level, id) + { + private static string? VerifyFormat(string? format, LogLevel level) + { + Debug.Assert(format is null == level is LogLevel.None); +#if DEBUG + if (format != null) + { + var actualArity = format.Count(c => c == '{'); + var expectedArity = typeof(TArgs) == typeof(None) ? 0 + : typeof(TArgs).IsAssignableTo(typeof(ITuple)) ? typeof(TArgs).GenericTypeArguments.Length + : 1; + + Debug.Assert(actualArity == expectedArity, $"Arguments of format string '{format}' do not match the specified type: {typeof(TArgs)} (actual arity: {actualArity}, expected arity: {expectedArity})"); + } +#endif + return format; + } + + public string GetMessage(TArgs args) + { + Debug.Assert(Format != null); + return Id.Id == 0 ? Format : string.Format(Format, LogEvents.GetArgumentValues(args)); + } } internal interface IProcessOutputReporter diff --git a/src/WatchPrototype/Watch/Utilities/Disposables.cs b/src/WatchPrototype/Watch/Utilities/Disposables.cs index 7aa9de71941..89d5c5ee951 100644 --- a/src/WatchPrototype/Watch/Utilities/Disposables.cs +++ b/src/WatchPrototype/Watch/Utilities/Disposables.cs @@ -3,15 +3,22 @@ namespace Microsoft.DotNet.Watch; -internal readonly record struct Disposables(List disposables) : IDisposable +internal readonly record struct Disposables(List disposables) : IAsyncDisposable { - public List Items => disposables; + public List Items => disposables; - public void Dispose() + public async ValueTask DisposeAsync() { foreach (var disposable in disposables) { - disposable.Dispose(); + if (disposable is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else + { + ((IDisposable)disposable).Dispose(); + } } } } diff --git a/src/WatchPrototype/Watch/Utilities/Option.cs b/src/WatchPrototype/Watch/Utilities/Option.cs new file mode 100644 index 00000000000..2c2e4a7b21a --- /dev/null +++ b/src/WatchPrototype/Watch/Utilities/Option.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. + +namespace Microsoft.DotNet.Watch; + +internal readonly struct Optional(T value) +{ + public static readonly Optional NoValue; + + public bool HasValue { get; } = true; + public T Value => value; + + public static implicit operator Optional(T value) + => new(value); + + public override string ToString() + => HasValue + ? Value?.ToString() ?? "null" + : "unspecified"; +} diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index de9fa1d13ad..2cc8d7f8ad6 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -2306,7 +2306,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) { @@ -2323,6 +2324,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); @@ -2349,7 +2351,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. }