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